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/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 deployer DeployTriggerer webhook *webhook.Handler eventBus *events.Bus encKey [32]byte localAuth *auth.LocalAuth oidcProvider *auth.OIDCProvider } // NewServer creates a new API Server with all required dependencies. func NewServer( st *store.Store, dockerClient *docker.Client, deployer DeployTriggerer, webhookHandler *webhook.Handler, eventBus *events.Bus, encKey [32]byte, ) *Server { localAuth := auth.NewLocalAuth(encKey) s := &Server{ store: st, docker: dockerClient, 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 } // 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(logging) r.Use(cors) r.Route("/api", func(r chi.Router) { // JSON content type only for API routes (not static files). r.Use(jsonContentType) // Public auth endpoints (no auth required). r.Post("/auth/login", s.login) 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)) // Config export (protected — reveals project/infra details). r.Get("/config/export", s.exportConfig) // Auth management. r.Get("/auth/me", s.currentUser) 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 endpoints. r.Get("/projects", s.listProjects) r.Post("/projects", s.createProject) r.Route("/projects/{id}", func(r chi.Router) { r.Get("/", s.getProject) 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.Get("/stages/{stage}/env", s.listStageEnv) 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.Get("/stages/{stage}/instances", s.listInstances) 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.Get("/volumes", s.listVolumes) r.Post("/volumes", s.createVolume) r.Put("/volumes/{volId}", s.updateVolume) r.Delete("/volumes/{volId}", s.deleteVolume) }) // Deploy endpoints. r.Get("/deploys", s.listDeploys) r.Get("/deploys/{id}/logs", s.streamDeployLogs) // SSE endpoint for real-time instance status and deploy events. r.Get("/events", s.streamEvents) // Quick deploy endpoints. r.Post("/deploy/inspect", s.inspectImage) r.Post("/deploy/quick", s.quickDeploy) // Registry endpoints. r.Get("/registries", s.listRegistries) r.Post("/registries", s.createRegistry) r.Route("/registries/{id}", func(r chi.Router) { r.Put("/", s.updateRegistry) r.Delete("/", s.deleteRegistry) r.Post("/test", s.testRegistry) r.Get("/tags/*", s.listRegistryTags) r.Get("/images", s.listRegistryImages) }) // Settings endpoints. r.Get("/settings", s.getSettings) r.Put("/settings", s.updateSettings) r.Get("/settings/webhook-url", s.getWebhookURL) r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret) }) }) return r }