Conformité RGPD (P3) + transparence éditoriale (P4)
P3 — RGPD :
- Table `consents` + `POST /api/consent` (art. 7.1 — preuve du consentement)
- Dialogue de consentement explicite avant la première contribution (art. 9.2.a)
- Pages `/mentions-legales` et `/politique-confidentialite`
- `docs/RGPD.md` — registre des traitements, bases légales, sous-traitants
- `getVisitorId()` exporté depuis l'API client React
P4 — Transparence éditoriale :
- Page `/contributions-brutes` avec pagination et export JSON/CSV
- `GET /api/contributions`, `GET /api/contributions/export/{json,csv}`
- `GET /api/stats/public` — stats publiques sans données de rejet
- Label de transparence IA sur la colonne de synthèse
- Compteurs (acceptées / soumises) dans le bandeau d'intro
- `docs/PROMPTS_IA.md` — prompts intégraux publiés + analyse des biais
- Pied de page avec liens légaux et transparence
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ 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,
|
||||
create_consent, get_public_contributions, get_public_stats,
|
||||
)
|
||||
from ai_agent import filter_idea, synthesize_ideas
|
||||
|
||||
@@ -422,6 +423,103 @@ def get_synthesis_route():
|
||||
})
|
||||
|
||||
|
||||
# ─── Routes publiques : consentement, stats, contributions ───────────────────
|
||||
|
||||
@app.post("/api/consent")
|
||||
@limiter.limit("5 per minute")
|
||||
def record_consent():
|
||||
"""Enregistre le consentement explicite d'un citoyen (art. 9.2.a RGPD)."""
|
||||
if not request.is_json:
|
||||
return jsonify({"error": "bad_request", "message": "Content-Type doit être application/json."}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
consent_version = sanitize_text(str(data.get("consent_version", "1.0") or "1.0"))[:20]
|
||||
raw_fp = request.headers.get("X-Visitor-Id", "").strip()
|
||||
fingerprint_hash = (
|
||||
hashlib.sha256(raw_fp.encode()).hexdigest()[:32] if raw_fp else "anonymous"
|
||||
)
|
||||
create_consent(fingerprint_hash, consent_version)
|
||||
logger.info("Consentement enregistré — fingerprint: %s... | version: %s", fingerprint_hash[:8], consent_version)
|
||||
return jsonify({"ok": True}), 201
|
||||
|
||||
|
||||
@app.get("/api/stats/public")
|
||||
@limiter.limit("120 per minute")
|
||||
def public_stats():
|
||||
"""Statistiques publiques : total soumis et acceptées (sans données de rejet)."""
|
||||
return jsonify(get_public_stats())
|
||||
|
||||
|
||||
@app.get("/api/contributions")
|
||||
@limiter.limit("60 per minute")
|
||||
def public_contributions():
|
||||
"""Liste paginée des contributions acceptées — vue publique anti-chronologique."""
|
||||
try:
|
||||
page = max(1, int(request.args.get("page", 1)))
|
||||
per_page = min(50, max(5, int(request.args.get("per_page", 20))))
|
||||
except (ValueError, TypeError):
|
||||
page, per_page = 1, 20
|
||||
contributions, total = get_public_contributions(page=page, per_page=per_page)
|
||||
pages = max(1, -(-total // per_page))
|
||||
return jsonify({
|
||||
"contributions": [
|
||||
{
|
||||
"id": c["id"],
|
||||
"content": c["content"],
|
||||
"author": c.get("author"),
|
||||
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
|
||||
}
|
||||
for c in contributions
|
||||
],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"perPage": per_page,
|
||||
"pages": pages,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/api/contributions/export/json")
|
||||
@limiter.limit("10 per minute")
|
||||
def export_contributions_json():
|
||||
"""Exporte l'intégralité des contributions acceptées en JSON (champs publics)."""
|
||||
contributions, _ = get_public_contributions(page=1, per_page=10000)
|
||||
payload = [
|
||||
{
|
||||
"id": c["id"],
|
||||
"content": c["content"],
|
||||
"author": c.get("author"),
|
||||
"createdAt": c["created_at"].isoformat() if c.get("created_at") else None,
|
||||
}
|
||||
for c in contributions
|
||||
]
|
||||
return Response(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
mimetype="application/json",
|
||||
headers={"Content-Disposition": "attachment; filename=contributions.json"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/contributions/export/csv")
|
||||
@limiter.limit("10 per minute")
|
||||
def export_contributions_csv():
|
||||
"""Exporte les contributions acceptées en CSV (champs publics uniquement — pas de fingerprint)."""
|
||||
contributions, _ = get_public_contributions(page=1, per_page=10000)
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
|
||||
writer.writerow(["id", "content", "author", "created_at"])
|
||||
for c in contributions:
|
||||
writer.writerow([
|
||||
c.get("id"),
|
||||
c.get("content", ""),
|
||||
c.get("author", ""),
|
||||
c["created_at"].isoformat() if c.get("created_at") else "",
|
||||
])
|
||||
return Response(
|
||||
output.getvalue().encode("utf-8-sig"),
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=contributions.csv"},
|
||||
)
|
||||
|
||||
|
||||
# ─── Route publique : signalement ────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/ideas/<int:idea_id>/flag")
|
||||
|
||||
@@ -62,6 +62,18 @@ def init_db() -> None:
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
# Table de traçabilité des consentements RGPD (art. 7.1 — charge de la preuve)
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS consents (
|
||||
id SERIAL PRIMARY KEY,
|
||||
fingerprint_hash VARCHAR(64) NOT NULL,
|
||||
consent_version VARCHAR(20) NOT NULL,
|
||||
consented_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""")
|
||||
cur.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_consents_fingerprint ON consents(fingerprint_hash)"
|
||||
)
|
||||
logger.info("Base de données initialisée.")
|
||||
|
||||
|
||||
@@ -85,6 +97,55 @@ def insert_idea(
|
||||
return dict(cur.fetchone())
|
||||
|
||||
|
||||
def create_consent(fingerprint_hash: str, consent_version: str) -> dict:
|
||||
"""Enregistre un consentement explicite (art. 7.1 RGPD — preuve)."""
|
||||
with db_cursor() as cur:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO consents (fingerprint_hash, consent_version)
|
||||
VALUES (%s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(fingerprint_hash, consent_version),
|
||||
)
|
||||
return dict(cur.fetchone())
|
||||
|
||||
|
||||
def get_public_contributions(page: int = 1, per_page: int = 20) -> tuple[list[dict], int]:
|
||||
"""Retourne les contributions acceptées paginées pour la vue publique."""
|
||||
offset = (page - 1) * per_page
|
||||
with db_cursor() as cur:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) as total FROM ideas WHERE accepted = TRUE AND flagged = FALSE"
|
||||
)
|
||||
total = cur.fetchone()["total"]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, content, author, created_at
|
||||
FROM ideas
|
||||
WHERE accepted = TRUE AND flagged = FALSE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(per_page, offset),
|
||||
)
|
||||
rows = [dict(row) for row in cur.fetchall()]
|
||||
return rows, total
|
||||
|
||||
|
||||
def get_public_stats() -> dict:
|
||||
"""Statistiques publiques — ne révèle pas les chiffres de rejet."""
|
||||
with db_cursor() as cur:
|
||||
cur.execute("SELECT COUNT(*) as total FROM ideas")
|
||||
total = cur.fetchone()["total"]
|
||||
cur.execute("SELECT COUNT(*) as accepted FROM ideas WHERE accepted = TRUE")
|
||||
accepted = cur.fetchone()["accepted"]
|
||||
cur.execute("SELECT updated_at FROM synthesis LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
last_updated = row["updated_at"].isoformat() if row and row.get("updated_at") else None
|
||||
return {"total": total, "accepted": accepted, "lastUpdated": last_updated}
|
||||
|
||||
|
||||
def get_accepted_ideas() -> list[dict]:
|
||||
with db_cursor() as cur:
|
||||
cur.execute(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import React from "react";
|
||||
import { Switch, Route, Router as WouterRouter, Link } from "wouter";
|
||||
import { Switch, Route, Router as WouterRouter, Link, useLocation } from "wouter";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
@@ -10,6 +10,9 @@ import About from "@/pages/about";
|
||||
import Transparence from "@/pages/transparence";
|
||||
import Flyer from "@/pages/flyer";
|
||||
import Admin from "@/pages/admin";
|
||||
import LegalNotice from "@/pages/legal-notice";
|
||||
import PrivacyPolicy from "@/pages/privacy-policy";
|
||||
import ContributionsBrutes from "@/pages/contributions-brutes";
|
||||
import { AccessibilityProvider } from "@/hooks/use-accessibility";
|
||||
import { AccessibilityPanel } from "@/components/accessibility-panel";
|
||||
import { setVisitorId } from "@workspace/api-client-react";
|
||||
@@ -55,7 +58,32 @@ function Navbar() {
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-border/40 bg-background py-5">
|
||||
<div className="container mx-auto max-w-7xl px-4 md:px-8">
|
||||
<div className="flex flex-col sm:flex-row gap-3 sm:gap-6 items-start sm:items-center justify-between text-xs text-muted-foreground font-mono">
|
||||
<span className="font-serif font-semibold text-foreground/70 text-sm">La Voix du Peuple</span>
|
||||
<nav className="flex flex-wrap gap-4" aria-label="Liens légaux et transparence">
|
||||
<Link href="/mentions-legales" className="hover:text-foreground transition-colors">
|
||||
Mentions légales
|
||||
</Link>
|
||||
<Link href="/politique-confidentialite" className="hover:text-foreground transition-colors">
|
||||
Confidentialité
|
||||
</Link>
|
||||
<Link href="/contributions-brutes" className="hover:text-foreground transition-colors">
|
||||
Contributions brutes
|
||||
</Link>
|
||||
</nav>
|
||||
<span className="opacity-50">EUPL-1.2 · billisdead</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function Router() {
|
||||
const [location] = useLocation();
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col font-sans">
|
||||
{/* Lien d'évitement pour lecteurs d'écran */}
|
||||
@@ -73,9 +101,14 @@ function Router() {
|
||||
<Route path="/transparence" component={Transparence} />
|
||||
<Route path="/flyer" component={Flyer} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/mentions-legales" component={LegalNotice} />
|
||||
<Route path="/politique-confidentialite" component={PrivacyPolicy} />
|
||||
<Route path="/contributions-brutes" component={ContributionsBrutes} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
{/* Pied de page masqué sur la page d'accueil (layout plein-écran) */}
|
||||
{location !== "/" && <Footer />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
interface ConsentDialogProps {
|
||||
open: boolean;
|
||||
onConsent: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConsentDialog({ open, onConsent, onCancel }: ConsentDialogProps) {
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
|
||||
// Réinitialise la case à cocher à chaque ouverture
|
||||
React.useEffect(() => {
|
||||
if (open) setChecked(false);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel(); }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 font-serif">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
Votre consentement
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3 text-sm text-foreground/80 leading-relaxed pt-1">
|
||||
<p>
|
||||
Avant de contribuer, nous avons besoin de votre accord explicite
|
||||
sur le traitement de vos données.
|
||||
</p>
|
||||
<div className="bg-muted/50 border border-border/60 rounded-sm p-4 space-y-2 text-xs">
|
||||
<p className="font-semibold text-foreground/90">Ce que nous collectons :</p>
|
||||
<ul className="space-y-1 text-foreground/75">
|
||||
<li>• Le texte de votre contribution (opinion politique — donnée sensible au sens de l'art. 9 RGPD)</li>
|
||||
<li>• Un pseudonyme facultatif librement choisi</li>
|
||||
<li>• La date et l'heure de la soumission</li>
|
||||
<li>• Un identifiant technique non-personnel pour la protection anti-abus (hash non réversible)</li>
|
||||
</ul>
|
||||
<p className="font-semibold text-foreground/90 pt-1">Durée de conservation :</p>
|
||||
<p className="text-foreground/75">24 mois, puis suppression ou anonymisation.</p>
|
||||
<p className="font-semibold text-foreground/90 pt-1">Sous-traitant IA :</p>
|
||||
<p className="text-foreground/75">
|
||||
Votre contribution est analysée par Mistral AI (France) pour modération et synthèse.
|
||||
Aucun transfert hors Union européenne.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/60">
|
||||
Vous pouvez retirer votre consentement et demander la suppression de vos données
|
||||
à tout moment via piron.antoine@gmail.com. Détails :{" "}
|
||||
<Link href="/politique-confidentialite" className="underline text-primary" onClick={onCancel}>
|
||||
politique de confidentialité
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-start gap-3 py-2">
|
||||
<Checkbox
|
||||
id="consent-check"
|
||||
checked={checked}
|
||||
onCheckedChange={(val) => setChecked(val === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="consent-check"
|
||||
className="text-sm leading-relaxed cursor-pointer"
|
||||
>
|
||||
J'ai lu et j'accepte la politique de confidentialité. Je consens
|
||||
au traitement de mes données, y compris mes opinions politiques,
|
||||
pour la finalité décrite ci-dessus.
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={onConsent} disabled={!checked}>
|
||||
Accepter et contribuer
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import {
|
||||
ArrowLeft, Download, ChevronLeft, ChevronRight, Loader2, AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
||||
const PER_PAGE = 20;
|
||||
|
||||
interface Contribution {
|
||||
id: number;
|
||||
content: string;
|
||||
author: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ContributionsResponse {
|
||||
contributions: Contribution[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export default function ContributionsBrutes() {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [data, setData] = React.useState<ContributionsResponse | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
fetch(`${API_BASE}/api/contributions?page=${page}&per_page=${PER_PAGE}`)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error();
|
||||
return res.json() as Promise<ContributionsResponse>;
|
||||
})
|
||||
.then((d) => {
|
||||
setData(d);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto max-w-3xl px-4 md:px-8 py-12">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" className="mb-8 -ml-3 text-muted-foreground">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Retour
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<header className="mb-8 space-y-3">
|
||||
<h1 className="text-4xl md:text-5xl font-serif font-bold text-primary">
|
||||
Contributions brutes
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
L'intégralité des contributions acceptées, dans leur ordre de soumission, sans mise en forme éditoriale.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Avertissement éditorial */}
|
||||
<Alert className="mb-8 border-amber-200/70 bg-amber-50/50 dark:bg-amber-950/20 dark:border-amber-800/40">
|
||||
<AlertDescription className="text-sm text-foreground/80 leading-relaxed">
|
||||
La{" "}
|
||||
<Link href="/" className="underline text-primary">
|
||||
synthèse collective
|
||||
</Link>{" "}
|
||||
visible sur la page d'accueil est une interprétation thématique automatisée de ces contributions.
|
||||
Elle peut regrouper, omettre ou reformuler des expressions individuelles.
|
||||
Cette page est la source primaire pour vérification.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Boutons d'export */}
|
||||
<div className="flex gap-3 mb-8">
|
||||
<a
|
||||
href={`${API_BASE}/api/contributions/export/json`}
|
||||
download="contributions.json"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="gap-2 font-mono text-xs">
|
||||
<Download className="h-3.5 w-3.5" /> Export JSON
|
||||
</Button>
|
||||
</a>
|
||||
<a
|
||||
href={`${API_BASE}/api/contributions/export/csv`}
|
||||
download="contributions.csv"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="gap-2 font-mono text-xs">
|
||||
<Download className="h-3.5 w-3.5" /> Export CSV
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Contenu */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16 text-muted-foreground">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-muted-foreground">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
<span className="text-sm">Impossible de charger les contributions.</span>
|
||||
</div>
|
||||
) : data && data.contributions.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs font-mono text-muted-foreground mb-6">
|
||||
{data.total} contribution{data.total !== 1 ? "s" : ""} acceptée{data.total !== 1 ? "s" : ""}
|
||||
{" "}— page {data.page} / {data.pages}
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{data.contributions.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="border-l-2 border-border/50 pl-4 space-y-1"
|
||||
id={`contrib-${c.id}`}
|
||||
>
|
||||
<p className="text-sm leading-relaxed font-serif text-foreground/90">
|
||||
{c.content}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-xs font-mono text-muted-foreground">
|
||||
<span className="font-semibold text-primary/70">
|
||||
{c.author || "Citoyen anonyme"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{format(new Date(c.createdAt), "d MMMM yyyy, HH:mm", { locale: fr })}
|
||||
</span>
|
||||
<span className="ml-auto opacity-40">#{c.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{data.pages > 1 && (
|
||||
<div className="flex items-center justify-between mt-10 pt-6 border-t border-border/40">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" /> Précédent
|
||||
</Button>
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{page} / {data.pages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(data.pages, p + 1))}
|
||||
disabled={page === data.pages}
|
||||
className="gap-1.5"
|
||||
>
|
||||
Suivant <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-16 text-muted-foreground text-sm border border-dashed border-border/60">
|
||||
Aucune contribution acceptée pour l'instant.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground/60 font-mono pt-8 mt-8 border-t border-border/30">
|
||||
Contributions modérées selon le droit international des droits humains (DUDH · PIDCP · CEDH).{" "}
|
||||
<a
|
||||
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/PROMPTS_IA.md"
|
||||
className="underline text-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Voir les critères de modération
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@@ -16,7 +17,9 @@ import {
|
||||
getGetIdeaStatsQueryKey,
|
||||
addExtraHeader,
|
||||
removeExtraHeader,
|
||||
getVisitorId,
|
||||
} from "@workspace/api-client-react";
|
||||
import { ConsentDialog } from "@/components/consent-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -98,6 +101,13 @@ export default function Home() {
|
||||
const captchaRef = React.useRef<HCaptcha>(null);
|
||||
const [captchaToken, setCaptchaToken] = React.useState<string | null>(null);
|
||||
|
||||
// Consentement RGPD — persisté dans localStorage (art. 9.2.a)
|
||||
const [consentGiven, setConsentGiven] = React.useState(
|
||||
() => !!localStorage.getItem("consent_v1")
|
||||
);
|
||||
const [showConsentDialog, setShowConsentDialog] = React.useState(false);
|
||||
const pendingSubmitData = React.useRef<SubmitIdeaValues | null>(null);
|
||||
|
||||
const handleFlag = async (ideaId: number) => {
|
||||
if (flaggedIds.has(ideaId) || flaggingId === ideaId) return;
|
||||
setFlaggingId(ideaId);
|
||||
@@ -123,6 +133,60 @@ export default function Home() {
|
||||
query: { refetchInterval: 15000 },
|
||||
});
|
||||
|
||||
// Soumission effective après confirmation du consentement (ou si déjà consenti)
|
||||
const doActualSubmit = (data: SubmitIdeaValues) => {
|
||||
if (captchaToken) {
|
||||
addExtraHeader("x-hcaptcha-token", captchaToken);
|
||||
}
|
||||
setSubmitResult(null);
|
||||
submitIdea.mutate({ data }, {
|
||||
onSuccess: (result) => {
|
||||
if (result.accepted) {
|
||||
setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." });
|
||||
form.reset();
|
||||
} else {
|
||||
setSubmitResult({
|
||||
success: false,
|
||||
message: "Cette contribution n'a pas pu être intégrée : elle n'est pas compatible avec le cadre de modération de cette plateforme.",
|
||||
reason: result.reason ?? undefined,
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: getListIdeasQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetIdeaStatsQueryKey() });
|
||||
},
|
||||
onError: () => {
|
||||
setSubmitResult({ success: false, message: "Une erreur est survenue lors de l'envoi. Veuillez réessayer." });
|
||||
},
|
||||
onSettled: () => {
|
||||
removeExtraHeader("x-hcaptcha-token");
|
||||
captchaRef.current?.resetCaptcha();
|
||||
setCaptchaToken(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Confirme le consentement, l'enregistre en DB, puis exécute la soumission en attente
|
||||
const handleConsentConfirm = () => {
|
||||
localStorage.setItem("consent_v1", new Date().toISOString());
|
||||
setConsentGiven(true);
|
||||
setShowConsentDialog(false);
|
||||
const visitorId = getVisitorId();
|
||||
fetch(`${API_BASE}/api/consent`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(visitorId ? { "X-Visitor-Id": visitorId } : {}),
|
||||
},
|
||||
body: JSON.stringify({ consent_version: "1.0" }),
|
||||
}).catch(() => {
|
||||
// Dégradation silencieuse — localStorage suffit comme preuve côté client
|
||||
});
|
||||
if (pendingSubmitData.current) {
|
||||
doActualSubmit(pendingSubmitData.current);
|
||||
pendingSubmitData.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
if (!synthesis?.text) return;
|
||||
const date = format(new Date(), "d MMMM yyyy 'à' HH:mm", { locale: fr });
|
||||
@@ -189,70 +253,53 @@ 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",
|
||||
});
|
||||
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);
|
||||
// Consentement RGPD — obligatoire avant la première contribution
|
||||
if (!consentGiven) {
|
||||
pendingSubmitData.current = data;
|
||||
setShowConsentDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitResult(null);
|
||||
submitIdea.mutate({ data }, {
|
||||
onSuccess: (result) => {
|
||||
if (result.accepted) {
|
||||
setSubmitResult({
|
||||
success: true,
|
||||
message: "Votre contribution a été ajoutée à la synthèse.",
|
||||
});
|
||||
form.reset();
|
||||
} else {
|
||||
setSubmitResult({
|
||||
success: false,
|
||||
message: "Cette contribution n'a pas pu être intégrée : elle n'est pas compatible avec le cadre de modération de cette plateforme.",
|
||||
reason: result.reason ?? undefined,
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: getListIdeasQueryKey() });
|
||||
queryClient.invalidateQueries({ queryKey: getGetIdeaStatsQueryKey() });
|
||||
},
|
||||
onError: () => {
|
||||
setSubmitResult({
|
||||
success: false,
|
||||
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);
|
||||
},
|
||||
});
|
||||
doActualSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConsentDialog
|
||||
open={showConsentDialog}
|
||||
onConsent={handleConsentConfirm}
|
||||
onCancel={() => { setShowConsentDialog(false); pendingSubmitData.current = null; }}
|
||||
/>
|
||||
|
||||
{/* Bandeau d'introduction */}
|
||||
<div className="border-b border-border/40 bg-muted/30 px-6 md:px-10 py-5">
|
||||
<p className="text-sm text-foreground/75 max-w-3xl leading-relaxed">
|
||||
<span className="font-semibold text-foreground">La Voix du Peuple</span> est un espace d'expression citoyenne, pas un sondage ni une vérité établie.
|
||||
Exprimez-vous librement — chaque contribution est modérée selon le droit international des droits humains, puis reflétée dans la synthèse collective affichée à droite.
|
||||
<span className="text-foreground/55 ml-1">Ce que vous lisez représente ce que des personnes ont choisi d'exprimer, pas un consensus validé.</span>
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4 max-w-5xl">
|
||||
<p className="text-sm text-foreground/75 leading-relaxed">
|
||||
<span className="font-semibold text-foreground">La Voix du Peuple</span> est un espace d'expression citoyenne, pas un sondage ni une vérité établie.
|
||||
Exprimez-vous librement — chaque contribution est modérée selon le droit international des droits humains, puis reflétée dans la synthèse collective affichée à droite.
|
||||
<span className="text-foreground/55 ml-1">Ce que vous lisez représente ce que des personnes ont choisi d'exprimer, pas un consensus validé.</span>
|
||||
</p>
|
||||
{stats && stats.total > 0 && (
|
||||
<div className="flex items-center gap-5 text-xs font-mono text-muted-foreground flex-shrink-0 sm:text-right">
|
||||
<div>
|
||||
<div className="text-xl font-bold text-primary leading-none">{stats.accepted}</div>
|
||||
<div className="mt-0.5">acceptées</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-bold text-foreground/40 leading-none">{stats.total}</div>
|
||||
<div className="mt-0.5">soumises</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid md:grid-cols-2 lg:grid-cols-[1fr_1.2fr] h-[calc(100vh-5rem)]">
|
||||
@@ -520,6 +567,19 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avertissement éditorial — transparence sur la nature de la synthèse IA */}
|
||||
<div className="px-6 md:px-10 pt-4 pb-1 relative z-10 flex-shrink-0">
|
||||
<Alert className="border-amber-200/70 bg-amber-50/50 dark:bg-amber-950/20 dark:border-amber-800/40 py-3">
|
||||
<AlertDescription className="text-xs text-foreground/70 leading-relaxed">
|
||||
Synthèse générée par IA — peut regrouper, omettre ou reformuler des contributions.{" "}
|
||||
<Link href="/contributions-brutes" className="underline text-primary">
|
||||
Voir les contributions brutes
|
||||
</Link>{" "}
|
||||
pour vérification indépendante.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
|
||||
{/* Texte défilable */}
|
||||
<ScrollArea className="flex-1 relative z-10">
|
||||
<div className="px-6 md:px-10 py-8">
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import { Link } from "wouter";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function LegalNotice() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto max-w-3xl px-4 md:px-8 py-12">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" className="mb-8 -ml-3 text-muted-foreground">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Retour
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<header className="mb-10 space-y-3">
|
||||
<h1 className="text-4xl md:text-5xl font-serif font-bold text-primary">
|
||||
Mentions légales
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm font-mono">
|
||||
Conformément à la loi n° 2004-575 du 21 juin 2004 (LCEN)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-10 text-sm leading-relaxed">
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Éditeur du site</h2>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{[
|
||||
{ label: "Nom", value: "Antoine Piron" },
|
||||
{ label: "Pseudonyme", value: "billisdead" },
|
||||
{ label: "Qualité", value: "Personne physique — exploitant à titre personnel" },
|
||||
{ label: "Contact", value: "piron.antoine@gmail.com" },
|
||||
{ label: "Adresse", value: "France" },
|
||||
].map(row => (
|
||||
<tr key={row.label}>
|
||||
<td className="py-2 pr-6 font-mono text-xs font-semibold text-muted-foreground uppercase tracking-wider w-40">{row.label}</td>
|
||||
<td className="py-2 text-foreground/90">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Directeur de la publication</h2>
|
||||
<p className="text-foreground/80">Antoine Piron (billisdead)</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Hébergement</h2>
|
||||
<p className="text-foreground/80">
|
||||
La plateforme est auto-hébergée sur un serveur privé virtuel (VPS) situé
|
||||
en Union européenne. Le choix d'hébergement répond à une exigence de
|
||||
souveraineté numérique européenne.
|
||||
</p>
|
||||
<p className="text-foreground/70 text-xs font-mono">
|
||||
Configuration : Rocky Linux 9 · Nginx · Python / Flask · PostgreSQL
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Propriété intellectuelle</h2>
|
||||
<p className="text-foreground/80">
|
||||
Le code source de cette plateforme est publié sous la{" "}
|
||||
<strong>European Union Public Licence v. 1.2 (EUPL-1.2)</strong>, licence
|
||||
open source officielle de l'Union européenne. Le code est librement
|
||||
réutilisable par toute association, collectivité ou citoyen dans les
|
||||
conditions de cette licence.
|
||||
</p>
|
||||
<p className="text-foreground/80">
|
||||
Dépôt :{" "}
|
||||
<a
|
||||
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple"
|
||||
className="underline text-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
homegit.gyozamancave.fr/billisdead/la-voix-du-peuple
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Responsabilité éditoriale</h2>
|
||||
<p className="text-foreground/80">
|
||||
Les contributions publiées sur cette plateforme sont soumises par des
|
||||
tiers. Elles sont automatiquement modérées selon le cadre légal international
|
||||
des droits humains et le droit français. L'éditeur n'est pas responsable des
|
||||
contenus soumis, sous réserve de prendre les mesures prévues par la LCEN
|
||||
dès qu'un contenu illicite lui est signalé.
|
||||
</p>
|
||||
<p className="text-foreground/80">
|
||||
La synthèse produite par l'IA est une interprétation éditoriale automatisée
|
||||
des contributions acceptées, pas un document factuel ni un consensus validé.
|
||||
Voir la page{" "}
|
||||
<Link href="/transparence" className="underline text-primary">Fonctionnement</Link>{" "}
|
||||
et les{" "}
|
||||
<Link href="/contributions-brutes" className="underline text-primary">Contributions brutes</Link>{" "}
|
||||
pour vérification.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Données personnelles</h2>
|
||||
<p className="text-foreground/80">
|
||||
Voir la{" "}
|
||||
<Link href="/politique-confidentialite" className="underline text-primary">
|
||||
politique de confidentialité
|
||||
</Link>{" "}
|
||||
et le document de conformité RGPD complet (
|
||||
<a
|
||||
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/RGPD.md"
|
||||
className="underline text-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
docs/RGPD.md
|
||||
</a>
|
||||
).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">Signalement d'un contenu illicite</h2>
|
||||
<p className="text-foreground/80">
|
||||
Conformément à l'article 6-I-7 de la LCEN, vous pouvez signaler tout
|
||||
contenu manifestement illicite par email à{" "}
|
||||
<a href="mailto:piron.antoine@gmail.com" className="underline text-primary">
|
||||
piron.antoine@gmail.com
|
||||
</a>{" "}
|
||||
ou en utilisant le bouton "Signaler" disponible sur chaque contribution.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-muted-foreground/60 font-mono pt-4 border-t border-border/30">
|
||||
Dernière mise à jour : mai 2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import { Link } from "wouter";
|
||||
import { ArrowLeft, ShieldCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto max-w-3xl px-4 md:px-8 py-12">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" className="mb-8 -ml-3 text-muted-foreground">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Retour
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<header className="mb-10 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className="h-8 w-8 text-primary" />
|
||||
<h1 className="text-4xl md:text-5xl font-serif font-bold text-primary">
|
||||
Politique de confidentialité
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
Version 1.0 — mai 2026 · Responsable : Antoine Piron (billisdead)
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-10 text-sm leading-relaxed">
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
En résumé
|
||||
</h2>
|
||||
<div className="bg-muted/30 border border-border/50 p-5 space-y-2 rounded-sm">
|
||||
<p className="text-foreground/90">
|
||||
Cette plateforme collecte uniquement ce qui est strictement nécessaire
|
||||
au fonctionnement du service.
|
||||
</p>
|
||||
<ul className="space-y-1 text-foreground/80">
|
||||
<li>→ Aucun compte utilisateur, aucune inscription</li>
|
||||
<li>→ Aucun cookie de tracking, aucune publicité</li>
|
||||
<li>→ Aucun transfert de données hors Union européenne</li>
|
||||
<li>→ Vos contributions sont supprimées après 24 mois</li>
|
||||
<li>→ Vous pouvez demander la suppression à tout moment</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Qui traite vos données ?
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Responsable de traitement : Antoine Piron (billisdead), France.
|
||||
Contact : <a href="mailto:piron.antoine@gmail.com" className="underline text-primary">piron.antoine@gmail.com</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Quelles données sont collectées ?
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 pr-4 font-mono text-xs uppercase tracking-wider text-muted-foreground">Donnée</th>
|
||||
<th className="text-left py-2 pr-4 font-mono text-xs uppercase tracking-wider text-muted-foreground">Collectée ?</th>
|
||||
<th className="text-left py-2 font-mono text-xs uppercase tracking-wider text-muted-foreground">Pourquoi ?</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{[
|
||||
{ d: "Votre contribution (texte)", c: true, r: "Afficher et synthétiser les propositions" },
|
||||
{ d: "Pseudonyme", c: true, r: "Facultatif — affiché avec votre contribution" },
|
||||
{ d: "Date et heure", c: true, r: "Traçabilité, affichage dans l'interface" },
|
||||
{ d: "Hash technique anti-abus", c: true, r: "Protection contre les bots — non réversible, non-PII" },
|
||||
{ d: "Horodatage du consentement", c: true, r: "Preuve légale de votre accord (art. 7.1 RGPD)" },
|
||||
{ d: "Adresse IP", c: false, r: "Non stockée — utilisée temporairement pour le rate limiting" },
|
||||
{ d: "Cookies de suivi", c: false, r: "Aucun tracker tiers" },
|
||||
{ d: "Compte utilisateur", c: false, r: "Aucune inscription requise" },
|
||||
{ d: "Géolocalisation", c: false, r: "—" },
|
||||
].map(row => (
|
||||
<tr key={row.d}>
|
||||
<td className="py-2.5 pr-4 font-medium">{row.d}</td>
|
||||
<td className="py-2.5 pr-4">
|
||||
{row.c
|
||||
? <span className="text-amber-700 font-semibold">Oui</span>
|
||||
: <span className="text-green-700 font-semibold">Non</span>
|
||||
}
|
||||
</td>
|
||||
<td className="py-2.5 text-foreground/70">{row.r}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Vos opinions politiques — donnée sensible
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Les contributions peuvent contenir vos opinions politiques, qui sont des{" "}
|
||||
<strong>données sensibles</strong> au sens de l'article 9 du RGPD.
|
||||
Leur traitement est autorisé uniquement avec votre{" "}
|
||||
<strong>consentement explicite</strong>, que vous donnez en cochant
|
||||
la case avant votre première contribution.
|
||||
</p>
|
||||
<p className="text-foreground/80">
|
||||
Vous pouvez retirer ce consentement à tout moment. Le retrait n'affecte
|
||||
pas les traitements déjà effectués, mais vous pouvez demander la
|
||||
suppression de vos contributions (voir § Vos droits).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Intelligence artificielle (Mistral AI)
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Votre contribution est transmise à{" "}
|
||||
<strong>Mistral AI (Paris, France)</strong> pour modération automatique.
|
||||
Si elle est acceptée, elle est également transmise avec l'ensemble des
|
||||
contributions acceptées pour générer la synthèse collective.
|
||||
</p>
|
||||
<p className="text-foreground/80">
|
||||
Mistral AI est un sous-traitant RGPD au sens de l'article 28.
|
||||
Son infrastructure est hébergée exclusivement en Union européenne
|
||||
(Pays-Bas). Aucun transfert hors UE n'est effectué.
|
||||
Mistral AI ne conserve pas les données pour entraîner ses modèles.
|
||||
</p>
|
||||
<p className="text-foreground/80">
|
||||
Les instructions précises données à l'IA sont publiques :{" "}
|
||||
<a
|
||||
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/PROMPTS_IA.md"
|
||||
className="underline text-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
docs/PROMPTS_IA.md
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Durée de conservation
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Vos contributions sont conservées pendant <strong>24 mois</strong> après la soumission,
|
||||
puis supprimées ou anonymisées. Le pseudonyme est supprimé en priorité si vous
|
||||
en avez fourni un.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Vos droits
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Conformément au RGPD (articles 15 à 22), vous disposez des droits suivants.
|
||||
Pour les exercer, envoyez un email à{" "}
|
||||
<a href="mailto:piron.antoine@gmail.com" className="underline text-primary">
|
||||
piron.antoine@gmail.com
|
||||
</a>{" "}
|
||||
avec l'objet "Demande RGPD".
|
||||
</p>
|
||||
<ul className="space-y-2 text-foreground/80">
|
||||
{[
|
||||
{ titre: "Accès", desc: "Obtenir la liste de vos contributions et des données associées." },
|
||||
{ titre: "Rectification", desc: "Corriger un pseudonyme ou une erreur dans vos données." },
|
||||
{ titre: "Effacement", desc: "Faire supprimer vos contributions (avec la date approximative)." },
|
||||
{ titre: "Opposition", desc: "Vous opposer au traitement basé sur l'intérêt légitime (hash technique)." },
|
||||
{ titre: "Portabilité", desc: "Obtenir vos contributions en format JSON ou CSV." },
|
||||
{ titre: "Retrait du consentement", desc: "Annuler votre accord pour les données sensibles (opinions politiques). Les contributions publiées restent visibles sauf demande d'effacement complémentaire." },
|
||||
].map(r => (
|
||||
<li key={r.titre} className="flex gap-3">
|
||||
<span className="font-semibold text-foreground/90 flex-shrink-0 w-36">{r.titre}</span>
|
||||
<span>{r.desc}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-foreground/70 text-xs">
|
||||
Délai de réponse : 1 mois (prolongeable à 3 mois).
|
||||
En cas de désaccord, vous pouvez saisir la{" "}
|
||||
<a href="https://www.cnil.fr/fr/plaintes" className="underline text-primary" rel="noopener noreferrer">
|
||||
CNIL
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-serif font-semibold border-b border-border/50 pb-2">
|
||||
Document complet
|
||||
</h2>
|
||||
<p className="text-foreground/80">
|
||||
Le document de conformité RGPD complet (registre des traitements, bases légales,
|
||||
sous-traitants, durées de conservation) est disponible en open source :{" "}
|
||||
<a
|
||||
href="https://homegit.gyozamancave.fr/billisdead/la-voix-du-peuple/src/branch/main/docs/RGPD.md"
|
||||
className="underline text-primary"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
docs/RGPD.md
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="text-xs text-muted-foreground/60 font-mono pt-4 border-t border-border/30">
|
||||
Version 1.0 — mai 2026
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
# Prompts IA — La Voix du Peuple
|
||||
|
||||
> Transparence radicale sur les instructions données aux modèles d'intelligence artificielle.
|
||||
> Ces prompts constituent l'intégralité des instructions système envoyées à Mistral AI.
|
||||
> Source : `artifacts/flask-api/legal_framework.py`
|
||||
|
||||
---
|
||||
|
||||
## Pourquoi publier ces prompts ?
|
||||
|
||||
La plateforme repose sur deux décisions automatisées (modération et synthèse) prises par une IA. Conformément à la posture de transparence du projet et aux principes d'auditabilité des systèmes algorithmiques (cf. règlement IA européen), ces instructions sont intégralement publiées.
|
||||
|
||||
Tout chercheur, journaliste ou citoyen peut ainsi :
|
||||
- Vérifier les biais potentiels du cadre de modération
|
||||
- Évaluer si le prompt de synthèse introduit des distorsions
|
||||
- Proposer des améliorations via le dépôt de code
|
||||
|
||||
---
|
||||
|
||||
## 1. Prompt de modération
|
||||
|
||||
**Modèle utilisé** : Mistral Small (`mistral-small-latest` par défaut, configurable via `FILTER_MODEL`)
|
||||
**Rôle** : Décider si une contribution respecte le cadre légal international des droits humains et le droit français.
|
||||
|
||||
```
|
||||
Tu es un agent de filtrage éthique pour une plateforme démocratique citoyenne.
|
||||
Ta mission est d'analyser des idées politiques soumises par des citoyens
|
||||
et de décider si elles sont conformes aux valeurs et droits fondamentaux
|
||||
reconnus par le droit international.
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
CADRE LÉGAL DE RÉFÉRENCE
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
1. DÉCLARATION UNIVERSELLE DES DROITS DE L'HOMME (DUDH, ONU 1948)
|
||||
• Art. 1 : "Tous les êtres humains naissent libres et égaux en dignité et
|
||||
en droits."
|
||||
• Art. 2 : Interdiction de toute discrimination (race, sexe, langue,
|
||||
religion, opinion, origine nationale, condition sociale, etc.)
|
||||
• Art. 3 : "Tout individu a droit à la vie, à la liberté et à la sûreté
|
||||
de sa personne."
|
||||
• Art. 5 : Interdiction de la torture et des traitements dégradants.
|
||||
• Art. 7 : Égalité devant la loi, protection contre la discrimination.
|
||||
• Art. 18 : Liberté de pensée, de conscience et de religion.
|
||||
• Art. 19 : "Tout individu a droit à la liberté d'opinion et d'expression."
|
||||
• Art. 20 : "Toute propagande en faveur de la guerre est interdite par la
|
||||
loi. Tout appel à la haine nationale, raciale ou religieuse
|
||||
qui constitue une incitation à la discrimination, à l'hostilité
|
||||
ou à la violence est interdit par la loi."
|
||||
• Art. 21 : Droit de participer au gouvernement de son pays, suffrage.
|
||||
• Art. 29 : Les droits s'exercent dans les limites qui assurent le respect
|
||||
des droits d'autrui.
|
||||
|
||||
[... cadre légal complet dans artifacts/flask-api/legal_framework.py ...]
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
CRITÈRES D'ACCEPTATION
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Accepte les idées qui :
|
||||
✓ Promeuvent les droits fondamentaux, la liberté, l'égalité, la justice (DUDH Art. 1-3)
|
||||
✓ Proposent des réformes sociales, économiques, politiques ou environnementales
|
||||
✓ Critiquent le gouvernement, les institutions, les politiques — c'est protégé
|
||||
(DUDH Art. 19, CEDH Art. 10)
|
||||
✓ Expriment des opinions politiques, même radicales, tant qu'elles respectent
|
||||
la dignité humaine et ne prônent pas la haine
|
||||
✓ Défendent des groupes marginalisés ou discriminés
|
||||
✓ Proposent des changements constitutionnels, législatifs ou systémiques par
|
||||
des voies démocratiques et pacifiques
|
||||
✓ Soulèvent des préoccupations légitimes de sécurité, d'économie, de justice
|
||||
✓ Sont rédigées dans n'importe quelle langue
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
CRITÈRES DE REJET (extraits)
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Rejette les idées qui :
|
||||
✗ Prônent le fascisme, le nazisme ou tout régime totalitaire
|
||||
→ CEDH Art. 17 (abus de droit), DUDH Art. 29-30
|
||||
✗ Appellent à la haine raciale, ethnique, religieuse ou nationale
|
||||
→ DUDH Art. 20, PIDCP Art. 20, CERD Art. 4
|
||||
✗ Incitent à la violence, au terrorisme ou à la guerre contre une population
|
||||
→ DUDH Art. 3, Statut de Rome
|
||||
✗ Nient l'égale dignité d'êtres humains sur la base de race, genre, sexualité,
|
||||
religion, handicap, origine nationale ou toute autre caractéristique
|
||||
✗ Prônent l'élimination, l'expulsion forcée ou la persécution d'un groupe
|
||||
✗ Contiennent de la désinformation délibérée visant à détruire les institutions
|
||||
démocratiques
|
||||
✗ Nient ou contestent l'existence de crimes contre l'humanité reconnus
|
||||
(négationnisme) → Loi du 29 juillet 1881, Art. 24 bis
|
||||
✗ Font l'apologie du terrorisme → Code pénal Art. 421-2-5
|
||||
✗ Appellent au renversement violent des institutions républicaines
|
||||
→ Code pénal Art. 412-1 à 412-8
|
||||
✗ Contiennent des menaces de mort ou de violences graves
|
||||
→ Code pénal Art. 222-17 à 222-18-3
|
||||
✗ Incitent au suicide → Code pénal Art. 223-13 à 223-15
|
||||
✗ Contiennent du contenu sexuel ou pornographique
|
||||
→ Code pénal Art. 222-32, Art. 227-24
|
||||
✗ Contiennent des données personnelles identifiables de tiers → RGPD
|
||||
✗ Ne constituent pas une proposition citoyenne (spam, tests, hors sujet)
|
||||
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
FORMAT DE RÉPONSE — OBLIGATOIRE
|
||||
═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Réponds UNIQUEMENT avec un objet JSON valide, sans markdown, sans commentaire :
|
||||
|
||||
Si acceptée :
|
||||
{"accepted": true}
|
||||
|
||||
Si rejetée :
|
||||
{"accepted": false, "reason": "Explication courte en français avec référence légale précise", "legal_basis": "DUDH Art. XX, ..."}
|
||||
```
|
||||
|
||||
> **Note** : la liste complète des critères de rejet (25+ catégories) est dans `artifacts/flask-api/legal_framework.py`. Cette page présente les catégories principales. Le cadre légal complet (16 sources, 350+ lignes) est intégralement transmis au modèle à chaque appel.
|
||||
|
||||
---
|
||||
|
||||
## 2. Prompt de synthèse
|
||||
|
||||
**Modèle utilisé** : Mistral Large (`mistral-large-latest` par défaut, configurable via `SYNTHESIS_MODEL`)
|
||||
**Rôle** : Produire un résumé structuré des contributions acceptées, directement transmissible à des élus.
|
||||
|
||||
```
|
||||
Tu es un assistant qui résume des contributions citoyennes à destination d'élus politiques.
|
||||
|
||||
STYLE
|
||||
- Phrases courtes, directes. Pas d'emphase, pas de lyrisme.
|
||||
- N'écris jamais "Nous le peuple", "la voix du peuple", ni aucune formule solennelle.
|
||||
- N'écris pas de phrase d'introduction générale. Va directement aux sujets.
|
||||
- Ton neutre : ni poétique, ni journalistique. Factuel.
|
||||
|
||||
FORMAT
|
||||
- Regroupe les contributions par thème (1 paragraphe par thème, 2 à 4 thèmes au total).
|
||||
- Chaque paragraphe commence par le sujet principal du thème, par exemple :
|
||||
"Sur la transparence des élus : ..." ou "Concernant les services publics : ..."
|
||||
- Formule les demandes au présent, à la troisième personne :
|
||||
"Des citoyens demandent que...", "Plusieurs contributions soulignent que..."
|
||||
- Si des contributions se contredisent sur un point, dis-le en une phrase.
|
||||
- Pas de conclusion, pas de résumé final.
|
||||
- Pas d'emojis, pas de markdown, pas de tirets.
|
||||
|
||||
Réponds avec UNIQUEMENT le texte, sans en-tête ni commentaire.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Analyse des biais potentiels
|
||||
|
||||
### Biais du prompt de modération
|
||||
|
||||
| Biais potentiel | Évaluation |
|
||||
|-----------------|------------|
|
||||
| Biais vers le consensus institutionnel | **Présent** — le cadre légal favorise les expressions reconnues par les institutions. Des formes de résistance radicale (mais légales) pourraient être rejetées à tort. |
|
||||
| Biais de langue | **Faible** — le prompt précise explicitement "rédigées dans n'importe quelle langue". Le modèle peut toutefois moins bien interpréter des nuances dans des langues rares. |
|
||||
| Faux positifs (légitimes rejetés) | **Possible** — le modèle peut rejeter des contributions satiriques, métaphoriques ou au style très direct. Aucun mécanisme de recours automatique n'existe dans la v1. |
|
||||
| Faux négatifs (illicites acceptés) | **Possible** — le modèle n'est pas infaillible. Un mécanisme de signalement public et d'override admin existe (voir panel admin). |
|
||||
|
||||
### Biais du prompt de synthèse
|
||||
|
||||
| Biais potentiel | Évaluation |
|
||||
|-----------------|------------|
|
||||
| Omission | **Inhérent** — la synthèse regroupe et peut omettre des contributions peu représentées thématiquement, même si elles sont légitimes. La vue "/contributions-brutes" permet de vérifier. |
|
||||
| Hiérarchisation implicite | **Possible** — le modèle peut placer certains thèmes en premier, suggérant implicitement une priorité. |
|
||||
| Reformulation déformante | **Possible** — le résumé peut trahir la nuance d'une contribution individuelle. La vue "/contributions-brutes" est le contrepoids. |
|
||||
| Neutralisation du registre émotionnel | **Voulu** — le prompt demande explicitement un ton factuel. Cela lisse intentionnellement l'expression citoyenne. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Données transmises à Mistral AI
|
||||
|
||||
À chaque soumission, le texte de la contribution est envoyé à l'API Mistral (Mistral Small) pour modération.
|
||||
|
||||
Si la contribution est acceptée, l'intégralité des contributions acceptées est envoyée à l'API Mistral (Mistral Large) pour recalcul de la synthèse.
|
||||
|
||||
**Données transmises** : uniquement le texte brut des contributions. Pas d'adresse IP, pas de pseudonyme, pas de fingerprint.
|
||||
|
||||
**Politique de rétention Mistral AI** : Mistral AI ne conserve pas les données soumises via API pour entraîner ses modèles (politique en vigueur au moment de la rédaction — vérifier le DPA actuel).
|
||||
|
||||
---
|
||||
|
||||
## 5. Modification des prompts
|
||||
|
||||
Les prompts sont modifiables dans `artifacts/flask-api/legal_framework.py`. Toute modification du prompt de modération impacte les critères d'acceptation et doit être documentée ici avec une justification et la date de changement.
|
||||
|
||||
**Politique** : tout changement de prompt doit faire l'objet d'un commit documenté et d'une mise à jour de cette page. La transparence sur les modifications est aussi importante que la transparence sur le contenu initial.
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
# Conformité RGPD — La Voix du Peuple
|
||||
|
||||
> Document de référence RGPD. Version 1.0 — mai 2026.
|
||||
> Responsable de traitement : billisdead (Antoine Piron) — piron.antoine@gmail.com
|
||||
|
||||
---
|
||||
|
||||
## 1. Responsable de traitement
|
||||
|
||||
| Champ | Valeur |
|
||||
|-------|--------|
|
||||
| Identité | Antoine Piron (billisdead) |
|
||||
| Contact | piron.antoine@gmail.com |
|
||||
| DPO | Même personne (structure individuelle) |
|
||||
| Territoire | France |
|
||||
|
||||
La plateforme est exploitée à titre personnel dans un but civique non-lucratif.
|
||||
|
||||
---
|
||||
|
||||
## 2. Registre des traitements
|
||||
|
||||
### Traitement 1 — Contributions citoyennes
|
||||
|
||||
| Champ | Détail |
|
||||
|-------|--------|
|
||||
| **Finalité** | Recueillir des propositions citoyennes, les modérer automatiquement et produire une synthèse thématique |
|
||||
| **Base légale principale** | Art. 6.1.e RGPD — exécution d'une mission d'intérêt public (participation démocratique) |
|
||||
| **Base légale données sensibles** | Art. 9.2.a RGPD — **consentement explicite** de la personne concernée (les opinions politiques sont des données sensibles au sens de l'art. 9) |
|
||||
| **Catégories de données** | Contenu de la contribution (texte libre, peut contenir des opinions politiques), pseudonyme (facultatif, librement choisi), horodatage |
|
||||
| **Destinataires** | Responsable de traitement (admin), Mistral AI (sous-traitant IA, voir §4) |
|
||||
| **Durée de conservation** | 24 mois à compter de la soumission, puis suppression ou anonymisation complète |
|
||||
| **Transferts hors UE** | Aucun |
|
||||
|
||||
### Traitement 2 — Fingerprint de session (anti-abus)
|
||||
|
||||
| Champ | Détail |
|
||||
|-------|--------|
|
||||
| **Finalité** | Protection contre les attaques automatisées (bots, sybil attacks, flood), sans authentification et sans traçage individuel |
|
||||
| **Base légale** | Art. 6.1.f RGPD — intérêt légitime du responsable de traitement (sécurité technique de la plateforme) |
|
||||
| **Nature de la donnée** | Hash SHA-256 (tronqué à 32 caractères hexadécimaux) d'un identifiant généré à partir des caractéristiques du navigateur (FingerprintJS). Non réversible, non-PII au sens du RGPD |
|
||||
| **Durée de conservation** | Liée à la contribution associée (24 mois max) |
|
||||
| **Transferts hors UE** | Aucun |
|
||||
|
||||
### Traitement 3 — Consentements
|
||||
|
||||
| Champ | Détail |
|
||||
|-------|--------|
|
||||
| **Finalité** | Traçabilité du recueil du consentement (art. 7.1 RGPD — charge de la preuve) |
|
||||
| **Base légale** | Art. 6.1.c RGPD — obligation légale (conservation de la preuve de consentement) |
|
||||
| **Catégories de données** | Hash fingerprint (voir §2.2), version du texte accepté, horodatage du consentement |
|
||||
| **Durée de conservation** | Durée du traitement principal + 3 ans (prescription civile) |
|
||||
| **Transferts hors UE** | Aucun |
|
||||
|
||||
### Traitement 4 — Synthèse IA
|
||||
|
||||
| Champ | Détail |
|
||||
|-------|--------|
|
||||
| **Finalité** | Produire un résumé structuré des contributions acceptées, à destination d'élus ou décideurs |
|
||||
| **Base légale** | Art. 6.1.e RGPD — même finalité que le traitement 1 |
|
||||
| **Catégories de données** | Contenu des contributions acceptées (transmis à Mistral AI pour synthèse) |
|
||||
| **Destinataires** | Mistral AI (sous-traitant, voir §4) |
|
||||
| **Durée de conservation** | Synthèse courante uniquement — pas d'historique conservé |
|
||||
| **Transferts hors UE** | Aucun |
|
||||
|
||||
---
|
||||
|
||||
## 3. Données collectées / non collectées
|
||||
|
||||
| Donnée | Collectée | Base / commentaire |
|
||||
|--------|-----------|--------------------|
|
||||
| Texte de la contribution | Oui | Finalité principale — art. 6.1.e + 9.2.a |
|
||||
| Pseudonyme | Oui (optionnel) | Librement choisi, pas de vérification |
|
||||
| Horodatage | Oui | Nécessaire à la traçabilité |
|
||||
| Résultat de modération | Oui (admin uniquement) | Traçabilité des décisions automatisées |
|
||||
| Hash fingerprint navigateur | Oui | Art. 6.1.f — sécurité, non-PII |
|
||||
| Consentement (version + date) | Oui | Art. 6.1.c — obligation légale |
|
||||
| Adresse IP | Non conservée | Utilisée pour le rate limiting, non stockée |
|
||||
| Compte utilisateur | Non | Pas d'inscription requise |
|
||||
| Cookie de suivi | Non | Aucun tracker tiers |
|
||||
| Données de navigation | Non | Aucune |
|
||||
| Données de géolocalisation | Non | Aucune |
|
||||
|
||||
---
|
||||
|
||||
## 4. Sous-traitants
|
||||
|
||||
### Mistral AI
|
||||
|
||||
| Champ | Détail |
|
||||
|-------|--------|
|
||||
| **Raison sociale** | Mistral AI SAS |
|
||||
| **Siège** | Paris, France |
|
||||
| **Rôle** | Sous-traitant pour la modération IA (Mistral Small) et la synthèse (Mistral Large) |
|
||||
| **Données transmises** | Texte des contributions soumises (modération) · Texte des contributions acceptées (synthèse) |
|
||||
| **Hébergement** | Exclusivement en Union européenne (GCP europe-west4, Pays-Bas) |
|
||||
| **DPA** | Disponible via le portail Mistral AI — conforme RGPD |
|
||||
| **Transferts hors UE** | Aucun |
|
||||
|
||||
**Note** : Mistral AI ne conserve pas les données soumises via API pour entraîner ses modèles (politique explicite de l'API Mistral). À vérifier et documenter lors de la signature du DPA.
|
||||
|
||||
### Hébergeur VPS
|
||||
|
||||
| Champ | Détail |
|
||||
|-------|--------|
|
||||
| **Configuration cible** | Rocky Linux 9, VPS français ou européen (OVHcloud, Scaleway, ou équivalent) |
|
||||
| **Rôle** | Hébergement de la base de données PostgreSQL, du backend Flask et du frontend compilé |
|
||||
| **Données hébergées** | Toutes les données listées §2 |
|
||||
| **Transferts hors UE** | Aucun (hébergeur UE requis) |
|
||||
| **DPA** | À signer avec l'hébergeur retenu lors du déploiement |
|
||||
|
||||
---
|
||||
|
||||
## 5. Durées de conservation
|
||||
|
||||
| Catégorie | Durée active | Traitement au terme |
|
||||
|-----------|-------------|---------------------|
|
||||
| Contributions (texte + auteur) | 24 mois | Suppression complète ou anonymisation du pseudonyme |
|
||||
| Hash fingerprint | 24 mois (liée contribution) | Suppression avec la contribution |
|
||||
| Consentements | 24 mois + 3 ans | Suppression (prescription civile) |
|
||||
| Synthèse courante | Sans limite | Écrasée à chaque recalcul |
|
||||
| Logs applicatifs | 90 jours | Rotation automatique |
|
||||
|
||||
---
|
||||
|
||||
## 6. Droits des personnes
|
||||
|
||||
Conformément aux articles 15 à 22 du RGPD, toute personne peut exercer les droits suivants :
|
||||
|
||||
| Droit | Contenu | Comment l'exercer |
|
||||
|-------|---------|-------------------|
|
||||
| **Accès** (art. 15) | Obtenir copie des données vous concernant | Email à piron.antoine@gmail.com |
|
||||
| **Rectification** (art. 16) | Corriger des données inexactes | Email à piron.antoine@gmail.com |
|
||||
| **Effacement** (art. 17) | "Droit à l'oubli" — suppression de vos contributions | Email à piron.antoine@gmail.com avec la date approximative de la contribution |
|
||||
| **Opposition** (art. 21) | S'opposer au traitement basé sur l'intérêt légitime (fingerprint) | Email à piron.antoine@gmail.com |
|
||||
| **Portabilité** (art. 20) | Obtenir vos données dans un format structuré | Email à piron.antoine@gmail.com |
|
||||
| **Retrait du consentement** (art. 7.3) | Retirer votre consentement à tout moment | Email à piron.antoine@gmail.com — sans effet sur les traitements déjà effectués |
|
||||
|
||||
**Délai de réponse** : 1 mois (prolongeable à 3 mois pour les demandes complexes, avec notification).
|
||||
|
||||
**Réclamation CNIL** : En cas de non-réponse ou de litige, vous pouvez adresser une réclamation à la CNIL (www.cnil.fr/fr/plaintes).
|
||||
|
||||
---
|
||||
|
||||
## 7. Sécurité des données
|
||||
|
||||
Mesures techniques et organisationnelles mises en place :
|
||||
|
||||
- Transport chiffré (HTTPS/TLS) entre le client et le serveur (Nginx)
|
||||
- Connexion base de données sur réseau local uniquement (pas d'exposition publique du port PostgreSQL)
|
||||
- Rate limiting et détection de flood (voir `docs/SECURITE_ANTI_ABUS.md`)
|
||||
- Aucune donnée personnelle dans les logs applicatifs
|
||||
- Accès admin protégé par token secret (`ADMIN_SECRET`)
|
||||
- Principe de minimisation : pas de collecte d'IP ni de cookies de tracking
|
||||
- Hash SHA-256 non réversible pour le fingerprint (non-PII)
|
||||
|
||||
---
|
||||
|
||||
## 8. Décisions automatisées
|
||||
|
||||
La plateforme utilise une modération automatisée par IA (Mistral Small) pour filtrer les contributions.
|
||||
|
||||
**Information prévue à l'art. 22 RGPD** : si votre contribution est rejetée, le motif et la référence légale précise vous sont communiqués. Vous pouvez reformuler et resoumettre.
|
||||
|
||||
Il n'existe pas de mécanisme de contestation formel de la décision de modération dans la version actuelle. En cas de déploiement institutionnel, un mécanisme de recours humain est recommandé.
|
||||
|
||||
---
|
||||
|
||||
## 9. Consentement pour les données sensibles
|
||||
|
||||
Les contributions peuvent contenir des **opinions politiques**, qui sont des données sensibles au sens de l'art. 9 RGPD. Leur traitement requiert un consentement explicite de la personne concernée.
|
||||
|
||||
**Mécanisme de recueil** : avant la première contribution, un bandeau de consentement explicite est présenté. Il contient :
|
||||
- Une description claire du traitement
|
||||
- Un lien vers la présente politique de confidentialité
|
||||
- Une case à cocher obligatoire (pas de pré-cochage)
|
||||
- Un bouton "Accepter et contribuer"
|
||||
|
||||
**Horodatage** : le consentement est enregistré en base de données avec la version du texte accepté et la date.
|
||||
|
||||
**Retrait** : le consentement peut être retiré à tout moment par email. Les contributions déjà publiées restent dans la base mais peuvent être supprimées sur demande (droit à l'effacement).
|
||||
|
||||
---
|
||||
|
||||
## 10. Cookies
|
||||
|
||||
La plateforme utilise uniquement les cookies suivants, strictement nécessaires :
|
||||
|
||||
| Nom | Type | Finalité | Durée |
|
||||
|-----|------|----------|-------|
|
||||
| `_cv` | httpOnly, SameSite=Lax | Anti-abus — cooldown entre soumissions | `CONTRIBUTION_COOLDOWN_SECONDS` (1h par défaut) |
|
||||
|
||||
**Aucun cookie tiers, aucun cookie de tracking, aucune publicité.**
|
||||
|
||||
---
|
||||
|
||||
## Historique des versions
|
||||
|
||||
| Version | Date | Changements |
|
||||
|---------|------|-------------|
|
||||
| 1.0 | Mai 2026 | Version initiale |
|
||||
@@ -47,6 +47,14 @@ export function removeExtraHeader(key: string): void {
|
||||
delete _extraHeaders[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne l'identifiant de visite FingerprintJS actuellement enregistré.
|
||||
* Utilisé pour transmettre l'identifiant lors d'appels fetch manuels (ex. enregistrement du consentement).
|
||||
*/
|
||||
export function getVisitorId(): string | null {
|
||||
return _visitorId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a base URL that is prepended to every relative request URL
|
||||
* (i.e. paths that start with `/`).
|
||||
|
||||
@@ -4,6 +4,7 @@ export {
|
||||
setBaseUrl,
|
||||
setAuthTokenGetter,
|
||||
setVisitorId,
|
||||
getVisitorId,
|
||||
addExtraHeader,
|
||||
removeExtraHeader,
|
||||
} from "./custom-fetch";
|
||||
|
||||
Reference in New Issue
Block a user