"""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.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.imports import page_router as import_page_router from app.routers.imports import router as imports_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.web_clipper import api_router as web_clipper_api_router from app.routers.web_clipper import router as web_clipper_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__) @asynccontextmanager async def lifespan(_app: FastAPI): init_db() init_webhook_tables() from app.db import get_conn from app.password_utils import hash_password admin_hash = hash_password("FlowDeck2026!") with get_conn() as conn: conn.execute( "INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)", (admin_hash,) ) conn.commit() # ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ── from app.routers.agent import agent_scheduler scheduler_task = asyncio.create_task(agent_scheduler()) # ── Automations (v5.1.0): cron trigger scheduler ── from app.services.automations import automation_scheduler automation_task = asyncio.create_task(automation_scheduler()) # ── Backups (v5.2.0): automatic daily SQLite snapshot ── from app.services.backup import backup_scheduler backup_task = asyncio.create_task(backup_scheduler()) # ── Forge projects sync (v5.2.0): hourly refresh of `projects` ── from app.services.projects import project_sync_scheduler projects_task = asyncio.create_task(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 = asyncio.create_task(trash_purge_scheduler()) # ── Reminders (v5.8.0): due-reminder scan for database rows ── from app.services.reminders import reminder_scheduler reminder_task = asyncio.create_task(reminder_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 = asyncio.create_task(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) 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="6.6.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) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) app.include_router(auth.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.mount("/static", StaticFiles(directory="static"), name="static") @app.get("/manifest.json") async 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") async 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") async 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") async 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") async 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) async 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)) if status == 404: if request.url.path.startswith("/api/v2"): from app.services.api_v2_helpers import problem_response return problem_response(request, exc) if "/api" in request.url.path: 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 request.url.path.startswith("/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)