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
+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 },
)
})