veza/veza-backend-api/internal/api/routes_tracks.go
senke 15e591305e
Some checks failed
Veza CI / Rust (Stream Server) (push) Successful in 5m12s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 54s
Veza CI / Backend (Go) (push) Failing after 8m38s
Veza CI / Frontend (Web) (push) Failing after 16m44s
Veza CI / Notify on failure (push) Successful in 15s
E2E Playwright / e2e (full) (push) Successful in 20m28s
feat(cdn): Bunny.net signed URLs + HLS cache headers + metric collision fix (W3 Day 13)
CDN edge in front of S3/MinIO via origin-pull. Backend signs URLs
with Bunny.net token-auth (SHA-256 over security_key + path + expires)
so edges verify before serving cached objects ; origin is never hit
on a valid token. Cloudflare CDN / R2 / CloudFront stubs kept.

- internal/services/cdn_service.go : new providers CDNProviderBunny +
  CDNProviderCloudflareR2. SecurityKey added to CDNConfig.
  generateBunnySignedURL implements the documented Bunny scheme
  (url-safe base64, no padding, expires query). HLSSegmentCacheHeaders
  + HLSPlaylistCacheHeaders helpers exported for handlers.
- internal/services/cdn_service_test.go : pin Bunny URL shape +
  base64-url charset ; assert empty SecurityKey fails fast (no
  silent fallback to unsigned URLs).
- internal/core/track/service.go : new CDNURLSigner interface +
  SetCDNService(cdn). GetStorageURL prefers CDN signed URL when
  cdnService.IsEnabled, falls back to direct S3 presign on signing
  error so a CDN partial outage doesn't block playback.
- internal/api/routes_tracks.go + routes_core.go : wire SetCDNService
  on the two TrackService construction sites that serve stream/download.
- internal/config/config.go : 4 new env vars (CDN_ENABLED, CDN_PROVIDER,
  CDN_BASE_URL, CDN_SECURITY_KEY). config.CDNService always non-nil
  after init ; IsEnabled gates the actual usage.
- internal/handlers/hls_handler.go : segments now return
  Cache-Control: public, max-age=86400, immutable (content-addressed
  filenames make this safe). Playlists at max-age=60.
- veza-backend-api/.env.template : 4 placeholder env vars.
- docs/ENV_VARIABLES.md §12 : provider matrix + Bunny vs Cloudflare
  vs R2 trade-offs.

Bug fix collateral : v1.0.9 Day 11 introduced veza_cache_hits_total
which collided in name with monitoring.CacheHitsTotal (different
label set ⇒ promauto MustRegister panic at process init). Day 13
deletes the monitoring duplicate and restores the metrics-package
counter as the single source of truth (label: subsystem). All 8
affected packages green : services, core/track, handlers, middleware,
websocket/chat, metrics, monitoring, config.

Acceptance (Day 13) : code path is wired ; verifying via real Bunny
edge requires a Pull Zone provisioned by the user (EX-? in roadmap).
On the user side : create Pull Zone w/ origin = MinIO, copy token
auth key into CDN_SECURITY_KEY, set CDN_ENABLED=true.

W3 progress : Redis Sentinel ✓ · MinIO distribué ✓ · CDN ✓ ·
DMCA  Day 14 · embed  Day 15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 14:07:20 +02:00

249 lines
9.6 KiB
Go

