Presigned URLs wie Fivemanage v3, einmalig und gefesselt (d4rk-rp-planung#239)

d4rk_phone gab den Token an jeden Client, weil der Dienst keinen Weg ohne
Token kannte. Jetzt wie Fivemanage (docs.fivemanage.com, gelesen 19.10.2026;
sky_phone nutzt denselben Weg):

  GET  /api/v3/file/presigned-url[?expiresAt=]   mit Token  -> data.presignedUrl
  POST /api/v3/file/presigned-url/<schluessel>   OHNE Token -> data.url

Strenger: jeder Schluessel einmal (auch bei Ablehnung verbraucht), hoechstens
15 Minuten, Fesseln des ausstellenden Tokens beim Hochladen frisch gelesen,
private Tokens bekommen keinen. Im Arbeitsspeicher (Betreiber 19.10.2026),
nur der Hash.

tools/presign-pruefen.mjs gegen einen Wegwerf-Container: 19/19, mit
Kontrollen (v3/file mit Token geht, ohne bleibt 401).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
D4rkst3randClaude Opus 5.5 committed 2026-10-19 21:08:21 +02:00
1 parent a4e918be8a
commit f077ac155b
3 files changed
+271 -2

No files matched your search

+84
View File
@@ -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: <token>,
// optional ?expiresAt=<Unix-Sekunden>, Standard 15 Minuten
// -> { status: "ok", data: { presignedUrl } }
// .../upload-file-via-presigned-url
// POST /api/v3/file/presigned-url/<schluessel>, 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<string, { tokenId: number; bis: number }>()
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 } })
})