go-bananas/internal/httpapi/router.go

281 lines
12 KiB
Go
Raw Normal View History

2026-10-07 15:11:35 +02:00
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(),
})
}