v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 20:19:48 +00:00
parent e9f6a1f23e
commit d392be7cb8
269 changed files with 6712 additions and 7122 deletions

62
api/test/lib/auth.test.ts Normal file
View File

@@ -0,0 +1,62 @@
import { createUser, db, get, test } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import { authenticate, authenticateMedia } from "../../lib/auth.ts";
test("authenticate: the role comes from the database, not the token", async () => {
const user = await createUser({ role: "user" });
db.prepare(`UPDATE users SET role = 'admin' WHERE id = ?;`).run(user.id);
assertEquals((await authenticate(user.token))?.role, "admin");
});
test("authenticate: a deleted user's token is refused", async () => {
const user = await createUser();
db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id);
assertEquals(await authenticate(user.token), null);
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
});
test("authenticate: tokens issued before tokens_valid_after are revoked", async () => {
const user = await createUser();
const future = new Date(Date.now() + 60_000).toISOString();
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
future,
user.id,
);
assertEquals(await authenticate(user.token), null);
assertEquals(await authenticateMedia(user.mediaToken), null);
// A token issued at the cutoff second or later passes.
const past = new Date(Date.now() - 60_000).toISOString();
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
past,
user.id,
);
assertEquals((await authenticate(user.token))?.userId, user.id);
});
test("authenticateMedia: takes a media token or a legacy session token", async () => {
const user = await createUser();
assertEquals(await authenticateMedia(user.mediaToken), user.id);
assertEquals(await authenticateMedia(user.token), user.id);
assertEquals(await authenticateMedia("garbage"), null);
});
test("optional auth: a missing or placeholder token is anonymous, a bad one is 401", async () => {
const owner = await createUser();
// A public listing that reads optional auth.
const path = `/api/users/${owner.username}/dumps`;
assertEquals((await get(path)).status, 200);
for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) {
assertEquals(
(await get(path, { headers: { Authorization: placeholder } })).status,
200,
placeholder,
);
}
assertEquals(
(await get(path, { headers: { Authorization: "Basic abc" } })).status,
200,
);
const bad = await get(path, { headers: { Authorization: "Bearer nope" } });
assertEquals(bad.status, 401);
assertEquals(bad.error.code, "UNAUTHORIZED");
});

View File

@@ -0,0 +1,76 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { canonicalizeUrl } from "../../lib/canonical-url.ts";
Deno.test("canonicalizeUrl: scheme, www and trailing slash collapse", () => {
const want = "https://example.com/a";
for (
const url of [
"http://example.com/a",
"https://www.example.com/a",
"https://example.com/a/",
"https://EXAMPLE.com/a//",
]
) {
assertEquals(canonicalizeUrl(url), want, url);
}
assertEquals(canonicalizeUrl("https://example.com/"), "https://example.com");
});
Deno.test("canonicalizeUrl: tracking parameters dropped, the rest sorted", () => {
assertEquals(
canonicalizeUrl(
"https://example.com/p?utm_source=x&b=2&fbclid=y&a=1&UTM_Medium=z&si=q",
),
"https://example.com/p?a=1&b=2",
);
assertEquals(
canonicalizeUrl("https://example.com/p?b=1&a=2"),
canonicalizeUrl("https://example.com/p?a=2&b=1"),
);
});
Deno.test("canonicalizeUrl: section anchors dropped, hash routes kept", () => {
assertEquals(
canonicalizeUrl("https://example.com/doc#intro"),
"https://example.com/doc",
);
assertEquals(
canonicalizeUrl("https://app.example.com/#/inbox"),
"https://app.example.com#/inbox",
);
assertEquals(
canonicalizeUrl("https://app.example.com/#!/inbox"),
"https://app.example.com#!/inbox",
);
});
Deno.test("canonicalizeUrl: every YouTube video shape is one key", () => {
const want = "https://youtube.com/watch?v=dQw4w9WgXcQ";
for (
const url of [
"https://youtu.be/dQw4w9WgXcQ",
"https://youtu.be/dQw4w9WgXcQ?t=42",
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL1&t=1m",
"https://m.youtube.com/watch?v=dQw4w9WgXcQ",
"https://music.youtube.com/watch?v=dQw4w9WgXcQ",
"https://www.youtube.com/embed/dQw4w9WgXcQ",
"https://youtube.com/shorts/dQw4w9WgXcQ",
"https://youtube.com/live/dQw4w9WgXcQ",
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ",
]
) {
assertEquals(canonicalizeUrl(url), want, url);
}
assertEquals(
canonicalizeUrl("https://www.youtube.com/playlist?list=PLx&si=abc"),
"https://youtube.com/playlist?list=PLx",
);
});
Deno.test("canonicalizeUrl: non-http and unparseable input", () => {
assertEquals(canonicalizeUrl("ftp://example.com/a"), null);
assertEquals(canonicalizeUrl("javascript:alert(1)"), null);
assertEquals(canonicalizeUrl("not a url"), null);
assertEquals(canonicalizeUrl(""), null);
});

