// Der Umzugsweg: sprechen wie Fivemanage. // // WOZU. Auf einem laufenden Server stecken die Fivemanage-Aufrufe in einem // Dutzend Ressourcen -- Kamera, Handy, MDT, Fahrzeugstudio, jede mit ihrem // eigenen Autor und ihrer eigenen Fassung. Sie alle auf unsere Schnittstelle // umzuschreiben heisst: ein Dutzend fremde Skripte anfassen, jedes davon // beim naechsten Update wieder. Das tut niemand, und deshalb bliebe der // Dienst ungenutzt neben dem alten stehen. // // SPRICHT DER DIENST DAGEGEN IHRE SPRACHE, ist der Umzug EINE Zeile je Skript: // die Adresse. Der Schluessel bleibt derselbe Kopf, die Antwort dieselbe Form. // // NACHGESEHEN, NICHT GERATEN. Beide Formen stammen aus echtem Code: // // v1 github.com/Awleks/Devm-Camera, client/client.lua: // requestScreenshotUpload('https://api.fivemanage.com/api/image', // 'image', { headers = { Authorization = apiKey } }) // -> die Antwort wird als resp.url gelesen // // v3 github.com/fivemanage/sdk, features/images/server/main.ts: // POST https://api.fivemanage.com/api/v3/file // Felder: file, metadata?, path?, filename?, retentionExempt? // -> { status, data: { id, url } } // // Der Schluessel steht bei beiden NACKT im Authorization-Kopf, ohne "Bearer". // Das nimmt tokenFromHeader inzwischen an. import { Hono } from 'hono' import type { Context, Next } from 'hono' import { config } from '../config.js' import { meldeUpload } from '../meldung.js' import { db, logEvent, now, type Media, type Token } from '../db.js' import { tokenAllows, tokenFromHeader } from '../auth.js' import { kontingentUeberschritten } from './upload.js' import { checkPath, kannBewegtVorschau, kannVorschau, lohntWebp, mimeFor, publicUrlFor, safeFilename, sha256, writeBewegtThumb, writeFileAtomic, writeThumb, writeWebp, } from '../storage.js' type Vars = { token: Token } export const fivemanageRoutes = new Hono<{ Variables: Vars }>() const wache = async (c: Context<{ Variables: Vars }>, next: Next) => { const token = tokenFromHeader(c.req.header('authorization')) if (!token) { // Die Form der FEHLERANTWORT ist auch Teil der Vertraeglichkeit: ein // Skript, das auf `status` prueft, soll hier nicht ins Leere greifen. return c.json({ status: 'error', message: 'Token fehlt oder ist unbekannt' }, 401) } if (token.expires_at && token.expires_at < now()) { return c.json({ status: 'error', message: 'Token ist abgelaufen' }, 401) } c.set('token', token) await next() } // AN DIE EIGENEN PFADE, NICHT AN '*'. // // Mit '*' galt die Wache fuer alles unterhalb des Einhaengepunkts -- und der // ist /api, also auch fuer /api/dash daneben. Das Dashboard bekam daraufhin // 401 auf die ANMELDUNG, obwohl es nie einen Token haben kann. // // Wortwoertlich derselbe Fehler steht in upload.ts als Kommentar, weil er dort // schon einmal passiert ist. Ich bin trotzdem hineingelaufen; gemerkt hat es // der Gegentest, nicht der Kopf. for (const weg of ['/image', '/video', '/audio', '/file', '/v3/file']) { fivemanageRoutes.use(weg, wache) } /** Wo eine Datei landet, wenn der Aufrufer nichts sagt. * * UND SIE UEBERSCHREIBT NIE. Das ist der Unterschied zu /api/upload, wo der * Pfad die Absicht ist: hier kommen Bildschirmfotos, die alle "screenshot.png" * heissen. Ohne einen eigenen Namen je Aufruf ueberschriebe das zweite Foto * das erste, und niemand merkte es -- der Aufrufer bekaeme sogar eine gueltige * Adresse zurueck. * * Der Hash macht den Namen: gleicher Inhalt ergibt denselben Namen (zweimal * dasselbe Bild kostet keinen zweiten Platz), verschiedener Inhalt einen * anderen. Genau die Eigenschaft, die Fivemanages ID auch hat. */ function pfadFuer(token: Token, mime: string, digest: string, name?: string): string { const basis = token.prefix ? token.prefix.replace(/\/+$/, '') : 'uploads' const kurz = digest.slice(0, 12) if (name) { const sauber = safeFilename(name, kurz) const punkt = sauber.lastIndexOf('.') const stamm = punkt > 0 ? sauber.slice(0, punkt) : sauber const endung = punkt > 0 ? sauber.slice(punkt + 1) : endungFuer(mime) return `${basis}/${stamm}-${kurz}.${endung}` } return `${basis}/${kurz}.${endungFuer(mime)}` } const ENDUNGEN: Record = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif', 'video/mp4': 'mp4', 'video/webm': 'webm', 'audio/mpeg': 'mp3', 'audio/ogg': 'ogg', 'audio/wav': 'wav', } const endungFuer = (mime: string) => ENDUNGEN[mime.split(';')[0]?.trim().toLowerCase() ?? ''] ?? 'bin' /** Die Datei aus dem Formular holen. * * DER FELDNAME IST NICHT FEST. screenshot-basic bekommt ihn als Argument * uebergeben ('image' bei Devm-Camera), das SDK schickt 'file', und andere * Skripte nehmen 'video' oder 'audio'. Ein Dienst, der nur eines davon * annimmt, lehnt die Haelfte der Aufrufer mit "Feld fehlt" ab -- und der * Aufrufer sucht den Fehler bei sich. */ const FELDER = ['file', 'image', 'video', 'audio', 'data', 'media'] as const async function dateiAus( c: Context<{ Variables: Vars }>, ): Promise<{ datei: File; pfad?: string; name?: string }> { const typ = c.req.header('content-type') ?? '' if (!typ.includes('multipart/form-data')) { throw new Error('Erwartet wird multipart/form-data') } // parseBody und NICHT c.req.raw.formData(): letzteres nimmt Hono den Rumpf // weg, und beim Aufraeumen schliesst dann jemand einen Strom, der schon zu // ist. Das hat diesen Dienst schon einmal in eine Neustartschleife // geschickt (siehe dash.ts). const roh = await c.req.parseBody() for (const feld of FELDER) { const wert = roh[feld] if (wert && typeof wert !== 'string') { return { datei: wert as unknown as File, pfad: typeof roh['path'] === 'string' ? roh['path'] : undefined, name: typeof roh['filename'] === 'string' ? roh['filename'] : undefined, } } } throw new Error(`Keine Datei dabei — erwartet wird eines der Felder: ${FELDER.join(', ')}`) } /** Was beim Ablegen herauskam: entweder die fertige Adresse, oder eine * Antwort, die der Aufrufer so bekommen soll. * * Ein Entweder-Oder und kein "hat das Ergebnis ein status-Feld?" -- die * Unterscheidung gehoert in den Typ und nicht in eine Ratefunktion. */ type Abgelegt = | { ok: true; path: string; url: string; id: string } | { ok: false; antwort: Response } /** Der gemeinsame Weg. `art` schraenkt ein, was durchgeht -- /api/image nimmt * eben nur Bilder, so wie es dort auch heisst. */ async function ablegen( c: Context<{ Variables: Vars }>, art?: 'image' | 'video' | 'audio', ): Promise { const token = c.get('token') let gefunden: Awaited> try { gefunden = await dateiAus(c) } catch (err) { return { ok: false, antwort: c.json( { status: 'error', message: err instanceof Error ? err.message : 'Rumpf unlesbar', }, 400, ), } } const { datei } = gefunden const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER) if (datei.size > grenze) { return { ok: false, antwort: c.json( { status: 'error', message: `zu gross: ${datei.size} > ${grenze} Bytes` }, 413, ), } } const data = Buffer.from(await datei.arrayBuffer()) if (data.length === 0) { return { ok: false, antwort: c.json({ status: 'error', message: 'leere Datei' }, 400) } } // Dasselbe Kontingent wie auf dem eigenen Weg -- eine Grenze, die nur an // einer von zwei Tueren haengt, ist keine. const zuViel = kontingentUeberschritten(token, data.length) if (zuViel) { return { ok: false, antwort: c.json({ status: 'error', message: zuViel }, 413) } } const digest = sha256(data) const gemeldet = (datei.type || '').split(';')[0]?.trim().toLowerCase() ?? '' // Der Name entscheidet, wenn der Absender nur mit den Schultern zuckt -- // dieselbe Regel wie beim Dashboard-Upload. const mime = gemeldet && gemeldet !== 'application/octet-stream' ? gemeldet : mimeFor(gefunden.name ?? datei.name ?? '') if (art && !mime.startsWith(`${art}/`)) { return { ok: false, antwort: c.json( { status: 'error', message: `Hier gehen nur ${art}-Dateien hinein, das hier ist ${mime}`, }, 415, ), } } // Ein ausdruecklicher Pfad (v3 kennt das Feld) hat Vorrang -- aber er wird // geprueft wie jeder andere, nicht zurechtgebogen. let path: string try { path = gefunden.pfad ? checkPath(gefunden.pfad.replace(/^\/+|\/+$/g, '')) : checkPath(pfadFuer(token, mime, digest, gefunden.name ?? datei.name)) } catch (err) { return { ok: false, antwort: c.json({ status: 'error', message: (err as Error).message }, 400), } } if (!tokenAllows(token, path)) { return { ok: false, antwort: c.json( { status: 'error', message: `dieser Token darf nur unter "${token.prefix}/" schreiben`, }, 403, ), } } if (token.arten) { const erlaubt = token.arten.split(',').map((a) => a.trim()).filter(Boolean) const meine = mime.startsWith('image/') ? 'bild' : mime.startsWith('video/') ? 'video' : mime.startsWith('audio/') ? 'ton' : 'andere' if (!erlaubt.includes(meine)) { return { ok: false, antwort: c.json( { status: 'error', message: `Dieser Token darf nur ${erlaubt.join(', ')} hochladen`, }, 403, ), } } } const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as | Media | undefined await writeFileAtomic(path, data) if (kannVorschau(mime)) await writeThumb(path, data) if (lohntWebp(mime)) await writeWebp(path, data) let dauer: number | null = null if (kannBewegtVorschau(mime)) dauer = (await writeBewegtThumb(path, mime)).dauer const zeit = now() if (vorhanden) { db.prepare( `UPDATE media SET size = ?, sha256 = ?, mime = ?, dauer = ?, token_id = ?, updated_at = ? WHERE id = ?`, ).run(data.length, digest, mime, dauer, token.id, zeit, vorhanden.id) } else { db.prepare( `INSERT INTO media (path, size, sha256, mime, dauer, token_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).run(path, data.length, digest, mime, dauer, token.id, zeit, zeit) } logEvent(vorhanden ? 'replace' : 'upload', path, data.length, 'token', token.name) meldeUpload(path, data.length, token.name) return { ok: true, path, url: publicUrlFor(path), id: digest.slice(0, 12) } } // ------------------------------------------------------------------ Die Wege // // v1: eine Adresse je Art, Antwort NUR { url, id }. Genau das liest // Devm-Camera (resp.url), und genau das lesen die meisten aelteren // Skripte. Zusaetzliche Felder schaden nicht, fehlende schon. for (const [weg, art] of [ ['/image', 'image'], ['/video', 'video'], ['/audio', 'audio'], ] as const) { fivemanageRoutes.post(weg, async (c) => { const r = await ablegen(c, art) if (!r.ok) return r.antwort return c.json({ url: r.url, id: r.id, path: r.path }) }) } /** Ohne Einschraenkung der Art -- der Sammelweg. */ fivemanageRoutes.post('/file', async (c) => { const r = await ablegen(c) if (!r.ok) return r.antwort return c.json({ url: r.url, id: r.id, path: r.path }) }) /** v3: dieselbe Arbeit, andere Verpackung. * * { status: "ok", data: { id, url } } -- so prueft es das SDK mit valibot, * und ein fehlendes Feld laesst es die Antwort verwerfen. */ fivemanageRoutes.post('/v3/file', async (c) => { const r = await ablegen(c) if (!r.ok) return r.antwort return c.json({ status: 'ok', data: { id: r.id, url: r.url } }) })