- aide : bouton ✕ en haut à droite du panneau (classe partagée settings-close, 44 px mobile) + helpInit.close() (retour en arrière, sinon /workspaces) ; .settings-panel en position:relative pour ancrer hamburger + ✕ au panneau - éditeur « Insert below » (/Write with AI) : insère le markdown source conservé à la génération (AIC._md) au lieu du texte extrait du HTML rendu (_resultText() reste seulement en repli) → titres/listes/gras conservés - listes numérotées : aucun numéro n'était affiché (CSS = retrait seul) ; le rang des blocs numbered_list contigus est calculé au rendu (data-num) et rendu via content: attr(data-num) — /numbered list et l'insertion agent affichent 1, 2, 3 - md2b : cases à cocher - [ ] / - [x] testées avant la branche des puces (aligné sur paste2b) + séparateur 1) accepté (CommonMark) - menu slash : requête tapée dans le bloc (/numbered list) répercutée dans le filtre du menu quand le focus n'a pas rejoint l'entrée - tests : tests/test_v7592_ui_fixes.py (5 tests, dont 1 comportemental node qui exécute le vrai md2b) ; ruff I001 préexistant sur test_agent.py - livraison : VERSION + app/main = 7.59.2, OpenAPI 525 chemins, CHANGELOG
389 lines
15 KiB
Python
389 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 Depends, 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 import plugins as plugins_service
|
|
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.59.2",
|
|
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)
|
|
# Partage public :-exempt de la dépendance d'authentification du router.
|
|
app.include_router(workspace.public_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)
|
|
# Plugin « automations » OFF → chaque route de ce router renvoie 404
|
|
app.include_router(automations_router,
|
|
dependencies=[Depends(plugins_service.plugin_required("automations"))])
|
|
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)
|
|
# Plugin « web-clipper » OFF → page /extensions + API clip refusées
|
|
app.include_router(web_clipper_api_router,
|
|
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
|
|
app.include_router(web_clipper_router,
|
|
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
|
|
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)
|