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
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>
526 lines
21 KiB
Go
526 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"os"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/redis/go-redis/v9"
|
|
"go.uber.org/zap"
|
|
|
|
"veza-backend-api/internal/config"
|
|
trackcore "veza-backend-api/internal/core/track"
|
|
elasticsearch "veza-backend-api/internal/elasticsearch"
|
|
"veza-backend-api/internal/handlers"
|
|
"veza-backend-api/internal/middleware"
|
|
"veza-backend-api/internal/models"
|
|
"veza-backend-api/internal/repositories"
|
|
"veza-backend-api/internal/services"
|
|
)
|
|
|
|
// setupValidateRoutes configures the validation endpoint (A01: rate limited)
|
|
func (r *APIRouter) setupValidateRoutes(router *gin.RouterGroup) {
|
|
validateHandler := handlers.NewValidateHandler(r.logger)
|
|
validateGroup := router.Group("/")
|
|
if r.config != nil && r.config.EndpointLimiter != nil {
|
|
validateGroup.Use(r.config.EndpointLimiter.ValidateRateLimit())
|
|
}
|
|
validateGroup.POST("validate", validateHandler.Validate)
|
|
}
|
|
|
|
// setupInternalRoutes configure les routes internal (legacy and modern)
|
|
func (r *APIRouter) setupInternalRoutes(router *gin.Engine) {
|
|
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
|
|
// v1.0.9 W3 Day 13: wire CDN + S3. SetCDNService is a no-op when CDN_ENABLED=false.
|
|
trackService.SetS3Storage(r.config.S3StorageService, r.config.TrackStorageBackend, r.config.S3Bucket)
|
|
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,
|
|
)
|
|
|
|
streamEventsHandler := handlers.NewStreamEventsHandler(r.logger)
|
|
liveStreamRepo := repositories.NewLiveStreamRepository(r.db.GormDB)
|
|
roomRepo := repositories.NewRoomRepository(r.db.GormDB)
|
|
liveStreamService := services.NewLiveStreamService(liveStreamRepo, roomRepo)
|
|
streamEventsHandler.SetLiveStreamService(liveStreamService)
|
|
|
|
expectedKey := ""
|
|
if r.config != nil {
|
|
expectedKey = r.config.StreamServerInternalAPIKey
|
|
}
|
|
streamCallbackAuth := middleware.StreamCallbackAuth(expectedKey, r.logger)
|
|
|
|
// v0.941: Removed deprecated /internal/* routes; use /api/v1/internal/* only
|
|
v1Internal := router.Group("/api/v1/internal")
|
|
v1Internal.Use(streamCallbackAuth)
|
|
{
|
|
v1Internal.POST("/tracks/:id/stream-ready", trackHandler.HandleStreamCallback)
|
|
v1Internal.POST("/stream-events", streamEventsHandler.HandleStreamEvent)
|
|
}
|
|
}
|
|
|
|
// setupCorePublicRoutes configure les routes publiques core (health, metrics, upload info)
|
|
func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) {
|
|
var healthCheckHandler gin.HandlerFunc
|
|
var livenessHandler gin.HandlerFunc
|
|
var readinessHandler gin.HandlerFunc
|
|
var deepHealthHandler gin.HandlerFunc
|
|
|
|
if r.db != nil && r.db.GormDB != nil {
|
|
var redisClient interface{}
|
|
if r.config != nil {
|
|
redisClient = r.config.RedisClient
|
|
}
|
|
var rabbitMQEventBus interface{}
|
|
if r.config != nil {
|
|
rabbitMQEventBus = r.config.RabbitMQEventBus
|
|
}
|
|
var env string
|
|
if r.config != nil {
|
|
env = r.config.Env
|
|
}
|
|
var s3Service interface{}
|
|
var jobWorker interface{}
|
|
var emailSender interface{}
|
|
if r.config != nil {
|
|
s3Service = r.config.S3StorageService
|
|
jobWorker = r.config.JobWorker
|
|
emailSender = r.config.EmailSender
|
|
}
|
|
healthHandler := handlers.NewHealthHandlerWithServices(
|
|
r.db.GormDB,
|
|
r.logger,
|
|
redisClient,
|
|
rabbitMQEventBus,
|
|
env,
|
|
s3Service,
|
|
jobWorker,
|
|
emailSender,
|
|
)
|
|
if r.config != nil {
|
|
healthHandler.SetDeepHealthConfig(&handlers.DeepHealthConfig{
|
|
JWTSecretSet: len(r.config.JWTSecret) >= 32,
|
|
StripeConnectEnabled: r.config.StripeConnectEnabled,
|
|
PlatformFeeRate: r.config.PlatformFeeRate,
|
|
TransferRetryEnabled: r.config.TransferRetryEnabled,
|
|
})
|
|
}
|
|
healthCheckHandler = healthHandler.Check
|
|
livenessHandler = healthHandler.Liveness
|
|
readinessHandler = healthHandler.Readiness
|
|
deepHealthHandler = healthHandler.DeepHealth
|
|
} else {
|
|
healthCheckHandler = handlers.SimpleHealthCheck
|
|
livenessHandler = handlers.SimpleHealthCheck
|
|
readinessHandler = handlers.SimpleHealthCheck
|
|
deepHealthHandler = func(c *gin.Context) {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{
|
|
"success": true,
|
|
"data": gin.H{"status": "unhealthy", "message": "Database not configured"},
|
|
})
|
|
}
|
|
}
|
|
|
|
deprecationMW := middleware.DeprecationWarning(r.logger)
|
|
healthMonitoringMW := middleware.HealthCheckMonitoring(r.logger, r.monitoringService)
|
|
metricsProtection := middleware.MetricsProtection(r.logger)
|
|
|
|
router.GET("/health", deprecationMW, healthMonitoringMW, healthCheckHandler)
|
|
router.GET("/health/deep", deprecationMW, healthMonitoringMW, deepHealthHandler)
|
|
router.GET("/healthz", deprecationMW, healthMonitoringMW, livenessHandler)
|
|
router.GET("/readyz", deprecationMW, healthMonitoringMW, readinessHandler)
|
|
router.GET("/metrics", deprecationMW, metricsProtection, handlers.PrometheusMetrics())
|
|
if r.config != nil && r.config.ErrorMetrics != nil {
|
|
router.GET("/metrics/aggregated", deprecationMW, metricsProtection, handlers.AggregatedMetrics(r.config.ErrorMetrics))
|
|
}
|
|
router.GET("/system/metrics", deprecationMW, metricsProtection, handlers.SystemMetrics)
|
|
|
|
v1Public := router.Group("/api/v1")
|
|
{
|
|
v1Public.GET("/health", healthCheckHandler)
|
|
v1Public.GET("/health/deep", deepHealthHandler)
|
|
v1Public.GET("/healthz", livenessHandler)
|
|
v1Public.GET("/readyz", readinessHandler)
|
|
|
|
if r.db != nil && r.db.GormDB != nil {
|
|
var redisClient interface{}
|
|
if r.config != nil {
|
|
redisClient = r.config.RedisClient
|
|
}
|
|
chatServerURL := ""
|
|
streamServerURL := ""
|
|
if r.config != nil {
|
|
chatServerURL = r.config.ChatServerURL
|
|
streamServerURL = r.config.StreamServerURL
|
|
}
|
|
getEnv := func(key, defaultValue string) string {
|
|
if value := os.Getenv(key); value != "" {
|
|
return value
|
|
}
|
|
return defaultValue
|
|
}
|
|
version := getEnv("APP_VERSION", "v1.0.0")
|
|
gitCommit := getEnv("GIT_COMMIT", "unknown")
|
|
buildTime := getEnv("BUILD_TIME", "")
|
|
environment := ""
|
|
if r.config != nil {
|
|
environment = r.config.Env
|
|
}
|
|
statusHandler := handlers.NewStatusHandler(
|
|
r.db.GormDB,
|
|
r.logger,
|
|
redisClient,
|
|
chatServerURL,
|
|
streamServerURL,
|
|
version,
|
|
gitCommit,
|
|
buildTime,
|
|
environment,
|
|
)
|
|
v1Public.GET("/status", statusHandler.GetStatus)
|
|
}
|
|
|
|
v1Public.GET("/metrics", metricsProtection, handlers.PrometheusMetrics())
|
|
if r.config != nil && r.config.ErrorMetrics != nil {
|
|
v1Public.GET("/metrics/aggregated", metricsProtection, handlers.AggregatedMetrics(r.config.ErrorMetrics))
|
|
}
|
|
v1Public.GET("/system/metrics", metricsProtection, handlers.SystemMetrics)
|
|
|
|
if r.db != nil && r.db.GormDB != nil && r.config != nil {
|
|
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)
|
|
}
|
|
auditService := services.NewAuditService(r.db, r.logger)
|
|
trackUploadService := services.NewTrackUploadService(r.db.GormDB, r.logger)
|
|
uploadHandler := handlers.NewUploadHandler(uploadValidator, auditService, trackUploadService, r.logger, r.config.MaxConcurrentUploads)
|
|
v1Public.GET("/upload/limits", uploadHandler.GetUploadLimits())
|
|
v1Public.GET("/upload/validate-type", uploadHandler.ValidateFileType())
|
|
}
|
|
|
|
if r.config != nil {
|
|
frontendLogHandler, err := handlers.NewFrontendLogHandler(r.config, r.logger)
|
|
if err != nil {
|
|
r.logger.Warn("Failed to create frontend log handler, frontend logs will not be stored", zap.Error(err))
|
|
} else {
|
|
logsRateLimit := middleware.FrontendLogRateLimit(r.config.RedisClient)
|
|
v1Public.POST("/logs/frontend", logsRateLimit, frontendLogHandler.ReceiveLog)
|
|
r.logger.Info("Frontend logging endpoint enabled", zap.String("endpoint", "/api/v1/logs/frontend"))
|
|
}
|
|
}
|
|
|
|
// v0.803 ADM1-04: Active announcements (public)
|
|
if r.db != nil && r.db.GormDB != nil {
|
|
announcementSvc := services.NewAnnouncementService(r.db.GormDB, r.logger)
|
|
announcementHandler := handlers.NewAnnouncementHandler(announcementSvc)
|
|
v1Public.GET("/announcements/active", announcementHandler.GetActive)
|
|
}
|
|
|
|
// v1.0.9 item 1.2 — WebRTC ICE servers for the SPA. Public so the
|
|
// frontend can fetch it before the user is authenticated (the call
|
|
// surface lives in the chat tab, but the STUN/TURN bootstrap is
|
|
// page-load-time, not call-time, to minimise latency on the first
|
|
// call attempt).
|
|
if r.config != nil {
|
|
v1Public.GET("/config/webrtc", handlers.GetWebRTCConfig(r.config))
|
|
}
|
|
}
|
|
}
|
|
|
|
// setupCoreProtectedRoutes configure les routes protégées core (sessions, uploads, audit, admin, conversations)
|
|
func (r *APIRouter) setupCoreProtectedRoutes(v1 *gin.RouterGroup) {
|
|
if r.db == nil || r.db.GormDB == nil || r.config == nil {
|
|
return
|
|
}
|
|
|
|
csrfMiddleware := middleware.NewCSRFMiddleware(r.config.RedisClient, r.logger)
|
|
csrfMiddleware.SetEnvironment(r.config.Env)
|
|
csrfHandler := handlers.NewCSRFHandler(csrfMiddleware, r.logger)
|
|
if r.config.AuthMiddleware != nil {
|
|
v1.GET("/csrf-token", r.config.AuthMiddleware.OptionalAuth(), csrfHandler.GetCSRFToken())
|
|
} else {
|
|
v1.GET("/csrf-token", csrfHandler.GetCSRFToken())
|
|
}
|
|
|
|
protected := v1.Group("/")
|
|
if r.config.AuthMiddleware != nil {
|
|
protected.Use(r.config.AuthMiddleware.RequireAuth())
|
|
}
|
|
|
|
sessionService := services.NewSessionService(r.db, r.logger)
|
|
|
|
if r.config.RedisClient != nil {
|
|
protected.Use(csrfMiddleware.Middleware())
|
|
r.logger.Info("CSRF protection enabled for core protected routes",
|
|
zap.String("environment", r.config.Env),
|
|
)
|
|
} else {
|
|
r.logger.Warn("Redis not available - CSRF protection disabled (non-production environment)",
|
|
zap.String("environment", r.config.Env),
|
|
)
|
|
}
|
|
|
|
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)
|
|
}
|
|
auditService := services.NewAuditService(r.db, r.logger)
|
|
trackUploadService := services.NewTrackUploadService(r.db.GormDB, r.logger)
|
|
|
|
sessionHandler := handlers.NewSessionHandler(sessionService, auditService, r.logger)
|
|
uploadHandler := handlers.NewUploadHandler(uploadValidator, auditService, trackUploadService, r.logger, r.config.MaxConcurrentUploads)
|
|
auditHandler := handlers.NewAuditHandler(auditService, r.logger)
|
|
|
|
sessions := protected.Group("/sessions")
|
|
{
|
|
sessions.POST("/logout", sessionHandler.Logout())
|
|
sessions.POST("/logout-all", sessionHandler.LogoutAll())
|
|
sessions.POST("/logout-others", sessionHandler.LogoutOthers())
|
|
sessions.GET("", sessionHandler.GetSessions())
|
|
sessions.GET("/", sessionHandler.GetSessions())
|
|
sessions.DELETE("/:session_id", sessionHandler.RevokeSession())
|
|
sessions.GET("/stats", sessionHandler.GetSessionStats())
|
|
sessions.POST("/refresh", sessionHandler.RefreshSession())
|
|
}
|
|
|
|
uploads := protected.Group("/uploads")
|
|
{
|
|
if r.config.RedisClient != nil {
|
|
uploads.Use(middleware.UploadRateLimit(r.config.RedisClient))
|
|
}
|
|
uploads.POST("/", uploadHandler.UploadFile())
|
|
uploads.POST("/batch", uploadHandler.BatchUpload())
|
|
uploads.GET("/:id/status", uploadHandler.GetUploadStatus())
|
|
uploads.GET("/:id/progress", uploadHandler.UploadProgress())
|
|
uploads.DELETE("/:id", uploadHandler.DeleteUpload())
|
|
uploads.GET("/stats", uploadHandler.GetUploadStats())
|
|
}
|
|
|
|
// v0.803 ADM1: User report endpoint — moved to routes_moderation.go (F412 enhanced reporting)
|
|
// reportServiceForUser := services.NewReportService(r.db.GormDB, r.logger)
|
|
// reportHandlerForUser := handlers.NewReportHandler(reportServiceForUser)
|
|
|
|
// v0.971: Client-visible feature flags (e.g. WEBRTC_CALLS for CallButton)
|
|
featureFlagSvc := services.NewFeatureFlagService(r.db.GormDB, r.logger)
|
|
featureFlagHandler := handlers.NewFeatureFlagHandler(featureFlagSvc)
|
|
protected.GET("/feature-flags", featureFlagHandler.List)
|
|
|
|
audit := protected.Group("/audit")
|
|
{
|
|
audit.GET("/logs", auditHandler.SearchLogs())
|
|
audit.GET("/stats", auditHandler.GetStats())
|
|
audit.GET("/activity", auditHandler.GetUserActivity())
|
|
audit.GET("/suspicious", auditHandler.DetectSuspiciousActivity())
|
|
audit.GET("/ip/:ip", auditHandler.GetIPActivity())
|
|
audit.GET("/logs/:id", auditHandler.GetAuditLog())
|
|
audit.POST("/cleanup", auditHandler.CleanupOldLogs())
|
|
}
|
|
|
|
uploadDir := r.config.UploadDir
|
|
if uploadDir == "" {
|
|
uploadDir = "uploads/tracks"
|
|
}
|
|
trackServiceForDashboard := trackcore.NewTrackServiceWithDB(r.db, r.logger, uploadDir)
|
|
if r.config.CacheService != nil {
|
|
trackServiceForDashboard.SetCacheService(r.config.CacheService)
|
|
}
|
|
|
|
trackListFunc := func(ctx context.Context, params handlers.TrackListParamsForDashboard) ([]*models.Track, int64, error) {
|
|
return trackServiceForDashboard.ListTracks(ctx, trackcore.TrackListParams{
|
|
Page: params.Page,
|
|
Limit: params.Limit,
|
|
UserID: params.UserID,
|
|
Genre: params.Genre,
|
|
Format: params.Format,
|
|
SortBy: params.SortBy,
|
|
SortOrder: params.SortOrder,
|
|
})
|
|
}
|
|
|
|
dashboardHandler := handlers.NewDashboardHandler(auditService, trackListFunc, r.logger)
|
|
protected.GET("/dashboard", dashboardHandler.GetDashboard())
|
|
|
|
roomRepo := repositories.NewRoomRepository(r.db.GormDB)
|
|
messageRepo := repositories.NewChatMessageRepository(r.db.GormDB)
|
|
roomService := services.NewRoomService(roomRepo, messageRepo, r.logger)
|
|
roomHandler := handlers.NewRoomHandler(roomService, r.logger)
|
|
|
|
conversations := protected.Group("/conversations")
|
|
{
|
|
conversations.GET("", roomHandler.GetUserRooms)
|
|
conversations.POST("", roomHandler.CreateRoom)
|
|
conversations.GET("/join/:token", roomHandler.JoinByToken)
|
|
conversations.GET("/:id", roomHandler.GetRoom)
|
|
conversations.PUT("/:id", roomHandler.UpdateRoom)
|
|
conversations.DELETE("/:id", roomHandler.DeleteRoom)
|
|
conversations.POST("/:id/leave", roomHandler.LeaveRoom) // v0.9.7 self-leave
|
|
conversations.GET("/:id/members", roomHandler.GetMembers)
|
|
conversations.POST("/:id/members", roomHandler.AddMember)
|
|
conversations.DELETE("/:id/members/:userId", roomHandler.KickMember)
|
|
conversations.PATCH("/:id/members/:userId", roomHandler.UpdateMemberRole) // v0.9.7
|
|
conversations.POST("/:id/participants", roomHandler.AddParticipant)
|
|
conversations.DELETE("/:id/participants/:userId", roomHandler.RemoveParticipant)
|
|
conversations.POST("/:id/invitations", roomHandler.CreateInvitation)
|
|
conversations.GET("/:id/history", roomHandler.GetRoomHistory)
|
|
}
|
|
|
|
r.notificationService = services.NewNotificationService(r.db, r.logger)
|
|
vapidPublic := os.Getenv("VAPID_PUBLIC_KEY")
|
|
vapidPrivate := os.Getenv("VAPID_PRIVATE_KEY")
|
|
r.pushService = services.NewPushService(r.db.GormDB, r.logger, vapidPublic, vapidPrivate)
|
|
r.notificationService.SetPushService(r.pushService)
|
|
handlers.NewNotificationHandlers(r.notificationService, r.pushService)
|
|
notifications := protected.Group("/notifications")
|
|
{
|
|
notifications.GET("", handlers.NotificationHandlersInstance.GetNotifications)
|
|
notifications.GET("/unread-count", handlers.NotificationHandlersInstance.GetUnreadCount)
|
|
notifications.GET("/preferences", handlers.NotificationHandlersInstance.GetPreferences)
|
|
notifications.PUT("/preferences", handlers.NotificationHandlersInstance.UpdatePreferences)
|
|
notifications.POST("/push/subscribe", handlers.NotificationHandlersInstance.SubscribePush)
|
|
notifications.POST("/:id/read", handlers.NotificationHandlersInstance.MarkAsRead)
|
|
notifications.POST("/read-all", handlers.NotificationHandlersInstance.MarkAllAsRead)
|
|
notifications.DELETE("/:id", handlers.NotificationHandlersInstance.DeleteNotification)
|
|
notifications.DELETE("", handlers.NotificationHandlersInstance.DeleteAllNotifications)
|
|
}
|
|
|
|
admin := v1.Group("/admin")
|
|
{
|
|
if r.config.AuthMiddleware != nil {
|
|
admin.Use(r.config.AuthMiddleware.RequireAuth())
|
|
admin.Use(r.config.AuthMiddleware.RequireAdmin())
|
|
admin.Use(r.config.AuthMiddleware.RequireMFA()) // SFIX-001: MFA obligatoire pour admin
|
|
}
|
|
|
|
admin.GET("/audit/logs", auditHandler.SearchLogs())
|
|
admin.GET("/audit/stats", auditHandler.GetStats())
|
|
admin.GET("/audit/suspicious", auditHandler.DetectSuspiciousActivity())
|
|
|
|
// v0.803 ADM1: Moderation queue
|
|
reportService := services.NewReportService(r.db.GormDB, r.logger)
|
|
reportHandler := handlers.NewReportHandler(reportService)
|
|
admin.GET("/reports", reportHandler.ListReports)
|
|
admin.POST("/reports/:id/resolve", reportHandler.ResolveReport)
|
|
|
|
// v0.803 ADM1-03: Maintenance mode toggle — v1.0.4: persisted via
|
|
// platform_settings so a toggle on one pod affects every other pod.
|
|
admin.PUT("/maintenance", func(c *gin.Context) {
|
|
var req struct {
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "enabled is required"})
|
|
return
|
|
}
|
|
if r.db != nil && r.db.GormDB != nil {
|
|
if err := r.db.GormDB.WithContext(c.Request.Context()).Exec(
|
|
`INSERT INTO platform_settings (key, value_bool, description)
|
|
VALUES ('maintenance_mode', ?, 'When TRUE, all API requests outside the exempt list return 503.')
|
|
ON CONFLICT (key) DO UPDATE SET value_bool = EXCLUDED.value_bool, updated_at = NOW()`,
|
|
req.Enabled,
|
|
).Error; err != nil {
|
|
r.logger.Error("Failed to persist maintenance flag",
|
|
zap.Bool("enabled", req.Enabled),
|
|
zap.Error(err),
|
|
)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to persist maintenance flag"})
|
|
return
|
|
}
|
|
}
|
|
middleware.SetMaintenanceMode(req.Enabled)
|
|
c.JSON(http.StatusOK, gin.H{"maintenance_mode": req.Enabled})
|
|
})
|
|
admin.GET("/maintenance", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"maintenance_mode": middleware.MaintenanceModeEnabled()})
|
|
})
|
|
|
|
// v0.803 ADM1-04: Announcements CRUD
|
|
announcementSvc := services.NewAnnouncementService(r.db.GormDB, r.logger)
|
|
announcementHandler := handlers.NewAnnouncementHandler(announcementSvc)
|
|
admin.GET("/announcements", announcementHandler.List)
|
|
admin.POST("/announcements", announcementHandler.Create)
|
|
admin.DELETE("/announcements/:id", announcementHandler.Delete)
|
|
|
|
// v0.803 ADM1-05: Feature flags CRUD
|
|
featureFlagSvc := services.NewFeatureFlagService(r.db.GormDB, r.logger)
|
|
featureFlagHandler := handlers.NewFeatureFlagHandler(featureFlagSvc)
|
|
admin.GET("/feature-flags", featureFlagHandler.List)
|
|
admin.PUT("/feature-flags/:name", featureFlagHandler.Toggle)
|
|
|
|
// v0.701: Admin Transfer Dashboard
|
|
var adminTransferHandler *handlers.AdminTransferHandler
|
|
if r.config != nil && r.config.StripeConnectEnabled && r.config.StripeConnectSecretKey != "" {
|
|
stripeConnectSvc := services.NewStripeConnectService(r.db.GormDB, r.config.StripeConnectSecretKey, r.logger)
|
|
adminTransferHandler = handlers.NewAdminTransferHandler(r.db.GormDB, stripeConnectSvc, r.config.PlatformFeeRate, r.logger)
|
|
} else {
|
|
feeRate := 0.10
|
|
if r.config != nil {
|
|
feeRate = r.config.PlatformFeeRate
|
|
}
|
|
adminTransferHandler = handlers.NewAdminTransferHandler(r.db.GormDB, nil, feeRate, r.logger)
|
|
}
|
|
admin.GET("/transfers", adminTransferHandler.GetTransfers)
|
|
admin.POST("/transfers/:id/retry", adminTransferHandler.RetryTransfer)
|
|
|
|
// P1.5: pprof endpoints disabled in production to avoid leaking sensitive runtime info
|
|
if r.config != nil && r.config.Env != config.EnvProduction && r.config.Env != "prod" {
|
|
admin.Any("/debug/pprof/*path", gin.WrapH(http.DefaultServeMux))
|
|
r.logger.Info("pprof endpoints enabled at /api/v1/admin/debug/pprof/")
|
|
}
|
|
|
|
if r.authService != nil {
|
|
admin.POST("/auth/unlock-account", handlers.UnlockAccount(r.authService, r.logger))
|
|
}
|
|
|
|
// v0.10.2 F361: Elasticsearch reindex (admin only)
|
|
admin.POST("/search/reindex", func(c *gin.Context) {
|
|
esCfg := elasticsearch.LoadConfig()
|
|
if !esCfg.Enabled {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Elasticsearch not configured"})
|
|
return
|
|
}
|
|
esClient, err := elasticsearch.NewClient(esCfg, r.logger)
|
|
if err != nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Elasticsearch unavailable", "detail": err.Error()})
|
|
return
|
|
}
|
|
idx := elasticsearch.NewIndexer(esClient, r.db.GormDB, r.logger)
|
|
if err := idx.ReindexAll(c.Request.Context()); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Reindex failed", "detail": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "Reindex completed"})
|
|
})
|
|
}
|
|
}
|