All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
// 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 { assertEquals, 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.body.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("<script>"),
|
|
"x.png",
|
|
"image/png",
|
|
),
|
|
})).status,
|
|
400,
|
|
);
|
|
assertEquals(
|
|
(await post("/api/attachments", { auth: user.auth, json: {} })).status,
|
|
400,
|
|
);
|
|
const empty = new FormData();
|
|
empty.set("other", "x");
|
|
assertEquals(
|
|
(await post("/api/attachments", { auth: user.auth, form: empty })).status,
|
|
400,
|
|
);
|
|
assertEquals(
|
|
(await post("/api/attachments", { form: formWithFile(PNG) })).status,
|
|
401,
|
|
);
|
|
assertEquals(count(`SELECT COUNT(*) as n FROM attachments;`), 0);
|
|
});
|
|
|
|
test("attachments are claimed by the first text that links them", async () => {
|
|
const user = await createUser();
|
|
const { id: dumpId } = insertDump({ userId: user.id });
|
|
const up = await post("/api/attachments", {
|
|
auth: user.auth,
|
|
form: formWithFile(PNG),
|
|
});
|
|
const attachmentId = up.body.data.id;
|
|
const link = ``;
|
|
const comment = await post(`/api/dumps/${dumpId}/comments`, {
|
|
auth: user.auth,
|
|
json: { body: link },
|
|
});
|
|
const owner = () =>
|
|
(db.prepare(`SELECT resource_id FROM attachments WHERE id = ?;`).get(
|
|
attachmentId,
|
|
) as { resource_id: string | null }).resource_id;
|
|
assertEquals(owner(), comment.body.data.id);
|
|
// A second text can't take it over.
|
|
await post(`/api/dumps/${dumpId}/comments`, {
|
|
auth: user.auth,
|
|
json: { body: link },
|
|
});
|
|
assertEquals(owner(), comment.body.data.id);
|
|
});
|
|
|
|
test("avatars: upload, serve, and 404 when there is none", async () => {
|
|
const user = await createUser();
|
|
assertEquals((await get(`/api/avatars/${user.id}`)).status, 404);
|
|
assertEquals((await get(`/api/avatars/${crypto.randomUUID()}`)).status, 404);
|
|
const res = await post("/api/avatars/me", {
|
|
auth: user.auth,
|
|
form: formWithFile(PNG),
|
|
});
|
|
assertEquals(res.status, 200);
|
|
assertEquals(res.body.data.avatarMime, "image/png");
|
|
assertEquals(res.body.data.passwordHash, undefined);
|
|
const served = await get(`/api/avatars/${user.id}`);
|
|
assertEquals(served.status, 200);
|
|
assertEquals(served.bytes, PNG);
|
|
assertEquals(
|
|
(await post("/api/avatars/me", {
|
|
auth: user.auth,
|
|
form: formWithFile(new Uint8Array([1, 2, 3])),
|
|
})).status,
|
|
400,
|
|
);
|
|
});
|