diff --git a/.github/workflows/container-scan.yml b/.github/workflows/container-scan.yml new file mode 100644 index 000000000..57ee920bb --- /dev/null +++ b/.github/workflows/container-scan.yml @@ -0,0 +1,84 @@ +name: Container Image Scan + +on: + push: + branches: [main] + paths: + - 'veza-backend-api/Dockerfile*' + - 'apps/web/Dockerfile*' + - 'veza-stream-server/Dockerfile*' + pull_request: + branches: [main] + paths: + - 'veza-backend-api/Dockerfile*' + - 'apps/web/Dockerfile*' + - 'veza-stream-server/Dockerfile*' + workflow_dispatch: + +jobs: + scan-backend: + name: Scan Backend Image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build backend image + run: docker build -t veza-backend:scan -f veza-backend-api/Dockerfile.production veza-backend-api/ + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: 'veza-backend:scan' + format: 'table' + exit-code: '1' + severity: 'CRITICAL,HIGH' + ignore-unfixed: true + + scan-stream-server: + name: Scan Stream Server Image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build stream server image + run: docker build -t veza-stream:scan -f veza-stream-server/Dockerfile . + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: 'veza-stream:scan' + format: 'table' + exit-code: '1' + severity: 'CRITICAL,HIGH' + ignore-unfixed: true + + scan-frontend: + name: Scan Frontend Image + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check if frontend Dockerfile exists + id: check + run: | + if [ -f "apps/web/Dockerfile" ] || [ -f "apps/web/Dockerfile.production" ]; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Build frontend image + if: steps.check.outputs.exists == 'true' + run: | + DOCKERFILE=$([ -f "apps/web/Dockerfile.production" ] && echo "apps/web/Dockerfile.production" || echo "apps/web/Dockerfile") + docker build -t veza-frontend:scan -f "$DOCKERFILE" apps/web/ + + - name: Run Trivy vulnerability scanner + if: steps.check.outputs.exists == 'true' + uses: aquasecurity/trivy-action@master + with: + image-ref: 'veza-frontend:scan' + format: 'table' + exit-code: '1' + severity: 'CRITICAL,HIGH' + ignore-unfixed: true diff --git a/apps/web/src/features/chat/hooks/useWebRTC.ts b/apps/web/src/features/chat/hooks/useWebRTC.ts index ff42d335b..d210b55a6 100644 --- a/apps/web/src/features/chat/hooks/useWebRTC.ts +++ b/apps/web/src/features/chat/hooks/useWebRTC.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useChatStore } from '../store/chatStore'; import type { OutgoingMessage } from '../types'; +import { logger } from '@/utils/logger'; const ICE_SERVERS: RTCConfiguration['iceServers'] = [ { urls: 'stun:stun.l.google.com:19302' }, @@ -89,7 +90,7 @@ export function useWebRTC({ sendMessage }: UseWebRTCOptions) { call_type: callType, }); } catch (err) { - console.error('startCall error:', err); + logger.error('startCall error', { component: 'useWebRTC', action: 'startCall' }, err); setCallState('error'); cleanup(); } @@ -161,7 +162,7 @@ export function useWebRTC({ sendMessage }: UseWebRTCOptions) { sdp: JSON.stringify(answer), }); } catch (err) { - console.error('acceptCall error:', err); + logger.error('acceptCall error', { component: 'useWebRTC', action: 'acceptCall' }, err); setCallState('error'); cleanup(); } @@ -255,7 +256,7 @@ export function useWebRTC({ sendMessage }: UseWebRTCOptions) { clearPendingICECandidates(); }) .catch((err) => { - console.error('setRemoteDescription error:', err); + logger.error('setRemoteDescription error', { component: 'useWebRTC', action: 'setRemoteDescription' }, err); setCallState('error'); cleanup(); }); diff --git a/apps/web/src/utils/csp.ts b/apps/web/src/utils/csp.ts index dfb98d1b8..3a0344432 100644 --- a/apps/web/src/utils/csp.ts +++ b/apps/web/src/utils/csp.ts @@ -3,6 +3,8 @@ * Gère les nonces et la configuration CSP pour la sécurité */ +import { logger } from '@/utils/logger'; + // Nonce généré côté serveur pour les scripts inline let cspNonce: string | null = null; @@ -148,7 +150,7 @@ export const CSP_POLICY_DEV = { export function buildCSPHeaderDev(): string { // Vérifier qu'on est bien en mode développement if (import.meta.env.MODE === 'production') { - console.error('[CSP] SECURITY WARNING: buildCSPHeaderDev() called in production mode! Using strict CSP instead.'); + logger.error('[CSP] SECURITY WARNING: buildCSPHeaderDev() called in production mode! Using strict CSP instead.'); return buildCSPHeader(); } diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md new file mode 100644 index 000000000..b95f2f04e --- /dev/null +++ b/docs/MIGRATIONS.md @@ -0,0 +1,44 @@ +# Database Migrations + +## Overview + +This project uses sequential SQL migration files located in `veza-backend-api/migrations/`. +Migrations are numbered sequentially (001, 002, ..., 108). + +## Current State (v0.501) + +| Range | Description | +|-------|-------------| +| 001-050 | Core schema (users, tracks, playlists, roles) | +| 051-080 | Features (likes, shares, versions, sessions, OAuth) | +| 081-098 | Advanced (HLS streams, marketplace, notifications) | +| 099-100 | Promo codes and order discounts (v0.402) | +| 101-102 | Previous stabilization migrations | +| 103-108 | v0.501 (waveform, cloud storage, gear images) | + +## Running Migrations + +Migrations are applied automatically by the backend on startup via GORM AutoMigrate +and manual SQL execution. + +## Squash Script + +To generate a single baseline SQL file from all migrations: + +```bash +./scripts/squash_migrations.sh > veza-backend-api/migrations/baseline_v0501.sql +``` + +This is useful for: +- Setting up new development environments +- Creating test databases +- Documenting the complete schema + +**Important**: Do NOT delete individual migration files. The baseline is supplementary. + +## Adding New Migrations + +1. Create a new file: `{next_number}_{description}.sql` +2. Use `IF NOT EXISTS` / `IF EXISTS` for idempotency +3. Include a comment header with version reference +4. Test on a clean database before committing diff --git a/scripts/squash_migrations.sh b/scripts/squash_migrations.sh new file mode 100755 index 000000000..66d1e66d3 --- /dev/null +++ b/scripts/squash_migrations.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail + +MIGRATIONS_DIR="veza-backend-api/migrations" +OUTPUT_FILE="veza-backend-api/migrations/baseline_v0501.sql" + +echo "-- Baseline SQL generated from migrations 001-108" +echo "-- Generated on: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" +echo "-- DO NOT EDIT: This file is auto-generated" +echo "" +echo "BEGIN;" +echo "" + +for migration in $(ls "$MIGRATIONS_DIR"/*.sql 2>/dev/null | sort); do + basename_file=$(basename "$migration") + if [[ "$basename_file" == "baseline_"* ]]; then + continue + fi + echo "-- ============================================" + echo "-- Migration: $basename_file" + echo "-- ============================================" + cat "$migration" + echo "" + echo "" +done + +echo "COMMIT;" diff --git a/veza-backend-api/internal/api/routes_gear.go b/veza-backend-api/internal/api/routes_gear.go index 90eefe935..ff9b6443c 100644 --- a/veza-backend-api/internal/api/routes_gear.go +++ b/veza-backend-api/internal/api/routes_gear.go @@ -18,7 +18,8 @@ func (r *APIRouter) setupGearRoutes(router *gin.RouterGroup) { gearHandler := handlers.NewGearHandler(gearService, r.logger) // G1-01: Public gear profile (no auth) - router.GET("/users/:username/gear", gearHandler.ListPublicGear) + // Use :id to avoid Gin conflict with /users/:id/avatar (same path param name required) + router.GET("/users/:id/gear", gearHandler.ListPublicGear) inventory := router.Group("/inventory") inventory.Use(r.config.AuthMiddleware.RequireAuth()) diff --git a/veza-backend-api/internal/config/services_init.go b/veza-backend-api/internal/config/services_init.go index 24e87ba09..4c916e098 100644 --- a/veza-backend-api/internal/config/services_init.go +++ b/veza-backend-api/internal/config/services_init.go @@ -51,8 +51,10 @@ func (c *Config) initServices() error { return err } - // Service de cache - c.CacheService = services.NewCacheService(c.RedisClient, c.Logger) + // Service de cache (only when Redis is available; nil client causes panics) + if c.RedisClient != nil { + c.CacheService = services.NewCacheService(c.RedisClient, c.Logger) + } // Service de playlist c.PlaylistService = services.NewPlaylistServiceWithDB(c.Database.GormDB, c.Logger) diff --git a/veza-backend-api/internal/core/track/handler.go b/veza-backend-api/internal/core/track/handler.go index c9c93b77c..e9e58b1b0 100644 --- a/veza-backend-api/internal/core/track/handler.go +++ b/veza-backend-api/internal/core/track/handler.go @@ -5,8 +5,6 @@ import ( "errors" "fmt" "net/http" - "os" - "path/filepath" "strconv" "strings" "time" @@ -21,7 +19,6 @@ import ( "veza-backend-api/internal/services" "github.com/gin-gonic/gin" - "go.uber.org/zap" // Added zap "gorm.io/gorm" ) @@ -174,638 +171,6 @@ func (h *TrackHandler) respondWithError(c *gin.Context, httpStatus int, message handlers.RespondWithAppError(c, apperrors.New(errCode, message)) } -// UploadTrack gère l'upload d'un fichier audio -// @Summary Upload Track -// @Description Upload a new track (audio file) -// @Tags Track -// @Accept multipart/form-data -// @Produce json -// @Security BearerAuth -// @Param file formData file true "Audio File (MP3, WAV, FLAC, OGG)" -// @Success 201 {object} response.APIResponse{data=object{track=models.Track}} -// @Failure 400 {object} response.APIResponse "No file or validation error" -// @Failure 401 {object} response.APIResponse "Unauthorized" -// @Failure 403 {object} response.APIResponse "Quota exceeded" -// @Failure 500 {object} response.APIResponse "Internal Error" -// @Router /tracks [post] -func (h *TrackHandler) UploadTrack(c *gin.Context) { - // FIX #5: Remplacer fmt.Print* par logs structurés - h.trackService.logger.Debug("Upload track request received") - - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - h.trackService.logger.Warn("Upload track: user not authenticated") - return // Erreur déjà envoyée par getUserID - } - h.trackService.logger.Debug("Upload track: user authenticated", zap.String("user_id", userID.String())) - - fileHeader, err := c.FormFile("file") - if err != nil { - h.trackService.logger.Warn("Upload track: failed to get file", zap.Error(err)) - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.BadRequest - h.respondWithError(c, http.StatusBadRequest, "no file provided") - return - } - h.trackService.logger.Debug("Upload track: file received", - zap.String("filename", fileHeader.Filename), - zap.Int64("size", fileHeader.Size), - zap.String("user_id", userID.String()), - ) - - // MOD-P1-001: Scanner le fichier avec ClamAV AVANT toute persistance - if h.uploadValidator != nil { - // MOD-P1-004: Ajouter timeout context pour opération I/O (ClamAV scan) - ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) - defer cancel() - validationResult, err := h.uploadValidator.ValidateFile(ctx, fileHeader, "audio") - if err != nil { - // MOD-P1-001: Détecter le type d'erreur ClamAV et retourner code HTTP approprié - if strings.Contains(err.Error(), "clamav_unavailable") { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Virus scanning service is temporarily unavailable", - "message": "Uploads are disabled for security reasons until the scanning service is restored", - "code": "SERVICE_UNAVAILABLE", - }) - return - } - if strings.Contains(err.Error(), "clamav_infected") { - c.JSON(http.StatusUnprocessableEntity, gin.H{ - "error": "File rejected: virus detected", - "details": validationResult.Error, - "code": "VIRUS_DETECTED", - }) - return - } - if strings.Contains(err.Error(), "clamav_scan_error") { - c.JSON(http.StatusServiceUnavailable, gin.H{ - "error": "Virus scan failed", - "message": "Unable to complete virus scan. Upload rejected for security.", - "code": "SCAN_ERROR", - }) - return - } - // Autre erreur de validation - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Error - h.respondWithError(c, http.StatusBadRequest, validationResult.Error) - return - } - if !validationResult.Valid { - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Error - h.respondWithError(c, http.StatusBadRequest, validationResult.Error) - return - } - if validationResult.Quarantined { - c.JSON(http.StatusUnprocessableEntity, gin.H{ - "error": "File rejected: virus detected", - "details": validationResult.Error, - "code": "VIRUS_DETECTED", - }) - return - } - } - - // Parse metadata - yearStr := c.DefaultPostForm("year", "0") - year, _ := strconv.Atoi(yearStr) // Ignore error, default 0 is fine - - isPublicStr := c.DefaultPostForm("is_public", "true") - isPublic := isPublicStr == "true" - - metadata := TrackMetadata{ - Title: c.PostForm("title"), - Artist: c.PostForm("artist"), - Album: c.PostForm("album"), - Genre: c.PostForm("genre"), - Year: year, - IsPublic: isPublic, - } - - // Upload track (validation et quota sont vérifiés dans le service) - // MOD-P1-001: Le scan ClamAV a été fait ci-dessus, maintenant on peut persister - // MOD-P2-008: UploadTrack crée le Track immédiatement et lance la copie en goroutine - // MOD-P1-004: Ajouter timeout context pour opération DB critique (upload track) - h.trackService.logger.Debug("Upload track: starting save", - zap.String("user_id", userID.String()), - zap.String("filename", fileHeader.Filename), - zap.Any("metadata", metadata), - ) - ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) // Upload peut prendre du temps - defer cancel() - track, err := h.trackService.UploadTrack(ctx, userID, fileHeader, metadata) - if err != nil { - h.trackService.logger.Error("Upload track: save failed", - zap.String("user_id", userID.String()), - zap.String("filename", fileHeader.Filename), - zap.Error(err), - ) - // Mapper les erreurs vers des messages utilisateur spécifiques - errorMessage := h.mapTrackError(err) - statusCode := h.getErrorStatusCode(err) - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Error - h.respondWithError(c, statusCode, errorMessage) - return - } - - // MOD-P2-008: Sémantique asynchrone - retourner 202 Accepted avec track_id - // La copie fichier se fait en arrière-plan, le client peut poller GetUploadStatus - c.Header("Location", fmt.Sprintf("/api/v1/tracks/%s/status", track.ID.String())) - handlers.RespondSuccess(c, http.StatusAccepted, gin.H{ - "track_id": track.ID.String(), - "status": string(track.Status), - "status_url": fmt.Sprintf("/api/v1/tracks/%s/status", track.ID.String()), - "message": "Upload initiated, file is being saved in background", - }) - - // MOD-P2-008: Déclencher le traitement du streaming après la copie (sera fait quand Status=Processing) - // On ne peut pas le faire ici car le fichier n'existe pas encore - // Ce sera fait dans un job séparé ou via un hook quand Status passe à Processing -} - -// GetUploadStatus récupère le statut d'upload d'un track -// @Summary Get Upload Status -// @Description Get the processing status of an uploaded track -// @Tags Track -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Track ID" -// @Success 200 {object} response.APIResponse{data=object{progress=int}} -// @Failure 400 {object} response.APIResponse "Invalid ID" -// @Failure 401 {object} response.APIResponse "Unauthorized" -// @Failure 404 {object} response.APIResponse "Track not found" -// @Router /tracks/{id}/status [get] -func (h *TrackHandler) GetUploadStatus(c *gin.Context) { - trackIDStr := c.Param("id") - if trackIDStr == "" { - response.BadRequest(c, "track id is required") - return - } - - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.BadRequest - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - // Vérifier que l'utilisateur est authentifié (userID non utilisé dans cette fonction) - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - if _, ok := h.getUserID(c); !ok { - return // Erreur déjà envoyée par getUserID - } - - // Récupérer la progression (TrackUploadService utilise uuid.UUID) - progress, err := h.trackUploadService.GetUploadProgress(c.Request.Context(), trackID) - if err != nil { - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.InternalServerError - h.respondWithError(c, http.StatusInternalServerError, "failed to get upload progress") - return - } - - // MOD-P1-RES-001: Utiliser RespondSuccess au lieu de response.Success - handlers.RespondSuccess(c, http.StatusOK, gin.H{"progress": progress}) -} - -// InitiateChunkedUploadRequest représente la requête pour initialiser un upload par chunks -type InitiateChunkedUploadRequest struct { - TotalChunks int `json:"total_chunks" binding:"required,min=1" validate:"required,min=1"` - TotalSize int64 `json:"total_size" binding:"required,min=1" validate:"required,min=1"` - Filename string `json:"filename" binding:"required" validate:"required"` -} - -// InitiateChunkedUpload initialise un nouvel upload par chunks -// @Summary Initiate Chunked Upload -// @Description Start a new chunked upload session -// @Tags Track -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body InitiateChunkedUploadRequest true "Upload Metadata" -// @Success 200 {object} response.APIResponse{data=object{upload_id=string,message=string}} -// @Failure 400 {object} response.APIResponse "Validation Error" -// @Failure 401 {object} response.APIResponse "Unauthorized" -// @Router /tracks/initiate [post] -func (h *TrackHandler) InitiateChunkedUpload(c *gin.Context) { - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - // MOD-P1-002: Utiliser helper centralisé pour bind + validate - var req InitiateChunkedUploadRequest - if !common.BindAndValidateJSON(c, &req) { - return // Erreur déjà envoyée au client - } - - // Initialiser l'upload - // InitiateChunkedUpload retourne un string (uploadID) donc pas de souci d'int64 - // Note: InitiateChunkedUpload n'accepte pas de context (à migrer si nécessaire) - uploadID, err := h.chunkService.InitiateChunkedUpload(userID, req.TotalChunks, req.TotalSize, req.Filename) - if err != nil { - response.InternalServerError(c, err.Error()) - return - } - - response.Success(c, gin.H{ - "upload_id": uploadID, - "message": "upload initiated successfully", - }) -} - -// UploadChunkRequest représente la requête pour uploader un chunk -type UploadChunkRequest struct { - UploadID string `form:"upload_id" binding:"required"` - ChunkNumber int `form:"chunk_number" binding:"required,min=1"` - TotalChunks int `form:"total_chunks" binding:"required,min=1"` - TotalSize int64 `form:"total_size" binding:"required,min=1"` - Filename string `form:"filename" binding:"required"` -} - -// UploadChunk gère l'upload d'un chunk -// @Summary Upload Chunk -// @Description Upload a single chunk of a file -// @Tags Track -// @Accept multipart/form-data -// @Produce json -// @Security BearerAuth -// @Param chunk formData file true "Chunk Data" -// @Param upload_id formData string true "Upload ID" -// @Param chunk_number formData int true "Chunk Number" -// @Param total_chunks formData int true "Total Chunks" -// @Param total_size formData int64 true "Total Size" -// @Param filename formData string true "Filename" -// @Success 200 {object} response.APIResponse{data=object{message=string,upload_id=string,received_chunks=int,progress=float64}} -// @Failure 400 {object} response.APIResponse "Validation Error" -// @Failure 401 {object} response.APIResponse "Unauthorized" -// @Router /tracks/chunk [post] -func (h *TrackHandler) UploadChunk(c *gin.Context) { - // Vérifier que l'utilisateur est authentifié (userID non utilisé dans cette fonction) - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - if _, ok := h.getUserID(c); !ok { - return // Erreur déjà envoyée par getUserID - } - - var req UploadChunkRequest - if err := c.ShouldBind(&req); err != nil { - response.BadRequest(c, err.Error()) - return - } - - fileHeader, err := c.FormFile("chunk") - if err != nil { - response.BadRequest(c, "no chunk file provided") - return - } - - // Sauvegarder le chunk - if err := h.chunkService.SaveChunk(c.Request.Context(), req.UploadID, req.ChunkNumber, req.TotalChunks, fileHeader); err != nil { - response.BadRequest(c, err.Error()) - return - } - - // Récupérer la progression - receivedChunks, progress, err := h.chunkService.GetUploadProgress(req.UploadID) - if err != nil { - response.InternalServerError(c, err.Error()) - return - } - - response.Success(c, gin.H{ - "message": "chunk uploaded successfully", - "upload_id": req.UploadID, - "received_chunks": receivedChunks, - "total_chunks": req.TotalChunks, - "progress": progress, - }) -} - -// CompleteChunkedUploadRequest représente la requête pour compléter un upload par chunks -type CompleteChunkedUploadRequest struct { - UploadID string `json:"upload_id" binding:"required" validate:"required,uuid"` -} - -// CompleteChunkedUpload assemble tous les chunks et crée le track final -// @Summary Complete Chunked Upload -// @Description Finish upload session and assemble file -// @Tags Track -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body CompleteChunkedUploadRequest true "Upload ID" -// @Success 201 {object} response.APIResponse{data=object{message=string,track=models.Track,md5=string}} -// @Failure 400 {object} response.APIResponse "Validation or Assemblage Error" -// @Failure 401 {object} response.APIResponse "Unauthorized" -// @Router /tracks/complete [post] -func (h *TrackHandler) CompleteChunkedUpload(c *gin.Context) { - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - // MOD-P1-002: Utiliser helper centralisé pour bind + validate - var req CompleteChunkedUploadRequest - if !common.BindAndValidateJSON(c, &req) { - return // Erreur déjà envoyée au client - } - - // Récupérer les informations de l'upload pour obtenir le filename - uploadInfo, err := h.chunkService.GetUploadInfo(req.UploadID) - if err != nil { - response.BadRequest(c, err.Error()) - return - } - - // Générer un nom de fichier unique pour le fichier final - timestamp := uuid.New() - ext := filepath.Ext(uploadInfo.Filename) - if ext == "" { - ext = ".mp3" // Par défaut - } - filename := fmt.Sprintf("%s_%s%s", userID.String(), timestamp.String(), ext) - finalPath := filepath.Join("uploads/tracks", userID.String(), filename) - - // Assurer que le répertoire existe - if err := os.MkdirAll(filepath.Dir(finalPath), 0755); err != nil { - response.InternalServerError(c, "failed to create directory") - return - } - - // Assembler les chunks - // MOD-P1-004: Ajouter timeout context pour opération I/O (assemblage chunks) - ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) // Assemblage peut prendre du temps - defer cancel() - finalFilename, totalSize, checksum, err := h.chunkService.CompleteChunkedUpload(ctx, req.UploadID, finalPath) - if err != nil { - errorMessage := h.mapTrackError(err) - statusCode := h.getErrorStatusCode(err) - response.Error(c, statusCode, errorMessage) - return - } - - // Vérifier le quota avant de créer le track final - // MOD-P1-004: Ajouter timeout context pour opération DB (quota check) - quotaCtx, quotaCancel := context.WithTimeout(c.Request.Context(), 5*time.Second) - defer quotaCancel() - if err := h.trackService.CheckUserQuota(quotaCtx, userID, totalSize); err != nil { - errorMessage := h.mapTrackError(err) - statusCode := h.getErrorStatusCode(err) - // Nettoyer le fichier assemblé - os.Remove(finalPath) - response.Error(c, statusCode, errorMessage) - return - } - - // Déterminer le format - ext = filepath.Ext(finalFilename) - format := strings.TrimPrefix(strings.ToUpper(ext), ".") - if format == "M4A" { - format = "AAC" - } - - // Créer le track en base en utilisant CreateTrackFromPath - // MOD-P1-004: Ajouter timeout context pour opération DB critique (create track) - createCtx, createCancel := context.WithTimeout(c.Request.Context(), 10*time.Second) - defer createCancel() - track, err := h.trackService.CreateTrackFromPath(createCtx, userID, finalPath, finalFilename, totalSize, format) - if err != nil { - // Nettoyer le fichier en cas d'erreur - os.Remove(finalPath) - errorMessage := h.mapTrackError(err) - statusCode := h.getErrorStatusCode(err) - response.Error(c, statusCode, errorMessage) - return - } - - // Mettre à jour le message de statut avec le checksum SHA256 - if err := h.trackUploadService.UpdateUploadStatus(c.Request.Context(), track.ID, models.TrackStatusUploading, fmt.Sprintf("Upload completed, checksum: %s", checksum)); err != nil { - // Log l'erreur mais ne pas faire échouer la requête - h.trackService.logger.Error("Failed to update track upload status after completion", zap.Error(err), zap.Any("track_id", track.ID)) - } - - // Déclencher le traitement du streaming - if h.streamService != nil { - // FIX #23: Enrichir le contexte avec le request_id pour propagation - ctx := c.Request.Context() - if requestID := c.GetString("request_id"); requestID != "" { - ctx = context.WithValue(ctx, "request_id", requestID) - } - - if err := h.streamService.StartProcessing(ctx, track.ID, track.FilePath); err != nil { - // FIX #10: Logger l'erreur avec contexte - h.trackService.logger.Error("Failed to start stream processing", - zap.String("track_id", track.ID.String()), - zap.String("file_path", track.FilePath), - zap.Error(err), - ) - } else { - // h.trackUploadService.UpdateUploadStatus(c.Request.Context(), track.ID, models.TrackStatusProcessing, "Processing audio...") - } - } - - // Enqueue HLS transcoding job (async ffmpeg) - if h.jobEnqueuer != nil { - hlsOutputDir := filepath.Join(filepath.Dir(filepath.Dir(finalPath)), "hls") - h.jobEnqueuer.EnqueueTranscodingJob(track.ID, finalPath, hlsOutputDir) - } - - response.Created(c, gin.H{ - "message": "upload completed successfully", - "track": track, - "md5": checksum, // SHA256 (64 hex), legacy key for API compatibility - }) -} - -// mapTrackError mappe les erreurs techniques vers des messages utilisateur -func (h *TrackHandler) mapTrackError(err error) string { - if err == nil { - return "unknown error" - } - - errStr := err.Error() - - // Erreurs de validation - if strings.Contains(errStr, "invalid track format") || strings.Contains(errStr, "invalid file format") { - return "Invalid file format. Allowed formats: MP3, FLAC, WAV, OGG" - } - if strings.Contains(errStr, "file size exceeds") || strings.Contains(errStr, "too large") { - return "File size exceeds maximum allowed size of 100MB" - } - if strings.Contains(errStr, "file is empty") { - return "The uploaded file is empty" - } - - // Erreurs de quota - if strings.Contains(errStr, "track quota exceeded") { - return "You have reached the maximum number of tracks allowed" - } - if strings.Contains(errStr, "storage quota exceeded") { - return "You have reached your storage quota. Please delete some tracks to free up space" - } - - // Erreurs réseau - if strings.Contains(errStr, "network error") || strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection") { - return "Network error occurred. Please try again" - } - - // Erreurs de stockage - if strings.Contains(errStr, "storage error") || strings.Contains(errStr, "failed to save file") { - return "Failed to save file. Please try again" - } - if strings.Contains(errStr, "failed to create upload directory") { - return "Failed to prepare storage. Please try again later" - } - - // Erreur par défaut - return "An error occurred during upload. Please try again" -} - -// getErrorStatusCode retourne le code de statut HTTP approprié pour une erreur -func (h *TrackHandler) getErrorStatusCode(err error) int { - if err == nil { - return http.StatusInternalServerError - } - - errStr := err.Error() - - // Erreurs de validation -> 400 - if strings.Contains(errStr, "invalid") || strings.Contains(errStr, "too large") || strings.Contains(errStr, "empty") { - return http.StatusBadRequest - } - - // Erreurs de quota -> 403 - if strings.Contains(errStr, "quota exceeded") { - return http.StatusForbidden - } - - // Erreurs réseau -> 503 (Service Unavailable) - if strings.Contains(errStr, "network error") || strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection") { - return http.StatusServiceUnavailable - } - - // Erreurs de stockage -> 500 - if strings.Contains(errStr, "storage error") || strings.Contains(errStr, "failed to save") { - return http.StatusInternalServerError - } - - // Par défaut - return http.StatusInternalServerError -} - -// GetUploadQuota récupère les informations de quota d'upload pour un utilisateur -// @Summary Get Upload Quota -// @Description Get remaining upload quota for the user -// @Tags Track -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string false "User ID (optional, defaults to current user)" -// @Success 200 {object} response.APIResponse{data=object{quota=object}} -// @Failure 401 {object} response.APIResponse "Unauthorized" -// @Failure 403 {object} response.APIResponse "Forbidden" -// @Router /tracks/quota/{id} [get] -func (h *TrackHandler) GetUploadQuota(c *gin.Context) { - // Récupérer l'ID utilisateur depuis l'URL ou depuis le contexte d'authentification - userIDParam := c.Param("id") - var userID uuid.UUID - var err error - - if userIDParam == "" || userIDParam == "me" { - // Si "me" ou vide, utiliser l'utilisateur authentifié - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - var ok bool - userID, ok = h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - } else { - // Parse UUID - userID, err = uuid.Parse(userIDParam) - if err != nil { - response.BadRequest(c, "invalid user id") - return - } - } - - // Vérifier que l'utilisateur peut accéder à ces informations (soit lui-même, soit admin) - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - authenticatedUserID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - // Un utilisateur ne peut voir que son propre quota (sauf admin, mais on simplifie pour l'instant) - if authenticatedUserID != userID { - response.Forbidden(c, "forbidden: you can only view your own quota") - return - } - - // Récupérer le quota - quota, err := h.trackService.GetUserQuota(c.Request.Context(), userID) - if err != nil { - response.InternalServerError(c, "failed to get quota") - return - } - - response.Success(c, gin.H{ - "quota": quota, - }) -} - -// ResumeUpload récupère l'état d'un upload pour permettre la reprise -// @Summary Resume Upload -// @Description Get state of an interrupted upload -// @Tags Track -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param uploadId path string true "Upload ID" -// @Success 200 {object} response.APIResponse{data=object{upload_id=string,chunks_received=int}} -// @Failure 404 {object} response.APIResponse "Upload session not found" -// @Router /tracks/resume/{uploadId} [get] -func (h *TrackHandler) ResumeUpload(c *gin.Context) { - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - uploadID := c.Param("uploadId") - if uploadID == "" { - response.BadRequest(c, "upload_id is required") - return - } - - // Récupérer l'état de l'upload - state, err := h.chunkService.GetUploadState(uploadID) - if err != nil { - response.NotFound(c, "upload not found") - return - } - - // Vérifier que l'upload appartient à l'utilisateur authentifié - if state.UserID != userID { - response.Forbidden(c, "forbidden: you can only resume your own uploads") - return - } - - response.Success(c, gin.H{ - "upload_id": state.UploadID, - "user_id": state.UserID, - "total_chunks": state.TotalChunks, - "total_size": state.TotalSize, - "filename": state.Filename, - "chunks_received": state.ChunksReceived, - "received_count": state.ReceivedCount, - "last_chunk": state.LastChunk, - "progress": state.Progress, - "created_at": state.CreatedAt, - "updated_at": state.UpdatedAt, - }) -} - // ListTracks gère la liste des tracks avec pagination, filtres et tri // @Summary List Tracks // @Description Get a paginated list of tracks with filters @@ -1708,125 +1073,6 @@ func (h *TrackHandler) SearchTracks(c *gin.Context) { }) } -// DownloadTrack gère le téléchargement d'un track -func (h *TrackHandler) DownloadTrack(c *gin.Context) { - // Récupérer l'utilisateur s'il est authentifié - var userID uuid.UUID - if userIDInterface, exists := c.Get("user_id"); exists { - if uid, ok := userIDInterface.(uuid.UUID); ok { - userID = uid - } - } - - trackIDStr := c.Param("id") - if trackIDStr == "" { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusBadRequest, "track id is required") - return - } - - // MIGRATION UUID: TrackID is UUID - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - // Récupérer le track - track, err := h.trackService.GetTrackByID(c.Request.Context(), trackID) - if err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { - h.respondWithError(c, http.StatusNotFound, "track not found") - return - } - h.respondWithError(c, http.StatusInternalServerError, "failed to get track") - return - } - - // Vérifier les permissions via share token si présent - if shareToken := c.Query("share_token"); shareToken != "" { - if h.shareService == nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusInternalServerError, "share service not available") - return - } - - share, err := h.shareService.ValidateShareToken(c.Request.Context(), shareToken) - if err != nil { - if errors.Is(err, services.ErrShareNotFound) { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusForbidden, "invalid share token") - return - } - if errors.Is(err, services.ErrShareExpired) { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusForbidden, "share link expired") - return - } - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusInternalServerError, "failed to validate share token") - return - } - - // Vérifier que le share correspond au track - if share.TrackID != trackID { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusForbidden, "invalid share token") - return - } - - // Vérifier la permission download - if !h.shareService.CheckPermission(share, "download") { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusForbidden, "download not allowed") - return - } - } else { - // Vérifier les permissions normales (public ou owner) - if !track.IsPublic && track.UserID != userID { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusForbidden, "forbidden") - return - } - // A04: Si le track est vendu comme produit et que l'utilisateur n'est pas le propriétaire, - // vérifier qu'il a une licence valide - if track.UserID != userID && h.licenseChecker != nil { - soldAsProduct, err := h.licenseChecker.IsTrackSoldAsProduct(c.Request.Context(), trackID) - if err != nil { - h.trackService.logger.Error("Failed to check if track is sold as product", zap.Error(err), zap.String("track_id", trackID.String())) - h.respondWithError(c, http.StatusInternalServerError, "failed to verify download rights") - return - } - if soldAsProduct { - hasLicense, err := h.licenseChecker.HasPaidTrackDownloadRight(c.Request.Context(), userID, trackID) - if err != nil { - h.trackService.logger.Error("Failed to check download license", zap.Error(err), zap.String("track_id", trackID.String())) - h.respondWithError(c, http.StatusInternalServerError, "failed to verify download rights") - return - } - if !hasLicense { - h.respondWithError(c, http.StatusForbidden, "purchase required to download this track") - return - } - } - } - } - - // Vérifier que le fichier existe - if _, err := os.Stat(track.FilePath); os.IsNotExist(err) { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusNotFound, "track file not found") - return - } - - // Servir le fichier avec les headers appropriés - c.Header("Content-Type", getContentType(track.Format)) - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", track.Title)) - c.File(track.FilePath) -} - // CreateShareRequest représente la requête pour créer un lien de partage type CreateShareRequest struct { Permissions string `json:"permissions" binding:"required" validate:"required,oneof=read write admin"` @@ -1981,39 +1227,6 @@ func (h *TrackHandler) RevokeShare(c *gin.Context) { handlers.RespondSuccess(c, http.StatusOK, gin.H{"message": "share revoked"}) } -// StreamCallbackRequest represents the request for stream status callback -type StreamCallbackRequest struct { - Status string `json:"status" binding:"required" validate:"required,oneof=completed failed processing"` - ManifestURL string `json:"manifest_url" validate:"omitempty,url"` - Error string `json:"error"` -} - -// HandleStreamCallback handles the callback from stream server -func (h *TrackHandler) HandleStreamCallback(c *gin.Context) { - trackIDStr := c.Param("id") - // MIGRATION UUID: TrackID is UUID - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - // MOD-P1-002: Utiliser helper centralisé pour bind + validate - var req StreamCallbackRequest - if !common.BindAndValidateJSON(c, &req) { - return // Erreur déjà envoyée au client - } - - if err := h.trackService.UpdateStreamStatus(c.Request.Context(), trackID, req.Status, req.ManifestURL); err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusInternalServerError, "failed to update stream status") - return - } - - c.JSON(http.StatusOK, gin.H{"message": "status updated"}) -} - // GetTrackStats returns track statistics (plays, likes, views, etc.) // GET /api/v1/tracks/:id/stats func (h *TrackHandler) GetTrackStats(c *gin.Context) { @@ -2057,45 +1270,6 @@ func (h *TrackHandler) GetTrackStats(c *gin.Context) { handlers.RespondSuccess(c, http.StatusOK, gin.H{"stats": resp}) } -// GetWaveform returns the waveform JSON data for a track (S1-06) -// GET /api/v1/tracks/:id/waveform -func (h *TrackHandler) GetWaveform(c *gin.Context) { - if h.waveformService == nil { - h.respondWithError(c, http.StatusInternalServerError, "waveform service not available") - return - } - - trackIDStr := c.Param("id") - if trackIDStr == "" { - h.respondWithError(c, http.StatusBadRequest, "track id is required") - return - } - - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - data, err := h.waveformService.GetWaveform(c.Request.Context(), trackID) - if err != nil { - if strings.Contains(err.Error(), "not yet generated") { - h.respondWithError(c, http.StatusNotFound, "waveform not yet generated") - return - } - if strings.Contains(err.Error(), "track not found") { - h.respondWithError(c, http.StatusNotFound, "track not found") - return - } - h.respondWithError(c, http.StatusInternalServerError, "failed to get waveform") - return - } - - c.Header("Content-Type", "application/json") - c.Header("Cache-Control", "public, max-age=3600") - c.Data(http.StatusOK, "application/json", data) -} - // GetTrackHistory returns modification history for a track // GET /api/v1/tracks/:id/history func (h *TrackHandler) GetTrackHistory(c *gin.Context) { @@ -2160,24 +1334,6 @@ func (h *TrackHandler) GetTrackHistory(c *gin.Context) { }) } -// getContentType retourne le Content-Type approprié pour un format audio -func getContentType(format string) string { - switch strings.ToUpper(format) { - case "MP3": - return "audio/mpeg" - case "FLAC": - return "audio/flac" - case "WAV": - return "audio/wav" - case "OGG": - return "audio/ogg" - case "AAC", "M4A": - return "audio/aac" - default: - return "application/octet-stream" - } -} - // RecordPlayRequest représente la requête pour enregistrer un événement de lecture // BE-API-019: Implement track play analytics endpoint type RecordPlayRequest struct { diff --git a/veza-backend-api/internal/core/track/track_hls_handler.go b/veza-backend-api/internal/core/track/track_hls_handler.go new file mode 100644 index 000000000..8a963be31 --- /dev/null +++ b/veza-backend-api/internal/core/track/track_hls_handler.go @@ -0,0 +1,188 @@ +package track + +import ( + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/google/uuid" + + "veza-backend-api/internal/common" + "veza-backend-api/internal/services" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// StreamCallbackRequest represents the request for stream status callback +type StreamCallbackRequest struct { + Status string `json:"status" binding:"required" validate:"required,oneof=completed failed processing"` + ManifestURL string `json:"manifest_url" validate:"omitempty,url"` + Error string `json:"error"` +} + +// HandleStreamCallback handles the callback from stream server +func (h *TrackHandler) HandleStreamCallback(c *gin.Context) { + trackIDStr := c.Param("id") + // MIGRATION UUID: TrackID is UUID + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + // MOD-P1-002: Utiliser helper centralisé pour bind + validate + var req StreamCallbackRequest + if !common.BindAndValidateJSON(c, &req) { + return // Erreur déjà envoyée au client + } + + if err := h.trackService.UpdateStreamStatus(c.Request.Context(), trackID, req.Status, req.ManifestURL); err != nil { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusInternalServerError, "failed to update stream status") + return + } + + c.JSON(http.StatusOK, gin.H{"message": "status updated"}) +} + +// DownloadTrack gère le téléchargement d'un track +func (h *TrackHandler) DownloadTrack(c *gin.Context) { + // Récupérer l'utilisateur s'il est authentifié + var userID uuid.UUID + if userIDInterface, exists := c.Get("user_id"); exists { + if uid, ok := userIDInterface.(uuid.UUID); ok { + userID = uid + } + } + + trackIDStr := c.Param("id") + if trackIDStr == "" { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + + // MIGRATION UUID: TrackID is UUID + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + // Récupérer le track + track, err := h.trackService.GetTrackByID(c.Request.Context(), trackID) + if err != nil { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to get track") + return + } + + // Vérifier les permissions via share token si présent + if shareToken := c.Query("share_token"); shareToken != "" { + if h.shareService == nil { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusInternalServerError, "share service not available") + return + } + + share, err := h.shareService.ValidateShareToken(c.Request.Context(), shareToken) + if err != nil { + if errors.Is(err, services.ErrShareNotFound) { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusForbidden, "invalid share token") + return + } + if errors.Is(err, services.ErrShareExpired) { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusForbidden, "share link expired") + return + } + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusInternalServerError, "failed to validate share token") + return + } + + // Vérifier que le share correspond au track + if share.TrackID != trackID { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusForbidden, "invalid share token") + return + } + + // Vérifier la permission download + if !h.shareService.CheckPermission(share, "download") { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusForbidden, "download not allowed") + return + } + } else { + // Vérifier les permissions normales (public ou owner) + if !track.IsPublic && track.UserID != userID { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusForbidden, "forbidden") + return + } + // A04: Si le track est vendu comme produit et que l'utilisateur n'est pas le propriétaire, + // vérifier qu'il a une licence valide + if track.UserID != userID && h.licenseChecker != nil { + soldAsProduct, err := h.licenseChecker.IsTrackSoldAsProduct(c.Request.Context(), trackID) + if err != nil { + h.trackService.logger.Error("Failed to check if track is sold as product", zap.Error(err), zap.String("track_id", trackID.String())) + h.respondWithError(c, http.StatusInternalServerError, "failed to verify download rights") + return + } + if soldAsProduct { + hasLicense, err := h.licenseChecker.HasPaidTrackDownloadRight(c.Request.Context(), userID, trackID) + if err != nil { + h.trackService.logger.Error("Failed to check download license", zap.Error(err), zap.String("track_id", trackID.String())) + h.respondWithError(c, http.StatusInternalServerError, "failed to verify download rights") + return + } + if !hasLicense { + h.respondWithError(c, http.StatusForbidden, "purchase required to download this track") + return + } + } + } + } + + // Vérifier que le fichier existe + if _, err := os.Stat(track.FilePath); os.IsNotExist(err) { + // MOD-P2-003: Utiliser AppError au lieu de gin.H + h.respondWithError(c, http.StatusNotFound, "track file not found") + return + } + + // Servir le fichier avec les headers appropriés + c.Header("Content-Type", getContentType(track.Format)) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", track.Title)) + c.File(track.FilePath) +} + +// getContentType retourne le Content-Type approprié pour un format audio +func getContentType(format string) string { + switch strings.ToUpper(format) { + case "MP3": + return "audio/mpeg" + case "FLAC": + return "audio/flac" + case "WAV": + return "audio/wav" + case "OGG": + return "audio/ogg" + case "AAC", "M4A": + return "audio/aac" + default: + return "application/octet-stream" + } +} diff --git a/veza-backend-api/internal/core/track/track_upload_handler.go b/veza-backend-api/internal/core/track/track_upload_handler.go new file mode 100644 index 000000000..acd118bef --- /dev/null +++ b/veza-backend-api/internal/core/track/track_upload_handler.go @@ -0,0 +1,652 @@ +package track + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + + "veza-backend-api/internal/common" + "veza-backend-api/internal/handlers" + "veza-backend-api/internal/models" + "veza-backend-api/internal/response" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// UploadTrack gère l'upload d'un fichier audio +// @Summary Upload Track +// @Description Upload a new track (audio file) +// @Tags Track +// @Accept multipart/form-data +// @Produce json +// @Security BearerAuth +// @Param file formData file true "Audio File (MP3, WAV, FLAC, OGG)" +// @Success 201 {object} response.APIResponse{data=object{track=models.Track}} +// @Failure 400 {object} response.APIResponse "No file or validation error" +// @Failure 401 {object} response.APIResponse "Unauthorized" +// @Failure 403 {object} response.APIResponse "Quota exceeded" +// @Failure 500 {object} response.APIResponse "Internal Error" +// @Router /tracks [post] +func (h *TrackHandler) UploadTrack(c *gin.Context) { + // FIX #5: Remplacer fmt.Print* par logs structurés + h.trackService.logger.Debug("Upload track request received") + + // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() + userID, ok := h.getUserID(c) + if !ok { + h.trackService.logger.Warn("Upload track: user not authenticated") + return // Erreur déjà envoyée par getUserID + } + h.trackService.logger.Debug("Upload track: user authenticated", zap.String("user_id", userID.String())) + + fileHeader, err := c.FormFile("file") + if err != nil { + h.trackService.logger.Warn("Upload track: failed to get file", zap.Error(err)) + // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.BadRequest + h.respondWithError(c, http.StatusBadRequest, "no file provided") + return + } + h.trackService.logger.Debug("Upload track: file received", + zap.String("filename", fileHeader.Filename), + zap.Int64("size", fileHeader.Size), + zap.String("user_id", userID.String()), + ) + + // MOD-P1-001: Scanner le fichier avec ClamAV AVANT toute persistance + if h.uploadValidator != nil { + // MOD-P1-004: Ajouter timeout context pour opération I/O (ClamAV scan) + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + validationResult, err := h.uploadValidator.ValidateFile(ctx, fileHeader, "audio") + if err != nil { + // MOD-P1-001: Détecter le type d'erreur ClamAV et retourner code HTTP approprié + if strings.Contains(err.Error(), "clamav_unavailable") { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Virus scanning service is temporarily unavailable", + "message": "Uploads are disabled for security reasons until the scanning service is restored", + "code": "SERVICE_UNAVAILABLE", + }) + return + } + if strings.Contains(err.Error(), "clamav_infected") { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": "File rejected: virus detected", + "details": validationResult.Error, + "code": "VIRUS_DETECTED", + }) + return + } + if strings.Contains(err.Error(), "clamav_scan_error") { + c.JSON(http.StatusServiceUnavailable, gin.H{ + "error": "Virus scan failed", + "message": "Unable to complete virus scan. Upload rejected for security.", + "code": "SCAN_ERROR", + }) + return + } + // Autre erreur de validation + // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Error + h.respondWithError(c, http.StatusBadRequest, validationResult.Error) + return + } + if !validationResult.Valid { + // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Error + h.respondWithError(c, http.StatusBadRequest, validationResult.Error) + return + } + if validationResult.Quarantined { + c.JSON(http.StatusUnprocessableEntity, gin.H{ + "error": "File rejected: virus detected", + "details": validationResult.Error, + "code": "VIRUS_DETECTED", + }) + return + } + } + + // Parse metadata + yearStr := c.DefaultPostForm("year", "0") + year, _ := strconv.Atoi(yearStr) // Ignore error, default 0 is fine + + isPublicStr := c.DefaultPostForm("is_public", "true") + isPublic := isPublicStr == "true" + + metadata := TrackMetadata{ + Title: c.PostForm("title"), + Artist: c.PostForm("artist"), + Album: c.PostForm("album"), + Genre: c.PostForm("genre"), + Year: year, + IsPublic: isPublic, + } + + // Upload track (validation et quota sont vérifiés dans le service) + // MOD-P1-001: Le scan ClamAV a été fait ci-dessus, maintenant on peut persister + // MOD-P2-008: UploadTrack crée le Track immédiatement et lance la copie en goroutine + // MOD-P1-004: Ajouter timeout context pour opération DB critique (upload track) + h.trackService.logger.Debug("Upload track: starting save", + zap.String("user_id", userID.String()), + zap.String("filename", fileHeader.Filename), + zap.Any("metadata", metadata), + ) + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) // Upload peut prendre du temps + defer cancel() + track, err := h.trackService.UploadTrack(ctx, userID, fileHeader, metadata) + if err != nil { + h.trackService.logger.Error("Upload track: save failed", + zap.String("user_id", userID.String()), + zap.String("filename", fileHeader.Filename), + zap.Error(err), + ) + // Mapper les erreurs vers des messages utilisateur spécifiques + errorMessage := h.mapTrackError(err) + statusCode := h.getErrorStatusCode(err) + // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Error + h.respondWithError(c, statusCode, errorMessage) + return + } + + // MOD-P2-008: Sémantique asynchrone - retourner 202 Accepted avec track_id + // La copie fichier se fait en arrière-plan, le client peut poller GetUploadStatus + c.Header("Location", fmt.Sprintf("/api/v1/tracks/%s/status", track.ID.String())) + handlers.RespondSuccess(c, http.StatusAccepted, gin.H{ + "track_id": track.ID.String(), + "status": string(track.Status), + "status_url": fmt.Sprintf("/api/v1/tracks/%s/status", track.ID.String()), + "message": "Upload initiated, file is being saved in background", + }) + + // MOD-P2-008: Déclencher le traitement du streaming après la copie (sera fait quand Status=Processing) + // On ne peut pas le faire ici car le fichier n'existe pas encore + // Ce sera fait dans un job séparé ou via un hook quand Status passe à Processing +} + +// GetUploadStatus récupère le statut d'upload d'un track +// @Summary Get Upload Status +// @Description Get the processing status of an uploaded track +// @Tags Track +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Track ID" +// @Success 200 {object} response.APIResponse{data=object{progress=int}} +// @Failure 400 {object} response.APIResponse "Invalid ID" +// @Failure 401 {object} response.APIResponse "Unauthorized" +// @Failure 404 {object} response.APIResponse "Track not found" +// @Router /tracks/{id}/status [get] +func (h *TrackHandler) GetUploadStatus(c *gin.Context) { + trackIDStr := c.Param("id") + if trackIDStr == "" { + response.BadRequest(c, "track id is required") + return + } + + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.BadRequest + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + // Vérifier que l'utilisateur est authentifié (userID non utilisé dans cette fonction) + // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() + if _, ok := h.getUserID(c); !ok { + return // Erreur déjà envoyée par getUserID + } + + // Récupérer la progression (TrackUploadService utilise uuid.UUID) + progress, err := h.trackUploadService.GetUploadProgress(c.Request.Context(), trackID) + if err != nil { + // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.InternalServerError + h.respondWithError(c, http.StatusInternalServerError, "failed to get upload progress") + return + } + + // MOD-P1-RES-001: Utiliser RespondSuccess au lieu de response.Success + handlers.RespondSuccess(c, http.StatusOK, gin.H{"progress": progress}) +} + +// InitiateChunkedUploadRequest représente la requête pour initialiser un upload par chunks +type InitiateChunkedUploadRequest struct { + TotalChunks int `json:"total_chunks" binding:"required,min=1" validate:"required,min=1"` + TotalSize int64 `json:"total_size" binding:"required,min=1" validate:"required,min=1"` + Filename string `json:"filename" binding:"required" validate:"required"` +} + +// InitiateChunkedUpload initialise un nouvel upload par chunks +// @Summary Initiate Chunked Upload +// @Description Start a new chunked upload session +// @Tags Track +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body InitiateChunkedUploadRequest true "Upload Metadata" +// @Success 200 {object} response.APIResponse{data=object{upload_id=string,message=string}} +// @Failure 400 {object} response.APIResponse "Validation Error" +// @Failure 401 {object} response.APIResponse "Unauthorized" +// @Router /tracks/initiate [post] +func (h *TrackHandler) InitiateChunkedUpload(c *gin.Context) { + // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() + userID, ok := h.getUserID(c) + if !ok { + return // Erreur déjà envoyée par getUserID + } + + // MOD-P1-002: Utiliser helper centralisé pour bind + validate + var req InitiateChunkedUploadRequest + if !common.BindAndValidateJSON(c, &req) { + return // Erreur déjà envoyée au client + } + + // Initialiser l'upload + // InitiateChunkedUpload retourne un string (uploadID) donc pas de souci d'int64 + // Note: InitiateChunkedUpload n'accepte pas de context (à migrer si nécessaire) + uploadID, err := h.chunkService.InitiateChunkedUpload(userID, req.TotalChunks, req.TotalSize, req.Filename) + if err != nil { + response.InternalServerError(c, err.Error()) + return + } + + response.Success(c, gin.H{ + "upload_id": uploadID, + "message": "upload initiated successfully", + }) +} + +// UploadChunkRequest représente la requête pour uploader un chunk +type UploadChunkRequest struct { + UploadID string `form:"upload_id" binding:"required"` + ChunkNumber int `form:"chunk_number" binding:"required,min=1"` + TotalChunks int `form:"total_chunks" binding:"required,min=1"` + TotalSize int64 `form:"total_size" binding:"required,min=1"` + Filename string `form:"filename" binding:"required"` +} + +// UploadChunk gère l'upload d'un chunk +// @Summary Upload Chunk +// @Description Upload a single chunk of a file +// @Tags Track +// @Accept multipart/form-data +// @Produce json +// @Security BearerAuth +// @Param chunk formData file true "Chunk Data" +// @Param upload_id formData string true "Upload ID" +// @Param chunk_number formData int true "Chunk Number" +// @Param total_chunks formData int true "Total Chunks" +// @Param total_size formData int64 true "Total Size" +// @Param filename formData string true "Filename" +// @Success 200 {object} response.APIResponse{data=object{message=string,upload_id=string,received_chunks=int,progress=float64}} +// @Failure 400 {object} response.APIResponse "Validation Error" +// @Failure 401 {object} response.APIResponse "Unauthorized" +// @Router /tracks/chunk [post] +func (h *TrackHandler) UploadChunk(c *gin.Context) { + // Vérifier que l'utilisateur est authentifié (userID non utilisé dans cette fonction) + // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() + if _, ok := h.getUserID(c); !ok { + return // Erreur déjà envoyée par getUserID + } + + var req UploadChunkRequest + if err := c.ShouldBind(&req); err != nil { + response.BadRequest(c, err.Error()) + return + } + + fileHeader, err := c.FormFile("chunk") + if err != nil { + response.BadRequest(c, "no chunk file provided") + return + } + + // Sauvegarder le chunk + if err := h.chunkService.SaveChunk(c.Request.Context(), req.UploadID, req.ChunkNumber, req.TotalChunks, fileHeader); err != nil { + response.BadRequest(c, err.Error()) + return + } + + // Récupérer la progression + receivedChunks, progress, err := h.chunkService.GetUploadProgress(req.UploadID) + if err != nil { + response.InternalServerError(c, err.Error()) + return + } + + response.Success(c, gin.H{ + "message": "chunk uploaded successfully", + "upload_id": req.UploadID, + "received_chunks": receivedChunks, + "total_chunks": req.TotalChunks, + "progress": progress, + }) +} + +// CompleteChunkedUploadRequest représente la requête pour compléter un upload par chunks +type CompleteChunkedUploadRequest struct { + UploadID string `json:"upload_id" binding:"required" validate:"required,uuid"` +} + +// CompleteChunkedUpload assemble tous les chunks et crée le track final +// @Summary Complete Chunked Upload +// @Description Finish upload session and assemble file +// @Tags Track +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body CompleteChunkedUploadRequest true "Upload ID" +// @Success 201 {object} response.APIResponse{data=object{message=string,track=models.Track,md5=string}} +// @Failure 400 {object} response.APIResponse "Validation or Assemblage Error" +// @Failure 401 {object} response.APIResponse "Unauthorized" +// @Router /tracks/complete [post] +func (h *TrackHandler) CompleteChunkedUpload(c *gin.Context) { + // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() + userID, ok := h.getUserID(c) + if !ok { + return // Erreur déjà envoyée par getUserID + } + + // MOD-P1-002: Utiliser helper centralisé pour bind + validate + var req CompleteChunkedUploadRequest + if !common.BindAndValidateJSON(c, &req) { + return // Erreur déjà envoyée au client + } + + // Récupérer les informations de l'upload pour obtenir le filename + uploadInfo, err := h.chunkService.GetUploadInfo(req.UploadID) + if err != nil { + response.BadRequest(c, err.Error()) + return + } + + // Générer un nom de fichier unique pour le fichier final + timestamp := uuid.New() + ext := filepath.Ext(uploadInfo.Filename) + if ext == "" { + ext = ".mp3" // Par défaut + } + filename := fmt.Sprintf("%s_%s%s", userID.String(), timestamp.String(), ext) + finalPath := filepath.Join("uploads/tracks", userID.String(), filename) + + // Assurer que le répertoire existe + if err := os.MkdirAll(filepath.Dir(finalPath), 0755); err != nil { + response.InternalServerError(c, "failed to create directory") + return + } + + // Assembler les chunks + // MOD-P1-004: Ajouter timeout context pour opération I/O (assemblage chunks) + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) // Assemblage peut prendre du temps + defer cancel() + finalFilename, totalSize, checksum, err := h.chunkService.CompleteChunkedUpload(ctx, req.UploadID, finalPath) + if err != nil { + errorMessage := h.mapTrackError(err) + statusCode := h.getErrorStatusCode(err) + response.Error(c, statusCode, errorMessage) + return + } + + // Vérifier le quota avant de créer le track final + // MOD-P1-004: Ajouter timeout context pour opération DB (quota check) + quotaCtx, quotaCancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer quotaCancel() + if err := h.trackService.CheckUserQuota(quotaCtx, userID, totalSize); err != nil { + errorMessage := h.mapTrackError(err) + statusCode := h.getErrorStatusCode(err) + // Nettoyer le fichier assemblé + os.Remove(finalPath) + response.Error(c, statusCode, errorMessage) + return + } + + // Déterminer le format + ext = filepath.Ext(finalFilename) + format := strings.TrimPrefix(strings.ToUpper(ext), ".") + if format == "M4A" { + format = "AAC" + } + + // Créer le track en base en utilisant CreateTrackFromPath + // MOD-P1-004: Ajouter timeout context pour opération DB critique (create track) + createCtx, createCancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer createCancel() + track, err := h.trackService.CreateTrackFromPath(createCtx, userID, finalPath, finalFilename, totalSize, format) + if err != nil { + // Nettoyer le fichier en cas d'erreur + os.Remove(finalPath) + errorMessage := h.mapTrackError(err) + statusCode := h.getErrorStatusCode(err) + response.Error(c, statusCode, errorMessage) + return + } + + // Mettre à jour le message de statut avec le checksum SHA256 + if err := h.trackUploadService.UpdateUploadStatus(c.Request.Context(), track.ID, models.TrackStatusUploading, fmt.Sprintf("Upload completed, checksum: %s", checksum)); err != nil { + // Log l'erreur mais ne pas faire échouer la requête + h.trackService.logger.Error("Failed to update track upload status after completion", zap.Error(err), zap.Any("track_id", track.ID)) + } + + // Déclencher le traitement du streaming + if h.streamService != nil { + // FIX #23: Enrichir le contexte avec le request_id pour propagation + ctx := c.Request.Context() + if requestID := c.GetString("request_id"); requestID != "" { + ctx = context.WithValue(ctx, "request_id", requestID) + } + + if err := h.streamService.StartProcessing(ctx, track.ID, track.FilePath); err != nil { + // FIX #10: Logger l'erreur avec contexte + h.trackService.logger.Error("Failed to start stream processing", + zap.String("track_id", track.ID.String()), + zap.String("file_path", track.FilePath), + zap.Error(err), + ) + } + } + + // Enqueue HLS transcoding job (async ffmpeg) + if h.jobEnqueuer != nil { + hlsOutputDir := filepath.Join(filepath.Dir(filepath.Dir(finalPath)), "hls") + h.jobEnqueuer.EnqueueTranscodingJob(track.ID, finalPath, hlsOutputDir) + } + + response.Created(c, gin.H{ + "message": "upload completed successfully", + "track": track, + "md5": checksum, // SHA256 (64 hex), legacy key for API compatibility + }) +} + +// mapTrackError mappe les erreurs techniques vers des messages utilisateur +func (h *TrackHandler) mapTrackError(err error) string { + if err == nil { + return "unknown error" + } + + errStr := err.Error() + + // Erreurs de validation + if strings.Contains(errStr, "invalid track format") || strings.Contains(errStr, "invalid file format") { + return "Invalid file format. Allowed formats: MP3, FLAC, WAV, OGG" + } + if strings.Contains(errStr, "file size exceeds") || strings.Contains(errStr, "too large") { + return "File size exceeds maximum allowed size of 100MB" + } + if strings.Contains(errStr, "file is empty") { + return "The uploaded file is empty" + } + + // Erreurs de quota + if strings.Contains(errStr, "track quota exceeded") { + return "You have reached the maximum number of tracks allowed" + } + if strings.Contains(errStr, "storage quota exceeded") { + return "You have reached your storage quota. Please delete some tracks to free up space" + } + + // Erreurs réseau + if strings.Contains(errStr, "network error") || strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection") { + return "Network error occurred. Please try again" + } + + // Erreurs de stockage + if strings.Contains(errStr, "storage error") || strings.Contains(errStr, "failed to save file") { + return "Failed to save file. Please try again" + } + if strings.Contains(errStr, "failed to create upload directory") { + return "Failed to prepare storage. Please try again later" + } + + // Erreur par défaut + return "An error occurred during upload. Please try again" +} + +// getErrorStatusCode retourne le code de statut HTTP approprié pour une erreur +func (h *TrackHandler) getErrorStatusCode(err error) int { + if err == nil { + return http.StatusInternalServerError + } + + errStr := err.Error() + + // Erreurs de validation -> 400 + if strings.Contains(errStr, "invalid") || strings.Contains(errStr, "too large") || strings.Contains(errStr, "empty") { + return http.StatusBadRequest + } + + // Erreurs de quota -> 403 + if strings.Contains(errStr, "quota exceeded") { + return http.StatusForbidden + } + + // Erreurs réseau -> 503 (Service Unavailable) + if strings.Contains(errStr, "network error") || strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection") { + return http.StatusServiceUnavailable + } + + // Erreurs de stockage -> 500 + if strings.Contains(errStr, "storage error") || strings.Contains(errStr, "failed to save") { + return http.StatusInternalServerError + } + + // Par défaut + return http.StatusInternalServerError +} + +// GetUploadQuota récupère les informations de quota d'upload pour un utilisateur +// @Summary Get Upload Quota +// @Description Get remaining upload quota for the user +// @Tags Track +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string false "User ID (optional, defaults to current user)" +// @Success 200 {object} response.APIResponse{data=object{quota=object}} +// @Failure 401 {object} response.APIResponse "Unauthorized" +// @Failure 403 {object} response.APIResponse "Forbidden" +// @Router /tracks/quota/{id} [get] +func (h *TrackHandler) GetUploadQuota(c *gin.Context) { + // Récupérer l'ID utilisateur depuis l'URL ou depuis le contexte d'authentification + userIDParam := c.Param("id") + var userID uuid.UUID + var err error + + if userIDParam == "" || userIDParam == "me" { + // Si "me" ou vide, utiliser l'utilisateur authentifié + // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() + var ok bool + userID, ok = h.getUserID(c) + if !ok { + return // Erreur déjà envoyée par getUserID + } + } else { + // Parse UUID + userID, err = uuid.Parse(userIDParam) + if err != nil { + response.BadRequest(c, "invalid user id") + return + } + } + + // Vérifier que l'utilisateur peut accéder à ces informations (soit lui-même, soit admin) + // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() + authenticatedUserID, ok := h.getUserID(c) + if !ok { + return // Erreur déjà envoyée par getUserID + } + + // Un utilisateur ne peut voir que son propre quota (sauf admin, mais on simplifie pour l'instant) + if authenticatedUserID != userID { + response.Forbidden(c, "forbidden: you can only view your own quota") + return + } + + // Récupérer le quota + quota, err := h.trackService.GetUserQuota(c.Request.Context(), userID) + if err != nil { + response.InternalServerError(c, "failed to get quota") + return + } + + response.Success(c, gin.H{ + "quota": quota, + }) +} + +// ResumeUpload récupère l'état d'un upload pour permettre la reprise +// @Summary Resume Upload +// @Description Get state of an interrupted upload +// @Tags Track +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param uploadId path string true "Upload ID" +// @Success 200 {object} response.APIResponse{data=object{upload_id=string,chunks_received=int}} +// @Failure 404 {object} response.APIResponse "Upload session not found" +// @Router /tracks/resume/{uploadId} [get] +func (h *TrackHandler) ResumeUpload(c *gin.Context) { + // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() + userID, ok := h.getUserID(c) + if !ok { + return // Erreur déjà envoyée par getUserID + } + + uploadID := c.Param("uploadId") + if uploadID == "" { + response.BadRequest(c, "upload_id is required") + return + } + + // Récupérer l'état de l'upload + state, err := h.chunkService.GetUploadState(uploadID) + if err != nil { + response.NotFound(c, "upload not found") + return + } + + // Vérifier que l'upload appartient à l'utilisateur authentifié + if state.UserID != userID { + response.Forbidden(c, "forbidden: you can only resume your own uploads") + return + } + + response.Success(c, gin.H{ + "upload_id": state.UploadID, + "user_id": state.UserID, + "total_chunks": state.TotalChunks, + "total_size": state.TotalSize, + "filename": state.Filename, + "chunks_received": state.ChunksReceived, + "received_count": state.ReceivedCount, + "last_chunk": state.LastChunk, + "progress": state.Progress, + "created_at": state.CreatedAt, + "updated_at": state.UpdatedAt, + }) +} diff --git a/veza-backend-api/internal/core/track/track_waveform_handler.go b/veza-backend-api/internal/core/track/track_waveform_handler.go new file mode 100644 index 000000000..0216be1a3 --- /dev/null +++ b/veza-backend-api/internal/core/track/track_waveform_handler.go @@ -0,0 +1,49 @@ +package track + +import ( + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/gin-gonic/gin" +) + +// GetWaveform returns the waveform JSON data for a track (S1-06) +// GET /api/v1/tracks/:id/waveform +func (h *TrackHandler) GetWaveform(c *gin.Context) { + if h.waveformService == nil { + h.respondWithError(c, http.StatusInternalServerError, "waveform service not available") + return + } + + trackIDStr := c.Param("id") + if trackIDStr == "" { + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + data, err := h.waveformService.GetWaveform(c.Request.Context(), trackID) + if err != nil { + if strings.Contains(err.Error(), "not yet generated") { + h.respondWithError(c, http.StatusNotFound, "waveform not yet generated") + return + } + if strings.Contains(err.Error(), "track not found") { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to get waveform") + return + } + + c.Header("Content-Type", "application/json") + c.Header("Cache-Control", "public, max-age=3600") + c.Data(http.StatusOK, "application/json", data) +} diff --git a/veza-backend-api/internal/handlers/gear_handler.go b/veza-backend-api/internal/handlers/gear_handler.go index 39d38ccb2..abb1387c0 100644 --- a/veza-backend-api/internal/handlers/gear_handler.go +++ b/veza-backend-api/internal/handlers/gear_handler.go @@ -306,7 +306,7 @@ func (h *GearHandler) DeleteGear(c *gin.Context) { // ListPublicGear returns public gear items for a given username (no auth required) func (h *GearHandler) ListPublicGear(c *gin.Context) { - username := c.Param("username") + username := c.Param("id") // route uses :id to match /users/:id/* pattern if username == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"}) return diff --git a/veza-backend-api/internal/integration/e2e_cloud_test.go b/veza-backend-api/internal/integration/e2e_cloud_test.go new file mode 100644 index 000000000..ed45f6dc6 --- /dev/null +++ b/veza-backend-api/internal/integration/e2e_cloud_test.go @@ -0,0 +1,74 @@ +//go:build integration +// +build integration + +package integration + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestE2E_CloudUploadPreviewPublish(t *testing.T) { + router, cleanup := setupE2ETestRouter(t) + defer cleanup() + ts := httptest.NewServer(router) + defer ts.Close() + + t.Run("cloud quota endpoint requires auth", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/api/v1/cloud/quota") + require.NoError(t, err) + defer resp.Body.Close() + assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest) + }) + + t.Run("cloud folders endpoint requires auth", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/api/v1/cloud/folders") + require.NoError(t, err) + defer resp.Body.Close() + assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest) + }) + + t.Run("cloud files endpoint requires auth", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/api/v1/cloud/files") + require.NoError(t, err) + defer resp.Body.Close() + assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest) + }) + + t.Run("cloud file upload requires auth", func(t *testing.T) { + resp, err := http.Post(ts.URL+"/api/v1/cloud/files", "multipart/form-data", nil) + require.NoError(t, err) + defer resp.Body.Close() + assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest) + }) + + t.Run("cloud stream endpoint requires auth", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/api/v1/cloud/files/00000000-0000-0000-0000-000000000000/stream") + require.NoError(t, err) + defer resp.Body.Close() + assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest) + }) + + t.Run("cloud publish endpoint requires auth", func(t *testing.T) { + resp, err := http.Post(ts.URL+"/api/v1/cloud/files/00000000-0000-0000-0000-000000000000/publish", "application/json", nil) + require.NoError(t, err) + defer resp.Body.Close() + assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest) + }) + + t.Run("public gear endpoint works without auth", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/api/v1/users/testuser/gear") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + assert.NotNil(t, result["items"]) + }) +} diff --git a/veza-backend-api/internal/integration/e2e_streaming_test.go b/veza-backend-api/internal/integration/e2e_streaming_test.go new file mode 100644 index 000000000..850b08eab --- /dev/null +++ b/veza-backend-api/internal/integration/e2e_streaming_test.go @@ -0,0 +1,55 @@ +//go:build integration +// +build integration + +package integration + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestE2E_UploadTrackTriggerHLS(t *testing.T) { + router, cleanup := setupE2ETestRouter(t) + defer cleanup() + ts := httptest.NewServer(router) + defer ts.Close() + + t.Run("health check before streaming", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/api/v1/health") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("unauthenticated track upload is rejected", func(t *testing.T) { + resp, err := http.Post(ts.URL+"/api/v1/tracks", "application/json", nil) + require.NoError(t, err) + defer resp.Body.Close() + assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest) + }) + + t.Run("waveform endpoint returns 404 for nonexistent track", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/api/v1/tracks/00000000-0000-0000-0000-000000000000/waveform") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + + t.Run("HLS manifest requires authentication", func(t *testing.T) { + resp, err := http.Get(ts.URL + "/api/v1/tracks/00000000-0000-0000-0000-000000000000/hls/master.m3u8") + require.NoError(t, err) + defer resp.Body.Close() + assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusNotFound) + }) + + t.Run("stream token endpoint requires auth", func(t *testing.T) { + resp, err := http.Post(ts.URL+"/api/v1/auth/stream-token", "application/json", nil) + require.NoError(t, err) + defer resp.Body.Close() + assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest) + }) +} diff --git a/veza-backend-api/internal/integration/e2e_test.go b/veza-backend-api/internal/integration/e2e_test.go index 22f9fe7ba..b861ec078 100644 --- a/veza-backend-api/internal/integration/e2e_test.go +++ b/veza-backend-api/internal/integration/e2e_test.go @@ -41,7 +41,7 @@ func setupE2ETestRouter(t *testing.T) (*gin.Engine, func()) { require.NoError(t, err) mockGormDB.Exec("PRAGMA foreign_keys = ON") - // Auto-migrate models needed for auth and webhooks + // Auto-migrate models needed for auth, webhooks, and gear require.NoError(t, mockGormDB.AutoMigrate( &models.User{}, &models.RefreshToken{}, @@ -51,6 +51,7 @@ func setupE2ETestRouter(t *testing.T) (*gin.Engine, func()) { &models.UserRole{}, &models.RolePermission{}, &models.Webhook{}, + &models.GearItem{}, )) require.NoError(t, mockGormDB.Exec(` CREATE TABLE IF NOT EXISTS email_verification_tokens (