veza/veza-backend-api/internal/handlers/social.go
senke a0a611525c fix(v0.12.6.1): remediate remaining 15 MEDIUM + LOW pentest findings
MEDIUM-002: Remove manual X-Forwarded-For parsing in metrics_protection.go,
  use c.ClientIP() only (respects SetTrustedProxies)
MEDIUM-003: Pin ClamAV Docker image to 1.4 across all compose files
MEDIUM-004: Add clampLimit(100) to 15+ handlers that parsed limit directly
MEDIUM-006: Remove unsafe-eval from CSP script-src on Swagger routes
MEDIUM-007: Pin all GitHub Actions to SHA in 11 workflow files
MEDIUM-008: Replace rabbitmq:3-management-alpine with rabbitmq:3-alpine in prod
MEDIUM-009: Add trial-already-used check in subscription service
MEDIUM-010: Add 60s periodic token re-validation to WebSocket connections
MEDIUM-011: Mask email in auth handler logs with maskEmail() helper
MEDIUM-012: Add k-anonymity threshold (k=5) to playback analytics stats
LOW-001: Align frontend password policy to 12 chars (matching backend)
LOW-003: Replace deprecated dotenv with dotenvy crate in Rust stream server
LOW-004: Enable xpack.security in Elasticsearch dev/local compose files
LOW-005: Accept context.Context in CleanupExpiredSessions instead of Background()
LOW-002: Noted — Hyperswitch version update deferred (requires payment integration tests)

29/30 findings remediated. 1 noted (LOW-002).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 06:13:38 +01:00

301 lines
9.3 KiB
Go

