feat: Token-Grenzen, Speicher nach Art, Aufbewahrungsbericht, Discord-Meldungen

KEINE TOKEN-ARTEN, SONDERN TOKEN-GRENZEN. Fivemanage hat Token-Typen; wir haben
schon ein Rechtesystem, es steht nur auf einer anderen Achse (Praefix und
Loeschrecht). Was wirklich fehlte, sind die drei Schrauben, die die Frage
beantworten "was passiert, wenn DIESER Token abhandenkommt":

  Groessengrenze   ein Token fuer Fahrzeugbilder braucht keine 64 MB
  erlaubte Arten   nur Bilder, kein Video
  Ablaufdatum      fuer eine Anbindung, die man mal ausprobiert

Alle drei am laufenden Dienst durchgemessen: 600 KB gegen 512 KB -> 413 mit
beiden Zahlen; ein mp4 gegen einen Bild-Token -> 403 mit Art UND MIME im Text;
ausserhalb des Praefix -> 403; abgelaufen -> 401 mit DEM DATUM. "Abgelaufen"
und "unbekannt" auseinanderzuhalten spart eine halbe Stunde Suche nach einem
Tippfehler, den es nicht gibt.

Mehr als die Dienstgrenze zu erlauben wird abgelehnt statt still gekappt -- es
waere eine Behauptung, denn durchgelassen wird ohnehin die schaerfere.

SPEICHER NACH ART: ein Balken fuer die Verhaeltnisse, eine Tabelle fuer die
Zahlen, dazu die groessten Dateien.

AUFBEWAHRUNG VORBEREITET, NICHT GEBAUT. Automatisches Loeschen zu bauen, bevor
man weiss, was da liegt, ist der Weg, wie man Daten verliert. Stattdessen der
Bericht davor: wie viel ist aelter als 7/30/90/365 Tage, was liegt doppelt
(gleicher SHA-256 unter zwei Pfaden) und wie viel Platz das kostet. "Alles
aelter als 90 Tage loeschen" ist eine Behauptung, solange niemand weiss, wie
viel das waere. Diese Seite loescht nichts.

DISCORD-MELDUNGEN -- und der Grund ist nicht Discord, sondern eine Luecke, die
wir heute selbst gebaut haben: die Sicherung laeuft nachts um halb fuenf, und
wenn sie aufhoert zu laufen, merkt es niemand. Der Dienst sieht jetzt
STUENDLICH nach und meldet, wenn der letzte Lauf gescheitert oder aelter als 26
Stunden ist (24 waeren zu knapp, 48 zu spaet). Hoechstens EINMAL je Zustand --
eine Meldung, die stuendlich wiederkommt, wird weggeklickt, und dann auch die,
die zaehlt.

Ein Webhook und keine Bot-Anbindung: eine Adresse, kein Token, keine
Berechtigungen. Die Adresse wird geprueft (nur echte discord.com-Webhooks),
und der Testknopf steht gleich daneben -- ein Webhook, den man eintraegt und
erst in drei Wochen im Fehlerfall ausprobiert, ist einer, der dann nicht geht.

Aus deren Doku mitgenommen und NICHT gebaut: presigned URLs (kurzlebige
signierte Adressen fuer Client-Uploads). Waere der richtige Weg fuer
Spieler-Screenshots -- solange keine Spieler hochladen, ist es Vorrat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
D4rkst3randClaude Opus 5 committed 2026-08-11 20:30:03 +02:00
1 parent 37bf6a68ed
commit c9cd8eaa84
11 files changed
+851 -21

No files matched your search

+45 -10
View File
@@ -52,10 +52,34 @@ const requireToken = async (c: Context<{ Variables: Vars }>, next: Next) => {
if (!token) {
return c.json({ error: 'Token fehlt oder ist unbekannt' }, 401)
}
// Abgelaufen ist etwas anderes als unbekannt, und die Meldung sagt es:
// sonst sucht jemand eine halbe Stunde nach einem Tippfehler im Token.
if (token.expires_at && token.expires_at < now()) {
return c.json(
{
error: `Dieser Token ist am ${new Date(token.expires_at).toLocaleDateString('de-DE')} abgelaufen.`,
},
401,
)
}
c.set('token', token)
await next()
}
/** Welche Medienart ist das? Dieselben Namen wie im Dashboard.
*
* An EINER Stelle, damit die Einordnung beim Hochladen und die Anzeige in der
* Galerie nicht auseinanderlaufen koennen. */
function artVon(mime: string): string {
const m = mime.split(';')[0]?.trim().toLowerCase() ?? ''
if (m.startsWith('image/')) return 'bild'
if (m.startsWith('video/')) return 'video'
if (m.startsWith('audio/')) return 'ton'
if (m === 'application/pdf' || m.startsWith('text/') || m.startsWith('application/json'))
return 'dokument'
return 'andere'
}
uploadRoutes.use('/upload', requireToken)
uploadRoutes.use('/media/*', requireToken)
uploadRoutes.use('/exists/*', requireToken)
@@ -96,12 +120,13 @@ uploadRoutes.post('/upload', async (c) => {
// Erst die angekuendigte Groesse pruefen, damit ein zu grosser Upload
// abgewiesen wird, BEVOR er im Speicher liegt.
// Die schaerfere der beiden Grenzen gilt: die des Dienstes und die dieses
// Tokens. Ein Token fuer Fahrzeugbilder braucht keine 64 MB.
const grenze = Math.min(config.maxUploadBytes, token.max_bytes ?? Number.MAX_SAFE_INTEGER)
const announced = Number(c.req.header('content-length') ?? 0)
if (announced > config.maxUploadBytes) {
return c.json(
{ error: `zu gross: ${announced} > ${config.maxUploadBytes} Bytes` },
413,
)
if (announced > grenze) {
return c.json({ error: `zu gross: ${announced} > ${grenze} Bytes` }, 413)
}
let body: Awaited<ReturnType<typeof readBody>>
@@ -113,11 +138,8 @@ uploadRoutes.post('/upload', async (c) => {
const { data } = body
if (data.length === 0) return c.json({ error: 'Rumpf ist leer' }, 400)
if (data.length > config.maxUploadBytes) {
return c.json(
{ error: `zu gross: ${data.length} > ${config.maxUploadBytes} Bytes` },
413,
)
if (data.length > grenze) {
return c.json({ error: `zu gross: ${data.length} > ${grenze} Bytes` }, 413)
}
const digest = sha256(data)
@@ -160,6 +182,19 @@ uploadRoutes.post('/upload', async (c) => {
const mime = c.req.header('x-mime') ?? mimeFor(path)
if (token.arten) {
const erlaubt = token.arten.split(',').map((a) => a.trim()).filter(Boolean)
const art = artVon(mime)
if (!erlaubt.includes(art)) {
return c.json(
{
error: `Dieser Token darf nur ${erlaubt.join(', ')} hochladen — das hier ist "${art}" (${mime}).`,
},
403,
)
}
}
try {
await writeFileAtomic(path, data)
} catch (err) {