v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-29 14:31:14 +00:00
parent 42e0356d21
commit 0d4e344d77
287 changed files with 5679 additions and 5135 deletions

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,17 @@
/**
* Adds comment likes: the `comment_likes` table and the `comments.like_count`
* 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.
* Adds likes on comments, as the `comment_likes` table and a `like_count` on
* each comment that comment-like-service keeps in step, so that listing
* comments doesn't count their likes.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Existing comments start with no likes, and a
* database made from schema.sql already has both.
*/
export function up(db: DatabaseSync): void {
const commentCols = db.prepare(`PRAGMA table_info(comments);`).all() as {
name: string;

View File

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

View File

@@ -1,16 +1,18 @@
/**
* 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.
* Adds `dump_backlinks`, which records the links from a dump's note or
* comments to other dumps, so a dump page can list the dumps related to it.
* backlink-service fills it, and the index on `dumps.url` lets it find the
* dumps an external link was posted as.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. It doesn't scan the notes and comments already
* posted, so their links are recorded only once they are edited.
*/
export function up(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS dump_backlinks (
source_type TEXT NOT NULL,

View File

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

View File

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

View File

@@ -1,16 +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.
* Adds categories, which admins create and posters file their dumps under, as
* the `categories` table and the `dump_categories` join table.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Existing dumps start in no category, and a
* database made from schema.sql already has both tables.
*/
export function up(db: DatabaseSync): void {
const tables = new Set(
(db.prepare(

View File

@@ -1,15 +1,16 @@
/**
* 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.
* Adds `password_reset_tokens`, which holds the single-use tokens that
* password-reset-service emails to users who forgot their password.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. A database made from schema.sql already has the
* table.
*/
export function up(db: DatabaseSync): void {
const tables = new Set(
(db.prepare(

View File

@@ -1,15 +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.
* Adds `chat_messages`, which holds the messages of the site-wide chat.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. A database made from schema.sql already has the
* table.
*/
export function up(db: DatabaseSync): void {
db.exec(`CREATE TABLE IF NOT EXISTS chat_messages (
id TEXT NOT NULL PRIMARY KEY,

View File

@@ -1,18 +1,18 @@
/**
* Adds an optional self-reference so a chat message can reply to another.
* 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.
* Adds `chat_messages.reply_to_id`, so a message can reply to another. It has
* no foreign key on purpose: when the replied-to message is deleted, the id
* stays and chat-service's join reads null, so the chat shows "deleted
* message" where the quote was.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Existing messages reply to nothing, and a
* database made from schema.sql already has the column.
*/
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(chat_messages);`).all() as {
name: string;

View File

@@ -1,25 +1,22 @@
/**
* Backfills the `start` parameter on stored YouTube embed URLs.
* Adds the `start` parameter to the YouTube embed URLs stored before the
* YouTube provider carried it over. `rich_content.embedUrl` is computed when a
* dump is posted, and used to drop the source URL's `t` (`?t=1265`,
* `?t=1h2m3s`), so those videos played from 0:00.
*
* `rich_content.embedUrl` is computed once, at dump creation, and previously
* 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.
* It reads the time from the stored source URL and fetches nothing. It keeps
* its own copy of the parsing, so that it migrates every database alike should
* the provider's change.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Parses `t`/`start` — plain seconds, `90s`, or `1h2m3s` / `20m5s`. */
/**
* Returns the seconds in `url`'s `t` or `start`, which may be plain seconds,
* `90s`, `20m5s` or `1h2m3s`.
*/
function extractStartSeconds(url: string): number | null {
let raw: string | null;
try {
@@ -38,7 +35,10 @@ function extractStartSeconds(url: string): number | null {
Number(hms[3] ?? 0);
}
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. It leaves alone an embed URL that already has a
* `start`, and a row whose `rich_content` doesn't parse.
*/
export function up(db: DatabaseSync): void {
const rows = db.prepare(
`SELECT id, url, rich_content FROM dumps
@@ -55,7 +55,7 @@ export function up(db: DatabaseSync): void {
try {
rich = JSON.parse(row.rich_content);
} catch {
continue; // malformed payload — leave it untouched
continue;
}
if (rich.type !== "youtube" || !rich.embedUrl) continue;

View File

@@ -1,21 +1,14 @@
/**
* Moves favicon-shaped values out of `rich_content.thumbnailUrl` into the new
* `faviconUrl` field.
* Moves site icons out of `rich_content.thumbnailUrl` into the new
* `faviconUrl`. The metadata fetch used to fall back on the page's icon, and
* then on a guessed `/favicon.ico`, so most thumbnails were a 16×16 icon, or a
* 404, that the UI stretched over a 128×72 box. It now stops at real artwork,
* and a missing `thumbnailUrl` means none, which the frontend draws a
* placeholder for. This gives the older rows that meaning.
*
* The extraction cascade used to end at the page's icon and then at a guessed
* `${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.
* It judges the stored URL alone and fetches nothing, so it doesn't fill
* `accentColor`. The frontend derives a hue from the hostname when it is
* missing, and refreshing a dump's metadata fetches the real one.
*
* @module
*/
@@ -23,12 +16,9 @@
import type { DatabaseSync } from "node:sqlite";
/**
* 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
* cover-cropped — mildly wrong, never broken — so chasing them isn't worth the
* extra rules.
* Checks whether a stored thumbnail URL is a site icon. It is loose on
* purpose, since artwork taken for an icon still shows whole on a tinted field,
* only not cropped to fill the box. 0012 fixes the rows it got wrong.
*/
function isIconUrl(raw: string): boolean {
let pathname: string;
@@ -41,7 +31,11 @@ function isIconUrl(raw: string): boolean {
/\.(ico|svg)$/i.test(pathname);
}
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. It leaves alone a row that already has a
* `faviconUrl`, and one whose `rich_content` doesn't parse. 0012 undoes its
* mistakes.
*/
export function up(db: DatabaseSync): void {
const rows = db.prepare(
`SELECT id, rich_content FROM dumps
@@ -58,7 +52,7 @@ export function up(db: DatabaseSync): void {
try {
rich = JSON.parse(row.rich_content);
} catch {
continue; // malformed payload — leave it untouched
continue;
}
if (rich.faviconUrl || !rich.thumbnailUrl) continue;
if (!isIconUrl(rich.thumbnailUrl)) continue;

View File

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

View File

@@ -1,19 +1,12 @@
/**
* Adds `dumps.url_canonical` — the lookup key behind the "already dumped?"
* check on the create form — and backfills it for every existing URL dump.
* Adds `dumps.url_canonical`, the key the create form's "already dumped" hint
* looks a URL up by, and fills it for every URL dump already posted.
*
* Purely local: it re-derives the key from the URL already stored on each row
* 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.
* It fills the key from each stored URL and fetches nothing. Its helpers are a
* copy of lib/canonical-url.ts as it stood when this shipped: importing the
* live one would give two databases migrated at different times keys made by
* different rules. A change to lib/canonical-url.ts therefore needs a new
* migration that fills the keys again.
*
* @module
*/
@@ -98,7 +91,10 @@ function canonicalizeUrl(raw: string): string | null {
return `https://${host}${path}${query ? `?${query}` : ""}${hash}`;
}
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. It fills only the keys still NULL, and leaves
* NULL a URL that isn't http(s).
*/
export function up(db: DatabaseSync): void {
const columns = db.prepare(`PRAGMA table_info(dumps);`).all() as {
name: string;

View File

@@ -1,21 +1,22 @@
/**
* 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.
* Moves the chat's unread state to the server, so that it survives a reload
* and agrees across tabs and devices. `users.chat_read_at` holds the time of
* the newest message the user has seen.
*
* 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.
* It also replaces the index on `created_at` with one on `(created_at, id)`,
* which the history now pages on, so that two messages from the same
* millisecond can't fall between two pages.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Existing users start with every message read,
* instead of the whole history unread. A user created later starts with a
* NULL `chat_read_at`, which chat-service reads as the user's creation time.
*/
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string;

View File

@@ -1,18 +1,17 @@
/**
* 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.
* Adds `users.tokens_valid_after`, so that changing a password ends every
* session opened before. Tokens are stateless JWTs, so lib/auth.ts refuses a
* token issued before that time instead.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. Existing users start with NULL, which revokes no
* token.
*/
export function up(db: DatabaseSync): void {
const cols = db.prepare(`PRAGMA table_info(users);`).all() as {
name: string;

View File

@@ -1,24 +1,21 @@
/**
* 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.
* Adds indexes for the queries that scanned a whole table. The feeds page
* dumps by `created_at`, and a user's dumps and upvotes by `(user_id,
* created_at)`, which replace the indexes on `user_id` alone. Dumps and
* playlists are looked up by slug. Each foreign key with ON DELETE gets an
* index on its child column, which SQLite otherwise scans on every delete of
* a parent row, and the followers of a user or playlist are read on every new
* dump. Mentions look usernames up by `lower(username)`.
*
* @module
*/
import type { DatabaseSync } from "node:sqlite";
/** Applies this migration to `db`. */
/**
* Run once by `runMigrations`. A database made from schema.sql already has
* these indexes.
*/
export function up(db: DatabaseSync): void {
db.exec(`
DROP INDEX IF EXISTS idx_dumps_user;

View File

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