package api import ( "context" "log/slog" "github.com/go-chi/chi/v5" "github.com/alexei/docker-watcher/internal/auth" "github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/docker" "github.com/alexei/docker-watcher/internal/events" "github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/proxy" "github.com/alexei/docker-watcher/internal/stale" "github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/webhook" ) // Server holds all dependencies for the API layer. type Server struct { store *store.Store docker *docker.Client npm *npm.Client deployer DeployTriggerer webhook *webhook.Handler eventBus *events.Bus encKey [32]byte localAuth *auth.LocalAuth oidcProvider *auth.OIDCProvider staleScanner *stale.Scanner proxyManager *proxy.Manager } // NewServer creates a new API Server with all required dependencies. func NewServer( st *store.Store, dockerClient *docker.Client, npmClient *npm.Client, deployer DeployTriggerer, webhookHandler *webhook.Handler, eventBus *events.Bus, encKey [32]byte, ) *Server { localAuth := auth.NewLocalAuth(encKey) s := &Server{ store: st, docker: dockerClient, npm: npmClient, deployer: deployer, webhook: webhookHandler, eventBus: eventBus, encKey: encKey, localAuth: localAuth, } // Try to initialize OIDC provider from stored settings. authSettings, err := st.GetAuthSettings() if err == nil && authSettings.AuthMode == "oidc" && authSettings.OIDCIssuerURL != "" { s.initOIDCProvider(context.Background(), authSettings) } return s } // SetStaleScanner sets the stale scanner on the server. // Called after both the API server and scanner are initialized. func (s *Server) SetStaleScanner(scanner *stale.Scanner) { s.staleScanner = scanner } // SetProxyManager sets the proxy manager on the server. // Called after both the API server and proxy manager are initialized. func (s *Server) SetProxyManager(pm *proxy.Manager) { s.proxyManager = pm } // initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal. func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) { // Decrypt the OIDC client secret if it's encrypted. clientSecret := as.OIDCClientSecret if clientSecret != "" { if decrypted, err := crypto.Decrypt(s.encKey, clientSecret); err == nil { clientSecret = decrypted } // If decrypt fails, assume it's already plaintext (migration scenario). } provider, err := auth.NewOIDCProvider(ctx, auth.OIDCConfig{ IssuerURL: as.OIDCIssuerURL, ClientID: as.OIDCClientID, ClientSecret: clientSecret, RedirectURL: as.OIDCRedirectURL, }) if err != nil { slog.Warn("failed to initialize OIDC provider", "error", err) return } s.oidcProvider = provider slog.Info("OIDC provider initialized", "issuer", as.OIDCIssuerURL) } // Router returns a chi router with all API routes mounted. func (s *Server) Router() chi.Router { r := chi.NewRouter() // Global middleware. r.Use(recovery) r.Use(securityHeaders) r.Use(logging) r.Use(cors) loginLimiter := newRateLimiter() r.Route("/api", func(r chi.Router) { // JSON content type and body size limit for API routes. r.Use(jsonContentType) r.Use(limitBody) // Public auth endpoints (no auth required). r.Post("/auth/login", s.rateLimitedLogin(loginLimiter)) r.Get("/auth/oidc/login", s.oidcLogin) r.Get("/auth/oidc/callback", s.oidcCallback) // Webhook handler (uses its own secret-based auth). r.Mount("/webhook", s.webhook.Route()) // Protected routes: require valid JWT. r.Group(func(r chi.Router) { r.Use(auth.Middleware(s.localAuth)) // Read-only endpoints (any authenticated user). r.Get("/health", s.getHealth) r.Get("/auth/me", s.currentUser) r.Get("/projects", s.listProjects) r.Route("/projects/{id}", func(r chi.Router) { r.Get("/", s.getProject) r.Get("/stages/{stage}/env", s.listStageEnv) r.Get("/stages/{stage}/instances", s.listInstances) r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats) r.Get("/volumes", s.listVolumes) r.Get("/volumes/{volId}/browse", s.browseVolume) r.Get("/volumes/{volId}/download", s.downloadVolume) // Admin-only project mutations. r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) r.Put("/", s.updateProject) r.Delete("/", s.deleteProject) // Stage endpoints. r.Post("/stages", s.createStage) r.Put("/stages/{stage}", s.updateStage) r.Delete("/stages/{stage}", s.deleteStage) // Stage env override endpoints. r.Post("/stages/{stage}/env", s.createStageEnv) r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv) r.Delete("/stages/{stage}/env/{envId}", s.deleteStageEnv) // Instance endpoints. r.Post("/stages/{stage}/instances", s.deployInstance) r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance) // Instance control endpoints. r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance) r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance) r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance) // Volume endpoints. r.Post("/volumes", s.createVolume) r.Put("/volumes/{volId}", s.updateVolume) r.Delete("/volumes/{volId}", s.deleteVolume) r.Post("/volumes/{volId}/upload", s.uploadToVolume) }) }) r.Get("/deploys", s.listDeploys) r.Get("/deploys/{id}/logs", s.streamDeployLogs) r.Get("/events", s.streamEvents) r.Get("/events/log", s.listEventLog) r.Get("/events/log/stats", s.getEventLogStats) r.Get("/registries", s.listRegistries) r.Route("/registries/{id}", func(r chi.Router) { r.Get("/tags/*", s.listRegistryTags) r.Get("/images", s.listRegistryImages) // Admin-only registry mutations. r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) r.Put("/", s.updateRegistry) r.Delete("/", s.deleteRegistry) r.Post("/test", s.testRegistry) }) }) r.Get("/settings", s.getSettings) r.Get("/settings/npm-certificates", s.listNpmCertificates) // Volume scope metadata (read-only). r.Get("/volumes/scopes", s.listVolumeScopes) // Stale container endpoints (read). r.Get("/containers/stale", s.listStaleContainers) // Proxy endpoints (read-only for any authenticated user). r.Get("/proxies", s.listProxies) r.Get("/proxies/all", s.listAllProxies) r.Route("/proxies/{id}", func(r chi.Router) { r.Get("/", s.getProxy) // Admin-only proxy mutations. r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) r.Put("/", s.updateProxy) r.Delete("/", s.deleteProxy) }) }) // Admin-only routes: require admin role. r.Group(func(r chi.Router) { r.Use(auth.AdminOnly) // Config export (reveals project/infra details). r.Get("/config/export", s.exportConfig) // Auth management. r.Get("/auth/settings", s.getAuthSettings) r.Put("/auth/settings", s.updateAuthSettings) r.Get("/auth/users", s.listUsers) r.Post("/auth/users", s.createUser) r.Delete("/auth/users/{uid}", s.deleteUser) // Project creation. r.Post("/projects", s.createProject) // Quick deploy endpoints. r.Post("/deploy/inspect", s.inspectImage) r.Post("/deploy/quick", s.quickDeploy) // Registry creation. r.Post("/registries", s.createRegistry) // Proxy mutation endpoints. r.Post("/proxies/validate", s.validateProxy) r.Post("/proxies", s.createProxy) // Stale container cleanup endpoints. // Bulk route must be registered before parameterized route. r.Post("/containers/stale/cleanup", s.bulkCleanupStaleContainers) r.Post("/containers/stale/{id}/cleanup", s.cleanupStaleContainer) // Settings endpoints. r.Put("/settings", s.updateSettings) r.Get("/settings/webhook-url", s.getWebhookURL) r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret) }) }) }) return r }