Files
PaperClipAI/server/src/attachment-types.ts
T
SamandSami Rusani b565603a86 fix(server): accept Office issue attachments (#8562)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents and board users can attach files to issues so context and
deliverables stay with the task
> - Some clients upload Microsoft Office files with generic binary MIME
types such as `application/octet-stream`
> - Current `master` now accepts arbitrary issue attachment MIME types,
so the upload should keep working for unknown binary files
> - Office files still benefit from being stored with a specific Office
MIME type when the filename makes that inference safe
> - Shared attachment allow-list defaults should also include common
Office MIME types for routes that still use that allow-list
> - This pull request keeps the current arbitrary-MIME issue upload
behavior and only narrows generic binary uploads to Office MIME types
for known Office filename extensions

## Linked Issues or Issue Description

Fixes #8243

Duplicate search performed before implementation:

- No matching open or closed PR found for `8243`, `Office document`,
`attachment MIME`, or `openxmlformats`.

## What Changed

- Added common Office MIME types to the default shared attachment
allow-list.
- Added upload content-type normalization that maps generic binary
uploads to a specific Office MIME type only for known Office filename
extensions.
- Added an optional helper-level allow-list gate so callers that still
validate against an effective allow-list can keep generic binary uploads
generic when the inferred Office MIME type is not allowed.
- Reused the shared generic attachment content-type list for response
handling.
- Preserved current `master` behavior for issue uploads that use unknown
or arbitrary MIME types.
- Added regression coverage for default Office allow-list matching,
filename inference, optional allow-list fallback, official Office MIME
uploads, inferred generic Office uploads, and preservation of unknown
generic binary uploads.

## Verification

- `env CI=true corepack pnpm install --frozen-lockfile --force`
- `env CI=true corepack pnpm --filter @paperclipai/server exec vitest
run src/__tests__/attachment-types.test.ts
src/__tests__/issue-attachment-routes.test.ts`
- `env CI=true corepack pnpm --filter @paperclipai/plugin-sdk
ensure-build-deps`
- `env CI=true corepack pnpm --filter @paperclipai/server exec tsc
--noEmit`
- `git diff --check origin/master...HEAD`

GitHub CI, security checks, and Greptile pass on rebased head
`acc364cfbe3440a59db6570bb907818046649eb4`.

## Risks

Low risk. The issue attachment route continues to accept arbitrary MIME
types as current `master` does; this change only stores a more specific
Office MIME type for generic binary uploads when the filename has a
known Office extension. Unknown generic binary uploads remain generic.

For callers that use an allow-list before storing uploads,
`normalizeUploadAttachmentContentType` supports an optional gate so
inference can be limited to MIME types that are already allowed.

No docs change included because this is a default upload compatibility
fix covered by server tests.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

This is a narrow bug fix, not roadmap-level core feature work.
`ROADMAP.md` was checked.

## Model Used

OpenAI Codex using GPT-5, tool-enabled coding agent. Context window
details are not exposed in this environment.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Sami Rusani <sr@samirusani>
2026-07-21 10:47:33 -07:00

158 lines
5.4 KiB
TypeScript

/**
* Shared attachment content-type configuration.
*
* By default a curated set of image/document/text/media types are allowed. Set the
* `PAPERCLIP_ALLOWED_ATTACHMENT_TYPES` environment variable to a
* comma-separated list of MIME types or wildcard patterns to expand the
* allowed set for routes that use this allowlist.
*
* Examples:
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf
* PAPERCLIP_ALLOWED_ATTACHMENT_TYPES=image/*,application/pdf,text/*
*
* Supported pattern syntax:
* - Exact types: "application/pdf"
* - Wildcards: "image/*" or "application/vnd.openxmlformats-officedocument.*"
*/
import {
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
} from "@paperclipai/shared";
export const DEFAULT_ALLOWED_TYPES: readonly string[] = [
"image/png",
"image/jpeg",
"image/jpg",
"image/webp",
"image/gif",
"application/pdf",
"application/zip",
"text/markdown",
"text/plain",
"application/json",
"text/csv",
"text/html",
"application/msword",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"video/mp4",
"video/webm",
"video/quicktime",
"video/x-m4v",
];
export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream";
export const SVG_CONTENT_TYPE = "image/svg+xml";
export const GENERIC_ATTACHMENT_CONTENT_TYPES: readonly string[] = [
"application/octet-stream",
"binary/octet-stream",
"application/x-binary",
];
export const INLINE_ATTACHMENT_TYPES: readonly string[] = [
"image/*",
"application/pdf",
"text/plain",
"text/markdown",
"application/json",
"text/csv",
"video/mp4",
"video/webm",
"video/quicktime",
"video/x-m4v",
];
/**
* Parse a comma-separated list of MIME type patterns into a normalised array.
* Returns the default image-only list when the input is empty or undefined.
*/
export function parseAllowedTypes(raw: string | undefined): string[] {
if (!raw) return [...DEFAULT_ALLOWED_TYPES];
const parsed = raw
.split(",")
.map((s) => s.trim().toLowerCase())
.filter((s) => s.length > 0);
return parsed.length > 0 ? parsed : [...DEFAULT_ALLOWED_TYPES];
}
/**
* Check whether `contentType` matches any entry in `allowedPatterns`.
*
* Supports exact matches ("application/pdf") and wildcard / prefix
* patterns ("image/*", "application/vnd.openxmlformats-officedocument.*").
*/
export function matchesContentType(contentType: string, allowedPatterns: string[]): boolean {
const ct = contentType.toLowerCase();
return allowedPatterns.some((pattern) => {
if (pattern === "*") return true;
if (pattern.endsWith("/*") || pattern.endsWith(".*")) {
return ct.startsWith(pattern.slice(0, -1));
}
return ct === pattern;
});
}
export function normalizeContentType(contentType: string | null | undefined): string {
const normalized = (contentType ?? "").trim().toLowerCase();
return normalized || DEFAULT_ATTACHMENT_CONTENT_TYPE;
}
export function inferOfficeAttachmentContentTypeFromFilename(
filename: string | null | undefined,
): string | null {
const lower = (filename ?? "").trim().toLowerCase();
if (lower.endsWith(".docx")) {
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
}
if (lower.endsWith(".xlsx")) {
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
if (lower.endsWith(".pptx")) {
return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
}
if (lower.endsWith(".doc")) return "application/msword";
if (lower.endsWith(".xls")) return "application/vnd.ms-excel";
if (lower.endsWith(".ppt")) return "application/vnd.ms-powerpoint";
return null;
}
export function normalizeUploadAttachmentContentType(input: {
contentType: string | null | undefined;
originalFilename?: string | null;
isAllowedContentType?: (contentType: string) => boolean;
}): string {
const normalized = normalizeContentType(input.contentType);
if (!GENERIC_ATTACHMENT_CONTENT_TYPES.includes(normalized)) return normalized;
const inferred = inferOfficeAttachmentContentTypeFromFilename(input.originalFilename);
if (!inferred) return normalized;
if (input.isAllowedContentType && !input.isAllowedContentType(inferred)) return normalized;
return inferred;
}
export function isInlineAttachmentContentType(contentType: string): boolean {
return matchesContentType(contentType, [...INLINE_ATTACHMENT_TYPES]);
}
// ---------- Module-level singletons read once at startup ----------
const allowedPatterns: string[] = parseAllowedTypes(
process.env.PAPERCLIP_ALLOWED_ATTACHMENT_TYPES,
);
/** Convenience wrapper using the process-level allowed list. */
export function isAllowedContentType(contentType: string): boolean {
return matchesContentType(contentType, allowedPatterns);
}
export const MAX_ATTACHMENT_BYTES =
Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
export function normalizeIssueAttachmentMaxBytes(value: number | null | undefined): number {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return Math.min(DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES, MAX_ATTACHMENT_BYTES);
}
return Math.min(Math.floor(value), MAX_COMPANY_ATTACHMENT_MAX_BYTES, MAX_ATTACHMENT_BYTES);
}