mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
## 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>
158 lines
5.4 KiB
TypeScript
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);
|
|
}
|