veza/veza-backend-api/internal/handlers/profile_handler.go

761 lines
27 KiB
Go
Raw Normal View History

2025-12-03 19:29:37 +00:00
package handlers
import (
"net/http"
"strconv"
2025-12-03 19:29:37 +00:00
"time"
apperrors "veza-backend-api/internal/errors"
2025-12-03 19:29:37 +00:00
"veza-backend-api/internal/services"
"veza-backend-api/internal/types"
"veza-backend-api/internal/utils"
2025-12-13 02:34:34 +00:00
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
2025-12-03 19:29:37 +00:00
)
// ProfileHandler handles profile-related operations
type ProfileHandler struct {
userService *services.UserService
commonHandler *CommonHandler
2026-03-05 22:03:43 +00:00
permissionService *services.PermissionService // MOD-P1-003: Added for admin check
socialService *services.SocialService // BE-API-017: Added for follow/unfollow functionality
notificationService *services.NotificationService // Phase 2.2: Optional, for follow notifications
2026-03-05 22:03:43 +00:00
logger *zap.Logger // BE-API-017: Added for logging
2025-12-03 19:29:37 +00:00
}
// NewProfileHandler creates a new ProfileHandler instance
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 10:14:38 +00:00
func NewProfileHandler(userService *services.UserService, logger *zap.Logger) *ProfileHandler {
return &ProfileHandler{
userService: userService,
commonHandler: NewCommonHandler(logger),
logger: logger,
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 10:14:38 +00:00
}
2025-12-03 19:29:37 +00:00
}
// SetSocialService sets the social service for follow/unfollow functionality
// BE-API-017: Implement user follow/unfollow endpoints
func (h *ProfileHandler) SetSocialService(socialService *services.SocialService) {
h.socialService = socialService
}
// SetNotificationService sets the notification service for follow notifications (Phase 2.2)
func (h *ProfileHandler) SetNotificationService(notificationService *services.NotificationService) {
h.notificationService = notificationService
}
2025-12-13 02:34:34 +00:00
// SetPermissionService définit le service de permissions (pour injection de dépendance)
// MOD-P1-003: Added for admin check in ownership verification
func (h *ProfileHandler) SetPermissionService(permissionService *services.PermissionService) {
h.permissionService = permissionService
}
2025-12-03 19:29:37 +00:00
// GetProfile retrieves a public user profile by ID
// @Summary Get Profile by ID
// @Description Get public profile information for a user
// @Tags User
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} handlers.APIResponse{data=object{profile=object}}
// @Failure 400 {object} handlers.APIResponse "Invalid ID"
// @Failure 404 {object} handlers.APIResponse "User not found"
// @Router /users/{id} [get]
2025-12-03 19:29:37 +00:00
func (h *ProfileHandler) GetProfile(c *gin.Context) {
userIDStr := c.Param("id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid user id"))
2025-12-03 19:29:37 +00:00
return
}
// Get the requesting user ID if authenticated (optional)
var requesterID *uuid.UUID
if reqID, exists := c.Get("user_id"); exists {
if reqUUID, ok := reqID.(uuid.UUID); ok {
requesterID = &reqUUID
}
}
// Get user profile with privacy check
fix(v0.12.6.1): remediate 2 CRITICAL + 10 HIGH + 1 MEDIUM pentest findings Security fixes implemented: CRITICAL: - CRIT-001: IDOR on chat rooms — added IsRoomMember check before returning room data or message history (returns 404, not 403) - CRIT-002: play_count/like_count exposed publicly — changed JSON tags to "-" so they are never serialized in API responses HIGH: - HIGH-001: TOCTOU race on marketplace downloads — transaction + SELECT FOR UPDATE on GetDownloadURL - HIGH-002: HS256 in production docker-compose — replaced JWT_SECRET with JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH (RS256) - HIGH-003: context.Background() bypass in user repository — full context propagation from handlers → services → repository (29 files) - HIGH-004: Race condition on promo codes — SELECT FOR UPDATE - HIGH-005: Race condition on exclusive licenses — SELECT FOR UPDATE - HIGH-006: Rate limiter IP spoofing — SetTrustedProxies(nil) default - HIGH-007: RGPD hard delete incomplete — added cleanup for sessions, settings, follows, notifications, audit_logs anonymization - HIGH-008: RTMP callback auth weak — fail-closed when unconfigured, header-only (no query param), constant-time compare - HIGH-009: Co-listening host hijack — UpdateHostState now takes *Conn and verifies IsHost before processing - HIGH-010: Moderator self-strike — added issuedBy != userID check MEDIUM: - MEDIUM-001: Recovery codes used math/rand — replaced with crypto/rand - MEDIUM-005: Stream token forgeable — resolved by HIGH-002 (RS256) Updated REMEDIATION_MATRIX: 14 findings marked ✅ CORRIGÉ. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 04:40:53 +00:00
profile, err := h.userService.GetProfile(c.Request.Context(), userID, requesterID)
2025-12-03 19:29:37 +00:00
if err != nil {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeNotFound, "user not found"))
2025-12-03 19:29:37 +00:00
return
}
RespondSuccess(c, http.StatusOK, profile)
2025-12-03 19:29:37 +00:00
}
// GetProfileByUsername retrieves a public profile by username
// @Summary Get Profile by Username
// @Description Get public profile information for a user by username
// @Tags User
// @Accept json
// @Produce json
// @Param username path string true "Username"
// @Success 200 {object} handlers.APIResponse{data=object{profile=object}}
// @Failure 400 {object} handlers.APIResponse "Missing username"
// @Failure 404 {object} handlers.APIResponse "User not found"
// @Router /users/by-username/{username} [get]
2025-12-03 19:29:37 +00:00
func (h *ProfileHandler) GetProfileByUsername(c *gin.Context) {
username := c.Param("username")
// BE-SEC-009: Sanitize username parameter to prevent injection
username = utils.SanitizeUsername(username)
2025-12-03 19:29:37 +00:00
if username == "" {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "username required"))
2025-12-03 19:29:37 +00:00
return
}
// Get the requesting user ID if authenticated (optional)
var requesterID *uuid.UUID
if reqID, exists := c.Get("user_id"); exists {
if reqUUID, ok := reqID.(uuid.UUID); ok {
requesterID = &reqUUID
}
}
// Get profile with privacy check
fix(v0.12.6.1): remediate 2 CRITICAL + 10 HIGH + 1 MEDIUM pentest findings Security fixes implemented: CRITICAL: - CRIT-001: IDOR on chat rooms — added IsRoomMember check before returning room data or message history (returns 404, not 403) - CRIT-002: play_count/like_count exposed publicly — changed JSON tags to "-" so they are never serialized in API responses HIGH: - HIGH-001: TOCTOU race on marketplace downloads — transaction + SELECT FOR UPDATE on GetDownloadURL - HIGH-002: HS256 in production docker-compose — replaced JWT_SECRET with JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH (RS256) - HIGH-003: context.Background() bypass in user repository — full context propagation from handlers → services → repository (29 files) - HIGH-004: Race condition on promo codes — SELECT FOR UPDATE - HIGH-005: Race condition on exclusive licenses — SELECT FOR UPDATE - HIGH-006: Rate limiter IP spoofing — SetTrustedProxies(nil) default - HIGH-007: RGPD hard delete incomplete — added cleanup for sessions, settings, follows, notifications, audit_logs anonymization - HIGH-008: RTMP callback auth weak — fail-closed when unconfigured, header-only (no query param), constant-time compare - HIGH-009: Co-listening host hijack — UpdateHostState now takes *Conn and verifies IsHost before processing - HIGH-010: Moderator self-strike — added issuedBy != userID check MEDIUM: - MEDIUM-001: Recovery codes used math/rand — replaced with crypto/rand - MEDIUM-005: Stream token forgeable — resolved by HIGH-002 (RS256) Updated REMEDIATION_MATRIX: 14 findings marked ✅ CORRIGÉ. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 04:40:53 +00:00
profile, err := h.userService.GetProfileByUsername(c.Request.Context(), username, requesterID)
2025-12-03 19:29:37 +00:00
if err != nil {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeNotFound, "user not found"))
2025-12-03 19:29:37 +00:00
return
}
RespondSuccess(c, http.StatusOK, profile)
2025-12-03 19:29:37 +00:00
}
// GetProfileCompletion retrieves the profile completion status
// GET /api/v1/users/:id/completion
// BE-API-023: Implement user completion endpoint validation
2025-12-03 19:29:37 +00:00
// T0220: Returns percentage and missing fields
// @Summary Get Profile Completion
// @Description Get profile completion percentage and missing fields
// @Tags User
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Success 200 {object} handlers.APIResponse{data=object}
// @Failure 400 {object} handlers.APIResponse "Invalid ID"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Forbidden"
// @Router /users/{id}/completion [get]
2025-12-03 19:29:37 +00:00
func (h *ProfileHandler) GetProfileCompletion(c *gin.Context) {
userIDStr := c.Param("id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
2025-12-03 19:29:37 +00:00
return
}
// Get authenticated user ID
authenticatedUserID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
2025-12-03 19:29:37 +00:00
}
// Verify that user_id corresponds to authenticated user
if userID != authenticatedUserID {
RespondWithAppError(c, apperrors.NewForbiddenError("cannot access other user's profile completion"))
2025-12-03 19:29:37 +00:00
return
}
// Calculate profile completion
fix(v0.12.6.1): remediate 2 CRITICAL + 10 HIGH + 1 MEDIUM pentest findings Security fixes implemented: CRITICAL: - CRIT-001: IDOR on chat rooms — added IsRoomMember check before returning room data or message history (returns 404, not 403) - CRIT-002: play_count/like_count exposed publicly — changed JSON tags to "-" so they are never serialized in API responses HIGH: - HIGH-001: TOCTOU race on marketplace downloads — transaction + SELECT FOR UPDATE on GetDownloadURL - HIGH-002: HS256 in production docker-compose — replaced JWT_SECRET with JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH (RS256) - HIGH-003: context.Background() bypass in user repository — full context propagation from handlers → services → repository (29 files) - HIGH-004: Race condition on promo codes — SELECT FOR UPDATE - HIGH-005: Race condition on exclusive licenses — SELECT FOR UPDATE - HIGH-006: Rate limiter IP spoofing — SetTrustedProxies(nil) default - HIGH-007: RGPD hard delete incomplete — added cleanup for sessions, settings, follows, notifications, audit_logs anonymization - HIGH-008: RTMP callback auth weak — fail-closed when unconfigured, header-only (no query param), constant-time compare - HIGH-009: Co-listening host hijack — UpdateHostState now takes *Conn and verifies IsHost before processing - HIGH-010: Moderator self-strike — added issuedBy != userID check MEDIUM: - MEDIUM-001: Recovery codes used math/rand — replaced with crypto/rand - MEDIUM-005: Stream token forgeable — resolved by HIGH-002 (RS256) Updated REMEDIATION_MATRIX: 14 findings marked ✅ CORRIGÉ. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 04:40:53 +00:00
completion, err := h.userService.CalculateProfileCompletion(c.Request.Context(), userID)
2025-12-03 19:29:37 +00:00
if err != nil {
if err.Error() == "user not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("user"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to calculate profile completion", err))
2025-12-03 19:29:37 +00:00
return
}
// Verify that percentage is between 0 and 100
if completion.Percentage < 0 || completion.Percentage > 100 {
h.logger.Warn("Invalid completion percentage calculated",
zap.Int("percentage", completion.Percentage),
zap.String("user_id", userID.String()))
// Clamp to valid range
if completion.Percentage < 0 {
completion.Percentage = 0
} else if completion.Percentage > 100 {
completion.Percentage = 100
}
}
RespondSuccess(c, http.StatusOK, completion)
2025-12-03 19:29:37 +00:00
}
// ListUsers gère la liste des utilisateurs avec pagination et filtrage
// BE-API-040: Implement user list endpoint
// @Summary List Users
// @Description Get a paginated list of users with optional filtering
// @Tags User
// @Accept json
// @Produce json
// @Param page query int false "Page number" default(1)
// @Param limit query int false "Items per page" default(20)
// @Param role query string false "Filter by role"
// @Param is_active query bool false "Filter by active status"
// @Param is_verified query bool false "Filter by verified status"
// @Param search query string false "Search by username, email, first_name, last_name"
// @Param sort_by query string false "Sort field (created_at, username, email, last_login_at)" default(created_at)
// @Param sort_order query string false "Sort order (asc, desc)" default(desc)
2026-01-07 18:39:21 +00:00
// @Success 200 {object} handlers.APIResponse{data=object{users=array,pagination=object}}
// @Failure 500 {object} handlers.APIResponse "Internal Error"
// @Router /users [get]
func (h *ProfileHandler) ListUsers(c *gin.Context) {
// Récupérer les paramètres de pagination
pageParam := c.DefaultQuery("page", "1")
limitParam := c.DefaultQuery("limit", "20")
// Parser les paramètres de pagination — return 400 if out of bounds (no silent normalization)
page, err := strconv.Atoi(pageParam)
if err != nil || page < 1 {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "pagination: page must be >= 1 and limit must be between 1 and 100"))
return
}
limit, err := strconv.Atoi(limitParam)
if err != nil || limit < 1 || limit > 100 {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "pagination: page must be >= 1 and limit must be between 1 and 100"))
return
}
// Récupérer les paramètres de filtrage
// BE-SEC-009: Sanitize search query to prevent injection
searchQuery := utils.SanitizeText(c.Query("search"), 100)
params := services.ListUsersParams{
Page: page,
Limit: limit,
Role: utils.SanitizeText(c.Query("role"), 50),
Search: searchQuery,
SortBy: utils.SanitizeText(c.DefaultQuery("sort_by", "created_at"), 50),
SortOrder: utils.SanitizeText(c.DefaultQuery("sort_order", "desc"), 10),
}
// Parser is_active si fourni
if isActiveStr := c.Query("is_active"); isActiveStr != "" {
if isActive, err := strconv.ParseBool(isActiveStr); err == nil {
params.IsActive = &isActive
}
}
// Parser is_verified si fourni
if isVerifiedStr := c.Query("is_verified"); isVerifiedStr != "" {
if isVerified, err := strconv.ParseBool(isVerifiedStr); err == nil {
params.IsVerified = &isVerified
}
}
// Lister les utilisateurs
users, total, err := h.userService.ListUsers(c.Request.Context(), params)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to list users", err))
return
}
// INT-007: Standardize pagination format
pagination := BuildPaginationData(page, limit, total)
RespondSuccess(c, http.StatusOK, gin.H{
"users": users,
"pagination": pagination,
})
}
// SearchUsers gère la recherche d'utilisateurs
// BE-API-008: Implement user search endpoint
func (h *ProfileHandler) SearchUsers(c *gin.Context) {
// Récupérer les paramètres de recherche
query := c.Query("q")
pageParam := c.DefaultQuery("page", "1")
limitParam := c.DefaultQuery("limit", "20")
// Bounds checking: return 400 instead of silently normalizing (DoS prevention)
page, err := strconv.Atoi(pageParam)
if err != nil || page < 1 {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "pagination: page must be >= 1 and limit must be between 1 and 100"))
return
}
limit, err := strconv.Atoi(limitParam)
if err != nil || limit < 1 || limit > 100 {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "pagination: page must be >= 1 and limit must be between 1 and 100"))
return
}
// Rechercher les utilisateurs
users, total, err := h.userService.SearchUsers(c.Request.Context(), services.SearchUsersParams{
Query: query,
Page: page,
Limit: limit,
})
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to search users", err))
return
}
// INT-007: Standardize pagination format
pagination := BuildPaginationData(page, limit, total)
RespondSuccess(c, http.StatusOK, gin.H{
"users": users,
"pagination": pagination,
})
}
// FollowUser gère le suivi d'un utilisateur
// POST /api/v1/users/:id/follow
// BE-API-017: Implement user follow/unfollow endpoints
func (h *ProfileHandler) FollowUser(c *gin.Context) {
// Récupérer l'ID de l'utilisateur à suivre depuis l'URL
userIDStr := c.Param("id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
return
}
// Récupérer l'ID de l'utilisateur authentifié
followerID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Vérifier qu'on ne peut pas se suivre soi-même
if followerID == userID {
RespondWithAppError(c, apperrors.NewValidationError("cannot follow yourself"))
return
}
// Vérifier que l'utilisateur existe (on peut utiliser GetProfile qui vérifie l'existence)
// Pour simplifier, on laisse le service social gérer l'erreur si l'utilisateur n'existe pas
// Vérifier que le service social est initialisé
if h.socialService == nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Social service not initialized", nil))
return
}
// Suivre l'utilisateur
2026-03-06 18:13:16 +00:00
err = h.socialService.FollowUser(c.Request.Context(), followerID, userID)
if err != nil {
h.logger.Error("failed to follow user",
zap.Error(err),
zap.String("follower_id", followerID.String()),
zap.String("followed_id", userID.String()))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to follow user", err))
return
}
h.logger.Info("user followed",
zap.String("follower_id", followerID.String()),
zap.String("followed_id", userID.String()))
// Phase 2.2: Create notification for the followed user
if h.notificationService != nil {
link := "/users/" + followerID.String()
if err := h.notificationService.CreateNotification(userID, "follow", "New follower", "Someone started following you", link); err != nil {
h.logger.Warn("failed to create follow notification", zap.Error(err), zap.String("followed_id", userID.String()))
}
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "User followed successfully"})
}
// GetFollowSuggestions returns users to follow (v0.10.0 F211)
// GET /api/v1/users/suggestions?limit=10
func (h *ProfileHandler) GetFollowSuggestions(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
if h.socialService == nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Social service not initialized", nil))
return
}
limit := 10
if l := c.Query("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 20 {
limit = n
}
}
suggestions, err := h.socialService.GetFollowSuggestions(c.Request.Context(), userID, limit)
if err != nil {
h.logger.Error("failed to get follow suggestions", zap.Error(err), zap.String("user_id", userID.String()))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get suggestions", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"suggestions": suggestions})
}
// UnfollowUser gère l'arrêt du suivi d'un utilisateur
// DELETE /api/v1/users/:id/follow
// BE-API-017: Implement user follow/unfollow endpoints
func (h *ProfileHandler) UnfollowUser(c *gin.Context) {
// Récupérer l'ID de l'utilisateur à ne plus suivre depuis l'URL
userIDStr := c.Param("id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
return
}
// Récupérer l'ID de l'utilisateur authentifié
followerID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Vérifier que le service social est initialisé
if h.socialService == nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Social service not initialized", nil))
return
}
// Ne plus suivre l'utilisateur
2026-03-06 18:13:16 +00:00
err = h.socialService.UnfollowUser(c.Request.Context(), followerID, userID)
if err != nil {
h.logger.Error("failed to unfollow user",
zap.Error(err),
zap.String("follower_id", followerID.String()),
zap.String("followed_id", userID.String()))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to unfollow user", err))
return
}
h.logger.Info("user unfollowed",
zap.String("follower_id", followerID.String()),
zap.String("followed_id", userID.String()))
RespondSuccess(c, http.StatusOK, gin.H{"message": "User unfollowed successfully"})
}
// BlockUser gère le blocage d'un utilisateur
// POST /api/v1/users/:id/block
// BE-API-018: Implement user block/unblock endpoints
func (h *ProfileHandler) BlockUser(c *gin.Context) {
// Récupérer l'ID de l'utilisateur à bloquer depuis l'URL
userIDStr := c.Param("id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
return
}
// Récupérer l'ID de l'utilisateur authentifié
blockerID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Vérifier qu'on ne peut pas se bloquer soi-même
if blockerID == userID {
RespondWithAppError(c, apperrors.NewValidationError("cannot block yourself"))
return
}
// Vérifier que le service social est initialisé
if h.socialService == nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Social service not initialized", nil))
return
}
// Bloquer l'utilisateur
err = h.socialService.BlockUser(blockerID, userID)
if err != nil {
if err.Error() == "cannot block yourself" {
RespondWithAppError(c, apperrors.NewValidationError("cannot block yourself"))
return
}
h.logger.Error("failed to block user",
zap.Error(err),
zap.String("blocker_id", blockerID.String()),
zap.String("blocked_id", userID.String()))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to block user", err))
return
}
h.logger.Info("user blocked",
zap.String("blocker_id", blockerID.String()),
zap.String("blocked_id", userID.String()))
RespondSuccess(c, http.StatusOK, gin.H{"message": "User blocked successfully"})
}
// UnblockUser gère le déblocage d'un utilisateur
// DELETE /api/v1/users/:id/block
// BE-API-018: Implement user block/unblock endpoints
func (h *ProfileHandler) UnblockUser(c *gin.Context) {
// Récupérer l'ID de l'utilisateur à débloquer depuis l'URL
userIDStr := c.Param("id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid user id"))
return
}
// Récupérer l'ID de l'utilisateur authentifié
blockerID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Vérifier que le service social est initialisé
if h.socialService == nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Social service not initialized", nil))
return
}
// Débloquer l'utilisateur
err = h.socialService.UnblockUser(blockerID, userID)
if err != nil {
h.logger.Error("failed to unblock user",
zap.Error(err),
zap.String("blocker_id", blockerID.String()),
zap.String("blocked_id", userID.String()))
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to unblock user", err))
return
}
h.logger.Info("user unblocked",
zap.String("blocker_id", blockerID.String()),
zap.String("blocked_id", userID.String()))
RespondSuccess(c, http.StatusOK, gin.H{"message": "User unblocked successfully"})
}
2025-12-03 19:29:37 +00:00
// UpdateProfileRequest represents the request body for updating a user profile
type UpdateProfileRequest struct {
2026-01-04 00:41:51 +00:00
FirstName string `json:"first_name" binding:"omitempty,max=100" validate:"omitempty,max=100"`
LastName string `json:"last_name" binding:"omitempty,max=100" validate:"omitempty,max=100"`
Username string `json:"username" binding:"omitempty,min=3,max=30" validate:"omitempty,min=3,max=30,username"`
Bio string `json:"bio" binding:"omitempty,max=500" validate:"omitempty,max=500"`
Location string `json:"location" binding:"omitempty,max=100" validate:"omitempty,max=100"`
Birthdate string `json:"birthdate" binding:"omitempty,datetime=2006-01-02" validate:"omitempty,datetime=2006-01-02"`
Gender string `json:"gender" binding:"omitempty,oneof=Male Female Other 'Prefer not to say'" validate:"omitempty,oneof=Male Female Other 'Prefer not to say'"`
SocialLinks map[string]interface{} `json:"social_links" binding:"omitempty"`
BannerURL string `json:"banner_url" binding:"omitempty,max=2048"`
IsPublic *bool `json:"is_public" binding:"omitempty"`
2025-12-03 19:29:37 +00:00
}
// UpdateProfile updates a user profile
// @Summary Update Profile
// @Description Update user profile details
// @Tags User
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
// @Param profile body UpdateProfileRequest true "Profile Data"
// @Success 200 {object} handlers.APIResponse{data=object{profile=object}}
// @Failure 400 {object} handlers.APIResponse "Validation Error"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Forbidden"
// @Router /users/{id} [put]
2025-12-03 19:29:37 +00:00
func (h *ProfileHandler) UpdateProfile(c *gin.Context) {
userIDStr := c.Param("id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid user id"))
2025-12-03 19:29:37 +00:00
return
}
// Get authenticated user ID
var authenticatedUserID uuid.UUID
if reqID, exists := c.Get("user_id"); exists {
if reqUUID, ok := reqID.(uuid.UUID); ok {
authenticatedUserID = reqUUID
} else {
RespondWithAppError(c, apperrors.NewUnauthorizedError("user not authenticated"))
2025-12-03 19:29:37 +00:00
return
}
} else {
RespondWithAppError(c, apperrors.NewUnauthorizedError("user not authenticated"))
2025-12-03 19:29:37 +00:00
return
}
2025-12-13 02:34:34 +00:00
// MOD-P1-003: Verify that user_id corresponds to authenticated user or user is admin
isAdmin := false
if h.permissionService != nil {
hasRole, err := h.permissionService.HasRole(c.Request.Context(), authenticatedUserID, "admin")
if err == nil && hasRole {
isAdmin = true
}
}
if userID != authenticatedUserID && !isAdmin {
RespondWithAppError(c, apperrors.NewForbiddenError("cannot update other user's profile"))
2025-12-03 19:29:37 +00:00
return
}
var req UpdateProfileRequest
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 10:14:38 +00:00
if appErr := h.commonHandler.BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
2025-12-03 19:29:37 +00:00
return
}
// BE-SEC-009: Sanitize user inputs to prevent XSS and injection attacks
if req.Username != "" {
req.Username = utils.SanitizeUsername(req.Username)
}
if req.Bio != "" {
req.Bio = utils.SanitizeText(req.Bio, 500)
}
if req.FirstName != "" {
req.FirstName = utils.SanitizeText(req.FirstName, 100)
}
if req.LastName != "" {
req.LastName = utils.SanitizeText(req.LastName, 100)
}
2025-12-03 19:29:37 +00:00
// Validate username if provided
if req.Username != "" {
// Validate username format (alphanumeric + underscore, 3-30 chars)
if !isValidUsername(req.Username) {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "username must be 3-30 characters, alphanumeric and underscore only"))
2025-12-03 19:29:37 +00:00
return
}
// Validate username uniqueness if modified
fix(v0.12.6.1): remediate 2 CRITICAL + 10 HIGH + 1 MEDIUM pentest findings Security fixes implemented: CRITICAL: - CRIT-001: IDOR on chat rooms — added IsRoomMember check before returning room data or message history (returns 404, not 403) - CRIT-002: play_count/like_count exposed publicly — changed JSON tags to "-" so they are never serialized in API responses HIGH: - HIGH-001: TOCTOU race on marketplace downloads — transaction + SELECT FOR UPDATE on GetDownloadURL - HIGH-002: HS256 in production docker-compose — replaced JWT_SECRET with JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH (RS256) - HIGH-003: context.Background() bypass in user repository — full context propagation from handlers → services → repository (29 files) - HIGH-004: Race condition on promo codes — SELECT FOR UPDATE - HIGH-005: Race condition on exclusive licenses — SELECT FOR UPDATE - HIGH-006: Rate limiter IP spoofing — SetTrustedProxies(nil) default - HIGH-007: RGPD hard delete incomplete — added cleanup for sessions, settings, follows, notifications, audit_logs anonymization - HIGH-008: RTMP callback auth weak — fail-closed when unconfigured, header-only (no query param), constant-time compare - HIGH-009: Co-listening host hijack — UpdateHostState now takes *Conn and verifies IsHost before processing - HIGH-010: Moderator self-strike — added issuedBy != userID check MEDIUM: - MEDIUM-001: Recovery codes used math/rand — replaced with crypto/rand - MEDIUM-005: Stream token forgeable — resolved by HIGH-002 (RS256) Updated REMEDIATION_MATRIX: 14 findings marked ✅ CORRIGÉ. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 04:40:53 +00:00
if err := h.userService.ValidateUsername(c.Request.Context(), userID, req.Username); err != nil {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, err.Error()))
2025-12-03 19:29:37 +00:00
return
}
// Check if username can be modified (once per month)
fix(v0.12.6.1): remediate 2 CRITICAL + 10 HIGH + 1 MEDIUM pentest findings Security fixes implemented: CRITICAL: - CRIT-001: IDOR on chat rooms — added IsRoomMember check before returning room data or message history (returns 404, not 403) - CRIT-002: play_count/like_count exposed publicly — changed JSON tags to "-" so they are never serialized in API responses HIGH: - HIGH-001: TOCTOU race on marketplace downloads — transaction + SELECT FOR UPDATE on GetDownloadURL - HIGH-002: HS256 in production docker-compose — replaced JWT_SECRET with JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH (RS256) - HIGH-003: context.Background() bypass in user repository — full context propagation from handlers → services → repository (29 files) - HIGH-004: Race condition on promo codes — SELECT FOR UPDATE - HIGH-005: Race condition on exclusive licenses — SELECT FOR UPDATE - HIGH-006: Rate limiter IP spoofing — SetTrustedProxies(nil) default - HIGH-007: RGPD hard delete incomplete — added cleanup for sessions, settings, follows, notifications, audit_logs anonymization - HIGH-008: RTMP callback auth weak — fail-closed when unconfigured, header-only (no query param), constant-time compare - HIGH-009: Co-listening host hijack — UpdateHostState now takes *Conn and verifies IsHost before processing - HIGH-010: Moderator self-strike — added issuedBy != userID check MEDIUM: - MEDIUM-001: Recovery codes used math/rand — replaced with crypto/rand - MEDIUM-005: Stream token forgeable — resolved by HIGH-002 (RS256) Updated REMEDIATION_MATRIX: 14 findings marked ✅ CORRIGÉ. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 04:40:53 +00:00
canChange, err := h.userService.CanChangeUsername(c.Request.Context(), userID)
2025-12-03 19:29:37 +00:00
if err != nil {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to check username change eligibility"))
2025-12-03 19:29:37 +00:00
return
}
if !canChange {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "username can only be changed once per month"))
2025-12-03 19:29:37 +00:00
return
}
}
// Validate birthdate if provided
if req.Birthdate != "" {
birthdate, err := time.Parse("2006-01-02", req.Birthdate)
if err != nil {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid birthdate format, expected YYYY-MM-DD"))
2025-12-03 19:29:37 +00:00
return
}
// Check if user is at least 13 years old
age := time.Since(birthdate)
minAge := 13 * 365 * 24 * time.Hour // 13 years
if age < minAge {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "user must be at least 13 years old"))
2025-12-03 19:29:37 +00:00
return
}
}
// Convert UpdateProfileRequest to types.UpdateProfileRequest
serviceReq := types.UpdateProfileRequest{
2026-01-04 00:41:51 +00:00
FirstName: &req.FirstName,
LastName: &req.LastName,
Username: &req.Username,
Bio: &req.Bio,
Location: &req.Location,
Gender: &req.Gender,
SocialLinks: req.SocialLinks,
2025-12-03 19:29:37 +00:00
}
2026-02-20 13:56:25 +00:00
if req.BannerURL != "" {
serviceReq.BannerURL = &req.BannerURL
}
2025-12-03 19:29:37 +00:00
if req.Birthdate != "" {
birthdate, _ := time.Parse("2006-01-02", req.Birthdate)
birthdateStr := birthdate.Format("2006-01-02")
serviceReq.BirthDate = &birthdateStr
}
if req.IsPublic != nil {
serviceReq.IsPublic = req.IsPublic
}
2025-12-03 19:29:37 +00:00
// Update profile using the new UpdateProfile method
fix(v0.12.6.1): remediate 2 CRITICAL + 10 HIGH + 1 MEDIUM pentest findings Security fixes implemented: CRITICAL: - CRIT-001: IDOR on chat rooms — added IsRoomMember check before returning room data or message history (returns 404, not 403) - CRIT-002: play_count/like_count exposed publicly — changed JSON tags to "-" so they are never serialized in API responses HIGH: - HIGH-001: TOCTOU race on marketplace downloads — transaction + SELECT FOR UPDATE on GetDownloadURL - HIGH-002: HS256 in production docker-compose — replaced JWT_SECRET with JWT_PRIVATE_KEY_PATH / JWT_PUBLIC_KEY_PATH (RS256) - HIGH-003: context.Background() bypass in user repository — full context propagation from handlers → services → repository (29 files) - HIGH-004: Race condition on promo codes — SELECT FOR UPDATE - HIGH-005: Race condition on exclusive licenses — SELECT FOR UPDATE - HIGH-006: Rate limiter IP spoofing — SetTrustedProxies(nil) default - HIGH-007: RGPD hard delete incomplete — added cleanup for sessions, settings, follows, notifications, audit_logs anonymization - HIGH-008: RTMP callback auth weak — fail-closed when unconfigured, header-only (no query param), constant-time compare - HIGH-009: Co-listening host hijack — UpdateHostState now takes *Conn and verifies IsHost before processing - HIGH-010: Moderator self-strike — added issuedBy != userID check MEDIUM: - MEDIUM-001: Recovery codes used math/rand — replaced with crypto/rand - MEDIUM-005: Stream token forgeable — resolved by HIGH-002 (RS256) Updated REMEDIATION_MATRIX: 14 findings marked ✅ CORRIGÉ. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 04:40:53 +00:00
profile, err := h.userService.UpdateProfile(c.Request.Context(), userID, serviceReq)
2025-12-03 19:29:37 +00:00
if err != nil {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeInternal, "failed to update profile"))
2025-12-03 19:29:37 +00:00
return
}
RespondSuccess(c, http.StatusOK, profile)
2025-12-03 19:29:37 +00:00
}
// isValidUsername validates username format (alphanumeric + underscore, 3-30 chars)
func isValidUsername(username string) bool {
if len(username) < 3 || len(username) > 30 {
return false
}
for _, char := range username {
if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_') {
return false
}
}
return true
}
// DeleteUser gère la suppression d'un utilisateur (soft delete)
// BE-API-041: DELETE /api/v1/users/:id with soft delete support
// @Summary Delete user
// @Description Soft delete a user (only user owner or admin can delete)
// @Tags User
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "User ID"
2026-01-07 18:39:21 +00:00
// @Success 200 {object} handlers.APIResponse "User deleted successfully"
// @Failure 400 {object} handlers.APIResponse "Invalid ID"
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
// @Failure 403 {object} handlers.APIResponse "Forbidden - Not user owner or admin"
// @Failure 404 {object} handlers.APIResponse "User not found"
// @Router /users/{id} [delete]
func (h *ProfileHandler) DeleteUser(c *gin.Context) {
userIDStr := c.Param("id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
RespondWithAppError(c, apperrors.New(apperrors.ErrCodeValidation, "invalid user id"))
return
}
// Get the requesting user ID
requesterID, ok := GetUserIDUUID(c)
if !ok {
return // Erreur déjà envoyée par GetUserIDUUID
}
// Check if requester is the user owner or admin
if requesterID != userID {
// Check if requester is admin
if h.permissionService != nil {
isAdmin, err := h.permissionService.HasRole(c.Request.Context(), requesterID, "admin")
if err != nil || !isAdmin {
RespondWithAppError(c, apperrors.NewForbiddenError("only user owner or admin can delete user"))
return
}
} else {
RespondWithAppError(c, apperrors.NewForbiddenError("only user owner or admin can delete user"))
return
}
}
// Delete user (soft delete)
if err := h.userService.DeleteUser(c.Request.Context(), userID); err != nil {
if err.Error() == "user not found" {
RespondWithAppError(c, apperrors.NewNotFoundError("user"))
return
}
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to delete user", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"message": "user deleted successfully"})
}