mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-18 19:50:38 +09:00
Detect misclassified video attachments
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e86d000c7b
commit
8af359b656
4 changed files with 89 additions and 3 deletions
|
|
@ -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 () => {
|
it("forces video downloads when the download path is requested", async () => {
|
||||||
const storage = createStorageService();
|
const storage = createStorageService();
|
||||||
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/webm", "clip.webm"));
|
mockIssueService.getAttachmentById.mockResolvedValue(makeAttachment("video/webm", "clip.webm"));
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,30 @@ function buildAttachmentContentPath(attachmentId: string): string {
|
||||||
return `/api/attachments/${attachmentId}/content`;
|
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: {
|
function requiresPaperclipAttachmentMetadata(input: {
|
||||||
type?: unknown;
|
type?: unknown;
|
||||||
provider?: unknown;
|
provider?: unknown;
|
||||||
|
|
@ -6267,7 +6291,11 @@ export function issueRoutes(
|
||||||
attachment.objectKey,
|
attachment.objectKey,
|
||||||
range.kind === "range" ? { range: { start: range.start, end: range.end } } : undefined,
|
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("Content-Type", responseContentType);
|
||||||
res.setHeader("Cache-Control", "private, max-age=60");
|
res.setHeader("Cache-Control", "private, max-age=60");
|
||||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,33 @@ describe("IssueAttachmentsSection", () => {
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<IssueAttachmentsSection
|
||||||
|
attachments={[attachment]}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
onImageClick={vi.fn()}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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 () => {
|
it("keeps generic attachments as compact file rows with open and download actions", async () => {
|
||||||
const attachment = makeAttachment({
|
const attachment = makeAttachment({
|
||||||
id: "pdf-attachment",
|
id: "pdf-attachment",
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,20 @@ export function isImageAttachment(attachment: Pick<IssueAttachment, "contentType
|
||||||
return normalizedContentType(attachment).startsWith("image/");
|
return normalizedContentType(attachment).startsWith("image/");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isVideoAttachment(attachment: Pick<IssueAttachment, "contentType">) {
|
export function isVideoAttachment(
|
||||||
return isVideoContentType(normalizedContentType(attachment));
|
attachment: Pick<IssueAttachment, "contentType" | "originalFilename">,
|
||||||
|
) {
|
||||||
|
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(
|
export function isMarkdownAttachment(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue