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>
301 lines
9.3 KiB
Go
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)
|
|
}
|