Closes FUNCTIONAL_AUDIT.md §4 #1: WebRTC 1:1 calls had working signaling but no NAT traversal, so calls between two peers behind symmetric NAT (corporate firewalls, mobile carrier CGNAT, Incus container default networking) failed silently after the SDP exchange. Backend: - GET /api/v1/config/webrtc (public) returns {iceServers: [...]} built from WEBRTC_STUN_URLS / WEBRTC_TURN_URLS / *_USERNAME / *_CREDENTIAL env vars. Half-config (URLs without creds, or vice versa) deliberately omits the TURN block — a half-configured TURN surfaces auth errors at call time instead of falling back cleanly to STUN-only. - 4 handler tests cover the matrix. Frontend: - services/api/webrtcConfig.ts caches the config for the page lifetime and falls back to the historical hardcoded Google STUN if the fetch fails. - useWebRTC fetches at mount, hands iceServers synchronously to every RTCPeerConnection, exposes a {hasTurn, loaded} hint. - CallButton tooltip warns up-front when TURN isn't configured instead of letting calls time out silently. Ops: - infra/coturn/turnserver.conf — annotated template with the SSRF- safe denied-peer-ip ranges, prometheus exporter, TLS for TURNS, static lt-cred-mech (REST-secret rotation deferred to v1.1). - infra/coturn/README.md — Incus deploy walkthrough, smoke test via turnutils_uclient, capacity rules of thumb. - docs/ENV_VARIABLES.md gains a 13bis. WebRTC ICE servers section. Coturn deployment itself is a separate ops action — this commit lands the plumbing so the deploy can light up the path with zero code changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
521 lines
21 KiB
Go
521 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
|
|
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"})
|
|
})
|
|
}
|
|
}
|