diff --git a/ROADMAP.md b/ROADMAP.md index 750385f..32bdf9f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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_... ``` +### ✅ 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 **Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container diff --git a/docker-compose.yml b/docker-compose.yml index adeb774..c3526a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,12 @@ services: MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-64} 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 # NPM mit TLS ist das richtig. # diff --git a/docs/API.md b/docs/API.md index bbd62cc..5e81a05 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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.16–31.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. --- diff --git a/docs/ideen.md b/docs/ideen.md index 3f68776..f115386 100644 --- a/docs/ideen.md +++ b/docs/ideen.md @@ -98,14 +98,50 @@ einer Stunde Klickerei fünf Minuten. ## 3 · Wäre schön, drängt nicht -- **Verlauf als CSV** — der Export kann heute nur Medien, nicht Ereignisse. -- **Meldung bei Upload** — Discord-Nachricht, wenn über einen Token etwas - Neues ankommt. Nützlich für Bildschirmfotos aus dem Spiel, sinnlos für einen - Serienlauf über 900 Fahrzeuge; bräuchte also eine Drossel. -- **Papierkorb mit Größendeckel** — heute nur 30 Tage. Wer 200 GB löscht, hat - sie 30 Tage lang doppelt. Bei 910 GB frei ist das keine Not. -- **Mehrere Sicherungsziele** — die Archive liegen auf derselben Platte wie die - Daten, die Kopie in der Nextcloud ist die einzige Trennung. +### ~~Verlauf als CSV~~ — gebaut am 12.08.2026 + +`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, 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. --- diff --git a/server/src/meldung.ts b/server/src/meldung.ts index af39ee5..eb5b00e 100644 --- a/server/src/meldung.ts +++ b/server/src/meldung.ts @@ -39,7 +39,7 @@ import { now } from './db.js' * Zwei Listen fuer dieselbe Sache sind zwei, von denen eine irgendwann falsch * 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 = { webhook: 'discord.webhook', @@ -152,8 +152,17 @@ export async function melde( } } -const groesse = (n: number) => - n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB` +// Die unterste Stufe ist neu: bisher meldete das hier nur Sicherungsarchive, +// 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. * @@ -319,3 +328,88 @@ export async function pruefePlatte(): Promise { 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; 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 { + 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', + }) +} diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index a4caedd..e406f71 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -55,7 +55,7 @@ import { type Vorlage, } from '../settings.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 { neuerSchluessel } from './freigabe.js' 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. */ 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 // // 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 * 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) => { const limit = Math.min(Number(c.req.query('limit') ?? 100) || 100, 500) const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0) @@ -2305,15 +2381,45 @@ async function inPapierkorb(row: Media, wer: string): Promise { * Stuendlich geprueft und nicht taeglich: ein Dienst, der einmal am Tag * aufraeumt, raeumt nach einem Neustart um 23:59 gar nicht auf. */ export async function papierkorbAusleeren(): Promise { - const alt = db - .prepare('SELECT * FROM papierkorb WHERE geloescht < ?') - .all(now() - PAPIERKORB_TAGE * 86_400_000) as PapierkorbEintrag[] - for (const e of alt) { + const weg = async (e: PapierkorbEintrag) => { await rm(papierkorbPfad(e.ablage), { force: true }) 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) => { @@ -2349,6 +2455,10 @@ dashRoutes.get('/papierkorb', async (c) => { items, ...summe, 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, }) }) diff --git a/server/src/routes/fivemanage.ts b/server/src/routes/fivemanage.ts index 0fb812e..69b73d7 100644 --- a/server/src/routes/fivemanage.ts +++ b/server/src/routes/fivemanage.ts @@ -28,6 +28,7 @@ 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' @@ -305,6 +306,7 @@ async function ablegen( } 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) } } diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index b38a1e9..feb4d25 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -22,6 +22,7 @@ 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 { @@ -321,6 +322,7 @@ uploadRoutes.post('/upload', async (c) => { } logEvent(existing ? 'replace' : 'upload', path, daten.length, 'token', token.name) + meldeUpload(path, daten.length, token.name) return c.json({ url: publicUrlFor(path), diff --git a/server/ui/src/components/MeldungenKarte.tsx b/server/ui/src/components/MeldungenKarte.tsx index fde73a0..41f82e2 100644 --- a/server/ui/src/components/MeldungenKarte.tsx +++ b/server/ui/src/components/MeldungenKarte.tsx @@ -4,10 +4,17 @@ import { api } from '../api' import { run, useStore } from '../store' 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 = [ ['sicherung', 'Sicherung fehlgeschlagen oder ausgeblieben'], ['verwaiste', 'Verwaiste Dateien gefunden'], ['platte', 'Der Platz auf der Platte wird knapp'], + ['upload', 'Neue Dateien sind angekommen'], ] as const /** Meldungen nach Discord. diff --git a/server/ui/src/pages/Verlauf.tsx b/server/ui/src/pages/Verlauf.tsx index 14cdee7..e2d9e36 100644 --- a/server/ui/src/pages/Verlauf.tsx +++ b/server/ui/src/pages/Verlauf.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { ChevronLeft, ChevronRight, + Download, History, Loader2, PenLine, @@ -371,6 +372,10 @@ function Reiter({ reiter: 'dateien' | 'verwaltung' 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) => `rounded-md px-3 py-1.5 text-sm ${ an @@ -378,15 +383,39 @@ function Reiter({ : 'text-schrift-leise hover:bg-flaeche-hoch/50 hover:text-schrift' }` return ( -
- +
+
+ + +
+ + {/* 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. */}
)