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:
parent
edde637c8e
commit
43309327e6
15 changed files with 1189 additions and 853 deletions
84
.github/workflows/container-scan.yml
vendored
Normal file
84
.github/workflows/container-scan.yml
vendored
Normal 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
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
44
docs/MIGRATIONS.md
Normal 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
27
scripts/squash_migrations.sh
Executable 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;"
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
188
veza-backend-api/internal/core/track/track_hls_handler.go
Normal file
188
veza-backend-api/internal/core/track/track_hls_handler.go
Normal 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"
|
||||
}
|
||||
}
|
||||
652
veza-backend-api/internal/core/track/track_upload_handler.go
Normal file
652
veza-backend-api/internal/core/track/track_upload_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
74
veza-backend-api/internal/integration/e2e_cloud_test.go
Normal file
74
veza-backend-api/internal/integration/e2e_cloud_test.go
Normal 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"])
|
||||
})
|
||||
}
|
||||
55
veza-backend-api/internal/integration/e2e_streaming_test.go
Normal file
55
veza-backend-api/internal/integration/e2e_streaming_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
Loading…
Reference in a new issue