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
182 lines
6.3 KiB
Python
182 lines
6.3 KiB
Python
"""Einstiegspunkt der Anwendung.
|
|
|
|
FastAPI liefert später sowohl die REST-API als auch die gerenderten Seiten
|
|
aus - ein Dienst, eine Datenbank, kein getrenntes Frontend (Variante A aus
|
|
Plan.md).
|
|
"""
|
|
|
|
import logging
|
|
import secrets
|
|
from contextlib import asynccontextmanager
|
|
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
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from app.config import einstellungen
|
|
from app.database import SessionLocal, engine
|
|
from app.routers import auth, categories, images, items, pages
|
|
from app.security import limiter
|
|
from app.seed import stammdaten_anlegen
|
|
|
|
log = logging.getLogger("kleiderboerse")
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lebenszyklus(_app: FastAPI):
|
|
# Die Tabellen legt Alembic an (alembic upgrade head). Hier werden nur
|
|
# die Stammdaten ergänzt, damit eine frische Datenbank sofort brauchbar
|
|
# ist - Kategorien und Grössen sind Voraussetzung fürs Erfassen.
|
|
cfg = einstellungen()
|
|
cfg.upload_pfad.mkdir(parents=True, exist_ok=True)
|
|
with SessionLocal() as db:
|
|
stammdaten_anlegen(db)
|
|
|
|
if not cfg.admin_aktiv:
|
|
log.warning(
|
|
"Kein ADMIN_PASSWORD gesetzt - der Erfassungsbereich ist gesperrt. "
|
|
"Die Galerie funktioniert."
|
|
)
|
|
yield
|
|
engine.dispose()
|
|
|
|
|
|
app = FastAPI(
|
|
title="Kinderkleider-Börse",
|
|
description="Zu klein gewordene Kinderkleidung katalogisieren und weitergeben.",
|
|
version="0.1.0",
|
|
lifespan=lebenszyklus,
|
|
# Eigene /docs-Route weiter unten: die mitgelieferte lädt Swagger von
|
|
# cdn.jsdelivr.net, was die CSP (script-src 'self') zu Recht blockiert -
|
|
# die Seite bliebe weiss. Ausserdem ginge bei jedem Aufruf die IP des
|
|
# Besuchers an einen Dritten.
|
|
docs_url=None,
|
|
redoc_url=None,
|
|
)
|
|
|
|
_cfg = einstellungen()
|
|
|
|
# Ohne festen Schlüssel wird bei jedem Start ein neuer erzeugt - dann ist
|
|
# man nach einem Neustart abgemeldet. Für den Betrieb SECRET_KEY setzen.
|
|
_schluessel = _cfg.secret_key or secrets.token_urlsafe(48)
|
|
if not _cfg.secret_key:
|
|
log.warning("Kein SECRET_KEY gesetzt - Anmeldungen überleben keinen Neustart.")
|
|
|
|
app.add_middleware(
|
|
SessionMiddleware,
|
|
secret_key=_schluessel,
|
|
session_cookie="kleiderboerse",
|
|
same_site="lax", # blockt Cookies bei seitenfremden POSTs (CSRF-Schutz)
|
|
https_only=_cfg.cookie_nur_https,
|
|
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.exception_handler(RateLimitExceeded)
|
|
async def rate_limit_ueberschritten(_request: Request, _exc: RateLimitExceeded):
|
|
return JSONResponse(
|
|
status_code=429,
|
|
content={"detail": "Zu viele Versuche. Bitte später nochmal probieren."},
|
|
)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def sicherheits_header(request: Request, call_next):
|
|
"""Schutz-Header für jede Antwort.
|
|
|
|
Wichtigster Punkt ist die CSP: sollte trotz Maskierung einmal
|
|
Fremd-Markup in eine Seite geraten, verhindert script-src 'self', dass
|
|
daraus ausführbarer Code wird.
|
|
|
|
'unsafe-inline' bei style-src ist für Tailwind-Utility-Klassen nicht
|
|
nötig, wohl aber für einzelne style-Attribute im Markup; script-src
|
|
bleibt bewusst ohne.
|
|
"""
|
|
antwort = await call_next(request)
|
|
antwort.headers.setdefault(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
|
"img-src 'self' data:; font-src 'self'; connect-src 'self'; "
|
|
"object-src 'none'; base-uri 'none'; form-action 'self'; "
|
|
"frame-ancestors 'none'",
|
|
)
|
|
antwort.headers.setdefault("X-Content-Type-Options", "nosniff")
|
|
antwort.headers.setdefault("Referrer-Policy", "same-origin")
|
|
antwort.headers.setdefault("X-Frame-Options", "DENY")
|
|
# Die Galerie ist frei zugänglich, soll aber nicht im Suchindex landen:
|
|
# sonst sind Fotos und Texte noch auffindbar, wenn die Börse längst
|
|
# abgeräumt ist.
|
|
antwort.headers.setdefault("X-Robots-Tag", "noindex, nofollow")
|
|
return antwort
|
|
|
|
|
|
app.include_router(auth.router)
|
|
app.include_router(categories.router)
|
|
app.include_router(items.router)
|
|
app.include_router(images.router)
|
|
app.include_router(pages.router)
|
|
|
|
|
|
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)
|
|
def swagger_ui():
|
|
"""Swagger-Oberfläche mit lokal ausgelieferten Dateien.
|
|
|
|
Die Dateien liegen unter app/static/swagger/ im Repository - so
|
|
funktioniert die Seite auch ohne Internetzugang, und es wird kein
|
|
fremder Server aufgerufen.
|
|
"""
|
|
return get_swagger_ui_html(
|
|
openapi_url=app.openapi_url,
|
|
title=f"{app.title} - API",
|
|
swagger_js_url="/static/swagger/swagger-ui-bundle.js",
|
|
swagger_css_url="/static/swagger/swagger-ui.css",
|
|
# Ohne das holt FastAPI das Favicon von fastapi.tiangolo.com -
|
|
# wieder ein fremder Server, wieder von der CSP blockiert.
|
|
swagger_favicon_url="/static/favicon.svg",
|
|
)
|
|
|
|
|
|
@app.get("/robots.txt", include_in_schema=False)
|
|
def robots():
|
|
return PlainTextResponse("User-agent: *\nDisallow: /\n")
|
|
|
|
|
|
@app.get("/health", tags=["Betrieb"])
|
|
def health():
|
|
"""Wird vom Docker-Healthcheck und von der CI benutzt."""
|
|
return {"status": "ok"}
|