// 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 { db, now, type Media, type Token } from '../db.js' import { tokenAllows, tokenFromHeader } from '../auth.js' import { PathError, checkPath, deleteFile, extensionFor, fileExists, kannVorschau, mimeFor, publicUrlFor, sha256, writeFileAtomic, writeThumb, } 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) } c.set('token', token) await next() } uploadRoutes.use('/upload', requireToken) uploadRoutes.use('/media/*', requireToken) uploadRoutes.use('/exists/*', requireToken) /** 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> 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) } // 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, data) 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. * * `: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) 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/:pfad{.+}', (c) => { let path: string try { path = checkPath(c.req.param('pfad')) } 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 }, ) })