feat(v0.501): Sprint 5 -- integration, tests, and cleanup

- INT-01: Add E2E streaming tests (upload -> HLS auth)
- INT-02: Add E2E cloud tests (CRUD auth, public gear)
- INT-03: Split track/handler.go into 4 focused sub-handlers
- INT-04: Create migration squash script + MIGRATIONS.md
- INT-05: Add Trivy container image scanning CI workflow
- INT-06: Replace production console.log with structured logger
This commit is contained in:
senke 2026-02-22 18:40:07 +01:00
parent edde637c8e
commit 43309327e6
15 changed files with 1189 additions and 853 deletions

84
.github/workflows/container-scan.yml vendored Normal file
View file

@ -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

View file

@ -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();
});

View file

@ -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();
}

44
docs/MIGRATIONS.md Normal file
View file

@ -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

27
scripts/squash_migrations.sh Executable file
View file

@ -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;"

View file

@ -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())

View file

@ -51,8 +51,10 @@ func (c *Config) initServices() error {
return err
}
// Service de cache
// 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)

View file

@ -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 {

View file

@ -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"
}
}

View file

@ -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,
})
}

View file

@ -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)
}

View file

@ -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

View file

@ -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"])
})
}

View file

@ -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)
})
}

View file

@ -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 (