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