package httpapi import ( "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/urpi/go-bananas/internal/auth" "github.com/urpi/go-bananas/internal/config" "github.com/urpi/go-bananas/internal/domain" "github.com/urpi/go-bananas/internal/store" "github.com/urpi/go-bananas/internal/tokenengine" ) // Deps are the collaborators the router needs. type Deps struct { Config config.Config Store *store.Store Cache *ThemeCache Authenticator *auth.Authenticator Log Logger Version string // Metrics is the collector registered on /metrics, or nil to disable the // endpoint entirely. Metrics *Metrics // StaticHandler serves the admin UI, or nil when no build is present. StaticHandler http.Handler // SDKHandler serves the runtime SDK bundles, or nil when no build is present. SDKHandler http.Handler // RuntimeDefaults resolves the instance-level default theme. RuntimeDefaults func() (string, domain.Appearance) } // NewRouter assembles the complete HTTP surface. // // The route tree mirrors the two-posture API split described in the package // comment: /api/v1 is authenticated and uncached, /rt/v1 is public and heavily // cacheable. Keeping them in separate subtrees makes the difference obvious to a // reviewer rather than something to be inferred per route. func NewRouter(deps Deps) http.Handler { router := chi.NewRouter() // Middleware order is deliberate and outermost-first: // request id -> recovery -> logging -> client IP -> CORS -> security // headers -> body limit -> authentication -> routing. // Recovery must wrap everything after it so a panic in any later stage is // still converted into a 500 rather than killing the process. router.Use(RequestIDMiddleware) router.Use(RecoverMiddleware(deps.Log)) router.Use(LoggingMiddleware(deps.Log)) router.Use(TrustedProxyMiddleware(deps.Config)) router.Use(CORSMiddleware(deps.Config)) router.Use(SecurityHeadersMiddleware(deps.Config)) router.Use(BodyLimitMiddleware(MaxBodyBytes)) router.Use(AuthMiddleware(deps.Authenticator)) if deps.Metrics != nil { router.Use(MetricsMiddleware(deps.Metrics)) } admin := NewAdminHandlers(deps.Store, deps.Cache, deps.Log, deps.Version) adminOnly := requireRole(domain.RoleAdmin) runtime := NewRuntimeHandlers(deps.Store, deps.Cache, deps.Config, deps.Log, deps.RuntimeDefaults) authHandlers := NewAuthHandlers(deps.Authenticator, deps.Store, deps.Log, deps.Config) preferences := NewPreferenceHandlers(deps.Store, deps.Log) health := NewHealthHandlers(deps.Store, deps.Cache, deps.Version, deps.Config) // ------------------------------------------------------- operational -- router.Get("/healthz", health.Live) router.Get("/livez", health.Live) router.Get("/readyz", health.Ready) if deps.Metrics != nil { router.Method(http.MethodGet, "/metrics", deps.Metrics.Handler()) } // ---------------------------------------------------------- runtime -- // The runtime API is rate limited separately from the admin API because its // traffic profile is completely different: many unauthenticated asset // requests from many clients rather than a handful of humans. runtimeLimiter := NewRateLimiter(deps.Config.RateLimitPerMinute, time.Minute) router.Route("/rt/v1", func(rt chi.Router) { rt.Use(RateLimitMiddleware(runtimeLimiter)) rt.Get("/manifest.json", runtime.Manifest) // The shared token contract. Separate from theme.css so that a selector has // every palette available without a request per switch, and a single-theme // consumer does not download the rest. rt.Get("/contract.css", runtime.ContractCSS) rt.Get("/contract.json", runtime.ContractJSON) rt.Get("/theme.css", runtime.ThemeCSS) rt.Get("/theme.json", runtime.ThemeJSON) // Two routes on purpose. The JSON one is what the admin UI fetches; the page // one is for a human to open and share. They used to be the same handler, which // meant the UI could not parse the default response. rt.Get("/embed", runtime.EmbedSnippet) rt.Get("/embed-page", runtime.EmbedPage) // Path-addressable equivalents, for consumers that prefer REST shapes // and for CDNs configured with path patterns rather than query strings. rt.Get("/themes/{slug}/theme.css", runtime.ThemeCSS) rt.Get("/themes/{slug}/theme.json", runtime.ThemeJSON) rt.Get("/themes/{slug}/embed", runtime.EmbedSnippet) rt.Get("/themes/{slug}/embed-page", runtime.EmbedPage) // The runtime SDKs. They are served from this origin so that a consuming // application needs no build step and no npm dependency at all. if deps.SDKHandler != nil { rt.Mount("/", deps.SDKHandler) } }) // ------------------------------------------------- session discovery -- // These two are reachable without a session, which is the entire point of them: // the UI asks who it is before it knows whether anyone is logged in, and a 401 // here would be a normal first-load state rather than an error. They are // registered before the guarded /api/v1 subtree so that the specific route wins // the match, and they expose nothing but the caller's own session. // // Everything else under /api/v1 is authenticated. Enforcing it on the subtree // rather than per route means a new endpoint is protected by default instead of // by remembering to protect it, and authorisation is then declared per route // with RequireRole, which makes an over-permissive route a visible, reviewable // mistake. router.Get(deps.Config.Path("/api/v1/auth/me"), authHandlers.Me) router.Get(deps.Config.Path("/api/v1/auth/config"), authHandlers.Config) router.Route("/api/v1", func(api chi.Router) { api.Use(RequireAuthMiddleware()) api.Get("/meta", admin.Meta) api.Get("/stats", admin.stats) // The caller's own interface preferences. Every authenticated role may // read and write these because a preference only ever affects what the // person sees; it grants nothing and is scoped to their own subject. api.Route("/me", func(me chi.Router) { me.Get("/preferences", preferences.Get) me.Put("/preferences", preferences.Update) me.Delete("/preferences", preferences.Reset) }) api.Route("/tokens", func(tokens chi.Router) { // Token tooling analyses a submitted document and changes nothing, so // any authenticated user may call it. tokens.Post("/validate", admin.validateTokens) tokens.Get("/palette", admin.generatePalette) tokens.Get("/contrast", admin.contrast) tokens.Get("/schema", tokenSchema) }) api.Route("/themes", func(themes chi.Router) { viewer := requireRole(domain.RoleViewer) themes.With(viewer).Get("/", admin.listThemes) themes.With(viewer).Get("/{slug}", admin.getTheme) themes.With(viewer).Get("/{slug}/export", admin.exportTheme) themes.With(viewer).Get("/{slug}/versions", admin.listVersions) themes.With(viewer).Get("/{slug}/versions/{version}", admin.getVersion) themes.With(viewer).Get("/{slug}/diff", admin.diffVersions) themes.With(requireRole(domain.RoleEditor)).Post("/", admin.createTheme) themes.With(requireRole(domain.RoleEditor)).Patch("/{slug}", admin.updateTheme) themes.With(requireRole(domain.RoleEditor)).Post("/{slug}/publish", admin.publishTheme) themes.With(requireRole(domain.RoleEditor)).Post("/{slug}/archive", admin.archiveTheme) themes.With(requireRole(domain.RoleEditor)).Post("/{slug}/duplicate", admin.duplicateTheme) themes.With(requireRole(domain.RoleEditor)).Post("/import", admin.importTheme) // Rolling back rewrites the current state, so it needs editor even // though it only reads from history. themes.With(requireRole(domain.RoleEditor)). Post("/{slug}/versions/{version}/rollback", admin.rollbackTheme) // Deletion is irreversible, so it is admin-only. themes.With(requireRole(domain.RoleAdmin)).Delete("/{slug}", admin.deleteTheme) }) api.Route("/admin", func(management chi.Router) { management.With(adminOnly).Get("/users", admin.listUsers) management.With(adminOnly).Get("/audit", admin.listAudit) management.With(adminOnly).Get("/settings", admin.listSettings) management.With(adminOnly).Put("/settings", admin.updateSettings) management.With(adminOnly).Post("/cache/rebuild", admin.rebuildCache) }) }) // ------------------------------------------------------- auth routes -- // The login flow lives at the top level rather than under /api/v1 because the // browser is redirected to it from outside the API. router.Get("/auth/login", authHandlers.Login) router.Get("/auth/callback", authHandlers.Callback) router.Get("/auth/logout", authHandlers.Logout) router.Post("/auth/logout", authHandlers.Logout) // ------------------------------------------------- admin SPA & docs -- mountStatic(router, deps, deps.StaticHandler) router.NotFound(func(w http.ResponseWriter, r *http.Request) { // An unmatched path under the API surfaces as JSON; anything else falls // through to the SPA handler above, so this only fires for genuine 404s. if strings.HasPrefix(r.URL.Path, deps.Config.Path("/api/")) || strings.HasPrefix(r.URL.Path, deps.Config.Path("/rt/")) || strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/rt/") { writeError(w, r, http.StatusNotFound, CodeNotFound, "no such endpoint", nil) return } writeError(w, r, http.StatusNotFound, CodeNotFound, "not found", nil) }) router.MethodNotAllowed(func(w http.ResponseWriter, r *http.Request) { writeError(w, r, http.StatusMethodNotAllowed, CodeBadRequest, "method "+r.Method+" is not allowed on this path", nil) }) return router } // mountStatic attaches the admin UI, if a build is present. // // The SPA handler is mounted at the root and last, so it acts as the fallback for // every unmatched path: an admin UI needs its own client-side routing, and a deep // link such as /themes/midnight must serve index.html rather than 404. func mountStatic(router chi.Router, deps Deps, handler http.Handler) { if handler == nil { return } // Only GET and HEAD reach the SPA; a POST to an unknown path should be a 404, // not the shell of a single-page application. router.MethodFunc(http.MethodGet, "/*", handler.ServeHTTP) router.MethodFunc(http.MethodHead, "/*", handler.ServeHTTP) } // tokenKindNames lists the token types the engine understands, so the editor can // pick a control per type without duplicating the list. func tokenKindNames() []string { kinds := []tokenengine.Kind{ tokenengine.KindColor, tokenengine.KindDimension, tokenengine.KindFontFamily, tokenengine.KindFontSize, tokenengine.KindFontWeight, tokenengine.KindLineHeight, tokenengine.KindLetterSpacing, tokenengine.KindDuration, tokenengine.KindCubicBezier, tokenengine.KindNumber, tokenengine.KindShadow, tokenengine.KindFontStyle, } out := make([]string, len(kinds)) for i, k := range kinds { out[i] = string(k) } return out } // tokenSchema serves the engine's token schema, which the editor uses to render // the right control for each token type instead of hard-coding a group list. func tokenSchema(w http.ResponseWriter, r *http.Request) { type groupSchema struct { Name string `json:"name"` Kind string `json:"kind"` Description string `json:"description"` Recommended []string `json:"recommended"` } groups := make([]groupSchema, 0, len(tokenengine.Groups)) for _, spec := range tokenengine.Groups { groups = append(groups, groupSchema{ Name: spec.Name, Kind: string(spec.Kind), Description: spec.Description, Recommended: spec.Recommended, }) } w.Header().Set("Cache-Control", "public, max-age=300") writeJSON(w, r, http.StatusOK, map[string]any{ "id": tokenengine.SchemaID, "prefix": tokenengine.VariablePrefix, "kinds": tokenKindNames(), "groups": groups, "shades": tokenengine.RampShades(), }) }