diff --git a/README.md b/README.md index 981d15e..068b7cb 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/ROADMAP.md b/ROADMAP.md index a61c606..b9c9caf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/docs/API.md b/docs/API.md index 2519bbd..0cc155b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 diff --git a/server/package-lock.json b/server/package-lock.json index 0cf8bf0..f464417 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index 1e603c5..724ad29 100644 --- a/server/package.json +++ b/server/package.json @@ -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" }, diff --git a/server/src/db.ts b/server/src/db.ts index dcced8d..364c5e3 100644 --- a/server/src/db.ts +++ b/server/src/db.ts @@ -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() + +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) } diff --git a/server/src/index.ts b/server/src/index.ts index ded5c62..a418c6a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 = { - '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( diff --git a/server/src/routes/dash.ts b/server/src/routes/dash.ts index b81d83e..309b6d4 100644 --- a/server/src/routes/dash.ts +++ b/server/src/routes/dash.ts @@ -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(c: Context): Promise { 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,6 +770,95 @@ dashRoutes.post('/upload', async (c) => { error?: string }[] = [] + /** 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 { + 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 (${data.length} > ${config.maxUploadBytes} Bytes)`, + }) + return + } + + const digest = sha256(data) + // Der Hash als Rettungsanker fuer Namen, von denen nichts uebrig + // 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 = 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 + | Media + | undefined + if (vorhanden && !ueberschreiben) { + ergebnisse.push({ name, ok: false, path, error: 'Pfad ist belegt' }) + 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". 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' ? 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) { + db.prepare( + `UPDATE media SET size = ?, sha256 = ?, mime = ?, updated_at = ? + WHERE id = ?`, + ).run(data.length, digest, mime, zeit, vorhanden.id) + } else { + db.prepare( + `INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at) + VALUES (?, ?, ?, ?, NULL, ?, ?)`, + ).run(path, data.length, digest, mime, zeit, zeit) + } + + logEvent( + vorhanden ? 'replace' : 'upload', + path, + data.length, + 'user', + c.get('user').username, + ) + + ergebnisse.push({ + name, + ok: true, + path, + url: publicUrlFor(path), + size: data.length, + replaced: Boolean(vorhanden), + }) + } + for (const datei of dateien) { const name = datei.name || 'datei' try { @@ -675,69 +872,27 @@ dashRoutes.post('/upload', async (c) => { } const data = Buffer.from(await datei.arrayBuffer()) - if (data.length === 0) { - ergebnisse.push({ name, ok: false, error: 'leere Datei' }) + + 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 } - const digest = sha256(data) - // Der Hash als Rettungsanker fuer Namen, von denen nichts uebrig - // 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 path = checkPath(ordner ? `${ordner}/${dateiname}` : dateiname) - - const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as - | Media - | undefined - if (vorhanden && !ueberschreiben) { - ergebnisse.push({ name, ok: false, path, error: 'Pfad ist belegt' }) - continue - } - - // 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() ?? '' - const mime = - gemeldet && gemeldet !== 'application/octet-stream' - ? datei.type - : mimeFor(path) - await writeFileAtomic(path, data) - if (kannVorschau(mime)) await writeThumb(path, data) - - const zeit = now() - if (vorhanden) { - db.prepare( - `UPDATE media SET size = ?, sha256 = ?, mime = ?, updated_at = ? - WHERE id = ?`, - ).run(data.length, digest, mime, zeit, vorhanden.id) - } else { - db.prepare( - `INSERT INTO media (path, size, sha256, mime, token_id, created_at, updated_at) - VALUES (?, ?, ?, ?, NULL, ?, ?)`, - ).run(path, data.length, digest, mime, zeit, zeit) - } - - logEvent( - vorhanden ? 'replace' : 'upload', - path, - data.length, - 'user', - c.get('user').username, - ) - - ergebnisse.push({ - name, - ok: true, - path, - url: publicUrlFor(path), - size: data.length, - replaced: Boolean(vorhanden), - }) + 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 { + 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 { + 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, + }) }) diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index 624793d..066b8c8 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -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) { diff --git a/server/src/storage.ts b/server/src/storage.ts index 0715cd0..9c7648a 100644 --- a/server/src/storage.ts +++ b/server/src/storage.ts @@ -235,21 +235,121 @@ export async function moveFile(von: string, nach: string): Promise { 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 { + await mkdir(dirname(nachPfad), { recursive: true }) + await rename(vonPfad, nachPfad) + } catch { + // 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 { + const ziel = webpPfad(path) try { - const thumbZiel = thumbPath(nach) - await mkdir(dirname(thumbZiel), { recursive: true }) - await rename(thumbPath(von), thumbZiel) - } catch { - // Keine Vorschau vorhanden — kein Fehler. Sie laesst sich jederzeit - // neu rechnen. + 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 { 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 { + 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 { + const ziel = absolutePath(path) + await mkdir(dirname(ziel), { recursive: true }) + await rename(papierkorbPfad(ablage), ziel) } export async function fileExists(path: string): Promise { diff --git a/server/ui/src/App.tsx b/server/ui/src/App.tsx index 0fefa7e..20226f4 100644 --- a/server/ui/src/App.tsx +++ b/server/ui/src/App.tsx @@ -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 }, diff --git a/server/ui/src/api.ts b/server/ui/src/api.ts index 13b2647..ff903df 100644 --- a/server/ui/src/api.ts +++ b/server/ui/src/api.ts @@ -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('/api/dash/uebersicht'), stand: () => get('/api/dash/stand'), speicher: () => get('/api/dash/speicher'), + papierkorb: () => get('/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('/api/dash/stats'), thumbsNachziehen: () => post('/api/dash/maintenance/thumbs'), + webpNachziehen: () => post('/api/dash/maintenance/webp'), verwaiste: () => get('/api/dash/maintenance/orphans'), verwaisteBehandeln: (aktion: 'aufnehmen' | 'aufraeumen') => post<{ aktion: string; aufgenommen?: number; datensaetze?: number; vorschauen?: number }>( diff --git a/server/ui/src/components/SpeicherKarten.tsx b/server/ui/src/components/SpeicherKarten.tsx index 0c2a4ca..8967320 100644 --- a/server/ui/src/components/SpeicherKarten.tsx +++ b/server/ui/src/components/SpeicherKarten.tsx @@ -151,6 +151,45 @@ export function SpeicherKarten() { + +

+ 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. +

+
+
+

Nie geholt

+

+ {daten.nieGeholt.n}{' '} + + ({bytes(daten.nieGeholt.bytes)}) + +

+
+
+

Am häufigsten geholt

+ {daten.beliebteste.length === 0 ? ( +

noch nichts gezählt

+ ) : ( +
    + {daten.beliebteste.slice(0, 5).map((m) => ( +
  • + + {m.path} + + + {m.abrufe}× + +
  • + ))} +
+ )} +
+
+
+

Die Zahlen, die eine Aufbewahrungsregel braucht. „Alles älter als 90 Tage diff --git a/server/ui/src/pages/Papierkorb.tsx b/server/ui/src/pages/Papierkorb.tsx new file mode 100644 index 0000000..4a69fbd --- /dev/null +++ b/server/ui/src/pages/Papierkorb.tsx @@ -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(null) + const [gewaehlt, setGewaehlt] = useState>(new Set()) + const [laeuft, setLaeuft] = useState(false) + const [fragt, setFragt] = useState(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 ( +

+ +
+ ) + } + + 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 ( +
+
+ + {daten.n} {daten.n === 1 ? 'Eintrag' : 'Einträge'} · {bytes(daten.bytes)} + + + {auswahl.length > 0 && ( + <> + void zurueck(auswahl)} + kind={ + <> + + {auswahl.length} zurückholen + + } + /> + setFragt(auswahl)} + kind={`${auswahl.length} endgültig löschen`} + /> + + )} + + {items.length > 0 && ( + setFragt('alles')} + kind={ + <> + + Papierkorb leeren + + } + /> + )} +
+ + + {items.length === 0 ? ( + } + text="Der Papierkorb ist leer." + /> + ) : ( + + + + + + + + + + + + + + + + + + {items.map((e) => ( + + + + + + + + ))} + +
+ PfadGrößeGelöschtVon
+ + 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" + /> + + {e.path} + + {bytes(e.size)} + + {zeit(e.geloescht)} + + {e.von} +
+ )} +
+ +

