veza/veza-backend-api/internal/api/router.go
senke 49335322b5
Some checks failed
Veza CI / Notify on failure (push) Blocked by required conditions
Veza CI / Rust (Stream Server) (push) Successful in 5m33s
Security Scan / Secret Scanning (gitleaks) (push) Failing after 1m0s
Veza CI / Backend (Go) (push) Failing after 9m37s
Veza CI / Frontend (Web) (push) Has been cancelled
E2E Playwright / e2e (full) (push) Has been cancelled
feat(legal): DMCA notice handler + admin queue + 451 playback gate (W3 Day 14)
End-to-end DMCA workflow. Public submission, admin queue, takedown
flips track to is_public=false + dmca_blocked=true, playback paths
return 451 Unavailable For Legal Reasons.

Backend
- migrations/988_dmca_notices.sql + rollback : table dmca_notices
  (id, status, claimant_*, work_description, infringing_track_id FK,
  sworn_statement_at, takedown_at, counter_notice_at, restored_at,
  audit_log JSONB, created_at, updated_at). Adds tracks.dmca_blocked
  BOOLEAN. Partial indexes for the pending queue + per-track lookup.
  Status enum constrained via CHECK.
- internal/models/dmca_notice.go + DmcaBlocked field on Track.
- internal/services/dmca_service.go : CreateNotice + ListPending +
  Takedown + Dismiss. Takedown is a single transaction that flips the
  track's flags AND appends an audit_log entry — partial state can't
  happen if the track was deleted between fetch and update.
- internal/handlers/dmca_handler.go : POST /api/v1/dmca/notice (public),
  GET /api/v1/admin/dmca/notices (paginated), POST /:id/takedown,
  POST /:id/dismiss. sworn_statement=false → 400. Conflict → 409.
  Track gone after notice → 410.
- internal/api/routes_legal.go : route registration. Admin chain :
  RequireAuth + RequireAdmin + RequireMFA (same as moderation routes).
- internal/core/track/track_hls_handler.go : both StreamTrack +
  DownloadTrack now early-return 451 when track.DmcaBlocked. Owner
  cannot bypass — only an admin restoring the notice clears the gate.
- internal/services/dmca_service_test.go : audit_log append helpers,
  malformed-JSON rejection, ordering preservation.

Frontend
- apps/web/src/features/legal/pages/DmcaNoticePage.tsx : public form
  at /legal/dmca/notice. Validates sworn-statement checkbox client-side.
  Receipt panel shows the notice ID after submission.
- apps/web/src/services/api/dmca.ts : thin client (POST /dmca/notice).
- routeConfig + lazy registry updated for the new route.
- DmcaPage now links to /legal/dmca/notice instead of saying "form
  pending".

E2E
- tests/e2e/29-dmca-notice.spec.ts : 3 tests. (1) anonymous submit
  yields 201 + pending receipt. (2) sworn_statement=false rejected
  with 400. (3) admin takedown gates playback with 451 — gated behind
  E2E_DMCA_ADMIN=1 because admin path requires MFA-bearing seed.

Acceptance (Day 14) : public submission produces a pending notice,
admin takedown blocks playback at 451. Lab-side validation pending
admin MFA seed for the e2e admin pathway.

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

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

495 lines
19 KiB
Go

