diff --git a/server/src/__tests__/attachment-types.test.ts b/server/src/__tests__/attachment-types.test.ts new file mode 100644 index 00000000..5a430102 --- /dev/null +++ b/server/src/__tests__/attachment-types.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { + parseAllowedTypes, + matchesContentType, + DEFAULT_ALLOWED_TYPES, +} from "../attachment-types.js"; + +describe("parseAllowedTypes", () => { + it("returns default image types when input is undefined", () => { + expect(parseAllowedTypes(undefined)).toEqual([...DEFAULT_ALLOWED_TYPES]); + }); + + it("returns default image types when input is empty string", () => { + expect(parseAllowedTypes("")).toEqual([...DEFAULT_ALLOWED_TYPES]); + }); + + it("parses comma-separated types", () => { + expect(parseAllowedTypes("image/*,application/pdf")).toEqual([ + "image/*", + "application/pdf", + ]); + }); + + it("trims whitespace", () => { + expect(parseAllowedTypes(" image/png , application/pdf ")).toEqual([ + "image/png", + "application/pdf", + ]); + }); + + it("lowercases entries", () => { + expect(parseAllowedTypes("Application/PDF")).toEqual(["application/pdf"]); + }); + + it("filters empty segments", () => { + expect(parseAllowedTypes("image/png,,application/pdf,")).toEqual([ + "image/png", + "application/pdf", + ]); + }); +}); + +describe("matchesContentType", () => { + it("matches exact types", () => { + const patterns = ["application/pdf", "image/png"]; + expect(matchesContentType("application/pdf", patterns)).toBe(true); + expect(matchesContentType("image/png", patterns)).toBe(true); + expect(matchesContentType("text/plain", patterns)).toBe(false); + }); + + it("matches /* wildcard patterns", () => { + const patterns = ["image/*"]; + expect(matchesContentType("image/png", patterns)).toBe(true); + expect(matchesContentType("image/jpeg", patterns)).toBe(true); + expect(matchesContentType("image/svg+xml", patterns)).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(false); + }); + + it("matches .* wildcard patterns", () => { + const patterns = ["application/vnd.openxmlformats-officedocument.*"]; + expect( + matchesContentType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + patterns, + ), + ).toBe(true); + expect( + matchesContentType( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + patterns, + ), + ).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(false); + }); + + it("is case-insensitive", () => { + const patterns = ["application/pdf"]; + expect(matchesContentType("APPLICATION/PDF", patterns)).toBe(true); + expect(matchesContentType("Application/Pdf", patterns)).toBe(true); + }); + + it("combines exact and wildcard patterns", () => { + const patterns = ["image/*", "application/pdf", "text/*"]; + expect(matchesContentType("image/webp", patterns)).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(true); + expect(matchesContentType("text/csv", patterns)).toBe(true); + expect(matchesContentType("application/zip", patterns)).toBe(false); + }); + + it("handles plain * as allow-all wildcard", () => { + const patterns = ["*"]; + expect(matchesContentType("image/png", patterns)).toBe(true); + expect(matchesContentType("application/pdf", patterns)).toBe(true); + expect(matchesContentType("text/plain", patterns)).toBe(true); + expect(matchesContentType("application/zip", patterns)).toBe(true); + }); +}); diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts new file mode 100644 index 00000000..f9625de1 --- /dev/null +++ b/server/src/attachment-types.ts @@ -0,0 +1,68 @@ +/** + * Shared attachment content-type configuration. + * + * By default only image 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. + * + * 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.*" + */ + +export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ + "image/png", + "image/jpeg", + "image/jpg", + "image/webp", + "image/gif", +]; + +/** + * 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; + }); +} + +// ---------- 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; diff --git a/server/src/routes/assets.ts b/server/src/routes/assets.ts index cde29ada..bd2f154d 100644 --- a/server/src/routes/assets.ts +++ b/server/src/routes/assets.ts @@ -5,22 +5,14 @@ import { createAssetImageMetadataSchema } from "@paperclipai/shared"; import type { StorageService } from "../storage/types.js"; import { assetService, logActivity } from "../services/index.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; - -const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; -const ALLOWED_IMAGE_CONTENT_TYPES = new Set([ - "image/png", - "image/jpeg", - "image/jpg", - "image/webp", - "image/gif", -]); +import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; export function assetRoutes(db: Db, storage: StorageService) { const router = Router(); const svc = assetService(db); const upload = multer({ storage: multer.memoryStorage(), - limits: { fileSize: MAX_ASSET_IMAGE_BYTES, files: 1 }, + limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 }, }); async function runSingleFileUpload(req: Request, res: Response) { @@ -41,7 +33,7 @@ export function assetRoutes(db: Db, storage: StorageService) { } catch (err) { if (err instanceof multer.MulterError) { if (err.code === "LIMIT_FILE_SIZE") { - res.status(422).json({ error: `Image exceeds ${MAX_ASSET_IMAGE_BYTES} bytes` }); + res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` }); return; } res.status(400).json({ error: err.message }); @@ -57,8 +49,8 @@ export function assetRoutes(db: Db, storage: StorageService) { } const contentType = (file.mimetype || "").toLowerCase(); - if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) { - res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` }); + if (!isAllowedContentType(contentType)) { + res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` }); return; } if (file.buffer.length <= 0) { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e4035dfc..8e398afc 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -26,15 +26,7 @@ import { logger } from "../middleware/logger.js"; import { forbidden, HttpError, unauthorized } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; - -const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024; -const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([ - "image/png", - "image/jpeg", - "image/jpg", - "image/webp", - "image/gif", -]); +import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js"; export function issueRoutes(db: Db, storage: StorageService) { const router = Router(); @@ -1067,7 +1059,7 @@ export function issueRoutes(db: Db, storage: StorageService) { return; } const contentType = (file.mimetype || "").toLowerCase(); - if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) { + if (!isAllowedContentType(contentType)) { res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` }); return; }