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:
@@ -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,
|
**Hochladen** — über die Skript-API mit Token (drei Rumpfformen: multipart,
|
||||||
Base64, rohe Bytes) oder aus dem Dashboard per Ziehen und Ablegen. Bilder,
|
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
|
**Ausliefern** — mit ETag, `304`, Bereichsanfragen und `nosniff`. Vorschaubilder
|
||||||
werden beim Upload erzeugt (320 px WebP): aus 7,5 MB je Galerieseite werden
|
werden beim Upload erzeugt (320 px WebP): aus 7,5 MB je Galerieseite werden
|
||||||
766 KB.
|
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,
|
**Verwalten** — Galerie mit Ordnerbaum, Suche, Filter nach Medienart,
|
||||||
Sortierung, Mehrfachauswahl, Verschieben, Löschen. Betrachter für Bild, Video,
|
Sortierung, Mehrfachauswahl, Verschieben, Löschen. Betrachter für Bild, Video,
|
||||||
Ton und PDF ohne neuen Tab.
|
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
|
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
|
steht. Vorschaubilder und WebP-Fassungen sind **nicht** im Archiv — beides ist
|
||||||
Knopf unter *Speicher* neu rechnen.
|
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
@@ -486,10 +486,52 @@ darin lag.
|
|||||||
2. Den Proxy-Host `fivecdn.d4rkst3r.de` löschen — er zeigt auf den toten Port
|
2. Den Proxy-Host `fivecdn.d4rkst3r.de` löschen — er zeigt auf den toten Port
|
||||||
9100 und gibt 502.
|
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
|
Vier Dinge, gemessen und im Betrieb:
|
||||||
wandern nach `legacy/`.
|
|
||||||
|
**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
|
**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
|
gelöscht worden — mit ihnen die Möglichkeit, im Zweifel nachzusehen, wie es
|
||||||
|
|||||||
+46
@@ -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
|
> Ein führender Unterstrich fällt durch. Das ist kein erfundener Fall: das
|
||||||
> Fotostudio legt seine Leerbilder als `_plate` ab.
|
> 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
|
## Anmeldung: Token
|
||||||
|
|||||||
Generated
+7
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "^11.7.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"hono": "^4.6.14",
|
"hono": "^4.6.14",
|
||||||
"sharp": "^0.35.3"
|
"sharp": "^0.35.3"
|
||||||
},
|
},
|
||||||
@@ -1226,6 +1227,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/file-uri-to-path": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"better-sqlite3": "^11.7.0",
|
"better-sqlite3": "^11.7.0",
|
||||||
|
"fflate": "^0.8.3",
|
||||||
"hono": "^4.6.14",
|
"hono": "^4.6.14",
|
||||||
"sharp": "^0.35.3"
|
"sharp": "^0.35.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
-- Port, Datenordner) bleibt in der Umgebung, damit ein falscher Klick den
|
||||||
-- Dienst nicht unerreichbar macht. Hier liegt nur, was ohne Neustart
|
-- Dienst nicht unerreichbar macht. Hier liegt nur, was ohne Neustart
|
||||||
-- verstellbar sein soll -- derzeit die Discord-Anmeldung.
|
-- 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 (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
@@ -141,6 +164,16 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
console.log('[db] Spalte events.von angelegt')
|
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')) {
|
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_avatar TEXT')
|
||||||
db.exec('ALTER TABLE users ADD COLUMN discord_name TEXT')
|
db.exec('ALTER TABLE users ADD COLUMN discord_name TEXT')
|
||||||
@@ -236,6 +269,20 @@ export type Media = {
|
|||||||
token_id: number | null
|
token_id: number | null
|
||||||
created_at: number
|
created_at: number
|
||||||
updated_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'
|
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
|
/** 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
|
* 900 Zeilen -- kein Problem, aber auf Dauer auch kein Grund, alles zu
|
||||||
* behalten. Ein halbes Jahr reicht fuer die Frage "wer war das". */
|
* 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) {
|
export function pruneEvents(tage = 180) {
|
||||||
db.prepare('DELETE FROM events WHERE at < ?').run(now() - tage * 86_400_000)
|
db.prepare('DELETE FROM events WHERE at < ?').run(now() - tage * 86_400_000)
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-6
@@ -28,11 +28,11 @@ import { Hono } from 'hono'
|
|||||||
import { logger } from 'hono/logger'
|
import { logger } from 'hono/logger'
|
||||||
import { config } from './config.js'
|
import { config } from './config.js'
|
||||||
import { ensureAdmin } from './auth.js'
|
import { ensureAdmin } from './auth.js'
|
||||||
import { pruneSessions } from './db.js'
|
import { pruneSessions, schreibeAbrufe, zaehleAbruf } from './db.js'
|
||||||
import { pruefeSicherung } from './meldung.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 { uploadRoutes } from './routes/upload.js'
|
||||||
import { dashRoutes } from './routes/dash.js'
|
import { dashRoutes, papierkorbAusleeren } from './routes/dash.js'
|
||||||
|
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ async function serveFile(c: any, rawPath: string) {
|
|||||||
return c.text('nicht gefunden', 404)
|
return c.text('nicht gefunden', 404)
|
||||||
}
|
}
|
||||||
|
|
||||||
const absolute = absolutePath(path)
|
let absolute = absolutePath(path)
|
||||||
let info
|
let info
|
||||||
try {
|
try {
|
||||||
info = await stat(absolute)
|
info = await stat(absolute)
|
||||||
@@ -106,10 +106,45 @@ async function serveFile(c: any, rawPath: string) {
|
|||||||
return c.text('nicht gefunden', 404)
|
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> = {
|
const headers: Record<string, string> = {
|
||||||
'Content-Type': mimeFor(path),
|
'Content-Type': typ,
|
||||||
'Cache-Control': 'public, max-age=300',
|
'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,
|
ETag: etag,
|
||||||
'Last-Modified': info.mtime.toUTCString(),
|
'Last-Modified': info.mtime.toUTCString(),
|
||||||
'Accept-Ranges': 'bytes',
|
'Accept-Ranges': 'bytes',
|
||||||
@@ -118,6 +153,13 @@ async function serveFile(c: any, rawPath: string) {
|
|||||||
'X-Content-Type-Options': 'nosniff',
|
'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.header('if-none-match') === etag) return c.body(null, 304, headers)
|
||||||
if (c.req.method === 'HEAD') {
|
if (c.req.method === 'HEAD') {
|
||||||
return c.body(null, 200, { ...headers, 'Content-Length': String(info.size) })
|
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()
|
setTimeout(() => void pruefeSicherung(), 60_000).unref()
|
||||||
setInterval(() => void pruefeSicherung(), 3600_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) => {
|
serve({ fetch: app.fetch, port: config.port }, (info) => {
|
||||||
console.log(`[d4rk_media] Port ${info.port}`)
|
console.log(`[d4rk_media] Port ${info.port}`)
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
+487
-48
@@ -6,6 +6,7 @@
|
|||||||
// Rueckmeldung wie ein kaputter Knopf aussieht.
|
// Rueckmeldung wie ein kaputter Knopf aussieht.
|
||||||
|
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
|
import { unzipSync } from 'fflate'
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
||||||
import { config } from '../config.js'
|
import { config } from '../config.js'
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
pruneSessions,
|
pruneSessions,
|
||||||
type Media,
|
type Media,
|
||||||
type MediaEvent,
|
type MediaEvent,
|
||||||
|
type PapierkorbEintrag,
|
||||||
type Token,
|
type Token,
|
||||||
type User,
|
type User,
|
||||||
} from '../db.js'
|
} from '../db.js'
|
||||||
@@ -50,16 +52,22 @@ import { MELDUNG_KEYS, meldungConfig, testMeldung } from '../meldung.js'
|
|||||||
import {
|
import {
|
||||||
absolutePath,
|
absolutePath,
|
||||||
checkPath,
|
checkPath,
|
||||||
|
ausDemPapierkorb,
|
||||||
deleteFile,
|
deleteFile,
|
||||||
|
inDenPapierkorb,
|
||||||
kannVorschau,
|
kannVorschau,
|
||||||
|
lohntWebp,
|
||||||
mimeFor,
|
mimeFor,
|
||||||
moveFile,
|
moveFile,
|
||||||
publicUrlFor,
|
publicUrlFor,
|
||||||
safeFilename,
|
safeFilename,
|
||||||
sha256,
|
sha256,
|
||||||
|
papierkorbPfad,
|
||||||
thumbPath,
|
thumbPath,
|
||||||
|
webpPfad,
|
||||||
writeFileAtomic,
|
writeFileAtomic,
|
||||||
writeThumb,
|
writeThumb,
|
||||||
|
writeWebp,
|
||||||
PathError,
|
PathError,
|
||||||
} from '../storage.js'
|
} 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
|
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
|
// ------------------------------------------------------------ Die Bremse
|
||||||
//
|
//
|
||||||
// Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch
|
// 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
|
const row = db.prepare('SELECT * FROM media WHERE id = ?').get(id) as Media | undefined
|
||||||
if (!row) return c.json({ error: 'nicht gefunden' }, 404)
|
if (!row) return c.json({ error: 'nicht gefunden' }, 404)
|
||||||
|
|
||||||
await deleteFile(row.path)
|
await inPapierkorb(row, c.get('user').username)
|
||||||
db.prepare('DELETE FROM media WHERE id = ?').run(id)
|
|
||||||
logEvent('delete', row.path, row.size, 'user', c.get('user').username)
|
|
||||||
return c.json({ deleted: row.path })
|
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 select = db.prepare('SELECT * FROM media WHERE id = ?')
|
||||||
const remove = db.prepare('DELETE FROM media WHERE id = ?')
|
|
||||||
const deleted: string[] = []
|
const deleted: string[] = []
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const row = select.get(id) as Media | undefined
|
const row = select.get(id) as Media | undefined
|
||||||
if (!row) continue
|
if (!row) continue
|
||||||
await deleteFile(row.path)
|
await inPapierkorb(row, c.get('user').username)
|
||||||
remove.run(id)
|
|
||||||
logEvent('delete', row.path, row.size, 'user', c.get('user').username)
|
|
||||||
deleted.push(row.path)
|
deleted.push(row.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({ deleted, count: deleted.length })
|
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.
|
/** 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
|
* 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
|
* haengt an einem Bearer-Token. Wer im Dashboard sitzt, hat eine SITZUNG und
|
||||||
@@ -662,22 +770,33 @@ dashRoutes.post('/upload', async (c) => {
|
|||||||
error?: string
|
error?: string
|
||||||
}[] = []
|
}[] = []
|
||||||
|
|
||||||
for (const datei of dateien) {
|
/** Eine Datei ablegen -- EIN Weg fuer alles.
|
||||||
const name = datei.name || 'datei'
|
*
|
||||||
try {
|
* Ob der Inhalt aus einem Formularteil kam oder aus einem Archiv, macht ab
|
||||||
if (datei.size > config.maxUploadBytes) {
|
* 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({
|
ergebnisse.push({
|
||||||
name,
|
name,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: `zu gross (${datei.size} > ${config.maxUploadBytes} Bytes)`,
|
error: `zu gross (${data.length} > ${config.maxUploadBytes} Bytes)`,
|
||||||
})
|
})
|
||||||
continue
|
return
|
||||||
}
|
|
||||||
|
|
||||||
const data = Buffer.from(await datei.arrayBuffer())
|
|
||||||
if (data.length === 0) {
|
|
||||||
ergebnisse.push({ name, ok: false, error: 'leere Datei' })
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const digest = sha256(data)
|
const digest = sha256(data)
|
||||||
@@ -685,7 +804,7 @@ dashRoutes.post('/upload', async (c) => {
|
|||||||
// bleibt (kyrillisch, nur Leerzeichen). Ohne ihn hiessen zwei
|
// bleibt (kyrillisch, nur Leerzeichen). Ohne ihn hiessen zwei
|
||||||
// verschiedene Dateien beide "datei.txt", und die zweite
|
// verschiedene Dateien beide "datei.txt", und die zweite
|
||||||
// ueberschriebe die erste still. Beim Messen genau so passiert.
|
// 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 path = checkPath(ordner ? `${ordner}/${dateiname}` : dateiname)
|
||||||
|
|
||||||
const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
|
const vorhanden = db.prepare('SELECT * FROM media WHERE path = ?').get(path) as
|
||||||
@@ -693,21 +812,21 @@ dashRoutes.post('/upload', async (c) => {
|
|||||||
| undefined
|
| undefined
|
||||||
if (vorhanden && !ueberschreiben) {
|
if (vorhanden && !ueberschreiben) {
|
||||||
ergebnisse.push({ name, ok: false, path, error: 'Pfad ist belegt' })
|
ergebnisse.push({ name, ok: false, path, error: 'Pfad ist belegt' })
|
||||||
continue
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Was der Absender ueber die Art sagt, gilt -- ABER NICHT sein
|
// Was der Absender ueber die Art sagt, gilt -- ABER NICHT sein
|
||||||
// Achselzucken. Ein Formularteil ohne erkannte Art traegt
|
// Achselzucken. Ein Formularteil ohne erkannte Art traegt
|
||||||
// "application/octet-stream", und das ist weniger wert als unsere
|
// "application/octet-stream", und das ist weniger wert als unsere
|
||||||
// Endungstabelle: beim Messen landeten so clip.mp4, ton.mp3 und
|
// Endungstabelle: beim Messen landeten so clip.mp4, ton.mp3 und
|
||||||
// handbuch.pdf allesamt unter "Anderes".
|
// handbuch.pdf allesamt unter "Anderes". Aus einem Archiv kommt gar
|
||||||
const gemeldet = (datei.type || '').split(';')[0]?.trim().toLowerCase() ?? ''
|
// keine Angabe -- dort entscheidet die Endung ohnehin allein.
|
||||||
|
const gemeldet = (gemeldeterTyp || '').split(';')[0]?.trim().toLowerCase() ?? ''
|
||||||
const mime =
|
const mime =
|
||||||
gemeldet && gemeldet !== 'application/octet-stream'
|
gemeldet && gemeldet !== 'application/octet-stream' ? gemeldeterTyp : mimeFor(path)
|
||||||
? datei.type
|
|
||||||
: mimeFor(path)
|
|
||||||
await writeFileAtomic(path, data)
|
await writeFileAtomic(path, data)
|
||||||
if (kannVorschau(mime)) await writeThumb(path, data)
|
if (kannVorschau(mime)) await writeThumb(path, data)
|
||||||
|
if (lohntWebp(mime)) await writeWebp(path, data)
|
||||||
|
|
||||||
const zeit = now()
|
const zeit = now()
|
||||||
if (vorhanden) {
|
if (vorhanden) {
|
||||||
@@ -738,6 +857,42 @@ dashRoutes.post('/upload', async (c) => {
|
|||||||
size: data.length,
|
size: data.length,
|
||||||
replaced: Boolean(vorhanden),
|
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) {
|
} catch (err) {
|
||||||
// Eine kaputte Datei bricht die anderen NICHT ab. Wer fuenf Dateien
|
// Eine kaputte Datei bricht die anderen NICHT ab. Wer fuenf Dateien
|
||||||
// zieht und bei der zweiten alles verliert, zieht sie danach
|
// zieht und bei der zweiten alles verliert, zieht sie danach
|
||||||
@@ -1303,10 +1458,15 @@ dashRoutes.delete('/folders/:pfad{.+}', async (c) => {
|
|||||||
400,
|
400,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Die Vorschauen desselben Ordners hinterher, falls einer da ist.
|
// Das Abgeleitete desselben Ordners hinterher, falls es da ist. HIER mit
|
||||||
await rm(join(config.dataDir, 'thumbs', pfad), { recursive: true, force: true }).catch(
|
// 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 })
|
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
|
* weiss, was da liegt, ist der Weg, wie man Daten verliert. Diese Route
|
||||||
* loescht nichts -- sie zeigt.
|
* 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) => {
|
dashRoutes.get('/speicher', (c) => {
|
||||||
const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => {
|
const nachArt = Object.entries(ARTEN).map(([name, bedingung]) => {
|
||||||
const z = db
|
const z = db
|
||||||
@@ -1430,7 +1729,24 @@ dashRoutes.get('/speicher', (c) => {
|
|||||||
return { tage, files: z.n, bytes: z.bytes }
|
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) => {
|
dashRoutes.get('/stats', (c) => {
|
||||||
@@ -1651,13 +1967,15 @@ async function verwaisteSuchen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Vorschauen ohne Bild. Der Name traegt die Endung des Originals plus
|
// Abgeleitetes ohne Bild -- Vorschauen UND sparsame Fassungen. Beide liegen
|
||||||
// ".webp" -- deshalb wird sie hier abgeschnitten, um zurueckzurechnen.
|
// im selben Baum wie das Original mit ".webp" hintendran, deshalb wird die
|
||||||
const vorschauen: string[] = []
|
// Endung hier abgeschnitten, um zurueckzurechnen, und deshalb reicht EINE
|
||||||
async function thumbsDurchlaufen(rel: string) {
|
// Schleife fuer beide Ordner.
|
||||||
|
const abgeleitetOhneBild: string[] = []
|
||||||
|
async function abgeleitetDurchlaufen(ordner: string, rel: string) {
|
||||||
let eintraege
|
let eintraege
|
||||||
try {
|
try {
|
||||||
eintraege = await readdir(join(config.dataDir, 'thumbs', rel), {
|
eintraege = await readdir(join(config.dataDir, ordner, rel), {
|
||||||
withFileTypes: true,
|
withFileTypes: true,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1665,14 +1983,19 @@ async function verwaisteSuchen() {
|
|||||||
}
|
}
|
||||||
for (const e of eintraege) {
|
for (const e of eintraege) {
|
||||||
const p = rel ? `${rel}/${e.name}` : e.name
|
const p = rel ? `${rel}/${e.name}` : e.name
|
||||||
if (e.isDirectory()) await thumbsDurchlaufen(p)
|
if (e.isDirectory()) await abgeleitetDurchlaufen(ordner, p)
|
||||||
else if (e.isFile()) vorschauen.push(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) => {
|
dashRoutes.get('/maintenance/orphans', async (c) => {
|
||||||
@@ -1686,9 +2009,9 @@ dashRoutes.get('/maintenance/orphans', async (c) => {
|
|||||||
anzahl: f.datensatzOhneDatei.length,
|
anzahl: f.datensatzOhneDatei.length,
|
||||||
beispiele: f.datensatzOhneDatei.slice(0, 20).map((r) => r.path),
|
beispiele: f.datensatzOhneDatei.slice(0, 20).map((r) => r.path),
|
||||||
},
|
},
|
||||||
vorschauOhneBild: {
|
abgeleitetOhneBild: {
|
||||||
anzahl: f.vorschauOhneBild.length,
|
anzahl: f.abgeleitetOhneBild.length,
|
||||||
beispiele: f.vorschauOhneBild.slice(0, 20),
|
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,
|
* 'aufnehmen' Dateien ohne Datensatz kommen in die Datenbank. Sie sind da,
|
||||||
* sie sind ausgeliefert -- sie fehlen nur in der Liste. Das ist
|
* sie sind ausgeliefert -- sie fehlen nur in der Liste. Das ist
|
||||||
* fast immer das Richtige und loescht NICHTS.
|
* fast immer das Richtige und loescht NICHTS.
|
||||||
* 'aufraeumen' Datensaetze ohne Datei und verwaiste Vorschauen fliegen raus.
|
* 'aufraeumen' Datensaetze ohne Datei und verwaistes Abgeleitetes fliegen
|
||||||
* Hier geht etwas weg, aber nur Verweise auf Dateien, die es
|
* raus. Hier geht etwas weg, aber nur Verweise auf Dateien, die
|
||||||
* nicht mehr gibt, und abgeleitete Bilder. */
|
* 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) => {
|
dashRoutes.post('/maintenance/orphans', async (c) => {
|
||||||
const body = await jsonBody<{ aktion?: string }>(c)
|
const body = await jsonBody<{ aktion?: string }>(c)
|
||||||
const aktion = body?.aktion ?? 'aufnehmen'
|
const aktion = body?.aktion ?? 'aufnehmen'
|
||||||
@@ -1718,6 +2043,7 @@ dashRoutes.post('/maintenance/orphans', async (c) => {
|
|||||||
VALUES (?, ?, ?, ?, NULL, ?, ?)`,
|
VALUES (?, ?, ?, ?, NULL, ?, ?)`,
|
||||||
).run(pfad, daten.length, sha256(daten), mimeFor(pfad), zeit, zeit)
|
).run(pfad, daten.length, sha256(daten), mimeFor(pfad), zeit, zeit)
|
||||||
if (kannVorschau(mimeFor(pfad))) await writeThumb(pfad, daten)
|
if (kannVorschau(mimeFor(pfad))) await writeThumb(pfad, daten)
|
||||||
|
if (lohntWebp(mimeFor(pfad))) await writeWebp(pfad, daten)
|
||||||
logEvent('upload', pfad, daten.length, 'user', wer)
|
logEvent('upload', pfad, daten.length, 'user', wer)
|
||||||
aufgenommen++
|
aufgenommen++
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1735,8 +2061,8 @@ dashRoutes.post('/maintenance/orphans', async (c) => {
|
|||||||
datensaetze++
|
datensaetze++
|
||||||
}
|
}
|
||||||
let vorschauen = 0
|
let vorschauen = 0
|
||||||
for (const p of f.vorschauOhneBild) {
|
for (const p of f.abgeleitetOhneBild) {
|
||||||
await rm(join(config.dataDir, 'thumbs', p), { force: true })
|
await rm(join(config.dataDir, p), { force: true })
|
||||||
vorschauen++
|
vorschauen++
|
||||||
}
|
}
|
||||||
return c.json({ aktion, datensaetze, 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
|
* 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
|
* Sekunden "fertig" sagt, ohne zu sagen WAS fertig ist, ist der Anfang der
|
||||||
* Frage, ob er ueberhaupt etwas getan hat. */
|
* Frage, ob er ueberhaupt etwas getan hat. */
|
||||||
|
const THUMB_STAPEL = 300
|
||||||
|
|
||||||
dashRoutes.post('/maintenance/thumbs', async (c) => {
|
dashRoutes.post('/maintenance/thumbs', async (c) => {
|
||||||
const rows = db.prepare('SELECT path, mime FROM media').all() as Pick<
|
const rows = db.prepare('SELECT path, mime FROM media').all() as Pick<
|
||||||
Media,
|
Media,
|
||||||
@@ -1763,6 +2091,7 @@ dashRoutes.post('/maintenance/thumbs', async (c) => {
|
|||||||
let vorhanden = 0
|
let vorhanden = 0
|
||||||
let uebersprungen = 0
|
let uebersprungen = 0
|
||||||
let fehler = 0
|
let fehler = 0
|
||||||
|
let offen = 0
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (!kannVorschau(row.mime)) {
|
if (!kannVorschau(row.mime)) {
|
||||||
@@ -1777,6 +2106,14 @@ dashRoutes.post('/maintenance/thumbs', async (c) => {
|
|||||||
/* gibt es noch nicht — gleich */
|
/* 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 {
|
try {
|
||||||
const data = await readFile(absolutePath(row.path))
|
const data = await readFile(absolutePath(row.path))
|
||||||
if (await writeThumb(row.path, data)) erzeugt++
|
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,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -31,11 +31,13 @@ import {
|
|||||||
extensionFor,
|
extensionFor,
|
||||||
fileExists,
|
fileExists,
|
||||||
kannVorschau,
|
kannVorschau,
|
||||||
|
lohntWebp,
|
||||||
mimeFor,
|
mimeFor,
|
||||||
publicUrlFor,
|
publicUrlFor,
|
||||||
sha256,
|
sha256,
|
||||||
writeFileAtomic,
|
writeFileAtomic,
|
||||||
writeThumb,
|
writeThumb,
|
||||||
|
writeWebp,
|
||||||
} from '../storage.js'
|
} from '../storage.js'
|
||||||
|
|
||||||
type Vars = { token: Token }
|
type Vars = { token: Token }
|
||||||
@@ -206,6 +208,7 @@ uploadRoutes.post('/upload', async (c) => {
|
|||||||
// eigenem Ergebnis: scheitert sie, ist das Bild trotzdem abgelegt und der
|
// eigenem Ergebnis: scheitert sie, ist das Bild trotzdem abgelegt und der
|
||||||
// Upload ein Erfolg. Das Dashboard faellt dann auf das Vollbild zurueck.
|
// Upload ein Erfolg. Das Dashboard faellt dann auf das Vollbild zurueck.
|
||||||
if (kannVorschau(mime)) await writeThumb(path, data)
|
if (kannVorschau(mime)) await writeThumb(path, data)
|
||||||
|
if (lohntWebp(mime)) await writeWebp(path, data)
|
||||||
|
|
||||||
const timestamp = now()
|
const timestamp = now()
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
+107
-7
@@ -235,21 +235,121 @@ export async function moveFile(von: string, nach: string): Promise<void> {
|
|||||||
await mkdir(dirname(ziel), { recursive: true })
|
await mkdir(dirname(ziel), { recursive: true })
|
||||||
await rename(absolutePath(von), ziel)
|
await rename(absolutePath(von), ziel)
|
||||||
|
|
||||||
|
const abgeleitet: [string, string][] = [
|
||||||
|
[thumbPath(von), thumbPath(nach)],
|
||||||
|
[webpPfad(von), webpPfad(nach)],
|
||||||
|
]
|
||||||
|
for (const [vonPfad, nachPfad] of abgeleitet) {
|
||||||
try {
|
try {
|
||||||
const thumbZiel = thumbPath(nach)
|
await mkdir(dirname(nachPfad), { recursive: true })
|
||||||
await mkdir(dirname(thumbZiel), { recursive: true })
|
await rename(vonPfad, nachPfad)
|
||||||
await rename(thumbPath(von), thumbZiel)
|
|
||||||
} catch {
|
} catch {
|
||||||
// Keine Vorschau vorhanden — kein Fehler. Sie laesst sich jederzeit
|
// Nicht vorhanden — kein Fehler. Beides ist abgeleitet und laesst
|
||||||
// neu rechnen.
|
// 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> {
|
export async function deleteFile(path: string): Promise<void> {
|
||||||
await rm(absolutePath(path), { force: true })
|
await rm(absolutePath(path), { force: true })
|
||||||
// Die Vorschau geht mit. Sonst bleibt sie liegen, und beim naechsten Bild
|
// Vorschau und sparsame Fassung gehen mit. Sonst bleiben sie liegen, und
|
||||||
// unter demselben Pfad sieht man das alte.
|
// beim naechsten Bild unter demselben Pfad sieht man das alte.
|
||||||
await rm(thumbPath(path), { force: true })
|
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> {
|
export async function fileExists(path: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
PieChart,
|
PieChart,
|
||||||
Settings,
|
Settings,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useStore } from './store'
|
import { useStore } from './store'
|
||||||
import { Anmelden } from './pages/Anmelden'
|
import { Anmelden } from './pages/Anmelden'
|
||||||
@@ -19,6 +20,7 @@ import { Konto } from './pages/Konto'
|
|||||||
import { Einstellungen } from './pages/Einstellungen'
|
import { Einstellungen } from './pages/Einstellungen'
|
||||||
import { Verlauf } from './pages/Verlauf'
|
import { Verlauf } from './pages/Verlauf'
|
||||||
import { Api } from './pages/Api'
|
import { Api } from './pages/Api'
|
||||||
|
import { Papierkorb } from './pages/Papierkorb'
|
||||||
import { Meldungen } from './components/Meldungen'
|
import { Meldungen } from './components/Meldungen'
|
||||||
import { gehZu, usePfad } from './router'
|
import { gehZu, usePfad } from './router'
|
||||||
import { Kontomenue } from './components/Kontomenue'
|
import { Kontomenue } from './components/Kontomenue'
|
||||||
@@ -37,6 +39,7 @@ const seiten = [
|
|||||||
{ pfad: '/galerie', name: 'Galerie', kind: Images, Inhalt: Galerie },
|
{ pfad: '/galerie', name: 'Galerie', kind: Images, Inhalt: Galerie },
|
||||||
{ pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf },
|
{ pfad: '/verlauf', name: 'Verlauf', kind: History, Inhalt: Verlauf },
|
||||||
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
|
{ 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: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
|
||||||
{ pfad: '/api', name: 'API', kind: Code2, Inhalt: Api },
|
{ pfad: '/api', name: 'API', kind: Code2, Inhalt: Api },
|
||||||
{ pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen },
|
{ pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen },
|
||||||
|
|||||||
+58
-1
@@ -110,6 +110,9 @@ export type MediaItem = {
|
|||||||
created_at: number
|
created_at: number
|
||||||
updated_at: number
|
updated_at: number
|
||||||
url: string
|
url: string
|
||||||
|
/** Wie oft die Datei oeffentlich geholt wurde. */
|
||||||
|
abrufe: number
|
||||||
|
zuletzt_abgerufen: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MediaPage = {
|
export type MediaPage = {
|
||||||
@@ -127,6 +130,23 @@ export type Stats = {
|
|||||||
publicUrl: string
|
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 = {
|
export type Speicher = {
|
||||||
nachArt: { art: string; files: number; bytes: number }[]
|
nachArt: { art: string; files: number; bytes: number }[]
|
||||||
groesste: MediaItem[]
|
groesste: MediaItem[]
|
||||||
@@ -134,6 +154,9 @@ export type Speicher = {
|
|||||||
doppelte: { sha256: string; n: number; bytes: number; pfade: string }[]
|
doppelte: { sha256: string; n: number; bytes: number; pfade: string }[]
|
||||||
verschwendet: number
|
verschwendet: number
|
||||||
stufen: { tage: number; files: number; bytes: 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 = {
|
export type TokenRow = {
|
||||||
@@ -206,7 +229,28 @@ export type Richtung = 'auf' | 'ab'
|
|||||||
export type Verwaiste = {
|
export type Verwaiste = {
|
||||||
dateiOhneDatensatz: { anzahl: number; beispiele: string[] }
|
dateiOhneDatensatz: { anzahl: number; beispiele: string[] }
|
||||||
datensatzOhneDatei: { 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. */
|
/** Was das Sicherungsskript nach jedem Lauf zurueckmeldet. */
|
||||||
@@ -296,6 +340,9 @@ export type ThumbLauf = {
|
|||||||
uebersprungen: number
|
uebersprungen: number
|
||||||
fehler: number
|
fehler: number
|
||||||
gesamt: number
|
gesamt: number
|
||||||
|
/** Wieviele noch fehlen. Siehe WebpLauf — derselbe Grund. */
|
||||||
|
offen: number
|
||||||
|
fertig: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die Adresse der Vorschau zu einem Pfad.
|
/** Die Adresse der Vorschau zu einem Pfad.
|
||||||
@@ -380,8 +427,18 @@ export const api = {
|
|||||||
uebersicht: () => get<Uebersicht>('/api/dash/uebersicht'),
|
uebersicht: () => get<Uebersicht>('/api/dash/uebersicht'),
|
||||||
stand: () => get<Stand>('/api/dash/stand'),
|
stand: () => get<Stand>('/api/dash/stand'),
|
||||||
speicher: () => get<Speicher>('/api/dash/speicher'),
|
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'),
|
stats: () => get<Stats>('/api/dash/stats'),
|
||||||
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
|
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
|
||||||
|
webpNachziehen: () => post<WebpLauf>('/api/dash/maintenance/webp'),
|
||||||
verwaiste: () => get<Verwaiste>('/api/dash/maintenance/orphans'),
|
verwaiste: () => get<Verwaiste>('/api/dash/maintenance/orphans'),
|
||||||
verwaisteBehandeln: (aktion: 'aufnehmen' | 'aufraeumen') =>
|
verwaisteBehandeln: (aktion: 'aufnehmen' | 'aufraeumen') =>
|
||||||
post<{ aktion: string; aufgenommen?: number; datensaetze?: number; vorschauen?: number }>(
|
post<{ aktion: string; aufgenommen?: number; datensaetze?: number; vorschauen?: number }>(
|
||||||
|
|||||||
@@ -151,6 +151,45 @@ export function SpeicherKarten() {
|
|||||||
</Karte>
|
</Karte>
|
||||||
</div>
|
</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">
|
<Karte titel="Wie alt der Bestand ist">
|
||||||
<p className="mb-3 text-xs text-schrift-leise">
|
<p className="mb-3 text-xs text-schrift-leise">
|
||||||
Die Zahlen, die eine Aufbewahrungsregel braucht. „Alles älter als 90 Tage
|
Die Zahlen, die eine Aufbewahrungsregel braucht. „Alles älter als 90 Tage
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
|
FileArchive,
|
||||||
Folder,
|
Folder,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Images,
|
Images,
|
||||||
@@ -21,6 +22,8 @@ export function Speicher() {
|
|||||||
const [stats, setStats] = useState<Stats | null>(null)
|
const [stats, setStats] = useState<Stats | null>(null)
|
||||||
const [laedt, setLaedt] = useState(true)
|
const [laedt, setLaedt] = useState(true)
|
||||||
const [zieht, setZieht] = useState(false)
|
const [zieht, setZieht] = useState(false)
|
||||||
|
const [spart, setSpart] = useState(false)
|
||||||
|
const [fortschritt, setFortschritt] = useState({ thumbs: '', webp: '' })
|
||||||
const [verwaiste, setVerwaiste] = useState<Verwaiste | null>(null)
|
const [verwaiste, setVerwaiste] = useState<Verwaiste | null>(null)
|
||||||
const [sucht, setSucht] = useState(false)
|
const [sucht, setSucht] = useState(false)
|
||||||
const [handelt, setHandelt] = 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))
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<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">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<Knopf
|
<Knopf
|
||||||
wartet={zieht}
|
wartet={zieht}
|
||||||
onClick={async () => {
|
onClick={() =>
|
||||||
setZieht(true)
|
void runden(
|
||||||
await run(
|
setZieht,
|
||||||
() => api.thumbsNachziehen(),
|
'thumbs',
|
||||||
(r) =>
|
api.thumbsNachziehen,
|
||||||
r.erzeugt === 0
|
(erzeugt, r) =>
|
||||||
|
(erzeugt === 0
|
||||||
? `Nichts zu tun — ${r.vorhanden} Vorschauen sind schon da.`
|
? `Nichts zu tun — ${r.vorhanden} Vorschauen sind schon da.`
|
||||||
: `${r.erzeugt} Vorschauen erzeugt` +
|
: `${erzeugt} Vorschauen erzeugt.`) +
|
||||||
(r.fehler ? `, ${r.fehler} fehlgeschlagen` : '') +
|
(r.fehler ? ` ${r.fehler} fehlgeschlagen.` : ''),
|
||||||
`. ${r.vorhanden} waren schon da.`,
|
|
||||||
)
|
)
|
||||||
setZieht(false)
|
}
|
||||||
}}
|
|
||||||
kind={
|
kind={
|
||||||
<>
|
<>
|
||||||
<Images size={15} />
|
<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">
|
<p className="text-xs text-schrift-leise">
|
||||||
Die Galerie lädt Vorschauen statt Vollbilder. Was vor dieser Funktion
|
Die Galerie lädt Vorschauen statt Vollbilder. Was vor dieser Funktion
|
||||||
hochgeladen wurde, hat noch keine.
|
hochgeladen wurde, hat noch keine.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="mt-4 border-t border-rand pt-4">
|
<div className="mt-4 border-t border-rand pt-4">
|
||||||
@@ -128,7 +218,7 @@ export function Speicher() {
|
|||||||
(r) =>
|
(r) =>
|
||||||
r.dateiOhneDatensatz.anzahl +
|
r.dateiOhneDatensatz.anzahl +
|
||||||
r.datensatzOhneDatei.anzahl +
|
r.datensatzOhneDatei.anzahl +
|
||||||
r.vorschauOhneBild.anzahl ===
|
r.abgeleitetOhneBild.anzahl ===
|
||||||
0
|
0
|
||||||
? 'Alles passt zusammen.'
|
? 'Alles passt zusammen.'
|
||||||
: 'Abweichungen gefunden — siehe unten.',
|
: 'Abweichungen gefunden — siehe unten.',
|
||||||
@@ -144,7 +234,7 @@ export function Speicher() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-schrift-leise">
|
<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.
|
Bild. Sucht nur — verändert nichts.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,18 +262,18 @@ export function Speicher() {
|
|||||||
hinweis="Zeigen ins Leere — die Galerie zeigt ein kaputtes Bild."
|
hinweis="Zeigen ins Leere — die Galerie zeigt ein kaputtes Bild."
|
||||||
/>
|
/>
|
||||||
<Befund
|
<Befund
|
||||||
titel="Vorschauen ohne Bild"
|
titel="Abgeleitetes ohne Bild"
|
||||||
daten={verwaiste.vorschauOhneBild}
|
daten={verwaiste.abgeleitetOhneBild}
|
||||||
hinweis="Belegen Platz, sonst harmlos."
|
hinweis="Vorschauen und sparsame Fassungen, deren Original weg ist. Belegen Platz, sonst harmlos."
|
||||||
/>
|
/>
|
||||||
{(verwaiste.datensatzOhneDatei.anzahl > 0 ||
|
{(verwaiste.datensatzOhneDatei.anzahl > 0 ||
|
||||||
verwaiste.vorschauOhneBild.anzahl > 0) && (
|
verwaiste.abgeleitetOhneBild.anzahl > 0) && (
|
||||||
<div>
|
<div>
|
||||||
<Knopf
|
<Knopf
|
||||||
art="gefahr"
|
art="gefahr"
|
||||||
wartet={handelt}
|
wartet={handelt}
|
||||||
onClick={() => setFragt('aufraeumen')}
|
onClick={() => setFragt('aufraeumen')}
|
||||||
kind="Leere Datensätze und Vorschauen entfernen"
|
kind="Leere Datensätze und Abgeleitetes entfernen"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user