package api
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"veza-backend-api/internal/config"
"veza-backend-api/internal/database"
"veza-backend-api/internal/handlers"
"veza-backend-api/internal/middleware"
"veza-backend-api/internal/repositories"
"veza-backend-api/internal/services"
chatws "veza-backend-api/internal/websocket/chat"
swaggerFiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
authcore "veza-backend-api/internal/core/auth"
)
// APIRouter gère la configuration des routes de l'API
type APIRouter struct {
db *database.Database
config *config.Config
engine *gin.Engine
logger *zap.Logger
versionManager *VersionManager // BE-SVC-019: API versioning manager
monitoringService *services.MonitoringAlertingService // INT-021: API monitoring and alerting
authService *authcore.AuthService // Set by setupAuthRoutes for admin unlock
notificationService *services.NotificationService // Shared for N1.2 Web Push
pushService *services.PushService // N1 Web Push
}
// NewAPIRouter crée une nouvelle instance de APIRouter
func NewAPIRouter(db *database.Database, cfg *config.Config) *APIRouter {
logger := zap.L()
return &APIRouter{
db: db,
config: cfg,
logger: logger,
versionManager: NewVersionManager(logger), // BE-SVC-019: Initialize version manager
}
}
// applyCSRFProtection applique le middleware CSRF à un groupe de routes protégées
// BE-SEC-004: Ensure all POST/PUT/DELETE endpoints validate CSRF tokens
// INT-AUTH-001: Fail-fast in production if Redis unavailable (CSRF requires Redis)
func (r *APIRouter) applyCSRFProtection(protectedGroup *gin.RouterGroup) {
if r.config == nil {
if r.logger != nil {
r.logger.Error("CSRF protection cannot be applied: config is nil")
}
// In production, fail-fast (but we can't check env if config is nil)
// This should not happen in normal operation, but we log it
return
}
if r.config.RedisClient == nil {
// In non-production, log warning and continue without CSRF
// Production case is handled by early validation in Setup() (audit 1.4)
if r.logger != nil {
r.logger.Warn("Redis not available - CSRF protection disabled (non-production environment)")
}
return
}
// CSRF protection active
if r.logger != nil {
r.logger.Info("CSRF protection enabled",
zap.String("environment", r.config.Env),
)
}
csrfMiddleware := middleware.NewCSRFMiddleware(r.config.RedisClient, r.logger)
// MVP: Désactiver CSRF en développement
csrfMiddleware.SetEnvironment(r.config.Env)
protectedGroup.Use(csrfMiddleware.Middleware())
}
// getUploadConfigWithEnv charge la configuration d'upload depuis l'environnement
// Cette fonction garantit que ENABLE_CLAMAV et CLAMAV_REQUIRED sont correctement appliqués
func getUploadConfigWithEnv() *services.UploadConfig {
uploadConfig := services.DefaultUploadConfig()
// Lire ENABLE_CLAMAV depuis l'environnement (défaut: true pour sécurité en production)
envValue := os.Getenv("ENABLE_CLAMAV")
zap.L().Debug("ENABLE_CLAMAV from env", zap.String("value", envValue))
clamAVEnabled := getEnvBool("ENABLE_CLAMAV", true)
zap.L().Debug("ENABLE_CLAMAV parsed", zap.Bool("value", clamAVEnabled))
uploadConfig.ClamAVEnabled = clamAVEnabled
// Lire CLAMAV_REQUIRED depuis l'environnement (défaut: true pour sécurité)
clamAVRequired := getEnvBool("CLAMAV_REQUIRED", true)
uploadConfig.ClamAVRequired = clamAVRequired
zap.L().Info("Upload config final",
zap.Bool("clamav_enabled", uploadConfig.ClamAVEnabled),
zap.Bool("clamav_required", uploadConfig.ClamAVRequired))
return uploadConfig
}
// getEnvBool récupère une variable d'environnement booléenne avec une valeur par défaut
func getEnvBool(key string, defaultValue bool) bool {
value := os.Getenv(key)
if value == "" {
zap.L().Debug("Env var undefined, using default", zap.String("key", key), zap.Bool("default", defaultValue))
return defaultValue
}
// Nettoyer la valeur (trim spaces)
value = strings.TrimSpace(value)
zap.L().Debug("Env var trimmed", zap.String("key", key), zap.String("value", value))
if boolValue, err := strconv.ParseBool(value); err == nil {
zap.L().Debug("Env var parsed", zap.String("key", key), zap.Bool("value", boolValue))
return boolValue
}
zap.L().Warn("Env var parse error, using default", zap.String("key", key), zap.String("value", value), zap.Bool("default", defaultValue))
return defaultValue
}
// Setup configure toutes les routes de l'API
func (r *APIRouter) Setup(router *gin.Engine) error {
r.engine = router
// Audit 1.4 P0: Graceful error if Redis down in production (no panic/Fatal)
if r.config != nil && r.config.Env == config.EnvProduction && r.config.RedisClient == nil {
return fmt.Errorf("CSRF protection requires Redis in production. Redis is unavailable")
}
// INT-021: Initialize monitoring and alerting service
// Initialize monitoring service if Prometheus URL is configured
prometheusURL := os.Getenv("PROMETHEUS_URL")
if prometheusURL != "" {
monitoringConfig := services.MonitoringConfig{
PrometheusURL: prometheusURL,
Logger: r.logger,
}
monitoringService, err := services.NewMonitoringAlertingService(monitoringConfig)
if err != nil {
r.logger.Warn("Failed to initialize monitoring service", zap.Error(err))
} else {
r.monitoringService = monitoringService
// Add default alert rules
for _, rule := range services.GetDefaultAlertRules() {
monitoringService.AddAlertRule(rule)
}
// Start monitoring in background
go func() {
ctx := context.Background()
if err := monitoringService.StartMonitoring(ctx, 30*time.Second); err != nil {
r.logger.Error("Monitoring service stopped", zap.Error(err))
}
}()
r.logger.Info("Monitoring and alerting service initialized", zap.String("prometheus_url", prometheusURL))
}
} else {
r.logger.Info("Monitoring service disabled (PROMETHEUS_URL not configured)")
}
// P1.1: CORS middleware MUST be first to ensure headers are always present
// Even if subsequent middlewares reject the request (panic, timeout, error),
// the CORS headers will be set, preventing intermittent CORS errors
// SECURITY: CORS configuration - use config.CORSOrigins strictly (P0-SECURITY)
// No fallback to CORSDefault() to avoid wildcard in production
// MOD-P0-001: Apply CORS middleware even if CORSOrigins is empty (strict mode - reject all origins)
// The middleware itself handles empty list correctly (rejects all origins)
if r.config != nil {
// INT-018: Validate CORS configuration before applying middleware
// In production, this will fail startup if CORS is misconfigured
if err := middleware.ValidateCORSConfiguration(r.config.CORSOrigins, r.config.Env, r.logger); err != nil {
// In production, fail startup if CORS is misconfigured
if r.config.Env == "production" {
r.logger.Fatal("CORS configuration validation failed - startup aborted", zap.Error(err))
} else {
// In development/staging, log error but continue
r.logger.Error("CORS configuration validation failed", zap.Error(err))
}
}
router.Use(middleware.CORS(r.config.CORSOrigins))
if len(r.config.CORSOrigins) == 0 {
r.logger.Warn("CORS origins not configured - strict mode enabled: ALL CORS requests will be rejected.")
}
} else {
// Fallback: if config is nil, apply CORS with empty list (strict mode)
router.Use(middleware.CORS([]string{}))
r.logger.Warn("Config is nil - CORS middleware applied in strict mode (reject all origins).")
}
// Middlewares globaux (after CORS)
router.Use(middleware.CacheHeaders(middleware.DefaultCacheHeadersConfig())) // v0.12.4: CDN cache headers
// v1.0.4: Back the maintenance flag with platform_settings.maintenance_mode
// so flipping it on one pod propagates to every other pod within ~10s.
if r.db != nil && r.db.GormDB != nil {
middleware.InitMaintenanceMode(r.db.GormDB, r.logger)
}
router.Use(middleware.MaintenanceGin()) // v0.803 ADM1-03: Maintenance mode (503 except /health, /admin)
router.Use(middleware.RequestLogger(r.logger)) // Utilisation du structured logger
router.Use(middleware.Metrics()) // Prometheus Metrics
router.Use(middleware.SentryRecover(r.logger)) // Sentry error tracking
router.Use(middleware.SecurityHeaders()) // MOD-P2-005: Security headers (HSTS, CSP, etc.)
router.Use(middleware.CCPA()) // v0.803 SEC2-06: CCPA Do Not Sell (Sec-GPC)
// v0.803 SEC2-03: HTTP audit middleware for auto-logging POST/PUT/DELETE
if r.config != nil && r.config.AuditService != nil {
router.Use(middleware.AuditMiddleware(r.config.AuditService, r.logger))
}
// INT-021: Add API monitoring middleware to track failures and trigger alerts
router.Use(middleware.APIMonitoringMiddleware(r.logger, r.monitoringService))
// MOD-P1-005: Determine if stack traces should be included in logs
// Stack traces only in dev/DEBUG mode (not in production)
// Include if: APP_ENV=development OR LOG_LEVEL=DEBUG
// MOD-P1-005: Determine if stack traces should be included in logs
// Stack traces only in dev/DEBUG mode (not in production)
includeStackTrace := r.config.Env == config.EnvDevelopment || r.config.LogLevel == "DEBUG"
router.Use(middleware.ErrorHandler(r.logger, r.config.ErrorMetrics, includeStackTrace))
router.Use(middleware.Recovery(r.logger, includeStackTrace))
router.Use(middleware.RequestID())
// Global Timeout middleware (PR-6)
// MOD-P0-003: Removed duplicate timeout middleware registration
router.Use(middleware.Timeout(r.config.HandlerTimeout))
// v0.803 SEC1-04: DDoS rate limiting (1000 req/s global, 100 req/s per-IP)
if r.config != nil && r.config.RedisClient != nil {
router.Use(middleware.DDoSRateLimitMiddleware(r.config.RedisClient))
}
// Rate limiting via config.RateLimiter si disponible, sinon utiliser SimpleRateLimiter
// Toujours actif (A04) — limites assouplies en dev via config
if r.config != nil {
if r.config.RateLimiter != nil {
router.Use(r.config.RateLimiter.RateLimitMiddleware())
} else if r.config.SimpleRateLimiter != nil {
router.Use(r.config.SimpleRateLimiter.Middleware())
}
}
// v0.12.4: Response cache for public GET endpoints (Redis-backed)
if r.config != nil && r.config.RedisClient != nil {
router.Use(middleware.ResponseCache(middleware.ResponseCacheConfig{
RedisClient: r.config.RedisClient,
Logger: r.logger,
DefaultTTL: 5 * time.Minute,
KeyPrefix: "http_cache",
EndpointTTLs: map[string]time.Duration{
"/api/v1/tracks": 15 * time.Minute,
"/api/v1/discover": 10 * time.Minute,
"/api/v1/search": 5 * time.Minute,
"/api/v1/users": 5 * time.Minute,
},
}))
}
// Swagger Documentation — disabled in production (A05)
if r.config == nil || (r.config.Env != config.EnvProduction && r.config.Env != "prod") {
swaggerHandler := func(c *gin.Context) {
if c.Param("any") == "/doc.json" {
if _, err := os.Stat("./docs/swagger.json"); err == nil {
c.File("./docs/swagger.json")
return
}
}
ginSwagger.WrapHandler(swaggerFiles.Handler)(c)
}
router.GET("/swagger/*any", swaggerHandler)
router.GET("/docs", ginSwagger.WrapHandler(swaggerFiles.Handler))
router.GET("/docs/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
}
// BE-SVC-019: API versioning endpoint (before version middleware)
router.GET("/api/versions", VersionInfoHandler(r.versionManager))
// BE-SVC-019: Apply version middleware to API routes
router.Use(VersionMiddleware(r.versionManager))
// P1.6: Health endpoint for Docker/K8s healthchecks
// Must be before other routes to avoid middleware overhead
// DUPLICATE REMOVED to fix panic.
// Routes core publiques (health, metrics, upload info)
r.setupCorePublicRoutes(router)
// Setup internal routes (both legacy and modern) before v1 group
// These need to be on the root router, not under /api/v1
r.setupInternalRoutes(router)
// Groupe API v1 (nouveau frontend React)
v1 := router.Group("/api/v1")
// v0.12.8: API key rate limiting — applied to all v1 routes
// Only affects requests authenticated via X-API-Key (JWT requests pass through)
apiKeyRateLimiter := middleware.NewAPIKeyRateLimiter(middleware.DefaultAPIKeyRateLimiterConfig())
v1.Use(apiKeyRateLimiter.Middleware())
// v0.12.8: API key scope enforcement — check read/write scopes on API key requests
if r.config != nil && r.config.AuthMiddleware != nil {
apiKeySvc := services.NewAPIKeyService(r.db.GormDB, r.logger)
v1.Use(middleware.RequireAPIKeyScope(apiKeySvc))
}
{
// Auth routes first so r.authService is set for admin unlock in setupCoreProtectedRoutes
if err := r.setupAuthRoutes(v1); err != nil {
return err
}
// Routes core protégées (sessions, uploads, audit, admin, conversations)
r.setupCoreProtectedRoutes(v1)
// Action 5.2.1.1: Validation endpoint for pre-validation
r.setupValidateRoutes(v1)
// Réactivation des routes User et Track pour Phase 1
r.setupUserRoutes(v1)
r.setupTrackRoutes(v1)
// BE-API-007: Roles management routes
r.setupRoleRoutes(v1)
// Réactivation des routes Chat pour Phase 4
r.setupChatRoutes(v1)
// v0.502: Chat WebSocket endpoint (replaces Rust chat server)
r.setupChatWebSocket(v1)
// Réactivation des routes Playlists pour Phase 5
r.setupPlaylistRoutes(v1)
// Réactivation des routes Webhooks
r.setupWebhookRoutes(v1)
// Marketplace Routes (v1.2.0)
r.setupMarketplaceRoutes(v1)
// BE-API-035: Analytics routes
r.setupAnalyticsRoutes(v1)
// Social Routes
r.setupSocialRoutes(v1)
// Feed Routes (v0.10.0 F210: tracks from followed users)
r.setupFeedRoutes(v1)
// Discover Routes (v0.10.1 F351-F355: tags, genres, browse)
r.setupDiscoverRoutes(v1)
// Inventory / Gear Routes
r.setupGearRoutes(v1)
// Queue Routes
r.setupQueueRoutes(v1)
// Developer Portal (API Keys)
r.setupDeveloperRoutes(v1)
// Live Streams Routes
r.setupLiveRoutes(v1)
// Co-listening (v0.10.7 F481)
r.setupCoListeningRoutes(v1)
// Cloud Storage Routes (v0.501 C1)
r.setupCloudRoutes(v1)
// Tag suggestions (v0.802 FM1-03)
r.setupTagRoutes(v1)
// Unified search GET /search (tracks, users, playlists)
r.setupSearchRoutes(v1)
// v0.11.2: Advanced Moderation (F411-F420)
r.setupModerationRoutes(v1)
// v0.11.3: Admin Platform Management (F421-F435)
r.setupAdminPlatformRoutes(v1)
// v1.0.9 W3 Day 14: legal/DMCA — public submission + admin queue
r.setupLegalRoutes(v1)
// v0.12.1: Subscription Plans & Management (F001-F030)
r.setupSubscriptionRoutes(v1)
// v0.12.2: Distribution to External Platforms (F501-F510)
r.setupDistributionRoutes(v1)
// v0.12.3: Formation & Éducation (F276-F305)
r.setupEducationRoutes(v1)
}
return nil
}
// setupChatWebSocket configures the chat WebSocket endpoint (v0.502)
func (r *APIRouter) setupChatWebSocket(router *gin.RouterGroup) {
chatService := services.NewChatServiceWithDB(r.config.ChatJWTSecret, r.db.GormDB, r.logger)
presenceService := chatws.NewChatPresenceService(r.config.RedisClient, r.logger)
hub := chatws.NewHub(r.logger, presenceService)
go hub.Run()
msgRepo := repositories.NewChatMessageRepository(r.db.GormDB)
readRepo := repositories.NewReadReceiptRepository(r.db.GormDB)
deliveredRepo := repositories.NewDeliveredStatusRepository(r.db.GormDB)
reactionRepo := repositories.NewReactionRepository(r.db.GormDB)
userRepo := repositories.NewGormUserRepository(r.db.GormDB)
pubsub := services.NewChatPubSubService(r.config.RedisClient, r.logger)
permissions := chatws.NewPermissionService(r.db.GormDB, r.logger)
rateLimiter := chatws.NewRateLimiter(r.config.RedisClient, r.logger)
msgHandler := chatws.NewMessageHandler(
hub, msgRepo, readRepo, deliveredRepo, reactionRepo, userRepo,
pubsub, permissions, rateLimiter, r.logger,
)
wsHandler := handlers.NewChatWebSocketHandler(chatService, hub, msgHandler, r.logger)
router.GET("/ws", wsHandler.HandleWebSocket)
// v0.10.5 F551: Inject chat hub into notification service for real-time delivery
if r.notificationService != nil {
r.notificationService.SetWSNotifier(&chatHubNotifierAdapter{hub: hub})
}
r.logger.Info("Chat WebSocket endpoint registered at /api/v1/ws")
}
// chatHubNotifierAdapter adapts chatws.Hub to services.NotificationWSNotifier (F551)
type chatHubNotifierAdapter struct {
hub *chatws.Hub
}
func (a *chatHubNotifierAdapter) NotifyUser(userID uuid.UUID, payload []byte) {
a.hub.SendToUser(userID, payload)
}
// setupChatRoutes configure les routes de chat
func (r *APIRouter) setupChatRoutes(router *gin.RouterGroup) {
// BE-API-006: Use NewChatServiceWithDB to enable stats functionality
chatService := services.NewChatServiceWithDB(r.config.ChatJWTSecret, r.db.GormDB, r.logger)
userRepo := repositories.NewGormUserRepository(r.db.GormDB)
userService := services.NewUserServiceWithDB(userRepo, r.db.GormDB)
chatHandler := handlers.NewChatHandler(chatService, userService, r.logger)
// v0.9.6: Chat reactions REST + message search
reactionRepo := repositories.NewReactionRepository(r.db.GormDB)
msgRepo := repositories.NewChatMessageRepository(r.db.GormDB)
permissions := chatws.NewPermissionService(r.db.GormDB, r.logger)
chatReactionHandler := handlers.NewChatReactionHandler(reactionRepo, msgRepo, permissions, r.logger)
chatSearchHandler := handlers.NewChatSearchHandler(msgRepo, permissions, r.logger)
chat := router.Group("/chat")
{
if r.config.AuthMiddleware != nil {
chat.Use(r.config.AuthMiddleware.RequireAuth())
// BE-SEC-004: Apply CSRF protection to all state-changing endpoints
r.applyCSRFProtection(chat)
chat.POST("/token", chatHandler.GetToken)
chat.GET("/stats", chatHandler.GetStats) // BE-API-006: Chat stats endpoint
// v0.9.6: Chat rooms REST (reactions, search)
// v0.9.7: Chat room attachments (file upload)
rooms := chat.Group("/rooms")
{
rooms.POST("/:roomId/messages/:messageId/reactions", chatReactionHandler.AddReaction)
rooms.DELETE("/:roomId/messages/:messageId/reactions", chatReactionHandler.RemoveReaction)
rooms.GET("/:roomId/messages/search", chatSearchHandler.SearchMessages)
// v0.9.7: Chat attachment upload (mp3, wav, ogg, jpg, png, pdf, max 50MB)
if r.config.S3StorageService != nil {
uploadConfig := getUploadConfigWithEnv()
chatUploadValidator, err := services.NewUploadValidator(uploadConfig, r.logger)
if err != nil {
r.logger.Warn("Chat upload validator unavailable - attachments disabled", zap.Error(err))
} else {
chatAttachmentHandler := handlers.NewChatAttachmentHandler(
r.config.S3StorageService,
permissions,
chatUploadValidator,
r.logger,
)
rooms.POST("/:roomId/attachments", chatAttachmentHandler.UploadChatAttachment)
}
}
}
}
}
}