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
This commit is contained in:
2026-08-29 23:17:42 +02:00
co-authored by Claude Opus 5
parent 99ba74330c
commit 01cd0a73e8
5 changed files with 105 additions and 5 deletions
+59 -3
View File
@@ -8,9 +8,12 @@ Plan.md).
import logging import logging
import secrets import secrets
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse 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 slowapi.errors import RateLimitExceeded
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
@@ -47,6 +50,12 @@ app = FastAPI(
description="Zu klein gewordene Kinderkleidung katalogisieren und weitergeben.", description="Zu klein gewordene Kinderkleidung katalogisieren und weitergeben.",
version="0.1.0", version="0.1.0",
lifespan=lebenszyklus, 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() _cfg = einstellungen()
@@ -113,10 +122,57 @@ app.include_router(items.router)
app.include_router(images.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) @app.get("/robots.txt", include_in_schema=False)
def robots(): def robots():
from fastapi.responses import PlainTextResponse
return PlainTextResponse("User-agent: *\nDisallow: /\n") return PlainTextResponse("User-agent: *\nDisallow: /\n")
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0f766e"/>
<path d="M11 8h10l3 4-3 2v10H11V14l-3-2z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 181 B

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+37 -2
View File
@@ -40,8 +40,15 @@ def test_falsches_passwort(gast):
def test_ohne_passwort_ist_der_bereich_gesperrt(gast, monkeypatch, kategorie_id): def test_ohne_passwort_ist_der_bereich_gesperrt(gast, monkeypatch, kategorie_id):
"""Ohne hinterlegtes Passwort bleibt gesperrt statt offen zu stehen.""" """Ohne hinterlegtes Passwort bleibt gesperrt statt offen zu stehen.
monkeypatch.delenv("ADMIN_PASSWORD", raising=False)
Leerer Wert statt delenv: Umgebungsvariablen haben bei pydantic-settings
Vorrang vor der .env-Datei. Ein blosses Löschen brächte hier gar nichts,
weil eine lokale .env den Wert sonst wieder hereinreicht - dieselbe
Falle, die auch im Betrieb zuschlagen kann.
"""
monkeypatch.setenv("ADMIN_PASSWORD", "")
monkeypatch.setenv("ADMIN_PASSWORD_HASH", "")
einstellungen.cache_clear() einstellungen.cache_clear()
security.hash_zwischenspeicher_leeren() security.hash_zwischenspeicher_leeren()
@@ -168,3 +175,31 @@ def test_robots_txt_sperrt_suchmaschinen(gast):
antwort = gast.get("/robots.txt") antwort = gast.get("/robots.txt")
assert antwort.status_code == 200 assert antwort.status_code == 200
assert "Disallow: /" in antwort.text assert "Disallow: /" in antwort.text
def test_startseite_antwortet(gast):
"""Ohne eigene Route lieferte die Wurzel ein nacktes 404-JSON - im
Browser sieht das aus wie eine kaputte Anwendung."""
antwort = gast.get("/")
assert antwort.status_code == 200
assert "Kinderkleider-Börse" in antwort.text
def test_swagger_laedt_nichts_von_fremden_servern(gast):
"""Die mitgelieferte /docs-Seite holt Swagger von cdn.jsdelivr.net.
Die CSP (script-src 'self') blockiert das zu Recht - die Seite bliebe
weiss. Ausserdem ginge dabei die IP jedes Besuchers an einen Dritten.
"""
import re
antwort = gast.get("/docs")
assert antwort.status_code == 200
# Kein einziger Verweis auf einen anderen Host
fremde = re.findall(r'(?:src|href)="(https?:)?//[^"]+"', antwort.text)
assert not fremde, f"lädt von aussen: {fremde}"
assert "/static/swagger/swagger-ui-bundle.js" in antwort.text
# Und die Dateien sind auch wirklich da
assert gast.get("/static/swagger/swagger-ui-bundle.js").status_code == 200
assert gast.get("/static/swagger/swagger-ui.css").status_code == 200