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>
This commit is contained in:
2026-08-12 08:25:50 +02:00
co-authored by Claude Opus 5
parent 6a49713821
commit bbb74138c8
10 changed files with 465 additions and 28 deletions
+117 -3
View File
@@ -212,12 +212,49 @@ Tokens werden im Dashboard angelegt. Der Klartext wird **genau einmal** gezeigt
— danach steht nur noch sein Hash in der Datenbank, und auch der Dienst kann
ihn nicht mehr herausgeben.
Ein Token kann zwei Fesseln haben:
Ein Token kann **sechs** Fesseln haben — alle im Dashboard einstellbar, auch
nachträglich (`PATCH`, ohne dass sich der Schlüssel ändert):
| Fessel | Wirkung |
|---|---|
| Präfix | darf nur unterhalb dieses Pfades schreiben, z. B. `vehicles/` |
| darf löschen | ohne dieses Recht antwortet `DELETE` mit 403 |
| größte Datei | schärfer als `MAX_UPLOAD_MB`; darüber 413 |
| Kontingent | wieviel dieser Token **insgesamt** halten darf; darüber 413 |
| erlaubte Arten | Bilder, Video, Ton, Dokumente, Anderes; sonst 403 |
| gültig bis | danach 401 mit dem Datum im Satz |
> **Das Kontingent ist etwas anderes als die Größe einer Datei.** Ein Token mit
> „höchstens 2 MB je Bild" konnte die Platte trotzdem füllen — es brauchte nur
> genug Bilder. Gezählt wird über das, was wirklich liegt, nicht über einen
> mitlaufenden Zähler:
>
> ```
> Kontingent erschoepft: dieser Token haelt 12 KB von 20 KB,
> und diese Datei braucht 12 KB.
> ```
### ⚠️ Bilder können kleiner zurückkommen, als sie hingingen
Ein Token kann eine **größte Kantenlänge** tragen. Ist sie gesetzt und das Bild
größer, rechnet der Dienst es herunter, **bevor** er es ablegt:
```
3000 × 2000 hingeschickt → 512 × 341 abgelegt, 82 KB → 3 KB
```
Für ein Skript heißt das zweierlei:
1. **`size` in der Antwort ist die Größe der abgelegten Datei**, nicht die der
geschickten.
2. **`sha256` ebenso.** Wer den Hash vor dem Upload selbst rechnet und mit der
Antwort vergleicht, bekommt bei gesetzter Kantenlänge einen Unterschied —
und der ist kein Fehler. Der Hash gehört zu dem, was liegt; alles andere
wäre gelogen.
Kleinere Bilder werden **nicht** aufgeblasen, und Video, Ton und Dokumente
bleiben unberührt. Ohne gesetzte Kantenlänge passiert gar nichts — der
Standard ist „ablegen, wie es kommt".
---
@@ -251,6 +288,14 @@ Nimmt die Datei in einer von **drei Rumpfformen** entgegen:
}
```
Trägt der Token eine Kantenlänge und wurde wirklich gerechnet, kommt ein Feld
dazu — sonst steht es gar nicht erst da, denn ein Feld, das immer dasteht, sagt
nichts:
```json
"verkleinert": { "von": "3000x2000", "auf": "512x341" }
```
### Was schiefgehen kann
| Antwort | Grund |
@@ -259,7 +304,7 @@ Nimmt die Datei in einer von **drei Rumpfformen** entgegen:
| 403 | Der Token darf dort nicht schreiben (Präfix-Fessel) |
| 400 | Pfad unzulässig, Rumpf leer, kaputtes Base64 |
| 409 | `X-Overwrite: false` und der Pfad ist belegt |
| 413 | größer als `MAX_UPLOAD_MB` (Standard 64) |
| 413 | größer als `MAX_UPLOAD_MB` (Standard 64), **oder** größer als die Grenze dieses Tokens, **oder** sein Kontingent ist erschöpft — welches, steht im Satz |
Jede Fehlerantwort ist JSON mit einem Feld `error` und einem **Satz auf
Deutsch**. Ein nackter 500 ohne Text ist genau der Fehler, wegen dem dieser
@@ -349,14 +394,83 @@ Papierkorb. Die tägliche Sicherung hilft nur bis zum letzten Lauf.
---
## Die Wege des Dashboards
Diese brauchen eine **Sitzung** und keinen Token — sie sind für die Oberfläche
gedacht. Hier stehen sie, weil man sie auch mit `curl` und einem Cookie
benutzen kann, wenn man etwas einmalig braucht.
| Weg | Wozu |
|---|---|
| `POST /api/dash/upload-url` | Der Dienst holt die Datei selbst von einer Adresse. Siehe unten. |
| `POST /api/dash/fehlende` | Muster + Namensliste → was fehlt, was überzählig ist |
| `POST /api/dash/media/ersetzen` | Suchen und Ersetzen über Pfade. **Ohne `ausfuehren: true` wird nur gerechnet und gezeigt.** |
| `POST /api/dash/media/umbenennen` | Eine Datei; die Antwort nennt ihre Abrufzahl |
| `POST /api/dash/folders/umbenennen` | Ein Ordner mit allem darin, in einer Transaktion |
| `PATCH /api/dash/tokens/:id` | Grenzen ändern. Weggelassene Felder bleiben, `null` löscht eine Grenze. |
| `GET /api/dash/verwaltung` | Wer hat wann was eingestellt |
| `GET /api/dash/verlauf/export` | Verlauf als CSV; `?was=verwaltung` für den anderen. Semikolon + CRLF, höchstens 20 000 Zeilen. |
| `GET /api/dash/einstellungen/export` | Alle Einstellungen als JSON. **Ohne Geheimnisse**, es sei denn `?geheim=1`. |
| `POST /api/dash/einstellungen/import` | Zurück. Leere Werte überschreiben nichts. |
| `GET/POST/DELETE /api/dash/freigaben` | Nur-Lesen-Links auf einen Ordner |
| `POST /api/dash/maintenance/thumbs` \| `/webp` | Abgeleitetes nachziehen, **in Runden zu 300** |
### POST /api/dash/upload-url
```json
{ "url": "https://…/bild.png", "ordner": "items", "name": "optional.png" }
```
**Das ist die vorsichtigste Stelle im ganzen Dienst.** Ein Server, der eine vom
Benutzer genannte Adresse abruft, ist ein Angriff mit eigenem Namen — deshalb
wird geprüft, und zwar die **aufgelöste IP** und nicht der Name:
| abgelehnt | Beispiel |
|---|---|
| alles außer http/https | `file://`, `gopher://` |
| andere Anschlüsse als 80/443 | `http://host:9101/` |
| Rückschleife und Heimnetz | `127.0.0.1`, `::1`, `10.x`, `172.1631.x`, `192.168.x`, `169.254.x`, `0.0.0.0` |
| öffentliche Namen auf private Adressen | `127.0.0.1.nip.io` |
Der letzte Punkt ist der eigentliche: ein auflösbarer Name, der auf eine private
Adresse zeigt, ist der Standardweg um jeden Namensfilter herum. **Und jede
Umleitung wird einzeln neu geprüft** — `fetch` folgen zu lassen wäre genau dort
die Lücke.
---
## Grenzen
| | |
|---|---|
| Größte Datei | 64 MB (`MAX_UPLOAD_MB`) |
| Größte Datei | 64 MB (`MAX_UPLOAD_MB`), je Token weiter einschränkbar |
| Kontingent je Token | einstellbar, ohne Grenze wenn leer |
| Pfadlänge | 200 Zeichen, höchstens 8 Ebenen |
| Anmeldeversuche | 5 frei, danach Sperre ab 30 s (verdoppelt bis 15 min) |
| Sitzung | 30 Tage |
| Papierkorb | 30 Tage **und** höchstens 20 GB (`PAPIERKORB_MAX_MB`) — was zuerst greift, greift |
Der Papierkorb hat zwei Grenzen, weil eine nicht reicht: wer 200 GB löscht,
hält sie dreißig Tage lang doppelt. Beim Ausleeren fliegt erst, was zu alt ist,
dann das **Älteste**, bis der Deckel wieder passt. Die Zahl steht in der
Antwort von `GET /api/dash/papierkorb` als `maxBytes`.
---
## Meldungen nach Discord
Vier Anlässe, alle einzeln abschaltbar: `sicherung`, `verwaiste`, `platte`,
`upload`.
**`upload` meldet nicht sofort.** Jeder Upload schiebt eine Frist von zwei
Minuten nach hinten; erst wenn es ruhig bleibt, geht **eine** Nachricht raus —
mit Anzahl, Summe, Absendern und den ersten fünf Pfaden.
Das ist keine Bequemlichkeit, sondern der Grund, warum die Funktion überhaupt
brauchbar ist: ein Serienlauf des Fotostudios legt 900 Bilder ab. 900
Nachrichten wären keine Meldung mehr, sondern ein Ausfall des Kanals — Discord
drosselt Webhooks, und wer danach eine *echte* Meldung bekommt, sieht sie nicht
mehr.
---