veza/veza-backend-api/internal/handlers/common.go
okinrev b7955a680c P0: stabilisation backend/chat/stream + nouvelle base migrations v1
Backend Go:
- Remplacement complet des anciennes migrations par la base V1 alignée sur ORIGIN.
- Durcissement global du parsing JSON (BindAndValidateJSON + RespondWithAppError).
- Sécurisation de config.go, CORS, statuts de santé et monitoring.
- Implémentation des transactions P0 (RBAC, duplication de playlists, social toggles).
- Ajout d’un job worker structuré (emails, analytics, thumbnails) + tests associés.
- Nouvelle doc backend : AUDIT_CONFIG, BACKEND_CONFIG, AUTH_PASSWORD_RESET, JOB_WORKER_*.

Chat server (Rust):
- Refonte du pipeline JWT + sécurité, audit et rate limiting avancé.
- Implémentation complète du cycle de message (read receipts, delivered, edit/delete, typing).
- Nettoyage des panics, gestion d’erreurs robuste, logs structurés.
- Migrations chat alignées sur le schéma UUID et nouvelles features.

Stream server (Rust):
- Refonte du moteur de streaming (encoding pipeline + HLS) et des modules core.
- Transactions P0 pour les jobs et segments, garanties d’atomicité.
- Documentation détaillée de la pipeline (AUDIT_STREAM_*, DESIGN_STREAM_PIPELINE, TRANSACTIONS_P0_IMPLEMENTATION).

Documentation & audits:
- TRIAGE.md et AUDIT_STABILITY.md à jour avec l’état réel des 3 services.
- Cartographie complète des migrations et des transactions (DB_MIGRATIONS_*, DB_TRANSACTION_PLAN, AUDIT_DB_TRANSACTIONS, TRANSACTION_TESTS_PHASE3).
- Scripts de reset et de cleanup pour la lab DB et la V1.

Ce commit fige l’ensemble du travail de stabilisation P0 (UUID, backend, chat et stream) avant les phases suivantes (Coherence Guardian, WS hardening, etc.).
2025-12-06 11:14:38 +01:00

