From 5f481d50f1b45fe291fb189c12f286dd99809304 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 3 Jun 2026 23:17:16 +0000 Subject: [PATCH] Allow inline video attachment previews Co-Authored-By: Paperclip --- .../src/__tests__/issue-attachment-routes.test.ts | 13 ++++++++++++- server/src/attachment-types.ts | 2 ++ ui/src/components/IssueAttachmentsSection.test.tsx | 4 ---- ui/src/lib/issue-attachments.ts | 14 ++++++++------ 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 4bcb2410..3b47cabe 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -1,4 +1,5 @@ import { Readable } from "node:stream"; +import type { IncomingMessage } from "node:http"; import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -190,6 +191,13 @@ function makeAttachment(contentType: string, originalFilename: string) { }; } +function parseBinaryResponse(res: IncomingMessage, callback: (error: Error | null, body?: Buffer) => void) { + const chunks: Buffer[] = []; + res.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + res.on("end", () => callback(null, Buffer.concat(chunks))); + res.on("error", callback); +} + describe("normalizeIssueAttachmentMaxBytes", () => { it("keeps the process-level attachment cap as the final cap", async () => { const previous = process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES; @@ -362,7 +370,10 @@ describe("issue attachment routes", () => { mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("text/html", "report.html")); const app = await createApp(storage); - const res = await request(app).get("/api/attachments/attachment-1/content"); + const res = await request(app) + .get("/api/attachments/attachment-1/content") + .buffer(true) + .parse(parseBinaryResponse); expect(res.status).toBe(200); expect([ diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts index 4784ef82..304caf45 100644 --- a/server/src/attachment-types.ts +++ b/server/src/attachment-types.ts @@ -35,6 +35,7 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ "video/mp4", "video/webm", "video/quicktime", + "video/x-m4v", ]; export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream"; @@ -49,6 +50,7 @@ export const INLINE_ATTACHMENT_TYPES: readonly string[] = [ "video/mp4", "video/webm", "video/quicktime", + "video/x-m4v", ]; /** diff --git a/ui/src/components/IssueAttachmentsSection.test.tsx b/ui/src/components/IssueAttachmentsSection.test.tsx index d9c3ff1b..6c7f0086 100644 --- a/ui/src/components/IssueAttachmentsSection.test.tsx +++ b/ui/src/components/IssueAttachmentsSection.test.tsx @@ -60,8 +60,6 @@ function makeAttachment(overrides: Partial = {}): IssueAttachme createdAt: new Date("2026-06-01T00:00:00.000Z"), updatedAt: new Date("2026-06-01T00:00:00.000Z"), contentPath: "/api/attachments/attachment-1/content", - openPath: "/api/attachments/attachment-1/content", - downloadPath: "/api/attachments/attachment-1/content?download=1", ...overrides, }; } @@ -271,8 +269,6 @@ describe("IssueAttachmentsSection", () => { originalFilename: "report.pdf", contentType: "application/pdf", contentPath: "/api/attachments/pdf-attachment/content", - openPath: "/api/attachments/pdf-attachment/content", - downloadPath: "/api/attachments/pdf-attachment/content?download=1", }); await act(async () => { diff --git a/ui/src/lib/issue-attachments.ts b/ui/src/lib/issue-attachments.ts index 8c6859da..c75fbd34 100644 --- a/ui/src/lib/issue-attachments.ts +++ b/ui/src/lib/issue-attachments.ts @@ -7,6 +7,12 @@ const GENERIC_ATTACHMENT_CONTENT_TYPES = new Set([ "application/x-binary", ]); +type AttachmentPathLike = { + contentPath: string; + openPath?: string; + downloadPath?: string; +}; + function normalizedContentType(attachment: Pick) { return attachment.contentType.toLowerCase().split(";")[0]?.trim() ?? ""; } @@ -15,15 +21,11 @@ export function attachmentFilename(attachment: Pick, -) { +export function attachmentOpenPath(attachment: AttachmentPathLike) { return attachment.openPath ?? attachment.contentPath; } -export function attachmentDownloadPath( - attachment: Pick, -) { +export function attachmentDownloadPath(attachment: AttachmentPathLike) { return attachment.downloadPath ?? `${attachment.contentPath}?download=1`; }