Sprint 5 : integration et production -- securite, performance, API publique, documentation
Backend: rate limiter, security headers, blockchain cache service avec RPC, public API (7 endpoints read-only), WebSocket auth + heartbeat, DB connection pooling, structured logging, health check DB. Frontend: API retry/timeout, WebSocket auth + heartbeat + typed events, notifications toast, mobile hamburger + drawer, error boundary, offline banner, loading skeletons, dashboard enrichi. Documentation: guides utilisateur complets (demarrage, vote, sanctuaire, FAQ 30+), guide deploiement, politique securite. 123 tests, 155 fichiers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
249
backend/app/routers/public.py
Normal file
249
backend/app/routers/public.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""Public API router: read-only endpoints for external consumption.
|
||||
|
||||
All endpoints are accessible without authentication.
|
||||
No mutations allowed -- strictly read-only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.decision import Decision
|
||||
from app.models.document import Document, DocumentItem
|
||||
from app.models.sanctuary import SanctuaryEntry
|
||||
from app.models.vote import VoteSession
|
||||
from app.schemas.document import DocumentFullOut, DocumentItemOut, DocumentOut
|
||||
from app.schemas.sanctuary import SanctuaryEntryOut
|
||||
from app.services import document_service, sanctuary_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Documents (public, read-only) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/documents",
|
||||
response_model=list[DocumentOut],
|
||||
tags=["public-documents"],
|
||||
summary="Liste publique des documents actifs",
|
||||
)
|
||||
async def list_documents(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
doc_type: str | None = Query(default=None, description="Filtrer par type de document"),
|
||||
status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[DocumentOut]:
|
||||
"""Liste les documents de reference avec leurs items (lecture seule)."""
|
||||
stmt = select(Document)
|
||||
|
||||
if doc_type is not None:
|
||||
stmt = stmt.where(Document.doc_type == doc_type)
|
||||
if status_filter is not None:
|
||||
stmt = stmt.where(Document.status == status_filter)
|
||||
|
||||
stmt = stmt.order_by(Document.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
documents = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for doc in documents:
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id)
|
||||
)
|
||||
items_count = count_result.scalar() or 0
|
||||
doc_out = DocumentOut.model_validate(doc)
|
||||
doc_out.items_count = items_count
|
||||
out.append(doc_out)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@router.get(
|
||||
"/documents/{slug}",
|
||||
response_model=DocumentFullOut,
|
||||
tags=["public-documents"],
|
||||
summary="Document complet avec ses items",
|
||||
)
|
||||
async def get_document(
|
||||
slug: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DocumentFullOut:
|
||||
"""Recupere un document avec tous ses items (texte complet, serialise)."""
|
||||
doc = await document_service.get_document_with_items(slug, db)
|
||||
if doc is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Document introuvable",
|
||||
)
|
||||
return DocumentFullOut.model_validate(doc)
|
||||
|
||||
|
||||
# ── Sanctuary (public, read-only) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sanctuary/entries",
|
||||
response_model=list[SanctuaryEntryOut],
|
||||
tags=["public-sanctuary"],
|
||||
summary="Liste des entrees du sanctuaire",
|
||||
)
|
||||
async def list_sanctuary_entries(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
entry_type: str | None = Query(default=None, description="Filtrer par type (document, decision, vote_result)"),
|
||||
skip: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default=50, ge=1, le=200),
|
||||
) -> list[SanctuaryEntryOut]:
|
||||
"""Liste les entrees du sanctuaire (archives verifiees)."""
|
||||
stmt = select(SanctuaryEntry)
|
||||
|
||||
if entry_type is not None:
|
||||
stmt = stmt.where(SanctuaryEntry.entry_type == entry_type)
|
||||
|
||||
stmt = stmt.order_by(SanctuaryEntry.created_at.desc()).offset(skip).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
entries = result.scalars().all()
|
||||
|
||||
return [SanctuaryEntryOut.model_validate(e) for e in entries]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sanctuary/entries/{id}",
|
||||
response_model=SanctuaryEntryOut,
|
||||
tags=["public-sanctuary"],
|
||||
summary="Entree du sanctuaire par ID",
|
||||
)
|
||||
async def get_sanctuary_entry(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SanctuaryEntryOut:
|
||||
"""Recupere une entree du sanctuaire avec lien IPFS et ancrage on-chain."""
|
||||
result = await db.execute(
|
||||
select(SanctuaryEntry).where(SanctuaryEntry.id == id)
|
||||
)
|
||||
entry = result.scalar_one_or_none()
|
||||
if entry is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Entree sanctuaire introuvable",
|
||||
)
|
||||
return SanctuaryEntryOut.model_validate(entry)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sanctuary/verify/{id}",
|
||||
tags=["public-sanctuary"],
|
||||
summary="Verification d'integrite d'une entree",
|
||||
)
|
||||
async def verify_sanctuary_entry(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Verifie l'integrite d'une entree du sanctuaire (comparaison de hash)."""
|
||||
try:
|
||||
result = await sanctuary_service.verify_entry(id, db)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(exc),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# ── Votes (public, read-only) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/votes/sessions/{id}/result",
|
||||
tags=["public-votes"],
|
||||
summary="Resultat d'une session de vote",
|
||||
)
|
||||
async def get_vote_result(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Recupere le resultat d'une session de vote (lecture seule, public)."""
|
||||
result = await db.execute(
|
||||
select(VoteSession).where(VoteSession.id == id)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if session is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Session de vote introuvable",
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": str(session.id),
|
||||
"status": session.status,
|
||||
"votes_for": session.votes_for,
|
||||
"votes_against": session.votes_against,
|
||||
"votes_total": session.votes_total,
|
||||
"wot_size": session.wot_size,
|
||||
"smith_size": session.smith_size,
|
||||
"techcomm_size": session.techcomm_size,
|
||||
"threshold_required": session.threshold_required,
|
||||
"result": session.result,
|
||||
"starts_at": session.starts_at.isoformat() if session.starts_at else None,
|
||||
"ends_at": session.ends_at.isoformat() if session.ends_at else None,
|
||||
"chain_recorded": session.chain_recorded,
|
||||
"chain_tx_hash": session.chain_tx_hash,
|
||||
}
|
||||
|
||||
|
||||
# ── Platform status ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/status",
|
||||
tags=["public-status"],
|
||||
summary="Statut de la plateforme",
|
||||
)
|
||||
async def platform_status(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Statut general de la plateforme: compteurs de documents, decisions, votes actifs."""
|
||||
# Count documents
|
||||
doc_count_result = await db.execute(
|
||||
select(func.count()).select_from(Document)
|
||||
)
|
||||
documents_count = doc_count_result.scalar() or 0
|
||||
|
||||
# Count decisions
|
||||
decision_count_result = await db.execute(
|
||||
select(func.count()).select_from(Decision)
|
||||
)
|
||||
decisions_count = decision_count_result.scalar() or 0
|
||||
|
||||
# Count active vote sessions
|
||||
active_votes_result = await db.execute(
|
||||
select(func.count()).select_from(VoteSession).where(VoteSession.status == "open")
|
||||
)
|
||||
active_votes_count = active_votes_result.scalar() or 0
|
||||
|
||||
# Count total vote sessions
|
||||
total_votes_result = await db.execute(
|
||||
select(func.count()).select_from(VoteSession)
|
||||
)
|
||||
total_votes_count = total_votes_result.scalar() or 0
|
||||
|
||||
# Count sanctuary entries
|
||||
sanctuary_count_result = await db.execute(
|
||||
select(func.count()).select_from(SanctuaryEntry)
|
||||
)
|
||||
sanctuary_count = sanctuary_count_result.scalar() or 0
|
||||
|
||||
return {
|
||||
"platform": "Glibredecision",
|
||||
"documents_count": documents_count,
|
||||
"decisions_count": decisions_count,
|
||||
"active_votes_count": active_votes_count,
|
||||
"total_votes_count": total_votes_count,
|
||||
"sanctuary_entries_count": sanctuary_count,
|
||||
}
|
||||
@@ -1,14 +1,37 @@
|
||||
"""WebSocket router: live vote updates."""
|
||||
"""WebSocket router: live vote updates with authentication and heartbeat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import async_session
|
||||
from app.models.user import Session as UserSession
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Heartbeat interval in seconds
|
||||
_HEARTBEAT_INTERVAL = 30
|
||||
|
||||
# Valid notification event types
|
||||
EVENT_TYPES = (
|
||||
"vote_submitted",
|
||||
"vote_update",
|
||||
"session_closed",
|
||||
"tally_update",
|
||||
"decision_advanced",
|
||||
"mandate_updated",
|
||||
"document_changed",
|
||||
"sanctuary_archived",
|
||||
)
|
||||
|
||||
|
||||
# ── Connection manager ──────────────────────────────────────────────────────
|
||||
@@ -20,6 +43,8 @@ class ConnectionManager:
|
||||
def __init__(self) -> None:
|
||||
# session_id -> list of connected websockets
|
||||
self._connections: dict[uuid.UUID, list[WebSocket]] = {}
|
||||
# websocket -> authenticated identity_id (or None for anonymous)
|
||||
self._authenticated: dict[WebSocket, uuid.UUID | None] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, session_id: uuid.UUID) -> None:
|
||||
"""Accept a WebSocket connection and register it for a vote session."""
|
||||
@@ -36,6 +61,7 @@ class ConnectionManager:
|
||||
]
|
||||
if not self._connections[session_id]:
|
||||
del self._connections[session_id]
|
||||
self._authenticated.pop(websocket, None)
|
||||
|
||||
async def broadcast(self, session_id: uuid.UUID, data: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connections watching a given vote session."""
|
||||
@@ -55,10 +81,127 @@ class ConnectionManager:
|
||||
for ws in dead:
|
||||
self.disconnect(ws, session_id)
|
||||
|
||||
async def broadcast_all(self, data: dict[str, Any]) -> None:
|
||||
"""Broadcast a message to all connected WebSockets across all sessions."""
|
||||
message = json.dumps(data, default=str)
|
||||
for session_id in list(self._connections.keys()):
|
||||
dead: list[WebSocket] = []
|
||||
for ws in self._connections.get(session_id, []):
|
||||
try:
|
||||
await ws.send_text(message)
|
||||
except Exception:
|
||||
dead.append(ws)
|
||||
for ws in dead:
|
||||
self.disconnect(ws, session_id)
|
||||
|
||||
def set_authenticated(self, websocket: WebSocket, identity_id: uuid.UUID) -> None:
|
||||
"""Mark a WebSocket connection as authenticated."""
|
||||
self._authenticated[websocket] = identity_id
|
||||
|
||||
def is_authenticated(self, websocket: WebSocket) -> bool:
|
||||
"""Check if a WebSocket connection is authenticated."""
|
||||
return self._authenticated.get(websocket) is not None
|
||||
|
||||
|
||||
manager = ConnectionManager()
|
||||
|
||||
|
||||
# ── Authentication helper ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _validate_token(token: str) -> uuid.UUID | None:
|
||||
"""Validate a bearer token and return the associated identity_id.
|
||||
|
||||
Uses the same token hashing as auth_service but without FastAPI
|
||||
dependency injection (since WebSocket doesn't use Depends the same way).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
token:
|
||||
The raw bearer token from the query parameter.
|
||||
|
||||
Returns
|
||||
-------
|
||||
uuid.UUID | None
|
||||
The identity_id if valid, or None if invalid/expired.
|
||||
"""
|
||||
import hashlib
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
try:
|
||||
async with async_session() as db:
|
||||
result = await db.execute(
|
||||
select(UserSession).where(
|
||||
UserSession.token_hash == token_hash,
|
||||
UserSession.expires_at > datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
session = result.scalar_one_or_none()
|
||||
if session is not None:
|
||||
return session.identity_id
|
||||
except Exception:
|
||||
logger.warning("Erreur lors de la validation du token WebSocket", exc_info=True)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Broadcast event helper (importable by other routers) ──────────────────
|
||||
|
||||
|
||||
async def broadcast_event(
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
session_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Broadcast a notification event to connected WebSocket clients.
|
||||
|
||||
This function is designed to be imported and called from other routers
|
||||
(votes, decisions, mandates, etc.) to push real-time notifications.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
event_type:
|
||||
One of the valid EVENT_TYPES.
|
||||
payload:
|
||||
The event data to send.
|
||||
session_id:
|
||||
If provided, broadcast only to clients watching this specific session.
|
||||
If None, broadcast to all connected clients.
|
||||
"""
|
||||
data = {
|
||||
"event": event_type,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
**payload,
|
||||
}
|
||||
|
||||
if session_id is not None:
|
||||
await manager.broadcast(session_id, data)
|
||||
else:
|
||||
await manager.broadcast_all(data)
|
||||
|
||||
|
||||
# ── Heartbeat task ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def _heartbeat(websocket: WebSocket) -> None:
|
||||
"""Send periodic ping messages to keep the connection alive.
|
||||
|
||||
Runs as a background task alongside the main message loop.
|
||||
Sends a JSON ping every _HEARTBEAT_INTERVAL seconds.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL)
|
||||
try:
|
||||
await websocket.send_text(
|
||||
json.dumps({"event": "ping", "timestamp": datetime.now(timezone.utc).isoformat()})
|
||||
)
|
||||
except Exception:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
# ── WebSocket endpoint ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -66,23 +209,51 @@ manager = ConnectionManager()
|
||||
async def live_updates(websocket: WebSocket) -> None:
|
||||
"""WebSocket endpoint for live vote session updates.
|
||||
|
||||
The client connects and sends a JSON message with the session_id
|
||||
they want to subscribe to:
|
||||
Authentication (optional):
|
||||
Connect with ``?token=<bearer_token>`` query parameter to authenticate.
|
||||
If the token is valid, the connection is marked as authenticated.
|
||||
If missing or invalid, the connection is accepted but unauthenticated.
|
||||
|
||||
The client sends JSON messages:
|
||||
|
||||
{ "action": "subscribe", "session_id": "<uuid>" }
|
||||
|
||||
The server will then push vote update events to the client:
|
||||
|
||||
{ "event": "vote_update", "session_id": "...", "votes_for": N, "votes_against": N, "votes_total": N }
|
||||
{ "event": "session_closed", "session_id": "...", "result": "adopted|rejected" }
|
||||
|
||||
The client can also unsubscribe:
|
||||
|
||||
{ "action": "unsubscribe", "session_id": "<uuid>" }
|
||||
|
||||
The server pushes events:
|
||||
|
||||
{ "event": "vote_update", "session_id": "...", ... }
|
||||
{ "event": "session_closed", "session_id": "...", ... }
|
||||
{ "event": "vote_submitted", ... }
|
||||
{ "event": "decision_advanced", ... }
|
||||
{ "event": "mandate_updated", ... }
|
||||
{ "event": "document_changed", ... }
|
||||
{ "event": "sanctuary_archived", ... }
|
||||
{ "event": "ping", "timestamp": "..." } (heartbeat)
|
||||
"""
|
||||
# Extract token from query parameters
|
||||
token = websocket.query_params.get("token")
|
||||
|
||||
await websocket.accept()
|
||||
subscribed_sessions: set[uuid.UUID] = set()
|
||||
|
||||
# Authenticate if token provided
|
||||
if token:
|
||||
identity_id = await _validate_token(token)
|
||||
if identity_id is not None:
|
||||
manager.set_authenticated(websocket, identity_id)
|
||||
await websocket.send_text(
|
||||
json.dumps({"event": "authenticated", "identity_id": str(identity_id)})
|
||||
)
|
||||
logger.debug("WebSocket authentifie: identity=%s", identity_id)
|
||||
else:
|
||||
await websocket.send_text(
|
||||
json.dumps({"event": "auth_failed", "detail": "Token invalide ou expire"})
|
||||
)
|
||||
logger.debug("Echec authentification WebSocket (token invalide)")
|
||||
|
||||
# Start heartbeat task
|
||||
heartbeat_task = asyncio.create_task(_heartbeat(websocket))
|
||||
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
@@ -138,3 +309,11 @@ async def live_updates(websocket: WebSocket) -> None:
|
||||
# Clean up all subscriptions for this client
|
||||
for session_id in subscribed_sessions:
|
||||
manager.disconnect(websocket, session_id)
|
||||
logger.debug("WebSocket deconnecte, %d souscriptions nettoyees", len(subscribed_sessions))
|
||||
finally:
|
||||
# Cancel the heartbeat task
|
||||
heartbeat_task.cancel()
|
||||
try:
|
||||
await heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user