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
+247
View File
@@ -0,0 +1,247 @@
// Die Schnittstelle fuer das Dashboard.
//
// Eine Regel zieht sich durch alles hier: JEDE Antwort sagt, was passiert ist
// — auch die erfolgreiche. Bei Fivemanage hat genau das gefehlt, und das
// Ergebnis waren neunzehn gleichnamige Organisationen, weil ein Knopf ohne
// Rueckmeldung wie ein kaputter Knopf aussieht.
import { Hono } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { config } from '../config.js'
import { db, now, pruneSessions, type Media, type Token, type User } from '../db.js'
import {
SESSION_COOKIE,
createSession,
destroySession,
generateToken,
hashPassword,
tokenHash,
userForSession,
verifyPassword,
} from '../auth.js'
import { checkPath, deleteFile, publicUrlFor, PathError } from '../storage.js'
type Vars = { user: User }
export const dashRoutes = new Hono<{ Variables: Vars }>()
// ---------------------------------------------------------------- Anmeldung
dashRoutes.post('/auth/login', async (c) => {
const { username, password } = await c.req.json<{
username?: string
password?: string
}>()
if (!username || !password) {
return c.json({ error: 'Benutzername und Passwort noetig' }, 400)
}
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as
| User
| undefined
// Bewusst dieselbe Meldung fuer "kein solcher Benutzer" und "falsches
// Passwort": alles andere verraet, welche Namen es gibt.
if (!user || !verifyPassword(password, user.password_hash)) {
return c.json({ error: 'Benutzername oder Passwort stimmt nicht' }, 401)
}
const session = createSession(user.id)
setCookie(c, SESSION_COOKIE, session.id, {
httpOnly: true,
secure: !config.dev,
sameSite: 'Lax',
path: '/',
expires: new Date(session.expiresAt),
})
return c.json({ user: { id: user.id, username: user.username } })
})
dashRoutes.post('/auth/logout', (c) => {
const sid = getCookie(c, SESSION_COOKIE)
if (sid) destroySession(sid)
deleteCookie(c, SESSION_COOKIE, { path: '/' })
return c.json({ ok: true })
})
dashRoutes.get('/auth/me', (c) => {
const user = userForSession(getCookie(c, SESSION_COOKIE))
if (!user) return c.json({ error: 'nicht angemeldet' }, 401)
return c.json({ user: { id: user.id, username: user.username } })
})
// Ab hier gilt: angemeldet oder nichts.
dashRoutes.use('*', async (c, next) => {
const user = userForSession(getCookie(c, SESSION_COOKIE))
if (!user) return c.json({ error: 'nicht angemeldet' }, 401)
c.set('user', user)
await next()
})
dashRoutes.post('/auth/password', async (c) => {
const { current, next: fresh } = await c.req.json<{
current?: string
next?: string
}>()
const user = c.get('user')
if (!current || !fresh) return c.json({ error: 'beide Passwoerter noetig' }, 400)
if (fresh.length < 8) return c.json({ error: 'mindestens 8 Zeichen' }, 400)
if (!verifyPassword(current, user.password_hash)) {
return c.json({ error: 'aktuelles Passwort stimmt nicht' }, 401)
}
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(
hashPassword(fresh),
user.id,
)
// Alle anderen Sitzungen beenden — ein Passwortwechsel, der alte
// Anmeldungen weiterlaufen laesst, ist keiner.
db.prepare('DELETE FROM sessions WHERE user_id = ? AND id != ?').run(
user.id,
getCookie(c, SESSION_COOKIE),
)
return c.json({ ok: true })
})
// ------------------------------------------------------------------- Medien
dashRoutes.get('/media', (c) => {
const query = c.req.query('query')?.trim() ?? ''
const limit = Math.min(Number(c.req.query('limit') ?? 60) || 60, 200)
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
const where = query ? 'WHERE path LIKE ?' : ''
const params = query ? [`%${query}%`] : []
const total = db.prepare(`SELECT COUNT(*) AS n FROM media ${where}`).get(...params) as {
n: number
}
const rows = db
.prepare(
`SELECT * FROM media ${where} ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
)
.all(...params, limit, offset) as Media[]
return c.json({
total: total.n,
limit,
offset,
items: rows.map((row) => ({ ...row, url: publicUrlFor(row.path) })),
})
})
dashRoutes.delete('/media/:id', async (c) => {
const id = Number(c.req.param('id'))
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined
if (!row) return c.json({ error: 'nicht gefunden' }, 404)
await deleteFile(row.path)
db.prepare('DELETE FROM media WHERE id = ?').run(id)
return c.json({ deleted: row.path })
})
/** Mehrere auf einmal — bei 900 Fahrzeugbildern will niemand 900 Mal klicken. */
dashRoutes.post('/media/delete', async (c) => {
const { ids } = await c.req.json<{ ids?: number[] }>()
if (!Array.isArray(ids) || ids.length === 0) {
return c.json({ error: 'keine Auswahl' }, 400)
}
const select = db.prepare('SELECT * FROM media WHERE id = ?')
const remove = db.prepare('DELETE FROM media WHERE id = ?')
const deleted: string[] = []
for (const id of ids) {
const row = select.get(id) as Media | undefined
if (!row) continue
await deleteFile(row.path)
remove.run(id)
deleted.push(row.path)
}
return c.json({ deleted, count: deleted.length })
})
dashRoutes.get('/stats', (c) => {
const media = db
.prepare('SELECT COUNT(*) AS files, COALESCE(SUM(size), 0) AS bytes FROM media')
.get() as { files: number; bytes: number }
const tokens = db.prepare('SELECT COUNT(*) AS n FROM tokens').get() as { n: number }
// Die groessten Ordner — die erste Frage bei "wo ist mein Platz hin".
const folders = db
.prepare(
`SELECT
CASE WHEN instr(path, '/') > 0
THEN substr(path, 1, instr(path, '/') - 1)
ELSE '(Wurzel)' END AS folder,
COUNT(*) AS files,
SUM(size) AS bytes
FROM media GROUP BY folder ORDER BY bytes DESC LIMIT 10`,
)
.all() as { folder: string; files: number; bytes: number }[]
return c.json({ ...media, tokens: tokens.n, folders, publicUrl: config.publicUrl })
})
// ------------------------------------------------------------------- Tokens
dashRoutes.get('/tokens', (c) => {
const rows = db
.prepare('SELECT id, name, prefix, can_delete, created_at, last_used_at FROM tokens ORDER BY created_at DESC')
.all() as Omit<Token, 'hash'>[]
return c.json({ items: rows })
})
dashRoutes.post('/tokens', async (c) => {
const { name, prefix, canDelete } = await c.req.json<{
name?: string
prefix?: string
canDelete?: boolean
}>()
if (!name?.trim()) return c.json({ error: 'Name fehlt' }, 400)
// Das Praefix ist ein Pfadanfang und wird nach denselben Regeln geprueft.
let cleanPrefix = ''
if (prefix?.trim()) {
try {
cleanPrefix = checkPath(prefix.trim().replace(/\/+$/, ''))
} catch (err) {
if (err instanceof PathError) return c.json({ error: err.message }, 400)
throw err
}
}
const token = generateToken()
const info = db
.prepare(
`INSERT INTO tokens (name, hash, prefix, can_delete, created_at)
VALUES (?, ?, ?, ?, ?)`,
)
.run(name.trim(), tokenHash(token), cleanPrefix, canDelete ? 1 : 0, now())
// Der Klartext geht genau EINMAL raus. Danach steht nur noch der Hash in
// der Datenbank, und auch wir koennen ihn nicht mehr zeigen.
return c.json({
id: Number(info.lastInsertRowid),
name: name.trim(),
prefix: cleanPrefix,
canDelete: Boolean(canDelete),
token,
})
})
dashRoutes.delete('/tokens/:id', (c) => {
const info = db.prepare('DELETE FROM tokens WHERE id = ?').run(Number(c.req.param('id')))
if (info.changes === 0) return c.json({ error: 'nicht gefunden' }, 404)
return c.json({ ok: true })
})
dashRoutes.post('/maintenance/prune-sessions', (c) => {
pruneSessions()
return c.json({ ok: true })
})