Files
kleiderboerse/app/main.py
T
StefanandClaude Opus 5 01cd0a73e8 Weisse Seite beheben: Swagger lokal ausliefern, Startseite ergänzen
Zwei Fehler, beide erst im Browser sichtbar:

1. /docs blieb weiss. FastAPI holt Swagger von cdn.jsdelivr.net, und die
   in Phase 3 eingeführte CSP (script-src 'self') blockiert das zu Recht.
   Die Dateien liegen jetzt unter app/static/swagger/ im Repository - das
   funktioniert auch ohne Internet und ruft keinen fremden Server auf, aus
   demselben Grund, aus dem die Kantone-App ihre Schriften lokal ausliefert.
   Das Favicon holte FastAPI ebenfalls von aussen; auch das ist jetzt lokal.

2. Die Wurzel / lieferte ein nacktes 404-JSON - im Browser sieht das aus
   wie eine kaputte Anwendung. Jetzt eine schlichte Platzhalter-Seite, bis
   die Galerie in Phase 4 steht.

Ausserdem beim Testen aufgefallen: eine lokale .env-Datei reicht
ADMIN_PASSWORD auch dann herein, wenn die Umgebungsvariable gelöscht wird -
bei pydantic-settings haben Umgebungsvariablen Vorrang, ein leerer Wert
überstimmt die Datei, ein Löschen nicht. Der Test setzt jetzt leer statt zu
löschen; im Betrieb ist das dieselbe Falle.

Der neue Test prüft, dass /docs keinen einzigen Verweis auf einen fremden
Host enthält - genau das würde sonst unbemerkt wieder hereinrutschen.

61 Tests, alle grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
2026-08-29 23:17:42 +02:00

183 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.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
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,
)
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.mount("/static", StaticFiles(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("/", include_in_schema=False)
def startseite():
"""Platzhalter, bis die Galerie steht (Phase 4).
Ohne diese Route liefert die Wurzel ein nacktes 404-JSON - im Browser
sieht das aus wie eine kaputte Anwendung.
"""
return HTMLResponse(
"""<!doctype html>
<html lang="de-CH"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<title>Kinderkleider-Börse</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 34rem; margin: 4rem auto;
padding: 0 1rem; line-height: 1.6; color: #1f2937; }
code { background: #f3f4f6; padding: .1rem .35rem; border-radius: .25rem; }
</style></head><body>
<h1>Kinderkleider-Börse</h1>
<p>Die Anwendung läuft. Die Oberfläche entsteht noch bis dahin lässt sich
alles über die <a href="/docs">API-Oberfläche</a> ausprobieren.</p>
<p>Zum Erfassen zuerst <code>POST /api/v1/auth/login</code> ausführen.
Ohne Anmeldung sind Galerie und Reservierung nutzbar.</p>
</body></html>"""
)
@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"}