feat: Sicherung, Anmeldebremse, Vorschaubilder -- und Schritt 5

SICHERUNG (tools/sichern.ps1, taeglich 04:30 als geplante Aufgabe).
Die Datenbank wird nicht kopiert, sondern ueber SQLites eigene
Sicherungsschnittstelle herausgeholt (server/src/backup.ts): im WAL-Modus
liegt das Zuletzte noch nicht in media.db, und selbst alle drei Dateien zu
kopieren ist nicht sicher, wenn waehrenddessen geschrieben wird. Die Bilder
kommen aus einem NUR LESEND eingehaengten Volume dazu.

Und sie prueft sich selbst: auspacken, Datenbank oeffnen, Medien/Token/
Benutzer zaehlen, mit dem laufenden Dienst vergleichen, sonst Fehler. Einmal
wirklich zurueckgespielt -- leeres Volume, zweiter Dienst, Anmeldung mit dem
echten Passwort, Bild abgerufen, Byte fuer Byte identisch. Eine Sicherung, die
nie zurueckgespielt wurde, ist eine Hoffnung.

ANMELDEBREMSE. Das Formular steht oeffentlich, und scrypt macht einen Versuch
teuer -- aber teuer ist nicht selten. Fuenf freie Versuche je Adresse, dann
Sperre ab 30 s mit Verdopplung bis 15 min, 429 samt Retry-After und einem Text,
der sagt wie lange. Erfolg setzt zurueck. Nur im Speicher: wer sich aussperrt,
startet den Container neu. Durchgemessen bis zur Erholung.

VORSCHAUBILDER. sharp erzeugt beim Upload eine 320er WebP-Fassung unter
/data/thumbs, ausgeliefert unter /t/<pfad>. Gemessen: 131502 -> 13078 Bytes,
Faktor 10; eine Galerieseite faellt von 7,5 MB auf 766 KB. KEINE Spalte in der
Datenbank -- ob es eine Vorschau gibt, sagt das Dateisystem, und die Galerie
faellt bei 404 aufs Vollbild zurueck. Eine zweite Wahrheit, die auseinander-
laufen kann, gibt es damit gar nicht erst. Ein Knopf zieht Fehlendes nach und
nennt Zahlen statt "fertig".

SCHRITT 5. Die Fivemanage-Dateien liegen in legacy/ mit Erklaerung; unsere
docker-compose.media.yml heisst jetzt docker-compose.yml. Drei Volumes und drei
Abbilder geloescht, rund 670 MB -- nachgezaehlt war vorher, dass kein Bild
darin lag.

