diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 93799a4..cca9a01 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): """ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} - EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace"} + EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error"} async def dispatch(self, request: Request, call_next): # Webhook receiver, OAuth callback, and internal API are exempt diff --git a/app/routers/api.py b/app/routers/api.py index 36e928a..c5a028f 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -623,3 +623,55 @@ async def get_collaborators(owner: str, repo: str): return {"collaborators": collaborators} except Exception as e: return {"collaborators": [], "error": str(e)} + + +# ── Frontend Error Capture ─────────────────────────────────── +# Hermes diagnostique le frontend en appelant GET /api/frontend-errors + +_frontend_errors: list[dict] = [] +_MAX_STORED_ERRORS = 100 + + +@router.post("/frontend-error") +async def capture_frontend_error(request: Request): + """Reçoit les erreurs JS du navigateur. Appelé automatiquement par app.js.""" + try: + body = await request.json() + except Exception: + return {"status": "ignored", "reason": "invalid json"} + + msg = body.get("message", "") + err_type = body.get("type", "error") + source = body.get("source", "") + line = body.get("line", 0) + + # Dédupliquer les erreurs identiques consécutives + if _frontend_errors and _frontend_errors[-1].get("message") == msg: + _frontend_errors[-1]["count"] = _frontend_errors[-1].get("count", 1) + 1 + _frontend_errors[-1]["time"] = body.get("time", "") + else: + body["count"] = 1 + _frontend_errors.append(body) + + while len(_frontend_errors) > _MAX_STORED_ERRORS: + _frontend_errors.pop(0) + + if err_type == "error": + logger.warning("Frontend JS error: %s (%s:%s)", msg, source, line) + else: + logger.warning("Frontend unhandled rejection: %s", msg) + + return {"status": "ok"} + + +@router.get("/frontend-errors") +async def get_frontend_errors(request: Request, clear: bool = True): + """Retourne les erreurs frontend récentes. Hermes appelle cette route.""" + errors = list(_frontend_errors) + if clear: + _frontend_errors.clear() + return { + "errors": errors, + "count": len(errors), + "cleared": clear, + } diff --git a/static/js/app.js b/static/js/app.js index b896bfb..daffb73 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,6 +1,59 @@ // FlowDeck Notion UI — Client-side logic // Alpine.js + SortableJS + HTMX + Mobile support +// ── Frontend Error Capture ────────────────────────────────── +// Intercepte TOUTES les erreurs JS et les envoie au backend. +// Hermes peut les consulter via GET /api/frontend-errors. +(function() { + 'use strict'; + const MAX_ERRORS = 50; + var _fd_errors = []; + var _fd_throttle = 0; + + function _fd_send(error) { + _fd_errors.push(error); + if (_fd_errors.length > MAX_ERRORS) _fd_errors.shift(); + // Throttle: max 1 POST per second + var now = Date.now(); + if (now - _fd_throttle < 1000) return; + _fd_throttle = now; + try { + var xhr = new XMLHttpRequest(); + xhr.open('POST', '/api/frontend-error', true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(JSON.stringify(error)); + } catch(e) { /* fail silently */ } + } + + window.addEventListener('error', function(e) { + _fd_send({ + type: 'error', + message: e.message || 'Unknown error', + source: e.filename || '', + line: e.lineno || 0, + col: e.colno || 0, + stack: (e.error && e.error.stack) ? String(e.error.stack).substring(0, 2000) : '', + url: window.location.href, + userAgent: navigator.userAgent.substring(0, 200), + time: new Date().toISOString() + }); + }); + + window.addEventListener('unhandledrejection', function(e) { + _fd_send({ + type: 'unhandledrejection', + message: e.reason ? (e.reason.message || String(e.reason)) : 'Promise rejected', + stack: (e.reason && e.reason.stack) ? String(e.reason.stack).substring(0, 2000) : '', + url: window.location.href, + userAgent: navigator.userAgent.substring(0, 200), + time: new Date().toISOString() + }); + }); + + // Expose pour le debug console + window.__flowdeck_errors = _fd_errors; +})(); + (function() { 'use strict';