80
api/test/lib/jwt.test.ts Normal file
View File

@@ -0,0 +1,80 @@
import "../setup.ts";
import {
assert,
assertEquals,
assertFalse,
assertNotEquals,
} from "@std/assert";
import { SignJWT } from "@panva/jose";
import {
createJWT,
createMediaToken,
createPasswordResetToken,
hashPassword,
verifyJWT,
verifyMediaToken,
verifyPassword,
verifyPasswordResetToken,
} from "../../lib/jwt.ts";
const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret");
Deno.test("hashPassword/verifyPassword round-trip with a random salt", async () => {
const a = await hashPassword("hunter22");
const b = await hashPassword("hunter22");
assertNotEquals(a, b);
assert(await verifyPassword("hunter22", a));
assertFalse(await verifyPassword("hunter23", a));
// A malformed stored hash never verifies.
assertFalse(await verifyPassword("hunter22", "not-a-hash"));
assertFalse(await verifyPassword("hunter22", ""));
});
Deno.test("session, media and reset tokens don't stand in for each other", async () => {
const userId = crypto.randomUUID();
const session = await createJWT({ userId, username: "u", role: "user" });
const media = await createMediaToken(userId);
const reset = await createPasswordResetToken(userId);
assertEquals((await verifyJWT(session))?.userId, userId);
assertEquals(await verifyJWT(media), null);
assertEquals(await verifyJWT(reset), null);
assertEquals((await verifyMediaToken(media))?.userId, userId);
assertEquals(await verifyMediaToken(session), null);
assertEquals(await verifyMediaToken(reset), null);
assertEquals((await verifyPasswordResetToken(reset))?.userId, userId);
assertEquals(await verifyPasswordResetToken(session), null);
assertEquals(await verifyPasswordResetToken(media), null);
});
Deno.test("verifyJWT: refuses a bad signature, an expired token, garbage", async () => {
const forged = await new SignJWT({
userId: "x",
username: "x",
role: "admin",
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(new TextEncoder().encode("some-other-secret"));
assertEquals(await verifyJWT(forged), null);
const expired = await new SignJWT({
userId: "x",
username: "x",
role: "user",
})
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(Math.floor(Date.now() / 1000) - 10)
.sign(KEY);
assertEquals(await verifyJWT(expired), null);
const badRole = await new SignJWT({ userId: "x", username: "x", role: "god" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(KEY);
assertEquals(await verifyJWT(badRole), null);
assertEquals(await verifyJWT("not.a.jwt"), null);
});

View File

@@ -0,0 +1,15 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { parsePagination } from "../../lib/pagination.ts";
Deno.test("parsePagination: defaults, bounds and garbage", () => {
const p = (q: string, def?: number) =>
parsePagination(new URLSearchParams(q), def);
assertEquals(p(""), { page: 1, limit: 20 });
assertEquals(p("page=3&limit=5"), { page: 3, limit: 5 });
assertEquals(p("page=0&limit=0"), { page: 1, limit: 20 });
assertEquals(p("page=-4&limit=-1"), { page: 1, limit: 1 });
assertEquals(p("limit=1000"), { page: 1, limit: 100 });
assertEquals(p("page=abc&limit=xyz"), { page: 1, limit: 20 });
assertEquals(p("", 7), { page: 1, limit: 7 });
});

View File

@@ -0,0 +1,12 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import { can } from "../../lib/permissions.ts";
Deno.test("can: checks the token's role", () => {
assert(
can({ userId: "x", username: "x", role: "admin", exp: 0 }, "user:manage"),
);
assertFalse(
can({ userId: "x", username: "x", role: "user", exp: 0 }, "dump:moderate"),
);
});

View File

@@ -0,0 +1,27 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import { FakeTime } from "@std/testing/time";
import { takeToken } from "../../lib/rate-limit.ts";
Deno.test("takeToken: bursts to capacity, then refills over time", () => {
using time = new FakeTime();
const key = `test:${crypto.randomUUID()}`;
const limit = { capacity: 3, refill: 1 }; // one token a second
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assertFalse(takeToken(key, limit));
time.tick(500);
assertFalse(takeToken(key, limit));
time.tick(500);
assert(takeToken(key, limit));
assertFalse(takeToken(key, limit));
// Refill never exceeds capacity.
time.tick(60_000);
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assert(takeToken(key, limit));
assertFalse(takeToken(key, limit));
// Keys are independent.
assert(takeToken(`${key}:other`, limit));
});

View File

@@ -0,0 +1,21 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import {
isReservedSlug,
isSlugAvailable,
isValidSlugFormat,
} from "../../lib/reserved-slugs.ts";
Deno.test("category slugs: format and reserved words", () => {
assert(isValidSlugFormat("music"));
assert(isValidSlugFormat("lo-fi-2"));
for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) {
assertFalse(isValidSlugFormat(bad), bad);
}
assert(isReservedSlug("dumps"));
assert(isReservedSlug("api"));
assertFalse(isReservedSlug("music"));
assert(isSlugAvailable("music"));
assertFalse(isSlugAvailable("users"));
assertFalse(isSlugAvailable("Nope"));
});

View File

@@ -0,0 +1,13 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import { isSelfUrl } from "../../lib/self-url.ts";
Deno.test("isSelfUrl: this site's dump, user and playlist pages only", () => {
// GERBEUR_PUBLIC_URL is http://gerbeur.test in the suite.
assert(isSelfUrl(new URL("http://gerbeur.test/dumps/abc")));
assert(isSelfUrl(new URL("https://GERBEUR.test/users/bob")));
assert(isSelfUrl(new URL("http://gerbeur.test/playlists/p-1")));
assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc/edit")));
assertFalse(isSelfUrl(new URL("http://gerbeur.test/search")));
assertFalse(isSelfUrl(new URL("http://elsewhere.test/users/bob")));
});

View File

@@ -0,0 +1,11 @@
import "../setup.ts";
import { assertEquals, assertMatch } from "@std/assert";
import { emojiToCodepoint } from "../../lib/site-icons.ts";
Deno.test("emojiToCodepoint: Twemoji file naming", () => {
assertEquals(emojiToCodepoint("🚚"), "1f69a");
// A lone variation selector is dropped…
assertEquals(emojiToCodepoint("❤️"), "2764");
// …but kept inside a ZWJ sequence.
assertMatch(emojiToCodepoint("🏳️‍🌈"), /^1f3f3-fe0f-200d-1f308$/);
});

View File

@@ -0,0 +1,19 @@
import "../setup.ts";
import { assert, assertEquals, assertFalse } from "@std/assert";
import { makeSlug, slugify, UUID_RE } from "../../lib/slugify.ts";
Deno.test("slugify: lowercases, strips accents, dashes the rest", () => {
assertEquals(slugify("Hello, World!"), "hello-world");
assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris");
assertEquals(slugify(" --already--dashed-- "), "already-dashed");
assertEquals(slugify("日本語"), "untitled");
assertEquals(slugify(""), "untitled");
assertEquals(slugify("x".repeat(100)).length, 60);
});
Deno.test("makeSlug: suffixes the first 8 characters of the id", () => {
const id = "0123abcd-0000-4000-8000-000000000000";
assertEquals(makeSlug("My Song", id), "my-song-0123abcd");
assert(UUID_RE.test(id));
assertFalse(UUID_RE.test("my-song-0123abcd"));
});

View File

@@ -0,0 +1,74 @@
import { BASE_URL, TEST_DIR } from "../helpers.ts";
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
import { Application } from "@oak/oak";
import { routeStaticFilesFrom } from "../../lib/static.ts";
async function handle(
app: Application,
path: string,
headers: HeadersInit = {},
) {
const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers }));
if (!res) throw new Error("no response");
return { res, text: await res.text() };
}
Deno.test("routeStaticFilesFrom: index, SPA fallback, cache rules, precompressed files", async () => {
const root = `${TEST_DIR}/static-${crypto.randomUUID()}`;
await Deno.mkdir(`${root}/assets`, { recursive: true });
await Deno.writeTextFile(
`${root}/index.html`,
`<html><head><title>__SITE_NAME__</title></head><body>__BANDCAMP_PLAYER__</body></html>`,
);
const js = "console.log('app');".repeat(50);
await Deno.writeTextFile(`${root}/assets/app-abc123.js`, js);
const br = new Uint8Array([1, 2, 3]);
await Deno.writeFile(`${root}/assets/app-abc123.js.br`, br);
await Deno.writeTextFile(`${root}/robots.txt`, "User-agent: *");
const app = new Application();
app.use(routeStaticFilesFrom([root]));
const index = await handle(app, "/");
assertEquals(index.res.headers.get("cache-control"), "no-cache");
assertEquals(
index.text,
"<html><head><title>gerbeur</title></head><body>embed</body></html>",
);
const spa = await handle(app, "/users/someone");
assertEquals(spa.res.status, 200);
assertEquals(spa.text, index.text);
// No Accept-Encoding header: the plain file, not the .br sibling.
const asset = await handle(app, "/assets/app-abc123.js");
assertEquals(asset.text, js);
assertEquals(asset.res.headers.get("content-encoding"), null);
assertStringIncludes(asset.res.headers.get("cache-control")!, "immutable");
assertStringIncludes(
asset.res.headers.get("cache-control")!,
"max-age=31536000",
);
assertStringIncludes(asset.res.headers.get("vary")!, "Accept-Encoding");
const compressed = await handle(app, "/assets/app-abc123.js", {
"Accept-Encoding": "br, gzip",
});
assertEquals(compressed.res.headers.get("content-encoding"), "br");
assertEquals(compressed.text, "\x01\x02\x03"); // the .br file's bytes
const plain = await handle(app, "/robots.txt");
assertEquals(plain.text, "User-agent: *");
assertFalse(
(plain.res.headers.get("cache-control") ?? "").includes("immutable"),
);
// A chunk from an older build 404s rather than being answered with the index.
const stale = await handle(app, "/assets/app-old999.js");
assertEquals(stale.res.status, 404);
assertFalse(stale.text.includes("<html>"));
assertEquals(
(await handle(app, "/emojibase/17.0.0/missing.json")).res.status,
404,
);
});

View File

@@ -0,0 +1,42 @@
import "../setup.ts";
import { PNG } from "../helpers.ts";
import { assertEquals, assertThrows } from "@std/assert";
import { detectImageMime, validateImageUpload } from "../../lib/upload.ts";
import { APIException } from "../../lib/api-exception.ts";
Deno.test("detectImageMime: sniffs magic bytes, not names", () => {
assertEquals(detectImageMime(PNG), "image/png");
assertEquals(
detectImageMime(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])),
"image/jpeg",
);
assertEquals(
detectImageMime(new TextEncoder().encode("GIF89a")),
"image/gif",
);
assertEquals(
detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")),
"image/webp",
);
assertEquals(
detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WAVEfmt ")),
null,
);
assertEquals(
detectImageMime(new TextEncoder().encode("<svg xmlns=...>")),
null,
);
assertEquals(detectImageMime(new Uint8Array()), null);
});
Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () => {
assertEquals(validateImageUpload(PNG), "image/png");
assertThrows(
() => validateImageUpload(new TextEncoder().encode("<html>")),
APIException,
"not a recognised image",
);
const big = new Uint8Array(5 * 1024 * 1024 + 1);
big.set(PNG);
assertThrows(() => validateImageUpload(big), APIException, "too large");
});