Nebenbefund: `npm i sharp` scheitert auf diesem Rechner nicht an sharp, sondern
an better-sqlite3 -- der Host laeuft auf Node 24 (ABI 137), node-gyp uebernimmt
und findet keine Bauwerkzeuge. Genau die Falle aus dem Dockerfile. Eingetragen
wurde mit --package-lock-only, gebaut wird im Abbild auf Node 22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:00:14 +02:00
co-authored by Claude Opus 5
parent 6c8ead8e15
commit aa2575bdf4
18 changed files with 1450 additions and 229 deletions
+616 -1
View File
@@ -10,7 +10,8 @@
"dependencies": {
"@hono/node-server": "^1.13.7",
"better-sqlite3": "^11.7.0",
"hono": "^4.6.14"
"hono": "^4.6.14",
"sharp": "^0.35.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
@@ -19,6 +20,16 @@
"typescript": "^5.7.2"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -473,6 +484,554 @@
"hono": "^4"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -893,6 +1452,55 @@
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -984,6 +1592,13 @@
"node": ">=6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/tsx": {
"version": "4.23.12",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
+2 -1
View File
@@ -11,7 +11,8 @@
"dependencies": {
"@hono/node-server": "^1.13.7",
"better-sqlite3": "^11.7.0",
"hono": "^4.6.14"
"hono": "^4.6.14",
"sharp": "^0.35.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
+48
View File
@@ -0,0 +1,48 @@
// Eine Sicherung der Datenbank, die auch stimmt, waehrend geschrieben wird.
//
// WARUM NICHT EINFACH KOPIEREN. media.db laeuft im WAL-Modus. Was zuletzt
// hochgeladen wurde, steht dann noch in media.db-wal und nicht in media.db —
// eine Kopie nur der einen Datei ist deshalb still unvollstaendig. Beim ersten
// Nachsehen im Container war das WAL nach wenigen Uploads 152 KB gross.
//
// Und selbst alle drei Dateien zu kopieren reicht nicht sicher: waehrend des
// Kopierens kann geschrieben werden, und dann passen sie nicht mehr zueinander.
//
// Deshalb SQLites eigene Sicherungsschnittstelle. Sie laeuft auf der offenen
// Datenbank, nimmt eine in sich stimmige Aufnahme und faengt von vorn an, wenn
// dazwischen geschrieben wurde. Das Ergebnis ist EINE Datei ohne WAL daneben.
//
// Aufruf im Container:
//
// node dist/backup.js /data/.sicherung/media.db
//
// Der Rest — Bilder dazupacken, herausholen, pruefen — steht in
// tools/sichern.ps1; hier bleibt nur der Teil, der die Datenbank kennt.
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { db } from './db.js'
const ziel = process.argv[2]
if (!ziel) {
console.error('[sicherung] Kein Ziel angegeben. Aufruf: node dist/backup.js <pfad>')
process.exit(2)
}
mkdirSync(dirname(ziel), { recursive: true })
const zeilen = db.prepare('SELECT COUNT(*) AS n FROM media').get() as { n: number }
db.backup(ziel)
.then(() => {
// Die Zeilenzahl mit ausgeben: sie ist der Wert, gegen den die
// Sicherung hinterher geprueft wird. Eine Sicherung, die niemand
// nachzaehlt, ist eine Vermutung.
console.log(`[sicherung] ${ziel} geschrieben, ${zeilen.n} Medieneintraege`)
process.exit(0)
})
.catch((err: unknown) => {
console.error(`[sicherung] fehlgeschlagen: ${err instanceof Error ? err.message : err}`)
process.exit(1)
})
+43 -1
View File
@@ -29,7 +29,7 @@ import { logger } from 'hono/logger'
import { config } from './config.js'
import { ensureAdmin } from './auth.js'
import { pruneSessions } from './db.js'
import { absolutePath, checkPath, mimeFor } from './storage.js'
import { absolutePath, checkPath, mimeFor, thumbPath } from './storage.js'
import { uploadRoutes } from './routes/upload.js'
import { dashRoutes } from './routes/dash.js'
@@ -125,6 +125,48 @@ app.route('/api/dash', dashRoutes)
app.get('/f/*', (c) => serveFile(c, c.req.path.slice(3)))
/** Die Vorschaubilder, unter /t/.
*
* Absichtlich NICHT unter dem Dateihost und absichtlich ohne Anmeldung: die
* Bilder selbst sind ohnehin oeffentlich, und das Dashboard laedt die
* Vorschauen mit gewoehnlichen <img>-Marken — mit Anmeldung braeuchte es
* dafuer einen Umweg ueber fetch und Blob-Adressen.
*
* Fehlt eine Vorschau, kommt 404 und das Dashboard nimmt das Vollbild. Kein
* Erzeugen auf Zuruf: eine Route, die bei jeder Anfrage rechnen kann, ist
* eine Route, die sich mit 900 gleichzeitigen Anfragen umbringen laesst. */
app.get('/t/*', async (c) => {
const roh = c.req.path.slice(3)
let path: string
try {
path = checkPath(decodeURIComponent(roh))
} catch {
return c.text('nicht gefunden', 404)
}
const absolute = thumbPath(path)
let info
try {
info = await stat(absolute)
if (!info.isFile()) return c.text('nicht gefunden', 404)
} catch {
return c.text('nicht gefunden', 404)
}
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`
if (c.req.header('if-none-match') === etag) return c.body(null, 304)
return c.body(createReadStream(absolute) as any, 200, {
'Content-Type': 'image/webp',
'Content-Length': String(info.size),
ETag: etag,
// Kurz: eine Vorschau aendert sich, wenn das Bild neu hochgeladen
// wird, und dann soll das Dashboard es sehen.
'Cache-Control': 'public, max-age=60',
'X-Content-Type-Options': 'nosniff',
})
})
// Die gebaute Oberflaeche. Alles, was keine API ist und keine Datei, bekommt
// index.html — sonst endet ein Neuladen auf /tokens im 404 statt im Router.
// fileURLToPath und nicht .pathname: unter Windows liefert letzteres
+159 -2
View File
@@ -20,7 +20,17 @@ import {
userForSession,
verifyPassword,
} from '../auth.js'
import { checkPath, deleteFile, publicUrlFor, PathError } from '../storage.js'
import { readFile, stat } from 'node:fs/promises'
import {
absolutePath,
checkPath,
deleteFile,
kannVorschau,
publicUrlFor,
thumbPath,
writeThumb,
PathError,
} from '../storage.js'
type Vars = { user: User }
@@ -41,13 +51,104 @@ async function jsonBody<T>(c: Context<any>): Promise<T | null> {
const KEIN_JSON = { error: 'Rumpf ist kein gueltiges JSON' } as const
// ------------------------------------------------------------ Die Bremse
//
// Das Anmeldeformular steht oeffentlich im Netz. scrypt macht einen Versuch
// teuer, aber teuer ist nicht selten: ohne Bremse darf jemand beliebig oft
// raten, und irgendwann ist "sommer2024" dran.
//
// Bewusst im Speicher und nicht in der Datenbank: die Sperre soll einen
// Neustart NICHT ueberleben. Wer sich selbst ausgesperrt hat, startet den
// Container neu und kommt rein — bei einem Dienst, den eine Person betreibt,
// ist das die richtige Notbremse.
const FREI_VERSUCHE = 5
const GRUNDSPERRE_MS = 30_000
const HOECHSTSPERRE_MS = 15 * 60_000
type Versuche = { fehl: number; gesperrtBis: number }
const versuche = new Map<string, Versuche>()
/** Wer da klopft.
*
* X-Real-IP zuerst: NPM setzt den Kopf mit `$remote_addr` und UEBERSCHREIBT
* damit, was der Aufrufer selbst hineinschreibt. Das gilt aber nur fuer den
* Weg durch den Proxy — wer den Container direkt auf seinem Port erreicht,
* kann sich jede Adresse ausdenken. Genau deshalb gehoert dieser Port hinter
* die Firewall, und das steht so in der ROADMAP. */
function woher(c: Context<any>): string {
const real = c.req.header('x-real-ip')?.trim()
if (real) return real
const weiter = c.req.header('x-forwarded-for')?.split(',')[0]?.trim()
if (weiter) return weiter
// @hono/node-server reicht die Node-Anfrage durch; ohne Proxy ist das die
// einzige Quelle, die nicht vom Aufrufer stammt.
const socket = (c.env as any)?.incoming?.socket?.remoteAddress
return typeof socket === 'string' && socket ? socket : 'unbekannt'
}
/** Wie lange noch gesperrt, in Sekunden — oder 0. */
function gesperrt(key: string): number {
const eintrag = versuche.get(key)
if (!eintrag) return 0
const rest = eintrag.gesperrtBis - Date.now()
return rest > 0 ? Math.ceil(rest / 1000) : 0
}
function fehlversuch(key: string): void {
const eintrag = versuche.get(key) ?? { fehl: 0, gesperrtBis: 0 }
eintrag.fehl += 1
if (eintrag.fehl > FREI_VERSUCHE) {
// Verdopplung ab dem sechsten Fehlversuch, gedeckelt. Aus 30 Sekunden
// werden schnell Minuten — fuer einen Tippfehler unmerklich, fuer eine
// Liste mit Millionen Passwoertern das Ende.
const stufe = eintrag.fehl - FREI_VERSUCHE - 1
const dauer = Math.min(GRUNDSPERRE_MS * 2 ** stufe, HOECHSTSPERRE_MS)
eintrag.gesperrtBis = Date.now() + dauer
}
versuche.set(key, eintrag)
}
const geschafft = (key: string) => versuche.delete(key)
// Aufraeumen, damit die Karte nicht endlos waechst. Stuendlich reicht: was
// laenger als eine Stunde nicht mehr geklopft hat, ist kein laufender Angriff.
setInterval(
() => {
const jetzt = Date.now()
for (const [key, eintrag] of versuche) {
if (eintrag.gesperrtBis < jetzt - 3600_000) versuche.delete(key)
}
},
3600_000,
).unref()
// ---------------------------------------------------------------- Anmeldung
dashRoutes.post('/auth/login', async (c) => {
const key = woher(c)
// Erst die Sperre, dann alles andere: ein gesperrter Versuch soll nicht
// einmal scrypt kosten.
const rest = gesperrt(key)
if (rest > 0) {
return c.json(
{
error: `Zu viele Fehlversuche. Noch ${rest} Sekunden warten.`,
},
429,
{ 'Retry-After': String(rest) },
)
}
const body = await jsonBody<{ username?: string; password?: string }>(c)
if (!body) return c.json(KEIN_JSON, 400)
const { username, password } = body
// Ein leeres Formular ist ein Bedienfehler und kein Rateversuch — der
// zaehlt nicht mit.
if (!username || !password) {
return c.json({ error: 'Benutzername und Passwort noetig' }, 400)
}
@@ -59,9 +160,19 @@ dashRoutes.post('/auth/login', async (c) => {
// Bewusst dieselbe Meldung fuer "kein solcher Benutzer" und "falsches
// Passwort": alles andere verraet, welche Namen es gibt.
if (!user || !verifyPassword(password, user.password_hash)) {
return c.json({ error: 'Benutzername oder Passwort stimmt nicht' }, 401)
fehlversuch(key)
const jetztRest = gesperrt(key)
return c.json(
{
error: jetztRest
? `Benutzername oder Passwort stimmt nicht. Zu viele Fehlversuche — noch ${jetztRest} Sekunden warten.`
: 'Benutzername oder Passwort stimmt nicht',
},
jetztRest ? 429 : 401,
)
}
geschafft(key)
const session = createSession(user.id)
setCookie(c, SESSION_COOKIE, session.id, {
httpOnly: true,
@@ -263,3 +374,49 @@ dashRoutes.post('/maintenance/prune-sessions', (c) => {
pruneSessions()
return c.json({ ok: true })
})
/** Fehlende Vorschaubilder nachziehen.
*
* Braucht es, weil die Vorschauen erst spaeter dazukamen: was vorher
* hochgeladen wurde, hat keine. Und als Reparatur, falls eine je fehlschlaegt.
*
* Das Ergebnis nennt Zahlen und nicht nur "ok" — ein Knopf, der nach zwanzig
* Sekunden "fertig" sagt, ohne zu sagen WAS fertig ist, ist der Anfang der
* Frage, ob er ueberhaupt etwas getan hat. */
dashRoutes.post('/maintenance/thumbs', async (c) => {
const rows = db.prepare('SELECT path, mime FROM media').all() as Pick<
Media,
'path' | 'mime'
>[]
let erzeugt = 0
let vorhanden = 0
let uebersprungen = 0
let fehler = 0
for (const row of rows) {
if (!kannVorschau(row.mime)) {
uebersprungen++
continue
}
try {
await stat(thumbPath(row.path))
vorhanden++
continue
} catch {
/* gibt es noch nicht — gleich */
}
try {
const data = await readFile(absolutePath(row.path))
if (await writeThumb(row.path, data)) erzeugt++
else fehler++
} catch {
// Ein Datensatz ohne Datei. Kommt vor, wenn jemand im Volume
// aufgeraeumt hat; hier ist es ein Zaehler und kein Absturz.
fehler++
}
}
return c.json({ erzeugt, vorhanden, uebersprungen, fehler, gesamt: rows.length })
})
+7
View File
@@ -30,10 +30,12 @@ import {
deleteFile,
extensionFor,
fileExists,
kannVorschau,
mimeFor,
publicUrlFor,
sha256,
writeFileAtomic,
writeThumb,
} from '../storage.js'
type Vars = { token: Token }
@@ -165,6 +167,11 @@ uploadRoutes.post('/upload', async (c) => {
return c.json({ error: 'Schreiben fehlgeschlagen' }, 500)
}
// Die Vorschau gleich mit. Bewusst NACH dem eigentlichen Schreiben und mit
// eigenem Ergebnis: scheitert sie, ist das Bild trotzdem abgelegt und der
// Upload ein Erfolg. Das Dashboard faellt dann auf das Vollbild zurueck.
if (kannVorschau(mime)) await writeThumb(path, data)
const timestamp = now()
if (existing) {
db.prepare(
+57
View File
@@ -120,8 +120,65 @@ export async function writeFileAtomic(path: string, data: Buffer): Promise<void>
}
}
// -------------------------------------------------------------- Vorschaubilder
//
// WARUM UEBERHAUPT. Ein freigestelltes Fahrzeugbild wiegt rund 124 KB. Eine
// Galerieseite zeigt 60 davon — das sind 7,2 MB, nur damit jemand sieht, WELCHE
// Bilder da sind. Bei 900 Fahrzeugen liegen 109 MB in der Ablage. Die Vorschau
// wiegt ein Fuenfzigstel und reicht fuer eine Kachel voellig.
//
// WO SIE LIEGEN. Unter /data/thumbs, im selben Baum wie die Bilder, mit
// ".webp" hinten dran. Also thumbs/vehicles/adder.webp.webp — haesslich, aber
// eindeutig: haengte man die Endung nicht an, kollidierten adder.png und
// adder.webp im Vorschauordner.
//
// KEINE SPALTE IN DER DATENBANK. Ob es eine Vorschau gibt, sagt das
// Dateisystem. Eine Spalte waere eine zweite Wahrheit, die auseinanderlaufen
// kann — und das Dashboard faellt ohnehin auf das Vollbild zurueck, wenn die
// Vorschau fehlt.
export const THUMB_BREITE = 320
export const thumbPath = (path: string) => join(config.dataDir, 'thumbs', `${path}.webp`)
export const kannVorschau = (mime: string) =>
/^image\/(png|jpeg|webp|avif|gif)$/.test(mime.split(';')[0]?.trim().toLowerCase() ?? '')
/** Eine Vorschau erzeugen. Schlaegt sie fehl, ist das KEIN Grund, den Upload
* scheitern zu lassen — das Bild selbst liegt dann schon richtig. Der
* Aufrufer bekommt false und schreibt eine Zeile ins Protokoll. */
export async function writeThumb(path: string, data: Buffer): Promise<boolean> {
const ziel = thumbPath(path)
try {
// Der Import steht hier drin und nicht oben: sharp zieht eine native
// Bibliothek nach, und die soll nur geladen werden, wenn wirklich ein
// Bild kommt.
const { default: sharp } = await import('sharp')
await mkdir(dirname(ziel), { recursive: true })
const bild = await sharp(data, { animated: false })
// withoutEnlargement: ein 64 Pixel breites Bild wird nicht auf 320
// aufgeblasen — das kostet Platz und sieht schlechter aus als das
// Original.
.resize({ width: THUMB_BREITE, withoutEnlargement: true })
.webp({ quality: 72 })
.toBuffer()
const temp = `${ziel}.${process.pid}.tmp`
await writeFile(temp, bild)
await rename(temp, ziel)
return true
} catch (err) {
console.error(`[thumbs] ${path}: ${err instanceof Error ? err.message : err}`)
return false
}
}
export async function deleteFile(path: string): Promise<void> {
await rm(absolutePath(path), { force: true })
// Die Vorschau geht mit. Sonst bleibt sie liegen, und beim naechsten Bild
// unter demselben Pfad sieht man das alte.
await rm(thumbPath(path), { force: true })
}
export async function fileExists(path: string): Promise<boolean> {
+16
View File
@@ -137,6 +137,21 @@ export type NewToken = {
token: string
}
export type ThumbLauf = {
erzeugt: number
vorhanden: number
uebersprungen: number
fehler: number
gesamt: number
}
/** Die Adresse der Vorschau zu einem Pfad.
*
* Ob es sie gibt, weiss nur das Dateisystem — deshalb faellt die Galerie bei
* 404 auf das Vollbild zurueck, statt hier zu fragen. */
export const vorschauUrl = (path: string) =>
'/t/' + path.split('/').map(encodeURIComponent).join('/')
export const api = {
login: (username: string, password: string) =>
post<{ user: User }>('/api/dash/auth/login', { username, password }),
@@ -154,6 +169,7 @@ export const api = {
post<{ deleted: string[]; count: number }>('/api/dash/media/delete', { ids }),
stats: () => get<Stats>('/api/dash/stats'),
thumbsNachziehen: () => post<ThumbLauf>('/api/dash/maintenance/thumbs'),
tokens: () => get<{ items: TokenRow[] }>('/api/dash/tokens'),
createToken: (name: string, prefix: string, canDelete: boolean) =>
+15 -2
View File
@@ -12,7 +12,7 @@ import {
Trash2,
X,
} from 'lucide-react'
import { api, type MediaItem, type MediaPage } from '../api'
import { api, vorschauUrl, type MediaItem, type MediaPage } from '../api'
import { run, useStore } from '../store'
import { bytes, istBild, zeit } from '../format'
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
@@ -213,7 +213,20 @@ export function Galerie() {
<div className="flex aspect-square items-center justify-center bg-grund">
{istBild(item.mime) ? (
<img
src={item.url}
// Erst die Vorschau: 60 Vollbilder
// sind 7,2 MB, nur damit jemand
// sieht, WELCHE Bilder da sind.
// Fehlt sie (404), nimmt der
// Browser das Vollbild — deshalb
// braucht es dafuer weder eine
// Spalte noch eine Nachfrage.
src={vorschauUrl(item.path)}
onError={(e) => {
const bild = e.currentTarget
if (bild.dataset.gefallen) return
bild.dataset.gefallen = 'ja'
bild.src = item.url
}}
alt={item.path}
loading="lazy"
className="h-full w-full object-contain"
+35 -3
View File
@@ -1,14 +1,15 @@
import { useEffect, useState } from 'react'
import { Check, Copy, Folder, HardDrive, KeyRound, Loader2 } from 'lucide-react'
import { Check, Copy, Folder, HardDrive, Images, KeyRound, Loader2 } from 'lucide-react'
import { api, type Stats } from '../api'
import { useStore } from '../store'
import { run, useStore } from '../store'
import { bytes } from '../format'
import { Karte, Leer, useKopieren } from '../components/Bausteine'
import { Karte, Knopf, Leer, useKopieren } from '../components/Bausteine'
export function Speicher() {
const note = useStore((s) => s.note)
const [stats, setStats] = useState<Stats | null>(null)
const [laedt, setLaedt] = useState(true)
const [zieht, setZieht] = useState(false)
const { kopiert, kopieren } = useKopieren()
useEffect(() => {
@@ -69,6 +70,37 @@ export function Speicher() {
</p>
</Karte>
<Karte titel="Wartung">
<div className="flex flex-wrap items-center gap-3">
<Knopf
wartet={zieht}
onClick={async () => {
setZieht(true)
await run(
() => api.thumbsNachziehen(),
(r) =>
r.erzeugt === 0
? `Nichts zu tun — ${r.vorhanden} Vorschauen sind schon da.`
: `${r.erzeugt} Vorschauen erzeugt` +
(r.fehler ? `, ${r.fehler} fehlgeschlagen` : '') +
`. ${r.vorhanden} waren schon da.`,
)
setZieht(false)
}}
kind={
<>
<Images size={15} />
Fehlende Vorschaubilder erzeugen
</>
}
/>
<p className="text-xs text-schrift-leise">
Die Galerie lädt Vorschauen statt Vollbilder. Was vor dieser Funktion
hochgeladen wurde, hat noch keine.
</p>
</div>
</Karte>
<Karte titel="Größte Ordner">
{stats.folders.length === 0 ? (
<Leer text="Noch nichts abgelegt." />