- Symptôme rapporté : clic sur Home → rafale de menus/fenêtres + bande rouge « You are offline. Changes will sync when connection is restored. ». - Constat : le masquage de v7.45.3 ne couvrait QUE les swaps partiels fdLoad. Un chargement COMPLET de document (F5, première visite, navigation servie par le service worker) peignait toute l'app tant qu'Alpine n'avait pas initialisé .app-layout : sidebar brute (sections ouvertes, menu utilisateur) + zone brute dont la bande hors ligne (aucun x-cloak dessus). Le symptôme Home n'est par ailleurs PAS rejouable en navigateur frais (nav, clic Home en ligne/hors ligne, plein chargement → 0 frame de contenu brut, 0 erreur console). - Fix : x-cloak sur le nœud racine Alpine .app-layout (base.html) — sidebar + zone + bandeau masqués jusqu'au montage d'appState(), retrait par Alpine à l'init. Le probe asserte que l'attribut est bien retiré et que le node est visible en fin de parcours (garde-fou contre la page blanche). - Suppression de la classe fd-navigating (posée à chaque fdLoad, aucune règle CSS dans le dépôt → code mort). - e2e/probe_nav_perf.spec.js : 4 scénarios avec assertions (nav 5 pages, clic Home réel, Home hors ligne avec SW, plein chargement 3 pages) + capture screenshot auto si contenu brut + collecte console. Gates : probe_nav_perf 4/4 · regression_partial_nav + regression_editor_mount + regression_logout_dnd + smoke = 6 passed (errs=0 sur 7 pages × 3 passages) · pytest 1094 passed · ruff OK · OpenAPI 7.45.4.
381 lines
15 KiB
Python
381 lines
15 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.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.45.4",
|
|
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 = """<!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)
|
|
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)
|