package api
import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
discovercore "veza-backend-api/internal/core/discover"
trackcore "veza-backend-api/internal/core/track"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/services"
)
// setupTrackRoutes configure les routes de gestion des tracks
func (r *APIRouter) setupTrackRoutes(router *gin.RouterGroup) {
uploadDir := r.config.UploadDir
if uploadDir == "" {
uploadDir = "uploads/tracks"
}
chunksDir := uploadDir + "/chunks"
trackService := trackcore.NewTrackServiceWithDB(r.db, r.logger, uploadDir)
if r.config.CacheService != nil {
trackService.SetCacheService(r.config.CacheService)
}
streamService := services.NewStreamServiceWithAPIKey(r.config.StreamServerURL, r.config.StreamServerInternalAPIKey, r.logger)
trackService.SetStreamService(streamService) // INT-02: Enable HLS pipeline for regular uploads
discoverService := discovercore.NewService(r.db.GormDB, r.logger)
trackService.SetDiscoverService(discoverService) // v0.10.1: tags/genres sync
// v1.0.8 Phase 1: wire S3 storage when configured. s3Service will be nil
// if AWS_S3_ENABLED=false, which leaves TrackService in local-only mode
// regardless of TrackStorageBackend value.
trackService.SetS3Storage(r.config.S3StorageService, r.config.TrackStorageBackend, r.config.S3Bucket)
// v1.0.9 W3 Day 13: wire optional CDN edge. CDNService is always
// non-nil after Config init ; IsEnabled gates the actual usage.
if r.config.CDNService != nil {
trackService.SetCDNService(r.config.CDNService)
}
trackUploadService := services.NewTrackUploadService(r.db.GormDB, r.logger)
var redisClient *redis.Client
if r.config != nil {
redisClient = r.config.RedisClient
}
chunkService := services.NewTrackChunkService(chunksDir, redisClient, r.logger)
likeService := services.NewTrackLikeService(r.db.GormDB, r.logger)
trackHandler := trackcore.NewTrackHandler(
trackService,
trackUploadService,
chunkService,
likeService,
streamService,
)
if r.config != nil {
if r.config.PermissionService != nil {
trackHandler.SetPermissionService(r.config.PermissionService)
}
if r.config.JobWorker != nil {
trackHandler.SetJobEnqueuer(r.config.JobWorker)
}
}
uploadConfig := getUploadConfigWithEnv()
uploadValidator, err := services.NewUploadValidator(uploadConfig, r.logger)
if err != nil {
r.logger.Warn("Upload validator created with ClamAV unavailable - uploads will be rejected", zap.Error(err))
uploadConfig.ClamAVEnabled = false
uploadValidator, _ = services.NewUploadValidator(uploadConfig, r.logger)
}
trackHandler.SetUploadValidator(uploadValidator)
trackSearchService := services.NewTrackSearchServiceWithDB(r.db)
trackHandler.SetSearchService(trackSearchService)
trackVersionService := services.NewTrackVersionService(r.db.GormDB, r.logger, uploadDir)
trackHandler.SetVersionService(trackVersionService)
playbackAnalyticsService := services.NewPlaybackAnalyticsService(r.db.GormDB, r.logger)
trackHandler.SetPlaybackAnalyticsService(playbackAnalyticsService)
trackHistoryService := services.NewTrackHistoryService(r.db.GormDB, r.logger)
trackHandler.SetHistoryService(trackHistoryService)
licenseChecker := services.NewDBTrackDownloadLicenseChecker(r.db.GormDB, r.logger)
trackHandler.SetLicenseChecker(licenseChecker)
if r.notificationService == nil {
r.notificationService = services.NewNotificationService(r.db, r.logger)
if r.pushService != nil {
r.notificationService.SetPushService(r.pushService)
}
}
trackHandler.SetNotificationService(r.notificationService)
repostService := services.NewTrackRepostService(r.db.GormDB)
trackHandler.SetRepostService(repostService) // v0.10.3 F203
trackRecommendationService := services.NewTrackRecommendationService(r.db.GormDB, r.logger)
trackHandler.SetTrackRecommendationService(trackRecommendationService)
waveformService := services.NewWaveformService(r.db.GormDB, r.logger, r.config.S3StorageService)
if r.config.CacheService != nil {
waveformService.SetCacheService(r.config.CacheService)
}
trackHandler.SetWaveformService(waveformService)
tracks := router.Group("/tracks")
{
if r.config.AuthMiddleware != nil {
tracks.GET("", r.config.AuthMiddleware.OptionalAuth(), trackHandler.ListTracks)
} else {
tracks.GET("", trackHandler.ListTracks)
}
tracks.GET("/search", trackHandler.SearchTracks)
tracks.GET("/suggested-tags", trackHandler.GetSuggestedTags)
if r.config.AuthMiddleware != nil {
tracks.GET("/:id", r.config.AuthMiddleware.OptionalAuth(), trackHandler.GetTrack)
} else {
tracks.GET("/:id", trackHandler.GetTrack)
}
tracks.GET("/:id/lyrics", trackHandler.GetLyrics)
tracks.GET("/:id/stats", trackHandler.GetTrackStats)
tracks.GET("/:id/waveform", trackHandler.GetWaveform)
tracks.GET("/:id/history", trackHandler.GetTrackHistory)
tracks.GET("/:id/download", trackHandler.DownloadTrack)
if r.config.AuthMiddleware != nil {
tracks.GET("/:id/stream", r.config.AuthMiddleware.OptionalAuth(), trackHandler.StreamTrack)
} else {
tracks.GET("/:id/stream", trackHandler.StreamTrack)
}
tracks.GET("/shared/:token", trackHandler.GetSharedTrack)
if r.config.AuthMiddleware != nil {
tracks.GET("/:id/repost", r.config.AuthMiddleware.OptionalAuth(), trackHandler.GetRepostStatus) // v0.10.3 F203
} else {
tracks.GET("/:id/repost", trackHandler.GetRepostStatus)
}
if r.config.AuthMiddleware != nil {
protected := tracks.Group("")
protected.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(protected)
protected.GET("/recommendations", trackHandler.GetRecommendations)
uploadGroup := protected.Group("")
uploadGroup.Use(r.config.AuthMiddleware.RequireContentCreatorRole())
uploadGroup.POST("", trackHandler.UploadTrack)
trackOwnerResolver := func(c *gin.Context) (uuid.UUID, error) {
trackIDStr := c.Param("id")
trackID, err := uuid.Parse(trackIDStr)
if err != nil {
return uuid.Nil, err
}
track, err := trackService.GetTrackByID(c.Request.Context(), trackID)
if err != nil {
return uuid.Nil, err
}
return track.UserID, nil
}
protected.PUT("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), trackHandler.UpdateTrack)
protected.PUT("/:id/lyrics", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), trackHandler.UpdateLyrics)
protected.DELETE("/:id", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), trackHandler.DeleteTrack)
protected.GET("/:id/status", trackHandler.GetUploadStatus)
protected.POST("/initiate", trackHandler.InitiateChunkedUpload)
protected.POST("/chunk", trackHandler.UploadChunk)
protected.POST("/complete", trackHandler.CompleteChunkedUpload)
protected.GET("/quota/:id", trackHandler.GetUploadQuota)
protected.GET("/resume/:uploadId", trackHandler.ResumeUpload)
protected.POST("/batch/delete", trackHandler.BatchDeleteTracks)
protected.POST("/batch/update", trackHandler.BatchUpdateTracks)
protected.POST("/:id/like", trackHandler.LikeTrack)
protected.DELETE("/:id/like", trackHandler.UnlikeTrack)
protected.GET("/:id/likes", trackHandler.GetTrackLikes)
protected.POST("/:id/repost", trackHandler.RepostTrack)
protected.DELETE("/:id/repost", trackHandler.UnrepostTrack)
protected.POST("/:id/share", trackHandler.CreateShare)
protected.DELETE("/share/:id", trackHandler.RevokeShare)
protected.POST("/:id/versions/:versionId/restore", trackHandler.RestoreVersion)
protected.POST("/:id/play", trackHandler.RecordPlay)
// v0.10.7 F482: Stem sharing
stemUploadDir := uploadDir
if stemUploadDir == "" {
stemUploadDir = "uploads/tracks"
}
stemService := services.NewTrackStemService(r.db.GormDB, stemUploadDir, r.logger)
stemHandler := handlers.NewTrackStemHandler(stemService, trackService, r.logger)
protected.GET("/:id/stems/:name/download", stemHandler.DownloadStem)
protected.POST("/:id/stems", r.config.AuthMiddleware.RequireOwnershipOrAdmin("track", trackOwnerResolver), stemHandler.UploadStem)
protected.GET("/:id/stems", stemHandler.ListStems)
hlsOutputDir := r.config.UploadDir
if hlsOutputDir == "" {
hlsOutputDir = "uploads/tracks"
}
hlsService := services.NewHLSService(r.db.GormDB, hlsOutputDir, r.logger)
hlsHandler := handlers.NewHLSHandler(hlsService)
tracks.GET("/:id/hls/info", hlsHandler.GetStreamInfo)
tracks.GET("/:id/hls/status", hlsHandler.GetStreamStatus)
if r.config.HLSEnabled {
hlsStreaming := tracks.Group("/:id/hls")
{
hlsStreaming.GET("/master.m3u8", hlsHandler.ServeMasterPlaylist)
hlsStreaming.GET("/:bitrate/playlist.m3u8", hlsHandler.ServeQualityPlaylist)
hlsStreaming.GET("/:bitrate/:segment", hlsHandler.ServeSegment)
}
}
}
}
commentService := services.NewCommentService(r.db.GormDB, r.logger)
commentService.SetModerationService(services.NewCommentModerationService(r.db.GormDB)) // v0.10.3 F201
commentHandler := handlers.NewCommentHandler(commentService, r.logger)
commentHandler.SetNotificationService(r.notificationService)
comments := router.Group("/tracks")
{
comments.GET("/:id/comments", commentHandler.GetComments)
if r.config.AuthMiddleware != nil {
protected := comments.Group("")
protected.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(protected)
{
protected.POST("/:id/comments", commentHandler.CreateComment)
}
}
}
commentsProtected := router.Group("/comments")
{
if r.config.AuthMiddleware != nil {
commentsProtected.Use(r.config.AuthMiddleware.RequireAuth())
r.applyCSRFProtection(commentsProtected)
{
commentsProtected.DELETE("/:id", commentHandler.DeleteComment)
}
}
}
}