/** * Deletes what has expired and seeds the first admin, each time connection.ts * opens the database. * * @module */ import type { DatabaseSync } from "node:sqlite"; import { ATTACHMENTS_DIR, ORPHANED_ATTACHMENTS_RETENTION_HOURS, UNUSED_INVITES_RETENTION_DAYS, } from "../config.ts"; import { hashPassword } from "../lib/password.ts"; /** Returns the time `ms` ago as an ISO string, the format dates are stored in. */ function isoAgo(ms: number): string { return new Date(Date.now() - ms).toISOString(); } /** * 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 or whose resource is gone. 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 { // Dates are stored as ISO strings and compared as text, so the cutoffs are // ISO strings too. SQLite's datetime() has a space where ISO has a `T`. db.prepare( `DELETE FROM password_reset_tokens WHERE expires_at < ? OR used_at IS NOT NULL;`, ).run(isoAgo(0)); db.prepare( `DELETE FROM invites WHERE used_at IS NULL AND created_at < ?;`, ).run(isoAgo(UNUSED_INVITES_RETENTION_DAYS * 86_400_000)); // 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 unlinked one is left alone. One whose // dump, comment, playlist, message or user is gone goes whatever its age. const attachmentCutoff = isoAgo( ORPHANED_ATTACHMENTS_RETENTION_HOURS * 3_600_000, ); const ORPHANED = ` (resource_id IS NULL AND created_at < ?) OR (resource_id IS NOT NULL AND NOT CASE resource_type WHEN 'dump' THEN EXISTS (SELECT 1 FROM dumps WHERE id = resource_id) WHEN 'comment' THEN EXISTS (SELECT 1 FROM comments WHERE id = resource_id AND deleted = 0) WHEN 'playlist' THEN EXISTS (SELECT 1 FROM playlists WHERE id = resource_id) WHEN 'chat' THEN EXISTS (SELECT 1 FROM chat_messages WHERE id = resource_id) WHEN 'user' THEN EXISTS (SELECT 1 FROM users WHERE id = resource_id) ELSE 0 END)`; const orphanedAttachments = db.prepare( `SELECT id FROM attachments WHERE ${ORPHANED};`, ).all(attachmentCutoff) as { id: string }[]; if (orphanedAttachments.length > 0) { for (const { id } of orphanedAttachments) { await Deno.remove(`${ATTACHMENTS_DIR}/${id}`).catch(() => {}); } db.prepare(`DELETE FROM attachments WHERE ${ORPHANED};`) .run(attachmentCutoff); } // 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) { db.prepare( `INSERT INTO users (id, username, password_hash, role, created_at, email) VALUES (?, 'admin', ?, 'admin', ?, 'admin@localhost')`, ).run(crypto.randomUUID(), await hashPassword("admin"), isoAgo(0)); console.log( "Created default admin user (username: admin, password: admin)", ); } }