Findings from the 2026-06-24 security review, now mandatory before this platform leaves the LAN for a public VPS (see budget/externalization note): - Timing attack on admin password: hmac.compare_digest instead of != - CORS wildcard removed, restricted to ALLOWED_ORIGINS (needed for credentials) - hCaptcha now fails closed on network error instead of open - HMAC signatures use the full SHA-256 digest instead of a 64-bit truncation - Consultation-scoped submissions were missing the consent/cooldown checks the global submission route already had - Admin search capped at 200 chars (ILIKE cost) - Webhook URLs validated against SSRF (private/loopback/link-local/metadata IPs rejected, re-checked at trigger time too, redirects not followed) - Flood tracker backed by Redis when REDIS_URL is set (shared across Gunicorn workers, survives restarts), falls back to in-memory otherwise - Admin session moved from sessionStorage/Bearer token to an httpOnly+Secure+ SameSite=Strict cookie; new /api/admin/logout and /api/admin/session routes Also found while wiring ProxyFix for the cookie's Secure flag: the app never told Flask about the Nginx reverse proxy, so request.remote_addr saw Nginx's own IP for every request in production — silently breaking rate limiting, flood detection, and IP blacklist (everyone would collapse onto one shared key). Added ProxyFix (1 hop). CSV exports now stream instead of loading everything into memory. AI prompts delimit citizen-submitted content explicitly and instruct the model to treat it as inert data, never instructions, to reduce prompt injection surface. Docs (DEX.md, SECURITE_ANTI_ABUS.md, README.md, DEPLOIEMENT.md) and dev-local.sh updated to match.
1534 lines
60 KiB
Python
1534 lines
60 KiB
Python
"""
|
||
La Voix du Peuple — Backend Flask
|
||
==================================
|
||
Copyright (C) 2026 billisdead
|
||
Licence : European Union Public Licence v. 1.2 (EUPL-1.2)
|
||
|
||
Plateforme démocratique citoyenne.
|
||
Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000),
|
||
Code pénal français, Loi du 29 juillet 1881, LCEN, SREN 2024.
|
||
|
||
Sécurité :
|
||
- Rate limiting IP + fingerprint (flask-limiter, Redis si REDIS_URL défini)
|
||
- Honeypot anti-bot (champ caché + vérification serveur)
|
||
- Fingerprinting non-PII (FingerprintJS hash SHA-256, sans cookie tiers)
|
||
- Détection de flood (> FLOOD_THRESHOLD soumissions / 5 min / même IP)
|
||
- Cooldown session (cookie httpOnly signé HMAC-SHA256, si SECRET_KEY défini)
|
||
- hCaptcha stub (activer via HCAPTCHA_SECRET_KEY + VITE_HCAPTCHA_SITE_KEY) — fail-closed
|
||
si le service est injoignable
|
||
- Validation et assainissement des entrées (bleach)
|
||
- CORS restreint à ALLOWED_ORIGINS (jamais de wildcard, requis pour les cookies)
|
||
- En-têtes de sécurité HTTP
|
||
- Panel admin protégé par ADMIN_SECRET, session via cookie httpOnly signé (jamais
|
||
exposée à JS ni stockée côté client)
|
||
- ProxyFix (1 saut) pour voir la vraie IP/le vrai schéma derrière Nginx
|
||
"""
|
||
|
||
import csv
|
||
import datetime as dt
|
||
import hashlib
|
||
import hmac
|
||
import html as html_module
|
||
import io
|
||
import ipaddress
|
||
import json
|
||
import logging
|
||
import os
|
||
import socket
|
||
import threading
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
import uuid
|
||
from functools import wraps
|
||
|
||
import bleach
|
||
from flask import Flask, jsonify, make_response, request, Response
|
||
from flask_cors import CORS
|
||
from flask_limiter import Limiter
|
||
from flask_limiter.util import get_remote_address
|
||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||
|
||
from database import (
|
||
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,
|
||
create_consultation, get_consultation_by_slug, list_consultations,
|
||
close_consultation, get_consultations_to_autoclose, get_consultation_stats,
|
||
get_consultation_contributions, delete_consultation,
|
||
check_ip_blacklist, record_ip_rejection, get_ip_blacklist, remove_ip_blacklist,
|
||
)
|
||
from ai_agent import filter_idea, synthesize_ideas, check_existing_law
|
||
|
||
# ─── Logging ────────────────────────────────────────────────────────────────
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s %(name)s — %(message)s",
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ─── Constantes anti-abus ────────────────────────────────────────────────────
|
||
|
||
RATE_LIMIT_CONTRIBUTIONS = os.environ.get(
|
||
"RATE_LIMIT_CONTRIBUTIONS", "5 per minute;3 per hour"
|
||
)
|
||
CONTRIBUTION_COOLDOWN_SECONDS = int(
|
||
os.environ.get("CONTRIBUTION_COOLDOWN_SECONDS", "240")
|
||
)
|
||
CONSENT_TOKEN_TTL = 86400 # 24h — durée de validité du cookie de consentement
|
||
FLOOD_THRESHOLD = int(os.environ.get("FLOOD_THRESHOLD", "10"))
|
||
FLOOD_WINDOW_SECONDS = 300
|
||
IP_BLACKLIST_DAYS = int(os.environ.get("IP_BLACKLIST_DAYS", "30"))
|
||
|
||
_flood_tracker: dict[str, list[float]] = {}
|
||
_flood_lock = threading.Lock()
|
||
|
||
# ─── Application ────────────────────────────────────────────────────────────
|
||
|
||
app = Flask(__name__)
|
||
app.config["JSON_SORT_KEYS"] = False
|
||
|
||
# Un seul saut de proxy en prod (Nginx → Gunicorn, voir deploy/nginx.conf) : sans ça,
|
||
# request.remote_addr / request.is_secure voient l'IP/le schéma de Nginx pour TOUTE
|
||
# requête, pas celui du vrai visiteur — casse silencieusement le rate limiting, la
|
||
# détection de flood et la blacklist IP (tout le monde retombe sur la même clé).
|
||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1)
|
||
|
||
|
||
def _get_allowed_origins() -> list[str]:
|
||
"""Origines autorisées en CORS — pas de wildcard, nécessaire pour les cookies
|
||
(supports_credentials=True n'est jamais compatible avec origins="*")."""
|
||
raw = os.environ.get("ALLOWED_ORIGINS", "") or os.environ.get("VITE_APP_URL", "")
|
||
return [o.strip() for o in raw.split(",") if o.strip()]
|
||
|
||
|
||
CORS(
|
||
app,
|
||
resources={r"/api/*": {"origins": _get_allowed_origins()}},
|
||
supports_credentials=True,
|
||
)
|
||
|
||
_redis_url = os.environ.get("REDIS_URL", "")
|
||
_storage_uri = _redis_url if _redis_url else "memory://"
|
||
|
||
_redis_client = None
|
||
if _redis_url:
|
||
try:
|
||
import redis as _redis_lib
|
||
_redis_client = _redis_lib.from_url(_redis_url)
|
||
_redis_client.ping()
|
||
except Exception:
|
||
logger.warning("REDIS_URL configuré mais injoignable — flood tracker replié sur la mémoire du process")
|
||
_redis_client = None
|
||
|
||
limiter = Limiter(
|
||
get_remote_address,
|
||
app=app,
|
||
default_limits=["200 per day", "60 per hour"],
|
||
storage_uri=_storage_uri,
|
||
strategy="fixed-window",
|
||
)
|
||
|
||
# ─── Initialisation de la base de données ────────────────────────────────────
|
||
# Appelé au niveau module pour que Gunicorn initialise la BDD à l'import,
|
||
# pas seulement quand le fichier est exécuté directement.
|
||
|
||
init_db()
|
||
|
||
# ─── Boucle de fermeture automatique des consultations ───────────────────────
|
||
|
||
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:
|
||
closed = close_consultation(c["id"])
|
||
if closed:
|
||
logger.info(
|
||
"Consultation '%s' fermée automatiquement (échéance dépassée)", c["slug"]
|
||
)
|
||
synthesis = get_synthesis(consultation_id=c["id"])
|
||
threading.Thread(
|
||
target=_trigger_webhook,
|
||
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:
|
||
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()
|
||
|
||
# ─── En-têtes de sécurité HTTP ───────────────────────────────────────────────
|
||
|
||
@app.after_request
|
||
def set_security_headers(response: Response) -> Response:
|
||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||
response.headers["X-Frame-Options"] = "DENY"
|
||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||
# Les routes /export/print contiennent un <script>window.print()</script> légitime
|
||
if request.path.endswith("/export/print"):
|
||
csp = "default-src 'self'; script-src 'unsafe-inline'; object-src 'none';"
|
||
else:
|
||
csp = "default-src 'self'; script-src 'none'; object-src 'none';"
|
||
response.headers["Content-Security-Policy"] = csp
|
||
response.headers["Cache-Control"] = "no-store"
|
||
response.headers["Pragma"] = "no-cache"
|
||
return response
|
||
|
||
# ─── Validation des entrées ──────────────────────────────────────────────────
|
||
|
||
CONTENT_MIN = 10
|
||
CONTENT_MAX = 1000
|
||
AUTHOR_MAX = 100
|
||
|
||
|
||
def sanitize_text(text: str) -> str:
|
||
"""Supprime tout HTML/JavaScript — protection XSS."""
|
||
return bleach.clean(text, tags=[], strip=True).strip()
|
||
|
||
|
||
def validate_idea_input(data: dict) -> tuple[dict | None, str | None]:
|
||
"""Valide et assainit les données de soumission d'une idée."""
|
||
content = data.get("content")
|
||
author = data.get("author")
|
||
|
||
if not content or not isinstance(content, str):
|
||
return None, "Le champ 'content' est requis et doit être une chaîne de caractères."
|
||
|
||
content = sanitize_text(content)
|
||
|
||
if len(content) < CONTENT_MIN:
|
||
return None, f"L'idée doit contenir au moins {CONTENT_MIN} caractères."
|
||
|
||
if len(content) > CONTENT_MAX:
|
||
return None, f"L'idée ne peut pas dépasser {CONTENT_MAX} caractères."
|
||
|
||
if author is not None:
|
||
if not isinstance(author, str):
|
||
return None, "Le champ 'author' doit être une chaîne de caractères."
|
||
author = sanitize_text(author)
|
||
if len(author) > AUTHOR_MAX:
|
||
return None, f"Le pseudonyme ne peut pas dépasser {AUTHOR_MAX} caractères."
|
||
if not author:
|
||
author = None
|
||
|
||
return {"content": content, "author": author}, None
|
||
|
||
# ─── Authentification Admin ──────────────────────────────────────────────────
|
||
|
||
def _get_admin_secret() -> str | None:
|
||
return os.environ.get("ADMIN_SECRET")
|
||
|
||
|
||
def require_admin(f):
|
||
@wraps(f)
|
||
def decorated(*args, **kwargs):
|
||
secret = _get_admin_secret()
|
||
if not secret:
|
||
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
|
||
token = request.cookies.get("admin_session", "")
|
||
if not token or not _verify_admin_token(token, secret):
|
||
return jsonify({"error": "unauthorized", "message": "Accès non autorisé."}), 401
|
||
return f(*args, **kwargs)
|
||
return decorated
|
||
|
||
# ─── Helpers anti-abus ───────────────────────────────────────────────────────
|
||
|
||
def get_fingerprint_key() -> str:
|
||
"""Clé de rate limiting : fingerprint hashé si présent, sinon IP."""
|
||
visitor_id = request.headers.get("X-Visitor-Id", "").strip()
|
||
if visitor_id:
|
||
return "fp:" + hashlib.sha256(visitor_id.encode()).hexdigest()[:32]
|
||
return "ip:" + get_remote_address()
|
||
|
||
|
||
def _get_ip_hash() -> str:
|
||
"""Hash SHA-256 (32 car.) de l'IP cliente — stocké à la place de l'IP brute (RGPD)."""
|
||
return hashlib.sha256(get_remote_address().encode()).hexdigest()[:32]
|
||
|
||
|
||
def _sign_cooldown(secret: str) -> str:
|
||
ts = int(time.time())
|
||
msg = ts.to_bytes(8, "big")
|
||
sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
||
return f"{ts}.{sig}"
|
||
|
||
|
||
def _verify_cooldown(cookie: str, secret: str, cooldown_seconds: int) -> bool:
|
||
try:
|
||
ts_str, sig = cookie.rsplit(".", 1)
|
||
ts = int(ts_str)
|
||
msg = ts.to_bytes(8, "big")
|
||
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
||
if not hmac.compare_digest(sig, expected):
|
||
return False
|
||
return (time.time() - ts) < cooldown_seconds
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _sign_consent_token(secret: str) -> str:
|
||
ts = int(time.time())
|
||
msg = f"consent:{ts}".encode()
|
||
sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
||
return f"{ts}.{sig}"
|
||
|
||
|
||
def _verify_consent_token(token: str, secret: str) -> bool:
|
||
try:
|
||
ts_str, sig = token.rsplit(".", 1)
|
||
ts = int(ts_str)
|
||
msg = f"consent:{ts}".encode()
|
||
expected = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
|
||
if not hmac.compare_digest(sig, expected):
|
||
return False
|
||
return (time.time() - ts) < CONSENT_TOKEN_TTL
|
||
except Exception:
|
||
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()
|
||
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()
|
||
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.
|
||
Renvoie True si HCAPTCHA_SECRET_KEY n'est pas configuré (stub désactivé).
|
||
"""
|
||
secret = os.environ.get("HCAPTCHA_SECRET_KEY", "").strip()
|
||
if not secret:
|
||
return True
|
||
if not token:
|
||
return False
|
||
try:
|
||
payload = urllib.parse.urlencode({"secret": secret, "response": token}).encode()
|
||
req = urllib.request.Request(
|
||
"https://hcaptcha.com/siteverify",
|
||
data=payload,
|
||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
result = json.loads(resp.read())
|
||
return bool(result.get("success", False))
|
||
except Exception:
|
||
# Fail-closed : une panne réseau de hCaptcha ne doit jamais laisser passer
|
||
# une soumission non vérifiée quand la protection est explicitement activée.
|
||
logger.warning("Vérification hCaptcha impossible (erreur réseau) — rejet")
|
||
return False
|
||
|
||
|
||
def _check_flood_redis(client, key: str) -> bool:
|
||
"""Fenêtre glissante sur un sorted set Redis — partagée entre tous les workers
|
||
Gunicorn et survit à un redémarrage, contrairement au dict en mémoire du process."""
|
||
now = time.time()
|
||
redis_key = f"flood:{key}"
|
||
member = f"{now}:{uuid.uuid4().hex[:8]}" # membre unique même à timestamp identique
|
||
pipe = client.pipeline()
|
||
pipe.zremrangebyscore(redis_key, 0, now - FLOOD_WINDOW_SECONDS)
|
||
pipe.zadd(redis_key, {member: now})
|
||
pipe.zcard(redis_key)
|
||
pipe.expire(redis_key, FLOOD_WINDOW_SECONDS)
|
||
_, _, count, _ = pipe.execute()
|
||
return count > FLOOD_THRESHOLD
|
||
|
||
|
||
def _check_flood(ip: str, fingerprint_hash: str | None) -> bool:
|
||
key = fingerprint_hash if fingerprint_hash else ip
|
||
if _redis_client is not None:
|
||
try:
|
||
return _check_flood_redis(_redis_client, key)
|
||
except Exception:
|
||
logger.warning("Flood tracker Redis indisponible pour cette requête — repli mémoire")
|
||
now = time.time()
|
||
with _flood_lock:
|
||
times = _flood_tracker.get(key, [])
|
||
times = [t for t in times if now - t < FLOOD_WINDOW_SECONDS]
|
||
times.append(now)
|
||
_flood_tracker[key] = times
|
||
return len(times) > FLOOD_THRESHOLD
|
||
|
||
# ─── Helpers consultations ────────────────────────────────────────────────────
|
||
|
||
def _is_consultation_open(c: dict) -> bool:
|
||
"""Retourne True si la consultation est dans sa fenêtre temporelle et non fermée."""
|
||
if c.get("closed_at"):
|
||
return False
|
||
now = dt.datetime.now(dt.timezone.utc)
|
||
starts_at = c.get("starts_at")
|
||
if starts_at and starts_at.tzinfo and starts_at > now:
|
||
return False
|
||
ends_at = c.get("ends_at")
|
||
if ends_at and ends_at.tzinfo and ends_at < now:
|
||
return False
|
||
return True
|
||
|
||
|
||
def serialize_consultation(c: dict) -> dict:
|
||
return {
|
||
"id": c["id"],
|
||
"slug": c["slug"],
|
||
"title": c["title"],
|
||
"subject": c["subject"],
|
||
"introMessage": c.get("intro_message"),
|
||
"organizerName": c.get("organizer_name"),
|
||
"organizerLogoUrl": c.get("organizer_logo_url"),
|
||
"startsAt": c["starts_at"].isoformat() if c.get("starts_at") else None,
|
||
"endsAt": c["ends_at"].isoformat() if c.get("ends_at") else None,
|
||
"closedAt": c["closed_at"].isoformat() if c.get("closed_at") else None,
|
||
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
|
||
"isOpen": _is_consultation_open(c),
|
||
}
|
||
|
||
|
||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||
"""Bloque toute redirection — une 30x pourrait rediriger vers une adresse interne
|
||
après que l'URL d'origine ait passé la vérification SSRF ci-dessous."""
|
||
def redirect_request(self, *args, **kwargs):
|
||
return None
|
||
|
||
|
||
def _is_safe_webhook_host(hostname: str) -> bool:
|
||
"""Résout le nom d'hôte et rejette toute IP privée/loopback/lien-local/réservée —
|
||
protection SSRF contre le réseau interne et le metadata du cloud une fois le
|
||
service hébergé sur un VPS public."""
|
||
try:
|
||
infos = socket.getaddrinfo(hostname, None)
|
||
except socket.gaierror:
|
||
return False
|
||
if not infos:
|
||
return False
|
||
for info in infos:
|
||
ip = ipaddress.ip_address(info[4][0])
|
||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
|
||
return False
|
||
return True
|
||
|
||
|
||
def _is_safe_webhook_url(url: str) -> bool:
|
||
try:
|
||
parsed = urllib.parse.urlparse(url)
|
||
except ValueError:
|
||
return False
|
||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||
return False
|
||
return _is_safe_webhook_host(parsed.hostname)
|
||
|
||
|
||
def _trigger_webhook(consultation: dict, synthesis: dict | None) -> None:
|
||
"""Envoie les résultats de clôture au webhook configuré (non-bloquant)."""
|
||
url = consultation.get("webhook_url")
|
||
if not url:
|
||
return
|
||
# Revalidé ici (pas seulement à la création de la consultation) : protège aussi
|
||
# les webhooks enregistrés avant ce correctif, et contre le DNS rebinding entre
|
||
# la création et la clôture de la consultation.
|
||
if not _is_safe_webhook_url(url):
|
||
logger.warning("Webhook consultation '%s' — URL non autorisée (SSRF), envoi annulé", consultation.get("slug"))
|
||
return
|
||
try:
|
||
payload = {
|
||
"event": "consultation_closed",
|
||
"consultation": {
|
||
"slug": consultation["slug"],
|
||
"title": consultation["title"],
|
||
"closedAt": consultation["closed_at"].isoformat() if consultation.get("closed_at") else None,
|
||
},
|
||
"synthesis": {
|
||
"text": synthesis["text"] if synthesis else None,
|
||
"ideaCount": synthesis["idea_count"] if synthesis else 0,
|
||
},
|
||
}
|
||
data = json.dumps(payload).encode()
|
||
req = urllib.request.Request(
|
||
url,
|
||
data=data,
|
||
headers={
|
||
"Content-Type": "application/json",
|
||
"User-Agent": "LaVoixDuPeuple/1.0",
|
||
},
|
||
)
|
||
opener = urllib.request.build_opener(_NoRedirect)
|
||
with opener.open(req, timeout=10) as resp:
|
||
logger.info("Webhook consultation '%s' → HTTP %d", consultation["slug"], resp.status)
|
||
except Exception:
|
||
logger.warning("Webhook consultation '%s' échoué (non-bloquant)", consultation.get("slug"))
|
||
|
||
# ─── Gestion des erreurs ─────────────────────────────────────────────────────
|
||
|
||
@app.errorhandler(400)
|
||
def bad_request(e):
|
||
return jsonify({"error": "bad_request", "message": "Requête invalide."}), 400
|
||
|
||
|
||
@app.errorhandler(404)
|
||
def not_found(e):
|
||
return jsonify({"error": "not_found", "message": "Ressource introuvable."}), 404
|
||
|
||
|
||
@app.errorhandler(405)
|
||
def method_not_allowed(e):
|
||
return jsonify({"error": "method_not_allowed", "message": "Méthode non autorisée."}), 405
|
||
|
||
|
||
@app.errorhandler(429)
|
||
def rate_limit_exceeded(e):
|
||
return jsonify({
|
||
"error": "rate_limit_exceeded",
|
||
"message": "Trop de requêtes. Veuillez patienter avant de soumettre une nouvelle idée.",
|
||
}), 429
|
||
|
||
|
||
@app.errorhandler(500)
|
||
def internal_error(e):
|
||
logger.exception("Erreur interne non gérée")
|
||
return jsonify({"error": "internal_error", "message": "Erreur interne du serveur."}), 500
|
||
|
||
# ─── Routes publiques ─────────────────────────────────────────────────────────
|
||
|
||
@app.get("/api/healthz")
|
||
def health():
|
||
return jsonify({"status": "ok"})
|
||
|
||
|
||
@app.get("/api/ideas")
|
||
@limiter.limit("120 per minute")
|
||
def list_ideas():
|
||
"""Retourne toutes les idées globales acceptées (consultation_id IS NULL)."""
|
||
ideas = get_accepted_ideas(consultation_id=None)
|
||
return jsonify([serialize_idea(i) for i in ideas])
|
||
|
||
|
||
@app.get("/api/ideas/stats")
|
||
@limiter.limit("120 per minute")
|
||
def idea_stats():
|
||
stats = get_stats()
|
||
return jsonify(stats)
|
||
|
||
|
||
@app.post("/api/ideas")
|
||
@limiter.limit(RATE_LIMIT_CONTRIBUTIONS, key_func=get_fingerprint_key)
|
||
def submit_idea():
|
||
"""
|
||
Soumet une idée citoyenne dans le contexte global.
|
||
|
||
Protections anti-abus (dans l'ordre) :
|
||
1. Honeypot — rejet silencieux si champ leurre rempli
|
||
2. hCaptcha — vérification si HCAPTCHA_SECRET_KEY configuré
|
||
3. Cooldown cookie — rejet si soumission trop récente
|
||
4. Rate limiting — 3/heure par IP ou fingerprint (configurable)
|
||
5. Flood detection — alerte si > 10 soumissions / 5 min
|
||
6. Fingerprinting non-PII — hash de l'identifiant FingerprintJS
|
||
7. Filtrage IA — cadre légal international
|
||
"""
|
||
if not request.is_json:
|
||
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
|
||
|
||
data = request.get_json(silent=True)
|
||
if data is None:
|
||
return jsonify({"error": "bad_request", "message": "Corps JSON invalide."}), 400
|
||
|
||
if check_ip_blacklist(_get_ip_hash()):
|
||
logger.warning("Soumission bloquée — IP blacklistée: %s", get_remote_address())
|
||
return jsonify({
|
||
"error": "ip_blacklisted",
|
||
"message": (
|
||
"Votre accès à cette plateforme est temporairement suspendu suite à des "
|
||
"soumissions de contenus contraires au cadre légal (RGPD art. 6(1)(f))."
|
||
),
|
||
}), 403
|
||
|
||
if data.get("_hp"):
|
||
logger.info("Honeypot déclenché — soumission ignorée silencieusement")
|
||
return jsonify({"id": 0, "accepted": True, "reason": None, "legalBasis": None}), 201
|
||
|
||
hcaptcha_token = (
|
||
(data.get("_h") or "")
|
||
or request.headers.get("X-HCaptcha-Token", "")
|
||
)
|
||
if not _verify_hcaptcha(hcaptcha_token):
|
||
logger.warning("hCaptcha échoué — IP: %s", get_remote_address())
|
||
return jsonify({"error": "captcha_failed", "message": "Vérification CAPTCHA échouée. Veuillez réessayer."}), 400
|
||
|
||
secret_key = os.environ.get("SECRET_KEY", "").strip()
|
||
if secret_key:
|
||
ct = request.cookies.get("_ct", "")
|
||
if not ct or not _verify_consent_token(ct, secret_key):
|
||
logger.info("Soumission refusée — consentement absent ou expiré (IP: %s)", get_remote_address())
|
||
return jsonify({
|
||
"error": "consent_required",
|
||
"message": "Votre consentement est requis avant de contribuer.",
|
||
}), 403
|
||
|
||
if CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key:
|
||
cv = request.cookies.get("_cv", "")
|
||
if cv and _verify_cooldown(cv, secret_key, CONTRIBUTION_COOLDOWN_SECONDS):
|
||
logger.info("Cooldown actif — soumission rejetée (même session)")
|
||
return jsonify({
|
||
"error": "cooldown",
|
||
"message": "Vous avez déjà contribué récemment. Veuillez patienter avant de soumettre une nouvelle idée.",
|
||
}), 429
|
||
|
||
validated, error = validate_idea_input(data)
|
||
if error:
|
||
return jsonify({"error": "validation_error", "message": error}), 400
|
||
|
||
content = validated["content"]
|
||
author = validated["author"]
|
||
|
||
raw_fp = request.headers.get("X-Visitor-Id", "").strip()
|
||
fingerprint_hash = (
|
||
hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else None
|
||
)
|
||
|
||
client_ip = get_remote_address()
|
||
if _check_flood(client_ip, fingerprint_hash):
|
||
logger.warning(
|
||
"ALERTE FLOOD — IP: %s | fingerprint: %s | seuil: %d/5min",
|
||
client_ip, fingerprint_hash, FLOOD_THRESHOLD,
|
||
)
|
||
|
||
logger.info("Filtrage d'une nouvelle idée (longueur: %d)", len(content))
|
||
filter_result = filter_idea(content)
|
||
accepted = bool(filter_result.get("accepted", False))
|
||
rejection_reason = filter_result.get("reason") if not accepted else None
|
||
legal_basis = filter_result.get("legal_basis") if not accepted else None
|
||
|
||
idea = insert_idea(content, author, accepted, rejection_reason, legal_basis, fingerprint_hash)
|
||
|
||
if not accepted:
|
||
ip_hash = _get_ip_hash()
|
||
newly_blacklisted = record_ip_rejection(ip_hash, idea["id"], IP_BLACKLIST_DAYS)
|
||
if newly_blacklisted:
|
||
logger.warning(
|
||
"IP blacklistée %d jours — hash: %s | déclencheur: contribution #%d",
|
||
IP_BLACKLIST_DAYS, ip_hash, idea["id"],
|
||
)
|
||
|
||
if accepted:
|
||
threading.Thread(target=_update_synthesis_background, daemon=True).start()
|
||
|
||
resp_data = {
|
||
"id": idea["id"],
|
||
"accepted": accepted,
|
||
"reason": rejection_reason,
|
||
"legalBasis": legal_basis if not accepted else None,
|
||
"idea": serialize_idea(idea),
|
||
}
|
||
response = make_response(jsonify(resp_data), 201)
|
||
|
||
if accepted and CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key:
|
||
response.set_cookie(
|
||
"_cv",
|
||
_sign_cooldown(secret_key),
|
||
max_age=CONTRIBUTION_COOLDOWN_SECONDS,
|
||
httponly=True,
|
||
samesite="Lax",
|
||
secure=request.is_secure,
|
||
)
|
||
|
||
return response
|
||
|
||
|
||
@app.get("/api/synthesis")
|
||
@limiter.limit("120 per minute")
|
||
def get_synthesis_route():
|
||
"""Retourne la synthèse globale (consultation_id IS NULL)."""
|
||
synthesis = get_synthesis(consultation_id=None)
|
||
if not synthesis:
|
||
return jsonify({
|
||
"text": (
|
||
"Aucune idée n'a encore été soumise. "
|
||
"Soyez le premier à partager votre vision pour une société meilleure, "
|
||
"fondée sur la Déclaration universelle des droits de l'homme."
|
||
),
|
||
"ideaCount": 0,
|
||
"updatedAt": None,
|
||
})
|
||
return jsonify({
|
||
"text": synthesis["text"],
|
||
"ideaCount": synthesis["idea_count"],
|
||
"updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None,
|
||
})
|
||
|
||
|
||
# ─── Routes publiques : consentement, stats, contributions ───────────────────
|
||
|
||
@app.post("/api/consent")
|
||
@limiter.limit("5 per minute")
|
||
def record_consent():
|
||
"""Enregistre le consentement explicite d'un citoyen (art. 9.2.a RGPD)."""
|
||
if not request.is_json:
|
||
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
|
||
data = request.get_json(silent=True) or {}
|
||
consent_version = sanitize_text(str(data.get("consent_version", "1.0") or "1.0"))[:20]
|
||
raw_fp = request.headers.get("X-Visitor-Id", "").strip()
|
||
fingerprint_hash = (
|
||
hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else "anonymous"
|
||
)
|
||
create_consent(fingerprint_hash, consent_version)
|
||
logger.info(
|
||
"Consentement enregistré — fingerprint: %s... | version: %s",
|
||
fingerprint_hash[:8], consent_version,
|
||
)
|
||
secret_key = os.environ.get("SECRET_KEY", "").strip()
|
||
response = make_response(jsonify({"ok": True}), 201)
|
||
if secret_key:
|
||
response.set_cookie(
|
||
"_ct",
|
||
_sign_consent_token(secret_key),
|
||
max_age=CONSENT_TOKEN_TTL,
|
||
httponly=True,
|
||
samesite="Lax",
|
||
secure=request.is_secure,
|
||
)
|
||
return response
|
||
|
||
|
||
@app.get("/api/stats/public")
|
||
@limiter.limit("120 per minute")
|
||
def public_stats():
|
||
"""Statistiques publiques globales (hors consultations)."""
|
||
return jsonify(get_public_stats())
|
||
|
||
|
||
@app.get("/api/contributions")
|
||
@limiter.limit("60 per minute")
|
||
def public_contributions():
|
||
"""Contributions globales acceptées, paginées, anti-chronologiques."""
|
||
try:
|
||
page = max(1, int(request.args.get("page", 1)))
|
||
per_page = min(50, max(5, int(request.args.get("per_page", 20))))
|
||
except (ValueError, TypeError):
|
||
page, per_page = 1, 20
|
||
contributions, total = get_public_contributions(page=page, per_page=per_page)
|
||
pages = max(1, -(-total // per_page))
|
||
return jsonify({
|
||
"contributions": [
|
||
{
|
||
"id": c["id"],
|
||
"content": c["content"],
|
||
"author": c.get("author"),
|
||
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
|
||
}
|
||
for c in contributions
|
||
],
|
||
"total": total,
|
||
"page": page,
|
||
"perPage": per_page,
|
||
"pages": pages,
|
||
})
|
||
|
||
|
||
@app.get("/api/contributions/export/json")
|
||
@limiter.limit("10 per minute")
|
||
def export_contributions_json():
|
||
"""Export JSON intégral des contributions globales acceptées."""
|
||
contributions, _ = get_public_contributions(page=1, per_page=10000)
|
||
payload = [
|
||
{
|
||
"id": c["id"],
|
||
"content": c["content"],
|
||
"author": c.get("author"),
|
||
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
|
||
}
|
||
for c in contributions
|
||
]
|
||
return Response(
|
||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||
mimetype="application/json",
|
||
headers={"Content-Disposition": "attachment; filename=contributions.json"},
|
||
)
|
||
|
||
|
||
def _stream_csv_rows(header: list[str], fetch_page, row_to_fields, page_size: int = 1000):
|
||
"""Génère un CSV en streaming, sans jamais charger l'ensemble des lignes en mémoire
|
||
ni côté buffer CSV ni côté requête base de données (pagination interne).
|
||
|
||
fetch_page(page, per_page) -> (rows, total) — même contrat que les fonctions
|
||
de pagination existantes de database.py.
|
||
"""
|
||
buf = io.StringIO()
|
||
writer = csv.writer(buf, quoting=csv.QUOTE_ALL)
|
||
|
||
def _flush() -> bytes:
|
||
data = buf.getvalue().encode("utf-8")
|
||
buf.seek(0)
|
||
buf.truncate(0)
|
||
return data
|
||
|
||
yield "".encode("utf-8")
|
||
writer.writerow(header)
|
||
yield _flush()
|
||
|
||
page = 1
|
||
while True:
|
||
rows, total = fetch_page(page, page_size)
|
||
if not rows:
|
||
break
|
||
for row in rows:
|
||
writer.writerow(row_to_fields(row))
|
||
yield _flush()
|
||
if page * page_size >= total:
|
||
break
|
||
page += 1
|
||
|
||
|
||
@app.get("/api/contributions/export/csv")
|
||
@limiter.limit("10 per minute")
|
||
def export_contributions_csv():
|
||
"""Export CSV des contributions globales acceptées (champs publics uniquement)."""
|
||
def fields(c):
|
||
return [
|
||
c.get("id"),
|
||
c.get("content", ""),
|
||
c.get("author", ""),
|
||
c["created_at"].isoformat() if c.get("created_at") else "",
|
||
]
|
||
|
||
return Response(
|
||
_stream_csv_rows(["id", "content", "author", "created_at"], get_public_contributions, fields),
|
||
mimetype="text/csv",
|
||
headers={"Content-Disposition": "attachment; filename=contributions.csv"},
|
||
)
|
||
|
||
|
||
# ─── Route publique : signalement ────────────────────────────────────────────
|
||
|
||
@app.post("/api/ideas/<int:idea_id>/flag")
|
||
@limiter.limit("3 per minute; 10 per hour")
|
||
def flag_idea_route(idea_id: int):
|
||
"""Signale une contribution pour examen par l'administrateur."""
|
||
result = flag_idea(idea_id)
|
||
if not result:
|
||
return jsonify({"error": "not_found", "message": "Contribution introuvable ou non publiée."}), 404
|
||
return jsonify({"ok": True, "flagCount": result.get("flag_count", 1)})
|
||
|
||
|
||
# ─── Routes publiques : consultations (P5) ───────────────────────────────────
|
||
|
||
@app.get("/api/consultations")
|
||
@limiter.limit("120 per minute")
|
||
def list_consultations_route():
|
||
"""Liste les consultations (actives par défaut, toutes si include_closed=true)."""
|
||
include_closed = request.args.get("include_closed", "false").lower() == "true"
|
||
consultations = list_consultations(include_closed=include_closed)
|
||
return jsonify([serialize_consultation(c) for c in consultations])
|
||
|
||
|
||
@app.get("/api/consultations/<slug>")
|
||
@limiter.limit("120 per minute")
|
||
def get_consultation_route(slug: str):
|
||
"""Détails et statistiques d'une consultation."""
|
||
slug = sanitize_text(slug)[:100]
|
||
consultation = get_consultation_by_slug(slug)
|
||
if not consultation:
|
||
return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404
|
||
stats = get_consultation_stats(consultation["id"])
|
||
return jsonify({**serialize_consultation(consultation), "stats": stats})
|
||
|
||
|
||
@app.get("/api/consultations/<slug>/synthesis")
|
||
@limiter.limit("120 per minute")
|
||
def get_consultation_synthesis_route(slug: str):
|
||
"""Synthèse collective d'une consultation."""
|
||
slug = sanitize_text(slug)[:100]
|
||
consultation = get_consultation_by_slug(slug)
|
||
if not consultation:
|
||
return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404
|
||
synthesis = get_synthesis(consultation_id=consultation["id"])
|
||
if not synthesis:
|
||
return jsonify({
|
||
"text": "Aucune contribution n'a encore été soumise pour cette consultation.",
|
||
"ideaCount": 0,
|
||
"updatedAt": None,
|
||
})
|
||
return jsonify({
|
||
"text": synthesis["text"],
|
||
"ideaCount": synthesis["idea_count"],
|
||
"updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None,
|
||
})
|
||
|
||
|
||
@app.get("/api/consultations/<slug>/contributions")
|
||
@limiter.limit("60 per minute")
|
||
def get_consultation_contributions_route(slug: str):
|
||
"""Contributions acceptées d'une consultation, paginées."""
|
||
slug = sanitize_text(slug)[:100]
|
||
consultation = get_consultation_by_slug(slug)
|
||
if not consultation:
|
||
return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404
|
||
try:
|
||
page = max(1, int(request.args.get("page", 1)))
|
||
per_page = min(50, max(5, int(request.args.get("per_page", 20))))
|
||
except (ValueError, TypeError):
|
||
page, per_page = 1, 20
|
||
contributions, total = get_consultation_contributions(
|
||
consultation["id"], page=page, per_page=per_page
|
||
)
|
||
pages = max(1, -(-total // per_page))
|
||
return jsonify({
|
||
"contributions": [
|
||
{
|
||
"id": c["id"],
|
||
"content": c["content"],
|
||
"author": c.get("author"),
|
||
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
|
||
}
|
||
for c in contributions
|
||
],
|
||
"total": total,
|
||
"page": page,
|
||
"perPage": per_page,
|
||
"pages": pages,
|
||
})
|
||
|
||
|
||
@app.post("/api/consultations/<slug>/ideas")
|
||
@limiter.limit(RATE_LIMIT_CONTRIBUTIONS, key_func=get_fingerprint_key)
|
||
def submit_consultation_idea(slug: str):
|
||
"""Soumet une idée dans le contexte d'une consultation ciblée."""
|
||
slug = sanitize_text(slug)[:100]
|
||
consultation = get_consultation_by_slug(slug)
|
||
if not consultation:
|
||
return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404
|
||
if not _is_consultation_open(consultation):
|
||
return jsonify({
|
||
"error": "consultation_closed",
|
||
"message": "Cette consultation est terminée ou n'a pas encore débuté.",
|
||
}), 403
|
||
|
||
if not request.is_json:
|
||
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
|
||
data = request.get_json(silent=True)
|
||
if data is None:
|
||
return jsonify({"error": "bad_request", "message": "Corps JSON invalide."}), 400
|
||
|
||
if check_ip_blacklist(_get_ip_hash()):
|
||
logger.warning("Soumission bloquée — IP blacklistée: %s (consultation: %s)", get_remote_address(), slug)
|
||
return jsonify({
|
||
"error": "ip_blacklisted",
|
||
"message": (
|
||
"Votre accès à cette plateforme est temporairement suspendu suite à des "
|
||
"soumissions de contenus contraires au cadre légal (RGPD art. 6(1)(f))."
|
||
),
|
||
}), 403
|
||
|
||
if data.get("_hp"):
|
||
return jsonify({"id": 0, "accepted": True, "reason": None}), 201
|
||
|
||
hcaptcha_token = (data.get("_h") or "") or request.headers.get("X-HCaptcha-Token", "")
|
||
if not _verify_hcaptcha(hcaptcha_token):
|
||
return jsonify({"error": "captcha_failed", "message": "Vérification CAPTCHA échouée."}), 400
|
||
|
||
secret_key = os.environ.get("SECRET_KEY", "").strip()
|
||
if secret_key:
|
||
ct = request.cookies.get("_ct", "")
|
||
if not ct or not _verify_consent_token(ct, secret_key):
|
||
logger.info(
|
||
"Soumission refusée — consentement absent ou expiré (consultation: %s, IP: %s)",
|
||
slug, get_remote_address(),
|
||
)
|
||
return jsonify({
|
||
"error": "consent_required",
|
||
"message": "Votre consentement est requis avant de contribuer.",
|
||
}), 403
|
||
|
||
if CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key:
|
||
cv = request.cookies.get("_cv", "")
|
||
if cv and _verify_cooldown(cv, secret_key, CONTRIBUTION_COOLDOWN_SECONDS):
|
||
logger.info("Cooldown actif — soumission rejetée (même session, consultation: %s)", slug)
|
||
return jsonify({
|
||
"error": "cooldown",
|
||
"message": "Vous avez déjà contribué récemment. Veuillez patienter avant de soumettre une nouvelle idée.",
|
||
}), 429
|
||
|
||
validated, error = validate_idea_input(data)
|
||
if error:
|
||
return jsonify({"error": "validation_error", "message": error}), 400
|
||
|
||
content = validated["content"]
|
||
author = validated["author"]
|
||
|
||
raw_fp = request.headers.get("X-Visitor-Id", "").strip()
|
||
fingerprint_hash = hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else None
|
||
|
||
if _check_flood(get_remote_address(), fingerprint_hash):
|
||
logger.warning("ALERTE FLOOD — consultation '%s' | IP: %s", slug, get_remote_address())
|
||
|
||
filter_result = filter_idea(content)
|
||
accepted = bool(filter_result.get("accepted", False))
|
||
rejection_reason = filter_result.get("reason") if not accepted else None
|
||
legal_basis = filter_result.get("legal_basis") if not accepted else None
|
||
|
||
idea = insert_idea(
|
||
content, author, accepted, rejection_reason, legal_basis,
|
||
fingerprint_hash, consultation["id"],
|
||
)
|
||
|
||
if not accepted:
|
||
ip_hash = _get_ip_hash()
|
||
newly_blacklisted = record_ip_rejection(ip_hash, idea["id"], IP_BLACKLIST_DAYS)
|
||
if newly_blacklisted:
|
||
logger.warning(
|
||
"IP blacklistée %d jours — hash: %s | déclencheur: contribution #%d (consultation: %s)",
|
||
IP_BLACKLIST_DAYS, ip_hash, idea["id"], slug,
|
||
)
|
||
|
||
if accepted:
|
||
threading.Thread(
|
||
target=_update_synthesis_background,
|
||
kwargs={"consultation_id": consultation["id"]},
|
||
daemon=True,
|
||
).start()
|
||
|
||
resp_data = {
|
||
"id": idea["id"],
|
||
"accepted": accepted,
|
||
"reason": rejection_reason,
|
||
"legalBasis": legal_basis if not accepted else None,
|
||
"idea": serialize_idea(idea),
|
||
}
|
||
response = make_response(jsonify(resp_data), 201)
|
||
|
||
if accepted and CONTRIBUTION_COOLDOWN_SECONDS > 0 and secret_key:
|
||
response.set_cookie(
|
||
"_cv",
|
||
_sign_cooldown(secret_key),
|
||
max_age=CONTRIBUTION_COOLDOWN_SECONDS,
|
||
httponly=True,
|
||
samesite="Lax",
|
||
secure=request.is_secure,
|
||
)
|
||
|
||
return response
|
||
|
||
|
||
@app.get("/api/consultations/<slug>/export/print")
|
||
@limiter.limit("10 per minute")
|
||
def export_consultation_print(slug: str):
|
||
"""Rendu HTML de la synthèse pour impression/export PDF côté client."""
|
||
slug = sanitize_text(slug)[:100]
|
||
consultation = get_consultation_by_slug(slug)
|
||
if not consultation:
|
||
return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404
|
||
|
||
synthesis = get_synthesis(consultation_id=consultation["id"])
|
||
stats = get_consultation_stats(consultation["id"])
|
||
|
||
synthesis_text = synthesis["text"] if synthesis else "Aucune synthèse disponible."
|
||
idea_count = synthesis["idea_count"] if synthesis else 0
|
||
|
||
title_esc = html_module.escape(consultation["title"])
|
||
subject_esc = html_module.escape(consultation["subject"])
|
||
organizer_esc = html_module.escape(consultation.get("organizer_name") or "")
|
||
now_str = dt.datetime.now(dt.timezone.utc).strftime("%d/%m/%Y %H:%M")
|
||
|
||
# Mise en forme du texte : "Sur X :" / "Concernant X :" en gras teal
|
||
import re as _re
|
||
_THEME_RE = _re.compile(
|
||
r'^((?:Sur|Concernant|À propos d[eu]?|En ce qui concerne|Quant à)[^:]{0,100}:\s?)([\s\S]*)$',
|
||
_re.IGNORECASE,
|
||
)
|
||
paragraphs = [p.strip() for p in synthesis_text.split("\n\n") if p.strip()]
|
||
body_html = ""
|
||
for para in paragraphs:
|
||
m = _THEME_RE.match(para)
|
||
if m:
|
||
prefix = html_module.escape(m.group(1))
|
||
rest = html_module.escape(m.group(2))
|
||
body_html += f'<p><span class="theme">{prefix}</span>{rest}</p>\n'
|
||
else:
|
||
body_html += f'<p>{html_module.escape(para)}</p>\n'
|
||
|
||
html_content = f"""<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>{title_esc}</title>
|
||
<style>
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{ font-family: Georgia, 'Times New Roman', serif; background: #F9F7F1; color: #1a1a1a; }}
|
||
.page {{ max-width: 700px; margin: 0 auto; background: #fff; min-height: 100vh; padding: 52px 56px; }}
|
||
.tricolor {{ display: flex; height: 3px; margin-bottom: 40px; }}
|
||
.tricolor span:nth-child(1) {{ flex: 1; background: #002395; }}
|
||
.tricolor span:nth-child(2) {{ flex: 1; background: #E5E5E5; }}
|
||
.tricolor span:nth-child(3) {{ flex: 1; background: #ED2939; }}
|
||
.label {{ font-family: 'Courier New', monospace; font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.14em; color: #1b5f6a; margin-bottom: 10px; }}
|
||
h1 {{ font-size: 1.5rem; font-weight: 700; color: #111; line-height: 1.25; margin-bottom: 4px; }}
|
||
.organizer {{ font-size: 0.85rem; font-weight: 600; color: #1b5f6a; margin-top: 6px; }}
|
||
.subject {{ font-size: 0.85rem; color: #555; margin-top: 4px; font-style: italic; }}
|
||
.meta {{ font-family: 'Courier New', monospace; font-size: 0.68rem; color: #999; margin-top: 16px; padding-top: 16px; border-top: 1px solid #E8E4DC; margin-bottom: 36px; }}
|
||
.body {{ font-size: 0.95rem; line-height: 1.85; color: #1a1a1a; }}
|
||
.body p {{ margin-bottom: 1.1em; }}
|
||
.theme {{ font-family: Georgia, serif; font-weight: 600; color: #1b5f6a; }}
|
||
.footer {{ margin-top: 52px; padding-top: 14px; border-top: 1px solid #E8E4DC; font-family: 'Courier New', monospace; font-size: 0.6rem; color: #bbb; display: flex; justify-content: space-between; gap: 16px; }}
|
||
@media print {{ body {{ background: white; }} .page {{ box-shadow: none; padding: 32px 40px; }} }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<div class="tricolor"><span></span><span></span><span></span></div>
|
||
<p class="label">La Voix du Peuple · Consultation citoyenne</p>
|
||
<h1>{title_esc}</h1>
|
||
{f'<p class="organizer">{organizer_esc}</p>' if organizer_esc else ""}
|
||
<p class="subject">{subject_esc}</p>
|
||
<div class="meta">Exporté le {now_str} · {idea_count} contribution(s) intégrée(s)</div>
|
||
<div class="body">{body_html}</div>
|
||
<div class="footer">
|
||
<span>lavoixdupeuple.fr</span>
|
||
<span>Modération DUDH · PIDCP · CEDH · droit pénal français</span>
|
||
</div>
|
||
</div>
|
||
<script>window.print();</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
return Response(html_content, mimetype="text/html")
|
||
|
||
|
||
# ─── Routes Admin ─────────────────────────────────────────────────────────────
|
||
|
||
@app.post("/api/admin/login")
|
||
@limiter.limit("10 per minute")
|
||
def admin_login():
|
||
"""Vérifie le mot de passe admin. Retourne ok si correct."""
|
||
secret = _get_admin_secret()
|
||
if not secret:
|
||
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
|
||
data = request.get_json(silent=True) or {}
|
||
password = str(data.get("password", "") or "")
|
||
if not hmac.compare_digest(password, secret):
|
||
logger.warning("Tentative de connexion admin échouée")
|
||
return jsonify({"error": "unauthorized", "message": "Mot de passe incorrect."}), 401
|
||
logger.info("Connexion admin réussie")
|
||
resp = jsonify({"ok": True})
|
||
resp.set_cookie(
|
||
"admin_session",
|
||
_sign_admin_token(secret),
|
||
max_age=28800,
|
||
httponly=True,
|
||
secure=request.is_secure,
|
||
samesite="Strict",
|
||
)
|
||
return resp
|
||
|
||
|
||
@app.post("/api/admin/logout")
|
||
def admin_logout():
|
||
"""Efface le cookie de session admin côté navigateur."""
|
||
resp = jsonify({"ok": True})
|
||
resp.delete_cookie("admin_session", samesite="Strict")
|
||
return resp
|
||
|
||
|
||
@app.get("/api/admin/session")
|
||
@require_admin
|
||
def admin_session_check():
|
||
"""Permet au frontend de savoir si le cookie de session admin est encore valide."""
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@app.get("/api/admin/stats")
|
||
@require_admin
|
||
def admin_stats():
|
||
"""Statistiques détaillées pour l'administrateur (toutes contributions confondues)."""
|
||
stats = get_stats()
|
||
return jsonify(stats)
|
||
|
||
|
||
@app.get("/api/admin/ideas")
|
||
@require_admin
|
||
@limiter.limit("120 per minute")
|
||
def admin_list_ideas():
|
||
"""Liste toutes les contributions avec filtres et pagination."""
|
||
status = request.args.get("status", "all")
|
||
page = max(1, int(request.args.get("page", 1)))
|
||
per_page = min(100, max(10, int(request.args.get("per_page", 50))))
|
||
# Limite de longueur : une chaîne ILIKE arbitrairement longue coûte cher à
|
||
# matcher sur chaque ligne (LIKE %...% force un scan complet quoi qu'il arrive).
|
||
search = request.args.get("q", "").strip()[:200]
|
||
|
||
if status not in ("all", "accepted", "rejected", "flagged"):
|
||
status = "all"
|
||
|
||
ideas, total = get_ideas_admin(status=status, page=page, per_page=per_page, search=search)
|
||
return jsonify({
|
||
"ideas": [serialize_idea_admin(i) for i in ideas],
|
||
"total": total,
|
||
"page": page,
|
||
"perPage": per_page,
|
||
"pages": max(1, -(-total // per_page)),
|
||
})
|
||
|
||
|
||
@app.delete("/api/admin/ideas/<int:idea_id>")
|
||
@require_admin
|
||
def admin_delete_idea(idea_id: int):
|
||
"""Supprime une contribution et régénère la synthèse du contexte concerné."""
|
||
deleted = delete_idea(idea_id)
|
||
if not deleted:
|
||
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
|
||
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})
|
||
|
||
|
||
@app.post("/api/admin/ideas/bulk-delete")
|
||
@require_admin
|
||
def admin_bulk_delete():
|
||
"""Suppression en masse de contributions."""
|
||
data = request.get_json(silent=True) or {}
|
||
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
|
||
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()
|
||
logger.info("Admin — %d contribution(s) supprimée(s) en masse", count)
|
||
return jsonify({"ok": True, "deleted": count, "synthesisUpdating": count > 0})
|
||
|
||
|
||
@app.post("/api/admin/ideas/<int:idea_id>/override")
|
||
@require_admin
|
||
def admin_override_idea(idea_id: int):
|
||
"""Modifie manuellement le statut d'une contribution."""
|
||
data = request.get_json(silent=True) or {}
|
||
accepted = bool(data.get("accepted", False))
|
||
reason = sanitize_text(data.get("reason", "") or "")
|
||
note = sanitize_text(data.get("note", "") or "")
|
||
result = override_idea(idea_id, accepted, reason or None, note or None)
|
||
if not result:
|
||
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
|
||
threading.Thread(target=_update_synthesis_background, daemon=True).start()
|
||
logger.info("Admin — contribution #%d override → accepted=%s", idea_id, accepted)
|
||
return jsonify({"ok": True, "idea": serialize_idea_admin(result), "synthesisUpdating": True})
|
||
|
||
|
||
@app.post("/api/admin/ideas/<int:idea_id>/unflag")
|
||
@require_admin
|
||
def admin_unflag_idea(idea_id: int):
|
||
"""Retire le signalement d'une contribution."""
|
||
result = unflag_idea(idea_id)
|
||
if not result:
|
||
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
|
||
return jsonify({"ok": True, "idea": serialize_idea_admin(result)})
|
||
|
||
|
||
@app.post("/api/admin/synthesis/regenerate")
|
||
@require_admin
|
||
@limiter.limit("5 per minute")
|
||
def admin_regenerate_synthesis():
|
||
"""Force la régénération complète de la synthèse globale."""
|
||
threading.Thread(target=_update_synthesis_background, daemon=True).start()
|
||
logger.info("Admin — régénération manuelle de la synthèse déclenchée")
|
||
return jsonify({"ok": True, "message": "Régénération lancée en arrière-plan."})
|
||
|
||
|
||
@app.get("/api/admin/export/csv")
|
||
@require_admin
|
||
def admin_export_csv():
|
||
"""Exporte toutes les contributions en CSV (admin)."""
|
||
def fetch_page(page, per_page):
|
||
return get_ideas_admin(status="all", page=page, per_page=per_page)
|
||
|
||
def fields(idea):
|
||
return [
|
||
idea.get("id"),
|
||
idea.get("content", ""),
|
||
idea.get("author", ""),
|
||
"oui" if idea.get("accepted") else "non",
|
||
"oui" if idea.get("flagged") else "non",
|
||
idea.get("flag_count", 0),
|
||
idea.get("rejection_reason", ""),
|
||
idea.get("legal_basis", ""),
|
||
idea.get("admin_note", ""),
|
||
idea.get("fingerprint_hash", ""),
|
||
idea.get("consultation_id", ""),
|
||
idea.get("created_at").isoformat() if idea.get("created_at") else "",
|
||
]
|
||
|
||
header = [
|
||
"id", "content", "author", "accepted", "flagged", "flag_count",
|
||
"rejection_reason", "legal_basis", "admin_note",
|
||
"fingerprint_hash", "consultation_id", "created_at",
|
||
]
|
||
return Response(
|
||
_stream_csv_rows(header, fetch_page, fields),
|
||
mimetype="text/csv",
|
||
headers={"Content-Disposition": "attachment; filename=contributions.csv"},
|
||
)
|
||
|
||
|
||
# ─── Routes Admin : consultations (P5) ───────────────────────────────────────
|
||
|
||
@app.get("/api/admin/consultations")
|
||
@require_admin
|
||
def admin_list_consultations():
|
||
"""Liste toutes les consultations (actives et fermées) avec leurs statistiques."""
|
||
consultations = list_consultations(include_closed=True)
|
||
result = []
|
||
for c in consultations:
|
||
stats = get_consultation_stats(c["id"])
|
||
result.append({**serialize_consultation(c), "stats": stats})
|
||
return jsonify(result)
|
||
|
||
|
||
@app.post("/api/admin/consultations")
|
||
@require_admin
|
||
@limiter.limit("20 per minute")
|
||
def admin_create_consultation():
|
||
"""Crée une nouvelle consultation ciblée."""
|
||
data = request.get_json(silent=True) or {}
|
||
|
||
slug_raw = sanitize_text(str(data.get("slug", "") or ""))[:100].lower()
|
||
# Normalisation : uniquement alphanumériques et tirets
|
||
slug = "".join(c if c.isalnum() or c == "-" else "-" for c in slug_raw)
|
||
slug = "-".join(filter(None, slug.split("-"))) # supprime les tirets multiples/en bord
|
||
|
||
title = sanitize_text(str(data.get("title", "") or ""))[:200]
|
||
subject = sanitize_text(str(data.get("subject", "") or ""))[:2000]
|
||
intro_message = sanitize_text(str(data.get("introMessage", "") or ""))[:2000] or None
|
||
organizer_name = sanitize_text(str(data.get("organizerName", "") or ""))[:200] or None
|
||
webhook_url = sanitize_text(str(data.get("webhookUrl", "") or ""))[:500] or None
|
||
|
||
# URL logo : uniquement HTTPS pour éviter le contenu mixte
|
||
logo_raw = sanitize_text(str(data.get("organizerLogoUrl", "") or ""))[:500]
|
||
organizer_logo_url = logo_raw if logo_raw.startswith("https://") else None
|
||
|
||
if not slug or not title or not subject:
|
||
return jsonify({"error": "validation_error", "message": "slug, title et subject sont requis."}), 400
|
||
|
||
if webhook_url and not _is_safe_webhook_url(webhook_url):
|
||
return jsonify({
|
||
"error": "validation_error",
|
||
"message": "URL de webhook invalide ou pointant vers une adresse non autorisée (réseau interne).",
|
||
}), 400
|
||
|
||
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
|
||
|
||
try:
|
||
consultation = create_consultation(
|
||
slug, title, subject, intro_message,
|
||
organizer_name, organizer_logo_url,
|
||
starts_at, ends_at, webhook_url,
|
||
)
|
||
except Exception as e:
|
||
if "unique" in str(e).lower() or "duplicate" in str(e).lower():
|
||
return jsonify({"error": "conflict", "message": f"Le slug '{slug}' est déjà utilisé."}), 409
|
||
raise
|
||
|
||
logger.info("Admin — consultation '%s' créée", slug)
|
||
return jsonify(serialize_consultation(consultation)), 201
|
||
|
||
|
||
@app.post("/api/admin/consultations/<slug>/close")
|
||
@require_admin
|
||
def admin_close_consultation(slug: str):
|
||
"""Ferme manuellement une consultation et déclenche le webhook si configuré."""
|
||
slug = sanitize_text(slug)[:100]
|
||
consultation = get_consultation_by_slug(slug)
|
||
if not consultation:
|
||
return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404
|
||
|
||
closed = close_consultation(consultation["id"])
|
||
if not closed:
|
||
return jsonify({"error": "already_closed", "message": "Cette consultation est déjà fermée."}), 409
|
||
|
||
synthesis = get_synthesis(consultation_id=consultation["id"])
|
||
threading.Thread(target=_trigger_webhook, args=(closed, synthesis), daemon=True).start()
|
||
|
||
logger.info("Admin — consultation '%s' fermée manuellement", slug)
|
||
return jsonify({"ok": True, "consultation": serialize_consultation(closed)})
|
||
|
||
|
||
@app.delete("/api/admin/consultations/<slug>")
|
||
@require_admin
|
||
def admin_delete_consultation(slug: str):
|
||
"""Supprime une consultation et toutes ses contributions/synthèse associées."""
|
||
slug = sanitize_text(slug)[:100]
|
||
consultation = get_consultation_by_slug(slug)
|
||
if not consultation:
|
||
return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404
|
||
|
||
deleted = delete_consultation(consultation["id"])
|
||
if not deleted:
|
||
return jsonify({"error": "not_found", "message": "Consultation introuvable."}), 404
|
||
|
||
logger.info("Admin — consultation '%s' supprimée", slug)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@app.post("/api/check-law")
|
||
@limiter.limit("5 per minute;20 per hour")
|
||
def check_law_route():
|
||
"""
|
||
Vérifie si une proposition est déjà couverte par une loi française ou européenne.
|
||
Endpoint public, rate-limité. Non bloquant — retourne une note informative.
|
||
"""
|
||
if not request.is_json:
|
||
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
|
||
data = request.get_json(silent=True) or {}
|
||
content = sanitize_text(str(data.get("content", ""))).strip()
|
||
if not content or len(content) < 10:
|
||
return jsonify({"error": "validation_error", "message": "Contenu trop court."}), 400
|
||
if len(content) > 1000:
|
||
return jsonify({"error": "validation_error", "message": "Contenu trop long (max 1000 caractères)."}), 400
|
||
note = check_existing_law(content)
|
||
return jsonify({"note": note})
|
||
|
||
|
||
@app.get("/api/admin/ip-blacklist")
|
||
@require_admin
|
||
def admin_list_ip_blacklist():
|
||
"""Liste des IPs blacklistées actives (hash SHA-256, RGPD-compliant)."""
|
||
entries = get_ip_blacklist()
|
||
return jsonify([
|
||
{
|
||
"ipHash": e["ip_hash"],
|
||
"rejectionCount": e["rejection_count"],
|
||
"firstRejectedAt": e["first_rejected_at"].isoformat() if e.get("first_rejected_at") else None,
|
||
"lastRejectedAt": e["last_rejected_at"].isoformat() if e.get("last_rejected_at") else None,
|
||
"lastIdeaId": e.get("last_idea_id"),
|
||
"blacklistedAt": e["blacklisted_at"].isoformat() if e.get("blacklisted_at") else None,
|
||
"expiresAt": e["expires_at"].isoformat() if e.get("expires_at") else None,
|
||
}
|
||
for e in entries
|
||
])
|
||
|
||
|
||
@app.delete("/api/admin/ip-blacklist/<ip_hash>")
|
||
@require_admin
|
||
def admin_remove_ip_blacklist(ip_hash: str):
|
||
"""Lève le blacklist d'une IP (supprime l'entrée de la table ip_abuse)."""
|
||
ip_hash = ip_hash.strip()[:64]
|
||
ok = remove_ip_blacklist(ip_hash)
|
||
if not ok:
|
||
return jsonify({"error": "not_found", "message": "Entrée introuvable."}), 404
|
||
logger.info("Admin — blacklist IP levée — hash: %s", ip_hash)
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
# ─── Helpers sérialiseurs ─────────────────────────────────────────────────────
|
||
|
||
def serialize_idea(idea: dict) -> dict:
|
||
return {
|
||
"id": idea["id"],
|
||
"content": idea["content"],
|
||
"author": idea.get("author"),
|
||
"accepted": idea["accepted"],
|
||
"flagged": idea.get("flagged", False),
|
||
"flagCount": idea.get("flag_count", 0),
|
||
"rejectionReason": idea.get("rejection_reason"),
|
||
"legalBasis": idea.get("legal_basis"),
|
||
"createdAt": idea["created_at"].isoformat() if idea.get("created_at") else None,
|
||
}
|
||
|
||
|
||
def serialize_idea_admin(idea: dict) -> dict:
|
||
base = serialize_idea(idea)
|
||
base["adminNote"] = idea.get("admin_note")
|
||
base["fingerprintHash"] = idea.get("fingerprint_hash")
|
||
base["consultationId"] = idea.get("consultation_id")
|
||
return base
|
||
|
||
|
||
def _update_synthesis_background(consultation_id: int | None = None) -> None:
|
||
"""Met à jour la synthèse d'un contexte donné en arrière-plan."""
|
||
try:
|
||
ideas = get_accepted_ideas(consultation_id=consultation_id)
|
||
texts = [i["content"] for i in ideas]
|
||
synthesized = synthesize_ideas(texts)
|
||
upsert_synthesis(synthesized, len(texts), consultation_id=consultation_id)
|
||
logger.info(
|
||
"Synthèse mise à jour (consultation_id=%s) — %d idée(s).",
|
||
consultation_id, len(texts),
|
||
)
|
||
except Exception:
|
||
logger.exception("Erreur lors de la mise à jour de la synthèse en arrière-plan")
|
||
|
||
# ─── Démarrage ────────────────────────────────────────────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
port = int(os.environ.get("PORT", 8080))
|
||
logger.info(
|
||
"La Voix du Peuple — Flask démarre sur le port %d (storage: %s)",
|
||
port, "Redis" if _redis_url else "mémoire",
|
||
)
|
||
app.run(host="0.0.0.0", port=port, debug=False)
|