diff --git a/server/src/__tests__/attachment-types.test.ts b/server/src/__tests__/attachment-types.test.ts index a1508cf017..dcf202a539 100644 --- a/server/src/__tests__/attachment-types.test.ts +++ b/server/src/__tests__/attachment-types.test.ts @@ -2,9 +2,11 @@ import { describe, it, expect } from "vitest"; import { DEFAULT_ALLOWED_TYPES, INLINE_ATTACHMENT_TYPES, + inferOfficeAttachmentContentTypeFromFilename, isInlineAttachmentContentType, matchesContentType, normalizeContentType, + normalizeUploadAttachmentContentType, parseAllowedTypes, } from "../attachment-types.js"; @@ -97,6 +99,19 @@ describe("matchesContentType", () => { expect(matchesContentType("text/plain", patterns)).toBe(true); expect(matchesContentType("application/zip", patterns)).toBe(true); }); + + it("allows common Office document types by default", () => { + for (const contentType of [ + "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", + ]) { + expect(matchesContentType(contentType, [...DEFAULT_ALLOWED_TYPES])).toBe(true); + } + }); }); describe("normalizeContentType", () => { @@ -110,6 +125,67 @@ describe("normalizeContentType", () => { }); }); +describe("inferOfficeAttachmentContentTypeFromFilename", () => { + it("infers common Office content types from filenames", () => { + expect(inferOfficeAttachmentContentTypeFromFilename("notes.docx")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + expect(inferOfficeAttachmentContentTypeFromFilename("raw-data.xlsx")).toBe( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + expect(inferOfficeAttachmentContentTypeFromFilename("deck.pptx")).toBe( + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ); + expect(inferOfficeAttachmentContentTypeFromFilename("legacy.doc")).toBe("application/msword"); + expect(inferOfficeAttachmentContentTypeFromFilename("legacy.xls")).toBe("application/vnd.ms-excel"); + expect(inferOfficeAttachmentContentTypeFromFilename("legacy.ppt")).toBe("application/vnd.ms-powerpoint"); + }); + + it("does not infer unknown extensions", () => { + expect(inferOfficeAttachmentContentTypeFromFilename("payload.bin")).toBeNull(); + expect(inferOfficeAttachmentContentTypeFromFilename(undefined)).toBeNull(); + }); +}); + +describe("normalizeUploadAttachmentContentType", () => { + it("keeps explicit content types unchanged", () => { + expect( + normalizeUploadAttachmentContentType({ + contentType: "application/pdf", + originalFilename: "raw-data.xlsx", + }), + ).toBe("application/pdf"); + }); + + it("infers Office content type for generic binary uploads", () => { + expect( + normalizeUploadAttachmentContentType({ + contentType: "application/octet-stream", + originalFilename: "raw-data.xlsx", + }), + ).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + }); + + it("keeps generic binary uploads generic when the inferred Office type is not allowed", () => { + expect( + normalizeUploadAttachmentContentType({ + contentType: "application/octet-stream", + originalFilename: "raw-data.xlsx", + isAllowedContentType: (contentType) => contentType === "application/octet-stream", + }), + ).toBe("application/octet-stream"); + }); + + it("keeps generic binary uploads generic for unknown filenames", () => { + expect( + normalizeUploadAttachmentContentType({ + contentType: "application/octet-stream", + originalFilename: "payload.bin", + }), + ).toBe("application/octet-stream"); + }); +}); + describe("isInlineAttachmentContentType", () => { it("allows the configured inline-safe types", () => { for (const contentType of ["image/png", "image/svg+xml", "application/pdf", "text/plain", "video/mp4"]) { diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 648f2b4fc9..2103aa5880 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -350,6 +350,93 @@ describe("issue attachment routes", () => { expect(res.body.contentType).toBe("application/x-msdownload"); }); + it("accepts Office uploads with official MIME types for issue attachments", async () => { + const contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + const storage = createStorageService(); + mockIssueService.getById.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + }); + mockIssueService.createAttachment.mockResolvedValue(makeAttachment(contentType, "raw-data.xlsx")); + + const app = await createApp(storage); + const res = await request(app) + .post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments") + .attach("file", Buffer.from("xlsx"), { filename: "raw-data.xlsx", contentType }); + + expect(res.status).toBe(201); + expect(storage.__calls.putFile).toMatchObject({ + contentType, + originalFilename: "raw-data.xlsx", + }); + expect(mockIssueService.createAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + contentType, + originalFilename: "raw-data.xlsx", + }), + ); + }); + + it("infers Office MIME types for generic binary issue attachment uploads", async () => { + const contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + const storage = createStorageService(); + mockIssueService.getById.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + }); + mockIssueService.createAttachment.mockResolvedValue(makeAttachment(contentType, "raw-data.xlsx")); + + const app = await createApp(storage); + const res = await request(app) + .post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments") + .attach("file", Buffer.from("xlsx"), { + filename: "raw-data.xlsx", + contentType: "application/octet-stream", + }); + + expect(res.status).toBe(201); + expect(storage.__calls.putFile).toMatchObject({ + contentType, + originalFilename: "raw-data.xlsx", + }); + expect(mockIssueService.createAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + contentType, + originalFilename: "raw-data.xlsx", + }), + ); + }); + + it("preserves generic binary uploads when the filename is not a known Office document", async () => { + const storage = createStorageService(); + mockIssueService.getById.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + }); + mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/octet-stream", "payload.bin")); + + const app = await createApp(storage); + const res = await request(app) + .post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments") + .attach("file", Buffer.from("bin"), { filename: "payload.bin", contentType: "application/octet-stream" }); + + expect(res.status).toBe(201); + expect(storage.__calls.putFile).toMatchObject({ + contentType: "application/octet-stream", + originalFilename: "payload.bin", + }); + expect(mockIssueService.createAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + contentType: "application/octet-stream", + originalFilename: "payload.bin", + }), + ); + expect(res.body.contentType).toBe("application/octet-stream"); + }); + it("enforces the process-level issue attachment limit even when the company limit allows more", async () => { const storage = createStorageService(); mockIssueService.getById.mockResolvedValue({ diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts index 304caf45a3..b5b949b492 100644 --- a/server/src/attachment-types.ts +++ b/server/src/attachment-types.ts @@ -32,6 +32,12 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ "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", @@ -40,6 +46,11 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ 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", @@ -88,6 +99,38 @@ export function normalizeContentType(contentType: string | null | undefined): st 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]); } diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index a426207567..3138619e87 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -137,9 +137,11 @@ import { } from "./workspace-command-authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { + GENERIC_ATTACHMENT_CONTENT_TYPES, isInlineAttachmentContentType, normalizeIssueAttachmentMaxBytes, normalizeContentType, + normalizeUploadAttachmentContentType, SVG_CONTENT_TYPE, } from "../attachment-types.js"; import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; @@ -351,11 +353,7 @@ function buildAttachmentContentPath(attachmentId: string): string { return `/api/attachments/${attachmentId}/content`; } -const GENERIC_ATTACHMENT_CONTENT_TYPES = new Set([ - "application/octet-stream", - "binary/octet-stream", - "application/x-binary", -]); +const GENERIC_RESPONSE_ATTACHMENT_CONTENT_TYPES = new Set(GENERIC_ATTACHMENT_CONTENT_TYPES); function inferVideoContentTypeFromFilename(filename: string | null | undefined): string | null { const lower = (filename ?? "").toLowerCase(); @@ -371,7 +369,7 @@ function resolveAttachmentResponseContentType(input: { originalFilename?: string | null; }) { const storedContentType = normalizeContentType(input.storedContentType || input.objectContentType); - if (!GENERIC_ATTACHMENT_CONTENT_TYPES.has(storedContentType)) return storedContentType; + if (!GENERIC_RESPONSE_ATTACHMENT_CONTENT_TYPES.has(storedContentType)) return storedContentType; return inferVideoContentTypeFromFilename(input.originalFilename) ?? storedContentType; } @@ -10443,7 +10441,10 @@ export function issueRoutes( res.status(400).json({ error: "Missing file field 'file'" }); return; } - const contentType = normalizeContentType(file.mimetype); + const contentType = normalizeUploadAttachmentContentType({ + contentType: file.mimetype, + originalFilename: file.originalname, + }); if (file.buffer.length <= 0) { res.status(422).json({ error: "Attachment is empty" }); return;