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
183 lines
6.4 KiB
Python
183 lines
6.4 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.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"}
|