veza/veza-backend-api/internal/handlers/common.go
senke 775b320b42 feat(e2e): add 303 deep behavioral tests + fix WebSocket + lint-staged
9 deep E2E test files (303 tests total):
41-chat(33) 42-player(31) 43-upload(28) 44-auth(37) 45-playlists(35)
46-search(32) 47-social(30) 48-marketplace(30) 49-settings(37)

Fix WebSocket origin bug (Chat never worked):
GetAllowedWebSocketOrigins() excluded localhost/127.0.0.1 in dev.

Fix lint-staged gofmt: pass files as args not stdin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 13:35:26 +02:00

643 lines
20 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"veza-backend-api/internal/common"
"veza-backend-api/internal/dto"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/validators"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"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 standardisées
// INT-007: Standardize pagination format with snake_case for consistency
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"`
HasPrev bool `json:"has_prev"` // INT-007: Changed from HasPrevious to HasPrev for consistency
NextCursor string `json:"next_cursor,omitempty"`
PrevCursor string `json:"prev_cursor,omitempty"` // INT-007: Changed from PreviousCursor to PrevCursor for consistency
}
// 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) {
// Utiliser la structure unifiée APIResponse via RespondSuccess
// Si message est présent, on l'encapsule avec les données
if message != "" {
RespondSuccess(c, http.StatusOK, gin.H{
"message": message,
"data": data,
})
} else {
RespondSuccess(c, http.StatusOK, data)
}
}
// RespondWithError répond avec une erreur
func (h *CommonHandler) RespondWithError(c *gin.Context, statusCode int, message string, err error) {
// Utiliser la structure unifiée APIResponse
// On crée une structure d'erreur ad-hoc pour correspondre à l'interface attendue par APIResponse.Error (qui est interface{})
// Ou mieux, on utilise RespondWithError qui attend un code, message et détails
// Note: RespondWithError est defined in error_response.go et attend (c, code, message, details...)
// Ici on a statusCode HTTP. RespondWithError attend un ErrorCode interne.
// C'est un conflit de signature.
// On va donc construire manuellement la réponse d'erreur unifiée.
errResponse := gin.H{
"code": statusCode,
"message": message,
"details": nil,
}
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),
)
// On pourrait ajouter err.Error() dans details, mais pour sécurité on évite d'exposer l'erreur brute sauf si nécessaire
}
c.JSON(statusCode, APIResponse{
Success: false,
Data: nil,
Error: errResponse,
})
}
// 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) {
// Adapter pour l'enveloppe unifiée
// Code 400 ou 422
c.JSON(http.StatusBadRequest, APIResponse{
Success: false,
Data: nil,
Error: gin.H{
"code": http.StatusBadRequest,
"message": "Validation failed",
"details": errors,
},
})
}
// RespondWithPaginatedData répond avec des données paginées
// INT-007: Standardize pagination response format
func (h *CommonHandler) RespondWithPaginatedData(c *gin.Context, data interface{}, pagination PaginationData, message string) {
// Pour la pagination, on met tout dans Data
responseData := gin.H{
"list": data,
"pagination": pagination,
}
if message != "" {
responseData["message"] = message
}
RespondSuccess(c, http.StatusOK, responseData)
}
// BuildPaginationData construit une PaginationData standardisée à partir des paramètres
// INT-007: Helper pour standardiser la création de PaginationData
func BuildPaginationData(page, limit int, total int64) PaginationData {
totalPages := int((total + int64(limit) - 1) / int64(limit))
if totalPages == 0 {
totalPages = 1
}
return PaginationData{
Page: page,
Limit: limit,
Total: total,
TotalPages: totalPages,
HasNext: page < totalPages,
HasPrev: page > 1,
}
}
// BuildPaginationDataWithCursor construit une PaginationData avec curseurs
// INT-007: Helper pour pagination cursor-based
func BuildPaginationDataWithCursor(limit int, total int64, nextCursor, prevCursor string) PaginationData {
// Pour cursor-based, on ne peut pas calculer totalPages exactement
// mais on peut indiquer s'il y a une page suivante/précédente
hasNext := nextCursor != ""
hasPrev := prevCursor != ""
return PaginationData{
Limit: limit,
Total: total,
HasNext: hasNext,
HasPrev: hasPrev,
NextCursor: nextCursor,
PrevCursor: prevCursor,
}
}
// 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
}
// 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")
maxSize := common.GetMaxJSONBodySize()
// 1. Vérifier la taille du body (TASK-SEC-005: 1MB défaut)
if c.Request.ContentLength > maxSize {
h.logger.Warn("Request body too large",
zap.Int64("content_length", c.Request.ContentLength),
zap.Int64("max_size", maxSize),
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", maxSize),
)
}
// 2. Limiter la lecture du body pour éviter les attaques par body trop gros
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
// 3. Parser le JSON avec DisallowUnknownFields (rejette les champs inconnus)
body, err := io.ReadAll(c.Request.Body)
if err != nil {
h.logger.Warn("Failed to read request body",
zap.Error(err),
zap.String("request_id", requestID),
zap.String("endpoint", c.Request.URL.Path),
)
return apperrors.New(apperrors.ErrCodeValidation, "Failed to read request body")
}
c.Request.Body = io.NopCloser(bytes.NewReader(body)) // Restore for middleware
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
if err := decoder.Decode(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", common.GetMaxJSONBodySize()),
)
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
// Les erreurs de validation de binding Gin (comme "required", "url", "min") doivent être retournées
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),
)
// Améliorer le message d'erreur pour les validations courantes
errMsg := err.Error()
if strings.Contains(errMsg, "Password") && strings.Contains(errMsg, "min") {
errMsg = "Le mot de passe doit contenir au moins 12 caractères"
} else if strings.Contains(errMsg, "PasswordConfirm") && strings.Contains(errMsg, "eqfield") {
errMsg = "Les mots de passe ne correspondent pas"
} else if strings.Contains(errMsg, "Email") && strings.Contains(errMsg, "email") {
errMsg = "Format d'email invalide"
} else if strings.Contains(errMsg, "Username") && strings.Contains(errMsg, "min") {
errMsg = "Le nom d'utilisateur doit contenir au moins 3 caractères"
} else if strings.Contains(errMsg, "required") {
// Extraire le nom du champ
if strings.Contains(errMsg, "Password") {
errMsg = "Le mot de passe est requis"
} else if strings.Contains(errMsg, "Email") {
errMsg = "L'email est requis"
} else if strings.Contains(errMsg, "PasswordConfirm") {
errMsg = "La confirmation du mot de passe est requise"
} else if strings.Contains(errMsg, "Username") {
errMsg = "Le nom d'utilisateur est requis"
} else {
errMsg = fmt.Sprintf("Validation failed: %s", errMsg)
}
} else {
errMsg = fmt.Sprintf("Validation failed: %s", errMsg)
}
// Retourner l'erreur de validation de binding
return apperrors.New(
apperrors.ErrCodeValidation,
errMsg,
)
}
}
// 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
}
// GetUserIDUUID extrait l'ID utilisateur du contexte comme uuid.UUID (MOD-P1-001)
// Retourne false si user_id est absent ou invalide (répond déjà avec 401)
func GetUserIDUUID(c *gin.Context) (uuid.UUID, bool) {
userIDInterface, exists := c.Get("user_id")
if !exists {
RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized"))
return uuid.Nil, false
}
userID, ok := userIDInterface.(uuid.UUID)
if !ok {
RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized"))
return uuid.Nil, false
}
return userID, true
}
// WithTimeout crée un context avec timeout pour les opérations I/O critiques (MOD-P1-004)
// Utilise le timeout par défaut de 5s pour DB/Redis, ou le timeout fourni
func WithTimeout(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout == 0 {
timeout = 5 * time.Second // Default timeout pour DB/Redis
}
return context.WithTimeout(ctx, timeout)
}
// SECURITY(MEDIUM-004): clampLimit ensures limit is within [1, 100].
// Prevents unbounded queries that could cause DoS.
func clampLimit(limit int) int {
if limit < 1 {
return 20
}
if limit > 100 {
return 100
}
return limit
}
// 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
}
// SafeMarshalJSON sérialise en JSON de manière sécurisée
func (h *CommonHandler) SafeMarshalJSON(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 via Gin's trusted proxy mechanism.
// SECURITY(REM-006): Uses c.ClientIP() which respects SetTrustedProxies() instead of
// manually parsing X-Forwarded-For (which is spoofable).
func (h *CommonHandler) GetClientIP(c *gin.Context) string {
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)
}
// GetAllowedWebSocketOrigins returns the list of allowed origins for WebSocket connections.
// SECURITY(REM-001): Replaces InsecureSkipVerify: true with explicit origin whitelist.
func GetAllowedWebSocketOrigins() []string {
originsStr := os.Getenv("CORS_ALLOWED_ORIGINS")
if originsStr == "" {
// Default development origins
return []string{"http://localhost:*", "http://127.0.0.1:*", "http://veza.fr:*"}
}
origins := strings.Split(originsStr, ",")
var patterns []string
for _, o := range origins {
o = strings.TrimSpace(o)
if o != "" {
patterns = append(patterns, o)
}
}
if len(patterns) == 0 {
return []string{"http://localhost:*"}
}
// Always include localhost + 127.0.0.1 in non-production for development/testing
env := os.Getenv("APP_ENV")
if env != "production" {
patterns = append(patterns, "http://localhost:*", "http://127.0.0.1:*")
}
return patterns
}