""" 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) - Validation et assainissement des entrées (bleach) - CORS restreint - En-têtes de sécurité HTTP - Panel admin protégé par ADMIN_SECRET (Bearer token) """ import csv import hashlib import hmac import io import json import logging import os import threading import time import urllib.parse import urllib.request 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 database import ( init_db, 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, ) from ai_agent import filter_idea, synthesize_ideas # ─── Logging ──────────────────────────────────────────────────────────────── logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s", ) logger = logging.getLogger(__name__) # ─── Constantes anti-abus ──────────────────────────────────────────────────── # Rate limit pour POST /api/ideas (format flask-limiter) RATE_LIMIT_CONTRIBUTIONS = os.environ.get( "RATE_LIMIT_CONTRIBUTIONS", "5 per minute;3 per hour" ) # Cooldown entre deux soumissions d'une même session (secondes) CONTRIBUTION_COOLDOWN_SECONDS = int( os.environ.get("CONTRIBUTION_COOLDOWN_SECONDS", "3600") ) # Seuil de flood : nombre de soumissions / 5 min / IP avant alerte FLOOD_THRESHOLD = int(os.environ.get("FLOOD_THRESHOLD", "10")) FLOOD_WINDOW_SECONDS = 300 # 5 minutes # État interne flood detection (en mémoire, reset au redémarrage) _flood_tracker: dict[str, list[float]] = {} _flood_lock = threading.Lock() # ─── Application ──────────────────────────────────────────────────────────── app = Flask(__name__) app.config["JSON_SORT_KEYS"] = False # CORS : autorise uniquement les origines du même domaine en production CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=False) # Storage Redis si disponible, sinon mémoire (dev / instance unique) _redis_url = os.environ.get("REDIS_URL", "") _storage_uri = _redis_url if _redis_url else "memory://" limiter = Limiter( get_remote_address, app=app, default_limits=["200 per day", "60 per hour"], storage_uri=_storage_uri, strategy="fixed-window", ) # ─── 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" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " "script-src 'none'; " "object-src 'none';" ) 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 auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer ") or auth[7:] != 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 _sign_cooldown(secret: str) -> str: """Génère un token de cooldown signé HMAC-SHA256.""" ts = int(time.time()) msg = ts.to_bytes(8, "big") sig = hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()[:16] return f"{ts}.{sig}" def _verify_cooldown(cookie: str, secret: str, cooldown_seconds: int) -> bool: """Retourne True si le cooldown est encore actif pour ce cookie.""" 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()[:16] if not hmac.compare_digest(sig, expected): return False return (time.time() - ts) < cooldown_seconds 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 # Stub désactivé — pas de clé configurée 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: logger.warning("Vérification hCaptcha impossible (erreur réseau) — skip") return True # En cas d'erreur réseau, on ne bloque pas def _check_flood(ip: str, fingerprint_hash: str | None) -> bool: """ Enregistre la soumission et retourne True si le seuil de flood est dépassé. Utilise l'empreinte si disponible, sinon l'IP seule. """ key = fingerprint_hash if fingerprint_hash else ip now = time.time() with _flood_lock: times = _flood_tracker.get(key, []) # Nettoyage des timestamps expirés times = [t for t in times if now - t < FLOOD_WINDOW_SECONDS] times.append(now) _flood_tracker[key] = times return len(times) > FLOOD_THRESHOLD # ─── 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 acceptées (conformes au droit international).""" ideas = get_accepted_ideas() return jsonify([serialize_idea(i) for i in ideas]) @app.get("/api/ideas/stats") @limiter.limit("120 per minute") def idea_stats(): """Statistiques publiques : total, acceptées, rejetées.""" 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. 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 # 1. Honeypot — si le champ leurre est rempli, c'est un bot if data.get("_hp"): logger.info("Honeypot déclenché — soumission ignorée silencieusement") # Réponse 201 factice pour ne pas informer le bot return jsonify({"id": 0, "accepted": True, "reason": None, "legalBasis": None}), 201 # 2. hCaptcha (stub — skip si HCAPTCHA_SECRET_KEY non configuré) 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 # 3. Cooldown cookie — protection contre les soumissions en rafale d'une même session secret_key = os.environ.get("SECRET_KEY", "").strip() 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 # 4. Validation et assainissement validated, error = validate_idea_input(data) if error: return jsonify({"error": "validation_error", "message": error}), 400 content = validated["content"] author = validated["author"] # 5. Extraction et hashage du fingerprint (non-PII) raw_fp = request.headers.get("X-Visitor-Id", "").strip() fingerprint_hash = ( hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else None ) # 6. Détection de flood 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, ) # 7. Filtrage IA selon le cadre légal international 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 accepted: threading.Thread(target=_update_synthesis_background, daemon=True).start() # Construction de la réponse 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) # Cookie cooldown httpOnly — marque la session comme ayant contribué 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 actuelle de la Voix du Peuple.""" synthesis = get_synthesis() 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, }) # ─── Route publique : signalement ──────────────────────────────────────────── @app.post("/api/ideas//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 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 = data.get("password", "") if 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") return jsonify({"ok": True, "token": secret}) @app.get("/api/admin/stats") @require_admin def admin_stats(): """Statistiques détaillées pour l'administrateur.""" 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)))) search = request.args.get("q", "").strip() 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/") @require_admin def admin_delete_idea(idea_id: int): """Supprime une contribution et régénère la synthèse.""" deleted = delete_idea(idea_id) if not deleted: return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404 threading.Thread(target=_update_synthesis_background, daemon=True).start() 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 ids = [int(i) for i in ids if isinstance(i, (int, str)) and str(i).isdigit()] 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//override") @require_admin def admin_override_idea(idea_id: int): """Modifie manuellement le statut d'une contribution (accepter/rejeter).""" 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//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.""" 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.""" ideas, _ = get_ideas_admin(status="all", page=1, per_page=10000) output = io.StringIO() writer = csv.writer(output, quoting=csv.QUOTE_ALL) writer.writerow(["id", "content", "author", "accepted", "flagged", "flag_count", "rejection_reason", "legal_basis", "admin_note", "fingerprint_hash", "created_at"]) for idea in ideas: writer.writerow([ 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("created_at").isoformat() if idea.get("created_at") else "", ]) csv_bytes = output.getvalue().encode("utf-8-sig") return Response( csv_bytes, mimetype="text/csv", headers={"Content-Disposition": "attachment; filename=contributions.csv"}, ) # ─── Helpers ───────────────────────────────────────────────────────────────── 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") return base def _update_synthesis_background() -> None: try: ideas = get_accepted_ideas() texts = [i["content"] for i in ideas] synthesized = synthesize_ideas(texts) upsert_synthesis(synthesized, len(texts)) logger.info("Synthèse mise à jour — %d idée(s) intégrée(s).", 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("Initialisation de la base de données...") init_db() 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)