"""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( """
Die Anwendung läuft. Die Oberfläche entsteht noch – bis dahin lässt sich alles über die API-Oberfläche ausprobieren.
Zum Erfassen zuerst POST /api/v1/auth/login ausführen.
Ohne Anmeldung sind Galerie und Reservierung nutzbar.