import { createUser, db, get, insertDump, test, TEST_DIR } from "../helpers.ts"; import { assertEquals, assertStringIncludes } from "@std/assert"; const BYTES = new TextEncoder().encode("0123456789"); 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: 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", ); }); 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); });