v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 21:07:34 +00:00
parent 1702370447
commit d62e4a9883
119 changed files with 2967 additions and 611 deletions

View File

@@ -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/<owner>/<repo>/).
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

3
.gitignore vendored
View File

@@ -178,3 +178,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# deno task doc
docs/

View File

@@ -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`,

View File

@@ -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<string> = 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<string> = 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,

View File

@@ -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

View File

@@ -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";

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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(

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<void> {
// Purge expired/used password reset tokens

View File

@@ -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;

View File

@@ -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<string | null> {
}
/**
* 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<string | null> {
* throws 401, so a stale client session surfaces as an auth error it can react
* to, instead of being silently downgraded to anonymous (which yields a
* misleading 404 on private resources).
*
* @throws {APIException} 401 when a token is present but fails verification.
*/
export async function parseOptionalAuth(ctx: Context): Promise<string | null> {
const authHeader = ctx.request.headers.get("Authorization");

View File

@@ -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 {

38
api/lib/index-html.ts Normal file
View File

@@ -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<string, { mtime: number; html: string }>();
/**
* Returns `${root}/index.html` with its placeholders filled.
*
* @throws {Error} When the file can't be read.
*/
export async function loadIndexHtml(root: string): Promise<string> {
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;
}

View File

@@ -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<string> {
@@ -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<PasswordResetPayload | null> {
@@ -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<AuthPayload, "exp">,
): Promise<string> {
@@ -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<string> {
return await new SignJWT({ purpose: "media", userId })
.setProtectedHeader({ alg: "HS256" })
@@ -68,6 +87,10 @@ export async function createMediaToken(userId: string): Promise<string> {
.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<AuthPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_KEY);
@@ -97,6 +124,7 @@ export async function verifyJWT(token: string): Promise<AuthPayload | null> {
}
}
/** Hashes `password` with scrypt and a random salt, as `hash.salt` in hex. */
export function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16).toString("hex");
@@ -108,6 +136,7 @@ export function hashPassword(password: string): Promise<string> {
});
}
/** Checks `password` against a hash made by {@link hashPassword}. */
export function verifyPassword(
password: string,
storedHash: string,

View File

@@ -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<T> {
/** The page's items, in order. */
items: T[];
/** Whether another page follows this one. */
hasMore: boolean;
}

View File

@@ -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);
}

View File

@@ -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.

View File

@@ -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<string, Bucket>();
/** 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 };

View File

@@ -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);
}

View File

@@ -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()) &&

View File

@@ -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;

View File

@@ -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-<size>x<size>.png` (16–512),
* Generates `favicon.svg`, `favicon.ico`, `favicon-<size>x<size>.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,

View File

@@ -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)}`;
}

View File

@@ -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<string, { mtime: number; html: string }>();
async function loadIndexHtml(root: string): Promise<string> {
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<Record<string, object>>,
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<Record<string, object>>, next: Next) => Promise<void> {
return async (context: Context<Record<string, object>>, next: Next) => {
const pathname = context.request.url.pathname;

View File

@@ -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,

View File

@@ -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 });

View File

@@ -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<R> = RouteParams<R>,
> = RouterContext<R, P, AuthState>;
/**
* 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<R extends string>(
ctx: AuthContext<R>,
next: Next,
@@ -51,6 +68,11 @@ export async function authMiddleware<R extends string>(
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<R extends string>(
ctx: AuthContext<R>,
next: Next,
@@ -66,9 +88,15 @@ export async function adminOnlyMiddleware<R extends string>(
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,
): <R extends string>(ctx: AuthContext<R>, next: Next) => Promise<void> {
return async <R extends string>(
ctx: AuthContext<R>,
next: Next,

View File

@@ -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<void> {
try {
await next();
} catch (err) {

View File

@@ -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("</head>", ` ${buildTags(meta)}\n </head>`);
}
let cachedHtml: string | null | undefined; // undefined = not yet loaded, null = not found
async function loadIndexHtml(): Promise<string | null> {
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<string | null> {
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 `/~/<tab>` suffix so linkable profile tabs keep OG meta.
/** A profile, with an optional `/~/<tab>` 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<unknown> {
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");

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<string, Tralbum>();
/** 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,

View File

@@ -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 `&amp;` before `&quot;`, 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);

View File

@@ -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 <head>,
* The most bytes of a fetched page that are read. Metadata lives in the <head>,
* 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);

View File

@@ -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(/&amp;/gi, "&")
@@ -13,8 +21,8 @@ function decodeHtmlEntities(str: string): string {
}
/**
* Find the first `<meta>` 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 `<meta>` 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 `<meta property="og:<tag>">`. */
export function extractOgTag(
html: string,
tag: string,
@@ -43,7 +52,10 @@ export function extractOgTag(
return extractMetaContent(html, "property", `og:${tag}`);
}
/** Extract content from `<meta name="…" content="…">` (both attribute orderings). */
/**
* Returns the content of `<meta name="…" content="…">` (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 `<title>` 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,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>;
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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)) {

View File

@@ -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,

View File

@@ -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> {

View File

@@ -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") ?? "";

View File

@@ -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;

View File

@@ -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";

View File

@@ -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[]> = {

View File

@@ -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");

View File

@@ -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");

View File

@@ -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 {

View File

@@ -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());
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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)) {

View File

@@ -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)) {

View File

@@ -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;

View File

@@ -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>,

View File

@@ -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);

View File

@@ -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;

View File

@@ -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> = {

View File

@@ -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";

View File

@@ -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.
*/

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);

View File

@@ -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> {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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,

View File

@@ -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,

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