initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
280
internal/httpapi/router.go
Normal file
280
internal/httpapi/router.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
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(),
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue