Add secure admin panel for content moderation and contribution flagging

Adds an admin interface with authentication for manual content deletion and flagging. Implements a flagging system for user contributions and secures the admin panel with a secret token.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 923ae0e3-a363-4db8-b04a-e8baca2a1330
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 7e5834b1-796d-4a9e-bbde-cd91012292de
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8af7d2ec-2cc3-4ece-8af3-9f071488d072/923ae0e3-a363-4db8-b04a-e8baca2a1330/nghZcOj
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
pironantoine
2026-04-05 03:42:58 +00:00
parent e58c1cef85
commit 2a792cbbb5
5 changed files with 984 additions and 10 deletions
+191 -4
View File
@@ -2,7 +2,8 @@
La Voix du Peuple — Backend Flask
==================================
Plateforme démocratique citoyenne.
Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000)
Base légale : DUDH (ONU 1948), PIDCP (ONU 1966), CEDH (1950), Charte UE (2000),
Code pénal français, Loi du 29 juillet 1881, LCEN, SREN 2024.
Sécurité :
- Rate limiting (flask-limiter)
@@ -10,21 +11,28 @@ Sécurité :
- CORS restreint
- En-têtes de sécurité HTTP (CSP, HSTS, X-Frame-Options, etc.)
- Protection contre l'injection via requêtes paramétrées (psycopg2)
- Panel admin protégé par ADMIN_SECRET (Bearer token)
- Aucun secret exposé dans les réponses d'erreur
"""
import csv
import io
import os
import logging
import threading
from datetime import datetime, timezone
from functools import wraps
import bleach
from flask import Flask, jsonify, request, g
from flask import Flask, jsonify, request, Response
from flask_cors import CORS
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from database import init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis, get_synthesis, get_all_ideas
from database import (
init_db, insert_idea, get_accepted_ideas, get_stats, upsert_synthesis,
get_synthesis, get_all_ideas, get_ideas_admin, delete_idea, bulk_delete_ideas,
override_idea, flag_idea, unflag_idea,
)
from ai_agent import filter_idea, synthesize_ideas
# ─── Logging ────────────────────────────────────────────────────────────────
@@ -106,6 +114,23 @@ def validate_idea_input(data: dict) -> tuple[dict | None, str | None]:
return {"content": content, "author": author}, None
# ─── Authentification Admin ──────────────────────────────────────────────────
def _get_admin_secret() -> str | None:
return os.environ.get("ADMIN_SECRET")
def require_admin(f):
@wraps(f)
def decorated(*args, **kwargs):
secret = _get_admin_secret()
if not secret:
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer ") or auth[7:] != secret:
return jsonify({"error": "unauthorized", "message": "Accès non autorisé."}), 401
return f(*args, **kwargs)
return decorated
# ─── Gestion des erreurs ─────────────────────────────────────────────────────
@app.errorhandler(400)
@@ -220,6 +245,160 @@ def get_synthesis_route():
"updatedAt": synthesis["updated_at"].isoformat() if synthesis["updated_at"] else None,
})
# ─── Route publique : signalement ────────────────────────────────────────────
@app.post("/api/ideas/<int:idea_id>/flag")
@limiter.limit("3 per minute; 10 per hour")
def flag_idea_route(idea_id: int):
"""Signale une contribution pour examen par l'administrateur."""
result = flag_idea(idea_id)
if not result:
return jsonify({"error": "not_found", "message": "Contribution introuvable ou non publiée."}), 404
return jsonify({"ok": True, "flagCount": result.get("flag_count", 1)})
# ─── Routes Admin ─────────────────────────────────────────────────────────────
@app.post("/api/admin/login")
@limiter.limit("10 per minute")
def admin_login():
"""Vérifie le mot de passe admin. Retourne ok si correct."""
secret = _get_admin_secret()
if not secret:
return jsonify({"error": "admin_not_configured", "message": "ADMIN_SECRET non configuré."}), 503
data = request.get_json(silent=True) or {}
password = data.get("password", "")
if password != secret:
logger.warning("Tentative de connexion admin échouée")
return jsonify({"error": "unauthorized", "message": "Mot de passe incorrect."}), 401
logger.info("Connexion admin réussie")
return jsonify({"ok": True, "token": secret})
@app.get("/api/admin/stats")
@require_admin
def admin_stats():
"""Statistiques détaillées pour l'administrateur."""
stats = get_stats()
return jsonify(stats)
@app.get("/api/admin/ideas")
@require_admin
@limiter.limit("120 per minute")
def admin_list_ideas():
"""Liste toutes les contributions avec filtres et pagination."""
status = request.args.get("status", "all")
page = max(1, int(request.args.get("page", 1)))
per_page = min(100, max(10, int(request.args.get("per_page", 50))))
search = request.args.get("q", "").strip()
if status not in ("all", "accepted", "rejected", "flagged"):
status = "all"
ideas, total = get_ideas_admin(status=status, page=page, per_page=per_page, search=search)
return jsonify({
"ideas": [serialize_idea_admin(i) for i in ideas],
"total": total,
"page": page,
"perPage": per_page,
"pages": max(1, -(-total // per_page)),
})
@app.delete("/api/admin/ideas/<int:idea_id>")
@require_admin
def admin_delete_idea(idea_id: int):
"""Supprime une contribution et régénère la synthèse."""
deleted = delete_idea(idea_id)
if not deleted:
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
threading.Thread(target=_update_synthesis_background, daemon=True).start()
logger.info("Admin — contribution #%d supprimée", idea_id)
return jsonify({"ok": True, "synthesisUpdating": True})
@app.post("/api/admin/ideas/bulk-delete")
@require_admin
def admin_bulk_delete():
"""Suppression en masse de contributions."""
data = request.get_json(silent=True) or {}
ids = data.get("ids", [])
if not isinstance(ids, list) or not ids:
return jsonify({"error": "validation_error", "message": "ids doit être une liste non vide."}), 400
ids = [int(i) for i in ids if isinstance(i, (int, str)) and str(i).isdigit()]
count = bulk_delete_ideas(ids)
if count > 0:
threading.Thread(target=_update_synthesis_background, daemon=True).start()
logger.info("Admin — %d contribution(s) supprimée(s) en masse", count)
return jsonify({"ok": True, "deleted": count, "synthesisUpdating": count > 0})
@app.post("/api/admin/ideas/<int:idea_id>/override")
@require_admin
def admin_override_idea(idea_id: int):
"""Modifie manuellement le statut d'une contribution (accepter/rejeter)."""
data = request.get_json(silent=True) or {}
accepted = bool(data.get("accepted", False))
reason = sanitize_text(data.get("reason", "") or "")
note = sanitize_text(data.get("note", "") or "")
result = override_idea(idea_id, accepted, reason or None, note or None)
if not result:
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
threading.Thread(target=_update_synthesis_background, daemon=True).start()
logger.info("Admin — contribution #%d override → accepted=%s", idea_id, accepted)
return jsonify({"ok": True, "idea": serialize_idea_admin(result), "synthesisUpdating": True})
@app.post("/api/admin/ideas/<int:idea_id>/unflag")
@require_admin
def admin_unflag_idea(idea_id: int):
"""Retire le signalement d'une contribution."""
result = unflag_idea(idea_id)
if not result:
return jsonify({"error": "not_found", "message": "Contribution introuvable."}), 404
return jsonify({"ok": True, "idea": serialize_idea_admin(result)})
@app.post("/api/admin/synthesis/regenerate")
@require_admin
@limiter.limit("5 per minute")
def admin_regenerate_synthesis():
"""Force la régénération complète de la synthèse."""
threading.Thread(target=_update_synthesis_background, daemon=True).start()
logger.info("Admin — régénération manuelle de la synthèse déclenchée")
return jsonify({"ok": True, "message": "Régénération lancée en arrière-plan."})
@app.get("/api/admin/export/csv")
@require_admin
def admin_export_csv():
"""Exporte toutes les contributions en CSV."""
ideas, _ = get_ideas_admin(status="all", page=1, per_page=10000)
output = io.StringIO()
writer = csv.writer(output, quoting=csv.QUOTE_ALL)
writer.writerow(["id", "content", "author", "accepted", "flagged",
"flag_count", "rejection_reason", "legal_basis",
"admin_note", "created_at"])
for idea in ideas:
writer.writerow([
idea.get("id"),
idea.get("content", ""),
idea.get("author", ""),
"oui" if idea.get("accepted") else "non",
"oui" if idea.get("flagged") else "non",
idea.get("flag_count", 0),
idea.get("rejection_reason", ""),
idea.get("legal_basis", ""),
idea.get("admin_note", ""),
idea.get("created_at").isoformat() if idea.get("created_at") else "",
])
csv_bytes = output.getvalue().encode("utf-8-sig")
return Response(
csv_bytes,
mimetype="text/csv",
headers={"Content-Disposition": "attachment; filename=contributions.csv"},
)
# ─── Helpers ─────────────────────────────────────────────────────────────────
def serialize_idea(idea: dict) -> dict:
@@ -228,12 +407,20 @@ def serialize_idea(idea: dict) -> dict:
"content": idea["content"],
"author": idea.get("author"),
"accepted": idea["accepted"],
"flagged": idea.get("flagged", False),
"flagCount": idea.get("flag_count", 0),
"rejectionReason": idea.get("rejection_reason"),
"legalBasis": idea.get("legal_basis"),
"createdAt": idea["created_at"].isoformat() if idea.get("created_at") else None,
}
def serialize_idea_admin(idea: dict) -> dict:
base = serialize_idea(idea)
base["adminNote"] = idea.get("admin_note")
return base
def _update_synthesis_background():
try:
ideas = get_accepted_ideas()
+98 -5
View File
@@ -46,10 +46,10 @@ def init_db():
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
""")
cur.execute("""
ALTER TABLE ideas
ADD COLUMN IF NOT EXISTS legal_basis TEXT
""")
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS legal_basis TEXT")
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flagged BOOLEAN NOT NULL DEFAULT FALSE")
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS flag_count INTEGER NOT NULL DEFAULT 0")
cur.execute("ALTER TABLE ideas ADD COLUMN IF NOT EXISTS admin_note TEXT")
cur.execute("""
CREATE TABLE IF NOT EXISTS synthesis (
id SERIAL PRIMARY KEY,
@@ -91,6 +91,97 @@ def get_all_ideas(limit: int = 50) -> list[dict]:
return [dict(row) for row in cur.fetchall()]
def get_ideas_admin(status: str = "all", page: int = 1,
per_page: int = 50, search: str = "") -> tuple[list[dict], int]:
offset = (page - 1) * per_page
conditions = []
params: list = []
if status == "accepted":
conditions.append("accepted = TRUE")
elif status == "rejected":
conditions.append("accepted = FALSE AND flagged = FALSE")
elif status == "flagged":
conditions.append("flagged = TRUE")
if search:
conditions.append("content ILIKE %s")
params.append(f"%{search}%")
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
with db_cursor() as cur:
cur.execute(f"SELECT COUNT(*) as total FROM ideas {where}", params)
total = cur.fetchone()["total"]
cur.execute(
f"SELECT * FROM ideas {where} ORDER BY flagged DESC, created_at DESC LIMIT %s OFFSET %s",
params + [per_page, offset],
)
rows = [dict(row) for row in cur.fetchall()]
return rows, total
def delete_idea(idea_id: int) -> bool:
with db_cursor() as cur:
cur.execute("DELETE FROM ideas WHERE id = %s RETURNING id", (idea_id,))
return cur.fetchone() is not None
def bulk_delete_ideas(idea_ids: list[int]) -> int:
if not idea_ids:
return 0
with db_cursor() as cur:
cur.execute(
"DELETE FROM ideas WHERE id = ANY(%s) RETURNING id",
(idea_ids,),
)
return len(cur.fetchall())
def override_idea(idea_id: int, accepted: bool,
reason: str | None, note: str | None) -> dict | None:
with db_cursor() as cur:
cur.execute(
"""
UPDATE ideas
SET accepted = %s,
rejection_reason = %s,
flagged = FALSE,
admin_note = %s
WHERE id = %s
RETURNING *
""",
(accepted, reason, note, idea_id),
)
row = cur.fetchone()
return dict(row) if row else None
def flag_idea(idea_id: int) -> dict | None:
with db_cursor() as cur:
cur.execute(
"""
UPDATE ideas
SET flagged = TRUE, flag_count = flag_count + 1
WHERE id = %s AND accepted = TRUE
RETURNING *
""",
(idea_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def unflag_idea(idea_id: int) -> dict | None:
with db_cursor() as cur:
cur.execute(
"UPDATE ideas SET flagged = FALSE WHERE id = %s RETURNING *",
(idea_id,),
)
row = cur.fetchone()
return dict(row) if row else None
def get_stats() -> dict:
with db_cursor() as cur:
cur.execute("SELECT COUNT(*) as total FROM ideas")
@@ -99,7 +190,9 @@ def get_stats() -> dict:
accepted = cur.fetchone()["accepted"]
cur.execute("SELECT COUNT(*) as rejected FROM ideas WHERE accepted = FALSE")
rejected = cur.fetchone()["rejected"]
return {"total": total, "accepted": accepted, "rejected": rejected}
cur.execute("SELECT COUNT(*) as flagged FROM ideas WHERE flagged = TRUE")
flagged = cur.fetchone()["flagged"]
return {"total": total, "accepted": accepted, "rejected": rejected, "flagged": flagged}
def upsert_synthesis(text: str, idea_count: int) -> dict: