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 { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useChatStore } from '../store/chatStore';
|
import { useChatStore } from '../store/chatStore';
|
||||||
import type { OutgoingMessage } from '../types';
|
import type { OutgoingMessage } from '../types';
|
||||||
|
import { logger } from '@/utils/logger';
|
||||||
|
|
||||||
const ICE_SERVERS: RTCConfiguration['iceServers'] = [
|
const ICE_SERVERS: RTCConfiguration['iceServers'] = [
|
||||||
{ urls: 'stun:stun.l.google.com:19302' },
|
{ urls: 'stun:stun.l.google.com:19302' },
|
||||||
|
|
@ -89,7 +90,7 @@ export function useWebRTC({ sendMessage }: UseWebRTCOptions) {
|
||||||
call_type: callType,
|
call_type: callType,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('startCall error:', err);
|
logger.error('startCall error', { component: 'useWebRTC', action: 'startCall' }, err);
|
||||||
setCallState('error');
|
setCallState('error');
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
@ -161,7 +162,7 @@ export function useWebRTC({ sendMessage }: UseWebRTCOptions) {
|
||||||
sdp: JSON.stringify(answer),
|
sdp: JSON.stringify(answer),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('acceptCall error:', err);
|
logger.error('acceptCall error', { component: 'useWebRTC', action: 'acceptCall' }, err);
|
||||||
setCallState('error');
|
setCallState('error');
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
@ -255,7 +256,7 @@ export function useWebRTC({ sendMessage }: UseWebRTCOptions) {
|
||||||
clearPendingICECandidates();
|
clearPendingICECandidates();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('setRemoteDescription error:', err);
|
logger.error('setRemoteDescription error', { component: 'useWebRTC', action: 'setRemoteDescription' }, err);
|
||||||
setCallState('error');
|
setCallState('error');
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
* Gère les nonces et la configuration CSP pour la sécurité
|
* 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
|
// Nonce généré côté serveur pour les scripts inline
|
||||||
let cspNonce: string | null = null;
|
let cspNonce: string | null = null;
|
||||||
|
|
||||||
|
|
@ -148,7 +150,7 @@ export const CSP_POLICY_DEV = {
|
||||||
export function buildCSPHeaderDev(): string {
|
export function buildCSPHeaderDev(): string {
|
||||||
// Vérifier qu'on est bien en mode développement
|
// Vérifier qu'on est bien en mode développement
|
||||||
if (import.meta.env.MODE === 'production') {
|
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();
|
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)
|
gearHandler := handlers.NewGearHandler(gearService, r.logger)
|
||||||
|
|
||||||
// G1-01: Public gear profile (no auth)
|
// 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 := router.Group("/inventory")
|
||||||
inventory.Use(r.config.AuthMiddleware.RequireAuth())
|
inventory.Use(r.config.AuthMiddleware.RequireAuth())
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,10 @@ func (c *Config) initServices() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service de cache
|
// Service de cache (only when Redis is available; nil client causes panics)
|
||||||
c.CacheService = services.NewCacheService(c.RedisClient, c.Logger)
|
if c.RedisClient != nil {
|
||||||
|
c.CacheService = services.NewCacheService(c.RedisClient, c.Logger)
|
||||||
|
}
|
||||||
|
|
||||||
// Service de playlist
|
// Service de playlist
|
||||||
c.PlaylistService = services.NewPlaylistServiceWithDB(c.Database.GormDB, c.Logger)
|
c.PlaylistService = services.NewPlaylistServiceWithDB(c.Database.GormDB, c.Logger)
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -21,7 +19,6 @@ import (
|
||||||
"veza-backend-api/internal/services"
|
"veza-backend-api/internal/services"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.uber.org/zap" // Added zap
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -174,638 +171,6 @@ func (h *TrackHandler) respondWithError(c *gin.Context, httpStatus int, message
|
||||||
handlers.RespondWithAppError(c, apperrors.New(errCode, 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
|
// ListTracks gère la liste des tracks avec pagination, filtres et tri
|
||||||
// @Summary List Tracks
|
// @Summary List Tracks
|
||||||
// @Description Get a paginated list of tracks with filters
|
// @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
|
// CreateShareRequest représente la requête pour créer un lien de partage
|
||||||
type CreateShareRequest struct {
|
type CreateShareRequest struct {
|
||||||
Permissions string `json:"permissions" binding:"required" validate:"required,oneof=read write admin"`
|
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"})
|
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.)
|
// GetTrackStats returns track statistics (plays, likes, views, etc.)
|
||||||
// GET /api/v1/tracks/:id/stats
|
// GET /api/v1/tracks/:id/stats
|
||||||
func (h *TrackHandler) GetTrackStats(c *gin.Context) {
|
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})
|
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
|
// GetTrackHistory returns modification history for a track
|
||||||
// GET /api/v1/tracks/:id/history
|
// GET /api/v1/tracks/:id/history
|
||||||
func (h *TrackHandler) GetTrackHistory(c *gin.Context) {
|
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
|
// RecordPlayRequest représente la requête pour enregistrer un événement de lecture
|
||||||
// BE-API-019: Implement track play analytics endpoint
|
// BE-API-019: Implement track play analytics endpoint
|
||||||
type RecordPlayRequest struct {
|
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)
|
// ListPublicGear returns public gear items for a given username (no auth required)
|
||||||
func (h *GearHandler) ListPublicGear(c *gin.Context) {
|
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 == "" {
|
if username == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"})
|
||||||
return
|
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)
|
require.NoError(t, err)
|
||||||
mockGormDB.Exec("PRAGMA foreign_keys = ON")
|
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(
|
require.NoError(t, mockGormDB.AutoMigrate(
|
||||||
&models.User{},
|
&models.User{},
|
||||||
&models.RefreshToken{},
|
&models.RefreshToken{},
|
||||||
|
|
@ -51,6 +51,7 @@ func setupE2ETestRouter(t *testing.T) (*gin.Engine, func()) {
|
||||||
&models.UserRole{},
|
&models.UserRole{},
|
||||||
&models.RolePermission{},
|
&models.RolePermission{},
|
||||||
&models.Webhook{},
|
&models.Webhook{},
|
||||||
|
&models.GearItem{},
|
||||||
))
|
))
|
||||||
require.NoError(t, mockGormDB.Exec(`
|
require.NoError(t, mockGormDB.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue