Files
d4rk_media/server/src/routes/fivemanage.ts
T
D4rkst3randClaude Opus 5 bbb74138c8 feat: Verlauf als CSV, Meldung bei Upload mit Drossel, Deckel auf dem Papierkorb
Abschnitt 3 aus docs/ideen.md, bis auf die Sicherungsziele.

VERLAUF ALS CSV. Der Export konnte nur den Bestand; wer nachrechnen wollte,
was ueber einen Zeitraum passiert ist, sass vor einer Tabelle, die nach 500
Zeilen umblaettert. Der Knopf sitzt in der Reiterleiste und holt den Verlauf,
DER GERADE OFFEN IST -- zwei Knoepfe nebeneinander waeren die Frage "welchen
von beiden?" an einer Stelle, wo die Antwort schon auf dem Bildschirm steht.

Semikolon und CRLF wie beim Bestands-Export; das war dort nachgemessen worden.
Gemessen an der ausgelieferten Datei: 5371 Zeilen, kein einziges nacktes LF.
Ohne Anmeldung 401 -- die Route liegt hinter dem Waechter, was nach dem
Sitzungs-Fehler von vorgestern ausdruecklich nachgeprueft wurde.

MELDUNG BEI UPLOAD -- die Drossel ist der eigentliche Inhalt. Jeder Upload
schiebt eine Frist von zwei Minuten nach hinten; erst nach der Ruhe geht EINE
Nachricht raus. Ohne das waere die Funktion ein Schaden: 900 Fahrzeugbilder
ergaeben 900 Nachrichten, Discord drosselt Webhooks, und wer danach eine echte
Meldung bekommt, sieht sie nicht mehr.

Gemessen: drei Uploads mit zehn Sekunden Abstand -> eine Meldung ueber drei
Dateien. Ein einzelner Upload -> eine Meldung ueber eine Datei.

Nur Token-Uploads, absichtlich. Was ueber das Dashboard hereinkommt, hat gerade
jemand selbst hochgeladen und bestaetigt bekommen. Zurueckholen aus dem
Papierkorb meldet gar nichts: das ist kein Neues, das ist ein Wiedergefundenes.

Und derselbe Fehler wie bei 'platte' lauerte schon wieder: die Liste der
Anlaesse steht im Server UND in der Oberflaeche, und der Server verwirft beim
Speichern alles, was in seiner Liste fehlt -- ein Kreuzchen, das sich setzen
laesst und beim naechsten Laden weg ist. Diesmal beide angefasst, in beiden
steht jetzt ein Verweis auf die andere. Nachgemessen: vier geschickt, vier
gespeichert.

DECKEL AUF DEM PAPIERKORB. 30 Tage UND 20 GB, was zuerst greift. Wer 200 GB
loescht, haelt sie sonst 30 Tage doppelt und merkt es erst, wenn die Platte
voll ist. Geraeumt wird das AELTESTE zuerst -- dessen Versehen waere am
ehesten schon aufgefallen.

Ueber PAPIERKORB_MAX_MB verstellbar, und das nicht aus Bequemlichkeit: ein
Deckel von 20 GB laesst sich nicht pruefen, ohne 20 GB zu loeschen. Mit
0.0002 MB nachgemessen -- vier Eintraege zu 422 Bytes, drei entfernt, uebrig
blieb der NEUESTE. Danach zurueck auf 20 GB, gemessen 21474836480.

NEBENBEI, DREIMAL DIESELBE SORTE FEHLER: "0.0 MB" fuer 422 Bytes, "0 KB" fuer
106 Bytes, "0.0 MB von 0.0 MB" beim Deckel. Es gab drei Byte-Formatierer, jeder
mit anderer Untergrenze. Jetzt einer in meldung.ts, von Bytes bis GB, exportiert
und von dash.ts mitbenutzt. Nachgemessen ueber alle Stufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:25:50 +02:00

348 lines
12 KiB
TypeScript

// 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<string, string> = {
'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<Abgelegt> {
const token = c.get('token')
let gefunden: Awaited<ReturnType<typeof dateiAus>>
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 } })
})