+ 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. +

+ + 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={ +

+ + + {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. + +

+ } + /> +
+ ) +} diff --git a/server/ui/src/pages/Speicher.tsx b/server/ui/src/pages/Speicher.tsx index e8a4bdd..4c3e283 100644 --- a/server/ui/src/pages/Speicher.tsx +++ b/server/ui/src/pages/Speicher.tsx @@ -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(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(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( + setzeWartet: (b: boolean) => void, + welche: 'thumbs' | 'webp', + lauf: () => Promise, + 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 (
@@ -91,19 +140,18 @@ export function Speicher() {
{ - 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={ <> @@ -111,10 +159,52 @@ export function Speicher() { } /> -

- Die Galerie lädt Vorschauen statt Vollbilder. Was vor dieser Funktion - hochgeladen wurde, hat noch keine. -

+ {fortschritt.thumbs ? ( + {fortschritt.thumbs} + ) : ( +

+ Die Galerie lädt Vorschauen statt Vollbilder. Was vor dieser Funktion + hochgeladen wurde, hat noch keine. +

+ )} +
+ +
+
+ + 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={ + <> + + Sparsame Fassungen erzeugen + + } + /> + {fortschritt.webp ? ( + {fortschritt.webp} + ) : ( +

+ Legt neben jedes PNG und JPEG eine WebP-Fassung. Die Adresse + ändert sich nicht — wer + WebP versteht, bekommt die kleine Datei, alle anderen unverändert + das Original. Bei vielen Bildern läuft das in mehreren Runden. +

+ )} +
@@ -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() { } />

- Dateien ohne Datensatz, Datensätze ohne Datei, Vorschauen ohne + Dateien ohne Datensatz, Datensätze ohne Datei, Abgeleitetes ohne Bild. Sucht nur — verändert nichts.

@@ -172,18 +262,18 @@ export function Speicher() { hinweis="Zeigen ins Leere — die Galerie zeigt ein kaputtes Bild." /> {(verwaiste.datensatzOhneDatei.anzahl > 0 || - verwaiste.vorschauOhneBild.anzahl > 0) && ( + verwaiste.abgeleitetOhneBild.anzahl > 0) && (
setFragt('aufraeumen')} - kind="Leere Datensätze und Vorschauen entfernen" + kind="Leere Datensätze und Abgeleitetes entfernen" />
)}