package api import ( "net/http" "os" "path/filepath" "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/kindredsystems/silo/internal/auth" "github.com/rs/zerolog" ) // NewRouter creates a new chi router with all routes registered. func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r := chi.NewRouter() // Base middleware stack r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(RequestLogger(logger)) r.Use(Recoverer(logger)) // CORS: configurable origins, locked down when auth is enabled corsOrigins := []string{"*"} corsCredentials := false if server.authConfig != nil && server.authConfig.Enabled { if len(server.authConfig.CORS.AllowedOrigins) > 0 { corsOrigins = server.authConfig.CORS.AllowedOrigins } corsCredentials = true } r.Use(cors.Handler(cors.Options{ AllowedOrigins: corsOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Request-ID"}, ExposedHeaders: []string{"Link", "X-Request-ID"}, AllowCredentials: corsCredentials, MaxAge: 300, })) // Session middleware (must come before auth middleware) if server.sessions != nil { r.Use(server.sessions.LoadAndSave) } // Health endpoints (no auth) r.Get("/health", server.HandleHealth) r.Get("/ready", server.HandleReady) // Auth endpoints (no auth required) r.Get("/login", server.HandleLoginPage) r.Post("/login", server.HandleLogin) r.Post("/logout", server.HandleLogout) r.Get("/auth/oidc", server.HandleOIDCLogin) r.Get("/auth/callback", server.HandleOIDCCallback) // Public API endpoints (no auth required) r.Get("/api/modules", server.HandleGetModules) r.Get("/api/auth/config", server.HandleAuthConfig) // API routes (require auth, no CSRF — token auth instead) r.Route("/api", func(r chi.Router) { r.Use(server.RequireAuth) // SSE event stream (viewer+) r.Get("/events", server.HandleEvents) // Workflows (viewer+) r.Get("/workflows", server.HandleListWorkflows) // Auth endpoints r.Get("/auth/me", server.HandleGetCurrentUser) r.Route("/auth/tokens", func(r chi.Router) { r.Get("/", server.HandleListTokens) r.Post("/", server.HandleCreateToken) r.Delete("/{id}", server.HandleRevokeToken) }) // Presigned uploads (editor) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/uploads/presign", server.HandlePresignUpload) }) // Schemas (read: viewer, write: editor) r.Route("/schemas", func(r chi.Router) { r.Get("/", server.HandleListSchemas) r.Get("/{name}", server.HandleGetSchema) r.Get("/{name}/form", server.HandleGetFormDescriptor) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Route("/{name}/segments/{segment}/values", func(r chi.Router) { r.Post("/", server.HandleAddSchemaValue) r.Put("/{code}", server.HandleUpdateSchemaValue) r.Delete("/{code}", server.HandleDeleteSchemaValue) }) }) }) // Projects (read: viewer, write: editor) r.Route("/projects", func(r chi.Router) { r.Use(server.RequireModule("projects")) r.Get("/", server.HandleListProjects) r.Get("/{code}", server.HandleGetProject) r.Get("/{code}/items", server.HandleGetProjectItems) r.Get("/{code}/sheet.ods", server.HandleProjectSheetODS) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/", server.HandleCreateProject) r.Put("/{code}", server.HandleUpdateProject) r.Delete("/{code}", server.HandleDeleteProject) }) }) // Locations (read: viewer, write: editor) r.Route("/locations", func(r chi.Router) { r.Get("/", server.HandleListLocations) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/", server.HandleCreateLocation) }) // Wildcard routes for path-based lookup (e.g., /api/locations/lab/shelf-a/bin-3) r.Get("/*", server.HandleGetLocation) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Put("/*", server.HandleUpdateLocation) r.Delete("/*", server.HandleDeleteLocation) }) }) // Items (read: viewer, write: editor) r.Route("/items", func(r chi.Router) { r.Get("/", server.HandleListItems) r.Get("/search", server.HandleFuzzySearch) r.Get("/by-uuid/{uuid}", server.HandleGetItemByUUID) r.Get("/export.csv", server.HandleExportCSV) r.Get("/template.csv", server.HandleCSVTemplate) r.Get("/export.ods", server.HandleExportODS) r.Get("/template.ods", server.HandleODSTemplate) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/", server.HandleCreateItem) r.Post("/import", server.HandleImportCSV) r.Post("/import.ods", server.HandleImportODS) }) r.Route("/{partNumber}", func(r chi.Router) { r.Get("/", server.HandleGetItem) r.Get("/projects", server.HandleGetItemProjects) r.Get("/revisions", server.HandleListRevisions) r.Get("/revisions/compare", server.HandleCompareRevisions) r.Get("/revisions/{revision}", server.HandleGetRevision) r.Get("/files", server.HandleListItemFiles) r.Get("/files/{fileId}/download", server.HandleDownloadItemFile) r.Get("/file", server.HandleDownloadLatestFile) r.Get("/file/{revision}", server.HandleDownloadFile) r.Get("/bom", server.HandleGetBOM) r.Get("/bom/expanded", server.HandleGetExpandedBOM) r.Get("/bom/flat", server.HandleGetFlatBOM) r.Get("/bom/cost", server.HandleGetBOMCost) r.Get("/bom/where-used", server.HandleGetWhereUsed) r.Get("/bom/export.csv", server.HandleExportBOMCSV) r.Get("/bom/export.ods", server.HandleExportBOMODS) r.Get("/metadata", server.HandleGetMetadata) r.Get("/dependencies", server.HandleGetDependencies) r.Get("/dependencies/resolve", server.HandleResolveDependencies) r.Get("/macros", server.HandleGetMacros) r.Get("/macros/{filename}", server.HandleGetMacro) r.Get("/approvals", server.HandleGetApprovals) r.Get("/solver/results", server.HandleGetSolverResults) // DAG (gated by dag module) r.Route("/dag", func(r chi.Router) { r.Use(server.RequireModule("dag")) r.Get("/", server.HandleGetDAG) r.Get("/forward-cone/{nodeKey}", server.HandleGetForwardCone) r.Get("/dirty", server.HandleGetDirtySubgraph) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Put("/", server.HandleSyncDAG) r.Post("/mark-dirty/{nodeKey}", server.HandleMarkDirty) }) }) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Put("/", server.HandleUpdateItem) r.Delete("/", server.HandleDeleteItem) r.Post("/projects", server.HandleAddItemProjects) r.Delete("/projects/{code}", server.HandleRemoveItemProject) r.Post("/revisions", server.HandleCreateRevision) r.Patch("/revisions/{revision}", server.HandleUpdateRevision) r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision) r.Post("/file", server.HandleUploadFile) r.Post("/files", server.HandleAssociateItemFile) r.Post("/files/upload", server.HandleUploadItemFile) r.Delete("/files/{fileId}", server.HandleDeleteItemFile) r.Put("/thumbnail", server.HandleSetItemThumbnail) r.Post("/thumbnail/upload", server.HandleUploadItemThumbnail) r.Post("/bom", server.HandleAddBOMEntry) r.Post("/bom/import", server.HandleImportBOMCSV) r.Post("/bom/merge", server.HandleMergeBOM) r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry) r.Delete("/bom/{childPartNumber}", server.HandleDeleteBOMEntry) r.Put("/metadata", server.HandleUpdateMetadata) r.Patch("/metadata/lifecycle", server.HandleUpdateLifecycle) r.Patch("/metadata/tags", server.HandleUpdateTags) r.Post("/approvals", server.HandleCreateApproval) r.Post("/approvals/{id}/sign", server.HandleSignApproval) }) }) }) // Audit (read-only, viewer role) r.Route("/audit", func(r chi.Router) { r.Use(server.RequireModule("audit")) r.Get("/completeness", server.HandleAuditCompleteness) r.Get("/completeness/{partNumber}", server.HandleAuditItemDetail) }) // Integrations (read: viewer, write: editor) r.Route("/integrations/odoo", func(r chi.Router) { r.Use(server.RequireModule("odoo")) r.Get("/config", server.HandleGetOdooConfig) r.Get("/sync-log", server.HandleGetOdooSyncLog) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Put("/config", server.HandleUpdateOdooConfig) r.Post("/test-connection", server.HandleTestOdooConnection) r.Post("/sync/push/{partNumber}", server.HandleOdooPush) r.Post("/sync/pull/{odooId}", server.HandleOdooPull) }) }) // Solver (gated by solver module) r.Route("/solver", func(r chi.Router) { r.Use(server.RequireModule("solver")) r.Get("/solvers", server.HandleGetSolverRegistry) r.Get("/jobs", server.HandleListSolverJobs) r.Get("/jobs/{jobID}", server.HandleGetSolverJob) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/jobs", server.HandleSubmitSolverJob) r.Post("/jobs/{jobID}/cancel", server.HandleCancelSolverJob) }) }) // Sheets (editor) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/sheets/diff", server.HandleSheetDiff) }) // Jobs (read: viewer, write: editor) r.Route("/jobs", func(r chi.Router) { r.Use(server.RequireModule("jobs")) r.Get("/", server.HandleListJobs) r.Get("/{jobID}", server.HandleGetJob) r.Get("/{jobID}/logs", server.HandleGetJobLogs) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/", server.HandleCreateJob) r.Post("/{jobID}/cancel", server.HandleCancelJob) }) }) // Job definitions (read: viewer, reload: admin) r.Route("/job-definitions", func(r chi.Router) { r.Use(server.RequireModule("jobs")) r.Get("/", server.HandleListJobDefinitions) r.Get("/{name}", server.HandleGetJobDefinition) r.Group(func(r chi.Router) { r.Use(server.RequireRole(auth.RoleAdmin)) r.Post("/reload", server.HandleReloadJobDefinitions) }) }) // Runners (admin) r.Route("/runners", func(r chi.Router) { r.Use(server.RequireModule("jobs")) r.Use(server.RequireRole(auth.RoleAdmin)) r.Get("/", server.HandleListRunners) r.Post("/", server.HandleRegisterRunner) r.Delete("/{runnerID}", server.HandleDeleteRunner) }) // Part number generation (editor) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/generate-part-number", server.HandleGeneratePartNumber) }) // Admin settings (admin only) r.Route("/admin/settings", func(r chi.Router) { r.Use(server.RequireRole(auth.RoleAdmin)) r.Get("/", server.HandleGetAllSettings) r.Get("/{module}", server.HandleGetModuleSettings) r.Put("/{module}", server.HandleUpdateModuleSettings) r.Post("/{module}/test", server.HandleTestModuleConnectivity) }) }) // Runner-facing API (runner token auth, not user auth) r.Route("/api/runner", func(r chi.Router) { r.Use(server.RequireModule("jobs")) r.Use(server.RequireRunnerAuth) r.Post("/heartbeat", server.HandleRunnerHeartbeat) r.Post("/claim", server.HandleRunnerClaim) r.Post("/jobs/{jobID}/start", server.HandleRunnerStartJob) r.Put("/jobs/{jobID}/progress", server.HandleRunnerUpdateProgress) r.Post("/jobs/{jobID}/complete", server.HandleRunnerCompleteJob) r.Post("/jobs/{jobID}/fail", server.HandleRunnerFailJob) r.Post("/jobs/{jobID}/log", server.HandleRunnerAppendLog) r.Put("/jobs/{jobID}/dag", server.HandleRunnerSyncDAG) }) // React SPA — serve from web/dist at root, fallback to index.html if info, err := os.Stat("web/dist"); err == nil && info.IsDir() { spa := http.FileServerFS(os.DirFS("web/dist")) r.NotFound(func(w http.ResponseWriter, req *http.Request) { // Try to serve a static file first path := strings.TrimPrefix(req.URL.Path, "/") if f, err := os.Open(filepath.Join("web/dist", path)); err == nil { f.Close() spa.ServeHTTP(w, req) return } // Otherwise serve index.html for SPA client-side routing http.ServeFileFS(w, req, os.DirFS("web/dist"), "index.html") }) } return r }