package handlers
import (
"net/http"
"strconv"
"veza-backend-api/internal/core/social"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// SocialHandler gère les opérations sociales
type SocialHandler struct {
service social.SocialService
commonHandler *CommonHandler
}
// NewSocialHandler crée une nouvelle instance de SocialHandler
func NewSocialHandler(service social.SocialService, logger *zap.Logger) *SocialHandler {
return &SocialHandler{
service: service,
commonHandler: NewCommonHandler(logger),
}
}
// NewSocialHandlerWithInterface crée une nouvelle instance de SocialHandler avec une interface (pour les tests)
func NewSocialHandlerWithInterface(service social.SocialService, logger *zap.Logger) *SocialHandler {
return &SocialHandler{
service: service,
commonHandler: NewCommonHandler(logger),
}
}
// CreatePostRequest DTO pour la création de post
// GO-013: Validation améliorée avec tags go-validator
// MOD-P1-001: Ajout tags validate pour validation systématique
type CreatePostRequest struct {
Content string `json:"content" binding:"required,min=1,max=5000" validate:"required,min=1,max=5000"`
Attachments map[string]string `json:"attachments" validate:"omitempty"` // track_id, playlist_id (UUID strings)
}
// CreatePost crée un post
// GO-013: Utilise validator centralisé pour validation améliorée
// P0: JSON Hardening - Utilise BindAndValidateJSON pour une gestion robuste des erreurs
func (h *SocialHandler) CreatePost(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
var req CreatePostRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// BE-SEC-009: Sanitize user inputs to prevent XSS and injection attacks
req.Content = utils.SanitizeText(req.Content, 5000)
attachments := make(map[string]uuid.UUID)
for k, v := range req.Attachments {
if id, err := uuid.Parse(v); err == nil {
attachments[k] = id
}
}
post, err := h.service.CreatePost(c.Request.Context(), userID, req.Content, attachments)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to create post", err))
return
}
RespondSuccess(c, http.StatusCreated, post)
}
// ToggleLikeRequest DTO pour liker
// GO-013: Validation améliorée avec tags go-validator
// MOD-P1-001: Ajout tags validate pour validation systématique
type ToggleLikeRequest struct {
TargetID string `json:"target_id" binding:"required,uuid" validate:"required,uuid"`
TargetType string `json:"target_type" binding:"required,oneof=post track playlist" validate:"required,oneof=post track playlist"`
}
// ToggleLike like ou unlike un objet
// GO-013: Utilise validator centralisé pour validation améliorée
// P0: JSON Hardening - Utilise BindAndValidateJSON pour une gestion robuste des erreurs
func (h *SocialHandler) ToggleLike(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
var req ToggleLikeRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// UUID validation déjà fait par binding tag, mais on garde le parse pour compatibilité
targetID, err := uuid.Parse(req.TargetID)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid target_id format"))
return
}
liked, err := h.service.ToggleLike(c.Request.Context(), userID, targetID, req.TargetType)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to toggle like", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"liked": liked})
}
// AddCommentRequest DTO pour commenter
// GO-013: Validation améliorée avec tags go-validator
// MOD-P1-001: Ajout tags validate pour validation systématique
type AddCommentRequest struct {
TargetID string `json:"target_id" binding:"required,uuid" validate:"required,uuid"`
TargetType string `json:"target_type" binding:"required,oneof=post track playlist" validate:"required,oneof=post track playlist"`
Content string `json:"content" binding:"required,min=1,max=2000" validate:"required,min=1,max=2000"`
}
// AddComment ajoute un commentaire
// GO-013: Utilise validator centralisé pour validation améliorée
// P0: JSON Hardening - Utilise BindAndValidateJSON pour une gestion robuste des erreurs
func (h *SocialHandler) AddComment(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
var req AddCommentRequest
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
// UUID validation déjà fait par binding tag, mais on garde le parse pour compatibilité
targetID, err := uuid.Parse(req.TargetID)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid target_id format"))
return
}
comment, err := h.service.AddComment(c.Request.Context(), userID, targetID, req.TargetType, req.Content)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to add comment", err))
return
}
RespondSuccess(c, http.StatusCreated, comment)
}
// GetFeed récupère le feed global (S1.2, S1.4, S1.6: cursor, limit, type filter)
// v0.931: Cursor-based pagination via ?cursor=base64. Falls back to offset when cursor not provided.
func (h *SocialHandler) GetFeed(c *gin.Context) {
feedType := c.DefaultQuery("type", "all") // all | following | groups
if feedType != "all" && feedType != "following" && feedType != "groups" {
feedType = "all"
}
limitParam := c.DefaultQuery("limit", "20")
limit := 20
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 {
limit = l
if limit > 50 {
limit = 50
}
}
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
cursor := c.Query("cursor")
offset := 0
if o, err := strconv.Atoi(c.Query("offset")); err == nil && o >= 0 {
offset = o
}
if cursor != "" {
if o, err := strconv.Atoi(cursor); err == nil && o >= 0 {
offset = o // Backward compat: cursor as number = offset
}
}
// Use keyset pagination when cursor looks like base64 (Atoi fails)
_, numErr := strconv.Atoi(cursor)
useCursorKeyset := cursor != "" && numErr != nil
var userID *uuid.UUID
if feedType == "following" {
if uid, ok := GetUserIDUUID(c); ok {
userID = &uid
} else {
feedType = "all" // Fallback if not authenticated
}
}
if useCursorKeyset {
feed, nextCursor, err := h.service.GetGlobalFeedWithCursor(c.Request.Context(), limit, cursor, feedType, userID)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get feed", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{
"items": feed,
"next_cursor": nextCursor,
})
return
}
feed, err := h.service.GetGlobalFeed(c.Request.Context(), limit, offset, feedType, userID)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get feed", err))
return
}
nextCursor := ""
if len(feed) >= limit {
nextCursor = strconv.Itoa(offset + limit)
}
RespondSuccess(c, http.StatusOK, gin.H{
"items": feed,
"next_cursor": nextCursor,
})
}
// GetExplore returns trending hashtags and suggested users (S1.5)
func (h *SocialHandler) GetExplore(c *gin.Context) {
limitParam := c.DefaultQuery("limit", "10")
limit := 10
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 {
limit = l
if limit > 50 {
limit = 50
}
}
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
tags, err := h.service.GetTrendingHashtags(c.Request.Context(), limit)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get explore data", err))
return
}
// suggested_users: placeholder - can add users not followed with most followers
RespondSuccess(c, http.StatusOK, gin.H{
"trending": tags,
"suggested_users": []interface{}{},
})
}
// GetTrending returns trending hashtags from recent posts (v0.203 Lot L)
func (h *SocialHandler) GetTrending(c *gin.Context) {
limitParam := c.DefaultQuery("limit", "10")
limit := 10
if l, err := strconv.Atoi(limitParam); err == nil && l > 0 {
limit = l
if limit > 50 {
limit = 50
}
}
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
tags, err := h.service.GetTrendingHashtags(c.Request.Context(), limit)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get trending", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"tags": tags})
}
// GetPostsByUser récupère les posts d'un utilisateur spécifique
func (h *SocialHandler) GetPostsByUser(c *gin.Context) {
userIDStr := c.Param("user_id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("Invalid user ID"))
return
}
pageParam := c.DefaultQuery("page", "1")
limitParam := c.DefaultQuery("limit", "20")
page, err := strconv.Atoi(pageParam)
if err != nil || page < 1 {
page = 1
}
limit, err := strconv.Atoi(limitParam)
if err != nil || limit < 1 {
limit = 20
}
offset := (page - 1) * limit
posts, err := h.service.GetPostsByUser(c.Request.Context(), userID, limit, offset)
if err != nil {
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get posts", err))
return
}
RespondSuccess(c, http.StatusOK, posts)
}