veza/veza-backend-api/internal/api/routes_core.go

527 lines
21 KiB
Go
Raw Normal View History

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"
2026-03-05 22:03:43 +00:00
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
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 12:07:20 +00:00
// 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)
2026-03-05 18:27:34 +00:00
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)
2026-03-05 18:27:34 +00:00
router.GET("/metrics", deprecationMW, metricsProtection, handlers.PrometheusMetrics())
if r.config != nil && r.config.ErrorMetrics != nil {
2026-03-05 18:27:34 +00:00
router.GET("/metrics/aggregated", deprecationMW, metricsProtection, handlers.AggregatedMetrics(r.config.ErrorMetrics))
}
2026-03-05 18:27:34 +00:00
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)
}
2026-03-05 18:27:34 +00:00
v1Public.GET("/metrics", metricsProtection, handlers.PrometheusMetrics())
if r.config != nil && r.config.ErrorMetrics != nil {
2026-03-05 18:27:34 +00:00
v1Public.GET("/metrics/aggregated", metricsProtection, handlers.AggregatedMetrics(r.config.ErrorMetrics))
}
2026-03-05 18:27:34 +00:00
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)
}
feat(webrtc): coturn ICE config endpoint + frontend wiring + ops template (v1.0.9 item 1.2) 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>
2026-04-26 21:38:42 +00:00
// 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)
2026-03-06 17:52:08 +00:00
conversations.GET("/join/:token", roomHandler.JoinByToken)
conversations.GET("/:id", roomHandler.GetRoom)
conversations.PUT("/:id", roomHandler.UpdateRoom)
conversations.DELETE("/:id", roomHandler.DeleteRoom)
2026-03-06 17:58:37 +00:00
conversations.POST("/:id/leave", roomHandler.LeaveRoom) // v0.9.7 self-leave
2026-03-06 09:29:30 +00:00
conversations.GET("/:id/members", roomHandler.GetMembers)
conversations.POST("/:id/members", roomHandler.AddMember)
2026-03-06 17:52:08 +00:00
conversations.DELETE("/:id/members/:userId", roomHandler.KickMember)
2026-03-06 17:58:37 +00:00
conversations.PATCH("/:id/members/:userId", roomHandler.UpdateMemberRole) // v0.9.7
conversations.POST("/:id/participants", roomHandler.AddParticipant)
conversations.DELETE("/:id/participants/:userId", roomHandler.RemoveParticipant)
2026-03-06 17:52:08 +00:00
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)
fix(middleware): persist maintenance flag via platform_settings table The maintenance toggle lived in a package-level `bool` inside `middleware/maintenance.go`. Flipping it via `PUT /admin/maintenance` only updated the pod handling that request — the other N-1 pods stayed open for traffic. In practice this meant deploys-in-progress or incident playbooks silently failed to put the fleet into maintenance. New storage: * Migration `976_platform_settings.sql` adds a typed key/value table (`value_bool` / `value_text` to avoid string parsing in the hot path) and seeds `maintenance_mode=false`. Idempotent on re-run. * `middleware/maintenance.go` rewritten around a `maintenanceState` with a 10s TTL cache. `InitMaintenanceMode(db, logger)` primes the cache at boot; `MaintenanceModeEnabled()` refreshes lazily when the next request lands after the TTL. Startup `MAINTENANCE_MODE` env is still honoured for fresh pods. * `router.go` calls `InitMaintenanceMode` before applying the `MaintenanceGin()` middleware so the first request sees DB truth. * `PUT /api/v1/admin/maintenance` in `routes_core.go` now does an `INSERT ... ON CONFLICT DO UPDATE` on the table *before* the in-memory setter, so the flip survives restarts and propagates to every pod within ~10s (one TTL window). Tests: `TestMaintenanceGin_DBBacked` flips the DB row, waits past a shrunk-for-test TTL, and asserts the cache picked up the change. All four pre-existing tests preserved (`Disabled`, `Enabled_Returns503`, `HealthExempt`, `AdminExempt`). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:57:06 +00:00
// 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
}
fix(middleware): persist maintenance flag via platform_settings table The maintenance toggle lived in a package-level `bool` inside `middleware/maintenance.go`. Flipping it via `PUT /admin/maintenance` only updated the pod handling that request — the other N-1 pods stayed open for traffic. In practice this meant deploys-in-progress or incident playbooks silently failed to put the fleet into maintenance. New storage: * Migration `976_platform_settings.sql` adds a typed key/value table (`value_bool` / `value_text` to avoid string parsing in the hot path) and seeds `maintenance_mode=false`. Idempotent on re-run. * `middleware/maintenance.go` rewritten around a `maintenanceState` with a 10s TTL cache. `InitMaintenanceMode(db, logger)` primes the cache at boot; `MaintenanceModeEnabled()` refreshes lazily when the next request lands after the TTL. Startup `MAINTENANCE_MODE` env is still honoured for fresh pods. * `router.go` calls `InitMaintenanceMode` before applying the `MaintenanceGin()` middleware so the first request sees DB truth. * `PUT /api/v1/admin/maintenance` in `routes_core.go` now does an `INSERT ... ON CONFLICT DO UPDATE` on the table *before* the in-memory setter, so the flip survives restarts and propagates to every pod within ~10s (one TTL window). Tests: `TestMaintenanceGin_DBBacked` flips the DB row, waits past a shrunk-for-test TTL, and asserts the cache picked up the change. All four pre-existing tests preserved (`Disabled`, `Enabled_Returns503`, `HealthExempt`, `AdminExempt`). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:57:06 +00:00
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"})
})
}
}