From d62e4a98832ec2d21aa49b293486adeab5e2cd97 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sun, 27 Sep 2026 21:07:34 +0000 Subject: [PATCH] =?UTF-8?q?v3:=20the=20backend=20is=20documented=20for=20d?= =?UTF-8?q?eno=20doc,=20one=20way=20in=20every=20file,=20and=20the=20docs?= =?UTF-8?q?=20build=20and=20publish=20themselves=20=E2=80=94=20comments=20?= =?UTF-8?q?were=20in=20three=20shapes:=20file=20headers=20as=20`//`=20line?= =?UTF-8?q?s,=20as=20an=20untagged=20`/**=20*/`=20(which=20deno=20doc=20dr?= =?UTF-8?q?ops,=20only=20an=20`@module`=20block=20counts)=20or=20missing;?= =?UTF-8?q?=2043=20exports=20described=20by=20a=20`//`=20deno=20doc=20can'?= =?UTF-8?q?t=20see,=20two=20JSDoc=20blocks=20sitting=20on=20the=20wrong=20?= =?UTF-8?q?declaration=20and=20one=20duplicated;=20endpoints=20commented?= =?UTF-8?q?=20in=205=20of=2018=20route=20files,=20three=20ways,=20on=20`ro?= =?UTF-8?q?uter.get()`=20calls=20no=20doc=20tool=20reads.=20The=20rule,=20?= =?UTF-8?q?applied=20to=20every=20non-test=20module=20of=20api/,=20migrati?= =?UTF-8?q?ons=20included:=20each=20opens=20with=20a=20`/**=20=E2=80=A6=20?= =?UTF-8?q?@module=20*/`=20block;=20every=20export=20has=20JSDoc,=20and=20?= =?UTF-8?q?so=20does=20every=20field=20of=20an=20exported=20interface;=20a?= =?UTF-8?q?=20comment=20describing=20a=20declaration=20is=20`/**=20*/`,=20?= =?UTF-8?q?`//`=20only=20notes=20code;=20functions=20open=20with=20a=20ver?= =?UTF-8?q?b,=20everything=20else=20with=20a=20noun=20phrase;=20parameters?= =?UTF-8?q?=20are=20named=20in=20the=20prose,=20`{@link}`=20only=20for=20s?= =?UTF-8?q?ymbols=20in=20scope;=20whatever=20throws=20to=20its=20caller=20?= =?UTF-8?q?says=20so=20with=20`@throws`=20(for=20an=20APIException,=20the?= =?UTF-8?q?=20statuses=20and=20when);=20a=20router's=20JSDoc=20lists=20its?= =?UTF-8?q?=20endpoints,=20method=20and=20path,=20who=20may=20call=20it,?= =?UTF-8?q?=20what=20it=20does.=20`deno=20doc=20--lint`=20goes=20from=2052?= =?UTF-8?q?8=20errors=20to=20none=20in=20api/=20(shared/=20keeps=20its=203?= =?UTF-8?q?85;=20it=20is=20next).=20The=20lint=20also=20wanted=20explicit?= =?UTF-8?q?=20types=20on=20config.ts's=20settings=20and=20a=20few=20return?= =?UTF-8?q?=20types,=20`JsonLdResult`=20exported,=20and=20the=20routers=20?= =?UTF-8?q?typed=20`Router`.=20`deno=20task=20doc`=20builds=20the=20HTML?= =?UTF-8?q?=20reference=20into=20docs/=20from=20every=20module=20of=20api/?= =?UTF-8?q?=20and=20shared/=20but=20the=20tests,=20`deno=20task=20doc:lint?= =?UTF-8?q?`=20lints=20it;=20the=20index=20page=20is=20the=20module=20doc?= =?UTF-8?q?=20of=20a=20root=20mod.ts=20(deno.json's=20`exports`),=20which?= =?UTF-8?q?=20introduces=20the=20backend,=20shared/=20and=20the=20frontend?= =?UTF-8?q?=20to=20come=20and=20links=20every=20module,=20and=20mod.test.t?= =?UTF-8?q?s=20fails=20when=20one=20is=20missing.=20The=20docs=20workflow?= =?UTF-8?q?=20builds=20it=20on=20every=20push=20to=20main=20that=20touches?= =?UTF-8?q?=20them=20and=20publishes=20it=20to=20the=20gh-pages=20branch,?= =?UTF-8?q?=20like=20publish-assistant's.=20Two=20defects=20found=20along?= =?UTF-8?q?=20the=20way=20are=20fixed:=20a=20private=20playlist's=20cover?= =?UTF-8?q?=20was=20served=20to=20anyone=20who=20had=20its=20id=20or=20slu?= =?UTF-8?q?g,=20and=20now=20needs=20its=20owner's=20media=20token,=20as=20?= =?UTF-8?q?private=20dump=20files=20do=20(playlistImageUrl=20adds=20it=20f?= =?UTF-8?q?or=20private=20playlists=20only,=20so=20public=20cover=20URLs?= =?UTF-8?q?=20stay=20the=20same=20for=20everyone);=20og.ts=20and=20static.?= =?UTF-8?q?ts=20each=20filled=20index.html's=20placeholders,=20and=20now?= =?UTF-8?q?=20share=20lib/index-html.ts,=20so=20the=20OG=20middleware=20pi?= =?UTF-8?q?cks=20up=20a=20rebuilt=20index.html=20instead=20of=20keeping=20?= =?UTF-8?q?the=20first=20it=20read.=20The=20README=20states=20the=20docume?= =?UTF-8?q?ntation=20rule.=20205=20backend=20and=20628=20frontend=20tests?= =?UTF-8?q?=20pass.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/docs.yaml | 45 +++++ .gitignore | 3 + README.md | 18 ++ api/config.ts | 144 +++++++++++----- api/db/connection.ts | 10 +- api/db/init.ts | 7 + api/db/migrate.ts | 23 ++- api/db/migrations/0001_comment_likes.ts | 13 +- api/db/migrations/0002_dump_thumbnail.ts | 12 +- api/db/migrations/0003_dump_backlinks.ts | 13 +- api/db/migrations/0004_user_roles.ts | 12 +- api/db/migrations/0005_drop_is_admin.ts | 12 +- api/db/migrations/0006_categories.ts | 13 +- .../migrations/0007_password_reset_tokens.ts | 12 +- api/db/migrations/0008_chat_messages.ts | 12 +- api/db/migrations/0009_chat_reply.ts | 20 ++- api/db/migrations/0010_youtube_embed_start.ts | 37 ++-- .../0011_split_favicon_thumbnail.ts | 43 +++-- .../0012_fix_favicon_reclassification.ts | 49 ++++-- api/db/migrations/0013_dump_url_canonical.ts | 37 ++-- api/db/migrations/0014_chat_read_state.ts | 26 +-- api/db/migrations/0015_token_revocation.ts | 20 ++- api/db/migrations/0016_performance_indexes.ts | 30 ++-- api/db/startup.ts | 11 +- api/lib/api-exception.ts | 14 +- api/lib/auth.ts | 12 +- api/lib/canonical-url.ts | 12 +- api/lib/index-html.ts | 38 ++++ api/lib/jwt.ts | 37 +++- api/lib/pagination.ts | 15 +- api/lib/permissions.ts | 7 + api/lib/playlist-members.ts | 11 +- api/lib/rate-limit.ts | 14 +- api/lib/reserved-slugs.ts | 23 ++- api/lib/self-url.ts | 13 +- api/lib/serve-file.ts | 12 +- api/lib/site-icons.ts | 40 +++-- api/lib/slugify.ts | 16 +- api/lib/static.ts | 48 +++--- api/lib/upload.ts | 23 ++- api/main.ts | 11 +- api/middleware/auth.ts | 40 ++++- api/middleware/error.ts | 15 +- api/middleware/og.ts | 53 +++--- api/model/auth.ts | 23 +++ api/model/category.ts | 17 ++ api/model/chat.ts | 36 +++- api/model/comment.ts | 25 +++ api/model/dump.ts | 45 ++++- api/model/follow.ts | 17 ++ api/model/invite.ts | 13 ++ api/model/notification.ts | 21 +++ api/model/password-reset.ts | 13 ++ api/model/playlist.ts | 23 +++ api/model/user.ts | 32 +++- api/remote/bandcamp-streams.ts | 33 ++-- api/remote/bandcamp-tralbum.ts | 16 +- api/remote/fetch.ts | 30 +++- api/remote/html.ts | 70 +++++--- api/remote/image-proxy.ts | 29 +++- api/remote/providers/bandcamp.ts | 7 + api/remote/providers/generic.ts | 11 ++ api/remote/providers/provider.ts | 12 ++ api/remote/providers/self.ts | 18 +- api/remote/providers/soundcloud.ts | 7 + api/remote/providers/youtube.ts | 23 ++- api/remote/public-address.ts | 19 +- api/remote/twemoji.ts | 16 +- api/remote/youtube-playlist.ts | 51 ++++-- api/routes/attachments.ts | 16 +- api/routes/avatars.ts | 16 +- api/routes/bandcamp.ts | 22 ++- api/routes/categories.ts | 21 ++- api/routes/chat.ts | 24 ++- api/routes/comments.ts | 28 ++- api/routes/dumps.ts | 45 ++++- api/routes/files.ts | 30 +++- api/routes/follows.ts | 43 +++-- api/routes/invites.ts | 17 +- api/routes/notifications.ts | 19 +- api/routes/playlists.ts | 57 ++++-- api/routes/preview.ts | 23 ++- api/routes/search.ts | 15 +- api/routes/thumbnails.ts | 67 ++++++-- api/routes/users.ts | 74 +++++--- api/routes/ws.ts | 72 ++++++-- api/routes/youtube.ts | 18 +- api/scripts/generate-icons.ts | 21 ++- api/services/attachment-service.ts | 18 +- api/services/backlink-service.ts | 33 +++- api/services/category-service.ts | 61 ++++++- api/services/chat-service.ts | 78 +++++++-- api/services/comment-like-service.ts | 31 +++- api/services/comment-service.ts | 37 ++++ api/services/dump-service.ts | 120 +++++++++++-- api/services/email-service.ts | 34 ++++ api/services/follow-service.ts | 35 ++++ api/services/invite-service.ts | 24 ++- api/services/notification-service.ts | 42 ++++- api/services/password-reset-service.ts | 18 +- api/services/playlist-service.ts | 107 +++++++++++- api/services/rich-content-service.ts | 46 +++-- api/services/user-service.ts | 66 ++++++- api/services/vote-service.ts | 33 +++- api/services/ws-service.ts | 126 +++++++++++--- api/test/lib/index-html.test.ts | 23 +++ api/test/routes/playlists.test.ts | 25 +++ api/validators/category.ts | 8 + api/validators/comment.ts | 14 ++ api/validators/dump.ts | 16 ++ api/validators/playlist.ts | 16 ++ api/validators/user.ts | 23 ++- deno.json | 9 +- mod.test.ts | 20 +++ mod.ts | 162 ++++++++++++++++++ src/components/playlist/PlaylistCard.tsx | 4 +- src/pages/PlaylistDetail.tsx | 10 +- src/test/utils/shared/urls.test.ts | 10 ++ src/utils/shared/urls.ts | 18 +- 119 files changed, 2967 insertions(+), 611 deletions(-) create mode 100644 .gitea/workflows/docs.yaml create mode 100644 api/lib/index-html.ts create mode 100644 api/test/lib/index-html.test.ts create mode 100644 mod.test.ts create mode 100644 mod.ts diff --git a/.gitea/workflows/docs.yaml b/.gitea/workflows/docs.yaml new file mode 100644 index 00000000..4c28ea5e --- /dev/null +++ b/.gitea/workflows/docs.yaml @@ -0,0 +1,45 @@ +name: Build and deploy docs + +on: + push: + branches: [main] + paths: + - "api/**" + - "shared/**" + - "mod.ts" + - "deno.json" + - "deno.lock" + - ".gitea/workflows/docs.yaml" + workflow_dispatch: + +env: + # Same Deno as the Docker image (Dockerfile). + DENO_VERSION: "2.9.0" + +jobs: + build-and-push: + runs-on: ubuntu-latest + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - uses: denoland/setup-deno@v2 + with: + deno-version: ${{ env.DENO_VERSION }} + + - name: Build docs + # `deno doc` writes relative links, so the site works under whatever + # path the gitea-pages proxy serves it from + # (https://pub.sqrt.fr///). + run: deno task doc + + - name: Deploy docs + uses: peaceiris/actions-gh-pages@v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + force_orphan: true + publish_branch: gh-pages + publish_dir: ./docs diff --git a/.gitignore b/.gitignore index 51d0ed36..73ba27cf 100644 --- a/.gitignore +++ b/.gitignore @@ -178,3 +178,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# deno task doc +docs/ diff --git a/README.md b/README.md index cb1c05a1..999b5e23 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,24 @@ place that fetches from other sites. Tests mirror the source one file per module, so the tests of `services/chat-service.ts` are in `test/services/chat-service.test.ts`. +The backend is documented for `deno doc`, 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 of an exported interface; a +comment that describes a declaration is always `/** */`, and `//` is only for +notes on the code inside. Functions open with a verb ("Returns…", "Checks…"), +everything else with a noun phrase. Parameters are named in the prose, in +backticks. `{@link}` points at 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 JSDoc lists its endpoints, one +line each: method and path, who may call it, what it does. `deno task doc` +builds the HTML reference into `docs/` from every module of `api/` and +`shared/` but the tests. Its index page is the module doc of `mod.ts` at the +root, which introduces the repository's three parts and links every documented +module (`mod.test.ts` checks it), and the `docs` workflow publishes it on every +push to `main` that touches them. `deno task doc:lint` checks +the rules the linter can see; `shared/` is not documented yet, so it still +fails there. + Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is split into the same domain folders — `auth`, `category`, `chat`, `comment`, `dump`, `editor`, `feed`, `form`, `layout`, `player`, `playlist`, `profile`, diff --git a/api/config.ts b/api/config.ts index 99573caf..c0bb00c3 100644 --- a/api/config.ts +++ b/api/config.ts @@ -1,6 +1,21 @@ -export const PORT = Number(Deno.env.get("GERBEUR_PORT")) || 8000; -export const SMTPS_URL = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || ""; -export const FROM_EMAIL = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || ""; +/** + * The server's settings: those read from `GERBEUR_*` environment variables, + * with their defaults, and the fixed limits the backend applies. + * + * @module + */ + +/** The port the server listens on (`GERBEUR_PORT`, default 8000). */ +export const PORT: number = Number(Deno.env.get("GERBEUR_PORT")) || 8000; +/** + * The SMTP server mail is sent through, as an `smtps://` URL + * (`GERBEUR_SMTPS_URL`). Empty disables email. + */ +export const SMTPS_URL: string = Deno.env.get("GERBEUR_SMTPS_URL")?.trim() || + ""; +/** The sender address of outgoing mail (`GERBEUR_FROM_EMAIL`). */ +export const FROM_EMAIL: string = Deno.env.get("GERBEUR_FROM_EMAIL")?.trim() || + ""; const DEFAULT_WELCOME_EMAIL_BODY = `# Welcome to {{site_name}}! @@ -8,53 +23,85 @@ Hi **{{username}}**, Your account has been created successfully. Welcome aboard!`; -export const WELCOME_EMAIL_BODY = +/** + * The Markdown body of the welcome email (`GERBEUR_WELCOME_EMAIL_BODY`), with + * `{{site_name}}` and `{{username}}` placeholders. + */ +export const WELCOME_EMAIL_BODY: string = Deno.env.get("GERBEUR_WELCOME_EMAIL_BODY")?.trim() || DEFAULT_WELCOME_EMAIL_BODY; -export const JWT_SECRET = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || ""; -// GERBEUR_LISTEN_HOST controls the network interface Oak binds to. -// Defaults to 0.0.0.0 so Docker port-forwarding works out of the box. -// Set to 127.0.0.1 to restrict to loopback only. -export const LISTEN_HOST = Deno.env.get("GERBEUR_LISTEN_HOST") || "0.0.0.0"; -// Public-facing URL of the server — used for CORS, WebSocket origin checks, -// email links, and OG image URLs. Behind a reverse proxy, set this to the -// externally-visible URL (e.g. https://example.com). No trailing slash. -// Defaults to http://localhost:PORT for local development. -export const PUBLIC_URL = +/** The key tokens are signed with (`GERBEUR_JWT_SECRET`). Required. */ +export const JWT_SECRET: string = Deno.env.get("GERBEUR_JWT_SECRET")?.trim() || + ""; +/** + * The network interface the server binds to (`GERBEUR_LISTEN_HOST`). Defaults + * to 0.0.0.0 so Docker port-forwarding works out of the box; 127.0.0.1 + * restricts it to loopback. + */ +export const LISTEN_HOST: string = Deno.env.get("GERBEUR_LISTEN_HOST") || + "0.0.0.0"; +/** + * The server's public-facing URL (`GERBEUR_PUBLIC_URL`), without a trailing + * slash — used for CORS, WebSocket origin checks, email links, and OG image + * URLs. Behind a reverse proxy, set it to the externally-visible URL (e.g. + * https://example.com). Defaults to http://localhost:PORT for local + * development. + */ +export const PUBLIC_URL: string = Deno.env.get("GERBEUR_PUBLIC_URL")?.trim().replace(/\/$/, "") || `http://localhost:${PORT}`; -// In single-container deployments the API serves the frontend, so FRONTEND_URL -// equals PUBLIC_URL. Override with GERBEUR_FRONTEND_URL when running the -// frontend on a separate host/port (e.g. Vite dev server or a CDN origin). -export const FRONTEND_URL = Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() || - PUBLIC_URL; -// Overridable so the test suite can run against a throwaway database and -// uploads directory (see api/test/setup.ts). -export const DB_PATH = Deno.env.get("GERBEUR_DB_PATH") || "api/sql/gerbeur.db"; +/** + * The frontend's URL (`GERBEUR_FRONTEND_URL`). In single-container deployments + * the API serves the frontend, so it defaults to {@link PUBLIC_URL}; set it + * when the frontend runs on a separate host or port (e.g. Vite dev server or a + * CDN origin). + */ +export const FRONTEND_URL: string = + Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() || PUBLIC_URL; +/** + * The SQLite database file (`GERBEUR_DB_PATH`). Overridable so the test suite + * can run against a throwaway database (see api/test/setup.ts). + */ +export const DB_PATH: string = Deno.env.get("GERBEUR_DB_PATH") || + "api/sql/gerbeur.db"; // Upload/files -export const UPLOADS_DIR = Deno.env.get("GERBEUR_UPLOADS_DIR") || "api/uploads"; +/** + * The directory uploads are stored under (`GERBEUR_UPLOADS_DIR`), one + * subdirectory per kind. + */ +export const UPLOADS_DIR: string = Deno.env.get("GERBEUR_UPLOADS_DIR") || + "api/uploads"; +/** Where file dumps are stored. */ export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`; +/** Where avatars are stored. */ export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`; +/** Where playlist cover images are stored. */ export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`; +/** Where images pasted into chat, comments and descriptions are stored. */ export const ATTACHMENTS_DIR = `${UPLOADS_DIR}/attachments`; +/** Where custom dump thumbnails are stored. */ export const THUMBNAILS_DIR = `${UPLOADS_DIR}/thumbnails`; -export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB -export const ALLOWED_IMAGE_MIMES = new Set([ +/** The largest image upload accepted: 5 MB. */ +export const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; +/** The image types accepted for avatars, covers, thumbnails and attachments. */ +export const ALLOWED_IMAGE_MIMES: Set = new Set([ "image/jpeg", "image/png", "image/gif", "image/webp", ]); -export const DUMP_ALLOWED_MIME_PREFIXES = [ +/** The MIME type prefixes a file dump may have. */ +export const DUMP_ALLOWED_MIME_PREFIXES: string[] = [ "text/", "image/", "video/", "audio/", ]; -export const DUMP_ALLOWED_MIME_TYPES = new Set([ +/** The exact MIME types a file dump may have, beyond the prefixes. */ +export const DUMP_ALLOWED_MIME_TYPES: Set = new Set([ "application/pdf", "application/json", "application/zip", @@ -68,37 +115,56 @@ export const DUMP_ALLOWED_MIME_TYPES = new Set([ ]); // Pagination +/** The page size when a request names none. */ export const PAGINATION_DEFAULT_LIMIT = 20; +/** The largest page size a request may ask for. */ export const PAGINATION_MAX_LIMIT = 100; // Startup housekeeping +/** How long an attachment nothing links to is kept before startup purges it. */ export const ORPHANED_ATTACHMENTS_RETENTION_HOURS = 1; +/** How long an unused invite is kept before startup purges it. */ export const UNUSED_INVITES_RETENTION_DAYS = 7; // SEO/OG -export const OG_SITE_NAME = Deno.env.get("GERBEUR_SITE_NAME") || "gerbeur"; +/** The site's name (`GERBEUR_SITE_NAME`), in page titles, OG tags and mail. */ +export const OG_SITE_NAME: string = Deno.env.get("GERBEUR_SITE_NAME") || + "gerbeur"; -// Emoji the favicon set + app header logo are generated/rendered from. -// Applied at runtime (server startup + index.html injection), so changing it -// only needs a restart — no rebuild. -export const SITE_EMOJI = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚"; +/** + * The emoji the favicon set and app header logo are generated from + * (`GERBEUR_SITE_EMOJI`). Applied at runtime (server startup and index.html + * injection), so changing it only needs a restart — no rebuild. + */ +export const SITE_EMOJI: string = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚"; -// Which Bandcamp playback path the frontend uses. -// "embed" — the bandcamp.com iframe (cannot autoplay) -// "native" — resolve the page's mp3 streams and play them in the app's own -// player, which autoplays and supports albums as a queue -// Anything unrecognised falls back to "embed", so a typo degrades safely. +/** + * A Bandcamp playback path: + * - `"embed"` — the bandcamp.com iframe (cannot autoplay) + * - `"native"` — resolve the page's mp3 streams and play them in the app's own + * player, which autoplays and supports albums as a queue + */ export type BandcampPlayer = "embed" | "native"; +/** + * The Bandcamp playback path the frontend uses (`GERBEUR_BANDCAMP_PLAYER`). + * Anything unrecognised falls back to `"embed"`, so a typo degrades safely. + */ export const BANDCAMP_PLAYER: BandcampPlayer = Deno.env.get("GERBEUR_BANDCAMP_PLAYER")?.trim() === "native" ? "native" : "embed"; -// Background color for generated icons and the manifest. Mirrors the -// hard-coded theme-color in index.html. +/** + * The background color of generated icons and the manifest. Mirrors the + * hard-coded theme-color in index.html. + */ export const THEME_COLOR = "#111827"; const rawOrigins = Deno.env.get("GERBEUR_ALLOWED_ORIGINS") ?? ""; +/** + * The origins CORS and the WebSocket accept: {@link PUBLIC_URL}, + * {@link FRONTEND_URL} and the comma-separated `GERBEUR_ALLOWED_ORIGINS`. + */ export const ALLOWED_ORIGINS: string[] = Array.from( new Set([ PUBLIC_URL, diff --git a/api/db/connection.ts b/api/db/connection.ts index c078b082..74048335 100644 --- a/api/db/connection.ts +++ b/api/db/connection.ts @@ -1,9 +1,17 @@ +/** + * The database handle. Importing this module opens the database at + * {@link DB_PATH}, applies the pending migrations and runs the startup tasks. + * + * @module + */ + import { DatabaseSync } from "node:sqlite"; import { DB_PATH } from "../config.ts"; import { runMigrations } from "./migrate.ts"; import { runStartupTasks } from "./startup.ts"; -export const db = new DatabaseSync(DB_PATH); +/** The one connection every service queries through. */ +export const db: DatabaseSync = new DatabaseSync(DB_PATH); // 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, // never corrupt the file). busy_timeout covers the init/migration scripts and diff --git a/api/db/init.ts b/api/db/init.ts index ff3f7e4b..c5d6daa5 100644 --- a/api/db/init.ts +++ b/api/db/init.ts @@ -1,3 +1,10 @@ +/** + * Creates the database from schema.sql when {@link DB_PATH} doesn't exist yet; + * run once before the server starts (`deno task start`). + * + * @module + */ + import { DatabaseSync } from "node:sqlite"; import { DB_PATH } from "../config.ts"; diff --git a/api/db/migrate.ts b/api/db/migrate.ts index 04e36e53..4e663f6f 100644 --- a/api/db/migrate.ts +++ b/api/db/migrate.ts @@ -1,3 +1,10 @@ +/** + * Brings an existing database up to date with the migrations under + * migrations/, recording each applied one in `schema_migrations`. + * + * @module + */ + import type { DatabaseSync } from "node:sqlite"; import { up as up0001CommentLikes } from "./migrations/0001_comment_likes.ts"; import { up as up0002DumpThumbnail } from "./migrations/0002_dump_thumbnail.ts"; @@ -21,9 +28,12 @@ interface Migration { up: (db: DatabaseSync) => void; } -// Append-only — never edit or reorder a migration once it has shipped. -// Each `up` must be idempotent: it also runs against fresh databases created -// straight from schema.sql, where the change it makes already exists. +/** + * Every migration, in order. Append-only — never edit or reorder a migration + * once it has shipped. Each `up` must be idempotent: it also runs against + * fresh databases created straight from schema.sql, where the change it makes + * already exists. + */ const MIGRATIONS: Migration[] = [ { name: "0001_comment_likes", up: up0001CommentLikes }, { name: "0002_dump_thumbnail", up: up0002DumpThumbnail }, @@ -46,6 +56,13 @@ const MIGRATIONS: Migration[] = [ { name: "0016_performance_indexes", up: up0016PerformanceIndexes }, ]; +/** + * Applies each migration `db` hasn't recorded yet, each in its own + * transaction. + * + * @throws {Error} When a migration fails; that migration is rolled back and + * the later ones aren't run. + */ export function runMigrations(db: DatabaseSync): void { db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( name TEXT PRIMARY KEY, diff --git a/api/db/migrations/0001_comment_likes.ts b/api/db/migrations/0001_comment_likes.ts index 714e44b9..59adca68 100644 --- a/api/db/migrations/0001_comment_likes.ts +++ b/api/db/migrations/0001_comment_likes.ts @@ -1,7 +1,16 @@ +/** + * Adds comment likes: the `comment_likes` table and the `comments.like_count` + * counter. + * + * 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 + */ + import type { DatabaseSync } from "node:sqlite"; -// Idempotent: safe to run against a fresh db (already created from schema.sql -// with these columns/tables) or an existing one that predates them. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const commentCols = db.prepare(`PRAGMA table_info(comments);`).all() as { name: string; diff --git a/api/db/migrations/0002_dump_thumbnail.ts b/api/db/migrations/0002_dump_thumbnail.ts index 50e9e35b..f71363d9 100644 --- a/api/db/migrations/0002_dump_thumbnail.ts +++ b/api/db/migrations/0002_dump_thumbnail.ts @@ -1,7 +1,15 @@ +/** + * Adds `dumps.custom_thumbnail_mime`, for thumbnails uploaded by the poster. + * + * Idempotent: safe to run against a fresh db (already created from schema.sql + * with this column) or an existing one that predates it. + * + * @module + */ + import type { DatabaseSync } from "node:sqlite"; -// Idempotent: safe to run against a fresh db (already created from schema.sql -// with this column) or an existing one that predates it. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const dumpCols = db.prepare(`PRAGMA table_info(dumps);`).all() as { name: string; diff --git a/api/db/migrations/0003_dump_backlinks.ts b/api/db/migrations/0003_dump_backlinks.ts index affa8487..0cc17c01 100644 --- a/api/db/migrations/0003_dump_backlinks.ts +++ b/api/db/migrations/0003_dump_backlinks.ts @@ -1,7 +1,16 @@ +/** + * Adds the `dump_backlinks` table, recording which dumps, comments and + * descriptions link to a dump, and indexes `dumps.url`. + * + * Idempotent: safe to run against a fresh db (already created from schema.sql + * with these) or an existing one that predates them. + * + * @module + */ + import type { DatabaseSync } from "node:sqlite"; -// Idempotent: safe to run against a fresh db (already created from schema.sql -// with these) or an existing one that predates them. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { db.exec(`CREATE TABLE IF NOT EXISTS dump_backlinks ( source_type TEXT NOT NULL, diff --git a/api/db/migrations/0004_user_roles.ts b/api/db/migrations/0004_user_roles.ts index 9563662b..861364ad 100644 --- a/api/db/migrations/0004_user_roles.ts +++ b/api/db/migrations/0004_user_roles.ts @@ -1,7 +1,15 @@ +/** + * Adds `users.role` and makes every legacy `is_admin` user an admin. + * + * 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 + */ + import type { DatabaseSync } from "node:sqlite"; -// Idempotent: safe to run against a fresh db (already created from schema.sql -// with the `role` column) or an existing one that predates it. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const userCols = db.prepare(`PRAGMA table_info(users);`).all() as { name: string; diff --git a/api/db/migrations/0005_drop_is_admin.ts b/api/db/migrations/0005_drop_is_admin.ts index aab2423d..0fea739c 100644 --- a/api/db/migrations/0005_drop_is_admin.ts +++ b/api/db/migrations/0005_drop_is_admin.ts @@ -1,8 +1,14 @@ +/** + * Drops the legacy is_admin column now that `role` is the single source of + * 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 + */ + import type { DatabaseSync } from "node:sqlite"; -// Drops the legacy is_admin column now that `role` is the single source of -// truth. Idempotent: skips when the column is already gone (fresh databases -// created from schema.sql never had it). Must run after 0004 has backfilled. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const userCols = db.prepare(`PRAGMA table_info(users);`).all() as { name: string; diff --git a/api/db/migrations/0006_categories.ts b/api/db/migrations/0006_categories.ts index 37e3a201..6f9aabe1 100644 --- a/api/db/migrations/0006_categories.ts +++ b/api/db/migrations/0006_categories.ts @@ -1,7 +1,16 @@ +/** + * Adds categories: 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 + */ + import type { DatabaseSync } from "node:sqlite"; -// Idempotent: safe to run against a fresh db (already created from schema.sql -// with the categories tables) or an existing one that predates them. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const tables = new Set( (db.prepare( diff --git a/api/db/migrations/0007_password_reset_tokens.ts b/api/db/migrations/0007_password_reset_tokens.ts index 343de0c4..316ef0a2 100644 --- a/api/db/migrations/0007_password_reset_tokens.ts +++ b/api/db/migrations/0007_password_reset_tokens.ts @@ -1,7 +1,15 @@ +/** + * Adds the `password_reset_tokens` table. + * + * 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 + */ + import type { DatabaseSync } from "node:sqlite"; -// 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. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const tables = new Set( (db.prepare( diff --git a/api/db/migrations/0008_chat_messages.ts b/api/db/migrations/0008_chat_messages.ts index a6fea611..03133a4e 100644 --- a/api/db/migrations/0008_chat_messages.ts +++ b/api/db/migrations/0008_chat_messages.ts @@ -1,7 +1,15 @@ +/** + * Adds the `chat_messages` table. + * + * Idempotent: safe to run against a fresh db (already created from schema.sql + * with this table) or an existing one that predates it. + * + * @module + */ + import type { DatabaseSync } from "node:sqlite"; -// Idempotent: safe to run against a fresh db (already created from schema.sql -// with this table) or an existing one that predates it. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { db.exec(`CREATE TABLE IF NOT EXISTS chat_messages ( id TEXT NOT NULL PRIMARY KEY, diff --git a/api/db/migrations/0009_chat_reply.ts b/api/db/migrations/0009_chat_reply.ts index 3b0226e0..bc361883 100644 --- a/api/db/migrations/0009_chat_reply.ts +++ b/api/db/migrations/0009_chat_reply.ts @@ -1,12 +1,18 @@ +/** + * Adds an optional self-reference so a chat message can reply to another. + * Kept as a plain column (no FK): when the replied-to message is later deleted + * the id is retained but the join yields null, letting the UI show a subtle + * "deleted message" reference instead of breaking the insert. + * + * Idempotent: also runs against fresh databases created from schema.sql, where + * the column already exists. + * + * @module + */ + import type { DatabaseSync } from "node:sqlite"; -// Adds an optional self-reference so a chat message can reply to another. -// Kept as a plain column (no FK): when the replied-to message is later deleted -// the id is retained but the join yields null, letting the UI show a subtle -// "deleted message" reference instead of breaking the insert. -// -// Idempotent: also runs against fresh databases created from schema.sql, where -// the column already exists. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as { name: string; diff --git a/api/db/migrations/0010_youtube_embed_start.ts b/api/db/migrations/0010_youtube_embed_start.ts index 661956eb..a1370db9 100644 --- a/api/db/migrations/0010_youtube_embed_start.ts +++ b/api/db/migrations/0010_youtube_embed_start.ts @@ -1,21 +1,25 @@ +/** + * Backfills the `start` parameter on stored YouTube embed URLs. + * + * `rich_content.embedUrl` is computed once, at dump creation, and previously + * dropped the timestamp carried by the source URL (`?t=1265`, `?t=1h2m3s`), so + * those videos always opened at 0:00 in the global player. The provider now + * 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 + */ + import type { DatabaseSync } from "node:sqlite"; -// Backfills the `start` parameter on stored YouTube embed URLs. -// -// `rich_content.embedUrl` is computed once, at dump creation, and previously -// dropped the timestamp carried by the source URL (`?t=1265`, `?t=1h2m3s`), so -// those videos always opened at 0:00 in the global player. The provider now -// 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. - -/** Parse `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */ +/** Parses `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */ function extractStartSeconds(url: string): number | null { let raw: string | null; try { @@ -34,6 +38,7 @@ function extractStartSeconds(url: string): number | null { Number(hms[3] ?? 0); } +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const rows = db.prepare( `SELECT id, url, rich_content FROM dumps diff --git a/api/db/migrations/0011_split_favicon_thumbnail.ts b/api/db/migrations/0011_split_favicon_thumbnail.ts index 2a506eb5..6bfaa965 100644 --- a/api/db/migrations/0011_split_favicon_thumbnail.ts +++ b/api/db/migrations/0011_split_favicon_thumbnail.ts @@ -1,25 +1,29 @@ +/** + * Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new + * `faviconUrl` field. + * + * The extraction cascade used to end at the page's icon and then at a guessed + * `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just + * held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72 + * 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 + */ + import type { DatabaseSync } from "node:sqlite"; -// Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new -// `faviconUrl` field. -// -// The extraction cascade used to end at the page's icon and then at a guessed -// `${origin}/favicon.ico`, so `thumbnailUrl` was almost never empty — it just -// held a 16×16 icon (or a 404) that the UI then cover-cropped into a 128×72 -// 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. - /** - * Whether a stored thumbnail URL is really a site icon. + * Checks whether a stored thumbnail URL is really a site icon. * * Deliberately loose. A false positive (a genuine cover image living under * `/assets/icons/`) renders contained on a tinted field instead of @@ -37,6 +41,7 @@ function isIconUrl(raw: string): boolean { /\.(ico|svg)$/i.test(pathname); } +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const rows = db.prepare( `SELECT id, rich_content FROM dumps diff --git a/api/db/migrations/0012_fix_favicon_reclassification.ts b/api/db/migrations/0012_fix_favicon_reclassification.ts index 438cdf64..0126206f 100644 --- a/api/db/migrations/0012_fix_favicon_reclassification.ts +++ b/api/db/migrations/0012_fix_favicon_reclassification.ts @@ -1,23 +1,30 @@ +/** + * Repairs 0011, which was too eager about what counts as a site icon. + * + * 0011 tested for "favicon" anywhere in the path, so a legitimate `og:image` + * like mirtitles.org's `/wp-content/uploads/2022/04/mir-logo-favicon.png` was + * 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 + */ + import type { DatabaseSync } from "node:sqlite"; -// Repairs 0011, which was too eager about what counts as a site icon. -// -// 0011 tested for "favicon" anywhere in the path, so a legitimate `og:image` -// like mirtitles.org's `/wp-content/uploads/2022/04/mir-logo-favicon.png` was -// 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. - -/** 0011's rule, kept verbatim so its false positives can be identified. */ +/** + * Applies 0011's rule, kept verbatim so its false positives can be + * identified. + */ function wasIconUrl0011(raw: string): boolean { let pathname: string; try { @@ -29,7 +36,10 @@ function wasIconUrl0011(raw: string): boolean { /\.(ico|svg)$/i.test(pathname); } -/** Whether a URL names a site icon, judged on its filename and directory. */ +/** + * Checks whether a URL names a site icon, judged on its filename and + * directory. + */ function isIconUrl(raw: string): boolean { let pathname: string; try { @@ -45,6 +55,7 @@ function isIconUrl(raw: string): boolean { return /^(favicon|apple-touch-icon|icon)(?=$|[-_.\d])/i.test(basename); } +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const rows = db.prepare( `SELECT id, rich_content FROM dumps diff --git a/api/db/migrations/0013_dump_url_canonical.ts b/api/db/migrations/0013_dump_url_canonical.ts index 6539f03a..2781252a 100644 --- a/api/db/migrations/0013_dump_url_canonical.ts +++ b/api/db/migrations/0013_dump_url_canonical.ts @@ -1,20 +1,24 @@ -import type { DatabaseSync } from "node:sqlite"; +/** + * Adds `dumps.url_canonical` — the lookup key behind the "already dumped?" + * check on the create form — and backfills it for every existing URL dump. + * + * Purely local: it re-derives the key from the URL already stored on each row + * and makes no network calls. + * + * The helpers below are a deliberate frozen copy of `api/lib/canonical-url.ts` + * as it stood when this migration shipped, not an import of it. A migration + * 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 + */ -// Adds `dumps.url_canonical` — the lookup key behind the "already dumped?" -// check on the create form — and backfills it for every existing URL dump. -// -// Purely local: it re-derives the key from the URL already stored on each row -// and makes no network calls. -// -// The helpers below are a deliberate frozen copy of `api/lib/canonical-url.ts` -// as it stood when this migration shipped, not an import of it. A migration -// 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. +import type { DatabaseSync } from "node:sqlite"; const TRACKING_PARAMS = new Set([ "fbclid", @@ -94,6 +98,7 @@ function canonicalizeUrl(raw: string): string | null { return `https://${host}${path}${query ? `?${query}` : ""}${hash}`; } +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as { name: string; diff --git a/api/db/migrations/0014_chat_read_state.ts b/api/db/migrations/0014_chat_read_state.ts index f72b42f1..e7ba3ba0 100644 --- a/api/db/migrations/0014_chat_read_state.ts +++ b/api/db/migrations/0014_chat_read_state.ts @@ -1,15 +1,21 @@ +/** + * Chat unread state lives on the server so it survives reloads and agrees + * across tabs and devices: `users.chat_read_at` is the created_at of the newest + * message the user has seen. Existing users start caught up (now) rather than + * 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 + * millisecond can't be skipped at a page boundary; the index follows suit. + * + * Idempotent: also runs against fresh databases created from schema.sql, where + * the column and index already exist. + * + * @module + */ + import type { DatabaseSync } from "node:sqlite"; -// Chat unread state lives on the server so it survives reloads and agrees -// across tabs and devices: `users.chat_read_at` is the created_at of the newest -// message the user has seen. Existing users start caught up (now) rather than -// 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 -// millisecond can't be skipped at a page boundary; the index follows suit. -// -// Idempotent: also runs against fresh databases created from schema.sql, where -// the column and index already exist. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const cols = db.prepare(`PRAGMA table_info(users);`).all() as { name: string; diff --git a/api/db/migrations/0015_token_revocation.ts b/api/db/migrations/0015_token_revocation.ts index a2e0f0ed..62f283a1 100644 --- a/api/db/migrations/0015_token_revocation.ts +++ b/api/db/migrations/0015_token_revocation.ts @@ -1,12 +1,18 @@ +/** + * Changing or resetting a password must end every session opened before it. + * Tokens are stateless JWTs, so each user carries a cut-off instead: + * `users.tokens_valid_after` (ISO), and a token issued before it is refused. + * 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 + */ + import type { DatabaseSync } from "node:sqlite"; -// Changing or resetting a password must end every session opened before it. -// Tokens are stateless JWTs, so each user carries a cut-off instead: -// `users.tokens_valid_after` (ISO), and a token issued before it is refused. -// 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. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { const cols = db.prepare(`PRAGMA table_info(users);`).all() as { name: string; diff --git a/api/db/migrations/0016_performance_indexes.ts b/api/db/migrations/0016_performance_indexes.ts index 6895204b..dbffa314 100644 --- a/api/db/migrations/0016_performance_indexes.ts +++ b/api/db/migrations/0016_performance_indexes.ts @@ -1,17 +1,23 @@ +/** + * Indexes for the queries that ran as full scans: + * - feeds page by created_at (newest first), a user's dumps and upvotes by + * (user_id, created_at) — these replace the bare user_id indexes; + * - dump and playlist pages are looked up by slug; + * - the child side of every ON DELETE CASCADE / SET NULL foreign key, which + * SQLite otherwise scans on each parent delete (a comment's replies, a user's + * 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 + */ + import type { DatabaseSync } from "node:sqlite"; -// Indexes for the queries that ran as full scans: -// - feeds page by created_at (newest first), a user's dumps and upvotes by -// (user_id, created_at) — these replace the bare user_id indexes; -// - dump and playlist pages are looked up by slug; -// - the child side of every ON DELETE CASCADE / SET NULL foreign key, which -// SQLite otherwise scans on each parent delete (a comment's replies, a user's -// 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. +/** Applies this migration to `db`. */ export function up(db: DatabaseSync): void { db.exec(` DROP INDEX IF EXISTS idx_dumps_user; diff --git a/api/db/startup.ts b/api/db/startup.ts index c89e3eff..119e1b83 100644 --- a/api/db/startup.ts +++ b/api/db/startup.ts @@ -1,3 +1,9 @@ +/** + * Housekeeping run when the database is opened. + * + * @module + */ + import { randomBytes, scryptSync } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; import { @@ -7,8 +13,9 @@ import { } from "../config.ts"; /** - * Housekeeping run once when the database is opened, after the migrations: - * drops what has expired, and seeds the default admin on an empty instance. + * Runs once when the database is opened, after the migrations: drops what has + * expired (reset tokens, unused invites, orphaned attachments), and seeds the + * default admin (admin/admin) on an empty instance. */ export async function runStartupTasks(db: DatabaseSync): Promise { // Purge expired/used password reset tokens diff --git a/api/lib/api-exception.ts b/api/lib/api-exception.ts index 76c15f47..415b89cd 100644 --- a/api/lib/api-exception.ts +++ b/api/lib/api-exception.ts @@ -1,10 +1,22 @@ +/** + * The exception that carries an HTTP error out of any layer below a route. + * + * @module + */ + import type { APIErrorCode } from "../../shared/api.ts"; -/** Thrown anywhere below a route; errorMiddleware turns it into a response. */ +/** + * An error meant for the client, thrown anywhere below a route; the error + * middleware turns it into an `APIFailure` response. + */ export class APIException extends Error { + /** The error code the response carries. */ readonly code: APIErrorCode; + /** The HTTP status the response is sent with. */ readonly status: number; + /** Creates an exception answering `status` with `code` and `message`. */ constructor(code: APIErrorCode, status: number, message: string) { super(message); this.code = code; diff --git a/api/lib/auth.ts b/api/lib/auth.ts index 973a7f4d..5a2d853d 100644 --- a/api/lib/auth.ts +++ b/api/lib/auth.ts @@ -1,3 +1,10 @@ +/** + * Authenticates the tokens a request carries: session tokens in the + * `Authorization` header and media tokens in a media URL's query string. + * + * @module + */ + import type { Context } from "@oak/oak"; import { verifyJWT, verifyMediaToken } from "./jwt.ts"; import { APIErrorCode } from "../../shared/api.ts"; @@ -58,7 +65,8 @@ export async function authenticateMedia(token: string): Promise { } /** - * Extracts the userId from an optional Bearer token. + * Returns the user id of an optional Bearer token, or null for an anonymous + * request. * * Distinguishes *missing* credentials from *invalid* ones: * - No `Authorization: Bearer …` header (or a placeholder value such as the @@ -67,6 +75,8 @@ export async function authenticateMedia(token: string): Promise { * 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 { const authHeader = ctx.request.headers.get("Authorization"); diff --git a/api/lib/canonical-url.ts b/api/lib/canonical-url.ts index 0f803f0e..dfe13d8b 100644 --- a/api/lib/canonical-url.ts +++ b/api/lib/canonical-url.ts @@ -1,5 +1,5 @@ /** - * Canonical form of a URL, used *only* to recognise that two links point at the + * The canonical form of a URL, used *only* to recognise that two links point at the * same thing — the duplicate check the create form runs while it fetches a * preview. It is a lookup key, never something we display or fetch: `dumps.url` * keeps the exact string the poster submitted. @@ -13,6 +13,8 @@ * 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 */ const TRACKING_PARAMS = new Set([ @@ -40,7 +42,7 @@ function isTrackingParam(key: string): boolean { return k.startsWith("utm_") || TRACKING_PARAMS.has(k); } -// Hosts already stripped of a leading "www.". +/** YouTube's hosts, already stripped of a leading "www.". */ const YOUTUBE_HOSTS = new Set([ "youtube.com", "m.youtube.com", @@ -49,7 +51,7 @@ const YOUTUBE_HOSTS = new Set([ ]); /** - * The video a YouTube URL points at, in any of the shapes people paste + * Returns the video a YouTube URL points at, in any of the shapes people paste * (`youtu.be/ID`, `/watch?v=ID`, `/embed/ID`, `/shorts/ID`, `/live/ID`). * Timestamps are deliberately ignored: the same video linked at 2:30 is still * the same video for duplicate purposes. @@ -68,6 +70,10 @@ function youtubeVideoId( return null; } +/** + * Returns the canonical form of `raw` (see the module doc), or null when it + * isn't an http(s) URL. + */ export function canonicalizeUrl(raw: string): string | null { let u: URL; try { diff --git a/api/lib/index-html.ts b/api/lib/index-html.ts new file mode 100644 index 00000000..ca72c81b --- /dev/null +++ b/api/lib/index-html.ts @@ -0,0 +1,38 @@ +/** + * The built frontend's `index.html`, with its runtime placeholders filled: the + * site name, emoji, icon version and Bandcamp player mode. + * + * @module + */ + +import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; +import { emojiToCodepoint } from "./site-icons.ts"; + +/** The cache-busting version of the icon URLs. */ +const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); + +/** + * `index.html` with its placeholders filled, by root. Kept until the file + * changes (a rebuild under a running server): a stat per navigation instead of + * a read and four replaceAll. + */ +const indexHtml = new Map(); + +/** + * Returns `${root}/index.html` with its placeholders filled. + * + * @throws {Error} When the file can't be read. + */ +export async function loadIndexHtml(root: string): Promise { + const path = `${root}/index.html`; + const mtime = (await Deno.stat(path)).mtime?.getTime() ?? 0; + const cached = indexHtml.get(root); + if (cached && cached.mtime === mtime) return cached.html; + const html = (await Deno.readTextFile(path)) + .replaceAll("__SITE_NAME__", OG_SITE_NAME) + .replaceAll("__SITE_EMOJI__", SITE_EMOJI) + .replaceAll("__ICON_VERSION__", ICON_VERSION) + .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER); + indexHtml.set(root, { mtime, html }); + return html; +} diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 16df76e0..31d7e800 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -1,3 +1,12 @@ +/** + * Signed tokens and password hashes: session tokens, media tokens, password + * reset tokens, and scrypt hashing. + * + * Importing this module throws when `GERBEUR_JWT_SECRET` is unset. + * + * @module + */ + import { randomBytes, scrypt, timingSafeEqual } from "node:crypto"; import { Buffer } from "node:buffer"; import { jwtVerify, SignJWT } from "@panva/jose"; @@ -19,6 +28,7 @@ const JWT_KEY = new TextEncoder().encode(JWT_SECRET); // ── Password reset tokens ───────────────────────────────────────────────────── +/** Signs a single-use password reset token for `userId`, valid for an hour. */ export async function createPasswordResetToken( userId: string, ): Promise { @@ -29,6 +39,10 @@ export async function createPasswordResetToken( .sign(JWT_KEY); } +/** + * Verifies a password reset token and returns its payload, or null when it is + * invalid, expired or not a reset token. + */ export async function verifyPasswordResetToken( token: string, ): Promise { @@ -43,6 +57,7 @@ export async function verifyPasswordResetToken( // ── Auth tokens ─────────────────────────────────────────────────────────────── +/** Signs a session token carrying `payload`, valid for 7 days. */ export async function createJWT( payload: Omit, ): Promise { @@ -56,10 +71,14 @@ export async function createJWT( // ── Media tokens ────────────────────────────────────────────────────────────── -// Media elements can't send an Authorization header, so a private dump's file -// and thumbnail URLs carry a token in the query string, where it lands in -// access logs and browser history. That token is this one: it only reads media, -// so a leaked URL exposes the owner's private files, not their account. +/** + * Signs a media token for `userId`, valid for 7 days. + * + * Media elements can't send an Authorization header, so a private dump's file + * and thumbnail URLs carry a token in the query string, where it lands in + * access logs and browser history. That token is this one: it only reads media, + * so a leaked URL exposes the owner's private files, not their account. + */ export async function createMediaToken(userId: string): Promise { return await new SignJWT({ purpose: "media", userId }) .setProtectedHeader({ alg: "HS256" }) @@ -68,6 +87,10 @@ export async function createMediaToken(userId: string): Promise { .sign(JWT_KEY); } +/** + * Verifies a media token and returns its user id and issue time, or null when + * it is invalid, expired or not a media token. + */ export async function verifyMediaToken( token: string, ): Promise<{ userId: string; iat?: number } | null> { @@ -82,6 +105,10 @@ export async function verifyMediaToken( } } +/** + * Verifies a session token's signature and expiry and returns its payload, or + * null. Checking it against the live user is `authenticate`'s job (lib/auth.ts). + */ export async function verifyJWT(token: string): Promise { try { const { payload } = await jwtVerify(token, JWT_KEY); @@ -97,6 +124,7 @@ export async function verifyJWT(token: string): Promise { } } +/** Hashes `password` with scrypt and a random salt, as `hash.salt` in hex. */ export function hashPassword(password: string): Promise { const salt = randomBytes(16).toString("hex"); @@ -108,6 +136,7 @@ export function hashPassword(password: string): Promise { }); } +/** Checks `password` against a hash made by {@link hashPassword}. */ export function verifyPassword( password: string, storedHash: string, diff --git a/api/lib/pagination.ts b/api/lib/pagination.ts index 16ea4789..700134fb 100644 --- a/api/lib/pagination.ts +++ b/api/lib/pagination.ts @@ -1,9 +1,16 @@ +/** + * Page-based pagination: reading `page` and `limit` from a query string, and + * cutting fetched rows into a page. + * + * @module + */ + import { PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT } from "../config.ts"; /** - * Parses page/limit query parameters with sensible defaults and bounds. - * page: clamped to [1, ∞) - * limit: clamped to [1, 100], defaults to 20 + * Reads the `page` and `limit` query parameters. `page` is at least 1 and + * defaults to 1; `limit` is clamped to [1, {@link PAGINATION_MAX_LIMIT}] and + * defaults to `defaultLimit`. */ export function parsePagination( params: URLSearchParams, @@ -25,7 +32,9 @@ export function parsePagination( /** One page of a list, and whether another follows. */ export interface Page { + /** The page's items, in order. */ items: T[]; + /** Whether another page follows this one. */ hasMore: boolean; } diff --git a/api/lib/permissions.ts b/api/lib/permissions.ts index 3029b216..36f1b8b9 100644 --- a/api/lib/permissions.ts +++ b/api/lib/permissions.ts @@ -1,6 +1,13 @@ +/** + * Permission checks against an authenticated caller. + * + * @module + */ + import { hasPermission, type Permission } from "../../shared/permissions.ts"; import type { AuthPayload } from "../model/auth.ts"; +/** Checks whether the caller's role grants `permission`. */ export function can(payload: AuthPayload, permission: Permission): boolean { return hasPermission(payload.role, permission); } diff --git a/api/lib/playlist-members.ts b/api/lib/playlist-members.ts index a5aae7a4..14a4329d 100644 --- a/api/lib/playlist-members.ts +++ b/api/lib/playlist-members.ts @@ -1,14 +1,23 @@ +/** + * Which of a playlist's dumps a given viewer may see. + * + * @module + */ + import type { Playlist } from "../../shared/playlist.ts"; /** A playlist's dump, with what deciding who may see it takes. */ export interface PlaylistMember { + /** The dump's id. */ id: string; + /** Whether the dump is private. */ isPrivate: boolean; + /** The id of the dump's poster. */ userId: string; } /** - * Whether `viewerId` sees this member when the playlist is shown to them: a + * Checks whether `viewerId` sees this member when the playlist is shown to them: a * public dump, or a private one of the owner's own, to the owner. Everyone * else — moderators too — doesn't see private dumps, and neither does the * owner see someone else's dump once it has gone private. diff --git a/api/lib/rate-limit.ts b/api/lib/rate-limit.ts index baccbf38..b35c5c16 100644 --- a/api/lib/rate-limit.ts +++ b/api/lib/rate-limit.ts @@ -1,12 +1,18 @@ -// Token buckets keyed by the caller's choice (e.g. `${kind}:${userId}`) — per -// user rather than per socket or request, so more tabs or parallel requests -// don't buy more throughput. +/** + * In-memory rate limiting with token buckets, keyed by the caller's choice + * (e.g. `${kind}:${userId}`) — per user rather than per socket or request, so + * more tabs or parallel requests don't buy more throughput. + * + * @module + */ + interface Bucket { tokens: number; at: number; } const buckets = new Map(); +/** A token bucket's shape. */ export interface RateLimit { /** Burst size. */ capacity: number; @@ -14,7 +20,7 @@ export interface RateLimit { refill: number; } -/** Takes one token for `key`; false when the bucket is empty. */ +/** Takes one token from `key`'s bucket, or returns false when it is empty. */ export function takeToken(key: string, limit: RateLimit): boolean { const now = Date.now(); const bucket = buckets.get(key) ?? { tokens: limit.capacity, at: now }; diff --git a/api/lib/reserved-slugs.ts b/api/lib/reserved-slugs.ts index 7ea87b2f..d4eb2f32 100644 --- a/api/lib/reserved-slugs.ts +++ b/api/lib/reserved-slugs.ts @@ -1,6 +1,14 @@ -// Top-level path segments that already have meaning in the SPA router -// (see src/App.tsx) plus a few reserved for infrastructure. Category slugs -// live at the URL root (e.g. /music), so they must never collide with these. +/** + * Which slugs a category may take. Category slugs live at the URL root (e.g. + * /music), so they must never collide with the frontend's own routes. + * + * @module + */ + +/** + * Top-level path segments that already have meaning in the SPA router (see + * src/App.tsx), plus a few reserved for infrastructure. + */ export const RESERVED_SLUGS: readonly string[] = [ "~", "dumps", @@ -16,18 +24,23 @@ export const RESERVED_SLUGS: readonly string[] = [ "assets", ]; -// Lowercase alphanumerics, hyphen-separated, no leading/trailing/double hyphen. +/** + * A well-formed category slug: lowercase alphanumerics, + * hyphen-separated, no leading, trailing or double hyphen. + */ export const CATEGORY_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +/** Checks whether `slug` is well-formed (see {@link CATEGORY_SLUG_RE}). */ export function isValidSlugFormat(slug: string): boolean { return CATEGORY_SLUG_RE.test(slug); } +/** Checks whether `slug` is one of the {@link RESERVED_SLUGS}. */ export function isReservedSlug(slug: string): boolean { return RESERVED_SLUGS.includes(slug); } -/** A slug is available when it is well-formed and not reserved. */ +/** Checks whether `slug` is well-formed and not reserved. */ export function isSlugAvailable(slug: string): boolean { return isValidSlugFormat(slug) && !isReservedSlug(slug); } diff --git a/api/lib/self-url.ts b/api/lib/self-url.ts index be823b27..dba4fd10 100644 --- a/api/lib/self-url.ts +++ b/api/lib/self-url.ts @@ -1,3 +1,10 @@ +/** + * Recognises links to this site's own pages, which previews resolve from the + * database instead of fetching. + * + * @module + */ + import { FRONTEND_URL, PUBLIC_URL } from "../config.ts"; const SELF_HOSTS = new Set( @@ -13,9 +20,9 @@ const SELF_HOSTS = new Set( const SELF_PATH_RE = /^\/(dumps|users|playlists)\/[^/]+$/; /** - * A link to one of this site's own dump, user or playlist pages. Matched on the - * host as well as the path: `/users/bob` on some other site is someone else's - * page, not ours. + * Checks whether `url` links to one of this site's own dump, user or playlist + * pages. Matched on the host as well as the path: `/users/bob` on some other + * site is someone else's page, not ours. */ export function isSelfUrl(url: URL): boolean { return SELF_HOSTS.has(url.host.toLowerCase()) && diff --git a/api/lib/serve-file.ts b/api/lib/serve-file.ts index 57bb7255..57226f89 100644 --- a/api/lib/serve-file.ts +++ b/api/lib/serve-file.ts @@ -1,3 +1,10 @@ +/** + * Serving files from disk with ETags and byte ranges, streamed a chunk at a + * time. + * + * @module + */ + import type { Context } from "@oak/oak"; const CHUNK_SIZE = 64 * 1024; @@ -52,7 +59,7 @@ function fileStream( }); } -/** Whether an If-None-Match header names `etag` (weak comparison). */ +/** Checks whether an If-None-Match header names `etag` (weak comparison). */ function matchesEtag(header: string | null, etag: string): boolean { if (!header) return false; if (header.trim() === "*") return true; @@ -60,8 +67,11 @@ function matchesEtag(header: string | null, etag: string): boolean { return header.split(",").some((tag) => strip(tag) === strip(etag)); } +/** How {@link serveFile} answers. */ export interface ServeFileOptions { + /** The `Content-Type` sent. */ mime: string; + /** The `Cache-Control` sent. */ cacheControl: string; /** Answer a single `Range: bytes=…` with a 206 (media files). */ ranges?: boolean; diff --git a/api/lib/site-icons.ts b/api/lib/site-icons.ts index 79a7effa..9eac4d11 100644 --- a/api/lib/site-icons.ts +++ b/api/lib/site-icons.ts @@ -1,11 +1,16 @@ -// Generates the full favicon set + web manifest from a single emoji. -// -// Twemoji ships each emoji as a plain vector-path SVG, so it rasterizes without -// any system emoji font (which matters in the minimal Alpine runtime image). -// @resvg/resvg-wasm does the rasterization; the .ico is assembled by hand. -// -// Invoked by api/scripts/generate-icons.ts at server startup (into dist/ in -// prod, public/ in dev) — see that script for how the output dir is chosen. +/** + * Generates the full favicon set and web manifest from a single emoji. + * + * Twemoji ships each emoji as a plain vector-path SVG, so it rasterizes without + * any system emoji font (which matters in the minimal Alpine runtime image). + * `@resvg/resvg-wasm` does the rasterization; the .ico is assembled by hand. + * + * Invoked by api/scripts/generate-icons.ts at server startup (into dist/ in + * prod, public/ in dev) — see that script for how the output dir is chosen. + * + * @module + */ + import { initWasm, Resvg } from "@resvg/resvg-wasm"; import { fetchTwemojiSvg } from "../remote/twemoji.ts"; @@ -14,9 +19,9 @@ const APPLE_SIZE = 180; const ICO_SIZES = [16, 32, 48]; /** - * Mirror Twemoji's filename convention: strip variation selectors (FE0F) unless - * the emoji is a ZWJ sequence, then join codepoints with "-". Also doubles as a - * stable cache-busting version string for the icon URLs. + * Returns the name Twemoji files `emoji` under: its codepoints joined with + * "-", variation selectors (FE0F) stripped unless the emoji is a ZWJ sequence. + * Also doubles as a stable cache-busting version string for the icon URLs. */ export function emojiToCodepoint(emoji: string): string { const cleaned = emoji.includes("‍") ? emoji : emoji.replace(/️/g, ""); @@ -42,7 +47,7 @@ function renderPng(svg: string, size: number, background?: string): Uint8Array { return resvg.render().asPng(); } -// Pack PNG payloads into a (PNG-compressed) .ico container. +/** Packs PNG payloads into a (PNG-compressed) .ico container. */ function buildIco(images: { size: number; png: Uint8Array }[]): Uint8Array { const HEADER = 6; const ENTRY = 16; @@ -94,6 +99,7 @@ function buildManifest(siteName: string, bgColor: string): string { return JSON.stringify(manifest, null, 2) + "\n"; } +/** What {@link generateSiteIcons} renders, and where. */ export interface GenerateSiteIconsOptions { /** Emoji to render the icons from. */ emoji: string; @@ -101,14 +107,18 @@ export interface GenerateSiteIconsOptions { outDir: string; /** Site name written into the manifest. */ siteName: string; - /** Background color for the Apple touch icon and manifest (iOS ignores transparency). */ + /** + * Background color for the Apple touch icon and manifest (iOS ignores + * transparency). + */ bgColor: string; } /** - * Generate `favicon.svg`, `favicon.ico`, `favicon-x.png` (16–512), + * Generates `favicon.svg`, `favicon.ico`, `favicon-x.png` (16–512), * `apple-touch-icon.png` and `manifest.webmanifest` into `outDir`. - * Throws if the emoji glyph can't be fetched. + * + * @throws {Error} When the emoji's glyph can't be fetched. */ export async function generateSiteIcons( opts: GenerateSiteIconsOptions, diff --git a/api/lib/slugify.ts b/api/lib/slugify.ts index 03d27d5b..1b4cb090 100644 --- a/api/lib/slugify.ts +++ b/api/lib/slugify.ts @@ -1,6 +1,17 @@ +/** + * URL slugs for records that have a title. + * + * @module + */ + +/** A UUID, which a route parameter may carry instead of a slug. */ export const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** + * Turns `title` into a URL-safe slug: lowercase ASCII, dash-separated, at most + * 60 characters, `untitled` when nothing is left. + */ export function slugify(title: string): string { const slug = title .toLowerCase() @@ -12,7 +23,10 @@ export function slugify(title: string): string { return slug || "untitled"; } -/** Stable slug tied to the record's id — unique by construction. */ +/** + * Returns the slug of a record: its title's slug followed by the start of its + * `id`, so it stays unique by construction. + */ export function makeSlug(title: string, id: string): string { return `${slugify(title)}-${id.substring(0, 8)}`; } diff --git a/api/lib/static.ts b/api/lib/static.ts index c6f44466..3c3643bb 100644 --- a/api/lib/static.ts +++ b/api/lib/static.ts @@ -1,28 +1,14 @@ +/** + * Serving the built frontend: static files, and `index.html` with its + * placeholders filled for every client-side route. + * + * @module + */ + import { Context, Next, send } from "@oak/oak"; -import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; -import { emojiToCodepoint } from "./site-icons.ts"; - -const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); - -// index.html with its placeholders filled, by root. Kept until the file -// changes (a rebuild under a running server): a stat per navigation instead of -// a read and four replaceAll. -const indexHtml = new Map(); - -async function loadIndexHtml(root: string): Promise { - const path = `${root}/index.html`; - const mtime = (await Deno.stat(path)).mtime?.getTime() ?? 0; - const cached = indexHtml.get(root); - if (cached && cached.mtime === mtime) return cached.html; - const html = (await Deno.readTextFile(path)) - .replaceAll("__SITE_NAME__", OG_SITE_NAME) - .replaceAll("__SITE_EMOJI__", SITE_EMOJI) - .replaceAll("__ICON_VERSION__", ICON_VERSION) - .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER); - indexHtml.set(root, { mtime, html }); - return html; -} +import { loadIndexHtml } from "./index-html.ts"; +/** Answers with the filled `index.html` of `root`, always revalidated. */ async function serveIndexHtml( context: Context>, root: string, @@ -34,12 +20,22 @@ async function serveIndexHtml( context.response.body = html; } -// Build output whose URL changes whenever its content does: Vite's hashed -// chunks, and the emoji data under its package version (see vite.config.ts). +/** + * Build output whose URL changes whenever its content does: Vite's hashed + * chunks, and the emoji data under its package version (see vite.config.ts). + */ const IMMUTABLE_PREFIXES = ["/assets/", "/emojibase/"]; const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; -export function routeStaticFilesFrom(staticPaths: string[]) { +/** + * Returns a middleware serving static files from the first of `staticPaths` + * that has them, and `index.html` from the first for any other path so + * client-side routes load on direct navigation. Hashed build output is cached + * for a year, and a missing chunk is a 404. + */ +export function routeStaticFilesFrom( + staticPaths: string[], +): (context: Context>, next: Next) => Promise { return async (context: Context>, next: Next) => { const pathname = context.request.url.pathname; diff --git a/api/lib/upload.ts b/api/lib/upload.ts index bbe47818..a36cfdfc 100644 --- a/api/lib/upload.ts +++ b/api/lib/upload.ts @@ -1,3 +1,9 @@ +/** + * Checking uploaded images and serving uploaded files back. + * + * @module + */ + import type { Context } from "@oak/oak"; import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "./api-exception.ts"; @@ -21,7 +27,10 @@ export { UPLOADS_DIR, }; -/** Detect image MIME type from magic bytes, ignoring the browser-declared type. */ +/** + * 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. + */ export function detectImageMime(data: Uint8Array): string | null { if (data[0] === 0xFF && data[1] === 0xD8 && data[2] === 0xFF) { return "image/jpeg"; @@ -42,7 +51,12 @@ export function detectImageMime(data: Uint8Array): string | null { return null; } -/** Validates image upload data: checks size and MIME. Returns the detected MIME type or throws APIException. */ +/** + * Checks an uploaded image's size and content, and returns its MIME type. + * + * @throws {APIException} 400 when it exceeds {@link MAX_IMAGE_SIZE_BYTES} or + * isn't a JPEG, PNG, GIF or WebP image. + */ export function validateImageUpload(data: Uint8Array): string { if (data.length > MAX_IMAGE_SIZE_BYTES) { throw new APIException( @@ -62,6 +76,11 @@ export function validateImageUpload(data: Uint8Array): string { return mime; } +/** + * Serves an uploaded file inline as `mime`, cached for `cacheMaxAge` seconds + * (privately when `isPrivate`). Answers 404 and resolves false when the file + * is missing. + */ export async function serveUploadedFile( ctx: Context, filePath: string, diff --git a/api/main.ts b/api/main.ts index d5b4f8fe..46fd854d 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1,3 +1,11 @@ +/** + * The server's entry point: builds the Oak application — error and OG + * middleware, CORS, every router, then the static frontend — and listens on + * {@link LISTEN_HOST}:{@link PORT} when run directly. + * + * @module + */ + import { Application } from "@oak/oak"; import { oakCors } from "@tajpouria/cors"; @@ -25,7 +33,8 @@ import { errorMiddleware } from "./middleware/error.ts"; import { ogMiddleware } from "./middleware/og.ts"; import { routeStaticFilesFrom } from "./lib/static.ts"; -const app = new Application(); +/** The application, exported so the tests can drive it without listening. */ +const app: Application = new Application(); const cors = oakCors({ origin: ALLOWED_ORIGINS }); diff --git a/api/middleware/auth.ts b/api/middleware/auth.ts index acdb1303..b70d6d88 100644 --- a/api/middleware/auth.ts +++ b/api/middleware/auth.ts @@ -1,3 +1,10 @@ +/** + * Middleware gating routes on authentication, the admin role, or a + * permission. + * + * @module + */ + import { type Next, type RouteParams, @@ -11,18 +18,28 @@ import type { AuthPayload } from "../model/auth.ts"; import { can } from "../lib/permissions.ts"; import type { Permission } from "../../shared/permissions.ts"; +/** The request state once {@link authMiddleware} has run. */ export interface AuthState extends State { + /** The authenticated caller, with their live role. */ user: AuthPayload; } -// A router context whose state is guaranteed to carry the authenticated user -// (populated by authMiddleware). Generic over the route path so `ctx.params` -// stays typed per-route — e.g. `AuthContext<"/:userId/role">`. +/** + * A router context whose state is guaranteed to carry the authenticated user + * (populated by {@link authMiddleware}). Generic over the route path so + * `ctx.params` stays typed per-route — e.g. `AuthContext<"/:userId/role">`. + */ export type AuthContext< R extends string = string, P extends RouteParams = RouteParams, > = RouterContext; +/** + * Requires a valid session token in the `Authorization` header and puts its + * caller in `ctx.state.user`. + * + * @throws {APIException} 401 when the token is missing or invalid. + */ export async function authMiddleware( ctx: AuthContext, next: Next, @@ -51,6 +68,11 @@ export async function authMiddleware( await next(); } +/** + * Requires the caller to be an admin. Must run after {@link authMiddleware}. + * + * @throws {APIException} 403 when the caller isn't an admin. + */ export async function adminOnlyMiddleware( ctx: AuthContext, next: Next, @@ -66,9 +88,15 @@ export async function adminOnlyMiddleware( await next(); } -// Gates a route on a named permission. Must run after authMiddleware. Generic -// over the route path so it composes with path-typed route handlers. -export function requirePermission(permission: Permission) { +/** + * Returns a middleware requiring the caller to hold `permission`, which throws + * an {@link APIException} (403) when they don't. Must run after + * {@link authMiddleware}. Generic over the route path so it composes with + * path-typed route handlers. + */ +export function requirePermission( + permission: Permission, +): (ctx: AuthContext, next: Next) => Promise { return async ( ctx: AuthContext, next: Next, diff --git a/api/middleware/error.ts b/api/middleware/error.ts index 0444065f..e9a56437 100644 --- a/api/middleware/error.ts +++ b/api/middleware/error.ts @@ -1,9 +1,22 @@ +/** + * Turns errors thrown while handling a request into `APIFailure` responses. + * + * @module + */ + import { Context, Next } from "@oak/oak"; import { APIErrorCode, APIFailure } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; -export async function errorMiddleware(ctx: Context, next: Next) { +/** + * Answers an {@link APIException} with its status and code, and anything else + * with a 500 `SERVER_ERROR`, logging both. + */ +export async function errorMiddleware( + ctx: Context, + next: Next, +): Promise { try { await next(); } catch (err) { diff --git a/api/middleware/og.ts b/api/middleware/og.ts index 513f6f4d..49e4c7ff 100644 --- a/api/middleware/og.ts +++ b/api/middleware/og.ts @@ -1,17 +1,26 @@ +/** + * Open Graph and Twitter card tags for shared links: a dump, user or public + * playlist page is answered with `index.html` carrying that page's title, + * description and image, so link unfurlers that don't run the app see them. + * + * @module + */ + import { Context, Next } from "@oak/oak"; import { getDump } from "../services/dump-service.ts"; import { getUserByUsername } from "../services/user-service.ts"; import { getPlaylistById } from "../services/playlist-service.ts"; -import { BANDCAMP_PLAYER, OG_SITE_NAME, SITE_EMOJI } from "../config.ts"; -import { emojiToCodepoint } from "../lib/site-icons.ts"; - -const ICON_VERSION = emojiToCodepoint(SITE_EMOJI); +import { OG_SITE_NAME } from "../config.ts"; +import { loadIndexHtml } from "../lib/index-html.ts"; interface OGMeta { title: string; description?: string; imageUrl?: string; - /** True only for real artwork — a square site icon must not claim a wide card. */ + /** + * True only for real artwork — a square site icon must not claim a wide + * card. + */ imageIsWide?: boolean; url: string; } @@ -58,34 +67,34 @@ function inject(html: string, meta: OGMeta): string { .replace("", ` ${buildTags(meta)}\n `); } -let cachedHtml: string | null | undefined; // undefined = not yet loaded, null = not found - -async function loadIndexHtml(): Promise { - if (cachedHtml !== undefined) return cachedHtml; - for ( - const path of [`${Deno.cwd()}/dist/index.html`, `${Deno.cwd()}/index.html`] - ) { +/** + * Returns the filled `index.html` of the build, or of the working directory + * when there is no build; null when there is neither. + */ +async function findIndexHtml(): Promise { + for (const root of [`${Deno.cwd()}/dist`, Deno.cwd()]) { try { - cachedHtml = (await Deno.readTextFile(path)) - .replaceAll("__SITE_NAME__", OG_SITE_NAME) - .replaceAll("__SITE_EMOJI__", SITE_EMOJI) - .replaceAll("__ICON_VERSION__", ICON_VERSION) - .replaceAll("__BANDCAMP_PLAYER__", BANDCAMP_PLAYER); - return cachedHtml; + return await loadIndexHtml(root); } catch { continue; } } - cachedHtml = null; return null; } const DUMP_RE = /^\/dumps\/([^/]+)$/; -// Allow an optional `/~/` suffix so linkable profile tabs keep OG meta. +/** A profile, with an optional `/~/` suffix so its tabs keep OG meta. */ const USER_RE = /^\/users\/([^/]+)(?:\/~\/[^/]+)?$/; const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; -export async function ogMiddleware(ctx: Context, next: Next) { +/** + * Serves `index.html` with OG tags for a dump, user or public playlist page + * that exists, and passes every other request on. + */ +export async function ogMiddleware( + ctx: Context, + next: Next, +): Promise { const { pathname } = ctx.request.url; if (pathname.startsWith("/api/") || pathname.includes(".")) { @@ -153,7 +162,7 @@ export async function ogMiddleware(ctx: Context, next: Next) { if (!meta) return await next(); - const html = await loadIndexHtml(); + const html = await findIndexHtml(); if (!html) return await next(); ctx.response.headers.set("Content-Type", "text/html; charset=utf-8"); diff --git a/api/model/auth.ts b/api/model/auth.ts index cac3a2bc..9c2942d1 100644 --- a/api/model/auth.ts +++ b/api/model/auth.ts @@ -1,13 +1,31 @@ +/** + * The payloads of the tokens the server signs, and their guards. + * + * @module + */ + import { isRole, type Role } from "../../shared/user.ts"; +/** What a session token carries. */ export interface AuthPayload { + /** The user's id. */ userId: string; + /** The user's name when the token was issued. */ username: string; + /** + * The user's role; the live one replaces it once the token is authenticated. + */ role: Role; + /** When the token expires, in seconds since the epoch. */ exp: number; + /** When the token was issued, in seconds since the epoch. */ iat?: number; } +/** + * Checks whether `obj` is an {@link AuthPayload}, and not a purpose-scoped + * token's payload. + */ export function isAuthPayload(obj: unknown): obj is AuthPayload { return !!obj && typeof obj === "object" && @@ -19,12 +37,17 @@ export function isAuthPayload(obj: unknown): obj is AuthPayload { "exp" in obj && typeof obj.exp === "number"; } +/** What a password reset token carries. */ export interface PasswordResetPayload { + /** The token's purpose, which makes it a reset token. */ purpose: "password-reset"; + /** The id of the user whose password it resets. */ userId: string; + /** When the token expires, in seconds since the epoch. */ exp: number; } +/** Checks whether `obj` is a {@link PasswordResetPayload}. */ export function isPasswordResetPayload( obj: unknown, ): obj is PasswordResetPayload { diff --git a/api/model/category.ts b/api/model/category.ts index c3af3440..6cfb24c6 100644 --- a/api/model/category.ts +++ b/api/model/category.ts @@ -1,16 +1,32 @@ +/** + * The `categories` table: its row type, its guard, and its mapper to the API's + * {@link Category}. + * + * @module + */ + import type { SQLOutputValue } from "node:sqlite"; import type { Category } from "../../shared/category.ts"; +/** A row of the `categories` table. */ export interface CategoryRow { + /** The category's id. */ id: string; + /** The category's URL slug, at the site root. */ slug: string; + /** The category's display name. */ name: string; + /** The category's place in the list, ascending. */ position: number; + /** When the category was created. */ created_at: string; + /** When the category was last changed, if ever. */ updated_at: string | null; + /** Any other column the query selects. */ [key: string]: SQLOutputValue; } +/** Checks whether `obj` is a {@link CategoryRow}. */ export function isCategoryRow(obj: unknown): obj is CategoryRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && @@ -22,6 +38,7 @@ export function isCategoryRow(obj: unknown): obj is CategoryRow { (typeof obj.updated_at === "string" || obj.updated_at === null); } +/** Converts a row to the API's {@link Category}. */ export function categoryRowToApi(row: CategoryRow): Category { return { id: row.id, diff --git a/api/model/chat.ts b/api/model/chat.ts index b6cb372b..ec60decd 100644 --- a/api/model/chat.ts +++ b/api/model/chat.ts @@ -1,23 +1,52 @@ +/** + * The `chat_messages` table: its row type, its guard, and its mapper to the + * API's {@link ChatMessage}. + * + * @module + */ + import type { SQLOutputValue } from "node:sqlite"; import type { ChatMessage } from "../../shared/chat.ts"; +/** + * A row of the `chat_messages` table, joined with its author and, when it is + * a reply, the replied-to message and its author. + */ export interface ChatMessageRow { + /** The message's id. */ id: string; + /** The author's id. */ user_id: string; + /** The message, in Markdown. */ body: string; + /** When the message was sent. */ created_at: string; + /** When the message was last edited, if ever. */ updated_at: string | null; + /** The author's name (joined). */ author_username: string; + /** The author's avatar type, null without one (joined). */ author_avatar_mime: string | null; - // Reply columns: reply_to_id is the stored target; the author/body preview are - // LEFT-JOINed from the target and go null when there's no reply (or it was - // since deleted). + /** + * The id of the message this replies to, kept even once that message is + * deleted. + */ 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; + /** + * The start of the replied-to message (joined); null when there's no reply or + * it was since deleted. + */ reply_to_body: string | null; + /** Any other column the query selects. */ [key: string]: SQLOutputValue; } +/** Checks whether `obj` is a {@link ChatMessageRow}. */ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow { return !!obj && typeof obj === "object" && "id" in obj && typeof obj.id === "string" && @@ -38,6 +67,7 @@ export function isChatMessageRow(obj: unknown): obj is ChatMessageRow { (typeof obj.reply_to_body === "string" || obj.reply_to_body === null); } +/** Converts a row to the API's {@link ChatMessage}. */ export function chatMessageRowToApi(row: ChatMessageRow): ChatMessage { return { id: row.id, diff --git a/api/model/comment.ts b/api/model/comment.ts index 08ac55d8..a6fbd42b 100644 --- a/api/model/comment.ts +++ b/api/model/comment.ts @@ -1,21 +1,45 @@ +/** + * The `comments` table: its row type, its guard, and its mapper to the API's + * {@link Comment}. + * + * @module + */ + import type { SQLOutputValue } from "node:sqlite"; import type { Comment } from "../../shared/comment.ts"; +/** A row of the `comments` table, joined with its author. */ export interface CommentRow { + /** The comment's id. */ id: string; + /** The id of the dump it is posted under. */ dump_id: string; + /** The author's id. */ user_id: string; + /** The id of the comment it replies to; null at the top level. */ parent_id: string | null; + /** The comment, in Markdown. */ body: string; + /** When the comment was posted. */ created_at: string; + /** When the comment was last edited, if ever. */ updated_at: string | null; + /** + * 1 once deleted: the body is emptied but the row stays, so its replies + * keep their place. + */ deleted: number; + /** How many users like the comment. */ like_count: number; + /** The author's name (joined). */ author_username: string; + /** The author's avatar type, null without one (joined). */ author_avatar_mime: string | null; + /** Any other column the query selects. */ [key: string]: SQLOutputValue; } +/** Checks whether `obj` is a {@link CommentRow}. */ export function isCommentRow( obj: unknown, ): obj is CommentRow { @@ -37,6 +61,7 @@ export function isCommentRow( obj.author_avatar_mime === null); } +/** Converts a row to the API's {@link Comment}. */ export function commentRowToApi(row: CommentRow): Comment { return { id: row.id, diff --git a/api/model/dump.ts b/api/model/dump.ts index cc47c58b..351b6547 100644 --- a/api/model/dump.ts +++ b/api/model/dump.ts @@ -1,41 +1,74 @@ +/** + * The `dumps` table: its row type, its guard, the column lists services select + * it with, and its mappers to and from the API's {@link Dump}. + * + * @module + */ + import type { SQLOutputValue } from "node:sqlite"; import type { Dump, RichContent } from "../../shared/dump.ts"; +/** A row of the `dumps` table, with its live comment count. */ export interface DumpRow { + /** The dump's id. */ id: string; + /** `url` for a link, `file` for an upload. */ kind: string; + /** The dump's title. */ title: string; + /** The poster's description, in Markdown. */ comment: string | null; + /** The poster's id. */ user_id: string; + /** When the dump was posted. */ created_at: string; + /** When the dump was last edited, if ever. */ updated_at: string | null; + /** The dump's URL slug. */ slug: string | null; + /** The link exactly as submitted, for a URL dump. */ url: string | null; + /** The link's preview, as {@link RichContent} JSON. */ rich_content: string | null; + /** The uploaded file's name, for a file dump. */ file_name: string | null; + /** The uploaded file's type, for a file dump. */ file_mime: string | null; + /** The uploaded file's size in bytes, for a file dump. */ file_size: number | null; + /** How many users upvoted the dump. */ vote_count: number; + /** How many comments it has, not counting deleted ones (computed). */ comment_count: number; + /** 1 when only the poster sees the dump. */ is_private: number; + /** The uploaded thumbnail's type, null without one. */ custom_thumbnail_mime: string | null; - [key: string]: SQLOutputValue; // Index signature + /** Any other column the query selects. */ + [key: string]: SQLOutputValue; } -// Shared `dumps` column list — keep in sync with DumpRow/isDumpRow. -// Services import these instead of repeating the column list themselves. +/** + * The stored columns of {@link DumpRow} — keep in sync with it and + * {@link isDumpRow}. Services select with these instead of repeating the + * column list themselves. + */ 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"; +/** {@link DUMP_COLUMNS}, for a query aliasing `dumps` as `d`. */ 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"; -export const DUMP_SELECT_COLUMNS = `${DUMP_COLUMNS}, +/** {@link DUMP_COLUMNS} and the computed `comment_count`: a full {@link DumpRow}. */ +export const DUMP_SELECT_COLUMNS: string = `${DUMP_COLUMNS}, (SELECT COUNT(*) FROM comments WHERE dump_id = dumps.id AND deleted = 0) as comment_count`; -export const DUMP_SELECT_COLUMNS_ALIASED = `${DUMP_COLUMNS_ALIASED}, +/** {@link DUMP_SELECT_COLUMNS}, for a query aliasing `dumps` as `d`. */ +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`; +/** Checks whether `obj` is a {@link DumpRow}. */ export function isDumpRow(obj: unknown): obj is DumpRow { return !!obj && typeof obj === "object" && @@ -66,6 +99,7 @@ export function isDumpRow(obj: unknown): obj is DumpRow { obj.custom_thumbnail_mime === null); } +/** Converts a row to the API's {@link Dump}. */ export function dumpRowToApi(row: DumpRow): Dump { return { id: row.id, @@ -90,6 +124,7 @@ export function dumpRowToApi(row: DumpRow): Dump { }; } +/** Converts a {@link Dump} back to a row. */ export function dumpApiToRow(dump: Dump): DumpRow { return { id: dump.id, diff --git a/api/model/follow.ts b/api/model/follow.ts index 98110877..90cf0584 100644 --- a/api/model/follow.ts +++ b/api/model/follow.ts @@ -1,14 +1,31 @@ +/** + * The `follows` table: its row type and its guard. + * + * @module + */ + import type { SQLOutputValue } from "node:sqlite"; +/** + * A row of the `follows` table: one user following either another user or a + * playlist. + */ export interface FollowRow { + /** The follow's id. */ id: string; + /** The id of the user following. */ follower_id: string; + /** The id of the user followed, when it is a user. */ followed_user_id: string | null; + /** The id of the playlist followed, when it is a playlist. */ followed_playlist_id: string | null; + /** When the follow began. */ created_at: string; + /** Any other column the query selects. */ [key: string]: SQLOutputValue; } +/** Checks whether `obj` is a {@link FollowRow}. */ export function isFollowRow( obj: unknown, ): obj is FollowRow { diff --git a/api/model/invite.ts b/api/model/invite.ts index 6865e693..bd5f4a7f 100644 --- a/api/model/invite.ts +++ b/api/model/invite.ts @@ -1,13 +1,26 @@ +/** + * The `invites` table: its row type and its guard. + * + * @module + */ + import type { SQLOutputValue } from "node:sqlite"; +/** A row of the `invites` table. */ export interface InviteRow { + /** The invite's token, which registering redeems. */ token: string; + /** The id of the user who made the invite. */ inviter_id: string; + /** When the invite was redeemed; null while unused. */ used_at: string | null; + /** When the invite was made. */ created_at: string; + /** Any other column the query selects. */ [key: string]: SQLOutputValue; } +/** Checks whether `obj` is a {@link InviteRow}. */ export function isInviteRow( obj: unknown, ): obj is InviteRow { diff --git a/api/model/notification.ts b/api/model/notification.ts index ea944127..a42763dd 100644 --- a/api/model/notification.ts +++ b/api/model/notification.ts @@ -1,3 +1,10 @@ +/** + * The `notifications` table: its row type, its guard, and its mapper to the + * API's {@link Notification}. + * + * @module + */ + import type { SQLOutputValue } from "node:sqlite"; import type { Notification, @@ -5,17 +12,30 @@ import type { NotificationType, } from "../../shared/notification.ts"; +/** A row of the `notifications` table. */ export interface NotificationRow { + /** The notification's id. */ id: string; + /** The id of the user notified. */ user_id: string; + /** The notification's {@link NotificationType}. */ type: string; + /** The notification's {@link NotificationData}, as JSON. */ data: string; + /** 1 once the user has read it. */ read: number; + /** When the notification was made. */ created_at: string; + /** + * What caused the notification: a second one with the same + * `(user_id, source_key)` isn't stored. Null when duplicates are allowed. + */ source_key: string | null; + /** Any other column the query selects. */ [key: string]: SQLOutputValue; } +/** Checks whether `obj` is a {@link NotificationRow}. */ export function isNotificationRow( obj: unknown, ): obj is NotificationRow { @@ -30,6 +50,7 @@ export function isNotificationRow( (typeof obj.source_key === "string" || obj.source_key === null); } +/** Converts a row to the API's {@link Notification}. */ export function notificationRowToApi(row: NotificationRow): Notification { return { id: row.id, diff --git a/api/model/password-reset.ts b/api/model/password-reset.ts index e3f9f15a..97f80e59 100644 --- a/api/model/password-reset.ts +++ b/api/model/password-reset.ts @@ -1,13 +1,26 @@ +/** + * The `password_reset_tokens` table: its row type and its guard. + * + * @module + */ + import type { SQLOutputValue } from "node:sqlite"; +/** A row of the `password_reset_tokens` table. */ export interface PasswordResetTokenRow { + /** The reset token. */ token: string; + /** The id of the user whose password it resets. */ user_id: string; + /** When the token stops working. */ expires_at: string; + /** When the token was used; null while unused. */ used_at: string | null; + /** Any other column the query selects. */ [key: string]: SQLOutputValue; } +/** Checks whether `obj` is a {@link PasswordResetTokenRow}. */ export function isPasswordResetTokenRow( obj: unknown, ): obj is PasswordResetTokenRow { diff --git a/api/model/playlist.ts b/api/model/playlist.ts index 919d1ad0..129d4ceb 100644 --- a/api/model/playlist.ts +++ b/api/model/playlist.ts @@ -1,19 +1,41 @@ +/** + * The `playlists` table: its row type, its guard, and its mapper to the API's + * {@link Playlist}. + * + * @module + */ + import type { SQLOutputValue } from "node:sqlite"; import type { Playlist } from "../../shared/playlist.ts"; +/** + * A row of the `playlists` table. Queries may add `dump_count` and + * `owner_username`, which {@link playlistRowToApi} carries over. + */ export interface PlaylistRow { + /** The playlist's id. */ id: string; + /** The owner's id. */ user_id: string; + /** The playlist's title. */ title: string; + /** The playlist's URL slug. */ slug: string | null; + /** The owner's description, in Markdown. */ description: string | null; + /** 1 when anyone may see the playlist, 0 when only its owner does. */ is_public: number; + /** When the playlist was created. */ created_at: string; + /** When the playlist was last changed, if ever. */ updated_at: string | null; + /** The cover image's type, null without one. */ image_mime: string | null; + /** Any other column the query selects. */ [key: string]: SQLOutputValue; } +/** Checks whether `obj` is a {@link PlaylistRow}. */ export function isPlaylistRow( obj: unknown, ): obj is PlaylistRow { @@ -32,6 +54,7 @@ export function isPlaylistRow( (typeof obj.image_mime === "string" || obj.image_mime === null); } +/** Converts a row to the API's {@link Playlist}. */ export function playlistRowToApi(row: PlaylistRow): Playlist { return { id: row.id, diff --git a/api/model/user.ts b/api/model/user.ts index 372af415..c3989083 100644 --- a/api/model/user.ts +++ b/api/model/user.ts @@ -1,3 +1,10 @@ +/** + * The `users` table: its row type, its guard, its mappers to and from the + * stored {@link User}, and the views of a user the API sends. + * + * @module + */ + import type { SQLOutputValue } from "node:sqlite"; import { isRole, @@ -8,35 +15,54 @@ import { /** A user as stored: what they see of themselves, plus the password hash. */ export interface User extends SelfUser { + /** The scrypt hash of the password, as `hash.salt`. */ passwordHash: string; } +/** Returns what anyone may see of `user`: no hash, no email. */ export function toPublicUser( { passwordHash: _, email: _email, ...user }: User, ): PublicUser { return user; } +/** Returns what `user` sees of themselves: everything but the hash. */ export function toSelfUser({ passwordHash: _, ...user }: User): SelfUser { return user; } +/** A row of the `users` table. */ export interface UserRow { + /** The user's id. */ id: string; + /** The user's name, unique. */ username: string; + /** The scrypt hash of the password, as `hash.salt`. */ password_hash: string; + /** The user's role. */ role: Role; + /** When the user registered. */ created_at: string; + /** When the profile was last changed, if ever. */ updated_at: string | null; + /** The avatar's type, null without one. */ avatar_mime: string | null; + /** The user's profile text, in Markdown. */ description: string | null; + /** The id of the user whose invite they registered with. */ invited_by: string | null; - // Present only when joined: LEFT JOIN users i ON i.id = u.invited_by + /** + * The inviter's name; present only when the query joins it (`LEFT JOIN users + * i ON i.id = u.invited_by`). + */ invited_by_username: string | null; + /** The user's email address. */ email: string; - [key: string]: SQLOutputValue; // Index signature + /** Any other column the query selects. */ + [key: string]: SQLOutputValue; } +/** Checks whether `obj` is a {@link UserRow}. */ export function isUserRow(obj: unknown): obj is UserRow { return !!obj && typeof obj === "object" && @@ -56,6 +82,7 @@ export function isUserRow(obj: unknown): obj is UserRow { "email" in obj && typeof obj.email === "string"; } +/** Converts a row to the stored {@link User}. */ export function userRowToApi(row: UserRow): User { return { id: row.id, @@ -73,6 +100,7 @@ export function userRowToApi(row: UserRow): User { }; } +/** Converts a {@link User} back to a row, without the join-only columns. */ export function userApiToRow(user: User): UserRow { return { id: user.id, diff --git a/api/remote/bandcamp-streams.ts b/api/remote/bandcamp-streams.ts index e1d17d7d..79bba2f7 100644 --- a/api/remote/bandcamp-streams.ts +++ b/api/remote/bandcamp-streams.ts @@ -1,9 +1,3 @@ -import { APIErrorCode } from "../../shared/api.ts"; -import { APIException } from "../lib/api-exception.ts"; -import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts"; -import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts"; -import type { Tralbum } from "../../shared/player.ts"; - /** * Resolves a Bandcamp page to its streamable tracks, at play time. * @@ -11,16 +5,31 @@ import type { Tralbum } from "../../shared/player.ts"; * never be baked into `rich_content` at dump-creation time the way `embedUrl` * is. This service is the play-time counterpart: the frontend asks for a page, * gets a whole tracklist back, and plays the mp3s directly from bcbits. + * + * @module */ -/** Comfortably inside the 24h signature life, so a cache hit is never a URL - * that is about to expire mid-listen. */ +import { APIErrorCode } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import { fetchWithTimeout, isValidHttpUrl, readTextCapped } from "./fetch.ts"; +import { BANDCAMP_HOST_RE, parseTralbum } from "./bandcamp-tralbum.ts"; +import type { Tralbum } from "../../shared/player.ts"; + +/** + * How long a resolved page is cached: comfortably inside the 24h signature + * life, so a cache hit is never a URL that is about to expire mid-listen. + */ const TTL_MS = 12 * 60 * 60 * 1000; +/** The most pages cached; the oldest write goes first. */ const MAX_ENTRIES = 200; +/** Resolved pages, by {@link cacheKey}. */ const cache = new Map(); -/** Cache key: a page is the same page regardless of query, fragment or case. */ +/** + * Returns the cache key of `url`: a page is the same page regardless of query, + * fragment or case. + */ function cacheKey(url: string): string { try { const u = new URL(url); @@ -52,10 +61,14 @@ function writeCache(key: string, value: Tralbum): void { } /** - * Fetch and parse a Bandcamp page. + * Fetches a Bandcamp page and returns its tracklist, cached. * * `force` bypasses the cache — used by the client's re-resolve path, so a * cached entry whose URLs have started 403ing can't keep being handed back. + * + * @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 + * the page has nothing streamable. */ export async function resolveBandcamp( pageUrl: string, diff --git a/api/remote/bandcamp-tralbum.ts b/api/remote/bandcamp-tralbum.ts index 5a12f880..7e5bff7a 100644 --- a/api/remote/bandcamp-tralbum.ts +++ b/api/remote/bandcamp-tralbum.ts @@ -13,17 +13,20 @@ * 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 */ import type { Tralbum, TralbumTrack } from "../../shared/player.ts"; -/** Hostnames Bandcamp serves its own sites from. Anchored at the end so that - * `bandcamp.com.example.org` cannot pass. Artists on custom domains do not - * match this and keep using the embed. */ +/** + * The hostnames Bandcamp serves its own sites from, anchored at the end so that `bandcamp.com.example.org` cannot pass. Artists on custom + * domains do not match this and keep using the embed. + */ export const BANDCAMP_HOST_RE = /(?:^|\.)bandcamp\.com$/; /** - * Decode HTML entities in a single pass. + * Decodes HTML entities in a single pass. * * The single pass is the point: `decodeHtmlEntities` in remote/html.ts * replaces `&` before `"`, so an escaped-ampersand-then-quot sequence @@ -50,7 +53,7 @@ function decodeEntitiesOnce(input: string): string { ); } -/** Resolve Bandcamp's protocol-relative and root-relative art URLs. */ +/** Returns the URL of the large square artwork numbered `artId`. */ function artworkUrl(artId: unknown): string | undefined { if (typeof artId !== "number" && typeof artId !== "string") return undefined; const id = String(artId).trim(); @@ -60,7 +63,7 @@ function artworkUrl(artId: unknown): string | undefined { } /** - * Pull the `data-tralbum` blob out of a Bandcamp page and normalise it. + * Pulls the `data-tralbum` blob out of a Bandcamp page and normalises it. * Returns null when the page has no blob, no audio, or nothing parseable. */ export function parseTralbum(html: string, sourceUrl: string): Tralbum | null { @@ -114,6 +117,7 @@ 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 { try { const { protocol, hostname } = new URL(url); diff --git a/api/remote/fetch.ts b/api/remote/fetch.ts index 98e99662..af747610 100644 --- a/api/remote/fetch.ts +++ b/api/remote/fetch.ts @@ -1,3 +1,11 @@ +/** + * The guarded fetch every request to another site goes through: each hop must + * resolve to a public address, the whole exchange has a deadline, and pages are + * read up to a cap. + * + * @module + */ + import { isPrivateHostname, resolvePublicAddress } from "./public-address.ts"; import { isSelfUrl } from "../lib/self-url.ts"; @@ -12,7 +20,8 @@ const FETCH_HEADERS = { const MAX_REDIRECTS = 5; /** - * Fetch `url` via a `curl --insecure` subprocess and return a minimal Response. + * Fetches `url` through a `curl --insecure` subprocess and returns a minimal + * Response. * Used as a last resort when Deno's rustls rejects the server's TLS certificate * due to an unsupported algorithm (e.g. UnsupportedSignatureAlgorithm). * Returns null if curl is unavailable or exits non-zero. @@ -91,7 +100,7 @@ async function fetchViaCurl( } /** - * Most bytes of a fetched page that are read. Metadata lives in the , + * The most bytes of a fetched page that are read. Metadata lives in the , * and a "page" past this is a download, not something to scrape. */ export const MAX_PAGE_BYTES = 4 * 1024 * 1024; @@ -161,7 +170,7 @@ export async function readTextCapped( return new TextDecoder().decode(bytes); } -/** One request, no redirects followed. The timeout covers the body too. */ +/** Makes one request, following no redirects. The timeout covers the body too. */ async function fetchOnce( url: URL, address: string, @@ -203,9 +212,14 @@ async function fetchOnce( } /** - * Fetch a user-supplied URL. Every hop — the URL itself and each redirect — - * must resolve to a public address (see resolvePublicAddress), so a link or a - * redirect can't point the server at its own network. + * Fetches a user-supplied URL, following up to five redirects. Every hop — the + * URL itself and each redirect — must resolve to a public address (see + * {@link resolvePublicAddress}), so a link or a redirect can't point the + * server at its own network. + * + * @throws {TypeError} When a hop isn't http(s), there are too many redirects, + * or the request fails. + * @throws {Error} When a hop's host is private or can't be resolved. */ export async function fetchWithTimeout( url: string, @@ -227,6 +241,10 @@ export async function fetchWithTimeout( } } +/** + * Checks whether `raw` is an http(s) URL a dump may link to: not on a private + * host, unless it is one of this site's own pages. + */ export function isValidHttpUrl(raw: string): boolean { try { const u = new URL(raw); diff --git a/api/remote/html.ts b/api/remote/html.ts index 29a5e5d9..da91b37e 100644 --- a/api/remote/html.ts +++ b/api/remote/html.ts @@ -1,3 +1,11 @@ +/** + * Scraping helpers: the metadata a link preview reads from a page's HTML — + * meta and OG tags, the title, JSON-LD, images, icons and brand colors. + * + * @module + */ + +/** Decodes the named and numeric HTML entities in `str`. */ function decodeHtmlEntities(str: string): string { return str .replace(/&/gi, "&") @@ -13,8 +21,8 @@ function decodeHtmlEntities(str: string): string { } /** - * Find the first `` tag whose `attr` equals `value` and return its - * `content` (either attribute ordering). Each tag is matched in isolation so + * Returns the `content` of the first `` tag whose `attr` equals `value` + * (either attribute ordering). Each tag is matched in isolation so * the `content` capture can never span across tag boundaries. */ function extractMetaContent( @@ -36,6 +44,7 @@ function extractMetaContent( return undefined; } +/** Returns the content of ``. */ export function extractOgTag( html: string, tag: string, @@ -43,7 +52,10 @@ export function extractOgTag( return extractMetaContent(html, "property", `og:${tag}`); } -/** Extract content from `` (both attribute orderings). */ +/** + * Returns the content of `` (both attribute + * orderings). + */ export function extractMetaName( html: string, name: string, @@ -51,7 +63,7 @@ export function extractMetaName( return extractMetaContent(html, "name", name); } -/** Extract the text content of the `` element. */ +/** Returns the text content of the `<title>` element. */ export function extractPageTitle(html: string): string | undefined { const match = html.match(/<title[^>]*>([^<]+)<\/title>/i); return match ? decodeHtmlEntities(match[1].trim()) : undefined; @@ -60,7 +72,7 @@ export function extractPageTitle(html: string): string | undefined { // ── Brand color helpers ─────────────────────────────────────────────────────── /** - * Find the page's `theme-color`. + * Returns the page's `theme-color`. * * Sites routinely ship the tag twice, scoped by `media`, and often list the * dark-scheme one first — so unlike `extractMetaName` this can't just take the @@ -88,7 +100,7 @@ export function extractThemeColor(html: string): string | undefined { return unscoped ?? light ?? any; } -/** Extract the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */ +/** Returns the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */ export function extractMaskIconColor(html: string): string | undefined { const linkRe = /<link[^>]+>/gi; let m: RegExpExecArray | null; @@ -101,8 +113,10 @@ export function extractMaskIconColor(html: string): string | undefined { return undefined; } -// The handful of named colors that actually show up in `theme-color`. The full -// 148-name table isn't worth carrying for the long tail. +/** + * The handful of named colors that actually show up in `theme-color`. The full + * 148-name table isn't worth carrying for the long tail. + */ const NAMED_COLORS: Record<string, string> = { black: "#000000", white: "#ffffff", @@ -131,7 +145,7 @@ function clampByte(n: number): number { } /** - * Normalize a CSS color to canonical lowercase `#rrggbb`, or `undefined` when + * Normalizes a CSS color to canonical lowercase `#rrggbb`, or `undefined` when * it isn't one of the forms sites actually use. Alpha is dropped — the color is * only ever used as a tint over the app's own surface. */ @@ -168,13 +182,20 @@ export function normalizeCssColor(raw: string | undefined): string | undefined { return undefined; } -// ── JSON-LD helpers (file-private) ──────────────────────────────────────────── +// ── JSON-LD helpers ─────────────────────────────────────────────────────────── -type JsonLdResult = { +/** What a page's JSON-LD says about it. */ +export interface JsonLdResult { + /** The node's `name` or `headline`. */ title?: string; + /** The node's `description`. */ description?: string; + /** + * The node's `image`, `thumbnailUrl` or `thumbnail`, when it is an absolute + * URL. + */ thumbnailUrl?: string; -}; +} function ldString(v: unknown): string | undefined { if (typeof v === "string" && v.trim()) return v.trim(); @@ -216,7 +237,7 @@ function ldExtractNode(data: unknown): JsonLdResult { } /** - * Parse every `<script type="application/ld+json">` block and return the first + * Parses every `<script type="application/ld+json">` block and returns the first * node that yields a title or image. Handles `@graph`, arrays, and the common * `image` shapes (string, string[], ImageObject). */ @@ -234,7 +255,7 @@ export function extractJsonLd(html: string): JsonLdResult { } /** - * Return the `src` of the first `<img>` whose declared width or height is at + * Returns the `src` of the first `<img>` whose declared width or height is at * least `minSize` pixels (default 200). Skips data URIs. Resolves relative URLs. */ export function extractLargeImage( @@ -262,8 +283,8 @@ export function extractLargeImage( } /** - * Collect all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, rank - * them by declared size (largest wins), and return the best resolved URL. + * Collects all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, ranks + * them by declared size (largest wins), and returns the best resolved URL. * Falls back to the first match when no `sizes` attribute is present. * * SVG icons are ranked above everything: they carry no `sizes` attribute, so @@ -309,12 +330,7 @@ export function extractBestIcon( return candidates[0].href; } -/** - * Return the `src` of the first `<img>` that looks like content rather than UI - * chrome. Skips SVGs, data URIs, and images whose filename matches common - * icon/logo/nav patterns (logo, icon, sprite, favicon, avatar, banner, etc.). - * Resolves relative and protocol-relative URLs. - */ +/** Filename tokens that mark an image as UI chrome rather than content. */ const UI_IMAGE_KEYWORDS = new Set([ "logo", "icon", @@ -333,6 +349,10 @@ const UI_IMAGE_KEYWORDS = new Set([ "background", ]); +/** + * Checks whether `src` looks like UI chrome: an SVG, or a filename with one of + * the {@link UI_IMAGE_KEYWORDS}. + */ function isUiImage(src: string): boolean { if (/\.svg(\?|$)/i.test(src)) return true; const filename = src.split("?")[0].split("/").pop() ?? ""; @@ -344,7 +364,7 @@ function isUiImage(src: string): boolean { } /** - * Return the `src` of the first `<img>` that looks like content rather than UI + * Returns the `src` of the first `<img>` that looks like content rather than UI * chrome. Skips SVGs, data URIs, and images whose filename tokens match common * icon/logo/nav patterns (logo, icon, sprite, etc.). * Resolves relative and protocol-relative URLs. @@ -370,8 +390,8 @@ export function extractFirstContentImage( } /** - * Extract `href` from the first `<link rel="…">` whose rel contains `relFragment`, - * resolved to an absolute URL using `baseUrl`. + * Returns the `href` of the first `<link rel="…">` whose rel contains + * `relFragment`, resolved to an absolute URL using `baseUrl`. */ export function extractLinkHref( html: string, diff --git a/api/remote/image-proxy.ts b/api/remote/image-proxy.ts index a223ccae..dca5447b 100644 --- a/api/remote/image-proxy.ts +++ b/api/remote/image-proxy.ts @@ -1,7 +1,17 @@ +/** + * The image proxy's fetch and cache: remote thumbnails are served through + * this site, so viewers' browsers never contact the hosts. + * + * @module + */ + import { fetchWithTimeout } from "./fetch.ts"; +/** An image fetched for the proxy. */ export interface ProxiedImage { + /** The image's bytes. */ bytes: Uint8Array; + /** The image's MIME type, as its host sent it. */ contentType: string; } @@ -16,11 +26,15 @@ const PROXYABLE_IMAGE_TYPES = new Set([ "image/vnd.microsoft.icon", ]); -// Proxied images, by URL: every viewer of a feed asks for the same -// thumbnails, which were fetched from their hosts again for each one. Bounded -// by total size; the least recently used go first. +/** How long a proxied image is served from the cache. */ 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; +/** + * Proxied images, by URL: every viewer of a feed asks for the same thumbnails, + * which were fetched from their hosts again for each one. Bounded by total + * size; the least recently used go first. Map order is recency order. + */ const imageCache = new Map< string, { bytes: Uint8Array; contentType: string; at: number } @@ -63,16 +77,17 @@ function writeImageCache( imageCacheBytes += bytes.byteLength; } -/** Forget every proxied image (tests). */ +/** Forgets every proxied image (for the tests). */ export function clearImageCache(): void { imageCache.clear(); imageCacheBytes = 0; } /** - * 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. Throws when - * the host can't be reached. + * 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. + * + * @throws {Error} When the host can't be reached (see {@link fetchWithTimeout}). */ export async function fetchProxiedImage( url: string, diff --git a/api/remote/providers/bandcamp.ts b/api/remote/providers/bandcamp.ts index 13ba4a74..a0785172 100644 --- a/api/remote/providers/bandcamp.ts +++ b/api/remote/providers/bandcamp.ts @@ -1,3 +1,9 @@ +/** + * The Bandcamp link provider: previews from a release page's OG tags. + * + * @module + */ + import type { RichContent } from "../../../shared/dump.ts"; import type { RichContentProvider } from "./provider.ts"; import { extractOgTag } from "../html.ts"; @@ -5,6 +11,7 @@ import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; const BANDCAMP_REGEX = /(?:^|\.)bandcamp\.com/; +/** The provider for bandcamp.com pages, with the embed player from `og:video`. */ export const bandcampProvider: RichContentProvider = { name: "bandcamp", diff --git a/api/remote/providers/generic.ts b/api/remote/providers/generic.ts index 61de230a..9b84cb63 100644 --- a/api/remote/providers/generic.ts +++ b/api/remote/providers/generic.ts @@ -1,3 +1,9 @@ +/** + * The fallback link provider, for any page no other provider claims. + * + * @module + */ + import type { RichContent } from "../../../shared/dump.ts"; import type { RichContentProvider } from "./provider.ts"; import { @@ -14,6 +20,11 @@ import { } from "../html.ts"; import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; +/** + * The provider for any HTML page, previewed from its OG and Twitter tags, JSON-LD, and markup: + * title, description, artwork, icon and brand color. Matches every URL, so it + * must come last. + */ export const genericProvider: RichContentProvider = { name: "generic", diff --git a/api/remote/providers/provider.ts b/api/remote/providers/provider.ts index 4f6e059c..67ac846a 100644 --- a/api/remote/providers/provider.ts +++ b/api/remote/providers/provider.ts @@ -1,8 +1,20 @@ +/** + * The interface every link provider implements. + * + * @module + */ + import type { RichContent } from "../../../shared/dump.ts"; /** Metadata for the links one kind of site serves. */ export interface RichContentProvider { + /** The provider's name, for logs. */ name: string; + /** Checks whether this provider handles `url`. */ matches(url: string): boolean; + /** + * Fetches `url`'s preview. Rejects when the page can't be fetched; a page + * that isn't HTML yields a preview with no metadata. + */ fetch(url: string): Promise<RichContent>; } diff --git a/api/remote/providers/self.ts b/api/remote/providers/self.ts index 304a17ae..04e45e37 100644 --- a/api/remote/providers/self.ts +++ b/api/remote/providers/self.ts @@ -1,3 +1,10 @@ +/** + * The link provider for this site's own pages, read from the database + * instead of fetched. + * + * @module + */ + import type { RichContent } from "../../../shared/dump.ts"; import type { RichContentProvider } from "./provider.ts"; import { getDump } from "../../services/dump-service.ts"; @@ -10,14 +17,21 @@ const DUMP_RE = /^\/dumps\/([^/]+)$/; const USER_RE = /^\/users\/([^/]+)$/; const PLAYLIST_RE = /^\/playlists\/([^/]+)$/; -// Internal links carry gerbeur's own branding, so an art-less one still gets a -// recognizable placeholder instead of a generic tint. +/** + * Internal links carry gerbeur's own branding, so an art-less one still gets a + * recognizable placeholder instead of a generic tint. + */ const SELF_BRANDING = { siteName: "gerbeur", faviconUrl: `${PUBLIC_URL}/apple-touch-icon.png`, accentColor: THEME_COLOR, } as const; +/** + * The provider for links to this site's dump, user and playlist pages, read + * from the database. Its `fetch` rejects with the services' `APIException` (404) for a + * page that doesn't exist or is private. + */ export const selfProvider: RichContentProvider = { name: "self", diff --git a/api/remote/providers/soundcloud.ts b/api/remote/providers/soundcloud.ts index 487f3d34..4e56e744 100644 --- a/api/remote/providers/soundcloud.ts +++ b/api/remote/providers/soundcloud.ts @@ -1,8 +1,15 @@ +/** + * The SoundCloud link provider: previews from a track page's OG tags. + * + * @module + */ + import type { RichContent } from "../../../shared/dump.ts"; import type { RichContentProvider } from "./provider.ts"; import { extractOgTag } from "../html.ts"; import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; +/** The provider for soundcloud.com pages, with SoundCloud's embed player. */ export const soundcloudProvider: RichContentProvider = { name: "soundcloud", diff --git a/api/remote/providers/youtube.ts b/api/remote/providers/youtube.ts index 44139d67..aa8a53ec 100644 --- a/api/remote/providers/youtube.ts +++ b/api/remote/providers/youtube.ts @@ -1,8 +1,15 @@ +/** + * The YouTube link provider: videos, playlists and channels. + * + * @module + */ + import type { RichContent } from "../../../shared/dump.ts"; import type { RichContentProvider } from "./provider.ts"; import { extractOgTag } from "../html.ts"; import { fetchWithTimeout, readTextCapped } from "../fetch.ts"; +/** Returns the id of the video a youtu.be or youtube.com URL points at. */ function extractVideoId(url: string): string | null { try { const u = new URL(url); @@ -25,6 +32,7 @@ function extractVideoId(url: string): string | null { return null; } +/** Returns the `list` parameter of a URL: the playlist it plays. */ function extractPlaylistId(url: string): string | null { try { return new URL(url).searchParams.get("list"); @@ -34,7 +42,7 @@ function extractPlaylistId(url: string): string | null { } /** - * Extract the playback offset in seconds from a YouTube URL. + * Returns the playback offset in seconds of a YouTube URL. * Accepts `t` (plain seconds, `90s`, or `1h2m3s` / `20m5s` forms) and `start` * (plain seconds). Returns null when absent or unparseable. */ @@ -57,7 +65,10 @@ function extractStartSeconds(url: string): number | null { Number(hms[3] ?? 0); } -/** Matches /channel/UC…, /@handle, /c/name, /user/name */ +/** + * Returns the path of a channel URL: /channel/UC…, /@handle, /c/name or + * /user/name. + */ function extractChannelPath(url: string): string | null { try { const u = new URL(url); @@ -77,6 +88,10 @@ function extractChannelPath(url: string): string | null { return null; } +/** + * Fetches the title and thumbnail of `url` from YouTube's oEmbed endpoint; + * empty when that fails. + */ async function fetchOEmbed( url: string, ): Promise<{ title?: string; thumbnailUrl?: string }> { @@ -99,6 +114,10 @@ async function fetchOEmbed( return {}; } +/** + * The provider for YouTube videos (with their playlist and start time in the embed), + * playlists (as a `videoseries` embed) and channels (not embeddable). + */ export const youtubeProvider: RichContentProvider = { name: "youtube", diff --git a/api/remote/public-address.ts b/api/remote/public-address.ts index 51013135..bb9adad8 100644 --- a/api/remote/public-address.ts +++ b/api/remote/public-address.ts @@ -3,6 +3,8 @@ * image proxy, Bandcamp resolution): the server must not become a way to reach * addresses only it can see — loopback, the LAN, container networks, or the * cloud metadata endpoint at 169.254.169.254. + * + * @module */ function parseIPv4(s: string): number[] | null { @@ -25,7 +27,7 @@ function isPrivateIPv4([a, b]: number[]): boolean { a >= 224; // multicast, reserved, broadcast } -/** 16 bytes, or null when `s` isn't an IPv6 address. */ +/** Parses `s` into its 16 bytes, or null when it isn't an IPv6 address. */ function parseIPv6(s: string): number[] | null { let addr = s.toLowerCase().replace(/%.*$/, ""); // drop a zone id // An embedded dotted quad (::ffff:10.0.0.1) becomes the last two groups. @@ -80,7 +82,10 @@ function isPrivateIPv6(b: number[]): boolean { b[0] === 0xff; // multicast } -/** True for an IP literal outside the public internet. Non-IPs are false. */ +/** + * Checks whether `ip` is an IP literal outside the public internet. Non-IPs + * are false. + */ export function isPrivateAddress(ip: string): boolean { const v4 = parseIPv4(ip); if (v4) return isPrivateIPv4(v4); @@ -92,13 +97,16 @@ function isIpLiteral(host: string): boolean { return parseIPv4(host) !== null || parseIPv6(host) !== null; } -/** `URL.hostname` keeps the brackets around an IPv6 literal. */ +/** + * Returns `url`'s hostname without the brackets `URL.hostname` keeps around + * an IPv6 literal. + */ function bareHostname(url: URL): string { return url.hostname.replace(/^\[(.*)\]$/, "$1"); } /** - * The synchronous part of the check: a hostname that is a private IP literal, + * Checks whether `url`'s host is private without DNS: a private IP literal, * or a name that is local by definition. Names still need resolving (see * resolvePublicAddress) — this only rejects what is obvious without DNS. */ @@ -116,6 +124,9 @@ export function isPrivateHostname(url: URL): boolean { * The fetch itself resolves again, so a DNS server answering differently the * second time (rebinding) isn't fully excluded; the curl fallback pins the * address this returns. + * + * @throws {Error} When the host is private, can't be resolved, or resolves to + * a private address. */ export async function resolvePublicAddress(url: URL): Promise<string> { if (isPrivateHostname(url)) { diff --git a/api/remote/twemoji.ts b/api/remote/twemoji.ts index 0c704d71..3863a7ee 100644 --- a/api/remote/twemoji.ts +++ b/api/remote/twemoji.ts @@ -1,12 +1,20 @@ -// Pinned Twemoji release. +/** + * Twemoji glyphs, fetched from the jsDelivr CDN. + * + * @module + */ + +/** The pinned Twemoji release. */ const TWEMOJI_VERSION = "16.0.1"; -// Cache the fetched glyph per emoji so repeated runs don't re-hit the network. +/** Fetched glyphs by codepoint, so repeated runs don't re-hit the network. */ const glyphCache = new Map<string, string>(); /** - * Twemoji's plain vector-path SVG for `emoji`, whose file is named `cp` (see - * emojiToCodepoint in lib/site-icons.ts). + * Fetches Twemoji's plain vector-path SVG for `emoji`, whose file is named + * `cp` (see `emojiToCodepoint` in lib/site-icons.ts). + * + * @throws {Error} When the glyph can't be fetched. */ export async function fetchTwemojiSvg( emoji: string, diff --git a/api/remote/youtube-playlist.ts b/api/remote/youtube-playlist.ts index 80f0a5cf..90e19740 100644 --- a/api/remote/youtube-playlist.ts +++ b/api/remote/youtube-playlist.ts @@ -1,11 +1,3 @@ -import { APIErrorCode } from "../../shared/api.ts"; -import { APIException } from "../lib/api-exception.ts"; -import { readTextCapped } from "./fetch.ts"; -import type { - YouTubePlaylist, - YouTubePlaylistTrack, -} from "../../shared/player.ts"; - /** * Resolves a YouTube playlist to its tracklist, at play time. * @@ -20,17 +12,33 @@ import type { * 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 */ -/** Titles and lengths rarely change; a stale entry only mislabels a row. */ +import { APIErrorCode } from "../../shared/api.ts"; +import { APIException } from "../lib/api-exception.ts"; +import { readTextCapped } from "./fetch.ts"; +import type { + YouTubePlaylist, + YouTubePlaylistTrack, +} from "../../shared/player.ts"; + +/** + * 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; +/** The most playlists cached; the oldest write goes first. */ const MAX_ENTRIES = 200; +/** Resolved playlists, by list id. */ const cache = new Map<string, { value: YouTubePlaylist; at: number }>(); -/** Playlist IDs are URL-safe base64-ish: PL…, OLAK5uy_…, RD… (mixes). */ +/** A playlist id: URL-safe base64-ish, PL…, OLAK5uy_…, RD… (mixes). */ const LIST_ID_RE = /^[\w-]{2,64}$/; +/** The headers the playlist page is requested with. */ const HEADERS = { "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", @@ -41,13 +49,13 @@ const HEADERS = { "Cookie": "SOCS=CAI", }; -/** "3:55", "1:02:03" → seconds. */ +/** Converts a clock like "3:55" or "1:02:03" to seconds. */ function parseClock(text: string): number | undefined { if (!/^\d+(?::\d{1,2}){1,2}$/.test(text)) return undefined; return text.split(":").reduce((acc, part) => acc * 60 + Number(part), 0); } -/** Depth-first walk collecting every value stored under `key`. */ +/** Collects, depth-first, every value stored under `key` into `out`. */ function collect(node: unknown, key: string, out: unknown[]): void { if (Array.isArray(node)) { for (const child of node) collect(child, key, out); @@ -59,13 +67,17 @@ function collect(node: unknown, key: string, out: unknown[]): void { } } +/** Returns the first string stored under `key`, at any depth. */ function firstString(node: unknown, key: string): string | undefined { const found: unknown[] = []; collect(node, key, found); return found.find((v): v is string => typeof v === "string"); } -/** Follows `path` into parsed JSON; undefined wherever 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 { let cur = node; for (const key of path) { @@ -75,6 +87,7 @@ function at(node: unknown, ...path: (string | number)[]): unknown { return cur; } +/** Reads a track from a current `lockupViewModel` row; null for a non-video. */ function fromLockup(lockup: unknown): YouTubePlaylistTrack | null { if (at(lockup, "contentType") !== "LOCKUP_CONTENT_TYPE_VIDEO") return null; const videoId = at(lockup, "contentId"); @@ -96,6 +109,7 @@ function fromLockup(lockup: unknown): YouTubePlaylistTrack | null { return { videoId, title, duration }; } +/** Reads a track from an older `playlistVideoRenderer` row. */ function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null { const videoId = at(r, "videoId"); const title = at(r, "title", "runs", 0, "text") ?? @@ -109,6 +123,10 @@ function fromLegacyRenderer(r: unknown): YouTubePlaylistTrack | null { }; } +/** + * Reads the tracklist from a playlist page's `ytInitialData`; null when the + * page has none or no track in it. + */ export function parsePlaylistPage( html: string, listId: string, @@ -145,6 +163,13 @@ export function parsePlaylistPage( return { listId, title, tracks }; } +/** + * Fetches the playlist `listId` and returns its tracklist, cached. + * + * @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 + * no tracks to read (private, deleted, or a mix). + */ export async function resolveYouTubePlaylist( listId: string, ): Promise<YouTubePlaylist> { diff --git a/api/routes/attachments.ts b/api/routes/attachments.ts index 18b660c2..0614f9be 100644 --- a/api/routes/attachments.ts +++ b/api/routes/attachments.ts @@ -1,3 +1,9 @@ +/** + * The attachment routes, for images pasted into Markdown bodies. + * + * @module + */ + import { Router } from "@oak/oak"; import { authMiddleware } from "../middleware/auth.ts"; import { @@ -13,7 +19,15 @@ import { validateImageUpload, } from "../lib/upload.ts"; -const router = new Router(); +/** + * The attachment routes, for images pasted into Markdown bodies: + * + * - `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 + * links it. + * - `GET /api/attachments/:attachmentId` — public. Serves the image. + */ +const router: Router = new Router(); router.post("/api/attachments", authMiddleware, async (ctx) => { const contentType = ctx.request.headers.get("content-type") ?? ""; diff --git a/api/routes/avatars.ts b/api/routes/avatars.ts index 0f6ad8af..117d4f21 100644 --- a/api/routes/avatars.ts +++ b/api/routes/avatars.ts @@ -1,3 +1,9 @@ +/** + * The avatar routes. + * + * @module + */ + import { Router } from "@oak/oak"; import { authMiddleware } from "../middleware/auth.ts"; import { getUserById, updateUserAvatar } from "../services/user-service.ts"; @@ -15,7 +21,15 @@ import { validateImageUpload, } from "../lib/upload.ts"; -const router = new Router(); +/** + * The avatar routes: + * + * - `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. + * - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without + * one. + */ +const router: Router = new Router(); router.post("/api/avatars/me", authMiddleware, async (ctx) => { const authPayload = ctx.state.user; diff --git a/api/routes/bandcamp.ts b/api/routes/bandcamp.ts index 2c01dde5..a2f33b2b 100644 --- a/api/routes/bandcamp.ts +++ b/api/routes/bandcamp.ts @@ -1,18 +1,26 @@ +/** + * The Bandcamp route. + * + * @module + */ + import { Router } from "@oak/oak"; import type { APIResponse } from "../../shared/api.ts"; import { resolveBandcamp } from "../remote/bandcamp-streams.ts"; import type { Tralbum } from "../../shared/player.ts"; -const bandcampRouter = new Router(); - /** - * Resolve a Bandcamp page to its streamable tracks. + * The Bandcamp route, for the native player: * - * Public, like /api/preview: it only reads a public page and returns URLs the - * same page already hands any visitor. Errors are thrown as APIException and - * rendered by errorMiddleware; the client treats any failure as "fall back to - * the iframe embed". + * - `GET /api/bandcamp/tracks?url=&force=` — public. Resolves a Bandcamp page + * to its streamable tracks, at play time; `force=1` bypasses the cache. */ +const bandcampRouter: Router = new Router(); + +// Public: it only reads a public page and returns URLs the same page already +// hands any visitor. Errors are thrown as APIException and rendered by +// errorMiddleware; the client treats any failure as "fall back to the iframe +// embed". bandcampRouter.get("/api/bandcamp/tracks", async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; const force = ctx.request.url.searchParams.get("force") === "1"; diff --git a/api/routes/categories.ts b/api/routes/categories.ts index 5b5d3c7e..8d4b1738 100644 --- a/api/routes/categories.ts +++ b/api/routes/categories.ts @@ -1,3 +1,9 @@ +/** + * The category routes. + * + * @module + */ + import { Router } from "@oak/oak"; import { @@ -25,7 +31,20 @@ import { updateCategory, } from "../services/category-service.ts"; -const router = new Router({ prefix: "/api/categories" }); +/** + * The category routes: + * + * - `GET /api/categories` — public. Answers every category, in order. + * - `GET /api/categories/:slug` — public. Answers one category. + * - `GET /api/categories/:slug/dumps?page=&limit=` — optional sign-in. Answers + * a page of its dumps, newest first, with the caller's private ones. + * - `POST /api/categories` — permission `category:manage`. Creates a category. + * - `PUT /api/categories/:id` — permission `category:manage`. Updates a + * category. + * - `DELETE /api/categories/:id` — permission `category:manage`. Deletes a + * category; its dumps stay. + */ +const router: Router = new Router({ prefix: "/api/categories" }); router.get("/", (ctx) => { const responseBody: APIResponse<Category[]> = { diff --git a/api/routes/chat.ts b/api/routes/chat.ts index 9c591757..7d754474 100644 --- a/api/routes/chat.ts +++ b/api/routes/chat.ts @@ -1,3 +1,9 @@ +/** + * The chat history route. + * + * @module + */ + import { Router } from "@oak/oak"; import type { APIResponse } from "../../shared/api.ts"; import type { ChatMessage } from "../../shared/chat.ts"; @@ -7,13 +13,19 @@ import { getRecentChatMessages, } from "../services/chat-service.ts"; -const router = new Router({ prefix: "/api/chat" }); +/** + * The chat history route; messages are posted and edited over the socket (see + * routes/ws.ts): + * + * - `GET /api/chat?limit=&before=&beforeId=&after=&afterId=` — signed in. + * Answers messages in ascending order: the most recent by default; with + * `before`, the page of older messages preceding that cursor ("load older"); + * with `after`, the messages following it (resync after a reconnect). A + * cursor is an ISO `created_at` plus the message id to break ties; without + * `afterId` the `after` timestamp itself is included. + */ +const router: Router = new Router({ prefix: "/api/chat" }); -// GET /api/chat — recent messages (ascending). `limit` caps the page size. -// Cursors (ISO created_at, plus the message id to break ties): -// before/beforeId — the page of older messages preceding it ("load older"); -// after/afterId — the messages following it (resync after a reconnect); -// without afterId the timestamp itself is included. router.get("/", authMiddleware, (ctx) => { const params = ctx.request.url.searchParams; const limitParam = params.get("limit"); diff --git a/api/routes/comments.ts b/api/routes/comments.ts index 76af1f86..86c44ea6 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -1,3 +1,9 @@ +/** + * The comment routes. + * + * @module + */ + import { Router } from "@oak/oak"; import { APIErrorCode, @@ -31,9 +37,23 @@ import { broadcastCommentUpdated, } from "../services/ws-service.ts"; -const router = new Router({ prefix: "/api" }); +/** + * The comment routes. A private dump's comments exist only for its poster, and + * changes to them aren't broadcast: + * + * - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment + * on a dump, oldest first. + * - `POST /api/dumps/:dumpId/comments` — signed in. Posts a comment, replying + * to `parentId` if given. + * - `PATCH /api/comments/:commentId` — signed in, as its author or with + * `comment:moderate`. Edits a comment. + * - `GET /api/comments/:commentId/likers?page=&limit=` — optional sign-in. + * Answers a page of the users who like a comment. + * - `DELETE /api/comments/:commentId` — signed in, as its author or with + * `comment:moderate`. Deletes a comment, leaving its place in the thread. + */ +const router: Router = new Router({ prefix: "/api" }); -// GET /api/dumps/:dumpId/comments — optional auth (to access private dump comments) router.get("/dumps/:dumpId/comments", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const dump = getDump(ctx.params.dumpId, requestingUserId); @@ -45,7 +65,6 @@ router.get("/dumps/:dumpId/comments", async (ctx) => { ctx.response.body = responseBody; }); -// POST /api/dumps/:dumpId/comments — auth required router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => { const userId = ctx.state.user.userId; const dump = getDump(ctx.params.dumpId, userId); @@ -69,7 +88,6 @@ router.post("/dumps/:dumpId/comments", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); -// PATCH /api/comments/:commentId — auth required router.patch("/comments/:commentId", authMiddleware, async (ctx) => { const userId = ctx.state.user.userId; const canModerate = can(ctx.state.user, "comment:moderate"); @@ -92,7 +110,6 @@ router.patch("/comments/:commentId", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); -// GET /api/comments/:commentId/likers — optional auth (to access private dump's comment likers) router.get("/comments/:commentId/likers", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx) ?? undefined; const dumpId = getCommentDumpId(ctx.params.commentId); @@ -113,7 +130,6 @@ router.get("/comments/:commentId/likers", async (ctx) => { ctx.response.body = responseBody; }); -// DELETE /api/comments/:commentId — auth required router.delete("/comments/:commentId", authMiddleware, (ctx) => { const userId = ctx.state.user.userId; const canModerate = can(ctx.state.user, "comment:moderate"); diff --git a/api/routes/dumps.ts b/api/routes/dumps.ts index 49766775..f537ed71 100644 --- a/api/routes/dumps.ts +++ b/api/routes/dumps.ts @@ -1,3 +1,9 @@ +/** + * The dump routes. + * + * @module + */ + import { Router } from "@oak/oak"; import { @@ -38,13 +44,48 @@ import { getDumpVoters } from "../services/vote-service.ts"; import { getRelatedDumps } from "../services/backlink-service.ts"; import { getPlaylistsForDump } from "../services/playlist-service.ts"; -const router = new Router({ prefix: "/api/dumps" }); +/** + * The dump routes. `:dumpId` takes an id or a slug; a private dump exists only + * for its poster: + * + * - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file + * (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`). + * - `GET /api/dumps/by-url?url=` — optional sign-in. Answers the dumps already + * pointing at the same thing as `url`, for the create form's duplicate hint. + * - `GET /api/dumps/:dumpId` — optional sign-in. Answers a dump. + * - `GET /api/dumps/:dumpId/voters?page=&limit=` — optional sign-in. Answers a + * page of the users who upvoted it. + * - `GET /api/dumps/:dumpId/related` — optional sign-in. Answers the dumps + * linked to or from it. + * - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the + * playlists it appears in. + * - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the + * dumps, newest first. + * - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with + * `dump:moderate`. Replaces a file dump's upload (multipart). + * - `PUT /api/dumps/:dumpId` — signed in, as its poster or with + * `dump:moderate`. Updates its title, comment, visibility, categories, and a + * link's URL. + * - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with + * `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5 + * MB). + * - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with + * `dump:moderate`. Removes its custom thumbnail. + * - `POST /api/dumps/:dumpId/refresh-metadata` — signed in, as its poster or + * with `dump:moderate`. Fetches a link's preview again. + * - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with + * `dump:moderate`. Deletes it. + */ +const router: Router = new Router({ prefix: "/api/dumps" }); // Every route here takes a slug or an id; the services below them key on the // id, so mutations always pass `dump.id` from the resolved dump, never the raw // parameter. -// Categories arrive on the multipart create path as a JSON-encoded string field. +/** + * Reads the category ids that arrive on the multipart create path as a + * JSON-encoded string field; empty when it is missing or malformed. + */ function parseCategoryIds(value: FormDataEntryValue | null): string[] { if (typeof value !== "string" || !value) return []; try { diff --git a/api/routes/files.ts b/api/routes/files.ts index 07b6b928..15790c8e 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -1,3 +1,9 @@ +/** + * The route serving file dumps' uploads. + * + * @module + */ + import { Router } from "@oak/oak"; import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; @@ -6,7 +12,14 @@ import { authenticateMedia } from "../lib/auth.ts"; import { serveFile } from "../lib/serve-file.ts"; import { dumpFileVersion, getDump } from "../services/dump-service.ts"; -const router = new Router({ prefix: "/api/files" }); +/** + * The route serving file dumps' uploads: + * + * - `GET /api/files/:dumpId?token=` — public; a private dump's file needs its + * poster's media `token`. Serves the file, inline when that is safe and as a + * download otherwise, with byte ranges. + */ +const router: Router = new Router({ prefix: "/api/files" }); /** * Builds an RFC 6266 `Content-Disposition` value that is always a valid @@ -28,15 +41,18 @@ function contentDisposition( }`; } -// The MIME type of an upload is whatever the uploader's browser claimed, and -// these responses come from the app's own origin — where the session token -// lives. Only types a browser renders without running the file's own code are -// served inline; anything else (text/html, image/svg+xml, XML, …) downloads. -// <img>, <audio> and fetch() ignore Content-Disposition, so previews that embed -// or read the file still work. +/** + * The types served inline. The MIME type of an upload is whatever the + * uploader's browser claimed, and these responses come from the app's own + * origin — where the session token lives. Only types a browser renders without + * running the file's own code are served inline; anything else (text/html, + * image/svg+xml, XML, …) downloads. <img>, <audio> and fetch() ignore + * Content-Disposition, so previews that embed or read the file still work. + */ const INLINE_MIME_RE = /^(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 { return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase()); } diff --git a/api/routes/follows.ts b/api/routes/follows.ts index c5f28f22..fc77a258 100644 --- a/api/routes/follows.ts +++ b/api/routes/follows.ts @@ -1,3 +1,9 @@ +/** + * The follow routes. + * + * @module + */ + import { Router } from "@oak/oak"; import { authMiddleware } from "../middleware/auth.ts"; import type { APIResponse, PaginatedData } from "../../shared/api.ts"; @@ -15,9 +21,12 @@ import { } from "../services/follow-service.ts"; import { getCategoryIdBySlug } from "../services/category-service.ts"; -// Resolves the optional ?category=<slug> filter. Returns the matching category -// id, or "missing" when a slug was given but matches nothing (so the feed comes -// back empty rather than unfiltered), or undefined when no filter was requested. +/** + * Resolves the optional ?category=<slug> filter. Returns the matching category + * id, or "missing" when a slug was given but matches nothing (so the feed comes + * back empty rather than unfiltered), or undefined when no filter was + * requested. + */ function resolveCategoryFilter( params: URLSearchParams, ): string | "missing" | undefined { @@ -26,20 +35,37 @@ function resolveCategoryFilter( return getCategoryIdBySlug(slug) ?? "missing"; } +/** The feed a filter naming no category yields. */ const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false }; -const router = new Router({ prefix: "/api/follows" }); +/** + * The follow routes, all for what the caller follows: + * + * - `GET /api/follows/status` — signed in. Answers the ids of the users and + * playlists they follow. + * - `GET /api/follows/feed/users?page=&limit=&category=` — signed in. Answers a + * page of the public dumps of the users they follow, newest first, optionally + * in one category. + * - `GET /api/follows/feed/playlists?page=&limit=&category=` — signed in. + * Answers a page of the public dumps in the public playlists they follow, + * most recently added first, optionally in one category. + * - `POST /api/follows/users/:userId` — signed in. Follows a user. + * - `DELETE /api/follows/users/:userId` — signed in. Stops following a user. + * - `POST /api/follows/playlists/:playlistId` — signed in. Follows a public + * playlist. + * - `DELETE /api/follows/playlists/:playlistId` — signed in. Stops following a + * playlist. + */ +const router: Router = new Router({ prefix: "/api/follows" }); // Static routes first to prevent Oak matching "status"/"feed" as a :param -// GET /api/follows/status router.get("/status", authMiddleware, (ctx) => { const status = getFollowStatus(ctx.state.user.userId); const body: APIResponse<FollowStatus> = { success: true, data: status }; ctx.response.body = body; }); -// GET /api/follows/feed/users?page=&limit= router.get("/feed/users", authMiddleware, (ctx) => { const { page, limit } = parsePagination(ctx.request.url.searchParams); const categoryId = resolveCategoryFilter(ctx.request.url.searchParams); @@ -54,7 +80,6 @@ router.get("/feed/users", authMiddleware, (ctx) => { ctx.response.body = body; }); -// GET /api/follows/feed/playlists?page=&limit= router.get("/feed/playlists", authMiddleware, (ctx) => { const { page, limit } = parsePagination(ctx.request.url.searchParams); const categoryId = resolveCategoryFilter(ctx.request.url.searchParams); @@ -74,25 +99,21 @@ router.get("/feed/playlists", authMiddleware, (ctx) => { ctx.response.body = body; }); -// POST /api/follows/users/:userId router.post("/users/:userId", authMiddleware, (ctx) => { followUser(ctx.state.user.userId, ctx.params.userId); ctx.response.status = 204; }); -// DELETE /api/follows/users/:userId router.delete("/users/:userId", authMiddleware, (ctx) => { unfollowUser(ctx.state.user.userId, ctx.params.userId); ctx.response.status = 204; }); -// POST /api/follows/playlists/:playlistId router.post("/playlists/:playlistId", authMiddleware, (ctx) => { followPlaylist(ctx.state.user.userId, ctx.params.playlistId); ctx.response.status = 204; }); -// DELETE /api/follows/playlists/:playlistId router.delete("/playlists/:playlistId", authMiddleware, (ctx) => { unfollowPlaylist(ctx.state.user.userId, ctx.params.playlistId); ctx.response.status = 204; diff --git a/api/routes/invites.ts b/api/routes/invites.ts index ed1bc2e0..767ea8f4 100644 --- a/api/routes/invites.ts +++ b/api/routes/invites.ts @@ -1,3 +1,9 @@ +/** + * The invite routes. + * + * @module + */ + import { Router } from "@oak/oak"; import { APIErrorCode, type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; @@ -5,9 +11,15 @@ import type { InviteResponse } from "../../shared/user.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { createInvite, validateInvite } from "../services/invite-service.ts"; -const router = new Router({ prefix: "/api/invites" }); +/** + * The invite routes: + * + * - `POST /api/invites` — signed in. Makes an invite and answers its token. + * - `GET /api/invites/:token` — public. Checks an invite is valid, before the + * register page shows its form; 404 when it isn't. + */ +const router: Router = new Router({ prefix: "/api/invites" }); -// Create a new invite link (any authenticated user) router.post("/", authMiddleware, (ctx: AuthContext) => { if (!ctx.state.user) { throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated"); @@ -20,7 +32,6 @@ router.post("/", authMiddleware, (ctx: AuthContext) => { ctx.response.body = responseBody; }); -// Validate an invite token (used by the register page before showing the form) router.get("/:token", (ctx) => { try { validateInvite(ctx.params.token); diff --git a/api/routes/notifications.ts b/api/routes/notifications.ts index 2d6db119..ce1e2241 100644 --- a/api/routes/notifications.ts +++ b/api/routes/notifications.ts @@ -1,3 +1,9 @@ +/** + * The notification routes. + * + * @module + */ + import { Router } from "@oak/oak"; import { APIErrorCode, @@ -14,9 +20,16 @@ import { markOneRead, } from "../services/notification-service.ts"; -const router = new Router({ prefix: "/api/notifications" }); +/** + * The notification routes, all for the caller's own notifications: + * + * - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them, + * newest first. + * - `POST /api/notifications/read-all` — signed in. Marks them all read. + * - `PATCH /api/notifications/:id/read` — signed in. Marks one read. + */ +const router: Router = new Router({ prefix: "/api/notifications" }); -// GET /api/notifications?page=N&limit=N router.get("/", authMiddleware, (ctx: AuthContext) => { if (!ctx.state.user) { throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated"); @@ -34,7 +47,6 @@ router.get("/", authMiddleware, (ctx: AuthContext) => { ctx.response.body = responseBody; }); -// POST /api/notifications/read-all router.post("/read-all", authMiddleware, (ctx: AuthContext) => { if (!ctx.state.user) { throw new APIException(APIErrorCode.UNAUTHORIZED, 401, "Not authenticated"); @@ -43,7 +55,6 @@ router.post("/read-all", authMiddleware, (ctx: AuthContext) => { ctx.response.status = 204; }); -// PATCH /api/notifications/:id/read router.patch("/:id/read", authMiddleware, (ctx) => { const user = ctx.state.user; if (!user) { diff --git a/api/routes/playlists.ts b/api/routes/playlists.ts index 445d1672..c1777688 100644 --- a/api/routes/playlists.ts +++ b/api/routes/playlists.ts @@ -1,5 +1,11 @@ +/** + * The playlist routes. + * + * @module + */ + import { Router } from "@oak/oak"; -import { parseOptionalAuth } from "../lib/auth.ts"; +import { authenticateMedia, parseOptionalAuth } from "../lib/auth.ts"; import { APIErrorCode, type APIResponse } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { @@ -33,9 +39,35 @@ import { validateImageUpload, } from "../lib/upload.ts"; -const router = new Router<AuthState>({ prefix: "/api/playlists" }); +/** + * The playlist routes. `:playlistId` takes an id or a slug: + * + * - `GET /api/playlists/by-dump/:dumpId/memberships` — signed in. Answers each + * of the caller's playlists and whether it holds the dump. + * - `POST /api/playlists` — signed in. Creates 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. + * - `PUT /api/playlists/:playlistId` — signed in, as its owner or with + * `playlist:moderate`. Updates its title, description and visibility. + * - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with + * `playlist:moderate`. Deletes it. + * - `POST /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner + * or with `playlist:moderate`. Adds a dump at the top. + * - `DELETE /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner + * or with `playlist:moderate`. Removes a dump. + * - `POST /api/playlists/:playlistId/image` — signed in, as its owner or with + * `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB). + * - `GET /api/playlists/:playlistId/image?token=` — public; a private + * playlist's cover needs its owner's media `token`. Serves its cover; 404 + * without one. + * - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with + * `playlist:moderate`. Reorders the dumps the caller can see in it. + */ +const router: Router<AuthState> = new Router<AuthState>({ + prefix: "/api/playlists", +}); -// GET /api/playlists/by-dump/:dumpId/memberships — must be before /:playlistId +// Before /:playlistId, which would otherwise match "by-dump". router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => { const { dumpId } = ctx.params; const userId = ctx.state.user.userId; @@ -46,7 +78,6 @@ router.get("/by-dump/:dumpId/memberships", authMiddleware, (ctx) => { ctx.response.body = responseBody; }); -// POST /api/playlists — create router.post("/", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isCreatePlaylistRequest(body)) { @@ -64,7 +95,6 @@ router.post("/", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); -// GET /api/playlists/:playlistId — optional auth router.get("/:playlistId", async (ctx) => { const requestingUserId = await parseOptionalAuth(ctx); const responseBody: APIResponse<PlaylistWithDumps> = { @@ -74,7 +104,6 @@ router.get("/:playlistId", async (ctx) => { ctx.response.body = responseBody; }); -// PUT /api/playlists/:playlistId — update metadata router.put("/:playlistId", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isUpdatePlaylistRequest(body)) { @@ -94,7 +123,6 @@ router.put("/:playlistId", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); -// DELETE /api/playlists/:playlistId router.delete("/:playlistId", authMiddleware, (ctx) => { deletePlaylist( ctx.params.playlistId, @@ -104,7 +132,6 @@ router.delete("/:playlistId", authMiddleware, (ctx) => { ctx.response.status = 204; }); -// POST /api/playlists/:playlistId/dumps/:dumpId — add dump router.post("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => { addDumpToPlaylist( ctx.params.playlistId, @@ -115,7 +142,6 @@ router.post("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => { ctx.response.status = 204; }); -// DELETE /api/playlists/:playlistId/dumps/:dumpId — remove dump router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => { removeDumpFromPlaylist( ctx.params.playlistId, @@ -126,7 +152,6 @@ router.delete("/:playlistId/dumps/:dumpId", authMiddleware, (ctx) => { ctx.response.status = 204; }); -// POST /api/playlists/:playlistId/image — upload playlist image router.post("/:playlistId/image", authMiddleware, async (ctx) => { const contentType = ctx.request.headers.get("content-type") ?? ""; if (!contentType.includes("multipart/form-data")) { @@ -177,21 +202,27 @@ router.post("/:playlistId/image", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); -// GET /api/playlists/:playlistId/image — serve playlist image router.get("/:playlistId/image", async (ctx) => { - const info = getPlaylistImageInfo(ctx.params.playlistId); + // An <img> can't send an Authorization header, so a private playlist's owner + // is known by the media token in the query string, as for dump files. + const token = ctx.request.url.searchParams.get("token"); + const viewerId = token ? await authenticateMedia(token) : null; + const info = getPlaylistImageInfo(ctx.params.playlistId, viewerId); if (!info) { ctx.response.status = 404; return; } + // The URL may carry a media token; keep it out of anyone else's logs. + ctx.response.headers.set("Referrer-Policy", "no-referrer"); await serveUploadedFile( ctx, `${PLAYLIST_IMAGES_DIR}/${info.id}`, info.imageMime, + 3600, + !info.isPublic, ); }); -// PUT /api/playlists/:playlistId/order — reorder router.put("/:playlistId/order", authMiddleware, async (ctx) => { const body: unknown = await ctx.request.body.json(); if (!isReorderPlaylistRequest(body)) { diff --git a/api/routes/preview.ts b/api/routes/preview.ts index 197bb07b..65063282 100644 --- a/api/routes/preview.ts +++ b/api/routes/preview.ts @@ -1,3 +1,9 @@ +/** + * The link preview routes. + * + * @module + */ + import { Router } from "@oak/oak"; import { isValidHttpUrl } from "../remote/fetch.ts"; import { fetchProxiedImage } from "../remote/image-proxy.ts"; @@ -10,7 +16,15 @@ import { import type { LinkPreview } from "../../shared/dump.ts"; import { authMiddleware } from "../middleware/auth.ts"; -const previewRouter = new Router(); +/** + * The link preview routes: + * + * - `GET /api/preview?url=` — signed in. Answers the preview of a link, and + * whether its page could be reached. + * - `GET /api/proxy-image?url=` — public. Serves a remote raster image through + * this site; 400 when it isn't one, 502 when its host can't be reached. + */ +const previewRouter: Router = new Router(); // Signed-in only: it's the create form's preview, and an open one lets anyone // use the server to fetch pages for them. @@ -37,6 +51,7 @@ previewRouter.get("/api/preview", authMiddleware, async (ctx) => { ctx.response.body = responseBody; }); +/** Answers with a proxied image, sandboxed so it can't run as a document. */ function sendImage( ctx: { response: { headers: Headers; body?: unknown } }, bytes: Uint8Array, @@ -52,10 +67,8 @@ function sendImage( ctx.response.body = bytes; } -/** - * Proxy an external image through the server so HTTP thumbnail URLs don't - * trigger mixed-content blocks when the frontend is served over HTTPS. - */ +// Proxies an external image through the server so HTTP thumbnail URLs don't +// trigger mixed-content blocks when the frontend is served over HTTPS. previewRouter.get("/api/proxy-image", async (ctx) => { const url = ctx.request.url.searchParams.get("url") ?? ""; if (!isValidHttpUrl(url)) { diff --git a/api/routes/search.ts b/api/routes/search.ts index 40199d7d..0638a1eb 100644 --- a/api/routes/search.ts +++ b/api/routes/search.ts @@ -1,3 +1,9 @@ +/** + * The search route. + * + * @module + */ + import { Router } from "@oak/oak"; import { parseOptionalAuth } from "../lib/auth.ts"; import { parsePagination } from "../lib/pagination.ts"; @@ -7,7 +13,14 @@ import { searchPlaylists } from "../services/playlist-service.ts"; import type { APIResponse } from "../../shared/api.ts"; import type { SearchResults } from "../../shared/search.ts"; -const router = new Router({ prefix: "/api/search" }); +/** + * The search route: + * + * - `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; + * private dumps and playlists only the caller's own. + */ +const router: Router = new Router({ prefix: "/api/search" }); router.get("/", async (ctx) => { const params = ctx.request.url.searchParams; diff --git a/api/routes/thumbnails.ts b/api/routes/thumbnails.ts index 78d3693b..9ba0cef4 100644 --- a/api/routes/thumbnails.ts +++ b/api/routes/thumbnails.ts @@ -1,3 +1,9 @@ +/** + * The route serving dumps' thumbnails, generated with ffmpeg where needed. + * + * @module + */ + import { Router } from "@oak/oak"; import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; @@ -11,12 +17,24 @@ import { serveUploadedFile } from "../lib/upload.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { authenticateMedia } from "../lib/auth.ts"; -const router = new Router({ prefix: "/api/thumbnails" }); +/** + * The route serving dumps' thumbnails: + * + * - `GET /api/thumbnails/:dumpId?token=&v=` — public; a private dump's + * 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 + * scaled-down copy of an image (the image itself when none can be made); 404 + * when there is neither. + */ +const router: Router = new Router({ prefix: "/api/thumbnails" }); -// Probed once per process: spawning `ffmpeg -version` on every uncached -// thumbnail request cost a process per card. +/** + * Whether ffmpeg runs, probed once per process: spawning `ffmpeg -version` on + * every uncached thumbnail request cost a process per card. + */ let ffmpegProbe: Promise<boolean> | undefined; +/** Checks whether ffmpeg is available. */ function ffmpegAvailable(): Promise<boolean> { ffmpegProbe ??= (async () => { try { @@ -33,16 +51,23 @@ function ffmpegAvailable(): Promise<boolean> { return ffmpegProbe; } -// The upload's type is only what the uploader claimed, and ffmpeg probes the -// real format from the bytes — a "video" could be an HLS or concat playlist -// pointing ffmpeg at local files or internal URLs. Read nothing but the file -// itself, and only as the kind of container it's meant to be. +/** + * The container formats ffmpeg may read a video as. The upload's type is only + * what the uploader claimed, and ffmpeg probes the real format from the bytes — + * a "video" could be an HLS or concat playlist pointing ffmpeg at local files + * or internal URLs. Read nothing but the file itself, and only as the kind of + * container it's meant to be. + */ const VIDEO_FORMATS = "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}). */ const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe"; -/** Images the feed shows through a scaled-down preview. GIFs keep their - * animation (served as they are); anything else has no preview to make. */ +/** + * The image types the feed shows through a scaled-down preview, and the + * preview's format. GIFs keep their animation (served as they are); anything + * else has no preview to make. + */ const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = { "image/jpeg": "jpg", "image/bmp": "jpg", @@ -51,10 +76,17 @@ const PREVIEWABLE_IMAGES: Record<string, "jpg" | "png"> = { "image/webp": "png", }; -/** Width of an image preview: comfortably covers the widest feed card on a - * high-density screen, a fraction of a phone photo's bytes. */ +/** + * The width of an image preview: comfortably covers the widest feed card on a + * high-density screen, a fraction of a phone photo's bytes. + */ const PREVIEW_WIDTH = 960; +/** + * Runs ffmpeg with `args` into `outPath`. + * + * @throws {Error} When ffmpeg fails. + */ async function runFfmpeg(args: string[], outPath: string): Promise<void> { await Deno.mkdir(THUMBNAILS_DIR, { recursive: true }); // Written beside and renamed over, so a concurrent reader never gets half. @@ -71,6 +103,7 @@ async function runFfmpeg(args: string[], outPath: string): Promise<void> { await Deno.rename(tmpPath, outPath); } +/** Writes a 320-pixel-wide JPEG still of a video, one second in. */ function videoStill(srcPath: string, outPath: string): Promise<void> { return runFfmpeg([ "-format_whitelist", @@ -90,6 +123,7 @@ function videoStill(srcPath: string, outPath: string): Promise<void> { ], outPath); } +/** Writes a copy of an image scaled down to {@link PREVIEW_WIDTH}. */ function imagePreview(srcPath: string, outPath: string): Promise<void> { return runFfmpeg([ "-format_whitelist", @@ -108,10 +142,17 @@ function imagePreview(srcPath: string, outPath: string): Promise<void> { ], outPath); } -// One ffmpeg run per output: a feed full of cards asking for the same new -// still at once waits on a single generation. +/** + * The generations under way, by output path: one ffmpeg run per output, so a + * feed full of cards asking for the same new still at once waits on a single + * generation. + */ const inFlight = new Map<string, Promise<void>>(); +/** + * Makes `outPath` with `generate` unless it exists; false when it can't be + * made (no ffmpeg, or ffmpeg failed). + */ async function ensureGenerated( outPath: string, generate: () => Promise<void>, diff --git a/api/routes/users.ts b/api/routes/users.ts index 3bc13638..06cd49ef 100644 --- a/api/routes/users.ts +++ b/api/routes/users.ts @@ -1,3 +1,9 @@ +/** + * The user and account routes. + * + * @module + */ + import { Router } from "@oak/oak"; import { @@ -64,14 +70,54 @@ import { getFollowedUsersByUser, } from "../services/follow-service.ts"; -// Users router -const router = new Router({ prefix: "/api/users" }); +/** + * The user and account routes. Public profiles never carry the password hash or + * the email: + * + * - `POST /api/users/register` — public, with a valid invite. Creates an + * account, sends the welcome email, and answers the user and their tokens. + * - `POST /api/users/login` — public. Answers the user and their tokens; + * rate-limited per account. + * - `GET /api/users/me` — signed in. Answers the caller's own user. + * - `GET /api/users/me/media-token` — signed in. Answers a media token, for a + * session that predates them. + * - `POST /api/users/request-password-reset` — public. Emails a reset link to + * the account with that address, if any; always answers 200. + * - `POST /api/users/reset-password` — public, with a reset token. Sets a new + * password. + * - `POST /api/users/me/change-password` — signed in, with the current + * password. Sets a new one, ending every other session, and answers fresh + * tokens. + * - `PATCH /api/users/me` — signed in. Updates the caller's profile fields: + * description and email. + * - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another + * user's role. + * - `GET /api/users/search?q=` — public. Answers up to 8 users whose name + * starts with `q`, for @mention suggestions. + * - `GET /api/users/by-id/:userId` — public. Answers a user's profile by id. + * - `GET /api/users/:username/followed-users?page=&limit=` — public. Answers a + * page of the users they follow. + * - `GET /api/users/:username/followed-playlists?page=&limit=` — public. + * Answers a page of the public playlists they follow. + * - `GET /api/users/:username/playlists?page=&limit=` — optional sign-in. + * Answers a page of their playlists, the private ones only to themselves. + * - `GET /api/users/:username/invitees` — public. Answers the tree of users + * they invited. + * - `GET /api/users/:username` — public. Answers a user's profile. + * - `GET /api/users/:username/dumps?page=&limit=` — optional sign-in. Answers a + * page of their dumps, the private ones only to themselves. + * - `GET /api/users/:username/votes?page=&limit=` — optional sign-in. Answers a + * page of the dumps they upvoted, private ones only to themselves. + */ +const router: Router = new Router({ prefix: "/api/users" }); -// A burst of 10 attempts, then one every 30s. +/** Login attempts per account: a burst of 10, then one every 30s. */ const LOGIN_LIMIT: RateLimit = { capacity: 10, refill: 1 / 30 }; -// The session token, plus the media-only token that goes in file and -// thumbnail URLs (see createMediaToken). +/** + * Issues `user` a session token, plus the media-only token that goes in file + * and thumbnail URLs (see {@link createMediaToken}). + */ async function issueTokens(user: User): Promise<AuthTokens> { return { token: await createJWT({ @@ -83,7 +129,6 @@ async function issueTokens(user: User): Promise<AuthTokens> { }; } -// Register a new user (requires a valid invite token) router.post("/register", async (ctx) => { const body: unknown = await ctx.request.body.json(); @@ -129,7 +174,6 @@ router.post("/register", async (ctx) => { ctx.response.body = responseBody; }); -// Login router.post("/login", async (ctx) => { const body: unknown = await ctx.request.body.json(); @@ -179,7 +223,6 @@ router.post("/login", async (ctx) => { ctx.response.body = responseBody; }); -// Get current user profile router.get("/me", authMiddleware, (ctx: AuthContext) => { try { if (!ctx.state.user) { @@ -206,7 +249,6 @@ router.get("/me", authMiddleware, (ctx: AuthContext) => { } }); -// A media token for a session that predates them (see createMediaToken). router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => { const responseBody: APIResponse<MediaTokenResponse> = { success: true, @@ -215,7 +257,6 @@ router.get("/me/media-token", authMiddleware, async (ctx: AuthContext) => { ctx.response.body = responseBody; }); -// Request a password reset email (unauthenticated; always returns 200) router.post("/request-password-reset", async (ctx) => { const body: unknown = await ctx.request.body.json(); const email = fieldOf(body, "email"); @@ -229,7 +270,6 @@ router.post("/request-password-reset", async (ctx) => { ctx.response.body = responseBody; }); -// Consume a reset token and set a new password (unauthenticated) router.post("/reset-password", async (ctx) => { const body: unknown = await ctx.request.body.json(); const token = fieldOf(body, "token"); @@ -246,7 +286,6 @@ router.post("/reset-password", async (ctx) => { ctx.response.body = responseBody; }); -// Change current user's password (requires current password verification) router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => { const body: unknown = await ctx.request.body.json(); const currentPassword = fieldOf(body, "currentPassword"); @@ -293,7 +332,6 @@ router.post("/me/change-password", authMiddleware, async (ctx: AuthContext) => { ctx.response.body = responseBody; }); -// Update current user profile (description, etc.) router.patch("/me", authMiddleware, async (ctx: AuthContext) => { const body: unknown = await ctx.request.body.json(); if (!isUpdateUserRequest(body)) { @@ -320,7 +358,6 @@ router.patch("/me", authMiddleware, async (ctx: AuthContext) => { ctx.response.body = responseBody; }); -// Set another user's role. Requires the user:manage permission. router.patch( "/:userId/role", authMiddleware, @@ -355,7 +392,6 @@ router.patch( }, ); -// User search for @mention autocomplete router.get("/search", (ctx) => { const q = (ctx.request.url.searchParams.get("q") ?? "").trim(); const responseBody: APIResponse<UserSuggestion[]> = { @@ -365,7 +401,6 @@ router.get("/search", (ctx) => { ctx.response.body = responseBody; }); -// Public user profile by internal ID (used when only userId is available, e.g. dump.userId) router.get("/by-id/:userId", (ctx) => { const responseBody: APIResponse<PublicUser> = { success: true, @@ -374,7 +409,6 @@ router.get("/by-id/:userId", (ctx) => { ctx.response.body = responseBody; }); -// Followed users for a user (public) router.get("/:username/followed-users", (ctx) => { const user = getUserByUsername(ctx.params.username); const { page, limit } = parsePagination(ctx.request.url.searchParams); @@ -389,7 +423,6 @@ router.get("/:username/followed-users", (ctx) => { ctx.response.body = responseBody; }); -// Followed playlists for a user (public only) router.get("/:username/followed-playlists", (ctx) => { const user = getUserByUsername(ctx.params.username); const { page, limit } = parsePagination(ctx.request.url.searchParams); @@ -401,7 +434,6 @@ router.get("/:username/followed-playlists", (ctx) => { ctx.response.body = responseBody; }); -// Playlists by user (optional auth: include private only if requester === owner) router.get("/:username/playlists", async (ctx) => { const user = getUserByUsername(ctx.params.username); const requestingUserId = await parseOptionalAuth(ctx); @@ -419,7 +451,6 @@ router.get("/:username/playlists", async (ctx) => { ctx.response.body = responseBody; }); -// Invite tree for a user (public) router.get("/:username/invitees", (ctx) => { const user = getUserByUsername(ctx.params.username); const responseBody: APIResponse<Invitee[]> = { @@ -429,7 +460,6 @@ router.get("/:username/invitees", (ctx) => { ctx.response.body = responseBody; }); -// Public user profile by username (no passwordHash) router.get("/:username", (ctx) => { const responseBody: APIResponse<PublicUser> = { success: true, @@ -438,7 +468,6 @@ router.get("/:username", (ctx) => { ctx.response.body = responseBody; }); -// Dumps posted by user (optional auth: owner sees their private dumps) router.get("/:username/dumps", async (ctx) => { const user = getUserByUsername(ctx.params.username); const requestingUserId = await parseOptionalAuth(ctx); @@ -457,7 +486,6 @@ router.get("/:username/dumps", async (ctx) => { ctx.response.body = responseBody; }); -// Dumps upvoted by user (optional auth: hide private dump entries for non-owners) router.get("/:username/votes", async (ctx) => { const user = getUserByUsername(ctx.params.username); const requestingUserId = await parseOptionalAuth(ctx); diff --git a/api/routes/ws.ts b/api/routes/ws.ts index f2e0f499..419ba0f9 100644 --- a/api/routes/ws.ts +++ b/api/routes/ws.ts @@ -1,3 +1,9 @@ +/** + * The WebSocket route, and the handling of what clients send over it. + * + * @module + */ + import { Router } from "@oak/oak"; import { ALLOWED_ORIGINS } from "../config.ts"; import { authenticate } from "../lib/auth.ts"; @@ -50,8 +56,18 @@ import type { } from "../../shared/ws.ts"; import type { User } from "../model/user.ts"; -const router = new Router(); +/** + * The WebSocket route: + * + * - `GET /ws?token=` — public; a session `token` signs the socket in, and an + * invalid one leaves it anonymous. Opens the socket that carries presence, + * live updates, votes, comment likes and the chat (see + * `ClientToServerMessage` and `ServerToClientMessage` in shared/ws.ts); 403 + * from an origin not in {@link ALLOWED_ORIGINS}. + */ +const router: Router = new Router(); +/** Checks whether a socket may be opened from `origin`. */ function isAllowedOrigin(origin: string): boolean { return ALLOWED_ORIGINS.includes(origin); } @@ -132,6 +148,7 @@ router.get("/ws", async (ctx) => { }); }); +/** Parses one message from a client and dispatches it by `type`. */ function handleMessage(client: WsClient, data: unknown): void { if (typeof data !== "string") return; let parsed: unknown; @@ -195,11 +212,15 @@ function handleMessage(client: WsClient, data: unknown): void { } } -// Sends only while the socket is open: `send` on a closing socket throws. +/** Sends a message only while the socket is open: `send` on a closing socket throws. */ function send(socket: WebSocket, data: ServerToClientMessage): void { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(data)); } +/** + * Casts or removes the client's upvote, acknowledges it, and broadcasts the new + * count. + */ function handleVote( client: WsClient, dumpId: unknown, @@ -240,6 +261,10 @@ function handleVote( } } +/** + * Casts or removes the client's like on a comment, acknowledges it, and + * broadcasts the new count. + */ function handleCommentLike( client: WsClient, commentId: unknown, @@ -278,14 +303,21 @@ function handleCommentLike( } } -// Sending, editing and deleting share one bucket: a burst of 8, then one every -// 1.5s — room for a quick back-and-forth, not for a flood. +/** + * Chat writes per user. Sending, editing and deleting share one bucket: a + * burst of 8, then one every 1.5s — room for a quick back-and-forth, not for a + * flood. + */ const CHAT_WRITE_LIMIT: RateLimit = { capacity: 8, refill: 1 / 1.5 }; +/** Typing signals per user. */ const TYPING_LIMIT: RateLimit = { capacity: 10, refill: 1 }; -// Read markers are cheap for the client to send often; past this they're -// dropped, and the next one catches up. +/** + * Read markers per user. They are cheap for the client to send often; past + * this they're dropped, and the next one catches up. + */ const CHAT_READ_LIMIT: RateLimit = { capacity: 10, refill: 1 }; +/** Reports a failed chat write to the client, with the nonce of its pending copy. */ function sendChatError( client: WsClient, message: string, @@ -294,8 +326,10 @@ function sendChatError( send(client.socket, { type: "error", scope: "chat", message, nonce }); } -// Validates the common preconditions of a chat write: signed in and under the -// rate limit. Returns the user id, or undefined after reporting why not. +/** + * Validates the common preconditions of a chat write: signed in and under the + * rate limit. Returns the user id, or undefined after reporting why not. + */ function chatWriter(client: WsClient, nonce?: string): string | undefined { if (!client.userId) { sendChatError(client, "Authentication required", nonce); @@ -312,12 +346,17 @@ function chatWriter(client: WsClient, nonce?: string): string | undefined { return client.userId; } +/** Returns `value` when it is a string of at most `maxLength`, else undefined. */ function optionalString(value: unknown, maxLength: number): string | undefined { return typeof value === "string" && value.length <= maxLength ? value : undefined; } +/** + * Posts a chat message, broadcasts it, and notifies the @mentioned users not + * already reading the chat. + */ function handleChatSend( client: WsClient, body: unknown, @@ -364,9 +403,12 @@ function handleChatSend( } } -// Reads the role from the DB on every moderation check rather than trusting the -// one captured at connect: a demoted moderator loses the power right away, not -// whenever their socket next reconnects. +/** + * Checks whether the client's user may moderate the chat. Reads the role from + * the DB on every check rather than trusting the one captured at connect: a + * demoted moderator loses the power right away, not whenever their socket next + * reconnects. + */ function canModerateChat(client: WsClient): boolean { if (!client.userId) return false; try { @@ -377,6 +419,7 @@ function canModerateChat(client: WsClient): boolean { return !!client.role && hasPermission(client.role, "chat:moderate"); } +/** Edits a chat message, broadcasts it, and notifies newly @mentioned users. */ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void { const userId = chatWriter(client); if (!userId) return; @@ -412,6 +455,7 @@ function handleChatEdit(client: WsClient, id: unknown, body: unknown): void { } } +/** Deletes a chat message and broadcasts it. */ function handleChatDelete(client: WsClient, id: unknown): void { const userId = chatWriter(client); if (!userId) return; @@ -431,8 +475,10 @@ function handleChatDelete(client: WsClient, id: unknown): void { } } -// Moves the user's read marker and tells all their sockets, so every tab and -// device drops its badge together. +/** + * Moves the user's read marker and tells all their sockets, so every tab and + * device drops its badge together. + */ function handleChatRead(client: WsClient, at: unknown): void { if (!client.userId || typeof at !== "string") return; if (!takeToken(`read:${client.userId}`, CHAT_READ_LIMIT)) return; diff --git a/api/routes/youtube.ts b/api/routes/youtube.ts index ed796257..34d83c88 100644 --- a/api/routes/youtube.ts +++ b/api/routes/youtube.ts @@ -1,16 +1,24 @@ +/** + * The YouTube route. + * + * @module + */ + import { Router } from "@oak/oak"; import type { APIResponse } from "../../shared/api.ts"; import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts"; import type { YouTubePlaylist } from "../../shared/player.ts"; -const youtubeRouter = new Router(); - /** - * Resolve a YouTube playlist ID to its tracklist (titles and lengths). + * The YouTube route, for the player's tracklist: * - * Public, like /api/bandcamp/tracks: it only reads a public page. The client - * treats any failure as "show the embed without a tracklist". + * - `GET /api/youtube/playlist?list=` — public. Resolves a YouTube playlist id + * to its tracklist (titles and lengths). */ +const youtubeRouter: Router = new Router(); + +// Public, like /api/bandcamp/tracks: it only reads a public page. The client +// treats any failure as "show the embed without a tracklist". youtubeRouter.get("/api/youtube/playlist", async (ctx) => { const listId = ctx.request.url.searchParams.get("list") ?? ""; const responseBody: APIResponse<YouTubePlaylist> = { diff --git a/api/scripts/generate-icons.ts b/api/scripts/generate-icons.ts index 8037a648..5704781f 100644 --- a/api/scripts/generate-icons.ts +++ b/api/scripts/generate-icons.ts @@ -1,11 +1,16 @@ -// Generates the favicon set + manifest from the runtime GERBEUR_SITE_EMOJI. -// Runs before the server starts, so changing the emoji only requires a restart -// — no rebuild. -// -// The output directory is passed as the first argument (relative to cwd): -// - prod (`deno task start` / `serve`): `dist` — what the Oak server serves -// - dev (`deno task dev`): `public` — what the Vite server serves -// Defaults to `dist` when omitted. +/** + * Generates the favicon set and manifest from the runtime `GERBEUR_SITE_EMOJI`. + * Runs before the server starts, so changing the emoji only requires a restart + * — no rebuild. + * + * The output directory is passed as the first argument (relative to cwd), and + * defaults to `dist`: + * - prod (`deno task start` / `serve`): `dist` — what the Oak server serves + * - dev (`deno task dev`): `public` — what the Vite server serves + * + * @module + */ + import { OG_SITE_NAME, SITE_EMOJI, THEME_COLOR } from "../config.ts"; import { generateSiteIcons } from "../lib/site-icons.ts"; diff --git a/api/services/attachment-service.ts b/api/services/attachment-service.ts index ad42fc4e..f8df9265 100644 --- a/api/services/attachment-service.ts +++ b/api/services/attachment-service.ts @@ -1,13 +1,27 @@ +/** + * Images pasted into Markdown bodies (chat, comments, descriptions): their + * records, and binding them to the resource whose body links them, so the + * startup purge keeps them. + * + * @module + */ + import { db, transaction } from "../db/connection.ts"; import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; +/** Records the upload of attachment `id`, not yet bound to any resource. */ export function createAttachment(id: string, mime: string): void { db.prepare( "INSERT INTO attachments (id, mime, created_at) VALUES (?, ?, ?)", ).run(id, mime, new Date().toISOString()); } +/** + * Returns attachment `attachmentId`'s MIME type. + * + * @throws {APIException} 404 when there is no such attachment. + */ export function getAttachment(attachmentId: string): { mime: string } { const row = db.prepare( "SELECT mime FROM attachments WHERE id = ?", @@ -24,12 +38,12 @@ export function getAttachment(attachmentId: string): { mime: string } { return { mime: row.mime }; } -// UUID pattern used to extract attachment IDs from markdown/text bodies. +/** An attachment URL in a Markdown body, capturing its id. */ 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; /** - * Parse `text` for attachment URLs and bind any unowned attachments to + * Binds the unowned attachments whose URLs appear in `text` to * `resourceId`. Only updates rows where resource_id IS NULL so a user cannot * claim attachments that already belong to another resource. */ diff --git a/api/services/backlink-service.ts b/api/services/backlink-service.ts index f4653bb0..1d517295 100644 --- a/api/services/backlink-service.ts +++ b/api/services/backlink-service.ts @@ -1,3 +1,10 @@ +/** + * Backlinks between dumps: which dumps a dump's description and comments link + * to, and the dumps related to one through them. + * + * @module + */ + import type { Dump } from "../../shared/dump.ts"; import { db, transaction } from "../db/connection.ts"; import { @@ -7,17 +14,22 @@ import { } from "../model/dump.ts"; import { UUID_RE } from "../lib/slugify.ts"; -// Matches http(s) URLs in free text — stops at whitespace or markdown-link -// delimiters (so `[text](url)` and `<url>` don't swallow the closing char). +/** + * An http(s) URL in free text — stops at whitespace or markdown-link + * delimiters (so `[text](url)` and `<url>` don't swallow the closing char). + */ const URL_RE = /https?:\/\/[^\s)\]<>"']+/g; -// A link to another dump's page on this same site (e.g. pasted from the -// address bar) — host-agnostic, since the configured public URL can differ -// between dev/prod/reverse-proxy setups. Matches `/dumps/{slug-or-id}`, -// ignoring any trailing path/query/hash (so `/edit` or `#comment-x` links -// still resolve to the same target dump). +/** + * A link to another dump's page on this same site (e.g. pasted from + * the address bar) — host-agnostic, since the configured public URL can differ + * between dev/prod/reverse-proxy setups. Matches `/dumps/{slug-or-id}`, + * ignoring any trailing path/query/hash (so `/edit` or `#comment-x` links + * still resolve to the same target dump). + */ const DUMP_PATH_RE = /^\/dumps\/([^/?#]+)/; +/** Returns `url` as the URL parser normalises it, or null when it isn't one. */ function canonicalize(url: string): string | null { try { return new URL(url).toString(); @@ -35,7 +47,7 @@ function resolveDumpPathId(idOrSlug: string): string | null { } /** - * Re-derive the backlinks contributed by one text source (a dump's own + * Re-derives the backlinks contributed by one text source (a dump's own * `comment` field, or a single comment's `body`) and persist them. Always * deletes the source's previous contribution first, so editing/clearing the * text correctly removes stale links — unlike `linkAttachments`/`notifyMentions`, @@ -93,7 +105,10 @@ export function relinkBacklinks( }); } -/** Bidirectional, privacy-filtered, capped at 20 — backlinks are a small, bounded list. */ +/** + * Returns the dumps linked to or from `dumpId`, newest first and at most 20, + * without the private ones the requesting user can't see. + */ export function getRelatedDumps( dumpId: string, requestingUserId?: string, diff --git a/api/services/category-service.ts b/api/services/category-service.ts index ee98fb98..f5bbab43 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -1,3 +1,9 @@ +/** + * Categories: managing them, and which dumps belong to which. + * + * @module + */ + import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { @@ -17,6 +23,7 @@ import { type Page, pageOf } from "../lib/pagination.ts"; import { isReservedSlug, isValidSlugFormat } from "../lib/reserved-slugs.ts"; import { VALIDATION } from "../../shared/validation.ts"; +/** Throws a 400 unless `slug` is well-formed, short enough, and not reserved. */ function validateSlug(slug: string): void { if (!isValidSlugFormat(slug)) { throw new APIException( @@ -41,6 +48,7 @@ function validateSlug(slug: string): void { } } +/** Returns `name` trimmed, or throws a 400 when it is blank or too long. */ function requireName(name: string): string { const trimmed = name.trim(); if (!trimmed) { @@ -56,7 +64,10 @@ function requireName(name: string): string { return trimmed; } -// Maps SQLite UNIQUE violations to a friendly 409 naming the offending field. +/** + * Rethrows a SQLite UNIQUE violation as a friendly 409 naming the offending + * field, and anything else as is. + */ function uniqueConflict(err: unknown, slug: string, name: string): never { const msg = err instanceof Error ? err.message : String(err); if (msg.includes("categories.name")) { @@ -76,6 +87,11 @@ function uniqueConflict(err: unknown, slug: string, name: string): never { throw err as Error; } +/** + * Returns category `id`. + * + * @throws {APIException} 404 when there is no such category. + */ export function getCategoryById(id: string): Category { const row = db.prepare(`SELECT * FROM categories WHERE id = ?;`).get(id); if (!row || !isCategoryRow(row)) { @@ -84,6 +100,11 @@ export function getCategoryById(id: string): Category { return categoryRowToApi(row); } +/** + * Returns the category at `slug`. + * + * @throws {APIException} 404 when there is no such category. + */ export function getCategoryBySlug(slug: string): Category { const row = db.prepare(`SELECT * FROM categories WHERE slug = ?;`).get(slug); if (!row || !isCategoryRow(row)) { @@ -92,7 +113,7 @@ export function getCategoryBySlug(slug: string): Category { return categoryRowToApi(row); } -/** Resolve a slug to its category id, or undefined if no such category. */ +/** Returns the id of the category at `slug`, or undefined when there is none. */ export function getCategoryIdBySlug(slug: string): string | undefined { const row = db.prepare(`SELECT id FROM categories WHERE slug = ?;`).get(slug); return row && typeof (row as { id?: unknown }).id === "string" @@ -100,6 +121,7 @@ export function getCategoryIdBySlug(slug: string): string | undefined { : undefined; } +/** Returns every category, by position then slug. */ export function listCategories(): Category[] { const rows = db.prepare( `SELECT * FROM categories ORDER BY position ASC, slug ASC;`, @@ -107,6 +129,12 @@ export function listCategories(): Category[] { return rows.filter(isCategoryRow).map(categoryRowToApi); } +/** + * Creates a category. + * + * @throws {APIException} 400 when the slug or name is invalid, 409 when + * either is taken. + */ export function createCategory(req: CreateCategoryRequest): Category { validateSlug(req.slug); const name = requireName(req.name); @@ -137,6 +165,12 @@ export function createCategory(req: CreateCategoryRequest): Category { }; } +/** + * Applies `req` to category `id`. + * + * @throws {APIException} 404 when there is no such category, 400 when the new + * slug or name is invalid, 409 when either is taken. + */ export function updateCategory( id: string, req: UpdateCategoryRequest, @@ -176,6 +210,11 @@ export function updateCategory( }; } +/** + * Deletes category `id`; its dumps stay, outside it. + * + * @throws {APIException} 404 when there is no such category. + */ export function deleteCategory(id: string): void { const result = db.prepare(`DELETE FROM categories WHERE id = ?;`).run(id); if (result.changes === 0) { @@ -184,9 +223,11 @@ export function deleteCategory(id: string): void { } /** - * Throws a 400 unless every id names an existing category. Callers that write + * Checks that every id names an existing category. Callers that write * anything else first (a dump row, an uploaded file) run this before those * writes, so a bad id can't leave them half-done. + * + * @throws {APIException} 400 when an id names no category. */ export function assertCategoriesExist(categoryIds: string[]): void { const unique = [...new Set(categoryIds)]; @@ -207,8 +248,10 @@ export function assertCategoriesExist(categoryIds: string[]): void { } /** - * Replace the set of categories a dump belongs to. Unknown category ids are - * rejected. An empty list clears all category assignments. + * Replaces the set of categories a dump belongs to. An empty list clears all + * category assignments. + * + * @throws {APIException} 400 when an id names no category. */ export function setDumpCategories( dumpId: string, @@ -228,7 +271,7 @@ export function setDumpCategories( }); } -/** Fill `categoryIds` on each dump with a single batched lookup. */ +/** Fills `categoryIds` on each dump with a single batched lookup. */ export function attachCategoryIds(dumps: Dump[]): void { if (dumps.length === 0) return; const ids = dumps.map((d) => d.id); @@ -248,6 +291,12 @@ export function attachCategoryIds(dumps: Dump[]): void { } } +/** + * Returns a page of the dumps in the category at `slug`, newest first, with + * the requesting user's private ones. + * + * @throws {APIException} 404 when there is no such category. + */ export function listDumpsByCategory( slug: string, page: number, diff --git a/api/services/chat-service.ts b/api/services/chat-service.ts index ebee27fc..02105d51 100644 --- a/api/services/chat-service.ts +++ b/api/services/chat-service.ts @@ -1,3 +1,9 @@ +/** + * The site-wide chat: messages, their history, and each user's read marker. + * + * @module + */ + import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { ChatMessage } from "../../shared/chat.ts"; @@ -6,27 +12,36 @@ import { db } from "../db/connection.ts"; import { linkAttachments } from "./attachment-service.ts"; import { VALIDATION } from "../../shared/validation.ts"; +/** How many messages a page holds when the caller names no limit. */ const DEFAULT_LIMIT = 50; +/** The most messages a page may hold. */ const MAX_LIMIT = 100; -// Length cap for the replied-to preview snippet, so a reply to a long message -// doesn't ship the whole 2000-char body over the wire for every reply. This is -// raw markdown: the client strips it to text before showing a line of it, so -// it's cut generously enough that an image URL or link doesn't eat it all. +/** + * The length cap of the replied-to preview snippet, so a reply to a long + * message doesn't ship the whole 2000-char body over the wire for every reply. + * This is raw markdown: the client strips it to text before showing a line of + * it, so it's cut generously enough that an image URL or link doesn't eat it + * all. + */ const REPLY_SNIPPET_LEN = 300; +/** The columns of a full `ChatMessageRow`, over {@link FROM_JOINS}. */ const SELECT_COLS = `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, ru.username as reply_to_author, substr(rm.body, 1, ${REPLY_SNIPPET_LEN}) as reply_to_body`; -// Common joins: author (required) plus the optional replied-to message and its -// author (LEFT — null when there's no reply or the target was deleted). +/** + * The common joins: author (required) plus the optional replied-to message and + * its author (LEFT — null when there's no reply or the target was deleted). + */ const FROM_JOINS = `FROM chat_messages m JOIN users u ON m.user_id = u.id LEFT JOIN chat_messages rm ON m.reply_to_id = rm.id LEFT JOIN users ru ON rm.user_id = ru.id`; +/** Returns message `id`, or throws a 404. */ function fetchMessage(id: string): ChatMessage { const row = db.prepare( `SELECT ${SELECT_COLS} ${FROM_JOINS} WHERE m.id = ?;`, @@ -37,6 +52,13 @@ function fetchMessage(id: string): ChatMessage { return chatMessageRowToApi(row); } +/** + * Posts a message from `userId`, replying to `replyToId` when that message + * exists, and binds its attachments. + * + * @throws {APIException} 400 when the body is blank or longer than + * `VALIDATION.CHAT_MESSAGE_MAX`. + */ export function createChatMessage( userId: string, body: string, @@ -77,6 +99,7 @@ export function createChatMessage( return fetchMessage(id); } +/** Returns the author of message `id`, or throws a 404. */ function getMessageOwner(id: string): string { const row = db.prepare(`SELECT user_id FROM chat_messages WHERE id = ?;`).get( id, @@ -87,7 +110,14 @@ function getMessageOwner(id: string): string { return row.user_id; } -// Owners may edit their own messages; moderators may edit any. +/** + * Replaces a message's body, as its author or a moderator (`canModerate`), and + * binds any new attachment. + * + * @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 + * long. + */ export function updateChatMessage( id: string, body: string, @@ -124,7 +154,12 @@ export function updateChatMessage( return fetchMessage(id); } -// Owners may delete their own messages; moderators may delete any. +/** + * Deletes a message, as its author or a moderator (`canModerate`). + * + * @throws {APIException} 404 when there is no such message, 401 when the user + * is neither its author nor a moderator. + */ export function deleteChatMessage( id: string, requestingUserId: string, @@ -141,16 +176,22 @@ export function deleteChatMessage( db.prepare(`DELETE FROM chat_messages WHERE id = ?;`).run(id); } -/** A position in the chat stream. Messages are ordered by (createdAt, id), so +/** + * A position in the chat stream. Messages are ordered by (createdAt, id), so * two sharing a millisecond still have a strict order and a page boundary - * between them can't skip either. */ + * between them can't skip either. + */ export interface ChatCursor { + /** The `created_at` of the message the cursor sits at. */ createdAt: string; - /** Omitted: the cursor sits before (for `after`) or after (for `before`) - * every message sharing that timestamp. */ + /** + * The id of that message. Omitted: the cursor sits before (for `after`) or + * after (for `before`) every message sharing that timestamp. + */ id?: string; } +/** Converts query rows to messages. */ function toMessages(rows: unknown[]): ChatMessage[] { if (!rows.every(isChatMessageRow)) { throw new APIException( @@ -162,6 +203,7 @@ function toMessages(rows: unknown[]): ChatMessage[] { return rows.map(chatMessageRowToApi); } +/** Clamps a page size to [1, {@link MAX_LIMIT}]. */ function capLimit(limit: number): number { return Math.min(Math.max(Math.floor(limit), 1), MAX_LIMIT); } @@ -213,13 +255,20 @@ export function getChatMessagesAfter( return toMessages(rows); } +/** How far a user has read the chat. */ export interface ChatReadState { - /** created_at of the newest message the user has seen (ISO). */ + /** The `created_at` of the newest message the user has seen (ISO). */ readAt: string; /** Messages from other people since then. */ unread: number; } +/** + * Returns how far `userId` has read the chat; a user who never opened it has + * read up to their registration. + * + * @throws {APIException} 404 when there is no such user. + */ export function getChatReadState(userId: string): ChatReadState { const row = db.prepare( `SELECT COALESCE(u.chat_read_at, u.created_at) AS read_at, @@ -238,6 +287,9 @@ export function getChatReadState(userId: string): ChatReadState { * Moves the user's read marker forward to `at` (a message's created_at) — * never backward, so a lagging tab can't undo a newer tab's read, and never * past the newest message, so a bogus timestamp can't pre-read the future. + * + * @throws {APIException} 400 when `at` isn't a date, 404 when there is no + * such user. */ export function markChatRead(userId: string, at: string): ChatReadState { const parsed = new Date(at); diff --git a/api/services/comment-like-service.ts b/api/services/comment-like-service.ts index f2b39ab1..15998a8a 100644 --- a/api/services/comment-like-service.ts +++ b/api/services/comment-like-service.ts @@ -1,3 +1,9 @@ +/** + * Likes on comments. + * + * @module + */ + import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { isUserRow, type User, userRowToApi } from "../model/user.ts"; @@ -5,13 +11,18 @@ import { db } from "../db/connection.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { notifyCommentOwnerLike } from "./notification-service.ts"; +/** A comment's likes after a like was cast or removed. */ export interface LikeResult { + /** The comment's like count now. */ likeCount: number; - /** Likes on a private dump's comments go to the dump's owner only. */ + /** Whether the comment's dump is private: its likes go to the dump's owner only. */ isPrivate: boolean; } -// Visibility of the comment's dump, or a 404 when the user can't see it. +/** + * Returns whether the comment's dump is private, or throws a 404 when the user + * can't see it. + */ function commentVisibility(commentId: string, userId: string): boolean { const row = db.prepare( `SELECT d.is_private, d.user_id FROM comments c JOIN dumps d ON d.id = c.dump_id @@ -23,6 +34,12 @@ function commentVisibility(commentId: string, userId: string): boolean { return Boolean(row.is_private); } +/** + * Records `userId`'s like on a comment and notifies its author. + * + * @throws {APIException} 404 when the comment doesn't exist or sits on someone + * else's private dump, 409 when the user already likes it. + */ export function castCommentLike( commentId: string, userId: string, @@ -62,8 +79,12 @@ export function castCommentLike( return { likeCount, isPrivate }; } -// No visibility check: taking back a like is harmless, and it's the user's own -// row. +/** + * Takes back `userId`'s like on a comment. No visibility check: taking back a + * like is harmless, and it's the user's own row. + * + * @throws {APIException} 404 when the user doesn't like the comment. + */ export function removeCommentLike( commentId: string, userId: string, @@ -97,6 +118,7 @@ export function removeCommentLike( } } +/** Returns the ids of every comment `userId` likes. */ export function getUserCommentLikes(userId: string): string[] { const rows = db.prepare( `SELECT comment_id FROM comment_likes WHERE user_id = ?;`, @@ -104,6 +126,7 @@ export function getUserCommentLikes(userId: string): string[] { return rows.map((r) => r.comment_id); } +/** Returns a page of the users who like a comment, latest first. */ export function getCommentLikers( commentId: string, page: number, diff --git a/api/services/comment-service.ts b/api/services/comment-service.ts index 15dac887..7b98bdfe 100644 --- a/api/services/comment-service.ts +++ b/api/services/comment-service.ts @@ -1,3 +1,9 @@ +/** + * Comments on dumps, threaded by `parent_id`. + * + * @module + */ + import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Comment } from "../../shared/comment.ts"; @@ -11,10 +17,12 @@ import { import { linkAttachments } from "./attachment-service.ts"; import { relinkBacklinks } from "./backlink-service.ts"; +/** The columns of a full `CommentRow`, from `comments c` and `users u`. */ 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, u.username as author_username, u.avatar_mime as author_avatar_mime`; +/** Returns comment `commentId`, or throws a 404. */ function fetchComment(commentId: string): Comment { const row = db.prepare( `SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id WHERE c.id = ?;`, @@ -32,6 +40,11 @@ function fetchComment(commentId: string): Comment { return commentRowToApi(row); } +/** + * Returns the id of the dump comment `commentId` is posted under. + * + * @throws {APIException} 404 when there is no such comment. + */ export function getCommentDumpId(commentId: string): string { const row = db.prepare(`SELECT dump_id FROM comments WHERE id = ?;`).get( commentId, @@ -42,6 +55,10 @@ export function getCommentDumpId(commentId: string): string { return row.dump_id; } +/** + * Returns every comment on dump `dumpId`, deleted ones included, oldest + * first. + */ export function getComments(dumpId: string): Comment[] { const rows = db.prepare( `SELECT ${SELECT_COLS} FROM comments c JOIN users u ON c.user_id = u.id @@ -57,6 +74,11 @@ export function getComments(dumpId: string): Comment[] { return rows.map(commentRowToApi); } +/** + * Posts a comment on dump `dumpId`, replying to `parentId` if given; notifies + * the dump's poster and anyone @mentioned, and binds its attachments and + * backlinks. + */ export function createComment( dumpId: string, userId: string, @@ -86,6 +108,13 @@ export function createComment( return comment; } +/** + * Replaces a comment's body, as its author or a moderator (`canModerate`); + * notifies anyone newly @mentioned, and rebinds its attachments and backlinks. + * + * @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. + */ export function updateComment( commentId: string, body: string, @@ -139,6 +168,14 @@ export function updateComment( }; } +/** + * Deletes a comment, as its author or a moderator (`canModerate`): its body is + * emptied and its backlinks dropped, but the row stays so its replies keep + * their place. + * + * @throws {APIException} 404 when there is no such comment, 401 when the user + * is neither its author nor a moderator. + */ export function deleteComment( commentId: string, requestingUserId: string, diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index cddc0c10..7ebb84fc 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -1,3 +1,13 @@ +/** + * Dumps: posting links and files, editing and deleting them, and reading them + * as each viewer may see them. A private dump exists only for its poster. + * + * The functions that change a dump don't check who asks: the routes do, before + * calling them. + * + * @module + */ + import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { @@ -49,13 +59,17 @@ import { setDumpCategories, } from "./category-service.ts"; +/** Checks whether a file dump may have type `mime`. */ function isAllowedMime(mime: string): boolean { return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || DUMP_ALLOWED_MIME_TYPES.has(mime); } -// The JSON routes validate these in isCreateUrlDumpRequest/isUpdateDumpRequest; -// the multipart ones (file create and replace) arrive here unchecked. +/** + * Throws a 400 when a file dump's title or comment is too long. The JSON routes + * validate these in isCreateUrlDumpRequest/isUpdateDumpRequest; the multipart + * ones (file create and replace) arrive here unchecked. + */ function assertFileDumpText(title?: string, comment?: string): void { if (title && title.trim().length > VALIDATION.DUMP_TITLE_MAX) { throw new APIException( @@ -73,6 +87,7 @@ function assertFileDumpText(title?: string, comment?: string): void { } } +/** Returns the title a link gets without a better one: its host. */ function titleFromUrl(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ""); @@ -81,6 +96,13 @@ function titleFromUrl(url: string): string { } } +/** + * Posts a link as `userId`, with the preview fetched from it; a public one is + * announced and notified to the poster's followers. + * + * @throws {APIException} 400 when the URL isn't a public http(s) URL or a + * category doesn't exist. + */ export async function createUrlDump( request: CreateUrlDumpRequest, userId: string, @@ -148,6 +170,13 @@ export async function createUrlDump( return dump; } +/** + * Posts an uploaded file as `userId`, stored under {@link DUMPS_DIR}; a public + * one is announced and notified to the poster's followers. + * + * @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. + */ export async function createFileDump( file: File, comment: string | undefined, @@ -239,7 +268,7 @@ export async function createFileDump( } /** - * A file dump's version as the app puts it in file and thumbnail URLs + * Returns a file dump's version as the app puts it in file and thumbnail URLs * (`?v=`): it changes whenever the file can have. Mirrored by * `dumpFileVersion` in src/utils/shared/urls.ts. */ @@ -251,8 +280,11 @@ export function dumpFileVersion( }`; } -/** Files generated from a dump's upload (see api/routes/thumbnails.ts): - * dropped when it's replaced or gone, so they're made again from the new one. */ +/** + * Removes the files generated from a dump's upload (see + * api/routes/thumbnails.ts) when it's replaced or gone, so they're made again + * from the new one. + */ async function removeGeneratedThumbnails(dumpId: string): Promise<void> { await Promise.all( [`${dumpId}.jpg`, `${dumpId}.preview.jpg`, `${dumpId}.preview.png`].map( @@ -261,7 +293,10 @@ async function removeGeneratedThumbnails(dumpId: string): Promise<void> { ); } -// Internal fetch — no privacy check. Use only when ownership is already enforced. +/** + * Returns the dump with id or slug `idOrSlug`, or throws a 404. No privacy + * check: use only when ownership is already enforced. + */ function fetchDump(idOrSlug: string): Dump { const row = UUID_RE.test(idOrSlug) ? db.prepare(`SELECT ${SELECT_COLS} FROM dumps WHERE id = ?;`).get(idOrSlug) @@ -274,7 +309,11 @@ function fetchDump(idOrSlug: string): Dump { return dumpRowToApi(row); } -// Public fetch — enforces visibility. Returns 404 for private dumps the requester doesn't own. +/** + * Returns a dump, by id or slug, as `requestingUserId` may see it. + * + * @throws {APIException} 404 when there is no such dump, or it is private and not theirs. + */ export function getDump(dumpId: string, requestingUserId?: string): Dump { const dump = fetchDump(dumpId); if (dump.isPrivate && dump.userId !== requestingUserId) { @@ -284,6 +323,11 @@ export function getDump(dumpId: string, requestingUserId?: string): Dump { return dump; } +/** + * Returns a page of the dumps whose title, comment, or previewed title or + * description contains `query`, newest first, and how many match in all: the + * public ones, and the requesting user's own. + */ export function searchDumps( query: string, page: number, @@ -321,6 +365,10 @@ export function searchDumps( } } +/** + * Returns a page of the dumps, newest first: the public ones, and the + * requesting user's own. + */ export function listDumps( page: number, limit: number, @@ -350,7 +398,8 @@ export function listDumps( } /** - * Dumps that already point at the same thing as `url`, newest first. + * Returns the dumps that already point at the same thing as `url`, newest + * first. * * Matched on the canonical key rather than the raw URL, so a link reposted with * different tracking parameters, scheme, `www.`, or (for YouTube) in a @@ -397,6 +446,14 @@ export function findDumpsByUrl( })); } +/** + * Applies `request` to a dump: its title, comment, visibility and categories, + * and a URL dump's link (fetching the new preview). Announces the change as the + * visibility requires, and notifies anyone newly @mentioned. + * + * @throws {APIException} 404 when there is no such dump, 400 when a new URL isn't a public http(s) URL + * or a category doesn't exist. + */ export async function updateDump( dumpId: string, request: UpdateDumpRequest, @@ -538,14 +595,23 @@ export async function updateDump( return updatedDump; } +/** + * Replaces a file dump's upload, and with it its comment and optionally its + * title, categories and visibility. `isPrivate` left out, visibility stays as + * it is; it is taken here rather than by a second request so a new file and a + * change of visibility land together. The old file is restored if the update + * fails. + * + * @throws {APIException} 404 when there is no such dump; 400 when it isn't a file dump, the title or + * comment is too long, the file's type isn't allowed, it exceeds 50 MB, or a + * category doesn't exist. + */ export async function replaceFileDump( dumpId: string, file: File, comment: string | undefined, title?: string, categoryIds?: string[], - // Left out, visibility stays as it is. Taken here rather than by a second - // request so a new file and a change of visibility land together. isPrivate?: boolean, ): Promise<Dump> { assertFileDumpText(title, comment); @@ -651,6 +717,10 @@ export async function replaceFileDump( return updatedDump; } +/** + * Returns a page of `userId`'s dumps, newest first, the private ones only with + * `includePrivate`. + */ export function getDumpsByUser( userId: string, page: number, @@ -674,6 +744,10 @@ export function getDumpsByUser( return pageOf(items, limit); } +/** + * Returns a page of the dumps `userId` upvoted, latest vote first — their own + * private ones only when they are the one asking. + */ export function getVotedDumpsByUser( userId: string, page: number, @@ -715,6 +789,12 @@ export function getVotedDumpsByUser( return pageOf(items, limit); } +/** + * Fetches a URL dump's preview again and replaces its title and preview. + * + * @throws {APIException} 404 when there is no such dump, 400 when it isn't a URL dump, 502 when the + * page can't be reached. + */ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> { const dump = fetchDump(dumpId); @@ -754,6 +834,11 @@ export async function refreshDumpMetadata(dumpId: string): Promise<Dump> { return updatedDump; } +/** + * Returns a dump's id and custom thumbnail type, or undefined without one. + * + * @throws {APIException} 404 when there is no such dump. + */ export function getDumpThumbnailInfo( idOrSlug: string, ): { id: string; mime: string } | undefined { @@ -763,6 +848,11 @@ export function getDumpThumbnailInfo( : undefined; } +/** + * Records a dump's new custom thumbnail, once its file is in place. + * + * @throws {APIException} 404 when there is no such dump. + */ export function setDumpThumbnail(dumpId: string, mime: string): Dump { const dump = fetchDump(dumpId); db.prepare(`UPDATE dumps SET custom_thumbnail_mime = ? WHERE id = ?;`).run( @@ -774,6 +864,11 @@ export function setDumpThumbnail(dumpId: string, mime: string): Dump { return updatedDump; } +/** + * Removes a dump's custom thumbnail, and its file. + * + * @throws {APIException} 404 when there is no such dump. + */ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> { const dump = fetchDump(dumpId); db.prepare(`UPDATE dumps SET custom_thumbnail_mime = NULL WHERE id = ?;`) @@ -784,6 +879,11 @@ export async function removeDumpThumbnail(dumpId: string): Promise<Dump> { return updatedDump; } +/** + * Deletes a dump, its files and thumbnails, and announces it. + * + * @throws {APIException} 404 when there is no such dump. + */ export async function deleteDump(dumpId: string): Promise<void> { const dump = fetchDump(dumpId); diff --git a/api/services/email-service.ts b/api/services/email-service.ts index 090b0632..e0e38e6a 100644 --- a/api/services/email-service.ts +++ b/api/services/email-service.ts @@ -1,32 +1,54 @@ +/** + * Sending mail through the SMTP server in {@link SMTPS_URL}. + * + * @module + */ + import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer"; import { SMTPS_URL } from "../config.ts"; +/** One address, a comma-separated list of them, or an array. */ export type EmailRecipient = string | string[]; +/** A message to send. It needs a recipient, a subject, and a text or HTML body. */ export interface EmailMessage { + /** The sender's address. */ from: string; + /** The recipients. */ to: EmailRecipient; + /** The subject line. */ subject: string; + /** The plain-text body. */ text?: string; + /** The HTML body. */ html?: string; + /** The carbon-copy recipients. */ cc?: EmailRecipient; + /** The blind-carbon-copy recipients. */ bcc?: EmailRecipient; + /** The address replies go to. */ replyTo?: string; } +/** What the SMTP server said about a sent message. */ export interface EmailSendResult { + /** The message's `Message-ID`. */ messageId: string; + /** The recipients the server accepted. */ accepted: string[]; + /** The recipients the server rejected. */ rejected: string[]; } let transporter: Transporter | null = null; +/** Checks whether an SMTP server is configured. */ export function isEmailEnabled(): boolean { return SMTPS_URL.length > 0; } +/** Flattens the recipient forms nodemailer takes and returns into addresses. */ function normalizeRecipients( recipient: string | readonly (string | { address?: string })[] | undefined, ): string[] { @@ -53,12 +75,24 @@ function getTransporter(): Transporter { return transporter; } +/** + * Checks the connection to the SMTP server. Resolves false when email is + * disabled. + * + * @throws {Error} When the server can't be reached or refuses the login. + */ export async function verifyEmailTransport(): Promise<boolean> { if (!isEmailEnabled()) return false; await getTransporter().verify(); return true; } +/** + * Sends `message`. + * + * @throws {Error} When email is disabled, the message lacks a recipient, + * subject or body, or the server refuses it. + */ export async function sendEmail( message: EmailMessage, ): Promise<EmailSendResult> { diff --git a/api/services/follow-service.ts b/api/services/follow-service.ts index 31c71796..c85a5d7c 100644 --- a/api/services/follow-service.ts +++ b/api/services/follow-service.ts @@ -1,3 +1,9 @@ +/** + * Following users and playlists, and the feeds made of what one follows. + * + * @module + */ + import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { Dump } from "../../shared/dump.ts"; @@ -21,6 +27,12 @@ import { attachCategoryIds } from "./category-service.ts"; // ── Follow / unfollow a user ────────────────────────────────────────────────── +/** + * Makes `followerId` follow `followedUserId`, and notifies them the first + * time. Following again changes nothing. + * + * @throws {APIException} 400 when a user tries to follow themselves. + */ export function followUser(followerId: string, followedUserId: string): void { if (followerId === followedUserId) { throw new APIException( @@ -51,6 +63,7 @@ export function followUser(followerId: string, followedUserId: string): void { if (isNew) notifyUserNewFollower(followerId, followedUserId); } +/** Makes `followerId` stop following `followedUserId`, if they did. */ export function unfollowUser(followerId: string, followedUserId: string): void { db.prepare( `DELETE FROM follows WHERE follower_id = ? AND followed_user_id = ?;`, @@ -59,6 +72,13 @@ export function unfollowUser(followerId: string, followedUserId: string): void { // ── Follow / unfollow a playlist ───────────────────────────────────────────── +/** + * Makes `followerId` follow a public playlist, and notifies its owner the + * first time. Following again changes nothing. + * + * @throws {APIException} 404 when there is no such playlist, 403 when it is + * private. + */ export function followPlaylist(followerId: string, playlistId: string): void { const row = db.prepare( `SELECT id, is_public FROM playlists WHERE id = ?;`, @@ -97,6 +117,7 @@ export function followPlaylist(followerId: string, playlistId: string): void { if (isNew) notifyPlaylistOwnerNewFollower(followerId, playlistId); } +/** Makes `followerId` stop following a playlist, if they did. */ export function unfollowPlaylist(followerId: string, playlistId: string): void { db.prepare( `DELETE FROM follows WHERE follower_id = ? AND followed_playlist_id = ?;`, @@ -105,6 +126,7 @@ export function unfollowPlaylist(followerId: string, playlistId: string): void { // ── Follow status ───────────────────────────────────────────────────────────── +/** Returns the ids of every user and playlist `followerId` follows. */ export function getFollowStatus(followerId: string): FollowStatus { const rawUserRows = db.prepare( `SELECT id, follower_id, followed_user_id, followed_playlist_id, created_at @@ -132,6 +154,10 @@ export function getFollowStatus(followerId: string): FollowStatus { // ── Followed-users feed ─────────────────────────────────────────────────────── +/** + * Returns a page of the public dumps posted by the users `followerId` follows, + * newest first, optionally only those in `categoryId`. + */ export function getFollowedUsersDumpFeed( followerId: string, page: number, @@ -172,6 +198,10 @@ export function getFollowedUsersDumpFeed( // ── Followed-playlists dump feed ────────────────────────────────────────────── +/** + * Returns a page of the public dumps in the public playlists `followerId` + * follows, most recently added first, optionally only those in `categoryId`. + */ export function getFollowedPlaylistsDumpFeed( followerId: string, page: number, @@ -214,6 +244,10 @@ export function getFollowedPlaylistsDumpFeed( // ── Followed playlists (as playlist objects) ────────────────────────────────── +/** + * Returns a page of the public playlists `userId` follows, most recently + * followed first. + */ export function getFollowedPlaylistsByUser( userId: string, page: number, @@ -245,6 +279,7 @@ export function getFollowedPlaylistsByUser( // ── Followed users (as user objects) ───────────────────────────────────────── +/** Returns a page of the users `userId` follows, most recently followed first. */ export function getFollowedUsersByUser( userId: string, page: number, diff --git a/api/services/invite-service.ts b/api/services/invite-service.ts index 6bdce2cd..007fa44c 100644 --- a/api/services/invite-service.ts +++ b/api/services/invite-service.ts @@ -1,3 +1,9 @@ +/** + * Invites: making them, and checking and claiming one on registration. + * + * @module + */ + import { randomBytes } from "node:crypto"; import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; @@ -5,11 +11,15 @@ import { db } from "../db/connection.ts"; import { isInviteRow } from "../model/invite.ts"; import { UNUSED_INVITES_RETENTION_DAYS } from "../config.ts"; -/** Short, opaque, URL-safe invite token (128 bits of entropy → 22 chars). */ +/** + * Returns a short, opaque, URL-safe invite token (128 bits of entropy → 22 + * chars). + */ function generateInviteToken(): string { return randomBytes(16).toString("base64url"); } +/** Makes an invite from `inviterId` and returns its token. */ export function createInvite(inviterId: string): string { const token = generateInviteToken(); db.prepare( @@ -20,12 +30,14 @@ export function createInvite(inviterId: string): string { /** * Looks the token up, checks it has not been used and has not expired (same - * 7-day window as the startup cleanup). Returns the inviterId on success; - * throws APIException otherwise. + * 7-day window as the startup cleanup), and returns the inviter's id. * * 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, + * 409 when it has been used. */ export function validateInvite(token: string): string { const row = db.prepare( @@ -62,9 +74,13 @@ export function validateInvite(token: string): string { } /** - * Validates the token and marks it used in one step, returning the inviterId. + * Validates the token and marks it used in one step, returning the inviter's + * id. * Claim before the (async) account creation, not after: checking first and * marking later lets parallel registrations all pass the check on one invite. + * + * @throws {APIException} 404 when the invite doesn't exist or has expired, + * 409 when it has been used. */ export function claimInvite(token: string): string { const inviterId = validateInvite(token); diff --git a/api/services/notification-service.ts b/api/services/notification-service.ts index 86530fb7..f717d5cf 100644 --- a/api/services/notification-service.ts +++ b/api/services/notification-service.ts @@ -1,3 +1,11 @@ +/** + * Notifications: storing them, pushing each new one to its user's sockets, + * and the triggers that create them. A trigger passes a source key, so the same + * event never notifies a user twice. + * + * @module + */ + import type { Notification, NotificationData, @@ -17,7 +25,10 @@ import { MENTION_RE } from "../../shared/mention.ts"; // ── Core CRUD ───────────────────────────────────────────────────────────────── -// sourceKey: if set, INSERT OR IGNORE — same (user_id, source_key) pair is a no-op. +/** + * Stores a notification for `userId` and pushes it live. With a `sourceKey`, + * a second one for the same `(userId, sourceKey)` is a no-op. + */ function createNotification( userId: string, type: NotificationType, @@ -61,6 +72,7 @@ function createNotification( } } +/** Returns a page of `userId`'s notifications, newest first. */ export function getNotificationsForUser( userId: string, page: number, @@ -81,6 +93,7 @@ export function getNotificationsForUser( return pageOf(rawRows.map(notificationRowToApi), limit); } +/** Returns how many of `userId`'s notifications are unread. */ export function getUnreadCount(userId: string): number { const row = db.prepare( `SELECT COUNT(*) as count FROM notifications WHERE user_id = ? AND read = 0;`, @@ -88,12 +101,14 @@ export function getUnreadCount(userId: string): number { return row?.count ?? 0; } +/** Marks every notification of `userId` read. */ export function markAllRead(userId: string): void { db.prepare(`UPDATE notifications SET read = 1 WHERE user_id = ?;`).run( userId, ); } +/** Marks one of `userId`'s notifications read; someone else's is left alone. */ export function markOneRead(notificationId: string, userId: string): void { db.prepare( `UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?;`, @@ -102,6 +117,7 @@ export function markOneRead(notificationId: string, userId: string): void { // ── Trigger helpers ─────────────────────────────────────────────────────────── +/** Notifies `followedUserId` that `followerId` follows them. */ export function notifyUserNewFollower( followerId: string, followedUserId: string, @@ -120,6 +136,10 @@ export function notifyUserNewFollower( ); } +/** + * Notifies a playlist's owner that `followerId` follows it, unless they are + * the owner. + */ export function notifyPlaylistOwnerNewFollower( followerId: string, playlistId: string, @@ -148,6 +168,7 @@ export function notifyPlaylistOwnerNewFollower( ); } +/** Notifies everyone following `dumperId` that they posted a dump. */ export function notifyUserFollowersNewDump( dumperId: string, dumpId: string, @@ -215,6 +236,7 @@ export function notifyUserFollowersNewDump( }); } +/** Notifies a dump's poster that `voterId` upvoted it, unless they are the poster. */ export function notifyDumpOwnerUpvote( voterId: string, dumpId: string, @@ -243,6 +265,13 @@ export function notifyDumpOwnerUpvote( ); } +/** + * Notifies every user @mentioned in `body`, except its author, that they were + * mentioned in the given comment, dump, playlist or chat message. Each is + * notified once per mention context, however often the body is edited. + * `skipUserId` spares a mentioned user (e.g. one already reading the chat + * live, for whom the persistent notification would be redundant). + */ export function notifyMentions( mentionerUserId: string, body: string, @@ -250,8 +279,6 @@ export function notifyMentions( contextId: string, contextTitle: string, dumpId?: string, - // Optional: skip notifying a mentioned user (e.g. they're already reading the - // chat live, so the persistent notification would be redundant). skipUserId?: (userId: string) => boolean, ): void { const mentionerRow = db.prepare( @@ -288,6 +315,10 @@ export function notifyMentions( } } +/** + * Notifies a dump's poster that `commenterId` commented on it, unless they are + * the poster. + */ export function notifyDumpOwnerNewComment( commenterId: string, commentId: string, @@ -318,6 +349,10 @@ export function notifyDumpOwnerNewComment( ); } +/** + * Notifies a comment's author that `likerId` likes it, unless they are the + * author. + */ export function notifyCommentOwnerLike( likerId: string, commentId: string, @@ -350,6 +385,7 @@ export function notifyCommentOwnerLike( ); } +/** Notifies everyone following a playlist that a dump was added to it. */ export function notifyPlaylistFollowersNewDump( playlistId: string, playlistTitle: string, diff --git a/api/services/password-reset-service.ts b/api/services/password-reset-service.ts index b1caf6ba..d1d1bd6a 100644 --- a/api/services/password-reset-service.ts +++ b/api/services/password-reset-service.ts @@ -1,3 +1,10 @@ +/** + * Password resets by email: sending a reset link, and setting the new + * password with it. + * + * @module + */ + import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { db } from "../db/connection.ts"; @@ -13,16 +20,17 @@ import { VALIDATION } from "../../shared/validation.ts"; import { marked } from "marked"; import { type RateLimit, takeToken } from "../lib/rate-limit.ts"; +/** How long a reset link works. */ const RESET_TOKEN_TTL_HOURS = 1; +/** Per address: enough to retry a lost email, not enough to flood an inbox. */ +const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 }; + /** * Looks up the user by email, creates a signed reset token, persists it, and * sends the reset link. Always resolves without throwing so callers can return * 200 unconditionally (no user enumeration). */ -// Per address: enough to retry a lost email, not enough to flood an inbox. -const RESET_REQUEST_LIMIT: RateLimit = { capacity: 3, refill: 1 / 600 }; - export async function requestPasswordReset(email: string): Promise<void> { if (!takeToken(`reset:${email.toLowerCase()}`, RESET_REQUEST_LIMIT)) return; @@ -60,6 +68,10 @@ export async function requestPasswordReset(email: string): Promise<void> { /** * Verifies the token (JWT signature + expiry + DB record + not used), updates * the password, and marks the token consumed. + * + * @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 + * user is gone, 409 when it has been used. */ export async function resetPassword( token: string, diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 89075dcc..0c3ab919 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -1,3 +1,11 @@ +/** + * Playlists: managing them and their dumps, and reading them as each viewer + * may see them. A playlist is public or private to its owner; its members are + * filtered per viewer (see {@link isMemberVisibleTo}). + * + * @module + */ + import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { @@ -33,10 +41,20 @@ import { type PlaylistMember, } from "../lib/playlist-members.ts"; +/** + * The columns of a `PlaylistRow` with its owner's name and dump count, + * and the `FROM playlists p` they come from. + */ const PLAYLIST_SELECT = `p.*, u.username as owner_username, (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`; +/** + * Returns the playlist with id or slug `idOrSlug`, private or not: callers + * that show it check its visibility. + * + * @throws {APIException} 404 when there is no such playlist. + */ export function getPlaylistById(idOrSlug: string): Playlist { const row = UUID_RE.test(idOrSlug) ? db.prepare(`SELECT ${PLAYLIST_SELECT} WHERE p.id = ?;`).get(idOrSlug) @@ -47,6 +65,10 @@ export function getPlaylistById(idOrSlug: string): Playlist { return playlistRowToApi(row); } +/** + * Creates a playlist owned by `userId`, notifies anyone @mentioned in its + * description, and announces it. + */ export function createPlaylist( req: CreatePlaylistRequest, userId: string, @@ -83,6 +105,12 @@ export function createPlaylist( return playlist; } +/** + * Returns a playlist with the dumps `requestingUserId` may see in it, in + * order. + * + * @throws {APIException} 404 when there is no such playlist, or it is private and not theirs. + */ export function getPlaylist( playlistId: string, requestingUserId: string | null, @@ -111,6 +139,10 @@ export function getPlaylist( return { ...playlist, dumps: visibleDumps }; } +/** + * Returns up to `limit` playlists whose title or description contains + * `query`, newest first: the public ones, and the requesting user's own. + */ export function searchPlaylists( query: string, limit: number, @@ -131,6 +163,10 @@ export function searchPlaylists( return rows.filter(isPlaylistRow).map(playlistRowToApi); } +/** + * Returns a page of `userId`'s playlists, newest first — the private ones only + * to `userId` themselves. + */ export function listPlaylistsByUser( userId: string, requestingUserId: string | null, @@ -148,6 +184,13 @@ export function listPlaylistsByUser( return pageOf(rows.filter(isPlaylistRow).map(playlistRowToApi), limit); } +/** + * Applies `req` to a playlist (its slug follows the title), as its owner or a + * moderator (`canModerate`); notifies anyone @mentioned in the description, + * and announces the change. + * + * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. + */ export function updatePlaylist( playlistId: string, req: UpdatePlaylistRequest, @@ -203,6 +246,12 @@ export function updatePlaylist( return updated; } +/** + * Deletes a playlist, as its owner or a moderator (`canModerate`), and + * announces it. + * + * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. + */ export function deletePlaylist( playlistId: string, requestingUserId: string, @@ -218,7 +267,11 @@ export function deletePlaylist( broadcastPlaylistDeleted(playlist.id, playlist.userId, playlist.isPublic); } -/** The playlist, if the requester may edit it; 404 or 403 otherwise. */ +/** + * Returns the playlist, if the requester may edit it. + * + * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. + */ export function getEditablePlaylist( playlistId: string, requestingUserId: string, @@ -231,7 +284,12 @@ export function getEditablePlaylist( return playlist; } -// Call once the image file is in place: this is what announces it. +/** + * Records a playlist's new cover image and announces it. Call once the image + * file is in place: this is what announces it. + * + * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. + */ export function setPlaylistImage( playlistId: string, imageMime: string, @@ -253,6 +311,13 @@ export function setPlaylistImage( return updated; } +/** + * Puts a dump at the top of a playlist, announces the change, and notifies the + * followers of a public playlist 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 + * someone else's private dump, 409 when it is already in the playlist. + */ export function addDumpToPlaylist( playlistId: string, dumpId: string, @@ -313,6 +378,11 @@ export function addDumpToPlaylist( } } +/** + * Takes a dump out of a playlist, if it is in it, and announces the change. + * + * @throws {APIException} 404 when there is no such playlist, 403 when the user is neither its owner nor a moderator. + */ export function removeDumpFromPlaylist( playlistId: string, dumpId: string, @@ -332,6 +402,13 @@ export function removeDumpFromPlaylist( broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id)); } +/** + * Puts the playlist's members the requester can see in the order of + * `dumpIds`, and announces the change. + * + * @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 + * members they can see. + */ export function reorderPlaylist( playlistId: string, dumpIds: string[], @@ -382,6 +459,10 @@ export function reorderPlaylist( broadcastPlaylistDumpsUpdated(playlist, getCurrentMembers(playlist.id)); } +/** + * Returns each of `userId`'s playlists, newest first, and whether it holds + * dump `dumpId`. + */ export function getPlaylistMembershipsForDump( dumpId: string, userId: string, @@ -410,7 +491,7 @@ export function getPlaylistMembershipsForDump( } /** - * The playlists a dump appears in — the "in collections" counterpart to + * Returns the playlists a dump appears in — the "in collections" counterpart to * backlinks. Privacy-filtered like `getRelatedDumps`: public playlists for * everyone, plus the requesting user's own private ones. Capped at 20 — a * small, bounded list of supporting context. @@ -429,15 +510,31 @@ export function getPlaylistsForDump( return rows.filter(isPlaylistRow).map(playlistRowToApi); } +/** + * Returns a playlist's id, cover image type and visibility, or undefined + * without a cover. A private playlist's cover exists only for its owner. + * + * @throws {APIException} 404 when there is no such playlist, or it is private + * and `requestingUserId` isn't its owner. + */ export function getPlaylistImageInfo( idOrSlug: string, -): { id: string; imageMime: string } | undefined { + requestingUserId: string | null, +): { id: string; imageMime: string; isPublic: boolean } | undefined { const playlist = getPlaylistById(idOrSlug); + if (!playlist.isPublic && requestingUserId !== playlist.userId) { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Playlist not found"); + } return playlist.imageMime - ? { id: playlist.id, imageMime: playlist.imageMime } + ? { + id: playlist.id, + imageMime: playlist.imageMime, + isPublic: playlist.isPublic, + } : undefined; } +/** Returns a playlist's members, in order, with what their visibility takes. */ function getCurrentMembers(playlistId: string): PlaylistMember[] { const rows = db.prepare( `SELECT pd.dump_id, d.is_private, d.user_id diff --git a/api/services/rich-content-service.ts b/api/services/rich-content-service.ts index eda744fe..1247dae9 100644 --- a/api/services/rich-content-service.ts +++ b/api/services/rich-content-service.ts @@ -1,3 +1,9 @@ +/** + * Link previews: picks the provider for a URL and caches recent results. + * + * @module + */ + import type { RichContent } from "../../shared/dump.ts"; import type { RichContentProvider } from "../remote/providers/provider.ts"; import { youtubeProvider } from "../remote/providers/youtube.ts"; @@ -7,7 +13,7 @@ import { selfProvider } from "../remote/providers/self.ts"; import { genericProvider } from "../remote/providers/generic.ts"; /** - * Register providers in priority order. The first match wins. + * The providers, in priority order. The first match wins. * `selfProvider` resolves gerbeur URLs directly from the DB (no HTTP round-trip). * `genericProvider` must stay last — it always matches. */ @@ -19,36 +25,45 @@ const providers: RichContentProvider[] = [ genericProvider, ]; +/** The outcome of fetching a link's preview. */ 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 - * not overwrite it with a failure stub. */ + * not overwrite it with a failure stub. + */ ok: boolean; + /** The preview, or on failure a stub with just the URL and host. */ content?: RichContent; } -/** - * Fetch metadata for `url`, reporting whether the fetch itself succeeded. - * - * 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. - */ -// Metadata fetched in the last few minutes, by URL. The create form previews a -// link and then posts it; without this the page was fetched twice, and the -// second fetch was the slow part of posting. Only successes are kept, and a -// metadata refresh (`fresh`) always goes to the page. +/** How long a fetched preview is reused. */ const RECENT_TTL_MS = 5 * 60 * 1000; +/** The most previews kept; the oldest write goes first. */ const RECENT_MAX = 200; +/** + * Previews fetched in the last few minutes, by URL. The create form previews a + * link and then posts it; without this the page was fetched twice, and the + * second fetch was the slow part of posting. Only successes are kept, and a + * metadata refresh (`fresh`) always goes to the page. + */ const recent = new Map< string, { at: number; result: Promise<FetchRichContentResult> } >(); -/** Forget every cached result (tests). */ +/** Forgets every cached preview (for the tests). */ export function clearRichContentCache(): void { recent.clear(); } +/** + * Fetches the preview of `url`, reporting whether the fetch itself succeeded. + * A preview fetched in the last five minutes is reused unless `fresh` is set. + * + * 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( url: string, { fresh = false }: { fresh?: boolean } = {}, @@ -71,6 +86,7 @@ export function tryFetchRichContent( return result; } +/** Fetches the preview of `url` through the first provider that matches. */ async function fetchRichContentUncached( url: string, ): Promise<FetchRichContentResult> { @@ -94,7 +110,7 @@ async function fetchRichContentUncached( } } -/** Metadata for `url`, or a minimal stub when it can't be fetched. */ +/** Returns the preview of `url`, or a minimal stub when it can't be fetched. */ export async function fetchRichContent( url: string, ): Promise<RichContent | undefined> { diff --git a/api/services/user-service.ts b/api/services/user-service.ts index 3bac8f44..516ab328 100644 --- a/api/services/user-service.ts +++ b/api/services/user-service.ts @@ -1,3 +1,9 @@ +/** + * Users: accounts, profiles, lookups and search, and the invite tree. + * + * @module + */ + import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import type { @@ -21,12 +27,19 @@ import { closeUserSockets, disconnectUser } from "./ws-service.ts"; import { hashPassword } from "../lib/jwt.ts"; import { linkAttachments } from "./attachment-service.ts"; +/** The query selecting full `UserRow`s from `users u`, with the inviter's name. */ const USER_SELECT = `SELECT u.id, u.username, u.password_hash, u.role, u.created_at, u.updated_at, u.avatar_mime, u.description, u.invited_by, u.email, i.username as invited_by_username FROM users u LEFT JOIN users i ON i.id = u.invited_by`; +/** + * Creates a user with the `user` role, invited by `inviterId`. + * + * @throws {APIException} 400 when the username is taken, 409 when the email + * is. + */ export async function createUser( request: RegisterUserRequest, inviterId: string | null, @@ -86,6 +99,11 @@ export async function createUser( }; } +/** + * Returns user `userId`. + * + * @throws {APIException} 404 when there is no such user. + */ export function getUserById(userId: string): User { const userRow = db.prepare( `${USER_SELECT} WHERE u.id = ?`, @@ -99,9 +117,9 @@ export function getUserById(userId: string): User { } /** - * What a session token is checked against: the user's live role, and the - * moment older tokens stopped counting (their last password change). Undefined - * when the user is gone. + * Returns what a session token is checked against: the user's live role, and + * the moment older tokens stopped counting (their last password change). + * Undefined when the user is gone. */ export function getSessionState( userId: string, @@ -117,6 +135,11 @@ export function getSessionState( }; } +/** + * Returns the user named `username`. + * + * @throws {APIException} 404 when there is no such user. + */ export function getUserByUsername(username: string): User { const userRow = db.prepare( `${USER_SELECT} WHERE u.username = ?`, @@ -130,8 +153,9 @@ export function getUserByUsername(username: string): User { } /** - * Password resets are looked up by email, so an address can belong to one - * account only — otherwise which account a reset link resets is up to row order. + * Throws a 409 when another account than `exceptUserId` has `email`. Password + * resets are looked up by email, so an address can belong to one account only + * — otherwise which account a reset link resets is up to row order. */ function assertEmailAvailable(email: string, exceptUserId?: string): void { const row = db.prepare( @@ -146,6 +170,7 @@ function assertEmailAvailable(email: string, exceptUserId?: string): void { } } +/** Returns the user whose email is `email`, or null. */ export function getUserByEmail(email: string): User | null { const userRow = db.prepare( `${USER_SELECT} WHERE u.email = ?`, @@ -155,6 +180,10 @@ export function getUserByEmail(email: string): User | null { return userRowToApi(userRow); } +/** + * Returns up to `limit` users whose name starts with `query`, for @mention + * suggestions. + */ export function searchUsers( query: string, limit: number, @@ -174,7 +203,10 @@ export function searchUsers( })); } -// For the public search page: profiles only, never hashes or emails. +/** + * Returns up to `limit` users whose name or profile contains `query`, for the + * public search page: profiles only, never hashes or emails. + */ export function searchUsersGlobal(query: string, limit: number): PublicUser[] { if (!query.trim()) return []; const pattern = `%${query}%`; @@ -184,6 +216,7 @@ export function searchUsersGlobal(query: string, limit: number): PublicUser[] { return rows.filter(isUserRow).map(userRowToApi).map(toPublicUser); } +/** Returns every user. */ export function listUsers(): User[] { const userRows = db.prepare( `${USER_SELECT}`, @@ -196,6 +229,13 @@ export function listUsers(): User[] { return userRows.map(userRowToApi); } +/** + * Applies `request` to user `userId`. A new password also ends every session + * opened before it and closes the user's sockets. + * + * @throws {APIException} 404 when there is no such user, 409 when the new + * email belongs to another account. + */ export async function updateUser( userId: string, request: UpdateUserRequest, @@ -251,6 +291,11 @@ export async function updateUser( return updatedUser; } +/** + * Records that user `userId` now has an avatar of type `mime`. + * + * @throws {APIException} 404 when there is no such user. + */ export function updateUserAvatar(userId: string, mime: string): void { const result = db.prepare( `UPDATE users SET avatar_mime = ?, updated_at = ? WHERE id = ?`, @@ -261,6 +306,10 @@ export function updateUserAvatar(userId: string, mime: string): void { } } +/** + * Returns everyone `userId` invited, and everyone they invited in turn, down + * to ten levels, in registration order. + */ export function getInviteTree(userId: string): Invitee[] { const rows = db.prepare(` WITH RECURSIVE tree AS ( @@ -291,6 +340,11 @@ export function getInviteTree(userId: string): Invitee[] { })); } +/** + * Deletes user `userId` and disconnects their sockets. + * + * @throws {APIException} 404 when there is no such user. + */ export function deleteUser(userId: string): void { disconnectUser(userId); diff --git a/api/services/vote-service.ts b/api/services/vote-service.ts index d04deaad..663b170a 100644 --- a/api/services/vote-service.ts +++ b/api/services/vote-service.ts @@ -1,3 +1,9 @@ +/** + * Upvotes on dumps. + * + * @module + */ + import { APIErrorCode } from "../../shared/api.ts"; import { APIException } from "../lib/api-exception.ts"; import { isUserRow, type User, userRowToApi } from "../model/user.ts"; @@ -5,14 +11,18 @@ import { db } from "../db/connection.ts"; import { type Page, pageOf } from "../lib/pagination.ts"; import { notifyDumpOwnerUpvote } from "./notification-service.ts"; +/** A dump's votes after a vote was cast or removed. */ export interface VoteResult { + /** The dump's vote count now. */ voteCount: number; - /** Private dumps' vote counts go to their owner only. */ + /** Whether the dump is private: its vote count goes to its owner only. */ isPrivate: boolean; } -// Visibility of the dump, or a 404 when the user can't see it — voting must not -// confirm that a private dump exists. +/** + * Returns whether the dump is private, or throws a 404 when the user can't see + * it — voting must not confirm that a private dump exists. + */ function visibleDump(dumpId: string, userId: string): { isPrivate: boolean } { const row = db.prepare( `SELECT is_private, user_id FROM dumps WHERE id = ?;`, @@ -23,6 +33,12 @@ function visibleDump(dumpId: string, userId: string): { isPrivate: boolean } { return { isPrivate: Boolean(row.is_private) }; } +/** + * Records `userId`'s upvote on a dump and notifies its poster. + * + * @throws {APIException} 404 when the dump doesn't exist or is someone else's + * private dump, 409 when the user already voted for it. + */ export function castVote(dumpId: string, userId: string): VoteResult { const { isPrivate } = visibleDump(dumpId, userId); let voteCount: number; @@ -59,8 +75,13 @@ export function castVote(dumpId: string, userId: string): VoteResult { return { voteCount, isPrivate }; } -// No visibility check: taking back a vote on a dump that has since gone -// private is harmless, and it's the voter's own row. +/** + * Takes back `userId`'s upvote on a dump. No visibility check: taking back a + * vote on a dump that has since gone private is harmless, and it's the voter's + * own row. + * + * @throws {APIException} 404 when the user hasn't voted for the dump. + */ export function removeVote(dumpId: string, userId: string): VoteResult { const row = db.prepare(`SELECT is_private FROM dumps WHERE id = ?;`).get( dumpId, @@ -90,6 +111,7 @@ export function removeVote(dumpId: string, userId: string): VoteResult { } } +/** Returns the ids of every dump `userId` upvoted. */ export function getUserVotes(userId: string): string[] { const rows = db.prepare( `SELECT dump_id FROM votes WHERE user_id = ?;`, @@ -97,6 +119,7 @@ export function getUserVotes(userId: string): string[] { return rows.map((r) => r.dump_id); } +/** Returns a page of the users who upvoted a dump, latest first. */ export function getDumpVoters( dumpId: string, page: number, diff --git a/api/services/ws-service.ts b/api/services/ws-service.ts index ee36cd0a..2ce766b1 100644 --- a/api/services/ws-service.ts +++ b/api/services/ws-service.ts @@ -1,3 +1,11 @@ +/** + * The connected WebSocket clients, and everything pushed to them: presence, + * live updates to dumps, comments, playlists and chat, typing indicators, and + * the keepalive that drops dead sockets. + * + * @module + */ + import type { ChatMessage } from "../../shared/chat.ts"; import type { Comment } from "../../shared/comment.ts"; import type { Dump } from "../../shared/dump.ts"; @@ -9,31 +17,45 @@ import { type PlaylistMember, } from "../lib/playlist-members.ts"; +/** A connected socket, and what the server knows of its user. */ export interface WsClient { + /** The socket. */ socket: WebSocket; + /** The user's id; undefined for an anonymous socket. */ userId?: string; + /** The user's name. */ username?: string; + /** The user's role. */ role?: Role; + /** The user's avatar type, undefined without one. */ avatarMime?: string; + /** When the avatar last changed, to bust the client's cache. */ avatarVersion?: number; + /** Whether the client answered the last ping; undefined before the first. */ pongReceived?: boolean; /** Whether this client currently has the chatbox open (is reading chat). */ chatOpen?: boolean; - /** Timestamp (ms) of the client's last "typing" signal; undefined when not - * composing. Entries older than TYPING_TTL are treated as stale. */ + /** + * Timestamp (ms) of the client's last "typing" signal; undefined when not + * composing. Entries older than {@link TYPING_TTL} are treated as stale. + */ typingAt?: number; } +/** Every connected client. */ const clients = new Set<WsClient>(); +/** Adds a connected client. */ export function register(client: WsClient): void { clients.add(client); } +/** Removes a disconnected client. */ export function unregister(client: WsClient): void { clients.delete(client); } +/** Records a user's new avatar on their clients and rebroadcasts presence. */ export function updateClientAvatar(userId: string, avatarMime: string): void { const version = Date.now(); for (const client of clients) { @@ -45,6 +67,7 @@ export function updateClientAvatar(userId: string, avatarMime: string): void { broadcastPresence(); } +/** Returns each signed-in user with at least one socket, once. */ export function getOnlineUsers(): OnlineUser[] { const seen = new Map<string, OnlineUser>(); for (const client of clients) { @@ -60,16 +83,20 @@ export function getOnlineUsers(): OnlineUser[] { return Array.from(seen.values()); } +/** Sends an already serialized message, if the socket is open. */ function sendRaw(socket: WebSocket, payload: string): void { if (socket.readyState === WebSocket.OPEN) socket.send(payload); } +/** Sends one message to one socket. */ function send(socket: WebSocket, data: ServerToClientMessage): void { sendRaw(socket, JSON.stringify(data)); } -/** Sends one message to every client `include` accepts (all by default), - * serialized once rather than once per socket. */ +/** + * Sends one message to every client `include` accepts (all by default), + * serialized once rather than once per socket. + */ function broadcast( data: ServerToClientMessage, include?: (client: WsClient) => boolean, @@ -82,10 +109,12 @@ function broadcast( } } +/** Sends a message to every socket of `userId`. */ export function sendToUser(userId: string, data: ServerToClientMessage): void { broadcast(data, (client) => client.userId === userId); } +/** Logs a deleted user out of every socket and closes them. */ export function disconnectUser(userId: string): void { for (const client of clients) { if (client.userId === userId) { @@ -107,23 +136,30 @@ export function closeUserSockets(userId: string, reason: string): void { } } +/** Sends everyone the list of online users. */ export function broadcastPresence(): void { broadcast({ type: "presence_update", users: getOnlineUsers() }); } +/** Tells everyone a dump was posted. */ export function broadcastNewDump(dump: Dump): void { broadcast({ type: "dump_created", dump }); } +/** Tells everyone a dump changed. */ export function broadcastDumpUpdated(dump: Dump): void { broadcast({ type: "dump_updated", dump }); } +/** Tells everyone a dump was deleted. */ export function broadcastDumpDeleted(dumpId: string): void { broadcast({ type: "dump_deleted", dumpId }); } -// `onlyUserId`: a private dump's count goes to that user's sockets alone. +/** + * Tells everyone a dump's vote count changed. With `onlyUserId`, a private + * dump's count goes to that user's sockets alone. + */ export function broadcastVoteUpdate( dumpId: string, voteCount: number, @@ -137,7 +173,10 @@ export function broadcastVoteUpdate( ); } -// `onlyUserId`: a like on a private dump's comment goes to that user alone. +/** + * Tells everyone a comment's like count changed. With `onlyUserId`, a like on + * a private dump's comment goes to that user alone. + */ export function broadcastCommentLikeUpdate( commentId: string, likeCount: number, @@ -151,6 +190,7 @@ export function broadcastCommentLikeUpdate( ); } +/** Sends a message to everyone for a public playlist, to its owner otherwise. */ function sendToPlaylistAudience( playlist: Pick<Playlist, "isPublic" | "userId">, data: ServerToClientMessage, @@ -161,13 +201,17 @@ function sendToPlaylistAudience( ); } +/** Tells a new playlist's audience it was created. */ export function broadcastPlaylistCreated(playlist: Playlist): void { sendToPlaylistAudience(playlist, { type: "playlist_created", playlist }); } -// `wasPublic`: the visibility before this change. A playlist that just went -// private still goes to everyone, so non-owners can drop it from their views; -// one that was and stays private goes to its owner alone. +/** + * Tells a playlist's audience it changed. `wasPublic` is the visibility before + * this change: a playlist that just went private still goes to everyone, so + * non-owners can drop it from their views; one that was and stays private goes + * to its owner alone. + */ export function broadcastPlaylistUpdated( playlist: Playlist, wasPublic: boolean, @@ -178,6 +222,7 @@ export function broadcastPlaylistUpdated( ); } +/** Tells a playlist's audience it was deleted. */ export function broadcastPlaylistDeleted( playlistId: string, userId: string, @@ -190,8 +235,11 @@ export function broadcastPlaylistDeleted( }); } -// Each viewer gets the members they can see, as GET would list them: the ids -// of private dumps don't go out to the whole audience of a public playlist. +/** + * Tells a playlist's audience its dumps changed. Each viewer gets the members + * they can see, as GET would list them: the ids of private dumps don't go out + * to the whole audience of a public playlist. + */ export function broadcastPlaylistDumpsUpdated( playlist: Playlist, members: PlaylistMember[], @@ -209,14 +257,17 @@ export function broadcastPlaylistDumpsUpdated( } } +/** Tells everyone a user's profile changed. */ export function broadcastUserUpdated(user: PublicUser): void { broadcast({ type: "user_updated", user }); } +/** Tells everyone a comment was posted. */ export function broadcastCommentCreated(comment: Comment): void { broadcast({ type: "comment_created", comment }); } +/** Tells everyone a comment was deleted. */ export function broadcastCommentDeleted( commentId: string, dumpId: string, @@ -224,17 +275,23 @@ export function broadcastCommentDeleted( broadcast({ type: "comment_deleted", commentId, dumpId }); } +/** Tells everyone a comment was edited. */ export function broadcastCommentUpdated(comment: Comment): void { broadcast({ type: "comment_updated", comment }); } -// Global chat room for members: history is behind auth, so the live stream is -// too — an anonymous socket gets nothing chat-related. +/** + * Sends a chat message to signed-in sockets only: history is behind auth, so + * the live stream is too — an anonymous socket gets nothing chat-related. + */ function sendToMembers(data: ServerToClientMessage): void { broadcast(data, (client) => !!client.userId); } -// `nonce` is the sender's id for its pending copy; harmless to everyone else. +/** + * Sends a new chat message to the members. `nonce` is the sender's id for its + * pending copy; harmless to everyone else. + */ export function broadcastChatMessage( message: ChatMessage, nonce?: string, @@ -242,21 +299,28 @@ export function broadcastChatMessage( sendToMembers({ type: "chat_message", message, nonce }); } +/** Tells the members a chat message was edited. */ export function broadcastChatMessageUpdated(message: ChatMessage): void { sendToMembers({ type: "chat_message_updated", message }); } +/** Tells the members a chat message was deleted. */ export function broadcastChatMessageDeleted(id: string): void { sendToMembers({ type: "chat_message_deleted", id }); } -// Typing indicator: a client's "typing" signal is considered live for this long -// after its last refresh. Must exceed the client's typing-ping cadence so a -// steady typist never flickers out between pings. +/** + * How long a client's "typing" signal is considered live after its last + * refresh. Must exceed the client's typing-ping cadence so a steady typist + * never flickers out between pings. + */ const TYPING_TTL = 6_000; -// Deduped set of users currently composing a chat message (typing signal within -// the TTL), shaped like OnlineUser so the client renders the same avatars. +/** + * Returns each user currently composing a chat message (typing signal within + * {@link TYPING_TTL}) once, shaped like the online users so the client renders + * the same avatars. + */ export function getTypingUsers(): OnlineUser[] { const now = Date.now(); const seen = new Map<string, OnlineUser>(); @@ -276,13 +340,17 @@ export function getTypingUsers(): OnlineUser[] { return Array.from(seen.values()); } +/** Sends the members the list of users typing. */ export function broadcastTyping(): void { sendToMembers({ type: "chat_typing_update", users: getTypingUsers() }); } -// Record (or clear) a client's typing state and tell everyone. The client also -// resends "true" periodically while composing; the sweep below expires it if -// they go quiet without a clean "false" (e.g. they closed the tab). +/** + * Records (or clears) a client's typing state and tells everyone when it + * changed. The client also resends "true" periodically while composing; a + * sweep expires it if they go quiet without a clean "false" (e.g. they closed + * the tab). + */ export function setClientTyping(client: WsClient, typing: boolean): void { const wasTyping = !!client.typingAt; client.typingAt = typing ? Date.now() : undefined; @@ -305,8 +373,11 @@ setInterval(() => { if (changed) broadcastTyping(); }, 2_000); -// True if the user has the chatbox open on any of their connected clients — i.e. -// they're already reading chat, so a mention notification would be redundant. +/** + * Checks whether the user has the chatbox open on any of their connected + * clients — i.e. they're already reading chat, so a mention notification would + * be redundant. + */ export function isUserChatOpen(userId: string): boolean { for (const client of clients) { if (client.userId === userId && client.chatOpen) return true; @@ -316,12 +387,17 @@ export function isUserChatOpen(userId: string): boolean { export { type RateLimit, takeToken } from "../lib/rate-limit.ts"; +/** Records that a client answered the keepalive ping. */ export function handleClientPong(client: WsClient): void { client.pongReceived = true; } -// Keepalive: ping all clients every 30s, disconnect non-responsive ones +/** + * How often every client is pinged. One that hasn't answered the previous ping + * is disconnected. + */ const PING_INTERVAL = 30_000; +/** The serialized ping. */ const PING = JSON.stringify({ type: "ping" } satisfies ServerToClientMessage); setInterval(() => { diff --git a/api/test/lib/index-html.test.ts b/api/test/lib/index-html.test.ts new file mode 100644 index 00000000..e7a9d8af --- /dev/null +++ b/api/test/lib/index-html.test.ts @@ -0,0 +1,23 @@ +import { TEST_DIR } from "../helpers.ts"; +import { assertEquals, assertRejects } from "@std/assert"; +import { loadIndexHtml } from "../../lib/index-html.ts"; + +Deno.test("loadIndexHtml: fills the placeholders, and rereads a rebuilt file", async () => { + const root = `${TEST_DIR}/index-html-${crypto.randomUUID()}`; + await Deno.mkdir(root, { recursive: true }); + const path = `${root}/index.html`; + await Deno.writeTextFile( + path, + "<title>__SITE_NAME____BANDCAMP_PLAYER__", + ); + assertEquals( + await loadIndexHtml(root), + "gerbeurembed", + ); + + await Deno.writeTextFile(path, "rebuilt __SITE_NAME__"); + await Deno.utime(path, new Date(), new Date(Date.now() + 60_000)); + assertEquals(await loadIndexHtml(root), "rebuilt gerbeur"); + + await assertRejects(() => loadIndexHtml(`${root}/missing`)); +}); diff --git a/api/test/routes/playlists.test.ts b/api/test/routes/playlists.test.ts index 3e1d0c62..52c27b0b 100644 --- a/api/test/routes/playlists.test.ts +++ b/api/test/routes/playlists.test.ts @@ -322,6 +322,31 @@ test("playlist images: upload and serve", async () => { assertEquals(served.bytes, PNG); }); +test("playlist images: a private playlist's cover only for its owner's media token", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id } = insertPlaylist({ userId: owner.id, isPublic: false }); + await post(`/api/playlists/${id}/image`, { + auth: owner.auth, + form: formWithFile(PNG), + }); + assertEquals((await get(`/api/playlists/${id}/image`)).status, 404); + assertEquals( + (await get(`/api/playlists/${id}/image?token=${other.mediaToken}`)).status, + 404, + ); + assertEquals( + (await get(`/api/playlists/${id}/image?token=garbage`)).status, + 404, + ); + const served = await get( + `/api/playlists/${id}/image?token=${owner.mediaToken}`, + ); + assertEquals(served.status, 200); + assertEquals(served.bytes, PNG); + assertMatch(served.headers.get("cache-control") ?? "", /^private/); +}); + test("playlist images: a failed write leaves the row and the old cover alone", async () => { const owner = await createUser(); const { id } = insertPlaylist({ userId: owner.id }); diff --git a/api/validators/category.ts b/api/validators/category.ts index 0aab891b..df7f8a49 100644 --- a/api/validators/category.ts +++ b/api/validators/category.ts @@ -1,8 +1,15 @@ +/** + * Guards for the category request bodies. + * + * @module + */ + import type { CreateCategoryRequest, UpdateCategoryRequest, } from "../../shared/category.ts"; +/** Checks whether `obj` is a valid {@link CreateCategoryRequest}. */ export function isCreateCategoryRequest( obj: unknown, ): obj is CreateCategoryRequest { @@ -14,6 +21,7 @@ export function isCreateCategoryRequest( return true; } +/** Checks whether `obj` is a valid {@link UpdateCategoryRequest}. */ export function isUpdateCategoryRequest( obj: unknown, ): obj is UpdateCategoryRequest { diff --git a/api/validators/comment.ts b/api/validators/comment.ts index 4a58cf00..5d037596 100644 --- a/api/validators/comment.ts +++ b/api/validators/comment.ts @@ -1,9 +1,19 @@ +/** + * Guards for the comment request bodies. + * + * @module + */ + import type { CreateCommentRequest, UpdateCommentRequest, } from "../../shared/comment.ts"; import { VALIDATION } from "../../shared/validation.ts"; +/** + * Checks whether `obj` is a valid {@link CreateCommentRequest}: a body that + * isn't blank and fits `VALIDATION.COMMENT_BODY_MAX`. + */ export function isCreateCommentRequest( obj: unknown, ): obj is CreateCommentRequest { @@ -16,6 +26,10 @@ export function isCreateCommentRequest( o.parentId === null); } +/** + * Checks whether `obj` is a valid {@link UpdateCommentRequest}: a body that + * isn't blank and fits `VALIDATION.COMMENT_BODY_MAX`. + */ export function isUpdateCommentRequest( obj: unknown, ): obj is UpdateCommentRequest { diff --git a/api/validators/dump.ts b/api/validators/dump.ts index b634e66e..4d3ad18a 100644 --- a/api/validators/dump.ts +++ b/api/validators/dump.ts @@ -1,3 +1,10 @@ +/** + * Guards for the dump request bodies. File dumps arrive as multipart forms + * and are checked in their route. + * + * @module + */ + import type { CreateUrlDumpRequest, UpdateDumpRequest, @@ -8,6 +15,10 @@ function isStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((v) => typeof v === "string"); } +/** + * Checks whether `obj` is a valid {@link CreateUrlDumpRequest}, its title and + * comment within the `VALIDATION` limits. + */ export function isCreateUrlDumpRequest( obj: unknown, ): obj is CreateUrlDumpRequest { @@ -35,6 +46,11 @@ export function isCreateUrlDumpRequest( return true; } +/** + * Checks whether `obj` is a valid {@link UpdateDumpRequest}: a title, when + * given, that isn't blank, and title and comment within the `VALIDATION` + * limits. + */ export function isUpdateDumpRequest(obj: unknown): obj is UpdateDumpRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; diff --git a/api/validators/playlist.ts b/api/validators/playlist.ts index 75a50ce3..7ff70eaa 100644 --- a/api/validators/playlist.ts +++ b/api/validators/playlist.ts @@ -1,3 +1,9 @@ +/** + * Guards for the playlist request bodies. + * + * @module + */ + import type { CreatePlaylistRequest, ReorderPlaylistRequest, @@ -5,6 +11,10 @@ import type { } from "../../shared/playlist.ts"; import { VALIDATION } from "../../shared/validation.ts"; +/** + * Checks whether `obj` is a valid {@link CreatePlaylistRequest}: a non-empty + * title, and title and description within the `VALIDATION` limits. + */ export function isCreatePlaylistRequest( obj: unknown, ): obj is CreatePlaylistRequest { @@ -29,6 +39,11 @@ export function isCreatePlaylistRequest( return true; } +/** + * Checks whether `obj` is a valid {@link UpdatePlaylistRequest}: a title, when + * given, that isn't empty, and title and description within the `VALIDATION` + * limits. + */ export function isUpdatePlaylistRequest( obj: unknown, ): obj is UpdatePlaylistRequest { @@ -53,6 +68,7 @@ export function isUpdatePlaylistRequest( return true; } +/** Checks whether `obj` is a valid {@link ReorderPlaylistRequest}. */ export function isReorderPlaylistRequest( obj: unknown, ): obj is ReorderPlaylistRequest { diff --git a/api/validators/user.ts b/api/validators/user.ts index 9ddbbd31..821d8448 100644 --- a/api/validators/user.ts +++ b/api/validators/user.ts @@ -1,3 +1,9 @@ +/** + * Guards for the user and authentication request bodies. + * + * @module + */ + import { isRole, type LoginUserRequest, @@ -6,26 +12,35 @@ import { } from "../../shared/user.ts"; import { VALIDATION } from "../../shared/validation.ts"; +/** Checks whether `obj` is a valid {@link LoginUserRequest}. */ export function isLoginUserRequest(obj: unknown): obj is LoginUserRequest { return !!obj && typeof obj === "object" && "username" in obj && typeof obj.username === "string" && "password" in obj && typeof obj.password === "string"; } -/** `obj[key]` when `obj` is an object that has it, undefined otherwise. */ +/** Returns `obj[key]` when `obj` is an object that has it, else undefined. */ export function fieldOf(obj: unknown, key: string): unknown { return obj !== null && typeof obj === "object" && key in obj ? (obj as Record)[key] : undefined; } +/** + * Checks whether `obj` is a valid {@link RegisterUserRequest} (see + * {@link validateRegisterUserRequest}). + */ export function isRegisterUserRequest( obj: unknown, ): obj is RegisterUserRequest { return validateRegisterUserRequest(obj) === null; } -/** Returns a human-readable error string, or null if the request is valid. */ +/** + * Checks a registration body — its shape, the email's form, and the username + * and password against the `VALIDATION` limits — and returns what is wrong + * with it, readable by the user, or null when it is valid. + */ export function validateRegisterUserRequest(obj: unknown): string | null { if ( !obj || typeof obj !== "object" || @@ -55,6 +70,10 @@ export function validateRegisterUserRequest(obj: unknown): string | null { return null; } +/** + * Checks whether `obj` is a valid {@link UpdateUserRequest}: each field it + * carries well-formed, and within the `VALIDATION` limits. + */ export function isUpdateUserRequest(obj: unknown): obj is UpdateUserRequest { if (!obj || typeof obj !== "object") return false; const o = obj as Record; diff --git a/deno.json b/deno.json index e7a4e761..263e77b5 100644 --- a/deno.json +++ b/deno.json @@ -8,11 +8,14 @@ "i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1", "i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1", "start": "deno task icons dist && deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts", - "test": "deno test -A api/ shared/", - "test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/", + "test": "deno test -A api/ shared/ mod.test.ts", + "test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/ mod.test.ts", "test:web": "deno run -A npm:vitest run", - "test:web:coverage": "deno run -A npm:vitest run --coverage" + "test:web:coverage": "deno run -A npm:vitest run --coverage", + "doc": "deno doc --html --name=gerbeur --output=docs mod.ts $(find api shared -name '*.ts' -not -path '*/test/*')", + "doc:lint": "deno doc --lint mod.ts $(find api shared -name '*.ts' -not -path '*/test/*')" }, + "exports": "./mod.ts", "nodeModulesDir": "auto", "compilerOptions": { "types": [ diff --git a/mod.test.ts b/mod.test.ts new file mode 100644 index 00000000..790f9b44 --- /dev/null +++ b/mod.test.ts @@ -0,0 +1,20 @@ +import { assertEquals } from "@std/assert"; + +Deno.test("mod.ts, the docs' index page, links every documented module", async () => { + const doc = await Deno.readTextFile("mod.ts"); + const linked = new Set( + [...doc.matchAll(/\]\(\.\/(\S+?\.ts)\/index\.html\)/g)].map((m) => m[1]), + ); + const modules: string[] = []; + async function walk(dir: string) { + for await (const entry of Deno.readDir(dir)) { + const path = `${dir}/${entry.name}`; + if (entry.isDirectory && entry.name !== "test") await walk(path); + else if (entry.name.endsWith(".ts")) modules.push(path); + } + } + await walk("api"); + await walk("shared"); + assertEquals(modules.filter((m) => !linked.has(m)), []); + assertEquals([...linked].filter((m) => !modules.includes(m)), []); +}); diff --git a/mod.ts b/mod.ts new file mode 100644 index 00000000..eecbbe2a --- /dev/null +++ b/mod.ts @@ -0,0 +1,162 @@ +/** + * # gerbeur + * + * A small invite-only social platform for sharing links and files. The + * repository holds three parts: the backend in `api/`, the frontend in `src/`, + * and in `shared/` whatever crosses the wire between them. This reference + * documents the backend and the shared modules; the frontend isn't in it yet. + * + * ## Backend — `api/` + * + * An Oak server over SQLite that answers the API and the WebSocket, and serves + * the built frontend. A request goes through a route, which parses it and + * answers. The route calls services, which hold the logic and every SQL query; + * they read rows through the model, and reach other sites only through + * `remote/`. + * + * - **Entry point** — builds the application and listens: + * [main](./api/main.ts/index.html). + * - **Routes** — the HTTP API; each router's page lists its endpoints: + * [attachments](./api/routes/attachments.ts/index.html), + * [avatars](./api/routes/avatars.ts/index.html), + * [bandcamp](./api/routes/bandcamp.ts/index.html), + * [categories](./api/routes/categories.ts/index.html), + * [chat](./api/routes/chat.ts/index.html), + * [comments](./api/routes/comments.ts/index.html), + * [dumps](./api/routes/dumps.ts/index.html), + * [files](./api/routes/files.ts/index.html), + * [follows](./api/routes/follows.ts/index.html), + * [invites](./api/routes/invites.ts/index.html), + * [notifications](./api/routes/notifications.ts/index.html), + * [playlists](./api/routes/playlists.ts/index.html), + * [preview](./api/routes/preview.ts/index.html), + * [search](./api/routes/search.ts/index.html), + * [thumbnails](./api/routes/thumbnails.ts/index.html), + * [users](./api/routes/users.ts/index.html), + * [ws](./api/routes/ws.ts/index.html), + * [youtube](./api/routes/youtube.ts/index.html). + * - **Middleware** — what runs around every route: + * [auth](./api/middleware/auth.ts/index.html), + * [error](./api/middleware/error.ts/index.html), + * [og](./api/middleware/og.ts/index.html). + * - **Services** — the logic and every SQL query, one module per domain: + * [attachment-service](./api/services/attachment-service.ts/index.html), + * [backlink-service](./api/services/backlink-service.ts/index.html), + * [category-service](./api/services/category-service.ts/index.html), + * [chat-service](./api/services/chat-service.ts/index.html), + * [comment-like-service](./api/services/comment-like-service.ts/index.html), + * [comment-service](./api/services/comment-service.ts/index.html), + * [dump-service](./api/services/dump-service.ts/index.html), + * [email-service](./api/services/email-service.ts/index.html), + * [follow-service](./api/services/follow-service.ts/index.html), + * [invite-service](./api/services/invite-service.ts/index.html), + * [notification-service](./api/services/notification-service.ts/index.html), + * [password-reset-service](./api/services/password-reset-service.ts/index.html), + * [playlist-service](./api/services/playlist-service.ts/index.html), + * [rich-content-service](./api/services/rich-content-service.ts/index.html), + * [user-service](./api/services/user-service.ts/index.html), + * [vote-service](./api/services/vote-service.ts/index.html), + * [ws-service](./api/services/ws-service.ts/index.html). + * - **Model** — per table, its row type, guard and mappers: + * [auth](./api/model/auth.ts/index.html), + * [category](./api/model/category.ts/index.html), + * [chat](./api/model/chat.ts/index.html), + * [comment](./api/model/comment.ts/index.html), + * [dump](./api/model/dump.ts/index.html), + * [follow](./api/model/follow.ts/index.html), + * [invite](./api/model/invite.ts/index.html), + * [notification](./api/model/notification.ts/index.html), + * [password-reset](./api/model/password-reset.ts/index.html), + * [playlist](./api/model/playlist.ts/index.html), + * [user](./api/model/user.ts/index.html). + * - **Validators** — the request-body guards: + * [category](./api/validators/category.ts/index.html), + * [comment](./api/validators/comment.ts/index.html), + * [dump](./api/validators/dump.ts/index.html), + * [playlist](./api/validators/playlist.ts/index.html), + * [user](./api/validators/user.ts/index.html). + * - **Remote** — the only place that fetches from other sites: + * [bandcamp-streams](./api/remote/bandcamp-streams.ts/index.html), + * [bandcamp-tralbum](./api/remote/bandcamp-tralbum.ts/index.html), + * [fetch](./api/remote/fetch.ts/index.html), + * [html](./api/remote/html.ts/index.html), + * [image-proxy](./api/remote/image-proxy.ts/index.html), + * [public-address](./api/remote/public-address.ts/index.html), + * [twemoji](./api/remote/twemoji.ts/index.html), + * [youtube-playlist](./api/remote/youtube-playlist.ts/index.html). + * - **Link providers** — the previews of each kind of site: + * [bandcamp](./api/remote/providers/bandcamp.ts/index.html), + * [generic](./api/remote/providers/generic.ts/index.html), + * [provider](./api/remote/providers/provider.ts/index.html), + * [self](./api/remote/providers/self.ts/index.html), + * [soundcloud](./api/remote/providers/soundcloud.ts/index.html), + * [youtube](./api/remote/providers/youtube.ts/index.html). + * - **Lib** — helpers that belong to no layer: + * [api-exception](./api/lib/api-exception.ts/index.html), + * [auth](./api/lib/auth.ts/index.html), + * [canonical-url](./api/lib/canonical-url.ts/index.html), + * [index-html](./api/lib/index-html.ts/index.html), + * [jwt](./api/lib/jwt.ts/index.html), + * [pagination](./api/lib/pagination.ts/index.html), + * [permissions](./api/lib/permissions.ts/index.html), + * [playlist-members](./api/lib/playlist-members.ts/index.html), + * [rate-limit](./api/lib/rate-limit.ts/index.html), + * [reserved-slugs](./api/lib/reserved-slugs.ts/index.html), + * [self-url](./api/lib/self-url.ts/index.html), + * [serve-file](./api/lib/serve-file.ts/index.html), + * [site-icons](./api/lib/site-icons.ts/index.html), + * [slugify](./api/lib/slugify.ts/index.html), + * [static](./api/lib/static.ts/index.html), + * [upload](./api/lib/upload.ts/index.html). + * - **Database** — the handle, its creation, migration and startup + * housekeeping: [connection](./api/db/connection.ts/index.html), + * [init](./api/db/init.ts/index.html), + * [migrate](./api/db/migrate.ts/index.html), + * [startup](./api/db/startup.ts/index.html). + * - **Migrations** — applied in order, each once: + * [0001_comment_likes](./api/db/migrations/0001_comment_likes.ts/index.html), + * [0002_dump_thumbnail](./api/db/migrations/0002_dump_thumbnail.ts/index.html), + * [0003_dump_backlinks](./api/db/migrations/0003_dump_backlinks.ts/index.html), + * [0004_user_roles](./api/db/migrations/0004_user_roles.ts/index.html), + * [0005_drop_is_admin](./api/db/migrations/0005_drop_is_admin.ts/index.html), + * [0006_categories](./api/db/migrations/0006_categories.ts/index.html), + * [0007_password_reset_tokens](./api/db/migrations/0007_password_reset_tokens.ts/index.html), + * [0008_chat_messages](./api/db/migrations/0008_chat_messages.ts/index.html), + * [0009_chat_reply](./api/db/migrations/0009_chat_reply.ts/index.html), + * [0010_youtube_embed_start](./api/db/migrations/0010_youtube_embed_start.ts/index.html), + * [0011_split_favicon_thumbnail](./api/db/migrations/0011_split_favicon_thumbnail.ts/index.html), + * [0012_fix_favicon_reclassification](./api/db/migrations/0012_fix_favicon_reclassification.ts/index.html), + * [0013_dump_url_canonical](./api/db/migrations/0013_dump_url_canonical.ts/index.html), + * [0014_chat_read_state](./api/db/migrations/0014_chat_read_state.ts/index.html), + * [0015_token_revocation](./api/db/migrations/0015_token_revocation.ts/index.html), + * [0016_performance_indexes](./api/db/migrations/0016_performance_indexes.ts/index.html). + * - **Settings and scripts**: [config](./api/config.ts/index.html), + * [generate-icons](./api/scripts/generate-icons.ts/index.html). + * + * ## Shared — `shared/` + * + * The types and constants both halves use: the API envelope, each domain's + * types and request bodies, the socket protocol, the validation limits and the + * role permissions. It imports nothing from either half. Its modules aren't + * documented yet. + * + * Modules: [api](./shared/api.ts/index.html), + * [category](./shared/category.ts/index.html), + * [chat](./shared/chat.ts/index.html), + * [comment](./shared/comment.ts/index.html), + * [dump](./shared/dump.ts/index.html), [follow](./shared/follow.ts/index.html), + * [mention](./shared/mention.ts/index.html), + * [notification](./shared/notification.ts/index.html), + * [permissions](./shared/permissions.ts/index.html), + * [player](./shared/player.ts/index.html), + * [playlist](./shared/playlist.ts/index.html), + * [search](./shared/search.ts/index.html), [user](./shared/user.ts/index.html), + * [validation](./shared/validation.ts/index.html), + * [ws](./shared/ws.ts/index.html). + * + * ## Frontend — `src/` + * + * The React app, built with Vite. Not in this reference yet. + * + * @module + */ diff --git a/src/components/playlist/PlaylistCard.tsx b/src/components/playlist/PlaylistCard.tsx index 4cb2552a..30f5ff3b 100644 --- a/src/components/playlist/PlaylistCard.tsx +++ b/src/components/playlist/PlaylistCard.tsx @@ -4,6 +4,7 @@ import { Plural, Trans } from "@lingui/react/macro"; import type { Playlist } from "../../../shared/playlist.ts"; import { relativeTime } from "../../utils/shared/relativeTime.ts"; import { playlistImageUrl, playlistUrl } from "../../utils/shared/urls.ts"; +import { useAuth } from "../../hooks/auth/useAuth.ts"; import { isPlaylistVisited, isRecent, @@ -21,6 +22,7 @@ export function PlaylistCard( { playlist, onDelete, isOwner }: PlaylistCardProps, ) { const navigate = useNavigate(); + const { mediaToken } = useAuth(); const unread = !isOwner && isRecent(playlist.createdAt) && !isPlaylistVisited(playlist.id); @@ -39,7 +41,7 @@ export function PlaylistCard( {playlist.imageMime ? ( (); const navigate = useNavigate(); - const { user, authFetch, token } = useAuth(); + const { user, authFetch, token, mediaToken } = useAuth(); const { voteCounts, myVotes, @@ -635,7 +635,7 @@ export function PlaylistDetail() { <> {playlist.imageMime && ( @@ -818,7 +818,7 @@ interface EditValues { function PlaylistEditForm( { playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps, ) { - const { authFetch } = useAuth(); + const { authFetch, mediaToken } = useAuth(); const [imagePreview, setImagePreview] = useState(null); const form = useApiForm({ defaultValues: { @@ -884,7 +884,9 @@ function PlaylistEditForm( render={({ field }) => ( { expect(playlistUrl({ id: "2", slug: "mix" })).toBe("/playlists/mix"); expect(playlistUrl({ id: "2" })).toBe("/playlists/2"); }); + test("playlistImageUrl carries the token for a private playlist only", () => { + const updatedAt = new Date(9000); + expect(playlistImageUrl({ id: "p", updatedAt, isPublic: true }, "tk")) + .toBe(`${API_URL}/api/playlists/p/image?v=9000`); + expect(playlistImageUrl({ id: "p", updatedAt, isPublic: false }, "tk")) + .toBe(`${API_URL}/api/playlists/p/image?v=9000&token=tk`); + expect(playlistImageUrl({ id: "p", isPublic: false }, null)) + .toBe(`${API_URL}/api/playlists/p/image`); + }); }); diff --git a/src/utils/shared/urls.ts b/src/utils/shared/urls.ts index 13e21cb4..d1c288f1 100644 --- a/src/utils/shared/urls.ts +++ b/src/utils/shared/urls.ts @@ -67,10 +67,20 @@ export function playlistUrl(playlist: { id: string; slug?: string }): string { } /** A playlist's cover. Versioned on `updatedAt`, which a new cover bumps, so a - * replaced image isn't served from the browser's cache. */ + * replaced image isn't served from the browser's cache. A private playlist's + * cover carries the viewer's token (see {@link dumpFileUrl}); a public one + * doesn't, so its URL is the same for everyone. */ export function playlistImageUrl( - playlist: { id: string; updatedAt?: Date }, + playlist: { id: string; updatedAt?: Date; isPublic?: boolean }, + token?: string | null, ): string { - const v = playlist.updatedAt ? `?v=${playlist.updatedAt.getTime()}` : ""; - return `${API_URL}/api/playlists/${playlist.id}/image${v}`; + const params = [ + ...(playlist.updatedAt ? [`v=${playlist.updatedAt.getTime()}`] : []), + ...(token && playlist.isPublic === false + ? [`token=${encodeURIComponent(token)}`] + : []), + ]; + return `${API_URL}/api/playlists/${playlist.id}/image${ + params.length ? `?${params.join("&")}` : "" + }`; }