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>
424 lines
13 KiB
TypeScript
424 lines
13 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 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<boolean> {
|
|
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<AttachmentResponse>("/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("<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<AttachmentResponse>("/api/attachments", {
|
|
auth: user.auth,
|
|
form: formWithFile(PNG),
|
|
});
|
|
const attachmentId = up.data.id;
|
|
const link = ``;
|
|
const comment = await post<Comment>(`/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.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.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<SelfUser>("/api/avatars/me", {
|
|
auth: user.auth,
|
|
form: formWithFile(PNG),
|
|
});
|
|
assertEquals(res.status, 200);
|
|
assertEquals(res.data.avatarMime, "image/png");
|
|
assertFalse("passwordHash" in res.data);
|
|
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,
|
|
);
|
|
});
|