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:
StefanandClaude Opus 5 committed 2026-08-29 23:17:42 +02:00
1 parent 99ba74330c
commit 01cd0a73e8
5 files changed
+105 -5

No files matched your search

+59 -3
View File
@@ -8,9 +8,12 @@ Plan.md).
import logging
import secrets
from contextlib import asynccontextmanager
from pathlib import Path
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 starlette.middleware.sessions import SessionMiddleware
@@ -47,6 +50,12 @@ app = FastAPI(
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()
@@ -113,10 +122,57 @@ 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():
from fastapi.responses import PlainTextResponse
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

+2
View File
@@ -0,0 +1,2 @@
/*! For license information please see swagger-ui-bundle.js.LICENSE.txt */
!function webpackUniversalModuleDefinition(o,s){"object"==typeof exports&&"object"==typeof module?module.exports=s():"function"==typeof define&&define.amd?define([],s):"object"==typeof exports?exports.SwaggerUIBundle=s():o.SwaggerUIBundle=s()}(this,(()=>(()=>{var o,s,i={69119:(o,s)=>{"use strict";Object.defineProperty(s,"__esModule",{value:!0}),s.BLANK_URL=s.relativeFirstCharacters=s.whitespaceEscapeCharsRegex=s.urlSchemeRegex=s.ctrlCharactersRegex=s.htmlCtrlEntityRegex=s.htmlEntitiesRegex=s.invalidProtocolRegex=void 0,s.invalidProtocolRegex=/^([^\w]*)(javascript|data|vbscript)/im,s.htmlEntitiesRegex=/&#(\w+)(^\w|;)?/g,s.htmlCtrlEntityRegex=/&(newline|tab);/gi,s.ctrlCharactersRegex=/[\u0000-\u001F\u007F-\u009F\u2000-\u200D\uFEFF]/gim,s.urlSchemeRegex=/^.+(:|&colon;)/gim,s.whitespaceEscapeCharsRegex=/(\\|%5[cC])((%(6[eE]|72|74))|[nrt])/g,s.relativeFirstCharacters=[".","/"],s.BLANK_URL="about:blank"},16750:(o,s,i)=>{"use strict";s.J=void 0;var u=i(69119);function decodeURI(o){try{return decodeURIComponent(o)}catch(s){return o}}s.J=function sanitizeUrl(o){if(!o)return u.BLANK_URL;var s,i,_=decodeURI(o);do{s=(_=decodeURI(_=(i=_,i.replace(u.ctrlCharactersRegex,"").replace(u.htmlEntitiesRegex,(function(o,s){return String.fromCharCode(s)}))).replace(u.htmlCtrlEntityRegex,"").replace(u.ctrlCharactersRegex,"").replace(u.whitespaceEscapeCharsRegex,"").trim())).match(u.ctrlCharactersRegex)||_.match(u.htmlEntitiesRegex)||_.match(u.htmlCtrlEntityRegex)||_.match(u.whitespaceEscapeCharsRegex)}while(s&&s.length>0);var w=_;if(!w)return u.BLANK_URL;if(function isRelativeUrlWithoutProtocol(o){return u.relativeFirstCharacters.indexOf(o[0])>-1}(w))return w;var x=w.match(u.urlSchemeRegex);if(!x)return w;var C=x[0];return u.invalidProtocolRegex.test(C)?u.BLANK_URL:w}},67526:(o,s)=>{"use strict";s.byteLength=function byteLength(o){var s=getLens(o),i=s[0],u=s[1];return 3*(i+u)/4-u},s.toByteArray=function toByteArray(o){var s,i,w=getLens(o),x=w[0],C=w[1],j=new _(function _byteLength(o,s,i){return 3*(s+i)/4-i}(0,x,C)),L=0,B=C>0?x-4:x;for(i=0;i<B;i+=4)s=u[o.charCodeAt(i)]<<18|u[o.charCodeAt(i+1)]<<12|u[o.charCodeAt(i+2)]<<6|u[o.charCodeAt(i+3)],j[L++]=s>>16&255,j[L++]=s>>8&255,j[L++]=255&s;2===C&&(s=u[o.charCodeAt(i)]<<2|u[o.charCodeAt(i+1)]>>4,j[L++]=255&s);1===C&&(s=u[o.charCodeAt(i)]<<10|u[o.charCodeAt(i+1)]<<4|u[o.charCodeAt(i+2)]>>2,j[L++]=s>>8&255,j[L++]=255&s);return j},s.fromByteArray=function fromByteArray(o){for(var s,u=o.length,_=u%3,w=[],x=16383,C=0,j=u-_;C<j;C+=x)w.push(encodeChunk(o,C,C+x>j?j:C+x));1===_?(s=o[u-1],w.push(i[s>>2]+i[s<<4&63]+"==")):2===_&&(s=(o[u-2]<<8)+o[u-1],w.push(i[s>>10]+i[s>>4&63]+i[s<<2&63]+"="));return w.join("")};for(var i=[],u=[],_="undefined"!=typeof Uint8Array?Uint8Array:Array,w="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",x=0;x<64;++x)i[x]=w[x],u[w.charCodeAt(x)]=x;function getLens(o){var s=o.length;if(s%4>0)throw new Error("Invalid string. Length must be a multiple of 4");var i=o.indexOf("=");return-1===i&&(i=s),[i,i===s?0:4-i%4]}function encodeChunk(o,s,u){for(var _,w,x=[],C=s;C<u;C+=3)_=(o[C]<<16&16711680)+(o[C+1]<<8&65280)+(255&o[C+2]),x.push(i[(w=_)>>18&63]+i[w>>12&63]+i[w>>6&63]+i[63&w]);return x.join("")}u["-".charCodeAt(0)]=62,u["_".charCodeAt(0)]=63},48287:(o,s,i)=>{"use strict";const u=i(67526),_=i(251),w="function"==typeof Symbol&&"function"==typeof Symbol.for?Symbol.for("nodejs.util.inspect.custom"):null;s.Buffer=Buffer,s.SlowBuffer=function SlowBuffer(o){+o!=o&&(o=0);return Buffer.alloc(+o)},s.INSPECT_MAX_BYTES=50;const x=2147483647;function createBuffer(o){if(o>x)throw new RangeError('The value "'+o+'" is invalid for option "size"');const s=new Uint8Array(o);return Object.setPrototypeOf(s,Buffer.prototype),s}function Buffer(o,s,i){if("number"==typeof o){if("string"==typeof s)throw new TypeError('The "string" argument must be of type string. Received type number');return allocUnsafe(o)}return from(o,s,i)}function from(o,s,i){if("string"==typeof o)return function fromString(o,s){"string"==typeof s&&""!==s||(s="utf8");if(!Buffer.isEncoding(s))throw new TypeError("Unknown encoding: "+s);const i=0|byteLength(o,s);let u=createBuffer(i);const _=u.write(o,s);_!==i&&(u=u.slice(0,_));return u}(o,s);if(ArrayBuffer.isView(o))return function fromArrayView(o){if(isInstance(o,Uint8Array)){const s=new Uint8Array(o);return fromArrayBuffer(s.buffer,s.byteOffset,s.byteLength)}return fromArrayLike(o)}(o);if(null==o)throw new TypeError("The first argument must be one of type string, Buffer, ArrayBuffer, Array, or Array-like Object. Received type "+typeof o);if(isInstance(o,ArrayBuffer)||o&&isInstance(o.buffer,ArrayBuffer))return fromArrayBuffer(o,s,i);if("undefined"!=typeof SharedArrayBuffer&&(isInstance(o,SharedArrayBuffer)||o&&isInstance(o.buffer,SharedArrayBuffer)))return fromArrayBuffer(o,s,i);if("number"==typeof o)throw new TypeError('The "value" argument must not be of type number. Received type number');const u=o.valueOf&&o.valueOf();if(null!=u&&u!==o)return Buffer.from(Line truncated
+3
View File
@@ -0,0 +1,3 @@
.swagger-ui{color:#3b4151;font-family:sans-serif/*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */}.swagger-ui html{line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}.swagger-ui body{margin:0}.swagger-ui article,.swagger-ui aside,.swagger-ui footer,.swagger-ui header,.swagger-ui nav,.swagger-ui section{display:block}.swagger-ui h1{font-size:2em;margin:.67em 0}.swagger-ui figcaption,.swagger-ui figure,.swagger-ui main{display:block}.swagger-ui figure{margin:1em 40px}.swagger-ui hr{box-sizing:content-box;height:0;overflow:visible}.swagger-ui pre{font-family:monospace,monospace;font-size:1em}.swagger-ui a{background-color:transparent;-webkit-text-decoration-skip:objects}.swagger-ui abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.swagger-ui b,.swagger-ui strong{font-weight:inherit;font-weight:bolder}.swagger-ui code,.swagger-ui kbd,.swagger-ui samp{font-family:monospace,monospace;font-size:1em}.swagger-ui dfn{font-style:italic}.swagger-ui mark{background-color:#ff0;color:#000}.swagger-ui small{font-size:80%}.swagger-ui sub,.swagger-ui sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}.swagger-ui sub{bottom:-.25em}.swagger-ui sup{top:-.5em}.swagger-ui audio,.swagger-ui video{display:inline-block}.swagger-ui audio:not([controls]){display:none;height:0}.swagger-ui img{border-style:none}.swagger-ui svg:not(:root){overflow:hidden}.swagger-ui button,.swagger-ui input,.swagger-ui optgroup,.swagger-ui select,.swagger-ui textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}.swagger-ui button,.swagger-ui input{overflow:visible}.swagger-ui button,.swagger-ui select{text-transform:none}.swagger-ui [type=reset],.swagger-ui [type=submit],.swagger-ui button,.swagger-ui html [type=button]{-webkit-appearance:button}.swagger-ui [type=button]::-moz-focus-inner,.swagger-ui [type=reset]::-moz-focus-inner,.swagger-ui [type=submit]::-moz-focus-inner,.swagger-ui button::-moz-focus-inner{border-style:none;padding:0}.swagger-ui [type=button]:-moz-focusring,.swagger-ui [type=reset]:-moz-focusring,.swagger-ui [type=submit]:-moz-focusring,.swagger-ui button:-moz-focusring{outline:1px dotted ButtonText}.swagger-ui fieldset{padding:.35em .75em .625em}.swagger-ui legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}.swagger-ui progress{display:inline-block;vertical-align:baseline}.swagger-ui textarea{overflow:auto}.swagger-ui [type=checkbox],.swagger-ui [type=radio]{box-sizing:border-box;padding:0}.swagger-ui [type=number]::-webkit-inner-spin-button,.swagger-ui [type=number]::-webkit-outer-spin-button{height:auto}.swagger-ui [type=search]{-webkit-appearance:textfield;outline-offset:-2px}.swagger-ui [type=search]::-webkit-search-cancel-button,.swagger-ui [type=search]::-webkit-search-decoration{-webkit-appearance:none}.swagger-ui ::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}.swagger-ui details,.swagger-ui menu{display:block}.swagger-ui summary{display:list-item}.swagger-ui canvas{display:inline-block}.swagger-ui [hidden],.swagger-ui template{display:none}.swagger-ui .debug *{outline:1px solid gold}.swagger-ui .debug-white *{outline:1px solid #fff}.swagger-ui .debug-black *{outline:1px solid #000}.swagger-ui .debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==) repeat 0 0}.swagger-ui .debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+ILine truncated
/*# sourceMappingURL=swagger-ui.css.map*/
+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):
"""Ohne hinterlegtes Passwort bleibt gesperrt statt offen zu stehen."""
monkeypatch.delenv("ADMIN_PASSWORD", raising=False)
"""Ohne hinterlegtes Passwort bleibt gesperrt statt offen zu stehen.
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()
security.hash_zwischenspeicher_leeren()
@@ -168,3 +175,31 @@ def test_robots_txt_sperrt_suchmaschinen(gast):
antwort = gast.get("/robots.txt")
assert antwort.status_code == 200
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