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 -7
View File
@@ -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<void> {
* 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<number> {
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,
})
})
+2
View File
@@ -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) }
}
+2
View File
@@ -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),