// 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 // // // 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, Next } from 'hono' import { config } from '../config.js' import { FehlendeFehler, fehlendeRechnen } from '../fehlende.js' import { meldeUpload } from '../meldung.js' import { db, logEvent, now, type Media, type Token } from '../db.js' import { fesselText, tokenAllows, tokenFromHeader, tokenPrefixes } from '../auth.js' import { PathError, checkPath, deleteFile, extensionFor, fileExists, kannBewegtVorschau, vielleichtKleiner, kannVorschau, lohntWebp, mimeFor, publicUrlFor, sha256, writeFileAtomic, writeBewegtThumb, writeThumb, writeWebp, } from '../storage.js' type Vars = { token: Token } export const uploadRoutes = new Hono<{ Variables: Vars }>() // Die Token-Pruefung haengt an den drei eigenen Pfaden und NICHT an '*'. // // Mit '*' galt sie fuer alles unterhalb des Einhaengepunkts — auch fuer // /api/dash/*, das daneben liegt. Das Dashboard bekam dann "Token fehlt oder // ist unbekannt" auf die Anmeldung, obwohl es nie einen Token haben kann. const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => { const token = tokenFromHeader(c.req.header('authorization')) if (!token) { return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401) } // Abgelaufen ist etwas anderes als unbekannt, und die Meldung sagt es: // sonst sucht jemand eine halbe Stunde nach einem Tippfehler im Token. if (token.expires_at && token.expires_at < now()) { return c.json( { error: `Dieser Token ist am ${new Date(token.expires_at).toLocaleDateString('de-DE')} abgelaufen.`, }, 401, ) } // Ein PRIVATER Token kommt an keinen oeffentlichen Weg (d4rk-rp-planung#227): // sonst laege ein Polizeifoto nach einem falschen Aufruf unter /f/, fuer // jeden lesbar, der die Adresse kennt. if (token.privat) { return c.json({ error: PRIVAT_NUR_DORT }, 403) } c.set('token', token) await next() } /** Die Antwort an einen privaten Token auf einem oeffentlichen Weg. Auch von * fivemanage.ts benutzt -- EIN Satz fuer beide Wachen. */ export const PRIVAT_NUR_DORT = 'Dieser Token gilt nur fuer den privaten Bereich (/api/privat) und kann hier nicht hochladen.' /** Prueft das Gesamtkontingent. Gibt einen SATZ zurueck, wenn es reisst. * * Die Meldung nennt Zahlen: "belegt 1,9 GB von 2 GB, diese Datei braucht 130 * KB" beantwortet die Frage, die man danach hat. Ein blosses "Kontingent * erschoepft" beantwortet sie nicht. * * Ein ERSETZEN zaehlt hier grosszuegig als voller Neuzugang: der genaue Wert * waere (neu - alt), aber dafuer muesste hier schon feststehen, unter welchem * Pfad die Datei landet -- und das steht erst nach dem Lesen des Rumpfes fest. * Die Ungenauigkeit geht zugunsten der Grenze, nicht darueber hinaus. */ export function kontingentUeberschritten(token: Token, kommt: number): string | null { if (!token.quota_bytes) return null const belegt = ( db .prepare('SELECT COALESCE(SUM(size),0) AS b FROM media WHERE token_id = ?') .get(token.id) as { b: number } ).b if (belegt + kommt <= token.quota_bytes) return null return ( `Kontingent erschoepft: dieser Token haelt ${groesse(belegt)} von ` + `${groesse(token.quota_bytes)}, und diese Datei braucht ${groesse(kommt)}.` ) } /** Bytes lesbar -- mit MITWACHSENDER Einheit. * * Erst stand hier ein festes "MB", und die Meldung lautete daraufhin * "haelt 0.0 MB von 0.0 MB, und diese Datei braucht 0.0 MB". Formal richtig, * praktisch eine Meldung, die genau die Frage nicht beantwortet, wegen der man * sie liest. Bei einem Kontingent von 20 KB muss dort KB stehen. * * 1024 und nicht 1000: dieselbe Rechnung wie in der Oberflaeche, sonst nennt * der Dienst eine andere Zahl als das Dashboard daneben. */ function groesse(n: number): string { if (n < 1024) return `${n} B` const einheiten = ['KB', 'MB', 'GB', 'TB'] let wert = n / 1024 let i = 0 while (wert >= 1024 && i < einheiten.length - 1) { wert /= 1024 i++ } // Eine Nachkommastelle nur, wo sie etwas sagt: "1,5 GB" ja, "512,0 KB" nein. return `${wert < 10 ? wert.toFixed(1) : Math.round(wert)} ${einheiten[i]}` } /** Welche Medienart ist das? Dieselben Namen wie im Dashboard. * * An EINER Stelle, damit die Einordnung beim Hochladen und die Anzeige in der * Galerie nicht auseinanderlaufen koennen. */ export function artVon(mime: string): string { const m = mime.split(';')[0]?.trim().toLowerCase() ?? '' if (m.startsWith('image/')) return 'bild' if (m.startsWith('video/')) return 'video' if (m.startsWith('audio/')) return 'ton' if (m === 'application/pdf' || m.startsWith('text/') || m.startsWith('application/json')) return 'dokument' return 'andere' } uploadRoutes.use('/upload', requireToken) uploadRoutes.use('/media/*', requireToken) // BEIDE Formen hinter der Wache. Die Abfrageform steht als eigener Eintrag da: // '/exists/*' trifft sie NICHT, weil dort kein Schraegstrich mehr folgt -- und // eine Auskunft ohne Token waere genau das Loch, das die Abfrageform schliessen // soll. uploadRoutes.use('/zugang', requireToken) uploadRoutes.use('/fehlende', requireToken) uploadRoutes.use('/liste', requireToken) uploadRoutes.use('/exists', requireToken) uploadRoutes.use('/exists/*', requireToken) /** Den Rumpf einsammeln, in welcher der drei Formen er auch kommt. */ export 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. // Die schaerfere der beiden Grenzen gilt: die des Dienstes und die dieses // Tokens. Ein Token fuer Fahrzeugbilder braucht keine 64 MB. const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER) const announced = Number(c.req.header('content-length') ?? 0) if (announced > grenze) { return c.json({ error: `zu gross: ${announced} > ${grenze} Bytes` }, 413) } // DAS GESAMTKONTINGENT, geprueft BEVOR der Rumpf im Speicher liegt. // // max_bytes daneben gilt je Datei -- ein Token mit "hoechstens 2 MB je // Bild" konnte die Platte trotzdem fuellen, es brauchte nur genug Bilder. // Gezaehlt wird ueber media.token_id, also ueber das, was WIRKLICH liegt, // und nicht ueber einen mitlaufenden Zaehler, der auseinanderlaufen kann. const zuViel = kontingentUeberschritten(token, announced) if (zuViel) return c.json({ error: zuViel }, 413) let body: Awaited> 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 > grenze) { return c.json({ error: `zu gross: ${data.length} > ${grenze} Bytes` }, 413) } // Noch einmal mit der WIRKLICHEN Groesse: content-length ist eine Ansage // des Aufrufers, keine Tatsache. const zuViel2 = kontingentUeberschritten(token, data.length) if (zuViel2) return c.json({ error: zuViel2 }, 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 ${fesselText(token)} 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) if (token.arten) { const erlaubt = token.arten.split(',').map((a) => a.trim()).filter(Boolean) const art = artVon(mime) if (!erlaubt.includes(art)) { return c.json( { error: `Dieser Token darf nur ${erlaubt.join(', ')} hochladen — das hier ist "${art}" (${mime}).`, }, 403, ) } } // VOR dem Schreiben verkleinern, wenn der Token es verlangt. Danach waere // es zu spaet: dann laege das grosse Bild schon auf der Platte, und der // Hash im Datensatz gehoerte zu einer anderen Datei als der abgelegten. let daten = data let verkleinert: false | { von: string; auf: string } = false if (token.max_kante) { const r = await vielleichtKleiner(data, mime, token.max_kante) daten = r.data verkleinert = r.verkleinert } // DER HASH GEHOERT ZU DEM, WAS WIRKLICH LIEGT. Weiter oben wird er aus dem // Original gerechnet -- der braucht ihn fuer den Ersatzpfad, wenn kein // X-Path kam. Wird das Bild verkleinert, ist das eine ANDERE Datei, und ein // Datensatz mit dem Hash des Originals waere schlicht falsch: der // Verwaisten-Sucher und jede spaetere Gegenprobe verglichen dann gegen // etwas, das nirgends liegt. const digestAbgelegt = verkleinert ? sha256(daten) : digest try { await writeFileAtomic(path, daten) } catch (err) { console.error('[upload] Schreiben fehlgeschlagen:', err) return c.json({ error: 'Schreiben fehlgeschlagen' }, 500) } // Die Vorschau gleich mit. Bewusst NACH dem eigentlichen Schreiben und mit // eigenem Ergebnis: scheitert sie, ist das Bild trotzdem abgelegt und der // Upload ein Erfolg. Das Dashboard faellt dann auf das Vollbild zurueck. if (kannVorschau(mime)) await writeThumb(path, daten) if (lohntWebp(mime)) await writeWebp(path, daten) // Video und Ton: das Standbild kommt aus der Datei AUF DER PLATTE und // deshalb erst hier -- ffmpeg muss darin springen koennen. Die Laenge faellt // beim selben Blick in die Datei mit ab. let dauer: number | null = null if (kannBewegtVorschau(mime)) { dauer = (await writeBewegtThumb(path, mime)).dauer } const timestamp = now() if (existing) { db.prepare( `UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, token_id = ?, updated_at = ? WHERE id = ?`, ).run(daten.length, digestAbgelegt, mime, dauer, token.id, timestamp, existing.id) } else { db.prepare( `INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).run(path, daten.length, digestAbgelegt, mime, dauer, token.id, timestamp, timestamp) } logEvent(existing ? 'replace' : 'upload', path, daten.length, 'token', token.name) meldeUpload(path, daten.length, token.name) return c.json({ url: publicUrlFor(path), path, size: daten.length, sha256: digestAbgelegt, mime, replaced: Boolean(existing), // Nur dann, wenn wirklich gerechnet wurde -- ein Feld, das immer // dasteht, sagt nichts. ...(verkleinert ? { verkleinert } : {}), }) }) /** Loeschen ueber den Pfad — Skripte kennen den Pfad, nicht unsere ID. * * `:pfad{.+}` und nicht `*`: ein Sternchen wird von Hono nicht als Parameter * erfasst, und `c.req.path` traegt den Einhaengepunkt mit — aus * DELETE /api/media/vehicles/adder.png wurde damit der Pfad * "api/media/vehicles/adder.png". Der Parameter kommt bereits dekodiert, * ein zweites decodeURIComponent waere eine Dekodierung zu viel. */ uploadRoutes.delete('/media/:pfad{.+}', 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(c.req.param('pfad')) } 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) // Die Groesse VOR dem Loeschen holen: danach steht sie nirgends mehr, und // ein Verlaufseintrag ohne Groesse beantwortet die Frage "wie viel Platz // wurde da frei" nicht. const zeile = db.prepare('SELECT size FROM media WHERE path = ?').get(path) as | { size: number } | undefined await deleteFile(path) db.prepare('DELETE FROM media WHERE path = ?').run(path) logEvent('delete', path, zeile?.size ?? null, 'token', token.name) return c.json({ deleted: path }) }) /** Nachsehen, ob es etwas schon gibt — damit ein Lauf "nur fehlende" * beantworten kann, ohne 900 Bilder hochzuladen. * * ZWEI FORMEN, UND DIE ZWEITE IST DIE WICHTIGE: * * GET /api/exists/vehicles/adder.webp die alte, mit Pfad * GET /api/exists?pfad=vehicles/adder.webp die neue, mit Abfrage * * Der Grund ist NACHGEMESSEN und unangenehm. Nginx Proxy Manager bringt eine * `assets.conf` mit, deren Regel auf JEDE URL greift, die auf .webp, .png, .js * ... endet: * * location ~* ^.*\.(css|js|jpe?g|gif|png|webp|...)$ { * proxy_cache public-cache; * proxy_ignore_headers Set-Cookie Cache-Control Expires ...; * proxy_cache_valid any 30m; * } * * Die ERSTE Form endet auf ".webp" und faellt damit hinein. Der Proxy legt die * Antwort weg -- und liefert sie danach an JEDEN aus, auch ohne Token. * Gemessen: 200 mit vollstaendiger Auskunft, Authorization-Kopf gar nicht * gesetzt. Unser eigenes `Cache-Control` hilft nicht, `proxy_ignore_headers` * wirft es weg. * * Ein `location ^~ /api/`-Block im Proxy schliesst das -- aber eine * Zugangspruefung, die an einer Zeile in einer fremden Oberflaeche haengt, ist * keine. Die ZWEITE Form endet nie auf eine Bildendung (die Abfrage zaehlt * fuer die Regel nicht mit) und ist deshalb von sich aus dicht. * * Die alte Form bleibt, weil Skripte sie benutzen -- aber die Dokumentation * nennt nur noch die neue. */ function existsAntwort(c: Context<{ Variables: Vars }>, roh: string) { let path: string try { path = checkPath(roh) } catch { return c.json({ exists: false }, 200) } const row = db.prepare('SELECT size, sha256, updated_at FROM media WHERE path = ?').get( path, ) as Pick | undefined return c.json( row ? { exists: true, path, url: publicUrlFor(path), ...row } : { exists: false, path }, ) } /** Was darf dieser Token eigentlich? * * WOZU. Das Fotostudio soll beim Einrichten eine AUSWAHL der Ordner zeigen * statt eines Freitextfeldes -- und eine Auswahl, die etwas anbietet, das * hinterher mit 403 abprallt, ist schlimmer als keine. * * Bisher erfuhr man die Fesseln nur, indem man dagegen lief: die * 403-Meldung nennt sie. Das ist eine Auskunft ueber den eigenen Zugang, kein * Geheimnis -- wer den Token hat, kennt seinen Umfang ohnehin, er muss ihn * nur ausprobieren. Also lieber geradeheraus. * * Eine leere Liste heisst "ueberall", genau wie in der Datenbank. Das steht * auch im Feld daneben, damit niemand "keine Ordner" liest. */ uploadRoutes.get('/zugang', (c) => { const token = c.get('token') as Token const fesseln = tokenPrefixes(token) // WELCHE ORDNER ZUR AUSWAHL STEHEN. // // Ist der Token gefesselt, sind es genau seine -- eine Auswahl, die etwas // anbietet, das hinterher mit 403 abprallt, waere schlimmer als keine. // // Ist er es NICHT, waere die Antwort "ueberall" fuer ein Auswahlfeld // wertlos. Dann kommen die Ordner, die es wirklich gibt. Das ist keine // Preisgabe: wer ueberall schreiben darf, kommt an jeden davon ohnehin -- // er muesste den Namen nur raten. const vorhanden = ( db .prepare( `SELECT DISTINCT substr(path, 1, instr(path, '/') - 1) AS o FROM media WHERE instr(path, '/') > 0 ORDER BY o`, ) .all() as { o: string }[] ).map((z) => z.o) return c.json({ // Woraus das Auswahlfeld waehlen darf. ordner: fesseln.length > 0 ? fesseln : vorhanden, // Und ob daneben noch etwas anderes ginge -- dann darf das Feld auch // einen freien Namen zulassen. ueberall: fesseln.length === 0, darfLoeschen: token.can_delete === 1, name: token.name, }) }) /** Wozu fehlt ein Bild -- dieselbe Frage wie im Dashboard, mit Token. * * WOZU, ergaenzt am 03.10.2026. Das Fotostudio soll "fehlt" so beantworten, * wie der Laden es sieht: liegt es HIER oder nicht. Mit /exists waeren das * fuer die Kleidung rund 8.000 Anfragen; hier sind es zwei (hoechstens * FEHLENDE_HOECHSTENS Namen je Anfrage). * * KEINE ORDNERFESSEL, genau wie bei /exists: was hier beantwortet wird, ist * ueber /f/ ohnehin oeffentlich abrufbar. Der Token haelt nur Fremde davon ab, * den Dienst mit Sammelabfragen zu beschaeftigen. * * Kein .webp/.png am Ende der Adresse -- die Proxy-Falle von /exists/ * (siehe unten) kann hier nicht zuschnappen. */ /** Was liegt in einem Ordner -- die Liste, nicht die Frage nach Namen. * * WOZU, ergaenzt am 03.10.2026. /api/fehlende braucht die NAMEN, und die * kennt beim Fotostudio nur der Spiel-Client: die Kleidungsnamen entstehen * dort aus Natives, je Textur eines. Sie zum Server zu schicken hiesse rund * 8.000 Namen ueber ein Netzereignis. Umgekehrt ist es eine Anfrage: der * Dienst sagt, was liegt, und das Studio prueft jeden Namen selbst. * * SCHREIBWEISE GENAU, wie ueberall: `substr(...) = ?` und nicht LIKE -- * LIKE ist in SQLite fuer ASCII schreibweisenblind und naehme * `Clothing/...` mit in `clothing/`. * * Mit Unterordnern (felgen// wird einer sein). Ohne * Ordnerfessel, wie /exists: alles hier ist ueber /f/ ohnehin abrufbar. * * GEKAPPT bei LISTE_HOECHSTENS, und dann steht `gekappt: true` daneben -- * eine abgeschnittene Liste darf nicht als vollstaendige durchgehen, sonst * meldet der Aufrufer alles dahinter als fehlend. */ const LISTE_HOECHSTENS = 100_000 uploadRoutes.get('/liste', (c) => { const roh = (c.req.query('ordner') ?? '').trim().replace(/^\/+|\/+$/g, '') if (!roh) return c.json({ error: 'Abfrage "ordner" fehlt, z. B. ?ordner=clothing' }, 400) let ordner: string try { ordner = checkPath(roh) } catch (err) { return c.json({ error: err instanceof PathError ? err.message : 'Ordner unbrauchbar' }, 400) } const praefix = ordner + '/' const gesamt = ( db .prepare('SELECT count(*) AS n FROM media WHERE substr(path, 1, ?) = ?') .get(praefix.length, praefix) as { n: number } ).n const pfade = ( db .prepare( 'SELECT path FROM media WHERE substr(path, 1, ?) = ? ORDER BY path LIMIT ?', ) .all(praefix.length, praefix, LISTE_HOECHSTENS) as { path: string }[] ).map((z) => z.path) return c.json({ ordner, gesamt, gekappt: pfade.length < gesamt, pfade }) }) uploadRoutes.post('/fehlende', async (c) => { let body: { muster?: unknown; namen?: unknown } | null = null try { body = await c.req.json() } catch { return c.json({ error: 'Kein gueltiges JSON' }, 400) } try { return c.json(fehlendeRechnen(body?.muster, body?.namen)) } catch (err) { if (err instanceof FehlendeFehler) return c.json({ error: err.message }, 400) throw err } }) uploadRoutes.get('/exists', (c) => { const roh = c.req.query('pfad') ?? c.req.query('path') ?? '' if (!roh) return c.json({ error: 'Abfrage "pfad" fehlt' }, 400) return existsAntwort(c, roh) }) uploadRoutes.get('/exists/:pfad{.+}', (c) => existsAntwort(c, c.req.param('pfad')))