diff --git a/AzA march 2026/backend_main.py b/AzA march 2026/backend_main.py index 58e449f..0f75c87 100644 --- a/AzA march 2026/backend_main.py +++ b/AzA march 2026/backend_main.py @@ -664,160 +664,167 @@ app = FastAPI( title="AZA Transkriptions-Backend", version="0.1.0", default_response_class=JSONResponse, ) _CORS_ORIGINS = [ o.strip() for o in os.environ.get("AZA_CORS_ORIGINS", "").split(",") if o.strip() ] or [ "https://aza-medwork.ch", "https://www.aza-medwork.ch", "http://127.0.0.1:8000", "http://localhost:8000", ] try: from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=_CORS_ORIGINS, allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["Content-Type", "X-API-Token", "X-Device-Id"], allow_credentials=False, ) except Exception: pass _app_root = Path(__file__).resolve().parent _web_dir = _app_root / "web" _release_dir = _app_root / "release" try: from fastapi.staticfiles import StaticFiles if _web_dir.is_dir(): app.mount("/web", StaticFiles(directory=str(_web_dir), html=True), name="web_static") if _release_dir.is_dir(): app.mount("/release", StaticFiles(directory=str(_release_dir)), name="release_static") except Exception: pass MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024 # 1 MB MAX_TRANSCRIBE_BODY_BYTES = 500 * 1024 * 1024 # 500 MB @app.middleware("http") async def request_size_limit_middleware(request: Request, call_next): body = await request.body() max_bytes = MAX_REQUEST_BODY_BYTES if request.url.path.startswith("/v1/transcribe"): max_bytes = MAX_TRANSCRIBE_BODY_BYTES if len(body) > max_bytes: raise HTTPException(status_code=413, detail="request body too large") response = await call_next(request) return response try: status_file = Path(__file__).resolve().parent / "project_status.json" if status_file.is_file(): with open(status_file, "r", encoding="utf-8") as f: s = json.load(f) print("\n=== AZA PROJECT STATUS ===") print("Phase:", s.get("phase")) print("Step:", s.get("current_step")) print("Next:", s.get("next_step")) print("Notes:", s.get("last_update")) print("==========================\n") except Exception: pass # Stripe routes try: from stripe_routes import router as stripe_router app.include_router(stripe_router, prefix="/stripe") except Exception: # Stripe is optional until env + deps are in place pass +# Admin monitor routes +try: + from admin_routes import router as admin_router + app.include_router(admin_router, prefix="/admin") +except Exception as _admin_err: + print(f"[ADMIN] admin_routes not loaded: {_admin_err}") + # Project status route try: from project_status_routes import router as project_status_router app.include_router(project_status_router) except Exception: pass @app.on_event("startup") def _print_routes(): if not API_TOKEN: raise RuntimeError("FEHLER: ENV MEDWORK_API_TOKEN ist nicht gesetzt. Server wird nicht gestartet.") print("=== Aktive Routes im Server-Prozess ===") for route in app.routes: methods = getattr(route, "methods", None) path = getattr(route, "path", "?") if methods: print(f" {', '.join(sorted(methods)):8s} {path}") print("========================================") class TranscribeResponse(BaseModel): success: bool transcript: str error: str request_id: str duration_ms: int model: str debug: dict | None = None model_used: str | None = None def _read_expected_token() -> str: # backend_token.txt hat Vorrang (gleiche Logik wie Client) try: token_path = Path(__file__).resolve().parent / "backend_token.txt" if token_path.is_file(): with open(token_path, "r", encoding="utf-8-sig") as f: t = (f.read() or "").replace("\ufeff", "").strip(" \t\r\n") if t: return t except Exception: pass return (os.environ.get("MEDWORK_API_TOKEN", "") or "").strip() def _extract_request_token(request: Request) -> str: token = (request.headers.get("X-API-Token", "") or "").strip() if token: return token auth = (request.headers.get("Authorization", "") or "").strip() if auth.startswith("Bearer "): return auth[len("Bearer "):].strip() return "" def _require_token(request: Request) -> None: expected = _read_expected_token() got = _extract_request_token(request) if not expected or got != expected: raise HTTPException(status_code=401, detail="Unauthorized") def _check_token(request: Request) -> bool: try: _require_token(request) return True except HTTPException: return False def _get_user(request: Request) -> Optional[str]: """Return X-User header value or None if missing/empty.""" user = request.headers.get("X-User", "").strip() return user if user else None _NO_USER_RESPONSE = {"success": False, "error": "X-User header required"} @@ -1496,292 +1503,306 @@ def telemetry_ping(data: TelemetryPing, request: Request): recent_hits = [ ts for ts in _telemetry_hits[client_ip] if now_ts - ts < TELEMETRY_RATE_WINDOW_SECONDS ] _telemetry_hits[client_ip] = recent_hits if len(recent_hits) >= TELEMETRY_RATE_LIMIT: raise HTTPException(status_code=429, detail="telemetry rate limit exceeded") _telemetry_hits[client_ip].append(now_ts) if data.event not in ALLOWED_TELEMETRY_EVENTS: raise HTTPException(status_code=400, detail="invalid telemetry event") if data.event == "crash": if not data.crash_type or data.crash_type not in ALLOWED_CRASH_TYPES: raise HTTPException(status_code=400, detail="invalid crash_type") if data.event != "update_check" and data.target_version is not None: raise HTTPException(status_code=400, detail="target_version only allowed for update_check") # Minimal telemetry ÔÇô no PHI, no persistence yet print( "[telemetry]", { "time": datetime.utcnow().isoformat(), "event": data.event, "version": data.version, "platform": data.platform, "app": data.app, "crash_type": data.crash_type, "target_version": data.target_version, }, ) _telemetry_event_counts[data.event] += 1 return {"status": "ok"} @app.get("/admin/telemetry/stats") def telemetry_stats(): uptime_seconds = int((datetime.utcnow() - _server_start_time).total_seconds()) return { "server_start_time": _server_start_time.isoformat() + "Z", "uptime_seconds": uptime_seconds, "events": dict(_telemetry_event_counts) } @app.get("/license/debug") def license_debug(): db_path = _stripe_db_path() exists = db_path.exists() active_count = 0 current_period_end = None if exists: try: with sqlite3.connect(db_path) as con: row = con.execute("SELECT COUNT(*) FROM licenses WHERE status='active'").fetchone() active_count = int(row[0]) if row else 0 row2 = con.execute("SELECT MAX(current_period_end) FROM licenses").fetchone() current_period_end = int(row2[0]) if row2 and row2[0] is not None else None except Exception: active_count = 0 current_period_end = None return JSONResponse(content={ "stripe_db_path": str(db_path.resolve()), "exists": exists, "active_count": active_count, "current_period_end": current_period_end, "cwd": os.getcwd(), }) @app.get("/license/status") def license_status( request: Request, + email: Optional[str] = Query(None), _: None = Depends(require_api_token), ): db_path = _stripe_db_path() if not db_path.exists(): return {"valid": False, "valid_until": None} status = None current_period_end = None customer_email = None try: try: import stripe_routes # type: ignore if hasattr(stripe_routes, "_ensure_storage"): stripe_routes._ensure_storage() # type: ignore except Exception: pass with sqlite3.connect(db_path) as con: - row = con.execute( - """ - SELECT status, current_period_end, customer_email - FROM licenses - ORDER BY updated_at DESC - LIMIT 1 - """ - ).fetchone() + row = None + if email and email.strip(): + row = con.execute( + """ + SELECT status, current_period_end, customer_email + FROM licenses + WHERE lower(customer_email) = ? + ORDER BY updated_at DESC + LIMIT 1 + """, + (email.strip().lower(),), + ).fetchone() + if row is None: + row = con.execute( + """ + SELECT status, current_period_end, customer_email + FROM licenses + ORDER BY updated_at DESC + LIMIT 1 + """ + ).fetchone() if row: status = row[0] current_period_end = int(row[1]) if row[1] is not None else None customer_email = str(row[2]).strip() if row[2] is not None else None except Exception: status = None current_period_end = None customer_email = None decision = compute_license_decision(current_period_end=current_period_end, status=status) # --- Device enforcement (devices_per_user) --- device_id = request.headers.get("X-Device-Id") # For now: single-user desktop client -> stable key user_key = "default" if not customer_email: return {"valid": False, "valid_until": None} # customer_email must be loaded from the license row # IMPORTANT: Ensure you have customer_email from DB already. dd = enforce_and_touch_device(customer_email=customer_email, user_key=user_key, device_id=device_id, db_path=str(db_path)) if not dd.allowed: return {"valid": False, "valid_until": None} # Keep schema EXACT: valid=false -> valid_until=null is_valid = bool(decision.valid) return { "valid": is_valid, "valid_until": decision.valid_until if is_valid else None, } @app.get("/billing/success") def billing_success(session_id: Optional[str] = Query(None)) -> HTMLResponse: customer_email = "" if session_id: try: import stripe as _stripe _stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "") sess = _stripe.checkout.Session.retrieve(session_id) customer_email = getattr(sess, "customer_email", "") or "" if not customer_email and getattr(sess, "customer_details", None): customer_email = sess.customer_details.get("email", "") or "" except Exception: pass download_url = "/download/aza_desktop_setup.exe" try: vf = Path(__file__).resolve().parent / "release" / "version.json" if vf.exists(): with open(vf, "r", encoding="utf-8") as _f: _vd = json.load(_f) download_url = _vd.get("download_url", download_url) except Exception: pass email_line = "" if customer_email: email_line = f'

