Jinja2 mit HTMX, Tailwind über die Standalone-Binärdatei im Docker-Build - kein Node.js, kein npm. Mobile-First, Tippflächen mindestens 44 px hoch. Seiten: Galerie mit Filterleiste, Detailseite, Erfassungsformular, Stapel-Import mit Entwurfsliste, Anmeldung. HTMX tauscht beim Filtern nur die Liste aus statt die ganze Seite neu zu laden. Aus den Entscheidungen im Plan folgt konkret: - Der Stapel-Import steht ZUERST im Formular: wer zwanzig Teile hat, will nicht zwanzigmal ein Formular ausfüllen. Bewusst ohne capture-Attribut, damit sich wahlweise fotografieren oder aus der Mediathek wählen lässt - mit capture ginge nur je ein Foto pro Vorgang. - Grösse, Kategorie, Geschlecht, Saison und Zustand werden vom zuletzt gespeicherten Teil übernommen. Beim Abarbeiten einer Kiste sind das fünf Felder weniger pro Stück. - Nach dem Nachtragen eines Entwurfs geht es direkt zum nächsten, nicht auf die Detailseite - das ist der eigentliche Arbeitsablauf. - Titel bleibt leer erlaubt; angezeigt wird dann Kategorie und Grösse. - reserved_by erscheint öffentlich nur als "reserviert". Den Namen sieht ausschliesslich der angemeldete Betreiber. - Nach dem Reservieren wird der Freigabe-Link angezeigt, als vollständige Adresse zum Aufbewahren. - Post/Redirect/Get überall, damit ein Neuladen nicht erneut abschickt. - CSRF-Token in jedem Formular, zusätzlich zu SameSite=Lax. HTMX liegt lokal im Repository, nicht vom CDN: die CSP erlaubt nur script-src 'self', und ein CDN-Aufruf gäbe die IP jedes Besuchers weiter - dieselbe Überlegung wie bei Swagger und bei den Schriften der Kantone-App. Ein Test prüft für alle Seiten, dass nichts von aussen geladen wird. Nebenbei TemplateResponse auf die aktuelle Signatur umgestellt (64 Deprecation-Warnungen weniger). 77 Tests, alle grün. Zusätzlich der ganze Weg gegen den Container: anmelden, drei Fotos als Stapel, Entwurf nachtragen, Werte werden für das nächste Teil übernommen, als Gast reservieren, Name bleibt verborgen, Selbst-Freigabe über den Link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GR4bNaj9GtRu57J4Niii8o
157 lines
5.3 KiB
Python
157 lines
5.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.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,
|
|
)
|
|
|
|
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)
|
|
|
|
|
|
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("/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"}
|