482 lines
14 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"veza-backend-api/internal/dto"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/validators"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// ResponseData représente la structure standardisée des réponses API
type ResponseData struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Timestamp time.Time `json:"timestamp"`
RequestID string `json:"request_id,omitempty"`
}
// PaginationData représente les données de pagination
type PaginationData struct {
Page int `json:"page"`
Limit int `json:"limit"`
Total int64 `json:"total"`
TotalPages int `json:"total_pages"`
HasNext bool `json:"has_next"`
HasPrevious bool `json:"has_previous"`
NextCursor string `json:"next_cursor,omitempty"`
PreviousCursor string `json:"previous_cursor,omitempty"`
}
// PaginatedResponse représente une réponse paginée
type PaginatedResponse struct {
ResponseData
Pagination PaginationData `json:"pagination"`
}
// ValidationError et ValidationErrors sont maintenant dans internal/dto/validation.go
// pour éviter les cycles d'import. Utiliser dto.ValidationError et dto.ValidationErrors
// CommonHandler contient les dépendances communes aux handlers
type CommonHandler struct {
logger *zap.Logger
validator *validators.Validator // GO-013: Validator centralisé
}
// NewCommonHandler crée une nouvelle instance de CommonHandler
// GO-013: Initialise le validator centralisé
func NewCommonHandler(logger *zap.Logger) *CommonHandler {
return &CommonHandler{
logger: logger,
validator: validators.NewValidator(),
}
}
// ValidateRequest valide une requête avec le validator centralisé
// GO-013: Helper pour valider les requêtes et retourner des erreurs formatées
func (h *CommonHandler) ValidateRequest(c *gin.Context, req interface{}) bool {
validationErrors := h.validator.Validate(req)
if len(validationErrors) > 0 {
h.RespondWithValidationError(c, validationErrors)
return false
}
return true
}
// RespondWithSuccess répond avec une réponse de succès
func (h *CommonHandler) RespondWithSuccess(c *gin.Context, data interface{}, message string) {
response := ResponseData{
Success: true,
Message: message,
Data: data,
Timestamp: time.Now(),
RequestID: c.GetString("request_id"),
}
c.JSON(http.StatusOK, response)
}
// RespondWithError répond avec une erreur
func (h *CommonHandler) RespondWithError(c *gin.Context, statusCode int, message string, err error) {
response := ResponseData{
Success: false,
Error: message,
Timestamp: time.Now(),
RequestID: c.GetString("request_id"),
}
if err != nil {
h.logger.Error("Handler error",
zap.String("error", err.Error()),
zap.String("request_id", c.GetString("request_id")),
zap.String("endpoint", c.Request.URL.Path),
)
}
c.JSON(statusCode, response)
}
// RespondWithValidationError répond avec des erreurs de validation
// GO-013: Utilise dto.ValidationError pour éviter les cycles d'import
func (h *CommonHandler) RespondWithValidationError(c *gin.Context, errors []dto.ValidationError) {
response := ResponseData{
Success: false,
Error: "Validation failed",
Data: dto.ValidationErrors{Errors: errors},
Timestamp: time.Now(),
RequestID: c.GetString("request_id"),
}
c.JSON(http.StatusBadRequest, response)
}
// RespondWithPaginatedData répond avec des données paginées
func (h *CommonHandler) RespondWithPaginatedData(c *gin.Context, data interface{}, pagination PaginationData, message string) {
response := PaginatedResponse{
ResponseData: ResponseData{
Success: true,
Message: message,
Data: data,
Timestamp: time.Now(),
RequestID: c.GetString("request_id"),
},
Pagination: pagination,
}
c.JSON(http.StatusOK, response)
}
// BindJSON lie les données JSON de la requête à une structure
// DEPRECATED: Utiliser BindAndValidateJSON à la place pour une gestion d'erreurs robuste
func (h *CommonHandler) BindJSON(c *gin.Context, obj interface{}) error {
if err := c.ShouldBindJSON(obj); err != nil {
h.logger.Warn("Failed to bind JSON",
zap.Error(err),
zap.String("request_id", c.GetString("request_id")),
)
return err
}
return nil
}
// MaxJSONBodySize définit la taille maximale du body JSON (10MB par défaut)
const MaxJSONBodySize = 10 * 1024 * 1024 // 10MB
// BindAndValidateJSON lie et valide les données JSON de la requête de manière robuste
// P0: JSON Hardening - Garantit qu'aucune erreur de parsing/validation ne passe silencieusement
//
// Comportement:
// - Vérifie la taille du body (max 10MB par défaut)
// - Parse le JSON avec ShouldBindJSON (Gin)
// - Valide avec le validator centralisé
// - Retourne une AppError avec code approprié (400 pour JSON malformé, 422 pour validation)
//
// Usage:
//
// var req MyRequest
// if appErr := h.BindAndValidateJSON(c, &req); appErr != nil {
// RespondWithAppError(c, appErr)
// return
// }
func (h *CommonHandler) BindAndValidateJSON(c *gin.Context, obj interface{}) *apperrors.AppError {
requestID := c.GetString("request_id")
// 1. Vérifier la taille du body
if c.Request.ContentLength > MaxJSONBodySize {
h.logger.Warn("Request body too large",
zap.Int64("content_length", c.Request.ContentLength),
zap.Int64("max_size", MaxJSONBodySize),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
fmt.Sprintf("Request body too large: maximum size is %d bytes", MaxJSONBodySize),
)
}
// 2. Limiter la lecture du body pour éviter les attaques par body trop gros
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, MaxJSONBodySize)
// 3. Parser le JSON avec ShouldBindJSON
if err := c.ShouldBindJSON(obj); err != nil {
// Analyser le type d'erreur pour retourner le bon code
var jsonSyntaxError *json.SyntaxError
var jsonUnmarshalTypeError *json.UnmarshalTypeError
var maxBytesError *http.MaxBytesError
switch {
case errors.As(err, &maxBytesError):
// Body trop gros (dépassement de la limite)
h.logger.Warn("Request body exceeds maximum size",
zap.Error(err),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
fmt.Sprintf("Request body too large: maximum size is %d bytes", MaxJSONBodySize),
)
case errors.As(err, &jsonSyntaxError):
// JSON syntaxiquement invalide
h.logger.Warn("Invalid JSON syntax",
zap.Error(err),
zap.Int64("offset", jsonSyntaxError.Offset),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
fmt.Sprintf("Invalid JSON syntax at offset %d: %s", jsonSyntaxError.Offset, jsonSyntaxError.Error()),
)
case errors.As(err, &jsonUnmarshalTypeError):
// Type incorrect pour un champ
h.logger.Warn("Invalid JSON type",
zap.Error(err),
zap.String("field", jsonUnmarshalTypeError.Field),
zap.String("type", jsonUnmarshalTypeError.Type.String()),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeInvalidFormat,
fmt.Sprintf("Invalid type for field '%s': expected %s", jsonUnmarshalTypeError.Field, jsonUnmarshalTypeError.Type.String()),
)
case errors.Is(err, io.EOF):
// Body vide
h.logger.Warn("Empty request body",
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
"Request body is empty or invalid JSON",
)
case errors.Is(err, io.ErrUnexpectedEOF):
// JSON incomplet
h.logger.Warn("Incomplete JSON",
zap.Error(err),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
"Incomplete or malformed JSON",
)
default:
// Erreur générique de binding (peut inclure des erreurs de validation Gin)
// On va laisser le validator gérer les erreurs de validation
// Si c'est une erreur de binding Gin (ex: unknown field), on la traite ici
errStr := err.Error()
if strings.Contains(errStr, "unknown field") || strings.Contains(errStr, "unknown") {
h.logger.Warn("Unknown fields in JSON",
zap.Error(err),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(
apperrors.ErrCodeValidation,
"Unknown fields in JSON payload",
)
}
// Pour les autres erreurs de binding, on considère que c'est une erreur de validation
// et on va laisser le validator s'en occuper
h.logger.Debug("JSON binding error (will be handled by validator)",
zap.Error(err),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
}
}
// 4. Valider avec le validator centralisé
validationErrors := h.validator.Validate(obj)
if len(validationErrors) > 0 {
// Convertir dto.ValidationError en errors.ErrorDetail
details := make([]apperrors.ErrorDetail, 0, len(validationErrors))
for _, ve := range validationErrors {
details = append(details, apperrors.ErrorDetail{
Field: ve.Field,
Message: ve.Message,
})
}
h.logger.Warn("Validation failed",
zap.Int("error_count", len(validationErrors)),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.NewValidationError("Validation failed", details...)
}
return nil
}
// GetUserIDFromContext extrait l'ID utilisateur du contexte
func (h *CommonHandler) GetUserIDFromContext(c *gin.Context) (string, error) {
userID, exists := c.Get("user_id")
if !exists {
return "", apperrors.NewUnauthorizedError("User not authenticated")
}
userIDStr, ok := userID.(string)
if !ok {
return "", apperrors.New(apperrors.ErrCodeValidation, "Invalid user ID type")
}
return userIDStr, nil
}
// GetPaginationParams extrait les paramètres de pagination de la requête
func (h *CommonHandler) GetPaginationParams(c *gin.Context) (page, limit int, cursor string) {
page = 1
limit = 20
if pageStr := c.Query("page"); pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
if limitStr := c.Query("limit"); limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
}
cursor = c.Query("cursor")
return page, limit, cursor
}
// ValidatePagination valide les paramètres de pagination
// GO-013: Utilise dto.ValidationError
func (h *CommonHandler) ValidatePagination(page, limit int) []dto.ValidationError {
var validationErrors []dto.ValidationError
if page < 1 {
validationErrors = append(validationErrors, dto.ValidationError{
Field: "page",
Message: "Page must be greater than 0",
Value: strconv.Itoa(page),
})
}
if limit < 1 || limit > 100 {
validationErrors = append(validationErrors, dto.ValidationError{
Field: "limit",
Message: "Limit must be between 1 and 100",
Value: strconv.Itoa(limit),
})
}
return validationErrors
}
// LogRequest log une requête entrante
func (h *CommonHandler) LogRequest(c *gin.Context, operation string) {
h.logger.Info("Request received",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.String("operation", operation),
zap.String("user_id", c.GetString("user_id")),
zap.String("request_id", c.GetString("request_id")),
zap.String("ip", c.ClientIP()),
zap.String("user_agent", c.Request.UserAgent()),
)
}
// LogResponse log une réponse sortante
func (h *CommonHandler) LogResponse(c *gin.Context, statusCode int, duration time.Duration) {
h.logger.Info("Response sent",
zap.Int("status_code", statusCode),
zap.Duration("duration", duration),
zap.String("request_id", c.GetString("request_id")),
)
}
// SetRequestID middleware pour ajouter un ID de requête
func (h *CommonHandler) SetRequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = generateRequestID()
}
c.Set("request_id", requestID)
c.Header("X-Request-ID", requestID)
c.Next()
}
}
// generateRequestID génère un ID de requête unique
func generateRequestID() string {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
// ValidateRequiredFields valide que les champs requis sont présents
// GO-013: Utilise dto.ValidationError
func (h *CommonHandler) ValidateRequiredFields(fields map[string]interface{}) []dto.ValidationError {
var validationErrors []dto.ValidationError
for field, value := range fields {
if value == nil || value == "" {
validationErrors = append(validationErrors, dto.ValidationError{
Field: field,
Message: "This field is required",
})
}
}
return validationErrors
}
// SanitizeString nettoie une chaîne de caractères
func (h *CommonHandler) SanitizeString(input string) string {
// Supprimer les caractères de contrôle et les espaces en début/fin
cleaned := strings.TrimSpace(input)
// Limiter la longueur
if len(cleaned) > 1000 {
cleaned = cleaned[:1000]
}
return cleaned
}
// ParseJSON parse du JSON de manière sécurisée
func (h *CommonHandler) ParseJSON(data []byte, v interface{}) error {
if err := json.Unmarshal(data, v); err != nil {
h.logger.Error("Failed to parse JSON", zap.Error(err))
return err
}
return nil
}
// MarshalJSON sérialise en JSON de manière sécurisée
func (h *CommonHandler) MarshalJSON(v interface{}) ([]byte, error) {
data, err := json.Marshal(v)
if err != nil {
h.logger.Error("Failed to marshal JSON", zap.Error(err))
return nil, err
}
return data, nil
}
// GetClientIP obtient l'IP réelle du client
func (h *CommonHandler) GetClientIP(c *gin.Context) string {
// Vérifier les headers de proxy
if ip := c.GetHeader("X-Forwarded-For"); ip != "" {
return strings.Split(ip, ",")[0]
}
if ip := c.GetHeader("X-Real-IP"); ip != "" {
return ip
}
return c.ClientIP()
}
// RateLimitKey génère une clé pour le rate limiting
func (h *CommonHandler) RateLimitKey(c *gin.Context, prefix string) string {
userID := c.GetString("user_id")
if userID != "" {
return prefix + ":user:" + userID
}
return prefix + ":ip:" + h.GetClientIP(c)
}