Merge branch 'verbesserungen'
Docker-Image bauen / build (push) Successful in 1m44s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
This commit is contained in:
2026-09-14 04:35:34 +02:00
co-authored by Claude Opus 5
3 changed files with 136 additions and 6 deletions
+24 -5
View File
@@ -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 Der wichtigste offene Punkt, und hier wiegt er schwerer als bei der
Kantone-App: In `kleiderboerse.sqlite` stecken Einträge und in `uploads/` 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 Netzwerk an einer Datenbank-Migration hängengeblieben. Dass es dort
Sicherungen gab, war der einzige Grund, warum die Lage beherrschbar blieb. 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 `app/main.py` bindet keine `GZipMiddleware` ein. Jede HTML- und
HTMX-Antwort geht unkomprimiert über die Leitung — bei einer Galerie mit 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) app.add_middleware(GZipMiddleware, minimum_size=1000)
``` ```
## P2 — Statische Dateien ohne Cache-Dauer ## P2 — Statische Dateien ohne Cache-Dauer ✅ ERLEDIGT 13.09.2026
```python ```python
app.mount("/static", StaticFiles(directory=...), name="static") 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 Swagger-Dateien bei jedem Aufruf neu verhandelt. Eine lange Cache-Dauer
setzen und bei Änderungen einen Versionsanhänger an die URL hängen. setzen und bei Änderungen einen Versionsanhänger an die URL hängen.
Für die hochgeladenen Bilder gilt dasselbe — die Dateinamen sind zufällig **Korrektur:** Für die hochgeladenen Bilder galt das *nicht* — die haben
erzeugt und ändern sich nie, die dürfen dauerhaft im Cache bleiben. 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 ## 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 1. Sicherungen einrichten, inklusive Bilder, mit einer echten Rückspielprobe
2. GZip und Cache-Dauer — zusammen wenige Zeilen 2. GZip und Cache-Dauer — zusammen wenige Zeilen
3. Rest nach Bedarf 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.
+26 -1
View File
@@ -12,6 +12,7 @@ from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.openapi.docs import get_swagger_ui_html from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
@@ -75,6 +76,11 @@ app.add_middleware(
max_age=_cfg.sitzung_max_ruhe_sekunden, 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 app.state.limiter = limiter
@@ -123,7 +129,26 @@ app.include_router(images.router)
app.include_router(pages.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) @app.get("/docs", include_in_schema=False)
Executable
+86
View File
@@ -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