feat: Papierkorb, Abrufzaehler, WebP ohne Adressaenderung, ZIP hochladen

Vier Dinge, alle am laufenden Dienst gemessen.

PAPIERKORB. Loeschen war endgueltig -- bei einem Knopf "alle 3861 waehlen"
direkt neben "loeschen" ist das die falsche Haerte, und die Sicherung half nur
bis zum letzten naechtlichen Lauf. Die Datei wandert jetzt nach
/data/papierkorb, der Datensatz in eine eigene Tabelle, nach 30 Tagen raeumt
der Dienst stuendlich selbst auf. Der Ablagename traegt eine laufende Nummer:
unter demselben Pfad koennen nacheinander verschiedene Dateien gelegen haben,
und beide sollen zurueckholbar sein. Geprueft: loeschen -> oeffentlich 404,
Eintrag im Papierkorb, zurueckholen -> 200 samt neu gerechneter Vorschau.

ABRUFZAEHLER. media.abrufe und zuletzt_abgerufen. Gezaehlt wird im Speicher und
alle 30 Sekunden weggeschrieben -- ein UPDATE je Kachel waeren bei einer
Galerieseite 120 Schreibvorgaenge. Der 304 zaehlt mit (der Aufrufer WOLLTE die
Datei, er hatte sie nur schon), HEAD nicht. Was vor dem Einbau lag, steht als
"nie geholt" da, auch wenn es taeglich benutzt wurde -- die Karte im
Speicherbericht sagt das auch dazu, statt eine Zahl zu zeigen, die luegt.

WEBP OHNE ADRESSAENDERUNG. Der Pfad ist die Adresse: aus items/foo.png darf
nicht items/foo.webp werden. Die sparsame Fassung liegt deshalb DANEBEN und
wird unter DERSELBEN Adresse ausgeliefert, wenn der Aufrufer per Accept sagt,
dass er WebP versteht. Nachgemessen an einer Datei: 6448 Bytes fuer curl, 2758
fuer einen Browser. Ueber den ganzen Bestand: 3606 Fassungen, 199,3 MB ->
33,1 MB, gespart 166,2 MB. Bei 29 lohnt WebP nicht -- dort bleibt es beim
Original.

Zwei Dinge haengen daran, beide gegengeprueft: "Vary: Accept" (ohne die Zeile
legt ein Zwischenspeicher die WebP-Fassung fuer alle ab, auch fuer die, die sie
nicht lesen koennen) und getrennte ETags mit "-w" (sonst bekaeme jemand auf
seinen If-None-Match hin ein 304 fuer das falsche Bild). Gemessen: derselbe
ETag mit Accept -> 304, ohne Accept -> 200 mit dem PNG.

ZIP HOCHLADEN. Ein Archiv wird ausgepackt statt abgelegt, die Ordner darin
bleiben erhalten und haengen sich hinter den gewaehlten Zielordner. fflate und
nicht adm-zip: reines JavaScript, keine native Bibliothek -- dieses Projekt hat
schon einen halben Abend an einer ABI-Nummer verloren. Grenzen: 5000 Eintraege,
256 MB entpackt, geprueft VOR dem Entpacken (eine Zip-Bombe waere sonst schon
im Speicher). Zip-Slip gemessen: "../../../../etc/passwd" und
"..\..\windows\hosts" abgelehnt, die harmlose Datei im selben Archiv abgelegt.

UND EIN KNOPF, DER GELOGEN HAT. Der erste Nachruestlauf ueber alle Dateien
brauchte mehr als 90 Sekunden -- genau da gibt Nginx Proxy Manager auf. Der
Aufrufer sah einen 504, waehrend die Arbeit im Hintergrund weiterlief und
fertig wurde. Ein Knopf, der Erfolg als Fehler meldet, ist schlimmer als einer
ohne Rueckmeldung.

Beide Wartungsknoepfe laufen deshalb in Runden zu 300 Stueck: der Server meldet
"offen" und "fertig", die Oberflaeche ruft erneut auf und zeigt dabei "280
erzeugt, noch 129 offen ...". Gemessen: 7,8 s je Runde statt 90+ am Stueck. Der
Vorschau-Knopf hatte dieselbe Wand und wurde mitgezogen, obwohl er noch nicht
dagegengelaufen war.

