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/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) // 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.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) }) }) // 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("/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.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.Delete("/files/{fileId}", server.HandleDeleteItemFile) r.Put("/thumbnail", server.HandleSetItemThumbnail) 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) }) }) }) // Audit (read-only, viewer role) r.Route("/audit", func(r chi.Router) { 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.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) }) }) // Sheets (editor) r.Group(func(r chi.Router) { r.Use(server.RequireWritable) r.Use(server.RequireRole(auth.RoleEditor)) r.Post("/sheets/diff", server.HandleSheetDiff) }) // 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) }) }) // 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 }