""" Shinkan Jinkendo - Main Application Entry Point Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung """ from pathlib import Path from typing import Optional from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles import os import sys from slowapi import _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS def _is_production_environment() -> bool: return os.getenv("ENVIRONMENT", "development").strip().lower() in ("production", "prod") def _public_openapi_enabled() -> bool: return os.getenv("PUBLIC_OPENAPI", "").strip().lower() in ("1", "true", "yes") def _health_ready_public_detail_enabled() -> bool: """In Prod standardmäßig keine Tabellen-/Migrations-Details (Information Disclosure).""" return os.getenv("HEALTH_READY_PUBLIC_DETAIL", "").strip().lower() in ("1", "true", "yes") # Run database migrations before API start — halbes Schema ist schlimmer als kein Start # (run_migrations: pending *.sql in einer Transaktion pro Datei, Buchführung schema_migrations) # Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1 if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"): print("[SKIP_DB_MIGRATE] Migrationen uebersprungen (nur fuer Entwicklung ohne DB)") else: try: import run_migrations rc = run_migrations.main() if rc != 0: print(f"[FAIL] Datenbank-Migration fehlgeschlagen (Exit-Code {rc}). Start abgebrochen.") sys.exit(1) print("[OK] Database migrations completed") except SystemExit: raise except Exception as e: print(f"[FAIL] Migration-Laufzeitfehler: {e}") sys.exit(1) # Registry-first: Module → DB (nur registrierte Rechte/Kontingente in Admin-Matrix) if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"): try: from rights_registry import sync_rights_registry_to_db counts = sync_rights_registry_to_db() print( f"[OK] Rights registry sync: {counts['capabilities']} capabilities, " f"{counts['features']} features" ) except Exception as e: print(f"[FAIL] Rights registry sync: {e}") sys.exit(1) from club_features import club_feature_enforcement_enabled _cfe = os.getenv("CLUB_FEATURE_ENFORCE", "0") print( f"[OK] CLUB_FEATURE_ENFORCE raw={_cfe!r} " f"active={club_feature_enforcement_enabled()}" ) from routers.auth import limiter as auth_rate_limiter # OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1 _expose_docs = (not _is_production_environment()) or _public_openapi_enabled() _openapi_url = "/openapi.json" if _expose_docs else None _docs_url = "/docs" if _expose_docs else None _redoc_url = "/redoc" if _expose_docs else None # Initialize FastAPI app app = FastAPI( title="Shinkan Jinkendo API", description="Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung", version=APP_VERSION, openapi_url=_openapi_url, docs_url=_docs_url, redoc_url=_redoc_url, ) # SlowAPI: Rate Limits auf /api/auth/* (Decorator in routers/auth.py) app.state.limiter = auth_rate_limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) # CORS — kommaseparierte Liste (z. B. https://dev.shinkan… und http://192.168.x.x:3098) _cors_raw = os.getenv("ALLOWED_ORIGINS", "http://localhost:3098") ALLOWED_ORIGINS = [o.strip() for o in _cors_raw.split(",") if o.strip()] app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "X-Auth-Token", "X-Active-Club-Id"], ) @app.middleware("http") async def account_onboarding_api_gate(request: Request, call_next): """ Phase A: Domänen-APIs für unverified / verified_pending_club sperren. Siehe account_onboarding_gate.py und MEMBERSHIP_RBAC_DECISIONS_2026-06.md §1.1 """ from account_onboarding_gate import evaluate_request_gate token = request.headers.get("x-auth-token") or request.headers.get("X-Auth-Token") allowed, reason, _state = evaluate_request_gate( token, request.url.path, request.method, ) if not allowed: return JSONResponse( status_code=403, content={ "detail": ( "Zugriff erst nach E-Mail-Bestätigung und Vereinsmitgliedschaft möglich. " "Du kannst einen Beitrittsantrag stellen oder dein Konto in den Einstellungen verwalten." ), "reason": reason, }, ) return await call_next(request) @app.middleware("http") async def add_api_security_headers(request: Request, call_next): """Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing).""" response = await call_next(request) response.headers.setdefault("X-Content-Type-Options", "nosniff") return response # Version Endpoint (public, no auth) @app.get("/api/version") def get_version(): """Get application version and build info""" return { "app_version": APP_VERSION, "build_date": BUILD_DATE, "backend_version": APP_VERSION, "modules": MODULE_VERSIONS, "db_schema_version": DB_SCHEMA_VERSION, "environment": os.getenv("ENVIRONMENT", "development") } # Health Check @app.get("/health") def health_check(): """Health check endpoint""" return {"status": "healthy", "version": APP_VERSION} @app.get("/api/health/ready") def health_ready(): """ Verbindung + Kern-Tabellen prüfen (ohne Login). Nutzen bei Prod-Debugging: wenn schema_complete=false, Migrationen nicht vollständig. """ from db import get_db, get_cursor REQUIRED = ( "schema_migrations", "skills", "skill_main_categories", "skill_categories", "maturity_models", "sessions", "club_members", "club_member_roles", ) tables: dict = {} err: Optional[str] = None try: with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT 1") for tbl in REQUIRED: cur.execute( """ SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = %s ) """, (tbl,), ) row = cur.fetchone() tables[tbl] = bool(next(iter(row.values()))) if row else False migration_count = 0 if tables.get("schema_migrations"): cur.execute("SELECT COUNT(*)::int FROM schema_migrations") mig_row = cur.fetchone() migration_count = ( int(list(mig_row.values())[0]) if mig_row is not None else 0 ) except Exception as e: err = str(e) migration_count = 0 complete = bool(err is None and all(tables.get(t) for t in REQUIRED)) body = { "status": "ready" if complete else "degraded", "database": err is None, "detail": err, "schema_complete": complete, "tables": tables, "schema_migrations_count": migration_count, } if _is_production_environment() and not _health_ready_public_detail_enabled(): return { "status": body["status"], "database": body["database"], "schema_complete": body["schema_complete"], } return body # Root Endpoint @app.get("/") def read_root(): """Root endpoint - API info""" out = { "app": "Shinkan Jinkendo API", "version": APP_VERSION, "health": "/health", } if _expose_docs: out["docs"] = "/docs" return out # Register routers from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, club_creation_requests, admin_users, admin_user_content, admin_rights, me_entitlements, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin app.include_router(auth.router) app.include_router(profiles.router) app.include_router(exercises.router) app.include_router(exercise_progression_graphs.router) app.include_router(clubs.router) app.include_router(club_memberships.router) app.include_router(club_join_requests.router) app.include_router(club_creation_requests.router) app.include_router(admin_users.router) app.include_router(admin_user_content.router) app.include_router(admin_rights.router) app.include_router(me_entitlements.router) app.include_router(platform_media_storage.router) app.include_router(media_assets.router) app.include_router(media_assets.admin_rights_router) app.include_router(media_assets.admin_legal_hold_router) app.include_router(skills.router) app.include_router(skill_profiles.router) app.include_router(training_planning.router) app.include_router(planning_exercise_suggest.router) app.include_router(dashboard.router) app.include_router(training_modules.router) app.include_router(training_framework_programs.router) app.include_router(catalogs.router) app.include_router(maturity_models.router) app.include_router(matrix_stack_bundle.router) app.include_router(matrix_editor.router) app.include_router(import_wiki.router) app.include_router(import_wiki_admin.router) app.include_router(legal_documents.router) app.include_router(content_reports.router) app.include_router(ai_prompts_admin.router) app.include_router(ai_skill_retrieval_admin.router) app.include_router(exercise_enrichment_admin.router) # Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad # GET /api/exercises/{id}/media/{mid}/file (?ssetoken für /