feat: Umbenennen, Fivemanage-Sprache, Freigabe-Links, sichtbare Zahlen

UMBENENNEN gab es gar nicht -- verschieben ja, umbenennen nirgends. Jetzt fuer
Datei und Ordner. Die WARNUNG ist dabei der eigentliche Teil: beim Verschieben
wandert eine Datei, beim Umbenennen aendert sich IHRE ADRESSE, und die steht
womoeglich in einem Skript, das niemand mehr im Kopf hat. Der Dialog zeigt
deshalb die Abrufzahl und beim Ordner die Zahl der betroffenen Dateien, BEVOR
gedrueckt wird -- eine Datei mit viertausend Abrufen umzubenennen ist etwas
anderes als eine mit null, und der Dienst ist die einzige Stelle, die den
Unterschied kennt.

Am echten Fall geprueft, dem Kollisionsfund: items/coiloverss.png (hielt die
+-Fassung) -> items/coiloverss-plus.png, alte Adresse 404, neue 200 mit 4375 B,
Vorschau mitgewandert. Ordner: probe -> beispiele, 5 Dateien, alle Pfade in
einer Transaktion umgeschrieben.

Ein Fallstrick dabei: thumbPath haengt ".webp" an. Fuer eine Datei richtig, fuer
einen ORDNER Unsinn -- der Vorschauordner heisst thumbs/vehicles und nicht
thumbs/vehicles.webp. Dafuer gibt es jetzt moveFolder.

DIE 93 KOLLISIONEN SIND ERLEDIGT. tools/kollisionen.ps1 rechnet dieselbe
Zaehmung auf der Quelle nach und zeigt, was zusammenfaellt -- es aendert nichts,
damit die Entscheidung auf Zahlen steht. Ergebnis: 91 von 93 sind dieselbe Datei
zweimal (WEAPON_SMG.png und weapon_smg.png, byteweise gleich gross), da fehlte
nichts. Echt verloren waren ZWEI, beide mit + im Namen. Beide nachgeholt.

FIVEMANAGE-SPRACHE. POST /api/image|video|audio (v1) und POST /api/v3/file (v3),
Schluessel nackt im Authorization-Kopf. Auf einem laufenden Server stecken die
Fivemanage-Aufrufe in einem Dutzend fremder Ressourcen; sie alle umzuschreiben
tut niemand, und deshalb bliebe dieser Dienst ungenutzt daneben stehen. So ist
der Umzug eine Zeile je Skript: die Adresse.

Beide Formen sind AUS ECHTEM CODE abgelesen und nicht geraten -- fivemanage/sdk
fuer v3, Awleks/Devm-Camera fuer den aelteren Weg ueber screenshot-basic.
Gemessen: v1 -> {url,id,path}, v3 -> {status:"ok",data:{id,url}}, ohne
Schluessel 401, ein Video an /api/image -> 415 mit Grund.

Dabei bin ich in eine Falle gelaufen, vor der im eigenen Repo ein Kommentar
warnt: die Token-Wache hing an use('*'), und der Einhaengepunkt ist /api -- also
galt sie auch fuer /api/dash daneben. Das Dashboard bekam 401 auf die ANMELDUNG.
Wortwoertlich derselbe Fehler steht in upload.ts als Kommentar, weil er dort
schon einmal passiert ist. Gemerkt hat es der Gegentest, nicht der Kopf.

FREIGABE-LINKS. /s/<schluessel> zeigt einen Ordner ohne Anmeldung. Was dabei
ausdruecklich dabeisteht, in der Karte und in der Rueckfrage vor dem
Zurueckziehen: FREIGEGEBEN WIRD DIE LISTE, NICHT DER INHALT. Die Dateien sind
ohnehin oeffentlich; ein zurueckgezogener Link macht sie nicht wieder privat, er
nimmt nur die Uebersicht weg. Ohne diesen Satz zieht jemand einen Link zurueck
und glaubt, etwas sei verschwunden.

Die Antwort ist abgemessen: Name, Groesse, Art, Adresse, Laenge. NICHT Hash,
Token, Zeitpunkte, Abrufzahlen, IDs -- nichts davon braucht, wer einen Katalog
ansieht, und jedes davon waere eine Auskunft ueber den Betrieb. Ein unbekannter
Schluessel und ein zurueckgezogener geben dieselbe Antwort.

ZAHLEN, DIE SCHON DA WAREN. Sortieren nach Abrufen und nach "zuletzt geholt";
die Kachel zeigt dann auch diese Zahl statt Groesse und Datum, denn nach etwas
zu ordnen, das man nirgends sieht, ist eine Reihenfolge ohne Begruendung. Und je
Token, was damit abgelegt wurde: media.token_id wird seit dem ersten Tag
geschrieben und war NIRGENDS zu sehen. Gemessen: d4rk_photostudio haelt 810
Dateien / 99,5 MB, 3640 liegen ohne Token da.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 22:53:22 +02:00
co-authored by Claude Opus 5
parent d125c6bf72
commit 44ad7dc6b3
22 changed files with 1843 additions and 19 deletions
+337
View File
@@ -0,0 +1,337 @@
// 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 { db, logEvent, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.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) }
}
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)
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 } })
})