276 lines
10 KiB
Python
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)
|