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
+123
View File
@@ -0,0 +1,123 @@
// Anmeldung, Sitzungen und Tokens.
//
// Ohne Bibliothek, aber nicht selbstgestrickt: scrypt und timingSafeEqual
// kommen aus node:crypto. Was hier selbst geschrieben ist, ist die Verdrahtung
// — nicht die Kryptografie.
import {
randomBytes,
scryptSync,
timingSafeEqual,
createHash,
} from 'node:crypto'
import { db, now, type Token, type User } from './db.js'
import { config } from './config.js'
const SCRYPT_N = 16384 // ~100 ms je Pruefung, spuerbar fuer einen Angreifer
const KEY_LENGTH = 64
/** Format: scrypt$<N>$<salt-hex>$<hash-hex>. Das Verfahren steht im String,
* damit spaetere Parameter alte Passwoerter nicht ungueltig machen. */
export function hashPassword(password: string): string {
const salt = randomBytes(16)
const key = scryptSync(password, salt, KEY_LENGTH, { N: SCRYPT_N })
return `scrypt$${SCRYPT_N}$${salt.toString('hex')}$${key.toString('hex')}`
}
export function verifyPassword(password: string, stored: string): boolean {
const [scheme, n, saltHex, keyHex] = stored.split('$')
if (scheme !== 'scrypt' || !n || !saltHex || !keyHex) return false
const key = scryptSync(password, Buffer.from(saltHex, 'hex'), KEY_LENGTH, {
N: Number(n),
})
const expected = Buffer.from(keyHex, 'hex')
// Laengen muessen vor timingSafeEqual stimmen, sonst wirft es.
if (key.length !== expected.length) return false
return timingSafeEqual(key, expected)
}
// ---------------------------------------------------------------- Sitzungen
export const SESSION_COOKIE = 'sid'
export function createSession(userId: number): { id: string; expiresAt: number } {
const id = randomBytes(32).toString('base64url')
const expiresAt = now() + config.sessionDays * 86_400_000
db.prepare(
'INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)',
).run(id, userId, now(), expiresAt)
return { id, expiresAt }
}
export function userForSession(sessionId: string | undefined): User | null {
if (!sessionId) return null
const row = db
.prepare(
`SELECT u.* FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.expires_at > ?`,
)
.get(sessionId, now()) as User | undefined
return row ?? null
}
export function destroySession(sessionId: string) {
db.prepare('DELETE FROM sessions WHERE id = ?').run(sessionId)
}
// ------------------------------------------------------------------- Tokens
/** Der Klartext-Token. Traegt ein Praefix, damit er in einem Log oder einer
* Konfigurationsdatei sofort als solcher zu erkennen ist. */
export function generateToken(): string {
return `d4rk_${randomBytes(24).toString('base64url')}`
}
export function tokenHash(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
/** Den Token zu einem Authorization-Kopf finden.
*
* Gesucht wird ueber den HASH, nicht ueber den Klartext — in der Datenbank
* steht nichts, womit jemand hochladen koennte, der sie in die Haende
* bekommt. */
export function tokenFromHeader(header: string | undefined): Token | null {
if (!header) return null
const match = /^Bearer\s+(.+)$/i.exec(header.trim())
if (!match?.[1]) return null
const row = db
.prepare('SELECT * FROM tokens WHERE hash = ?')
.get(tokenHash(match[1])) as Token | undefined
if (!row) return null
db.prepare('UPDATE tokens SET last_used_at = ? WHERE id = ?').run(now(), row.id)
return row
}
/** Darf dieser Token auf diesen Pfad schreiben? */
export function tokenAllows(token: Token, path: string): boolean {
if (!token.prefix) return true
const prefix = token.prefix.replace(/\/+$/, '') + '/'
return path.startsWith(prefix)
}
// -------------------------------------------------------------------- Start
/** Den ersten Benutzer anlegen, falls noch keiner da ist.
*
* Nur DANN — ein geaendertes ADMIN_PASSWORD setzt ein bestehendes Passwort
* nicht zurueck. Wer sich ausgesperrt hat, loescht die Zeile in der Datenbank
* und startet neu. */
export function ensureAdmin() {
const count = db.prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }
if (count.n > 0) return
db.prepare(
'INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?)',
).run(config.adminUsername, hashPassword(config.adminPassword), now())
console.log(`[auth] Benutzer "${config.adminUsername}" angelegt.`)
}