"""Aplicatia FastAPI: API v1 + dashboard web + /healthz + /metrics. Worker-ul ruleaza ca PROCES SEPARAT (python -m app.worker), NU ca task aici (plan.md sect. 4: un worker mort nu trebuie sa lase containerul "sanatos"). Pornire dev: uvicorn app.main:app --reload """ from __future__ import annotations import secrets from contextlib import asynccontextmanager from datetime import datetime, timezone from pathlib import Path from fastapi import FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from starlette.responses import RedirectResponse import traceback from . import __version__ from . import errors from .api.v1.import_router import router as import_v1_router from .api.v1.integrare_router import router as integrare_v1_router from .api.v1.router import router as api_v1_router from .config import get_settings from .crypto import validate_creds_key from .db import get_connection, init_db, queue_depth, read_heartbeat from .observ import log_event, request_id_var from .security import install_log_redaction, scrub_text from .web.middleware import RequestIDMiddleware from .web.routes import router as web_router from .web.auth_routes import router as auth_router from .web.admin_routes import router as admin_router from .web.csrf import CsrfError from .web.session import AdminRequired, LoginRequired @asynccontextmanager async def lifespan(app: FastAPI): install_log_redaction() # Fail-fast: o cheie Fernet setata dar invalida opreste pornirea cu mesaj clar, # in loc de 500 brut la primul POST /v1/prezentari (cazul reprodus din VFP). validate_creds_key() init_db() yield app = FastAPI(title="Gateway RAR AUTOPASS", version=__version__, lifespan=lifespan) settings = get_settings() _session_secret = settings.session_secret or secrets.token_hex(32) app.add_middleware( SessionMiddleware, secret_key=_session_secret, session_cookie="autopass_session", https_only=settings.session_https_only, same_site="strict", ) # US-002: request_id pe fiecare cerere. Adaugat dupa SessionMiddleware -> ruleaza # OUTERMOST (add_middleware prepend), deci `X-Request-ID` se pune pe TOATE raspunsurile, # inclusiv 401/404/422/500 produse mai in interior. app.add_middleware(RequestIDMiddleware) @app.exception_handler(LoginRequired) async def login_required_handler(request: Request, exc: LoginRequired) -> RedirectResponse: return RedirectResponse("/login", status_code=303) @app.exception_handler(AdminRequired) async def admin_required_handler(request: Request, exc: AdminRequired) -> JSONResponse: return JSONResponse(status_code=403, content={"detail": "acces interzis (necesita admin)"}) @app.exception_handler(CsrfError) async def csrf_error_handler(request: Request, exc: CsrfError) -> JSONResponse: return JSONResponse(status_code=403, content={"detail": "CSRF invalid"}) @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse: """422 fara echo de credentiale. Pydantic include implicit `input` (+ uneori `ctx`) in fiecare eroare — pe /v1/prezentari asta ar reflecta inapoi `rar_credentials.password`. Pastram type/loc/msg (clientul stie ce camp e gresit) si DROP-am input/ctx. Defense in depth pe TOATE rutele, nu doar prezentari. """ cleaned = [{"type": e.get("type"), "loc": e.get("loc"), "msg": e.get("msg")} for e in exc.errors()] return JSONResponse(status_code=422, content={"detail": cleaned}) @app.exception_handler(Exception) async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: """Orice excepție neprinsa -> 500 STRUCTURAT (3 niveluri, PRD 5.4) in loc de 500 brut. Body = envelope-ul standard din catalog (6 chei: field/cod/problema/cauza/fix/message) + `request_id` — fara traceback, fara mesaj de excepție brut, fara creds. Traceback-ul complet + ruta + cont + request_id se scriu DOAR in jurnal (redactate prin scrub_text). Handlerele specifice (LoginRequired/AdminRequired/CSRF/RequestValidationError/HTTPException) raman neatinse — acesta prinde doar ce nu are handler dedicat. """ request_id = getattr(request.state, "request_id", None) or request_id_var.get() try: account_id = request.session.get("account_id") except (AssertionError, KeyError, AttributeError): account_id = None tb = scrub_text("".join(traceback.format_exception(type(exc), exc, exc.__traceback__))) log_event( "eroare_interna", nivel="ERROR", account_id=account_id, cod="EROARE_INTERNA", mesaj=f"{request.method} {request.url.path}: {type(exc).__name__}", context={"path": request.url.path, "method": request.method, "traceback": tb}, request_id=request_id, ) body = errors.eroare("EROARE_INTERNA") body["request_id"] = request_id return JSONResponse(status_code=500, content=body, headers={"X-Request-ID": request_id or ""}) # Assets servite local (htmx vendorizat), NU din CDN: gateway-ul ruleaza # offline (LXC/VPS, Cloudflare Tunnel). Fara asta, dashboard-ul ramane static # (zero polling banner/coada) cand unpkg e blocat/inaccesibil. Aceeasi decizie # offline-first ca fontul UI (fara dependinta CDN). _STATIC_DIR = Path(__file__).resolve().parent / "web" / "static" app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") app.include_router(api_v1_router) app.include_router(import_v1_router) app.include_router(integrare_v1_router) app.include_router(web_router) app.include_router(auth_router) app.include_router(admin_router) @app.get("/healthz") def healthz() -> dict: """Sanatate: worker viu + ultimul login RAR reusit + adancime coada. Pica (200 cu ok=False / sau folosit de orchestrator) cand worker-ul e mort -> semnal de restart (plan.md sect. 8). Intoarce 200 mereu cu detalii; orchestratorul decide pe campul `worker_alive`. """ settings = get_settings() conn = get_connection() try: hb = read_heartbeat(conn) depth = queue_depth(conn) finally: conn.close() worker_alive = False last_beat = hb["last_beat"] if hb else None if last_beat: try: age = (datetime.now(timezone.utc) - datetime.fromisoformat(last_beat)).total_seconds() worker_alive = age <= settings.worker_heartbeat_stale_s except ValueError: worker_alive = False return { "ok": True, "version": __version__, "rar_env": settings.rar_env, "worker_alive": worker_alive, "last_beat": last_beat, "last_rar_login_ok": hb["last_rar_login_ok"] if hb else None, "queue_depth": depth, } @app.get("/metrics", response_class=PlainTextResponse) def metrics() -> str: """Metrici text simplu (submissions pe status + backlog). Format Prometheus-lite.""" conn = get_connection() try: rows = conn.execute("SELECT status, COUNT(*) AS n FROM submissions GROUP BY status").fetchall() finally: conn.close() lines = ["# submissions pe status"] for r in rows: lines.append(f'autopass_submissions{{status="{r["status"]}"}} {r["n"]}') return "\n".join(lines) + "\n"