Files
flowdeck/app/main.py
T
bruno 5951c707eb
FlowDeck CI / lint (push) Successful in 1m27s
FlowDeck CI / test (push) Successful in 10m51s
FlowDeck CI / docker (push) Successful in 1m21s
feat: v6.5.0 Synced blocks production — pages contenu par lignes de DB, résolution serveur à chaque lecture, propagation écrite réelle
2026-09-24 08:28:25 -04:00

276 lines
10 KiB
Python

"""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.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.5.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.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 = """<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>404 — FlowDeck</title>
<style>
:root{--bg:#191919;--text:#e0e0e0;--text-dim:#999;--accent:#2383E2}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center}
.box h1{font-size:6rem;font-weight:800;opacity:.08;line-height:1}
.box p{font-size:18px;color:var(--text-dim);margin:8px 0 24px}
.box a{color:var(--accent);text-decoration:none;font-size:14px}
.box a:hover{text-decoration:underline}
</style>
</head>
<body>
<div class="box">
<h1>404</h1>
<p>This page doesn't exist or has been moved.</p>
<a href="/">← Back to FlowDeck</a>
</div>
</body>
</html>"""
@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)