1580 lines
62 KiB
Python
1580 lines
62 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,
|
||
set_setting,
|
||
)
|
||
from ai_agent import filter_idea, synthesize_ideas, check_existing_law, get_client, get_model, list_model_ids
|
||
|
||
# ─── 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/ai/models")
|
||
@require_admin
|
||
def admin_ai_models():
|
||
"""Modèles actuellement actifs + liste de ceux accessibles avec la clé en place."""
|
||
out = {"filter": get_model("filter"), "synthesis": get_model("synthesis"), "available": [], "error": None}
|
||
try:
|
||
out["available"] = list_model_ids()
|
||
except Exception as e:
|
||
logger.warning("Admin — liste des modèles IA impossible : %s", type(e).__name__)
|
||
out["error"] = f"{type(e).__name__}: {str(e)[:200]}"
|
||
return jsonify(out)
|
||
|
||
|
||
@app.post("/api/admin/ai/models")
|
||
@require_admin
|
||
def admin_ai_set_models():
|
||
"""Enregistre les modèles filter/synthesis (chaîne vide → retour à l'env)."""
|
||
body = request.get_json(silent=True) or {}
|
||
for kind in ("filter", "synthesis"):
|
||
if kind in body:
|
||
v = str(body[kind] or "").strip()
|
||
if len(v) > 100:
|
||
return jsonify({"error": "invalid_model"}), 400
|
||
set_setting(f"{kind}_model", v or None)
|
||
logger.info("Admin — modèles IA mis à jour")
|
||
return jsonify({"ok": True, "filter": get_model("filter"), "synthesis": get_model("synthesis")})
|
||
|
||
|
||
@app.post("/api/admin/ai/test")
|
||
@require_admin
|
||
@limiter.limit("10 per minute")
|
||
def admin_ai_test():
|
||
"""Appel minimal (1 token) pour vérifier qu'un modèle répond avec la clé en place."""
|
||
model = str((request.get_json(silent=True) or {}).get("model", "")).strip()
|
||
if not model or len(model) > 100:
|
||
return jsonify({"error": "invalid_model"}), 400
|
||
try:
|
||
get_client().chat.completions.create(
|
||
model=model, max_tokens=1, messages=[{"role": "user", "content": "ping"}],
|
||
)
|
||
return jsonify({"ok": True})
|
||
except Exception as e:
|
||
return jsonify({"ok": False, "error": f"{type(e).__name__}: {str(e)[:200]}"})
|
||
|
||
|
||
@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)
|