3 Commits
Author SHA1 Message Date
StefanandClaude Opus 5 f3ac3d526e 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
2026-09-14 04:35:34 +02:00
StefanandClaude Opus 5 c9861613cc 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
2026-09-13 21:08:44 +02:00
StefanandClaude Opus 5 c748013e4a Plan fuer Verbesserungen aus der Durchsicht vom 13.09.2026
Vorabpruefung der Branches und Tests ist enthalten. Nichts davon ist
umgesetzt - der Plan haelt nur fest, was gefunden wurde.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
2026-09-13 20:54:09 +02:00
3 changed files with 226 additions and 1 deletions
+114
View File
@@ -0,0 +1,114 @@
# Plan: Verbesserungen
Erstellt am 13.09.2026 nach einer Durchsicht des gesamten Repos, parallel zur
Kantone-App. Ergänzt `Plan.md`, der den ursprünglichen Aufbau beschreibt.
**Noch nichts davon ist umgesetzt.**
## Vorabprüfung (13.09.2026, erledigt)
| | |
|---|---|
| Arbeitsverzeichnis | sauber |
| Offene Branches | **keine** |
| Tests | **81 grün in 12 Sekunden** |
| Letzter Tag | v1.0.0 |
**Der CI-Lauf zu v1.0.0 wurde nie bestätigt.** Der Tag wurde einmal auf dem
Server gelöscht und danach auf `e0212cf` neu gesetzt. Vor dem nächsten
Release prüfen, ob in der Registry tatsächlich ein Image liegt.
## Zum Zustand des Codes
Die Durchsicht hat wenig ergeben, und das ist das Ergebnis: Escaping, CSRF,
Token-Vergleich in konstanter Zeit, Bild-Upload mit Inhaltsprüfung statt
Endung, bewusst vermiedenes N+1, CHECK-Constraints in der Datenbank statt
nur in Python — das ist alles vorhanden und richtig gemacht.
Die Punkte unten sind Ergänzungen, keine Korrekturen.
---
## 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/`
die dazugehörigen Fotos. **Beides ist nicht wiederherstellbar** — anders als
die Kantonsdaten, die sich neu importieren liessen.
`docker-compose.betrieb.yml` sieht keinen Sicherungsschritt vor.
Ein `cp` der laufenden Datei genügt nicht; sie kann mitten in einer
Schreiboperation erwischt werden. Richtig ist `sqlite3 ... ".backup ..."`
oder `VACUUM INTO`, beide konsistent auch bei laufendem Zugriff.
**Vorgehen**
1. Täglicher Lauf, sieben Stände, danach rollierend überschreiben.
2. **Die Bilder gehören dazu** — eine Datenbank ohne `uploads/` ist wertlos.
3. Einmal einen Stand tatsächlich zurückspielen und prüfen, dass die
Galerie danach vollständig ist. Eine ungetestete Sicherung ist keine.
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 ✅ 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
vielen Kacheln merkbar, besonders über Mobilfunk.
```python
from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware, minimum_size=1000)
```
## P2 — Statische Dateien ohne Cache-Dauer ✅ ERLEDIGT 13.09.2026
```python
app.mount("/static", StaticFiles(directory=...), name="static")
```
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.
**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
`crud.items_suchen()` filtert unter anderem nach `gender` und `season`.
Beide Spalten haben keinen Index; `status`, `size` und `category_id` haben
einen.
Bei den zu erwartenden Stückzahlen ist das ohne praktische Bedeutung — der
Vollständigkeit halber notiert, nicht als Handlungsbedarf.
---
## Reihenfolge
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.
+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