Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
78 lines
3.1 KiB
TypeScript
78 lines
3.1 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
// 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)",
|
|
);
|
|
}
|
|
}
|