Licence EUPL-1.2 + hardening anti-abus
P1 — Licence : - Ajout du fichier LICENSE (EUPL-1.2 complet) - README mis à jour : section licence, table docs, vars d'environnement - En-têtes EUPL ajoutés dans les fichiers sources principaux (Flask, React) P2 — Hardening anti-abus : - Rate limiting Redis-ready (REDIS_URL) avec clé fingerprint + IP - Honeypot anti-bot : champ caché côté client + vérification serveur - Fingerprinting non-PII via FingerprintJS (hash SHA-256, colonne ideas.fingerprint_hash) - Cooldown session : cookie httpOnly signé HMAC-SHA256 (SECRET_KEY requis) - Détection de flood : alerte WARNING si > FLOOD_THRESHOLD soumissions / 5 min - hCaptcha stub : intégré, activable via HCAPTCHA_SECRET_KEY + VITE_HCAPTCHA_SITE_KEY - Nouvelles dépendances : redis (backend), @fingerprintjs/fingerprintjs + @hcaptcha/react-hcaptcha (frontend) - docs/SECURITE_ANTI_ABUS.md : documentation complète des seuils et de la configuration Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
"""
|
||||
La Voix du Peuple — Agent IA
|
||||
Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
|
||||
Agent IA pour le filtrage éthique et la synthèse démocratique.
|
||||
Supporte Mistral AI, OpenAI, et les intégrations Replit AI.
|
||||
"""
|
||||
|
||||
+205
-23
@@ -1,29 +1,41 @@
|
||||
"""
|
||||
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 (flask-limiter)
|
||||
- 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 (CSP, HSTS, X-Frame-Options, etc.)
|
||||
- Protection contre l'injection via requêtes paramétrées (psycopg2)
|
||||
- En-têtes de sécurité HTTP
|
||||
- Panel admin protégé par ADMIN_SECRET (Bearer token)
|
||||
- Aucun secret exposé dans les réponses d'erreur
|
||||
"""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import os
|
||||
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, request, Response
|
||||
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
|
||||
@@ -43,27 +55,50 @@ logging.basicConfig(
|
||||
)
|
||||
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 Replit
|
||||
# CORS : autorise uniquement les origines du même domaine en production
|
||||
CORS(app, resources={r"/api/*": {"origins": "*"}}, supports_credentials=False)
|
||||
|
||||
# Rate limiting — protection anti-spam et anti-DDoS
|
||||
# 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="memory://",
|
||||
storage_uri=_storage_uri,
|
||||
strategy="fixed-window",
|
||||
)
|
||||
|
||||
# ─── En-têtes de sécurité HTTP ───────────────────────────────────────────────
|
||||
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
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"
|
||||
@@ -83,10 +118,12 @@ 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")
|
||||
@@ -119,6 +156,7 @@ def validate_idea_input(data: dict) -> tuple[dict | None, str | None]:
|
||||
def _get_admin_secret() -> str | None:
|
||||
return os.environ.get("ADMIN_SECRET")
|
||||
|
||||
|
||||
def require_admin(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
@@ -131,20 +169,95 @@ def require_admin(f):
|
||||
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({
|
||||
@@ -152,12 +265,13 @@ def rate_limit_exceeded(e):
|
||||
"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 ──────────────────────────────────────────────────────────────────
|
||||
# ─── Routes publiques ─────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/api/healthz")
|
||||
def health():
|
||||
@@ -175,18 +289,25 @@ def list_ideas():
|
||||
@app.get("/api/ideas/stats")
|
||||
@limiter.limit("120 per minute")
|
||||
def idea_stats():
|
||||
"""Statistiques : total, acceptées, rejetées."""
|
||||
"""Statistiques publiques : total, acceptées, rejetées."""
|
||||
stats = get_stats()
|
||||
return jsonify(stats)
|
||||
|
||||
|
||||
@app.post("/api/ideas")
|
||||
@limiter.limit("5 per minute; 20 per hour")
|
||||
@limiter.limit(RATE_LIMIT_CONTRIBUTIONS, key_func=get_fingerprint_key)
|
||||
def submit_idea():
|
||||
"""
|
||||
Soumet une idée citoyenne.
|
||||
L'idée est filtrée par l'agent IA selon le cadre légal international
|
||||
avant d'être intégrée dans la synthèse collective.
|
||||
|
||||
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
|
||||
@@ -195,6 +316,33 @@ def submit_idea():
|
||||
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
|
||||
@@ -202,26 +350,54 @@ def submit_idea():
|
||||
content = validated["content"]
|
||||
author = validated["author"]
|
||||
|
||||
logger.info("Filtrage d'une nouvelle idée (longueur: %d)", len(content))
|
||||
# 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)
|
||||
idea = insert_idea(content, author, accepted, rejection_reason, legal_basis, fingerprint_hash)
|
||||
|
||||
if accepted:
|
||||
# Synthèse mise à jour en arrière-plan — ne bloque pas la réponse
|
||||
threading.Thread(target=_update_synthesis_background, daemon=True).start()
|
||||
|
||||
return jsonify({
|
||||
# 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),
|
||||
}), 201
|
||||
}
|
||||
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")
|
||||
@@ -245,6 +421,7 @@ def get_synthesis_route():
|
||||
"updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None,
|
||||
})
|
||||
|
||||
|
||||
# ─── Route publique : signalement ────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/ideas/<int:idea_id>/flag")
|
||||
@@ -378,7 +555,7 @@ def admin_export_csv():
|
||||
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
|
||||
writer.writerow(["id", "content", "author", "accepted", "flagged",
|
||||
"flag_count", "rejection_reason", "legal_basis",
|
||||
"admin_note", "created_at"])
|
||||
"admin_note", "fingerprint_hash", "created_at"])
|
||||
for idea in ideas:
|
||||
writer.writerow([
|
||||
idea.get("id"),
|
||||
@@ -390,6 +567,7 @@ def admin_export_csv():
|
||||
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")
|
||||
@@ -418,10 +596,11 @@ def serialize_idea(idea: dict) -> dict:
|
||||
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():
|
||||
def _update_synthesis_background() -> None:
|
||||
try:
|
||||
ideas = get_accepted_ideas()
|
||||
texts = [i["content"] for i in ideas]
|
||||
@@ -437,5 +616,8 @@ 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", port)
|
||||
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)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""
|
||||
Couche d'accès à la base de données PostgreSQL.
|
||||
La Voix du Peuple — Couche d'accès à la base de données PostgreSQL
|
||||
Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
|
||||
Utilise psycopg2 directement — pas d'ORM, code lisible et transparent.
|
||||
"""
|
||||
import os
|
||||
@@ -32,7 +34,7 @@ def db_cursor():
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
def init_db() -> None:
|
||||
"""Crée les tables si elles n'existent pas, et applique les migrations nécessaires."""
|
||||
with db_cursor() as cur:
|
||||
cur.execute("""
|
||||
@@ -46,10 +48,12 @@ def init_db():
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
# Migrations incrémentales — idempotentes
|
||||
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS legal_basis TEXT")
|
||||
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flagged BOOLEAN NOT NULL DEFAULT FALSE")
|
||||
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flag_count INTEGER NOT NULL DEFAULT 0")
|
||||
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS admin_note TEXT")
|
||||
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS fingerprint_hash VARCHAR(64)")
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS synthesis (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -61,16 +65,22 @@ def init_db():
|
||||
logger.info("Base de données initialisée.")
|
||||
|
||||
|
||||
def insert_idea(content: str, author: str | None, accepted: bool,
|
||||
rejection_reason: str | None, legal_basis: str | None) -> dict:
|
||||
def insert_idea(
|
||||
content: str,
|
||||
author: str | None,
|
||||
accepted: bool,
|
||||
rejection_reason: str | None,
|
||||
legal_basis: str | None,
|
||||
fingerprint_hash: str | None = None,
|
||||
) -> dict:
|
||||
with db_cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO ideas (content, author, accepted, rejection_reason, legal_basis)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
INSERT INTO ideas (content, author, accepted, rejection_reason, legal_basis, fingerprint_hash)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(content, author, accepted, rejection_reason, legal_basis),
|
||||
(content, author, accepted, rejection_reason, legal_basis, fingerprint_hash),
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
|
||||
@@ -91,8 +101,12 @@ def get_all_ideas(limit: int = 50) -> list[dict]:
|
||||
return [dict(row) for row in cur.fetchall()]
|
||||
|
||||
|
||||
def get_ideas_admin(status: str = "all", page: int = 1,
|
||||
per_page: int = 50, search: str = "") -> tuple[list[dict], int]:
|
||||
def get_ideas_admin(
|
||||
status: str = "all",
|
||||
page: int = 1,
|
||||
per_page: int = 50,
|
||||
search: str = "",
|
||||
) -> tuple[list[dict], int]:
|
||||
offset = (page - 1) * per_page
|
||||
conditions = []
|
||||
params: list = []
|
||||
@@ -138,8 +152,12 @@ def bulk_delete_ideas(idea_ids: list[int]) -> int:
|
||||
return len(cur.fetchall())
|
||||
|
||||
|
||||
def override_idea(idea_id: int, accepted: bool,
|
||||
reason: str | None, note: str | None) -> dict | None:
|
||||
def override_idea(
|
||||
idea_id: int,
|
||||
accepted: bool,
|
||||
reason: str | None,
|
||||
note: str | None,
|
||||
) -> dict | None:
|
||||
with db_cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""
|
||||
La Voix du Peuple — Cadre légal de référence
|
||||
Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
|
||||
Base légale internationale ET française servant de référence pour le filtre éthique.
|
||||
|
||||
Sources internationales :
|
||||
|
||||
@@ -6,3 +6,4 @@ gunicorn>=23.0.0
|
||||
openai>=1.77.0
|
||||
psycopg2-binary>=2.9.10
|
||||
python-dotenv>=1.0.1
|
||||
redis>=5.0.0
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fingerprintjs/fingerprintjs": "^4.5.1",
|
||||
"@hcaptcha/react-hcaptcha": "^1.11.0",
|
||||
"qrcode.react": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import React from "react";
|
||||
import { Switch, Route, Router as WouterRouter, Link } from "wouter";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
@@ -10,6 +12,8 @@ import Flyer from "@/pages/flyer";
|
||||
import Admin from "@/pages/admin";
|
||||
import { AccessibilityProvider } from "@/hooks/use-accessibility";
|
||||
import { AccessibilityPanel } from "@/components/accessibility-panel";
|
||||
import { setVisitorId } from "@workspace/api-client-react";
|
||||
import FingerprintJS from "@fingerprintjs/fingerprintjs";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -77,6 +81,18 @@ function Router() {
|
||||
}
|
||||
|
||||
function App() {
|
||||
// Initialise FingerprintJS une seule fois au chargement
|
||||
// L'identifiant de visite est envoyé sur chaque appel API (header X-Visitor-Id)
|
||||
// Il est hashé côté serveur avant stockage — aucune donnée PII conservée
|
||||
React.useEffect(() => {
|
||||
FingerprintJS.load()
|
||||
.then((fp) => fp.get())
|
||||
.then((result) => setVisitorId(result.visitorId))
|
||||
.catch(() => {
|
||||
// Dégradation silencieuse si FingerprintJS indisponible
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -5,6 +6,7 @@ import { z } from "zod";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||
import {
|
||||
useSubmitIdea,
|
||||
useListIdeas,
|
||||
@@ -12,6 +14,8 @@ import {
|
||||
useGetSynthesis,
|
||||
getListIdeasQueryKey,
|
||||
getGetIdeaStatsQueryKey,
|
||||
addExtraHeader,
|
||||
removeExtraHeader,
|
||||
} from "@workspace/api-client-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
@@ -71,6 +75,9 @@ const VALEURS = [
|
||||
},
|
||||
];
|
||||
|
||||
// Clé hCaptcha — activée si la variable d'environnement est définie
|
||||
const HCAPTCHA_SITE_KEY = import.meta.env.VITE_HCAPTCHA_SITE_KEY as string | undefined;
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
||||
|
||||
export default function Home() {
|
||||
@@ -84,6 +91,13 @@ export default function Home() {
|
||||
const [flaggedIds, setFlaggedIds] = React.useState<Set<number>>(new Set());
|
||||
const [flaggingId, setFlaggingId] = React.useState<number | null>(null);
|
||||
|
||||
// Ref pour le champ leurre honeypot — invisible, non relié à react-hook-form
|
||||
const honeypotRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// hCaptcha — widget et token
|
||||
const captchaRef = React.useRef<HCaptcha>(null);
|
||||
const [captchaToken, setCaptchaToken] = React.useState<string | null>(null);
|
||||
|
||||
const handleFlag = async (ideaId: number) => {
|
||||
if (flaggedIds.has(ideaId) || flaggingId === ideaId) return;
|
||||
setFlaggingId(ideaId);
|
||||
@@ -173,6 +187,29 @@ export default function Home() {
|
||||
});
|
||||
|
||||
const onSubmit = (data: SubmitIdeaValues) => {
|
||||
// Honeypot — si le champ leurre est rempli, c'est un bot
|
||||
if (honeypotRef.current?.value) {
|
||||
// Simulation silencieuse d'un succès sans appel API
|
||||
setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." });
|
||||
form.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// hCaptcha — obligatoire si la clé de site est configurée
|
||||
if (HCAPTCHA_SITE_KEY && !captchaToken) {
|
||||
toast({
|
||||
title: "Vérification requise",
|
||||
description: "Veuillez valider le CAPTCHA avant de soumettre.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Transmission du token hCaptcha si disponible
|
||||
if (captchaToken) {
|
||||
addExtraHeader("x-hcaptcha-token", captchaToken);
|
||||
}
|
||||
|
||||
setSubmitResult(null);
|
||||
submitIdea.mutate({ data }, {
|
||||
onSuccess: (result) => {
|
||||
@@ -198,6 +235,12 @@ export default function Home() {
|
||||
message: "Une erreur est survenue lors de l'envoi. Veuillez réessayer.",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
// Nettoyage du token hCaptcha après chaque tentative
|
||||
removeExtraHeader("x-hcaptcha-token");
|
||||
captchaRef.current?.resetCaptcha();
|
||||
setCaptchaToken(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -227,6 +270,17 @@ export default function Home() {
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* Champ leurre anti-bot (honeypot) — invisible, ne jamais supprimer */}
|
||||
<input
|
||||
ref={honeypotRef}
|
||||
type="text"
|
||||
name="_hp"
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
style={{ display: "none", position: "absolute", left: "-9999px" }}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
@@ -268,7 +322,7 @@ export default function Home() {
|
||||
<Button
|
||||
type="submit"
|
||||
className="font-bold tracking-wide flex-shrink-0"
|
||||
disabled={submitIdea.isPending}
|
||||
disabled={submitIdea.isPending || (HCAPTCHA_SITE_KEY ? !captchaToken : false)}
|
||||
data-testid="button-submit-idea"
|
||||
>
|
||||
{submitIdea.isPending ? (
|
||||
@@ -278,6 +332,21 @@ export default function Home() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* hCaptcha — activé uniquement si VITE_HCAPTCHA_SITE_KEY est défini */}
|
||||
{HCAPTCHA_SITE_KEY && (
|
||||
<div className="flex justify-start">
|
||||
<HCaptcha
|
||||
ref={captchaRef}
|
||||
sitekey={HCAPTCHA_SITE_KEY}
|
||||
onVerify={(token) => setCaptchaToken(token)}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
onError={() => setCaptchaToken(null)}
|
||||
size="compact"
|
||||
languageOverride="fr"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user