"""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.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.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.realtime import router as realtime_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()) logger.info("FlowDeck v5.12.0 started on port %d", settings.app_port) try: yield finally: for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task): task.cancel() for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task): try: await task except asyncio.CancelledError: pass app = FastAPI( title="FlowDeck", version="6.0.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, 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.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 = """