// Die Ablage: SQLite, eine Datei neben den Bildern. // // Warum nicht PostgreSQL: weil hier eine Handvoll Tabellen liegt, auf die ein // Server schreibt. SQLite ist dafuer nicht die kleine Loesung, sondern die // angemessene — sie kostet keinen Container, keine Verbindung, kein Passwort, // und eine Sicherung ist ein Dateikopie. Wenn daraus je Millionen Zeilen // werden, tauschen wir dieses Modul aus; die Aufrufer sehen davon nichts. import Database from 'better-sqlite3' import { config } from './config.js' export const db = new Database(config.dbPath) // WAL: Lesen blockiert Schreiben nicht. Bei einem Dashboard, das waehrend // eines laufenden Serienuploads die Galerie zeigt, ist genau das der Fall. db.pragma('journal_mode = WAL') db.pragma('foreign_keys = ON') db.pragma('busy_timeout = 5000') db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS sessions_expires ON sessions(expires_at); CREATE TABLE IF NOT EXISTS tokens ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, -- Nur der Hash. Der Token selbst wird genau einmal gezeigt und ist danach -- auch fuer uns nicht mehr lesbar — wer ihn verliert, legt einen neuen an. hash TEXT NOT NULL UNIQUE, -- Optionale Fessel: dieser Token darf nur unterhalb dieses Praefix -- schreiben. Leer heisst ueberall. prefix TEXT NOT NULL DEFAULT '', can_delete INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, last_used_at INTEGER ); CREATE TABLE IF NOT EXISTS media ( id INTEGER PRIMARY KEY, -- Der Pfad IST der Schluessel. Fuer das Fotostudio ist das der ganze -- Punkt: vehicles/adder.webp bleibt vehicles/adder.webp, damit die -- Vorlage im Handy {model}.webp lauten kann und niemand eine -- Uebersetzungstabelle mitschleppen muss. path TEXT NOT NULL UNIQUE, size INTEGER NOT NULL, sha256 TEXT NOT NULL, mime TEXT NOT NULL, token_id INTEGER REFERENCES tokens(id) ON DELETE SET NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS media_created ON media(created_at DESC); -- Der Verlauf: wer hat wann was abgelegt, ersetzt oder geloescht. -- -- WARUM EINE EIGENE TABELLE und nicht einfach media.created_at. Drei Gruende, -- und jeder allein wuerde reichen: -- -- 1. media kennt nur den JETZIGEN Stand. Was geloescht wurde, ist weg -- -- samt der Frage, wer es geloescht hat. -- 2. Ein Ueberschreiben hebt updated_at an und ueberschreibt damit die -- Auskunft, wann die Datei urspruenglich kam. -- 3. media.token_id ist bei einem Upload aus dem Dashboard NULL: dort hat -- niemand einen Token, sondern eine Sitzung. Das "von wem" stand also -- nirgends. -- -- Das ist NICHT das Logging, das in der ROADMAP abgelehnt wird -- dort geht es -- um Anfrage-Protokolle samt ClickHouse. Hier sind es ein paar Zeilen je -- Upload in derselben SQLite-Datei. CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY, at INTEGER NOT NULL, -- 'upload' | 'replace' | 'delete' kind TEXT NOT NULL, path TEXT NOT NULL, size INTEGER, -- 'token' | 'user' | 'unbekannt' actor_kind TEXT NOT NULL, actor TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS events_at ON events(at DESC); -- Einstellungen, die zur Laufzeit aenderbar sein sollen. -- -- Bewusst NICHT alles: was den Dienst ueberhaupt hochfahren laesst (PUBLIC_URL, -- Port, Datenordner) bleibt in der Umgebung, damit ein falscher Klick den -- Dienst nicht unerreichbar macht. Hier liegt nur, was ohne Neustart -- verstellbar sein soll -- derzeit die Discord-Anmeldung. CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at INTEGER NOT NULL ); `) // Nachtraegliche Spalten. SQLite kann kein "ADD COLUMN IF NOT EXISTS", also // wird nachgesehen — das ist die ganze Wanderung, die dieses Projekt braucht. { const spalten = db.prepare('PRAGMA table_info(users)').all() as { name: string }[] if (!spalten.some((s) => s.name === 'discord_id')) { db.exec('ALTER TABLE users ADD COLUMN discord_id TEXT') // Kein UNIQUE in der Spaltendefinition: SQLite erlaubt das bei ADD // COLUMN nicht. Der Index danach leistet dasselbe. db.exec('CREATE UNIQUE INDEX IF NOT EXISTS users_discord ON users(discord_id)') console.log('[db] Spalte users.discord_id angelegt') } // Der Bildstempel, NICHT die fertige Adresse: Discord baut die URL aus ID // und Stempel zusammen, und das Schema gehoert an eine Stelle (siehe // avatarUrl unten). Eine gespeicherte URL waere beim naechsten Wechsel des // Schemas eine Baustelle in jeder Zeile. // Grenzen je Token. Sie beantworten die Frage, die "Token-Arten" nur // scheinbar beantwortet: was passiert, wenn DIESER Token abhandenkommt. const tSpalten = db.prepare('PRAGMA table_info(tokens)').all() as { name: string }[] if (!tSpalten.some((s) => s.name === 'max_bytes')) { db.exec('ALTER TABLE tokens ADD COLUMN max_bytes INTEGER') // Komma-getrennte Artnamen ('bild,video'). Leer heisst: alles, was der // Dienst kennt. db.exec("ALTER TABLE tokens ADD COLUMN arten TEXT NOT NULL DEFAULT ''") db.exec('ALTER TABLE tokens ADD COLUMN expires_at INTEGER') console.log('[db] Spalten tokens.max_bytes, arten, expires_at angelegt') } const eSpalten = db.prepare('PRAGMA table_info(events)').all() as { name: string }[] if (!eSpalten.some((s) => s.name === 'von')) { // Nur beim Verschieben gefuellt: WOHER die Datei kam. Ohne diese // Spalte stuende im Verlauf nur das Ziel, und die Frage "wo war die // vorher" waere nicht mehr zu beantworten. db.exec('ALTER TABLE events ADD COLUMN von TEXT') console.log('[db] Spalte events.von angelegt') } if (!spalten.some((s) => s.name === 'discord_avatar')) { db.exec('ALTER TABLE users ADD COLUMN discord_avatar TEXT') db.exec('ALTER TABLE users ADD COLUMN discord_name TEXT') console.log('[db] Spalten users.discord_avatar und users.discord_name angelegt') } } // Den Verlauf aus dem vorhandenen Bestand nachtragen -- einmalig, und nur // wenn er leer ist. // // Ohne das stuende der Tab am ersten Tag leer da, obwohl 46 Bilder in der // Ablage liegen; das sieht aus wie ein kaputter Tab. Die nachgetragenen // Eintraege sind ehrlich als solche gekennzeichnet: sie tragen den // urspruenglichen Zeitpunkt, aber ueber Loeschungen und Ersetzungen von // frueher weiss niemand mehr etwas. { const vorhanden = db.prepare('SELECT COUNT(*) AS n FROM events').get() as { n: number } const medien = db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number } if (vorhanden.n === 0 && medien.n > 0) { const nachtragen = db.prepare( `INSERT INTO events (at, kind, path, size, actor_kind, actor) SELECT m.created_at, 'upload', m.path, m.size, CASE WHEN t.name IS NULL THEN 'unbekannt' ELSE 'token' END, COALESCE(t.name, 'vor der Aufzeichnung') FROM media m LEFT JOIN tokens t ON t.id = m.token_id`, ) const info = nachtragen.run() console.log(`[db] Verlauf aus dem Bestand nachgetragen: ${info.changes} Eintraege`) } } export type User = { id: number username: string password_hash: string created_at: number /** Gesetzt, wenn dieses Konto ueber Discord hereinkommt. Ein solches Konto * hat kein brauchbares Passwort — verifyPassword scheitert daran sauber. */ discord_id: string | null /** Der Bildstempel von Discord, nicht die Adresse. Null heisst: kein * eigenes Bild, dann gilt Discords Ersatzbild. */ discord_avatar: string | null discord_name: string | null } /** Die Adresse des Discord-Bildes — an EINER Stelle gebaut. * * Faengt der Stempel mit "a_" an, ist es ein bewegtes Bild und muss als .gif * angefragt werden; sonst kommt ein Standbild. Ohne eigenen Stempel gibt es * Discords Ersatzbild, dessen Nummer sich aus der ID ergibt (die neue * Rechnung mit >> 22, nicht die alte ueber den Diskriminator). * * Null nur, wenn gar keine Discord-ID da ist — dann zeigt die Oberflaeche * Initialen. */ export function avatarUrl( discordId: string | null, stempel: string | null, groesse = 64, ): string | null { if (!discordId) return null if (stempel) { const endung = stempel.startsWith('a_') ? 'gif' : 'png' return `https://cdn.discordapp.com/avatars/${discordId}/${stempel}.${endung}?size=${groesse}` } const nummer = Number((BigInt(discordId) >> 22n) % 6n) return `https://cdn.discordapp.com/embed/avatars/${nummer}.png` } export type Token = { id: number name: string hash: string prefix: string can_delete: number created_at: number last_used_at: number | null /** Groesste erlaubte Datei fuer DIESEN Token. Null heisst: die Grenze des * Dienstes gilt (MAX_UPLOAD_MB). */ max_bytes: number | null /** Erlaubte Medienarten, mit Komma getrennt. Leer heisst: alle. */ arten: string /** Ab wann der Token nicht mehr gilt. Null heisst: unbefristet. */ expires_at: number | null } export type Media = { id: number path: string size: number sha256: string mime: string token_id: number | null created_at: number updated_at: number } export type EventKind = 'upload' | 'replace' | 'delete' | 'move' export type MediaEvent = { id: number at: number kind: EventKind path: string size: number | null actor_kind: 'token' | 'user' | 'unbekannt' actor: string /** Nur bei 'move': der Pfad, unter dem die Datei vorher lag. */ von: string | null } export const now = () => Date.now() const eventEinfuegen = db.prepare( `INSERT INTO events (at, kind, path, size, actor_kind, actor, von) VALUES (?, ?, ?, ?, ?, ?, ?)`, ) /** Einen Eintrag in den Verlauf schreiben. * * Wirft NICHT: ein Verlauf, der einen Upload scheitern laesst, waere die * Buchhaltung, die das Geschaeft verhindert. Wenn hier etwas schiefgeht, * steht es in der Konsole und die Datei liegt trotzdem richtig. */ export function logEvent( kind: EventKind, path: string, size: number | null, actorKind: MediaEvent['actor_kind'], actor: string, von?: string, ): void { try { eventEinfuegen.run(now(), kind, path, size, actorKind, actor, von ?? null) } catch (err) { console.error('[events] konnte nicht schreiben:', err) } } /** Der Verlauf waechst mit jedem Upload. Bei 900 Fahrzeugen je Lauf sind das * 900 Zeilen -- kein Problem, aber auf Dauer auch kein Grund, alles zu * behalten. Ein halbes Jahr reicht fuer die Frage "wer war das". */ export function pruneEvents(tage = 180) { db.prepare('DELETE FROM events WHERE at < ?').run(now() - tage * 86_400_000) } /** Abgelaufene Sitzungen wegraeumen. Beim Start und danach stuendlich — * sonst waechst die Tabelle ewig, und niemand merkt es. */ export function pruneSessions() { db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(now()) }