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 })
})
+225
View File
@@ -0,0 +1,225 @@
// Die Schnittstelle fuer Skripte: hochladen und wieder loeschen.
//
// Das ist der Teil, den der FiveM-Server anspricht — deshalb ist er stur
// einfach gehalten. Ein Aufruf, ein Token, eine Antwort mit der fertigen URL.
//
// POST /api/upload
// Authorization: Bearer d4rk_...
// X-Path: vehicles/adder.webp
// X-Encoding: base64
// <Rumpf>
//
// DREI FORMEN FUER DEN RUMPF, und der Grund fuer die dritte ist FiveM:
//
// multipart/form-data Feld "file" — so laedt das Dashboard hoch
// X-Encoding: base64 Base64-Text — so laedt Lua hoch
// sonst rohe Bytes
//
// PerformHttpRequest schickt einen Lua-String; rohe Bytes ueberstehen den Weg
// nicht zuverlaessig. Im Fotostudio kommt das Bild ohnehin als Data-URL aus
// dem NUI, ist also schon Base64 — der Umweg ist damit keiner.
import { Hono } from 'hono'
import type { Context } from 'hono'
import { config } from '../config.js'
import { db, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.js'
import {
PathError,
checkPath,
deleteFile,
extensionFor,
fileExists,
mimeFor,
publicUrlFor,
sha256,
writeFileAtomic,
} from '../storage.js'
type Vars = { token: Token }
export const uploadRoutes = new Hono<{ Variables: Vars }>()
uploadRoutes.use('*', async (c, next) => {
const token = tokenFromHeader(c.req.header('authorization'))
if (!token) {
return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401)
}
c.set('token', token)
await next()
})
/** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */
async function readBody(
c: Context<{ Variables: Vars }>,
): Promise<{ data: Buffer; path?: string; filename?: string }> {
const contentType = c.req.header('content-type') ?? ''
if (contentType.includes('multipart/form-data')) {
const form = await c.req.parseBody()
const file = form['file']
if (!(file instanceof File)) {
throw new Error('Feld "file" fehlt im Formular')
}
return {
data: Buffer.from(await file.arrayBuffer()),
path: typeof form['path'] === 'string' ? form['path'] : undefined,
filename: file.name,
}
}
const raw = Buffer.from(await c.req.arrayBuffer())
if (/base64/i.test(c.req.header('x-encoding') ?? '')) {
// Data-URLs kommen mit Vorspann ("data:image/webp;base64,"). Den
// abzuschneiden ist freundlicher, als den Aufrufer dazu zu zwingen.
const text = raw.toString('utf8').replace(/^data:[^,]*,/, '')
return { data: Buffer.from(text, 'base64') }
}
return { data: raw }
}
uploadRoutes.post('/upload', async (c) => {
const token = c.get('token')
// Erst die angekuendigte Groesse pruefen, damit ein zu grosser Upload
// abgewiesen wird, BEVOR er im Speicher liegt.
const announced = Number(c.req.header('content-length') ?? 0)
if (announced > config.maxUploadBytes) {
return c.json(
{ error: `zu gross: ${announced} > ${config.maxUploadBytes} Bytes` },
413,
)
}
let body: Awaited<ReturnType<typeof readBody>>
try {
body = await readBody(c)
} catch (err) {
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400)
}
const { data } = body
if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400)
if (data.length > config.maxUploadBytes) {
return c.json(
{ error: `zu gross: ${data.length} > ${config.maxUploadBytes} Bytes` },
413,
)
}
const digest = sha256(data)
// Der Pfad: ausdruecklich gewuenscht, oder aus dem Hash gebaut. Der
// gewuenschte hat Vorrang und wird NICHT zurechtgebogen.
const wanted = c.req.header('x-path') ?? body.path ?? body.filename
let path: string
try {
path = wanted
? checkPath(wanted)
: checkPath(
`uploads/${digest.slice(0, 2)}/${digest}.${extensionFor(
c.req.header('x-mime') ?? 'application/octet-stream',
)}`,
)
} catch (err) {
if (err instanceof PathError) return c.json({ error: err.message }, 400)
throw err
}
if (!tokenAllows(token, path)) {
return c.json(
{ error: `dieser Token darf nur unter "${token.prefix}/" schreiben` },
403,
)
}
const existing = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
| Media
| undefined
// Ueberschreiben ist der Normalfall: ein neu aufgenommenes Fahrzeugbild
// soll das alte ersetzen, sonst braeuchte jeder zweite Lauf einen anderen
// Namen. Wer das nicht will, sagt es.
const overwrite = (c.req.header('x-overwrite') ?? 'true').toLowerCase() !== 'false'
if (existing && !overwrite) {
return c.json({ error: 'Pfad ist belegt', path, url: publicUrlFor(path) }, 409)
}
const mime = c.req.header('x-mime') ?? mimeFor(path)
try {
await writeFileAtomic(path, data)
} catch (err) {
console.error('[upload] Schreiben fehlgeschlagen:', err)
return c.json({ error: 'Schreiben fehlgeschlagen' }, 500)
}
const timestamp = now()
if (existing) {
db.prepare(
`UPDATE media SET size = ?, sha256 = ?, mime = ?, token_id = ?, updated_at = ?
WHERE id = ?`,
).run(data.length, digest, mime, token.id, timestamp, existing.id)
} else {
db.prepare(
`INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(path, data.length, digest, mime, token.id, timestamp, timestamp)
}
return c.json({
url: publicUrlFor(path),
path,
size: data.length,
sha256: digest,
mime,
replaced: Boolean(existing),
})
})
/** Loeschen ueber den Pfad — Skripte kennen den Pfad, nicht unsere ID. */
uploadRoutes.delete('/media/*', async (c) => {
const token = c.get('token')
if (!token.can_delete) {
return c.json({ error: 'dieser Token darf nicht loeschen' }, 403)
}
let path: string
try {
path = checkPath(decodeURIComponent(c.req.path.replace(/^\/media\//, '')))
} catch (err) {
if (err instanceof PathError) return c.json({ error: err.message }, 400)
throw err
}
if (!tokenAllows(token, path)) {
return c.json({ error: 'ausserhalb des erlaubten Bereichs' }, 403)
}
if (!(await fileExists(path))) return c.json({ error: 'nicht gefunden', path }, 404)
await deleteFile(path)
db.prepare('DELETE FROM media WHERE path = ?').run(path)
return c.json({ deleted: path })
})
/** Nachsehen, ob es etwas schon gibt — damit ein Lauf "nur fehlende"
* beantworten kann, ohne 900 Bilder hochzuladen. */
uploadRoutes.get('/exists/*', (c) => {
let path: string
try {
path = checkPath(decodeURIComponent(c.req.path.replace(/^\/exists\//, '')))
} catch {
return c.json({ exists: false }, 200)
}
const row = db.prepare('SELECT size, sha256, updated_at FROM media WHERE path = ?').get(
path,
) as Pick<Media, 'size' | 'sha256' | 'updated_at'> | undefined
return c.json(
row
? { exists: true, path, url: publicUrlFor(path), ...row }
: { exists: false, path },
)
})