v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The exception that carries an HTTP error out of any layer below a route.
|
||||
* Defines the exception that carries an HTTP error from any layer up to the
|
||||
* error middleware.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,16 +8,18 @@
|
||||
import type { APIErrorCode } from "../../shared/api.ts";
|
||||
|
||||
/**
|
||||
* An error meant for the client, thrown anywhere below a route; the error
|
||||
* middleware turns it into an `APIFailure` response.
|
||||
* Thrown by a route, a service or a remote fetch to answer the request with an
|
||||
* error. The error middleware sends it as an `APIFailure`, message included,
|
||||
* so the message must be fit for the user.
|
||||
*/
|
||||
export class APIException extends Error {
|
||||
/** The error code the response carries. */
|
||||
readonly code: APIErrorCode;
|
||||
/** The HTTP status the response is sent with. */
|
||||
readonly status: number;
|
||||
|
||||
/** Creates an exception answering `status` with `code` and `message`. */
|
||||
/**
|
||||
* Creates the exception. A code goes with several statuses, as
|
||||
* `UNAUTHORIZED` does with 401 and 403, so both are given.
|
||||
*/
|
||||
constructor(code: APIErrorCode, status: number, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Authenticates the tokens a request carries: session tokens in the
|
||||
* `Authorization` header and media tokens in a media URL's query string.
|
||||
* Checks session and media tokens against the live user row, not just their
|
||||
* signature. The auth middleware, the WebSocket route, the media routes and the
|
||||
* routes open to anonymous callers all authenticate through it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -14,13 +15,10 @@ import type { Role } from "../../shared/user.ts";
|
||||
import { getSessionState } from "../services/user-service.ts";
|
||||
|
||||
/**
|
||||
* Checks a signature-verified token against the live user row: the user must
|
||||
* still exist, and the token must not predate their last password change
|
||||
* (`users.tokens_valid_after`). Returns the live role, or null to refuse.
|
||||
*
|
||||
* `iat` has one-second resolution, so a token issued in the same second as the
|
||||
* change survives it; one issued after it (the fresh one the change hands back)
|
||||
* always does.
|
||||
* Returns the live role of a verified token's user, or null when the user is
|
||||
* gone or the token predates their last password change
|
||||
* (`users.tokens_valid_after`). `iat` counts whole seconds, so a token issued
|
||||
* in the same second as the change survives it.
|
||||
*/
|
||||
function checkTokenOwner(
|
||||
userId: string,
|
||||
@@ -38,9 +36,10 @@ function checkTokenOwner(
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a session token end to end: signature, expiry, a user that still
|
||||
* exists and no password change since it was issued. The role comes from the
|
||||
* DB, not the token, since tokens last 7 days and roles change under them.
|
||||
* Returns the payload of a valid session token, or null. Besides the signature
|
||||
* and expiry, it refuses a token whose user is gone or which predates a
|
||||
* password change. The returned role comes from the database, not the token,
|
||||
* since a token lasts 7 days and a role can change under it.
|
||||
*/
|
||||
export async function authenticate(
|
||||
token: string,
|
||||
@@ -53,8 +52,9 @@ export async function authenticate(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the `?token=` on a media URL to the viewer's user id. Takes a media
|
||||
* token, or a session token from a client that predates them.
|
||||
* Returns the user id behind the `?token=` of a file, thumbnail or playlist
|
||||
* cover URL, or null when it isn't valid. A session token is accepted too, for
|
||||
* clients from before media tokens.
|
||||
*/
|
||||
export async function authenticateMedia(token: string): Promise<string | null> {
|
||||
const media = await verifyMediaToken(token);
|
||||
@@ -65,18 +65,13 @@ export async function authenticateMedia(token: string): Promise<string | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user id of an optional Bearer token, or null for an anonymous
|
||||
* request.
|
||||
* Returns the caller's user id on a route open to anonymous callers, or null
|
||||
* when the request has no token. A placeholder such as `Bearer undefined`
|
||||
* counts as no token. An invalid token throws instead of reading as anonymous,
|
||||
* so that a stale session gets a 401 the client signs out on, rather than a
|
||||
* misleading 404 on a private resource.
|
||||
*
|
||||
* Distinguishes *missing* credentials from *invalid* ones:
|
||||
* - No `Authorization: Bearer …` header (or a placeholder value such as the
|
||||
* `Bearer undefined` that the client emits when logged out) → anonymous (null).
|
||||
* - A real token that fails verification (expired / bad signature / revoked) →
|
||||
* throws 401, so a stale client session surfaces as an auth error it can react
|
||||
* to, instead of being silently downgraded to anonymous (which yields a
|
||||
* misleading 404 on private resources).
|
||||
*
|
||||
* @throws {APIException} 401 when a token is present but fails verification.
|
||||
* @throws {APIException} 401 when a token is present but invalid.
|
||||
*/
|
||||
export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
|
||||
const authHeader = ctx.request.headers.get("Authorization");
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
/**
|
||||
* The canonical form of a URL, used *only* to recognise that two links point at the
|
||||
* same thing — the duplicate check the create form runs while it fetches a
|
||||
* preview. It is a lookup key, never something we display or fetch: `dumps.url`
|
||||
* keeps the exact string the poster submitted.
|
||||
* Reduces a URL to a key shared by every link to the same page or video.
|
||||
* `dump-service` stores it in `dumps.url_canonical` when a
|
||||
* dump is posted or edited, and `GET /api/dumps/by-url` matches on it
|
||||
* to warn the create form of a repost. The key is never fetched or shown, so it
|
||||
* may be lossy: it forces `https`, drops `www.`, tracking parameters and
|
||||
* timestamps.
|
||||
*
|
||||
* Because it is only ever compared against other canonical forms, it may be
|
||||
* lossy in ways a real URL never could be — it forces `https`, drops `www.`,
|
||||
* and throws away share/tracking parameters and timestamps, so
|
||||
* `http://www.example.com/a/?utm_source=x` and `https://example.com/a` collapse
|
||||
* to one key.
|
||||
*
|
||||
* Stored in `dumps.url_canonical`. Migration 0013 carries a frozen copy of this
|
||||
* logic to backfill existing rows; changing the rules here therefore needs a
|
||||
* new backfill migration, or old rows keep keys computed under the old rules.
|
||||
* Migration 0013 backfilled existing rows with a frozen copy of these rules.
|
||||
* Changing them needs a new backfill migration, or old rows keep old keys.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -42,7 +37,7 @@ function isTrackingParam(key: string): boolean {
|
||||
return k.startsWith("utm_") || TRACKING_PARAMS.has(k);
|
||||
}
|
||||
|
||||
/** YouTube's hosts, already stripped of a leading "www.". */
|
||||
/** YouTube's hosts, without the "www." {@link canonicalizeUrl} strips first. */
|
||||
const YOUTUBE_HOSTS = new Set([
|
||||
"youtube.com",
|
||||
"m.youtube.com",
|
||||
@@ -51,10 +46,10 @@ const YOUTUBE_HOSTS = new Set([
|
||||
]);
|
||||
|
||||
/**
|
||||
* Returns the video a YouTube URL points at, in any of the shapes people paste
|
||||
* (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`, `/live/ID`).
|
||||
* Timestamps are deliberately ignored: the same video linked at 2:30 is still
|
||||
* the same video for duplicate purposes.
|
||||
* Returns the id of the video a YouTube URL points at, in any of the shapes
|
||||
* people paste (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`,
|
||||
* `/live/ID`). The timestamp is dropped: the same video linked at 2:30 is still
|
||||
* a repost.
|
||||
*/
|
||||
function youtubeVideoId(
|
||||
host: string,
|
||||
@@ -71,8 +66,8 @@ function youtubeVideoId(
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the canonical form of `raw` (see the module doc), or null when it
|
||||
* isn't an http(s) URL.
|
||||
* Returns the canonical key of `raw`, or null when it isn't an http(s) URL. A
|
||||
* YouTube video or playlist becomes one fixed URL whatever shape it came in.
|
||||
*/
|
||||
export function canonicalizeUrl(raw: string): string | null {
|
||||
let u: URL;
|
||||
@@ -94,7 +89,7 @@ export function canonicalizeUrl(raw: string): string | null {
|
||||
: null;
|
||||
if (listId) return `https://youtube.com/playlist?list=${listId}`;
|
||||
|
||||
// A single trailing slash is never meaningful; "/" itself becomes "".
|
||||
// Trailing slashes are dropped; "/" itself becomes "".
|
||||
const path = u.pathname.replace(/\/+$/, "");
|
||||
|
||||
const params = [...u.searchParams.entries()]
|
||||
@@ -102,8 +97,8 @@ export function canonicalizeUrl(raw: string): string | null {
|
||||
.sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv));
|
||||
const query = new URLSearchParams(params).toString();
|
||||
|
||||
// A bare "#section" anchor points into the same page, so it is dropped —
|
||||
// but "#/route" and "#!/route" address distinct pages of a hash-routed app.
|
||||
// A "#section" anchor points into the same page and is dropped, but "#/route"
|
||||
// and "#!/route" are distinct pages of a hash-routed app.
|
||||
const hash = /^#!?\//.test(u.hash) ? u.hash : "";
|
||||
|
||||
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The built frontend's `index.html`, with its runtime placeholders filled: the
|
||||
* site name, emoji, icon version and Bandcamp player mode.
|
||||
* Fills the placeholders of the frontend's `index.html` with settings read at
|
||||
* runtime, so they change with a restart and no rebuild. The static and OG
|
||||
* middlewares serve what it returns.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,18 +9,20 @@
|
||||
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
|
||||
import { emojiToCodepoint } from "./site-icons.ts";
|
||||
|
||||
/** The cache-busting version of the icon URLs. */
|
||||
/**
|
||||
* Versions the icon URLs, so browsers fetch new icons when the emoji changes.
|
||||
*/
|
||||
const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
|
||||
|
||||
/**
|
||||
* `index.html` with its placeholders filled, by root. Kept until the file
|
||||
* changes (a rebuild under a running server): a stat per navigation instead of
|
||||
* a read and four replaceAll.
|
||||
* Caches each root's filled file until its mtime changes, as a rebuild under a
|
||||
* running server does, so a navigation costs a stat instead of a read.
|
||||
*/
|
||||
const indexHtml = new Map<string, { mtime: number; html: string }>();
|
||||
|
||||
/**
|
||||
* Returns `${root}/index.html` with its placeholders filled.
|
||||
* Returns `${root}/index.html` with the site name, emoji, icon version and
|
||||
* Bandcamp player mode filled in.
|
||||
*
|
||||
* @throws {Error} When the file can't be read.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Signed tokens and password hashes: session tokens, media tokens, password
|
||||
* reset tokens, and scrypt hashing.
|
||||
* Signs and verifies the server's three kinds of token (session, media and
|
||||
* password reset), all HS256 with the one key, and hashes passwords. Only a
|
||||
* token's signature and expiry are checked here: `lib/auth.ts` checks a session
|
||||
* or media token against its user.
|
||||
*
|
||||
* Importing this module throws when `GERBEUR_JWT_SECRET` is unset.
|
||||
*
|
||||
@@ -26,22 +28,26 @@ if (!JWT_SECRET) {
|
||||
}
|
||||
const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
|
||||
|
||||
// ── Password reset tokens ─────────────────────────────────────────────────────
|
||||
|
||||
/** Signs a single-use password reset token for `userId`, valid for an hour. */
|
||||
/**
|
||||
* Signs a password reset token for `userId`, valid for an hour. The token
|
||||
* isn't single-use by itself: `requestPasswordReset` stores it, and
|
||||
* `resetPassword` marks it used.
|
||||
*/
|
||||
export async function createPasswordResetToken(
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
return await new SignJWT({ purpose: "password-reset", userId })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
// Two requests in the same second would otherwise sign the same token,
|
||||
// which is the primary key of `password_reset_tokens`.
|
||||
.setJti(crypto.randomUUID())
|
||||
.setExpirationTime("1h")
|
||||
.sign(JWT_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a password reset token and returns its payload, or null when it is
|
||||
* invalid, expired or not a reset token.
|
||||
* Returns the payload of a password reset token, or null when it is invalid,
|
||||
* expired or another kind of token. Whether it was used is the caller's check.
|
||||
*/
|
||||
export async function verifyPasswordResetToken(
|
||||
token: string,
|
||||
@@ -55,29 +61,29 @@ export async function verifyPasswordResetToken(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth tokens ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Signs a session token carrying `payload`, valid for 7 days. */
|
||||
/**
|
||||
* Signs a session token carrying `payload`, valid for 7 days. The users routes
|
||||
* issue it on sign-up, sign-in and password change, and the client sends it as
|
||||
* a bearer token.
|
||||
*/
|
||||
export async function createJWT(
|
||||
payload: Omit<AuthPayload, "exp">,
|
||||
): Promise<string> {
|
||||
return await new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
// `iat` is what a password change revokes against (users.tokens_valid_after).
|
||||
// A password change revokes tokens by comparing `iat` with
|
||||
// `users.tokens_valid_after`.
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(JWT_KEY);
|
||||
}
|
||||
|
||||
// ── Media tokens ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Signs a media token for `userId`, valid for 7 days.
|
||||
*
|
||||
* Media elements can't send an Authorization header, so a private dump's file
|
||||
* and thumbnail URLs carry a token in the query string, where it lands in
|
||||
* access logs and browser history. That token is this one: it only reads media,
|
||||
* so a leaked URL exposes the owner's private files, not their account.
|
||||
* Signs a media token for `userId`, valid for 7 days. Media elements can't send
|
||||
* an `Authorization` header, so the URLs of private files, thumbnails and
|
||||
* playlist covers carry this token as `?token=`, where it lands in logs and
|
||||
* browser history. It only reads media, so a leaked URL doesn't expose the
|
||||
* account.
|
||||
*/
|
||||
export async function createMediaToken(userId: string): Promise<string> {
|
||||
return await new SignJWT({ purpose: "media", userId })
|
||||
@@ -88,8 +94,8 @@ export async function createMediaToken(userId: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a media token and returns its user id and issue time, or null when
|
||||
* it is invalid, expired or not a media token.
|
||||
* Returns the user id and issue time of a media token, or null when it is
|
||||
* invalid, expired or another kind of token.
|
||||
*/
|
||||
export async function verifyMediaToken(
|
||||
token: string,
|
||||
@@ -106,8 +112,9 @@ export async function verifyMediaToken(
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a session token's signature and expiry and returns its payload, or
|
||||
* null. Checking it against the live user is `authenticate`'s job (lib/auth.ts).
|
||||
* Returns the payload of a session token whose signature and expiry are valid,
|
||||
* or null. Call `authenticate` (`lib/auth.ts`) instead, which also checks the
|
||||
* token against its user.
|
||||
*/
|
||||
export async function verifyJWT(token: string): Promise<AuthPayload | null> {
|
||||
try {
|
||||
@@ -124,7 +131,10 @@ export async function verifyJWT(token: string): Promise<AuthPayload | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Hashes `password` with scrypt and a random salt, as `hash.salt` in hex. */
|
||||
/**
|
||||
* Hashes `password` with scrypt and a random salt, as `hash.salt` in hex, for
|
||||
* `users.password_hash`.
|
||||
*/
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
|
||||
@@ -142,8 +152,7 @@ export function verifyPassword(
|
||||
storedHash: string,
|
||||
): Promise<boolean> {
|
||||
const [hash, salt] = storedHash.split(".");
|
||||
// A malformed stored hash can't match anything; scrypt would throw on the
|
||||
// missing salt instead of answering.
|
||||
// scrypt would throw on a missing salt, and a malformed hash matches nothing.
|
||||
if (!hash || !salt) return Promise.resolve(false);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Page-based pagination: reading `page` and `limit` from a query string, and
|
||||
* cutting fetched rows into a page.
|
||||
* Reads the page a list route's query string asks for, and cuts the rows a
|
||||
* service fetched into that page. The routes send the page as
|
||||
* `PaginatedData`.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,9 +9,9 @@
|
||||
import { PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT } from "../config.ts";
|
||||
|
||||
/**
|
||||
* Reads the `page` and `limit` query parameters. `page` is at least 1 and
|
||||
* defaults to 1; `limit` is clamped to [1, {@link PAGINATION_MAX_LIMIT}] and
|
||||
* defaults to `defaultLimit`.
|
||||
* Reads the `page` and `limit` query parameters of a list route. It never
|
||||
* fails: a missing or invalid `page` reads as 1, and `limit` is kept between 1
|
||||
* and {@link PAGINATION_MAX_LIMIT}.
|
||||
*/
|
||||
export function parsePagination(
|
||||
params: URLSearchParams,
|
||||
@@ -30,18 +31,19 @@ export function parsePagination(
|
||||
return { page, limit };
|
||||
}
|
||||
|
||||
/** One page of a list, and whether another follows. */
|
||||
/**
|
||||
* Returned to the routes by the services' list functions, and sent as
|
||||
* `PaginatedData`.
|
||||
*/
|
||||
export interface Page<T> {
|
||||
/** The page's items, in order. */
|
||||
items: T[];
|
||||
/** Whether another page follows this one. */
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns rows fetched with `LIMIT limit + 1` into a page: the extra row, if it
|
||||
* came back, is dropped and says there's a next page. No COUNT(*) over the
|
||||
* whole list just to know that.
|
||||
* Turns rows fetched with `LIMIT limit + 1` into a page. The extra row, when it
|
||||
* comes back, is dropped and tells that another page follows, which spares a
|
||||
* COUNT(*) over the whole list.
|
||||
*/
|
||||
export function pageOf<T>(rows: T[], limit: number): Page<T> {
|
||||
return rows.length > limit
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/**
|
||||
* Permission checks against an authenticated caller.
|
||||
* Checks an authenticated caller's permissions. Routes call it where the
|
||||
* answer depends on more than the permission, such as "the owner, or a
|
||||
* moderator"; `requirePermission` gates a whole route instead.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -7,7 +9,10 @@
|
||||
import { hasPermission, type Permission } from "../../shared/permissions.ts";
|
||||
import type { AuthPayload } from "../model/auth.ts";
|
||||
|
||||
/** Checks whether the caller's role grants `permission`. */
|
||||
/**
|
||||
* Checks whether the caller's role grants `permission`. The role is the live
|
||||
* one `authenticate` put in the payload, not the token's.
|
||||
*/
|
||||
export function can(payload: AuthPayload, permission: Permission): boolean {
|
||||
return hasPermission(payload.role, permission);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
/**
|
||||
* Which of a playlist's dumps a given viewer may see.
|
||||
* Decides which of a playlist's dumps a viewer may see. The playlist service
|
||||
* filters its listings with it, and the WebSocket service filters each
|
||||
* client's `playlist_dumps_updated` message the same way.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type { Playlist } from "../../shared/playlist.ts";
|
||||
|
||||
/** A playlist's dump, with what deciding who may see it takes. */
|
||||
/**
|
||||
* The fields of a playlist's dump that {@link isMemberVisibleTo} needs. A
|
||||
* `Dump` has them. For its broadcasts, the playlist service reads only these.
|
||||
*/
|
||||
export interface PlaylistMember {
|
||||
/** The dump's id. */
|
||||
id: string;
|
||||
/** Whether the dump is private. */
|
||||
isPrivate: boolean;
|
||||
/** The id of the dump's poster. */
|
||||
/** The dump's poster, not the playlist's owner. */
|
||||
userId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether `viewerId` sees this member when the playlist is shown to them: a
|
||||
* public dump, or a private one of the owner's own, to the owner. Everyone
|
||||
* else — moderators too — doesn't see private dumps, and neither does the
|
||||
* owner see someone else's dump once it has gone private.
|
||||
* Checks whether `viewerId` may see `member` in `playlist`. A public dump is
|
||||
* visible to all. A private dump is visible only to the playlist's owner, and
|
||||
* only if they posted it: moderators don't see it, and neither does the owner
|
||||
* once someone else's dump in their playlist goes private. A null or undefined
|
||||
* `viewerId` is a signed-out viewer.
|
||||
*/
|
||||
export function isMemberVisibleTo(
|
||||
member: PlaylistMember,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* In-memory rate limiting with token buckets, keyed by the caller's choice
|
||||
* (e.g. `${kind}:${userId}`) — per user rather than per socket or request, so
|
||||
* more tabs or parallel requests don't buy more throughput.
|
||||
* Limits how often something may happen, with token buckets kept in memory,
|
||||
* which a restart empties. Logins, password reset requests and the chat's
|
||||
* typing messages use it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -12,15 +12,22 @@ interface Bucket {
|
||||
}
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
/** A token bucket's shape. */
|
||||
/**
|
||||
* Passed to {@link takeToken} by each limited action, which declares its own
|
||||
* as a constant.
|
||||
*/
|
||||
export interface RateLimit {
|
||||
/** Burst size. */
|
||||
/** How many tokens a full bucket holds, and so the largest burst. */
|
||||
capacity: number;
|
||||
/** Tokens regained per second. */
|
||||
refill: number;
|
||||
}
|
||||
|
||||
/** Takes one token from `key`'s bucket, or returns false when it is empty. */
|
||||
/**
|
||||
* Takes one token from `key`'s bucket, or returns false when it is empty. The
|
||||
* caller picks the key, such as `typing:<userId>`, and keys by user or account
|
||||
* so that more tabs or parallel requests don't buy more tokens.
|
||||
*/
|
||||
export function takeToken(key: string, limit: RateLimit): boolean {
|
||||
const now = Date.now();
|
||||
const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now };
|
||||
@@ -35,8 +42,8 @@ export function takeToken(key: string, limit: RateLimit): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Idle buckets have refilled by now for every limit in use; drop them so the
|
||||
// map tracks only keys that have been active lately.
|
||||
// Every limit in use refills within the hour, so an older bucket is full and
|
||||
// dropping it changes nothing.
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 60 * 60_000;
|
||||
for (const [key, bucket] of buckets) {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
/**
|
||||
* Which slugs a category may take. Category slugs live at the URL root (e.g.
|
||||
* /music), so they must never collide with the frontend's own routes.
|
||||
* Checks which slugs a category may take. A category's feed lives at the URL
|
||||
* root (`/music`), so its slug must not shadow another top-level route. The
|
||||
* category service runs these checks when a category is created or renamed.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Top-level path segments that already have meaning in the SPA router (see
|
||||
* src/App.tsx), plus a few reserved for infrastructure.
|
||||
* The top-level path segments a category slug may not take. They are the SPA's
|
||||
* own routes (`src/App.tsx`), `api` and `assets`, which the server answers
|
||||
* before the SPA, and `manage`, which no route uses any more. Keep it in step
|
||||
* with the route table.
|
||||
*/
|
||||
export const RESERVED_SLUGS: readonly string[] = [
|
||||
"~",
|
||||
@@ -25,12 +28,12 @@ export const RESERVED_SLUGS: readonly string[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* A well-formed category slug: lowercase alphanumerics,
|
||||
* hyphen-separated, no leading, trailing or double hyphen.
|
||||
* Matches a well-formed category slug, made of lowercase letters and digits
|
||||
* joined by single hyphens.
|
||||
*/
|
||||
export const CATEGORY_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
/** Checks whether `slug` is well-formed (see {@link CATEGORY_SLUG_RE}). */
|
||||
/** Checks whether `slug` matches {@link CATEGORY_SLUG_RE}. */
|
||||
export function isValidSlugFormat(slug: string): boolean {
|
||||
return CATEGORY_SLUG_RE.test(slug);
|
||||
}
|
||||
@@ -40,7 +43,10 @@ export function isReservedSlug(slug: string): boolean {
|
||||
return RESERVED_SLUGS.includes(slug);
|
||||
}
|
||||
|
||||
/** Checks whether `slug` is well-formed and not reserved. */
|
||||
/**
|
||||
* Checks whether `slug` is well-formed and not reserved. The category service
|
||||
* runs the two checks apart, to give each failure its own message.
|
||||
*/
|
||||
export function isSlugAvailable(slug: string): boolean {
|
||||
return isValidSlugFormat(slug) && !isReservedSlug(slug);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Recognises links to this site's own pages, which previews resolve from the
|
||||
* database instead of fetching.
|
||||
* Recognises links to this site's own pages. The self provider previews them
|
||||
* from the database, and remote/fetch.ts accepts them as dump links even on a
|
||||
* private host such as localhost.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Serving files from disk with ETags and byte ranges, streamed a chunk at a
|
||||
* time.
|
||||
* Sends files from disk with ETags and byte ranges, streamed a chunk at a
|
||||
* time. The files and thumbnails routes call it, and lib/upload.ts for the
|
||||
* other uploads.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -67,21 +68,24 @@ function matchesEtag(header: string | null, etag: string): boolean {
|
||||
return header.split(",").some((tag) => strip(tag) === strip(etag));
|
||||
}
|
||||
|
||||
/** How {@link serveFile} answers. */
|
||||
/**
|
||||
* Passed to {@link serveFile} by its caller, which knows the file's type and
|
||||
* how long it may be cached.
|
||||
*/
|
||||
export interface ServeFileOptions {
|
||||
/** The `Content-Type` sent. */
|
||||
mime: string;
|
||||
/** The `Cache-Control` sent. */
|
||||
cacheControl: string;
|
||||
/** Answer a single `Range: bytes=…` with a 206 (media files). */
|
||||
/**
|
||||
* Answers a single `Range: bytes=…` with a 206. The files route sets it, so
|
||||
* that audio and video can seek.
|
||||
*/
|
||||
ranges?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a file from disk with an ETag (a 304 when the client already has it)
|
||||
* and, when `ranges` is set, single byte ranges. The body is streamed.
|
||||
*
|
||||
* Returns false, having sent nothing, when the file doesn't exist.
|
||||
* Sends a file from disk with an ETag, answering 304 when the client already
|
||||
* has it, and single byte ranges when `ranges` is set. It returns false, having
|
||||
* sent nothing, when the file doesn't exist, and leaves the 404 to the caller.
|
||||
*/
|
||||
export async function serveFile(
|
||||
ctx: Context,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
/**
|
||||
* Generates the full favicon set and web manifest from a single emoji.
|
||||
* Renders the site's favicons and web manifest from its emoji. The `icons`
|
||||
* task (`api/scripts/generate-icons.ts`) runs it before the server starts, so
|
||||
* the emoji can change without a rebuild.
|
||||
*
|
||||
* Twemoji ships each emoji as a plain vector-path SVG, so it rasterizes without
|
||||
* any system emoji font (which matters in the minimal Alpine runtime image).
|
||||
* `@resvg/resvg-wasm` does the rasterization; the .ico is assembled by hand.
|
||||
*
|
||||
* Invoked by api/scripts/generate-icons.ts at server startup (into dist/ in
|
||||
* prod, public/ in dev) — see that script for how the output dir is chosen.
|
||||
* The glyph comes from Twemoji as plain vector paths, so rendering it needs no
|
||||
* emoji font, which the Alpine runtime image lacks.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -19,9 +17,10 @@ const APPLE_SIZE = 180;
|
||||
const ICO_SIZES = [16, 32, 48];
|
||||
|
||||
/**
|
||||
* Returns the name Twemoji files `emoji` under: its codepoints joined with
|
||||
* "-", variation selectors (FE0F) stripped unless the emoji is a ZWJ sequence.
|
||||
* Also doubles as a stable cache-busting version string for the icon URLs.
|
||||
* Returns the name Twemoji files `emoji` under: its codepoints in hex, joined
|
||||
* with "-", without variation selectors (FE0F) unless the emoji is a ZWJ
|
||||
* sequence. `lib/index-html.ts` also uses it as the icon URLs' cache-busting
|
||||
* version.
|
||||
*/
|
||||
export function emojiToCodepoint(emoji: string): string {
|
||||
const cleaned = emoji.includes("") ? emoji : emoji.replace(/️/g, "");
|
||||
@@ -47,7 +46,7 @@ function renderPng(svg: string, size: number, background?: string): Uint8Array {
|
||||
return resvg.render().asPng();
|
||||
}
|
||||
|
||||
/** Packs PNG payloads into a (PNG-compressed) .ico container. */
|
||||
/** Packs PNG images into an .ico file, which may hold PNGs as they are. */
|
||||
function buildIco(images: { size: number; png: Uint8Array }[]): Uint8Array {
|
||||
const HEADER = 6;
|
||||
const ENTRY = 16;
|
||||
@@ -99,24 +98,23 @@ function buildManifest(siteName: string, bgColor: string): string {
|
||||
return JSON.stringify(manifest, null, 2) + "\n";
|
||||
}
|
||||
|
||||
/** What {@link generateSiteIcons} renders, and where. */
|
||||
/**
|
||||
* Passed to {@link generateSiteIcons} by `api/scripts/generate-icons.ts`, from
|
||||
* the site's config.
|
||||
*/
|
||||
export interface GenerateSiteIconsOptions {
|
||||
/** Emoji to render the icons from. */
|
||||
emoji: string;
|
||||
/** Directory to write the assets into (created if missing). */
|
||||
/** Created if missing. */
|
||||
outDir: string;
|
||||
/** Site name written into the manifest. */
|
||||
siteName: string;
|
||||
/**
|
||||
* Background color for the Apple touch icon and manifest (iOS ignores
|
||||
* transparency).
|
||||
*/
|
||||
/** Fills the Apple touch icon, and the manifest's theme and background. */
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.png` (16–512),
|
||||
* `apple-touch-icon.png` and `manifest.webmanifest` into `outDir`.
|
||||
* Writes `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.png` (16 to 512),
|
||||
* `apple-touch-icon.png` and `manifest.webmanifest` into `opts.outDir`, the
|
||||
* directory the site's static files are served from.
|
||||
*
|
||||
* @throws {Error} When the emoji's glyph can't be fetched.
|
||||
*/
|
||||
@@ -137,7 +135,7 @@ export async function generateSiteIcons(
|
||||
for (const size of PNG_SIZES) {
|
||||
await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!);
|
||||
}
|
||||
// Apple touch icon gets a solid background — iOS doesn't honor transparency.
|
||||
// iOS draws a transparent touch icon on black.
|
||||
await Deno.writeFile(
|
||||
out("apple-touch-icon.png"),
|
||||
renderPng(svg, APPLE_SIZE, bgColor),
|
||||
|
||||
@@ -1,31 +1,37 @@
|
||||
/**
|
||||
* URL slugs for records that have a title.
|
||||
* Builds the URL slugs of dumps and playlists from their titles. The dump and
|
||||
* playlist services store the slug when the record is created or retitled.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** A UUID, which a route parameter may carry instead of a slug. */
|
||||
/**
|
||||
* Matches a UUID. A dump or playlist route takes either an id or a slug, and
|
||||
* the services test the parameter with this to know which column to look up.
|
||||
*/
|
||||
export const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Turns `title` into a URL-safe slug: lowercase ASCII, dash-separated, at most
|
||||
* 60 characters, `untitled` when nothing is left.
|
||||
* Turns `title` into lowercase ASCII words joined by dashes, at most 60
|
||||
* characters long. Accents are dropped, and a title with no ASCII letter or
|
||||
* digit gives `untitled`.
|
||||
*/
|
||||
export function slugify(title: string): string {
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "") // strip diacritics
|
||||
.replace(/[^a-z0-9]+/g, "-") // non-alphanumeric → dash
|
||||
.replace(/^-+|-+$/g, "") // trim leading/trailing dashes
|
||||
// NFD splits an accented letter into the letter and a combining mark.
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.substring(0, 60);
|
||||
return slug || "untitled";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the slug of a record: its title's slug followed by the start of its
|
||||
* `id`, so it stays unique by construction.
|
||||
* Returns a record's slug, its title's {@link slugify} followed by the first 8
|
||||
* characters of `id`. The suffix keeps two records with the same title apart.
|
||||
*/
|
||||
export function makeSlug(title: string, id: string): string {
|
||||
return `${slugify(title)}-${id.substring(0, 8)}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Serving the built frontend: static files, and `index.html` with its
|
||||
* placeholders filled for every client-side route.
|
||||
* Serves the built frontend, as its static files and, for every client-side
|
||||
* route, `index.html` with its placeholders filled. main.ts installs it after
|
||||
* the routers.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -8,30 +9,31 @@
|
||||
import { Context, Next, send } from "@oak/oak";
|
||||
import { loadIndexHtml } from "./index-html.ts";
|
||||
|
||||
/** Answers with the filled `index.html` of `root`, always revalidated. */
|
||||
async function serveIndexHtml(
|
||||
context: Context<Record<string, object>>,
|
||||
root: string,
|
||||
) {
|
||||
const html = await loadIndexHtml(root);
|
||||
context.response.type = "text/html";
|
||||
// Always revalidated: it names the hashed chunks of the current build.
|
||||
// It names the hashed chunks of the current build, so it is always
|
||||
// revalidated.
|
||||
context.response.headers.set("Cache-Control", "no-cache");
|
||||
context.response.body = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build output whose URL changes whenever its content does: Vite's hashed
|
||||
* chunks, and the emoji data under its package version (see vite.config.ts).
|
||||
* Prefixes the build output whose URL changes whenever its content does, which
|
||||
* are Vite's hashed chunks and the emoji data under its package version (see
|
||||
* vite.config.ts).
|
||||
*/
|
||||
const IMMUTABLE_PREFIXES = ["/assets/", "/emojibase/"];
|
||||
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Returns a middleware serving static files from the first of `staticPaths`
|
||||
* that has them, and `index.html` from the first for any other path so
|
||||
* client-side routes load on direct navigation. Hashed build output is cached
|
||||
* for a year, and a missing chunk is a 404.
|
||||
* Returns the middleware that serves a static file from the first of
|
||||
* `staticPaths` that has it, and `index.html` of the first for any other path.
|
||||
* Hashed build output is cached for a year, and a missing chunk is a 404.
|
||||
* main.ts passes dist/ then public/.
|
||||
*/
|
||||
export function routeStaticFilesFrom(
|
||||
staticPaths: string[],
|
||||
@@ -39,7 +41,6 @@ export function routeStaticFilesFrom(
|
||||
return async (context: Context<Record<string, object>>, next: Next) => {
|
||||
const pathname = context.request.url.pathname;
|
||||
|
||||
// Serve index.html with runtime placeholder replacement
|
||||
if (pathname === "/" || pathname === "/index.html") {
|
||||
await serveIndexHtml(context, staticPaths[0]);
|
||||
return;
|
||||
@@ -53,7 +54,8 @@ export function routeStaticFilesFrom(
|
||||
const compress = context.request.headers.has("accept-encoding");
|
||||
for (const path of staticPaths) {
|
||||
try {
|
||||
// send() picks a prebuilt .br or .gz sibling when the browser accepts it.
|
||||
// send() picks a prebuilt .br or .gz sibling when the browser accepts
|
||||
// it.
|
||||
await send(context, pathname, {
|
||||
root: path,
|
||||
brotli: compress,
|
||||
@@ -74,7 +76,7 @@ export function routeStaticFilesFrom(
|
||||
return;
|
||||
}
|
||||
|
||||
// SPA fallback: serve index.html so client-side routes work on direct navigation
|
||||
// Any other path is a client-side route, opened directly or reloaded.
|
||||
await serveIndexHtml(context, staticPaths[0]);
|
||||
await next();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Checking uploaded images and serving uploaded files back.
|
||||
* Checks the images users upload and serves them back. The avatar, playlist
|
||||
* cover, dump thumbnail and attachment routes call it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -29,7 +30,8 @@ export {
|
||||
|
||||
/**
|
||||
* Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic
|
||||
* bytes, ignoring the type the browser declared; null for anything else.
|
||||
* bytes, or null for anything else. The type the browser declared is never
|
||||
* trusted, since the file is later served with the type found here.
|
||||
*/
|
||||
export function detectImageMime(data: Uint8Array): string | null {
|
||||
if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) {
|
||||
@@ -52,7 +54,8 @@ export function detectImageMime(data: Uint8Array): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks an uploaded image's size and content, and returns its MIME type.
|
||||
* Checks an uploaded image's size and content, and returns its MIME type,
|
||||
* which the route stores to serve the image with.
|
||||
*
|
||||
* @throws {APIException} 400 when it exceeds {@link MAX_IMAGE_SIZE_BYTES} or
|
||||
* isn't a JPEG, PNG, GIF or WebP image.
|
||||
@@ -77,9 +80,10 @@ export function validateImageUpload(data: Uint8Array): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves an uploaded file inline as `mime`, cached for `cacheMaxAge` seconds
|
||||
* (privately when `isPrivate`). Answers 404 and resolves false when the file
|
||||
* is missing.
|
||||
* Serves an uploaded image inline as `mime`, cached for `cacheMaxAge` seconds,
|
||||
* and only by the browser when `isPrivate`. It answers 404 and resolves false
|
||||
* when the file is missing. The caller checks first that the viewer may see
|
||||
* it.
|
||||
*/
|
||||
export async function serveUploadedFile(
|
||||
ctx: Context,
|
||||
@@ -89,6 +93,8 @@ export async function serveUploadedFile(
|
||||
isPrivate = false,
|
||||
): Promise<boolean> {
|
||||
ctx.response.headers.set("Content-Disposition", "inline");
|
||||
// The browser must take the checked type, never guess another from the
|
||||
// bytes, and a frontend on another origin may still show the image.
|
||||
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
const served = await serveFile(ctx, filePath, {
|
||||
|
||||
Reference in New Issue
Block a user