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
This commit is contained in:
+24
-5
@@ -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
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user