Files
d4rk_media/server/src/db.ts
T
D4rkst3randClaude Opus 5 c9cd8eaa84 feat: Token-Grenzen, Speicher nach Art, Aufbewahrungsbericht, Discord-Meldungen
KEINE TOKEN-ARTEN, SONDERN TOKEN-GRENZEN. Fivemanage hat Token-Typen; wir haben
schon ein Rechtesystem, es steht nur auf einer anderen Achse (Praefix und
Loeschrecht). Was wirklich fehlte, sind die drei Schrauben, die die Frage
beantworten "was passiert, wenn DIESER Token abhandenkommt":

  Groessengrenze   ein Token fuer Fahrzeugbilder braucht keine 64 MB
  erlaubte Arten   nur Bilder, kein Video
  Ablaufdatum      fuer eine Anbindung, die man mal ausprobiert

Alle drei am laufenden Dienst durchgemessen: 600 KB gegen 512 KB -> 413 mit
beiden Zahlen; ein mp4 gegen einen Bild-Token -> 403 mit Art UND MIME im Text;
ausserhalb des Praefix -> 403; abgelaufen -> 401 mit DEM DATUM. "Abgelaufen"
und "unbekannt" auseinanderzuhalten spart eine halbe Stunde Suche nach einem
Tippfehler, den es nicht gibt.

Mehr als die Dienstgrenze zu erlauben wird abgelehnt statt still gekappt -- es
waere eine Behauptung, denn durchgelassen wird ohnehin die schaerfere.

SPEICHER NACH ART: ein Balken fuer die Verhaeltnisse, eine Tabelle fuer die
Zahlen, dazu die groessten Dateien.

AUFBEWAHRUNG VORBEREITET, NICHT GEBAUT. Automatisches Loeschen zu bauen, bevor
man weiss, was da liegt, ist der Weg, wie man Daten verliert. Stattdessen der
Bericht davor: wie viel ist aelter als 7/30/90/365 Tage, was liegt doppelt
(gleicher SHA-256 unter zwei Pfaden) und wie viel Platz das kostet. "Alles
aelter als 90 Tage loeschen" ist eine Behauptung, solange niemand weiss, wie
viel das waere. Diese Seite loescht nichts.

DISCORD-MELDUNGEN -- und der Grund ist nicht Discord, sondern eine Luecke, die
wir heute selbst gebaut haben: die Sicherung laeuft nachts um halb fuenf, und
wenn sie aufhoert zu laufen, merkt es niemand. Der Dienst sieht jetzt
STUENDLICH nach und meldet, wenn der letzte Lauf gescheitert oder aelter als 26
Stunden ist (24 waeren zu knapp, 48 zu spaet). Hoechstens EINMAL je Zustand --
eine Meldung, die stuendlich wiederkommt, wird weggeklickt, und dann auch die,
die zaehlt.

Ein Webhook und keine Bot-Anbindung: eine Adresse, kein Token, keine
Berechtigungen. Die Adresse wird geprueft (nur echte discord.com-Webhooks),
und der Testknopf steht gleich daneben -- ein Webhook, den man eintraegt und
erst in drei Wochen im Fehlerfall ausprobiert, ist einer, der dann nicht geht.

Aus deren Doku mitgenommen und NICHT gebaut: presigned URLs (kurzlebige
signierte Adressen fuer Client-Uploads). Waere der richtige Weg fuer
Spieler-Screenshots -- solange keine Spieler hochladen, ist es Vorrat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:30:03 +02:00

294 lines
11 KiB
TypeScript

// 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())
}