From 9553115d8c198c8396fb3316c02584a8f150e787 Mon Sep 17 00:00:00 2001 From: billisdead Date: Tue, 23 Jun 2026 16:26:52 +0200 Subject: [PATCH] Fix 10 bugs found in general code review Backend (app.py / database.py / ai_agent.py): - [Critique] Autoclose loop: add pg_try_advisory_lock so only one Gunicorn worker runs the check per 60s cycle; add random startup jitter - [Critique] admin_delete_idea: pass consultation_id to _update_synthesis_background so the right synthesis is regenerated - [Majeur] admin_login: return HMAC-signed session token instead of raw ADMIN_SECRET; require_admin verifies the signature (TTL 8h) - [Majeur] bulk_delete: replace str.isdigit() (Unicode-unsafe) with try/except int() to prevent crash on Unicode digit characters - [Majeur] create_consultation: force UTC timezone on naive datetime from fromisoformat() to prevent TypeError when comparing with UTC-aware now() - [Majeur] ai_agent.py: fix 'raw' in dir() -> 'raw' in locals() so the JSON parse error log actually shows the raw response - [Mineur] export print: use datetime.now(UTC) instead of datetime.now() Frontend (React): - [Majeur] consultation.tsx: show startsAt (not endsAt) for upcoming consultations; add startsAt variable - [Majeur] consultations-list.tsx: same fix for the list view - [Mineur] home.tsx: guard new Date(idea.createdAt) against null - [Mineur] admin.tsx: check HTTP status in exportCsv XHR before creating download link; show error toast on non-200 Co-Authored-By: Claude Sonnet 4.6 --- artifacts/flask-api/ai_agent.py | 2 +- artifacts/flask-api/app.py | 88 ++++++++++++++++--- artifacts/flask-api/database.py | 8 +- artifacts/voix-du-peuple/src/pages/admin.tsx | 4 + .../voix-du-peuple/src/pages/consultation.tsx | 5 +- .../src/pages/consultations-list.tsx | 2 +- artifacts/voix-du-peuple/src/pages/home.tsx | 2 +- 7 files changed, 92 insertions(+), 19 deletions(-) diff --git a/artifacts/flask-api/ai_agent.py b/artifacts/flask-api/ai_agent.py index c1efa17..35abeba 100644 --- a/artifacts/flask-api/ai_agent.py +++ b/artifacts/flask-api/ai_agent.py @@ -84,7 +84,7 @@ def filter_idea(content: str) -> dict: result = json.loads(raw) return result except json.JSONDecodeError: - logger.warning("Impossible de parser la réponse JSON du filtre, raw=%r", raw if 'raw' in dir() else 'N/A') + logger.warning("Impossible de parser la réponse JSON du filtre, raw=%r", raw if 'raw' in locals() else 'N/A') return {"accepted": False, "reason": "Erreur interne de filtrage"} except BadRequestError as e: if "content_filter" in str(e) or "content management policy" in str(e): diff --git a/artifacts/flask-api/app.py b/artifacts/flask-api/app.py index 3841894..6715cb9 100644 --- a/artifacts/flask-api/app.py +++ b/artifacts/flask-api/app.py @@ -43,7 +43,7 @@ from flask_limiter import Limiter from flask_limiter.util import get_remote_address from database import ( - init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis, + init_db, get_connection, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis, get_synthesis, get_all_ideas, get_ideas_admin, delete_idea, bulk_delete_ideas, override_idea, flag_idea, unflag_idea, create_consent, get_public_contributions, get_public_stats, @@ -102,10 +102,16 @@ init_db() # ─── Boucle de fermeture automatique des consultations ─────────────────────── -def _autoclose_consultations_loop() -> None: - """Vérifie toutes les 60 s les consultations expirées et les ferme automatiquement.""" - while True: - time.sleep(60) +def _try_autoclose() -> None: + """Tente d'acquérir un verrou consultatif PostgreSQL pour éviter que plusieurs workers + Gunicorn exécutent le check simultanément. Le verrou est relâché à la fin de l'appel.""" + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT pg_try_advisory_lock(743209897)") + conn.commit() + if not cur.fetchone()[0]: + return # Un autre worker gère déjà ce cycle try: expired = get_consultations_to_autoclose() for c in expired: @@ -120,8 +126,26 @@ def _autoclose_consultations_loop() -> None: args=(closed, synthesis), daemon=True, ).start() + finally: + with conn.cursor() as cur: + cur.execute("SELECT pg_advisory_unlock(743209897)") + conn.commit() + except Exception: + logger.exception("Erreur dans la boucle de fermeture automatique des consultations") + finally: + try: + conn.close() except Exception: - logger.exception("Erreur dans la boucle de fermeture automatique des consultations") + pass + + +def _autoclose_consultations_loop() -> None: + """Vérifie toutes les 60 s les consultations expirées et les ferme automatiquement.""" + import random as _random + time.sleep(_random.uniform(0, 15)) # Décale le démarrage de chaque worker + while True: + time.sleep(60) + _try_autoclose() threading.Thread(target=_autoclose_consultations_loop, daemon=True).start() @@ -196,7 +220,7 @@ def require_admin(f): if not secret: return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503 auth = request.headers.get("Authorization", "") - if not auth.startswith("Bearer ") or auth[7:] != secret: + if not auth.startswith("Bearer ") or not _verify_admin_token(auth[7:], secret): return jsonify({"error": "unauthorized", "message": "Accès non autorisé."}), 401 return f(*args, **kwargs) return decorated @@ -251,6 +275,28 @@ def _verify_consent_token(token: str, secret: str) -> bool: return False +def _sign_admin_token(secret: str) -> str: + """Émet un token de session admin signé HMAC-SHA256 (TTL 8h).""" + ts = int(time.time()) + msg = f"admin:{ts}".encode() + sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16] + return f"{ts}.{sig}" + + +def _verify_admin_token(token: str, secret: str, max_age: int = 28800) -> bool: + """Vérifie la signature et la durée de validité (défaut 8h) d'un token admin.""" + try: + ts_str, sig = token.rsplit(".", 1) + ts = int(ts_str) + msg = f"admin:{ts}".encode() + expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16] + if not hmac.compare_digest(sig, expected): + return False + return (time.time() - ts) < max_age + except Exception: + return False + + def _verify_hcaptcha(token: str) -> bool: """ Vérifie un token hCaptcha. @@ -817,7 +863,7 @@ def export_consultation_print(slug: str): subject_esc = html_module.escape(consultation["subject"]) organizer_esc = html_module.escape(consultation.get("organizer_name") or "") synthesis_esc = html_module.escape(synthesis_text).replace("\n", "
") - now_str = dt.datetime.now().strftime("%d/%m/%Y %H:%M") + now_str = dt.datetime.now(dt.timezone.utc).strftime("%d/%m/%Y %H:%M") html_content = f""" @@ -874,7 +920,7 @@ def admin_login(): logger.warning("Tentative de connexion admin échouée") return jsonify({"error": "unauthorized", "message": "Mot de passe incorrect."}), 401 logger.info("Connexion admin réussie") - return jsonify({"ok": True, "token": secret}) + return jsonify({"ok": True, "token": _sign_admin_token(secret)}) @app.get("/api/admin/stats") @@ -915,7 +961,12 @@ def admin_delete_idea(idea_id: int): deleted = delete_idea(idea_id) if not deleted: return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404 - threading.Thread(target=_update_synthesis_background, daemon=True).start() + consultation_id = deleted.get("consultation_id") + threading.Thread( + target=_update_synthesis_background, + kwargs={"consultation_id": consultation_id}, + daemon=True, + ).start() logger.info("Admin — contribution #%d supprimée", idea_id) return jsonify({"ok": True, "synthesisUpdating": True}) @@ -928,7 +979,18 @@ def admin_bulk_delete(): ids = data.get("ids", []) if not isinstance(ids, list) or not ids: return jsonify({"error": "validation_error", "message": "ids doit être une liste non vide."}), 400 - ids = [int(i) for i in ids if isinstance(i, (int, str)) and str(i).isdigit()] + safe_ids = [] + for i in ids: + if isinstance(i, int) and i > 0: + safe_ids.append(i) + elif isinstance(i, str): + try: + n = int(i) + if n > 0: + safe_ids.append(n) + except ValueError: + pass + ids = safe_ids count = bulk_delete_ideas(ids) if count > 0: threading.Thread(target=_update_synthesis_background, daemon=True).start() @@ -1048,8 +1110,12 @@ def admin_create_consultation(): try: starts_at_str = data.get("startsAt", "") starts_at = dt.datetime.fromisoformat(starts_at_str) if starts_at_str else dt.datetime.now(dt.timezone.utc) + if starts_at.tzinfo is None: + starts_at = starts_at.replace(tzinfo=dt.timezone.utc) ends_at_str = data.get("endsAt", "") ends_at = dt.datetime.fromisoformat(ends_at_str) if ends_at_str else None + if ends_at is not None and ends_at.tzinfo is None: + ends_at = ends_at.replace(tzinfo=dt.timezone.utc) except (ValueError, TypeError): return jsonify({"error": "validation_error", "message": "Format de date invalide (ISO 8601 requis)."}), 400 diff --git a/artifacts/flask-api/database.py b/artifacts/flask-api/database.py index cf61490..1a4c548 100644 --- a/artifacts/flask-api/database.py +++ b/artifacts/flask-api/database.py @@ -193,10 +193,12 @@ def get_ideas_admin( return rows, total -def delete_idea(idea_id: int) -> bool: +def delete_idea(idea_id: int) -> dict | None: + """Supprime une idée et retourne son enregistrement (avec consultation_id) ou None si absente.""" with db_cursor() as cur: - cur.execute("DELETE FROM ideas WHERE id = %s RETURNING id", (idea_id,)) - return cur.fetchone() is not None + cur.execute("DELETE FROM ideas WHERE id = %s RETURNING id, consultation_id", (idea_id,)) + row = cur.fetchone() + return dict(row) if row else None def bulk_delete_ideas(idea_ids: list[int]) -> int: diff --git a/artifacts/voix-du-peuple/src/pages/admin.tsx b/artifacts/voix-du-peuple/src/pages/admin.tsx index dab9224..c84188d 100644 --- a/artifacts/voix-du-peuple/src/pages/admin.tsx +++ b/artifacts/voix-du-peuple/src/pages/admin.tsx @@ -658,6 +658,10 @@ export default function Admin() { req.setRequestHeader("Authorization", `Bearer ${token}`); req.responseType = "blob"; req.onload = () => { + if (req.status !== 200) { + toast({ title: "Erreur export", description: `Erreur ${req.status} lors de l'export CSV.`, variant: "destructive" }); + return; + } const a = document.createElement("a"); a.href = URL.createObjectURL(req.response); a.setAttribute("download", "contributions.csv"); diff --git a/artifacts/voix-du-peuple/src/pages/consultation.tsx b/artifacts/voix-du-peuple/src/pages/consultation.tsx index 594cf26..ba52147 100644 --- a/artifacts/voix-du-peuple/src/pages/consultation.tsx +++ b/artifacts/voix-du-peuple/src/pages/consultation.tsx @@ -235,6 +235,7 @@ export default function ConsultationPage() { const isOpen = consultation.isOpen; const isClosed = !!consultation.closedAt; const endsAt = consultation.endsAt ? new Date(consultation.endsAt) : null; + const startsAt = consultation.startsAt ? new Date(consultation.startsAt) : null; const closedAt = consultation.closedAt ? new Date(consultation.closedAt) : null; return ( @@ -299,8 +300,8 @@ export default function ConsultationPage() { {isOpen - ? `Fermeture ${formatDistanceToNow(endsAt, { locale: fr, addSuffix: true })}` - : `Ouverture ${formatDistanceToNow(endsAt, { locale: fr, addSuffix: true })}`} + ? `Fermeture ${formatDistanceToNow(endsAt!, { locale: fr, addSuffix: true })}` + : `Ouverture ${formatDistanceToNow(startsAt ?? endsAt!, { locale: fr, addSuffix: true })}`} {" · "} {format(endsAt, "d MMMM yyyy à HH:mm", { locale: fr })} UTC diff --git a/artifacts/voix-du-peuple/src/pages/consultations-list.tsx b/artifacts/voix-du-peuple/src/pages/consultations-list.tsx index b05ac78..405ae23 100644 --- a/artifacts/voix-du-peuple/src/pages/consultations-list.tsx +++ b/artifacts/voix-du-peuple/src/pages/consultations-list.tsx @@ -115,7 +115,7 @@ export default function ConsultationsList() { {c.isOpen ? `Ferme ${formatDistanceToNow(new Date(c.endsAt), { locale: fr, addSuffix: true })}` - : `Ouvre ${formatDistanceToNow(new Date(c.endsAt), { locale: fr, addSuffix: true })}`} + : `Ouvre ${formatDistanceToNow(new Date(c.startsAt ?? c.endsAt), { locale: fr, addSuffix: true })}`} )} {c.closedAt && ( diff --git a/artifacts/voix-du-peuple/src/pages/home.tsx b/artifacts/voix-du-peuple/src/pages/home.tsx index 13fbc0a..5c707ec 100644 --- a/artifacts/voix-du-peuple/src/pages/home.tsx +++ b/artifacts/voix-du-peuple/src/pages/home.tsx @@ -484,7 +484,7 @@ export default function Home() { - {format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr })} + {idea.createdAt ? format(new Date(idea.createdAt), "d MMM, HH:mm", { locale: fr }) : "—"}