"""FlowDeck — Kanban léger intégré à Gitea.""" from __future__ import annotations import asyncio import logging from contextlib import asynccontextmanager from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from starlette.exceptions import HTTPException as _StarHTTPException from starlette.middleware.sessions import SessionMiddleware from app.config import settings from app.db import init_db from app.middleware.csrf import CSRFMiddleware from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware from app.routers import ( admin, agent, api, auth, board, collections, dashboard, export, library, my_tasks, notes, onboarding, projects, public_api, search, security, sharing, sidebar_config, sync, webhooks, workspace, ) from app.routers.api_v2 import router as api_v2_router from app.routers.api_v2_agent import router as api_v2_agent_router from app.routers.audit import router as audit_router from app.routers.automations import router as automations_router from app.routers.collaboration import router as collaboration_router from app.routers.emoji import router as emoji_router from app.routers.gitea import router as gitea_router from app.routers.github_routes import router as github_router from app.routers.governance import router as governance_router from app.routers.imports import page_router as import_page_router from app.routers.imports import router as imports_router from app.routers.meetings import router as meetings_router from app.routers.notifications import router as notifications_router from app.routers.permissions import router as permissions_router from app.routers.realtime import router as realtime_router from app.routers.scim import router as scim_router from app.routers.search_ai import router as search_ai_router from app.routers.sites import router as sites_router from app.routers.sso import router as sso_router from app.routers.web_clipper import api_router as web_clipper_api_router from app.routers.web_clipper import router as web_clipper_router from app.routers.webauthn import router as webauthn_router from app.routers.wiki import router as wiki_router from app.routers.workers import router as workers_router from app.services.webhook_outbound import init_webhook_tables logging.basicConfig( level=getattr(logging, settings.log_level.upper(), logging.INFO), format="%(asctime)s [%(levelname)s] %(message)s", ) logger = logging.getLogger(__name__) def _spawn(name: str, factory): """A34 : une tâche scheduler meurt en silence (aucun done_callback). Loggue l'exception puis recrée la coroutine 10 s plus tard. ponytail: pas de backoff exponentiel — un scheduler qui replante à chaque tick reste visible (1 cycle / 10 s) dans les logs ; ajouter un backoff si le bruit devient un problème. """ async def _guard(): while True: try: await factory() except asyncio.CancelledError: raise except Exception: logger.exception("scheduler %s plante - redemarrage dans 10 s", name) await asyncio.sleep(10) else: logger.warning("scheduler %s termine - redemarrage dans 10 s", name) await asyncio.sleep(10) return asyncio.create_task(_guard()) @asynccontextmanager async def lifespan(_app: FastAPI): init_db() init_webhook_tables() import os import secrets from app.db import get_conn from app.password_utils import hash_password # A26 : secret de session par défaut refusé (il signe `flowdeck_session`). if settings.app_secret_key == "change-me-to-random": raise RuntimeError( "APP_SECRET_KEY non défini — générer une valeur : " 'python -c "import secrets;print(secrets.token_hex(32))" puis la mettre dans .env' ) # A8 : plus de mot de passe admin codé en dur — mot de passe aléatoire au # premier boot (affiché une fois) ou FLOWDECK_ADMIN_PASSWORD ; re-seed si absent. with get_conn() as conn: if not conn.execute("SELECT 1 FROM users WHERE login='admin'").fetchone(): admin_pw = os.environ.get("FLOWDECK_ADMIN_PASSWORD") or secrets.token_urlsafe(12) conn.execute( "INSERT INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)", (hash_password(admin_pw),), ) conn.commit() logger.warning( "Premier démarrage : compte admin créé, mot de passe = %s " "(définir FLOWDECK_ADMIN_PASSWORD pour le fixer)", admin_pw, ) # ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ── from app.routers.agent import agent_scheduler scheduler_task = _spawn("agent_scheduler", agent_scheduler) # ── Automations (v5.1.0): cron trigger scheduler ── from app.services.automations import automation_scheduler automation_task = _spawn("automation_scheduler", automation_scheduler) # ── Backups (v5.2.0): automatic daily SQLite snapshot ── from app.services.backup import backup_scheduler backup_task = _spawn("backup_scheduler", backup_scheduler) # ── Forge projects sync (v5.2.0): hourly refresh of `projects` ── from app.services.projects import project_sync_scheduler projects_task = _spawn("project_sync_scheduler", project_sync_scheduler) # ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ── from app.services.trash import trash_purge_scheduler trash_task = _spawn("trash_purge_scheduler", trash_purge_scheduler) # ── Reminders (v5.8.0): due-reminder scan for database rows ── from app.services.reminders import reminder_scheduler reminder_task = _spawn("reminder_scheduler", reminder_scheduler) # ── Semantic search (v6.9.0): incremental vector indexing ── from app.services.semantic_search import semantic_index_scheduler semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler) # ── Calendar sync (v7.1.0): external calendars every 15 min ── from app.services.calendar_sync import calendar_sync_scheduler calendar_task = _spawn("calendar_sync_scheduler", calendar_sync_scheduler) # ── Webhooks outbound (v6.4.0): retry failed deliveries ── from app.services.webhook_outbound import webhook_retry_scheduler webhook_task = None if settings.webhook_retry_enabled: webhook_task = _spawn("webhook_retry_scheduler", webhook_retry_scheduler) logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port) try: yield finally: _tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, semantic_task, calendar_task) if webhook_task is not None: _tasks = _tasks + (webhook_task,) for task in _tasks: task.cancel() for task in _tasks: try: await task except asyncio.CancelledError: pass app = FastAPI( title="FlowDeck", version="7.36.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, ) app.add_middleware(SessionMiddleware, secret_key=settings.app_secret_key, max_age=3600) app.add_middleware(CSRFMiddleware) app.add_middleware(ContentSecurityPolicyMiddleware) app.add_middleware(RateLimitMiddleware) # A37 : origines explicites (l'auth est un cookie de session ; le front est # servi par le même hôte). `*` + credentials est la combinaison interdite par la # spec CORS — ici ni les deux ni l'un : liste fermée, méthodes/entêtes minutées. _CORS_ORIGINS = sorted( {o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))} ) # Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement — # pas de cookie → `allow_credentials` ne s'applique pas à ces origines). app.add_middleware( CORSMiddleware, allow_origins=_CORS_ORIGINS, allow_origin_regex=r"https?://(localhost|127\.0\.0\.1)(:\d+)?|\w+-extension://.*", allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"], allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "Idempotency-Key"], allow_credentials=True, ) app.include_router(auth.router) app.include_router(sso_router) app.include_router(dashboard.router) app.include_router(board.router) app.include_router(notes.router) app.include_router(projects.router) app.include_router(projects.backups_router) app.include_router(api.router) app.include_router(webhooks.router) app.include_router(collections.router) app.include_router(my_tasks.router) app.include_router(workspace.router) app.include_router(library.router) app.include_router(admin.router) app.include_router(gitea_router) app.include_router(github_router) app.include_router(public_api.router) app.include_router(sharing.router) app.include_router(sidebar_config.router) app.include_router(export.router) app.include_router(notifications_router) app.include_router(automations_router) app.include_router(collaboration_router) app.include_router(emoji_router) app.include_router(realtime_router) app.include_router(agent.router) app.include_router(search.router) app.include_router(security.router) app.include_router(onboarding.router) app.include_router(sync.router) app.include_router(imports_router) app.include_router(import_page_router) app.include_router(permissions_router) app.include_router(web_clipper_api_router) app.include_router(web_clipper_router) app.include_router(api_v2_router) app.include_router(api_v2_agent_router) app.include_router(sites_router) app.include_router(search_ai_router) app.include_router(workers_router) app.include_router(meetings_router) # v7.2.0 — enterprise admin app.include_router(scim_router) app.include_router(webauthn_router) app.include_router(audit_router) app.include_router(governance_router) # v7.3.0 — teamspaces + verified wiki app.include_router(wiki_router) app.mount("/static", StaticFiles(directory="static"), name="static") @app.get("/manifest.json") def pwa_manifest(): """Serve the static web manifest from disk (same URL as before v6.0.0).""" from fastapi.responses import FileResponse return FileResponse("static/manifest.json", media_type="application/manifest+json") @app.get("/sw.js") def service_worker(): """Serve the PWA service worker at top-level scope (/).""" from fastapi.responses import FileResponse return FileResponse("static/sw.js", media_type="application/javascript") # ═══════════ API aliases (v4.0.1) ═══════════ @app.get("/api/csrf-token") def csrf_token_endpoint(request: Request): """Return a fresh CSRF token. Used by the frontend to auto-recover from 403.""" import secrets from fastapi.responses import JSONResponse token = secrets.token_hex(32) response = JSONResponse({"csrf_token": token}) response.set_cookie( "csrf_token", token, httponly=False, samesite="lax", max_age=86400, path="/", ) return response @app.get("/api/pages") def api_pages_alias(request: Request): """Alias /api/pages → /board/api/pages for API path consistency.""" from fastapi.responses import RedirectResponse qs = str(request.url.query) target = f"/board/api/pages{'?' + qs if qs else ''}" return RedirectResponse(url=target, status_code=307) @app.post("/api/pages") def api_pages_post_alias(request: Request): """Alias POST /api/pages → /board/api/pages for API path consistency.""" from fastapi.responses import RedirectResponse return RedirectResponse(url="/board/api/pages", status_code=307) # ═══════════ Styled 404 handler ═══════════ NOT_FOUND_HTML = """ 404 — FlowDeck

