veza/veza-backend-api/internal/services/playlist_service.go
senke 24b29d229d 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 05:40:53 +01:00

1016 lines
36 KiB
Go

package services
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"veza-backend-api/internal/models"
"veza-backend-api/internal/repositories"
"go.uber.org/zap"
"gorm.io/gorm"
)
// UserRepositoryForPlaylist définit l'interface minimale nécessaire pour PlaylistService
// T0453: Interface pour vérifier l'existence des utilisateurs
type UserRepositoryForPlaylist interface {
GetByID(ctx context.Context, id string) (*models.User, error)
GetByEmail(ctx context.Context, email string) (*models.User, error)
GetByUsername(ctx context.Context, username string) (*models.User, error)
Create(ctx context.Context, user *models.User) error
Update(ctx context.Context, user *models.User) error
Delete(ctx context.Context, id string) error
}
// PlaylistService gère les opérations sur les playlists
// T0453: Utilise le repository pattern pour l'accès aux données
// BE-SVC-001: Add cache service for playlist caching
type PlaylistService struct {
playlistRepo repositories.PlaylistRepository
playlistTrackRepo repositories.PlaylistTrackRepository
playlistCollaboratorRepo repositories.PlaylistCollaboratorRepository
playlistShareService *PlaylistShareService
playlistFollowService *PlaylistFollowService
playlistNotificationService *PlaylistNotificationService
playlistVersionService *PlaylistVersionService
userRepo UserRepositoryForPlaylist
cacheService *CacheService
logger *zap.Logger
}
// NewPlaylistService crée un nouveau service de playlists avec repositories
func NewPlaylistService(playlistRepo repositories.PlaylistRepository, playlistTrackRepo repositories.PlaylistTrackRepository, playlistCollaboratorRepo repositories.PlaylistCollaboratorRepository, userRepo UserRepositoryForPlaylist, logger *zap.Logger) *PlaylistService {
if logger == nil {
logger = zap.NewNop()
}
return &PlaylistService{
playlistRepo: playlistRepo,
playlistTrackRepo: playlistTrackRepo,
playlistCollaboratorRepo: playlistCollaboratorRepo,
userRepo: userRepo,
logger: logger,
}
}
// SetPlaylistShareService définit le service de partage de playlist
// T0488: Create Playlist Public Share Link
func (s *PlaylistService) SetPlaylistShareService(shareService *PlaylistShareService) {
s.playlistShareService = shareService
}
// SetPlaylistFollowService définit le service de follow de playlist
// T0489: Create Playlist Follow Feature
func (s *PlaylistService) SetPlaylistFollowService(followService *PlaylistFollowService) {
s.playlistFollowService = followService
}
// SetPlaylistNotificationService définit le service de notifications de playlist
// T0508: Create Playlist Notifications
func (s *PlaylistService) SetPlaylistNotificationService(notificationService *PlaylistNotificationService) {
s.playlistNotificationService = notificationService
}
// SetPlaylistVersionService définit le service de versions de playlist
// T0509: Create Playlist Version History
func (s *PlaylistService) SetPlaylistVersionService(versionService *PlaylistVersionService) {
s.playlistVersionService = versionService
}
// SetCacheService définit le service de cache pour PlaylistService
// BE-SVC-001: Implement caching layer for frequently accessed data
func (s *PlaylistService) SetCacheService(cacheService *CacheService) {
s.cacheService = cacheService
}
// NewPlaylistServiceWithDB crée un nouveau service de playlists avec GORM (compatibilité)
// Cette fonction crée les repositories en interne pour maintenir la compatibilité
func NewPlaylistServiceWithDB(db *gorm.DB, logger *zap.Logger) *PlaylistService {
if logger == nil {
logger = zap.NewNop()
}
playlistRepo := repositories.NewPlaylistRepository(db)
playlistTrackRepo := repositories.NewPlaylistTrackRepository(db)
playlistCollaboratorRepo := repositories.NewPlaylistCollaboratorRepository(db)
// Pour UserRepository, on utilise une implémentation simple qui utilise GORM
// Note: On pourrait créer un UserRepository GORM aussi, mais pour l'instant on garde la compatibilité
userRepo := &gormUserRepository{db: db}
service := &PlaylistService{
playlistRepo: playlistRepo,
playlistTrackRepo: playlistTrackRepo,
playlistCollaboratorRepo: playlistCollaboratorRepo,
userRepo: userRepo,
logger: logger,
}
// Créer et injecter le service de partage
shareService := NewPlaylistShareService(db)
service.SetPlaylistShareService(shareService)
return service
}
// gormUserRepository est une implémentation temporaire de UserRepository avec GORM
// pour maintenir la compatibilité avec le code existant
type gormUserRepository struct {
db *gorm.DB
}
func (r *gormUserRepository) GetByID(ctx context.Context, id string) (*models.User, error) {
var user models.User
if err := r.db.WithContext(ctx).First(&user, "id = ?", id).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *gormUserRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) {
var user models.User
if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *gormUserRepository) GetByUsername(ctx context.Context, username string) (*models.User, error) {
var user models.User
if err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *gormUserRepository) Create(ctx context.Context, user *models.User) error {
return r.db.WithContext(ctx).Create(user).Error
}
func (r *gormUserRepository) Update(ctx context.Context, user *models.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
func (r *gormUserRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&models.User{}, "id = ?", id).Error
}
// Exists vérifie si un utilisateur existe (méthode helper pour le service)
func (r *gormUserRepository) Exists(ctx context.Context, userID uuid.UUID) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&models.User{}).Where("id = ?", userID).Count(&count).Error
return count > 0, err
}
// CreatePlaylist crée une nouvelle playlist
// T0453: Utilise le repository pattern avec validation
// MOD-P2-003: Enregistre la métrique business
func (s *PlaylistService) CreatePlaylist(ctx context.Context, userID uuid.UUID, title, description string, isPublic bool) (*models.Playlist, error) {
// Validation
if title == "" {
return nil, errors.New("title is required")
}
if len(title) > 200 {
return nil, errors.New("title must be less than 200 characters")
}
// Vérifier que l'utilisateur existe
// Note: On utilise une méthode helper Exists si disponible
if gormRepo, ok := s.userRepo.(interface {
Exists(ctx context.Context, userID uuid.UUID) (bool, error)
}); ok {
exists, err := gormRepo.Exists(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to check user: %w", err)
}
if !exists {
return nil, errors.New("user not found")
}
} else {
// Pour les autres implémentations, on essaie de récupérer l'utilisateur
_, err := s.userRepo.GetByID(ctx, userID.String())
if err != nil {
return nil, errors.New("user not found")
}
}
// Créer la playlist
playlist := &models.Playlist{
UserID: userID,
Title: title,
Description: description,
IsPublic: isPublic,
TrackCount: 0,
}
if err := s.playlistRepo.Create(ctx, playlist); err != nil {
return nil, fmt.Errorf("failed to create playlist: %w", err)
}
s.logger.Info("Playlist created",
zap.String("playlist_id", playlist.ID.String()),
zap.String("user_id", userID.String()),
zap.String("title", title),
)
// T0509: Sauvegarder la version initiale
if s.playlistVersionService != nil {
// Let's try to pass it.
if _, err := s.playlistVersionService.SaveVersion(ctx, playlist.ID, userID, models.PlaylistVersionActionCreated); err != nil {
s.logger.Warn("Failed to save initial playlist version", zap.Error(err))
}
}
return playlist, nil
}
// GetPlaylist récupère une playlist avec ses tracks
// T0453: Utilise le repository pattern avec vérification d'accès
// MIGRATION UUID: userID migré vers *uuid.UUID
// BE-SVC-001: Add caching for playlist data
func (s *PlaylistService) GetPlaylist(ctx context.Context, playlistID uuid.UUID, userID *uuid.UUID) (*models.Playlist, error) {
cacheConfig := DefaultCacheConfig()
// Try to get from cache first
if s.cacheService != nil {
var cachedPlaylist models.Playlist
if err := s.cacheService.GetPlaylist(ctx, playlistID, &cachedPlaylist); err == nil {
// Cache hit - but we still need to check access
// Vérifier accès si playlist privée
if !cachedPlaylist.IsPublic {
if userID == nil || *userID != cachedPlaylist.UserID {
return nil, ErrPlaylistNotFound
}
}
return &cachedPlaylist, nil
}
}
// Cache miss - fetch from database
playlist, err := s.playlistRepo.GetByIDWithTracks(ctx, playlistID) // Use GetByIDWithTracks
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrPlaylistNotFound
}
return nil, fmt.Errorf("failed to get playlist: %w", err)
}
// Vérifier accès si playlist privée
if !playlist.IsPublic {
if userID == nil || *userID != playlist.UserID {
return nil, ErrPlaylistNotFound // Return NotFound for security (hide private playlists)
}
}
// Cache the playlist
if s.cacheService != nil {
if err := s.cacheService.SetPlaylist(ctx, playlistID, playlist, cacheConfig); err != nil {
s.logger.Warn("Failed to cache playlist", zap.Error(err), zap.String("playlist_id", playlistID.String()))
}
}
return playlist, nil
}
// GetPlaylists récupère une liste de playlists avec pagination
// T0453: Utilise le repository pattern avec filtres
// T0501: Optimisé avec pagination efficace et lazy loading
// MIGRATION UUID: currentUserID et filterUserID migrés vers *uuid.UUID
// MOD: Utilisation du filtre viewerID pour gestion SQL de la visibilité
func (s *PlaylistService) GetPlaylists(ctx context.Context, currentUserID *uuid.UUID, filterUserID *uuid.UUID, page, limit int) ([]*models.Playlist, int64, error) {
// Appliquer la pagination avec limites optimisées
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
if page < 1 {
page = 1
}
offset := (page - 1) * limit
// T0501: Optimisation - Utiliser un offset calculé efficacement
// Pour les grandes pages, utiliser un curseur si disponible
if page > 100 {
// Pour les très grandes pages, limiter à 100 pour éviter les problèmes de performance
page = 100
offset = (page - 1) * limit
}
// Déterminer le filtre isPublic
var isPublic *bool
// Gestion simplifiée grâce au viewerID dans le repository:
// Si on filtre par utilisateur
if filterUserID != nil {
if currentUserID == nil {
// Visiteur anonyme -> Public only
public := true
isPublic = &public
} else if *filterUserID != *currentUserID {
// Visiteur authentifié regardant un autre user -> Public only
// (Sauf si on implémente logic ami/collaborateur plus tard, mais pour l'instant Public)
public := true
isPublic = &public
}
// Si (filterUserID == currentUserID), on laisse isPublic à nil pour tout voir
} else {
// Liste globale (Feed)
if currentUserID == nil {
// Anonyme -> Public only
public := true
isPublic = &public
}
// Si authentifié, on laisse isPublic à nil et on passe viewerID=currentUserID
// Le repository fera (is_public=true OR user_id=viewerID)
}
// Appel optimisé au repository
// On passe currentUserID comme viewerID
playlists, total, err := s.playlistRepo.List(ctx, filterUserID, currentUserID, isPublic, limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("playlist repository List failed: %w", err)
}
// T0501: Lazy loading - Ne pas charger les tracks pour la liste
for _, p := range playlists {
p.Tracks = nil
}
// Plus besoin de filtrage en mémoire, le SQL a tout géré !
return playlists, total, nil
}
// SearchPlaylistsParams représente les paramètres de recherche de playlists
// T0496: Create Playlist Search Backend
// MIGRATION UUID: UserID et CurrentUserID migrés vers *uuid.UUID
type SearchPlaylistsParams struct {
Query string // Recherche par titre ou description
UserID *uuid.UUID // Filtrer par utilisateur
IsPublic *bool // Filtrer par statut public/privé
Page int // Numéro de page (défaut: 1)
Limit int // Nombre de résultats par page (défaut: 20, max: 100)
CurrentUserID *uuid.UUID // ID de l'utilisateur actuel pour les règles d'accès
}
// SearchPlaylists recherche des playlists selon les critères fournis
// T0496: Create Playlist Search Backend
func (s *PlaylistService) SearchPlaylists(ctx context.Context, params SearchPlaylistsParams) ([]*models.Playlist, int64, error) {
// Appliquer la pagination
if params.Limit <= 0 {
params.Limit = 20
}
if params.Limit > 100 {
params.Limit = 100
}
if params.Page < 1 {
params.Page = 1
}
offset := (params.Page - 1) * params.Limit
// Déterminer le filtre isPublic selon les règles d'accès
var isPublic *bool
if params.IsPublic != nil {
isPublic = params.IsPublic
} else if params.CurrentUserID == nil {
// Si pas d'utilisateur authentifié, seulement les playlists publiques
public := true
isPublic = &public
} else if params.UserID != nil && *params.UserID != *params.CurrentUserID {
// Si on recherche les playlists d'un autre utilisateur, seulement publiques
public := true
isPublic = &public
}
// Si params.UserID == nil ou params.UserID == params.CurrentUserID, on ne filtre pas par isPublic
// (on laisse le repository gérer)
// Utiliser la méthode Search du repository
playlists, total, err := s.playlistRepo.Search(ctx, params.Query, params.UserID, isPublic, params.Limit, offset)
if err != nil {
return nil, 0, fmt.Errorf("failed to search playlists: %w", err)
}
// Filtrer les playlists selon les règles d'accès si nécessaire
if params.CurrentUserID != nil && params.UserID == nil && isPublic == nil {
// Recherche globale : filtrer pour ne garder que les publiques ou celles de l'utilisateur
filtered := make([]*models.Playlist, 0)
for _, p := range playlists {
if p.IsPublic || p.UserID == *params.CurrentUserID {
filtered = append(filtered, p)
}
}
playlists = filtered
}
s.logger.Debug("Playlists searched",
zap.String("query", params.Query),
zap.Any("user_id", params.UserID),
zap.Any("is_public", params.IsPublic),
zap.Int("page", params.Page),
zap.Int("limit", params.Limit),
zap.Int64("total", total),
zap.Int("results", len(playlists)),
)
return playlists, total, nil
}
// UpdatePlaylist met à jour une playlist
// T0453: Utilise le repository pattern avec vérification d'ownership
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) UpdatePlaylist(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID, title, description *string, isPublic *bool) (*models.Playlist, error) {
// Vérifier ownership
playlist, err := s.playlistRepo.GetByID(ctx, playlistID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrPlaylistNotFound
}
return nil, fmt.Errorf("failed to check playlist: %w", err)
}
if playlist.UserID != userID {
return nil, ErrAccessDenied
}
// Validation
if title != nil {
if *title == "" {
return nil, ErrTitleEmpty
}
if len(*title) > 200 {
return nil, ErrTitleTooLong
}
playlist.Title = *title
}
if description != nil {
playlist.Description = *description
}
if isPublic != nil {
playlist.IsPublic = *isPublic
}
if err := s.playlistRepo.Update(ctx, playlist); err != nil {
return nil, fmt.Errorf("failed to update playlist: %w", err)
}
s.logger.Info("Playlist updated",
zap.String("playlist_id", playlistID.String()),
zap.String("user_id", userID.String()),
)
// T0509: Sauvegarder une version avant la mise à jour
if s.playlistVersionService != nil {
if _, err := s.playlistVersionService.SaveVersion(ctx, playlistID, userID, models.PlaylistVersionActionUpdated); err != nil {
s.logger.Warn("Failed to save playlist version", zap.Error(err))
}
}
// T0508: Envoyer une notification
if s.playlistNotificationService != nil {
if err := s.playlistNotificationService.NotifyPlaylistUpdated(ctx, playlistID, userID); err != nil {
s.logger.Warn("Failed to send playlist updated notification", zap.Error(err))
}
}
return playlist, nil
}
// DeletePlaylist supprime une playlist (soft delete)
// T0453: Utilise le repository pattern avec vérification d'ownership
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) DeletePlaylist(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID) error {
// Vérifier ownership
playlist, err := s.playlistRepo.GetByID(ctx, playlistID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return ErrPlaylistNotFound
}
return fmt.Errorf("failed to check playlist: %w", err)
}
if playlist.UserID != userID {
return ErrAccessDenied
}
if err := s.playlistRepo.Delete(ctx, playlistID); err != nil {
return fmt.Errorf("failed to delete playlist: %w", err)
}
s.logger.Info("Playlist deleted",
zap.String("playlist_id", playlistID.String()),
zap.String("user_id", userID.String()),
)
return nil
}
// AddTrackToPlaylist ajoute un track à une playlist
// T0466: Implémentation avec PlaylistTrackRepository
// MIGRATION UUID: userID en uuid.UUID, playlistID et trackID en uuid.UUID
func (s *PlaylistService) AddTrackToPlaylist(ctx context.Context, playlistID, trackID uuid.UUID, userID uuid.UUID, position int) error {
// Vérifier ownership
playlist, err := s.playlistRepo.GetByID(ctx, playlistID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return ErrPlaylistNotFound
}
return fmt.Errorf("failed to check playlist: %w", err)
}
if playlist.UserID != userID {
return ErrAccessDenied
}
// Ajouter le track via le repository (qui vérifie l'existence du track)
if err := s.playlistTrackRepo.AddTrack(ctx, playlistID, trackID, position); err != nil {
if err.Error() == "track not found" {
return ErrTrackNotFound
}
if err.Error() == "track already in playlist" {
return ErrTrackAlreadyInPlaylist
}
return fmt.Errorf("failed to add track to playlist: %w", err)
}
s.logger.Info("Track added to playlist",
zap.String("playlist_id", playlistID.String()),
zap.String("track_id", trackID.String()),
zap.String("user_id", userID.String()),
zap.Int("position", position),
)
// T0508: Envoyer une notification (trackTitle sera vide, le service utilisera un message générique)
if s.playlistNotificationService != nil {
if err := s.playlistNotificationService.NotifyTrackAdded(ctx, playlistID, "", userID); err != nil {
s.logger.Warn("Failed to send track added notification", zap.Error(err))
}
}
return nil
}
// AddTrack est un alias pour AddTrackToPlaylist (compatibilité)
// MIGRATION UUID: userID en uuid.UUID, playlistID et trackID en uuid.UUID
func (s *PlaylistService) AddTrack(ctx context.Context, playlistID, trackID uuid.UUID, userID uuid.UUID) error {
return s.AddTrackToPlaylist(ctx, playlistID, trackID, userID, 0)
}
// RemoveTrackFromPlaylist retire un track d'une playlist
// T0466: Implémentation avec PlaylistTrackRepository
// MIGRATION UUID: userID en uuid.UUID, playlistID et trackID en uuid.UUID
func (s *PlaylistService) RemoveTrackFromPlaylist(ctx context.Context, playlistID, trackID uuid.UUID, userID uuid.UUID) error {
// Vérifier ownership
playlist, err := s.playlistRepo.GetByID(ctx, playlistID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return ErrPlaylistNotFound
}
return fmt.Errorf("failed to check playlist: %w", err)
}
if playlist.UserID != userID {
return errors.New("forbidden")
}
// Retirer le track via le repository
if err := s.playlistTrackRepo.RemoveTrack(ctx, playlistID, trackID); err != nil {
if err.Error() == "track not found in playlist" {
return errors.New("track not found in playlist")
}
return fmt.Errorf("failed to remove track from playlist: %w", err)
}
s.logger.Info("Track removed from playlist",
zap.String("playlist_id", playlistID.String()),
zap.String("track_id", trackID.String()),
zap.String("user_id", userID.String()),
)
return nil
}
// RemoveTrack est un alias pour RemoveTrackFromPlaylist (compatibilité)
// MIGRATION UUID: userID en uuid.UUID, playlistID et trackID en uuid.UUID
func (s *PlaylistService) RemoveTrack(ctx context.Context, playlistID, trackID uuid.UUID, userID uuid.UUID) error {
return s.RemoveTrackFromPlaylist(ctx, playlistID, trackID, userID)
}
// ReorderPlaylistTracks réorganise les tracks d'une playlist
// T0466: Implémentation avec PlaylistTrackRepository
// trackPositions est une map de trackID -> position
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) ReorderPlaylistTracks(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID, trackPositions map[uuid.UUID]int) error {
// Vérifier ownership
playlist, err := s.playlistRepo.GetByID(ctx, playlistID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return ErrPlaylistNotFound
}
return fmt.Errorf("failed to check playlist: %w", err)
}
if playlist.UserID != userID {
return errors.New("forbidden")
}
// Réorganiser les tracks via le repository
if err := s.playlistTrackRepo.ReorderTracks(ctx, playlistID, trackPositions); err != nil {
return fmt.Errorf("failed to reorder tracks: %w", err)
}
s.logger.Info("Playlist tracks reordered",
zap.String("playlist_id", playlistID.String()),
zap.String("user_id", userID.String()),
zap.Int("tracks_count", len(trackPositions)),
)
return nil
}
// ReorderTracks est un alias pour ReorderPlaylistTracks (compatibilité)
// trackIDs est une liste de trackIDs dans l'ordre souhaité (position = index + 1)
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) ReorderTracks(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID, trackIDs []uuid.UUID) error {
trackPositions := make(map[uuid.UUID]int)
for i, trackID := range trackIDs {
trackPositions[trackID] = i + 1
}
return s.ReorderPlaylistTracks(ctx, playlistID, userID, trackPositions)
}
// AddCollaborator ajoute un collaborateur à une playlist
// T0478: Implémentation avec vérification d'ownership
// MIGRATION UUID: ownerID et collaboratorUserID migrés vers uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) AddCollaborator(ctx context.Context, playlistID uuid.UUID, ownerID, collaboratorUserID uuid.UUID, permission models.PlaylistPermission) (*models.PlaylistCollaborator, error) {
// Vérifier ownership
playlist, err := s.playlistRepo.GetByID(ctx, playlistID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrPlaylistNotFound
}
return nil, fmt.Errorf("failed to check playlist: %w", err)
}
if playlist.UserID != ownerID {
return nil, errors.New("forbidden: only playlist owner can add collaborators")
}
// Vérifier que l'utilisateur collaborateur existe
if gormRepo, ok := s.userRepo.(interface {
Exists(ctx context.Context, userID uuid.UUID) (bool, error)
}); ok {
exists, err := gormRepo.Exists(ctx, collaboratorUserID)
if err != nil {
return nil, fmt.Errorf("failed to check user: %w", err)
}
if !exists {
return nil, errors.New("user not found")
}
} else {
_, err := s.userRepo.GetByID(ctx, collaboratorUserID.String())
if err != nil {
return nil, errors.New("user not found")
}
}
// Vérifier qu'on n'ajoute pas le propriétaire comme collaborateur
if collaboratorUserID == ownerID {
return nil, errors.New("cannot add playlist owner as collaborator")
}
// Ajouter le collaborateur via le repository
collaborator, err := s.playlistCollaboratorRepo.AddCollaborator(ctx, playlistID, collaboratorUserID, permission)
if err != nil {
if err.Error() == "collaborator already exists" {
return nil, errors.New("user is already a collaborator")
}
return nil, fmt.Errorf("failed to add collaborator: %w", err)
}
s.logger.Info("Collaborator added to playlist",
zap.String("playlist_id", playlistID.String()),
zap.String("owner_id", ownerID.String()),
zap.String("collaborator_user_id", collaboratorUserID.String()),
zap.String("permission", string(permission)),
)
// T0508: Envoyer une notification au collaborateur
if s.playlistNotificationService != nil {
if err := s.playlistNotificationService.NotifyCollaboratorAdded(ctx, playlistID, collaboratorUserID, ownerID); err != nil {
s.logger.Warn("Failed to send collaborator added notification", zap.Error(err))
}
}
return collaborator, nil
}
// RemoveCollaborator retire un collaborateur d'une playlist
// T0478: Implémentation avec vérification d'ownership
// MIGRATION UUID: ownerID et collaboratorUserID migrés vers uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) RemoveCollaborator(ctx context.Context, playlistID uuid.UUID, ownerID, collaboratorUserID uuid.UUID) error {
// Vérifier ownership
playlist, err := s.playlistRepo.GetByID(ctx, playlistID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return ErrPlaylistNotFound
}
return fmt.Errorf("failed to check playlist: %w", err)
}
if playlist.UserID != ownerID {
return errors.New("forbidden: only playlist owner can remove collaborators")
}
// Retirer le collaborateur via le repository
if err := s.playlistCollaboratorRepo.RemoveCollaborator(ctx, playlistID, collaboratorUserID); err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New("collaborator not found")
}
return fmt.Errorf("failed to remove collaborator: %w", err)
}
s.logger.Info("Collaborator removed from playlist",
zap.String("playlist_id", playlistID.String()),
zap.String("owner_id", ownerID.String()),
zap.String("collaborator_user_id", collaboratorUserID.String()),
)
return nil
}
// UpdateCollaboratorPermission met à jour la permission d'un collaborateur
// T0478: Implémentation avec vérification d'ownership
// MIGRATION UUID: ownerID et collaboratorUserID migrés vers uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) UpdateCollaboratorPermission(ctx context.Context, playlistID uuid.UUID, ownerID, collaboratorUserID uuid.UUID, permission models.PlaylistPermission) error {
// Vérifier ownership
playlist, err := s.playlistRepo.GetByID(ctx, playlistID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return ErrPlaylistNotFound
}
return fmt.Errorf("failed to check playlist: %w", err)
}
if playlist.UserID != ownerID {
return errors.New("forbidden: only playlist owner can update collaborator permissions")
}
// Valider la permission
if !permission.IsValid() {
return errors.New("invalid permission")
}
// Mettre à jour la permission via le repository
if err := s.playlistCollaboratorRepo.UpdatePermission(ctx, playlistID, collaboratorUserID, permission); err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New("collaborator not found")
}
return fmt.Errorf("failed to update collaborator permission: %w", err)
}
s.logger.Info("Collaborator permission updated",
zap.String("playlist_id", playlistID.String()),
zap.String("owner_id", ownerID.String()),
zap.String("collaborator_user_id", collaboratorUserID.String()),
zap.String("permission", string(permission)),
)
return nil
}
// CheckPermission vérifie si un utilisateur a une certaine permission sur une playlist
// T0478: Vérifie les permissions (read, write, admin)
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) CheckPermission(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID, requiredPermission models.PlaylistPermission) (bool, error) {
// Récupérer la playlist
playlist, err := s.playlistRepo.GetByID(ctx, playlistID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return false, errors.New("playlist not found")
}
return false, fmt.Errorf("failed to check playlist: %w", err)
}
// Le propriétaire a toujours toutes les permissions
if playlist.UserID == userID {
return true, nil
}
// Si la playlist est publique, tout le monde peut la lire
if playlist.IsPublic && requiredPermission == models.PlaylistPermissionRead {
return true, nil
}
// Vérifier si l'utilisateur est collaborateur
collaborator, err := s.playlistCollaboratorRepo.GetCollaborator(ctx, playlistID, userID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil // Pas de permission
}
return false, fmt.Errorf("failed to check collaborator: %w", err)
}
// Vérifier la permission selon le niveau requis
switch requiredPermission {
case models.PlaylistPermissionRead:
return collaborator.CanRead(), nil
case models.PlaylistPermissionWrite:
return collaborator.CanWrite(), nil
case models.PlaylistPermissionAdmin:
return collaborator.CanAdmin(), nil
default:
return false, errors.New("invalid permission")
}
}
// GetCollaborators récupère tous les collaborateurs d'une playlist
// T0478: Helper method pour récupérer les collaborateurs
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) GetCollaborators(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID) ([]*models.PlaylistCollaborator, error) {
// Vérifier que l'utilisateur a accès à la playlist (propriétaire ou collaborateur)
hasAccess, err := s.CheckPermission(ctx, playlistID, userID, models.PlaylistPermissionRead)
if err != nil {
return nil, err
}
if !hasAccess {
return nil, errors.New("forbidden: access denied")
}
// Récupérer les collaborateurs
collaborators, err := s.playlistCollaboratorRepo.GetCollaborators(ctx, playlistID)
if err != nil {
return nil, fmt.Errorf("failed to get collaborators: %w", err)
}
return collaborators, nil
}
// CreateShareLink crée un nouveau lien de partage public pour une playlist
// T0488: Create Playlist Public Share Link
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) CreateShareLink(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID, expiresAt *time.Time) (*models.PlaylistShareLink, error) {
if s.playlistShareService == nil {
return nil, errors.New("playlist share service not initialized")
}
// Vérifier que l'utilisateur a la permission (owner ou admin)
hasPermission, err := s.CheckPermission(ctx, playlistID, userID, models.PlaylistPermissionAdmin)
if err != nil {
return nil, err
}
if !hasPermission {
// Vérifier si l'utilisateur est le propriétaire
playlist, err := s.GetPlaylist(ctx, playlistID, &userID)
if err != nil {
return nil, err
}
if playlist.UserID != userID {
return nil, errors.New("forbidden: only owner or admin can create share links")
}
}
shareLink, err := s.playlistShareService.CreateShareLink(ctx, playlistID, userID, expiresAt)
if err != nil {
return nil, err
}
// T0508: Envoyer une notification
if s.playlistNotificationService != nil {
if err := s.playlistNotificationService.NotifyPlaylistShared(ctx, playlistID, userID); err != nil {
s.logger.Warn("Failed to send playlist shared notification", zap.Error(err))
}
}
return shareLink, nil
}
// GetPlaylistByShareToken returns a playlist by its public share token (v0.10.4 F143).
// No auth required; valid token grants access even to private playlists.
func (s *PlaylistService) GetPlaylistByShareToken(ctx context.Context, token string) (*models.Playlist, error) {
if s.playlistShareService == nil {
return nil, errors.New("playlist share service not initialized")
}
shareLink, err := s.playlistShareService.ValidateShareToken(ctx, token)
if err != nil {
if errors.Is(err, ErrPlaylistShareNotFound) || errors.Is(err, ErrPlaylistShareExpired) {
return nil, ErrPlaylistNotFound
}
return nil, err
}
// Bypass privacy check: valid share token grants access
playlist, err := s.playlistRepo.GetByIDWithTracks(ctx, shareLink.PlaylistID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrPlaylistNotFound
}
return nil, fmt.Errorf("failed to get playlist: %w", err)
}
return playlist, nil
}
// ImportPlaylistRequest represents JSON import payload (v0.10.4 F145)
type ImportPlaylistRequest struct {
Playlist struct {
Title string `json:"title"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
} `json:"playlist"`
Tracks []struct {
ID string `json:"id"`
} `json:"tracks"`
}
// ImportPlaylist creates a playlist from imported data (v0.10.4 F145)
func (s *PlaylistService) ImportPlaylist(ctx context.Context, userID uuid.UUID, req *ImportPlaylistRequest) (*models.Playlist, error) {
if req == nil {
return nil, errors.New("import request is required")
}
title := req.Playlist.Title
if title == "" {
title = "Imported Playlist"
}
trackIDs := make([]uuid.UUID, 0, len(req.Tracks))
for _, t := range req.Tracks {
if t.ID == "" {
continue
}
id, err := uuid.Parse(t.ID)
if err != nil {
s.logger.Warn("Import: invalid track id skipped", zap.String("id", t.ID))
continue
}
trackIDs = append(trackIDs, id)
}
return s.ImportPlaylistWithTracks(ctx, userID, title, req.Playlist.Description, req.Playlist.IsPublic, trackIDs)
}
// ImportPlaylistWithTracks creates a playlist and adds tracks (v0.10.4 F145).
// Accepts parsed track IDs for handlers that decode JSON themselves.
func (s *PlaylistService) ImportPlaylistWithTracks(ctx context.Context, userID uuid.UUID, title, description string, isPublic bool, trackIDs []uuid.UUID) (*models.Playlist, error) {
if title == "" {
title = "Imported Playlist"
}
playlist, err := s.CreatePlaylist(ctx, userID, title, description, isPublic)
if err != nil {
return nil, fmt.Errorf("create playlist: %w", err)
}
for i, trackID := range trackIDs {
if err := s.AddTrackToPlaylist(ctx, playlist.ID, trackID, userID, i+1); err != nil {
s.logger.Warn("Import: failed to add track", zap.String("track_id", trackID.String()), zap.Error(err))
}
}
return s.playlistRepo.GetByIDWithTracks(ctx, playlist.ID)
}
// GetOrCreateFavorisPlaylist returns the user's Favoris playlist, creating it if needed (v0.10.4 F136)
func (s *PlaylistService) GetOrCreateFavorisPlaylist(ctx context.Context, userID uuid.UUID) (*models.Playlist, error) {
existing, err := s.playlistRepo.GetFavorisByUserID(ctx, userID)
if err == nil {
return s.playlistRepo.GetByIDWithTracks(ctx, existing.ID)
}
if err != gorm.ErrRecordNotFound {
return nil, fmt.Errorf("failed to get favoris playlist: %w", err)
}
// Create Favoris playlist
playlist := &models.Playlist{
UserID: userID,
Title: "Favoris",
IsDefaultFavorites: true,
IsPublic: false,
}
if err := s.playlistRepo.Create(ctx, playlist); err != nil {
return nil, fmt.Errorf("failed to create favoris playlist: %w", err)
}
s.logger.Info("Favoris playlist created", zap.String("user_id", userID.String()), zap.String("playlist_id", playlist.ID.String()))
return s.playlistRepo.GetByIDWithTracks(ctx, playlist.ID)
}
// FollowPlaylist permet à un utilisateur de suivre une playlist
// T0489: Create Playlist Follow Feature
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) FollowPlaylist(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID) error {
if s.playlistFollowService == nil {
return errors.New("playlist follow service not initialized")
}
return s.playlistFollowService.FollowPlaylist(ctx, userID, playlistID)
}
// UnfollowPlaylist permet à un utilisateur de ne plus suivre une playlist
// T0489: Create Playlist Follow Feature
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) UnfollowPlaylist(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID) error {
if s.playlistFollowService == nil {
return errors.New("playlist follow service not initialized")
}
return s.playlistFollowService.UnfollowPlaylist(ctx, userID, playlistID)
}
// IsFollowing vérifie si un utilisateur suit une playlist
// T0489: Create Playlist Follow Feature
// MIGRATION UUID: userID en uuid.UUID, playlistID en uuid.UUID
func (s *PlaylistService) IsFollowing(ctx context.Context, playlistID uuid.UUID, userID uuid.UUID) (bool, error) {
if s.playlistFollowService == nil {
return false, errors.New("playlist follow service not initialized")
}
return s.playlistFollowService.IsFollowing(ctx, userID, playlistID)
}