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:
1 parent
a4e918be8a
commit
f077ac155b
3 files changed
+271
-2
No files matched your search
@@ -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 } })
|
||||
})
|
||||
Reference in new issue
Block a user