feat: eigener Medien-Dienst statt Fivemanage Lite — Server-Grundgeruest

Ein Container statt vier: Hono, SQLite, Dateien auf einer Platte. Kein
PostgreSQL, kein MinIO, kein ClickHouse. Das ist nicht die kleine Loesung,
sondern die, die man nicht pflegt — eine Sicherung ist ein cp -a.

Der Ausloeser war nicht der Funktionsumfang von Lite, sondern dessen
Zustand. web/src/utils/http-util.ts wirft jeden Fehler als new Error(...),
die Hooks pruefen auf "instanceof ApiError" — damit wird jeder Fehlschlag
lautlos verschluckt. "Create organization" meldet weder Erfolg noch
Misserfolg; das Ergebnis waren 19 gleichnamige Organisationen, die sich auch
nicht loeschen lassen, weil die Route fehlt. Von allem, was Lite darueber
hinaus kann, haben wir nichts gebraucht.

Zwei Hostnamen, ein Prozess: unter FILES_HOST gibt es ausschliesslich
Dateien — kein Dashboard, keine API, nichts anzumelden. Das haelt die
oeffentliche Adresse frei von Angriffsflaeche und die URLs huebsch, ohne
/f/-Praefix. Die Vorlage im Handy bleibt {model}.webp.

Der Pfad ist der Schluessel: X-Path legt die Datei genau dort ab, wo der
Aufrufer sie erwartet. Bei Lite haetten wir die zurueckgegebenen Adressen in
einer urls.json mitschleppen muessen — vorhersagbare URLs sind fuer das
Fotostudio die ganze Voraussetzung.

storage.ts bereinigt Pfade NICHT, es lehnt sie ab. Ein zurechtgebogener Pfad
legt die Datei unter einem anderen Namen ab als dem erwarteten, und das faellt
erst auf, wenn das Bild fehlt.

Typprueft. Noch nicht gestartet, noch nicht getestet — das ist der naechste
Schritt und steht in ROADMAP.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 15:20:48 +02:00
co-authored by Claude Opus 5
parent b1576cefb0
commit e36db8c015
12 changed files with 2333 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
// 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);
`)
export type User = {
id: number
username: string
password_hash: string
created_at: number
}
export type Token = {
id: number
name: string
hash: string
prefix: string
can_delete: number
created_at: number
last_used_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 const now = () => Date.now()
/** 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())
}