diff --git a/docs/API.md b/docs/API.md index 1a81017..bc49d4f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -111,6 +111,8 @@ Dienstes** ein. | `POST /api/video` | ✅ gleich | `video` | dito | | `POST /api/audio` | ✅ gleich | `audio` | dito | | `POST /api/v3/file` | ✅ gleich | `file`, `path?`, `filename?` | `{ "status": "ok", "data": { "id", "url" } }` | +| `GET /api/v3/file/presigned-url` | ✅ gleich, strenger | `?expiresAt=` (Unix-Sekunden) | `{ "status": "ok", "data": { "presignedUrl", "expiresAt" } }` | +| `POST /api/v3/file/presigned-url/` | ✅ gleich, **ohne Token** | `file`, `path?`, `filename?` | wie `/api/v3/file` | Der Schlüssel steht **nackt** im `Authorization`-Kopf, ohne `Bearer` — genau so wie bei Fivemanage. `Bearer …` geht weiterhin auch. @@ -136,8 +138,35 @@ Fivemanages ID auch hat. **Was NICHT übernommen wird:** `metadata` wird angenommen und ignoriert (es gibt hier keine Spielerdaten an einer Datei), `retentionExempt` ebenso — dieser -Dienst löscht nichts von selbst. Presigned URLs gibt es nicht: der Upload geht -direkt hierher. +Dienst löscht nichts von selbst. + +### Presigned URLs — wenn die Bytes vom Spieler kommen + +Seit 19.10.2026 (d4rk-rp-planung#239). **Der Token gehört nie in den Browser +eines Spielers.** Muss ein Bild, Ton oder Video aus der NUI hochgehen, holt der +**Server** mit seinem Token eine Adresse, und nur die geht an den Client: + +```lua +-- Server: Adresse holen (Token nackt im Kopf, wie bei Fivemanage) +PerformHttpRequest('https://media.d4rkst3r.de/api/v3/file/presigned-url', cb, 'GET', '', + { Authorization = GetConvar('mein_token', '') }) +-- cb: data.presignedUrl an den Client geben +``` + +```js +// NUI: dorthin hochladen, OHNE Authorization +const f = new FormData(); f.append('file', blob, 'foto.webp') +const r = await fetch(presignedUrl, { method: 'POST', body: f }) // -> data.url +``` + +**Strenger als Fivemanage, mit Absicht** (gemessen mit `tools/presign-pruefen.mjs`, 19/19): + +- jede Adresse gilt **genau einmal**, auch ein Versuch, der an einer Fessel scheitert, verbraucht sie; +- höchstens **15 Minuten**, auch wenn `expiresAt` mehr verlangt; ohne Angabe 15 Minuten; +- es gelten **die Fesseln des ausstellenden Tokens**, beim Hochladen frisch gelesen — wird der Token gelöscht oder gefesselt, gilt das sofort auch für seine offenen Adressen; +- ein **privater** Token bekommt keine (403). + +Die Schlüssel liegen im Arbeitsspeicher; ein Neustart des Dienstes verwirft offene. > Beide Formen sind **aus echtem Code abgelesen**, nicht geraten: > [fivemanage/sdk](https://github.com/fivemanage/sdk) für v3 und diff --git a/server/src/routes/fivemanage.ts b/server/src/routes/fivemanage.ts index ff34db6..510700d 100644 --- a/server/src/routes/fivemanage.ts +++ b/server/src/routes/fivemanage.ts @@ -27,6 +27,7 @@ import { Hono } from 'hono' import type { Context, Next } from 'hono' +import { createHash, randomBytes } from 'node:crypto' import { config } from '../config.js' import { meldeUpload } from '../meldung.js' import { db, logEvent, now, type Media, type Token } from '../db.js' @@ -349,3 +350,86 @@ fivemanageRoutes.post('/v3/file', async (c) => { if (!r.ok) return r.antwort return c.json({ status: 'ok', data: { id: r.id, url: r.url } }) }) + +// ------------------------------------------------------- Presigned URLs (v3) +// +// WOZU (d4rk-rp-planung#239). Ein Skript, dessen Bytes vom SPIELER kommen +// (Handy-Kamera, Galerie, Video), musste bisher den Token selbst an die NUI +// geben - und damit an jeden Client. Fivemanage loest das mit einer +// kurzlebigen Adresse: der Server holt sie MIT Token, der Client laedt OHNE +// Token dorthin hoch. Diesen Weg spricht der Dienst jetzt auch. +// +// NACHGELESEN (19.10.2026), nicht geraten: +// docs.fivemanage.com, api-reference/files/create-presigned-upload-url +// GET /api/v3/file/presigned-url, Authorization: , +// optional ?expiresAt=, Standard 15 Minuten +// -> { status: "ok", data: { presignedUrl } } +// .../upload-file-via-presigned-url +// POST /api/v3/file/presigned-url/, multipart, Feld file +// -> { status, data: { id, url } } +// sky_phone (source/server/media.lua, PhoneMediaCapture.vue) nutzt genau das. +// +// STRENGER ALS FIVEMANAGE, mit Absicht: +// - jeder Schluessel gilt EINMAL. Ob Fivemanages Adressen mehrfach gehen, +// sagt deren Doku nicht; hier ist es ausgeschlossen. Auch ein Versuch, +// der an einer Fessel scheitert, verbraucht ihn. +// - hoechstens 15 Minuten, auch wenn expiresAt mehr verlangt. +// - es gelten die Fesseln des AUSSTELLENDEN Tokens, frisch aus der +// Datenbank beim Hochladen gelesen - wer den Token inzwischen sperrt oder +// fesselt, sperrt damit auch seine offenen Schluessel. +// +// IM ARBEITSSPEICHER (Betreiber 19.10.2026): ein Neustart verwirft offene +// Schluessel. Sie gelten ohnehin nur Minuten, und so braucht es keine Tabelle. +// Gespeichert wird nur der HASH - der Schluessel selbst steht nur in der Adresse. + +const SCHLUESSEL_HOECHSTENS_MS = 15 * 60_000 +const offeneSchluessel = new Map() +const hashVon = (s: string) => createHash('sha256').update(s).digest('hex') + +fivemanageRoutes.use('/v3/file/presigned-url', wache) +fivemanageRoutes.get('/v3/file/presigned-url', (c) => { + const token = c.get('token') + const jetzt = now() + for (const [h, s] of offeneSchluessel) if (s.bis <= jetzt) offeneSchluessel.delete(h) + + // expiresAt in Unix-SEKUNDEN wie bei Fivemanage; ohne Angabe 15 Minuten. + const roh = c.req.query('expiresAt') + let bis = jetzt + SCHLUESSEL_HOECHSTENS_MS + if (roh !== undefined) { + const s = Number(roh) + if (!Number.isFinite(s) || s * 1000 <= jetzt) { + return c.json({ status: 'error', message: 'expiresAt muss ein Zeitpunkt in der Zukunft sein (Unix-Sekunden)' }, 400) + } + bis = Math.min(s * 1000, jetzt + SCHLUESSEL_HOECHSTENS_MS) + } + + const schluessel = randomBytes(32).toString('base64url') + offeneSchluessel.set(hashVon(schluessel), { tokenId: token.id, bis }) + // Kein Eintrag im Verlauf: der zaehlt Dateien. Der Upload danach steht dort. + return c.json({ + status: 'ok', + data: { presignedUrl: `${config.publicUrl}/api/v3/file/presigned-url/${schluessel}`, expiresAt: Math.floor(bis / 1000) }, + }) +}) + +fivemanageRoutes.post('/v3/file/presigned-url/:schluessel', async (c) => { + const h = hashVon(c.req.param('schluessel')) + const s = offeneSchluessel.get(h) + // ZUERST verbrauchen, dann pruefen: ein zweiter Versuch mit demselben + // Schluessel findet nichts mehr, egal wie der erste ausging. + offeneSchluessel.delete(h) + if (!s) return c.json({ status: 'error', message: 'Schluessel unbekannt oder schon benutzt' }, 401) + if (s.bis <= now()) return c.json({ status: 'error', message: 'Schluessel abgelaufen' }, 401) + + const token = db.prepare('SELECT * FROM tokens WHERE id = ?').get(s.tokenId) as Token | undefined + if (!token) return c.json({ status: 'error', message: 'Der ausstellende Token gibt es nicht mehr' }, 401) + if (token.expires_at && token.expires_at < now()) { + return c.json({ status: 'error', message: 'Der ausstellende Token ist abgelaufen' }, 401) + } + if (token.privat) return c.json({ status: 'error', message: PRIVAT_NUR_DORT }, 403) + + c.set('token', token) + const r = await ablegen(c) + if (!r.ok) return r.antwort + return c.json({ status: 'ok', data: { id: r.id, url: r.url } }) +}) diff --git a/tools/presign-pruefen.mjs b/tools/presign-pruefen.mjs new file mode 100644 index 0000000..d2a2763 --- /dev/null +++ b/tools/presign-pruefen.mjs @@ -0,0 +1,156 @@ +// Prueft die PRESIGNED URLS (d4rk-rp-planung#239) gegen einen WEGWERF-Container +// des fertigen Abbilds -- nicht gegen den laufenden Dienst. +// +// docker build -t d4rk-media:presign-test server +// node tools/presign-pruefen.mjs +// +// Wie privat-pruefen.mjs: kein Volume, ein Port nur auf 127.0.0.1, ein +// Zufallspasswort, das nirgends ausgegeben wird; am Ende wird der Container +// entfernt, auch wenn eine Pruefung scheitert. +// +// DIE KONTROLLEN ZUERST: der gewoehnliche Weg /api/v3/file MIT Token muss +// gehen. Erst dann heisst "mit Schluessel abgelehnt" etwas. + +import { execFileSync } from 'node:child_process' +import { createHash, randomBytes } from 'node:crypto' + +const ABBILD = 'd4rk-media:presign-test' +const NAME = 'd4rk-media-presign-test' +const PORT = 9197 +const BASIS = `http://127.0.0.1:${PORT}` + +// Ein echtes 1x1-PNG; die Bytes muessen unveraendert zurueckkommen. +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64') +const sha = (b) => createHash('sha256').update(b).digest('hex') +const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) +const wegraeumen = () => { try { docker('rm', '-f', NAME) } catch { /* war nicht da */ } } +const schlaf = (ms) => new Promise((r) => setTimeout(r, ms)) + +const ergebnisse = [] +function pruefe(id, was, ok, ist) { + ergebnisse.push({ id, was, ok, ist }) + console.log(`${ok ? 'OK ' : 'FAIL'} ${id.padEnd(4)} ${was} [${ist}]`) +} + +/** multipart wie die NUI eines Handys: Feld "file", optional path. */ +const formular = (bytes, typ, name = 'foto.png', pfad) => { + const f = new FormData() + f.append('file', new Blob([bytes], { type: typ }), name) + if (pfad) f.append('path', pfad) + return f +} + +async function main() { + wegraeumen() + const kennwort = randomBytes(18).toString('base64url') + docker('run', '-d', '--rm', '--name', NAME, '-p', `127.0.0.1:${PORT}:8080`, + '-e', `PUBLIC_URL=${BASIS}`, '-e', 'ADMIN_USERNAME=admin', '-e', `ADMIN_PASSWORD=${kennwort}`, ABBILD) + + let bereit = false + for (let i = 0; i < 60 && !bereit; i++) { + try { bereit = (await fetch(`${BASIS}/health`)).ok } catch { /* noch nicht */ } + if (!bereit) await schlaf(500) + } + if (!bereit) { console.log('UNGUELTIG: der Testcontainer antwortet nicht auf /health'); return 2 } + + const login = await fetch(`${BASIS}/api/dash/auth/login`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: kennwort }), + }) + const keks = (login.headers.get('set-cookie') ?? '').split(';')[0] + if (!login.ok || !keks) { console.log(`UNGUELTIG: Anmeldung am Dashboard fehlgeschlagen (${login.status})`); return 2 } + const dash = (pfad, init = {}) => + fetch(`${BASIS}/api/dash${pfad}`, { ...init, headers: { 'Content-Type': 'application/json', Cookie: keks, ...(init.headers ?? {}) } }) + const anlegen = async (daten) => (await dash('/tokens', { method: 'POST', body: JSON.stringify(daten) })).json() + + // Wie der Handy-Token gefesselt werden soll: Praefix, nur Bild, 1 MB. + const tHandy = await anlegen({ name: 'presign handy', prefix: 'phone', canDelete: false, arten: ['bild'], maxMb: 1 }) + const tPrivat = await anlegen({ name: 'presign privat', prefix: 'fotos', canDelete: false, privat: true }) + if (!tHandy.token || !tPrivat.token) { console.log('UNGUELTIG: Tokens liessen sich nicht anlegen'); return 2 } + const H = { Authorization: tHandy.token } // nackt, wie Fivemanage und d4rk_phone + const holen = async (kopf = H, query = '') => fetch(`${BASIS}/api/v3/file/presigned-url${query}`, { headers: kopf }) + const schluessel = async (query = '') => (await (await holen(H, query)).json()).data?.presignedUrl + + // --- Kontrolle ------------------------------------------------------------- + let r = await fetch(`${BASIS}/api/v3/file`, { method: 'POST', headers: H, body: formular(PNG, 'image/png') }) + pruefe('K1', 'Kontrolle: /api/v3/file MIT Token laedt hoch', r.status === 200, r.status) + if (r.status !== 200) { console.log('UNGUELTIG: der gewoehnliche Weg geht nicht - die Ablehnungen saehen nichts'); return 2 } + r = await fetch(`${BASIS}/api/v3/file`, { method: 'POST', body: formular(PNG, 'image/png') }) + pruefe('K2', 'Kontrolle: /api/v3/file OHNE Token bleibt 401 (Wache greift noch)', r.status === 401, r.status) + + // --- Ausstellen -------------------------------------------------------------- + r = await holen({}) + pruefe('S1', 'Schluessel holen ohne Token: 401', r.status === 401, r.status) + r = await holen() + const antwort = r.status === 200 ? await r.json() : null + const url = antwort?.data?.presignedUrl + pruefe('S2', 'Schluessel holen mit Token: Fivemanage-Form data.presignedUrl', + r.status === 200 && antwort?.status === 'ok' && typeof url === 'string' && url.startsWith(`${BASIS}/api/v3/file/presigned-url/`), `${r.status} ${url ? 'url da' : 'url fehlt'}`) + const bisS = antwort?.data?.expiresAt + const jetztS = Date.now() / 1000 + pruefe('S3', 'ohne expiresAt: 15 Minuten', typeof bisS === 'number' && Math.abs(bisS - (jetztS + 900)) < 5, `${bisS ? Math.round(bisS - jetztS) : '?'} s`) + + // --- Hochladen OHNE Token ------------------------------------------------------ + r = await fetch(url, { method: 'POST', body: formular(PNG, 'image/png') }) + const hoch = r.status === 200 ? await r.json() : null + pruefe('U1', 'Hochladen mit Schluessel, OHNE Token', r.status === 200 && hoch?.status === 'ok' && !!hoch?.data?.url, `${r.status}`) + const zurueck = hoch?.data?.url ? await fetch(hoch.data.url) : null + const bytes = zurueck?.ok ? Buffer.from(await zurueck.arrayBuffer()) : null + pruefe('U2', 'die Datei liegt unter phone/ und kommt unveraendert zurueck', + !!hoch?.data?.url && hoch.data.url.includes('/f/phone/') && bytes && sha(bytes) === sha(PNG), hoch?.data?.url?.replace(BASIS, '') ?? 'keine url') + + // --- Einmal ---------------------------------------------------------------------- + r = await fetch(url, { method: 'POST', body: formular(PNG, 'image/png') }) + pruefe('E1', 'derselbe Schluessel ein zweites Mal: 401', r.status === 401, r.status) + r = await fetch(`${BASIS}/api/v3/file/presigned-url/${randomBytes(32).toString('base64url')}`, { method: 'POST', body: formular(PNG, 'image/png') }) + pruefe('E2', 'ausgedachter Schluessel: 401', r.status === 401, r.status) + + // --- Ablauf ------------------------------------------------------------------------ + r = await holen(H, `?expiresAt=${Math.floor(Date.now() / 1000) - 10}`) + pruefe('A1', 'expiresAt in der Vergangenheit: 400', r.status === 400, r.status) + const lang = await (await holen(H, `?expiresAt=${Math.floor(Date.now() / 1000) + 86400}`)).json() + pruefe('A2', 'expiresAt in einem Tag: auf 15 Minuten gedeckelt', typeof lang?.data?.expiresAt === 'number' && lang.data.expiresAt - Date.now() / 1000 <= 905, `${Math.round((lang?.data?.expiresAt ?? 0) - Date.now() / 1000)} s`) + const kurz = await schluessel(`?expiresAt=${Math.floor(Date.now() / 1000) + 2}`) + await schlaf(3500) + r = await fetch(kurz, { method: 'POST', body: formular(PNG, 'image/png') }) + pruefe('A3', 'abgelaufener Schluessel: 401', r.status === 401, r.status) + + // --- Fesseln des ausstellenden Tokens ------------------------------------------------ + let s = await schluessel() + r = await fetch(s, { method: 'POST', body: formular(Buffer.from('ID3'), 'audio/mpeg', 'ton.mp3') }) + pruefe('F1', 'Art-Fessel: Ton mit Bild-Token abgelehnt (403)', r.status === 403, r.status) + r = await fetch(s, { method: 'POST', body: formular(PNG, 'image/png') }) + pruefe('F2', '... und der Schluessel ist damit verbraucht (401)', r.status === 401, r.status) + s = await schluessel() + r = await fetch(s, { method: 'POST', body: formular(PNG, 'image/png', 'x.png', 'items/fremd.png') }) + pruefe('F3', 'Praefix-Fessel: Pfad ausserhalb phone/ abgelehnt (403)', r.status === 403, r.status) + s = await schluessel() + r = await fetch(s, { method: 'POST', body: formular(Buffer.alloc(1_300_000, 1), 'image/png', 'gross.png') }) + pruefe('F4', 'Groessen-Fessel: 1,3 MB bei 1 MB abgelehnt (413)', r.status === 413, r.status) + const svg = Buffer.from('') + s = await schluessel() + r = await fetch(s, { method: 'POST', body: formular(svg, 'image/svg+xml', 'x.svg') }) + const svgUrl = r.status === 200 ? (await r.json()).data?.url : null + const svgKopf = svgUrl ? (await fetch(svgUrl)).headers.get('content-security-policy') ?? '' : '' + pruefe('F5', 'ein SVG (zaehlt als Bild) kommt mit Sandbox zurueck (#254)', !svgUrl || svgKopf.includes('sandbox'), svgUrl ? (svgKopf || 'keine CSP') : `nicht angenommen (${r.status})`) + + // --- Token gesperrt, Schluessel offen ------------------------------------------------ + s = await schluessel() + const liste = await (await dash('/tokens')).json() + const id = liste.items?.find((t) => t.name === 'presign handy')?.id + const weg = id ? await dash(`/tokens/${id}`, { method: 'DELETE' }) : null + r = await fetch(s, { method: 'POST', body: formular(PNG, 'image/png') }) + pruefe('T1', 'Token geloescht: sein offener Schluessel geht nicht mehr (401)', weg?.ok === true && r.status === 401, `loeschen=${weg?.status} hoch=${r.status}`) + + // --- Privater Token ---------------------------------------------------------------- + r = await holen({ Authorization: tPrivat.token }) + pruefe('P1', 'privater Token bekommt keinen Schluessel (403)', r.status === 403, r.status) + + const fehl = ergebnisse.filter((e) => !e.ok).length + console.log(`\n${ergebnisse.length - fehl}/${ergebnisse.length} bestanden`) + return fehl ? 1 : 0 +} + +let code = 1 +try { code = await main() } catch (err) { console.log(`FEHLER: ${err instanceof Error ? err.message : err}`) } finally { wegraeumen() } +process.exit(code)