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:
+59
-3
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user