// 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;"); assertEquals(res.headers.get("cache-control"), null); }); 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"); // 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, max-age=31536000, immutable", ); assertEquals((await get(`/api/thumbnails/${priv.id}`)).status, 404); const own = await get(`/api/thumbnails/${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("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("