Nebenbei: der Verwaisten-Sucher kennt jetzt auch /data/webp (gleiche Schleife,
Ordner im Ergebnis vorangestellt), Verschieben und Loeschen nehmen die sparsame
Fassung mit, und das Loeschen eines Ordners raeumt thumbs/ und webp/ hinterher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 21:40:55 +02:00
co-authored by Claude Opus 5
parent e4a125e1c6
commit 40c446f859
15 changed files with 1334 additions and 134 deletions
+19 -3
View File
@@ -58,12 +58,27 @@ Dass das Sitzungs-Cookie dabei ankommt, liegt an einer Ausnahme: es trägt
**Hochladen** — über die Skript-API mit Token (drei Rumpfformen: multipart,
Base64, rohe Bytes) oder aus dem Dashboard per Ziehen und Ablegen. Bilder,
Videos, Ton, PDF.
Videos, Ton, PDF. Ein **ZIP wird ausgepackt** statt abgelegt, und die Ordner
darin bleiben erhalten — 3670 Bilder einzeln ins Fenster zu ziehen ist keine
Bedienung.
**Ausliefern** — mit ETag, `304`, Bereichsanfragen und `nosniff`. Vorschaubilder
werden beim Upload erzeugt (320 px WebP): aus 7,5 MB je Galerieseite werden
766 KB.
**Sparen, ohne Adressen zu ändern** — neben jedem PNG und JPEG liegt eine
WebP-Fassung, ausgeliefert unter *derselben* Adresse je nach `Accept` des
Aufrufers. Nachgemessen über den ganzen Bestand: **199 MB → 33 MB**, das sind
83 %. Für Lua ändert sich nichts — es schickt kein `Accept: image/webp` und
bekommt weiterhin das Original.
**Zurückholen** — Gelöschtes liegt 30 Tage im Papierkorb, danach räumt der
Dienst selbst auf. Die Datei ist dann nur nicht mehr öffentlich, nicht weg.
**Zählen** — wie oft eine Datei geholt wurde und wann zuletzt. Beantwortet die
Frage, die ein Aufbewahrungsbericht sonst nicht beantworten kann: *braucht das
noch jemand?*
**Verwalten** — Galerie mit Ordnerbaum, Suche, Filter nach Medienart,
Sortierung, Mehrfachauswahl, Verschieben, Löschen. Betrachter für Bild, Video,
Ton und PDF ohne neuen Tab.
@@ -137,8 +152,9 @@ tar xzf d4rk_media-JJJJ-MM-TT-HHMM.tar.gz
```
Ergibt `media.db` und `files/`. Beides ins Volume legen, während der Container
steht. Die Vorschaubilder sind **nicht** im Archiv — sie lassen sich mit einem
Knopf unter *Speicher* neu rechnen.
steht. Vorschaubilder und WebP-Fassungen sind **nicht** im Archiv — beides ist
abgeleitet und wird mit zwei Knöpfen unter *Speicher → Wartung* neu gerechnet.
Der Papierkorb ist ebenfalls nicht drin: was dort liegt, ist gelöscht.
---
+45 -3
View File
@@ -486,10 +486,52 @@ darin lag.
2. Den Proxy-Host `fivecdn.d4rkst3r.de` löschen — er zeigt auf den toten Port
9100 und gibt 502.
### ⬜ Als Nächstes
### ✅ Fertig — Papierkorb, Abrufzähler, sparsame Fassungen, Archive
**5 · Umzug.** Root-`docker-compose.yml` wird unsere; die Fivemanage-Dateien
wandern nach `legacy/`.
Vier Dinge, gemessen und im Betrieb:
**Papierkorb.** Löschen war endgültig, und das bei einem Knopf „alle 3861
wählen" direkt neben „löschen". Jetzt wandert die Datei nach `/data/papierkorb`
und der Datensatz in eine eigene Tabelle; nach **30 Tagen** räumt der Dienst
stündlich selbst auf. Ende zu Ende geprüft: löschen → öffentlich 404, Eintrag
im Papierkorb, zurückholen → 200 samt neu gerechneter Vorschau.
**Abrufzähler.** `media.abrufe` und `zuletzt_abgerufen`. Gezählt wird im
Speicher und alle 30 Sekunden weggeschrieben — ein `UPDATE` je Kachel wären
bei einer Galerieseite 120 Schreibvorgänge. Der 304 zählt mit (der Aufrufer
*wollte* die Datei), `HEAD` nicht. **Was vor dem Einbau lag, steht als „nie
geholt" da, auch wenn es täglich benutzt wurde** — das sagt die Karte im
Speicherbericht auch dazu.
**Sparsame Fassungen ohne Adressänderung.** Neben jedem PNG/JPEG liegt eine
WebP-Fassung, ausgeliefert unter *derselben* Adresse je nach `Accept`.
Nachgemessen: 6448 → 2758 Bytes für einen Browser, unverändert 6448 für `curl`.
Mit `Vary: Accept` und getrennten ETags (`…-w`). Details in `docs/API.md`.
Über den ganzen Bestand nachgerechnet: **3606 Fassungen, 199,3 MB → 33,1 MB,
gespart 166,2 MB (83 %).** Bei **29** lohnt WebP nicht — dort bleibt es beim
Original, und das ist kein Fehler, sondern die Regel „nur wenn es kleiner wird".
**Und ein Knopf, der gelogen hat.** Der erste Nachrüstlauf über alle Dateien
brauchte mehr als 90 Sekunden — genau da gibt Nginx Proxy Manager auf
(`proxy_read_timeout`). Ergebnis: **504 im Browser, während die Arbeit im
Hintergrund weiterlief und fertig wurde.** Ein Knopf, der Erfolg als Fehler
meldet, ist schlimmer als einer ohne Rückmeldung.
Behoben, indem beide Wartungsknöpfe jetzt in **Runden zu 300 Stück** laufen:
der Server meldet `offen` und `fertig`, die Oberfläche ruft erneut auf und
zeigt dabei „280 erzeugt, noch 129 offen …". Nachgemessen: 7,8 s je Runde
statt 90+ am Stück. Der Vorschau-Knopf hatte dieselbe Wand und wurde
mitgezogen, obwohl er noch nicht dagegengelaufen war.
**ZIP hochladen.** Ein Archiv wird ausgepackt statt abgelegt, die Ordner darin
bleiben erhalten und hängen sich hinter den gewählten Zielordner. `fflate`,
also reines JavaScript — dieses Projekt hat schon einen halben Abend an einer
ABI-Nummer verloren. Grenzen: 5000 Einträge, 256 MB entpackt, geprüft **vor**
dem Entpacken (Zip-Bombe). Zip-Slip nachgemessen: `../../../../etc/passwd` und
`..\..\windows\hosts` abgelehnt, die harmlose Datei im selben Archiv abgelegt.
### ⬜ Als Nächstes
**Der Vergleichsmaßstab ist weg.** Am 11.08.2026 sind die Lite-Container
gelöscht worden — mit ihnen die Möglichkeit, im Zweifel nachzusehen, wie es
+46
View File
@@ -37,6 +37,52 @@ und niemand merkte es, bis das Bild fehlt.
> Ein führender Unterstrich fällt durch. Das ist kein erfundener Fall: das
> Fotostudio legt seine Leerbilder als `_plate` ab.
### Groß- und Kleinschreibung
Was über das Dashboard oder ein Werkzeug hochgeladen wird, kommt **klein**
an: `WEAPON_SMG.png` liegt als `weapon_smg.png`. Der Grund ist, dass ein Name
zu einer Adresse wird und Adressen sich nicht auf die Schreibweise eines
Dateisystems verlassen sollen.
Für ein Skript heißt das: **die Vorlage arbeitet mit Kleinbuchstaben.**
```lua
local url = ('https://fivemanage.d4rkst3r.de/f/items/%s.png'):format(item:lower())
```
Über `/api/upload` mit eigenem `X-Path` gilt das nicht — dort steht der Pfad
so, wie er geschickt wird.
---
## Dieselbe Adresse, zwei Fassungen
Neben jedem PNG und JPEG kann eine **WebP-Fassung** liegen. Ausgeliefert wird
sie unter **derselben Adresse** — welche der beiden kommt, entscheidet die
Kopfzeile `Accept` des Aufrufers:
```
Accept: …image/webp… → Content-Type: image/webp (klein)
kein Accept → Content-Type: image/png (das Original)
```
Nachgemessen an `probe/probe-webp.png`: 6448 Bytes für `curl`, 2758 Bytes für
einen Browser. **Für ein Skript ändert sich nichts** — Lua schickt kein
`Accept: image/webp` und bekommt weiterhin Byte für Byte das Original.
Zwei Dinge, die daran hängen:
- Die Antwort trägt `Vary: Accept`. Ohne die Zeile legte ein Zwischenspeicher
die WebP-Fassung für alle ab, auch für die, die sie nicht lesen können.
- Die beiden Fassungen haben **verschiedene ETags** (die WebP-Fassung endet auf
`-w`). Sonst bekäme jemand auf seinen `If-None-Match` hin ein 304 für das
falsche Bild.
Erzeugt werden die Fassungen beim Hochladen; für alles, was vorher schon da
war, gibt es im Dashboard unter **Speicher → Wartung** den Knopf
„Sparsame Fassungen erzeugen". Wo WebP nicht kleiner ausfällt, entsteht keine —
dann bleibt es beim Original.
---
## Anmeldung: Token
+7
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@hono/node-server": "^1.13.7",
"better-sqlite3": "^11.7.0",
"fflate": "^0.8.3",
"hono": "^4.6.14",
"sharp": "^0.35.3"
},
@@ -1226,6 +1227,12 @@
"node": ">=6"
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@hono/node-server": "^1.13.7",
"better-sqlite3": "^11.7.0",
"fflate": "^0.8.3",
"hono": "^4.6.14",
"sharp": "^0.35.3"
},
+85
View File
@@ -98,6 +98,29 @@ CREATE INDEX IF NOT EXISTS events_at ON events(at DESC);
-- Port, Datenordner) bleibt in der Umgebung, damit ein falscher Klick den
-- Dienst nicht unerreichbar macht. Hier liegt nur, was ohne Neustart
-- verstellbar sein soll -- derzeit die Discord-Anmeldung.
-- Der Papierkorb.
--
-- Loeschen war bis hierhin endgueltig: die Datei war von der Platte weg, und
-- die Sicherung half nur bis zum letzten naechtlichen Lauf. Bei einem Knopf
-- "alle 3670 waehlen" direkt neben "loeschen" ist das die falsche Haerte.
--
-- Die Datei wandert deshalb nach /data/papierkorb und der Datensatz hierher.
-- Was hier steht, ist alles, was zum Zurueckholen noetig ist -- der Pfad ist
-- der Schluessel, und den behalten wir.
CREATE TABLE IF NOT EXISTS papierkorb (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL,
size INTEGER NOT NULL,
sha256 TEXT NOT NULL,
mime TEXT NOT NULL,
geloescht INTEGER NOT NULL,
von TEXT NOT NULL,
-- Der Name IM Papierkorb. Nicht der Pfad: zwei Dateien koennen nacheinander
-- unter demselben Pfad gelegen haben, und beide sollen zurueckholbar sein.
ablage TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS papierkorb_zeit ON papierkorb(geloescht DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
@@ -141,6 +164,16 @@ CREATE TABLE IF NOT EXISTS settings (
console.log('[db] Spalte events.von angelegt')
}
// Abrufe. Die Frage "was braucht eigentlich noch jemand" laesst sich von
// innen nicht beantworten -- von aussen schon: wer wird geholt, wie oft,
// wann zuletzt. Das ist die Zahl, die dem Aufbewahrungsbericht fehlt.
const mSpalten = db.prepare('PRAGMA table_info(media)').all() as { name: string }[]
if (!mSpalten.some((s) => s.name === 'abrufe')) {
db.exec('ALTER TABLE media ADD COLUMN abrufe INTEGER NOT NULL DEFAULT 0')
db.exec('ALTER TABLE media ADD COLUMN zuletzt_abgerufen INTEGER')
console.log('[db] Spalten media.abrufe und zuletzt_abgerufen angelegt')
}
if (!spalten.some((s) => s.name === 'discord_avatar')) {
db.exec('ALTER TABLE users ADD COLUMN discord_avatar TEXT')
db.exec('ALTER TABLE users ADD COLUMN discord_name TEXT')
@@ -236,6 +269,20 @@ export type Media = {
token_id: number | null
created_at: number
updated_at: number
/** Wie oft die Datei oeffentlich geholt wurde. */
abrufe: number
zuletzt_abgerufen: number | null
}
export type PapierkorbEintrag = {
id: number
path: string
size: number
sha256: string
mime: string
geloescht: number
von: string
ablage: string
}
export type EventKind = 'upload' | 'replace' | 'delete' | 'move'
@@ -282,6 +329,44 @@ export function logEvent(
/** Der Verlauf waechst mit jedem Upload. Bei 900 Fahrzeugen je Lauf sind das
* 900 Zeilen -- kein Problem, aber auf Dauer auch kein Grund, alles zu
* behalten. Ein halbes Jahr reicht fuer die Frage "wer war das". */
// -- Abrufe zaehlen, ohne die Auslieferung zu bremsen -------------------------
//
// Ein UPDATE je Bildabruf waere bei einer Galerieseite mit 120 Kacheln ein
// Schreibvorgang je Kachel. Stattdessen wird im Speicher gezaehlt und
// gelegentlich weggeschrieben -- wer wissen will, ob eine Datei benutzt wird,
// braucht keine Genauigkeit auf die Sekunde.
//
// Was bei einem Absturz verlorengeht, sind hoechstens 30 Sekunden Zaehlerei.
const offeneAbrufe = new Map<string, { anzahl: number; zuletzt: number }>()
export function zaehleAbruf(path: string): void {
const bisher = offeneAbrufe.get(path)
if (bisher) {
bisher.anzahl++
bisher.zuletzt = now()
} else {
offeneAbrufe.set(path, { anzahl: 1, zuletzt: now() })
}
}
const abrufSchreiben = db.prepare(
'UPDATE media SET abrufe = abrufe + ?, zuletzt_abgerufen = ? WHERE path = ?',
)
export function schreibeAbrufe(): void {
if (offeneAbrufe.size === 0) return
const stapel = [...offeneAbrufe.entries()]
offeneAbrufe.clear()
try {
db.transaction(() => {
for (const [path, w] of stapel) abrufSchreiben.run(w.anzahl, w.zuletzt, path)
})()
} catch (err) {
console.error('[abrufe] konnten nicht geschrieben werden:', err)
}
}
export function pruneEvents(tage = 180) {
db.prepare('DELETE FROM events WHERE at < ?').run(now() - tage * 86_400_000)
}
+65 -6
View File
@@ -28,11 +28,11 @@ import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { config } from './config.js'
import { ensureAdmin } from './auth.js'
import { pruneSessions } from './db.js'
import { pruneSessions, schreibeAbrufe, zaehleAbruf } from './db.js'
import { pruefeSicherung } from './meldung.js'
import { absolutePath, checkPath, mimeFor, thumbPath } from './storage.js'
import { absolutePath, checkPath, lohntWebp, mimeFor, thumbPath, webpPfad } from './storage.js'
import { uploadRoutes } from './routes/upload.js'
import { dashRoutes } from './routes/dash.js'
import { dashRoutes, papierkorbAusleeren } from './routes/dash.js'
const app = new Hono()
@@ -97,7 +97,7 @@ async function serveFile(c: any, rawPath: string) {
return c.text('nicht gefunden', 404)
}
const absolute = absolutePath(path)
let absolute = absolutePath(path)
let info
try {
info = await stat(absolute)
@@ -106,10 +106,45 @@ async function serveFile(c: any, rawPath: string) {
return c.text('nicht gefunden', 404)
}
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`
// DIE SPARSAME FASSUNG, unter DERSELBEN Adresse.
//
// Der Browser sagt mit `Accept`, was er versteht. Versteht er WebP und
// liegt eine Fassung daneben, bekommt er sie. Die Adresse bleibt
// items/foo.png, und kein Skript merkt etwas davon. Lua, curl und alles
// ohne diese Ansage bekommen unveraendert das Original.
//
// Der Vergleich ist absichtlich stumpf: `Accept` traegt Gewichte, und ein
// "image/webp;q=0" hiesse eigentlich "bitte nicht". Das schickt in der
// Praxis niemand -- und faellt jemand doch darauf herein, bekommt er ein
// gueltiges Bild, nur in einem anderen Format. Der Preis eines Fehlers ist
// hier klein genug, dass die einfache Pruefung die richtige ist.
let typ = mimeFor(path)
let sparsam = false
if (lohntWebp(typ) && (c.req.header('accept') ?? '').includes('image/webp')) {
try {
const wInfo = await stat(webpPfad(path))
if (wInfo.isFile()) {
absolute = webpPfad(path)
info = wInfo
typ = 'image/webp'
sparsam = true
}
} catch {
/* keine sparsame Fassung -- dann eben das Original */
}
}
// Das Kennzeichen gehoert IN den ETag: sonst traegt das Original und die
// sparsame Fassung dieselbe Marke, sobald beide zufaellig gleich gross und
// gleich alt sind, und ein Aufrufer bekaeme auf seinen 304 hin das falsche
// Bild aus seinem eigenen Zwischenspeicher.
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}${sparsam ? '-w' : ''}"`
const headers: Record<string, string> = {
'Content-Type': mimeFor(path),
'Content-Type': typ,
'Cache-Control': 'public, max-age=300',
// OHNE DIESE ZEILE legt ein Zwischenspeicher die WebP-Fassung fuer ALLE
// ab -- auch fuer die, die sie nicht lesen koennen.
Vary: 'Accept',
ETag: etag,
'Last-Modified': info.mtime.toUTCString(),
'Accept-Ranges': 'bytes',
@@ -118,6 +153,13 @@ async function serveFile(c: any, rawPath: string) {
'X-Content-Type-Options': 'nosniff',
}
// GEZAEHLT WIRD HIER, nach der Pruefung und vor der Auslieferung.
//
// Auch der 304 zaehlt: der Aufrufer WOLLTE die Datei, er hatte sie nur
// schon. Fuer die Frage "braucht das noch jemand" ist das dasselbe.
// HEAD zaehlt nicht -- das fragt nach der Datei, nicht nach dem Inhalt.
if (c.req.method !== 'HEAD') zaehleAbruf(path)
if (c.req.header('if-none-match') === etag) return c.body(null, 304, headers)
if (c.req.method === 'HEAD') {
return c.body(null, 200, { ...headers, 'Content-Length': String(info.size) })
@@ -328,6 +370,23 @@ setInterval(pruneSessions, 3600_000).unref()
setTimeout(() => void pruefeSicherung(), 60_000).unref()
setInterval(() => void pruefeSicherung(), 3600_000).unref()
// Die gezaehlten Abrufe wegschreiben. Alle 30 Sekunden reicht: wer wissen
// will, ob eine Datei benutzt wird, braucht keine Genauigkeit auf die Sekunde.
setInterval(schreibeAbrufe, 30_000).unref()
// Und beim Herunterfahren, damit die letzten nicht verlorengehen.
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
schreibeAbrufe()
process.exit(0)
})
}
// Den Papierkorb ausleeren, was ueberfaellig ist. Beim Start mit Verzoegerung,
// danach stuendlich.
setTimeout(() => void papierkorbAusleeren(), 90_000).unref()
setInterval(() => void papierkorbAusleeren(), 3600_000).unref()
serve({ fetch: app.fetch, port: config.port }, (info) => {
console.log(`[d4rk_media] Port ${info.port}`)
console.log(
+487 -48
View File
@@ -6,6 +6,7 @@
// Rueckmeldung wie ein kaputter Knopf aussieht.
import { Hono } from 'hono'
import { unzipSync } from 'fflate'
import type { Context } from 'hono'
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
import { config } from '../config.js'
@@ -18,6 +19,7 @@ import {
pruneSessions,
type Media,
type MediaEvent,
type PapierkorbEintrag,
type Token,
type User,
} from '../db.js'
@@ -50,16 +52,22 @@ import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
import {
absolutePath,
checkPath,
ausDemPapierkorb,
deleteFile,
inDenPapierkorb,
kannVorschau,
lohntWebp,
mimeFor,
moveFile,
publicUrlFor,
safeFilename,
sha256,
papierkorbPfad,
thumbPath,
webpPfad,
writeFileAtomic,
writeThumb,
writeWebp,
PathError,
} from '../storage.js'
@@ -82,6 +90,10 @@ async function jsonBody<T>(c: Context<any>): Promise<T | null> {
const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const
/** Wie lange Geloeschtes im Papierkorb bleibt. 30 Tage: lang genug, dass ein
* Versehen auffaellt, kurz genug, dass er nicht zur zweiten Ablage wird. */
const PAPIERKORB_TAGE = 30
// ------------------------------------------------------------ Die Bremse
//
// Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch
@@ -523,9 +535,7 @@ dashRoutes.delete('/media/:id', async (c) => {
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined
if (!row) return c.json({ error: 'nicht gefunden' }, 404)
await deleteFile(row.path)
db.prepare('DELETE FROM media WHERE id = ?').run(id)
logEvent('delete', row.path, row.size, 'user', c.get('user').username)
await inPapierkorb(row, c.get('user').username)
return c.json({ deleted: row.path })
})
@@ -539,22 +549,120 @@ dashRoutes.post('/media/delete', async (c) => {
}
const select = db.prepare('SELECT * FROM media WHERE id = ?')
const remove = db.prepare('DELETE FROM media WHERE id = ?')
const deleted: string[] = []
for (const id of ids) {
const row = select.get(id) as Media | undefined
if (!row) continue
await deleteFile(row.path)
remove.run(id)
logEvent('delete', row.path, row.size, 'user', c.get('user').username)
await inPapierkorb(row, c.get('user').username)
deleted.push(row.path)
}
return c.json({ deleted, count: deleted.length })
})
// ------------------------------------------------------------------- Archive
//
// WOZU. 3670 Item-Bilder einzeln ins Fenster zu ziehen ist keine Bedienung,
// das ist eine Strafe. Ein ZIP ist die Form, in der solche Sammlungen ohnehin
// unterwegs sind -- und es bringt die ORDNER MIT, was der wichtigere Teil ist:
// items/foo.png im Archiv landet unter items/foo.png im Dienst.
//
// fflate und nicht adm-zip: reines JavaScript, keine native Bibliothek. Dieses
// Projekt hat schon einmal einen halben Abend an einer ABI-Nummer verloren
// (better-sqlite3 gegen Node 24), und das soll sich nicht wiederholen.
/** Wie viele Eintraege ein Archiv hoechstens haben darf. */
const ZIP_MAX_EINTRAEGE = 5000
/** Und wieviel es ENTPACKT hoechstens ergeben darf.
*
* Das ist die Zip-Bombe: ein Archiv von 40 KB kann zu Gigabyte aufgehen, und
* entpackt wird in den Arbeitsspeicher. Geprueft wird deshalb VOR dem
* Entpacken -- die Groesse des Originals steht im Kopf jedes Eintrags, und
* fflate laesst uns darauf filtern. Eine Pruefung danach waere keine. */
const ZIP_MAX_GESAMT = 256 * 1024 * 1024
const istArchiv = (name: string, typ: string) =>
/\.zip$/i.test(name) || /^application\/(x-)?zip(-compressed)?$/i.test(typ.split(';')[0] ?? '')
/** Einen Pfad aus dem Archiv in einen zulaessigen Pfad verwandeln.
*
* Jedes Segment einzeln durch dieselbe Zaehmung wie ein Dateiname aus dem
* Fenster. Gibt null zurueck, wenn nichts Brauchbares uebrig bleibt.
*
* ".." FLIEGT RAUS, und zwar hier schon: ein Archiv ist eine Datei von
* draussen, und "../../etc/passwd" als Eintragsname ist ein Angriff mit
* eigenem Namen (Zip Slip). checkPath faengt es zwar auch, aber eine Abwehr,
* die nur an EINER Stelle steht, ist eine Abwehr, die beim Umbauen verschwindet. */
function archivPfad(roh: string): string | null {
const teile = roh
.replace(/\\/g, '/')
.split('/')
.filter((t) => t && t !== '.')
if (teile.some((t) => t === '..')) return null
const sauber = teile.map((t) => safeFilename(t)).filter(Boolean)
return sauber.length ? sauber.join('/') : null
}
/** Ein Archiv auspacken. Was uebersprungen wird, steht mit Grund im Ergebnis --
* ein stilles Weglassen waere hier das Schlimmste: der Aufrufer glaubt, seine
* 900 Bilder seien da, und merkt bei Bild 314, dass sie es nicht sind. */
function auspacken(
data: Buffer,
ergebnisse: { name: string; ok: boolean; error?: string }[],
): [string, Uint8Array][] {
let gesamt = 0
let anzahl = 0
const inhalt = unzipSync(data, {
filter: (eintrag) => {
// Ordnereintraege und der Beifang, den macOS in jedes Archiv legt.
if (eintrag.name.endsWith('/')) return false
if (/(^|\/)(__MACOSX\/|\.DS_Store$|Thumbs\.db$)/i.test(eintrag.name)) return false
if (eintrag.originalSize === 0) return false
if (++anzahl > ZIP_MAX_EINTRAEGE) return false
gesamt += eintrag.originalSize
return gesamt <= ZIP_MAX_GESAMT
},
})
if (anzahl > ZIP_MAX_EINTRAEGE) {
ergebnisse.push({
name: 'Archiv',
ok: false,
error: `mehr als ${ZIP_MAX_EINTRAEGE} Eintraege — der Rest wurde nicht ausgepackt`,
})
}
if (gesamt > ZIP_MAX_GESAMT) {
ergebnisse.push({
name: 'Archiv',
ok: false,
error:
`ergibt entpackt mehr als ${Math.round(ZIP_MAX_GESAMT / 1024 / 1024)} MB ` +
'— der Rest wurde nicht ausgepackt',
})
}
const raus: [string, Uint8Array][] = []
for (const [roh, bytes] of Object.entries(inhalt)) {
const pfad = archivPfad(roh)
if (!pfad) {
ergebnisse.push({ name: roh, ok: false, error: 'unbrauchbarer Name im Archiv' })
continue
}
raus.push([pfad, bytes])
}
return raus
}
/** Hochladen aus dem Dashboard.
*
* Ein ZIP wird dabei AUSGEPACKT statt abgelegt: es ist so gut wie nie das
* Archiv, das jemand oeffentlich haben will, sondern sein Inhalt. Die Ordner
* im Archiv bleiben erhalten und haengen sich hinter den gewaehlten Zielordner.
*
* Ein eigener Weg neben /api/upload, und zwar aus einem Grund: der dortige
* haengt an einem Bearer-Token. Wer im Dashboard sitzt, hat eine SITZUNG und
@@ -662,22 +770,33 @@ dashRoutes.post('/upload', async (c) => {
error?: string
}[] = []
for (const datei of dateien) {
const name = datei.name || 'datei'
try {
if (datei.size > config.maxUploadBytes) {
/** Eine Datei ablegen -- EIN Weg fuer alles.
*
* Ob der Inhalt aus einem Formularteil kam oder aus einem Archiv, macht ab
* hier keinen Unterschied mehr: gleiche Namensregeln, gleiche Pruefung auf
* Belegtsein, gleiche Vorschau, gleicher Eintrag im Verlauf. Ein zweiter
* Weg fuer ZIP-Inhalte waere ein zweiter Weg, der beim naechsten Mal
* auseinanderlaeuft.
*
* `zielpfad` ist gesetzt, wenn der Name schon ein Pfad ist -- bei
* Archiveintraegen naemlich, die ihre Ordner mitbringen. */
async function ablegen(
name: string,
data: Buffer,
gemeldeterTyp: string,
zielpfad?: string,
): Promise<void> {
if (data.length === 0) {
ergebnisse.push({ name, ok: false, error: 'leere Datei' })
return
}
if (data.length > config.maxUploadBytes) {
ergebnisse.push({
name,
ok: false,
error: `zu gross (${datei.size} > ${config.maxUploadBytes} Bytes)`,
error: `zu gross (${data.length} > ${config.maxUploadBytes} Bytes)`,
})
continue
}
const data = Buffer.from(await datei.arrayBuffer())
if (data.length === 0) {
ergebnisse.push({ name, ok: false, error: 'leere Datei' })
continue
return
}
const digest = sha256(data)
@@ -685,7 +804,7 @@ dashRoutes.post('/upload', async (c) => {
// bleibt (kyrillisch, nur Leerzeichen). Ohne ihn hiessen zwei
// verschiedene Dateien beide "datei.txt", und die zweite
// ueberschriebe die erste still. Beim Messen genau so passiert.
const dateiname = safeFilename(name, digest.slice(0, 8))
const dateiname = zielpfad ?? safeFilename(name, digest.slice(0, 8))
const path = checkPath(ordner ? `${ordner}/${dateiname}` : dateiname)
const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
@@ -693,21 +812,21 @@ dashRoutes.post('/upload', async (c) => {
| undefined
if (vorhanden && !ueberschreiben) {
ergebnisse.push({ name, ok: false, path, error: 'Pfad ist belegt' })
continue
return
}
// Was der Absender ueber die Art sagt, gilt -- ABER NICHT sein
// Achselzucken. Ein Formularteil ohne erkannte Art traegt
// "application/octet-stream", und das ist weniger wert als unsere
// Endungstabelle: beim Messen landeten so clip.mp4, ton.mp3 und
// handbuch.pdf allesamt unter "Anderes".
const gemeldet = (datei.type || '').split(';')[0]?.trim().toLowerCase() ?? ''
// handbuch.pdf allesamt unter "Anderes". Aus einem Archiv kommt gar
// keine Angabe -- dort entscheidet die Endung ohnehin allein.
const gemeldet = (gemeldeterTyp || '').split(';')[0]?.trim().toLowerCase() ?? ''
const mime =
gemeldet && gemeldet !== 'application/octet-stream'
? datei.type
: mimeFor(path)
gemeldet && gemeldet !== 'application/octet-stream' ? gemeldeterTyp : mimeFor(path)
await writeFileAtomic(path, data)
if (kannVorschau(mime)) await writeThumb(path, data)
if (lohntWebp(mime)) await writeWebp(path, data)
const zeit = now()
if (vorhanden) {
@@ -738,6 +857,42 @@ dashRoutes.post('/upload', async (c) => {
size: data.length,
replaced: Boolean(vorhanden),
})
}
for (const datei of dateien) {
const name = datei.name || 'datei'
try {
if (datei.size > config.maxUploadBytes) {
ergebnisse.push({
name,
ok: false,
error: `zu gross (${datei.size} > ${config.maxUploadBytes} Bytes)`,
})
continue
}
const data = Buffer.from(await datei.arrayBuffer())
if (istArchiv(name, datei.type)) {
for (const [eintrag, inhalt] of auspacken(data, ergebnisse)) {
// EIGENES try JE EINTRAG. Sonst reisst ein einziger
// unbrauchbarer Name -- zu tief, zu lang -- die restlichen
// tausend mit, und der Aufrufer sieht eine Fehlermeldung
// statt neunhundertneunundneunzig Erfolgen.
try {
await ablegen(`${name}${eintrag}`, Buffer.from(inhalt), '', eintrag)
} catch (err) {
ergebnisse.push({
name: `${name}${eintrag}`,
ok: false,
error: err instanceof Error ? err.message : 'unbekannter Fehler',
})
}
}
continue
}
await ablegen(name, data, datei.type)
} catch (err) {
// Eine kaputte Datei bricht die anderen NICHT ab. Wer fuenf Dateien
// zieht und bei der zweiten alles verliert, zieht sie danach
@@ -1303,10 +1458,15 @@ dashRoutes.delete('/folders/:pfad{.+}', async (c) => {
400,
)
}
// Die Vorschauen desselben Ordners hinterher, falls einer da ist.
await rm(join(config.dataDir, 'thumbs', pfad), { recursive: true, force: true }).catch(
// Das Abgeleitete desselben Ordners hinterher, falls es da ist. HIER mit
// recursive: anders als oben geht es nicht um eine Sicherung gegen
// versehentliches Loeschen -- der Ordner mit den Originalen ist zu diesem
// Zeitpunkt schon weg, und was hier noch liegt, ist nachrechenbar.
for (const ordner of ['thumbs', 'webp']) {
await rm(join(config.dataDir, ordner, pfad), { recursive: true, force: true }).catch(
() => {},
)
}
return c.json({ ok: true })
})
@@ -1392,6 +1552,145 @@ dashRoutes.get('/export', (c) => {
* weiss, was da liegt, ist der Weg, wie man Daten verliert. Diese Route
* loescht nichts -- sie zeigt.
*/
/** Eine Datei in den Papierkorb legen: Datei verschieben, Datensatz umziehen.
*
* An EINER Stelle, weil es zwei Loeschwege gibt (einzeln und im Block) und
* die beim naechsten Mal sonst auseinanderlaufen. */
async function inPapierkorb(row: Media, wer: string): Promise<void> {
const eintrag = db
.prepare(
`INSERT INTO papierkorb (path, size, sha256, mime, geloescht, von, ablage)
VALUES (?, ?, ?, ?, ?, ?, '')`,
)
.run(row.path, row.size, row.sha256, row.mime, now(), wer)
const id = Number(eintrag.lastInsertRowid)
try {
const ablage = await inDenPapierkorb(row.path, id)
db.prepare('UPDATE papierkorb SET ablage = ? WHERE id = ?').run(ablage, id)
} catch (err) {
// Konnte die Datei nicht verschoben werden, darf der Datensatz nicht
// stehen bleiben -- sonst zeigt der Papierkorb etwas an, das sich nicht
// zurueckholen laesst.
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(id)
throw err
}
db.prepare('DELETE FROM media WHERE id = ?').run(row.id)
logEvent('delete', row.path, row.size, 'user', wer)
}
/** Was zu lange im Papierkorb liegt, ist endgueltig weg.
*
* 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) {
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
}
dashRoutes.get('/papierkorb', (c) => {
const items = db
.prepare('SELECT * FROM papierkorb ORDER BY geloescht DESC LIMIT 500')
.all() as PapierkorbEintrag[]
const summe = db
.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM papierkorb')
.get() as { n: number; bytes: number }
return c.json({ items, ...summe, haltbarTage: PAPIERKORB_TAGE })
})
/** Zurueck an ihren Platz.
*
* Ist der Pfad inzwischen wieder belegt, wird NICHT ueberschrieben: dann
* verschwaende eine Datei, die jemand nach dem Loeschen bewusst dort abgelegt
* hat. */
dashRoutes.post('/papierkorb/zurueck', async (c) => {
const body = await jsonBody<{ ids?: number[] }>(c)
if (!Array.isArray(body?.ids) || body.ids.length === 0) {
return c.json({ error: 'keine Auswahl' }, 400)
}
const wer = c.get('user').username
const ergebnisse: { path: string; ok: boolean; error?: string }[] = []
for (const id of body.ids) {
const e = db.prepare('SELECT * FROM papierkorb WHERE id = ?').get(id) as
| PapierkorbEintrag
| undefined
if (!e) {
ergebnisse.push({ path: `#${id}`, ok: false, error: 'nicht gefunden' })
continue
}
if (db.prepare('SELECT 1 FROM media WHERE path = ?').get(e.path)) {
ergebnisse.push({ path: e.path, ok: false, error: 'Pfad ist wieder belegt' })
continue
}
try {
await ausDemPapierkorb(e.ablage, e.path)
const zeit = now()
db.prepare(
`INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at)
VALUES (?, ?, ?, ?, NULL, ?, ?)`,
).run(e.path, e.size, e.sha256, e.mime, zeit, zeit)
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(id)
// Vorschau und sparsame Fassung wurden beim Loeschen weggeworfen --
// beides ist abgeleitet. Jetzt neu rechnen, sonst zeigt die Galerie
// ein Loch und die Datei kommt wieder in voller Groesse heraus.
if (kannVorschau(e.mime) || lohntWebp(e.mime)) {
try {
const daten = await readFile(absolutePath(e.path))
if (kannVorschau(e.mime)) await writeThumb(e.path, daten)
if (lohntWebp(e.mime)) await writeWebp(e.path, daten)
} catch {
/* die Galerie faellt aufs Vollbild zurueck */
}
}
logEvent('upload', e.path, e.size, 'user', wer)
ergebnisse.push({ path: e.path, ok: true })
} catch (err) {
ergebnisse.push({
path: e.path,
ok: false,
error: err instanceof Error ? err.message : 'unbekannt',
})
}
}
const gut = ergebnisse.filter((e) => e.ok).length
return c.json({ zurueck: gut, fehlgeschlagen: ergebnisse.length - gut, ergebnisse })
})
/** Endgueltig weg. Hier gibt es kein Zurueck mehr, und die Oberflaeche sagt
* das auch. */
dashRoutes.post('/papierkorb/leeren', async (c) => {
const body = await jsonBody<{ ids?: number[] }>(c)
const eintraege = (
Array.isArray(body?.ids) && body.ids.length > 0
? body.ids
.map((id) => db.prepare('SELECT * FROM papierkorb WHERE id = ?').get(id))
.filter(Boolean)
: db.prepare('SELECT * FROM papierkorb').all()
) as PapierkorbEintrag[]
let weg = 0
for (const e of eintraege) {
await rm(papierkorbPfad(e.ablage), { force: true })
db.prepare('DELETE FROM papierkorb WHERE id = ?').run(e.id)
weg++
}
return c.json({ geleert: weg })
})
dashRoutes.get('/speicher', (c) => {
const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => {
const z = db
@@ -1430,7 +1729,24 @@ dashRoutes.get('/speicher', (c) => {
return { tage, files: z.n, bytes: z.bytes }
})
return c.json({ nachArt, groesste, aelteste, doppelte, verschwendet, stufen })
// Die Frage, die der Bericht bisher nicht beantworten konnte.
const nieGeholt = db
.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(size),0) AS bytes FROM media WHERE abrufe = 0')
.get() as { n: number; bytes: number }
const beliebteste = (
db.prepare('SELECT * FROM media WHERE abrufe > 0 ORDER BY abrufe DESC LIMIT 10').all() as Media[]
).map((r) => ({ ...r, url: publicUrlFor(r.path) }))
return c.json({
nachArt,
groesste,
aelteste,
doppelte,
verschwendet,
stufen,
nieGeholt,
beliebteste,
})
})
dashRoutes.get('/stats', (c) => {
@@ -1651,13 +1967,15 @@ async function verwaisteSuchen() {
}
}
// Vorschauen ohne Bild. Der Name traegt die Endung des Originals plus
// ".webp" -- deshalb wird sie hier abgeschnitten, um zurueckzurechnen.
const vorschauen: string[] = []
async function thumbsDurchlaufen(rel: string) {
// Abgeleitetes ohne Bild -- Vorschauen UND sparsame Fassungen. Beide liegen
// im selben Baum wie das Original mit ".webp" hintendran, deshalb wird die
// Endung hier abgeschnitten, um zurueckzurechnen, und deshalb reicht EINE
// Schleife fuer beide Ordner.
const abgeleitetOhneBild: string[] = []
async function abgeleitetDurchlaufen(ordner: string, rel: string) {
let eintraege
try {
eintraege = await readdir(join(config.dataDir, 'thumbs', rel), {
eintraege = await readdir(join(config.dataDir, ordner, rel), {
withFileTypes: true,
})
} catch {
@@ -1665,14 +1983,19 @@ async function verwaisteSuchen() {
}
for (const e of eintraege) {
const p = rel ? `${rel}/${e.name}` : e.name
if (e.isDirectory()) await thumbsDurchlaufen(p)
else if (e.isFile()) vorschauen.push(p)
if (e.isDirectory()) await abgeleitetDurchlaufen(ordner, p)
else if (e.isFile() && !bekannt.has(p.replace(/\.webp$/, ''))) {
// MIT Ordner davor: der Aufraeumer muss wissen, welche der
// beiden Dateien er wegwerfen soll -- der Pfad allein ist in
// thumbs/ und webp/ derselbe.
abgeleitetOhneBild.push(`${ordner}/${p}`)
}
}
await thumbsDurchlaufen('')
const vorschauOhneBild = vorschauen.filter((p) => !bekannt.has(p.replace(/\.webp$/, '')))
}
await abgeleitetDurchlaufen('thumbs', '')
await abgeleitetDurchlaufen('webp', '')
return { rows, dateiOhneDatensatz, datensatzOhneDatei, vorschauOhneBild }
return { rows, dateiOhneDatensatz, datensatzOhneDatei, abgeleitetOhneBild }
}
dashRoutes.get('/maintenance/orphans', async (c) => {
@@ -1686,9 +2009,9 @@ dashRoutes.get('/maintenance/orphans', async (c) => {
anzahl: f.datensatzOhneDatei.length,
beispiele: f.datensatzOhneDatei.slice(0, 20).map((r) => r.path),
},
vorschauOhneBild: {
anzahl: f.vorschauOhneBild.length,
beispiele: f.vorschauOhneBild.slice(0, 20),
abgeleitetOhneBild: {
anzahl: f.abgeleitetOhneBild.length,
beispiele: f.abgeleitetOhneBild.slice(0, 20),
},
})
})
@@ -1698,9 +2021,11 @@ dashRoutes.get('/maintenance/orphans', async (c) => {
* 'aufnehmen' Dateien ohne Datensatz kommen in die Datenbank. Sie sind da,
* sie sind ausgeliefert -- sie fehlen nur in der Liste. Das ist
* fast immer das Richtige und loescht NICHTS.
* 'aufraeumen' Datensaetze ohne Datei und verwaiste Vorschauen fliegen raus.
* Hier geht etwas weg, aber nur Verweise auf Dateien, die es
* nicht mehr gibt, und abgeleitete Bilder. */
* 'aufraeumen' Datensaetze ohne Datei und verwaistes Abgeleitetes fliegen
* raus. Hier geht etwas weg, aber nur Verweise auf Dateien, die
* es nicht mehr gibt, und Bilder, die sich neu rechnen lassen --
* deshalb geht das hier AN DER PAPIERKORB VORBEI. Ein Papierkorb
* voller Vorschaubilder waere keine Sicherheit, nur Ballast. */
dashRoutes.post('/maintenance/orphans', async (c) => {
const body = await jsonBody<{ aktion?: string }>(c)
const aktion = body?.aktion ?? 'aufnehmen'
@@ -1718,6 +2043,7 @@ dashRoutes.post('/maintenance/orphans', async (c) => {
VALUES (?, ?, ?, ?, NULL, ?, ?)`,
).run(pfad, daten.length, sha256(daten), mimeFor(pfad), zeit, zeit)
if (kannVorschau(mimeFor(pfad))) await writeThumb(pfad, daten)
if (lohntWebp(mimeFor(pfad))) await writeWebp(pfad, daten)
logEvent('upload', pfad, daten.length, 'user', wer)
aufgenommen++
} catch (err) {
@@ -1735,8 +2061,8 @@ dashRoutes.post('/maintenance/orphans', async (c) => {
datensaetze++
}
let vorschauen = 0
for (const p of f.vorschauOhneBild) {
await rm(join(config.dataDir, 'thumbs', p), { force: true })
for (const p of f.abgeleitetOhneBild) {
await rm(join(config.dataDir, p), { force: true })
vorschauen++
}
return c.json({ aktion, datensaetze, vorschauen })
@@ -1753,6 +2079,8 @@ dashRoutes.post('/maintenance/orphans', async (c) => {
* Das Ergebnis nennt Zahlen und nicht nur "ok" — ein Knopf, der nach zwanzig
* Sekunden "fertig" sagt, ohne zu sagen WAS fertig ist, ist der Anfang der
* Frage, ob er ueberhaupt etwas getan hat. */
const THUMB_STAPEL = 300
dashRoutes.post('/maintenance/thumbs', async (c) => {
const rows = db.prepare('SELECT path, mime FROM media').all() as Pick<
Media,
@@ -1763,6 +2091,7 @@ dashRoutes.post('/maintenance/thumbs', async (c) => {
let vorhanden = 0
let uebersprungen = 0
let fehler = 0
let offen = 0
for (const row of rows) {
if (!kannVorschau(row.mime)) {
@@ -1777,6 +2106,14 @@ dashRoutes.post('/maintenance/thumbs', async (c) => {
/* gibt es noch nicht — gleich */
}
// Stapelweise, aus demselben Grund wie unten bei den sparsamen
// Fassungen: nach 90 Sekunden gibt der Proxy auf, und ein 504 auf einen
// Knopf, dessen Arbeit weiterlaeuft, ist eine Luege.
if (erzeugt + fehler >= THUMB_STAPEL) {
offen++
continue
}
try {
const data = await readFile(absolutePath(row.path))
if (await writeThumb(row.path, data)) erzeugt++
@@ -1788,5 +2125,107 @@ dashRoutes.post('/maintenance/thumbs', async (c) => {
}
}
return c.json({ erzeugt, vorhanden, uebersprungen, fehler, gesamt: rows.length })
return c.json({
erzeugt,
vorhanden,
uebersprungen,
fehler,
gesamt: rows.length,
offen,
fertig: offen === 0,
})
})
/** Die sparsamen Fassungen nachziehen.
*
* Dasselbe Muster wie bei den Vorschauen und aus demselben Grund: die
* uebernommenen Bilder liegen laengst da, die Umkodierung kam spaeter. Ohne
* diesen Knopf haetten nur neue Uploads eine sparsame Fassung — und genau die
* 3670 Item-Bilder, um die es geht, keine.
*
* DAS ERGEBNIS NENNT BYTES, nicht nur Stueckzahlen. "1240 erzeugt" beantwortet
* die Frage nicht, die man beim Druecken hatte; "spart 118 MB" schon. Dass
* einige NICHT lohnen, steht als eigene Zahl da und ist kein Fehler: bei
* kleinen PNG faellt WebP groesser aus, und dann bleibt es beim Original.
*
* UND ES LAEUFT IN STAPELN -- das ist nicht Vorsicht, das ist nachgemessen.
* Der erste Lauf ueber 3861 Dateien brauchte weit ueber neunzig Sekunden, und
* bei genau neunzig gibt Nginx Proxy Manager auf: `proxy_read_timeout`. Der
* Aufrufer sah einen 504, die Arbeit lief unbemerkt weiter, und der Knopf log
* damit den Anwender an. Ein Knopf, der Erfolg als Fehler meldet, ist schlimmer
* als einer ohne Rueckmeldung.
*
* Also: hoechstens WEBP_STAPEL Stueck je Aufruf, der Rest wird gezaehlt und
* als `offen` gemeldet. Die Oberflaeche ruft erneut auf, solange `fertig`
* falsch ist -- und kann dabei endlich sagen, wie weit sie ist. */
const WEBP_STAPEL = 300
dashRoutes.post('/maintenance/webp', async (c) => {
const rows = db.prepare('SELECT path, mime, size FROM media').all() as Pick<
Media,
'path' | 'mime' | 'size'
>[]
let erzeugt = 0
let vorhanden = 0
let uebersprungen = 0
let lohntNicht = 0
let fehler = 0
let vorher = 0
let nachher = 0
let offen = 0
for (const row of rows) {
if (!lohntWebp(row.mime)) {
uebersprungen++
continue
}
try {
const info = await stat(webpPfad(row.path))
vorhanden++
vorher += row.size
nachher += info.size
continue
} catch {
/* gibt es noch nicht — gleich */
}
// DER STAPEL IST VOLL: ab hier wird nur noch GEZAEHLT, nicht mehr
// gerechnet. Das kostet ein stat() je Datei und liefert dafuer eine
// ehrliche Restzahl -- besser als ein "fertig?", das raten muss.
if (erzeugt + lohntNicht + fehler >= WEBP_STAPEL) {
offen++
continue
}
try {
const data = await readFile(absolutePath(row.path))
const groesse = await writeWebp(row.path, data)
if (groesse === null) {
lohntNicht++
} else {
erzeugt++
vorher += row.size
nachher += groesse
}
} catch {
// Ein Datensatz ohne Datei — wie oben ein Zaehler, kein Absturz.
fehler++
}
}
return c.json({
erzeugt,
vorhanden,
uebersprungen,
lohntNicht,
fehler,
gesamt: rows.length,
offen,
fertig: offen === 0,
vorher,
nachher,
gespart: vorher - nachher,
})
})
+3
View File
@@ -31,11 +31,13 @@ import {
extensionFor,
fileExists,
kannVorschau,
lohntWebp,
mimeFor,
publicUrlFor,
sha256,
writeFileAtomic,
writeThumb,
writeWebp,
} from '../storage.js'
type Vars = { token: Token }
@@ -206,6 +208,7 @@ uploadRoutes.post('/upload', async (c) => {
// eigenem Ergebnis: scheitert sie, ist das Bild trotzdem abgelegt und der
// Upload ein Erfolg. Das Dashboard faellt dann auf das Vollbild zurueck.
if (kannVorschau(mime)) await writeThumb(path, data)
if (lohntWebp(mime)) await writeWebp(path, data)
const timestamp = now()
if (existing) {
+107 -7
View File
@@ -235,21 +235,121 @@ export async function moveFile(von: string, nach: string): Promise<void> {
await mkdir(dirname(ziel), { recursive: true })
await rename(absolutePath(von), ziel)
const abgeleitet: [string, string][] = [
[thumbPath(von), thumbPath(nach)],
[webpPfad(von), webpPfad(nach)],
]
for (const [vonPfad, nachPfad] of abgeleitet) {
try {
const thumbZiel = thumbPath(nach)
await mkdir(dirname(thumbZiel), { recursive: true })
await rename(thumbPath(von), thumbZiel)
await mkdir(dirname(nachPfad), { recursive: true })
await rename(vonPfad, nachPfad)
} catch {
// Keine Vorschau vorhanden — kein Fehler. Sie laesst sich jederzeit
// neu rechnen.
// Nicht vorhanden — kein Fehler. Beides ist abgeleitet und laesst
// sich jederzeit neu rechnen.
}
}
}
// ---------------------------------------------------------- Die sparsame Fassung
//
// DAS PROBLEM. Die uebernommenen Item-Bilder sind PNG. Als WebP waeren sie ein
// Bruchteil davon. Nur: DER PFAD IST DIE ADRESSE -- aus items/foo.png darf
// nicht items/foo.webp werden, sonst zeigt jedes Skript ins Leere, das den
// Namen schon kennt.
//
// DIE LOESUNG steht im Protokoll selbst. Der Browser sagt mit `Accept`, was er
// versteht. Wir legen die sparsame Fassung DANEBEN und liefern sie unter
// DERSELBEN Adresse aus, wenn der Aufrufer sie mag. Wer sie nicht mag -- Lua,
// curl, ein aelteres Werkzeug -- bekommt unveraendert das Original. Niemand
// muss etwas umstellen.
//
// Dazu gehoert `Vary: Accept` in der Antwort. Ohne die Zeile legt ein
// Zwischenspeicher die WebP-Fassung fuer ALLE ab, auch fuer die, die sie nicht
// lesen koennen. Unser Proxy speichert /f/ zwar gar nicht zwischen -- aber das
// ist eine Einstellung, die jemand aendern kann, und darauf soll die
// Richtigkeit nicht beruhen.
export const webpPfad = (path: string) => join(config.dataDir, 'webp', `${path}.webp`)
/** Fuer welche Arten sich eine sparsame Fassung lohnt.
*
* WebP selbst NICHT: das waere ein Umkodieren ohne Gewinn. GIF auch nicht --
* dort braeuchte es animiertes WebP, und der Gewinn ist klein gegen das
* Risiko, dass die Bewegung dabei verlorengeht. */
export const lohntWebp = (mime: string) =>
/^image\/(png|jpeg)$/.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
/** Die sparsame Fassung erzeugen. Gibt ihre Groesse zurueck, oder null.
*
* Schlaegt es fehl, ist das KEIN Fehler fuer den Aufrufer: dann wird eben
* weiter das Original ausgeliefert, so wie bisher. */
export async function writeWebp(path: string, data: Buffer): Promise<number | null> {
const ziel = webpPfad(path)
try {
const { default: sharp } = await import('sharp')
await mkdir(dirname(ziel), { recursive: true })
// OHNE Groessenaenderung -- anders als bei der Vorschau. Es geht um die
// Kodierung, nicht um die Aufloesung: wer ein 4K-Bild hochlaedt, will
// ein 4K-Bild, nur kleiner in Bytes.
const bild = await sharp(data, { animated: false }).webp({ quality: 82 }).toBuffer()
// NUR wenn es sich lohnt. Bei kleinen oder schon dichten PNG faellt
// WebP durchaus groesser aus -- dann waere die "sparsame" Fassung eine
// Verschwendung mit zusaetzlicher Verwaltung obendrauf. Eine alte
// Fassung, die nun nicht mehr lohnt, muss dabei weg: sonst liefert der
// Dienst weiter die groessere aus.
if (bild.length >= data.length) {
await rm(ziel, { force: true })
return null
}
const temp = `${ziel}.${process.pid}.tmp`
await writeFile(temp, bild)
await rename(temp, ziel)
return bild.length
} catch (err) {
console.error(`[webp] ${path}: ${err instanceof Error ? err.message : err}`)
return null
}
}
export async function deleteFile(path: string): Promise<void> {
await rm(absolutePath(path), { force: true })
// Die Vorschau geht mit. Sonst bleibt sie liegen, und beim naechsten Bild
// unter demselben Pfad sieht man das alte.
// Vorschau und sparsame Fassung gehen mit. Sonst bleiben sie liegen, und
// beim naechsten Bild unter demselben Pfad sieht man das alte.
await rm(thumbPath(path), { force: true })
await rm(webpPfad(path), { force: true })
}
// ------------------------------------------------------------- Papierkorb
//
// Verschieben statt Loeschen. Der Ablagename traegt eine laufende Nummer, weil
// unter demselben Pfad nacheinander verschiedene Dateien gelegen haben koennen
// -- und beide sollen zurueckholbar sein.
export const papierkorbPfad = (ablage: string) => join(config.dataDir, 'papierkorb', ablage)
/** Die Datei in den Papierkorb legen. Gibt den Ablagenamen zurueck.
*
* Die VORSCHAU wandert NICHT mit: sie laesst sich aus dem Bild neu rechnen,
* und ein Papierkorb, der Abgeleitetes mitschleppt, ist doppelt so gross wie
* noetig. */
export async function inDenPapierkorb(path: string, id: number): Promise<string> {
const ablage = `${id}-${path.replace(/[\\/]/g, '_')}`
const ziel = papierkorbPfad(ablage)
await mkdir(dirname(ziel), { recursive: true })
await rename(absolutePath(path), ziel)
await rm(thumbPath(path), { force: true })
await rm(webpPfad(path), { force: true })
return ablage
}
/** Zurueck an ihren Platz. */
export async function ausDemPapierkorb(ablage: string, path: string): Promise<void> {
const ziel = absolutePath(path)
await mkdir(dirname(ziel), { recursive: true })
await rename(papierkorbPfad(ablage), ziel)
}
export async function fileExists(path: string): Promise<boolean> {
+3
View File
@@ -8,6 +8,7 @@ import {
KeyRound,
PieChart,
Settings,
Trash2,
} from 'lucide-react'
import { useStore } from './store'
import { Anmelden } from './pages/Anmelden'
@@ -19,6 +20,7 @@ import { Konto } from './pages/Konto'
import { Einstellungen } from './pages/Einstellungen'
import { Verlauf } from './pages/Verlauf'
import { Api } from './pages/Api'
import { Papierkorb } from './pages/Papierkorb'
import { Meldungen } from './components/Meldungen'
import { gehZu, usePfad } from './router'
import { Kontomenue } from './components/Kontomenue'
@@ -37,6 +39,7 @@ const seiten = [
{ pfad: '/galerie', name: 'Galerie', kind: Images, Inhalt: Galerie },
{ pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf },
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
{ pfad: '/papierkorb', name: 'Papierkorb', kind: Trash2, Inhalt: Papierkorb },
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
{ pfad: '/api', name: 'API', kind: Code2, Inhalt: Api },
{ pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen },
+58 -1
View File
@@ -110,6 +110,9 @@ export type MediaItem = {
created_at: number
updated_at: number
url: string
/** Wie oft die Datei oeffentlich geholt wurde. */
abrufe: number
zuletzt_abgerufen: number | null
}
export type MediaPage = {
@@ -127,6 +130,23 @@ export type Stats = {
publicUrl: string
}
export type PapierkorbEintrag = {
id: number
path: string
size: number
sha256: string
mime: string
geloescht: number
von: string
}
export type PapierkorbSeite = {
items: PapierkorbEintrag[]
n: number
bytes: number
haltbarTage: number
}
export type Speicher = {
nachArt: { art: string; files: number; bytes: number }[]
groesste: MediaItem[]
@@ -134,6 +154,9 @@ export type Speicher = {
doppelte: { sha256: string; n: number; bytes: number; pfade: string }[]
verschwendet: number
stufen: { tage: number; files: number; bytes: number }[]
/** Was seit dem Einbau des Zaehlers NIE geholt wurde. */
nieGeholt: { n: number; bytes: number }
beliebteste: MediaItem[]
}
export type TokenRow = {
@@ -206,7 +229,28 @@ export type Richtung = 'auf' | 'ab'
export type Verwaiste = {
dateiOhneDatensatz: { anzahl: number; beispiele: string[] }
datensatzOhneDatei: { anzahl: number; beispiele: string[] }
vorschauOhneBild: { anzahl: number; beispiele: string[] }
/** Vorschauen UND sparsame Fassungen, deren Original es nicht mehr gibt.
* Die Beispiele tragen den Ordner vorn — thumbs/… oder webp/…. */
abgeleitetOhneBild: { anzahl: number; beispiele: string[] }
}
/** Was EIN STAPEL „sparsame Fassungen nachziehen" zurueckmeldet.
*
* `fertig` ist der Punkt: der Server rechnet hoechstens ein paar hundert
* Stueck je Aufruf, weil der Proxy nach 90 Sekunden aufgibt. Solange `fertig`
* falsch ist, steht in `offen`, wieviele noch fehlen. */
export type WebpLauf = {
erzeugt: number
vorhanden: number
uebersprungen: number
lohntNicht: number
fehler: number
gesamt: number
offen: number
fertig: boolean
vorher: number
nachher: number
gespart: number
}
/** Was das Sicherungsskript nach jedem Lauf zurueckmeldet. */
@@ -296,6 +340,9 @@ export type ThumbLauf = {
uebersprungen: number
fehler: number
gesamt: number
/** Wieviele noch fehlen. Siehe WebpLauf — derselbe Grund. */
offen: number
fertig: boolean
}
/** Die Adresse der Vorschau zu einem Pfad.
@@ -380,8 +427,18 @@ export const api = {
uebersicht: () => get<Uebersicht>('/api/dash/uebersicht'),
stand: () => get<Stand>('/api/dash/stand'),
speicher: () => get<Speicher>('/api/dash/speicher'),
papierkorb: () => get<PapierkorbSeite>('/api/dash/papierkorb'),
papierkorbZurueck: (ids: number[]) =>
post<{
zurueck: number
fehlgeschlagen: number
ergebnisse: { path: string; ok: boolean; error?: string }[]
}>('/api/dash/papierkorb/zurueck', { ids }),
papierkorbLeeren: (ids?: number[]) =>
post<{ geleert: number }>('/api/dash/papierkorb/leeren', ids ? { ids } : {}),
stats: () => get<Stats>('/api/dash/stats'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
webpNachziehen: () => post<WebpLauf>('/api/dash/maintenance/webp'),
verwaiste: () => get<Verwaiste>('/api/dash/maintenance/orphans'),
verwaisteBehandeln: (aktion: 'aufnehmen' | 'aufraeumen') =>
post<{ aktion: string; aufgenommen?: number; datensaetze?: number; vorschauen?: number }>(
@@ -151,6 +151,45 @@ export function SpeicherKarten() {
</Karte>
</div>
<Karte titel="Wird das überhaupt geholt?">
<p className="mb-3 text-xs text-schrift-leise">
Die Frage, die eine Aufbewahrungsregel wirklich braucht und die sich von
innen nicht beantworten lässt. Gezählt wird seit dem Einbau des Zählers;
was davor lag, steht hier als nie geholt", auch wenn es täglich benutzt
wurde. Nach ein paar Wochen stimmt die Zahl.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-md border border-rand bg-grund p-3">
<p className="text-xs text-schrift-leise">Nie geholt</p>
<p className="text-xl font-semibold">
{daten.nieGeholt.n}{' '}
<span className="text-sm font-normal text-schrift-leise">
({bytes(daten.nieGeholt.bytes)})
</span>
</p>
</div>
<div className="rounded-md border border-rand bg-grund p-3">
<p className="mb-1 text-xs text-schrift-leise">Am häufigsten geholt</p>
{daten.beliebteste.length === 0 ? (
<p className="text-sm text-schrift-leise">noch nichts gezählt</p>
) : (
<ul className="flex flex-col gap-0.5 text-xs">
{daten.beliebteste.slice(0, 5).map((m) => (
<li key={m.id} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-mono" title={m.path}>
{m.path}
</span>
<span className="shrink-0 tabular-nums text-schrift-leise">
{m.abrufe}×
</span>
</li>
))}
</ul>
)}
</div>
</div>
</Karte>
<Karte titel="Wie alt der Bestand ist">
<p className="mb-3 text-xs text-schrift-leise">
Die Zahlen, die eine Aufbewahrungsregel braucht. „Alles älter als 90 Tage
+213
View File
@@ -0,0 +1,213 @@
import { useCallback, useEffect, useState } from 'react'
import { AlertTriangle, Loader2, RotateCcw, Trash2 } from 'lucide-react'
import { api, type PapierkorbSeite, type PapierkorbEintrag } from '../api'
import { run, useStore } from '../store'
import { bytes, zeit } from '../format'
import { Karte, Knopf, Leer, Nachfrage } from '../components/Bausteine'
/** Der Papierkorb.
*
* Löschen war bis vor Kurzem endgültig — die Sicherung half nur bis zum
* letzten nächtlichen Lauf. Bei einem Knopf „alle 3670 wählen" direkt neben
* „löschen" ist das die falsche Härte.
*
* Was hier steht, liegt weiterhin auf der Platte, nur nicht mehr unter seiner
* öffentlichen Adresse. Nach 30 Tagen räumt der Dienst selbst auf. */
export function Papierkorb() {
const note = useStore((s) => s.note)
const [daten, setDaten] = useState<PapierkorbSeite | null>(null)
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
const [laeuft, setLaeuft] = useState(false)
const [fragt, setFragt] = useState<PapierkorbEintrag[] | 'alles' | null>(null)
const laden = useCallback(async () => {
try {
setDaten(await api.papierkorb())
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
}
}, [note])
useEffect(() => {
void laden()
}, [laden])
if (!daten) {
return (
<div className="flex justify-center py-16">
<Loader2 className="animate-spin text-schrift-leise" />
</div>
)
}
const items = daten.items
const auswahl = items.filter((i) => gewaehlt.has(i.id))
async function zurueck(liste: PapierkorbEintrag[]) {
setLaeuft(true)
await run(
() => api.papierkorbZurueck(liste.map((e) => e.id)),
(r) =>
r.fehlgeschlagen === 0
? `${r.zurueck} zurückgeholt.`
: `${r.zurueck} zurückgeholt, ${r.fehlgeschlagen} nicht: ` +
r.ergebnisse
.filter((e) => !e.ok)
.slice(0, 3)
.map((e) => `${e.path} (${e.error})`)
.join('; '),
)
setLaeuft(false)
setGewaehlt(new Set())
void laden()
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-schrift-leise">
{daten.n} {daten.n === 1 ? 'Eintrag' : 'Einträge'} · {bytes(daten.bytes)}
</span>
{auswahl.length > 0 && (
<>
<Knopf
art="haupt"
wartet={laeuft}
onClick={() => void zurueck(auswahl)}
kind={
<>
<RotateCcw size={15} />
{auswahl.length} zurückholen
</>
}
/>
<Knopf
art="gefahr"
onClick={() => setFragt(auswahl)}
kind={`${auswahl.length} endgültig löschen`}
/>
</>
)}
{items.length > 0 && (
<Knopf
art="gefahr"
className="ml-auto"
onClick={() => setFragt('alles')}
kind={
<>
<Trash2 size={15} />
Papierkorb leeren
</>
}
/>
)}
</div>
<Karte>
{items.length === 0 ? (
<Leer
kind={<Trash2 size={26} className="text-schrift-leise/50" />}
text="Der Papierkorb ist leer."
/>
) : (
<table className="w-full table-fixed text-sm">
<colgroup>
<col className="w-10" />
<col />
<col className="w-24" />
<col className="w-40" />
<col className="w-32" />
</colgroup>
<thead>
<tr className="text-left text-xs text-schrift-leise">
<th className="px-3 pb-2" />
<th className="px-3 pb-2 font-medium">Pfad</th>
<th className="px-3 pb-2 text-right font-medium">Größe</th>
<th className="px-3 pb-2 font-medium">Gelöscht</th>
<th className="px-3 pb-2 font-medium">Von</th>
</tr>
</thead>
<tbody>
{items.map((e) => (
<tr key={e.id} className="border-t border-rand">
<td className="px-3 py-1.5">
<input
type="checkbox"
checked={gewaehlt.has(e.id)}
onChange={(ev) =>
setGewaehlt((alt) => {
const neu = new Set(alt)
if (ev.target.checked) neu.add(e.id)
else neu.delete(e.id)
return neu
})
}
className="accent-akzent"
/>
</td>
<td
className="max-w-0 truncate px-3 py-1.5 font-mono text-xs"
title={e.path}
>
{e.path}
</td>
<td className="px-3 py-1.5 text-right tabular-nums">
{bytes(e.size)}
</td>
<td className="px-3 py-1.5 whitespace-nowrap text-schrift-leise tabular-nums">
{zeit(e.geloescht)}
</td>
<td className="truncate px-3 py-1.5 text-schrift-leise">
{e.von}
</td>
</tr>
))}
</tbody>
</table>
)}
</Karte>
<p className="text-xs text-schrift-leise">
Einträge werden nach {daten.haltbarTage} Tagen von selbst endgültig entfernt.
Solange sie hier stehen, liegen sie weiterhin auf der Platte nur nicht mehr
unter ihrer öffentlichen Adresse.
</p>
<Nachfrage
offen={fragt !== null}
titel={fragt === 'alles' ? 'Papierkorb leeren?' : 'Endgültig löschen?'}
bestaetigung="Endgültig löschen"
wartet={laeuft}
onNein={() => setFragt(null)}
onJa={async () => {
setLaeuft(true)
await run(
() =>
api.papierkorbLeeren(
fragt === 'alles' ? undefined : (fragt ?? []).map((e) => e.id),
),
(r) => `${r.geleert} endgültig entfernt.`,
)
setLaeuft(false)
setFragt(null)
setGewaehlt(new Set())
void laden()
}}
text={
<p className="flex items-start gap-2">
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-schlecht" />
<span>
{fragt === 'alles'
? `Alle ${daten.n} Einträge werden von der Platte entfernt.`
: `${(fragt as PapierkorbEintrag[])?.length} Einträge werden von der Platte entfernt.`}{' '}
Danach hilft nur noch die Sicherung und die ist bis zu 24 Stunden
alt.
</span>
</p>
}
/>
</div>
)
}
+108 -18
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import {
Check,
Copy,
FileArchive,
Folder,
HardDrive,
Images,
@@ -21,6 +22,8 @@ export function Speicher() {
const [stats, setStats] = useState<Stats | null>(null)
const [laedt, setLaedt] = useState(true)
const [zieht, setZieht] = useState(false)
const [spart, setSpart] = useState(false)
const [fortschritt, setFortschritt] = useState({ thumbs: '', webp: '' })
const [verwaiste, setVerwaiste] = useState<Verwaiste | null>(null)
const [sucht, setSucht] = useState(false)
const [handelt, setHandelt] = useState(false)
@@ -52,6 +55,52 @@ export function Speicher() {
const groesster = Math.max(1, ...stats.folders.map((f) => f.bytes))
/** Eine Wartungsarbeit, die in RUNDEN läuft — für beide Knöpfe hier.
*
* WARUM ÜBERHAUPT RUNDEN: der Server rechnet höchstens ein paar hundert
* Stück je Aufruf, weil der Proxy nach 90 Sekunden aufgibt. Der erste Lauf
* über 3861 Dateien lief genau da hinein — der Anwender sah einen 504,
* während die Arbeit im Hintergrund weiterlief. Ein Knopf, der Erfolg als
* Fehler meldet, ist schlimmer als einer ohne Rückmeldung.
*
* Der Umbau kostet nichts und bringt etwas: statt eines Rädchens, das
* minutenlang schweigt, steht eine Zahl da, die sich bewegt.
*
* Ein Fehler bricht ab, statt weiterzuschleifen — sonst rennt die Schleife
* gegen dieselbe Wand und wiederholt dieselbe Meldung. */
async function runden<T extends { erzeugt: number; offen: number; fertig: boolean }>(
setzeWartet: (b: boolean) => void,
welche: 'thumbs' | 'webp',
lauf: () => Promise<T>,
fertigText: (erzeugt: number, letzte: T) => string,
) {
setzeWartet(true)
let erzeugt = 0
for (;;) {
let r: T
try {
r = await lauf()
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannt')
break
}
erzeugt += r.erzeugt
if (r.fertig) {
note('ok', fertigText(erzeugt, r))
break
}
setFortschritt((alt) => ({
...alt,
[welche]: `${erzeugt} erzeugt, noch ${r.offen} offen …`,
}))
}
setzeWartet(false)
setFortschritt((alt) => ({ ...alt, [welche]: '' }))
}
return (
<div className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-3">
@@ -91,19 +140,18 @@ export function Speicher() {
<div className="flex flex-wrap items-center gap-3">
<Knopf
wartet={zieht}
onClick={async () => {
setZieht(true)
await run(
() => api.thumbsNachziehen(),
(r) =>
r.erzeugt === 0
onClick={() =>
void runden(
setZieht,
'thumbs',
api.thumbsNachziehen,
(erzeugt, r) =>
(erzeugt === 0
? `Nichts zu tun — ${r.vorhanden} Vorschauen sind schon da.`
: `${r.erzeugt} Vorschauen erzeugt` +
(r.fehler ? `, ${r.fehler} fehlgeschlagen` : '') +
`. ${r.vorhanden} waren schon da.`,
: `${erzeugt} Vorschauen erzeugt.`) +
(r.fehler ? ` ${r.fehler} fehlgeschlagen.` : ''),
)
setZieht(false)
}}
}
kind={
<>
<Images size={15} />
@@ -111,10 +159,52 @@ export function Speicher() {
</>
}
/>
{fortschritt.thumbs ? (
<span className="text-sm tabular-nums">{fortschritt.thumbs}</span>
) : (
<p className="text-xs text-schrift-leise">
Die Galerie lädt Vorschauen statt Vollbilder. Was vor dieser Funktion
hochgeladen wurde, hat noch keine.
</p>
)}
</div>
<div className="mt-4 border-t border-rand pt-4">
<div className="flex flex-wrap items-center gap-3">
<Knopf
wartet={spart}
onClick={() =>
void runden(
setSpart,
'webp',
api.webpNachziehen,
(erzeugt, r) =>
(erzeugt === 0
? 'Nichts zu tun — alle Fassungen sind schon da.'
: `${erzeugt} Fassungen erzeugt.`) +
` Insgesamt ${bytes(r.vorher)}${bytes(r.nachher)}, spart ${bytes(r.gespart)}.` +
(r.lohntNicht ? ` Bei ${r.lohntNicht} lohnt WebP nicht.` : '') +
(r.fehler ? ` ${r.fehler} fehlgeschlagen.` : ''),
)
}
kind={
<>
<FileArchive size={15} />
Sparsame Fassungen erzeugen
</>
}
/>
{fortschritt.webp ? (
<span className="text-sm tabular-nums">{fortschritt.webp}</span>
) : (
<p className="max-w-md text-xs text-schrift-leise">
Legt neben jedes PNG und JPEG eine WebP-Fassung. Die Adresse
ändert sich <strong className="text-schrift">nicht</strong> wer
WebP versteht, bekommt die kleine Datei, alle anderen unverändert
das Original. Bei vielen Bildern läuft das in mehreren Runden.
</p>
)}
</div>
</div>
<div className="mt-4 border-t border-rand pt-4">
@@ -128,7 +218,7 @@ export function Speicher() {
(r) =>
r.dateiOhneDatensatz.anzahl +
r.datensatzOhneDatei.anzahl +
r.vorschauOhneBild.anzahl ===
r.abgeleitetOhneBild.anzahl ===
0
? 'Alles passt zusammen.'
: 'Abweichungen gefunden — siehe unten.',
@@ -144,7 +234,7 @@ export function Speicher() {
}
/>
<p className="text-xs text-schrift-leise">
Dateien ohne Datensatz, Datensätze ohne Datei, Vorschauen ohne
Dateien ohne Datensatz, Datensätze ohne Datei, Abgeleitetes ohne
Bild. Sucht nur verändert nichts.
</p>
</div>
@@ -172,18 +262,18 @@ export function Speicher() {
hinweis="Zeigen ins Leere — die Galerie zeigt ein kaputtes Bild."
/>
<Befund
titel="Vorschauen ohne Bild"
daten={verwaiste.vorschauOhneBild}
hinweis="Belegen Platz, sonst harmlos."
titel="Abgeleitetes ohne Bild"
daten={verwaiste.abgeleitetOhneBild}
hinweis="Vorschauen und sparsame Fassungen, deren Original weg ist. Belegen Platz, sonst harmlos."
/>
{(verwaiste.datensatzOhneDatei.anzahl > 0 ||
verwaiste.vorschauOhneBild.anzahl > 0) && (
verwaiste.abgeleitetOhneBild.anzahl > 0) && (
<div>
<Knopf
art="gefahr"
wartet={handelt}
onClick={() => setFragt('aufraeumen')}
kind="Leere Datensätze und Vorschauen entfernen"
kind="Leere Datensätze und Abgeleitetes entfernen"
/>
</div>
)}