Merge ARCHITECTURE.md into README, expand Légifrance section on transparence
- README: add sommaire, integrate full architecture content (diagram, components, routes, flows, security, decisions, scalability), add Légifrance articles table and PISTE setup instructions - ARCHITECTURE.md: removed (content fully merged into README) - transparence.tsx: expand Légifrance block with full table of 11 tracked articles (priority, description), cache/resilience/monitoring details Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
-460
@@ -1,460 +0,0 @@
|
|||||||
# Architecture Technique — La Voix du Peuple
|
|
||||||
|
|
||||||
**Version :** 1.1
|
|
||||||
**Date :** Juin 2026
|
|
||||||
**Statut :** Recette (homelab, pas encore de domaine public)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Vue d'ensemble
|
|
||||||
|
|
||||||
La Voix du Peuple est une plateforme démocratique citoyenne permettant la soumission de propositions politiques en texte libre, leur filtrage automatique par un agent IA ancré dans le droit international des droits humains et le droit pénal français, et leur synthèse en un texte collectif vivant.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Architecture générale
|
|
||||||
|
|
||||||
```
|
|
||||||
RÉSEAU EXTERNE (Internet)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────┐
|
|
||||||
│ HAProxy │
|
|
||||||
│ (réseau séparé) │
|
|
||||||
│ - Terminaison TLS │
|
|
||||||
│ - Load balancing │
|
|
||||||
│ - ACL / filtrage IP │
|
|
||||||
└──────────┬──────────┘
|
|
||||||
│ HTTP (port 80, réseau interne)
|
|
||||||
▼
|
|
||||||
┌─────────────────────┐
|
|
||||||
│ Nginx │
|
|
||||||
│ (reverse proxy) │
|
|
||||||
│ - Routage /api/* │
|
|
||||||
│ - Serve SPA React │
|
|
||||||
│ - Cache statiques │
|
|
||||||
└────────┬────────────┘
|
|
||||||
│
|
|
||||||
┌─────────────────┴──────────────────┐
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌───────────────────────┐ ┌───────────────────────┐
|
|
||||||
│ Gunicorn + Flask │ │ Fichiers statiques │
|
|
||||||
│ (127.0.0.1:8000) │ │ React SPA (dist/) │
|
|
||||||
│ - API REST /api/* │ │ (servis par Nginx) │
|
|
||||||
│ - Filtrage IA │ └───────────────────────┘
|
|
||||||
│ - Rate limiting │
|
|
||||||
└──────────┬────────────┘
|
|
||||||
│
|
|
||||||
┌─────────┴──────────┐
|
|
||||||
│ │
|
|
||||||
▼ ▼
|
|
||||||
┌──────────┐ ┌───────────────────┐
|
|
||||||
│PostgreSQL│ │ Mistral AI API │
|
|
||||||
│(local) │ │ (externe, UE) │
|
|
||||||
│- ideas │ │ mistral-small │
|
|
||||||
│- synth. │ │ mistral-large │
|
|
||||||
│- consult.│ │ (ou OpenAI-compat│
|
|
||||||
│- consents│ │ si configuré) │
|
|
||||||
│- ip_abuse│ └───────────────────┘
|
|
||||||
└──────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Composants
|
|
||||||
|
|
||||||
### 3.1 HAProxy (réseau séparé)
|
|
||||||
|
|
||||||
| Propriété | Valeur |
|
|
||||||
|-----------|--------|
|
|
||||||
| Rôle | Point d'entrée unique, terminaison TLS, load balancing |
|
|
||||||
| Réseau | DMZ / réseau séparé dédié |
|
|
||||||
| Protocoles entrants | HTTPS/443, HTTP/80 (redirect) |
|
|
||||||
| Protocoles sortants | HTTP/80 vers Nginx (réseau interne) |
|
|
||||||
| TLS | Terminaison SSL/TLS sur HAProxy, communication interne en HTTP clair |
|
|
||||||
|
|
||||||
Configuration recommandée dans HAProxy :
|
|
||||||
```
|
|
||||||
frontend voix_du_peuple_https
|
|
||||||
bind *:443 ssl crt /etc/ssl/certs/voix-du-peuple.pem
|
|
||||||
mode http
|
|
||||||
option forwardfor
|
|
||||||
http-request set-header X-Forwarded-Proto https
|
|
||||||
default_backend voix_du_peuple_backend
|
|
||||||
|
|
||||||
frontend voix_du_peuple_http
|
|
||||||
bind *:80
|
|
||||||
mode http
|
|
||||||
redirect scheme https code 301 if !{ ssl_fc }
|
|
||||||
|
|
||||||
backend voix_du_peuple_backend
|
|
||||||
mode http
|
|
||||||
balance roundrobin
|
|
||||||
option httpchk GET /api/healthz
|
|
||||||
http-check expect status 200
|
|
||||||
server app01 <IP_NGINX>:80 check inter 10s
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 Nginx
|
|
||||||
|
|
||||||
| Propriété | Valeur |
|
|
||||||
|-----------|--------|
|
|
||||||
| Rôle | Reverse proxy applicatif, service des fichiers statiques |
|
|
||||||
| Écoute | 0.0.0.0:80 (réseau interne uniquement) |
|
|
||||||
| Vers Flask | 127.0.0.1:8000 pour toute requête `/api/*` |
|
|
||||||
| Vers SPA | `dist/public/` pour tout le reste (`try_files`) |
|
|
||||||
|
|
||||||
Responsabilités :
|
|
||||||
- **Routage** : `/api/*` → Gunicorn, `/*` → SPA React
|
|
||||||
- **Cache** : assets JS/CSS/images avec `Cache-Control: immutable`
|
|
||||||
- **Headers de sécurité** : X-Frame-Options, X-Content-Type-Options, Referrer-Policy
|
|
||||||
- **Logging** : access.log et error.log structurés
|
|
||||||
|
|
||||||
> **Note HAProxy :** Nginx doit faire confiance à l'en-tête `X-Forwarded-For` positionné par HAProxy pour que Flask voie la vraie IP cliente (rate limiting par IP).
|
|
||||||
|
|
||||||
Configuration Nginx requise :
|
|
||||||
```nginx
|
|
||||||
set_real_ip_from <IP_HAPROXY>;
|
|
||||||
real_ip_header X-Forwarded-For;
|
|
||||||
real_ip_recursive on;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 Gunicorn + Flask (Backend API)
|
|
||||||
|
|
||||||
| Propriété | Valeur |
|
|
||||||
|-----------|--------|
|
|
||||||
| Serveur WSGI | Gunicorn 23+ |
|
|
||||||
| Workers | 4 (synchrones, ajustable) |
|
|
||||||
| Bind | 127.0.0.1:8000 (non exposé directement) |
|
|
||||||
| Framework | Flask 3.1+ |
|
|
||||||
| Python | 3.11+ |
|
|
||||||
|
|
||||||
**Routes exposées :**
|
|
||||||
|
|
||||||
| Méthode | Route | Description | Auth |
|
|
||||||
|---------|-------|-------------|------|
|
|
||||||
| GET | `/api/healthz` | Santé du service | — |
|
|
||||||
| GET | `/api/ideas` | Idées globales acceptées | — |
|
|
||||||
| POST | `/api/ideas` | Soumettre une idée globale | — |
|
|
||||||
| GET | `/api/ideas/stats` | Statistiques globales (admin) | — |
|
|
||||||
| GET | `/api/synthesis` | Synthèse collective globale | — |
|
|
||||||
| POST | `/api/consent` | Enregistrer le consentement RGPD | — |
|
|
||||||
| GET | `/api/stats/public` | Statistiques publiques globales | — |
|
|
||||||
| GET | `/api/contributions` | Contributions paginées (vue publique) | — |
|
|
||||||
| GET | `/api/contributions/export/json` | Export JSON contributions globales | — |
|
|
||||||
| GET | `/api/contributions/export/csv` | Export CSV contributions globales | — |
|
|
||||||
| POST | `/api/ideas/:id/flag` | Signaler une contribution | — |
|
|
||||||
| POST | `/api/check-law` | Vérifier si une proposition est couverte par la loi | — |
|
|
||||||
| GET | `/api/consultations` | Liste des consultations | — |
|
|
||||||
| GET | `/api/consultations/:slug` | Détails d'une consultation | — |
|
|
||||||
| GET | `/api/consultations/:slug/synthesis` | Synthèse d'une consultation | — |
|
|
||||||
| GET | `/api/consultations/:slug/contributions` | Contributions paginées d'une consultation | — |
|
|
||||||
| POST | `/api/consultations/:slug/ideas` | Soumettre une idée dans une consultation | — |
|
|
||||||
| GET | `/api/consultations/:slug/export/print` | Rendu HTML pour impression | — |
|
|
||||||
| POST | `/api/admin/login` | Authentification admin | — |
|
|
||||||
| GET | `/api/admin/stats` | Statistiques détaillées | Admin |
|
|
||||||
| GET | `/api/admin/ideas` | Liste toutes les contributions (filtres, pagination) | Admin |
|
|
||||||
| DELETE | `/api/admin/ideas/:id` | Supprimer une contribution | Admin |
|
|
||||||
| POST | `/api/admin/ideas/bulk-delete` | Suppression en masse | Admin |
|
|
||||||
| POST | `/api/admin/ideas/:id/override` | Modifier le statut d'une contribution | Admin |
|
|
||||||
| POST | `/api/admin/ideas/:id/unflag` | Retirer un signalement | Admin |
|
|
||||||
| POST | `/api/admin/synthesis/regenerate` | Régénérer la synthèse globale | Admin |
|
|
||||||
| GET | `/api/admin/export/csv` | Export CSV complet (tous champs) | Admin |
|
|
||||||
| GET | `/api/admin/consultations` | Liste toutes les consultations | Admin |
|
|
||||||
| POST | `/api/admin/consultations` | Créer une consultation | Admin |
|
|
||||||
| POST | `/api/admin/consultations/:slug/close` | Fermer une consultation | Admin |
|
|
||||||
| DELETE | `/api/admin/consultations/:slug` | Supprimer une consultation | Admin |
|
|
||||||
| GET | `/api/admin/ip-blacklist` | Liste des IPs blacklistées | Admin |
|
|
||||||
| DELETE | `/api/admin/ip-blacklist/:hash` | Lever un blacklist IP | Admin |
|
|
||||||
|
|
||||||
**Sécurité applicative :**
|
|
||||||
- Rate limiting par IP réelle ou fingerprint FingerprintJS (via `flask-limiter`)
|
|
||||||
- Assainissement XSS : `bleach.clean()` sur toutes les entrées
|
|
||||||
- Requêtes SQL paramétrées (`psycopg2`) — pas de concaténation de chaînes
|
|
||||||
- En-têtes HTTP de sécurité sur toutes les réponses
|
|
||||||
- Aucun secret exposé dans les messages d'erreur
|
|
||||||
|
|
||||||
### 3.4 Agent IA
|
|
||||||
|
|
||||||
Trois agents distincts, tous via le client OpenAI-compatible (Mistral AI par défaut) :
|
|
||||||
|
|
||||||
#### Agent de filtrage
|
|
||||||
|
|
||||||
| Propriété | Valeur |
|
|
||||||
|-----------|--------|
|
|
||||||
| Modèle par défaut | `mistral-small-latest` |
|
|
||||||
| Variable d'env | `FILTER_MODEL` (fallback : `OPENAI_FILTER_MODEL`) |
|
|
||||||
| Tokens max | 300 |
|
|
||||||
| Format de sortie | JSON strict (`response_format: json_object`) |
|
|
||||||
| Déclenchement | À chaque soumission d'idée |
|
|
||||||
|
|
||||||
Logique de décision :
|
|
||||||
1. Appel API avec le prompt légal complet (16 sources, ~350 lignes)
|
|
||||||
2. Parse JSON `{"accepted": bool, "reason"?: str, "legal_basis"?: str}`
|
|
||||||
3. Si l'API retourne une erreur de filtre de contenu → rejet automatique avec citation DUDH/PIDCP/CEDH
|
|
||||||
4. Résultat persisté en base avec l'idée
|
|
||||||
|
|
||||||
#### Agent de synthèse
|
|
||||||
|
|
||||||
| Propriété | Valeur |
|
|
||||||
|-----------|--------|
|
|
||||||
| Modèle par défaut | `mistral-large-latest` |
|
|
||||||
| Variable d'env | `SYNTHESIS_MODEL` (fallback : `OPENAI_SYNTHESIS_MODEL`) |
|
|
||||||
| Tokens max | 1200 |
|
|
||||||
| Déclenchement | En arrière-plan après chaque acceptation, ou à la suppression |
|
|
||||||
|
|
||||||
La synthèse est non-bloquante : l'API répond immédiatement à l'utilisateur, la synthèse s'effectue dans un thread daemon.
|
|
||||||
|
|
||||||
#### Agent de vérification légale
|
|
||||||
|
|
||||||
| Propriété | Valeur |
|
|
||||||
|-----------|--------|
|
|
||||||
| Modèle | même que `FILTER_MODEL` |
|
|
||||||
| Tokens max | 200 |
|
|
||||||
| Déclenchement | À la demande via `POST /api/check-law` |
|
|
||||||
| Rôle | Informer le contributeur si sa proposition est déjà couverte par une loi — non bloquant |
|
|
||||||
|
|
||||||
#### Base légale du filtre (16 sources)
|
|
||||||
|
|
||||||
| # | Texte |
|
|
||||||
|---|-------|
|
|
||||||
| 1 | Déclaration universelle des droits de l'homme (DUDH, ONU 1948) |
|
|
||||||
| 2 | Pacte international relatif aux droits civils et politiques (PIDCP, ONU 1966) |
|
|
||||||
| 3 | Convention européenne des droits de l'homme (CEDH, 1950) |
|
|
||||||
| 4 | Charte des droits fondamentaux de l'UE (2000/2009) |
|
|
||||||
| 5 | Convention pour la prévention du génocide (ONU 1948) |
|
|
||||||
| 6 | Statut de Rome / CPI (1998) |
|
|
||||||
| 7 | Convention sur la discrimination raciale (CERD, ONU 1965) |
|
|
||||||
| 8-13 | Code pénal français — Livres I à VI |
|
|
||||||
| 14 | Loi sur la liberté de la presse du 29 juillet 1881 |
|
|
||||||
| 15 | LCEN (2004) · Loi SREN (2024) · RGPD · Code de la santé publique |
|
|
||||||
| 16 | Principes constitutionnels (CC 94-343/344 DC) · Code civil Art. 9, 1240 |
|
|
||||||
|
|
||||||
Le texte intégral des prompts est consultable sur la page [Fonctionnement](/transparence) du site.
|
|
||||||
|
|
||||||
### 3.5 PostgreSQL
|
|
||||||
|
|
||||||
| Propriété | Valeur |
|
|
||||||
|-----------|--------|
|
|
||||||
| Version | 15+ |
|
|
||||||
| Accès | Localhost uniquement (127.0.0.1) |
|
|
||||||
| Connexion Flask | Via `DATABASE_URL` (psycopg2-binary) |
|
|
||||||
| Authentification | scram-sha-256 |
|
|
||||||
| Migrations | Incrémentales et idempotentes — appliquées à chaque démarrage |
|
|
||||||
|
|
||||||
**Schéma de la base de données :**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE consultations (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
|
||||||
title VARCHAR(200) NOT NULL,
|
|
||||||
subject TEXT NOT NULL,
|
|
||||||
intro_message TEXT,
|
|
||||||
organizer_name VARCHAR(200),
|
|
||||||
organizer_logo_url TEXT,
|
|
||||||
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
ends_at TIMESTAMPTZ,
|
|
||||||
closed_at TIMESTAMPTZ,
|
|
||||||
webhook_url TEXT,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE ideas (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
author VARCHAR(100),
|
|
||||||
accepted BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
rejection_reason TEXT,
|
|
||||||
legal_basis TEXT,
|
|
||||||
flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
flag_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
admin_note TEXT,
|
|
||||||
fingerprint_hash VARCHAR(64),
|
|
||||||
consultation_id INTEGER REFERENCES consultations(id) ON DELETE SET NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
-- consultation_id IS NULL → contribution globale (page d'accueil)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE synthesis (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
text TEXT NOT NULL,
|
|
||||||
idea_count INTEGER NOT NULL DEFAULT 0,
|
|
||||||
consultation_id INTEGER REFERENCES consultations(id) ON DELETE CASCADE,
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
-- consultation_id IS NULL → synthèse globale
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE consents (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
fingerprint_hash VARCHAR(64) NOT NULL,
|
|
||||||
consent_version VARCHAR(20) NOT NULL,
|
|
||||||
consented_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE ip_abuse (
|
|
||||||
ip_hash TEXT PRIMARY KEY, -- SHA-256 de l'IP (RGPD)
|
|
||||||
rejection_count INTEGER NOT NULL DEFAULT 1,
|
|
||||||
first_rejected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
last_rejected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
last_idea_id INTEGER,
|
|
||||||
blacklisted_at TIMESTAMPTZ,
|
|
||||||
expires_at TIMESTAMPTZ
|
|
||||||
-- blacklist déclenché à rejection_count >= 2
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.6 Frontend React
|
|
||||||
|
|
||||||
| Propriété | Valeur |
|
|
||||||
|-----------|--------|
|
|
||||||
| Framework | React 18 + Vite 7 |
|
|
||||||
| Styles | Tailwind CSS v4 + shadcn/ui + Radix UI |
|
|
||||||
| Routing | Wouter (léger, côté client) |
|
|
||||||
| Requêtes API | TanStack React Query |
|
|
||||||
| Build | Sortie statique dans `dist/public/` |
|
|
||||||
|
|
||||||
**Communication avec le backend :**
|
|
||||||
- Toutes les requêtes passent par `/api/` (même origine, pas de CORS cross-origin en production)
|
|
||||||
- Rafraîchissement automatique de la synthèse toutes les 15 secondes
|
|
||||||
|
|
||||||
**Pages publiques :**
|
|
||||||
|
|
||||||
| Route | Composant | Description |
|
|
||||||
|-------|-----------|-------------|
|
|
||||||
| `/` | Home | Formulaire de soumission + synthèse (onglets mobile) |
|
|
||||||
| `/about` | About | Présentation du projet |
|
|
||||||
| `/transparence` | Transparence | Fonctionnement, critères IA, prompts verbatim |
|
|
||||||
| `/contributions-brutes` | ContributionsBrutes | Fil paginé des contributions acceptées |
|
|
||||||
| `/consultations` | ConsultationsList | Index des consultations actives et clôturées |
|
|
||||||
| `/consultation/:slug` | ConsultationPage | Page d'une consultation ciblée |
|
|
||||||
| `/flyer` | Flyer | Flyer QR code imprimable |
|
|
||||||
| `/mentions-legales` | LegalNotice | Mentions légales (LCEN) |
|
|
||||||
| `/politique-confidentialite` | PrivacyPolicy | Politique de confidentialité RGPD |
|
|
||||||
| `/admin` | Admin | Panel d'administration (protégé) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Flux de données
|
|
||||||
|
|
||||||
### 4.1 Soumission d'une idée
|
|
||||||
|
|
||||||
```
|
|
||||||
Citoyen
|
|
||||||
│
|
|
||||||
│ POST /api/ideas {"content": "...", "author": "..."}
|
|
||||||
▼
|
|
||||||
HAProxy ──[TLS]──► Nginx ──► Gunicorn/Flask
|
|
||||||
│
|
|
||||||
├─ Vérification blacklist IP
|
|
||||||
├─ Honeypot / hCaptcha
|
|
||||||
├─ Consentement RGPD (cookie _ct)
|
|
||||||
├─ Cooldown session (cookie _cv)
|
|
||||||
├─ Validation entrées (longueur, XSS)
|
|
||||||
├─ Rate limiting (fingerprint ou IP)
|
|
||||||
├─ Flood detection
|
|
||||||
│
|
|
||||||
├─ Agent filtrage ──► Mistral AI API
|
|
||||||
│ │
|
|
||||||
│ JSON {"accepted": bool, ...}
|
|
||||||
│
|
|
||||||
├─ INSERT INTO ideas (...)
|
|
||||||
│
|
|
||||||
├─ Si refusé :
|
|
||||||
│ record_ip_rejection() → blacklist si >= 2 rejets
|
|
||||||
│
|
|
||||||
├─ Si accepté :
|
|
||||||
│ Thread daemon ──► Agent synthèse ──► Mistral AI
|
|
||||||
│ │
|
|
||||||
│ UPSERT synthesis
|
|
||||||
│
|
|
||||||
◄── HTTP 201 {"accepted": bool, "reason": "...", "idea": {...}} ──────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 Lecture de la synthèse
|
|
||||||
|
|
||||||
```
|
|
||||||
Citoyen (polling 15s)
|
|
||||||
│
|
|
||||||
│ GET /api/synthesis
|
|
||||||
▼
|
|
||||||
HAProxy ──► Nginx ──► Flask ──► SELECT FROM synthesis ──► {"text": "...", "ideaCount": N}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 Fermeture automatique des consultations
|
|
||||||
|
|
||||||
```
|
|
||||||
Thread daemon (toutes les 60s, par worker)
|
|
||||||
│
|
|
||||||
├─ pg_try_advisory_lock() — un seul worker exécute le check
|
|
||||||
├─ SELECT consultations WHERE ends_at < NOW() AND closed_at IS NULL
|
|
||||||
├─ UPDATE consultations SET closed_at = NOW()
|
|
||||||
└─ Thread ──► webhook (non-bloquant)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Sécurité
|
|
||||||
|
|
||||||
### 5.1 Couches de défense
|
|
||||||
|
|
||||||
| Couche | Mécanisme |
|
|
||||||
|--------|-----------|
|
|
||||||
| Réseau | HAProxy sur réseau séparé, Nginx non exposé directement |
|
|
||||||
| Transport | TLS 1.2/1.3 terminé sur HAProxy |
|
|
||||||
| Applicative Flask | Rate limiting, validation, assainissement XSS, headers sécurité |
|
|
||||||
| Anti-abus | Honeypot · hCaptcha (optionnel) · cooldown cookie · flood detection · blacklist IP (SHA-256, 30 jours) |
|
|
||||||
| IA | Filtre de contenu Mistral AI + filtre légal interne (16 sources) |
|
|
||||||
| Base de données | Requêtes paramétrées, accès localhost uniquement |
|
|
||||||
| Système | Utilisateur dédié non-root, systemd sandboxing |
|
|
||||||
|
|
||||||
### 5.2 Périmètre des ports réseau
|
|
||||||
|
|
||||||
| Port | Interface | Exposé à | Rôle |
|
|
||||||
|------|-----------|----------|------|
|
|
||||||
| 443 | HAProxy | Internet | HTTPS public |
|
|
||||||
| 80 | HAProxy | Internet | Redirect HTTPS |
|
|
||||||
| 80 | Nginx | Réseau interne | HTTP depuis HAProxy |
|
|
||||||
| 8000 | Gunicorn | 127.0.0.1 | API Flask |
|
|
||||||
| 5432 | PostgreSQL | 127.0.0.1 | Base de données |
|
|
||||||
|
|
||||||
### 5.3 En-têtes HTTP de sécurité (Flask)
|
|
||||||
|
|
||||||
```
|
|
||||||
X-Content-Type-Options: nosniff
|
|
||||||
X-Frame-Options: DENY
|
|
||||||
X-XSS-Protection: 1; mode=block
|
|
||||||
Referrer-Policy: strict-origin-when-cross-origin
|
|
||||||
Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none'
|
|
||||||
Cache-Control: no-store
|
|
||||||
Pragma: no-cache
|
|
||||||
```
|
|
||||||
|
|
||||||
Exception : `/api/consultations/:slug/export/print` autorise `script-src 'unsafe-inline'` pour `window.print()`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Décisions d'architecture
|
|
||||||
|
|
||||||
| Décision | Choix | Justification |
|
|
||||||
|----------|-------|---------------|
|
|
||||||
| ORM vs SQL direct | psycopg2 direct | Transparence, simplicité, facilité d'audit |
|
|
||||||
| Sync vs Async Flask | Synchrone (Gunicorn) | Complexité réduite, suffisant pour la charge attendue |
|
|
||||||
| Synthèse bloquante vs thread | Thread daemon | Latence utilisateur réduite, synthèse non-critique au retour |
|
|
||||||
| TLS sur HAProxy vs Nginx | HAProxy | TLS centralisé sur le composant réseau dédié à cet usage |
|
|
||||||
| SPA vs SSR | SPA statique | Déploiement simple, aucune dépendance Node en production |
|
|
||||||
| Rate limiting en mémoire vs Redis | Mémoire (`memory://`) | Installation simple ; Redis optionnel pour cluster multi-instances |
|
|
||||||
| Fournisseur IA | Mistral AI (défaut) | Hébergement UE, conformité RGPD, API compatible OpenAI |
|
|
||||||
| Stockage IP | SHA-256 (32 car.) | Anti-abus sans stocker l'IP brute — conformité RGPD art. 6(1)(f) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Scalabilité
|
|
||||||
|
|
||||||
L'architecture actuelle est dimensionnée pour une instance unique. En cas de montée en charge :
|
|
||||||
|
|
||||||
- **Gunicorn workers** : augmenter via `--workers` (règle : `2 × CPU + 1`)
|
|
||||||
- **Multi-instances** : ajouter des backends dans HAProxy + basculer le rate limiting sur Redis (`REDIS_URL=redis://...`)
|
|
||||||
- **Base de données** : index existants sur `ideas.consultation_id`, `synthesis.consultation_id`, `consents.fingerprint_hash`, `ip_abuse.expires_at` ; ajouter un index sur `ideas.accepted` si la table grossit
|
|
||||||
- **Cache synthèse** : la synthèse est unique en base par contexte, naturellement partagée entre workers
|
|
||||||
@@ -11,11 +11,24 @@ La Voix du Peuple est un projet personnel open-source. Si vous l'utilisez et vou
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Sommaire
|
||||||
|
|
||||||
|
1. [Fonctionnalités](#fonctionnalités)
|
||||||
|
2. [Architecture](#architecture)
|
||||||
|
3. [Stack technique](#stack-technique)
|
||||||
|
4. [Installation et développement local](#installation-et-développement-local)
|
||||||
|
5. [Déploiement en production](#déploiement-en-production)
|
||||||
|
6. [Contribuer](#contribuer)
|
||||||
|
7. [Licence](#licence)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Fonctionnalités
|
## Fonctionnalités
|
||||||
|
|
||||||
**Contribution citoyenne**
|
**Contribution citoyenne**
|
||||||
- Formulaire texte libre (10 à 1 000 caractères), pseudonyme optionnel
|
- Formulaire texte libre (10 à 1 000 caractères), pseudonyme optionnel
|
||||||
- Filtrage IA automatique à la soumission selon quinze textes de référence : DUDH, PIDCP, CEDH, Code pénal français, Loi du 29 juillet 1881, LCEN, SREN 2024, RGPD, et autres
|
- Filtrage IA automatique à la soumission selon quinze textes de référence : DUDH, PIDCP, CEDH, Code pénal français, Loi du 29 juillet 1881, LCEN, SREN 2024, RGPD, et autres
|
||||||
|
- Injection dynamique des textes légaux consolidés depuis l'API officielle Légifrance (PISTE) — le modèle travaille sur la version du jour, pas sur sa mémoire d'entraînement
|
||||||
- Motif de rejet et base légale retournés au contributeur si la contribution est refusée
|
- Motif de rejet et base légale retournés au contributeur si la contribution est refusée
|
||||||
- Dialogue de recueil du consentement RGPD avant soumission
|
- Dialogue de recueil du consentement RGPD avant soumission
|
||||||
|
|
||||||
@@ -65,6 +78,462 @@ La Voix du Peuple est un projet personnel open-source. Si vous l'utilisez et vou
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Vue d'ensemble
|
||||||
|
|
||||||
|
```
|
||||||
|
RÉSEAU EXTERNE (Internet)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ HAProxy │
|
||||||
|
│ (réseau séparé) │
|
||||||
|
│ - Terminaison TLS │
|
||||||
|
│ - Load balancing │
|
||||||
|
│ - ACL / filtrage IP │
|
||||||
|
└──────────┬──────────┘
|
||||||
|
│ HTTP (port 80, réseau interne)
|
||||||
|
▼
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ Nginx │
|
||||||
|
│ (reverse proxy) │
|
||||||
|
│ - Routage /api/* │
|
||||||
|
│ - Serve SPA React │
|
||||||
|
│ - Cache statiques │
|
||||||
|
└────────┬────────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────┴──────────────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌───────────────────────┐ ┌───────────────────────┐
|
||||||
|
│ Gunicorn + Flask │ │ Fichiers statiques │
|
||||||
|
│ (127.0.0.1:8000) │ │ React SPA (dist/) │
|
||||||
|
│ - API REST /api/* │ │ (servis par Nginx) │
|
||||||
|
│ - Filtrage IA │ └───────────────────────┘
|
||||||
|
│ - Rate limiting │
|
||||||
|
└──────────┬────────────┘
|
||||||
|
│
|
||||||
|
┌─────────┴──────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────┐ ┌───────────────────┐
|
||||||
|
│PostgreSQL│ │ Mistral AI API │
|
||||||
|
│(local) │ │ (externe, UE) │
|
||||||
|
│- ideas │ │ mistral-small │
|
||||||
|
│- synth. │ │ mistral-large │
|
||||||
|
│- consult.│ │ (ou OpenAI-compat│
|
||||||
|
│- consents│ │ si configuré) │
|
||||||
|
│- ip_abuse│ └───────────────────┘
|
||||||
|
└──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### HAProxy
|
||||||
|
|
||||||
|
| Propriété | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Rôle | Point d'entrée unique, terminaison TLS, load balancing |
|
||||||
|
| Réseau | DMZ / réseau séparé dédié |
|
||||||
|
| Protocoles entrants | HTTPS/443, HTTP/80 (redirect) |
|
||||||
|
| Protocoles sortants | HTTP/80 vers Nginx (réseau interne) |
|
||||||
|
| TLS | Terminaison SSL/TLS sur HAProxy, communication interne en HTTP clair |
|
||||||
|
|
||||||
|
Configuration de référence :
|
||||||
|
|
||||||
|
```haproxy
|
||||||
|
frontend voix_du_peuple_https
|
||||||
|
bind *:443 ssl crt /etc/ssl/certs/voix-du-peuple.pem
|
||||||
|
mode http
|
||||||
|
option forwardfor
|
||||||
|
http-request set-header X-Forwarded-Proto https
|
||||||
|
default_backend voix_du_peuple_backend
|
||||||
|
|
||||||
|
frontend voix_du_peuple_http
|
||||||
|
bind *:80
|
||||||
|
mode http
|
||||||
|
redirect scheme https code 301 if !{ ssl_fc }
|
||||||
|
|
||||||
|
backend voix_du_peuple_backend
|
||||||
|
mode http
|
||||||
|
balance roundrobin
|
||||||
|
option httpchk GET /api/healthz
|
||||||
|
http-check expect status 200
|
||||||
|
server app01 <IP_NGINX>:80 check inter 10s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nginx
|
||||||
|
|
||||||
|
| Propriété | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Rôle | Reverse proxy applicatif, service des fichiers statiques |
|
||||||
|
| Écoute | 0.0.0.0:80 (réseau interne uniquement) |
|
||||||
|
| Vers Flask | 127.0.0.1:8000 pour toute requête `/api/*` |
|
||||||
|
| Vers SPA | `dist/public/` pour tout le reste (`try_files`) |
|
||||||
|
|
||||||
|
Nginx doit faire confiance à l'en-tête `X-Forwarded-For` positionné par HAProxy pour que Flask voie la vraie IP cliente (rate limiting par IP) :
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
set_real_ip_from <IP_HAPROXY>;
|
||||||
|
real_ip_header X-Forwarded-For;
|
||||||
|
real_ip_recursive on;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gunicorn + Flask (Backend API)
|
||||||
|
|
||||||
|
| Propriété | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Serveur WSGI | Gunicorn 23+ |
|
||||||
|
| Workers | 4 (synchrones, ajustable) |
|
||||||
|
| Bind | 127.0.0.1:8000 (non exposé directement) |
|
||||||
|
| Framework | Flask 3.1+ |
|
||||||
|
| Python | 3.11+ |
|
||||||
|
|
||||||
|
**Routes exposées :**
|
||||||
|
|
||||||
|
| Méthode | Route | Description | Auth |
|
||||||
|
|---------|-------|-------------|------|
|
||||||
|
| GET | `/api/healthz` | Santé du service | — |
|
||||||
|
| GET | `/api/ideas` | Idées globales acceptées | — |
|
||||||
|
| POST | `/api/ideas` | Soumettre une idée globale | — |
|
||||||
|
| GET | `/api/ideas/stats` | Statistiques globales (admin) | — |
|
||||||
|
| GET | `/api/synthesis` | Synthèse collective globale | — |
|
||||||
|
| POST | `/api/consent` | Enregistrer le consentement RGPD | — |
|
||||||
|
| GET | `/api/stats/public` | Statistiques publiques globales | — |
|
||||||
|
| GET | `/api/contributions` | Contributions paginées (vue publique) | — |
|
||||||
|
| GET | `/api/contributions/export/json` | Export JSON contributions globales | — |
|
||||||
|
| GET | `/api/contributions/export/csv` | Export CSV contributions globales | — |
|
||||||
|
| POST | `/api/ideas/:id/flag` | Signaler une contribution | — |
|
||||||
|
| POST | `/api/check-law` | Vérifier si une proposition est couverte par la loi | — |
|
||||||
|
| GET | `/api/consultations` | Liste des consultations | — |
|
||||||
|
| GET | `/api/consultations/:slug` | Détails d'une consultation | — |
|
||||||
|
| GET | `/api/consultations/:slug/synthesis` | Synthèse d'une consultation | — |
|
||||||
|
| GET | `/api/consultations/:slug/contributions` | Contributions paginées d'une consultation | — |
|
||||||
|
| POST | `/api/consultations/:slug/ideas` | Soumettre une idée dans une consultation | — |
|
||||||
|
| GET | `/api/consultations/:slug/export/print` | Rendu HTML pour impression | — |
|
||||||
|
| POST | `/api/admin/login` | Authentification admin | — |
|
||||||
|
| GET | `/api/admin/stats` | Statistiques détaillées | Admin |
|
||||||
|
| GET | `/api/admin/ideas` | Liste toutes les contributions (filtres, pagination) | Admin |
|
||||||
|
| DELETE | `/api/admin/ideas/:id` | Supprimer une contribution | Admin |
|
||||||
|
| POST | `/api/admin/ideas/bulk-delete` | Suppression en masse | Admin |
|
||||||
|
| POST | `/api/admin/ideas/:id/override` | Modifier le statut d'une contribution | Admin |
|
||||||
|
| POST | `/api/admin/ideas/:id/unflag` | Retirer un signalement | Admin |
|
||||||
|
| POST | `/api/admin/synthesis/regenerate` | Régénérer la synthèse globale | Admin |
|
||||||
|
| GET | `/api/admin/export/csv` | Export CSV complet (tous champs) | Admin |
|
||||||
|
| GET | `/api/admin/consultations` | Liste toutes les consultations | Admin |
|
||||||
|
| POST | `/api/admin/consultations` | Créer une consultation | Admin |
|
||||||
|
| POST | `/api/admin/consultations/:slug/close` | Fermer une consultation | Admin |
|
||||||
|
| DELETE | `/api/admin/consultations/:slug` | Supprimer une consultation | Admin |
|
||||||
|
| GET | `/api/admin/ip-blacklist` | Liste des IPs blacklistées | Admin |
|
||||||
|
| DELETE | `/api/admin/ip-blacklist/:hash` | Lever un blacklist IP | Admin |
|
||||||
|
|
||||||
|
**Sécurité applicative :**
|
||||||
|
- Rate limiting par IP réelle ou fingerprint FingerprintJS (via `flask-limiter`)
|
||||||
|
- Assainissement XSS : `bleach.clean()` sur toutes les entrées
|
||||||
|
- Requêtes SQL paramétrées (`psycopg2`) — pas de concaténation de chaînes
|
||||||
|
- En-têtes HTTP de sécurité sur toutes les réponses
|
||||||
|
- Aucun secret exposé dans les messages d'erreur
|
||||||
|
|
||||||
|
### Agent IA
|
||||||
|
|
||||||
|
Trois agents distincts, tous via le client OpenAI-compatible (Mistral AI par défaut).
|
||||||
|
|
||||||
|
#### Agent de filtrage
|
||||||
|
|
||||||
|
| Propriété | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Modèle par défaut | `mistral-small-latest` |
|
||||||
|
| Variable d'env | `FILTER_MODEL` |
|
||||||
|
| Tokens max | 300 |
|
||||||
|
| Format de sortie | JSON strict (`response_format: json_object`) |
|
||||||
|
| Déclenchement | À chaque soumission d'idée |
|
||||||
|
|
||||||
|
Logique de décision :
|
||||||
|
1. Récupération des textes légaux consolidés depuis Légifrance (PISTE) — cache 24 h, fallback statique si API indisponible
|
||||||
|
2. Appel API avec le prompt légal complet (16 sources + textes injectés)
|
||||||
|
3. Parse JSON `{"accepted": bool, "reason"?: str, "legal_basis"?: str}`
|
||||||
|
4. Si l'API retourne une erreur de filtre de contenu → rejet automatique avec citation DUDH/PIDCP/CEDH
|
||||||
|
5. Résultat persisté en base avec l'idée
|
||||||
|
|
||||||
|
#### Agent de synthèse
|
||||||
|
|
||||||
|
| Propriété | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Modèle par défaut | `mistral-large-latest` |
|
||||||
|
| Variable d'env | `SYNTHESIS_MODEL` |
|
||||||
|
| Tokens max | 1200 |
|
||||||
|
| Déclenchement | En arrière-plan après chaque acceptation, ou à la suppression |
|
||||||
|
|
||||||
|
La synthèse est non-bloquante : l'API répond immédiatement à l'utilisateur, la synthèse s'effectue dans un thread daemon.
|
||||||
|
|
||||||
|
#### Agent de vérification légale
|
||||||
|
|
||||||
|
| Propriété | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Modèle | même que `FILTER_MODEL` |
|
||||||
|
| Tokens max | 200 |
|
||||||
|
| Déclenchement | À la demande via `POST /api/check-law` |
|
||||||
|
| Rôle | Informer le contributeur si sa proposition est déjà couverte par une loi — non bloquant |
|
||||||
|
|
||||||
|
#### Base légale du filtre (16 sources)
|
||||||
|
|
||||||
|
| # | Texte |
|
||||||
|
|---|-------|
|
||||||
|
| 1 | Déclaration universelle des droits de l'homme (DUDH, ONU 1948) |
|
||||||
|
| 2 | Pacte international relatif aux droits civils et politiques (PIDCP, ONU 1966) |
|
||||||
|
| 3 | Convention européenne des droits de l'homme (CEDH, 1950) |
|
||||||
|
| 4 | Charte des droits fondamentaux de l'UE (2000/2009) |
|
||||||
|
| 5 | Convention pour la prévention du génocide (ONU 1948) |
|
||||||
|
| 6 | Statut de Rome / CPI (1998) |
|
||||||
|
| 7 | Convention sur la discrimination raciale (CERD, ONU 1965) |
|
||||||
|
| 8-13 | Code pénal français — Livres I à VI |
|
||||||
|
| 14 | Loi sur la liberté de la presse du 29 juillet 1881 |
|
||||||
|
| 15 | LCEN (2004) · Loi SREN (2024) · RGPD · Code de la santé publique |
|
||||||
|
| 16 | Principes constitutionnels (CC 94-343/344 DC) · Code civil Art. 9, 1240 |
|
||||||
|
|
||||||
|
#### Intégration Légifrance (PISTE)
|
||||||
|
|
||||||
|
Les textes des articles les plus sensibles sont récupérés à chaque filtrage depuis l'API officielle Légifrance (PISTE) et injectés dans le prompt avant les critères d'analyse. Le modèle travaille ainsi sur les textes consolidés du jour.
|
||||||
|
|
||||||
|
Articles surveillés (vérifiés et mis à jour via `scripts/check_legal_refs.py`) :
|
||||||
|
|
||||||
|
| Clé | Référence | Description | Priorité |
|
||||||
|
|-----|-----------|-------------|----------|
|
||||||
|
| CP-225-1 | Code pénal Art. 225-1 | Discrimination | Critique |
|
||||||
|
| L1881-24 | Loi 1881 Art. 24 | Provocation à la haine | Critique |
|
||||||
|
| CP-222-33 | Code pénal Art. 222-33 | Harcèlement sexuel | Haute |
|
||||||
|
| CP-222-33-2-2 | Code pénal Art. 222-33-2-2 | Cyberharcèlement en meute | Haute |
|
||||||
|
| CP-421-2-5 | Code pénal Art. 421-2-5 | Apologie du terrorisme | Haute |
|
||||||
|
| CP-211-1 | Code pénal Art. 211-1 | Génocide | Haute |
|
||||||
|
| L1881-24bis | Loi 1881 Art. 24 bis | Négationnisme | Haute |
|
||||||
|
| CP-226-4-1 | Code pénal Art. 226-4-1 | Usurpation d'identité numérique | Haute |
|
||||||
|
| CP-212-1 | Code pénal Art. 212-1 | Crimes contre l'humanité | Moyenne |
|
||||||
|
| CP-223-13 | Code pénal Art. 223-13 | Provocation au suicide | Moyenne |
|
||||||
|
| L1881-29 | Loi 1881 Art. 29 | Diffamation | Moyenne |
|
||||||
|
|
||||||
|
Les articles de priorité **critique** et **haute** sont injectés dans le prompt. Les articles de priorité **moyenne** sont suivis pour détecter les changements législatifs mais ne sont pas injectés (le cadre légal statique les couvre déjà).
|
||||||
|
|
||||||
|
Le script `scripts/check_legal_refs.py --check` compare les textes courants avec la baseline stockée dans `artifacts/flask-api/legal_refs.yaml`. Exit code 2 si des modifications sont détectées — intégrable dans un cron ou une pipeline CI.
|
||||||
|
|
||||||
|
### PostgreSQL
|
||||||
|
|
||||||
|
| Propriété | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Version | 15+ |
|
||||||
|
| Accès | Localhost uniquement (127.0.0.1) |
|
||||||
|
| Connexion Flask | Via `DATABASE_URL` (psycopg2-binary) |
|
||||||
|
| Authentification | scram-sha-256 |
|
||||||
|
| Migrations | Incrémentales et idempotentes — appliquées à chaque démarrage |
|
||||||
|
|
||||||
|
**Schéma de la base de données :**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE consultations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
intro_message TEXT,
|
||||||
|
organizer_name VARCHAR(200),
|
||||||
|
organizer_logo_url TEXT,
|
||||||
|
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
ends_at TIMESTAMPTZ,
|
||||||
|
closed_at TIMESTAMPTZ,
|
||||||
|
webhook_url TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ideas (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
author VARCHAR(100),
|
||||||
|
accepted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
rejection_reason TEXT,
|
||||||
|
legal_basis TEXT,
|
||||||
|
flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
flag_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
admin_note TEXT,
|
||||||
|
fingerprint_hash VARCHAR(64),
|
||||||
|
consultation_id INTEGER REFERENCES consultations(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
-- consultation_id IS NULL → contribution globale (page d'accueil)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE synthesis (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
idea_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
consultation_id INTEGER REFERENCES consultations(id) ON DELETE CASCADE,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
-- consultation_id IS NULL → synthèse globale
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE consents (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
fingerprint_hash VARCHAR(64) NOT NULL,
|
||||||
|
consent_version VARCHAR(20) NOT NULL,
|
||||||
|
consented_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ip_abuse (
|
||||||
|
ip_hash TEXT PRIMARY KEY, -- SHA-256 de l'IP (RGPD)
|
||||||
|
rejection_count INTEGER NOT NULL DEFAULT 1,
|
||||||
|
first_rejected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_rejected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_idea_id INTEGER,
|
||||||
|
blacklisted_at TIMESTAMPTZ,
|
||||||
|
expires_at TIMESTAMPTZ
|
||||||
|
-- blacklist déclenché à rejection_count >= 2
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend React
|
||||||
|
|
||||||
|
| Propriété | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Framework | React 18 + Vite 7 |
|
||||||
|
| Styles | Tailwind CSS v4 + shadcn/ui + Radix UI |
|
||||||
|
| Routing | Wouter (léger, côté client) |
|
||||||
|
| Requêtes API | TanStack React Query |
|
||||||
|
| Build | Sortie statique dans `dist/public/` |
|
||||||
|
|
||||||
|
Communication avec le backend : toutes les requêtes passent par `/api/` (même origine). La synthèse est rafraîchie automatiquement toutes les 15 secondes.
|
||||||
|
|
||||||
|
**Pages publiques :**
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `/` | Formulaire de soumission + synthèse |
|
||||||
|
| `/about` | Présentation du projet |
|
||||||
|
| `/transparence` | Fonctionnement, critères IA, prompts verbatim |
|
||||||
|
| `/contributions-brutes` | Fil paginé des contributions acceptées |
|
||||||
|
| `/consultations` | Index des consultations |
|
||||||
|
| `/consultation/:slug` | Page d'une consultation ciblée |
|
||||||
|
| `/flyer` | Flyer QR code imprimable |
|
||||||
|
| `/mentions-legales` | Mentions légales (LCEN) |
|
||||||
|
| `/politique-confidentialite` | Politique de confidentialité RGPD |
|
||||||
|
| `/admin` | Panel d'administration (protégé) |
|
||||||
|
|
||||||
|
### Flux de données
|
||||||
|
|
||||||
|
#### Soumission d'une idée
|
||||||
|
|
||||||
|
```
|
||||||
|
Citoyen
|
||||||
|
│
|
||||||
|
│ POST /api/ideas {"content": "...", "author": "..."}
|
||||||
|
▼
|
||||||
|
HAProxy ──[TLS]──► Nginx ──► Gunicorn/Flask
|
||||||
|
│
|
||||||
|
├─ Vérification blacklist IP
|
||||||
|
├─ Honeypot / hCaptcha
|
||||||
|
├─ Consentement RGPD (cookie _ct)
|
||||||
|
├─ Cooldown session (cookie _cv)
|
||||||
|
├─ Validation entrées (longueur, XSS)
|
||||||
|
├─ Rate limiting (fingerprint ou IP)
|
||||||
|
├─ Flood detection
|
||||||
|
│
|
||||||
|
├─ Fetch textes Légifrance (cache 24h)
|
||||||
|
├─ Agent filtrage ──► Mistral AI API
|
||||||
|
│ │
|
||||||
|
│ JSON {"accepted": bool, ...}
|
||||||
|
│
|
||||||
|
├─ INSERT INTO ideas (...)
|
||||||
|
│
|
||||||
|
├─ Si refusé :
|
||||||
|
│ record_ip_rejection() → blacklist si >= 2 rejets
|
||||||
|
│
|
||||||
|
├─ Si accepté :
|
||||||
|
│ Thread daemon ──► Agent synthèse ──► Mistral AI
|
||||||
|
│ │
|
||||||
|
│ UPSERT synthesis
|
||||||
|
│
|
||||||
|
◄── HTTP 201 {"accepted": bool, "reason": "...", "idea": {...}} ──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Lecture de la synthèse
|
||||||
|
|
||||||
|
```
|
||||||
|
Citoyen (polling 15s)
|
||||||
|
│
|
||||||
|
│ GET /api/synthesis
|
||||||
|
▼
|
||||||
|
HAProxy ──► Nginx ──► Flask ──► SELECT FROM synthesis ──► {"text": "...", "ideaCount": N}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fermeture automatique des consultations
|
||||||
|
|
||||||
|
```
|
||||||
|
Thread daemon (toutes les 60s, par worker)
|
||||||
|
│
|
||||||
|
├─ pg_try_advisory_lock() — un seul worker exécute le check
|
||||||
|
├─ SELECT consultations WHERE ends_at < NOW() AND closed_at IS NULL
|
||||||
|
├─ UPDATE consultations SET closed_at = NOW()
|
||||||
|
└─ Thread ──► webhook (non-bloquant)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
|
||||||
|
#### Couches de défense
|
||||||
|
|
||||||
|
| Couche | Mécanisme |
|
||||||
|
|--------|-----------|
|
||||||
|
| Réseau | HAProxy sur réseau séparé, Nginx non exposé directement |
|
||||||
|
| Transport | TLS 1.2/1.3 terminé sur HAProxy |
|
||||||
|
| Applicative Flask | Rate limiting, validation, assainissement XSS, headers sécurité |
|
||||||
|
| Anti-abus | Honeypot · hCaptcha (optionnel) · cooldown cookie · flood detection · blacklist IP (SHA-256, 30 jours) |
|
||||||
|
| IA | Filtre de contenu Mistral AI + filtre légal interne (16 sources + Légifrance live) |
|
||||||
|
| Base de données | Requêtes paramétrées, accès localhost uniquement |
|
||||||
|
| Système | Utilisateur dédié non-root, systemd sandboxing |
|
||||||
|
|
||||||
|
#### Périmètre des ports réseau
|
||||||
|
|
||||||
|
| Port | Interface | Exposé à | Rôle |
|
||||||
|
|------|-----------|----------|------|
|
||||||
|
| 443 | HAProxy | Internet | HTTPS public |
|
||||||
|
| 80 | HAProxy | Internet | Redirect HTTPS |
|
||||||
|
| 80 | Nginx | Réseau interne | HTTP depuis HAProxy |
|
||||||
|
| 8000 | Gunicorn | 127.0.0.1 | API Flask |
|
||||||
|
| 5432 | PostgreSQL | 127.0.0.1 | Base de données |
|
||||||
|
|
||||||
|
#### En-têtes HTTP de sécurité (Flask)
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
X-Frame-Options: DENY
|
||||||
|
X-XSS-Protection: 1; mode=block
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none'
|
||||||
|
Cache-Control: no-store
|
||||||
|
Pragma: no-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
Exception : `/api/consultations/:slug/export/print` autorise `script-src 'unsafe-inline'` pour `window.print()`.
|
||||||
|
|
||||||
|
### Décisions d'architecture
|
||||||
|
|
||||||
|
| Décision | Choix | Justification |
|
||||||
|
|----------|-------|---------------|
|
||||||
|
| ORM vs SQL direct | psycopg2 direct | Transparence, simplicité, facilité d'audit |
|
||||||
|
| Sync vs Async Flask | Synchrone (Gunicorn) | Complexité réduite, suffisant pour la charge attendue |
|
||||||
|
| Synthèse bloquante vs thread | Thread daemon | Latence utilisateur réduite, synthèse non-critique au retour |
|
||||||
|
| TLS sur HAProxy vs Nginx | HAProxy | TLS centralisé sur le composant réseau dédié à cet usage |
|
||||||
|
| SPA vs SSR | SPA statique | Déploiement simple, aucune dépendance Node en production |
|
||||||
|
| Rate limiting en mémoire vs Redis | Mémoire (`memory://`) | Installation simple ; Redis optionnel pour cluster multi-instances |
|
||||||
|
| Fournisseur IA | Mistral AI (défaut) | Hébergement UE, conformité RGPD, API compatible OpenAI |
|
||||||
|
| Stockage IP | SHA-256 (32 car.) | Anti-abus sans stocker l'IP brute — conformité RGPD art. 6(1)(f) |
|
||||||
|
| Textes légaux | Légifrance PISTE live | Textes consolidés du jour injectés dans le prompt, fallback statique si indisponible |
|
||||||
|
|
||||||
|
### Scalabilité
|
||||||
|
|
||||||
|
L'architecture actuelle est dimensionnée pour une instance unique. En cas de montée en charge :
|
||||||
|
|
||||||
|
- **Gunicorn workers** : augmenter via `--workers` (règle : `2 × CPU + 1`)
|
||||||
|
- **Multi-instances** : ajouter des backends dans HAProxy + basculer le rate limiting sur Redis (`REDIS_URL=redis://...`)
|
||||||
|
- **Base de données** : index existants sur `ideas.consultation_id`, `synthesis.consultation_id`, `consents.fingerprint_hash`, `ip_abuse.expires_at` ; ajouter un index sur `ideas.accepted` si la table grossit
|
||||||
|
- **Cache synthèse** : la synthèse est unique en base par contexte, naturellement partagée entre workers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Stack technique
|
## Stack technique
|
||||||
|
|
||||||
| Couche | Technologie |
|
| Couche | Technologie |
|
||||||
@@ -73,12 +542,13 @@ La Voix du Peuple est un projet personnel open-source. Si vous l'utilisez et vou
|
|||||||
| Backend | Python 3.11+ · Flask 3 · Gunicorn |
|
| Backend | Python 3.11+ · Flask 3 · Gunicorn |
|
||||||
| Base de données | PostgreSQL 15+ (psycopg2, sans ORM) |
|
| Base de données | PostgreSQL 15+ (psycopg2, sans ORM) |
|
||||||
| IA | Mistral AI (défaut) ou tout fournisseur compatible API OpenAI |
|
| IA | Mistral AI (défaut) ou tout fournisseur compatible API OpenAI |
|
||||||
|
| Textes légaux | API Légifrance PISTE — injection temps réel dans le prompt de filtrage |
|
||||||
| Sécurité | flask-limiter · bleach · FingerprintJS · Redis (optionnel) · hCaptcha (optionnel) |
|
| Sécurité | flask-limiter · bleach · FingerprintJS · Redis (optionnel) · hCaptcha (optionnel) |
|
||||||
| Déploiement | Nginx · systemd |
|
| Déploiement | Nginx · systemd |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation et développement local
|
||||||
|
|
||||||
### Prérequis
|
### Prérequis
|
||||||
|
|
||||||
@@ -114,6 +584,21 @@ chmod 600 .env
|
|||||||
| `HCAPTCHA_SECRET_KEY` | Active la vérification hCaptcha côté serveur |
|
| `HCAPTCHA_SECRET_KEY` | Active la vérification hCaptcha côté serveur |
|
||||||
| `VITE_HCAPTCHA_SITE_KEY` | Clé publique hCaptcha côté frontend (nécessite un rebuild) |
|
| `VITE_HCAPTCHA_SITE_KEY` | Clé publique hCaptcha côté frontend (nécessite un rebuild) |
|
||||||
|
|
||||||
|
**Intégration Légifrance (optionnelle — recommandée) :**
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `PISTE_CLIENT_ID` | Client ID OAuth de l'application PISTE |
|
||||||
|
| `PISTE_CLIENT_SECRET` | Client Secret OAuth de l'application PISTE |
|
||||||
|
| `LEGIFRANCE_CACHE_TTL` | Durée de cache des textes en secondes (défaut : 86400 = 24h) |
|
||||||
|
|
||||||
|
Inscription gratuite sur [piste.gouv.fr](https://piste.gouv.fr) — créer une application et activer l'API Légifrance. Après configuration, initialiser les identifiants d'articles :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/check_legal_refs.py --init
|
||||||
|
python scripts/check_legal_refs.py --update
|
||||||
|
```
|
||||||
|
|
||||||
Les modèles IA sont configurables via `FILTER_MODEL` (défaut : `mistral-small-latest`) et `SYNTHESIS_MODEL` (défaut : `mistral-large-latest`).
|
Les modèles IA sont configurables via `FILTER_MODEL` (défaut : `mistral-small-latest`) et `SYNTHESIS_MODEL` (défaut : `mistral-large-latest`).
|
||||||
|
|
||||||
### Développement local
|
### Développement local
|
||||||
@@ -147,9 +632,15 @@ Pour configurer l'URL du QR code avant le build :
|
|||||||
bash scripts/set-domain.sh https://votredomaine.fr
|
bash scripts/set-domain.sh https://votredomaine.fr
|
||||||
```
|
```
|
||||||
|
|
||||||
### Déploiement en production
|
---
|
||||||
|
|
||||||
La procédure complète (PostgreSQL, Gunicorn, Nginx, systemd) est documentée dans `docs/INSTALL_ROCKY.md` pour Rocky Linux 9 / AlmaLinux 9 / RHEL 9. Les fichiers de configuration prêts à l'emploi se trouvent dans `deploy/`.
|
## Déploiement en production
|
||||||
|
|
||||||
|
La procédure complète (PostgreSQL, Gunicorn, Nginx, systemd, certificat TLS) est documentée dans [`DEPLOIEMENT.md`](DEPLOIEMENT.md).
|
||||||
|
|
||||||
|
Les fichiers de configuration prêts à l'emploi se trouvent dans `deploy/` :
|
||||||
|
- `deploy/nginx.conf` — configuration Nginx
|
||||||
|
- `deploy/voix-du-peuple-api.service` — unité systemd
|
||||||
|
|
||||||
La base de données est initialisée automatiquement au premier démarrage du backend. Les migrations sont incrémentales et idempotentes — un redémarrage après mise à jour suffit.
|
La base de données est initialisée automatiquement au premier démarrage du backend. Les migrations sont incrémentales et idempotentes — un redémarrage après mise à jour suffit.
|
||||||
|
|
||||||
|
|||||||
@@ -86,21 +86,72 @@ export default function Transparence() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted/40 border border-border/50 p-5 space-y-2">
|
<div className="bg-muted/40 border border-border/50 p-5 space-y-4">
|
||||||
<p className="font-mono text-xs font-semibold uppercase tracking-widest text-primary">Sources légales — Légifrance en temps réel</p>
|
<p className="font-mono text-xs font-semibold uppercase tracking-widest text-primary">Sources légales — Légifrance en temps réel</p>
|
||||||
<p className="text-foreground/90 text-sm leading-relaxed">
|
<p className="text-foreground/90 text-sm leading-relaxed">
|
||||||
Les textes des articles légaux critiques (Art. 225-1, 24 Loi 1881, Art. 421-2-5…) sont
|
Les textes des articles légaux les plus sensibles sont récupérés à chaque analyse
|
||||||
récupérés en temps réel depuis <strong>l'API officielle Légifrance (PISTE)</strong> et injectés
|
depuis <strong>l'API officielle Légifrance (PISTE)</strong> et injectés dans le prompt
|
||||||
dans le prompt avant chaque analyse. Le modèle travaille ainsi sur le texte
|
immédiatement avant les critères d'acceptation. Le modèle travaille ainsi sur les textes
|
||||||
consolidé du jour, pas sur sa mémoire d'entraînement figée.
|
consolidés du jour, pas sur sa mémoire d'entraînement figée qui peut dater de
|
||||||
|
plusieurs années.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-foreground/90 text-sm leading-relaxed">
|
<div className="space-y-2">
|
||||||
Les textes sont mis en cache 24 h. Un script de surveillance hebdomadaire
|
<p className="text-foreground/90 text-sm font-semibold">Articles surveillés</p>
|
||||||
(<code className="bg-muted px-1 py-0.5 rounded text-xs">scripts/check_legal_refs.py</code>) détecte
|
<p className="text-foreground/80 text-xs leading-relaxed">
|
||||||
tout changement législatif et alerte en cas de modification. Si l'API Légifrance
|
Les articles de priorité <strong>critique</strong> et <strong>haute</strong> sont injectés dans chaque prompt.
|
||||||
est indisponible, le système bascule automatiquement sur le cadre légal statique
|
Les articles <strong>moyenne</strong> sont suivis pour détecter les changements législatifs mais ne sont pas injectés
|
||||||
intégré au prompt — le filtrage continue sans interruption.
|
(le cadre légal statique les couvre déjà).
|
||||||
</p>
|
</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs border-collapse mt-2">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border">
|
||||||
|
<th className="text-left py-1.5 pr-4 font-mono uppercase tracking-wider text-muted-foreground">Référence</th>
|
||||||
|
<th className="text-left py-1.5 pr-4 font-mono uppercase tracking-wider text-muted-foreground">Infraction</th>
|
||||||
|
<th className="text-left py-1.5 font-mono uppercase tracking-wider text-muted-foreground">Priorité</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border/40">
|
||||||
|
{[
|
||||||
|
{ ref: "Code pénal Art. 225-1", desc: "Discrimination", prio: "Critique", color: "text-red-600 dark:text-red-400" },
|
||||||
|
{ ref: "Loi 1881 Art. 24", desc: "Provocation à la haine", prio: "Critique", color: "text-red-600 dark:text-red-400" },
|
||||||
|
{ ref: "Code pénal Art. 222-33", desc: "Harcèlement sexuel", prio: "Haute", color: "text-amber-600 dark:text-amber-400" },
|
||||||
|
{ ref: "Code pénal Art. 222-33-2-2", desc: "Cyberharcèlement en meute", prio: "Haute", color: "text-amber-600 dark:text-amber-400" },
|
||||||
|
{ ref: "Code pénal Art. 421-2-5", desc: "Apologie du terrorisme", prio: "Haute", color: "text-amber-600 dark:text-amber-400" },
|
||||||
|
{ ref: "Code pénal Art. 211-1", desc: "Génocide", prio: "Haute", color: "text-amber-600 dark:text-amber-400" },
|
||||||
|
{ ref: "Loi 1881 Art. 24 bis", desc: "Négationnisme", prio: "Haute", color: "text-amber-600 dark:text-amber-400" },
|
||||||
|
{ ref: "Code pénal Art. 226-4-1", desc: "Usurpation d'identité numérique", prio: "Haute", color: "text-amber-600 dark:text-amber-400" },
|
||||||
|
{ ref: "Code pénal Art. 212-1", desc: "Crimes contre l'humanité", prio: "Moyenne", color: "text-muted-foreground" },
|
||||||
|
{ ref: "Code pénal Art. 223-13", desc: "Provocation au suicide", prio: "Moyenne", color: "text-muted-foreground" },
|
||||||
|
{ ref: "Loi 1881 Art. 29", desc: "Diffamation", prio: "Moyenne", color: "text-muted-foreground" },
|
||||||
|
].map((row) => (
|
||||||
|
<tr key={row.ref}>
|
||||||
|
<td className="py-1.5 pr-4 font-mono text-foreground/80">{row.ref}</td>
|
||||||
|
<td className="py-1.5 pr-4 text-foreground/70">{row.desc}</td>
|
||||||
|
<td className={`py-1.5 font-semibold ${row.color}`}>{row.prio}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 text-foreground/80 text-sm">
|
||||||
|
<p>
|
||||||
|
<strong>Cache :</strong> les textes sont mis en cache 24 h (Redis si disponible, sinon mémoire par worker).
|
||||||
|
Un appel API Légifrance n'est effectué qu'à l'expiration du cache.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Surveillance hebdomadaire :</strong> le script{" "}
|
||||||
|
<code className="bg-muted px-1 py-0.5 rounded text-xs">check_legal_refs.py --check</code>{" "}
|
||||||
|
compare les textes courants avec la baseline stockée et retourne un code d'erreur si des
|
||||||
|
modifications sont détectées — intégrable dans une pipeline CI ou un cron.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Résilience :</strong> si l'API Légifrance est indisponible, le système bascule
|
||||||
|
automatiquement sur le cadre légal statique intégré au prompt — le filtrage continue sans
|
||||||
|
interruption et sans dégradation visible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user