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:
khannurien
2026-09-29 14:31:14 +00:00
parent 42e0356d21
commit 0d4e344d77
287 changed files with 5679 additions and 5135 deletions

111
README.md
View File

@@ -1,6 +1,10 @@
# gerbeur # gerbeur
A small invite-only social platform for sharing links and files. Users can post URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote, comment, follow each other, build playlists, and search content. A real-time WebSocket layer handles live presence, vote counts, and notifications. The UI is localized (English and French) and ships multiple visual themes. A small invite-only social platform for sharing links and files. Users can post
URLs and media (YouTube, SoundCloud, Bandcamp, images, audio, video, …), vote,
comment, follow each other, build playlists, and search content. A real-time
WebSocket layer handles live presence, vote counts, and notifications. The UI is
localized (English and French) and ships multiple visual themes.
## Stack ## Stack
@@ -30,13 +34,16 @@ cp .env.example .env
deno task dev deno task dev
``` ```
This starts both the API server (port 8000, with file watching) and the Vite dev server (port 3000) concurrently. This starts both the API server (port 8000, with file watching) and the Vite dev
server (port 3000) concurrently.
Open [http://localhost:3000](http://localhost:3000). On first run a default `admin` / `admin` account is created — change the password immediately. Open [http://localhost:3000](http://localhost:3000). On first run a default
`admin` / `admin` account is created — change the password immediately.
### Environment variables ### Environment variables
See [`.env.example`](.env.example) for the full list with descriptions. Key variables: See [`.env.example`](.env.example) for the full list with descriptions. Key
variables:
| Variable | Description | Default | | Variable | Description | Default |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
@@ -58,7 +65,10 @@ See [`.env.example`](.env.example) for the full list with descriptions. Key vari
### Docker (recommended) ### Docker (recommended)
The standard deployment runs API and frontend in a single container. The API server (Oak) serves the compiled frontend as static files, so both share the same origin — no `VITE_API_*` build args needed. Set `GERBEUR_PUBLIC_URL` to the externally-visible URL; it is automatically allowed for HTTP/WebSocket requests. The standard deployment runs API and frontend in a single container. The API
server (Oak) serves the compiled frontend as static files, so both share the
same origin — no `VITE_API_*` build args needed. Set `GERBEUR_PUBLIC_URL` to the
externally-visible URL; it is automatically allowed for HTTP/WebSocket requests.
```sh ```sh
docker build -t gerbeur . docker build -t gerbeur .
@@ -77,12 +87,17 @@ docker run -d \
``` ```
The two volumes are required for persistence: The two volumes are required for persistence:
- `gerbeur-db` — SQLite database (`api/sql/gerbeur.db`), initialized automatically on first run
- `gerbeur-db` — SQLite database (`api/sql/gerbeur.db`), initialized
automatically on first run
- `gerbeur-uploads` — user-uploaded files (`api/uploads/`) - `gerbeur-uploads` — user-uploaded files (`api/uploads/`)
#### Separate API and frontend (optional) #### Separate API and frontend (optional)
If you need to run the API on a different host than the frontend, pass the API location as build args so it gets baked into the frontend bundle. In that setup, add the frontend origin to `GERBEUR_ALLOWED_ORIGINS` so cross-origin HTTP/WebSocket requests are accepted: If you need to run the API on a different host than the frontend, pass the API
location as build args so it gets baked into the frontend bundle. In that setup,
add the frontend origin to `GERBEUR_ALLOWED_ORIGINS` so cross-origin
HTTP/WebSocket requests are accepted:
```sh ```sh
docker build \ docker build \
@@ -94,7 +109,8 @@ docker build \
### Reverse proxy ### Reverse proxy
Put a reverse proxy (nginx, Caddy, …) in front of the container to handle TLS. Forward everything to port 8000. Example Caddyfile: Put a reverse proxy (nginx, Caddy, …) in front of the container to handle TLS.
Forward everything to port 8000. Example Caddyfile:
``` ```
example.com { example.com {
@@ -158,27 +174,65 @@ place that fetches from other sites. Tests mirror the source one file per
module, so the tests of `services/chat-service.ts` are in module, so the tests of `services/chat-service.ts` are in
`test/services/chat-service.test.ts`. `test/services/chat-service.test.ts`.
All three parts — `api/`, `shared/` and `src/` — are documented for `deno doc`, ### Comments
the same way in every file. Each module opens with a `/** … @module */` block
saying what it is for. Every export has a JSDoc comment, and so does every field A comment says what the code can't. If deleting it would lose nothing, it is the
of an exported interface; a comment that describes a declaration is always `/** wrong comment: on an export, rewrite it; anywhere else, delete it.
*/`, and `//` is only for notes on the code inside. Functions open with a verb
("Returns…", "Checks…", "Renders…" for a component), everything else with a noun **Every module and every export has a JSDoc** (`/** */`). Each gives at least
phrase. Parameters are named in the prose, in backticks. `{@link}` points at one of:
symbols the file already has in scope. A function that can throw to its caller
says so with `@throws`: for an `APIException`, the statuses and when. A router's 1. **Context**: who uses it and where (a page and its route, a caller, a
module doc lists its endpoints, one line each: method and path, who may call it, WebSocket message).
what it does; a page's JSDoc names its route and who may see it. The first 2. **Usage**: what a caller must know to use it correctly, such as a
paragraph of a doc is its summary, which the reference shows alone in lists, so precondition, an invariant, what it leaves to the caller, or a side effect.
it is a sentence that stands on its own: it never ends in a colon introducing Add an example only when the call isn't obvious.
what follows. 3. **Place in the chain**: where its input comes from and where its output goes.
A type's doc is phrased from the value's side, naming the producer and the
consumer: "Returned to Y by X when Z", "Passed to Y by X". It is never "What X
returns to Y" or "What X passes to Y".
Don't pack a doc into a label followed by a colon and a list ("X: one thing,
plus some other thing"). Write sentences with a subject and a verb.
When the obvious comment is useless, write the useful one: the context is always
there. A **field** (of an interface, or a parameter) gets a doc only when it
adds something its parent's doc doesn't. Context that concerns the whole type
goes on the type, not on one of its fields. The same goes for private functions
and locals. None of these count as a doc:
- a restatement of the name or type (`/** The dump. */`, "Whether it is
loading.", "The props of X.", "Renders the X.");
- a description of what the body, the JSX or the SQL plainly does;
- a list that goes stale (every caller of a widely used component): name the
kind of caller instead.
When a comment is there, it is as short as it can be, often one sentence, and it
is written in full sentences. Labels and fragments ("Upvotes on dumps.") don't
count. A module doc is a few lines at most. It opens with what the module does,
then says who calls it and what it hands back, and it doesn't repeat what its
exports' docs say. `@throws` stays on whatever throws to its caller, with the
statuses of an `APIException` and when each happens. A router's module doc lists
its endpoints (method, path, who may call it), since the code states them
nowhere in one place. A summary never ends in a colon: the reference shows it
alone.
**`//` comments** go wherever the code isn't obvious. They explain why, not
what: a workaround, an ordering constraint, a surprising limitation, a security
reason, an event handler that must not bubble.
**Every claim is checked against the code** (grep the callers). A wrong comment
is worse than none.
### Reference
In the frontend, every exported function states its return type, so the In the frontend, every exported function states its return type, so the
reference can show it: a component returns `React.JSX.Element` (or `| null`), a reference can show it: a component returns `React.JSX.Element` (or `| null`), a
hook returning several things returns a documented `Use<Name>Result` interface. hook returning several things returns a documented `Use<Name>Result` interface.
A component that takes props takes an exported `<Name>Props` interface, each A component that takes props takes an exported `<Name>Props` interface. React's
prop documented. React's types are used through the global `React` namespace types are used through the global `React` namespace rather than a default import
rather than a default import of `react`, which `deno doc` can't follow. of `react`, which `deno doc` can't follow.
`deno task doc` builds the HTML reference into `docs/` from every module of `deno task doc` builds the HTML reference into `docs/` from every module of
`api/`, `shared/` and `src/` but the tests (and the generated `src/locales/`). `api/`, `shared/` and `src/` but the tests (and the generated `src/locales/`).
@@ -187,9 +241,10 @@ repository's three parts and their layers in prose; `deno doc` builds the list
of every module itself, grouped by path. The `docs` workflow publishes it on of every module itself, grouped by path. The `docs` workflow publishes it on
every push to `main` that touches them. `deno task doc:lint` checks the rules every push to `main` that touches them. `deno task doc:lint` checks the rules
the linter can see, and that no summary ends in a colon, through `doc-lint.ts`. the linter can see, and that no summary ends in a colon, through `doc-lint.ts`.
`deno doc` doesn't document npm packages, so it takes a type imported from one Two kinds of diagnostic are let through. A field without a doc passes, since a
(react-hook-form's `FieldValues`, say) for a private one, and those references field gets one only when it adds to its type's. `deno doc` doesn't document npm
alone are let through. packages, so it takes a type imported from one (react-hook-form's `FieldValues`,
say) for a private one, and those references pass too.
Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is
split into the same domain folders — `auth`, `category`, `chat`, `comment`, split into the same domain folders — `auth`, `category`, `chat`, `comment`,

View File

@@ -1,6 +1,7 @@
/** /**
* The server's settings: those read from `GERBEUR_*` environment variables, * Reads the server's settings from the `GERBEUR_*` environment variables, once
* with their defaults, and the fixed limits the backend applies. * at import, and holds the fixed limits the backend applies. The deno tasks
* load `.env` with `--env-file`, so changing a setting needs a restart.
* *
* @module * @module
*/ */
@@ -8,12 +9,13 @@
/** The port the server listens on (`GERBEUR_PORT`, default 8000). */ /** The port the server listens on (`GERBEUR_PORT`, default 8000). */
export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000; export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000;
/** /**
* The SMTP server mail is sent through, as an `smtps://` URL * The `smtps://` URL of the server email-service sends mail through
* (`GERBEUR_SMTPS_URL`). Empty disables email. * (`GERBEUR_SMTPS_URL`). When it is empty, the welcome and password reset
* emails are silently skipped.
*/ */
export const SMTPS_URL: string = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || export const SMTPS_URL: string = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() ||
""; "";
/** The sender address of outgoing mail (`GERBEUR_FROM_EMAIL`). */ /** The `From` address of the welcome and password reset emails. */
export const FROM_EMAIL: string = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || export const FROM_EMAIL: string = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() ||
""; "";
@@ -24,66 +26,76 @@ Hi **{{username}}**,
Your account has been created successfully. Welcome aboard!`; Your account has been created successfully. Welcome aboard!`;
/** /**
* The Markdown body of the welcome email (`GERBEUR_WELCOME_EMAIL_BODY`), with * The Markdown of the welcome email sent on registration
* `{{site_name}}` and `{{username}}` placeholders. * (`GERBEUR_WELCOME_EMAIL_BODY`). The users route replaces `{{site_name}}` and
* `{{username}}` in it and sends it as both text and HTML.
*/ */
export const WELCOME_EMAIL_BODY: string = export const WELCOME_EMAIL_BODY: string =
Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() || Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() ||
DEFAULT_WELCOME_EMAIL_BODY; DEFAULT_WELCOME_EMAIL_BODY;
/** The key tokens are signed with (`GERBEUR_JWT_SECRET`). Required. */ /**
* The key lib/jwt.ts signs and verifies tokens with (`GERBEUR_JWT_SECRET`).
* The server refuses to start without it.
*/
export const JWT_SECRET: string = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || export const JWT_SECRET: string = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() ||
""; "";
/** /**
* The network interface the server binds to (`GERBEUR_LISTEN_HOST`). Defaults * The interface the server binds to (`GERBEUR_LISTEN_HOST`). The default,
* to 0.0.0.0 so Docker port-forwarding works out of the box; 127.0.0.1 * 0.0.0.0, lets Docker forward the port; 127.0.0.1 keeps it to loopback.
* restricts it to loopback.
*/ */
export const LISTEN_HOST: string = Deno.env.get("GERBEUR_LISTEN_HOST") || export const LISTEN_HOST: string = Deno.env.get("GERBEUR_LISTEN_HOST") ||
"0.0.0.0"; "0.0.0.0";
/** /**
* The server's public-facing URL (`GERBEUR_PUBLIC_URL`), without a trailing * The URL visitors reach the server at (`GERBEUR_PUBLIC_URL`), without a
* slash — used for CORS, WebSocket origin checks, email links, and OG image * trailing slash. Behind a reverse proxy it must be the outside URL: it is an
* URLs. Behind a reverse proxy, set it to the externally-visible URL (e.g. * allowed origin, it tells links to this site's own pages from other links,
* https://example.com). Defaults to http://localhost:PORT for local * and it prefixes the image URLs in their previews.
* development.
*/ */
export const PUBLIC_URL: string = export const PUBLIC_URL: string =
Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") || Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") ||
`http://localhost:${PORT}`; `http://localhost:${PORT}`;
/** /**
* The frontend's URL (`GERBEUR_FRONTEND_URL`). In single-container deployments * The URL the frontend is served from (`GERBEUR_FRONTEND_URL`), when it isn't
* the API serves the frontend, so it defaults to {@link PUBLIC_URL}; set it * {@link PUBLIC_URL}, as with a separate Vite server. It is an allowed origin,
* when the frontend runs on a separate host or port (e.g. Vite dev server or a * and password reset links point to it.
* CDN origin).
*/ */
export const FRONTEND_URL: string = export const FRONTEND_URL: string =
Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() || PUBLIC_URL; Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() || PUBLIC_URL;
/** /**
* The SQLite database file (`GERBEUR_DB_PATH`). Overridable so the test suite * The SQLite database file (`GERBEUR_DB_PATH`). api/test/setup.ts points it at
* can run against a throwaway database (see api/test/setup.ts). * a throwaway file before anything opens the database.
*/ */
export const DB_PATH: string = Deno.env.get("GERBEUR_DB_PATH") || export const DB_PATH: string = Deno.env.get("GERBEUR_DB_PATH") ||
"api/sql/gerbeur.db"; "api/sql/gerbeur.db";
// Upload/files
/** /**
* The directory uploads are stored under (`GERBEUR_UPLOADS_DIR`), one * The directory uploads are stored under (`GERBEUR_UPLOADS_DIR`). Each kind has
* subdirectory per kind. * its subdirectory below, created by the first upload, and each file is named
* after the id of what it belongs to.
*/ */
export const UPLOADS_DIR: string = Deno.env.get("GERBEUR_UPLOADS_DIR") || export const UPLOADS_DIR: string = Deno.env.get("GERBEUR_UPLOADS_DIR") ||
"api/uploads"; "api/uploads";
/** Where file dumps are stored. */ /** Holds the file of each file dump. */
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`; export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
/** Where avatars are stored. */ /** Holds each user's avatar. */
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`; export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
/** Where playlist cover images are stored. */ /** Holds each playlist's cover. */
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`; export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
/** Where images pasted into chat, comments and descriptions are stored. */ /**
* Holds the images pasted into chat messages, comments, dump notes and
* descriptions. The startup tasks purge those nothing links to.
*/
export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`; export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`;
/** Where custom dump thumbnails are stored. */ /**
* Holds the thumbnails a poster uploads (`<id>-custom`) and those the
* thumbnails route generates from file dumps.
*/
export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`; export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`;
/** The largest image upload accepted: 5 MB. */ /**
* The largest image upload accepted, in bytes. lib/upload.ts's error message
* says "5 MB", so the two change together.
*/
export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024;
/** The image types accepted for avatars, covers, thumbnails and attachments. */ /** The image types accepted for avatars, covers, thumbnails and attachments. */
export const ALLOWED_IMAGE_MIMES: Set<string> = new Set([ export const ALLOWED_IMAGE_MIMES: Set<string> = new Set([
@@ -93,14 +105,17 @@ export const ALLOWED_IMAGE_MIMES: Set<string> = new Set([
"image/webp", "image/webp",
]); ]);
/** The MIME type prefixes a file dump may have. */ /**
* The MIME type prefixes a file dump may have. dump-service also accepts the
* exact types of {@link DUMP_ALLOWED_MIME_TYPES}.
*/
export const DUMP_ALLOWED_MIME_PREFIXES: string[] = [ export const DUMP_ALLOWED_MIME_PREFIXES: string[] = [
"text/", "text/",
"image/", "image/",
"video/", "video/",
"audio/", "audio/",
]; ];
/** The exact MIME types a file dump may have, beyond the prefixes. */ /** The documents and archives a file dump may be, beyond the prefixes. */
export const DUMP_ALLOWED_MIME_TYPES: Set<string> = new Set([ export const DUMP_ALLOWED_MIME_TYPES: Set<string> = new Set([
"application/pdf", "application/pdf",
"application/json", "application/json",
@@ -114,41 +129,44 @@ export const DUMP_ALLOWED_MIME_TYPES: Set<string> = new Set([
"application/vnd.ms-powerpoint", "application/vnd.ms-powerpoint",
]); ]);
// Pagination
/** The page size when a request names none. */ /** The page size when a request names none. */
export const PAGINATION_DEFAULT_LIMIT = 20; export const PAGINATION_DEFAULT_LIMIT = 20;
/** The largest page size a request may ask for. */ /** The largest page size a request may ask for. */
export const PAGINATION_MAX_LIMIT = 100; export const PAGINATION_MAX_LIMIT = 100;
// Startup housekeeping
/** How long an attachment nothing links to is kept before startup purges it. */ /** How long an attachment nothing links to is kept before startup purges it. */
export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1; export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1;
/** How long an unused invite is kept before startup purges it. */ /**
* How long an invite stays usable. invite-service refuses an older one, and
* the startup tasks purge it.
*/
export const UNUSED_INVITES_RETENTION_DAYS = 7; export const UNUSED_INVITES_RETENTION_DAYS = 7;
// SEO/OG /**
/** The site's name (`GERBEUR_SITE_NAME`), in page titles, OG tags and mail. */ * The site's name (`GERBEUR_SITE_NAME`). It is filled into index.html, the OG
* tags, the icons' manifest and the emails.
*/
export const OG_SITE_NAME: string = Deno.env.get("GERBEUR_SITE_NAME") || export const OG_SITE_NAME: string = Deno.env.get("GERBEUR_SITE_NAME") ||
"gerbeur"; "gerbeur";
/** /**
* The emoji the favicon set and app header logo are generated from * The emoji that serves as the site's logo (`GERBEUR_SITE_EMOJI`). The icons
* (`GERBEUR_SITE_EMOJI`). Applied at runtime (server startup and index.html * script draws the favicons from it, and index.html carries it to the app's
* injection), so changing it only needs a restart — no rebuild. * header, so changing it needs a restart but no rebuild.
*/ */
export const SITE_EMOJI: string = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚"; export const SITE_EMOJI: string = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚";
/** /**
* A Bandcamp playback path. * How the frontend plays Bandcamp. `"embed"` uses Bandcamp's iframe, which
* * can't autoplay. `"native"` resolves the page's streams through
* - `"embed"` — the bandcamp.com iframe (cannot autoplay) * `GET /api/bandcamp/tracks` and plays them in the app's player, which
* - `"native"` — resolve the page's mp3 streams and play them in the app's own * autoplays and queues an album's tracks.
* player, which autoplays and supports albums as a queue
*/ */
export type BandcampPlayer = "embed" | "native"; export type BandcampPlayer = "embed" | "native";
/** /**
* The Bandcamp playback path the frontend uses (`GERBEUR_BANDCAMP_PLAYER`). * How the frontend plays Bandcamp (`GERBEUR_BANDCAMP_PLAYER`), passed to it in
* Anything unrecognised falls back to `"embed"`, so a typo degrades safely. * index.html. Any value but `"native"` means `"embed"`, so a typo falls back to
* the player that always works.
*/ */
export const BANDCAMP_PLAYER: BandcampPlayer = export const BANDCAMP_PLAYER: BandcampPlayer =
Deno.env.get("GERBEUR_BANDCAMP_PLAYER")?.trim() === "native" Deno.env.get("GERBEUR_BANDCAMP_PLAYER")?.trim() === "native"
@@ -156,21 +174,21 @@ export const BANDCAMP_PLAYER: BandcampPlayer =
: "embed"; : "embed";
/** /**
* The background color of generated icons and the manifest. Mirrors the * The background of the generated Apple touch icon, the manifest's colors, and
* hard-coded theme-color in index.html. * the accent of previews of this site's own pages. index.html's `theme-color`
* tags don't follow it.
*/ */
export const THEME_COLOR = "#111827"; export const THEME_COLOR = "#111827";
const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? ""; const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? "";
/** /**
* The origins CORS and the WebSocket accept: {@link PUBLIC_URL}, * The origins that CORS in main.ts and the WebSocket route accept. They are
* {@link FRONTEND_URL} and the comma-separated `GERBEUR_ALLOWED_ORIGINS`. * {@link PUBLIC_URL}, {@link FRONTEND_URL} and the comma-separated
* `GERBEUR_ALLOWED_ORIGINS`.
*/ */
export const ALLOWED_ORIGINS: string[] = Array.from( export const ALLOWED_ORIGINS: string[] = Array.from(
new Set([ new Set([
PUBLIC_URL, PUBLIC_URL,
// FRONTEND_URL is auto-included so the separate-frontend case only needs
// GERBEUR_FRONTEND_URL — no need to repeat it in GERBEUR_ALLOWED_ORIGINS.
FRONTEND_URL, FRONTEND_URL,
...rawOrigins.split(",").map((o) => o.trim()).filter(Boolean), ...rawOrigins.split(",").map((o) => o.trim()).filter(Boolean),
]), ]),

View File

@@ -1,6 +1,7 @@
/** /**
* The database handle. Importing this module opens the database at * Opens the database at {@link DB_PATH} when first imported, applies the
* {@link DB_PATH}, applies the pending migrations and runs the startup tasks. * pending migrations and runs the startup tasks before any importer runs. Only
* the services import it.
* *
* @module * @module
*/ */
@@ -10,12 +11,16 @@ import { DB_PATH } from "../config.ts";
import { runMigrations } from "./migrate.ts"; import { runMigrations } from "./migrate.ts";
import { runStartupTasks } from "./startup.ts"; import { runStartupTasks } from "./startup.ts";
/** The one connection every service queries through. */ /**
* The server's only connection. Its queries are synchronous, so each one
* blocks the event loop while it runs.
*/
export const db: DatabaseSync = new DatabaseSync(DB_PATH); export const db: DatabaseSync = new DatabaseSync(DB_PATH);
// WAL lets readers run alongside a writer, and with synchronous = NORMAL a // WAL lets readers run alongside a writer, and with synchronous = NORMAL a
// commit no longer waits on an fsync (a crash can lose the last transactions, // commit doesn't wait on an fsync: a power loss can lose the last commits but
// never corrupt the file). busy_timeout covers the init/migration scripts and // never corrupts the file. busy_timeout makes another connection to the file
// the test harness opening the same file. // wait for the lock instead of failing. SQLite enforces foreign keys, and so
// the schema's ON DELETE cascades, only when each connection asks.
db.exec(` db.exec(`
PRAGMA journal_mode = WAL; PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL; PRAGMA synchronous = NORMAL;
@@ -26,9 +31,10 @@ runMigrations(db);
await runStartupTasks(db); await runStartupTasks(db);
/** /**
* Runs `fn` inside one transaction: several writes commit together (and cost * Runs `fn` in a transaction, so its writes commit together, in one commit
* one commit rather than one each), and a throw rolls them all back. Nested * rather than one each, and a throw rolls them all back and rethrows. A call
* calls join the outer transaction. * inside another joins the outer transaction. `fn` must be synchronous: an
* `await` would commit before the rest of it runs.
*/ */
export function transaction<T>(fn: () => T): T { export function transaction<T>(fn: () => T): T {
if (db.isTransaction) return fn(); if (db.isTransaction) return fn();

View File

@@ -1,6 +1,8 @@
/** /**
* Creates the database from schema.sql when {@link DB_PATH} doesn't exist yet; * Creates the database from schema.sql when {@link DB_PATH} doesn't exist yet,
* run once before the server starts (`deno task start`). * and leaves an existing one alone. `deno task start` runs it before the
* server, which then runs every migration on the new file: schema.sql already
* holds their changes, so each one finds nothing to do.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Brings an existing database up to date with the migrations under * Brings the database up to date with the migrations under migrations/, and
* migrations/, recording each applied one in `schema_migrations`. * records each one it applies in `schema_migrations`. connection.ts runs it
* each time the server opens the database.
* *
* @module * @module
*/ */
@@ -29,10 +30,10 @@ interface Migration {
} }
/** /**
* Every migration, in order. Append-only — never edit or reorder a migration * Every migration, in the order they run. A new one goes at the end, along
* once it has shipped. Each `up` must be idempotent: it also runs against * with the same change to schema.sql, and a shipped one is never edited or
* fresh databases created straight from schema.sql, where the change it makes * moved. A database made by init.ts records none of them, so each `up` must
* already exists. * find its change already made and leave it be.
*/ */
const MIGRATIONS: Migration[] = [ const MIGRATIONS: Migration[] = [
{ name: "0001_comment_likes", up: up0001CommentLikes }, { name: "0001_comment_likes", up: up0001CommentLikes },
@@ -57,11 +58,11 @@ const MIGRATIONS: Migration[] = [
]; ];
/** /**
* Applies each migration `db` hasn't recorded yet, each in its own * Applies each migration `db` hasn't recorded yet, in order, each in its own
* transaction. * transaction.
* *
* @throws {Error} When a migration fails; that migration is rolled back and * @throws {Error} When a migration fails. That one is rolled back, the later
* the later ones aren't run. * ones don't run, and the server doesn't start.
*/ */
export function runMigrations(db: DatabaseSync): void { export function runMigrations(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (

View File

@@ -1,16 +1,17 @@
/** /**
* Adds comment likes: the `comment_likes` table and the `comments.like_count` * Adds likes on comments, as the `comment_likes` table and a `like_count` on
* counter. * each comment that comment-like-service keeps in step, so that listing
* * comments doesn't count their likes.
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with these columns/tables) or an existing one that predates them.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. Existing comments start with no likes, and a
* database made from schema.sql already has both.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const commentCols = db.prepare(`PRAGMA table_info(comments);`).all() as { const commentCols = db.prepare(`PRAGMA table_info(comments);`).all() as {
name: string; name: string;

View File

@@ -1,15 +1,17 @@
/** /**
* Adds `dumps.custom_thumbnail_mime`, for thumbnails uploaded by the poster. * Adds `dumps.custom_thumbnail_mime`, so a poster can upload a thumbnail that
* * replaces the fetched one. It holds the uploaded image's type, and NULL means
* Idempotent: safe to run against a fresh db (already created from schema.sql * the dump has none.
* with this column) or an existing one that predates it.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. Existing dumps keep their fetched thumbnails,
* and a database made from schema.sql already has the column.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const dumpCols = db.prepare(`PRAGMA table_info(dumps);`).all() as { const dumpCols = db.prepare(`PRAGMA table_info(dumps);`).all() as {
name: string; name: string;

View File

@@ -1,16 +1,18 @@
/** /**
* Adds the `dump_backlinks` table, recording which dumps, comments and * Adds `dump_backlinks`, which records the links from a dump's note or
* descriptions link to a dump, and indexes `dumps.url`. * comments to other dumps, so a dump page can list the dumps related to it.
* * backlink-service fills it, and the index on `dumps.url` lets it find the
* Idempotent: safe to run against a fresh db (already created from schema.sql * dumps an external link was posted as.
* with these) or an existing one that predates them.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. It doesn't scan the notes and comments already
* posted, so their links are recorded only once they are edited.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS dump_backlinks ( db.exec(`CREATE TABLE IF NOT EXISTS dump_backlinks (
source_type TEXT NOT NULL, source_type TEXT NOT NULL,

View File

@@ -1,15 +1,16 @@
/** /**
* Adds `users.role` and makes every legacy `is_admin` user an admin. * Adds `users.role`, which replaces the `is_admin` flag so that moderators can
* * exist between users and admins. 0005 then drops the flag.
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with the `role` column) or an existing one that predates it.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. Every user becomes a `user`, except those with
* `is_admin` set, who become admins.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as { const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string; name: string;
@@ -20,8 +21,7 @@ export function up(db: DatabaseSync): void {
); );
} }
// Backfill existing admins to role 'admin'. Guarded because the legacy // schema.sql never had is_admin.
// is_admin column is absent on fresh databases (and dropped by 0005).
if (userCols.some((c) => c.name === "is_admin")) { if (userCols.some((c) => c.name === "is_admin")) {
db.exec( db.exec(
`UPDATE users SET role = 'admin' WHERE is_admin = 1 AND role != 'admin';`, `UPDATE users SET role = 'admin' WHERE is_admin = 1 AND role != 'admin';`,

View File

@@ -1,14 +1,15 @@
/** /**
* Drops the legacy is_admin column now that `role` is the single source of * Drops `users.is_admin`, now that 0004 has copied it into `users.role`.
* truth. Idempotent: skips when the column is already gone (fresh databases
* created from schema.sql never had it). Must run after 0004 has backfilled.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`, after 0004. A database made from schema.sql
* never had the column.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const userCols = db.prepare(`PRAGMA table_info(users);`).all() as { const userCols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string; name: string;

View File

@@ -1,16 +1,16 @@
/** /**
* Adds categories: the `categories` table and the `dump_categories` join * Adds categories, which admins create and posters file their dumps under, as
* table. * the `categories` table and the `dump_categories` join table.
*
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with the categories tables) or an existing one that predates them.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. Existing dumps start in no category, and a
* database made from schema.sql already has both tables.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const tables = new Set( const tables = new Set(
(db.prepare( (db.prepare(

View File

@@ -1,15 +1,16 @@
/** /**
* Adds the `password_reset_tokens` table. * Adds `password_reset_tokens`, which holds the single-use tokens that
* * password-reset-service emails to users who forgot their password.
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with password_reset_tokens) or an existing one that predates the table.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. A database made from schema.sql already has the
* table.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const tables = new Set( const tables = new Set(
(db.prepare( (db.prepare(

View File

@@ -1,15 +1,15 @@
/** /**
* Adds the `chat_messages` table. * Adds `chat_messages`, which holds the messages of the site-wide chat.
*
* Idempotent: safe to run against a fresh db (already created from schema.sql
* with this table) or an existing one that predates it.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. A database made from schema.sql already has the
* table.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS chat_messages ( db.exec(`CREATE TABLE IF NOT EXISTS chat_messages (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,

View File

@@ -1,18 +1,18 @@
/** /**
* Adds an optional self-reference so a chat message can reply to another. * Adds `chat_messages.reply_to_id`, so a message can reply to another. It has
* Kept as a plain column (no FK): when the replied-to message is later deleted * no foreign key on purpose: when the replied-to message is deleted, the id
* the id is retained but the join yields null, letting the UI show a subtle * stays and chat-service's join reads null, so the chat shows "deleted
* "deleted message" reference instead of breaking the insert. * message" where the quote was.
*
* Idempotent: also runs against fresh databases created from schema.sql, where
* the column already exists.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. Existing messages reply to nothing, and a
* database made from schema.sql already has the column.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as { const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as {
name: string; name: string;

View File

@@ -1,25 +1,22 @@
/** /**
* Backfills the `start` parameter on stored YouTube embed URLs. * Adds the `start` parameter to the YouTube embed URLs stored before the
* YouTube provider carried it over. `rich_content.embedUrl` is computed when a
* dump is posted, and used to drop the source URL's `t` (`?t=1265`,
* `?t=1h2m3s`), so those videos played from 0:00.
* *
* `rich_content.embedUrl` is computed once, at dump creation, and previously * It reads the time from the stored source URL and fetches nothing. It keeps
* dropped the timestamp carried by the source URL (`?t=1265`, `?t=1h2m3s`), so * its own copy of the parsing, so that it migrates every database alike should
* those videos always opened at 0:00 in the global player. The provider now * the provider's change.
* translates `t`/`start` into the `start` param the iframe player honours; this
* migration applies the same translation to rows written before that fix.
*
* Purely local — it re-derives the param from the already-stored source URL and
* makes no network calls. Self-contained (no imports from api/services) so it
* stays reproducible even if the provider's parsing evolves later.
*
* Idempotent: rows whose embed URL already carries a `start` are skipped, so a
* fresh database built from schema.sql is simply a no-op.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Parses `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */ /**
* Returns the seconds in `url`'s `t` or `start`, which may be plain seconds,
* `90s`, `20m5s` or `1h2m3s`.
*/
function extractStartSeconds(url: string): number | null { function extractStartSeconds(url: string): number | null {
let raw: string | null; let raw: string | null;
try { try {
@@ -38,7 +35,10 @@ function extractStartSeconds(url: string): number | null {
Number(hms[3] ?? 0); Number(hms[3] ?? 0);
} }
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. It leaves alone an embed URL that already has a
* `start`, and a row whose `rich_content` doesn't parse.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const rows = db.prepare( const rows = db.prepare(
`SELECT id, url, rich_content FROM dumps `SELECT id, url, rich_content FROM dumps
@@ -55,7 +55,7 @@ export function up(db: DatabaseSync): void {
try { try {
rich = JSON.parse(row.rich_content); rich = JSON.parse(row.rich_content);
} catch { } catch {
continue; // malformed payload — leave it untouched continue;
} }
if (rich.type !== "youtube" || !rich.embedUrl) continue; if (rich.type !== "youtube" || !rich.embedUrl) continue;

View File

@@ -1,21 +1,14 @@
/** /**
* Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new * Moves site icons out of `rich_content.thumbnailUrl` into the new
* `faviconUrl` field. * `faviconUrl`. The metadata fetch used to fall back on the page's icon, and
* then on a guessed `/favicon.ico`, so most thumbnails were a 16×16 icon, or a
* 404, that the UI stretched over a 128×72 box. It now stops at real artwork,
* and a missing `thumbnailUrl` means none, which the frontend draws a
* placeholder for. This gives the older rows that meaning.
* *
* The extraction cascade used to end at the page's icon and then at a guessed * It judges the stored URL alone and fetches nothing, so it doesn't fill
* `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just * `accentColor`. The frontend derives a hue from the hostname when it is
* held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72 * missing, and refreshing a dump's metadata fetches the real one.
* box. The cascade now stops at real artwork, and an absent `thumbnailUrl`
* means "no artwork", which is what lets the frontend draw a placeholder.
* This migration gives rows written before that change the same meaning.
*
* Purely local — it classifies the already-stored URL and makes no network
* calls, so `accentColor` is deliberately not backfilled: the frontend derives
* a stable hue from the hostname whenever one is missing, and
* `refreshDumpMetadata` fetches the real color on demand.
*
* Idempotent: rows that already carry a `faviconUrl` are skipped, so a fresh
* database built from schema.sql is a no-op.
* *
* @module * @module
*/ */
@@ -23,12 +16,9 @@
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** /**
* Checks whether a stored thumbnail URL is really a site icon. * Checks whether a stored thumbnail URL is a site icon. It is loose on
* * purpose, since artwork taken for an icon still shows whole on a tinted field,
* Deliberately loose. A false positive (a genuine cover image living under * only not cropped to fill the box. 0012 fixes the rows it got wrong.
* `/assets/icons/`) renders contained on a tinted field instead of
* cover-cropped — mildly wrong, never broken — so chasing them isn't worth the
* extra rules.
*/ */
function isIconUrl(raw: string): boolean { function isIconUrl(raw: string): boolean {
let pathname: string; let pathname: string;
@@ -41,7 +31,11 @@ function isIconUrl(raw: string): boolean {
/\.(ico|svg)$/i.test(pathname); /\.(ico|svg)$/i.test(pathname);
} }
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. It leaves alone a row that already has a
* `faviconUrl`, and one whose `rich_content` doesn't parse. 0012 undoes its
* mistakes.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const rows = db.prepare( const rows = db.prepare(
`SELECT id, rich_content FROM dumps `SELECT id, rich_content FROM dumps
@@ -58,7 +52,7 @@ export function up(db: DatabaseSync): void {
try { try {
rich = JSON.parse(row.rich_content); rich = JSON.parse(row.rich_content);
} catch { } catch {
continue; // malformed payload — leave it untouched continue;
} }
if (rich.faviconUrl || !rich.thumbnailUrl) continue; if (rich.faviconUrl || !rich.thumbnailUrl) continue;
if (!isIconUrl(rich.thumbnailUrl)) continue; if (!isIconUrl(rich.thumbnailUrl)) continue;

View File

@@ -1,30 +1,19 @@
/** /**
* Repairs 0011, which was too eager about what counts as a site icon. * Repairs the rows 0011 got wrong. 0011 took any path containing "favicon",
* or ending in `.svg`, for an icon, so artwork such as
* `/wp-content/uploads/2022/04/mir-logo-favicon.png` lost its `thumbnailUrl`,
* and its dump showed as a pull-quote instead of an image in the journal
* mosaic.
* *
* 0011 tested for "favicon" anywhere in the path, so a legitimate `og:image` * This rule judges the file name and its folder instead. It spares
* like mirtitles.org's `/wp-content/uploads/2022/04/mir-logo-favicon.png` was * `mir-logo-favicon.png` and catches `icon_SEARCH.png`, which 0011 missed.
* moved into `faviconUrl` and its `thumbnailUrl` cleared — which also demoted
* the dump from an image card to a pull-quote in the journal mosaic. A blanket
* `.svg` match had the same effect on real artwork.
*
* `isIconUrl` here looks at the *filename* rather than the whole path: an icon
* is named `favicon…`, `apple-touch-icon…` or `icon…`, lives in an `icons/`
* directory, or ends in `.ico`. That both spares `mir-logo-favicon.png` and
* catches `icon_SEARCH.png`, which 0011 missed.
*
* Applied in both directions, and deliberately narrow when reversing: a value
* is only promoted back to `thumbnailUrl` if it matches 0011's rule but not
* this one, so rows written by a normal fetch aren't disturbed.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** /** 0011's rule, copied as it shipped, to tell which rows it moved. */
* Applies 0011's rule, kept verbatim so its false positives can be
* identified.
*/
function wasIconUrl0011(raw: string): boolean { function wasIconUrl0011(raw: string): boolean {
let pathname: string; let pathname: string;
try { try {
@@ -37,8 +26,9 @@ function wasIconUrl0011(raw: string): boolean {
} }
/** /**
* Checks whether a URL names a site icon, judged on its filename and * Checks whether a URL is a site icon. It is one when the file ends in `.ico`,
* directory. * sits in a `favicon(s)` or `icon(s)` folder, or is named `favicon`,
* `apple-touch-icon` or `icon`, alone or followed by a digit, `-`, `_` or `.`.
*/ */
function isIconUrl(raw: string): boolean { function isIconUrl(raw: string): boolean {
let pathname: string; let pathname: string;
@@ -55,7 +45,12 @@ function isIconUrl(raw: string): boolean {
return /^(favicon|apple-touch-icon|icon)(?=$|[-_.\d])/i.test(basename); return /^(favicon|apple-touch-icon|icon)(?=$|[-_.\d])/i.test(basename);
} }
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. It moves a `faviconUrl` back to `thumbnailUrl`
* only when 0011's rule takes it for an icon and this one doesn't. That also
* catches the ones a later fetch stored that this rule doesn't recognise, such
* as a `logo.svg`. It then moves the icons 0011 missed.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const rows = db.prepare( const rows = db.prepare(
`SELECT id, rich_content FROM dumps `SELECT id, rich_content FROM dumps
@@ -73,10 +68,9 @@ export function up(db: DatabaseSync): void {
try { try {
rich = JSON.parse(row.rich_content); rich = JSON.parse(row.rich_content);
} catch { } catch {
continue; // malformed payload — leave it untouched continue;
} }
// Artwork 0011 mistook for an icon: put it back.
if ( if (
rich.faviconUrl && !rich.thumbnailUrl && rich.faviconUrl && !rich.thumbnailUrl &&
wasIconUrl0011(rich.faviconUrl) && !isIconUrl(rich.faviconUrl) wasIconUrl0011(rich.faviconUrl) && !isIconUrl(rich.faviconUrl)
@@ -90,7 +84,6 @@ export function up(db: DatabaseSync): void {
continue; continue;
} }
// An icon 0011's rule didn't recognize: move it now.
if ( if (
rich.thumbnailUrl && !rich.faviconUrl && isIconUrl(rich.thumbnailUrl) rich.thumbnailUrl && !rich.faviconUrl && isIconUrl(rich.thumbnailUrl)
) { ) {

View File

@@ -1,19 +1,12 @@
/** /**
* Adds `dumps.url_canonical` — the lookup key behind the "already dumped?" * Adds `dumps.url_canonical`, the key the create form's "already dumped" hint
* check on the create form — and backfills it for every existing URL dump. * looks a URL up by, and fills it for every URL dump already posted.
* *
* Purely local: it re-derives the key from the URL already stored on each row * It fills the key from each stored URL and fetches nothing. Its helpers are a
* and makes no network calls. * copy of lib/canonical-url.ts as it stood when this shipped: importing the
* * live one would give two databases migrated at different times keys made by
* The helpers below are a deliberate frozen copy of `api/lib/canonical-url.ts` * different rules. A change to lib/canonical-url.ts therefore needs a new
* as it stood when this migration shipped, not an import of it. A migration * migration that fills the keys again.
* runs exactly once per database, so importing the live version would mean two
* databases migrating at different times end up with keys computed under
* different rules. Improving the shared canonicalizer therefore calls for a new
* backfill migration rather than an edit here.
*
* Idempotent: the column is only added when missing and only rows whose key is
* still NULL are touched, so a fresh database built from schema.sql is a no-op.
* *
* @module * @module
*/ */
@@ -98,7 +91,10 @@ function canonicalizeUrl(raw: string): string | null {
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`; return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
} }
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. It fills only the keys still NULL, and leaves
* NULL a URL that isn't http(s).
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as { const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as {
name: string; name: string;

View File

@@ -1,21 +1,22 @@
/** /**
* Chat unread state lives on the server so it survives reloads and agrees * Moves the chat's unread state to the server, so that it survives a reload
* across tabs and devices: `users.chat_read_at` is the created_at of the newest * and agrees across tabs and devices. `users.chat_read_at` holds the time of
* message the user has seen. Existing users start caught up (now) rather than * the newest message the user has seen.
* with the whole history unread; new users fall back to their own created_at.
* *
* The history cursor now pages on (created_at, id) so two messages sharing a * It also replaces the index on `created_at` with one on `(created_at, id)`,
* millisecond can't be skipped at a page boundary; the index follows suit. * which the history now pages on, so that two messages from the same
* * millisecond can't fall between two pages.
* Idempotent: also runs against fresh databases created from schema.sql, where
* the column and index already exist.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. Existing users start with every message read,
* instead of the whole history unread. A user created later starts with a
* NULL `chat_read_at`, which chat-service reads as the user's creation time.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(users);`).all() as { const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string; name: string;

View File

@@ -1,18 +1,17 @@
/** /**
* Changing or resetting a password must end every session opened before it. * Adds `users.tokens_valid_after`, so that changing a password ends every
* Tokens are stateless JWTs, so each user carries a cut-off instead: * session opened before. Tokens are stateless JWTs, so lib/auth.ts refuses a
* `users.tokens_valid_after` (ISO), and a token issued before it is refused. * token issued before that time instead.
* NULL — the state every existing user starts in — means no cut-off.
*
* Idempotent: also runs against fresh databases created from schema.sql, where
* the column already exists.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. Existing users start with NULL, which revokes no
* token.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(users);`).all() as { const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string; name: string;

View File

@@ -1,24 +1,21 @@
/** /**
* Indexes for the queries that ran as full scans. * Adds indexes for the queries that scanned a whole table. The feeds page
* * dumps by `created_at`, and a user's dumps and upvotes by `(user_id,
* - feeds page by created_at (newest first), a user's dumps and upvotes by * created_at)`, which replace the indexes on `user_id` alone. Dumps and
* (user_id, created_at) — these replace the bare user_id indexes; * playlists are looked up by slug. Each foreign key with ON DELETE gets an
* - dump and playlist pages are looked up by slug; * index on its child column, which SQLite otherwise scans on every delete of
* - the child side of every ON DELETE CASCADE / SET NULL foreign key, which * a parent row, and the followers of a user or playlist are read on every new
* SQLite otherwise scans on each parent delete (a comment's replies, a user's * dump. Mentions look usernames up by `lower(username)`.
* comments, follows, chat messages, invites, reset tokens), and the
* followers of a user or playlist, read on every new dump;
* - @mentions resolve usernames case-insensitively, by lower(username).
*
* Idempotent: also runs against fresh databases created from schema.sql, where
* these indexes already exist.
* *
* @module * @module
*/ */
import type { DatabaseSync } from "node:sqlite"; import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */ /**
* Run once by `runMigrations`. A database made from schema.sql already has
* these indexes.
*/
export function up(db: DatabaseSync): void { export function up(db: DatabaseSync): void {
db.exec(` db.exec(`
DROP INDEX IF EXISTS idx_dumps_user; DROP INDEX IF EXISTS idx_dumps_user;

View File

@@ -1,5 +1,6 @@
/** /**
* Housekeeping run when the database is opened. * Deletes what has expired and seeds the first admin, each time connection.ts
* opens the database.
* *
* @module * @module
*/ */
@@ -13,22 +14,22 @@ import {
} from "../config.ts"; } from "../config.ts";
/** /**
* Runs once when the database is opened, after the migrations: drops what has * Runs after the migrations, each time the server starts. It deletes used or
* expired (reset tokens, unused invites, orphaned attachments), and seeds the * expired reset tokens, invites past their validity and attachments nothing
* default admin (admin/admin) on an empty instance. * links to. On an instance with no users, it creates the user `admin` with the
* password `admin`, which whoever installs it must change.
*/ */
export async function runStartupTasks(db: DatabaseSync): Promise<void> { export async function runStartupTasks(db: DatabaseSync): Promise<void> {
// Purge expired/used password reset tokens
db.prepare( db.prepare(
`DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`, `DELETE FROM password_reset_tokens WHERE expires_at < datetime('now') OR used_at IS NOT NULL;`,
).run(); ).run();
// Purge expired unused invites
db.prepare( db.prepare(
`DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`, `DELETE FROM invites WHERE used_at IS NULL AND created_at < datetime('now', '-${UNUSED_INVITES_RETENTION_DAYS} days');`,
).run(); ).run();
// Prune orphaned attachments (uploaded but never linked to a resource) older than 1 hour // An image is uploaded as soon as it's pasted, and linked only when the text
// it's pasted into is saved, so a fresh one is left alone.
const orphanedAttachments = db.prepare( const orphanedAttachments = db.prepare(
`SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`, `SELECT id FROM attachments WHERE resource_id IS NULL AND created_at < datetime('now', '-${ORPHANED_ATTACHMENTS_RETENTION_HOURS} hour');`,
).all() as { id: string }[]; ).all() as { id: string }[];
@@ -41,11 +42,12 @@ export async function runStartupTasks(db: DatabaseSync): Promise<void> {
).run(); ).run();
} }
// Create default admin user if no users exist // Registration needs an invite, so the first user can only come from here.
const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as { const userCount = db.prepare(`SELECT COUNT(*) as count FROM users`).get() as {
count: number; count: number;
}; };
if (userCount.count === 0) { if (userCount.count === 0) {
// The format of hashPassword in lib/jwt.ts, which verifies it.
const salt = randomBytes(16).toString("hex"); const salt = randomBytes(16).toString("hex");
const hash = scryptSync("admin", salt, 64).toString("hex"); const hash = scryptSync("admin", salt, 64).toString("hex");
const passwordHash = `${hash}.${salt}`; const passwordHash = `${hash}.${salt}`;

View File

@@ -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 * @module
*/ */
@@ -7,16 +8,18 @@
import type { APIErrorCode } from "../../shared/api.ts"; import type { APIErrorCode } from "../../shared/api.ts";
/** /**
* An error meant for the client, thrown anywhere below a route; the error * Thrown by a route, a service or a remote fetch to answer the request with an
* middleware turns it into an `APIFailure` response. * 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 { export class APIException extends Error {
/** The error code the response carries. */
readonly code: APIErrorCode; readonly code: APIErrorCode;
/** The HTTP status the response is sent with. */
readonly status: number; 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) { constructor(code: APIErrorCode, status: number, message: string) {
super(message); super(message);
this.code = code; this.code = code;

View File

@@ -1,6 +1,7 @@
/** /**
* Authenticates the tokens a request carries: session tokens in the * Checks session and media tokens against the live user row, not just their
* `Authorization` header and media tokens in a media URL's query string. * signature. The auth middleware, the WebSocket route, the media routes and the
* routes open to anonymous callers all authenticate through it.
* *
* @module * @module
*/ */
@@ -14,13 +15,10 @@ import type { Role } from "../../shared/user.ts";
import { getSessionState } from "../services/user-service.ts"; import { getSessionState } from "../services/user-service.ts";
/** /**
* Checks a signature-verified token against the live user row: the user must * Returns the live role of a verified token's user, or null when the user is
* still exist, and the token must not predate their last password change * gone or the token predates their last password change
* (`users.tokens_valid_after`). Returns the live role, or null to refuse. * (`users.tokens_valid_after`). `iat` counts whole seconds, so a token issued
* * in the same second as the change survives it.
* `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.
*/ */
function checkTokenOwner( function checkTokenOwner(
userId: string, userId: string,
@@ -38,9 +36,10 @@ function checkTokenOwner(
} }
/** /**
* Verifies a session token end to end: signature, expiry, a user that still * Returns the payload of a valid session token, or null. Besides the signature
* exists and no password change since it was issued. The role comes from the * and expiry, it refuses a token whose user is gone or which predates a
* DB, not the token, since tokens last 7 days and roles change under them. * 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( export async function authenticate(
token: string, 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 * Returns the user id behind the `?token=` of a file, thumbnail or playlist
* token, or a session token from a client that predates them. * 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> { export async function authenticateMedia(token: string): Promise<string | null> {
const media = await verifyMediaToken(token); 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 * Returns the caller's user id on a route open to anonymous callers, or null
* request. * 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: * @throws {APIException} 401 when a token is present but invalid.
* - 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.
*/ */
export async function parseOptionalAuth(ctx: Context): Promise<string | null> { export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
const authHeader = ctx.request.headers.get("Authorization"); const authHeader = ctx.request.headers.get("Authorization");

View File

@@ -1,18 +1,13 @@
/** /**
* The canonical form of a URL, used *only* to recognise that two links point at the * Reduces a URL to a key shared by every link to the same page or video.
* same thing — the duplicate check the create form runs while it fetches a * `dump-service` stores it in `dumps.url_canonical` when a
* preview. It is a lookup key, never something we display or fetch: `dumps.url` * dump is posted or edited, and `GET /api/dumps/by-url` matches on it
* keeps the exact string the poster submitted. * 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 * Migration 0013 backfilled existing rows with a frozen copy of these rules.
* lossy in ways a real URL never could be — it forces `https`, drops `www.`, * Changing them needs a new backfill migration, or old rows keep old keys.
* 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.
* *
* @module * @module
*/ */
@@ -42,7 +37,7 @@ function isTrackingParam(key: string): boolean {
return k.startsWith("utm_") || TRACKING_PARAMS.has(k); 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([ const YOUTUBE_HOSTS = new Set([
"youtube.com", "youtube.com",
"m.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 * Returns the id of the video a YouTube URL points at, in any of the shapes
* (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`, `/live/ID`). * people paste (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`,
* Timestamps are deliberately ignored: the same video linked at 2:30 is still * `/live/ID`). The timestamp is dropped: the same video linked at 2:30 is still
* the same video for duplicate purposes. * a repost.
*/ */
function youtubeVideoId( function youtubeVideoId(
host: string, host: string,
@@ -71,8 +66,8 @@ function youtubeVideoId(
} }
/** /**
* Returns the canonical form of `raw` (see the module doc), or null when it * Returns the canonical key of `raw`, or null when it isn't an http(s) URL. A
* isn't an http(s) URL. * YouTube video or playlist becomes one fixed URL whatever shape it came in.
*/ */
export function canonicalizeUrl(raw: string): string | null { export function canonicalizeUrl(raw: string): string | null {
let u: URL; let u: URL;
@@ -94,7 +89,7 @@ export function canonicalizeUrl(raw: string): string | null {
: null; : null;
if (listId) return `https://youtube.com/playlist?list=${listId}`; 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 path = u.pathname.replace(/\/+$/, "");
const params = [...u.searchParams.entries()] 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)); .sort(([a, av], [b, bv]) => a.localeCompare(b) || av.localeCompare(bv));
const query = new URLSearchParams(params).toString(); const query = new URLSearchParams(params).toString();
// A bare "#section" anchor points into the same page, so it is dropped — // A "#section" anchor points into the same page and is dropped, but "#/route"
// but "#/route" and "#!/route" address distinct pages of a hash-routed app. // and "#!/route" are distinct pages of a hash-routed app.
const hash = /^#!?\//.test(u.hash) ? u.hash : ""; const hash = /^#!?\//.test(u.hash) ? u.hash : "";
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`; return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;

View File

@@ -1,6 +1,7 @@
/** /**
* The built frontend's `index.html`, with its runtime placeholders filled: the * Fills the placeholders of the frontend's `index.html` with settings read at
* site name, emoji, icon version and Bandcamp player mode. * runtime, so they change with a restart and no rebuild. The static and OG
* middlewares serve what it returns.
* *
* @module * @module
*/ */
@@ -8,18 +9,20 @@
import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts";
import { emojiToCodepoint } from "./site-icons.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); const ICON_VERSION = emojiToCodepoint(SITE_EMOJI);
/** /**
* `index.html` with its placeholders filled, by root. Kept until the file * Caches each root's filled file until its mtime changes, as a rebuild under a
* changes (a rebuild under a running server): a stat per navigation instead of * running server does, so a navigation costs a stat instead of a read.
* a read and four replaceAll.
*/ */
const indexHtml = new Map<string, { mtime: number; html: string }>(); 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. * @throws {Error} When the file can't be read.
*/ */

View File

@@ -1,6 +1,8 @@
/** /**
* Signed tokens and password hashes: session tokens, media tokens, password * Signs and verifies the server's three kinds of token (session, media and
* reset tokens, and scrypt hashing. * 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. * 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); const JWT_KEY = new TextEncoder().encode(JWT_SECRET);
// ── Password reset tokens ───────────────────────────────────────────────────── /**
* Signs a password reset token for `userId`, valid for an hour. The token
/** Signs a single-use password reset token for `userId`, valid for an hour. */ * isn't single-use by itself: `requestPasswordReset` stores it, and
* `resetPassword` marks it used.
*/
export async function createPasswordResetToken( export async function createPasswordResetToken(
userId: string, userId: string,
): Promise<string> { ): Promise<string> {
return await new SignJWT({ purpose: "password-reset", userId }) return await new SignJWT({ purpose: "password-reset", userId })
.setProtectedHeader({ alg: "HS256" }) .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()) .setJti(crypto.randomUUID())
.setExpirationTime("1h") .setExpirationTime("1h")
.sign(JWT_KEY); .sign(JWT_KEY);
} }
/** /**
* Verifies a password reset token and returns its payload, or null when it is * Returns the payload of a password reset token, or null when it is invalid,
* invalid, expired or not a reset token. * expired or another kind of token. Whether it was used is the caller's check.
*/ */
export async function verifyPasswordResetToken( export async function verifyPasswordResetToken(
token: string, token: string,
@@ -55,29 +61,29 @@ export async function verifyPasswordResetToken(
} }
} }
// ── Auth tokens ─────────────────────────────────────────────────────────────── /**
* Signs a session token carrying `payload`, valid for 7 days. The users routes
/** Signs a session token carrying `payload`, valid for 7 days. */ * issue it on sign-up, sign-in and password change, and the client sends it as
* a bearer token.
*/
export async function createJWT( export async function createJWT(
payload: Omit<AuthPayload, "exp">, payload: Omit<AuthPayload, "exp">,
): Promise<string> { ): Promise<string> {
return await new SignJWT(payload) return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" }) .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() .setIssuedAt()
.setExpirationTime("7d") .setExpirationTime("7d")
.sign(JWT_KEY); .sign(JWT_KEY);
} }
// ── Media tokens ──────────────────────────────────────────────────────────────
/** /**
* Signs a media token for `userId`, valid for 7 days. * 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
* Media elements can't send an Authorization header, so a private dump's file * playlist covers carry this token as `?token=`, where it lands in logs and
* and thumbnail URLs carry a token in the query string, where it lands in * browser history. It only reads media, so a leaked URL doesn't expose the
* access logs and browser history. That token is this one: it only reads media, * account.
* so a leaked URL exposes the owner's private files, not their account.
*/ */
export async function createMediaToken(userId: string): Promise<string> { export async function createMediaToken(userId: string): Promise<string> {
return await new SignJWT({ purpose: "media", userId }) 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 * Returns the user id and issue time of a media token, or null when it is
* it is invalid, expired or not a media token. * invalid, expired or another kind of token.
*/ */
export async function verifyMediaToken( export async function verifyMediaToken(
token: string, token: string,
@@ -106,8 +112,9 @@ export async function verifyMediaToken(
} }
/** /**
* Verifies a session token's signature and expiry and returns its payload, or * Returns the payload of a session token whose signature and expiry are valid,
* null. Checking it against the live user is `authenticate`'s job (lib/auth.ts). * 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> { export async function verifyJWT(token: string): Promise<AuthPayload | null> {
try { 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> { export function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString("hex"); const salt = randomBytes(16).toString("hex");
@@ -142,8 +152,7 @@ export function verifyPassword(
storedHash: string, storedHash: string,
): Promise<boolean> { ): Promise<boolean> {
const [hash, salt] = storedHash.split("."); const [hash, salt] = storedHash.split(".");
// A malformed stored hash can't match anything; scrypt would throw on the // scrypt would throw on a missing salt, and a malformed hash matches nothing.
// missing salt instead of answering.
if (!hash || !salt) return Promise.resolve(false); if (!hash || !salt) return Promise.resolve(false);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@@ -1,6 +1,7 @@
/** /**
* Page-based pagination: reading `page` and `limit` from a query string, and * Reads the page a list route's query string asks for, and cuts the rows a
* cutting fetched rows into a page. * service fetched into that page. The routes send the page as
* `PaginatedData`.
* *
* @module * @module
*/ */
@@ -8,9 +9,9 @@
import { PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT } from "../config.ts"; import { PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT } from "../config.ts";
/** /**
* Reads the `page` and `limit` query parameters. `page` is at least 1 and * Reads the `page` and `limit` query parameters of a list route. It never
* defaults to 1; `limit` is clamped to [1, {@link PAGINATION_MAX_LIMIT}] and * fails: a missing or invalid `page` reads as 1, and `limit` is kept between 1
* defaults to `defaultLimit`. * and {@link PAGINATION_MAX_LIMIT}.
*/ */
export function parsePagination( export function parsePagination(
params: URLSearchParams, params: URLSearchParams,
@@ -30,18 +31,19 @@ export function parsePagination(
return { page, limit }; 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> { export interface Page<T> {
/** The page's items, in order. */
items: T[]; items: T[];
/** Whether another page follows this one. */
hasMore: boolean; hasMore: boolean;
} }
/** /**
* Turns rows fetched with `LIMIT limit + 1` into a page: the extra row, if it * Turns rows fetched with `LIMIT limit + 1` into a page. The extra row, when it
* came back, is dropped and says there's a next page. No COUNT(*) over the * comes back, is dropped and tells that another page follows, which spares a
* whole list just to know that. * COUNT(*) over the whole list.
*/ */
export function pageOf<T>(rows: T[], limit: number): Page<T> { export function pageOf<T>(rows: T[], limit: number): Page<T> {
return rows.length > limit return rows.length > limit

View File

@@ -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 * @module
*/ */
@@ -7,7 +9,10 @@
import { hasPermission, type Permission } from "../../shared/permissions.ts"; import { hasPermission, type Permission } from "../../shared/permissions.ts";
import type { AuthPayload } from "../model/auth.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 { export function can(payload: AuthPayload, permission: Permission): boolean {
return hasPermission(payload.role, permission); return hasPermission(payload.role, permission);
} }

View File

@@ -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 * @module
*/ */
import type { Playlist } from "../../shared/playlist.ts"; 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 { export interface PlaylistMember {
/** The dump's id. */
id: string; id: string;
/** Whether the dump is private. */
isPrivate: boolean; isPrivate: boolean;
/** The id of the dump's poster. */ /** The dump's poster, not the playlist's owner. */
userId: string; userId: string;
} }
/** /**
* Checks whether `viewerId` sees this member when the playlist is shown to them: a * Checks whether `viewerId` may see `member` in `playlist`. A public dump is
* public dump, or a private one of the owner's own, to the owner. Everyone * visible to all. A private dump is visible only to the playlist's owner, and
* else — moderators too — doesn't see private dumps, and neither does the * only if they posted it: moderators don't see it, and neither does the owner
* owner see someone else's dump once it has gone private. * once someone else's dump in their playlist goes private. A null or undefined
* `viewerId` is a signed-out viewer.
*/ */
export function isMemberVisibleTo( export function isMemberVisibleTo(
member: PlaylistMember, member: PlaylistMember,

View File

@@ -1,7 +1,7 @@
/** /**
* In-memory rate limiting with token buckets, keyed by the caller's choice * Limits how often something may happen, with token buckets kept in memory,
* (e.g. `${kind}:${userId}`) — per user rather than per socket or request, so * which a restart empties. Logins, password reset requests and the chat's
* more tabs or parallel requests don't buy more throughput. * typing messages use it.
* *
* @module * @module
*/ */
@@ -12,15 +12,22 @@ interface Bucket {
} }
const buckets = new Map<string, 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 { export interface RateLimit {
/** Burst size. */ /** How many tokens a full bucket holds, and so the largest burst. */
capacity: number; capacity: number;
/** Tokens regained per second. */ /** Tokens regained per second. */
refill: number; 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 { export function takeToken(key: string, limit: RateLimit): boolean {
const now = Date.now(); const now = Date.now();
const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: 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; return true;
} }
// Idle buckets have refilled by now for every limit in use; drop them so the // Every limit in use refills within the hour, so an older bucket is full and
// map tracks only keys that have been active lately. // dropping it changes nothing.
setInterval(() => { setInterval(() => {
const cutoff = Date.now() - 60 * 60_000; const cutoff = Date.now() - 60 * 60_000;
for (const [key, bucket] of buckets) { for (const [key, bucket] of buckets) {

View File

@@ -1,13 +1,16 @@
/** /**
* Which slugs a category may take. Category slugs live at the URL root (e.g. * Checks which slugs a category may take. A category's feed lives at the URL
* /music), so they must never collide with the frontend's own routes. * 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 * @module
*/ */
/** /**
* Top-level path segments that already have meaning in the SPA router (see * The top-level path segments a category slug may not take. They are the SPA's
* src/App.tsx), plus a few reserved for infrastructure. * 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[] = [ export const RESERVED_SLUGS: readonly string[] = [
"~", "~",
@@ -25,12 +28,12 @@ export const RESERVED_SLUGS: readonly string[] = [
]; ];
/** /**
* A well-formed category slug: lowercase alphanumerics, * Matches a well-formed category slug, made of lowercase letters and digits
* hyphen-separated, no leading, trailing or double hyphen. * joined by single hyphens.
*/ */
export const CATEGORY_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; 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 { export function isValidSlugFormat(slug: string): boolean {
return CATEGORY_SLUG_RE.test(slug); return CATEGORY_SLUG_RE.test(slug);
} }
@@ -40,7 +43,10 @@ export function isReservedSlug(slug: string): boolean {
return RESERVED_SLUGS.includes(slug); 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 { export function isSlugAvailable(slug: string): boolean {
return isValidSlugFormat(slug) && !isReservedSlug(slug); return isValidSlugFormat(slug) && !isReservedSlug(slug);
} }

View File

@@ -1,6 +1,7 @@
/** /**
* Recognises links to this site's own pages, which previews resolve from the * Recognises links to this site's own pages. The self provider previews them
* database instead of fetching. * from the database, and remote/fetch.ts accepts them as dump links even on a
* private host such as localhost.
* *
* @module * @module
*/ */

View File

@@ -1,6 +1,7 @@
/** /**
* Serving files from disk with ETags and byte ranges, streamed a chunk at a * Sends files from disk with ETags and byte ranges, streamed a chunk at a
* time. * time. The files and thumbnails routes call it, and lib/upload.ts for the
* other uploads.
* *
* @module * @module
*/ */
@@ -67,21 +68,24 @@ function matchesEtag(header: string | null, etag: string): boolean {
return header.split(",").some((tag) => strip(tag) === strip(etag)); 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 { export interface ServeFileOptions {
/** The `Content-Type` sent. */
mime: string; mime: string;
/** The `Cache-Control` sent. */
cacheControl: string; 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; ranges?: boolean;
} }
/** /**
* Sends a file from disk with an ETag (a 304 when the client already has it) * Sends a file from disk with an ETag, answering 304 when the client already
* and, when `ranges` is set, single byte ranges. The body is streamed. * 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.
* Returns false, having sent nothing, when the file doesn't exist.
*/ */
export async function serveFile( export async function serveFile(
ctx: Context, ctx: Context,

View File

@@ -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 * The glyph comes from Twemoji as plain vector paths, so rendering it needs no
* any system emoji font (which matters in the minimal Alpine runtime image). * emoji font, which the Alpine runtime image lacks.
* `@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.
* *
* @module * @module
*/ */
@@ -19,9 +17,10 @@ const APPLE_SIZE = 180;
const ICO_SIZES = [16, 32, 48]; const ICO_SIZES = [16, 32, 48];
/** /**
* Returns the name Twemoji files `emoji` under: its codepoints joined with * Returns the name Twemoji files `emoji` under: its codepoints in hex, joined
* "-", variation selectors (FE0F) stripped unless the emoji is a ZWJ sequence. * with "-", without variation selectors (FE0F) unless the emoji is a ZWJ
* Also doubles as a stable cache-busting version string for the icon URLs. * sequence. `lib/index-html.ts` also uses it as the icon URLs' cache-busting
* version.
*/ */
export function emojiToCodepoint(emoji: string): string { export function emojiToCodepoint(emoji: string): string {
const cleaned = emoji.includes("‍") ? emoji : emoji.replace(/️/g, ""); 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(); 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 { function buildIco(images: { size: number; png: Uint8Array }[]): Uint8Array {
const HEADER = 6; const HEADER = 6;
const ENTRY = 16; const ENTRY = 16;
@@ -99,24 +98,23 @@ function buildManifest(siteName: string, bgColor: string): string {
return JSON.stringify(manifest, null, 2) + "\n"; 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 { export interface GenerateSiteIconsOptions {
/** Emoji to render the icons from. */
emoji: string; emoji: string;
/** Directory to write the assets into (created if missing). */ /** Created if missing. */
outDir: string; outDir: string;
/** Site name written into the manifest. */
siteName: string; siteName: string;
/** /** Fills the Apple touch icon, and the manifest's theme and background. */
* Background color for the Apple touch icon and manifest (iOS ignores
* transparency).
*/
bgColor: string; bgColor: string;
} }
/** /**
* Generates `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.png` (16–512), * Writes `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.png` (16 to 512),
* `apple-touch-icon.png` and `manifest.webmanifest` into `outDir`. * `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. * @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) { for (const size of PNG_SIZES) {
await Deno.writeFile(out(`favicon-${size}x${size}.png`), pngs.get(size)!); 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( await Deno.writeFile(
out("apple-touch-icon.png"), out("apple-touch-icon.png"),
renderPng(svg, APPLE_SIZE, bgColor), renderPng(svg, APPLE_SIZE, bgColor),

View File

@@ -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 * @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 = 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; /^[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 * Turns `title` into lowercase ASCII words joined by dashes, at most 60
* 60 characters, `untitled` when nothing is left. * characters long. Accents are dropped, and a title with no ASCII letter or
* digit gives `untitled`.
*/ */
export function slugify(title: string): string { export function slugify(title: string): string {
const slug = title const slug = title
.toLowerCase() .toLowerCase()
.normalize("NFD") .normalize("NFD")
.replace(/[\u0300-\u036f]/g, "") // strip diacritics // NFD splits an accented letter into the letter and a combining mark.
.replace(/[^a-z0-9]+/g, "-") // non-alphanumeric → dash .replace(/[\u0300-\u036f]/g, "")
.replace(/^-+|-+$/g, "") // trim leading/trailing dashes .replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.substring(0, 60); .substring(0, 60);
return slug || "untitled"; return slug || "untitled";
} }
/** /**
* Returns the slug of a record: its title's slug followed by the start of its * Returns a record's slug, its title's {@link slugify} followed by the first 8
* `id`, so it stays unique by construction. * characters of `id`. The suffix keeps two records with the same title apart.
*/ */
export function makeSlug(title: string, id: string): string { export function makeSlug(title: string, id: string): string {
return `${slugify(title)}-${id.substring(0, 8)}`; return `${slugify(title)}-${id.substring(0, 8)}`;

View File

@@ -1,6 +1,7 @@
/** /**
* Serving the built frontend: static files, and `index.html` with its * Serves the built frontend, as its static files and, for every client-side
* placeholders filled for every client-side route. * route, `index.html` with its placeholders filled. main.ts installs it after
* the routers.
* *
* @module * @module
*/ */
@@ -8,30 +9,31 @@
import { Context, Next, send } from "@oak/oak"; import { Context, Next, send } from "@oak/oak";
import { loadIndexHtml } from "./index-html.ts"; import { loadIndexHtml } from "./index-html.ts";
/** Answers with the filled `index.html` of `root`, always revalidated. */
async function serveIndexHtml( async function serveIndexHtml(
context: Context<Record<string, object>>, context: Context<Record<string, object>>,
root: string, root: string,
) { ) {
const html = await loadIndexHtml(root); const html = await loadIndexHtml(root);
context.response.type = "text/html"; 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.headers.set("Cache-Control", "no-cache");
context.response.body = html; context.response.body = html;
} }
/** /**
* Build output whose URL changes whenever its content does: Vite's hashed * Prefixes the build output whose URL changes whenever its content does, which
* chunks, and the emoji data under its package version (see vite.config.ts). * are Vite's hashed chunks and the emoji data under its package version (see
* vite.config.ts).
*/ */
const IMMUTABLE_PREFIXES = ["/assets/", "/emojibase/"]; const IMMUTABLE_PREFIXES = ["/assets/", "/emojibase/"];
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
/** /**
* Returns a middleware serving static files from the first of `staticPaths` * Returns the middleware that serves a static file from the first of
* that has them, and `index.html` from the first for any other path so * `staticPaths` that has it, and `index.html` of the first for any other path.
* client-side routes load on direct navigation. Hashed build output is cached * Hashed build output is cached for a year, and a missing chunk is a 404.
* for a year, and a missing chunk is a 404. * main.ts passes dist/ then public/.
*/ */
export function routeStaticFilesFrom( export function routeStaticFilesFrom(
staticPaths: string[], staticPaths: string[],
@@ -39,7 +41,6 @@ export function routeStaticFilesFrom(
return async (context: Context<Record<string, object>>, next: Next) => { return async (context: Context<Record<string, object>>, next: Next) => {
const pathname = context.request.url.pathname; const pathname = context.request.url.pathname;
// Serve index.html with runtime placeholder replacement
if (pathname === "/" || pathname === "/index.html") { if (pathname === "/" || pathname === "/index.html") {
await serveIndexHtml(context, staticPaths[0]); await serveIndexHtml(context, staticPaths[0]);
return; return;
@@ -53,7 +54,8 @@ export function routeStaticFilesFrom(
const compress = context.request.headers.has("accept-encoding"); const compress = context.request.headers.has("accept-encoding");
for (const path of staticPaths) { for (const path of staticPaths) {
try { 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, { await send(context, pathname, {
root: path, root: path,
brotli: compress, brotli: compress,
@@ -74,7 +76,7 @@ export function routeStaticFilesFrom(
return; 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 serveIndexHtml(context, staticPaths[0]);
await next(); await next();
}; };

View File

@@ -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 * @module
*/ */
@@ -29,7 +30,8 @@ export {
/** /**
* Returns the MIME type of a JPEG, PNG, GIF or WebP image from its magic * 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 { export function detectImageMime(data: Uint8Array): string | null {
if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) { 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 * @throws {APIException} 400 when it exceeds {@link MAX_IMAGE_SIZE_BYTES} or
* isn't a JPEG, PNG, GIF or WebP image. * 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 * Serves an uploaded image inline as `mime`, cached for `cacheMaxAge` seconds,
* (privately when `isPrivate`). Answers 404 and resolves false when the file * and only by the browser when `isPrivate`. It answers 404 and resolves false
* is missing. * when the file is missing. The caller checks first that the viewer may see
* it.
*/ */
export async function serveUploadedFile( export async function serveUploadedFile(
ctx: Context, ctx: Context,
@@ -89,6 +93,8 @@ export async function serveUploadedFile(
isPrivate = false, isPrivate = false,
): Promise<boolean> { ): Promise<boolean> {
ctx.response.headers.set("Content-Disposition", "inline"); 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("X-Content-Type-Options", "nosniff");
ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin"); ctx.response.headers.set("Cross-Origin-Resource-Policy", "cross-origin");
const served = await serveFile(ctx, filePath, { const served = await serveFile(ctx, filePath, {

View File

@@ -1,7 +1,8 @@
/** /**
* The server's entry point: builds the Oak application — error and OG * Builds the Oak application and, when run directly, listens on
* middleware, CORS, every router, then the static frontend — and listens on * {@link LISTEN_HOST}:{@link PORT}. A request goes through the error
* {@link LISTEN_HOST}:{@link PORT} when run directly. * middleware, the OG tags and CORS, then the routers, and last the built
* frontend, which answers every path no router did.
* *
* @module * @module
*/ */
@@ -33,16 +34,19 @@ import { errorMiddleware } from "./middleware/error.ts";
import { ogMiddleware } from "./middleware/og.ts"; import { ogMiddleware } from "./middleware/og.ts";
import { routeStaticFilesFrom } from "./lib/static.ts"; import { routeStaticFilesFrom } from "./lib/static.ts";
/** The application, exported so the tests can drive it without listening. */ /**
* The whole server, exported so the tests' helpers can send it requests
* without listening.
*/
const app: Application = new Application(); const app: Application = new Application();
const cors = oakCors({ origin: ALLOWED_ORIGINS }); const cors = oakCors({ origin: ALLOWED_ORIGINS });
app.use(errorMiddleware); app.use(errorMiddleware);
app.use(ogMiddleware); app.use(ogMiddleware);
// Only invoke oakCors when the origin is present and allowed. // oakCors calls next() without awaiting it when there is no origin, which
// The library calls next() without await when origin is falsy, which // corrupts the response in Oak's async chain. So it only sees requests from an
// corrupts the response in Oak's async middleware chain. // allowed origin, and the others get no CORS headers.
app.use(async (ctx, next) => { app.use(async (ctx, next) => {
const origin = ctx.request.headers.get("origin"); const origin = ctx.request.headers.get("origin");
if (origin && ALLOWED_ORIGINS.includes(origin)) { if (origin && ALLOWED_ORIGINS.includes(origin)) {

View File

@@ -1,6 +1,7 @@
/** /**
* Middleware gating routes on authentication, the admin role, or a * Gates routes on a session token, and on a permission after it. The routers
* permission. * put these middleware before a handler; a route open to anonymous callers
* calls `parseOptionalAuth` (`lib/auth.ts`) instead.
* *
* @module * @module
*/ */
@@ -18,16 +19,17 @@ import type { AuthPayload } from "../model/auth.ts";
import { can } from "../lib/permissions.ts"; import { can } from "../lib/permissions.ts";
import type { Permission } from "../../shared/permissions.ts"; import type { Permission } from "../../shared/permissions.ts";
/** The request state once {@link authMiddleware} has run. */ /**
* Set on `ctx.state` by {@link authMiddleware} for the handlers after it.
* `user` carries the caller's live role, not the one in their token.
*/
export interface AuthState extends State { export interface AuthState extends State {
/** The authenticated caller, with their live role. */
user: AuthPayload; user: AuthPayload;
} }
/** /**
* A router context whose state is guaranteed to carry the authenticated user * The context type of a handler behind {@link authMiddleware}. Pass the route
* (populated by {@link authMiddleware}). Generic over the route path so * path, as in `AuthContext<"/:userId/role">`, to keep `ctx.params` typed.
* `ctx.params` stays typed per-route — e.g. `AuthContext<"/:userId/role">`.
*/ */
export type AuthContext< export type AuthContext<
R extends string = string, R extends string = string,
@@ -35,8 +37,8 @@ export type AuthContext<
> = RouterContext<R, P, AuthState>; > = RouterContext<R, P, AuthState>;
/** /**
* Requires a valid session token in the `Authorization` header and puts its * Requires a valid session token in the `Authorization` header, and puts its
* caller in `ctx.state.user`. * payload in `ctx.state.user` for the rest of the route.
* *
* @throws {APIException} 401 when the token is missing or invalid. * @throws {APIException} 401 when the token is missing or invalid.
*/ */
@@ -55,8 +57,6 @@ export async function authMiddleware<R extends string>(
} }
const token = authHeader.substring(7); const token = authHeader.substring(7);
// Also checks the user still exists and hasn't changed their password since
// the token was issued, and takes the live role from the DB.
const payload = await authenticate(token); const payload = await authenticate(token);
if (!payload) { if (!payload) {
@@ -70,6 +70,7 @@ export async function authMiddleware<R extends string>(
/** /**
* Requires the caller to be an admin. Must run after {@link authMiddleware}. * Requires the caller to be an admin. Must run after {@link authMiddleware}.
* No route uses it: they gate on a permission with {@link requirePermission}.
* *
* @throws {APIException} 403 when the caller isn't an admin. * @throws {APIException} 403 when the caller isn't an admin.
*/ */
@@ -89,10 +90,11 @@ export async function adminOnlyMiddleware<R extends string>(
} }
/** /**
* Returns a middleware requiring the caller to hold `permission`, which throws * Returns a middleware that requires the caller to hold `permission`. It must
* an {@link APIException} (403) when they don't. Must run after * run after {@link authMiddleware}, as in
* {@link authMiddleware}. Generic over the route path so it composes with * `router.patch(path, authMiddleware, requirePermission("user:manage"), …)`.
* path-typed route handlers. * The returned middleware throws an {@link APIException} (403) when the caller
* lacks the permission.
*/ */
export function requirePermission( export function requirePermission(
permission: Permission, permission: Permission,

View File

@@ -1,5 +1,7 @@
/** /**
* Turns errors thrown while handling a request into `APIFailure` responses. * Turns the errors a request's handling throws into `APIFailure` responses.
* main.ts installs it first, so it catches what every later middleware and
* route throws.
* *
* @module * @module
*/ */
@@ -10,8 +12,9 @@ import { APIErrorCode, APIFailure } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts"; import { APIException } from "../lib/api-exception.ts";
/** /**
* Answers an {@link APIException} with its status and code, and anything else * Answers an {@link APIException} with its status, code and message. Anything
* with a 500 `SERVER_ERROR`, logging both. * else is logged with its stack and answered with a 500 `SERVER_ERROR` whose
* message gives nothing away.
*/ */
export async function errorMiddleware( export async function errorMiddleware(
ctx: Context, ctx: Context,

View File

@@ -1,7 +1,8 @@
/** /**
* Open Graph and Twitter card tags for shared links: a dump, user or public * Answers a link to a dump, a profile or a public playlist with `index.html`
* playlist page is answered with `index.html` carrying that page's title, * carrying that page's Open Graph and Twitter card tags, for the link previews
* description and image, so link unfurlers that don't run the app see them. * of chat apps and social sites, which don't run the app. main.ts installs it
* before the routers.
* *
* @module * @module
*/ */
@@ -18,8 +19,8 @@ interface OGMeta {
description?: string; description?: string;
imageUrl?: string; imageUrl?: string;
/** /**
* True only for real artwork — a square site icon must not claim a wide * Set for a dump's artwork only. It asks for the large card, which would
* card. * stretch a square icon or avatar.
*/ */
imageIsWide?: boolean; imageIsWide?: boolean;
url: string; url: string;
@@ -68,8 +69,8 @@ function inject(html: string, meta: OGMeta): string {
} }
/** /**
* Returns the filled `index.html` of the build, or of the working directory * Returns the filled `index.html` of dist/, or else the source one, which is
* when there is no build; null when there is neither. * all there is in development. Returns null when neither can be read.
*/ */
async function findIndexHtml(): Promise<string | null> { async function findIndexHtml(): Promise<string | null> {
for (const root of [`${Deno.cwd()}/dist`, Deno.cwd()]) { for (const root of [`${Deno.cwd()}/dist`, Deno.cwd()]) {
@@ -83,13 +84,14 @@ async function findIndexHtml(): Promise<string | null> {
} }
const DUMP_RE = /^\/dumps\/([^/]+)$/; const DUMP_RE = /^\/dumps\/([^/]+)$/;
/** A profile, with an optional `/~/<tab>` suffix so its tabs keep OG meta. */ /** Matches a profile, and its tabs under `/~/<tab>`. */
const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/; const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/;
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
/** /**
* Serves `index.html` with OG tags for a dump, user or public playlist page * Serves `index.html` with the tags of the dump, user or public playlist a
* that exists, and passes every other request on. * path names, and passes the request on when it names none that exists or
* the visitor may see. The tags' URLs are built on the request's origin.
*/ */
export async function ogMiddleware( export async function ogMiddleware(
ctx: Context, ctx: Context,
@@ -119,9 +121,8 @@ export async function ogMiddleware(
} else if (dump.richContent?.thumbnailUrl) { } else if (dump.richContent?.thumbnailUrl) {
imageUrl = dump.richContent.thumbnailUrl; imageUrl = dump.richContent.thumbnailUrl;
} }
// No artwork: fall back to our own site icon rather than the target's // Without artwork, the site's icon: the linked site's favicon is too
// favicon, which is typically too small to survive a social card (and may // small for a card, and may refuse hotlinking.
// be hotlink-protected).
meta = { meta = {
title: dump.title, title: dump.title,
description: dump.comment, description: dump.comment,
@@ -129,7 +130,7 @@ export async function ogMiddleware(
imageIsWide: !!imageUrl, imageIsWide: !!imageUrl,
url: pageUrl, url: pageUrl,
}; };
} catch { /* not found or private — serve default */ } } catch { /* Unknown, or private. */ }
} else if (userMatch) { } else if (userMatch) {
try { try {
const user = getUserByUsername(userMatch[1]); const user = getUserByUsername(userMatch[1]);
@@ -142,10 +143,11 @@ export async function ogMiddleware(
imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`, imageUrl: imageUrl ?? `${origin}/apple-touch-icon.png`,
url: pageUrl, url: pageUrl,
}; };
} catch { /* not found */ } } catch { /* Unknown. */ }
} else if (playlistMatch) { } else if (playlistMatch) {
try { try {
const playlist = getPlaylistById(playlistMatch[1]); const playlist = getPlaylistById(playlistMatch[1]);
// getPlaylistById doesn't check visibility, unlike getDump.
if (playlist.isPublic) { if (playlist.isPublic) {
const imageUrl = playlist.imageMime const imageUrl = playlist.imageMime
? `${origin}/api/playlists/${playlist.id}/image` ? `${origin}/api/playlists/${playlist.id}/image`
@@ -157,7 +159,7 @@ export async function ogMiddleware(
url: pageUrl, url: pageUrl,
}; };
} }
} catch { /* not found or private */ } } catch { /* Unknown. */ }
} }
if (!meta) return await next(); if (!meta) return await next();

View File

@@ -1,35 +1,35 @@
/** /**
* The payloads of the tokens the server signs, and their guards. * Types the payloads of session and password reset tokens, and guards them.
* `lib/jwt.ts` signs the tokens and runs the guards when it verifies one.
* *
* @module * @module
*/ */
import { isRole, type Role } from "../../shared/user.ts"; import { isRole, type Role } from "../../shared/user.ts";
/** What a session token carries. */ /**
* Signed into a session token by `createJWT`, and returned by `authenticate`
* to the auth middleware as `ctx.state.user`. By then `role` is the user's live
* role, while `username` is still the one at issue time.
*/
export interface AuthPayload { export interface AuthPayload {
/** The user's id. */
userId: string; userId: string;
/** The user's name when the token was issued. */
username: string; username: string;
/**
* The user's role; the live one replaces it once the token is authenticated.
*/
role: Role; role: Role;
/** When the token expires, in seconds since the epoch. */ /** Seconds since the epoch. */
exp: number; exp: number;
/** When the token was issued, in seconds since the epoch. */ /** Seconds since the epoch, compared with `users.tokens_valid_after`. */
iat?: number; iat?: number;
} }
/** /**
* Checks whether `obj` is an {@link AuthPayload}, and not a purpose-scoped * Checks whether `obj` is an {@link AuthPayload}. A media or reset token's
* token's payload. * payload, which has a `purpose`, never is.
*/ */
export function isAuthPayload(obj: unknown): obj is AuthPayload { export function isAuthPayload(obj: unknown): obj is AuthPayload {
return !!obj && return !!obj &&
typeof obj === "object" && typeof obj === "object" &&
// Purpose-scoped tokens (password reset, media) are never session tokens. // A media or reset token must not pass for a session token.
!("purpose" in obj) && !("purpose" in obj) &&
"userId" in obj && typeof obj.userId === "string" && "userId" in obj && typeof obj.userId === "string" &&
"username" in obj && typeof obj.username === "string" && "username" in obj && typeof obj.username === "string" &&
@@ -37,17 +37,21 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload {
"exp" in obj && typeof obj.exp === "number"; "exp" in obj && typeof obj.exp === "number";
} }
/** What a password reset token carries. */ /**
* Signed into a reset link's token by `createPasswordResetToken`, and returned
* by `verifyPasswordResetToken` to `resetPassword`.
*/
export interface PasswordResetPayload { export interface PasswordResetPayload {
/** The token's purpose, which makes it a reset token. */
purpose: "password-reset"; purpose: "password-reset";
/** The id of the user whose password it resets. */
userId: string; userId: string;
/** When the token expires, in seconds since the epoch. */ /** Seconds since the epoch. */
exp: number; exp: number;
} }
/** Checks whether `obj` is a {@link PasswordResetPayload}. */ /**
* Checks whether `obj` is a {@link PasswordResetPayload}. It doesn't check
* `exp`, which `jwtVerify` has already enforced.
*/
export function isPasswordResetPayload( export function isPasswordResetPayload(
obj: unknown, obj: unknown,
): obj is PasswordResetPayload { ): obj is PasswordResetPayload {

View File

@@ -1,6 +1,6 @@
/** /**
* The `categories` table: its row type, its guard, and its mapper to the API's * Types the rows the category service reads from `categories`, and turns them
* {@link Category}. * into the {@link Category} the category routes send.
* *
* @module * @module
*/ */
@@ -8,25 +8,19 @@
import type { SQLOutputValue } from "node:sqlite"; import type { SQLOutputValue } from "node:sqlite";
import type { Category } from "../../shared/category.ts"; import type { Category } from "../../shared/category.ts";
/** A row of the `categories` table. */ /** Returned by the category service's `SELECT`s on `categories`. */
export interface CategoryRow { export interface CategoryRow {
/** The category's id. */
id: string; id: string;
/** The category's URL slug, at the site root. */
slug: string; slug: string;
/** The category's display name. */
name: string; name: string;
/** The category's place in the list, ascending. */ /** Sorts the categories, lowest first. */
position: number; position: number;
/** When the category was created. */
created_at: string; created_at: string;
/** When the category was last changed, if ever. */
updated_at: string | null; updated_at: string | null;
/** Any other column the query selects. */
[key: string]: SQLOutputValue; [key: string]: SQLOutputValue;
} }
/** Checks whether `obj` is a {@link CategoryRow}. */ /** Checks whether a row `node:sqlite` returned is a {@link CategoryRow}. */
export function isCategoryRow(obj: unknown): obj is CategoryRow { export function isCategoryRow(obj: unknown): obj is CategoryRow {
return !!obj && typeof obj === "object" && return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" && "id" in obj && typeof obj.id === "string" &&
@@ -38,7 +32,7 @@ export function isCategoryRow(obj: unknown): obj is CategoryRow {
(typeof obj.updated_at === "string" || obj.updated_at === null); (typeof obj.updated_at === "string" || obj.updated_at === null);
} }
/** Converts a row to the API's {@link Category}. */ /** Converts `row` to the {@link Category} the category routes send. */
export function categoryRowToApi(row: CategoryRow): Category { export function categoryRowToApi(row: CategoryRow): Category {
return { return {
id: row.id, id: row.id,

View File

@@ -1,6 +1,6 @@
/** /**
* The `chat_messages` table: its row type, its guard, and its mapper to the * Maps the rows of `chat_messages` to the {@link ChatMessage} the API sends.
* API's {@link ChatMessage}. * Only `services/chat-service.ts` uses it, on the rows of its one query.
* *
* @module * @module
*/ */
@@ -9,44 +9,30 @@ import type { SQLOutputValue } from "node:sqlite";
import type { ChatMessage } from "../../shared/chat.ts"; import type { ChatMessage } from "../../shared/chat.ts";
/** /**
* A row of the `chat_messages` table, joined with its author and, when it is * Returned by chat-service's query, which joins each message to its author and
* a reply, the replied-to message and its author. * to the message it replies to. `reply_to_id` is kept when the message it
* points to is deleted, while `reply_to_author` and `reply_to_body` then read
* null.
*/ */
export interface ChatMessageRow { export interface ChatMessageRow {
/** The message's id. */
id: string; id: string;
/** The author's id. */
user_id: string; user_id: string;
/** The message, in Markdown. */
body: string; body: string;
/** When the message was sent. */
created_at: string; created_at: string;
/** When the message was last edited, if ever. */
updated_at: string | null; updated_at: string | null;
/** The author's name (joined). */
author_username: string; author_username: string;
/** The author's avatar type, null without one (joined). */
author_avatar_mime: string | null; author_avatar_mime: string | null;
/**
* The id of the message this replies to, kept even once that message is
* deleted.
*/
reply_to_id: string | null; reply_to_id: string | null;
/**
* The replied-to message's author (joined); null when there's no reply or it
* was since deleted.
*/
reply_to_author: string | null; reply_to_author: string | null;
/** /** Cut to chat-service's `REPLY_SNIPPET_LEN` characters. */
* The start of the replied-to message (joined); null when there's no reply or
* it was since deleted.
*/
reply_to_body: string | null; reply_to_body: string | null;
/** Any other column the query selects. */
[key: string]: SQLOutputValue; [key: string]: SQLOutputValue;
} }
/** Checks whether `obj` is a {@link ChatMessageRow}. */ /**
* Checks whether `obj`, a row read by chat-service, is a
* {@link ChatMessageRow}.
*/
export function isChatMessageRow(obj: unknown): obj is ChatMessageRow { export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
return !!obj && typeof obj === "object" && return !!obj && typeof obj === "object" &&
"id" in obj && typeof obj.id === "string" && "id" in obj && typeof obj.id === "string" &&
@@ -67,7 +53,10 @@ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow {
(typeof obj.reply_to_body === "string" || obj.reply_to_body === null); (typeof obj.reply_to_body === "string" || obj.reply_to_body === null);
} }
/** Converts a row to the API's {@link ChatMessage}. */ /**
* Converts a row to the {@link ChatMessage} that chat-service returns, turning
* its timestamps into Dates and its nulls into absent fields.
*/
export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage { export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage {
return { return {
id: row.id, id: row.id,

View File

@@ -1,6 +1,6 @@
/** /**
* The `comments` table: its row type, its guard, and its mapper to the API's * Types the rows the comment service reads from `comments`, and turns them into
* {@link Comment}. * the {@link Comment} the comment routes send.
* *
* @module * @module
*/ */
@@ -8,38 +8,31 @@
import type { SQLOutputValue } from "node:sqlite"; import type { SQLOutputValue } from "node:sqlite";
import type { Comment } from "../../shared/comment.ts"; import type { Comment } from "../../shared/comment.ts";
/** A row of the `comments` table, joined with its author. */ /**
* Returned by the comment service's `SELECT`s on `comments`, joined with
* `users` for the `author_` columns.
*/
export interface CommentRow { export interface CommentRow {
/** The comment's id. */
id: string; id: string;
/** The id of the dump it is posted under. */
dump_id: string; dump_id: string;
/** The author's id. */
user_id: string; user_id: string;
/** The id of the comment it replies to; null at the top level. */ /** It is null on a top-level comment. */
parent_id: string | null; parent_id: string | null;
/** The comment, in Markdown. */
body: string; body: string;
/** When the comment was posted. */
created_at: string; created_at: string;
/** When the comment was last edited, if ever. */
updated_at: string | null; updated_at: string | null;
/** /**
* 1 once deleted: the body is emptied but the row stays, so its replies * 1 once deleted. The body is emptied but the row stays, so its replies keep
* keep their place. * their place in the thread.
*/ */
deleted: number; deleted: number;
/** How many users like the comment. */
like_count: number; like_count: number;
/** The author's name (joined). */
author_username: string; author_username: string;
/** The author's avatar type, null without one (joined). */
author_avatar_mime: string | null; author_avatar_mime: string | null;
/** Any other column the query selects. */
[key: string]: SQLOutputValue; [key: string]: SQLOutputValue;
} }
/** Checks whether `obj` is a {@link CommentRow}. */ /** Checks whether a row `node:sqlite` returned is a {@link CommentRow}. */
export function isCommentRow( export function isCommentRow(
obj: unknown, obj: unknown,
): obj is CommentRow { ): obj is CommentRow {
@@ -61,7 +54,10 @@ export function isCommentRow(
obj.author_avatar_mime === null); obj.author_avatar_mime === null);
} }
/** Converts a row to the API's {@link Comment}. */ /**
* Converts `row` to the {@link Comment} the comment routes send and the
* WebSocket broadcasts.
*/
export function commentRowToApi(row: CommentRow): Comment { export function commentRowToApi(row: CommentRow): Comment {
return { return {
id: row.id, id: row.id,

View File

@@ -1,6 +1,7 @@
/** /**
* The `dumps` table: its row type, its guard, the column lists services select * Maps the `dumps` table to the API's {@link Dump}. Every service that reads
* it with, and its mappers to and from the API's {@link Dump}. * dumps selects them with the column lists here, checks each row with
* {@link isDumpRow} and converts it with {@link dumpRowToApi}.
* *
* @module * @module
*/ */
@@ -8,67 +9,69 @@
import type { SQLOutputValue } from "node:sqlite"; import type { SQLOutputValue } from "node:sqlite";
import type { Dump, RichContent } from "../../shared/dump.ts"; import type { Dump, RichContent } from "../../shared/dump.ts";
/** A row of the `dumps` table, with its live comment count. */ /**
* Read from the `dumps` table with {@link DUMP_SELECT_COLUMNS}, or written back
* from a {@link Dump} by {@link dumpApiToRow}. It lacks `url_canonical`, which
* only the duplicate lookup reads.
*/
export interface DumpRow { export interface DumpRow {
/** The dump's id. */
id: string; id: string;
/** `url` for a link, `file` for an upload. */
kind: string; kind: string;
/** The dump's title. */
title: string; title: string;
/** The poster's description, in Markdown. */
comment: string | null; comment: string | null;
/** The poster's id. */
user_id: string; user_id: string;
/** When the dump was posted. */
created_at: string; created_at: string;
/** When the dump was last edited, if ever. */
updated_at: string | null; updated_at: string | null;
/** The dump's URL slug. */
slug: string | null; slug: string | null;
/** The link exactly as submitted, for a URL dump. */ /** The link as the poster submitted it. */
url: string | null; url: string | null;
/** The link's preview, as {@link RichContent} JSON. */ /** {@link RichContent} as JSON. */
rich_content: string | null; rich_content: string | null;
/** The uploaded file's name, for a file dump. */
file_name: string | null; file_name: string | null;
/** The uploaded file's type, for a file dump. */
file_mime: string | null; file_mime: string | null;
/** The uploaded file's size in bytes, for a file dump. */
file_size: number | null; file_size: number | null;
/** How many users upvoted the dump. */
vote_count: number; vote_count: number;
/** How many comments it has, not counting deleted ones (computed). */ /** Computed by a subquery, which counts the comments that aren't deleted. */
comment_count: number; comment_count: number;
/** 1 when only the poster sees the dump. */
is_private: number; is_private: number;
/** The uploaded thumbnail's type, null without one. */
custom_thumbnail_mime: string | null; custom_thumbnail_mime: string | null;
/** Any other column the query selects. */ /** Lets a query select more columns, such as a join's. */
[key: string]: SQLOutputValue; [key: string]: SQLOutputValue;
} }
/** /**
* The stored columns of {@link DumpRow} — keep in sync with it and * Lists the stored columns of {@link DumpRow}. A column added to the table must
* {@link isDumpRow}. Services select with these instead of repeating the * be added here, to {@link DUMP_COLUMNS_ALIASED}, to {@link DumpRow} and to
* column list themselves. * {@link isDumpRow}.
*/ */
export const DUMP_COLUMNS = export const DUMP_COLUMNS =
"id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime"; "id, kind, title, slug, comment, user_id, created_at, updated_at, url, rich_content, file_name, file_mime, file_size, vote_count, is_private, custom_thumbnail_mime";
/** {@link DUMP_COLUMNS}, for a query aliasing `dumps` as `d`. */ /**
* Lists {@link DUMP_COLUMNS} for a query that aliases `dumps` as `d`. The
* playlist service selects with it and counts the comments itself.
*/
export const DUMP_COLUMNS_ALIASED = export const DUMP_COLUMNS_ALIASED =
"d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private, d.custom_thumbnail_mime"; "d.id, d.kind, d.title, d.slug, d.comment, d.user_id, d.created_at, d.updated_at, d.url, d.rich_content, d.file_name, d.file_mime, d.file_size, d.vote_count, d.is_private, d.custom_thumbnail_mime";
/** {@link DUMP_COLUMNS} and the computed `comment_count`: a full {@link DumpRow}. */ /**
* Selects a whole {@link DumpRow}, the stored columns and the computed
* `comment_count`.
*/
export const DUMP_SELECT_COLUMNS: string = `${DUMP_COLUMNS}, export const DUMP_SELECT_COLUMNS: string = `${DUMP_COLUMNS},
(SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`; (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`;
/** {@link DUMP_SELECT_COLUMNS}, for a query aliasing `dumps` as `d`. */ /**
* Selects a whole {@link DumpRow} in a query that aliases `dumps` as `d` to
* join it with other tables.
*/
export const DUMP_SELECT_COLUMNS_ALIASED: string = `${DUMP_COLUMNS_ALIASED}, export const DUMP_SELECT_COLUMNS_ALIASED: string = `${DUMP_COLUMNS_ALIASED},
(SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count`; (SELECT COUNT(*) FROM comments WHERE dump_id = d.id AND deleted = 0) as comment_count`;
/** Checks whether `obj` is a {@link DumpRow}. */ /**
* Checks whether `obj`, a row from a query, is a {@link DumpRow}. It checks
* each column's type, not that `kind` is `url` or `file`.
*/
export function isDumpRow(obj: unknown): obj is DumpRow { export function isDumpRow(obj: unknown): obj is DumpRow {
return !!obj && return !!obj &&
typeof obj === "object" && typeof obj === "object" &&
@@ -99,7 +102,10 @@ export function isDumpRow(obj: unknown): obj is DumpRow {
obj.custom_thumbnail_mime === null); obj.custom_thumbnail_mime === null);
} }
/** Converts a row to the API's {@link Dump}. */ /**
* Converts a row to the API's {@link Dump}, parsing its preview's JSON. It
* leaves `categoryIds` out: the services add them with `attachCategoryIds`.
*/
export function dumpRowToApi(row: DumpRow): Dump { export function dumpRowToApi(row: DumpRow): Dump {
return { return {
id: row.id, id: row.id,
@@ -124,7 +130,10 @@ export function dumpRowToApi(row: DumpRow): Dump {
}; };
} }
/** Converts a {@link Dump} back to a row. */ /**
* Converts a {@link Dump} back to a row, for a service that writes an edited
* dump's columns.
*/
export function dumpApiToRow(dump: Dump): DumpRow { export function dumpApiToRow(dump: Dump): DumpRow {
return { return {
id: dump.id, id: dump.id,

View File

@@ -1,5 +1,6 @@
/** /**
* The `follows` table: its row type and its guard. * Types and guards the rows of `follows`, which `follow-service.ts` reads to
* answer what a user follows.
* *
* @module * @module
*/ */
@@ -7,25 +8,22 @@
import type { SQLOutputValue } from "node:sqlite"; import type { SQLOutputValue } from "node:sqlite";
/** /**
* A row of the `follows` table: one user following either another user or a * Read by `getFollowStatus`. Exactly one of `followed_user_id` and
* playlist. * `followed_playlist_id` is set, as the table's CHECK constraint requires.
*/ */
export interface FollowRow { export interface FollowRow {
/** The follow's id. */
id: string; id: string;
/** The id of the user following. */
follower_id: string; follower_id: string;
/** The id of the user followed, when it is a user. */
followed_user_id: string | null; followed_user_id: string | null;
/** The id of the playlist followed, when it is a playlist. */
followed_playlist_id: string | null; followed_playlist_id: string | null;
/** When the follow began. */
created_at: string; created_at: string;
/** Any other column the query selects. */
[key: string]: SQLOutputValue; [key: string]: SQLOutputValue;
} }
/** Checks whether `obj` is a {@link FollowRow}. */ /**
* Checks whether `obj`, a row `getFollowStatus` selected, is a
* {@link FollowRow}.
*/
export function isFollowRow( export function isFollowRow(
obj: unknown, obj: unknown,
): obj is FollowRow { ): obj is FollowRow {

View File

@@ -1,26 +1,32 @@
/** /**
* The `invites` table: its row type and its guard. * Types and guards the rows of `invites`, which `invite-service.ts` reads to
* check an invite before sign-up.
* *
* @module * @module
*/ */
import type { SQLOutputValue } from "node:sqlite"; import type { SQLOutputValue } from "node:sqlite";
/** A row of the `invites` table. */ /**
* Stored by `createInvite`, and read back by `validateInvite` when the invite
* link is opened or used to sign up.
*/
export interface InviteRow { export interface InviteRow {
/** The invite's token, which registering redeems. */ /** The random token the invite link carries, which is the primary key. */
token: string; token: string;
/** The id of the user who made the invite. */ /** Becomes the new user's `invited_by`. */
inviter_id: string; inviter_id: string;
/** When the invite was redeemed; null while unused. */ /** Set when an account is created with the invite. */
used_at: string | null; used_at: string | null;
/** When the invite was made. */ /** Starts the 7-day window in which the invite works. */
created_at: string; created_at: string;
/** Any other column the query selects. */
[key: string]: SQLOutputValue; [key: string]: SQLOutputValue;
} }
/** Checks whether `obj` is a {@link InviteRow}. */ /**
* Checks whether `obj`, a row `validateInvite` selected, is an
* {@link InviteRow}.
*/
export function isInviteRow( export function isInviteRow(
obj: unknown, obj: unknown,
): obj is InviteRow { ): obj is InviteRow {

View File

@@ -1,6 +1,6 @@
/** /**
* The `notifications` table: its row type, its guard, and its mapper to the * Types and guards the rows of `notifications`, and converts them to the
* API's {@link Notification}. * {@link Notification} that `GET /api/notifications` answers.
* *
* @module * @module
*/ */
@@ -12,30 +12,33 @@ import type {
NotificationType, NotificationType,
} from "../../shared/notification.ts"; } from "../../shared/notification.ts";
/** A row of the `notifications` table. */ /**
* Stored by the triggers of `notification-service.ts`, and read back by
* `getNotificationsForUser`.
*/
export interface NotificationRow { export interface NotificationRow {
/** The notification's id. */
id: string; id: string;
/** The id of the user notified. */
user_id: string; user_id: string;
/** The notification's {@link NotificationType}. */ /** A {@link NotificationType}, not checked by the guard. */
type: string; type: string;
/** The notification's {@link NotificationData}, as JSON. */ /** The {@link NotificationData} as JSON, not checked by the guard. */
data: string; data: string;
/** 1 once the user has read it. */ /** 0 or 1. */
read: number; read: number;
/** When the notification was made. */
created_at: string; created_at: string;
/** /**
* What caused the notification: a second one with the same * Names the event that caused the notification. A unique index keeps a
* `(user_id, source_key)` isn't stored. Null when duplicates are allowed. * second row with the same `(user_id, source_key)` out; null allows
* duplicates.
*/ */
source_key: string | null; source_key: string | null;
/** Any other column the query selects. */
[key: string]: SQLOutputValue; [key: string]: SQLOutputValue;
} }
/** Checks whether `obj` is a {@link NotificationRow}. */ /**
* Checks whether `obj`, a row `getNotificationsForUser` selected, is a
* {@link NotificationRow}.
*/
export function isNotificationRow( export function isNotificationRow(
obj: unknown, obj: unknown,
): obj is NotificationRow { ): obj is NotificationRow {
@@ -50,7 +53,11 @@ export function isNotificationRow(
(typeof obj.source_key === "string" || obj.source_key === null); (typeof obj.source_key === "string" || obj.source_key === null);
} }
/** Converts a row to the API's {@link Notification}. */ /**
* Converts a row to the {@link Notification} the client gets.
*
* @throws {SyntaxError} When `data` isn't valid JSON.
*/
export function notificationRowToApi(row: NotificationRow): Notification { export function notificationRowToApi(row: NotificationRow): Notification {
return { return {
id: row.id, id: row.id,

View File

@@ -1,26 +1,34 @@
/** /**
* The `password_reset_tokens` table: its row type and its guard. * Types and guards the rows of `password_reset_tokens`, which
* `password-reset-service.ts` reads to make each reset link work once.
* *
* @module * @module
*/ */
import type { SQLOutputValue } from "node:sqlite"; import type { SQLOutputValue } from "node:sqlite";
/** A row of the `password_reset_tokens` table. */ /**
* Stored by `requestPasswordReset` when it emails a reset link, and read back
* by `resetPassword` when the link is used.
*/
export interface PasswordResetTokenRow { export interface PasswordResetTokenRow {
/** The reset token. */ /** The signed token itself, which the reset link carries. */
token: string; token: string;
/** The id of the user whose password it resets. */
user_id: string; user_id: string;
/** When the token stops working. */ /**
* Lets `db/startup.ts` purge old rows. The token's own `exp` is what ends
* the link.
*/
expires_at: string; expires_at: string;
/** When the token was used; null while unused. */ /** Set when the link is used, and reset to null if the reset then fails. */
used_at: string | null; used_at: string | null;
/** Any other column the query selects. */
[key: string]: SQLOutputValue; [key: string]: SQLOutputValue;
} }
/** Checks whether `obj` is a {@link PasswordResetTokenRow}. */ /**
* Checks whether `obj`, a row `resetPassword` selected, is a
* {@link PasswordResetTokenRow}.
*/
export function isPasswordResetTokenRow( export function isPasswordResetTokenRow(
obj: unknown, obj: unknown,
): obj is PasswordResetTokenRow { ): obj is PasswordResetTokenRow {

View File

@@ -1,6 +1,6 @@
/** /**
* The `playlists` table: its row type, its guard, and its mapper to the API's * Types the rows the playlist and follow services read from `playlists`, and
* {@link Playlist}. * turns them into the {@link Playlist} the API sends.
* *
* @module * @module
*/ */
@@ -9,33 +9,24 @@ import type { SQLOutputValue } from "node:sqlite";
import type { Playlist } from "../../shared/playlist.ts"; import type { Playlist } from "../../shared/playlist.ts";
/** /**
* A row of the `playlists` table. Queries may add `dump_count` and * Returned by the playlist and follow services' `SELECT`s on `playlists`. A
* `owner_username`, which {@link playlistRowToApi} carries over. * query may add `dump_count` and `owner_username` columns, which
* {@link playlistRowToApi} carries over when present.
*/ */
export interface PlaylistRow { export interface PlaylistRow {
/** The playlist's id. */
id: string; id: string;
/** The owner's id. */
user_id: string; user_id: string;
/** The playlist's title. */
title: string; title: string;
/** The playlist's URL slug. */
slug: string | null; slug: string | null;
/** The owner's description, in Markdown. */
description: string | null; description: string | null;
/** 1 when anyone may see the playlist, 0 when only its owner does. */
is_public: number; is_public: number;
/** When the playlist was created. */
created_at: string; created_at: string;
/** When the playlist was last changed, if ever. */
updated_at: string | null; updated_at: string | null;
/** The cover image's type, null without one. */
image_mime: string | null; image_mime: string | null;
/** Any other column the query selects. */
[key: string]: SQLOutputValue; [key: string]: SQLOutputValue;
} }
/** Checks whether `obj` is a {@link PlaylistRow}. */ /** Checks whether a row `node:sqlite` returned is a {@link PlaylistRow}. */
export function isPlaylistRow( export function isPlaylistRow(
obj: unknown, obj: unknown,
): obj is PlaylistRow { ): obj is PlaylistRow {
@@ -54,7 +45,7 @@ export function isPlaylistRow(
(typeof obj.image_mime === "string" || obj.image_mime === null); (typeof obj.image_mime === "string" || obj.image_mime === null);
} }
/** Converts a row to the API's {@link Playlist}. */ /** Converts `row` to the {@link Playlist} the playlist routes send. */
export function playlistRowToApi(row: PlaylistRow): Playlist { export function playlistRowToApi(row: PlaylistRow): Playlist {
return { return {
id: row.id, id: row.id,

View File

@@ -1,6 +1,7 @@
/** /**
* The `users` table: its row type, its guard, its mappers to and from the * Types and guards the rows of `users`, and converts them to the {@link User}
* stored {@link User}, and the views of a user the API sends. * that `user-service.ts` returns. A route turns a `User` into what it may send
* with {@link toPublicUser} or {@link toSelfUser}.
* *
* @module * @module
*/ */
@@ -13,56 +14,65 @@ import {
type SelfUser, type SelfUser,
} from "../../shared/user.ts"; } from "../../shared/user.ts";
/** A user as stored: what they see of themselves, plus the password hash. */ /**
* Returned by the lookups of `user-service.ts`. It must never be sent as is:
* it carries the password hash.
*/
export interface User extends SelfUser { export interface User extends SelfUser {
/** The scrypt hash of the password, as `hash.salt`. */ /** From `hashPassword`, as `hash.salt`. */
passwordHash: string; passwordHash: string;
} }
/** Returns what anyone may see of `user`: no hash, no email. */ /**
* Returns what anyone may see of `user`, without the hash and the email. Every
* route that sends another user's profile goes through it.
*/
export function toPublicUser( export function toPublicUser(
{ passwordHash: _, email: _email, ...user }: User, { passwordHash: _, email: _email, ...user }: User,
): PublicUser { ): PublicUser {
return user; return user;
} }
/** Returns what `user` sees of themselves: everything but the hash. */ /**
* Returns what `user` sees of themselves, without the hash, for the routes
* that answer the caller's own account.
*/
export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser { export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser {
return user; return user;
} }
/** A row of the `users` table. */ /**
* Selected by `user-service.ts` and `follow-service.ts`, and converted by
* {@link userRowToApi}. It leaves out `chat_read_at` and `tokens_valid_after`,
* which only the chat's read state and `getSessionState` query.
*/
export interface UserRow { export interface UserRow {
/** The user's id. */
id: string; id: string;
/** The user's name, unique. */
username: string; username: string;
/** The scrypt hash of the password, as `hash.salt`. */
password_hash: string; password_hash: string;
/** The user's role. */
role: Role; role: Role;
/** When the user registered. */
created_at: string; created_at: string;
/** When the profile was last changed, if ever. */ /** Bumped by any profile or avatar change. */
updated_at: string | null; updated_at: string | null;
/** The avatar's type, null without one. */ /** Null without an avatar. */
avatar_mime: string | null; avatar_mime: string | null;
/** The user's profile text, in Markdown. */ /** Markdown. */
description: string | null; description: string | null;
/** The id of the user whose invite they registered with. */ /** Null for the seeded admin, who had no inviter. */
invited_by: string | null; invited_by: string | null;
/** /**
* The inviter's name; present only when the query joins it (`LEFT JOIN users * Joined from the inviter's row rather than stored, and undefined when the
* i ON i.id = u.invited_by`). * query doesn't join it. The guard doesn't check it.
*/ */
invited_by_username: string | null; invited_by_username: string | null;
/** The user's email address. */
email: string; email: string;
/** Any other column the query selects. */
[key: string]: SQLOutputValue; [key: string]: SQLOutputValue;
} }
/** Checks whether `obj` is a {@link UserRow}. */ /**
* Checks whether `obj`, a row selected with the columns of {@link UserRow}, is
* one.
*/
export function isUserRow(obj: unknown): obj is UserRow { export function isUserRow(obj: unknown): obj is UserRow {
return !!obj && return !!obj &&
typeof obj === "object" && typeof obj === "object" &&
@@ -82,7 +92,7 @@ export function isUserRow(obj: unknown): obj is UserRow {
"email" in obj && typeof obj.email === "string"; "email" in obj && typeof obj.email === "string";
} }
/** Converts a row to the stored {@link User}. */ /** Converts a row to a {@link User}, turning nulls into undefined. */
export function userRowToApi(row: UserRow): User { export function userRowToApi(row: UserRow): User {
return { return {
id: row.id, id: row.id,
@@ -100,7 +110,10 @@ export function userRowToApi(row: UserRow): User {
}; };
} }
/** Converts a {@link User} back to a row, without the join-only columns. */ /**
* Converts a {@link User} back to a row, for `updateUser`. A `User` has no
* inviter id, so `invited_by` comes out null: don't write it back.
*/
export function userApiToRow(user: User): UserRow { export function userApiToRow(user: User): UserRow {
return { return {
id: user.id, id: user.id,

View File

@@ -1,10 +1,9 @@
/** /**
* Resolves a Bandcamp page to its streamable tracks, at play time. * Resolves a Bandcamp page to its streamable tracks when the native player
* * starts it, for `GET /api/bandcamp/tracks`. The stream URLs are signed and
* Bandcamp's signed stream URLs expire 24h after the page fetch, so they can * expire 24 hours after the page is fetched, so unlike the embed URL they
* never be baked into `rich_content` at dump-creation time the way `embedUrl` * can't be stored in the dump's preview. The browser plays them from bcbits
* is. This service is the play-time counterpart: the frontend asks for a page, * directly.
* gets a whole tracklist back, and plays the mp3s directly from bcbits.
* *
* @module * @module
*/ */
@@ -16,19 +15,18 @@ import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts";
import type { Tralbum } from "../../shared/player.ts"; import type { Tralbum } from "../../shared/player.ts";
/** /**
* How long a resolved page is cached: comfortably inside the 24h signature * Half the signatures' life, so a cached URL still has at least 12 hours left
* life, so a cache hit is never a URL that is about to expire mid-listen. * when it is handed out.
*/ */
const TTL_MS = 12 * 60 * 60 * 1000; const TTL_MS = 12 * 60 * 60 * 1000;
/** The most pages cached; the oldest write goes first. */ /** The oldest write goes first. */
const MAX_ENTRIES = 200; const MAX_ENTRIES = 200;
/** Resolved pages, by {@link cacheKey}. */
const cache = new Map<string, Tralbum>(); const cache = new Map<string, Tralbum>();
/** /**
* Returns the cache key of `url`: a page is the same page regardless of query, * Returns `url` without its query, fragment and trailing slashes, and with its
* fragment or case. * host in lowercase.
*/ */
function cacheKey(url: string): string { function cacheKey(url: string): string {
try { try {
@@ -61,10 +59,10 @@ function writeCache(key: string, value: Tralbum): void {
} }
/** /**
* Fetches a Bandcamp page and returns its tracklist, cached. * Returns the tracklist of the Bandcamp page `pageUrl`, from the cache when it
* * was resolved in the last 12 hours. `force` skips the cache: the player sets
* `force` bypasses the cache — used by the client's re-resolve path, so a * it when it re-resolves a queue whose URLs expired or are about to, which a
* cached entry whose URLs have started 403ing can't keep being handed back. * cached entry could hand back.
* *
* @throws {APIException} 400 when `pageUrl` isn't a public bandcamp.com URL, * @throws {APIException} 400 when `pageUrl` isn't a public bandcamp.com URL,
* 502 when Bandcamp can't be reached or doesn't answer with a page, 404 when * 502 when Bandcamp can't be reached or doesn't answer with a page, 404 when
@@ -112,9 +110,8 @@ export async function resolveBandcamp(
"Could not reach Bandcamp", "Could not reach Bandcamp",
); );
} }
// Every path out of here that doesn't read the body has to release it, or the // A body left unread keeps its connection open until GC, and this endpoint
// connection stays open until GC. This endpoint is unauthenticated: a caller // is public: every early return cancels it.
// hammering random /track/<slug> URLs would otherwise leak one body a request.
if (!res.ok) { if (!res.ok) {
await res.body?.cancel(); await res.body?.cancel();
throw new APIException( throw new APIException(
@@ -134,9 +131,8 @@ export async function resolveBandcamp(
const parsed = parseTralbum(await readTextCapped(res), pageUrl); const parsed = parseTralbum(await readTextCapped(res), pageUrl);
if (!parsed) { if (!parsed) {
// Covers "no data-tralbum", "hasAudio: false" and preorder-only releases // No data, no audio or nothing streamable yet (a preorder). The client
// with nothing streamable — all cases where the client falls back to the // plays the embed instead.
// iframe embed rather than showing an error.
throw new APIException( throw new APIException(
APIErrorCode.NOT_FOUND, APIErrorCode.NOT_FOUND,
404, 404,

View File

@@ -1,18 +1,8 @@
/** /**
* Parser for Bandcamp's `data-tralbum` attribute. * Reads a release out of a Bandcamp page, for `bandcamp-streams.ts`. Every
* * track and album page carries the whole release, stream URLs included, as
* Every Bandcamp track and album page carries the whole release — titles, * HTML-escaped JSON in a `data-tralbum` attribute, so one fetch covers an
* durations and signed mp3 stream URLs — as one HTML-escaped JSON blob on a * album. The format is undocumented, so every field is treated as optional.
* `data-tralbum` attribute. One fetch of an album page therefore yields every
* track's stream URL; there is no per-track request.
*
* The stream URLs are signed and expire 24h after the page was fetched, which
* is why nothing here is ever persisted to `rich_content` — see
* `api/routes/bandcamp.ts` for the resolve-at-play-time endpoint.
*
* The shape is undocumented, so every field is treated as optional and the
* whole parse is best-effort: a `null` return means "not a Bandcamp page",
* which callers turn into a fallback to the iframe embed.
* *
* @module * @module
*/ */
@@ -20,19 +10,16 @@
import type { Tralbum, TralbumTrack } from "../../shared/player.ts"; import type { Tralbum, TralbumTrack } from "../../shared/player.ts";
/** /**
* The hostnames Bandcamp serves its own sites from, anchored at the end so that `bandcamp.com.example.org` cannot pass. Artists on custom * Matches the hostnames `bandcamp-streams.ts` accepts: bandcamp.com and its
* domains do not match this and keep using the embed. * subdomains, and not `bandcamp.com.example.org`. An artist on a custom domain
* doesn't match, and the player falls back to the embed.
*/ */
export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/; export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/;
/** /**
* Decodes HTML entities in a single pass. * Decodes HTML entities in a single pass. `html.ts` decodes `&amp;` before
* * `&quot;`, which turns a title's literal `&quot;` (escaped as `&amp;quot;`)
* The single pass is the point: `decodeHtmlEntities` in remote/html.ts * into a bare `"` that breaks the JSON.
* replaces `&amp;` before `&quot;`, so an escaped-ampersand-then-quot sequence
* (`&amp;quot;`, i.e. the literal text `&quot;` inside a title) decodes twice
* and yields a bare `"` — which lands inside the JSON string and breaks the
* parse. Matching every entity form in one regex means each is replaced once.
*/ */
function decodeEntitiesOnce(input: string): string { function decodeEntitiesOnce(input: string): string {
const named: Record<string, string> = { const named: Record<string, string> = {
@@ -53,7 +40,7 @@ function decodeEntitiesOnce(input: string): string {
); );
} }
/** Returns the URL of the large square artwork numbered `artId`. */ /** Returns the URL of the artwork `artId`; undefined when it isn't numeric. */
function artworkUrl(artId: unknown): string | undefined { function artworkUrl(artId: unknown): string | undefined {
if (typeof artId !== "number" && typeof artId !== "string") return undefined; if (typeof artId !== "number" && typeof artId !== "string") return undefined;
const id = String(artId).trim(); const id = String(artId).trim();
@@ -63,8 +50,9 @@ function artworkUrl(artId: unknown): string | undefined {
} }
/** /**
* Pulls the `data-tralbum` blob out of a Bandcamp page and normalises it. * Returns the release on the Bandcamp page `html`, fetched from `sourceUrl`;
* Returns null when the page has no blob, no audio, or nothing parseable. * null when the page has no release data, no audio or no streamable track.
* Stream URLs outside bcbits.com are dropped.
*/ */
export function parseTralbum(html: string, sourceUrl: string): Tralbum | null { export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
const match = html.match(/\bdata-tralbum=(["'])([\s\S]*?)\1/); const match = html.match(/\bdata-tralbum=(["'])([\s\S]*?)\1/);
@@ -84,8 +72,8 @@ export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
const t = (entry ?? {}) as Record<string, unknown>; const t = (entry ?? {}) as Record<string, unknown>;
const file = (t.file ?? {}) as Record<string, unknown>; const file = (t.file ?? {}) as Record<string, unknown>;
const mp3 = typeof file["mp3-128"] === "string" ? file["mp3-128"] : null; const mp3 = typeof file["mp3-128"] === "string" ? file["mp3-128"] : null;
// Only ever hand out bcbits URLs. Without this the endpoint would relay // A hostile page could put any host here, and the endpoint would hand it
// whatever host a hostile page put in the blob straight to the browser. // to the browser.
const streamUrl = mp3 && isBcbitsUrl(mp3) ? mp3 : null; const streamUrl = mp3 && isBcbitsUrl(mp3) ? mp3 : null;
return { return {
index, index,
@@ -117,7 +105,6 @@ export function parseTralbum(html: string, sourceUrl: string): Tralbum | null {
}; };
} }
/** Checks whether `url` is an https URL on Bandcamp's bcbits.com CDN. */
function isBcbitsUrl(url: string): boolean { function isBcbitsUrl(url: string): boolean {
try { try {
const { protocol, hostname } = new URL(url); const { protocol, hostname } = new URL(url);

View File

@@ -1,7 +1,8 @@
/** /**
* The guarded fetch every request to another site goes through: each hop must * Fetches pages and images from other sites for link previews, the image proxy
* resolve to a public address, the whole exchange has a deadline, and pages are * and the Bandcamp resolver, without letting a user-supplied URL reach the
* read up to a cap. * server's own network. Each hop must resolve to a public address, the whole
* exchange has a deadline, and pages are read up to a cap.
* *
* @module * @module
*/ */
@@ -20,15 +21,11 @@ const FETCH_HEADERS = {
const MAX_REDIRECTS = 5; const MAX_REDIRECTS = 5;
/** /**
* Fetches `url` through a `curl --insecure` subprocess and returns a minimal * Fetches `url` with `curl --insecure`, for a site whose TLS certificate Deno
* Response. * rejects. Returns a Response carrying only the `Location` and `Content-Type`
* Used as a last resort when Deno's rustls rejects the server's TLS certificate * headers, or null when curl is missing or fails. The connection is pinned to
* due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm). * `address`, the one {@link resolvePublicAddress} checked, and redirects come
* Returns null if curl is unavailable or exits non-zero. * back unfollowed so that {@link fetchWithTimeout} checks each hop.
*
* `address` pins the connection to the IP already checked by
* resolvePublicAddress, and curl doesn't follow redirects itself: a 3xx comes
* back with its Location so fetchWithTimeout can check the next hop too.
*/ */
async function fetchViaCurl( async function fetchViaCurl(
url: URL, url: URL,
@@ -49,6 +46,8 @@ async function fetchViaCurl(
`${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`, `${url.hostname.replace(/^\[(.*)\]$/, "$1")}:${port}:${pinned}`,
"--max-time", "--max-time",
String(Math.ceil(timeoutMs / 1000)), String(Math.ceil(timeoutMs / 1000)),
// curl fails on a larger file rather than stopping at a cap, so this is
// looser than MAX_PAGE_BYTES, where readTextCapped still cuts.
"--max-filesize", "--max-filesize",
String(2 * MAX_PAGE_BYTES), String(2 * MAX_PAGE_BYTES),
"--user-agent", "--user-agent",
@@ -81,8 +80,8 @@ async function fetchViaCurl(
if (!contentType.startsWith("text/")) { if (!contentType.startsWith("text/")) {
return new Response(bytes, { status: statusCode, headers }); return new Response(bytes, { status: statusCode, headers });
} }
// Decode using the charset declared in the Content-Type header so that // readTextCapped decodes as UTF-8, so a page in another charset is
// pages served in ISO-8859-1, windows-1252, etc. are read correctly. // re-encoded here.
const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8"; const charset = /charset=([\w-]+)/i.exec(contentType)?.[1] ?? "utf-8";
let bodyText: string; let bodyText: string;
try { try {
@@ -100,15 +99,15 @@ async function fetchViaCurl(
} }
/** /**
* The most bytes of a fetched page that are read. Metadata lives in the <head>, * The default cap of {@link readTextCapped}. The metadata previews read is in
* and a "page" past this is a download, not something to scrape. * the `<head>`, and a page past this is a download.
*/ */
export const MAX_PAGE_BYTES = 4 * 1024 * 1024; export const MAX_PAGE_BYTES = 4 * 1024 * 1024;
/** /**
* Hands `body` on, and clears the request's deadline once it has been read to * Wraps `body` so that the request's deadline `timer` is cleared only once the
* the end or cancelled. Until then the deadline still applies: a server that * body is read to the end or cancelled. A server that answers at once and then
* answers at once and then drips its body can't hold the request open. * drips its body is still cut off.
*/ */
function withDeadline( function withDeadline(
body: ReadableStream<Uint8Array>, body: ReadableStream<Uint8Array>,
@@ -138,8 +137,9 @@ function withDeadline(
} }
/** /**
* Reads a response's body as text, stopping at `maxBytes` (the rest is * Reads the body of `res` as UTF-8 text, up to `maxBytes`; the rest is
* cancelled, never downloaded). * cancelled, never downloaded. The providers and the Bandcamp and YouTube
* resolvers read pages through it.
*/ */
export async function readTextCapped( export async function readTextCapped(
res: Response, res: Response,
@@ -170,7 +170,10 @@ export async function readTextCapped(
return new TextDecoder().decode(bytes); return new TextDecoder().decode(bytes);
} }
/** Makes one request, following no redirects. The timeout covers the body too. */ /**
* Makes one request to `url`, following no redirects, with a deadline that
* covers the body too.
*/
async function fetchOnce( async function fetchOnce(
url: URL, url: URL,
address: string, address: string,
@@ -191,10 +194,9 @@ async function fetchOnce(
throw err; throw err;
} }
// Deno/rustls rejected the server's TLS certificate (expired, self-signed, // Deno can't be told to skip certificate checks for one request, so a
// or an unsupported signature algorithm) and offers no way to override // site with an expired, self-signed or unusually signed certificate is
// verification programmatically. Fall back to a `curl --insecure` // fetched with curl instead.
// subprocess, which uses its own TLS stack.
const curlRes = await fetchViaCurl(url, address, timeoutMs); const curlRes = await fetchViaCurl(url, address, timeoutMs);
if (curlRes) return curlRes; if (curlRes) return curlRes;
@@ -212,10 +214,10 @@ async function fetchOnce(
} }
/** /**
* Fetches a user-supplied URL, following up to five redirects. Every hop — the * Fetches a user-supplied URL, following up to five redirects, each of which
* URL itself and each redirect — must resolve to a public address (see * must resolve to a public address too. `timeoutMs` bounds each hop, body
* {@link resolvePublicAddress}), so a link or a redirect can't point the * included. The caller must read or cancel the body, or its connection stays
* server at its own network. * open.
* *
* @throws {TypeError} When a hop isn't http(s), there are too many redirects, * @throws {TypeError} When a hop isn't http(s), there are too many redirects,
* or the request fails. * or the request fails.
@@ -242,16 +244,17 @@ export async function fetchWithTimeout(
} }
/** /**
* Checks whether `raw` is an http(s) URL a dump may link to: not on a private * Checks whether `raw` is an http(s) URL on a host that isn't private, or a
* host, unless it is one of this site's own pages. * link to one of this site's own pages. Routes and services check a URL with it
* before storing, previewing or proxying it; it does no DNS lookup, which
* {@link fetchWithTimeout} does.
*/ */
export function isValidHttpUrl(raw: string): boolean { export function isValidHttpUrl(raw: string): boolean {
try { try {
const u = new URL(raw); const u = new URL(raw);
if (u.protocol !== "http:" && u.protocol !== "https:") return false; if (u.protocol !== "http:" && u.protocol !== "https:") return false;
// This site's own pages are fine even on a private host (e.g. localhost in // This site may run on a private host (localhost in development), and the
// development): selfProvider reads them from the DB, no HTTP involved. // self provider reads its pages from the database without fetching them.
// Every actual fetch is checked again, after DNS, in fetchWithTimeout.
return !isPrivateHostname(u) || isSelfUrl(u); return !isPrivateHostname(u) || isSelfUrl(u);
} catch { } catch {
return false; return false;

View File

@@ -1,11 +1,17 @@
/** /**
* Scraping helpers: the metadata a link preview reads from a page's HTML — * Reads a link preview's metadata out of a page's HTML with regular
* meta and OG tags, the title, JSON-LD, images, icons and brand colors. * expressions, without parsing the document. The generic provider uses most of
* it; the Bandcamp, SoundCloud and YouTube providers read only OG tags. Each
* function returns undefined when the page doesn't say.
* *
* @module * @module
*/ */
/** Decodes the named and numeric HTML entities in `str`. */ /**
* Decodes the five XML entities and numeric ones in `str`. `&amp;` goes first,
* so `&amp;quot;` decodes twice; `bandcamp-tralbum.ts`, which decodes JSON,
* has its own single-pass decoder.
*/
function decodeHtmlEntities(str: string): string { function decodeHtmlEntities(str: string): string {
return str return str
.replace(/&amp;/gi, "&") .replace(/&amp;/gi, "&")
@@ -21,9 +27,9 @@ function decodeHtmlEntities(str: string): string {
} }
/** /**
* Returns the `content` of the first `<meta>` tag whose `attr` equals `value` * Returns the `content` of the first `<meta>` tag whose `attr` equals `value`,
* (either attribute ordering). Each tag is matched in isolation so * in whichever order the attributes come. Each tag is matched on its own, so
* the `content` capture can never span across tag boundaries. * the capture can't run into the next tag.
*/ */
function extractMetaContent( function extractMetaContent(
html: string, html: string,
@@ -44,7 +50,7 @@ function extractMetaContent(
return undefined; return undefined;
} }
/** Returns the content of `<meta property="og:<tag>">`. */ /** Returns the content of `<meta property="og:<tag>">`, entities decoded. */
export function extractOgTag( export function extractOgTag(
html: string, html: string,
tag: string, tag: string,
@@ -53,8 +59,8 @@ export function extractOgTag(
} }
/** /**
* Returns the content of `<meta name="…" content="…">` (both attribute * Returns the content of `<meta name="<name>">`, entities decoded. Twitter
* orderings). * card tags use `name`, not `property`.
*/ */
export function extractMetaName( export function extractMetaName(
html: string, html: string,
@@ -63,20 +69,16 @@ export function extractMetaName(
return extractMetaContent(html, "name", name); return extractMetaContent(html, "name", name);
} }
/** Returns the text content of the `<title>` element. */ /** Returns the text of the `<title>` element, trimmed and entities decoded. */
export function extractPageTitle(html: string): string | undefined { export function extractPageTitle(html: string): string | undefined {
const match = html.match(/<title[^>]*>([^<]+)<\/title>/i); const match = html.match(/<title[^>]*>([^<]+)<\/title>/i);
return match ? decodeHtmlEntities(match[1].trim()) : undefined; return match ? decodeHtmlEntities(match[1].trim()) : undefined;
} }
// ── Brand color helpers ───────────────────────────────────────────────────────
/** /**
* Returns the page's `theme-color`. * Returns the page's `theme-color`, as written: pass it to
* * {@link normalizeCssColor}. Sites often ship one per color scheme with the
* Sites routinely ship the tag twice, scoped by `media`, and often list the * dark one first, so the unscoped tag wins, then the light one, then any.
* dark-scheme one first — so unlike `extractMetaName` this can't just take the
* first match. Prefer the unscoped tag, then the light-scheme one, then any.
*/ */
export function extractThemeColor(html: string): string | undefined { export function extractThemeColor(html: string): string | undefined {
const tagRe = /<meta\b[^>]*>/gi; const tagRe = /<meta\b[^>]*>/gi;
@@ -100,7 +102,10 @@ export function extractThemeColor(html: string): string | undefined {
return unscoped ?? light ?? any; return unscoped ?? light ?? any;
} }
/** Returns the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */ /**
* Returns the `color` of `<link rel="mask-icon">`, Safari's pinned-tab icon,
* as written. The generic provider's last resort for a brand color.
*/
export function extractMaskIconColor(html: string): string | undefined { export function extractMaskIconColor(html: string): string | undefined {
const linkRe = /<link[^>]+>/gi; const linkRe = /<link[^>]+>/gi;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
@@ -114,8 +119,8 @@ export function extractMaskIconColor(html: string): string | undefined {
} }
/** /**
* The handful of named colors that actually show up in `theme-color`. The full * The named colors sites actually put in `theme-color`; the rest of CSS's names
* 148-name table isn't worth carrying for the long tail. * aren't worth carrying.
*/ */
const NAMED_COLORS: Record<string, string> = { const NAMED_COLORS: Record<string, string> = {
black: "#000000", black: "#000000",
@@ -145,9 +150,10 @@ function clampByte(n: number): number {
} }
/** /**
* Normalizes a CSS color to canonical lowercase `#rrggbb`, or `undefined` when * Returns `raw` as a lowercase `#rrggbb`, from a hex, `rgb()`, `rgba()` or
* it isn't one of the forms sites actually use. Alpha is dropped — the color is * common named color; undefined for any other form. The generic provider
* only ever used as a tint over the app's own surface. * stores it as the preview's `accentColor`, a tint over the app's own surface,
* so alpha is dropped.
*/ */
export function normalizeCssColor(raw: string | undefined): string | undefined { export function normalizeCssColor(raw: string | undefined): string | undefined {
if (!raw) return undefined; if (!raw) return undefined;
@@ -182,17 +188,17 @@ export function normalizeCssColor(raw: string | undefined): string | undefined {
return undefined; return undefined;
} }
// ── JSON-LD helpers ─────────────────────────────────────────────────────────── /**
* Returned to the generic provider by {@link extractJsonLd}, which ranks each
/** What a page's JSON-LD says about it. */ * field below the page's OG and Twitter tags.
*/
export interface JsonLdResult { export interface JsonLdResult {
/** The node's `name` or `headline`. */ /** From `name`, or else `headline`. */
title?: string; title?: string;
/** The node's `description`. */
description?: string; description?: string;
/** /**
* The node's `image`, `thumbnailUrl` or `thumbnail`, when it is an absolute * From `image`, or else `thumbnailUrl` or `thumbnail`, and only when it is
* URL. * an absolute URL.
*/ */
thumbnailUrl?: string; thumbnailUrl?: string;
} }
@@ -237,9 +243,10 @@ function ldExtractNode(data: unknown): JsonLdResult {
} }
/** /**
* Parses every `<script type="application/ld+json">` block and returns the first * Returns the first JSON-LD node in `html` that has a title or an image,
* node that yields a title or image. Handles `@graph`, arrays, and the common * looking through `@graph` and arrays; empty when there is none. An image may
* `image` shapes (string, string[], ImageObject). * be a URL, a list of them or an `ImageObject`. Blocks that aren't valid JSON
* are skipped.
*/ */
export function extractJsonLd(html: string): JsonLdResult { export function extractJsonLd(html: string): JsonLdResult {
const pattern = const pattern =
@@ -249,14 +256,15 @@ export function extractJsonLd(html: string): JsonLdResult {
try { try {
const result = ldExtractNode(JSON.parse(match[1])); const result = ldExtractNode(JSON.parse(match[1]));
if (result.title || result.thumbnailUrl) return result; if (result.title || result.thumbnailUrl) return result;
} catch { /* invalid JSON — skip */ } } catch { /* invalid JSON: try the next block */ }
} }
return {}; return {};
} }
/** /**
* Returns the `src` of the first `<img>` whose declared width or height is at * Returns the absolute URL of the first `<img>` whose declared width and height
* least `minSize` pixels (default 200). Skips data URIs. Resolves relative URLs. * are both at least `minSize` pixels; an image without them doesn't count.
* Data URIs are skipped.
*/ */
export function extractLargeImage( export function extractLargeImage(
html: string, html: string,
@@ -283,13 +291,12 @@ export function extractLargeImage(
} }
/** /**
* Collects all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, ranks * Returns the absolute URL of the page's largest icon, among its `icon` and
* them by declared size (largest wins), and returns the best resolved URL. * `apple-touch-icon` links, by declared `sizes`; the first one when none
* Falls back to the first match when no `sizes` attribute is present. * declares a size. The generic provider stores it as the preview's
* `faviconUrl`, which the thumbnail placeholder draws.
* *
* SVG icons are ranked above everything: they carry no `sizes` attribute, so * An SVG icon wins over any size: it declares none, yet scales to any.
* they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to
* whatever size the placeholder renders them at.
*/ */
export function extractBestIcon( export function extractBestIcon(
html: string, html: string,
@@ -330,7 +337,7 @@ export function extractBestIcon(
return candidates[0].href; return candidates[0].href;
} }
/** Filename tokens that mark an image as UI chrome rather than content. */ /** Filename words that mark an image as page furniture rather than content. */
const UI_IMAGE_KEYWORDS = new Set([ const UI_IMAGE_KEYWORDS = new Set([
"logo", "logo",
"icon", "icon",
@@ -350,24 +357,22 @@ const UI_IMAGE_KEYWORDS = new Set([
]); ]);
/** /**
* Checks whether `src` looks like UI chrome: an SVG, or a filename with one of * Checks whether `src` looks like page furniture: an SVG, or a filename with
* the {@link UI_IMAGE_KEYWORDS}. * one of the {@link UI_IMAGE_KEYWORDS} between its `-`, `_` or `.`.
*/ */
function isUiImage(src: string): boolean { function isUiImage(src: string): boolean {
if (/\.svg(\?|$)/i.test(src)) return true; if (/\.svg(\?|$)/i.test(src)) return true;
const filename = src.split("?")[0].split("/").pop() ?? ""; const filename = src.split("?")[0].split("/").pop() ?? "";
const baseName = filename.replace(/\.[^.]+$/, ""); // strip extension const baseName = filename.replace(/\.[^.]+$/, "");
// Split on common filename separators (-, _, .) and check each token
return baseName.toLowerCase().split(/[-_.]/).some((t) => return baseName.toLowerCase().split(/[-_.]/).some((t) =>
UI_IMAGE_KEYWORDS.has(t) UI_IMAGE_KEYWORDS.has(t)
); );
} }
/** /**
* Returns the `src` of the first `<img>` that looks like content rather than UI * Returns the absolute URL of the first `<img>` that looks like content rather
* chrome. Skips SVGs, data URIs, and images whose filename tokens match common * than a logo, icon or other page furniture. Data URIs are skipped. The generic
* icon/logo/nav patterns (logo, icon, sprite, etc.). * provider's last resort for a thumbnail.
* Resolves relative and protocol-relative URLs.
*/ */
export function extractFirstContentImage( export function extractFirstContentImage(
html: string, html: string,
@@ -390,8 +395,8 @@ export function extractFirstContentImage(
} }
/** /**
* Returns the `href` of the first `<link rel="…">` whose rel contains * Returns the absolute URL of the first `<link>` whose `rel` contains
* `relFragment`, resolved to an absolute URL using `baseUrl`. * `relFragment`. No provider calls it; only its tests do.
*/ */
export function extractLinkHref( export function extractLinkHref(
html: string, html: string,

View File

@@ -1,17 +1,21 @@
/** /**
* The image proxy's fetch and cache: remote thumbnails are served through * Fetches and caches the remote images `GET /api/proxy-image` serves. The
* this site, so viewers' browsers never contact the hosts. * frontend routes a thumbnail through it when the image is on plain http,
* which an https page would block, or when loading it directly failed, often
* to hotlink protection.
* *
* @module * @module
*/ */
import { fetchWithTimeout } from "./fetch.ts"; import { fetchWithTimeout } from "./fetch.ts";
/** An image fetched for the proxy. */ /**
* Returned to `GET /api/proxy-image` by {@link fetchProxiedImage}, which sends
* it back under `contentType`.
*/
export interface ProxiedImage { export interface ProxiedImage {
/** The image's bytes. */
bytes: Uint8Array; bytes: Uint8Array;
/** The image's MIME type, as its host sent it. */ /** One of the raster types, without parameters. */
contentType: string; contentType: string;
} }
@@ -26,14 +30,12 @@ const PROXYABLE_IMAGE_TYPES = new Set([
"image/vnd.microsoft.icon", "image/vnd.microsoft.icon",
]); ]);
/** How long a proxied image is served from the cache. */
const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000; const IMAGE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/** The cache's size bound, in image bytes. */
const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024; const IMAGE_CACHE_MAX_BYTES = 32 * 1024 * 1024;
/** /**
* Proxied images, by URL: every viewer of a feed asks for the same thumbnails, * Proxied images, by URL, since every viewer of a feed asks for the same
* which were fetched from their hosts again for each one. Bounded by total * thumbnails. The least recently used go first, and the Map's order is that
* size; the least recently used go first. Map order is recency order. * of use.
*/ */
const imageCache = new Map< const imageCache = new Map<
string, string,
@@ -51,7 +53,7 @@ function readImageCache(
imageCacheBytes -= hit.bytes.byteLength; imageCacheBytes -= hit.bytes.byteLength;
return undefined; return undefined;
} }
imageCache.set(url, hit); // most recently used imageCache.set(url, hit); // Moves it to the end: most recently used.
return hit; return hit;
} }
@@ -77,7 +79,7 @@ function writeImageCache(
imageCacheBytes += bytes.byteLength; imageCacheBytes += bytes.byteLength;
} }
/** Forgets every proxied image (for the tests). */ /** Empties the cache, for the tests' reset between cases. */
export function clearImageCache(): void { export function clearImageCache(): void {
imageCache.clear(); imageCache.clear();
imageCacheBytes = 0; imageCacheBytes = 0;
@@ -85,9 +87,11 @@ export function clearImageCache(): void {
/** /**
* Returns the image at `url`, from the cache or else from its host; null when * Returns the image at `url`, from the cache or else from its host; null when
* the host answers with something that isn't a raster image of 5 MB at most. * the host answers with anything but a raster image of 5 MB at most. The route
* checks `url` with `isValidHttpUrl` first.
* *
* @throws {Error} When the host can't be reached (see {@link fetchWithTimeout}). * @throws {Error} When the host can't be reached, as
* {@link fetchWithTimeout} reports it.
*/ */
export async function fetchProxiedImage( export async function fetchProxiedImage(
url: string, url: string,
@@ -96,9 +100,8 @@ export async function fetchProxiedImage(
if (cached) return cached; if (cached) return cached;
const res = await fetchWithTimeout(url, 8000); const res = await fetchWithTimeout(url, 8000);
// Raster formats only. The bytes go out under this site's origin, so an // Raster formats only: the bytes go out under this site's origin, where an
// SVG (which can carry script) or anything else a browser might execute // SVG, which can carry script, would run if opened directly.
// would run with the app's origin if opened directly.
const contentType = (res.headers.get("content-type") ?? "") const contentType = (res.headers.get("content-type") ?? "")
.split(";")[0].trim().toLowerCase(); .split(";")[0].trim().toLowerCase();
if (!PROXYABLE_IMAGE_TYPES.has(contentType)) { if (!PROXYABLE_IMAGE_TYPES.has(contentType)) {
@@ -106,7 +109,7 @@ export async function fetchProxiedImage(
return null; return null;
} }
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB const MAX_SIZE = 5 * 1024 * 1024;
const contentLength = Number(res.headers.get("content-length") ?? "0"); const contentLength = Number(res.headers.get("content-length") ?? "0");
if (contentLength > MAX_SIZE) { if (contentLength > MAX_SIZE) {
await res.body?.cancel(); await res.body?.cancel();

View File

@@ -1,5 +1,6 @@
/** /**
* The Bandcamp link provider: previews from a release page's OG tags. * Previews Bandcamp links from their page's OG tags, for
* `rich-content-service.ts`.
* *
* @module * @module
*/ */
@@ -11,7 +12,12 @@ import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/; const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/;
/** The provider for bandcamp.com pages, with the embed player from `og:video`. */ /**
* Claims bandcamp.com and its subdomains, and `bandcamp.com.example.org` too,
* since the pattern isn't anchored at the end. Takes the embed player's URL
* from the page's `og:video`. The native player fetches the page's streams
* itself at play time, through `GET /api/bandcamp/tracks`.
*/
export const bandcampProvider: RichContentProvider = { export const bandcampProvider: RichContentProvider = {
name: "bandcamp", name: "bandcamp",

View File

@@ -1,5 +1,6 @@
/** /**
* The fallback link provider, for any page no other provider claims. * Previews any page no other provider claims, for `rich-content-service.ts`,
* from whatever metadata its HTML carries.
* *
* @module * @module
*/ */
@@ -21,15 +22,16 @@ import {
import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
/** /**
* The provider for any HTML page, previewed from its OG and Twitter tags, JSON-LD, and markup: * Matches every URL, so it comes last in the service's list. Each field falls
* title, description, artwork, icon and brand color. Matches every URL, so it * back from OG tags to Twitter tags, JSON-LD and the markup itself. The icon
* must come last. * and brand color feed the placeholder the frontend draws when there is no
* thumbnail.
*/ */
export const genericProvider: RichContentProvider = { export const genericProvider: RichContentProvider = {
name: "generic", name: "generic",
matches(_url: string): boolean { matches(_url: string): boolean {
return true; // fallback — always matches return true;
}, },
async fetch(url: string): Promise<RichContent> { async fetch(url: string): Promise<RichContent> {
@@ -44,9 +46,9 @@ export const genericProvider: RichContentProvider = {
const html = await readTextCapped(res); const html = await readTextCapped(res);
const ld = extractJsonLd(html); const ld = extractJsonLd(html);
// If og:url is present but points to a different page (e.g. the homepage), // An og:url naming another page (often the home page) means the OG tags
// the og: block is a site-level fallback, not page-specific metadata. // are the site's defaults, not this page's: its title and image are
// In that case skip og:title and og:image so page-level signals win. // skipped.
const ogUrl = extractOgTag(html, "url"); const ogUrl = extractOgTag(html, "url");
const useOg = !ogUrl || (() => { const useOg = !ogUrl || (() => {
try { try {
@@ -58,36 +60,29 @@ export const genericProvider: RichContentProvider = {
} }
})(); })();
// Title: og:title (page-matched) → twitter:title → JSON-LD → <title>
const title = (useOg ? extractOgTag(html, "title") : undefined) ?? const title = (useOg ? extractOgTag(html, "title") : undefined) ??
extractMetaName(html, "twitter:title") ?? extractMetaName(html, "twitter:title") ??
ld.title ?? ld.title ??
extractPageTitle(html); extractPageTitle(html);
// Site name: og:site_name → hostname
const siteName = extractOgTag(html, "site_name") ?? const siteName = extractOgTag(html, "site_name") ??
new URL(url).hostname.replace(/^www\./, ""); new URL(url).hostname.replace(/^www\./, "");
// Description: og:description → twitter:description → JSON-LD → <meta name="description">
const description = extractOgTag(html, "description") ?? const description = extractOgTag(html, "description") ??
extractMetaName(html, "twitter:description") ?? extractMetaName(html, "twitter:description") ??
ld.description ?? ld.description ??
extractMetaName(html, "description"); extractMetaName(html, "description");
// Image: og:image (page-matched) → twitter:image → JSON-LD → large <img> → // Never the favicon: a 16×16 icon cropped into a thumbnail looks broken,
// first content <img>. The chain deliberately stops there: a favicon is not // and the placeholder drawn instead shows the icon properly.
// artwork, and pretending otherwise means every art-less page gets a 16×16
// icon cover-cropped into a 128×72 box. No match here means "no artwork",
// and the frontend draws a generated placeholder instead.
const thumbnailUrl = (useOg ? extractOgTag(html, "image") : undefined) ?? const thumbnailUrl = (useOg ? extractOgTag(html, "image") : undefined) ??
extractMetaName(html, "twitter:image") ?? extractMetaName(html, "twitter:image") ??
ld.thumbnailUrl ?? ld.thumbnailUrl ??
extractLargeImage(html, url) ?? extractLargeImage(html, url) ??
extractFirstContentImage(html, url); extractFirstContentImage(html, url);
// Icon and brand color are independent facts about the page, so they're // `/favicon.ico` is a guess. When it doesn't load, the placeholder shows
// collected whether or not there's artwork. `/favicon.ico` is a guess; when // the site's initial.
// it 404s the placeholder falls back to the site's initial.
const faviconUrl = extractBestIcon(html, url) ?? const faviconUrl = extractBestIcon(html, url) ??
`${new URL(url).origin}/favicon.ico`; `${new URL(url).origin}/favicon.ico`;

View File

@@ -1,20 +1,25 @@
/** /**
* The interface every link provider implements. * Declares what a link provider implements. `rich-content-service.ts` asks its
* providers in turn and fetches a link's preview from the first that matches.
* *
* @module * @module
*/ */
import type { RichContent } from "../../../shared/dump.ts"; import type { RichContent } from "../../../shared/dump.ts";
/** Metadata for the links one kind of site serves. */ /**
* Implemented by each file of `remote/providers/`, and listed in priority order
* by `rich-content-service.ts`.
*/
export interface RichContentProvider { export interface RichContentProvider {
/** The provider's name, for logs. */
name: string; name: string;
/** Checks whether this provider handles `url`. */ /** Called on a URL that `isValidHttpUrl` accepted; never throws. */
matches(url: string): boolean; matches(url: string): boolean;
/** /**
* Fetches `url`'s preview. Rejects when the page can't be fetched; a page * Returns the preview of `url`, which ends up in the dump's `rich_content` or
* that isn't HTML yields a preview with no metadata. * the create form's preview. It rejects when the page can't be fetched, and
* the service then falls back to a stub. A page that isn't HTML yields a
* preview without metadata.
*/ */
fetch(url: string): Promise<RichContent>; fetch(url: string): Promise<RichContent>;
} }

View File

@@ -1,6 +1,7 @@
/** /**
* The link provider for this site's own pages, read from the database * Previews links to this site's own dump, user and playlist pages from the
* instead of fetched. * database, for `rich-content-service.ts`. No request goes out, so this works
* even when the site runs on a private host that `fetchWithTimeout` refuses.
* *
* @module * @module
*/ */
@@ -18,8 +19,8 @@ const USER_RE = /^\/users\/([^/]+)$/;
const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; const PLAYLIST_RE = /^\/playlists\/([^/]+)$/;
/** /**
* Internal links carry gerbeur's own branding, so an art-less one still gets a * Gives a link to this site without a thumbnail a placeholder in the site's
* recognizable placeholder instead of a generic tint. * icon and color.
*/ */
const SELF_BRANDING = { const SELF_BRANDING = {
siteName: "gerbeur", siteName: "gerbeur",
@@ -28,9 +29,9 @@ const SELF_BRANDING = {
} as const; } as const;
/** /**
* The provider for links to this site's dump, user and playlist pages, read * Comes before the generic provider, and claims what `isSelfUrl` accepts. Its
* from the database. Its `fetch` rejects with the services' `APIException` (404) for a * `fetch` rejects with the services' 404 `APIException` for a missing page or
* page that doesn't exist or is private. * a private dump. A private playlist is previewed all the same.
*/ */
export const selfProvider: RichContentProvider = { export const selfProvider: RichContentProvider = {
name: "self", name: "self",
@@ -97,7 +98,7 @@ export const selfProvider: RichContentProvider = {
}); });
} }
// Should not reach here if matches() is correct // Unreachable: isSelfUrl accepts only the three paths above.
return Promise.resolve({ type: "generic", url }); return Promise.resolve({ type: "generic", url });
}, },
}; };

View File

@@ -1,5 +1,6 @@
/** /**
* The SoundCloud link provider: previews from a track page's OG tags. * Previews SoundCloud links from their page's OG tags, for
* `rich-content-service.ts`.
* *
* @module * @module
*/ */
@@ -9,7 +10,10 @@ import type { RichContentProvider } from "./provider.ts";
import { extractOgTag } from "../html.ts"; import { extractOgTag } from "../html.ts";
import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
/** The provider for soundcloud.com pages, with SoundCloud's embed player. */ /**
* Claims soundcloud.com only, not www. or m.soundcloud.com, and builds the
* embed player's URL from the link itself.
*/
export const soundcloudProvider: RichContentProvider = { export const soundcloudProvider: RichContentProvider = {
name: "soundcloud", name: "soundcloud",

View File

@@ -1,5 +1,7 @@
/** /**
* The YouTube link provider: videos, playlists and channels. * Previews YouTube videos, playlists and channels, for
* `rich-content-service.ts`. Videos and playlists are read from YouTube's
* oEmbed endpoint, channels from their page's OG tags.
* *
* @module * @module
*/ */
@@ -9,7 +11,10 @@ import type { RichContentProvider } from "./provider.ts";
import { extractOgTag } from "../html.ts"; import { extractOgTag } from "../html.ts";
import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; import { fetchWithTimeout, readTextCapped } from "../fetch.ts";
/** Returns the id of the video a youtu.be or youtube.com URL points at. */ /**
* Returns the id of the video a URL points at, on youtu.be, youtube.com or
* www.youtube.com only; `m.` and `music.` links go to the generic provider.
*/
function extractVideoId(url: string): string | null { function extractVideoId(url: string): string | null {
try { try {
const u = new URL(url); const u = new URL(url);
@@ -32,7 +37,10 @@ function extractVideoId(url: string): string | null {
return null; return null;
} }
/** Returns the `list` parameter of a URL: the playlist it plays. */ /**
* Returns the `list` parameter of a URL, whatever its host: a link to another
* site with a `list` parameter is taken for a YouTube playlist.
*/
function extractPlaylistId(url: string): string | null { function extractPlaylistId(url: string): string | null {
try { try {
return new URL(url).searchParams.get("list"); return new URL(url).searchParams.get("list");
@@ -42,9 +50,8 @@ function extractPlaylistId(url: string): string | null {
} }
/** /**
* Returns the playback offset in seconds of a YouTube URL. * Returns the start time of a YouTube URL in seconds, from `t` (`90`, `90s`,
* Accepts `t` (plain seconds, `90s`, or `1h2m3s` / `20m5s` forms) and `start` * `1h2m3s`) or `start`; null when there is none that parses.
* (plain seconds). Returns null when absent or unparseable.
*/ */
function extractStartSeconds(url: string): number | null { function extractStartSeconds(url: string): number | null {
let raw: string | null; let raw: string | null;
@@ -66,8 +73,8 @@ function extractStartSeconds(url: string): number | null {
} }
/** /**
* Returns the path of a channel URL: /channel/UC…, /@handle, /c/name or * Returns the path of a youtube.com channel URL (`/channel/UC…`, `/@handle`,
* /user/name. * `/c/name` or `/user/name`); null for any other URL.
*/ */
function extractChannelPath(url: string): string | null { function extractChannelPath(url: string): string | null {
try { try {
@@ -90,7 +97,7 @@ function extractChannelPath(url: string): string | null {
/** /**
* Fetches the title and thumbnail of `url` from YouTube's oEmbed endpoint; * Fetches the title and thumbnail of `url` from YouTube's oEmbed endpoint;
* empty when that fails. * empty when that fails, since the preview can do without them.
*/ */
async function fetchOEmbed( async function fetchOEmbed(
url: string, url: string,
@@ -109,14 +116,16 @@ async function fetchOEmbed(
} }
await res.body?.cancel(); await res.body?.cancel();
} catch { } catch {
// oembed failed — carry on // YouTube unreachable, or its answer not JSON.
} }
return {}; return {};
} }
/** /**
* The provider for YouTube videos (with their playlist and start time in the embed), * Comes first in the service's list. A video's embed keeps the link's playlist
* playlists (as a `videoseries` embed) and channels (not embeddable). * and start time, a playlist embeds as a `videoseries`, and a channel gets no
* embed. Its `fetch` never rejects: whatever YouTube doesn't answer is left
* out.
*/ */
export const youtubeProvider: RichContentProvider = { export const youtubeProvider: RichContentProvider = {
name: "youtube", name: "youtube",
@@ -134,9 +143,8 @@ export const youtubeProvider: RichContentProvider = {
const listId = extractPlaylistId(url); const listId = extractPlaylistId(url);
const channelPath = extractChannelPath(url); const channelPath = extractChannelPath(url);
// ── Channel ───────────────────────────────────────────────────────────────
if (channelPath && !videoId) { if (channelPath && !videoId) {
// oEmbed doesn't support channel URLs — scrape og:image from the page instead // oEmbed doesn't take channel URLs.
let title: string | undefined; let title: string | undefined;
let thumbnailUrl: string | undefined; let thumbnailUrl: string | undefined;
try { try {
@@ -149,7 +157,7 @@ export const youtubeProvider: RichContentProvider = {
await res.body?.cancel(); await res.body?.cancel();
} }
} catch { } catch {
// scrape failed — carry on with undefined values // YouTube unreachable: the preview goes without.
} }
return { return {
type: "youtube", type: "youtube",
@@ -157,11 +165,9 @@ export const youtubeProvider: RichContentProvider = {
url, url,
title, title,
thumbnailUrl, thumbnailUrl,
// channels are not embeddable as a player
}; };
} }
// ── Playlist (no specific video) ──────────────────────────────────────────
if (listId && !videoId) { if (listId && !videoId) {
const { title, thumbnailUrl } = await fetchOEmbed(url); const { title, thumbnailUrl } = await fetchOEmbed(url);
return { return {
@@ -175,7 +181,7 @@ export const youtubeProvider: RichContentProvider = {
}; };
} }
// ── Video (with optional playlist context) ──────────────────────────────── // Built, not taken from oEmbed, so it is there even when oEmbed fails.
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
const { title } = await fetchOEmbed(url); const { title } = await fetchOEmbed(url);

View File

@@ -1,8 +1,8 @@
/** /**
* Guards for server-side fetches of user-supplied URLs (link previews, the * Tells private addresses from public ones, so that a user-supplied URL can't
* image proxy, Bandcamp resolution): the server must not become a way to reach * make the server fetch what only it can reach: loopback, the LAN, container
* addresses only it can see — loopback, the LAN, container networks, or the * networks or the cloud metadata endpoint at 169.254.169.254. `fetch.ts` checks
* cloud metadata endpoint at 169.254.169.254. * every hop with it.
* *
* @module * @module
*/ */
@@ -83,8 +83,9 @@ function isPrivateIPv6(b: number[]): boolean {
} }
/** /**
* Checks whether `ip` is an IP literal outside the public internet. Non-IPs * Checks whether `ip` is an IPv4 or IPv6 address outside the public internet.
* are false. * An IPv4 address carried in IPv6 is judged as IPv4. Anything that isn't an IP
* address is false, so a hostname must be resolved first.
*/ */
export function isPrivateAddress(ip: string): boolean { export function isPrivateAddress(ip: string): boolean {
const v4 = parseIPv4(ip); const v4 = parseIPv4(ip);
@@ -106,9 +107,9 @@ function bareHostname(url: URL): string {
} }
/** /**
* Checks whether `url`'s host is private without DNS: a private IP literal, * Checks whether the host of `url` is private without a DNS lookup: a private
* or a name that is local by definition. Names still need resolving (see * IP address, or `localhost` and its subdomains. `isValidHttpUrl` uses it to
* resolvePublicAddress) — this only rejects what is obvious without DNS. * reject a link early; only {@link resolvePublicAddress} is a real guard.
*/ */
export function isPrivateHostname(url: URL): boolean { export function isPrivateHostname(url: URL): boolean {
const host = bareHostname(url).toLowerCase(); const host = bareHostname(url).toLowerCase();
@@ -117,13 +118,13 @@ export function isPrivateHostname(url: URL): boolean {
} }
/** /**
* Resolves `url`'s host and returns one of its addresses, or throws if the * Resolves the host of `url` and returns one of its addresses, all of which
* host is private or any address it resolves to is (a name with both a public * must be public: the fetch may pick any of them. `fetchWithTimeout` calls it
* and a private record is refused outright, since the fetch may pick either). * before each hop.
* *
* The fetch itself resolves again, so a DNS server answering differently the * Only the curl fallback connects to the address returned. Deno's fetch
* second time (rebinding) isn't fully excluded; the curl fallback pins the * resolves the name again, so a DNS server that answers differently the second
* address this returns. * time (rebinding) can still get through.
* *
* @throws {Error} When the host is private, can't be resolved, or resolves to * @throws {Error} When the host is private, can't be resolved, or resolves to
* a private address. * a private address.

View File

@@ -1,18 +1,23 @@
/** /**
* Twemoji glyphs, fetched from the jsDelivr CDN. * Fetches Twemoji glyphs from the jsDelivr CDN, for `lib/site-icons.ts` to
* render the favicons from.
* *
* @module * @module
*/ */
/** The pinned Twemoji release. */ /** Pinned, so a new Twemoji release can't change the icons unasked. */
const TWEMOJI_VERSION = "16.0.1"; const TWEMOJI_VERSION = "16.0.1";
/** Fetched glyphs by codepoint, so repeated runs don't re-hit the network. */ /**
* Glyphs by codepoint. It only lives as long as the process, and the `icons`
* task renders one emoji per process.
*/
const glyphCache = new Map<string, string>(); const glyphCache = new Map<string, string>();
/** /**
* Fetches Twemoji's plain vector-path SVG for `emoji`, whose file is named * Returns Twemoji's SVG for `emoji`, whose file name `cp` comes from
* `cp` (see `emojiToCodepoint` in lib/site-icons.ts). * `emojiToCodepoint`. `emoji` only serves the error message. It fetches with
* plain `fetch`, since the host is fixed.
* *
* @throws {Error} When the glyph can't be fetched. * @throws {Error} When the glyph can't be fetched.
*/ */

View File

@@ -1,17 +1,13 @@
/** /**
* Resolves a YouTube playlist to its tracklist, at play time. * Resolves a YouTube playlist to its entries' titles and lengths, for
* `GET /api/youtube/playlist`. The embed plays the list but doesn't expose
* them, and the global player needs them to draw a tracklist.
* *
* Playback itself stays in the iframe embed — YouTube plays the list and * They are read from the `ytInitialData` JSON of the public playlist page,
* advances through it on its own. This only supplies what the embed doesn't * whose undocumented shape changes, so both the current `lockupViewModel` rows
* expose: every entry's title and length, so the global player can draw a * and the older `playlistVideoRenderer` ones are accepted. Only the first 100
* tracklist the way it does for a Bandcamp album. The client syncs the * entries are there; the client adds the rest, untitled, as the player reports
* highlighted row and row clicks through the IFrame Player API. * them.
*
* The data comes from the `ytInitialData` blob on the public playlist page. Its
* shape is undocumented and changes, so the parse is best-effort and accepts
* both the current `lockupViewModel` rows and the older `playlistVideoRenderer`
* ones. Only the first page (100 entries) is read; the client appends any
* further IDs the player reports, untitled.
* *
* @module * @module
*/ */
@@ -24,28 +20,25 @@ import type {
YouTubePlaylistTrack, YouTubePlaylistTrack,
} from "../../shared/player.ts"; } from "../../shared/player.ts";
/** /** Titles and lengths rarely change, and a stale one only mislabels a row. */
* How long a resolved playlist is cached. Titles and lengths rarely change; a
* stale entry only mislabels a row.
*/
const TTL_MS = 6 * 60 * 60 * 1000; const TTL_MS = 6 * 60 * 60 * 1000;
/** The most playlists cached; the oldest write goes first. */ /** The oldest write goes first. */
const MAX_ENTRIES = 200; const MAX_ENTRIES = 200;
/** Resolved playlists, by list id. */
const cache = new Map<string, { value: YouTubePlaylist; at: number }>(); const cache = new Map<string, { value: YouTubePlaylist; at: number }>();
/** A playlist id: URL-safe base64-ish, PL…, OLAK5uy_…, RD… (mixes). */ /**
* Admits every kind of list id (`PL…`, `OLAK5uy_…`, `RD…`) and nothing else.
*/
const LIST_ID_RE = /^[\w-]{2,64}$/; const LIST_ID_RE = /^[\w-]{2,64}$/;
/** The headers the playlist page is requested with. */
const HEADERS = { const HEADERS = {
"User-Agent": "User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
"Accept": "text/html", "Accept": "text/html",
"Accept-Language": "en-US,en;q=0.8", "Accept-Language": "en-US,en;q=0.8",
// Pre-answered consent: without it EU requests are redirected to // Declines the consent prompt, which EU requests are otherwise redirected
// consent.youtube.com and never see the playlist. // to instead of the playlist.
"Cookie": "SOCS=CAI", "Cookie": "SOCS=CAI",
}; };
@@ -55,7 +48,7 @@ function parseClock(text: string): number | undefined {
return text.split(":").reduce((acc, part) => acc * 60 + Number(part), 0); return text.split(":").reduce((acc, part) => acc * 60 + Number(part), 0);
} }
/** Collects, depth-first, every value stored under `key` into `out`. */ /** Collects, depth first, every value stored under `key` into `out`. */
function collect(node: unknown, key: string, out: unknown[]): void { function collect(node: unknown, key: string, out: unknown[]): void {
if (Array.isArray(node)) { if (Array.isArray(node)) {
for (const child of node) collect(child, key, out); for (const child of node) collect(child, key, out);
@@ -74,10 +67,7 @@ function firstString(node: unknown, key: string): string | undefined {
return found.find((v): v is string => typeof v === "string"); return found.find((v): v is string => typeof v === "string");
} }
/** /** Returns the value at `path` in `node`; undefined when a step is missing. */
* Returns the value at `path` in parsed JSON; undefined wherever a step is
* missing.
*/
function at(node: unknown, ...path: (string | number)[]): unknown { function at(node: unknown, ...path: (string | number)[]): unknown {
let cur = node; let cur = node;
for (const key of path) { for (const key of path) {
@@ -124,8 +114,8 @@ function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null {
} }
/** /**
* Reads the tracklist from a playlist page's `ytInitialData`; null when the * Returns the playlist `listId` as read from its page `html`; null when the
* page has none or no track in it. * page has no `ytInitialData` or no track in it.
*/ */
export function parsePlaylistPage( export function parsePlaylistPage(
html: string, html: string,
@@ -148,8 +138,7 @@ export function parsePlaylistPage(
const legacy: unknown[] = []; const legacy: unknown[] = [];
collect(at(data, "contents"), "playlistVideoRenderer", legacy); collect(at(data, "contents"), "playlistVideoRenderer", legacy);
// A video can appear in a playlist twice; both rows are kept, so the list // A video listed twice keeps both rows, so that row n is the embed's entry n.
// stays positional and matches what the embed plays.
const tracks = [ const tracks = [
...lockups.map(fromLockup), ...lockups.map(fromLockup),
...legacy.map(fromLegacyRenderer), ...legacy.map(fromLegacyRenderer),
@@ -164,7 +153,8 @@ export function parsePlaylistPage(
} }
/** /**
* Fetches the playlist `listId` and returns its tracklist, cached. * Returns the tracklist of the playlist `listId`, from the cache when it was
* resolved in the last 6 hours.
* *
* @throws {APIException} 400 when `listId` isn't a playlist id, 502 when * @throws {APIException} 400 when `listId` isn't a playlist id, 502 when
* YouTube can't be reached or answers with an error, 404 when the playlist has * YouTube can't be reached or answers with an error, 404 when the playlist has
@@ -189,6 +179,8 @@ export async function resolveYouTubePlaylist(
const timer = setTimeout(() => controller.abort(), 8000); const timer = setTimeout(() => controller.abort(), 8000);
let html: string; let html: string;
try { try {
// youtube.com needs no address check, and fetchWithTimeout can't send the
// consent cookie.
const res = await fetch( const res = await fetch(
`https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}`, `https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}`,
{ headers: HEADERS, signal: controller.signal }, { headers: HEADERS, signal: controller.signal },
@@ -215,8 +207,7 @@ export async function resolveYouTubePlaylist(
const parsed = parsePlaylistPage(html, listId); const parsed = parsePlaylistPage(html, listId);
if (!parsed) { if (!parsed) {
// Private, deleted, or auto-generated mixes (RD…) that have no playlist // The client then shows the embed without a tracklist.
// page — the client just shows the embed without a tracklist.
throw new APIException( throw new APIException(
APIErrorCode.NOT_FOUND, APIErrorCode.NOT_FOUND,
404, 404,

View File

@@ -1,9 +1,9 @@
/** /**
* The attachment routes, for images pasted into Markdown bodies. * Stores and serves the images pasted or dropped into the text editor, which
* puts each one in the body as a link to its `GET` URL.
* *
* - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at * - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at
* most 5 MB) and answers its id; it is bound to a resource once a saved body * most 5 MB) and answers its id.
* links it.
* - `GET /api/attachments/:attachmentId` — public. Serves the image. * - `GET /api/attachments/:attachmentId` — public. Serves the image.
* *
* @module * @module
@@ -24,7 +24,10 @@ import {
validateImageUpload, validateImageUpload,
} from "../lib/upload.ts"; } from "../lib/upload.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router(); const router: Router = new Router();
router.post("/api/attachments", authMiddleware, async (ctx) => { router.post("/api/attachments", authMiddleware, async (ctx) => {

View File

@@ -1,8 +1,11 @@
/** /**
* The avatar routes. * Serves the avatar endpoints. The profile page uploads the avatar, and the
* client's `Avatar` component loads it, with the user's `updatedAt` as a
* cache-busting version.
* *
* - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart * - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart
* `file`, at most 5 MB), announces it, and answers the caller's user. * `file`, at most 5 MB), sends `user_updated` to everyone, and answers the
* caller's user.
* - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without * - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without
* one. * one.
* *
@@ -26,7 +29,10 @@ import {
validateImageUpload, validateImageUpload,
} from "../lib/upload.ts"; } from "../lib/upload.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router(); const router: Router = new Router();
router.post("/api/avatars/me", authMiddleware, async (ctx) => { router.post("/api/avatars/me", authMiddleware, async (ctx) => {
@@ -56,16 +62,15 @@ router.post("/api/avatars/me", authMiddleware, async (ctx) => {
try { try {
updateUserAvatar(authPayload.userId, mime); updateUserAvatar(authPayload.userId, mime);
} catch (err) { } catch (err) {
// DB write failed — clean up the orphaned file // The row doesn't describe the new file, so don't keep it.
await Deno.remove(filePath).catch(() => {}); await Deno.remove(filePath).catch(() => {});
throw err; throw err;
} }
updateClientAvatar(authPayload.userId, mime); updateClientAvatar(authPayload.userId, mime);
const user = getUserById(authPayload.userId); const user = getUserById(authPayload.userId);
// Let every client refresh this user's avatar wherever it's shown (feeds, // Presence alone would update only the online list; `user_updated` reaches
// comments, OP, profiles) — not just the presence row. updated_at, bumped on // every avatar on screen, and its bumped `updatedAt` busts their cache.
// the avatar write, doubles as the cache-busting version.
broadcastUserUpdated(toPublicUser(user)); broadcastUserUpdated(toPublicUser(user));
const responseBody: APIResponse<SelfUser> = { const responseBody: APIResponse<SelfUser> = {
success: true, success: true,

View File

@@ -1,8 +1,9 @@
/** /**
* The Bandcamp route, for the native player. * Serves the Bandcamp release the native player plays, resolved from its page
* when playback starts. On any failure the player falls back to the embed.
* *
* - `GET /api/bandcamp/tracks?url=&force=` — public. Resolves a Bandcamp page * - `GET /api/bandcamp/tracks?url=&force=` — public. Answers the page's
* to its streamable tracks, at play time; `force=1` bypasses the cache. * tracks and their stream URLs; `force=1` skips the server's cache.
* *
* @module * @module
*/ */
@@ -12,13 +13,14 @@ import type { APIResponse } from "../../shared/api.ts";
import { resolveBandcamp } from "../remote/bandcamp-streams.ts"; import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
import type { Tralbum } from "../../shared/player.ts"; import type { Tralbum } from "../../shared/player.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const bandcampRouter: Router = new Router(); const bandcampRouter: Router = new Router();
// Public: it only reads a public page and returns URLs the same page already // Public: it reads a public page and answers the URLs that page gives any
// hands any visitor. Errors are thrown as APIException and rendered by // visitor.
// errorMiddleware; the client treats any failure as "fall back to the iframe
// embed".
bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => { bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? ""; const url = ctx.request.url.searchParams.get("url") ?? "";
const force = ctx.request.url.searchParams.get("force") === "1"; const force = ctx.request.url.searchParams.get("force") === "1";
@@ -28,8 +30,8 @@ bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => {
data: await resolveBandcamp(url, { force }), data: await resolveBandcamp(url, { force }),
}; };
// Private: the signed URLs in the body are per-fetch, so they must not be // Not in a shared cache: the signed URLs belong to one page fetch. An hour
// held in a shared cache. Well inside the 24h signature life either way. // is well inside their 24 hours.
ctx.response.headers.set("Cache-Control", "private, max-age=3600"); ctx.response.headers.set("Cache-Control", "private, max-age=3600");
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });

View File

@@ -41,7 +41,10 @@ import {
updateCategory, updateCategory,
} from "../services/category-service.ts"; } from "../services/category-service.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/categories" }); const router: Router = new Router({ prefix: "/api/categories" });
router.get("/", (ctx) => { router.get("/", (ctx) => {

View File

@@ -1,13 +1,14 @@
/** /**
* The chat history route; messages are posted and edited over the socket (see * Serves the chat's history to the chatbox (`ChatModal`). Messages are sent,
* routes/ws.ts). * edited and deleted over the socket instead (`routes/ws.ts`).
* *
* - `GET /api/chat?limit=&before=&beforeId=&after=&afterId=` — signed in. * - `GET /api/chat?limit=&before=&beforeId=&after=&afterId=`, signed in.
* Answers messages in ascending order: the most recent by default; with * Returns messages oldest first, at most `limit` (50 by default, 100 at
* `before`, the page of older messages preceding that cursor ("load older"); * most). With no cursor, it returns the latest. With `before`, it returns
* with `after`, the messages following it (resync after a reconnect). A * the page just older than that cursor, for "load older". With `after`, it
* cursor is an ISO `created_at` plus the message id to break ties; without * returns what follows it, for the resync after a reconnect. A cursor is a
* `afterId` the `after` timestamp itself is included. * message's ISO `created_at`, and its id breaks ties. Without `afterId`,
* messages at the `after` timestamp itself are included.
* *
* @module * @module
*/ */
@@ -21,7 +22,10 @@ import {
getRecentChatMessages, getRecentChatMessages,
} from "../services/chat-service.ts"; } from "../services/chat-service.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/chat" }); const router: Router = new Router({ prefix: "/api/chat" });
router.get("/", authMiddleware, (ctx) => { router.get("/", authMiddleware, (ctx) => {

View File

@@ -1,6 +1,7 @@
/** /**
* The comment routes. A private dump's comments exist only for its poster, and * Serves the comments on dumps. The comments of a private dump 404 for anyone
* changes to them aren't broadcast. * but its poster, and changes to them aren't broadcast. Comments are otherwise
* broadcast over the WebSocket as they are posted, edited and deleted.
* *
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment * - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
* on a dump, oldest first. * on a dump, oldest first.
@@ -49,7 +50,10 @@ import {
broadcastCommentUpdated, broadcastCommentUpdated,
} from "../services/ws-service.ts"; } from "../services/ws-service.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api" }); const router: Router = new Router({ prefix: "/api" });
router.get("/dumps/:dumpId/comments", async (ctx) => { router.get("/dumps/:dumpId/comments", async (ctx) => {
@@ -111,7 +115,7 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => {
router.get("/comments/:commentId/likers", async (ctx) => { router.get("/comments/:commentId/likers", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
const dumpId = getCommentDumpId(ctx.params.commentId); const dumpId = getCommentDumpId(ctx.params.commentId);
getDump(dumpId, requestingUserId); // enforces privacy, 404s if not visible getDump(dumpId, requestingUserId); // 404s unless the caller may see the dump.
const { page, limit } = parsePagination(ctx.request.url.searchParams); const { page, limit } = parsePagination(ctx.request.url.searchParams);
const { items, hasMore } = getCommentLikers( const { items, hasMore } = getCommentLikers(
ctx.params.commentId, ctx.params.commentId,

View File

@@ -1,6 +1,7 @@
/** /**
* The dump routes. `:dumpId` takes an id or a slug; a private dump exists only * Posts, reads, edits and deletes dumps, for the index feed, the dump page, its
* for its poster. * edit page and the create form. `:dumpId` takes an id or a slug. A private
* dump answers 404 to anyone but its poster, moderators included.
* *
* - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file * - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file
* (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`). * (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`).
@@ -14,9 +15,10 @@
* - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the * - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the
* playlists it appears in. * playlists it appears in.
* - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the * - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the
* dumps, newest first. * dumps the caller may see, newest first.
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with * - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
* `dump:moderate`. Replaces a file dump's upload (multipart). * `dump:moderate`. Replaces a file dump's upload and comment, and any of its
* title, categories and visibility (multipart).
* - `PUT /api/dumps/:dumpId` — signed in, as its poster or with * - `PUT /api/dumps/:dumpId` — signed in, as its poster or with
* `dump:moderate`. Updates its title, comment, visibility, categories, and a * `dump:moderate`. Updates its title, comment, visibility, categories, and a
* link's URL. * link's URL.
@@ -73,16 +75,18 @@ import { getDumpVoters } from "../services/vote-service.ts";
import { getRelatedDumps } from "../services/backlink-service.ts"; import { getRelatedDumps } from "../services/backlink-service.ts";
import { getPlaylistsForDump } from "../services/playlist-service.ts"; import { getPlaylistsForDump } from "../services/playlist-service.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/dumps" }); const router: Router = new Router({ prefix: "/api/dumps" });
// Every route here takes a slug or an id; the services below them key on the // Each handler resolves `:dumpId` with getDump, which checks privacy and takes
// id, so mutations always pass `dump.id` from the resolved dump, never the raw // a slug, and passes `dump.id` on: the services' writes match on the id alone.
// parameter.
/** /**
* Reads the category ids that arrive on the multipart create path as a * Parses a multipart form's `categoryIds`, a JSON array sent as a string. A
* JSON-encoded string field; empty when it is missing or malformed. * missing or malformed value counts as no category.
*/ */
function parseCategoryIds(value: FormDataEntryValue | null): string[] { function parseCategoryIds(value: FormDataEntryValue | null): string[] {
if (typeof value !== "string" || !value) return []; if (typeof value !== "string" || !value) return [];
@@ -191,7 +195,6 @@ router.get("/:dumpId/related", async (ctx) => {
router.get("/:dumpId/playlists", async (ctx) => { router.get("/:dumpId/playlists", async (ctx) => {
const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const requestingUserId = await parseOptionalAuth(ctx) ?? undefined;
// Resolve through getDump so private dumps 404 for anyone but their owner.
const dump = getDump(ctx.params.dumpId, requestingUserId); const dump = getDump(ctx.params.dumpId, requestingUserId);
const playlists = getPlaylistsForDump(dump.id, requestingUserId); const playlists = getPlaylistsForDump(dump.id, requestingUserId);
const responseBody: APIResponse<Playlist[]> = { const responseBody: APIResponse<Playlist[]> = {
@@ -309,7 +312,8 @@ router.put("/:dumpId/thumbnail", authMiddleware, async (ctx) => {
const data = new Uint8Array(await file.arrayBuffer()); const data = new Uint8Array(await file.arrayBuffer());
const mime = validateImageUpload(data); const mime = validateImageUpload(data);
// DB update first (resolves slug → id), then file write. // The record is written first. If the file write then fails, the thumbnail
// route finds no custom file and falls back to the generated thumbnail.
const updatedDump = setDumpThumbnail(dump.id, mime); const updatedDump = setDumpThumbnail(dump.id, mime);
const filePath = `${THUMBNAILS_DIR}/${dump.id}-custom`; const filePath = `${THUMBNAILS_DIR}/${dump.id}-custom`;
await Deno.mkdir(THUMBNAILS_DIR, { recursive: true }); await Deno.mkdir(THUMBNAILS_DIR, { recursive: true });

View File

@@ -1,9 +1,10 @@
/** /**
* The route serving file dumps' uploads. * Serves file dumps' uploads to the players, viewers and download links of the
* frontend, which builds the URL with `dumpFileUrl`.
* *
* - `GET /api/files/:dumpId?token=` — public; a private dump's file needs its * - `GET /api/files/:dumpId?v=&token=` — public; a private dump's file needs
* poster's media `token`. Serves the file, inline when that is safe and as a * its poster's media `token`. Serves the file, inline when that is safe and
* download otherwise, with byte ranges. * as a download otherwise, with byte ranges.
* *
* @module * @module
*/ */
@@ -16,16 +17,16 @@ import { authenticateMedia } from "../lib/auth.ts";
import { serveFile } from "../lib/serve-file.ts"; import { serveFile } from "../lib/serve-file.ts";
import { dumpFileVersion, getDump } from "../services/dump-service.ts"; import { dumpFileVersion, getDump } from "../services/dump-service.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/files" }); const router: Router = new Router({ prefix: "/api/files" });
/** /**
* Builds an RFC 6266 `Content-Disposition` value that is always a valid * Builds a `Content-Disposition` value for a file called `name`. `Headers.set`
* ByteString. `headers.set` throws a TypeError if the value contains any * throws on any character above U+00FF, so `filename` gets an ASCII copy of the
* character > U+00FF, so a filename with an emoji / accent / CJK character * name and `filename*` (RFC 5987) the full one, percent-encoded.
* (e.g. an uploaded file) would otherwise crash the response. We provide an
* ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the
* full UTF-8 name for modern browsers.
*/ */
function contentDisposition( function contentDisposition(
type: "inline" | "attachment", type: "inline" | "attachment",
@@ -40,17 +41,16 @@ function contentDisposition(
} }
/** /**
* The types served inline. The MIME type of an upload is whatever the * Matches the types served inline. An upload's type is whatever the uploader's
* uploader's browser claimed, and these responses come from the app's own * browser claimed, and the file comes from the app's own origin, where the
* origin — where the session token lives. Only types a browser renders without * session token lives. So only types a browser shows without running code in
* running the file's own code are served inline; anything else (text/html, * them are inline, and anything else (HTML, SVG, XML…) downloads. `<img>`,
* image/svg+xml, XML, …) downloads. <img>, <audio> and fetch() ignore * `<audio>` and `fetch()` ignore `Content-Disposition`, so the players and
* Content-Disposition, so previews that embed or read the file still work. * previews still work.
*/ */
const INLINE_MIME_RE = const INLINE_MIME_RE =
/^(image\/(png|jpeg|gif|webp|avif|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf|text\/plain)$/; /^(image\/(png|jpeg|gif|webp|avif|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf|text\/plain)$/;
/** Checks whether a file of type `mime` may be served inline. */
function isInlineSafe(mime: string): boolean { function isInlineSafe(mime: string): boolean {
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase()); return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase());
} }
@@ -58,14 +58,13 @@ function isInlineSafe(mime: string): boolean {
router.get("/:dumpId", async (ctx) => { router.get("/:dumpId", async (ctx) => {
const { dumpId } = ctx.params; const { dumpId } = ctx.params;
// Guard against path traversal (UUIDs are safe, but be explicit) // The id names a file on disk: nothing but an id's characters gets through.
if (!/^[0-9a-f-]{36}$/.test(dumpId)) { if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
} }
// Media elements can't send an Authorization header, so the viewer's token // Media elements can't send an Authorization header, so the viewer's media
// arrives as a query param (same as the WebSocket route). Needed to authorize // token comes in the query string.
// access to private dumps the requester owns.
const token = ctx.request.url.searchParams.get("token"); const token = ctx.request.url.searchParams.get("token");
const viewerId = token ? await authenticateMedia(token) : null; const viewerId = token ? await authenticateMedia(token) : null;
const dump = getDump(dumpId, viewerId ?? undefined); const dump = getDump(dumpId, viewerId ?? undefined);
@@ -98,10 +97,9 @@ router.get("/:dumpId", async (ctx) => {
// links. // links.
ctx.response.headers.set("Referrer-Policy", "no-referrer"); ctx.response.headers.set("Referrer-Policy", "no-referrer");
// The app's URLs carry the file's version (see dumpFileVersion), so one // A URL naming the file's current version (dumpFileVersion) always serves the
// naming the current version never changes and is cached for good; any // same bytes, so it is cached for good; any other is revalidated against the
// other URL is revalidated against the ETag. A private dump's bytes must not // ETag. A private dump's bytes must not sit in a shared cache.
// sit in a shared cache.
const scope = dump.isPrivate ? "private" : "public"; const scope = dump.isPrivate ? "private" : "public";
const current = ctx.request.url.searchParams.get("v") === const current = ctx.request.url.searchParams.get("v") ===
dumpFileVersion(dump); dumpFileVersion(dump);

View File

@@ -1,5 +1,6 @@
/** /**
* The follow routes, all for what the caller follows. * Serves the endpoints for what the caller follows, and the index's "followed"
* tab built from it. What another user follows is served by `routes/users.ts`.
* *
* - `GET /api/follows/status` — signed in. Answers the ids of the users and * - `GET /api/follows/status` — signed in. Answers the ids of the users and
* playlists they follow. * playlists they follow.
@@ -37,10 +38,9 @@ import {
import { getCategoryIdBySlug } from "../services/category-service.ts"; import { getCategoryIdBySlug } from "../services/category-service.ts";
/** /**
* Resolves the optional ?category=<slug> filter. Returns the matching category * Returns the id of the category the `?category=` slug names, undefined without
* id, or "missing" when a slug was given but matches nothing (so the feed comes * one, or "missing" when it names none, so that the feed comes back empty
* back empty rather than unfiltered), or undefined when no filter was * rather than unfiltered.
* requested.
*/ */
function resolveCategoryFilter( function resolveCategoryFilter(
params: URLSearchParams, params: URLSearchParams,
@@ -50,14 +50,14 @@ function resolveCategoryFilter(
return getCategoryIdBySlug(slug) ?? "missing"; return getCategoryIdBySlug(slug) ?? "missing";
} }
/** The feed a filter naming no category yields. */
const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false }; const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false };
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/follows" }); const router: Router = new Router({ prefix: "/api/follows" });
// Static routes first to prevent Oak matching "status"/"feed" as a :param
router.get("/status", authMiddleware, (ctx) => { router.get("/status", authMiddleware, (ctx) => {
const status = getFollowStatus(ctx.state.user.userId); const status = getFollowStatus(ctx.state.user.userId);
const body: APIResponse<FollowStatus> = { success: true, data: status }; const body: APIResponse<FollowStatus> = { success: true, data: status };

View File

@@ -1,9 +1,10 @@
/** /**
* The invite routes. * Serves the invite endpoints. Any signed-in user can invite someone, from
* their profile page, and the invite link opens the `/register` page.
* *
* - `POST /api/invites` — signed in. Makes an invite and answers its token. * - `POST /api/invites` — signed in. Makes an invite and answers its token.
* - `GET /api/invites/:token` — public. Checks an invite is valid, before the * - `GET /api/invites/:token` — public. Checks that an invite still works,
* register page shows its form; 404 when it isn't. * before the register page shows its form; 404 when it doesn't.
* *
* @module * @module
*/ */
@@ -15,7 +16,10 @@ import type { InviteResponse } from "../../shared/user.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import { createInvite, validateInvite } from "../services/invite-service.ts"; import { createInvite, validateInvite } from "../services/invite-service.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/invites" }); const router: Router = new Router({ prefix: "/api/invites" });
router.post("/", authMiddleware, (ctx: AuthContext) => { router.post("/", authMiddleware, (ctx: AuthContext) => {
@@ -31,6 +35,7 @@ router.post("/", authMiddleware, (ctx: AuthContext) => {
}); });
router.get("/:token", (ctx) => { router.get("/:token", (ctx) => {
// A used invite answers 404 too: the page only needs to know it won't work.
try { try {
validateInvite(ctx.params.token); validateInvite(ctx.params.token);
const responseBody: APIResponse<null> = { success: true, data: null }; const responseBody: APIResponse<null> = { success: true, data: null };

View File

@@ -1,10 +1,13 @@
/** /**
* The notification routes, all for the caller's own notifications. * Serves the caller's own notifications to the client's `/notifications`
* page, which marks them all read when it opens. New ones arrive over the
* WebSocket instead, as `notification_created`.
* *
* - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them, * - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them,
* newest first. * newest first.
* - `POST /api/notifications/read-all` — signed in. Marks them all read. * - `POST /api/notifications/read-all` — signed in. Marks them all read.
* - `PATCH /api/notifications/:id/read` — signed in. Marks one read. * - `PATCH /api/notifications/:id/read` — signed in. Marks one of them read.
* The client doesn't use it.
* *
* @module * @module
*/ */
@@ -25,7 +28,10 @@ import {
markOneRead, markOneRead,
} from "../services/notification-service.ts"; } from "../services/notification-service.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/notifications" }); const router: Router = new Router({ prefix: "/api/notifications" });
router.get("/", authMiddleware, (ctx: AuthContext) => { router.get("/", authMiddleware, (ctx: AuthContext) => {

View File

@@ -1,11 +1,13 @@
/** /**
* The playlist routes. `:playlistId` takes an id or a slug. * Serves playlists, their dumps and their covers. `:playlistId` takes an id or
* a slug.
* *
* - `GET /api/playlists/by-dump/:dumpId/memberships` — signed in. Answers each * - `GET /api/playlists/by-dump/:dumpId/memberships` — signed in. Answers each
* of the caller's playlists and whether it holds the dump. * of the caller's playlists and whether it holds the dump.
* - `POST /api/playlists` — signed in. Creates a playlist. * - `POST /api/playlists` — signed in. Creates a playlist.
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist * - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
* with the dumps the caller may see in it; a private one only to its owner. * with the dumps the caller may see in it. A private one 404s but for its
* owner.
* - `PUT /api/playlists/:playlistId` — signed in, as its owner or with * - `PUT /api/playlists/:playlistId` — signed in, as its owner or with
* `playlist:moderate`. Updates its title, description and visibility. * `playlist:moderate`. Updates its title, description and visibility.
* - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with * - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with
@@ -16,8 +18,8 @@
* or with `playlist:moderate`. Removes a dump. * or with `playlist:moderate`. Removes a dump.
* - `POST /api/playlists/:playlistId/image` — signed in, as its owner or with * - `POST /api/playlists/:playlistId/image` — signed in, as its owner or with
* `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB). * `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB).
* - `GET /api/playlists/:playlistId/image?token=` — public; a private * - `GET /api/playlists/:playlistId/image?token=` — public, but a private
* playlist's cover needs its owner's media `token`. Serves its cover; 404 * playlist's cover needs its owner's media `token`. Serves its cover, or 404s
* without one. * without one.
* - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with * - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with
* `playlist:moderate`. Reorders the dumps the caller can see in it. * `playlist:moderate`. Reorders the dumps the caller can see in it.
@@ -60,7 +62,10 @@ import {
validateImageUpload, validateImageUpload,
} from "../lib/upload.ts"; } from "../lib/upload.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router<AuthState> = new Router<AuthState>({ const router: Router<AuthState> = new Router<AuthState>({
prefix: "/api/playlists", prefix: "/api/playlists",
}); });
@@ -170,11 +175,10 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => {
const data = new Uint8Array(await file.arrayBuffer()); const data = new Uint8Array(await file.arrayBuffer());
const mime = validateImageUpload(data); const mime = validateImageUpload(data);
// The file goes in before the row changes. Setting image_mime and bumping // The file is written before setPlaylistImage announces it, or clients would
// updated_at announces the cover (the `?v=` on its URL, the socket event), // fetch the new `?v=` URL too early and cache the old cover under it. It is
// so the bytes must already be there: an earlier fetch would cache the old // written beside the old one and renamed over it, so a failed write leaves
// cover under the new URL, and a failed write would leave a cover that 404s. // the old cover whole.
// Written beside and renamed over, so the old cover is never half-replaced.
const { id } = getEditablePlaylist( const { id } = getEditablePlaylist(
ctx.params.playlistId, ctx.params.playlistId,
ctx.state.user.userId, ctx.state.user.userId,
@@ -210,7 +214,7 @@ router.get("/:playlistId/image", async (ctx) => {
ctx.response.status = 404; ctx.response.status = 404;
return; return;
} }
// The URL may carry a media token; keep it out of anyone else's logs. // The URL may carry a media token, which a Referer would leak.
ctx.response.headers.set("Referrer-Policy", "no-referrer"); ctx.response.headers.set("Referrer-Policy", "no-referrer");
await serveUploadedFile( await serveUploadedFile(
ctx, ctx,

View File

@@ -1,5 +1,6 @@
/** /**
* The link preview routes. * Fetches link previews for the create form, and proxies the remote images
* that the frontend's thumbnails can't load directly.
* *
* - `GET /api/preview?url=` — signed in. Answers the preview of a link, and * - `GET /api/preview?url=` — signed in. Answers the preview of a link, and
* whether its page could be reached. * whether its page could be reached.
@@ -21,11 +22,13 @@ import {
import type { LinkPreview } from "../../shared/dump.ts"; import type { LinkPreview } from "../../shared/dump.ts";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const previewRouter: Router = new Router(); const previewRouter: Router = new Router();
// Signed-in only: it's the create form's preview, and an open one lets anyone // Signed-in only: an open preview would let anyone make the server fetch pages.
// use the server to fetch pages for them.
previewRouter.get("/api/preview", authMiddleware, async (ctx) => { previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? ""; const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) { if (!isValidHttpUrl(url)) {
@@ -37,10 +40,6 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
return; return;
} }
// `reached` is reported separately because a failed fetch still yields a
// usable stub (hostname only). Without the flag the create form cannot tell
// "this page has no preview" from "this link is dead", and shows the same
// bare card for both.
const { ok, content } = await tryFetchRichContent(url); const { ok, content } = await tryFetchRichContent(url);
const responseBody: APIResponse<LinkPreview> = { const responseBody: APIResponse<LinkPreview> = {
success: true, success: true,
@@ -49,7 +48,10 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
/** Answers with a proxied image, sandboxed so it can't run as a document. */ /**
* Answers with a proxied image. It is sent from this site's origin, so it is
* sandboxed in case a browser opens it as a document.
*/
function sendImage( function sendImage(
ctx: { response: { headers: Headers; body?: unknown } }, ctx: { response: { headers: Headers; body?: unknown } },
bytes: Uint8Array, bytes: Uint8Array,
@@ -65,8 +67,8 @@ function sendImage(
ctx.response.body = bytes; ctx.response.body = bytes;
} }
// Proxies an external image through the server so HTTP thumbnail URLs don't // The frontend sends a thumbnail here when it is plain HTTP, which an HTTPS
// trigger mixed-content blocks when the frontend is served over HTTPS. // page won't load, or when its host's hotlink protection refused the browser.
previewRouter.get("/api/proxy-image", async (ctx) => { previewRouter.get("/api/proxy-image", async (ctx) => {
const url = ctx.request.url.searchParams.get("url") ?? ""; const url = ctx.request.url.searchParams.get("url") ?? "";
if (!isValidHttpUrl(url)) { if (!isValidHttpUrl(url)) {

View File

@@ -1,9 +1,11 @@
/** /**
* The search route. * Searches dumps, users and playlists at once, for the client's `Search` page.
* Only the dumps are paged. Private dumps and playlists are found only by
* their owner.
* *
* - `GET /api/search?q=&page=&limit=` — optional sign-in. Answers a page of the * - `GET /api/search?q=&page=&limit=` — optional sign-in. Answers a page of the
* matching dumps with their total, and up to 20 matching users and playlists; * matching dumps with their total, and up to 20 matching users and
* private dumps and playlists only the caller's own. * playlists. A blank `q` finds nothing.
* *
* @module * @module
*/ */
@@ -17,7 +19,10 @@ import { searchPlaylists } from "../services/playlist-service.ts";
import type { APIResponse } from "../../shared/api.ts"; import type { APIResponse } from "../../shared/api.ts";
import type { SearchResults } from "../../shared/search.ts"; import type { SearchResults } from "../../shared/search.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/search" }); const router: Router = new Router({ prefix: "/api/search" });
router.get("/", async (ctx) => { router.get("/", async (ctx) => {

View File

@@ -1,7 +1,9 @@
/** /**
* The route serving dumps' thumbnails, generated with ffmpeg where needed. * Serves dumps' thumbnails to the cards and pages that show a file dump, which
* build the URL with `dumpThumbnailUrl`. Thumbnails made from an upload are
* generated with ffmpeg on first request and kept in `THUMBNAILS_DIR`.
* *
* - `GET /api/thumbnails/:dumpId?token=&v=` — public; a private dump's * - `GET /api/thumbnails/:dumpId?v=&token=` — public; a private dump's
* thumbnail needs its poster's media `token`. Serves the dump's custom * thumbnail needs its poster's media `token`. Serves the dump's custom
* thumbnail, else one made from its upload: a still of a video, or a * thumbnail, else one made from its upload: a still of a video, or a
* scaled-down copy of an image (the image itself when none can be made); 404 * scaled-down copy of an image (the image itself when none can be made); 404
@@ -23,16 +25,18 @@ import { serveUploadedFile } from "../lib/upload.ts";
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
import { authenticateMedia } from "../lib/auth.ts"; import { authenticateMedia } from "../lib/auth.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/thumbnails" }); const router: Router = new Router({ prefix: "/api/thumbnails" });
/** /**
* Whether ffmpeg runs, probed once per process: spawning `ffmpeg -version` on * Holds whether ffmpeg runs, probed once per process: probing on every uncached
* every uncached thumbnail request cost a process per card. * thumbnail request cost a process per card.
*/ */
let ffmpegProbe: Promise<boolean> | undefined; let ffmpegProbe: Promise<boolean> | undefined;
/** Checks whether ffmpeg is available. */
function ffmpegAvailable(): Promise<boolean> { function ffmpegAvailable(): Promise<boolean> {
ffmpegProbe ??= (async () => { ffmpegProbe ??= (async () => {
try { try {
@@ -50,21 +54,23 @@ function ffmpegAvailable(): Promise<boolean> {
} }
/** /**
* The container formats ffmpeg may read a video as. The upload's type is only * Lists the container formats ffmpeg may read a video as. The upload's type is
* what the uploader claimed, and ffmpeg probes the real format from the bytes — * only what the uploader claimed, and ffmpeg guesses the format from the bytes,
* a "video" could be an HLS or concat playlist pointing ffmpeg at local files * so a "video" could be an HLS or concat playlist pointing it at local files or
* or internal URLs. Read nothing but the file itself, and only as the kind of * internal URLs. With this list, and `-protocol_whitelist file`, ffmpeg reads
* container it's meant to be. * nothing but the file, and only as a video.
*/ */
const VIDEO_FORMATS = const VIDEO_FORMATS =
"mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc"; "mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc";
/** The formats ffmpeg may read an image as (see {@link VIDEO_FORMATS}). */ /**
* Lists the formats ffmpeg may read an image as, for the reason given at
* {@link VIDEO_FORMATS}.
*/
const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe"; const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe";
/** /**
* The image types the feed shows through a scaled-down preview, and the * Maps the image types that get a scaled-down preview to the preview's format.
* preview's format. GIFs keep their animation (served as they are); anything * A GIF is served as it is, to keep its animation.
* else has no preview to make.
*/ */
const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = { const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
"image/jpeg": "jpg", "image/jpeg": "jpg",
@@ -75,13 +81,13 @@ const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = {
}; };
/** /**
* The width of an image preview: comfortably covers the widest feed card on a * Caps an image preview's width. It covers the widest feed card on a
* high-density screen, a fraction of a phone photo's bytes. * high-density screen, at a fraction of a phone photo's bytes.
*/ */
const PREVIEW_WIDTH = 960; const PREVIEW_WIDTH = 960;
/** /**
* Runs ffmpeg with `args` into `outPath`. * Runs ffmpeg with `args`, writing to `outPath`.
* *
* @throws {Error} When ffmpeg fails. * @throws {Error} When ffmpeg fails.
*/ */
@@ -121,7 +127,7 @@ function videoStill(srcPath: string, outPath: string): Promise<void> {
], outPath); ], outPath);
} }
/** Writes a copy of an image scaled down to {@link PREVIEW_WIDTH}. */ /** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */
function imagePreview(srcPath: string, outPath: string): Promise<void> { function imagePreview(srcPath: string, outPath: string): Promise<void> {
return runFfmpeg([ return runFfmpeg([
"-format_whitelist", "-format_whitelist",
@@ -141,15 +147,14 @@ function imagePreview(srcPath: string, outPath: string): Promise<void> {
} }
/** /**
* The generations under way, by output path: one ffmpeg run per output, so a * Holds the generations under way, by output path. A feed full of cards asking
* feed full of cards asking for the same new still at once waits on a single * for the same new still at once then waits on a single ffmpeg run.
* generation.
*/ */
const inFlight = new Map<string, Promise<void>>(); const inFlight = new Map<string, Promise<void>>();
/** /**
* Makes `outPath` with `generate` unless it exists; false when it can't be * Makes `outPath` with `generate` unless it exists. Resolves false when it
* made (no ffmpeg, or ffmpeg failed). * can't be made, because ffmpeg is missing or failed.
*/ */
async function ensureGenerated( async function ensureGenerated(
outPath: string, outPath: string,
@@ -168,20 +173,22 @@ async function ensureGenerated(
router.get("/:dumpId", async (ctx) => { router.get("/:dumpId", async (ctx) => {
const { dumpId } = ctx.params; const { dumpId } = ctx.params;
// The id names files on disk: nothing but an id's characters gets through.
if (!/^[0-9a-f-]{36}$/.test(dumpId)) { if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID");
} }
// Token via query param (media elements can't send headers) — authorizes the // Media elements can't send an Authorization header, so the viewer's media
// owner's private dumps and gates the custom-thumbnail branch below too. // token comes in the query string. This privacy check covers the custom
// thumbnail too, since getDumpThumbnailInfo makes none.
const token = ctx.request.url.searchParams.get("token"); const token = ctx.request.url.searchParams.get("token");
const viewerId = token ? await authenticateMedia(token) : null; const viewerId = token ? await authenticateMedia(token) : null;
const dump = getDump(dumpId, viewerId ?? undefined); const dump = getDump(dumpId, viewerId ?? undefined);
// The URL may carry a media token; keep it out of anyone else's logs. // The URL may carry a media token, which must not leak through a Referer.
ctx.response.headers.set("Referrer-Policy", "no-referrer"); ctx.response.headers.set("Referrer-Policy", "no-referrer");
// A private dump's thumbnail must not sit in a shared cache. The app's URLs // What is generated from the file is cached for good under a URL naming its
// carry the file's version (see dumpFileVersion): what's generated from the // current version (dumpFileVersion), and revalidated under any other. A
// file is cached for good under the current one, and revalidated otherwise. // private dump's thumbnail must not sit in a shared cache.
const scope = dump.isPrivate ? "private" : "public"; const scope = dump.isPrivate ? "private" : "public";
const generatedCache = const generatedCache =
ctx.request.url.searchParams.get("v") === dumpFileVersion(dump) ctx.request.url.searchParams.get("v") === dumpFileVersion(dump)
@@ -190,8 +197,8 @@ router.get("/:dumpId", async (ctx) => {
const customThumb = getDumpThumbnailInfo(dumpId); const customThumb = getDumpThumbnailInfo(dumpId);
if (customThumb) { if (customThumb) {
// Replaceable without the URL changing: always revalidated (a 304 when // Replacing it doesn't change the file's version, so it is always
// unchanged). // revalidated.
const served = await serveUploadedFile( const served = await serveUploadedFile(
ctx, ctx,
`${THUMBNAILS_DIR}/${customThumb.id}-custom`, `${THUMBNAILS_DIR}/${customThumb.id}-custom`,

View File

@@ -1,6 +1,7 @@
/** /**
* The user and account routes. Public profiles never carry the password hash or * Serves accounts (sign-up, sign-in, tokens, passwords) and user profiles,
* the email. * with what each user posted, upvoted, follows and invited. A profile sent to
* anyone but its user goes through `toPublicUser`, which drops the email.
* *
* - `POST /api/users/register` — public, with a valid invite. Creates an * - `POST /api/users/register` — public, with a valid invite. Creates an
* account, sends the welcome email, and answers the user and their tokens. * account, sends the welcome email, and answers the user and their tokens.
@@ -106,15 +107,18 @@ import {
getFollowedUsersByUser, getFollowedUsersByUser,
} from "../services/follow-service.ts"; } from "../services/follow-service.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router({ prefix: "/api/users" }); const router: Router = new Router({ prefix: "/api/users" });
/** Login attempts per account: a burst of 10, then one every 30s. */ /** Allows a burst of 10 login attempts per account, then one every 30s. */
const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 }; const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 };
/** /**
* Issues `user` a session token, plus the media-only token that goes in file * Returns a new session token and media token for `user`, as sign-up, sign-in
* and thumbnail URLs (see {@link createMediaToken}). * and a password change answer them.
*/ */
async function issueTokens(user: User): Promise<AuthTokens> { async function issueTokens(user: User): Promise<AuthTokens> {
return { return {
@@ -138,8 +142,8 @@ router.post("/register", async (ctx) => {
); );
} }
// Claim the invite up front (throws 404/409 if bad) and hand it back if the // Claimed before the async account creation, so that one invite can't open
// account can't be created, so one invite can't open two accounts. // two accounts, and handed back if the creation fails.
const inviterId = claimInvite(body.inviteToken); const inviterId = claimInvite(body.inviteToken);
let user; let user;
@@ -150,7 +154,7 @@ router.post("/register", async (ctx) => {
throw err; throw err;
} }
// Send welcome email (fire-and-forget) // Not awaited: a slow or failing SMTP server mustn't hold up the sign-up.
if (isEmailEnabled()) { if (isEmailEnabled()) {
const emailMarkdown = WELCOME_EMAIL_BODY const emailMarkdown = WELCOME_EMAIL_BODY
.replaceAll("{{username}}", user.username) .replaceAll("{{username}}", user.username)
@@ -255,6 +259,8 @@ router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => {
ctx.response.body = responseBody; ctx.response.body = responseBody;
}); });
// Answers the same whatever happens, so it can't tell which addresses have an
// account.
router.post("/request-password-reset", async (ctx) => { router.post("/request-password-reset", async (ctx) => {
const body: unknown = await ctx.request.body.json(); const body: unknown = await ctx.request.body.json();
const email = fieldOf(body, "email"); const email = fieldOf(body, "email");
@@ -339,10 +345,9 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => {
"Invalid request", "Invalid request",
); );
} }
// Only the profile fields. The role is assigned by an admin, the password // Only the profile fields: a stolen token alone must not change the role,
// changes through /me/change-password (which asks for the current one), and // the password (which needs the current one) or the username (which is
// the username is fixed — a token alone must not be able to change any of // fixed).
// them.
const updated = await updateUser(ctx.state.user.userId, { const updated = await updateUser(ctx.state.user.userId, {
description: body.description, description: body.description,
email: body.email, email: body.email,
@@ -370,8 +375,7 @@ router.patch(
"Invalid role", "Invalid role",
); );
} }
// Disallow changing your own role: prevents an admin from accidentally // So that an admin can't demote themselves by mistake and leave no admin.
// locking themselves out, and self-promotion is meaningless anyway.
if (ctx.params.userId === ctx.state.user.userId) { if (ctx.params.userId === ctx.state.user.userId) {
throw new APIException( throw new APIException(
APIErrorCode.VALIDATION_ERROR, APIErrorCode.VALIDATION_ERROR,

View File

@@ -1,11 +1,12 @@
/** /**
* The WebSocket route, and the handling of what clients send over it. * Opens the WebSocket that `WSProvider` keeps for each tab, and handles what
* the client sends over it. Voting, comment likes and every chat write go
* through here, not through HTTP. The messages are typed in `shared/ws.ts`,
* and `services/ws-service.ts` sends the broadcasts.
* *
* - `GET /ws?token=` — public; a session `token` signs the socket in, and an * - `GET /ws?token=`, public. A valid session `token` signs the socket in, and
* invalid one leaves it anonymous. Opens the socket that carries presence, * an invalid one leaves it anonymous. Answers 403 to an origin not in
* live updates, votes, comment likes and the chat (see * {@link ALLOWED_ORIGINS}, and 426 to a request that isn't an upgrade.
* `ClientToServerMessage` and `ServerToClientMessage` in shared/ws.ts); 403
* from an origin not in {@link ALLOWED_ORIGINS}.
* *
* @module * @module
*/ */
@@ -62,10 +63,17 @@ import type {
} from "../../shared/ws.ts"; } from "../../shared/ws.ts";
import type { User } from "../model/user.ts"; import type { User } from "../model/user.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const router: Router = new Router(); const router: Router = new Router();
/** Checks whether a socket may be opened from `origin`. */ /**
* Checks whether a socket may be opened from `origin`. Browsers don't apply
* CORS to WebSockets, so this is what stops another site from opening one with
* its visitors' tokens.
*/
function isAllowedOrigin(origin: string): boolean { function isAllowedOrigin(origin: string): boolean {
return ALLOWED_ORIGINS.includes(origin); return ALLOWED_ORIGINS.includes(origin);
} }
@@ -82,9 +90,8 @@ router.get("/ws", async (ctx) => {
return; return;
} }
// authenticate() refuses a token whose user is gone or which predates a // A revoked token (deleted user, changed password) opens an anonymous
// password change, and reads the live role from the DB. Such a socket is // socket. The client signs out on its next API call, which gets a 401.
// simply anonymous; the client's next API call gets the 401.
const token = ctx.request.url.searchParams.get("token"); const token = ctx.request.url.searchParams.get("token");
const authPayload = token ? await authenticate(token) : null; const authPayload = token ? await authenticate(token) : null;
let user: User | undefined; let user: User | undefined;
@@ -104,9 +111,11 @@ router.get("/ws", async (ctx) => {
avatarMime: user?.avatarMime, avatarMime: user?.avatarMime,
}; };
// Use addEventListener — more reliable than onopen= with Deno.serve // addEventListener, since `onopen =` proved unreliable under Deno.serve.
socket.addEventListener("open", () => { socket.addEventListener("open", () => {
register(client); register(client);
// The new socket is registered, so it gets this presence_update too,
// before its welcome.
broadcastPresence(); broadcastPresence();
try { try {
@@ -141,12 +150,16 @@ router.get("/ws", async (ctx) => {
socket.addEventListener("close", () => { socket.addEventListener("close", () => {
unregister(client); unregister(client);
broadcastPresence(); broadcastPresence();
// If they were mid-compose, drop them from the typing set right away. // The sweep only sees registered clients, so it would never clear this one.
if (client.typingAt) broadcastTyping(); if (client.typingAt) broadcastTyping();
}); });
}); });
/** Parses one message from a client and dispatches it by `type`. */ /**
* Parses one message from a client and dispatches it by `type`. A message that
* isn't JSON, or whose `type` is unknown, is ignored. Its fields are still
* untrusted, so each handler checks their types.
*/
function handleMessage(client: WsClient, data: unknown): void { function handleMessage(client: WsClient, data: unknown): void {
if (typeof data !== "string") return; if (typeof data !== "string") return;
let parsed: unknown; let parsed: unknown;
@@ -155,8 +168,6 @@ function handleMessage(client: WsClient, data: unknown): void {
} catch { } catch {
return; return;
} }
// Anything but an object with a string `type` (`null`, a number, an array)
// is ignored rather than read from.
if ( if (
typeof parsed !== "object" || parsed === null || typeof parsed !== "object" || parsed === null ||
typeof (parsed as { type?: unknown }).type !== "string" typeof (parsed as { type?: unknown }).type !== "string"
@@ -187,7 +198,7 @@ function handleMessage(client: WsClient, data: unknown): void {
handleChatSend(client, msg.body, msg.replyToId, msg.nonce); handleChatSend(client, msg.body, msg.replyToId, msg.nonce);
break; break;
case "chat_typing": case "chat_typing":
// Typing is cosmetic: past the limit it's just dropped. // Typing is cosmetic: past the limit it's dropped, with no error.
if ( if (
client.userId && client.userId &&
takeToken(`typing:${client.userId}`, TYPING_LIMIT) takeToken(`typing:${client.userId}`, TYPING_LIMIT)
@@ -210,14 +221,15 @@ function handleMessage(client: WsClient, data: unknown): void {
} }
} }
/** Sends a message only while the socket is open: `send` on a closing socket throws. */ /** Sends `data` unless the socket is closing, where `send` would throw. */
function send(socket: WebSocket, data: ServerToClientMessage): void { function send(socket: WebSocket, data: ServerToClientMessage): void {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data)); if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data));
} }
/** /**
* Casts or removes the client's upvote, acknowledges it, and broadcasts the new * Handles `vote_cast` and `vote_remove`. It acks the voter with `vote_ack` and
* count. * broadcasts `votes_update`. A failure goes back as an `error` naming the dump
* and the action, so the client can undo its optimistic vote at once.
*/ */
function handleVote( function handleVote(
client: WsClient, client: WsClient,
@@ -244,8 +256,7 @@ function handleVote(
voteCount, voteCount,
}); });
// Only a private dump's owner can vote on it, so they're its whole // Only a private dump's owner can see it, and so vote on it.
// audience.
broadcastVoteUpdate( broadcastVoteUpdate(
dumpId, dumpId,
voteCount, voteCount,
@@ -260,8 +271,8 @@ function handleVote(
} }
/** /**
* Casts or removes the client's like on a comment, acknowledges it, and * Handles `comment_like_cast` and `comment_like_remove`, as
* broadcasts the new count. * {@link handleVote} does votes.
*/ */
function handleCommentLike( function handleCommentLike(
client: WsClient, client: WsClient,
@@ -302,20 +313,23 @@ function handleCommentLike(
} }
/** /**
* Chat writes per user. Sending, editing and deleting share one bucket: a * Limits each user's chat writes, sends, edits and deletes together, to a burst
* burst of 8, then one every 1.5s — room for a quick back-and-forth, not for a * of 8, then one every 1.5 s. The buckets are per user, so more tabs don't buy
* flood. * more.
*/ */
const CHAT_WRITE_LIMIT: RateLimit = { capacity: 8, refill: 1 / 1.5 }; const CHAT_WRITE_LIMIT: RateLimit = { capacity: 8, refill: 1 / 1.5 };
/** Typing signals per user. */
const TYPING_LIMIT: RateLimit = { capacity: 10, refill: 1 }; const TYPING_LIMIT: RateLimit = { capacity: 10, refill: 1 };
/** /**
* Read markers per user. They are cheap for the client to send often; past * Limits each user's `chat_read`. One over the limit is dropped, and the next
* this they're dropped, and the next one catches up. * one catches up, since a marker only moves forward.
*/ */
const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 }; const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 };
/** Reports a failed chat write to the client, with the nonce of its pending copy. */ /**
* Sends a chat-scoped `error`. With the `nonce` of a `chat_send`, the chatbox
* marks that pending message failed. Without one, it shows `message` as a
* notice.
*/
function sendChatError( function sendChatError(
client: WsClient, client: WsClient,
message: string, message: string,
@@ -325,8 +339,8 @@ function sendChatError(
} }
/** /**
* Validates the common preconditions of a chat write: signed in and under the * Returns the id of the user behind a chat write, or undefined after sending
* rate limit. Returns the user id, or undefined after reporting why not. * the error when they are signed out or over {@link CHAT_WRITE_LIMIT}.
*/ */
function chatWriter(client: WsClient, nonce?: string): string | undefined { function chatWriter(client: WsClient, nonce?: string): string | undefined {
if (!client.userId) { if (!client.userId) {
@@ -344,7 +358,9 @@ function chatWriter(client: WsClient, nonce?: string): string | undefined {
return client.userId; return client.userId;
} }
/** Returns `value` when it is a string of at most `maxLength`, else undefined. */ /**
* Returns `value` when it is a string of at most `maxLength`, else undefined.
*/
function optionalString(value: unknown, maxLength: number): string | undefined { function optionalString(value: unknown, maxLength: number): string | undefined {
return typeof value === "string" && value.length <= maxLength return typeof value === "string" && value.length <= maxLength
? value ? value
@@ -352,8 +368,8 @@ function optionalString(value: unknown, maxLength: number): string | undefined {
} }
/** /**
* Posts a chat message, broadcasts it, and notifies the @mentioned users not * Handles `chat_send`. It stores the message, broadcasts it, and notifies the
* already reading the chat. * @mentioned users who aren't reading the chat.
*/ */
function handleChatSend( function handleChatSend(
client: WsClient, client: WsClient,
@@ -375,14 +391,10 @@ function handleChatSend(
body, body,
optionalString(replyToId, 64), optionalString(replyToId, 64),
); );
// Sending a message ends composition — clear the typing flag before the // Before the broadcast, so the typing indicator doesn't linger beside the
// message broadcast so the indicator doesn't linger next to it. // message.
setClientTyping(client, false); setClientTyping(client, false);
// Broadcast to everyone including the sender, whose pending copy the nonce
// retires.
broadcastChatMessage(message, nonce); broadcastChatMessage(message, nonce);
// Skip mention notifications for users who already have the chat open —
// they've seen the message live.
notifyMentions( notifyMentions(
userId, userId,
message.body, message.body,
@@ -402,10 +414,9 @@ function handleChatSend(
} }
/** /**
* Checks whether the client's user may moderate the chat. Reads the role from * Checks whether the client's user may edit and delete others' chat messages.
* the DB on every check rather than trusting the one captured at connect: a * The role is read from the database, not from the socket's connect, so that a
* demoted moderator loses the power right away, not whenever their socket next * demoted moderator loses the right at once.
* reconnects.
*/ */
function canModerateChat(client: WsClient): boolean { function canModerateChat(client: WsClient): boolean {
if (!client.userId) return false; if (!client.userId) return false;
@@ -417,7 +428,10 @@ function canModerateChat(client: WsClient): boolean {
return !!client.role && hasPermission(client.role, "chat:moderate"); return !!client.role && hasPermission(client.role, "chat:moderate");
} }
/** Edits a chat message, broadcasts it, and notifies newly @mentioned users. */ /**
* Handles `chat_edit`. It stores the new body, broadcasts it, and notifies the
* users the edit newly @mentions.
*/
function handleChatEdit(client: WsClient, id: unknown, body: unknown): void { function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
const userId = chatWriter(client); const userId = chatWriter(client);
if (!userId) return; if (!userId) return;
@@ -434,8 +448,9 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
canModerateChat(client), canModerateChat(client),
); );
broadcastChatMessageUpdated(message); broadcastChatMessageUpdated(message);
// A newly added @mention in an edit should still notify (deduped per // Mentions already notified for this message are deduplicated, so only
// message+user), skipping anyone already reading the chat. // the new ones notify. After a moderator's edit, they come from the
// moderator.
notifyMentions( notifyMentions(
userId, userId,
message.body, message.body,
@@ -453,7 +468,7 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void {
} }
} }
/** Deletes a chat message and broadcasts it. */ /** Handles `chat_delete`. It deletes the message and broadcasts its id. */
function handleChatDelete(client: WsClient, id: unknown): void { function handleChatDelete(client: WsClient, id: unknown): void {
const userId = chatWriter(client); const userId = chatWriter(client);
if (!userId) return; if (!userId) return;
@@ -474,8 +489,9 @@ function handleChatDelete(client: WsClient, id: unknown): void {
} }
/** /**
* Moves the user's read marker and tells all their sockets, so every tab and * Handles `chat_read`. It moves the user's read marker and sends
* device drops its badge together. * `chat_read_update` to all their sockets, so every tab and device updates its
* badge together.
*/ */
function handleChatRead(client: WsClient, at: unknown): void { function handleChatRead(client: WsClient, at: unknown): void {
if (!client.userId || typeof at !== "string") return; if (!client.userId || typeof at !== "string") return;
@@ -488,7 +504,8 @@ function handleChatRead(client: WsClient, at: unknown): void {
unread: state.unread, unread: state.unread,
}); });
} catch { } catch {
// A malformed marker changes nothing; there's nothing to report. // A malformed marker changes nothing, and the client doesn't wait for an
// answer.
} }
} }

View File

@@ -1,8 +1,10 @@
/** /**
* The YouTube route, for the player's tracklist. * Serves the titles and lengths of a YouTube playlist's entries, which the
* global player lists beside the embed. On any failure it shows the embed
* without a tracklist.
* *
* - `GET /api/youtube/playlist?list=` — public. Resolves a YouTube playlist id * - `GET /api/youtube/playlist?list=` — public. Answers the playlist's
* to its tracklist (titles and lengths). * tracklist.
* *
* @module * @module
*/ */
@@ -12,11 +14,13 @@ import type { APIResponse } from "../../shared/api.ts";
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts"; import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
import type { YouTubePlaylist } from "../../shared/player.ts"; import type { YouTubePlaylist } from "../../shared/player.ts";
/** The router serving the endpoints the module doc lists. */ /**
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
* An `APIException` thrown by a handler becomes the JSON error response there.
*/
const youtubeRouter: Router = new Router(); const youtubeRouter: Router = new Router();
// Public, like /api/bandcamp/tracks: it only reads a public page. The client // Public, like /api/bandcamp/tracks: it only reads a public page.
// treats any failure as "show the embed without a tracklist".
youtubeRouter.get("/api/youtube/playlist", async (ctx) => { youtubeRouter.get("/api/youtube/playlist", async (ctx) => {
const listId = ctx.request.url.searchParams.get("list") ?? ""; const listId = ctx.request.url.searchParams.get("list") ?? "";
const responseBody: APIResponse<YouTubePlaylist> = { const responseBody: APIResponse<YouTubePlaylist> = {

View File

@@ -1,12 +1,10 @@
/** /**
* Generates the favicon set and manifest from the runtime `GERBEUR_SITE_EMOJI`. * Draws the favicons and writes the manifest from {@link SITE_EMOJI} and
* Runs before the server starts, so changing the emoji only requires a restart * {@link OG_SITE_NAME}, so changing them needs a restart and no rebuild.
* — no rebuild. * `deno task icons <dir>` runs it before the server starts, into the directory
* * the frontend is served from, relative to the working directory. That is
* The output directory is passed as the first argument (relative to cwd), and * dist/ for `start` and `serve`, and public/, which Vite serves, for `dev`.
* defaults to `dist`: * It does nothing when the directory doesn't exist.
* - prod (`deno task start` / `serve`): `dist` — what the Oak server serves
* - dev (`deno task dev`): `public` — what the Vite server serves
* *
* @module * @module
*/ */

View File

@@ -1,7 +1,7 @@
/** /**
* Images pasted into Markdown bodies (chat, comments, descriptions): their * Records the images pasted into Markdown bodies, and binds each one to the
* records, and binding them to the resource whose body links them, so the * resource whose body links it. `routes/attachments.ts` stores and serves the
* startup purge keeps them. * files; the services that save a body call {@link linkAttachments}.
* *
* @module * @module
*/ */
@@ -10,7 +10,11 @@ import { db, transaction } from "../db/connection.ts";
import { APIErrorCode } from "../../shared/api.ts"; import { APIErrorCode } from "../../shared/api.ts";
import { APIException } from "../lib/api-exception.ts"; import { APIException } from "../lib/api-exception.ts";
/** Records the upload of attachment `id`, not yet bound to any resource. */ /**
* Records attachment `id` once `POST /api/attachments` has written its file.
* Until a saved body links it, the startup purge deletes it after
* `ORPHANED_ATTACHMENTS_RETENTION_HOURS`.
*/
export function createAttachment(id: string, mime: string): void { export function createAttachment(id: string, mime: string): void {
db.prepare( db.prepare(
"INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)", "INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)",
@@ -18,7 +22,8 @@ export function createAttachment(id: string, mime: string): void {
} }
/** /**
* Returns attachment `attachmentId`'s MIME type. * Returns attachment `attachmentId`'s MIME type, for
* `GET /api/attachments/:attachmentId` to serve its file with.
* *
* @throws {APIException} 404 when there is no such attachment. * @throws {APIException} 404 when there is no such attachment.
*/ */
@@ -38,14 +43,15 @@ export function getAttachment(attachmentId: string): { mime: string } {
return { mime: row.mime }; return { mime: row.mime };
} }
/** An attachment URL in a Markdown body, capturing its id. */
const ATTACHMENT_RE = const ATTACHMENT_RE =
/\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g; /\/api\/attachments\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g;
/** /**
* Binds the unowned attachments whose URLs appear in `text` to * Binds the attachments whose URLs appear in `text` to `resourceId`, so the
* `resourceId`. Only updates rows where resource_id IS NULL so a user cannot * startup purge keeps them. It is called whenever a dump, comment, chat
* claim attachments that already belong to another resource. * message, playlist or profile description is saved. An attachment already
* bound stays with its resource, so pasting another's URL claims nothing, and
* removing the link from a body never unbinds it.
*/ */
export function linkAttachments(text: string, resourceId: string): void { export function linkAttachments(text: string, resourceId: string): void {
const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]); const ids = [...text.matchAll(ATTACHMENT_RE)].map((m) => m[1]);

View File

@@ -1,6 +1,7 @@
/** /**
* Backlinks between dumps: which dumps a dump's description and comments link * Records which dumps a dump's note and comments link to, and finds the dumps
* to, and the dumps related to one through them. * related to one through those links. The dump and comment services keep the
* links up to date, and the dump page lists the related dumps.
* *
* @module * @module
*/ */
@@ -15,21 +16,19 @@ import {
import { UUID_RE } from "../lib/slugify.ts"; import { UUID_RE } from "../lib/slugify.ts";
/** /**
* An http(s) URL in free text — stops at whitespace or markdown-link * Matches an http(s) URL in Markdown. It stops at the delimiters of
* delimiters (so `[text](url)` and `<url>` don't swallow the closing char). * `[text](url)` and `<url>`, which would otherwise end up in the URL.
*/ */
const URL_RE = /https?:\/\/[^\s)\]<>"']+/g; const URL_RE = /https?:\/\/[^\s)\]<>"']+/g;
/** /**
* A link to another dump's page on this same site (e.g. pasted from * Matches the path of a dump's page, `/dumps/{slug-or-id}`, and ignores what
* the address bar) — host-agnostic, since the configured public URL can differ * follows it, so an `/edit` or `#comment-x` link counts too. The host isn't
* between dev/prod/reverse-proxy setups. Matches `/dumps/{slug-or-id}`, * checked, since the site's public URL differs between setups.
* ignoring any trailing path/query/hash (so `/edit` or `#comment-x` links
* still resolve to the same target dump).
*/ */
const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/; const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/;
/** Returns `url` as the URL parser normalises it, or null when it isn't one. */ /** Returns `url` as the URL parser normalizes it, or null when it isn't one. */
function canonicalize(url: string): string | null { function canonicalize(url: string): string | null {
try { try {
return new URL(url).toString(); return new URL(url).toString();
@@ -38,7 +37,7 @@ function canonicalize(url: string): string | null {
} }
} }
/** Resolves a same-site `/dumps/{slug-or-id}` path segment to a dump id. */ /** Returns the id of the dump with id or slug `idOrSlug`, or null. */
function resolveDumpPathId(idOrSlug: string): string | null { function resolveDumpPathId(idOrSlug: string): string | null {
const row = UUID_RE.test(idOrSlug) const row = UUID_RE.test(idOrSlug)
? db.prepare(`SELECT id FROM dumps WHERE id = ?;`).get(idOrSlug) ? db.prepare(`SELECT id FROM dumps WHERE id = ?;`).get(idOrSlug)
@@ -47,11 +46,11 @@ function resolveDumpPathId(idOrSlug: string): string | null {
} }
/** /**
* Re-derives the backlinks contributed by one text source (a dump's own * Replaces the backlinks from one text, a dump's note (`dump_comment`) or one
* `comment` field, or a single comment's `body`) and persist them. Always * of its comments (`comment`), with the links its current `text` holds. A link
* deletes the source's previous contribution first, so editing/clearing the * counts when it is a dump's page on this site or the exact URL a dump was
* text correctly removes stale links — unlike `linkAttachments`/`notifyMentions`, * posted with. The dump and comment services call it on every save, and with
* which are purely additive. * an empty `text` when a comment is deleted.
*/ */
export function relinkBacklinks( export function relinkBacklinks(
sourceType: "dump_comment" | "comment", sourceType: "dump_comment" | "comment",
@@ -88,7 +87,7 @@ export function relinkBacklinks(
for (const row of rows) targetIds.add(row.id); for (const row of rows) targetIds.add(row.id);
} }
targetIds.delete(fromDumpId); // no self-links targetIds.delete(fromDumpId);
transaction(() => { transaction(() => {
db.prepare( db.prepare(
@@ -107,7 +106,8 @@ export function relinkBacklinks(
/** /**
* Returns the dumps linked to or from `dumpId`, newest first and at most 20, * Returns the dumps linked to or from `dumpId`, newest first and at most 20,
* without the private ones the requesting user can't see. * without the private ones the requesting user can't see. It doesn't check
* `dumpId` itself: `GET /api/dumps/:dumpId/related` does that first.
*/ */
export function getRelatedDumps( export function getRelatedDumps(
dumpId: string, dumpId: string,

View File

@@ -1,5 +1,7 @@
/** /**
* Categories: managing them, and which dumps belong to which. * Stores the categories and which dumps belong to them. The category routes
* manage and list them, the dump service files each dump it writes under its
* categories, and every service that lists dumps fills in their `categoryIds`.
* *
* @module * @module
*/ */
@@ -23,7 +25,10 @@ import { type Page, pageOf } from "../lib/pagination.ts";
import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts"; import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts";
import { VALIDATION } from "../../shared/validation.ts"; import { VALIDATION } from "../../shared/validation.ts";
/** Throws a 400 unless `slug` is well-formed, short enough, and not reserved. */ /**
* Throws a 400 unless `slug` is well-formed, short enough and not reserved,
* with a message for each case.
*/
function validateSlug(slug: string): void { function validateSlug(slug: string): void {
if (!isValidSlugFormat(slug)) { if (!isValidSlugFormat(slug)) {
throw new APIException( throw new APIException(
@@ -48,7 +53,6 @@ function validateSlug(slug: string): void {
} }
} }
/** Returns `name` trimmed, or throws a 400 when it is blank or too long. */
function requireName(name: string): string { function requireName(name: string): string {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) { if (!trimmed) {
@@ -65,8 +69,9 @@ function requireName(name: string): string {
} }
/** /**
* Rethrows a SQLite UNIQUE violation as a friendly 409 naming the offending * Rethrows a UNIQUE violation from SQLite as a 409 naming the slug or name
* field, and anything else as is. * that is taken, and any other error as is. SQLite's message names the
* column, so this matches on it.
*/ */
function uniqueConflict(err: unknown, slug: string, name: string): never { function uniqueConflict(err: unknown, slug: string, name: string): never {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
@@ -88,7 +93,7 @@ function uniqueConflict(err: unknown, slug: string, name: string): never {
} }
/** /**
* Returns category `id`. * Returns category `id`, for {@link updateCategory}.
* *
* @throws {APIException} 404 when there is no such category. * @throws {APIException} 404 when there is no such category.
*/ */
@@ -101,7 +106,7 @@ export function getCategoryById(id: string): Category {
} }
/** /**
* Returns the category at `slug`. * Returns the category at `slug`, for `GET /api/categories/:slug`.
* *
* @throws {APIException} 404 when there is no such category. * @throws {APIException} 404 when there is no such category.
*/ */
@@ -113,7 +118,10 @@ export function getCategoryBySlug(slug: string): Category {
return categoryRowToApi(row); return categoryRowToApi(row);
} }
/** Returns the id of the category at `slug`, or undefined when there is none. */ /**
* Returns the id of the category at `slug`, or undefined when there is none.
* The follows routes use it for their `?category=` feed filter.
*/
export function getCategoryIdBySlug(slug: string): string | undefined { export function getCategoryIdBySlug(slug: string): string | undefined {
const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug); const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug);
return row && typeof (row as { id?: unknown }).id === "string" return row && typeof (row as { id?: unknown }).id === "string"
@@ -121,7 +129,9 @@ export function getCategoryIdBySlug(slug: string): string | undefined {
: undefined; : undefined;
} }
/** Returns every category, by position then slug. */ /**
* Returns every category, by position then slug, for `GET /api/categories`.
*/
export function listCategories(): Category[] { export function listCategories(): Category[] {
const rows = db.prepare( const rows = db.prepare(
`SELECT * FROM categories ORDER BY position ASC, slug ASC;`, `SELECT * FROM categories ORDER BY position ASC, slug ASC;`,
@@ -130,7 +140,8 @@ export function listCategories(): Category[] {
} }
/** /**
* Creates a category. * Creates a category, for `POST /api/categories`. The name is stored trimmed,
* and the position defaults to 0.
* *
* @throws {APIException} 400 when the slug or name is invalid, 409 when * @throws {APIException} 400 when the slug or name is invalid, 409 when
* either is taken. * either is taken.
@@ -166,7 +177,8 @@ export function createCategory(req: CreateCategoryRequest): Category {
} }
/** /**
* Applies `req` to category `id`. * Applies `req` to category `id`, for `PUT /api/categories/:id`, and returns
* the category as it now is. Fields `req` leaves out keep their value.
* *
* @throws {APIException} 404 when there is no such category, 400 when the new * @throws {APIException} 404 when there is no such category, 400 when the new
* slug or name is invalid, 409 when either is taken. * slug or name is invalid, 409 when either is taken.
@@ -211,7 +223,8 @@ export function updateCategory(
} }
/** /**
* Deletes category `id`; its dumps stay, outside it. * Deletes category `id`, for `DELETE /api/categories/:id`. Its dumps stay, and
* lose it from their `categoryIds`.
* *
* @throws {APIException} 404 when there is no such category. * @throws {APIException} 404 when there is no such category.
*/ */
@@ -223,9 +236,9 @@ export function deleteCategory(id: string): void {
} }
/** /**
* Checks that every id names an existing category. Callers that write * Checks that every id in `categoryIds` names a category. The dump service
* anything else first (a dump row, an uploaded file) run this before those * runs it before it writes a dump row or stores an uploaded file, so a bad id
* writes, so a bad id can't leave them half-done. * fails the request before anything is written.
* *
* @throws {APIException} 400 when an id names no category. * @throws {APIException} 400 when an id names no category.
*/ */
@@ -248,8 +261,9 @@ export function assertCategoriesExist(categoryIds: string[]): void {
} }
/** /**
* Replaces the set of categories a dump belongs to. An empty list clears all * Replaces the categories of dump `dumpId` with `categoryIds`, duplicates
* category assignments. * dropped. An empty list takes the dump out of every category. Called inside
* the dump service's transaction, it joins that one.
* *
* @throws {APIException} 400 when an id names no category. * @throws {APIException} 400 when an id names no category.
*/ */
@@ -271,7 +285,10 @@ export function setDumpCategories(
}); });
} }
/** Fills `categoryIds` on each dump with a single batched lookup. */ /**
* Sets `categoryIds` on each of `dumps` in place, in one query. The dump row
* doesn't carry them, so every service that returns dumps calls this first.
*/
export function attachCategoryIds(dumps: Dump[]): void { export function attachCategoryIds(dumps: Dump[]): void {
if (dumps.length === 0) return; if (dumps.length === 0) return;
const ids = dumps.map((d) => d.id); const ids = dumps.map((d) => d.id);
@@ -292,8 +309,9 @@ export function attachCategoryIds(dumps: Dump[]): void {
} }
/** /**
* Returns a page of the dumps in the category at `slug`, newest first, with * Returns a page of the dumps in the category at `slug`, newest first, for
* the requesting user's private ones. * `GET /api/categories/:slug/dumps`. Private dumps are left out, except
* `requestingUserId`'s own.
* *
* @throws {APIException} 404 when there is no such category. * @throws {APIException} 404 when there is no such category.
*/ */

View File

@@ -1,5 +1,9 @@
/** /**
* The site-wide chat: messages, their history, and each user's read marker. * Stores the site-wide chat's messages and each user's read marker.
*
* The WebSocket route (`routes/ws.ts`) writes messages and moves read markers,
* then broadcasts the result. `routes/chat.ts` reads the history, a page at a
* time.
* *
* @module * @module
*/ */
@@ -12,36 +16,32 @@ import { db } from "../db/connection.ts";
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
import { VALIDATION } from "../../shared/validation.ts"; import { VALIDATION } from "../../shared/validation.ts";
/** How many messages a page holds when the caller names no limit. */
const DEFAULT_LIMIT = 50; const DEFAULT_LIMIT = 50;
/** The most messages a page may hold. */
const MAX_LIMIT = 100; const MAX_LIMIT = 100;
/** /**
* The length cap of the replied-to preview snippet, so a reply to a long * How much of the replied-to message a reply carries. The client shows one line
* message doesn't ship the whole 2000-char body over the wire for every reply. * of it, once stripped of its Markdown, so the cut leaves room for a long link
* This is raw markdown: the client strips it to text before showing a line of * or image URL before the text.
* it, so it's cut generously enough that an image URL or link doesn't eat it
* all.
*/ */
const REPLY_SNIPPET_LEN = 300; const REPLY_SNIPPET_LEN = 300;
/** The columns of a full `ChatMessageRow`, over {@link FROM_JOINS}. */ /** The columns of a `ChatMessageRow`, to select over {@link FROM_JOINS}. */
const SELECT_COLS = const SELECT_COLS =
`m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id, `m.id, m.user_id, m.body, m.created_at, m.updated_at, m.reply_to_id,
u.username as author_username, u.avatar_mime as author_avatar_mime, u.username as author_username, u.avatar_mime as author_avatar_mime,
ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`; ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`;
/** /**
* The common joins: author (required) plus the optional replied-to message and * Joins a message to its author, and to the message it replies to and that
* its author (LEFT — null when there's no reply or the target was deleted). * message's author. Those two are left joins: a reply outlives its target.
*/ */
const FROM_JOINS = `FROM chat_messages m const FROM_JOINS = `FROM chat_messages m
JOIN users u ON m.user_id = u.id JOIN users u ON m.user_id = u.id
LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id
LEFT JOIN users ru ON rm.user_id = ru.id`; LEFT JOIN users ru ON rm.user_id = ru.id`;
/** Returns message `id`, or throws a 404. */ /** Returns message `id` as the broadcast sends it, or throws a 404. */
function fetchMessage(id: string): ChatMessage { function fetchMessage(id: string): ChatMessage {
const row = db.prepare( const row = db.prepare(
`SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`, `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`,
@@ -53,8 +53,9 @@ function fetchMessage(id: string): ChatMessage {
} }
/** /**
* Posts a message from `userId`, replying to `replyToId` when that message * Stores a message from `userId` on a `chat_send`, and claims the pasted images
* exists, and binds its attachments. * it links to. A `replyToId` naming no message is dropped, and the message is
* posted as no reply. The caller broadcasts the result.
* *
* @throws {APIException} 400 when the body is blank or longer than * @throws {APIException} 400 when the body is blank or longer than
* `VALIDATION.CHAT_MESSAGE_MAX`. * `VALIDATION.CHAT_MESSAGE_MAX`.
@@ -79,8 +80,6 @@ export function createChatMessage(
`Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`, `Message exceeds ${VALIDATION.CHAT_MESSAGE_MAX} characters`,
); );
} }
// Only store the reply target if it actually exists — drop dangling ids so a
// message never references a phantom parent.
let replyTo: string | null = null; let replyTo: string | null = null;
if (replyToId) { if (replyToId) {
const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get( const exists = db.prepare(`SELECT 1 FROM chat_messages WHERE id = ?;`).get(
@@ -93,13 +92,12 @@ export function createChatMessage(
db.prepare( db.prepare(
`INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`, `INSERT INTO chat_messages (id, user_id, body, created_at, reply_to_id) VALUES (?, ?, ?, ?, ?);`,
).run(id, userId, trimmed, createdAt, replyTo); ).run(id, userId, trimmed, createdAt, replyTo);
// Claim any pasted/dropped image attachments so the startup cleanup doesn't // Unclaimed attachments are deleted as orphans at startup.
// purge them as orphans (mirrors dumps/comments/playlists).
linkAttachments(trimmed, id); linkAttachments(trimmed, id);
return fetchMessage(id); return fetchMessage(id);
} }
/** Returns the author of message `id`, or throws a 404. */ /** Returns the id of message `id`'s author, or throws a 404. */
function getMessageOwner(id: string): string { function getMessageOwner(id: string): string {
const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get( const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get(
id, id,
@@ -111,8 +109,9 @@ function getMessageOwner(id: string): string {
} }
/** /**
* Replaces a message's body, as its author or a moderator (`canModerate`), and * Replaces a message's body on a `chat_edit`, and claims any image the new body
* binds any new attachment. * links to. `canModerate` lets a moderator edit anyone's message. The caller
* broadcasts the result.
* *
* @throws {APIException} 404 when there is no such message, 401 when the user * @throws {APIException} 404 when there is no such message, 401 when the user
* is neither its author nor a moderator, 400 when the body is blank or too * is neither its author nor a moderator, 400 when the body is blank or too
@@ -149,13 +148,13 @@ export function updateChatMessage(
} }
db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`) db.prepare(`UPDATE chat_messages SET body = ?, updated_at = ? WHERE id = ?;`)
.run(trimmed, new Date().toISOString(), id); .run(trimmed, new Date().toISOString(), id);
// An edit may introduce a freshly pasted image; claim its attachment too.
linkAttachments(trimmed, id); linkAttachments(trimmed, id);
return fetchMessage(id); return fetchMessage(id);
} }
/** /**
* Deletes a message, as its author or a moderator (`canModerate`). * Deletes a message on a `chat_delete`. `canModerate` lets a moderator delete
* anyone's message. Replies to it keep their `replyToId`, without its preview.
* *
* @throws {APIException} 404 when there is no such message, 401 when the user * @throws {APIException} 404 when there is no such message, 401 when the user
* is neither its author nor a moderator. * is neither its author nor a moderator.
@@ -165,7 +164,7 @@ export function deleteChatMessage(
requestingUserId: string, requestingUserId: string,
canModerate: boolean, canModerate: boolean,
): void { ): void {
const ownerId = getMessageOwner(id); // 404s if the message doesn't exist const ownerId = getMessageOwner(id);
if (ownerId !== requestingUserId && !canModerate) { if (ownerId !== requestingUserId && !canModerate) {
throw new APIException( throw new APIException(
APIErrorCode.UNAUTHORIZED, APIErrorCode.UNAUTHORIZED,
@@ -177,21 +176,23 @@ export function deleteChatMessage(
} }
/** /**
* A position in the chat stream. Messages are ordered by (createdAt, id), so * Passed by `routes/chat.ts` to page through the history, from the message the
* two sharing a millisecond still have a strict order and a page boundary * client already has at one end. Messages are ordered by `createdAt`, then by
* between them can't skip either. * id, so a page boundary between two messages sent in the same millisecond
* skips neither.
*/ */
export interface ChatCursor { export interface ChatCursor {
/** The `created_at` of the message the cursor sits at. */ /** An ISO timestamp, compared as a string with `created_at`. */
createdAt: string; createdAt: string;
/** /**
* The id of that message. Omitted: the cursor sits before (for `after`) or * Without it, the cursor takes in every message at `createdAt`: they are
* after (for `before`) every message sharing that timestamp. * all older for {@link getRecentChatMessages} and all newer for
* {@link getChatMessagesAfter}.
*/ */
id?: string; id?: string;
} }
/** Converts query rows to messages. */ /** Converts query rows to messages, or throws a 500 if one doesn't parse. */
function toMessages(rows: unknown[]): ChatMessage[] { function toMessages(rows: unknown[]): ChatMessage[] {
if (!rows.every(isChatMessageRow)) { if (!rows.every(isChatMessageRow)) {
throw new APIException( throw new APIException(
@@ -203,15 +204,15 @@ function toMessages(rows: unknown[]): ChatMessage[] {
return rows.map(chatMessageRowToApi); return rows.map(chatMessageRowToApi);
} }
/** Clamps a page size to [1, {@link MAX_LIMIT}]. */
function capLimit(limit: number): number { function capLimit(limit: number): number {
return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT); return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT);
} }
/** /**
* Returns up to `limit` most recent messages in ascending (display) order. * Returns the latest `limit` messages, or with `before` the `limit` messages
* When `before` is given, returns the page of older messages immediately * just older than it, oldest first either way. `limit` is clamped to 1–100.
* preceding it — used for "load older" history. *
* @throws {APIException} 500 when a row doesn't parse as a message.
*/ */
export function getRecentChatMessages( export function getRecentChatMessages(
limit = DEFAULT_LIMIT, limit = DEFAULT_LIMIT,
@@ -226,7 +227,7 @@ export function getRecentChatMessages(
).all( ).all(
before.createdAt, before.createdAt,
before.createdAt, before.createdAt,
// No id: nothing at that timestamp sorts below the empty string. // Nothing sorts below "", so no message at that timestamp is taken.
before.id ?? "", before.id ?? "",
capped, capped,
) )
@@ -234,14 +235,15 @@ export function getRecentChatMessages(
`SELECT ${SELECT_COLS} ${FROM_JOINS} `SELECT ${SELECT_COLS} ${FROM_JOINS}
ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`, ORDER BY m.created_at DESC, m.id DESC LIMIT ?;`,
).all(capped); ).all(capped);
// Newest-first from SQL; reverse to ascending for display.
return toMessages(rows).reverse(); return toMessages(rows).reverse();
} }
/** /**
* Returns up to `limit` messages following `after`, in ascending order — used * Returns the first `limit` messages after `after`, oldest first. The chatbox
* to resync the chat after a reconnect. Without an id the cursor is inclusive * uses it to fetch what it missed while the socket was down. `limit` is
* of its timestamp. * clamped to 1–100.
*
* @throws {APIException} 500 when a row doesn't parse as a message.
*/ */
export function getChatMessagesAfter( export function getChatMessagesAfter(
after: ChatCursor, after: ChatCursor,
@@ -255,17 +257,21 @@ export function getChatMessagesAfter(
return toMessages(rows); return toMessages(rows);
} }
/** How far a user has read the chat. */ /**
* Returned to the WebSocket route by {@link getChatReadState} and
* {@link markChatRead}. The route sends it in `welcome` and in
* `chat_read_update`, and the client shows `unread` on the chat's badge.
*/
export interface ChatReadState { export interface ChatReadState {
/** The `created_at` of the newest message the user has seen (ISO). */ /** The `created_at` of the newest message the user has read. */
readAt: string; readAt: string;
/** Messages from other people since then. */ /** Counts only the messages of other users after `readAt`. */
unread: number; unread: number;
} }
/** /**
* Returns how far `userId` has read the chat; a user who never opened it has * Returns how far `userId` has read the chat. A user who never opened it has
* read up to their registration. * read up to their registration, so the history from before it isn't unread.
* *
* @throws {APIException} 404 when there is no such user. * @throws {APIException} 404 when there is no such user.
*/ */
@@ -284,9 +290,10 @@ export function getChatReadState(userId: string): ChatReadState {
} }
/** /**
* Moves the user's read marker forward to `at` (a message's created_at) — * Moves `userId`'s read marker to `at`, a message's `created_at`, on a
* never backward, so a lagging tab can't undo a newer tab's read, and never * `chat_read`. It never moves back, so a lagging tab can't undo a newer tab's
* past the newest message, so a bogus timestamp can't pre-read the future. * read. It never passes the newest message, so a timestamp from the future
* can't mark messages read before they are sent.
* *
* @throws {APIException} 400 when `at` isn't a date, 404 when there is no * @throws {APIException} 400 when `at` isn't a date, 404 when there is no
* such user. * such user.
@@ -303,8 +310,7 @@ export function markChatRead(userId: string, at: string): ChatReadState {
const newest = db.prepare( const newest = db.prepare(
`SELECT MAX(created_at) AS newest FROM chat_messages;`, `SELECT MAX(created_at) AS newest FROM chat_messages;`,
).get() as { newest: string | null }; ).get() as { newest: string | null };
// No messages yet: there's nothing to have read, and no newest message to // With no message to cap it at, a marker from the future would stay stuck.
// cap a bogus far-future marker at — which could never be moved back.
if (!newest.newest) return getChatReadState(userId); if (!newest.newest) return getChatReadState(userId);
const iso = parsed.toISOString(); const iso = parsed.toISOString();
const target = iso > newest.newest ? newest.newest : iso; const target = iso > newest.newest ? newest.newest : iso;

View File

@@ -1,5 +1,9 @@
/** /**
* Likes on comments. * Stores likes on comments and keeps each comment's `like_count` in step with
* them. Likes have no HTTP endpoint to cast or remove. The WebSocket route
* (`routes/ws.ts`) calls this module on `comment_like_cast` and
* `comment_like_remove` messages, then broadcasts the new count. The comment
* routes use it only to list a comment's likers.
* *
* @module * @module
*/ */
@@ -11,16 +15,19 @@ import { db } from "../db/connection.ts";
import { type Page, pageOf } from "../lib/pagination.ts"; import { type Page, pageOf } from "../lib/pagination.ts";
import { notifyCommentOwnerLike } from "./notification-service.ts"; import { notifyCommentOwnerLike } from "./notification-service.ts";
/** A comment's likes after a like was cast or removed. */ /**
* Returned to the WebSocket route by {@link castCommentLike} and
* {@link removeCommentLike}. The route acks the liker with `likeCount`, then
* broadcasts it to every client, or to the liker's alone when the comment's
* dump `isPrivate`.
*/
export interface LikeResult { export interface LikeResult {
/** The comment's like count now. */
likeCount: number; likeCount: number;
/** Whether the comment's dump is private: its likes go to the dump's owner only. */
isPrivate: boolean; isPrivate: boolean;
} }
/** /**
* Returns whether the comment's dump is private, or throws a 404 when the user * Returns whether the comment's dump is private, or throws a 404 when `userId`
* can't see it. * can't see it.
*/ */
function commentVisibility(commentId: string, userId: string): boolean { function commentVisibility(commentId: string, userId: string): boolean {
@@ -35,7 +42,8 @@ function commentVisibility(commentId: string, userId: string): boolean {
} }
/** /**
* Records `userId`'s like on a comment and notifies its author. * Records `userId`'s like on a comment and notifies the comment's author. The
* notification is best-effort: if it fails, the like still counts.
* *
* @throws {APIException} 404 when the comment doesn't exist or sits on someone * @throws {APIException} 404 when the comment doesn't exist or sits on someone
* else's private dump, 409 when the user already likes it. * else's private dump, 409 when the user already likes it.
@@ -72,7 +80,7 @@ export function castCommentLike(
} }
throw err; throw err;
} }
// Notification is best-effort — must not prevent like_ack from being sent // The like is committed: a failed notification must not report it as failed.
try { try {
notifyCommentOwnerLike(userId, commentId); notifyCommentOwnerLike(userId, commentId);
} catch { /* ignore */ } } catch { /* ignore */ }
@@ -80,8 +88,9 @@ export function castCommentLike(
} }
/** /**
* Takes back `userId`'s like on a comment. No visibility check: taking back a * Takes back `userId`'s like on a comment. It doesn't check that the user can
* like is harmless, and it's the user's own row. * still see the comment, since a like on a dump that has gone private is still
* theirs to take back.
* *
* @throws {APIException} 404 when the user doesn't like the comment. * @throws {APIException} 404 when the user doesn't like the comment.
*/ */
@@ -118,7 +127,11 @@ export function removeCommentLike(
} }
} }
/** Returns the ids of every comment `userId` likes. */ /**
* Returns the ids of the comments `userId` likes. The WebSocket route sends
* them in its `welcome` message, and the client shows those like buttons
* pressed.
*/
export function getUserCommentLikes(userId: string): string[] { export function getUserCommentLikes(userId: string): string[] {
const rows = db.prepare( const rows = db.prepare(
`SELECT comment_id FROM comment_likes WHERE user_id = ?;`, `SELECT comment_id FROM comment_likes WHERE user_id = ?;`,
@@ -126,7 +139,13 @@ export function getUserCommentLikes(userId: string): string[] {
return rows.map((r) => r.comment_id); return rows.map((r) => r.comment_id);
} }
/** Returns a page of the users who like a comment, latest first. */ /**
* Returns a page of the users who like a comment, latest like first, for
* `GET /api/comments/:commentId/likers`. It doesn't check that the caller may
* see the comment: the route does that first, with `getDump`.
*
* @throws {APIException} 500 when a row doesn't parse as a user.
*/
export function getCommentLikers( export function getCommentLikers(
commentId: string, commentId: string,
page: number, page: number,

View File

@@ -1,5 +1,7 @@
/** /**
* Comments on dumps, threaded by `parent_id`. * Stores the comments on dumps, threaded by `parent_id`. The comment routes
* call it and broadcast what it returns. It leaves checking that the caller
* may see the dump to them.
* *
* @module * @module
*/ */
@@ -17,12 +19,11 @@ import {
import { linkAttachments } from "./attachment-service.ts"; import { linkAttachments } from "./attachment-service.ts";
import { relinkBacklinks } from "./backlink-service.ts"; import { relinkBacklinks } from "./backlink-service.ts";
/** The columns of a full `CommentRow`, from `comments c` and `users u`. */ /** The select list of a `CommentRow`, from `comments c` joined to `users u`. */
const SELECT_COLS = const SELECT_COLS =
`c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, c.like_count, `c.id, c.dump_id, c.user_id, c.parent_id, c.body, c.created_at, c.updated_at, c.deleted, c.like_count,
u.username as author_username, u.avatar_mime as author_avatar_mime`; u.username as author_username, u.avatar_mime as author_avatar_mime`;
/** Returns comment `commentId`, or throws a 404. */
function fetchComment(commentId: string): Comment { function fetchComment(commentId: string): Comment {
const row = db.prepare( const row = db.prepare(
`SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`, `SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`,
@@ -41,7 +42,8 @@ function fetchComment(commentId: string): Comment {
} }
/** /**
* Returns the id of the dump comment `commentId` is posted under. * Returns the id of the dump comment `commentId` is posted under. The likers
* route uses it to check that the caller may see that dump.
* *
* @throws {APIException} 404 when there is no such comment. * @throws {APIException} 404 when there is no such comment.
*/ */
@@ -56,8 +58,11 @@ export function getCommentDumpId(commentId: string): string {
} }
/** /**
* Returns every comment on dump `dumpId`, deleted ones included, oldest * Returns every comment on dump `dumpId`, oldest first, for
* first. * `GET /api/dumps/:dumpId/comments`. Deleted comments are included, emptied,
* so the client can keep their replies in place.
*
* @throws {APIException} 500 when a row doesn't parse as a comment.
*/ */
export function getComments(dumpId: string): Comment[] { export function getComments(dumpId: string): Comment[] {
const rows = db.prepare( const rows = db.prepare(
@@ -75,9 +80,10 @@ export function getComments(dumpId: string): Comment[] {
} }
/** /**
* Posts a comment on dump `dumpId`, replying to `parentId` if given; notifies * Posts a comment on dump `dumpId`, replying to `parentId` if given, for
* the dump's poster and anyone @mentioned, and binds its attachments and * `POST /api/dumps/:dumpId/comments`. It notifies the dump's poster and the
* backlinks. * users it mentions, and links the comment's attachments and backlinks.
* `parentId` isn't checked to be a comment on the same dump.
*/ */
export function createComment( export function createComment(
dumpId: string, dumpId: string,
@@ -109,8 +115,10 @@ export function createComment(
} }
/** /**
* Replaces a comment's body, as its author or a moderator (`canModerate`); * Replaces a comment's body, for `PATCH /api/comments/:commentId`.
* notifies anyone newly @mentioned, and rebinds its attachments and backlinks. * `canModerate` lets someone other than the author do it. It notifies the users
* it mentions for the first time and relinks the attachments and backlinks. The
* route broadcasts the comment unless `isPrivate`.
* *
* @throws {APIException} 404 when there is no such comment, 400 when it is * @throws {APIException} 404 when there is no such comment, 400 when it is
* deleted, 401 when the user is neither its author nor a moderator. * deleted, 401 when the user is neither its author nor a moderator.
@@ -169,9 +177,10 @@ export function updateComment(
} }
/** /**
* Deletes a comment, as its author or a moderator (`canModerate`): its body is * Deletes a comment, for `DELETE /api/comments/:commentId`. `canModerate` lets
* emptied and its backlinks dropped, but the row stays so its replies keep * someone other than the author do it. The body is emptied and the backlinks
* their place. * dropped, but the row stays so its replies keep their place. The route
* broadcasts the deletion unless `isPrivate`.
* *
* @throws {APIException} 404 when there is no such comment, 401 when the user * @throws {APIException} 404 when there is no such comment, 401 when the user
* is neither its author nor a moderator. * is neither its author nor a moderator.

View File

@@ -1,9 +1,10 @@
/** /**
* Dumps: posting links and files, editing and deleting them, and reading them * Posts, edits and deletes dumps, and reads them as each viewer may see them.
* as each viewer may see them. A private dump exists only for its poster. * `routes/dumps.ts` calls it, and so do the search, user, file and thumbnail
* routes to read dumps. It broadcasts the changes to public dumps over the
* WebSocket.
* *
* The functions that change a dump don't check who asks: the routes do, before * The functions that change a dump don't check who asks: the routes do first.
* calling them.
* *
* @module * @module
*/ */
@@ -59,16 +60,14 @@ import {
setDumpCategories, setDumpCategories,
} from "./category-service.ts"; } from "./category-service.ts";
/** Checks whether a file dump may have type `mime`. */
function isAllowedMime(mime: string): boolean { function isAllowedMime(mime: string): boolean {
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
DUMP_ALLOWED_MIME_TYPES.has(mime); DUMP_ALLOWED_MIME_TYPES.has(mime);
} }
/** /**
* Throws a 400 when a file dump's title or comment is too long. The JSON routes * Throws a 400 when a file dump's title or comment is too long. The multipart
* validate these in isCreateUrlDumpRequest/isUpdateDumpRequest; the multipart * routes that post and replace a file don't check them, unlike the JSON ones.
* ones (file create and replace) arrive here unchecked.
*/ */
function assertFileDumpText(title?: string, comment?: string): void { function assertFileDumpText(title?: string, comment?: string): void {
if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) { if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) {
@@ -87,7 +86,7 @@ function assertFileDumpText(title?: string, comment?: string): void {
} }
} }
/** Returns the title a link gets without a better one: its host. */ /** Returns a link's host name, the title it gets when its page has none. */
function titleFromUrl(url: string): string { function titleFromUrl(url: string): string {
try { try {
return new URL(url).hostname.replace(/^www\./, ""); return new URL(url).hostname.replace(/^www\./, "");
@@ -97,8 +96,10 @@ function titleFromUrl(url: string): string {
} }
/** /**
* Posts a link as `userId`, with the preview fetched from it; a public one is * Posts a link as `userId`, for `POST /api/dumps`, with its preview fetched
* announced and notified to the poster's followers. * from the page. A public dump is broadcast as `dump_created` and notified to
* the poster's followers. Anyone @mentioned in the note is notified even when
* the dump is private.
* *
* @throws {APIException} 400 when the URL isn't a public http(s) URL or a * @throws {APIException} 400 when the URL isn't a public http(s) URL or a
* category doesn't exist. * category doesn't exist.
@@ -171,8 +172,9 @@ export async function createUrlDump(
} }
/** /**
* Posts an uploaded file as `userId`, stored under {@link DUMPS_DIR}; a public * Posts an uploaded file as `userId`, for `POST /api/dumps`, and stores it in
* one is announced and notified to the poster's followers. * `DUMPS_DIR` under the dump's id. It is announced and notified as
* {@link createUrlDump} does.
* *
* @throws {APIException} 400 when the title or comment is too long, the file's * @throws {APIException} 400 when the title or comment is too long, the file's
* type isn't allowed, it exceeds 50 MB, or a category doesn't exist. * type isn't allowed, it exceeds 50 MB, or a category doesn't exist.
@@ -234,7 +236,7 @@ export async function createFileDump(
setDumpCategories(dumpId, dedupedCategoryIds); setDumpCategories(dumpId, dedupedCategoryIds);
}); });
} catch (err) { } catch (err) {
// Roll back the file if DB insert fails // Leave no file behind when the write or the insert fails.
await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {}); await Deno.remove(`${DUMPS_DIR}/${dumpId}`).catch(() => {});
throw err; throw err;
} }
@@ -268,9 +270,10 @@ export async function createFileDump(
} }
/** /**
* Returns a file dump's version as the app puts it in file and thumbnail URLs * Returns a file dump's version, which the app's file and thumbnail URLs carry
* (`?v=`): it changes whenever the file can have. Mirrored by * as `?v=`. It changes whenever the file may have, so the file and thumbnail
* `dumpFileVersion` in src/utils/shared/urls.ts. * routes cache a URL naming the current one for good. The frontend's
* `dumpFileVersion` must compute the same.
*/ */
export function dumpFileVersion( export function dumpFileVersion(
dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">, dump: Pick<Dump, "fileSize" | "createdAt" | "updatedAt">,
@@ -281,9 +284,9 @@ export function dumpFileVersion(
} }
/** /**
* Removes the files generated from a dump's upload (see * Deletes the thumbnails that `routes/thumbnails.ts` generated from a dump's
* api/routes/thumbnails.ts) when it's replaced or gone, so they're made again * upload, once the upload is replaced or deleted. The route makes them again
* from the new one. * from a new one.
*/ */
async function removeGeneratedThumbnails(dumpId: string): Promise<void> { async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
await Promise.all( await Promise.all(
@@ -294,8 +297,8 @@ async function removeGeneratedThumbnails(dumpId: string): Promise<void> {
} }
/** /**
* Returns the dump with id or slug `idOrSlug`, or throws a 404. No privacy * Returns the dump with id or slug `idOrSlug`, without its categories, or
* check: use only when ownership is already enforced. * throws a 404. It doesn't check privacy: the routes do, with {@link getDump}.
*/ */
function fetchDump(idOrSlug: string): Dump { function fetchDump(idOrSlug: string): Dump {
const row = UUID_RE.test(idOrSlug) const row = UUID_RE.test(idOrSlug)
@@ -310,9 +313,11 @@ function fetchDump(idOrSlug: string): Dump {
} }
/** /**
* Returns a dump, by id or slug, as `requestingUserId` may see it. * Returns a dump, by id or slug, as `requestingUserId` may see it. The routes
* resolve every `:dumpId` with it before acting on the dump.
* *
* @throws {APIException} 404 when there is no such dump, or it is private and not theirs. * @throws {APIException} 404 when there is no such dump, or it is private and
* not theirs.
*/ */
export function getDump(dumpId: string, requestingUserId?: string): Dump { export function getDump(dumpId: string, requestingUserId?: string): Dump {
const dump = fetchDump(dumpId); const dump = fetchDump(dumpId);
@@ -324,9 +329,9 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump {
} }
/** /**
* Returns a page of the dumps whose title, comment, or previewed title or * Returns a page of the dumps `requestingUserId` may see whose title, note, or
* description contains `query`, newest first, and how many match in all: the * previewed title or description contains `query`, newest first, with how many
* public ones, and the requesting user's own. * match in all. `GET /api/search` calls it.
*/ */
export function searchDumps( export function searchDumps(
query: string, query: string,
@@ -366,8 +371,10 @@ export function searchDumps(
} }
/** /**
* Returns a page of the dumps, newest first: the public ones, and the * Returns a page of the dumps `requestingUserId` may see, newest first, for the
* requesting user's own. * index feed (`GET /api/dumps`).
*
* @throws {APIException} 500 when a row doesn't parse as a dump.
*/ */
export function listDumps( export function listDumps(
page: number, page: number,
@@ -375,7 +382,6 @@ export function listDumps(
requestingUserId?: string, requestingUserId?: string,
): Page<Dump> { ): Page<Dump> {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Show public dumps + the requesting user's own private dumps
const rows = requestingUserId const rows = requestingUserId
? db.prepare( ? db.prepare(
`SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`, `SELECT ${SELECT_COLS} FROM dumps WHERE (is_private = 0 OR user_id = ?) ORDER BY created_at DESC LIMIT ? OFFSET ?;`,
@@ -398,16 +404,11 @@ export function listDumps(
} }
/** /**
* Returns the dumps that already point at the same thing as `url`, newest * Returns the dumps `requestingUserId` may see that were posted with the same
* first. * link as `url`, newest first, for the create form's duplicate notice
* * (`GET /api/dumps/by-url`). Links are compared in their canonical form, so
* Matched on the canonical key rather than the raw URL, so a link reposted with * tracking parameters, the scheme, `www.` or a YouTube share format don't hide
* different tracking parameters, scheme, `www.`, or (for YouTube) in a * a repost.
* different share shape still counts as the same dump. Private dumps are
* visible only to their owner, exactly as everywhere else.
*
* Returns a small display-only projection: this feeds a hint on the create
* form, not a feed.
*/ */
export function findDumpsByUrl( export function findDumpsByUrl(
url: string, url: string,
@@ -447,12 +448,14 @@ export function findDumpsByUrl(
} }
/** /**
* Applies `request` to a dump: its title, comment, visibility and categories, * Applies `request` to a dump, for `PUT /api/dumps/:dumpId`. A new link has its
* and a URL dump's link (fetching the new preview). Announces the change as the * preview fetched and gives the dump its page's title, unless `request` sets
* visibility requires, and notifies anyone newly @mentioned. * one. A file dump ignores `url`. Clients get the dump as `dump_updated`, or a
* `dump_deleted` when it turns private and a `dump_created` when it turns
* public. Anyone @mentioned in the note is notified.
* *
* @throws {APIException} 404 when there is no such dump, 400 when a new URL isn't a public http(s) URL * @throws {APIException} 404 when there is no such dump, 400 when a new URL
* or a category doesn't exist. * isn't a public http(s) URL or a category doesn't exist.
*/ */
export async function updateDump( export async function updateDump(
dumpId: string, dumpId: string,
@@ -465,7 +468,6 @@ export async function updateDump(
const now = new Date(); const now = new Date();
// File dumps: title, comment and isPrivate are editable
if (dump.kind === "file") { if (dump.kind === "file") {
const title = request.title?.trim() || dump.title; const title = request.title?.trim() || dump.title;
const slug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug; const slug = title !== dump.title ? makeSlug(title, dumpId) : dump.slug;
@@ -516,7 +518,6 @@ export async function updateDump(
return updatedDump; return updatedDump;
} }
// URL dumps
const newUrl = request.url ?? dump.url!; const newUrl = request.url ?? dump.url!;
if (!isValidHttpUrl(newUrl)) { if (!isValidHttpUrl(newUrl)) {
@@ -596,15 +597,15 @@ export async function updateDump(
} }
/** /**
* Replaces a file dump's upload, and with it its comment and optionally its * Replaces a file dump's upload and its comment, for
* title, categories and visibility. `isPrivate` left out, visibility stays as * `PUT /api/dumps/:dumpId/file`. A `title`, `categoryIds` or `isPrivate` left
* it is; it is taken here rather than by a second request so a new file and a * out stays as it is. Visibility is taken here so that a new file and a change
* change of visibility land together. The old file is restored if the update * of visibility land together. The old file is put back if the database update
* fails. * fails, and clients are told as {@link updateDump} tells them.
* *
* @throws {APIException} 404 when there is no such dump; 400 when it isn't a file dump, the title or * @throws {APIException} 404 when there is no such dump, 400 when it isn't a
* comment is too long, the file's type isn't allowed, it exceeds 50 MB, or a * file dump, the title or comment is too long, the file's type isn't allowed,
* category doesn't exist. * it exceeds 50 MB, or a category doesn't exist.
*/ */
export async function replaceFileDump( export async function replaceFileDump(
dumpId: string, dumpId: string,
@@ -681,7 +682,6 @@ export async function replaceFileDump(
} }
}); });
} catch (err) { } catch (err) {
// Put the previous file back on DB failure
if (hadOld) await Deno.rename(backupPath, filePath).catch(() => {}); if (hadOld) await Deno.rename(backupPath, filePath).catch(() => {});
else await Deno.remove(filePath).catch(() => {}); else await Deno.remove(filePath).catch(() => {});
throw err; throw err;
@@ -718,8 +718,11 @@ export async function replaceFileDump(
} }
/** /**
* Returns a page of `userId`'s dumps, newest first, the private ones only with * Returns a page of `userId`'s dumps, newest first, for
* `includePrivate`. * `GET /api/users/:username/dumps`. The route sets `includePrivate` when users
* ask for their own.
*
* @throws {APIException} 500 when a row doesn't parse as a dump.
*/ */
export function getDumpsByUser( export function getDumpsByUser(
userId: string, userId: string,
@@ -745,8 +748,11 @@ export function getDumpsByUser(
} }
/** /**
* Returns a page of the dumps `userId` upvoted, latest vote first — their own * Returns a page of the dumps `userId` upvoted, latest vote first, for
* private ones only when they are the one asking. * `GET /api/users/:username/votes`. Their own private dumps are included only
* when they are the one asking.
*
* @throws {APIException} 500 when a row doesn't parse as a dump.
*/ */
export function getVotedDumpsByUser( export function getVotedDumpsByUser(
userId: string, userId: string,
@@ -759,7 +765,6 @@ export function getVotedDumpsByUser(
let rows: unknown[]; let rows: unknown[];
if (requestingUserId === userId) { if (requestingUserId === userId) {
// Own profile: include private dumps the user themselves voted on and owns.
rows = db.prepare( rows = db.prepare(
`SELECT ${dumpCols} `SELECT ${dumpCols}
FROM dumps d FROM dumps d
@@ -790,10 +795,11 @@ export function getVotedDumpsByUser(
} }
/** /**
* Fetches a URL dump's preview again and replaces its title and preview. * Fetches a URL dump's preview again, bypassing the cache, and replaces its
* title and preview, for `POST /api/dumps/:dumpId/refresh-metadata`.
* *
* @throws {APIException} 404 when there is no such dump, 400 when it isn't a URL dump, 502 when the * @throws {APIException} 404 when there is no such dump, 400 when it isn't a
* page can't be reached. * URL dump, 502 when the page can't be reached.
*/ */
export async function refreshDumpMetadata(dumpId: string): Promise<Dump> { export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
const dump = fetchDump(dumpId); const dump = fetchDump(dumpId);
@@ -806,10 +812,8 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
); );
} }
// A failed fetch yields a stub with no title and no thumbnail. Writing that // A failed fetch yields a stub with no title or thumbnail. Written over the
// over a dump that already has good metadata would silently destroy it and // dump's metadata, it would rename the dump to its host name.
// rename the dump to its bare hostname, so bail out instead — the caller
// surfaces the error and nothing is lost.
const { ok, content: richContent } = await tryFetchRichContent(dump.url, { const { ok, content: richContent } = await tryFetchRichContent(dump.url, {
fresh: true, fresh: true,
}); });
@@ -835,7 +839,9 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> {
} }
/** /**
* Returns a dump's id and custom thumbnail type, or undefined without one. * Returns a dump's id and custom thumbnail type, or undefined without one, for
* the thumbnail route. It doesn't check privacy: the route calls
* {@link getDump} first.
* *
* @throws {APIException} 404 when there is no such dump. * @throws {APIException} 404 when there is no such dump.
*/ */
@@ -849,7 +855,9 @@ export function getDumpThumbnailInfo(
} }
/** /**
* Records a dump's new custom thumbnail, once its file is in place. * Records that a dump has a custom thumbnail of type `mime`, for
* `PUT /api/dumps/:dumpId/thumbnail`. The route writes the file after this
* call returns.
* *
* @throws {APIException} 404 when there is no such dump. * @throws {APIException} 404 when there is no such dump.
*/ */
@@ -865,7 +873,8 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump {
} }
/** /**
* Removes a dump's custom thumbnail, and its file. * Removes a dump's custom thumbnail and its file, for
* `DELETE /api/dumps/:dumpId/thumbnail`.
* *
* @throws {APIException} 404 when there is no such dump. * @throws {APIException} 404 when there is no such dump.
*/ */
@@ -880,7 +889,8 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> {
} }
/** /**
* Deletes a dump, its files and thumbnails, and announces it. * Deletes a dump with its files and thumbnails, for
* `DELETE /api/dumps/:dumpId`, and broadcasts `dump_deleted`.
* *
* @throws {APIException} 404 when there is no such dump. * @throws {APIException} 404 when there is no such dump.
*/ */

View File

@@ -1,5 +1,7 @@
/** /**
* Sending mail through the SMTP server in {@link SMTPS_URL}. * Sends mail through the SMTP server of {@link SMTPS_URL}, which may be unset.
* Its two callers, the welcome email at sign-up and the password reset link,
* check {@link isEmailEnabled} first and don't wait for delivery.
* *
* @module * @module
*/ */
@@ -8,42 +10,43 @@ import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
import { SMTPS_URL } from "../config.ts"; import { SMTPS_URL } from "../config.ts";
/** One address, a comma-separated list of them, or an array. */ /**
* One address, a comma-separated list of them, or an array, as nodemailer
* takes them.
*/
export type EmailRecipient = string | string[]; export type EmailRecipient = string | string[];
/** A message to send. It needs a recipient, a subject, and a text or HTML body. */ /**
* Passed to {@link sendEmail}. It needs a recipient, a subject, and a text or
* HTML body; the callers send the same Markdown as both, rendered for `html`.
*/
export interface EmailMessage { export interface EmailMessage {
/** The sender's address. */
from: string; from: string;
/** The recipients. */
to: EmailRecipient; to: EmailRecipient;
/** The subject line. */
subject: string; subject: string;
/** The plain-text body. */
text?: string; text?: string;
/** The HTML body. */
html?: string; html?: string;
/** The carbon-copy recipients. */
cc?: EmailRecipient; cc?: EmailRecipient;
/** The blind-carbon-copy recipients. */
bcc?: EmailRecipient; bcc?: EmailRecipient;
/** The address replies go to. */
replyTo?: string; replyTo?: string;
} }
/** What the SMTP server said about a sent message. */ /**
* Returned by {@link sendEmail} once the SMTP server has taken the message.
* No caller reads it yet.
*/
export interface EmailSendResult { export interface EmailSendResult {
/** The message's `Message-ID`. */
messageId: string; messageId: string;
/** The recipients the server accepted. */
accepted: string[]; accepted: string[];
/** The recipients the server rejected. */
rejected: string[]; rejected: string[];
} }
let transporter: Transporter | null = null; let transporter: Transporter | null = null;
/** Checks whether an SMTP server is configured. */ /**
* Checks whether an SMTP server is configured. Without one, sign-up sends no
* welcome email and a password reset request does nothing.
*/
export function isEmailEnabled(): boolean { export function isEmailEnabled(): boolean {
return SMTPS_URL.length > 0; return SMTPS_URL.length > 0;
} }
@@ -76,8 +79,8 @@ function getTransporter(): Transporter {
} }
/** /**
* Checks the connection to the SMTP server. Resolves false when email is * Checks the connection to the SMTP server, or resolves false when email is
* disabled. * disabled. Nothing calls it yet.
* *
* @throws {Error} When the server can't be reached or refuses the login. * @throws {Error} When the server can't be reached or refuses the login.
*/ */
@@ -88,7 +91,8 @@ export async function verifyEmailTransport(): Promise<boolean> {
} }
/** /**
* Sends `message`. * Sends `message` and resolves once the SMTP server has accepted it. The
* transport is created on the first send.
* *
* @throws {Error} When email is disabled, the message lacks a recipient, * @throws {Error} When email is disabled, the message lacks a recipient,
* subject or body, or the server refuses it. * subject or body, or the server refuses it.

View File

@@ -1,5 +1,7 @@
/** /**
* Following users and playlists, and the feeds made of what one follows. * Stores who follows which users and playlists, and builds the feeds and
* lists made from it. `routes/follows.ts` serves the caller's own, and
* `routes/users.ts` what any user follows.
* *
* @module * @module
*/ */
@@ -25,11 +27,9 @@ import { isPlaylistRow, playlistRowToApi } from "../model/playlist.ts";
import { type Page, pageOf } from "../lib/pagination.ts"; import { type Page, pageOf } from "../lib/pagination.ts";
import { attachCategoryIds } from "./category-service.ts"; import { attachCategoryIds } from "./category-service.ts";
// ── Follow / unfollow a user ──────────────────────────────────────────────────
/** /**
* Makes `followerId` follow `followedUserId`, and notifies them the first * Makes `followerId` follow `followedUserId`, and notifies the followed user.
* time. Following again changes nothing. * Following again changes nothing.
* *
* @throws {APIException} 400 when a user tries to follow themselves. * @throws {APIException} 400 when a user tries to follow themselves.
*/ */
@@ -41,6 +41,7 @@ export function followUser(followerId: string, followedUserId: string): void {
"Cannot follow yourself", "Cannot follow yourself",
); );
} }
// The unique index answers "already following" without a race.
let isNew = true; let isNew = true;
try { try {
db.prepare( db.prepare(
@@ -70,11 +71,10 @@ export function unfollowUser(followerId: string, followedUserId: string): void {
).run(followerId, followedUserId); ).run(followerId, followedUserId);
} }
// ── Follow / unfollow a playlist ─────────────────────────────────────────────
/** /**
* Makes `followerId` follow a public playlist, and notifies its owner the * Makes `followerId` follow a public playlist, and notifies its owner.
* first time. Following again changes nothing. * Following again changes nothing. A follow outlives the playlist going
* private, but the feeds and lists skip it while it is.
* *
* @throws {APIException} 404 when there is no such playlist, 403 when it is * @throws {APIException} 404 when there is no such playlist, 403 when it is
* private. * private.
@@ -124,9 +124,12 @@ export function unfollowPlaylist(followerId: string, playlistId: string): void {
).run(followerId, playlistId); ).run(followerId, playlistId);
} }
// ── Follow status ───────────────────────────────────────────────────────────── /**
* Returns the ids of every user and playlist `followerId` follows, private
/** Returns the ids of every user and playlist `followerId` follows. */ * playlists included, for `GET /api/follows/status`.
*
* @throws {APIException} 500 when a row doesn't parse.
*/
export function getFollowStatus(followerId: string): FollowStatus { export function getFollowStatus(followerId: string): FollowStatus {
const rawUserRows = db.prepare( const rawUserRows = db.prepare(
`SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at `SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at
@@ -152,11 +155,12 @@ export function getFollowStatus(followerId: string): FollowStatus {
}; };
} }
// ── Followed-users feed ───────────────────────────────────────────────────────
/** /**
* Returns a page of the public dumps posted by the users `followerId` follows, * Returns a page of the public dumps posted by the users `followerId` follows,
* newest first, optionally only those in `categoryId`. * newest first, optionally only those in `categoryId`. It feeds the "users"
* half of the index's "followed" tab.
*
* @throws {APIException} 500 when a row doesn't parse as a dump.
*/ */
export function getFollowedUsersDumpFeed( export function getFollowedUsersDumpFeed(
followerId: string, followerId: string,
@@ -166,7 +170,6 @@ export function getFollowedUsersDumpFeed(
): Page<Dump> { ): Page<Dump> {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Optional category filter: join the membership table and constrain to it.
const catJoin = categoryId const catJoin = categoryId
? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?" ? "INNER JOIN dump_categories dc ON dc.dump_id = d.id AND dc.category_id = ?"
: ""; : "";
@@ -196,11 +199,13 @@ export function getFollowedUsersDumpFeed(
return pageOf(items, limit); return pageOf(items, limit);
} }
// ── Followed-playlists dump feed ──────────────────────────────────────────────
/** /**
* Returns a page of the public dumps in the public playlists `followerId` * Returns a page of the public dumps in the public playlists `followerId`
* follows, most recently added first, optionally only those in `categoryId`. * follows, optionally only those in `categoryId`. It feeds the "playlists"
* half of the index's "followed" tab. A dump in several of them shows once,
* dated by its latest addition.
*
* @throws {APIException} 500 when a row doesn't parse as a dump.
*/ */
export function getFollowedPlaylistsDumpFeed( export function getFollowedPlaylistsDumpFeed(
followerId: string, followerId: string,
@@ -242,11 +247,11 @@ export function getFollowedPlaylistsDumpFeed(
return pageOf(items, limit); return pageOf(items, limit);
} }
// ── Followed playlists (as playlist objects) ──────────────────────────────────
/** /**
* Returns a page of the public playlists `userId` follows, most recently * Returns a page of the public playlists `userId` follows, most recently
* followed first. * followed first, for their profile and playlists pages.
*
* @throws {APIException} 500 when a row doesn't parse as a playlist.
*/ */
export function getFollowedPlaylistsByUser( export function getFollowedPlaylistsByUser(
userId: string, userId: string,
@@ -277,9 +282,13 @@ export function getFollowedPlaylistsByUser(
return pageOf(rawRows.map(playlistRowToApi), limit); return pageOf(rawRows.map(playlistRowToApi), limit);
} }
// ── Followed users (as user objects) ───────────────────────────────────────── /**
* Returns a page of the users `userId` follows, most recently followed first,
/** Returns a page of the users `userId` follows, most recently followed first. */ * for their profile page. The users carry their password hash and email: pass
* them through `toPublicUser` before sending them.
*
* @throws {APIException} 500 when a row doesn't parse as a user.
*/
export function getFollowedUsersByUser( export function getFollowedUsersByUser(
userId: string, userId: string,
page: number, page: number,

View File

@@ -1,5 +1,7 @@
/** /**
* Invites: making them, and checking and claiming one on registration. * Makes invites, and checks and claims them at sign-up. An invite works once,
* for `UNUSED_INVITES_RETENTION_DAYS` days. `routes/invites.ts` makes and
* checks them, and `POST /api/users/register` claims one.
* *
* @module * @module
*/ */
@@ -11,15 +13,15 @@ import { db } from "../db/connection.ts";
import { isInviteRow } from "../model/invite.ts"; import { isInviteRow } from "../model/invite.ts";
import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts"; import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts";
/** /** Returns 128 random bits as 22 URL-safe characters. */
* Returns a short, opaque, URL-safe invite token (128 bits of entropy → 22
* chars).
*/
function generateInviteToken(): string { function generateInviteToken(): string {
return randomBytes(16).toString("base64url"); return randomBytes(16).toString("base64url");
} }
/** Makes an invite from `inviterId` and returns its token. */ /**
* Makes an invite from `inviterId` and returns its token, which the client
* puts in the invite link.
*/
export function createInvite(inviterId: string): string { export function createInvite(inviterId: string): string {
const token = generateInviteToken(); const token = generateInviteToken();
db.prepare( db.prepare(
@@ -29,12 +31,9 @@ export function createInvite(inviterId: string): string {
} }
/** /**
* Looks the token up, checks it has not been used and has not expired (same * Returns the id of the inviter when invite `token` is unused and still in its
* 7-day window as the startup cleanup), and returns the inviter's id. * window, which is the one after which `db/startup.ts` purges it. It doesn't
* * claim the invite: sign-up goes through {@link claimInvite}.
* Tokens are opaque random strings stored as the row's primary key. Invites
* created before this scheme (full JWTs) are still stored verbatim in the
* token column, so they continue to validate by direct lookup.
* *
* @throws {APIException} 404 when the invite doesn't exist or has expired, * @throws {APIException} 404 when the invite doesn't exist or has expired,
* 409 when it has been used. * 409 when it has been used.
@@ -74,10 +73,9 @@ export function validateInvite(token: string): string {
} }
/** /**
* Validates the token and marks it used in one step, returning the inviter's * Marks invite `token` used and returns the inviter's id. Call it before the
* id. * async account creation, and {@link releaseInvite} if that fails: checking
* Claim before the (async) account creation, not after: checking first and * first and marking after would let parallel sign-ups share one invite.
* marking later lets parallel registrations all pass the check on one invite.
* *
* @throws {APIException} 404 when the invite doesn't exist or has expired, * @throws {APIException} 404 when the invite doesn't exist or has expired,
* 409 when it has been used. * 409 when it has been used.
@@ -97,7 +95,10 @@ export function claimInvite(token: string): string {
return inviterId; return inviterId;
} }
/** Hands a claimed invite back when the account it was claimed for failed. */ /**
* Makes a claimed invite usable again, when the sign-up that claimed it
* failed.
*/
export function releaseInvite(token: string): void { export function releaseInvite(token: string): void {
db.prepare(`UPDATE invites SET used_at = NULL WHERE token = ?;`).run(token); db.prepare(`UPDATE invites SET used_at = NULL WHERE token = ?;`).run(token);
} }

View File

@@ -1,7 +1,9 @@
/** /**
* Notifications: storing them, pushing each new one to its user's sockets, * Stores notifications, pushes each new one to its user's sockets as
* and the triggers that create them. A trigger passes a source key, so the same * `notification_created`, and reads them back for `routes/notifications.ts`.
* event never notifies a user twice. * The services of follows, votes, likes, comments, dumps, playlists and the
* chat call its `notify…` triggers. Every trigger passes a source key, so the
* same event never notifies a user twice.
* *
* @module * @module
*/ */
@@ -23,11 +25,9 @@ import { type Page, pageOf } from "../lib/pagination.ts";
import { sendToUser } from "./ws-service.ts"; import { sendToUser } from "./ws-service.ts";
import { MENTION_RE } from "../../shared/mention.ts"; import { MENTION_RE } from "../../shared/mention.ts";
// ── Core CRUD ─────────────────────────────────────────────────────────────────
/** /**
* Stores a notification for `userId` and pushes it live. With a `sourceKey`, * Stores a notification for `userId` and pushes it live. With a `sourceKey`,
* a second one for the same `(userId, sourceKey)` is a no-op. * a second one for the same `(userId, sourceKey)` is neither stored nor sent.
*/ */
function createNotification( function createNotification(
userId: string, userId: string,
@@ -43,7 +43,6 @@ function createNotification(
let changes: number; let changes: number;
if (sourceKey) { if (sourceKey) {
// INSERT OR IGNORE: idempotent — same (user_id, source_key) pair is a no-op
const result = db.prepare( const result = db.prepare(
`INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key) `INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
VALUES (?, ?, ?, ?, 0, ?, ?);`, VALUES (?, ?, ?, ?, 0, ?, ?);`,
@@ -72,7 +71,12 @@ function createNotification(
} }
} }
/** Returns a page of `userId`'s notifications, newest first. */ /**
* Returns a page of `userId`'s notifications, newest first, for
* `GET /api/notifications`.
*
* @throws {APIException} 500 when a row doesn't parse.
*/
export function getNotificationsForUser( export function getNotificationsForUser(
userId: string, userId: string,
page: number, page: number,
@@ -93,7 +97,10 @@ export function getNotificationsForUser(
return pageOf(rawRows.map(notificationRowToApi), limit); return pageOf(rawRows.map(notificationRowToApi), limit);
} }
/** Returns how many of `userId`'s notifications are unread. */ /**
* Returns how many of `userId`'s notifications are unread. The WebSocket route
* sends it in its `welcome` message, for the bell's count.
*/
export function getUnreadCount(userId: string): number { export function getUnreadCount(userId: string): number {
const row = db.prepare( const row = db.prepare(
`SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0;`, `SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0;`,
@@ -101,23 +108,30 @@ export function getUnreadCount(userId: string): number {
return row?.count ?? 0; return row?.count ?? 0;
} }
/** Marks every notification of `userId` read. */ /**
* Marks every notification of `userId` read, when they open the
* `/notifications` page.
*/
export function markAllRead(userId: string): void { export function markAllRead(userId: string): void {
db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run( db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run(
userId, userId,
); );
} }
/** Marks one of `userId`'s notifications read; someone else's is left alone. */ /**
* Marks one of `userId`'s notifications read. An id that isn't theirs changes
* nothing, and says nothing.
*/
export function markOneRead(notificationId: string, userId: string): void { export function markOneRead(notificationId: string, userId: string): void {
db.prepare( db.prepare(
`UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?;`, `UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?;`,
).run(notificationId, userId); ).run(notificationId, userId);
} }
// ── Trigger helpers ─────────────────────────────────────────────────────────── /**
* Notifies `followedUserId` that `followerId` follows them, once however often
/** Notifies `followedUserId` that `followerId` follows them. */ * they unfollow and follow again. `followUser` calls it.
*/
export function notifyUserNewFollower( export function notifyUserNewFollower(
followerId: string, followerId: string,
followedUserId: string, followedUserId: string,
@@ -138,7 +152,8 @@ export function notifyUserNewFollower(
/** /**
* Notifies a playlist's owner that `followerId` follows it, unless they are * Notifies a playlist's owner that `followerId` follows it, unless they are
* the owner. * the owner, and once however often they unfollow and follow again.
* `followPlaylist` calls it.
*/ */
export function notifyPlaylistOwnerNewFollower( export function notifyPlaylistOwnerNewFollower(
followerId: string, followerId: string,
@@ -168,7 +183,10 @@ export function notifyPlaylistOwnerNewFollower(
); );
} }
/** Notifies everyone following `dumperId` that they posted a dump. */ /**
* Notifies everyone following `dumperId` that they posted a dump. The dump
* service calls it for a public dump only, when it is created.
*/
export function notifyUserFollowersNewDump( export function notifyUserFollowersNewDump(
dumperId: string, dumperId: string,
dumpId: string, dumpId: string,
@@ -196,10 +214,8 @@ export function notifyUserFollowersNewDump(
const createdAt = now.toISOString(); const createdAt = now.toISOString();
const sourceKey = `dump:${dumpId}`; const sourceKey = `dump:${dumpId}`;
// Insert per follower so we can broadcast only the rows that were actually // One insert per follower, so that only the rows actually stored are sent,
// created (INSERT OR IGNORE skips followers who already have this dump's // each with its stored id: the client dedupes a refetch against it.
// notification) and reuse each persisted id in the live event — otherwise a
// later refetch would dedupe against a mismatched id and show a duplicate.
const insert = db.prepare( const insert = db.prepare(
`INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key) `INSERT OR IGNORE INTO notifications (id, user_id, type, data, read, created_at, source_key)
VALUES (?, ?, ?, ?, 0, ?, ?);`, VALUES (?, ?, ?, ?, 0, ?, ?);`,
@@ -236,7 +252,11 @@ export function notifyUserFollowersNewDump(
}); });
} }
/** Notifies a dump's poster that `voterId` upvoted it, unless they are the poster. */ /**
* Notifies a dump's poster that `voterId` upvoted it, unless they are the
* poster, and once however often the vote is withdrawn and cast again.
* `castVote` calls it.
*/
export function notifyDumpOwnerUpvote( export function notifyDumpOwnerUpvote(
voterId: string, voterId: string,
dumpId: string, dumpId: string,
@@ -250,7 +270,7 @@ export function notifyDumpOwnerUpvote(
).get(dumpId) as { title: string; user_id: string } | undefined; ).get(dumpId) as { title: string; user_id: string } | undefined;
if (!voterRow || !dumpRow) return; if (!voterRow || !dumpRow) return;
if (voterId === dumpRow.user_id) return; // no self-notification if (voterId === dumpRow.user_id) return;
createNotification( createNotification(
dumpRow.user_id, dumpRow.user_id,
@@ -267,10 +287,10 @@ export function notifyDumpOwnerUpvote(
/** /**
* Notifies every user @mentioned in `body`, except its author, that they were * Notifies every user @mentioned in `body`, except its author, that they were
* mentioned in the given comment, dump, playlist or chat message. Each is * mentioned in the comment, dump, playlist or chat message `contextId`. Call
* notified once per mention context, however often the body is edited. * it again after each edit: a user already notified for this context isn't
* `skipUserId` spares a mentioned user (e.g. one already reading the chat * notified twice. `skipUserId` spares a mentioned user; the chat uses it for
* live, for whom the persistent notification would be redundant). * those who have it open.
*/ */
export function notifyMentions( export function notifyMentions(
mentionerUserId: string, mentionerUserId: string,
@@ -317,7 +337,7 @@ export function notifyMentions(
/** /**
* Notifies a dump's poster that `commenterId` commented on it, unless they are * Notifies a dump's poster that `commenterId` commented on it, unless they are
* the poster. * the poster. `createComment` calls it for replies too, but not on an edit.
*/ */
export function notifyDumpOwnerNewComment( export function notifyDumpOwnerNewComment(
commenterId: string, commenterId: string,
@@ -333,7 +353,7 @@ export function notifyDumpOwnerNewComment(
).get(dumpId) as { title: string; user_id: string } | undefined; ).get(dumpId) as { title: string; user_id: string } | undefined;
if (!commenterRow || !dumpRow) return; if (!commenterRow || !dumpRow) return;
if (commenterId === dumpRow.user_id) return; // no self-notification if (commenterId === dumpRow.user_id) return;
createNotification( createNotification(
dumpRow.user_id, dumpRow.user_id,
@@ -351,7 +371,7 @@ export function notifyDumpOwnerNewComment(
/** /**
* Notifies a comment's author that `likerId` likes it, unless they are the * Notifies a comment's author that `likerId` likes it, unless they are the
* author. * author, and once however often the like is withdrawn and given again.
*/ */
export function notifyCommentOwnerLike( export function notifyCommentOwnerLike(
likerId: string, likerId: string,
@@ -369,7 +389,7 @@ export function notifyCommentOwnerLike(
| undefined; | undefined;
if (!likerRow || !commentRow) return; if (!likerRow || !commentRow) return;
if (likerId === commentRow.user_id) return; // no self-notification if (likerId === commentRow.user_id) return;
createNotification( createNotification(
commentRow.user_id, commentRow.user_id,
@@ -385,7 +405,11 @@ export function notifyCommentOwnerLike(
); );
} }
/** Notifies everyone following a playlist that a dump was added to it. */ /**
* Notifies everyone following a playlist that a dump was added to it. The
* playlist service calls it only when both are public, since a follower can't
* open a private one.
*/
export function notifyPlaylistFollowersNewDump( export function notifyPlaylistFollowersNewDump(
playlistId: string, playlistId: string,
playlistTitle: string, playlistTitle: string,

View File

@@ -1,6 +1,8 @@
/** /**
* Password resets by email: sending a reset link, and setting the new * Emails password reset links and sets the new password when one is used. The
* password with it. * users routes call it for `POST /api/users/request-password-reset` and
* `POST /api/users/reset-password`; the link opens the client's
* `/reset-password` page.
* *
* @module * @module
*/ */
@@ -20,16 +22,20 @@ import { VALIDATION } from "../../shared/validation.ts";
import { marked } from "marked"; import { marked } from "marked";
import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts";
/** How long a reset link works. */ /** Must match the `1h` that `createPasswordResetToken` signs into the token. */
const RESET_TOKEN_TTL_HOURS = 1; const RESET_TOKEN_TTL_HOURS = 1;
/** Per address: enough to retry a lost email, not enough to flood an inbox. */ /**
* Applies per address. It allows a retry after a lost email, but not enough
* requests to flood an inbox.
*/
const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 }; const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 };
/** /**
* Looks up the user by email, creates a signed reset token, persists it, and * Emails a reset link to the account with address `email`, if there is one and
* sends the reset link. Always resolves without throwing so callers can return * email is configured. It says nothing of what it did, so that the route
* 200 unconditionally (no user enumeration). * answers the same whether the address has an account or not. It sends at
* most 3 links in a row per address, then one every 10 minutes.
*/ */
export async function requestPasswordReset(email: string): Promise<void> { export async function requestPasswordReset(email: string): Promise<void> {
if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return; if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return;
@@ -66,8 +72,8 @@ export async function requestPasswordReset(email: string): Promise<void> {
} }
/** /**
* Verifies the token (JWT signature + expiry + DB record + not used), updates * Sets `newPassword` for the user of a reset token and marks the token used.
* the password, and marks the token consumed. * Through `updateUser`, this ends the user's other sessions.
* *
* @throws {APIException} 400 when the password's length is out of bounds or * @throws {APIException} 400 when the password's length is out of bounds or
* the token is invalid or expired, 404 when the token isn't on record or its * the token is invalid or expired, 404 when the token isn't on record or its
@@ -109,8 +115,8 @@ export async function resetPassword(
); );
} }
// Consume the link before the (async) password hash: checking used_at and // Claim the link before the async hash: checking `used_at` first and
// marking it later would let parallel submissions all pass the check. // marking it after would let parallel submissions all pass.
const claimed = db.prepare( const claimed = db.prepare(
`UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL;`, `UPDATE password_reset_tokens SET used_at = ? WHERE token = ? AND used_at IS NULL;`,
).run(new Date().toISOString(), token); ).run(new Date().toISOString(), token);
@@ -125,6 +131,7 @@ export async function resetPassword(
try { try {
await updateUser(payload.userId, { password: newPassword }); await updateUser(payload.userId, { password: newPassword });
} catch (err) { } catch (err) {
// The password didn't change, so the link must keep working.
db.prepare( db.prepare(
`UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`, `UPDATE password_reset_tokens SET used_at = NULL WHERE token = ?;`,
) )

View File

@@ -1,7 +1,8 @@
/** /**
* Playlists: managing them and their dumps, and reading them as each viewer * Stores playlists and the ordered dumps in them. The playlist routes call it,
* may see them. A playlist is public or private to its owner; its members are * and so do the search, users and dumps routes to list playlists. Every write
* filtered per viewer (see {@link isMemberVisibleTo}). * is broadcast over the WebSocket, and a viewer is only ever sent the dumps
* {@link isMemberVisibleTo} lets them see.
* *
* @module * @module
*/ */
@@ -42,16 +43,18 @@ import {
} from "../lib/playlist-members.ts"; } from "../lib/playlist-members.ts";
/** /**
* The columns of a `PlaylistRow` with its owner's name and dump count, * The select list and `FROM` clause of a playlist query, adding the owner's
* and the `FROM playlists p` they come from. * name and the dump count. Callers append the `WHERE`. The dump count includes
* dumps the viewer can't see.
*/ */
const PLAYLIST_SELECT = `p.*, u.username as owner_username, const PLAYLIST_SELECT = `p.*, u.username as owner_username,
(SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count (SELECT COUNT(*) FROM playlist_dumps pd WHERE pd.playlist_id = p.id) as dump_count
FROM playlists p LEFT JOIN users u ON u.id = p.user_id`; FROM playlists p LEFT JOIN users u ON u.id = p.user_id`;
/** /**
* Returns the playlist with id or slug `idOrSlug`, private or not: callers * Returns the playlist with id or slug `idOrSlug`, private or not. A caller
* that show it check its visibility. * that shows it to someone must check `isPublic` first, as the OG middleware
* does.
* *
* @throws {APIException} 404 when there is no such playlist. * @throws {APIException} 404 when there is no such playlist.
*/ */
@@ -66,8 +69,9 @@ export function getPlaylistById(idOrSlug: string): Playlist {
} }
/** /**
* Creates a playlist owned by `userId`, notifies anyone @mentioned in its * Creates a playlist owned by `userId`, for `POST /api/playlists`. It notifies
* description, and announces it. * the users its description mentions, links the attachments it embeds, and
* broadcasts `playlist_created`.
*/ */
export function createPlaylist( export function createPlaylist(
req: CreatePlaylistRequest, req: CreatePlaylistRequest,
@@ -107,9 +111,11 @@ export function createPlaylist(
/** /**
* Returns a playlist with the dumps `requestingUserId` may see in it, in * Returns a playlist with the dumps `requestingUserId` may see in it, in
* order. * order, for `GET /api/playlists/:playlistId`. A null `requestingUserId` is a
* signed-out viewer.
* *
* @throws {APIException} 404 when there is no such playlist, or it is private and not theirs. * @throws {APIException} 404 when there is no such playlist, or it is private
* and not theirs.
*/ */
export function getPlaylist( export function getPlaylist(
playlistId: string, playlistId: string,
@@ -141,7 +147,8 @@ export function getPlaylist(
/** /**
* Returns up to `limit` playlists whose title or description contains * Returns up to `limit` playlists whose title or description contains
* `query`, newest first: the public ones, and the requesting user's own. * `query`, newest first, for `GET /api/search`. Private playlists are left
* out, except `requestingUserId`'s own. A blank `query` finds nothing.
*/ */
export function searchPlaylists( export function searchPlaylists(
query: string, query: string,
@@ -164,8 +171,9 @@ export function searchPlaylists(
} }
/** /**
* Returns a page of `userId`'s playlists, newest first — the private ones only * Returns a page of `userId`'s playlists, newest first, for
* to `userId` themselves. * `GET /api/users/:username/playlists`. The private ones are listed only when
* `requestingUserId` is `userId`.
*/ */
export function listPlaylistsByUser( export function listPlaylistsByUser(
userId: string, userId: string,
@@ -185,11 +193,13 @@ export function listPlaylistsByUser(
} }
/** /**
* Applies `req` to a playlist (its slug follows the title), as its owner or a * Applies `req` to a playlist for `PUT /api/playlists/:playlistId`, and returns
* moderator (`canModerate`); notifies anyone @mentioned in the description, * it as it now is. The slug follows the title. `canModerate` lets someone
* and announces the change. * other than the owner edit it. It notifies the users the description
* mentions, links its attachments and broadcasts `playlist_updated`.
* *
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. * @throws {APIException} 404 when there is no such playlist, 403 when the user
* is neither its owner nor a moderator.
*/ */
export function updatePlaylist( export function updatePlaylist(
playlistId: string, playlistId: string,
@@ -247,10 +257,11 @@ export function updatePlaylist(
} }
/** /**
* Deletes a playlist, as its owner or a moderator (`canModerate`), and * Deletes a playlist for `DELETE /api/playlists/:playlistId`, and broadcasts
* announces it. * `playlist_deleted`. `canModerate` lets someone other than the owner do it.
* *
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. * @throws {APIException} 404 when there is no such playlist, 403 when the user
* is neither its owner nor a moderator.
*/ */
export function deletePlaylist( export function deletePlaylist(
playlistId: string, playlistId: string,
@@ -268,9 +279,12 @@ export function deletePlaylist(
} }
/** /**
* Returns the playlist, if the requester may edit it. * Returns the playlist if `requestingUserId` may edit it. The cover upload
* route checks this before it writes the file, then calls
* {@link setPlaylistImage}.
* *
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. * @throws {APIException} 404 when there is no such playlist, 403 when the user
* is neither its owner nor a moderator.
*/ */
export function getEditablePlaylist( export function getEditablePlaylist(
playlistId: string, playlistId: string,
@@ -285,10 +299,12 @@ export function getEditablePlaylist(
} }
/** /**
* Records a playlist's new cover image and announces it. Call once the image * Records the type of a playlist's new cover and broadcasts `playlist_updated`.
* file is in place: this is what announces it. * The route must have written the file already, since clients fetch the cover
* as soon as they get the broadcast.
* *
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. * @throws {APIException} 404 when there is no such playlist, 403 when the user
* is neither its owner nor a moderator.
*/ */
export function setPlaylistImage( export function setPlaylistImage(
playlistId: string, playlistId: string,
@@ -301,8 +317,8 @@ export function setPlaylistImage(
requestingUserId, requestingUserId,
canModerate, canModerate,
); );
// A new cover is an edit: bumping updated_at also moves the `?v=` the // updated_at is the `?v=` the client puts on the cover URL, so bumping it
// client puts on the image URL, so a cached old cover isn't shown. // keeps browsers from showing the cached old cover.
db.prepare( db.prepare(
`UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`, `UPDATE playlists SET image_mime = ?, updated_at = ? WHERE id = ?;`,
).run(imageMime, new Date().toISOString(), playlist.id); ).run(imageMime, new Date().toISOString(), playlist.id);
@@ -312,11 +328,15 @@ export function setPlaylistImage(
} }
/** /**
* Puts a dump at the top of a playlist, announces the change, and notifies the * Puts a dump at the top of a playlist, for
* followers of a public playlist when the dump is public. * `POST /api/playlists/:playlistId/dumps/:dumpId`, and broadcasts
* `playlist_dumps_updated`. The followers of a public playlist are notified
* when the dump is public.
* *
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator; 404 when the dump doesn't exist or is * @throws {APIException} 404 when there is no such playlist, or when the dump
* someone else's private dump, 409 when it is already in the playlist. * doesn't exist or is someone else's private dump, 403 when the user is
* neither the playlist's owner nor a moderator, 409 when the dump is already
* in it.
*/ */
export function addDumpToPlaylist( export function addDumpToPlaylist(
playlistId: string, playlistId: string,
@@ -330,8 +350,8 @@ export function addDumpToPlaylist(
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
} }
// Only a dump the requester can see — someone else's private dump would // Else someone else's private dump would land in the playlist, and its title
// otherwise land in the playlist and its title in followers' notifications. // in the followers' notifications.
const dump = db.prepare( const dump = db.prepare(
`SELECT title, is_private, user_id FROM dumps WHERE id = ?;`, `SELECT title, is_private, user_id FROM dumps WHERE id = ?;`,
).get(dumpId) as ).get(dumpId) as
@@ -367,7 +387,7 @@ export function addDumpToPlaylist(
broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id)); broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id));
// Followers can't open a private dump, even the owner's own. // Followers can't open a private dump, even one of the owner's own.
if (playlist.isPublic && !dump.is_private) { if (playlist.isPublic && !dump.is_private) {
notifyPlaylistFollowersNewDump( notifyPlaylistFollowersNewDump(
playlist.id, playlist.id,
@@ -379,9 +399,12 @@ export function addDumpToPlaylist(
} }
/** /**
* Takes a dump out of a playlist, if it is in it, and announces the change. * Takes a dump out of a playlist, for
* `DELETE /api/playlists/:playlistId/dumps/:dumpId`, and broadcasts
* `playlist_dumps_updated`. A dump that isn't in it is no error.
* *
* @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. * @throws {APIException} 404 when there is no such playlist, 403 when the user
* is neither its owner nor a moderator.
*/ */
export function removeDumpFromPlaylist( export function removeDumpFromPlaylist(
playlistId: string, playlistId: string,
@@ -403,8 +426,9 @@ export function removeDumpFromPlaylist(
} }
/** /**
* Puts the playlist's members the requester can see in the order of * Puts the dumps of a playlist that the requester can see in the order of
* `dumpIds`, and announces the change. * `dumpIds`, for `PUT /api/playlists/:playlistId/order`, and broadcasts
* `playlist_dumps_updated`.
* *
* @throws {APIException} 404 when there is no such playlist, 403 when the user * @throws {APIException} 404 when there is no such playlist, 403 when the user
* is neither its owner nor a moderator, 400 when `dumpIds` isn't exactly the * is neither its owner nor a moderator, 400 when `dumpIds` isn't exactly the
@@ -422,10 +446,9 @@ export function reorderPlaylist(
throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden"); throw new APIException(APIErrorCode.UNAUTHORIZED, 403, "Forbidden");
} }
// The requester orders what they can see — the playlist as GET gives it to // The requester can only name the dumps GET showed them. A hidden one
// them. A member hidden from them (someone else's dump that went private, // (someone else's dump that went private, or the owner's private dump for a
// or the owner's private dumps for a moderator) can't be named, so it // moderator) keeps its slot, and the visible ones are reordered around it.
// keeps its slot and the visible ones are reordered around it.
const members = getCurrentMembers(playlist.id); const members = getCurrentMembers(playlist.id);
const visibleIds = members const visibleIds = members
.filter((m) => isMemberVisibleTo(m, playlist, requestingUserId)) .filter((m) => isMemberVisibleTo(m, playlist, requestingUserId))
@@ -462,7 +485,10 @@ export function reorderPlaylist(
/** /**
* Returns each of `userId`'s playlists, newest first, and whether it holds * Returns each of `userId`'s playlists, newest first, and whether it holds
* dump `dumpId`. * dump `dumpId`, for `GET /api/playlists/by-dump/:dumpId/memberships`. The
* client's add-to-playlist modal shows it. The playlists have no dump count.
*
* @throws {APIException} 500 when a row doesn't parse as a playlist.
*/ */
export function getPlaylistMembershipsForDump( export function getPlaylistMembershipsForDump(
dumpId: string, dumpId: string,
@@ -492,10 +518,10 @@ export function getPlaylistMembershipsForDump(
} }
/** /**
* Returns the playlists a dump appears in — the "in collections" counterpart to * Returns up to 20 playlists that hold dump `dumpId`, newest first, for
* backlinks. Privacy-filtered like `getRelatedDumps`: public playlists for * `GET /api/dumps/:dumpId/playlists`. Private playlists are left out, except
* everyone, plus the requesting user's own private ones. Capped at 20 — a * `requestingUserId`'s own. The route checks first that the caller may see the
* small, bounded list of supporting context. * dump.
*/ */
export function getPlaylistsForDump( export function getPlaylistsForDump(
dumpId: string, dumpId: string,
@@ -512,8 +538,9 @@ export function getPlaylistsForDump(
} }
/** /**
* Returns a playlist's id, cover image type and visibility, or undefined * Returns what `GET /api/playlists/:playlistId/image` needs to serve a
* without a cover. A private playlist's cover exists only for its owner. * playlist's cover, or undefined when it has none. The route serves a private
* playlist's cover as private, so no shared cache keeps it.
* *
* @throws {APIException} 404 when there is no such playlist, or it is private * @throws {APIException} 404 when there is no such playlist, or it is private
* and `requestingUserId` isn't its owner. * and `requestingUserId` isn't its owner.
@@ -535,7 +562,10 @@ export function getPlaylistImageInfo(
: undefined; : undefined;
} }
/** Returns a playlist's members, in order, with what their visibility takes. */ /**
* Returns a playlist's dumps in order, all of them, for the callers to filter
* per viewer.
*/
function getCurrentMembers(playlistId: string): PlaylistMember[] { function getCurrentMembers(playlistId: string): PlaylistMember[] {
const rows = db.prepare( const rows = db.prepare(
`SELECT pd.dump_id, d.is_private, d.user_id `SELECT pd.dump_id, d.is_private, d.user_id

View File

@@ -1,5 +1,8 @@
/** /**
* Link previews: picks the provider for a URL and caches recent results. * Fetches a link's preview through the provider that knows its site, and
* reuses it for a few minutes. `dump-service.ts` calls it when a link is
* posted, changed or refreshed, and `GET /api/preview` when the create form
* previews one.
* *
* @module * @module
*/ */
@@ -13,9 +16,9 @@ import { selfProvider } from "../remote/providers/self.ts";
import { genericProvider } from "../remote/providers/generic.ts"; import { genericProvider } from "../remote/providers/generic.ts";
/** /**
* The providers, in priority order. The first match wins. * Lists the providers in priority order: the first that matches a URL reads
* `selfProvider` resolves gerbeur URLs directly from the DB (no HTTP round-trip). * it. `selfProvider` reads this site's own pages from the database, and
* `genericProvider` must stay last — it always matches. * `genericProvider` must stay last, since it matches every URL.
*/ */
const providers: RichContentProvider[] = [ const providers: RichContentProvider[] = [
youtubeProvider, youtubeProvider,
@@ -25,44 +28,51 @@ const providers: RichContentProvider[] = [
genericProvider, genericProvider,
]; ];
/** The outcome of fetching a link's preview. */ /**
* Returned by {@link tryFetchRichContent} to `GET /api/preview`, which reports
* `ok` as `reached`, and to `refreshDumpMetadata`, which gives up when it is
* false.
*/
export interface FetchRichContentResult { export interface FetchRichContentResult {
/** /**
* False when the page couldn't be reached at all, as opposed to reached and * False when the page couldn't be reached at all, as opposed to reached and
* found to carry no metadata. Callers that already hold good metadata must * found to carry no metadata. A caller that already holds metadata must not
* not overwrite it with a failure stub. * overwrite it with the failure stub.
*/ */
ok: boolean; ok: boolean;
/** The preview, or on failure a stub with just the URL and host. */ /**
* On failure, a stub holding the URL and its host name. Missing only when
* the URL doesn't parse.
*/
content?: RichContent; content?: RichContent;
} }
/** How long a fetched preview is reused. */ /** How long a fetched preview is reused. */
const RECENT_TTL_MS = 5 * 60 * 1000; const RECENT_TTL_MS = 5 * 60 * 1000;
/** The most previews kept; the oldest write goes first. */ /** How many previews are kept. The oldest written goes first. */
const RECENT_MAX = 200; const RECENT_MAX = 200;
/** /**
* Previews fetched in the last few minutes, by URL. The create form previews a * Holds the previews fetched in the last few minutes, by URL. The create form
* link and then posts it; without this the page was fetched twice, and the * previews a link and then posts it, and without this the second fetch of the
* second fetch was the slow part of posting. Only successes are kept, and a * page was the slow part of posting. Only successes are kept.
* metadata refresh (`fresh`) always goes to the page.
*/ */
const recent = new Map< const recent = new Map<
string, string,
{ at: number; result: Promise<FetchRichContentResult> } { at: number; result: Promise<FetchRichContentResult> }
>(); >();
/** Forgets every cached preview (for the tests). */ /**
* Forgets every cached preview. The tests' `stubNetwork` calls it, since
* previews fetched from the previous stubbed network no longer apply.
*/
export function clearRichContentCache(): void { export function clearRichContentCache(): void {
recent.clear(); recent.clear();
} }
/** /**
* Fetches the preview of `url`, reporting whether the fetch itself succeeded. * Fetches the preview of `url`, and says whether the page could be reached. A
* A preview fetched in the last five minutes is reused unless `fresh` is set. * preview fetched in the last five minutes is reused unless `fresh` is set, as
* * a metadata refresh does. It never rejects: a failure resolves to a stub.
* On failure it still yields a minimal stub so a *new* dump has something
* displayable, but `ok: false` lets callers with existing metadata keep it.
*/ */
export function tryFetchRichContent( export function tryFetchRichContent(
url: string, url: string,
@@ -86,7 +96,10 @@ export function tryFetchRichContent(
return result; return result;
} }
/** Fetches the preview of `url` through the first provider that matches. */ /**
* Fetches the preview of `url` through the first provider that matches, or
* makes the failure stub when the provider rejects.
*/
async function fetchRichContentUncached( async function fetchRichContentUncached(
url: string, url: string,
): Promise<FetchRichContentResult> { ): Promise<FetchRichContentResult> {
@@ -110,7 +123,11 @@ async function fetchRichContentUncached(
} }
} }
/** Returns the preview of `url`, or a minimal stub when it can't be fetched. */ /**
* Returns the preview of `url`, or a stub holding its host name when the page
* can't be reached. `createUrlDump` and `updateDump` use it for a new link,
* which has no earlier preview to keep.
*/
export async function fetchRichContent( export async function fetchRichContent(
url: string, url: string,
): Promise<RichContent | undefined> { ): Promise<RichContent | undefined> {

Some files were not shown because too many files have changed in this diff Show More