// Serving uploaded bytes: dump files (headers, ranges, privacy), video // thumbnails, attachments, avatars. import { count, createUser, db, formWithFile, get, insertDump, PNG, post, test, TEST_DIR, } from "./helpers.ts"; import type { AttachmentResponse, Comment, SelfUser, } from "../model/interfaces.ts"; import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert"; const BYTES = new TextEncoder().encode("0123456789"); test("GET /api/files/:id: headers for a safe inline type", async () => { const user = await createUser(); const { id } = insertDump({ userId: user.id, fileName: "notes.txt", fileMime: "text/plain", fileData: BYTES, }); const res = await get(`/api/files/${id}`); assertEquals(res.status, 200); assertEquals(res.bytes, BYTES); assertEquals(res.headers.get("content-type"), "text/plain"); assertEquals(res.headers.get("content-length"), "10"); assertEquals(res.headers.get("accept-ranges"), "bytes"); assertEquals(res.headers.get("x-content-type-options"), "nosniff"); assertEquals(res.headers.get("referrer-policy"), "no-referrer"); assertStringIncludes(res.headers.get("content-security-policy")!, "sandbox"); assertStringIncludes(res.headers.get("content-disposition")!, "inline;"); // No version in the URL: kept, but asked about again each time. assertEquals(res.headers.get("cache-control"), "public, no-cache"); const etag = res.headers.get("etag")!; assertStringIncludes(etag, 'W/"'); // The client already has it: a 304, no bytes. const again = await get(`/api/files/${id}`, { headers: { "If-None-Match": etag }, }); assertEquals(again.status, 304); assertEquals(again.bytes.byteLength, 0); // The app's URL names the file's current version, which never changes. const v = `${BYTES.byteLength}-${Date.parse(createdAtOf(id))}`; assertEquals( (await get(`/api/files/${id}?v=${v}`)).headers.get("cache-control"), "public, max-age=31536000, immutable", ); // A stale version is revalidated like no version at all. assertEquals( (await get(`/api/files/${id}?v=10-0`)).headers.get("cache-control"), "public, no-cache", ); }); function createdAtOf(id: string): string { return (db.prepare(`SELECT created_at FROM dumps WHERE id = ?;`).get(id) as { created_at: string; }).created_at; } test("GET /api/files/:id: active content downloads instead of rendering", async () => { const user = await createUser(); for (const mime of ["text/html", "image/svg+xml", "application/xml"]) { const { id } = insertDump({ userId: user.id, fileName: "x", fileMime: mime, }); const res = await get(`/api/files/${id}`); assertStringIncludes( res.headers.get("content-disposition")!, "attachment;", mime, ); } const pdf = insertDump({ userId: user.id, fileName: "a.pdf", fileMime: "application/pdf", }); const res = await get(`/api/files/${pdf.id}`); assertStringIncludes(res.headers.get("content-disposition")!, "inline;"); assertEquals(res.headers.get("content-security-policy"), null); }); test("GET /api/files/:id: a non-Latin-1 filename doesn't break the header", async () => { const user = await createUser(); const { id } = insertDump({ userId: user.id, fileName: 'mix "été" 🎵.mp3', fileMime: "audio/mpeg", }); const res = await get(`/api/files/${id}`); assertEquals(res.status, 200); assertEquals( res.headers.get("content-disposition"), `inline; filename="mix __t__ __.mp3"; filename*=UTF-8''${ encodeURIComponent('mix "été" 🎵.mp3') }`, ); }); test("GET /api/files/:id: byte ranges", async () => { const user = await createUser(); const { id } = insertDump({ userId: user.id, fileName: "d", fileData: BYTES, }); const range = (r: string) => get(`/api/files/${id}`, { headers: { Range: r } }); let res = await range("bytes=2-5"); assertEquals(res.status, 206); assertEquals(new TextDecoder().decode(res.bytes), "2345"); assertEquals(res.headers.get("content-range"), "bytes 2-5/10"); assertEquals(res.headers.get("content-length"), "4"); res = await range("bytes=7-"); assertEquals(new TextDecoder().decode(res.bytes), "789"); res = await range("bytes=-3"); assertEquals(new TextDecoder().decode(res.bytes), "789"); assertEquals(res.headers.get("content-range"), "bytes 7-9/10"); // A suffix longer than the file is the whole file. res = await range("bytes=-100"); assertEquals(new TextDecoder().decode(res.bytes), "0123456789"); assertEquals(res.headers.get("content-range"), "bytes 0-9/10"); res = await range("bytes=5-100"); assertEquals(new TextDecoder().decode(res.bytes), "56789"); for ( const bad of [ "bytes=10-", "bytes=6-2", "bytes=-0", "bytes=-", ] ) { res = await range(bad); assertEquals(res.status, 416, bad); assertEquals(res.headers.get("content-range"), "bytes */10", bad); } // Several ranges, or a unit other than bytes, aren't refused: the header is // ignored and the whole file sent, as RFC 9110 allows. for ( const unsupported of [ "bytes=0-0,5-6", "bytes=0-1, 3-4", "items=0-1", "bytes=zero-one", ] ) { res = await range(unsupported); assertEquals(res.status, 200, unsupported); assertEquals(new TextDecoder().decode(res.bytes), "0123456789"); assertEquals(res.headers.get("content-range"), null, unsupported); assertEquals(res.headers.get("content-length"), "10", unsupported); } }); test("GET /api/files/:id: private files need the owner's media token", async () => { const owner = await createUser(); const other = await createUser(); const { id } = insertDump({ userId: owner.id, fileName: "p", isPrivate: true, }); assertEquals((await get(`/api/files/${id}`)).status, 404); assertEquals( (await get(`/api/files/${id}?token=${other.mediaToken}`)).status, 404, ); assertEquals((await get(`/api/files/${id}?token=garbage`)).status, 404); const res = await get(`/api/files/${id}?token=${owner.mediaToken}`); assertEquals(res.status, 200); assertEquals(res.headers.get("cache-control"), "private, no-cache"); // A legacy session token still works in the query string. assertEquals( (await get(`/api/files/${id}?token=${owner.token}`)).status, 200, ); }); test("GET /api/files/:id: bad ids, URL dumps and missing bytes", async () => { const user = await createUser(); assertEquals((await get(`/api/files/..%2F..%2Fetc%2Fpasswd`)).status, 400); const url = insertDump({ userId: user.id }); assertEquals((await get(`/api/files/${url.id}`)).status, 404); const gone = insertDump({ userId: user.id, fileName: "g" }); Deno.removeSync(`${TEST_DIR}/uploads/dumps/${gone.id}`); assertEquals((await get(`/api/files/${gone.id}`)).status, 404); }); test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async () => { const owner = await createUser(); const video = insertDump({ userId: owner.id, fileName: "v.mp4", fileMime: "video/mp4", }); const priv = insertDump({ userId: owner.id, fileName: "p.mp4", fileMime: "video/mp4", isPrivate: true, }); Deno.mkdirSync(`${TEST_DIR}/uploads/thumbnails`, { recursive: true }); for (const { id } of [video, priv]) { Deno.writeFileSync(`${TEST_DIR}/uploads/thumbnails/${id}.jpg`, PNG); } const res = await get(`/api/thumbnails/${video.id}`); assertEquals(res.status, 200); assertEquals(res.headers.get("content-type"), "image/jpeg"); assertEquals(res.headers.get("cache-control"), "public, no-cache"); const v = (id: string) => `5-${Date.parse(createdAtOf(id))}`; assertEquals( (await get(`/api/thumbnails/${video.id}?v=${v(video.id)}`)).headers.get( "cache-control", ), "public, max-age=31536000, immutable", ); assertEquals((await get(`/api/thumbnails/${priv.id}`)).status, 404); const own = await get( `/api/thumbnails/${priv.id}?v=${v(priv.id)}&token=${owner.mediaToken}`, ); assertEquals(own.status, 200); assertEquals( own.headers.get("cache-control"), "private, max-age=31536000, immutable", ); const text = insertDump({ userId: owner.id, fileName: "t.txt" }); assertEquals((await get(`/api/thumbnails/${text.id}`)).status, 404); assertEquals((await get(`/api/thumbnails/nope`)).status, 400); }); test("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", async () => { const owner = await createUser(); const gif = new TextEncoder().encode("GIF89a-not-really"); const animated = insertDump({ userId: owner.id, fileName: "a.gif", fileMime: "image/gif", fileData: gif, }); const res = await get(`/api/thumbnails/${animated.id}`); assertEquals(res.status, 200); assertEquals(res.headers.get("content-type"), "image/gif"); assertEquals(res.bytes, gif); // A real PNG is scaled into a preview, kept beside the video stills. const photo = insertDump({ userId: owner.id, fileName: "p.png", fileMime: "image/png", fileData: PNG, }); const preview = await get(`/api/thumbnails/${photo.id}`); assertEquals(preview.status, 200); assertEquals(preview.headers.get("content-type"), "image/png"); if (await hasFfmpeg()) { assertEquals( readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) => n.startsWith(photo.id) ), [`${photo.id}.preview.png`], ); } // Not an image ffmpeg can read (or no ffmpeg at all): the original goes out. const broken = insertDump({ userId: owner.id, fileName: "b.png", fileMime: "image/png", fileData: BYTES, }); const fallback = await get(`/api/thumbnails/${broken.id}`); assertEquals(fallback.status, 200); assertEquals(fallback.headers.get("content-type"), "image/png"); assertEquals(fallback.bytes, BYTES); assertEquals( readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) => n.startsWith(broken.id) ), [], ); }); async function hasFfmpeg(): Promise { try { return (await new Deno.Command("ffmpeg", { args: ["-version"], stdout: "null", stderr: "null", }).output()).success; } catch { return false; } } function readDir(path: string): string[] { try { return [...Deno.readDirSync(path)].map((e) => e.name); } catch { return []; } } test("attachments: upload an image, serve it back", async () => { const user = await createUser(); const res = await post("/api/attachments", { auth: user.auth, form: formWithFile(PNG), }); assertEquals(res.status, 201); const id = res.data.id; const served = await get(`/api/attachments/${id}`); assertEquals(served.status, 200); assertEquals(served.bytes, PNG); assertEquals(served.headers.get("content-type"), "image/png"); assertEquals(served.headers.get("x-content-type-options"), "nosniff"); assertEquals(served.headers.get("cache-control"), "public, max-age=3600"); assertEquals( (await get(`/api/attachments/${crypto.randomUUID()}`)).status, 404, ); }); test("attachments: refuses non-images, non-multipart and anonymous uploads", async () => { const user = await createUser(); assertEquals( (await post("/api/attachments", { auth: user.auth, form: formWithFile( new TextEncoder().encode("