90 lines
3.3 KiB
Python
90 lines
3.3 KiB
Python
"""Glory Defense – Flask-App-Factory.
|
||
|
||
Die App ist bewusst monolithisch und zustandslos (Zustand nur in SQLite), damit sie
|
||
unter Phusion Passenger mit mehreren Prozessen und ohne Hintergrunddienste läuft.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Mapping
|
||
from typing import Any
|
||
|
||
from flask import Flask, Response, jsonify, request
|
||
from werkzeug.exceptions import HTTPException
|
||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||
|
||
from . import api, models, routes
|
||
from .config import INSTANCE_DIR, load_config
|
||
|
||
#: Content-Security-Policy: Skripte nur vom eigenen Server (Phaser liegt lokal).
|
||
#: ``blob:``/``data:`` braucht Phaser, weil es geladene Bilder, SVGs und Audio als
|
||
#: Blob-URLs dekodiert und eingebaute Standardtexturen als Data-URLs mitbringt.
|
||
CONTENT_SECURITY_POLICY = "; ".join(
|
||
[
|
||
"default-src 'self'",
|
||
"script-src 'self'",
|
||
"style-src 'self' 'unsafe-inline'",
|
||
"img-src 'self' data: blob:",
|
||
"media-src 'self' data: blob:",
|
||
"font-src 'self' data:",
|
||
"connect-src 'self' data: blob:",
|
||
"worker-src 'self' blob:",
|
||
"object-src 'none'",
|
||
"base-uri 'self'",
|
||
"form-action 'self'",
|
||
"frame-ancestors 'self'",
|
||
]
|
||
)
|
||
|
||
|
||
def _apply_security_headers(response: Response) -> Response:
|
||
response.headers.setdefault("Content-Security-Policy", CONTENT_SECURITY_POLICY)
|
||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||
response.headers.setdefault("Referrer-Policy", "same-origin")
|
||
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
|
||
return response
|
||
|
||
|
||
def _handle_http_error(exc: HTTPException) -> Response | HTTPException:
|
||
"""API-Pfade bekommen Fehler als JSON, damit das Frontend sie einheitlich auswerten kann."""
|
||
if request.path.startswith("/api/"):
|
||
body: dict[str, Any] = {
|
||
"error": (exc.name or "error").lower().replace(" ", "_"),
|
||
"message": exc.description or "",
|
||
}
|
||
response = jsonify(body)
|
||
response.status_code = exc.code or 500
|
||
return response
|
||
return exc
|
||
|
||
|
||
def create_app(test_config: Mapping[str, Any] | None = None) -> Flask:
|
||
"""Erzeugt und konfiguriert die Flask-App.
|
||
|
||
Args:
|
||
test_config: Optionale Konfigurationswerte mit Vorrang vor den
|
||
Umgebungsvariablen (vor allem für Tests).
|
||
|
||
Returns:
|
||
Die fertig konfigurierte Flask-Anwendung.
|
||
"""
|
||
app = Flask(__name__, instance_path=str(INSTANCE_DIR))
|
||
app.config.from_mapping(load_config(test_config))
|
||
|
||
# Antworten in der Reihenfolge, in der der Code die Felder aufbaut (lesbarer).
|
||
app.json.sort_keys = False # type: ignore[attr-defined]
|
||
|
||
if app.config["TRUST_PROXY"]:
|
||
# Nur hinter einem vertrauenswürdigen Proxy aktivieren, sonst könnten Clients
|
||
# ihre IP (und damit das Rate-Limit) per Header fälschen. Übernommen werden nur
|
||
# Client-IP und Protokoll: Host und Pfadpräfix aus Headern landeten sonst in den
|
||
# erzeugten URLs (z. B. X-Forwarded-Prefix: //fremde.domain).
|
||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) # type: ignore[method-assign]
|
||
|
||
models.init_app(app)
|
||
app.register_blueprint(routes.bp)
|
||
app.register_blueprint(api.bp)
|
||
app.after_request(_apply_security_headers)
|
||
app.register_error_handler(HTTPException, _handle_http_error)
|
||
return app
|