diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 7b1f57a5..4bcb2410 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -412,6 +412,25 @@ describe("issue attachment routes", () => { ); }); + it("serves mp4 attachments inline when stored with a generic binary content type", async () => { + const storage = createStorageService(Buffer.from("abcdef")); + mockIssueService.getAttachmentById.mockResolvedValue({ + ...makeAttachment("application/octet-stream", "clip.mp4"), + byteSize: 6, + }); + + const app = await createApp(storage); + const res = await request(app) + .get("/api/attachments/attachment-1/content") + .set("Range", "bytes=1-3"); + + expect(res.status).toBe(206); + expect(res.headers["content-type"]).toContain("video/mp4"); + expect(res.headers["content-disposition"]).toBe('inline; filename="clip.mp4"'); + expect(res.headers["content-range"]).toBe("bytes 1-3/6"); + expect(Buffer.from(res.body).toString("utf8")).toBe("bcd"); + }); + it("forces video downloads when the download path is requested", async () => { const storage = createStorageService(); mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/webm", "clip.webm")); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index ba07190f..85492638 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -185,6 +185,30 @@ 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", +]); + +function inferVideoContentTypeFromFilename(filename: string | null | undefined): string | null { + const lower = (filename ?? "").toLowerCase(); + if (lower.endsWith(".mp4") || lower.endsWith(".m4v")) return "video/mp4"; + if (lower.endsWith(".webm")) return "video/webm"; + if (lower.endsWith(".mov") || lower.endsWith(".qt") || lower.endsWith(".quicktime")) return "video/quicktime"; + return null; +} + +function resolveAttachmentResponseContentType(input: { + storedContentType: string | null | undefined; + objectContentType?: string | null; + originalFilename?: string | null; +}) { + const storedContentType = normalizeContentType(input.storedContentType || input.objectContentType); + if (!GENERIC_ATTACHMENT_CONTENT_TYPES.has(storedContentType)) return storedContentType; + return inferVideoContentTypeFromFilename(input.originalFilename) ?? storedContentType; +} + function requiresPaperclipAttachmentMetadata(input: { type?: unknown; provider?: unknown; @@ -6267,7 +6291,11 @@ export function issueRoutes( attachment.objectKey, range.kind === "range" ? { range: { start: range.start, end: range.end } } : undefined, ); - const responseContentType = normalizeContentType(attachment.contentType || object.contentType); + const responseContentType = resolveAttachmentResponseContentType({ + storedContentType: attachment.contentType, + objectContentType: object.contentType, + originalFilename: attachment.originalFilename, + }); res.setHeader("Content-Type", responseContentType); res.setHeader("Cache-Control", "private, max-age=60"); res.setHeader("X-Content-Type-Options", "nosniff"); diff --git a/ui/src/components/IssueAttachmentsSection.test.tsx b/ui/src/components/IssueAttachmentsSection.test.tsx index 7cec72e6..4e54e05f 100644 --- a/ui/src/components/IssueAttachmentsSection.test.tsx +++ b/ui/src/components/IssueAttachmentsSection.test.tsx @@ -155,6 +155,33 @@ describe("IssueAttachmentsSection", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it("treats mp4 filenames as playable videos even with a generic binary content type", async () => { + const attachment = makeAttachment({ + id: "misclassified-mp4", + originalFilename: "demo.mp4", + contentType: "application/octet-stream", + contentPath: "/api/attachments/misclassified-mp4/content", + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const video = container.querySelector("video"); + expect(video?.getAttribute("src")).toBe("/api/attachments/misclassified-mp4/content"); + expect(container.textContent).toContain("application/octet-stream"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("keeps generic attachments as compact file rows with open and download actions", async () => { const attachment = makeAttachment({ id: "pdf-attachment", diff --git a/ui/src/lib/issue-attachments.ts b/ui/src/lib/issue-attachments.ts index b9dc1565..29219c46 100644 --- a/ui/src/lib/issue-attachments.ts +++ b/ui/src/lib/issue-attachments.ts @@ -25,8 +25,20 @@ export function isImageAttachment(attachment: Pick) { - return isVideoContentType(normalizedContentType(attachment)); +export function isVideoAttachment( + attachment: Pick, +) { + if (isVideoContentType(normalizedContentType(attachment))) return true; + + const filename = (attachment.originalFilename ?? "").toLowerCase(); + return ( + filename.endsWith(".mp4") || + filename.endsWith(".m4v") || + filename.endsWith(".webm") || + filename.endsWith(".mov") || + filename.endsWith(".qt") || + filename.endsWith(".quicktime") + ); } export function isMarkdownAttachment(