veza/veza-backend-api/internal/handlers/comment_handler.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

397 lines
15 KiB
Go

package handlers
import (
"context"
"errors"
"net/http"
"strconv"
apperrors "veza-backend-api/internal/errors"
"veza-backend-api/internal/models"
"veza-backend-api/internal/services"
"veza-backend-api/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// CommentServiceInterface defines the interface for comment operations
// This allows for easier testing with mocks
type CommentServiceInterface interface {
CreateComment(ctx context.Context, trackID uuid.UUID, userID uuid.UUID, content string, timestamp float64, parentID *uuid.UUID) (*models.TrackComment, error)
GetComments(ctx context.Context, trackID uuid.UUID, page, limit int) ([]models.TrackComment, int64, error)
UpdateComment(ctx context.Context, commentID uuid.UUID, userID uuid.UUID, content string) (*models.TrackComment, error)
DeleteComment(ctx context.Context, commentID uuid.UUID, userID uuid.UUID, isAdmin bool) error
GetReplies(ctx context.Context, parentID uuid.UUID, page, limit int) ([]models.TrackComment, int64, error)
GetTrackCreatorID(ctx context.Context, trackID uuid.UUID) (uuid.UUID, error)
}
// CommentHandler gère les opérations sur les commentaires de tracks
type CommentHandler struct {
commentService CommentServiceInterface
notificationService *services.NotificationService // Phase 2.2: Optional, for comment notifications
commonHandler *CommonHandler
}
// NewCommentHandler crée un nouveau handler de commentaires
func NewCommentHandler(commentService *services.CommentService, logger *zap.Logger) *CommentHandler {
return &CommentHandler{
commentService: commentService,
commonHandler: NewCommonHandler(logger),
}
}
// NewCommentHandlerWithInterface crée un nouveau handler avec une interface (pour les tests)
func NewCommentHandlerWithInterface(commentService CommentServiceInterface, logger *zap.Logger) *CommentHandler {
return &CommentHandler{
commentService: commentService,
commonHandler: NewCommonHandler(logger),
}
}
// SetNotificationService sets the notification service for comment notifications (Phase 2.2)
func (h *CommentHandler) SetNotificationService(notificationService *services.NotificationService) {
h.notificationService = notificationService
}
// CreateCommentRequest représente la requête pour créer un commentaire
// MOD-P1-001: Ajout tags validate pour validation systématique
// v0.10.3 F201: Timestamp (position in track in seconds) for seekable comments
type CreateCommentRequest struct {
Content string `json:"content" binding:"required,min=1,max=5000" validate:"required,min=1,max=5000"`
ParentID *uuid.UUID `json:"parent_id,omitempty" validate:"omitempty"`
Timestamp *float64 `json:"timestamp,omitempty"` // Position in seconds (0 = top-level, no specific time)
}
// UpdateCommentRequest représente la requête pour mettre à jour un commentaire
// MOD-P1-001: Ajout tags validate pour validation systématique
type UpdateCommentRequest struct {
Content string `json:"content" binding:"required,min=1,max=5000" validate:"required,min=1,max=5000"`
}
// CreateComment gère la création d'un commentaire sur un track
// @Summary Create comment
// @Description Create a new comment on a track. Can be a top-level comment or a reply to another comment (using parent_id).
// @Tags Comment
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Track ID (UUID)"
// @Param comment body handlers.CreateCommentRequest true "Comment data"
// @Success 201 {object} handlers.APIResponse{data=object{comment=object}}
// @Failure 400 {object} handlers.APIResponse "Validation error"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 404 {object} handlers.APIResponse "Track not found"
// @Failure 500 {object} handlers.APIResponse "Internal server error"
// @Router /tracks/{id}/comments [post]
func (h *CommentHandler) CreateComment(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
if userID == uuid.Nil {
// Action 1.3.2.1: Use wrapped format helper for errors
RespondWithError(c, int(apperrors.ErrCodeUnauthorized), "unauthorized")
return
}
trackIDStr := c.Param("id")
if trackIDStr == "" {
// Action 1.3.2.1: Use wrapped format helper for errors
RespondWithError(c, int(apperrors.ErrCodeValidation), "track id is required")
return
}
trackID, err := uuid.Parse(trackIDStr) // Changed to uuid.Parse
if err != nil {
// Action 1.3.2.1: Use wrapped format helper for errors
RespondWithError(c, int(apperrors.ErrCodeValidation), "invalid track id")
return
}
var req CreateCommentRequest
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)
timestamp := 0.0
if req.Timestamp != nil && *req.Timestamp >= 0 {
timestamp = *req.Timestamp
}
comment, err := h.commentService.CreateComment(c.Request.Context(), trackID, userID, req.Content, timestamp, req.ParentID)
if err != nil {
if errors.Is(err, services.ErrModerationRejected) {
RespondWithAppError(c, apperrors.NewValidationError("content does not comply with moderation rules"))
return
}
if errors.Is(err, services.ErrTrackNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("track"))
return
}
if errors.Is(err, services.ErrParentCommentNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("parent comment"))
return
}
if errors.Is(err, services.ErrParentTrackMismatch) {
RespondWithAppError(c, apperrors.NewValidationError("parent comment does not belong to the same track"))
return
}
h.commonHandler.logger.Error("failed to create comment", zap.Error(err))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to create comment", err))
return
}
// Phase 2.2: Create notification for track creator (skip if user comments on own track). F554: grouping by track
if h.notificationService != nil {
creatorID, err := h.commentService.GetTrackCreatorID(c.Request.Context(), trackID)
if err == nil && creatorID != userID {
link := "/tracks/" + trackID.String()
if err := h.notificationService.CreateNotificationWithGroup(creatorID, "comment", "New comment", "Someone commented on your track", link, "comment:track:"+trackID.String(), userID); err != nil {
h.commonHandler.logger.Warn("failed to create comment notification", zap.Error(err))
}
}
}
RespondSuccess(c, http.StatusCreated, comment)
}
// GetComments gère la récupération des commentaires d'un track
// @Summary Get track comments
// @Description Get paginated list of comments for a track
// @Tags Comment
// @Accept json
// @Produce json
// @Param id path string true "Track ID"
// @Param page query int false "Page number" default(1)
// @Param limit query int false "Items per page" default(20)
// @Success 200 {object} handlers.APIResponse{data=object{comments=array,pagination=object}}
// @Failure 400 {object} handlers.APIResponse "Validation error"
// @Failure 404 {object} handlers.APIResponse "Track not found"
// @Failure 500 {object} handlers.APIResponse "Internal server error"
// @Router /tracks/{id}/comments [get]
func (h *CommentHandler) GetComments(c *gin.Context) {
trackIDStr := c.Param("id")
if trackIDStr == "" {
RespondWithAppError(c, apperrors.NewValidationError("track id is required"))
return
}
trackID, err := uuid.Parse(trackIDStr) // Changed to uuid.Parse
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid track id"))
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
if page < 1 {
page = 1
}
if limit < 1 {
limit = 20
}
if limit > 100 {
limit = 100
}
comments, total, err := h.commentService.GetComments(c.Request.Context(), trackID, page, limit)
if err != nil {
h.commonHandler.logger.Error("failed to get comments", zap.Error(err))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get comments", err))
return
}
// INT-007: Standardize pagination format
pagination := BuildPaginationData(page, limit, total)
RespondSuccess(c, http.StatusOK, gin.H{
"comments": comments,
"pagination": pagination,
})
}
// UpdateComment gère la mise à jour d'un commentaire
// @Summary Update comment
// @Description Update a comment (only by owner)
// @Tags Comment
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Comment ID (UUID)"
// @Param comment body handlers.UpdateCommentRequest true "Updated comment content"
// @Success 200 {object} handlers.APIResponse{data=object{comment=object}}
// @Failure 400 {object} handlers.APIResponse "Validation error"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Forbidden - can only edit own comments"
// @Failure 404 {object} handlers.APIResponse "Comment not found"
// @Failure 500 {object} handlers.APIResponse "Internal server error"
// @Router /comments/{id} [put]
func (h *CommentHandler) UpdateComment(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
if userID == uuid.Nil {
RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized"))
return
}
commentIDStr := c.Param("id")
if commentIDStr == "" {
RespondWithAppError(c, apperrors.NewValidationError("comment id is required"))
return
}
commentID, err := uuid.Parse(commentIDStr) // Changed to uuid.Parse
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid comment id"))
return
}
var req UpdateCommentRequest
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)
comment, err := h.commentService.UpdateComment(c.Request.Context(), commentID, userID, req.Content)
if err != nil {
if errors.Is(err, services.ErrCommentNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("comment"))
return
}
if errors.Is(err, services.ErrForbidden) {
RespondWithAppError(c, apperrors.NewForbiddenError("unauthorized: you can only edit your own comments"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to update comment", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"comment": comment})
}
// DeleteComment gère la suppression d'un commentaire
// @Summary Delete comment
// @Description Delete a comment (only by owner or admin)
// @Tags Comment
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "Track ID"
// @Param comment_id path string true "Comment ID"
// @Success 200 {object} handlers.APIResponse{data=object{message=string}}
// @Failure 400 {object} handlers.APIResponse "Validation error"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Forbidden - not comment owner"
// @Failure 404 {object} handlers.APIResponse "Comment not found"
// @Router /tracks/{id}/comments/{comment_id} [delete]
func (h *CommentHandler) DeleteComment(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
if userID == uuid.Nil {
RespondWithAppError(c, apperrors.NewUnauthorizedError("unauthorized"))
return
}
commentIDStr := c.Param("id")
if commentIDStr == "" {
RespondWithAppError(c, apperrors.NewValidationError("comment id is required"))
return
}
commentID, err := uuid.Parse(commentIDStr) // Changed to uuid.Parse
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid comment id"))
return
}
err = h.commentService.DeleteComment(c.Request.Context(), commentID, userID, false) // Added false for isAdmin
if err != nil {
if errors.Is(err, services.ErrCommentNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("comment"))
return
}
if errors.Is(err, services.ErrForbidden) {
RespondWithAppError(c, apperrors.NewForbiddenError("you can only delete your own comments"))
return
}
h.commonHandler.logger.Error("failed to delete comment", zap.Error(err))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to delete comment", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "Comment deleted successfully"})
}
// GetReplies gère la récupération des réponses d'un commentaire
// @Summary Get comment replies
// @Description Get paginated list of replies to a comment
// @Tags Comment
// @Accept json
// @Produce json
// @Param id path string true "Parent Comment ID (UUID)"
// @Param page query int false "Page number" default(1) minimum(1)
// @Param limit query int false "Items per page" default(20) minimum(1) maximum(100)
// @Success 200 {object} handlers.APIResponse{data=object{replies=array,pagination=object}}
// @Failure 400 {object} handlers.APIResponse "Validation error"
// @Failure 404 {object} handlers.APIResponse "Parent comment not found"
// @Failure 500 {object} handlers.APIResponse "Internal server error"
// @Router /comments/{id}/replies [get]
func (h *CommentHandler) GetReplies(c *gin.Context) {
parentIDStr := c.Param("id")
if parentIDStr == "" {
RespondWithAppError(c, apperrors.NewValidationError("parent comment id is required"))
return
}
parentID, err := uuid.Parse(parentIDStr) // Changed to uuid.Parse
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid parent comment id"))
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
limit = clampLimit(limit) // SECURITY(MEDIUM-004)
if page < 1 {
page = 1
}
if limit < 1 {
limit = 20
}
if limit > 100 {
limit = 100
}
replies, total, err := h.commentService.GetReplies(c.Request.Context(), parentID, page, limit)
if err != nil {
if errors.Is(err, services.ErrParentCommentNotFound) {
RespondWithAppError(c, apperrors.NewNotFoundError("parent comment"))
return
}
RespondWithAppError(c, apperrors.NewInternalErrorWrap("Failed to get replies", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{
"replies": replies,
"total": total,
"page": page,
"limit": limit,
})
}