404

This page doesn't exist or has been moved.

← Back to FlowDeck
""" @app.exception_handler(_StarHTTPException) def http_exception_handler(request: Request, exc: _StarHTTPException): """Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML. Registered on Starlette's HTTPException (the base class) so it catches both raised exceptions and route-miss 404s. """ status = getattr(exc, "status_code", 500) detail = getattr(exc, "detail", str(exc)) is_api_v2 = request.url.path.startswith("/api/v2") # Programmatic API prefixes that must always answer JSON errors instead of # being redirected to the HTML shell (SCIM 2.0 clients, WebAuthn fetch). JSON_ERROR_PREFIXES = ("/api/v2", "/scim/v2", "/auth/webauthn") is_json_api = request.url.path.startswith(JSON_ERROR_PREFIXES) if status == 404: if is_api_v2: from app.services.api_v2_helpers import problem_response return problem_response(request, exc) if is_json_api and request.url.path.startswith("/scim/v2"): from fastapi.responses import JSONResponse return JSONResponse( {"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "detail": detail if isinstance(detail, str) else "Not found", "status": "404"}, status_code=404, headers={"Content-Type": "application/scim+json"}, ) if "/api" in request.url.path or is_json_api: from fastapi.responses import JSONResponse return JSONResponse({"detail": detail if isinstance(detail, str) else "Not found"}, status_code=404) from fastapi.responses import RedirectResponse return RedirectResponse("/workspaces", status_code=302) # Non-404: RFC7807 for /api/v2 if is_api_v2: from app.services.api_v2_helpers import problem_response return problem_response(request, exc) from fastapi.responses import JSONResponse return JSONResponse({"detail": detail if isinstance(detail, str) else str(detail)}, status_code=status)