195 lines
6.6 KiB
TypeScript
195 lines
6.6 KiB
TypeScript
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/dumps/:id/file: 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/dumps/${id}/file`);
|
|
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/dumps/${id}/file`, {
|
|
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/dumps/${id}/file?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/dumps/${id}/file?v=10-0`)).headers.get("cache-control"),
|
|
"public, no-cache",
|
|
);
|
|
});
|
|
|
|
test("GET /api/dumps/:id/file: 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/dumps/${id}/file`);
|
|
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/dumps/${pdf.id}/file`);
|
|
assertStringIncludes(res.headers.get("content-disposition")!, "inline;");
|
|
assertEquals(res.headers.get("content-security-policy"), null);
|
|
});
|
|
|
|
test("GET /api/dumps/:id/file: 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/dumps/${id}/file`);
|
|
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/dumps/:id/file: byte ranges", async () => {
|
|
const user = await createUser();
|
|
const { id } = insertDump({
|
|
userId: user.id,
|
|
fileName: "d",
|
|
fileData: BYTES,
|
|
});
|
|
const range = (r: string) =>
|
|
get(`/api/dumps/${id}/file`, { 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/dumps/:id/file: 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/dumps/${id}/file`)).status, 404);
|
|
assertEquals(
|
|
(await get(`/api/dumps/${id}/file?token=${other.mediaToken}`)).status,
|
|
404,
|
|
);
|
|
assertEquals((await get(`/api/dumps/${id}/file?token=garbage`)).status, 404);
|
|
const res = await get(`/api/dumps/${id}/file?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/dumps/${id}/file?token=${owner.token}`)).status,
|
|
200,
|
|
);
|
|
});
|
|
|
|
test("GET /api/dumps/:id/file: bad ids, URL dumps and missing bytes", async () => {
|
|
const user = await createUser();
|
|
assertEquals(
|
|
(await get(`/api/dumps/..%2F..%2Fetc%2Fpasswd/file`)).status,
|
|
400,
|
|
);
|
|
const url = insertDump({ userId: user.id });
|
|
assertEquals((await get(`/api/dumps/${url.id}/file`)).status, 404);
|
|
const gone = insertDump({ userId: user.id, fileName: "g" });
|
|
Deno.removeSync(`${TEST_DIR}/uploads/dumps/${gone.id}`);
|
|
assertEquals((await get(`/api/dumps/${gone.id}/file`)).status, 404);
|
|
});
|