Ihr Konto: {customer_email}

' html = f""" AZA ÔÇô Vielen Dank

Vielen Dank fuer Ihr Abonnement

Ihr Zugang zu AZA Medical AI Assistant ist jetzt aktiv.

{email_line}
AZA Desktop herunterladen

Installation in 3 Schritten

  1. Installer starten ÔÇô Doppelklick auf die heruntergeladene Datei. Falls Windows SmartScreen warnt: ┬½Weitere Informationen┬╗ ÔåÆ ┬½Trotzdem ausfuehren┬╗.
  2. -
  3. OpenAI-Schluessel einrichten ÔÇô Beim ersten Start werden Sie durch die - Einrichtung gefuehrt. Halten Sie Ihren OpenAI-API-Key bereit.
  4. +
  5. Registrieren ÔÇô Beim ersten Start erfassen Sie Ihr Profil. + Verwenden Sie dieselbe E-Mail-Adresse wie beim Kauf, damit Ihre Lizenz erkannt wird.
  6. Loslegen ÔÇô Waehlen Sie im Startbildschirm Ihr gewuenschtes Modul und beginnen Sie mit der Arbeit.
Hinweis: Ihr Abonnement ist an Ihre E-Mail-Adresse gebunden. Die Lizenz wird beim Start automatisch geprueft ÔÇô eine manuelle Aktivierung ist in der Regel nicht noetig.
""" return HTMLResponse(content=html) @app.get("/billing/cancel") def billing_cancel() -> HTMLResponse: html = """ AZA ÔÇô Checkout abgebrochen

Checkout abgebrochen

Der Bezahlvorgang wurde nicht abgeschlossen.
Sie koennen jederzeit zurueckkehren und es erneut versuchen.

Zurueck zur Startseite
""" return HTMLResponse(content=html) @app.get("/v1/schedule") def get_schedule( request: Request, start: Optional[str] = None, end: Optional[str] = None, employee: Optional[str] = None, date_from: Optional[str] = Query(None, alias="from"), date_to: Optional[str] = Query(None, alias="to"), ): request_id = f"srv_{uuid.uuid4().hex[:12]}" t0 = time.perf_counter() if not _check_token(request): return JSONResponse(status_code=401, content={ "success": False, "items": [], "error": "unauthorized", "request_id": request_id, "duration_ms": 0, }) user = _get_user(request) if not user: return JSONResponse(status_code=400, content={**_NO_USER_RESPONSE, "items": [], "request_id": request_id, "duration_ms": 0}) try: eff_start = date_from or start eff_end = date_to or end has_any_filter = employee or eff_start or eff_end