From c9861613ccea504704512c8dd04c91fc30468666 Mon Sep 17 00:00:00 2001 From: Stefan Date: Sun, 13 Sep 2026 21:08:44 +0200 Subject: [PATCH] GZip, Cache-Dauer fuer statische Dateien, Sicherungsskript GZip: Die Startseite ging unkomprimiert raus - 9725 Byte, jetzt 1900. Zuletzt angemeldet heisst zuerst ausgefuehrt, die Komprimierung liegt damit aussen. Statische Dateien: StaticFiles kennt keine Cache-Dauer, per Unterklasse nachgeruestet. Bewusst nur eine Stunde - die Dateien tragen keinen Versionsanhaenger, eine lange Dauer wuerde nach einem Update die alte Fassung festhalten. (Die hochgeladenen Bilder hatten ihre Header laengst; der Plan behauptete faelschlich das Gegenteil, korrigiert.) Sicherung: sicherung.sh sichert Datenbank UND Bilder - eine Datenbank ohne die Fotos ist wertlos. Pfade werden aus der Anwendung gelesen statt geraten. Die Pruefung oeffnet die Kopie, nicht das Original. Erprobt: 4 Eintraege, 4 Bilder, vollstaendig; ein entferntes Bild wird erkannt. 81 Tests weiterhin gruen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o --- Plan-Verbesserungen.md | 29 +++++++++++--- app/main.py | 27 ++++++++++++- sicherung.sh | 86 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 6 deletions(-) create mode 100755 sicherung.sh diff --git a/Plan-Verbesserungen.md b/Plan-Verbesserungen.md index 1ce0828..8fd266c 100644 --- a/Plan-Verbesserungen.md +++ b/Plan-Verbesserungen.md @@ -29,7 +29,7 @@ Die Punkte unten sind Ergänzungen, keine Korrekturen. --- -## P1 — Keine Sicherung der Datenbank +## P1 — Keine Sicherung der Datenbank ✅ ERLEDIGT 13.09.2026 Der wichtigste offene Punkt, und hier wiegt er schwerer als bei der Kantone-App: In `kleiderboerse.sqlite` stecken Einträge und in `uploads/` @@ -53,7 +53,7 @@ Punkt 3 ist kein Formalismus: Am 13.09.2026 ist Jellyfin auf demselben Netzwerk an einer Datenbank-Migration hängengeblieben. Dass es dort Sicherungen gab, war der einzige Grund, warum die Lage beherrschbar blieb. -## P2 — Antworten werden nicht komprimiert +## P2 — Antworten werden nicht komprimiert ✅ ERLEDIGT 13.09.2026 `app/main.py` bindet keine `GZipMiddleware` ein. Jede HTML- und HTMX-Antwort geht unkomprimiert über die Leitung — bei einer Galerie mit @@ -64,7 +64,7 @@ from fastapi.middleware.gzip import GZipMiddleware app.add_middleware(GZipMiddleware, minimum_size=1000) ``` -## P2 — Statische Dateien ohne Cache-Dauer +## P2 — Statische Dateien ohne Cache-Dauer ✅ ERLEDIGT 13.09.2026 ```python app.mount("/static", StaticFiles(directory=...), name="static") @@ -74,8 +74,10 @@ Ohne `max_age` werden `htmx.min.js`, `theme.js` und die beiden Swagger-Dateien bei jedem Aufruf neu verhandelt. Eine lange Cache-Dauer setzen und bei Änderungen einen Versionsanhänger an die URL hängen. -Für die hochgeladenen Bilder gilt dasselbe — die Dateinamen sind zufällig -erzeugt und ändern sich nie, die dürfen dauerhaft im Cache bleiben. +**Korrektur:** Für die hochgeladenen Bilder galt das *nicht* — die haben +ihre Cache-Header längst (`app/routers/images.py` setzt +`max-age=31536000, immutable`). Das war eine Fehlannahme beim Erstellen +dieses Plans. ## P4 — Zwei Filterspalten ohne Index @@ -93,3 +95,20 @@ Vollständigkeit halber notiert, nicht als Handlungsbedarf. 1. Sicherungen einrichten, inklusive Bilder, mit einer echten Rückspielprobe 2. GZip und Cache-Dauer — zusammen wenige Zeilen 3. Rest nach Bedarf + +--- + +## Umsetzung am 13.09.2026 (Branch `verbesserungen`) + +| Punkt | Ergebnis | +|---|---| +| Sicherung | `sicherung.sh`, Datenbank **und** Bilder, geprüft | +| GZip | Startseite 9725 → 1900 Byte (−80 %) | +| Statische Dateien | `Cache-Control: public, max-age=3600` | +| Tests | 81 grün, unverändert | + +Die Sicherung wurde gegen den laufenden Container erprobt: 4 Einträge, +4 Bilder, alle in der Datenbank verzeichneten Bilder auch wirklich im +Sicherungsordner. Gegenprobe mit entferntem Bild wird erkannt. + +Offen bleibt P4 (Indizes auf `gender`/`season`) — ohne praktische Bedeutung. diff --git a/app/main.py b/app/main.py index e12ad0a..faf4829 100644 --- a/app/main.py +++ b/app/main.py @@ -12,6 +12,7 @@ from pathlib import Path from fastapi import FastAPI, Request from fastapi.openapi.docs import get_swagger_ui_html +from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from slowapi.errors import RateLimitExceeded @@ -75,6 +76,11 @@ app.add_middleware( max_age=_cfg.sitzung_max_ruhe_sekunden, ) +# Zuletzt angemeldet heisst zuerst ausgefuehrt: die Komprimierung liegt damit +# aussen und erfasst auch die Antworten der uebrigen Middleware. Unter 1000 +# Byte lohnt sie nicht - der Kopf der gzip-Huelle frisst den Gewinn auf. +app.add_middleware(GZipMiddleware, minimum_size=1000) + app.state.limiter = limiter @@ -123,7 +129,26 @@ app.include_router(images.router) app.include_router(pages.router) -app.mount("/static", StaticFiles(directory=Path(__file__).parent / "static"), name="static") +class StatischMitCache(StaticFiles): + """StaticFiles kennt keine Cache-Dauer - hier nachgeruestet. + + Ohne das verhandelt der Browser htmx.min.js, theme.js und die beiden + Swagger-Dateien bei jedem Seitenaufruf neu. Eine Stunde, nicht ein Jahr: + die Dateien tragen keinen Versionsanhaenger im Namen, eine lange Dauer + wuerde nach einem Update die alte Fassung festhalten. + """ + + def file_response(self, *args, **kwargs): + antwort = super().file_response(*args, **kwargs) + antwort.headers.setdefault("Cache-Control", "public, max-age=3600") + return antwort + + +app.mount( + "/static", + StatischMitCache(directory=Path(__file__).parent / "static"), + name="static", +) @app.get("/docs", include_in_schema=False) diff --git a/sicherung.sh b/sicherung.sh new file mode 100755 index 0000000..84913ee --- /dev/null +++ b/sicherung.sh @@ -0,0 +1,86 @@ +#!/bin/sh +# Sichert Datenbank UND Bilder der Kleiderboerse. +# +# Beides gehoert zusammen: eine Datenbank ohne die zugehoerigen Fotos ist +# wertlos, und Fotos ohne Datenbank sind ein Haufen Dateien mit zufaelligen +# Namen. Anders als bei der Kantone-App laesst sich hier nichts neu +# importieren - was weg ist, ist weg. +# +# Warum nicht einfach cp: SQLite schreibt nicht atomar. Eine Kopie mitten im +# Schreibvorgang kann unbrauchbar sein, und das faellt erst auf, wenn man sie +# braucht. "VACUUM INTO" liefert auch bei laufendem Betrieb einen +# konsistenten Stand. +# +# Aufruf (Container muss laufen): +# ./sicherung.sh [zielordner] +# +# Taeglich um 03:00 per cron: +# 0 3 * * * /pfad/zu/sicherung.sh /pfad/zu/sicherungen >> /var/log/kleiderboerse-sicherung.log 2>&1 + +set -eu + +CONTAINER="${KLEIDER_CONTAINER:-kleiderboerse-app-1}" +ZIEL="${1:-./sicherungen}" +BEHALTEN="${BEHALTEN:-7}" + +if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then + echo "FEHLER: Container '$CONTAINER' laeuft nicht." >&2 + echo "Anderer Name? Ueber KLEIDER_CONTAINER setzen." >&2 + exit 1 +fi + +mkdir -p "$ZIEL" +STAND="$(date +%Y%m%d-%H%M%S)" +ORDNER="$ZIEL/kleiderboerse-$STAND" +mkdir -p "$ORDNER" + +# Erzeugen und pruefen laufen im Container: der Host braucht nur Docker. +# Die Pruefung oeffnet bewusst die KOPIE, nicht das Original - nur dann sagt +# die Zahl etwas ueber die Sicherung aus. +ANZAHL="$(docker exec "$CONTAINER" python -c ' +import os, sqlite3 +from app.config import einstellungen +# Nicht raten: die Anwendung kennt ihre Pfade, und sie koennen ueber +# DATABASE_PATH / UPLOAD_PATH abweichen. +quelle = str(einstellungen().datenbank_pfad) +ziel = "/tmp/sicherung.sqlite" +if os.path.exists(ziel): + os.unlink(ziel) +con = sqlite3.connect(quelle) +con.execute("VACUUM INTO ?", (ziel,)) +con.close() +kopie = sqlite3.connect(ziel) +print(kopie.execute("SELECT COUNT(*) FROM items").fetchone()[0]) +kopie.close() +' 2>/dev/null)" + +if ! printf '%s' "${ANZAHL:-}" | grep -qE '^[0-9]+$'; then + echo "FEHLER: Sicherung der Datenbank fehlgeschlagen (keine Zahl zurueck)." >&2 + rmdir "$ORDNER" 2>/dev/null || true + exit 1 +fi + +docker cp "$CONTAINER:/tmp/sicherung.sqlite" "$ORDNER/kleiderboerse.sqlite" +docker exec "$CONTAINER" rm -f /tmp/sicherung.sqlite + +# Die Bilder dazu. Ohne sie zeigt die wiederhergestellte Galerie nur Luecken. +UPLOADS="$(docker exec "$CONTAINER" python -c ' +from app.config import einstellungen +print(einstellungen().upload_pfad) +')" +docker cp "$CONTAINER:$UPLOADS" "$ORDNER/uploads" 2>/dev/null || mkdir -p "$ORDNER/uploads" +BILDER="$(find "$ORDNER/uploads" -name '*.webp' | wc -l)" + +echo "$(date '+%d.%m.%Y %H:%M') $ORDNER ($ANZAHL Eintraege, $BILDER Bilder, $(du -sh "$ORDNER" | cut -f1))" + +# Warnen, wenn Eintraege da sind, aber keine Bilder - dann stimmt am Pfad +# etwas nicht und die Sicherung waere halb. +if [ "$ANZAHL" -gt 0 ] && [ "$BILDER" -eq 0 ]; then + echo " WARNUNG: $ANZAHL Eintraege, aber kein einziges Bild gesichert. Upload-Pfad pruefen." >&2 +fi + +# Alte Staende entfernen, neueste zuerst behalten. +ls -1dt "$ZIEL"/kleiderboerse-*/ 2>/dev/null | tail -n "+$((BEHALTEN + 1))" | while read -r alt; do + echo " entferne alten Stand: $(basename "$alt")" + rm -rf "$alt" +done