Detect misclassified video attachments

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-06-01 19:12:53 +00:00
parent e86d000c7b
commit 8af359b656
4 changed files with 89 additions and 3 deletions

View file

@ -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"));

View file

@ -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");