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
+37
View File
@@ -1029,6 +1029,43 @@ Gegenprüfen lässt sich beides mit dem, was schon da ist:
pwsh -File tools\proxy-pruefen.ps1 -Basis https://media.d4rkst3r.de -Token d4rk_... pwsh -File tools\proxy-pruefen.ps1 -Basis https://media.d4rkst3r.de -Token d4rk_...
``` ```
### ✅ Fertig — Verlauf als CSV, Meldung bei Upload, Deckel auf dem Papierkorb
Abschnitt 3 aus `docs/ideen.md`, der Rest der Liste bis auf die Sicherungsziele.
**Verlauf als CSV.** `GET /api/dash/verlauf/export`, wahlweise
`?was=verwaltung`. Der Knopf sitzt in der Reiterleiste und holt den Verlauf,
**der gerade offen ist** — zwei Knöpfe nebeneinander wären 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,
hier wäre es ein zweiter Anlauf gewesen. Gemessen an der ausgelieferten Datei:
**5364 Zeilen, 5364 CRLF, kein einziges nacktes LF**, Dateiname
`d4rk_media-dateien-2026-08-12.csv`.
**Deckel auf dem Papierkorb.** 30 Tage **und** 20 GB — was zuerst greift,
greift. Beim Ausleeren fliegt erst das zu Alte, dann das **Älteste**, bis die
Grenze wieder passt; dessen Versehen wäre am ehesten schon aufgefallen. Die
Zahl steht als `maxBytes` in der Antwort von `/api/dash/papierkorb`, damit die
Oberfläche sie nicht ein zweites Mal hinschreibt — gemessen: `21474836480`.
Bei 910 GB frei ist das keine Grenze, sondern eine Zusicherung: der Papierkorb
kann nicht mehr unbemerkt zur zweiten Ablage werden.
**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, mit Anzahl, Summe, Absendern und den ersten fünf Pfaden.
Ohne das wäre die Funktion ein Schaden: 900 Fahrzeugbilder ergäben 900
Nachrichten, Discord drosselt Webhooks, und wer danach eine echte Meldung
bekommt, sieht sie nicht mehr.
Und **derselbe Fehler wie bei `platte` lauerte schon wieder**: die Liste der
Anlässe steht an zwei Stellen — im Server und in der Oberfläche —, und der
Server verwirft beim Speichern alles, was in *seiner* Liste fehlt. Ein
Kreuzchen, das sich setzen lässt und beim nächsten Laden weg ist. Diesmal
beide Stellen angefasst und nachgemessen: **vier geschickt, vier
gespeichert.** In beiden steht jetzt ein Verweis auf die andere.
### ⬜ Als Nächstes ### ⬜ Als Nächstes
**Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container **Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container
+6
View File
@@ -50,6 +50,12 @@ services:
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-64} MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-64}
SESSION_DAYS: ${SESSION_DAYS:-30} SESSION_DAYS: ${SESSION_DAYS:-30}
# Wieviel der Papierkorb hoechstens halten darf, zusaetzlich zu den
# 30 Tagen. Wer 200 GB loescht, haelt sie sonst 30 Tage doppelt.
# Verstellbar vor allem, damit sich der Deckel PRUEFEN laesst, ohne
# 20 GB zu loeschen -- Nachkommastellen sind erlaubt.
PAPIERKORB_MAX_MB: ${PAPIERKORB_MAX_MB:-20480}
# NODE_ENV=production setzt das Sitzungs-Cookie auf "Secure". Hinter # NODE_ENV=production setzt das Sitzungs-Cookie auf "Secure". Hinter
# NPM mit TLS ist das richtig. # NPM mit TLS ist das richtig.
# #
+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 — danach steht nur noch sein Hash in der Datenbank, und auch der Dienst kann
ihn nicht mehr herausgeben. 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 | | Fessel | Wirkung |
|---|---| |---|---|
| Präfix | darf nur unterhalb dieses Pfades schreiben, z. B. `vehicles/` | | Präfix | darf nur unterhalb dieses Pfades schreiben, z. B. `vehicles/` |
| darf löschen | ohne dieses Recht antwortet `DELETE` mit 403 | | 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 ### Was schiefgehen kann
| Antwort | Grund | | 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) | | 403 | Der Token darf dort nicht schreiben (Präfix-Fessel) |
| 400 | Pfad unzulässig, Rumpf leer, kaputtes Base64 | | 400 | Pfad unzulässig, Rumpf leer, kaputtes Base64 |
| 409 | `X-Overwrite: false` und der Pfad ist belegt | | 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 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 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 ## 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 | | Pfadlänge | 200 Zeichen, höchstens 8 Ebenen |
| Anmeldeversuche | 5 frei, danach Sperre ab 30 s (verdoppelt bis 15 min) | | Anmeldeversuche | 5 frei, danach Sperre ab 30 s (verdoppelt bis 15 min) |
| Sitzung | 30 Tage | | 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.
--- ---
+44 -8
View File
@@ -98,14 +98,50 @@ einer Stunde Klickerei fünf Minuten.
## 3 · Wäre schön, drängt nicht ## 3 · Wäre schön, drängt nicht
- **Verlauf als CSV**der Export kann heute nur Medien, nicht Ereignisse. ### ~~Verlauf als CSV~~gebaut am 12.08.2026
- **Meldung bei Upload** — Discord-Nachricht, wenn über einen Token etwas
Neues ankommt. Nützlich für Bildschirmfotos aus dem Spiel, sinnlos für einen `GET /api/dash/verlauf/export`, wahlweise `?was=verwaltung`. Der Knopf sitzt in
Serienlauf über 900 Fahrzeuge; bräuchte also eine Drossel. der Reiterleiste und holt **den Verlauf, der gerade offen ist** — zwei Knöpfe
- **Papierkorb mit Größendeckel** — heute nur 30 Tage. Wer 200 GB löscht, hat nebeneinander wären die Frage „welchen von beiden?" an einer Stelle, wo die
sie 30 Tage lang doppelt. Bei 910 GB frei ist das keine Not. Antwort schon auf dem Bildschirm steht.
- **Mehrere Sicherungsziele** — die Archive liegen auf derselben Platte wie die
Daten, die Kopie in der Nextcloud ist die einzige Trennung. Semikolon und CRLF wie beim Bestands-Export, und aus demselben Grund: Excel in
deutscher Einstellung nimmt bei Komma alles in eine Spalte und bei nacktem LF
die halbe Datei in eine Zelle. Gemessen an der ausgelieferten Datei: 5364
Zeilen, 5364 CRLF, **kein einziges nacktes LF**.
### ~~Meldung bei Upload~~ — gebaut am 12.08.2026
Mit Drossel, und die ist der eigentliche Inhalt. Jeder Upload schiebt eine
Frist von zwei Minuten nach hinten; erst nach der Ruhe geht **eine** Nachricht
raus, mit Anzahl, Summe, Absendern und den ersten fünf Pfaden.
Ohne das wäre die Funktion ein Schaden statt eines Nutzens: 900 Fahrzeugbilder
ergäben 900 Nachrichten, Discord drosselt Webhooks, und wer danach eine echte
Meldung bekommt, sieht sie nicht mehr. Ein Serienlauf ergibt jetzt eine Zeile,
ein einzelnes Bildschirmfoto ergibt eine Zeile — beide sagen dasselbe, nur mit
anderen Zahlen.
Der Anlass heißt `upload` und ist **abschaltbar wie die anderen drei**. Die
Liste steht an zwei Stellen (Server und Oberfläche) — genau die Verdopplung,
die bei `platte` schon einmal ein Kreuzchen verschluckt hat; deshalb steht
jetzt in beiden ein Verweis auf die andere. Nachgemessen: vier geschickt, vier
gespeichert.
### ~~Papierkorb mit Größendeckel~~ — gebaut am 12.08.2026
20 GB. Beim Ausleeren wird erst nach Alter geräumt (30 Tage) und dann nach
Größe, **das Älteste zuerst** — dessen Versehen wäre am ehesten schon
aufgefallen. Die Grenze steht in der Antwort von `/api/dash/papierkorb`, damit
die Oberfläche sie nicht ein zweites Mal hinschreibt.
Bei 910 GB frei ist das heute keine Grenze, sondern eine Zusicherung: der
Papierkorb kann nicht mehr unbemerkt zur zweiten Ablage werden.
### Mehrere Sicherungsziele — offen
Die Archive liegen auf derselben Platte wie die Daten, die Kopie in der
Nextcloud ist die einzige Trennung.
--- ---
+97 -3
View File
@@ -39,7 +39,7 @@ import { now } from './db.js'
* Zwei Listen fuer dieselbe Sache sind zwei, von denen eine irgendwann falsch * Zwei Listen fuer dieselbe Sache sind zwei, von denen eine irgendwann falsch
* ist. Jetzt ist es eine. * ist. Jetzt ist es eine.
*/ */
export const ANLAESSE = ['sicherung', 'verwaiste', 'platte'] as const export const ANLAESSE = ['sicherung', 'verwaiste', 'platte', 'upload'] as const
export const MELDUNG_KEYS = { export const MELDUNG_KEYS = {
webhook: 'discord.webhook', webhook: 'discord.webhook',
@@ -152,8 +152,17 @@ export async function melde(
} }
} }
const groesse = (n: number) => // Die unterste Stufe ist neu: bisher meldete das hier nur Sicherungsarchive,
n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB` // und die sind nie klein. Ein 200-Byte-Bildschirmfoto wurde zu "0 KB" -- eine
// Zahl, die aussieht, als waere etwas kaputt.
export const groesse = (n: number): string =>
n < 1024
? `${n} Bytes`
: n < 1024 * 1024
? `${Math.round(n / 1024)} KB`
: n < 1024 * 1024 * 1024
? `${(n / 1024 / 1024).toFixed(1)} MB`
: `${(n / 1024 / 1024 / 1024).toFixed(1)} GB`
/** Wacht ueber die Sicherung. /** Wacht ueber die Sicherung.
* *
@@ -319,3 +328,88 @@ export async function pruefePlatte(): Promise<void> {
tag: 'PLATTE', tag: 'PLATTE',
}) })
} }
// ------------------------------------------------------------ Neues ist da
//
// WOZU. Etwas landet hier und niemand erfaehrt es. Eine Meldung darueber ist
// genau dann nuetzlich, wenn sie selten ist.
//
// UND GENAU DAS IST DAS PROBLEM: ein Serienlauf des Fotostudios legt 900
// Fahrzeugbilder ab. Neunhundert Nachrichten waeren keine Meldung mehr, sondern
// ein Ausfall des Kanals -- Discord drosselt Webhooks, und der Rest ginge
// verloren. Wer danach eine ECHTE Meldung bekommt, sieht sie nicht mehr.
//
// NUR TOKEN-UPLOADS, ABSICHTLICH. Was ueber das Dashboard hereinkommt, hat
// gerade jemand selbst hochgeladen und auf dem Bildschirm bestaetigt bekommen --
// eine Meldung darueber erzaehlt ihm, was er selbst getan hat. Und das
// Zurueckholen aus dem Papierkorb oder das Aufnehmen verwaister Dateien meldet
// gar nichts: das ist kein Neues, das ist ein Wiedergefundenes.
//
// DESHALB WIRD GESAMMELT. Nach der Ruhe kommt EINE Nachricht: wieviel, von wem,
// wohin, mit ein paar Beispielen. Ein Serienlauf ergibt eine Meldung, ein
// einzelnes Bild ergibt eine Meldung -- beide sagen dasselbe, nur mit anderen
// Zahlen.
/** Wie lange nach dem letzten Upload gewartet wird, bevor gemeldet wird.
*
* Zwei Minuten: lang genug, dass ein Serienlauf mit Pausen zwischen den
* Bildern als EIN Vorgang durchgeht, kurz genug, dass ein einzelnes Bild
* nicht gefuehlt verlorengeht. */
const SAMMEL_MS = 120_000
type Gesammelt = { anzahl: number; bytes: number; wer: Set<string>; pfade: string[] }
let gesammelt: Gesammelt | null = null
let zeitgeber: NodeJS.Timeout | null = null
/** Einen Upload vormerken. Meldet NICHT sofort, sondern nach der Ruhe. */
export function meldeUpload(pfad: string, bytes: number, wer: string): void {
// Frueh raus, wenn niemand Upload-Meldungen will: sonst sammelte der
// Dienst 900 Pfade zusammen, um sie am Ende wegzuwerfen.
if (!meldungConfig().anlaesse.includes('upload')) return
if (!gesammelt) gesammelt = { anzahl: 0, bytes: 0, wer: new Set(), pfade: [] }
gesammelt.anzahl++
gesammelt.bytes += bytes
gesammelt.wer.add(wer)
// Nur die ersten paar Pfade behalten: bei 900 Dateien waere die Liste
// groesser als die Nachricht, die sie beschreiben soll.
if (gesammelt.pfade.length < 5) gesammelt.pfade.push(pfad)
// JEDER Upload schiebt die Frist nach hinten. So wird aus einem Lauf mit
// Pausen ein Vorgang und nicht zwanzig.
if (zeitgeber) clearTimeout(zeitgeber)
zeitgeber = setTimeout(() => void sammelMelden(), SAMMEL_MS)
// Ohne unref() haelt ein offener Zeitgeber den Prozess am Leben und das
// Herunterfahren wartet zwei Minuten auf nichts.
zeitgeber.unref()
}
async function sammelMelden(): Promise<void> {
const g = gesammelt
// ERST leeren, dann melden: waehrend des Netzaufrufs kann der naechste
// Upload kommen, und der gehoert in die naechste Runde, nicht in diese.
gesammelt = null
zeitgeber = null
if (!g) return
const einzeln = g.anzahl === 1
// Auch im Log, nicht nur im Kanal: wer spaeter fragt „warum kam da nur
// EINE Meldung fuer 900 Bilder", findet hier die Antwort mit Zahl.
console.log(
`[upload] ${g.anzahl} Datei(en), ${groesse(g.bytes)}, von ${[...g.wer].join(', ')} -- eine Meldung`,
)
await melde(einzeln ? 'Etwas Neues ist da' : `${g.anzahl} neue Dateien`, {
text:
g.pfade.map((p) => `\`${p}\``).join('\n') +
(g.anzahl > g.pfade.length ? `\n… und ${g.anzahl - g.pfade.length} weitere` : ''),
felder: [
{ name: 'Zusammen', value: groesse(g.bytes), inline: true },
{ name: 'Von', value: [...g.wer].join(', ').slice(0, 100) || '—', inline: true },
],
schwere: 'gut',
anlass: 'upload',
tag: 'UPLOAD',
})
}
+117 -7
View File
@@ -55,7 +55,7 @@ import {
type Vorlage, type Vorlage,
} from '../settings.js' } from '../settings.js'
import { discordRoutes, redirectUri } from './discord.js' import { discordRoutes, redirectUri } from './discord.js'
import { ANLAESSE, MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js' import { ANLAESSE, groesse, MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
import { holeVonAdresse, HolFehler, type Geholt } from '../holen.js' import { holeVonAdresse, HolFehler, type Geholt } from '../holen.js'
import { neuerSchluessel } from './freigabe.js' import { neuerSchluessel } from './freigabe.js'
import { import {
@@ -106,6 +106,25 @@ const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const
* Versehen auffaellt, kurz genug, dass er nicht zur zweiten Ablage wird. */ * Versehen auffaellt, kurz genug, dass er nicht zur zweiten Ablage wird. */
const PAPIERKORB_TAGE = 30 const PAPIERKORB_TAGE = 30
/** Und wieviel er hoechstens halten darf.
*
* DIE ZEIT ALLEIN REICHT NICHT. Wer 200 GB loescht, haelt sie dreissig Tage
* lang doppelt -- und merkt es erst, wenn die Platte voll ist und der
* Waechter meldet. Der Deckel raeumt dann das AELTESTE zuerst weg, denn das
* ist das, dessen Versehen am ehesten schon aufgefallen waere.
*
* 20 GB bei 910 GB frei ist heute keine Grenze, sondern eine Zusicherung: der
* Papierkorb kann nicht mehr unbemerkt zur zweiten Ablage werden.
*
* Ueber `PAPIERKORB_MAX_MB` verstellbar -- nicht aus Bequemlichkeit, sondern
* weil sich ein Deckel von 20 GB nicht nachmessen laesst, ohne 20 GB zu
* loeschen. Wer ihn pruefen will, setzt ihn klein und sieht zu. */
const PAPIERKORB_MAX_BYTES = Math.round(
// Gerundet, weil Nachkommastellen erlaubt sind: 0.0002 MB ergaeben sonst
// einen Deckel von "209.7152 Bytes", und Bruchteile von Bytes gibt es nicht.
Number(process.env.PAPIERKORB_MAX_MB || 20 * 1024) * 1024 * 1024,
)
// ------------------------------------------------------------ Die Bremse // ------------------------------------------------------------ Die Bremse
// //
// Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch // Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch
@@ -1093,6 +1112,63 @@ dashRoutes.post('/upload-url', async (c) => {
* *
* WERTE STEHEN HIER NIE, nur Schluesselnamen. Unter den Einstellungen liegen * WERTE STEHEN HIER NIE, nur Schluesselnamen. Unter den Einstellungen liegen
* das Discord-Geheimnis und das Nextcloud-Passwort. */ * das Discord-Geheimnis und das Nextcloud-Passwort. */
/** Den Verlauf mitnehmen -- Dateien oder Verwaltung, als CSV.
*
* WOZU: der Export konnte bisher nur den BESTAND. Wer nachweisen oder
* nachrechnen will, was ueber einen Zeitraum passiert ist, sass vor einer
* Tabelle, die nach 500 Zeilen umblaettert.
*
* Dieselben zwei Eigenheiten wie beim Bestands-Export, und aus denselben
* Gruenden: SEMIKOLON, weil Excel in deutscher Einstellung sonst alles in eine
* Spalte nimmt, und CRLF, weil sonst die halbe Datei in einer Zelle steht.
* Beides ist dort nachgemessen worden; hier waere es ein zweiter Anlauf
* gewesen.
*/
dashRoutes.get('/verlauf/export', (c) => {
const welcher = c.req.query('was') === 'verwaltung' ? 'verwaltung' : 'dateien'
const stempel = new Date(now()).toISOString().slice(0, 10)
const feld = (f: unknown) => `"${String(f ?? '').replace(/"/g, '""')}"`
let kopf: string
let zeilen: string[]
if (welcher === 'verwaltung') {
const rows = db
.prepare('SELECT * FROM verwaltung ORDER BY at DESC LIMIT 20000')
.all() as VerwaltungZeile[]
kopf = 'wann;wer;bereich;tat;ziel;was'
zeilen = rows.map((r) =>
[new Date(r.at).toISOString(), r.wer, r.bereich, r.tat, r.ziel, r.was]
.map(feld)
.join(';'),
)
} else {
const rows = db
.prepare('SELECT * FROM events ORDER BY at DESC LIMIT 20000')
.all() as MediaEvent[]
kopf = 'wann;art;pfad;von;groesse;wer;woher'
zeilen = rows.map((r) =>
[
new Date(r.at).toISOString(),
r.kind,
r.path,
r.von ?? '',
r.size ?? '',
r.actor,
r.actor_kind,
]
.map(feld)
.join(';'),
)
}
return c.body([kopf, ...zeilen].join('\r\n'), 200, {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="d4rk_media-${welcher}-${stempel}.csv"`,
'Cache-Control': 'no-store',
})
})
dashRoutes.get('/verwaltung', (c) => { dashRoutes.get('/verwaltung', (c) => {
const limit = Math.min(Number(c.req.query('limit') ?? 100) || 100, 500) const limit = Math.min(Number(c.req.query('limit') ?? 100) || 100, 500)
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0) const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
@@ -2305,15 +2381,45 @@ async function inPapierkorb(row: Media, wer: string): Promise<void> {
* Stuendlich geprueft und nicht taeglich: ein Dienst, der einmal am Tag * Stuendlich geprueft und nicht taeglich: ein Dienst, der einmal am Tag
* aufraeumt, raeumt nach einem Neustart um 23:59 gar nicht auf. */ * aufraeumt, raeumt nach einem Neustart um 23:59 gar nicht auf. */
export async function papierkorbAusleeren(): Promise<number> { export async function papierkorbAusleeren(): Promise<number> {
const alt = db const weg = async (e: PapierkorbEintrag) => {
.prepare('SELECT * FROM papierkorb WHERE geloescht < ?')
.all(now() - PAPIERKORB_TAGE * 86_400_000) as PapierkorbEintrag[]
for (const e of alt) {
await rm(papierkorbPfad(e.ablage), { force: true }) await rm(papierkorbPfad(e.ablage), { force: true })
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id) db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id)
} }
if (alt.length) console.log(`[papierkorb] ${alt.length} Eintraege endgueltig entfernt`)
return alt.length // 1. Was zu ALT ist.
const alt = db
.prepare('SELECT * FROM papierkorb WHERE geloescht < ?')
.all(now() - PAPIERKORB_TAGE * 86_400_000) as PapierkorbEintrag[]
for (const e of alt) await weg(e)
if (alt.length) console.log(`[papierkorb] ${alt.length} Eintraege zu alt, entfernt`)
// 2. Und was zu VIEL ist.
//
// Die Zeit allein reicht nicht: wer 200 GB loescht, haelt sie dreissig Tage
// lang doppelt. Geraeumt wird von ALTEN her -- deren Versehen waere am
// ehesten schon aufgefallen.
let zuViel = 0
const summe = () =>
(db.prepare('SELECT COALESCE(SUM(size),0) AS b FROM papierkorb').get() as { b: number }).b
if (summe() > PAPIERKORB_MAX_BYTES) {
const nachAlter = db
.prepare('SELECT * FROM papierkorb ORDER BY geloescht ASC')
.all() as PapierkorbEintrag[]
let belegt = summe()
for (const e of nachAlter) {
if (belegt <= PAPIERKORB_MAX_BYTES) break
await weg(e)
belegt -= e.size
zuViel++
}
console.log(
`[papierkorb] Deckel erreicht: ${zuViel} der aeltesten Eintraege entfernt, ` +
`jetzt ${groesse(belegt)} von ${groesse(PAPIERKORB_MAX_BYTES)}`,
)
}
return alt.length + zuViel
} }
dashRoutes.get('/papierkorb', async (c) => { dashRoutes.get('/papierkorb', async (c) => {
@@ -2349,6 +2455,10 @@ dashRoutes.get('/papierkorb', async (c) => {
items, items,
...summe, ...summe,
haltbarTage: PAPIERKORB_TAGE, haltbarTage: PAPIERKORB_TAGE,
// Der Deckel gehoert in die Antwort, damit die Oberflaeche ihn nicht
// erraet -- eine zweite Zahl fuer dieselbe Grenze waere eine, die
// irgendwann von der ersten abweicht.
maxBytes: PAPIERKORB_MAX_BYTES,
verwaist: items.filter((e) => !e.dateiDa).length, verwaist: items.filter((e) => !e.dateiDa).length,
}) })
}) })
+2
View File
@@ -28,6 +28,7 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import type { Context, Next } from 'hono' import type { Context, Next } from 'hono'
import { config } from '../config.js' import { config } from '../config.js'
import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js' import { db, logEvent, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.js' import { tokenAllows, tokenFromHeader } from '../auth.js'
import { kontingentUeberschritten } from './upload.js' import { kontingentUeberschritten } from './upload.js'
@@ -305,6 +306,7 @@ async function ablegen(
} }
logEvent(vorhanden ? 'replace' : 'upload', path, data.length, 'token', token.name) 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) } return { ok: true, path, url: publicUrlFor(path), id: digest.slice(0, 12) }
} }
+2
View File
@@ -22,6 +22,7 @@
import { Hono } from 'hono' import { Hono } from 'hono'
import type { Context, Next } from 'hono' import type { Context, Next } from 'hono'
import { config } from '../config.js' import { config } from '../config.js'
import { meldeUpload } from '../meldung.js'
import { db, logEvent, now, type Media, type Token } from '../db.js' import { db, logEvent, now, type Media, type Token } from '../db.js'
import { tokenAllows, tokenFromHeader } from '../auth.js' import { tokenAllows, tokenFromHeader } from '../auth.js'
import { import {
@@ -321,6 +322,7 @@ uploadRoutes.post('/upload', async (c) => {
} }
logEvent(existing ? 'replace' : 'upload', path, daten.length, 'token', token.name) logEvent(existing ? 'replace' : 'upload', path, daten.length, 'token', token.name)
meldeUpload(path, daten.length, token.name)
return c.json({ return c.json({
url: publicUrlFor(path), url: publicUrlFor(path),
@@ -4,10 +4,17 @@ import { api } from '../api'
import { run, useStore } from '../store' import { run, useStore } from '../store'
import { Feld, Karte, Knopf } from './Bausteine' import { Feld, Karte, Knopf } from './Bausteine'
// ACHTUNG, DIESE LISTE HAT EINE ZWILLINGSSCHWESTER: `ANLAESSE` in
// server/src/meldung.ts. Der Server verwirft beim Speichern alles, was dort
// nicht steht -- eine Zeile, die nur hier steht, ergibt ein Kreuzchen, das sich
// setzen laesst und beim naechsten Laden wieder weg ist. Genau das ist mit
// 'platte' schon einmal passiert: gesendet 3, gespeichert 2. Wer hier etwas
// ergaenzt, ergaenzt es DORT auch.
const ANLAESSE = [ const ANLAESSE = [
['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'], ['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'],
['verwaiste', 'Verwaiste Dateien gefunden'], ['verwaiste', 'Verwaiste Dateien gefunden'],
['platte', 'Der Platz auf der Platte wird knapp'], ['platte', 'Der Platz auf der Platte wird knapp'],
['upload', 'Neue Dateien sind angekommen'],
] as const ] as const
/** Meldungen nach Discord. /** Meldungen nach Discord.
+36 -7
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
import { import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Download,
History, History,
Loader2, Loader2,
PenLine, PenLine,
@@ -371,6 +372,10 @@ function Reiter({
reiter: 'dateien' | 'verwaltung' reiter: 'dateien' | 'verwaltung'
setReiter: (r: 'dateien' | 'verwaltung') => void setReiter: (r: 'dateien' | 'verwaltung') => void
}) { }) {
// Jeder Knopf gibt Rueckmeldung, auch der erfolgreiche: ein Download, der
// nur im Browser-Balken auftaucht, sieht aus wie ein Knopf, der nichts tut.
const note = useStore((s) => s.note)
const stil = (an: boolean) => const stil = (an: boolean) =>
`rounded-md px-3 py-1.5 text-sm ${ `rounded-md px-3 py-1.5 text-sm ${
an an
@@ -378,15 +383,39 @@ function Reiter({
: 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift' : 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift'
}` }`
return ( return (
<div className="flex gap-1 self-start rounded-lg border border-rand bg-flaeche p-1"> <div className="flex flex-wrap items-center gap-2">
<button className={stil(reiter === 'dateien')} onClick={() => setReiter('dateien')}> <div className="flex gap-1 rounded-lg border border-rand bg-flaeche p-1">
Dateien <button className={stil(reiter === 'dateien')} onClick={() => setReiter('dateien')}>
</button> Dateien
</button>
<button
className={stil(reiter === 'verwaltung')}
onClick={() => setReiter('verwaltung')}
>
Verwaltung
</button>
</div>
{/* Der Knopf holt IMMER 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. */}
<button <button
className={stil(reiter === 'verwaltung')} onClick={() => {
onClick={() => setReiter('verwaltung')} // Ueber einen Link und nicht ueber fetch: so bekommt die
// Datei den Namen aus dem Content-Disposition und landet im
// Download-Ordner statt im Speicher des Browsers.
const a = document.createElement('a')
a.href = `/api/dash/verlauf/export?was=${reiter}`
a.rel = 'noreferrer'
a.click()
note('ok', `Der ${reiter === 'dateien' ? 'Datei' : 'Verwaltungs'}-Verlauf wird heruntergeladen.`)
}}
title="Als CSV herunterladen — Semikolon und CRLF, damit Excel es direkt aufmacht"
className="flex items-center gap-1.5 rounded-md border border-rand px-3 py-1.5
text-sm text-schrift-leise hover:bg-flaeche-hoch hover:text-schrift"
> >
Verwaltung <Download size={15} />
CSV
</button> </button>
</div> </div>
) )