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
+82
View File
@@ -0,0 +1,82 @@
// Alle Einstellungen an einer Stelle, alle aus der Umgebung.
//
// Was fehlt, faellt beim START auf und nicht beim ersten Upload. Das ist
// Absicht: ein Dienst, der bereitwillig hochfaehrt und erst Stunden spaeter
// merkt, dass ihm die halbe Konfiguration fehlt, kostet mehr Zeit als einer,
// der sich sofort weigert.
import { existsSync, mkdirSync } from 'node:fs'
import { resolve } from 'node:path'
function required(name: string): string {
const value = process.env[name]
if (!value) {
console.error(`[config] ${name} fehlt. Ohne die Variable startet der Dienst nicht.`)
process.exit(1)
}
return value
}
function optional(name: string, fallback: string): string {
return process.env[name] || fallback
}
function number(name: string, fallback: number): number {
const raw = process.env[name]
if (!raw) return fallback
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) {
console.error(`[config] ${name}="${raw}" ist keine sinnvolle Zahl.`)
process.exit(1)
}
return parsed
}
/** Wohin die Dateien und die Datenbank kommen. EIN Ordner, damit eine
* Sicherung aus genau einem `cp -a` besteht. */
const dataDir = resolve(optional('DATA_DIR', './data'))
const filesDir = resolve(dataDir, 'files')
for (const dir of [dataDir, filesDir]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
}
/** Unter welcher Adresse die Dateien oeffentlich stehen. Daraus wird die URL
* gebaut, die nach dem Upload zurueckkommt — und die im Handy in der Vorlage
* steht. Ohne abschliessenden Schraegstrich. */
const publicUrl = required('PUBLIC_URL').replace(/\/+$/, '')
export const config = {
port: number('PORT', 8080),
dataDir,
filesDir,
dbPath: resolve(dataDir, 'media.db'),
publicUrl,
/** Der Hostname, unter dem NUR Dateien ausgeliefert werden.
*
* Trifft eine Anfrage unter diesem Namen ein, gibt es weder Dashboard
* noch API — nur GET auf Dateien. Das haelt die oeffentliche Adresse frei
* von jeder Angriffsflaeche und macht die URLs zugleich huebsch:
* https://fivecdn.d4rkst3r.de/vehicles/adder.webp statt eines /f/-Praefix.
*
* Leer lassen, wenn alles unter einem Namen laufen soll — dann liegen die
* Dateien unter /f/. */
filesHost: optional('FILES_HOST', new URL(publicUrl).host),
/** Wird beim Start angelegt, falls es noch keinen Benutzer gibt. */
adminPassword: required('ADMIN_PASSWORD'),
adminUsername: optional('ADMIN_USERNAME', 'admin'),
/** Groesste erlaubte Datei. Ein freigestelltes Fahrzeugbild wiegt rund
* 200 KB; 64 MB lassen Luft fuer Videos, ohne dass ein Fehlgriff die
* Platte fuellt. */
maxUploadBytes: number('MAX_UPLOAD_MB', 64) * 1024 * 1024,
/** Wie lange eine Anmeldung im Dashboard haelt. */
sessionDays: number('SESSION_DAYS', 30),
dev: process.env.NODE_ENV !== 'production',
} as const
export type Config = typeof config