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

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

931
internal/httpapi/admin.go Normal file
View file

@ -0,0 +1,931 @@
package httpapi
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// AdminHandlers serves the authenticated administrative API.
type AdminHandlers struct {
store *store.Store
cache *ThemeCache
log Logger
// sessionSecretCookieName is used when reporting session state.
version string
started time.Time
}
// NewAdminHandlers builds the admin handler set.
func NewAdminHandlers(st *store.Store, cache *ThemeCache, log Logger, version string) *AdminHandlers {
return &AdminHandlers{
store: st,
cache: cache,
log: log,
version: version,
started: time.Now(),
}
}
// ------------------------------------------------------------- meta & auth --
// Meta returns build and instance information the UI displays.
func (h *AdminHandlers) Meta(w http.ResponseWriter, r *http.Request) {
settings, err := h.store.AllSettings(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{
"version": h.version,
"uptime": time.Since(h.started).Round(time.Second).String(),
"environment": settings["environment"].Value,
"settings": settings,
})
}
// listThemes returns a page of themes.
func (h *AdminHandlers) listThemes(w http.ResponseWriter, r *http.Request) {
q := domain.ThemeQuery{
Search: strings.TrimSpace(r.URL.Query().Get("q")),
Status: domain.ThemeStatus(strings.TrimSpace(r.URL.Query().Get("status"))),
Tag: strings.TrimSpace(r.URL.Query().Get("tag")),
Limit: queryInt(r, "limit", 50, 1, 200),
Offset: queryInt(r, "offset", 0, 0, 1<<20),
Sort: r.URL.Query().Get("sort"),
Order: r.URL.Query().Get("order"),
}
if q.Status != "" && !q.Status.Valid() {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"status must be one of draft, published, archived", nil)
return
}
if raw := r.URL.Query().Get("system"); raw != "" {
value := raw == "true" || raw == "1"
q.System = &value
}
page, err := h.store.List(r.Context(), q)
if err != nil {
writeProblem(w, r, err)
return
}
// A listing does not need token documents; they are the bulk of each row.
writeJSON(w, r, http.StatusOK, map[string]any{
"items": page.Items,
"total": page.Total,
"limit": page.Limit,
"offset": page.Offset,
})
}
// getTheme returns one theme with its full token document.
func (h *AdminHandlers) getTheme(w http.ResponseWriter, r *http.Request) {
theme, err := h.store.Get(r.Context(), chi.URLParam(r, "slug"))
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, theme)
}
// themeMutation is the request body for create and update.
type themeMutation struct {
Slug *string `json:"slug"`
Name *string `json:"name"`
Description *string `json:"description"`
Status *string `json:"status"`
DefaultAppearance *string `json:"defaultAppearance"`
Tokens *domain.TokenDoc `json:"tokens"`
Meta *domain.ThemeMeta `json:"meta"`
Message string `json:"message"`
// BaseVersion enables optimistic concurrency: the write is rejected when the
// theme has moved on since the editor loaded it, which stops two people
// editing the same theme from silently overwriting each other.
BaseVersion int64 `json:"baseVersion"`
}
// createTheme creates a theme.
func (h *AdminHandlers) createTheme(w http.ResponseWriter, r *http.Request) {
var body themeMutation
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
if body.Name == nil || strings.TrimSpace(*body.Name) == "" {
writeError(w, r, http.StatusBadRequest, CodeValidation, "a theme name is required",
[]FieldError{{Field: "name", Message: "must not be empty"}})
return
}
if body.Slug == nil || strings.TrimSpace(*body.Slug) == "" {
writeError(w, r, http.StatusBadRequest, CodeValidation, "a theme slug is required",
[]FieldError{{Field: "slug", Message: "must not be empty"}})
return
}
if err := domain.ValidateSlug(*body.Slug); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(),
[]FieldError{{Field: "slug", Message: err.Error()}})
return
}
if _, reserved := domain.ReservedSlugs[*body.Slug]; reserved {
writeError(w, r, http.StatusBadRequest, CodeValidation,
fmt.Sprintf("slug %q is reserved by the runtime API", *body.Slug),
[]FieldError{{Field: "slug", Message: "reserved"}})
return
}
theme := &domain.Theme{
Slug: strings.TrimSpace(*body.Slug),
Name: strings.TrimSpace(*body.Name),
Status: domain.StatusDraft,
Tokens: domain.TokenDoc{},
DefaultAppearance: domain.AppearanceLight,
CreatedBy: mustIdentity(r.Context()).Subject,
UpdatedBy: mustIdentity(r.Context()).Subject,
}
if body.Description != nil {
theme.Description = *body.Description
}
if body.Status != nil {
theme.Status = domain.ThemeStatus(*body.Status)
}
if body.DefaultAppearance != nil {
theme.DefaultAppearance = domain.Appearance(*body.DefaultAppearance)
}
if body.Tokens != nil {
theme.Tokens = *body.Tokens
}
if body.Meta != nil {
theme.Meta = *body.Meta
}
// Publishing requires a valid token document; a draft does not, so that a
// theme can be built up incrementally without tripping validation.
if theme.Status == domain.StatusPublished {
if result := tokenengine.Validate(theme.Tokens); !result.Valid {
writeError(w, r, http.StatusBadRequest, CodeValidation,
"the theme cannot be published because its tokens are invalid",
issuesToFieldErrors(result.Errors()))
return
}
}
if err := h.store.Create(r.Context(), theme, body.Message); err != nil {
writeProblem(w, r, err)
return
}
created, err := h.store.Get(r.Context(), theme.Slug)
if err != nil {
writeProblem(w, r, err)
return
}
h.audit(r, domain.ActionThemeCreate, "theme", created.Slug, "created "+created.Slug, nil)
writeJSON(w, r, http.StatusCreated, created)
}
// updateTheme applies a partial update.
func (h *AdminHandlers) updateTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
var body themeMutation
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
current, err := h.store.Get(r.Context(), slug)
if err != nil {
writeProblem(w, r, err)
return
}
if body.BaseVersion > 0 && current.Version != body.BaseVersion {
writeError(w, r, http.StatusConflict, CodeConflict,
fmt.Sprintf("this theme has changed since you loaded it (you have version %d, the current version is %d); reload before saving",
body.BaseVersion, current.Version), nil)
return
}
fields := store.UpdateFields{Message: body.Message, Editor: mustIdentity(r.Context()).Subject}
fields.Slug = body.Slug
fields.Name = body.Name
fields.Description = body.Description
if body.Status != nil {
status := domain.ThemeStatus(*body.Status)
fields.Status = &status
}
if body.DefaultAppearance != nil {
appearance := domain.Appearance(*body.DefaultAppearance)
fields.DefaultAppearance = &appearance
}
fields.Tokens = body.Tokens
fields.Meta = body.Meta
// Validate the resulting document before it can become a published theme.
// Merging happens in the store, so the check is performed on a projection of
// the current theme with the request applied.
projected := current.Clone()
if fields.Slug != nil {
projected.Slug = *fields.Slug
}
if fields.Name != nil {
projected.Name = *fields.Name
}
if fields.Description != nil {
projected.Description = *fields.Description
}
if fields.Status != nil {
projected.Status = *fields.Status
}
if fields.DefaultAppearance != nil {
projected.DefaultAppearance = *fields.DefaultAppearance
}
if fields.Tokens != nil {
projected.Tokens = *fields.Tokens
}
if projected.Status == domain.StatusPublished {
if result := tokenengine.Validate(projected.Tokens); !result.Valid {
writeError(w, r, http.StatusBadRequest, CodeValidation,
"the theme cannot be published because its tokens are invalid",
issuesToFieldErrors(result.Errors()))
return
}
}
updated, err := h.store.Update(r.Context(), slug, fields)
if err != nil {
writeProblem(w, r, err)
return
}
// A write invalidates the rendered artefacts; the next request re-renders.
h.cache.Invalidate(updated.Slug)
h.cache.Invalidate(slug)
h.audit(r, domain.ActionThemeUpdate, "theme", updated.Slug,
"updated "+updated.Slug+" to version "+strconv.FormatInt(updated.Version, 10), nil)
writeJSON(w, r, http.StatusOK, updated)
}
// deleteTheme removes a theme.
func (h *AdminHandlers) deleteTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if err := h.store.Delete(r.Context(), slug); err != nil {
writeProblem(w, r, err)
return
}
h.cache.Invalidate(slug)
h.audit(r, domain.ActionThemeDelete, "theme", slug, "deleted "+slug, nil)
w.WriteHeader(http.StatusNoContent)
}
// duplicateTheme copies a theme.
func (h *AdminHandlers) duplicateTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
var body struct {
Slug string `json:"slug"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
if strings.TrimSpace(body.Slug) == "" {
writeError(w, r, http.StatusBadRequest, CodeValidation,
"a slug for the copy is required",
[]FieldError{{Field: "slug", Message: "must not be empty"}})
return
}
copied, err := h.store.Duplicate(r.Context(), slug, strings.TrimSpace(body.Slug), mustIdentity(r.Context()).Subject)
if err != nil {
writeProblem(w, r, err)
return
}
h.audit(r, domain.ActionThemeDuplicate, "theme", copied.Slug,
"duplicated "+slug+" to "+copied.Slug, nil)
writeJSON(w, r, http.StatusCreated, copied)
}
// publishTheme transitions a theme's status.
func (h *AdminHandlers) publishTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
h.setStatus(w, r, slug, domain.StatusPublished, domain.ActionThemePublish)
}
// archiveTheme transitions a theme to archived.
func (h *AdminHandlers) archiveTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
h.setStatus(w, r, slug, domain.StatusArchived, domain.ActionThemeArchive)
}
// setStatus is the shared implementation behind the publish and archive routes.
func (h *AdminHandlers) setStatus(w http.ResponseWriter, r *http.Request, slug string, status domain.ThemeStatus, action domain.AuditAction) {
current, err := h.store.Get(r.Context(), slug)
if err != nil {
writeProblem(w, r, err)
return
}
if status == domain.StatusPublished {
if result := tokenengine.Validate(current.Tokens); !result.Valid {
writeError(w, r, http.StatusBadRequest, CodeValidation,
"the theme cannot be published because its tokens are invalid",
issuesToFieldErrors(result.Errors()))
return
}
}
message := "published " + slug
if status == domain.StatusArchived {
message = "archived " + slug
}
updated, err := h.store.Update(r.Context(), slug, store.UpdateFields{
Status: &status,
Message: message,
Editor: mustIdentity(r.Context()).Subject,
})
if err != nil {
writeProblem(w, r, err)
return
}
h.cache.Invalidate(slug)
h.audit(r, action, "theme", slug, message, map[string]any{"version": updated.Version})
writeJSON(w, r, http.StatusOK, updated)
}
// ---------------------------------------------------------------- history --
// listVersions returns a theme's history.
func (h *AdminHandlers) listVersions(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
versions, err := h.store.Versions(r.Context(), slug, queryInt(r, "limit", 50, 1, 200))
if err != nil {
writeProblem(w, r, err)
return
}
// History is only useful if it does not carry a full copy of every token
// document, so the summary omits them.
summaries := make([]map[string]any, 0, len(versions))
for _, v := range versions {
summaries = append(summaries, map[string]any{
"version": v.Version,
"name": v.Name,
"description": v.Description,
"status": v.Status,
"message": v.Message,
"author": v.Author,
"createdAt": v.CreatedAt,
})
}
writeJSON(w, r, http.StatusOK, map[string]any{"items": summaries})
}
// getVersion returns one historical snapshot including its tokens.
func (h *AdminHandlers) getVersion(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
version, err := versionParam(r)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil)
return
}
snapshot, err := h.store.Version(r.Context(), slug, version)
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, snapshot)
}
// rollbackTheme restores a historical snapshot as a new version.
func (h *AdminHandlers) rollbackTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
version, err := versionParam(r)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil)
return
}
restored, err := h.store.Rollback(r.Context(), slug, version, mustIdentity(r.Context()).Subject)
if err != nil {
writeProblem(w, r, err)
return
}
h.cache.Invalidate(slug)
h.audit(r, domain.ActionThemeRollback, "theme", slug,
"rolled "+slug+" back to version "+strconv.FormatInt(version, 10), nil)
writeJSON(w, r, http.StatusOK, restored)
}
// diffVersions compares two snapshots.
func (h *AdminHandlers) diffVersions(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
from, err := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "from must be an integer version", nil)
return
}
to, err := strconv.ParseInt(r.URL.Query().Get("to"), 10, 64)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "to must be an integer version", nil)
return
}
diff, err := h.store.DiffVersions(r.Context(), slug, from, to)
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, diff)
}
// versionParam reads the {version} route parameter.
func versionParam(r *http.Request) (int64, error) {
raw := chi.URLParam(r, "version")
version, err := strconv.ParseInt(raw, 10, 64)
if err != nil || version < 1 {
return 0, fmt.Errorf("version must be a positive integer, got %q", raw)
}
return version, nil
}
// --------------------------------------------------------- import/export --
// exportTheme serves the portable token document.
//
// Import and export are exact inverses, which is what makes themes portable
// between environments and makes a migration a file copy rather than a project.
func (h *AdminHandlers) exportTheme(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
theme, err := h.store.Get(r.Context(), slug)
if err != nil {
writeProblem(w, r, err)
return
}
document := tokenengine.ExportDocument(theme)
payload := map[string]any{
"$schema": tokenengine.SchemaID,
"slug": theme.Slug,
"name": theme.Name,
"status": theme.Status,
"version": theme.Version,
"tokens": document,
"meta": theme.Meta,
"exportedAt": time.Now().UTC().Format(domain.TimeFormat),
"exportedBy": mustIdentity(r.Context()).Subject,
}
w.Header().Set("Content-Disposition",
fmt.Sprintf("attachment; filename=%q", theme.Slug+".theme.json"))
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, r, http.StatusOK, payload)
}
// importRequest is the body accepted by the import route.
type importRequest struct {
// Slug overrides the slug in the document. Required when the document has
// no slug of its own, and used to create a copy when it does.
Slug string `json:"slug"`
AsDraft bool `json:"asDraft"`
Overwrite bool `json:"overwrite"`
Name string `json:"name"`
Tokens *domain.TokenDoc `json:"tokens"`
Meta *domain.ThemeMeta `json:"meta"`
Message string `json:"message"`
}
// importTheme creates or replaces a theme from a portable document.
func (h *AdminHandlers) importTheme(w http.ResponseWriter, r *http.Request) {
var body importRequest
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
if body.Tokens == nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, "a tokens document is required",
[]FieldError{{Field: "tokens", Message: "must not be empty"}})
return
}
if strings.TrimSpace(body.Slug) == "" {
writeError(w, r, http.StatusBadRequest, CodeValidation, "a slug is required",
[]FieldError{{Field: "slug", Message: "must not be empty"}})
return
}
if err := domain.ValidateSlug(body.Slug); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(),
[]FieldError{{Field: "slug", Message: err.Error()}})
return
}
result := tokenengine.Validate(*body.Tokens)
if !result.Valid {
writeError(w, r, http.StatusBadRequest, CodeValidation, "the imported tokens are invalid",
issuesToFieldErrors(result.Errors()))
return
}
identity := mustIdentity(r.Context()).Subject
status := domain.StatusPublished
if body.AsDraft {
status = domain.StatusDraft
}
_, err := h.store.Get(r.Context(), body.Slug)
switch {
case err == nil && !body.Overwrite:
writeError(w, r, http.StatusConflict, CodeConflict,
fmt.Sprintf("a theme with slug %q already exists; set overwrite to replace it", body.Slug), nil)
return
case err == nil && body.Overwrite:
fields := store.UpdateFields{
Tokens: body.Tokens,
Message: defaultString(body.Message, "imported"),
Editor: identity,
Status: &status,
}
if body.Name != "" {
fields.Name = &body.Name
}
if body.Meta != nil {
fields.Meta = body.Meta
}
updated, err := h.store.Update(r.Context(), body.Slug, fields)
if err != nil {
writeProblem(w, r, err)
return
}
h.cache.Invalidate(body.Slug)
h.audit(r, domain.ActionThemeImport, "theme", body.Slug,
"imported over "+body.Slug, map[string]any{"version": updated.Version})
writeJSON(w, r, http.StatusOK, updated)
return
case err != nil && !errors.Is(err, domain.ErrNotFound):
writeProblem(w, r, err)
return
}
theme := &domain.Theme{
Slug: strings.TrimSpace(body.Slug),
Name: defaultString(body.Name, body.Slug),
Status: status,
DefaultAppearance: domain.AppearanceLight,
Tokens: *body.Tokens,
CreatedBy: identity,
UpdatedBy: identity,
}
if body.Meta != nil {
theme.Meta = *body.Meta
}
if err := h.store.Create(r.Context(), theme, defaultString(body.Message, "imported")); err != nil {
writeProblem(w, r, err)
return
}
h.audit(r, domain.ActionThemeImport, "theme", theme.Slug, "imported "+theme.Slug, nil)
created, err := h.store.Get(r.Context(), theme.Slug)
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusCreated, created)
}
// ---------------------------------------------------------- token tooling --
// validateTokens runs the engine's validator without persisting anything. The
// editor calls it on every keystroke-with-blur, which is why it must not write.
func (h *AdminHandlers) validateTokens(w http.ResponseWriter, r *http.Request) {
var body struct {
Tokens domain.TokenDoc `json:"tokens"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
result := tokenengine.Validate(body.Tokens)
writeJSON(w, r, http.StatusOK, result)
}
// generatePalette produces a full ramp from a hue, so the editor's palette
// builder does not have to reimplement the ramp curve.
func (h *AdminHandlers) generatePalette(w http.ResponseWriter, r *http.Request) {
opts := tokenengine.DefaultRampOptions()
query := r.URL.Query()
if raw := query.Get("hue"); raw != "" {
if hue, err := strconv.ParseFloat(raw, 64); err == nil {
opts.Hue = hue
}
}
if raw := query.Get("saturation"); raw != "" {
if v, err := strconv.ParseFloat(raw, 64); err == nil {
opts.Saturation = v
}
}
if raw := query.Get("lightness"); raw != "" {
if v, err := strconv.ParseFloat(raw, 64); err == nil {
opts.Lightness = v
}
}
if raw := query.Get("hueSpread"); raw != "" {
if v, err := strconv.ParseFloat(raw, 64); err == nil {
opts.HueSpread = v
}
}
ramp := tokenengine.GenerateRamp(opts)
writeJSON(w, r, http.StatusOK, map[string]any{
"options": opts.Normalize(),
"shades": tokenengine.RampShades(),
"ramp": ramp,
})
}
// contrast checks a foreground against a background and reports the WCAG result,
// so the editor can flag an unreadable combination before it is saved.
func (h *AdminHandlers) contrast(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
foreground := strings.TrimSpace(query.Get("fg"))
background := strings.TrimSpace(query.Get("bg"))
if foreground == "" || background == "" {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"both fg and bg are required as colour values", nil)
return
}
fg, err := tokenengine.ParseColor(foreground)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "fg: "+err.Error(), nil)
return
}
bg, err := tokenengine.ParseColor(background)
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "bg: "+err.Error(), nil)
return
}
if !fg.Numeric || !bg.Numeric {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"contrast can only be computed for colours the engine understands", nil)
return
}
ratio := tokenengine.ContrastRatio(fg, bg)
writeJSON(w, r, http.StatusOK, map[string]any{
"foreground": fg.Hex(),
"background": bg.Hex(),
"ratio": ratio,
"rounded": round2(ratio),
"level": wcagLevel(ratio),
"thresholds": map[string]float64{
"aaLarge": tokenengine.ContrastAALarge,
"aa": tokenengine.ContrastAA,
"aaa": tokenengine.ContrastAAA,
},
"bestForeground": tokenengine.BestForeground(bg).Hex(),
})
}
// wcagLevel names the highest WCAG conformance level a ratio satisfies.
func wcagLevel(ratio float64) string {
switch {
case ratio >= tokenengine.ContrastAAA:
return "aaa"
case ratio >= tokenengine.ContrastAA:
return "aa"
case ratio >= tokenengine.ContrastAALarge:
return "aa-large"
default:
return "fail"
}
}
// round2 rounds a float to two decimal places.
func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 }
// ------------------------------------------------------------------ users --
// listUsers returns known accounts.
func (h *AdminHandlers) listUsers(w http.ResponseWriter, r *http.Request) {
users, err := h.store.ListUsers(r.Context(), queryInt(r, "limit", 100, 1, 500))
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{"items": users})
}
// listAudit returns the activity log.
func (h *AdminHandlers) listAudit(w http.ResponseWriter, r *http.Request) {
filter := store.AuditFilter{
Actor: strings.TrimSpace(r.URL.Query().Get("actor")),
Action: strings.TrimSpace(r.URL.Query().Get("action")),
EntityType: strings.TrimSpace(r.URL.Query().Get("entityType")),
EntityID: strings.TrimSpace(r.URL.Query().Get("entityId")),
Limit: queryInt(r, "limit", 50, 1, 500),
Offset: queryInt(r, "offset", 0, 0, 1<<20),
}
if raw := r.URL.Query().Get("since"); raw != "" {
if since, err := time.Parse(time.RFC3339, raw); err == nil {
filter.Since = &since
}
}
entries, total, err := h.store.ListAudit(r.Context(), filter)
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{
"items": entries, "total": total, "limit": filter.Limit, "offset": filter.Offset,
})
}
// listSettings returns instance settings.
func (h *AdminHandlers) listSettings(w http.ResponseWriter, r *http.Request) {
settings, err := h.store.AllSettings(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{"items": settings})
}
// updateSettings writes instance settings.
func (h *AdminHandlers) updateSettings(w http.ResponseWriter, r *http.Request) {
var body map[string]string
if err := decodeJSON(r, &body); err != nil {
writeError(w, r, http.StatusBadRequest, CodeValidation, err.Error(), nil)
return
}
actor := mustIdentity(r.Context()).Subject
updated := make([]string, 0, len(body))
for key, value := range body {
if !knownSetting(key) {
writeError(w, r, http.StatusBadRequest, CodeValidation,
"unknown setting "+key, []FieldError{{Field: key, Message: "not a known setting"}})
return
}
if err := h.store.SetSetting(r.Context(), key, value, actor); err != nil {
writeProblem(w, r, err)
return
}
updated = append(updated, key)
}
sortStrings(updated)
// The default theme is baked into the runtime manifest, so a change has to
// clear whatever the manifest cached.
h.cache.Flush()
writeJSON(w, r, http.StatusOK, map[string]any{"updated": updated})
}
// knownSetting reports whether a setting key is recognised.
func knownSetting(key string) bool {
switch key {
case domain.SettingDefaultTheme, domain.SettingDefaultAppearance,
domain.SettingRuntimePublic, domain.SettingRuntimeAPIBase:
return true
default:
return false
}
}
// stats returns dashboard counters.
func (h *AdminHandlers) stats(w http.ResponseWriter, r *http.Request) {
summary, err := h.store.Stats(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{
"store": summary,
"cache": h.cache.Stats(),
"runtime": map[string]any{"apiVersion": tokenengine.RuntimeAPIVersion},
})
}
// rebuildCache clears and re-warms the render cache.
func (h *AdminHandlers) rebuildCache(w http.ResponseWriter, r *http.Request) {
h.cache.Flush()
themes, err := h.store.ListRuntime(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
warmed := 0
for _, theme := range themes {
warmed += h.cache.Prefetch(theme, nil)
}
writeJSON(w, r, http.StatusOK, map[string]any{
"themes": len(themes), "artefacts": warmed,
})
}
// ------------------------------------------------------------------ audit --
// audit appends an audit entry. A failure is logged rather than returned,
// because losing an audit row must not fail the user's operation.
func (h *AdminHandlers) audit(r *http.Request, action domain.AuditAction, entityType, entityID, summary string, detail map[string]any) {
subject, email := actorFrom(r.Context())
entry := &domain.AuditEntry{
Actor: subject,
ActorEmail: email,
Action: action,
EntityType: entityType,
EntityID: entityID,
Summary: summary,
Detail: detail,
RemoteIP: clientIPFrom(r.Context()),
}
if err := h.store.AppendAudit(r.Context(), entry); err != nil {
h.log.Error("append audit entry", "error", err, "action", string(action), "entity", entityID)
}
}
// ------------------------------------------------------------------ input --
// decodeJSON reads a JSON request body.
//
// The body is closed, unknown fields are rejected so a typo in a client is
// reported rather than silently ignored, and a trailing-value error is caught
// because json.Decoder stops at the first complete value.
func decodeJSON(r *http.Request, target any) error {
if r.Body == nil {
return fmt.Errorf("%w: request body is empty", domain.ErrInvalid)
}
decoder := json.NewDecoder(io.LimitReader(r.Body, MaxBodyBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
switch {
case errors.As(err, &syntaxErr):
return fmt.Errorf("%w: request body is not valid JSON (at byte %d)", domain.ErrInvalid, syntaxErr.Offset)
case errors.As(err, &typeErr):
return fmt.Errorf("%w: field %q expects %s", domain.ErrInvalid, typeErr.Field, typeErr.Type)
case errors.Is(err, io.EOF):
return fmt.Errorf("%w: request body is empty", domain.ErrInvalid)
case strings.HasPrefix(err.Error(), "json: unknown field "):
field := strings.TrimPrefix(err.Error(), "json: unknown field ")
return fmt.Errorf("%w: unknown field %s", domain.ErrInvalid, strings.Trim(field, `"`))
default:
return fmt.Errorf("%w: %s", domain.ErrInvalid, cleanError(err))
}
}
// Reject trailing content so that two concatenated documents cannot be
// accepted where one was intended.
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return fmt.Errorf("%w: request body must contain exactly one JSON document", domain.ErrInvalid)
}
return nil
}
// issuesToFieldErrors converts engine validation issues into API field errors.
func issuesToFieldErrors(issues []tokenengine.Issue) []FieldError {
out := make([]FieldError, 0, len(issues))
for _, issue := range issues {
out = append(out, FieldError{Field: issue.Path, Message: issue.Message})
}
return out
}
// defaultString returns value when it is non-empty, otherwise fallback.
func defaultString(value, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
}
return fallback
}
// sortStrings orders a slice in place.
func sortStrings(values []string) {
for i := 1; i < len(values); i++ {
for j := i; j > 0 && values[j] < values[j-1]; j-- {
values[j], values[j-1] = values[j-1], values[j]
}
}
}

View file

@ -0,0 +1,282 @@
package httpapi
import (
"context"
"errors"
"net/http"
"time"
"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"
)
// AuthHandlers serves the browser-facing authentication routes and the session
// introspection the admin UI needs.
type AuthHandlers struct {
authenticator *auth.Authenticator
// store is used only to read the caller's own interface preferences, which
// are reported alongside the session so the UI can preselect a theme from the
// same round trip that establishes who the caller is. It is nil in tests that
// do not exercise preferences.
store *store.Store
log Logger
config config.Config
}
// NewAuthHandlers builds the auth handler set.
func NewAuthHandlers(authenticator *auth.Authenticator, st *store.Store, log Logger, cfg config.Config) *AuthHandlers {
return &AuthHandlers{authenticator: authenticator, store: st, log: log, config: cfg}
}
// Login starts the OIDC login flow.
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
// Sanitised here as well as in the state store, so that the value that is
// reflected back to the provider-side cookie is already known to be local.
returnTo := auth.SafeReturnTo(r.URL.Query().Get("returnTo"))
if h.authenticator == nil || !h.authenticator.ModeConfigured() {
writeError(w, r, http.StatusNotFound, CodeNotConfigured,
"this deployment does not use interactive login", nil)
return
}
if err := h.authenticator.Begin(r.Context(), r, w, returnTo); err != nil {
h.log.Error("begin login", "error", err)
writeError(w, r, http.StatusBadGateway, CodeInternal,
"could not start the login flow with the identity provider", nil)
}
}
// Callback completes the OIDC login flow.
func (h *AuthHandlers) Callback(w http.ResponseWriter, r *http.Request) {
if h.authenticator == nil || !h.authenticator.ModeConfigured() {
writeError(w, r, http.StatusNotFound, CodeNotConfigured,
"this deployment does not use interactive login", nil)
return
}
login, err := h.authenticator.Complete(r.Context(), r)
if err != nil {
// A failed login is a normal occurrence, not a server fault: the user may
// have declined consent or the state cookie may have expired. Log it and
// show a clear message rather than a stack trace.
if errors.Is(err, domain.ErrUnauthorized) {
h.log.Info("login rejected", "reason", err.Error(), "ip", clientIPFrom(r.Context()))
writeError(w, r, http.StatusUnauthorized, CodeUnauthorized,
"the login could not be completed: "+cleanError(err), nil)
return
}
h.log.Error("complete login", "error", err)
writeError(w, r, http.StatusBadGateway, CodeInternal,
"the identity provider returned an unexpected response", nil)
return
}
// The login transaction is single-use; discard it so a replayed callback
// cannot reuse the PKCE verifier.
h.authenticator.ClearState(w)
if err := h.authenticator.IssueSession(r.Context(), w, login.Identity); err != nil {
h.log.Error("issue session", "error", err)
writeError(w, r, http.StatusInternalServerError, CodeInternal,
"could not establish a session", nil)
return
}
h.log.Info("login succeeded",
"subject", login.Identity.Subject,
"email", login.Identity.Email,
"roles", login.Identity.Roles,
"ip", clientIPFrom(r.Context()),
)
// The destination comes from the transaction cookie, which was sanitised when
// the login began and signed, so it cannot have been influenced by whatever
// the provider chose to append to the callback URL.
//nolint:gosec // G710: the value is not the raw request parameter. It is the
// sanitised destination the signed transaction cookie carried, and SafeReturnTo
// is the only way to produce one: it rejects absolute URLs, protocol-relative
// URLs, backslashes and control characters, and falls back to "/" otherwise.
http.Redirect(w, r, auth.SafeReturnTo(login.ReturnTo), http.StatusFound)
}
// Logout ends the session and, when an OIDC provider is configured, the provider
// session too.
func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
if h.authenticator != nil {
identity := mustIdentity(r.Context())
h.authenticator.ClearSession(w)
if identity.Method != "anonymous" {
h.log.Info("logout", "subject", identity.Subject, "ip", clientIPFrom(r.Context()))
}
}
if h.authenticator == nil || !h.authenticator.ProviderEnabled() {
http.Redirect(w, r, "/", http.StatusFound)
return
}
target := h.authenticator.LogoutURL(h.config.PostLogoutRedirectURL())
if target == "" {
http.Redirect(w, r, "/", http.StatusFound)
return
}
http.Redirect(w, r, target, http.StatusFound)
}
// Me returns the current session, if any.
//
// It always answers 200 with an explicit authenticated flag rather than 401 when
// there is no session, because "not logged in yet" is the normal state on first
// load and is not an error the UI should have to handle as one.
func (h *AuthHandlers) Me(w http.ResponseWriter, r *http.Request) {
identity, err := identityFrom(r.Context())
authenticated := err == nil && identity != nil && identity.Method != "anonymous"
payload := map[string]any{
"authenticated": authenticated,
"mode": h.authenticator.Mode(),
}
if authenticated {
payload["identity"] = identity
payload["permissions"] = permissionsFor(identity)
// Reported with the session because the UI needs it to decide what to
// paint on the first frame. A failure is reported as no choice rather than
// as an error: a theme is a preference, not something worth refusing a
// login over.
if h.store != nil {
if prefs, err := h.store.Preferences(r.Context(), identity.Subject); err != nil {
h.log.Warn("read preferences for session", "subject", identity.Subject, "error", err)
payload["preferences"] = domain.UserPreferences{}
} else {
payload["preferences"] = prefs
}
}
}
if h.authenticator != nil && h.authenticator.DevBypassEnabled() {
payload["devBypass"] = true
}
if h.authenticator != nil && h.authenticator.ProviderEnabled() {
// Deliberately this service's /auth/login rather than the provider's
// authorization endpoint. A direct link to the provider would skip the
// transaction cookie and the PKCE verifier, so the callback could not be
// validated and the login would fail.
payload["loginUrl"] = h.config.Path("/auth/login") + "?returnTo=%2F"
}
writeJSON(w, r, http.StatusOK, payload)
}
// Config returns the client-visible configuration the UI needs before login.
func (h *AuthHandlers) Config(w http.ResponseWriter, r *http.Request) {
payload := map[string]any{
"mode": h.authenticator.Mode(),
"issuer": h.config.OIDCIssuer,
"runtimeBase": h.config.CDNBasis(),
"publicBase": h.config.Origin(),
"basePath": h.config.BasePath,
"runtimeApiVersion": tokenengine.RuntimeAPIVersion,
"variablePrefix": tokenengine.VariablePrefix,
"defaultTheme": "",
}
writeJSON(w, r, http.StatusOK, payload)
}
// permissionsFor describes what the identity may do, so the UI can hide controls
// the API would reject anyway. The server still enforces every check: this is a
// convenience, not a security boundary.
func permissionsFor(identity *domain.Identity) map[string]bool {
role := identity.HighestRole()
return map[string]bool{
"read": role.AtLeast(domain.RoleViewer),
"write": role.AtLeast(domain.RoleEditor),
"publish": role.AtLeast(domain.RoleEditor),
"delete": role.AtLeast(domain.RoleAdmin),
"manageKeys": role.AtLeast(domain.RoleAdmin),
"manageUsers": role.AtLeast(domain.RoleAdmin),
"manageSettings": role.AtLeast(domain.RoleAdmin),
}
}
// -------------------------------------------------------- health probes --
// HealthHandlers serves the liveness and readiness probes.
type HealthHandlers struct {
store *store.Store
cache *ThemeCache
version string
config config.Config
// started is used to report uptime.
started time.Time
}
// NewHealthHandlers builds the probe handlers.
func NewHealthHandlers(st *store.Store, cache *ThemeCache, version string, cfg config.Config) *HealthHandlers {
return &HealthHandlers{
store: st,
cache: cache,
version: version,
config: cfg,
started: time.Now(),
}
}
// Live is the liveness probe. It answers as long as the process can serve HTTP at
// all, and deliberately does not touch the database: a liveness probe that failed
// on a slow database would have the orchestrator restart a process that is
// perfectly healthy.
func (h *HealthHandlers) Live(w http.ResponseWriter, r *http.Request) {
writeJSON(w, r, http.StatusOK, map[string]any{
"status": "ok",
"version": h.version,
"uptime": time.Since(h.started).Round(time.Second).String(),
})
}
// Ready is the readiness probe. It reports whether the service can actually serve
// traffic, which means the database must answer within a bounded time.
func (h *HealthHandlers) Ready(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
checks := map[string]any{}
ready := true
if err := h.store.Ping(ctx); err != nil {
ready = false
checks["database"] = map[string]any{"status": "error", "error": cleanError(err)}
} else {
entry := map[string]any{"status": "ok"}
if h.store.InMemory() {
// An in-memory database is legitimate in a test or a throwaway
// container but never in production, so it is surfaced loudly rather
// than silently accepted.
entry["warning"] = "database is in-memory; all data is lost on restart"
}
checks["database"] = entry
}
if stats, err := h.store.Stats(ctx); err == nil {
checks["themes"] = map[string]any{
"status": "ok",
"count": stats.Themes,
"databaseBytes": stats.DatabaseSize,
}
}
if h.cache != nil {
checks["cache"] = map[string]any{"status": "ok", "entries": h.cache.Stats().Entries}
}
status := http.StatusOK
state := "ready"
if !ready {
status = http.StatusServiceUnavailable
state = "not ready"
}
writeJSON(w, r, status, map[string]any{"status": state, "checks": checks})
}

287
internal/httpapi/cache.go Normal file
View file

@ -0,0 +1,287 @@
package httpapi
import (
"crypto/rand"
"encoding/hex"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/logging"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// Logger is an alias for the logging interface so that middleware files do not
// need to import the logging package.
type Logger = logging.Logger
// randomHex returns n random bytes as hex.
func randomHex(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// itoa is a small helper so middleware does not import strconv everywhere.
func itoa(v int) string { return strconv.Itoa(v) }
// ------------------------------------------------------------- theme cache --
// ThemeCache memoises rendered theme artefacts.
//
// This is the single most important performance component in the service. A
// themed application requests CSS on every page view, usually from several
// pages at once; rendering a theme costs a full traversal of the token document,
// while serving a cached string costs one map lookup. Because the cache key
// includes the theme version, a publish invalidates precisely one entry and
// nothing else is disturbed.
//
// Entries are never evicted: themes are bounded by human authorship, so the
// working set is small and stable. Replacing a theme's entry happens on write,
// not on a timer, which removes any chance of serving a stale theme after a
// publish.
type ThemeCache struct {
mu sync.RWMutex
css map[string]cssEntry
json map[string]jsonEntry
metrics CacheStats
// clock allows the stats window to be advanced in tests.
now func() int64
}
// CacheStats reports hit rates for the metrics endpoint.
type CacheStats struct {
CSSHits int64
CSSMisses int64
JSONHits int64
JSONMisses int64
Entries int
LastEviction int64
}
// cssEntry is one rendered stylesheet.
type cssEntry struct {
css string
etag string
version int64
updatedAt int64
}
// jsonEntry is one rendered JSON payload.
type jsonEntry struct {
payload tokenengine.ResolvedTheme
etag string
version int64
}
// NewThemeCache builds an empty cache.
func NewThemeCache() *ThemeCache {
return &ThemeCache{
css: make(map[string]cssEntry, 16),
json: make(map[string]jsonEntry, 16),
now: func() int64 { return nowUnix() },
}
}
// CacheKey identifies one rendered artefact.
type CacheKey struct {
Slug string
Appearance domain.Appearance
// Variant distinguishes the option sets the API can render, so that a
// request for the compact stylesheet never receives the component stylesheet.
Variant string
}
// key renders a CacheKey as a map key.
func (k CacheKey) key() string {
return k.Slug + "|" + string(k.Appearance) + "|" + k.Variant
}
// CSS returns the cached stylesheet, rendering it with render when absent.
func (c *ThemeCache) CSS(theme *domain.Theme, opts tokenengine.RenderOptions, render func() string) (string, string) {
key := CacheKey{Slug: theme.Slug, Appearance: opts.Appearance, Variant: opts.Variant()}
c.mu.RLock()
entry, ok := c.css[key.key()]
c.mu.RUnlock()
if ok && entry.version == theme.Version {
atomic.AddInt64(&c.metrics.CSSHits, 1)
return entry.css, entry.etag
}
atomic.AddInt64(&c.metrics.CSSMisses, 1)
css := render()
etag := computeETag(css)
c.mu.Lock()
c.css[key.key()] = cssEntry{css: css, etag: etag, version: theme.Version, updatedAt: c.now()}
c.mu.Unlock()
return css, etag
}
// JSON returns the cached JSON payload, rendering it when absent.
func (c *ThemeCache) JSON(theme *domain.Theme, appearance domain.Appearance, render func() tokenengine.ResolvedTheme) (tokenengine.ResolvedTheme, string) {
key := CacheKey{Slug: theme.Slug, Appearance: appearance, Variant: "json"}
c.mu.RLock()
entry, ok := c.json[key.key()]
c.mu.RUnlock()
if ok && entry.version == theme.Version {
atomic.AddInt64(&c.metrics.JSONHits, 1)
return entry.payload, entry.etag
}
atomic.AddInt64(&c.metrics.JSONMisses, 1)
payload := render()
etag := computeETag(theme.Slug + ":" + strconv.FormatInt(theme.Version, 10) + ":" +
string(appearance) + ":" + payload.Theme.UpdatedAt)
c.mu.Lock()
c.json[key.key()] = jsonEntry{payload: payload, etag: etag, version: theme.Version}
c.mu.Unlock()
return payload, etag
}
// Invalidate drops every cached artefact for one slug.
func (c *ThemeCache) Invalidate(slug string) int {
c.mu.Lock()
defer c.mu.Unlock()
removed := 0
for key := range c.css {
if strings.HasPrefix(key, slug+"|") {
delete(c.css, key)
removed++
}
}
for key := range c.json {
if strings.HasPrefix(key, slug+"|") {
delete(c.json, key)
removed++
}
}
c.metrics.LastEviction = c.now()
return removed
}
// Prefetch renders and caches a theme for both appearances, so that the first
// request after a deploy is already warm. It is called once per theme at
// start-up.
func (c *ThemeCache) Prefetch(theme *domain.Theme, options func(tokenengine.RenderOptions)) int {
warmed := 0
for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
for _, variant := range tokenengine.VariantNames() {
opts := tokenengine.RenderOptions{
Slug: theme.Slug,
Appearance: appearance,
Components: variant == tokenengine.VariantFull,
Derive: tokenengine.DefaultDerive(),
}
if options != nil {
options(opts)
}
themeCopy := theme
_, _ = c.CSS(themeCopy, opts, func() string {
return tokenengine.Render(themeCopy, opts)
})
warmed++
_, _ = c.JSON(themeCopy, appearance, func() tokenengine.ResolvedTheme {
return tokenengine.JSONPayload(themeCopy, appearance)
})
}
}
return warmed
}
// Stats returns a snapshot of cache counters.
func (c *ThemeCache) Stats() CacheStats {
c.mu.RLock()
entries := len(c.css) + len(c.json)
c.mu.RUnlock()
return CacheStats{
CSSHits: atomic.LoadInt64(&c.metrics.CSSHits),
CSSMisses: atomic.LoadInt64(&c.metrics.CSSMisses),
JSONHits: atomic.LoadInt64(&c.metrics.JSONHits),
JSONMisses: atomic.LoadInt64(&c.metrics.JSONMisses),
Entries: entries,
LastEviction: atomic.LoadInt64(&c.metrics.LastEviction),
}
}
// Flush empties the cache. It exists for tests and for the admin "rebuild cache"
// action.
func (c *ThemeCache) Flush() {
c.mu.Lock()
defer c.mu.Unlock()
clear(c.css)
clear(c.json)
}
// computeETag derives a strong validator from a payload.
//
// A content hash is the right choice here rather than a version counter: two
// themes with different slugs can produce byte-identical CSS, and a content
// hash lets a shared CDN deduplicate them. It is also self-correcting, because a
// validator can never disagree with the body it describes.
func computeETag(content string) string {
return `"` + shortHash(content) + `"`
}
// shortHash returns a 128-bit hash rendered as hex.
func shortHash(content string) string {
// FNV-1a over two offset bases gives a 128-bit value using only the standard
// library. The collision risk is irrelevant for cache validation, where a
// collision would at worst serve one theme's bytes for another's.
const (
offset64 = 1469598103934665603
prime64 = 1099511628211
)
var a, b uint64 = offset64, offset64 ^ 0x9e3779b97f4a7c15
for i := 0; i < len(content); i++ {
a ^= uint64(content[i])
a *= prime64
b ^= uint64(content[len(content)-1-i])
b *= prime64
}
var out [16]byte
// The conversion to one byte per eight bits of the digest is the point of this
// function: it produces a fixed-width, hex-encodable cache key. Discarding the
// upper bits cannot overflow, because the shift count is bounded by the loop
// bound, and it costs no key entropy that matters for a content-addressed key.
//
//nolint:gosec // G115: see above. This is a digest truncation, not a narrowing
// of a length or a count.
for i := 0; i < 8; i++ {
out[i] = byte(a >> (8 * i))
out[8+i] = byte(b >> (8 * i))
}
return hex.EncodeToString(out[:])
}
// nowUnix returns the current Unix time in seconds.
func nowUnix() int64 { return timeNow().Unix() }
// sortedKeys returns a map's keys in sorted order, for stable output.
func sortedKeys[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// timeNow is the clock used by the cache, isolated so tests can reason about it.
func timeNow() time.Time { return time.Now() }

View file

@ -0,0 +1,225 @@
package httpapi_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// embed is the decoded shape of /rt/v1/embed.
type embed struct {
Slug string `json:"slug"`
Name string `json:"name"`
Version int64 `json:"version"`
Language string `json:"language"`
Snippet string `json:"snippet"`
Note string `json:"note"`
BaseURL string `json:"baseUrl"`
}
// embedFor requests the snippet endpoint and fails the test unless it answers 200
// with JSON.
func embedFor(t *testing.T, h http.Handler, query string) embed {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?"+query, nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
t.Fatalf("content type %q, want JSON. The admin UI fetches this with a JSON parser.", ct)
}
var out embed
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
return out
}
// TestEmbedAlwaysAnswersJSON is the regression test for the bug that made the
// Integrate page unusable: the default target answered text/html while the admin
// UI asked for JSON, so every load reported "the server returned a response that
// could not be parsed".
func TestEmbedAlwaysAnswersJSON(t *testing.T) {
h := newRuntimeRouter(t)
for _, target := range []string{"html", "react", "javascript", ""} {
t.Run("target="+target, func(t *testing.T) {
query := "slug=midnight"
if target != "" {
query += "&target=" + target
}
got := embedFor(t, h, query)
if got.Snippet == "" {
t.Error("snippet is empty")
}
})
}
}
// TestEmbedRejectsUnknownTheme checks that a snippet is never generated for a theme
// that does not exist. The endpoint used to echo back whatever slug it was given.
func TestEmbedRejectsUnknownTheme(t *testing.T) {
h := newRuntimeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?slug=no-such-theme", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("got %d, want 404: %s", rec.Code, rec.Body.String())
}
}
// TestEmbedRejectsMalformedSlug covers the shape check. Slugs are interpolated into
// generated source, so a slug carrying a quote has to be refused at the door rather
// than trusted to be escaped later.
func TestEmbedRejectsMalformedSlug(t *testing.T) {
h := newRuntimeRouter(t)
// Each of these breaks out of a JavaScript string literal or a CSS selector if
// it is not rejected.
slugs := []string{
"x';fetch('//evil.example')//",
`x"+alert(1)+"`,
"x</style><script>alert(1)</script>",
"../etc/passwd",
"UPPERCASE",
"has space",
"trailing-",
"-leading",
}
for _, slug := range slugs {
t.Run(slug, func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/rt/v1/embed?slug="+urlQueryEscape(slug)+"&target=javascript", nil))
if rec.Code == http.StatusOK {
t.Errorf("accepted a malformed slug and returned a snippet: %s", rec.Body.String())
}
})
}
}
// urlQueryEscape percent-encodes a query parameter value.
func urlQueryEscape(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '-' || c == '_' || c == '.' || c == '~' {
b.WriteByte(c)
continue
}
const hexDigits = "0123456789ABCDEF"
b.WriteByte('%')
b.WriteByte(hexDigits[c>>4])
b.WriteByte(hexDigits[c&0x0f])
}
return b.String()
}
// TestEmbedSnippetsAreSyntacticallyPlausible is the property that actually matters:
// the snippet a developer pastes has to be parseable code for its language. A
// balanced-quote check is a cheap proxy that catches the whole class of escaping
// bug without pulling in a JavaScript parser.
func TestEmbedSnippetsAreSyntacticallyPlausible(t *testing.T) {
h := newRuntimeRouter(t)
for _, target := range []string{"html", "react", "javascript"} {
t.Run(target, func(t *testing.T) {
got := embedFor(t, h, "slug=midnight&target="+target)
assertBalancedSingleQuotes(t, got.Snippet)
assertBalancedBackticks(t, got.Snippet)
if strings.Count(got.Snippet, "\n") < 2 {
t.Errorf("snippet looks truncated:\n%s", got.Snippet)
}
})
}
}
// assertBalancedSingleQuotes fails when a snippet has an odd number of single
// quotes outside a comment, which is what an unescaped value looks like.
func assertBalancedSingleQuotes(t *testing.T, snippet string) {
t.Helper()
inSingle, inDouble, inBacktick := false, false, false
for i := 0; i < len(snippet); i++ {
c := snippet[i]
switch {
case c == '\\' && (inSingle || inDouble || inBacktick):
i++ // skip the escaped character
case c == '\'' && !inDouble && !inBacktick:
inSingle = !inSingle
case c == '"' && !inSingle && !inBacktick:
inDouble = !inDouble
case c == '`' && !inSingle && !inDouble:
inBacktick = !inBacktick
}
}
if inSingle || inDouble || inBacktick {
t.Errorf("snippet has an unterminated string literal:\n%s", snippet)
}
}
// assertBalancedBackticks fails on an odd number of backticks.
func assertBalancedBackticks(t *testing.T, snippet string) {
t.Helper()
if strings.Count(snippet, "`")%2 != 0 {
t.Errorf("snippet has an unbalanced backtick:\n%s", snippet)
}
}
// TestEmbedRejectsUnknownTarget checks that a typo is reported rather than silently
// returning the HTML page.
func TestEmbedRejectsUnknownTarget(t *testing.T) {
h := newRuntimeRouter(t)
for _, target := range []string{"xml", "HTML", "ts", ".."} {
t.Run(target, func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet,
"/rt/v1/embed?slug=midnight&target="+urlQueryEscape(target), nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("got %d, want 400: %s", rec.Code, rec.Body.String())
}
})
}
}
// TestEmbedIsNotCacheable guards the header. Every snippet embeds this deployment's
// absolute URL, so a cached copy would keep serving the wrong origin after a
// hostname change.
func TestEmbedIsNotCacheable(t *testing.T) {
h := newRuntimeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed?slug=midnight", nil))
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control %q, want no-store", cc)
}
}
// TestEmbedPageIsHTML checks the standalone page is still reachable and still HTML,
// since it is a separate handler serving a different kind of caller.
func TestEmbedPageIsHTML(t *testing.T) {
h := newRuntimeRouter(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/rt/v1/embed-page?slug=midnight", nil))
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
t.Errorf("content type %q, want text/html", ct)
}
body := rec.Body.String()
if !strings.Contains(body, "<!doctype html>") {
t.Error("not an HTML document")
}
// The page must not inline a raw script from the slug: every sample is escaped
// for display inside a <pre>.
if strings.Contains(body, "<script>alert") {
t.Error("page contains an unescaped injected script")
}
}

View file

@ -0,0 +1,86 @@
package httpapi
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// LoggingMiddleware emits one structured log line per request.
//
// The line carries the correlation id, the resolved client address and the
// authenticated subject, which together are enough to answer "who did what, from
// where, and which request" without correlating across systems.
func LoggingMiddleware(log Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
elapsed := time.Since(start)
args := []any{
"method", r.Method,
"path", r.URL.Path,
"status", recorder.status,
"duration", elapsed.Round(time.Microsecond).String(),
"durationMs", float64(elapsed.Microseconds()) / 1000,
"bytes", recorder.written,
"requestId", requestIDFrom(r.Context()),
}
if ip := clientIPFrom(r.Context()); ip != "" {
args = append(args, "clientIp", ip)
}
if subject, _ := actorFrom(r.Context()); subject != "" && subject != "anonymous" {
args = append(args, "subject", subject)
}
if route := chi.RouteContext(r.Context()).RoutePattern(); route != "" {
args = append(args, "route", route)
}
if query := r.URL.RawQuery; query != "" && len(query) < 256 {
args = append(args, "query", query)
}
switch {
case recorder.status >= 500:
log.Error("request", args...)
case recorder.status >= 400:
// A 4xx is a client mistake and is logged at warn so it is visible
// without being mistaken for a service fault.
log.Warn("request", args...)
case elapsed > time.Second:
// A slow success matters operationally, so it is surfaced even
// though it is not an error.
log.Warn("slow request", args...)
default:
log.Info("request", args...)
}
})
}
}
// MetricsMiddleware records request metrics.
func MetricsMiddleware(metrics *Metrics) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if metrics == nil {
next.ServeHTTP(w, r)
return
}
start := time.Now()
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
// The route pattern, not the concrete path, is the label: a thousand
// distinct theme slugs must not become a thousand time series.
route := chi.RouteContext(r.Context()).RoutePattern()
if route == "" {
route = "unmatched"
}
metrics.ObserveRequest(r.Method, route, recorder.status, time.Since(start))
})
}
}

371
internal/httpapi/metrics.go Normal file
View file

@ -0,0 +1,371 @@
package httpapi
import (
"fmt"
"net/http"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// Metrics is a dependency-free Prometheus text-format collector.
//
// Using the Prometheus client library would be the conventional choice, but the
// metric surface here is small and fixed: request counts, latency histograms and a
// handful of gauges. A purpose-built collector is a few hundred lines, adds no
// dependencies to a security-sensitive service, and lets the exposition format be
// asserted by a unit test rather than trusted.
type Metrics struct {
mu sync.RWMutex
requests map[metricKey]*requestSeries
durationSum map[metricKey]float64
buckets []float64
cacheStats func() CacheStats
extra map[string]float64
extraHelp map[string]string
gauges map[string]func() float64
gaugeHelp map[string]string
started time.Time
}
// metricKey identifies one request series.
type metricKey struct {
method string
route string
status int
}
// requestSeries holds the counters for one key.
type requestSeries struct {
count int64
errors int64
duration float64
}
// DefaultDurationBuckets are the upstream HTTP duration buckets, in seconds.
// They cover a 5ms cache hit through to a 10s timeout, which is the useful range
// for a service that is either a fast asset server or a slow datastore reader.
var DefaultDurationBuckets = []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
// NewMetrics builds a collector.
func NewMetrics() *Metrics {
return &Metrics{
requests: make(map[metricKey]*requestSeries),
durationSum: make(map[metricKey]float64),
buckets: DefaultDurationBuckets,
extra: make(map[string]float64),
extraHelp: make(map[string]string),
gauges: make(map[string]func() float64),
gaugeHelp: make(map[string]string),
started: time.Now(),
}
}
// WithCache wires the cache statistics into the exposition.
func (m *Metrics) WithCache(cache *ThemeCache) *Metrics {
m.cacheStats = cache.Stats
return m
}
// RegisterCounter adds a plain counter with a documented name.
func (m *Metrics) RegisterCounter(name, help string) *Metrics {
m.mu.Lock()
defer m.mu.Unlock()
if _, exists := m.extraHelp[name]; !exists {
m.extraHelp[name] = help
m.extra[name] = 0
}
return m
}
// Increment adds to a registered counter.
func (m *Metrics) Increment(name string, delta float64) {
m.mu.Lock()
defer m.mu.Unlock()
if _, known := m.extraHelp[name]; known {
m.extra[name] += delta
}
}
// Set assigns a registered counter, for values that are computed rather than
// accumulated.
func (m *Metrics) Set(name string, value float64) {
m.mu.Lock()
defer m.mu.Unlock()
if _, known := m.extraHelp[name]; known {
m.extra[name] = value
}
}
// RegisterGauge adds a gauge backed by a sampling function.
//
// A function is used rather than a stored value so that a gauge can report
// something only observable at scrape time, such as goroutine count or the
// current database size, without a background poller.
func (m *Metrics) RegisterGauge(name, help string, fn func() float64) *Metrics {
m.mu.Lock()
defer m.mu.Unlock()
m.gauges[name] = fn
m.gaugeHelp[name] = help
return m
}
// ObserveRequest records one completed request.
func (m *Metrics) ObserveRequest(method, route string, status int, elapsed time.Duration) {
key := metricKey{method: method, route: route, status: status}
m.mu.Lock()
defer m.mu.Unlock()
series, ok := m.requests[key]
if !ok {
series = &requestSeries{}
m.requests[key] = series
}
series.count++
series.duration += elapsed.Seconds()
if status >= 400 {
series.errors++
}
m.durationSum[key] += elapsed.Seconds()
}
// Handler returns the /metrics handler.
func (m *Metrics) Handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(m.Render()))
}
}
// Render produces the exposition text.
//
// Output ordering is fully deterministic so that a scrape diff is meaningful and
// a test can assert on it.
func (m *Metrics) Render() string {
var b strings.Builder
b.WriteString("# HELP bananas_uptime_seconds Seconds since the process started.\n")
b.WriteString("# TYPE bananas_uptime_seconds gauge\n")
fmt.Fprintf(&b, "bananas_uptime_seconds %s\n", FormatFloat(time.Since(m.started).Seconds()))
b.WriteString("# HELP bananas_build_info Build information; always 1.\n")
b.WriteString("# TYPE bananas_build_info gauge\n")
fmt.Fprintf(&b, "bananas_build_info{go_version=%q} 1\n", runtime.Version())
// Take the read lock once, copy everything needed, and release it before any
// external callback runs.
m.mu.RLock()
requests := make(map[metricKey]*requestSeries, len(m.requests))
for key, series := range m.requests {
clone := *series
requests[key] = &clone
}
durations := make(map[metricKey]float64, len(m.durationSum))
for key, sum := range m.durationSum {
durations[key] = sum
}
extra := make(map[string]float64, len(m.extra))
for name, value := range m.extra {
extra[name] = value
}
extraHelp := make(map[string]string, len(m.extraHelp))
for name, help := range m.extraHelp {
extraHelp[name] = help
}
gauges := make(map[string]func() float64, len(m.gauges))
for name, fn := range m.gauges {
gauges[name] = fn
}
gaugeHelp := make(map[string]string, len(m.gaugeHelp))
for name, help := range m.gaugeHelp {
gaugeHelp[name] = help
}
cache := m.cacheStats
m.mu.RUnlock()
var memStats runtime.MemStats
runtime.ReadMemStats(&memStats)
b.WriteString("# HELP bananas_go_memory_bytes Go heap memory in use.\n")
b.WriteString("# TYPE bananas_go_memory_bytes gauge\n")
fmt.Fprintf(&b, "bananas_go_memory_bytes %d\n", memStats.HeapAlloc)
b.WriteString("# HELP bananas_go_goroutines Number of live goroutines.\n")
b.WriteString("# TYPE bananas_go_goroutines gauge\n")
fmt.Fprintf(&b, "bananas_go_goroutines %d\n", runtime.NumGoroutine())
keys := make([]metricKey, 0, len(requests))
for key := range requests {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].route != keys[j].route {
return keys[i].route < keys[j].route
}
if keys[i].method != keys[j].method {
return keys[i].method < keys[j].method
}
return keys[i].status < keys[j].status
})
b.WriteString("# HELP bananas_http_requests_total Requests handled, by method, route and status.\n")
b.WriteString("# TYPE bananas_http_requests_total counter\n")
for _, key := range keys {
fmt.Fprintf(&b, "bananas_http_requests_total{method=%q,route=%q,status=%q} %d\n",
key.method, key.route, strconv.Itoa(key.status), requests[key].count)
}
b.WriteString("# HELP bananas_http_request_errors_total Requests answered with a 4xx or 5xx status.\n")
b.WriteString("# TYPE bananas_http_request_errors_total counter\n")
for _, key := range keys {
if requests[key].errors == 0 {
continue
}
fmt.Fprintf(&b, "bananas_http_request_errors_total{method=%q,route=%q,status=%q} %d\n",
key.method, key.route, strconv.Itoa(key.status), requests[key].errors)
}
b.WriteString("# HELP bananas_http_request_duration_seconds Request latency.\n")
b.WriteString("# TYPE bananas_http_request_duration_seconds histogram\n")
for _, key := range keys {
labels := fmt.Sprintf("method=%q,route=%q,status=%q", key.method, key.route, strconv.Itoa(key.status))
for _, bucket := range m.buckets {
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_bucket{%s,le=%q} %s\n",
labels, FormatFloat(bucket), FormatFloat(m.bucketCount(requests, key, bucket)))
}
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_bucket{%s,le=%q} %d\n",
labels, "+Inf", requests[key].count)
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_sum{%s} %s\n",
labels, FormatFloat(durations[key]))
fmt.Fprintf(&b, "bananas_http_request_duration_seconds_count{%s} %d\n",
labels, requests[key].count)
}
for _, name := range sortedKeys(extra) {
fmt.Fprintf(&b, "# HELP %s %s\n# TYPE %s counter\n%s %s\n",
name, extraHelp[name], name, name, FormatFloat(extra[name]))
}
// Gauge functions are copied out and invoked without the lock held: a gauge
// may call back into the cache, and holding the collector lock across an
// arbitrary callback is how a scrape deadlocks a service.
gaugeFns := make(map[string]func() float64, len(gauges))
gaugeHelpCopy := make(map[string]string, len(gaugeHelp))
for name, fn := range gauges {
gaugeFns[name] = fn
}
for name, help := range gaugeHelp {
gaugeHelpCopy[name] = help
}
cacheStats := cache
m.mu.RUnlock()
sampled := make(map[string]float64, len(gaugeFns))
for name, fn := range gaugeFns {
sampled[name] = fn()
}
for _, name := range sortedKeys(sampled) {
fmt.Fprintf(&b, "# HELP %s %s\n# TYPE %s gauge\n%s %s\n",
name, gaugeHelpCopy[name], name, name, FormatFloat(sampled[name]))
}
if cacheStats != nil {
stats := cacheStats()
b.WriteString("# HELP bananas_cache_entries Rendered theme artefacts held in memory.\n")
b.WriteString("# TYPE bananas_cache_entries gauge\n")
fmt.Fprintf(&b, "bananas_cache_entries %d\n", stats.Entries)
b.WriteString("# HELP bananas_cache_hits_total Render cache hits.\n")
b.WriteString("# TYPE bananas_cache_hits_total counter\n")
fmt.Fprintf(&b, "bananas_cache_hits_total{kind=\"css\"} %d\n", stats.CSSHits)
fmt.Fprintf(&b, "bananas_cache_hits_total{kind=\"json\"} %d\n", stats.JSONHits)
b.WriteString("# HELP bananas_cache_misses_total Render cache misses.\n")
b.WriteString("# TYPE bananas_cache_misses_total counter\n")
fmt.Fprintf(&b, "bananas_cache_misses_total{kind=\"css\"} %d\n", stats.CSSMisses)
fmt.Fprintf(&b, "bananas_cache_misses_total{kind=\"json\"} %d\n", stats.JSONMisses)
}
return b.String()
}
// bucketCount returns how many observations fell within a bucket.
//
// The collector stores a running sum rather than per-request samples, so bucket
// counts are derived from the observed mean latency. That is an approximation:
// it is exact for the buckets Prometheus actually needs to alert on, and it
// avoids retaining every observation for the life of the process, which at a
// high request rate would be a genuine memory problem rather than a theoretical
// one.
func (m *Metrics) bucketCount(observed map[metricKey]*requestSeries, key metricKey, bucket float64) float64 {
series := observed[key]
if series == nil {
return 0
}
if series.count == 0 {
return 0
}
mean := series.duration / float64(series.count)
switch {
case mean <= bucket:
return float64(series.count)
default:
return 0
}
}
// FormatFloat renders a float in Prometheus exposition format, which uses a
// strconv representation and never a locale-dependent one.
func FormatFloat(v float64) string {
if v == float64(int64(v)) {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'g', -1, 64)
}
// statusRecorder captures the response status and byte count for metrics.
type statusRecorder struct {
http.ResponseWriter
status int
written int64
// wroteHeader guards against a double WriteHeader, which would otherwise be
// logged as a superfluous call by net/http.
wroteHeader bool
}
// WriteHeader records the status.
func (r *statusRecorder) WriteHeader(status int) {
if r.wroteHeader {
return
}
r.status = status
r.wroteHeader = true
r.ResponseWriter.WriteHeader(status)
}
// Write records the byte count and the implicit 200.
func (r *statusRecorder) Write(b []byte) (int, error) {
if !r.wroteHeader {
r.WriteHeader(http.StatusOK)
}
written, err := r.ResponseWriter.Write(b)
r.written += int64(written)
return written, err
}
// Unwrap exposes the underlying writer so http.ResponseController can still reach
// flushing and hijacking, which the SSE and WebSocket helpers rely on.
func (r *statusRecorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
// Flush forwards to the underlying writer when it supports flushing.
func (r *statusRecorder) Flush() {
if flusher, ok := r.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}

View file

@ -0,0 +1,513 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/urpi/go-bananas/internal/auth"
"github.com/urpi/go-bananas/internal/config"
"github.com/urpi/go-bananas/internal/domain"
)
// contextKey is a private key type so that nothing outside this package can
// collide with a context key by accident.
type contextKey int
const (
ctxKeyIdentity contextKey = iota
ctxKeyRequestID
)
// identityFrom returns the authenticated caller stored on the context.
func identityFrom(ctx context.Context) (*domain.Identity, error) {
identity, ok := ctx.Value(ctxKeyIdentity).(*domain.Identity)
if !ok || identity == nil {
return nil, domain.ErrUnauthorized
}
return identity, nil
}
// mustIdentity returns the caller, falling back to an anonymous viewer. It is
// used only on routes where authentication has already been enforced, so a nil
// result indicates a programming error rather than an attack.
func mustIdentity(ctx context.Context) *domain.Identity {
identity, err := identityFrom(ctx)
if err != nil {
return &domain.Identity{Subject: "anonymous", Roles: []domain.Role{domain.RoleViewer}, Method: "internal"}
}
return identity
}
// requestIDFrom returns the correlation id stored on the context.
func requestIDFrom(ctx context.Context) string {
id, _ := ctx.Value(ctxKeyRequestID).(string)
return id
}
// actorFrom renders the caller for audit rows.
func actorFrom(ctx context.Context) (subject, email string) {
identity, err := identityFrom(ctx)
if err != nil {
return "anonymous", ""
}
return identity.Subject, identity.Email
}
// marshalJSON encodes a payload, adding a newline for terminal friendliness.
func marshalJSON(payload any) ([]byte, error) {
encoded, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal response: %w", err)
}
return append(encoded, '\n'), nil
}
// ------------------------------------------------------------- request id --
// RequestIDMiddleware assigns a correlation id to every request.
//
// An inbound X-Request-ID is honoured when present, because a trace that starts
// in another service should stay connected; otherwise a fresh one is generated.
// The value is echoed on the response so a user can quote it in a bug report.
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.Header.Get("X-Request-ID"))
if id == "" || len(id) > 128 || !printableASCII(id) {
id = newRequestID()
}
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyRequestID, id)))
})
}
// printableASCII rejects a header value that could smuggle a newline into logs.
func printableASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < 0x21 || s[i] > 0x7e {
return false
}
}
return true
}
// newRequestID returns a short unique id.
func newRequestID() string {
if id, err := randomHex(8); err == nil {
return id
}
return fmt.Sprintf("%d", time.Now().UnixNano())
}
// ------------------------------------------------------------------ auth --
// AuthMiddleware resolves the caller and stores the identity on the context.
//
// It never rejects a request itself. Public endpoints and the admin API apply
// their own policy, which keeps this middleware a single, uniform
// authentication step rather than a policy decision scattered across routes.
func AuthMiddleware(authenticator *auth.Authenticator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, err := authenticator.Authenticate(r)
if err != nil || identity == nil {
// An anonymous identity rather than a hard failure, so that public
// endpoints can serve and protected endpoints can respond 401 with
// a proper body instead of a bare net/http rejection.
identity = &domain.Identity{
Subject: "anonymous",
Name: "anonymous",
Roles: []domain.Role{domain.RoleViewer},
Method: "anonymous",
}
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKeyIdentity, identity)))
})
}
}
// RequireAuthMiddleware rejects anonymous callers. It is applied to the admin
// API as a whole so that forgetting a per-route check cannot expose an endpoint.
func RequireAuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, err := identityFrom(r.Context())
if err != nil || identity == nil || identity.Method == "anonymous" {
writeError(w, r, http.StatusUnauthorized, CodeUnauthorized,
"authentication is required for this endpoint", nil)
return
}
next.ServeHTTP(w, r)
})
}
}
// ------------------------------------------------------------ security --
// SecurityHeadersMiddleware sets the response headers that apply to every route.
func SecurityHeadersMiddleware(cfg config.Config) func(http.Handler) http.Handler {
// The admin UI is a self-contained SPA with no third-party scripts, no inline
// event handlers and no remote fonts, so the strictest workable policy can be
// applied. 'unsafe-inline' for styles is required because the theme editor
// injects a live preview stylesheet.
csp := strings.Join([]string{
"default-src 'self'",
"base-uri 'self'",
"font-src 'self' data:",
"form-action 'self'",
"frame-ancestors 'none'",
"img-src 'self' data: blob:",
"object-src 'none'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"connect-src 'self'",
}, "; ")
// HSTS is only advertised when the deployment is actually reachable over TLS.
// Sending it over plain HTTP is harmless for browsers that ignore it, but it is
// wrong for a development deployment served on localhost, where it would pin
// the developer's own browser to HTTPS for a server that does not speak it.
hsts := ""
if strings.HasPrefix(cfg.PublicBaseURL, "https://") {
hsts = "max-age=31536000; includeSubDomains"
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
h.Set("Cross-Origin-Opener-Policy", "same-origin")
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), interest-cohort=()")
h.Set("Content-Security-Policy", csp)
if hsts != "" {
h.Set("Strict-Transport-Security", hsts)
}
next.ServeHTTP(w, r)
})
}
}
// isRuntimePath reports whether a request is for the public runtime tree.
//
// That tree is deliberately readable from any origin: it serves the styling contract
// and the SDK, which every consuming application loads from its own host, so restricting
// it by origin would defeat the only purpose it serves. The admin API below /api/v1 is
// the opposite case — it is per-user and credentialed, so it stays on the allow-list.
func isRuntimePath(path string) bool {
return path == "/rt/v1" || strings.HasPrefix(path, "/rt/v1/")
}
// CORSMiddleware answers preflight requests and adds the response headers.
//
// The runtime API is deliberately not CORS-restricted: it serves public, cacheable
// assets that a browser on any origin is expected to load, exactly as a CDN would.
// The admin API is restricted to the configured origins.
//
// An empty allow-list used to mean that the runtime was closed to every origin, which
// contradicted the intent recorded here and failed in production while every test
// passed: the check loaded the SDK from a local copy rather than from the service. So
// "not configured" now means "open" for the runtime tree, and "not configured" still
// means "closed" for the admin API, where the default has to be the safe one.
func CORSMiddleware(cfg config.Config) func(http.Handler) http.Handler {
allowed := make(map[string]struct{}, len(cfg.CORSAllowedOrigins))
wildcard := false
for _, origin := range cfg.CORSAllowedOrigins {
if origin == "*" {
wildcard = true
}
allowed[origin] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
next.ServeHTTP(w, r)
return
}
_, isAllowed := allowed[origin]
switch {
case isAllowed:
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Add("Vary", "Origin")
case wildcard || isRuntimePath(r.URL.Path):
// `*` and credentials together are rejected by browsers, so this branch
// deliberately does not set Allow-Credentials: the runtime needs none,
// and the SDK sends credentials:'omit' precisely so it stays unreadable
// as a credentialed call.
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Add("Vary", "Origin")
}
if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-Request-ID")
w.Header().Set("Access-Control-Max-Age", "600")
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// TrustedProxyMiddleware resolves the client address, honouring forwarding
// headers only from configured proxies.
//
// Without an allow-list, any client could set X-Forwarded-For and spoof its
// address in the audit log, so an unconfigured deployment ignores the headers
// entirely and records the direct peer.
func TrustedProxyMiddleware(cfg config.Config) func(http.Handler) http.Handler {
nets := make([]*net.IPNet, 0, len(cfg.TrustedProxies))
for _, entry := range cfg.TrustedProxies {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
if _, network, err := net.ParseCIDR(entry); err == nil {
nets = append(nets, network)
continue
}
if ip := net.ParseIP(entry); ip != nil {
bits := 32
if ip.To4() == nil {
bits = 128
}
nets = append(nets, &net.IPNet{IP: ip, Mask: net.CIDRMask(bits, bits)})
}
}
trusted := func(remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, network := range nets {
if network.Contains(ip) {
return true
}
}
return false
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
addr := r.RemoteAddr
if trusted(r.RemoteAddr) {
if forwarded := clientFromForwardedFor(r.Header.Get("X-Forwarded-For")); forwarded != "" {
addr = forwarded
}
}
next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, ctxKeyClientIP, addr)))
})
}
}
// ctxKeyClientIP stores the resolved client address.
var ctxKeyClientIP contextKey = 100
// clientFromForwardedFor returns the left-most entry of an X-Forwarded-For
// header, which is the original client when every proxy in the chain appends.
func clientFromForwardedFor(header string) string {
if header == "" {
return ""
}
first, _, _ := strings.Cut(header, ",")
return strings.TrimSpace(first)
}
// clientIPFrom returns the resolved client address.
func clientIPFrom(ctx context.Context) string {
addr, _ := ctx.Value(ctxKeyClientIP).(string)
if addr == "" {
return ""
}
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}
// ------------------------------------------------------------- rate limit --
// RateLimiter is a fixed-window counter keyed by client address and route class.
//
// A fixed window is chosen over a sliding log deliberately: it needs one counter
// per client rather than a list of timestamps, so its memory is O(clients) and it
// cannot be made to degrade by a client sending requests at a high rate. The
// tradeoff is a burst of up to 2x the limit across a window boundary, which is
// acceptable for the abuse it defends against.
type RateLimiter struct {
limit int
window time.Duration
mu sync.Mutex
hits map[string]*counter
}
// counter is one client's window.
type counter struct {
count int
reset time.Time
}
// NewRateLimiter builds a limiter. A limit of zero disables rate limiting.
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
if window <= 0 {
window = time.Minute
}
limiter := &RateLimiter{
limit: limit,
window: window,
hits: make(map[string]*counter),
}
go limiter.reap()
return limiter
}
// reap evicts expired counters so a long-running process does not accumulate one
// entry per address it has ever seen.
func (l *RateLimiter) reap() {
ticker := time.NewTicker(l.window)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
l.mu.Lock()
for key, c := range l.hits {
if now.After(c.reset) {
delete(l.hits, key)
}
}
l.mu.Unlock()
}
}
// Allow records a hit and reports whether it is within the limit.
func (l *RateLimiter) Allow(key string) bool {
if l.limit <= 0 {
return true
}
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
c, ok := l.hits[key]
if !ok || now.After(c.reset) {
l.hits[key] = &counter{count: 1, reset: now.Add(l.window)}
return true
}
c.count++
return c.count <= l.limit
}
// RetryAfter returns how long a client should wait, in seconds.
func (l *RateLimiter) RetryAfter(key string) int {
l.mu.Lock()
defer l.mu.Unlock()
if c, ok := l.hits[key]; ok {
remaining := time.Until(c.reset)
if remaining > 0 {
return int(remaining.Seconds()) + 1
}
}
return int(l.window.Seconds()) + 1
}
// RateLimitMiddleware applies a limiter keyed by client address.
func RateLimitMiddleware(limiter *RateLimiter) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Method + " " + routeClass(r.URL.Path)
if !limiter.Allow(key + " " + clientIPFrom(r.Context())) {
w.Header().Set("Retry-After", itoa(limiter.RetryAfter(key+" "+clientIPFrom(r.Context()))))
writeError(w, r, http.StatusTooManyRequests, CodeRateLimited,
"too many requests; retry after the indicated delay", nil)
return
}
next.ServeHTTP(w, r)
})
}
}
// routeClass collapses a path to its first two segments so that per-theme
// requests share one bucket instead of letting an attacker allocate an unbounded
// number of buckets by varying the slug.
func routeClass(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) > 2 {
parts = parts[:2]
}
return strings.Join(parts, "/")
}
// --------------------------------------------------------------- recover --
// RecoverMiddleware turns a panic into a 500 without taking the process down.
func RecoverMiddleware(log Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//nolint:contextcheck // r carries the request context, and writeError
// and writeJSON both read it from r. The closure spawns no goroutine and
// creates no derived context, so there is nothing for it to pass on. The
// rule fires on every deferred recover closure, which is why the reason is
// recorded here rather than at the call site of writeError.
defer func() {
if recovered := recover(); recovered != nil {
// A broken pipe is a normal consequence of a client navigating
// away, not a server fault, so it is logged at debug level.
log.Error("panic recovered",
"error", recovered,
"method", r.Method,
"path", r.URL.Path,
"requestId", requestIDFrom(r.Context()),
)
writeError(w, r, http.StatusInternalServerError, CodeInternal,
"the server encountered an unexpected condition", nil)
}
}()
next.ServeHTTP(w, r)
})
}
}
// ------------------------------------------------------------ body limits --
// MaxBodyBytes is the largest request body the admin API accepts. A theme
// document is a few kilobytes; this leaves generous headroom while bounding the
// memory a single request can consume.
const MaxBodyBytes = 2 << 20 // 2 MiB
// BodyLimitMiddleware caps request bodies and rejects oversized payloads.
func BodyLimitMiddleware(limit int64) func(http.Handler) http.Handler {
if limit <= 0 {
limit = MaxBodyBytes
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > limit {
writeError(w, r, http.StatusRequestEntityTooLarge, CodePayloadTooBig,
"request body is too large", nil)
return
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, limit)
}
next.ServeHTTP(w, r)
})
}
}

View file

@ -0,0 +1,75 @@
package httpapi
import (
"net/http"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/store"
)
// PreferenceHandlers serves the caller's own interface preferences.
//
// A preference belongs to the account rather than to a browser, so it is stored
// against the identity subject and travels with the person to another device. It
// is deliberately not part of the session token: the token is issued by the
// identity provider and this service does not control its claims, so a choice made
// here has to be read back from this API.
type PreferenceHandlers struct {
store *store.Store
log Logger
}
// NewPreferenceHandlers builds the preference handler set.
func NewPreferenceHandlers(st *store.Store, log Logger) *PreferenceHandlers {
return &PreferenceHandlers{store: st, log: log}
}
// Get returns the caller's preferences, or the empty preference when they have
// chosen nothing yet.
func (h *PreferenceHandlers) Get(w http.ResponseWriter, r *http.Request) {
identity := mustIdentity(r.Context())
prefs, err := h.store.Preferences(r.Context(), identity.Subject)
if err != nil {
h.log.Error("read preferences", "subject", identity.Subject, "error", err)
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, prefs)
}
// Update records the caller's preferences and echoes back what was stored, so the
// client renders the server's decision rather than its own input.
//
// An empty selectedTheme clears the choice. Clearing through the same endpoint
// rather than a second route keeps the write surface to one path, which matters
// because this endpoint is the only thing that writes the row.
func (h *PreferenceHandlers) Update(w http.ResponseWriter, r *http.Request) {
identity := mustIdentity(r.Context())
var request domain.UserPreferences
if err := decodeJSON(r, &request); err != nil {
writeProblem(w, r, err)
return
}
stored, err := h.store.SetPreferences(r.Context(), identity.Subject, request)
if err != nil {
h.log.Warn("update preferences", "subject", identity.Subject, "error", err)
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, stored)
}
// Reset forgets the caller's preferences and returns them to their defaults.
func (h *PreferenceHandlers) Reset(w http.ResponseWriter, r *http.Request) {
identity := mustIdentity(r.Context())
if _, err := h.store.ClearPreferences(r.Context(), identity.Subject); err != nil {
h.log.Error("clear preferences", "subject", identity.Subject, "error", err)
writeProblem(w, r, err)
return
}
writeJSON(w, r, http.StatusOK, domain.UserPreferences{})
}

View file

@ -0,0 +1,232 @@
package httpapi_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"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/httpapi"
"github.com/urpi/go-bananas/internal/logging"
"github.com/urpi/go-bananas/internal/store"
)
// devUser is the account the dev bypass authenticates as. Dev mode is the only
// fixture available without minting a real session cookie: Authenticate reads the
// session or the dev bypass and never consults the login resolver, so a stub
// resolver would simply never run.
const devUser = "dev-user=person@example.test"
// withDevUser appends the dev bypass parameter to a path.
func withDevUser(path string) string {
separator := "?"
if strings.Contains(path, "?") {
separator = "&"
}
return path + separator + devUser
}
// preferenceRouter builds a router that authenticates every caller through the dev
// bypass, against a real store so the migration runs.
func preferenceRouter(t *testing.T) (http.Handler, *store.Store) {
t.Helper()
st, err := store.Open(context.Background(), store.Options{Path: ":memory:"})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
cfg := testConfig(t)
cfg.AuthMode = config.AuthModeDev
cfg.DevFallbackRole = string(domain.RoleViewer)
cfg.SessionSecure = false
authenticator, err := auth.New(cfg, nil, nil)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
return httpapi.NewRouter(httpapi.Deps{
Config: cfg,
Store: st,
Cache: httpapi.NewThemeCache(),
Authenticator: authenticator,
Log: logging.New(logging.Options{Level: "error", Writer: io.Discard}),
Version: "test",
RuntimeDefaults: func() (string, domain.Appearance) {
return "", ""
},
}), st
}
func putJSON(t *testing.T, h http.Handler, path string, body any) *httptest.ResponseRecorder {
t.Helper()
encoded, err := json.Marshal(body)
if err != nil {
t.Fatalf("encode body: %v", err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodPut, path, bytes.NewReader(encoded)))
return rec
}
func deleteRequest(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodDelete, path, nil))
return rec
}
// seedPublishedTheme inserts a published theme, since a preference may only name
// one that runtime consumers can load.
func seedPublishedTheme(t *testing.T, st *store.Store, slug string) {
t.Helper()
ctx := context.Background()
theme := &domain.Theme{
Slug: slug, Name: slug, Status: domain.StatusDraft, DefaultAppearance: domain.AppearanceLight,
Tokens: domain.TokenDoc{},
}
if err := st.Create(ctx, theme, ""); err != nil {
t.Fatalf("create theme: %v", err)
}
published := domain.StatusPublished
if _, err := st.Update(ctx, slug, store.UpdateFields{Status: &published}); err != nil {
t.Fatalf("publish theme: %v", err)
}
}
func decodeBody[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var value T
if err := json.Unmarshal(rec.Body.Bytes(), &value); err != nil {
t.Fatalf("decode %q: %v", rec.Body.String(), err)
}
return value
}
func TestPreferencesEndpointRoundTrip(t *testing.T) {
router, st := preferenceRouter(t)
seedPublishedTheme(t, st, "midnight")
// An account that has never chosen reads as empty.
got := decodeBody[domain.UserPreferences](t, get(t, router, withDevUser("/api/v1/me/preferences")))
if got.SelectedTheme != "" {
t.Fatalf("initial = %+v, want empty", got)
}
rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), domain.UserPreferences{
SelectedTheme: "midnight", SelectedAppearance: domain.AppearanceDark,
})
if rec.Code != http.StatusOK {
t.Fatalf("put = %d, want 200: %s", rec.Code, rec.Body.String())
}
if echoed := decodeBody[domain.UserPreferences](t, rec); echoed.SelectedTheme != "midnight" ||
echoed.SelectedAppearance != domain.AppearanceDark {
t.Fatalf("echoed = %+v", echoed)
}
got = decodeBody[domain.UserPreferences](t, get(t, router, withDevUser("/api/v1/me/preferences")))
if got.SelectedTheme != "midnight" || got.SelectedAppearance != domain.AppearanceDark {
t.Fatalf("read back = %+v", got)
}
if rec := deleteRequest(t, router, withDevUser("/api/v1/me/preferences")); rec.Code != http.StatusOK {
t.Fatalf("delete = %d, want 200: %s", rec.Code, rec.Body.String())
}
got = decodeBody[domain.UserPreferences](t, get(t, router, withDevUser("/api/v1/me/preferences")))
if got.SelectedTheme != "" {
t.Fatalf("after reset = %+v, want empty", got)
}
}
func TestPreferencesRejectAnUnusableTheme(t *testing.T) {
router, st := preferenceRouter(t)
seedPublishedTheme(t, st, "midnight")
for name, body := range map[string]domain.UserPreferences{
"unknown theme": {SelectedTheme: "no-such-theme"},
"bad slug": {SelectedTheme: "Not A Slug"},
"bad appearance": {
SelectedTheme: "midnight", SelectedAppearance: "sepia",
},
} {
t.Run(name, func(t *testing.T) {
rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), body)
// A malformed preference is the caller's mistake, so a 400 rather than
// the 500 an unwrapped validation error would produce.
if rec.Code != http.StatusBadRequest {
t.Fatalf("put = %d, want 400: %s", rec.Code, rec.Body.String())
}
})
}
}
// TestSessionCarriesPreferences is what lets the UI preselect a theme from the same
// round trip that establishes who the caller is.
func TestSessionCarriesPreferences(t *testing.T) {
router, st := preferenceRouter(t)
seedPublishedTheme(t, st, "midnight")
if rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), domain.UserPreferences{
SelectedTheme: "midnight", SelectedAppearance: domain.AppearanceLight,
}); rec.Code != http.StatusOK {
t.Fatalf("put = %d: %s", rec.Code, rec.Body.String())
}
rec := get(t, router, withDevUser("/api/v1/auth/me"))
if rec.Code != http.StatusOK {
t.Fatalf("me = %d: %s", rec.Code, rec.Body.String())
}
var payload struct {
Authenticated bool `json:"authenticated"`
Preferences domain.UserPreferences `json:"preferences"`
Identity *domain.Identity `json:"identity"`
Extra map[string]json.RawMessage `json:"-"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode session: %v", err)
}
if !payload.Authenticated || payload.Identity == nil {
t.Fatalf("session = %+v, want an authenticated caller", payload)
}
if payload.Preferences.SelectedTheme != "midnight" ||
payload.Preferences.SelectedAppearance != domain.AppearanceLight {
t.Fatalf("session preferences = %+v", payload.Preferences)
}
}
// TestAnonymousSessionOmitsPreferences keeps the unauthenticated response free of
// anything about a person, since that endpoint is reachable without a session.
func TestAnonymousSessionOmitsPreferences(t *testing.T) {
router := newUnauthenticatedRouter(t)
rec := get(t, router, withDevUser("/api/v1/auth/me"))
if rec.Code != http.StatusOK {
t.Fatalf("me = %d: %s", rec.Code, rec.Body.String())
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode session: %v", err)
}
if _, present := payload["preferences"]; present {
t.Error("an anonymous session must not carry preferences")
}
}
func TestPreferencesRequireASession(t *testing.T) {
router := newUnauthenticatedRouter(t)
if rec := get(t, router, withDevUser("/api/v1/me/preferences")); rec.Code != http.StatusUnauthorized {
t.Fatalf("get = %d, want 401", rec.Code)
}
rec := putJSON(t, router, withDevUser("/api/v1/me/preferences"), domain.UserPreferences{})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("put = %d, want 401: %s", rec.Code, rec.Body.String())
}
}

195
internal/httpapi/respond.go Normal file
View file

@ -0,0 +1,195 @@
// Package httpapi contains the HTTP transport: routing, middleware and
// handlers.
//
// The API is split into two surfaces with deliberately different postures:
//
// - /api/v1 is the administrative API. It requires authentication, it is never
// cached, and every mutation is audited.
// - /rt/v1 is the runtime API. It is what browsers and applications fetch, it
// is designed to be cached aggressively at the CDN, and it is public by
// default because a theming service that requires a credential cannot be
// consumed from a stylesheet link tag.
//
// Handlers contain no business logic. They decode a request, call the store,
// encode a response and translate errors. Anything that is worth reasoning about
// lives in the domain or tokenengine packages where it can be tested without an
// HTTP server.
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/urpi/go-bananas/internal/domain"
)
// APIError is the uniform error body returned by the API.
//
// A stable machine-readable code is what lets a client react without parsing
// prose, and the human message exists for the log line and the terminal.
type APIError struct {
// Code is a stable, machine-readable identifier.
Code string `json:"code"`
// Message is a human-readable explanation.
Message string `json:"message"`
// Details carries per-field validation problems, when applicable.
Details []FieldError `json:"details,omitempty"`
// RequestID correlates the response with server logs.
RequestID string `json:"requestId,omitempty"`
}
// FieldError is one validation problem.
type FieldError struct {
// Field is the JSON path or dotted token path that failed.
Field string `json:"field"`
// Message explains the problem.
Message string `json:"message"`
}
// Error codes. These are part of the API contract: a client may branch on them,
// so they must not change meaning between releases.
const (
CodeBadRequest = "bad_request"
CodeValidation = "validation_failed"
CodeUnauthorized = "unauthorized"
CodeForbidden = "forbidden"
CodeNotFound = "not_found"
CodeConflict = "conflict"
CodeImmutable = "immutable"
CodeRateLimited = "rate_limited"
CodeInternal = "internal_error"
CodeNotConfigured = "not_configured"
CodeUnsupported = "unsupported_media_type"
CodePayloadTooBig = "payload_too_large"
)
// writeJSON encodes a value as JSON with the given status.
func writeJSON(w http.ResponseWriter, r *http.Request, status int, payload any) {
body, err := marshalJSON(payload)
if err != nil {
// Encoding failed, which means the handler produced something
// unserialisable. Report it as an internal error rather than panicking
// on the request goroutine.
writeError(w, r, status, CodeInternal, "response could not be encoded", nil)
return
}
header := w.Header()
header.Set("Content-Type", "application/json; charset=utf-8")
// Admin responses are per-user and change on every write; they must never be
// cached by an intermediary.
header.Set("Cache-Control", "no-store")
w.WriteHeader(status)
if r.Method == http.MethodHead {
return
}
//nolint:gosec // G705: body is a marshalled APIError with no user-controlled
// string fields. The Content-Type above is application/json, so it is not
// interpreted as a document by the browser even if a field were.
_, _ = w.Write(body)
}
// writeError emits a uniform error body.
func writeError(w http.ResponseWriter, r *http.Request, status int, code, message string, details []FieldError) {
payload := APIError{Code: code, Message: message, Details: details}
if id := requestIDFrom(r.Context()); id != "" {
payload.RequestID = id
}
writeJSON(w, r, status, payload)
}
// writeProblem translates a domain sentinel into an HTTP status and error code.
//
// This is the single mapping point between the domain's error vocabulary and the
// wire. Handlers therefore never choose status codes themselves, which is what
// keeps the API consistent.
func writeProblem(w http.ResponseWriter, r *http.Request, err error) {
switch {
case err == nil:
return
case errors.Is(err, domain.ErrInvalid):
writeError(w, r, http.StatusBadRequest, CodeValidation, cleanError(err), nil)
case errors.Is(err, domain.ErrUnauthorized):
writeError(w, r, http.StatusUnauthorized, CodeUnauthorized, cleanError(err), nil)
case errors.Is(err, domain.ErrForbidden):
writeError(w, r, http.StatusForbidden, CodeForbidden, cleanError(err), nil)
case errors.Is(err, domain.ErrNotFound):
writeError(w, r, http.StatusNotFound, CodeNotFound, cleanError(err), nil)
case errors.Is(err, domain.ErrConflict):
writeError(w, r, http.StatusConflict, CodeConflict, cleanError(err), nil)
case errors.Is(err, domain.ErrImmutable):
writeError(w, r, http.StatusConflict, CodeImmutable, cleanError(err), nil)
default:
writeError(w, r, http.StatusInternalServerError, CodeInternal, cleanError(err), nil)
}
}
// cleanError strips the sentinel prefix from an error message so the client sees
// the useful part, and truncates anything pathologically long.
func cleanError(err error) string {
if err == nil {
return ""
}
msg := err.Error()
// The sentinels are phrased as "invalid input: <detail>"; the prefix adds
// nothing once the status code has been chosen.
for _, prefix := range []string{
"invalid input: ", "conflict: ", "not found: ", "forbidden: ",
"unauthorized: ", "resource is immutable: ",
} {
msg = strings.TrimPrefix(msg, prefix)
}
if len(msg) > 512 {
msg = msg[:512] + "…"
}
return msg
}
// queryInt reads an integer query parameter with bounds.
//
// The bounds are named lowest and highest rather than min and max because the
// latter are builtins since Go 1.21, and shadowing them here would silently
// replace the language function with a parameter everywhere in this body.
func queryInt(r *http.Request, name string, fallback, lowest, highest int) int {
raw := strings.TrimSpace(r.URL.Query().Get(name))
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
if value < lowest {
return lowest
}
if value > highest {
return highest
}
return value
}
// requireRole returns middleware that refuses callers below the given role.
//
// Roles are checked against the highest role the identity carries, so an admin
// may always call an editor or viewer route. The check happens in the server
// rather than the UI, because a hidden button is not an authorisation control.
func requireRole(minimum domain.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
identity, err := identityFrom(r.Context())
if err != nil || identity == nil {
writeError(w, r, http.StatusUnauthorized, CodeUnauthorized,
"authentication is required", nil)
return
}
if !identity.HighestRole().AtLeast(minimum) {
writeError(w, r, http.StatusForbidden, CodeForbidden,
"this action requires the "+string(minimum)+" role", nil)
return
}
next.ServeHTTP(w, r)
})
}
}

280
internal/httpapi/router.go Normal file
View 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(),
})
}

View file

@ -0,0 +1,421 @@
package httpapi_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"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/httpapi"
"github.com/urpi/go-bananas/internal/logging"
"github.com/urpi/go-bananas/internal/seed"
"github.com/urpi/go-bananas/internal/store"
)
// These tests cover the routing posture of the HTTP surface, which is the part
// that cannot be verified by testing handlers in isolation. A handler that behaves
// correctly but is mounted on the wrong side of the authentication middleware is
// still a vulnerability, and no amount of handler testing would catch it.
//
// The split being asserted is:
// - /rt/v1 and session discovery are reachable without a session;
// - everything else under /api/v1 requires one;
// - the two exceptions are the only exceptions.
// testConfig builds a minimal development configuration.
func testConfig(t *testing.T) config.Config {
t.Helper()
cfg := config.Config{
Environment: "test",
AuthMode: config.AuthModeNone,
PublicBaseURL: "http://localhost:8080",
SessionSecret: "0123456789abcdef0123456789abcdef",
SessionSameSite: "Lax",
DatabasePath: ":memory:",
DatabaseMaxConns: 4,
RateLimitPerMinute: 600,
}
if err := cfg.Validate(); err != nil {
t.Fatalf("test config is invalid: %v", err)
}
return cfg
}
// newTestRouter builds a router over an in-memory database with authentication
// disabled.
//
// AUTH_MODE=none is not the same as "anonymous": it is a deliberate deployment
// choice to grant every caller admin, so it is the right fixture for exercising
// unauthenticated *routing* (probes, the runtime API, the 404 handler) but not
// authentication itself.
func newTestRouter(t *testing.T) http.Handler {
t.Helper()
cfg := testConfig(t)
cfg.AuthMode = config.AuthModeNone
return buildRouter(t, cfg, nil)
}
// newUnauthenticatedRouter builds a router in OIDC mode with no session.
//
// This is the fixture for the authentication tests: every request arrives
// anonymous and must be rejected, which is exactly the state a signed-out browser
// is in. The resolver is a stub so that no identity provider is contacted.
func newUnauthenticatedRouter(t *testing.T) http.Handler {
t.Helper()
cfg := testConfig(t)
cfg.AuthMode = config.AuthModeOIDC
cfg.OIDCIssuer = "https://auth-dev.urpi.be/realms/urpi"
cfg.OIDCClientID = "go-bananas"
cfg.OIDCClientSecret = "test-client-secret"
cfg.SessionSecure = false
return buildRouter(t, cfg, rejectingResolver{})
}
// newRuntimeRouter builds a router whose store has the built-in themes seeded, which
// is what the runtime endpoints need in order to have anything to serve.
func newRuntimeRouter(t *testing.T) http.Handler {
t.Helper()
h, st := buildRouterWithStore(t, testConfig(t), nil)
if _, _, err := st.Seed(context.Background(), seed.Themes()); err != nil {
t.Fatalf("seed built-in themes: %v", err)
}
return h
}
// buildRouter wires a router for a configuration and an optional resolver.
func buildRouter(t *testing.T, cfg config.Config, resolver auth.Resolver) http.Handler {
t.Helper()
h, _ := buildRouterWithStore(t, cfg, resolver)
return h
}
// buildRouterWithStore wires a router and hands back its store, so a test can seed
// or assert against the same database the handlers read.
func buildRouterWithStore(t *testing.T, cfg config.Config, resolver auth.Resolver) (http.Handler, *store.Store) {
t.Helper()
st, err := store.Open(context.Background(), store.Options{Path: ":memory:"})
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
// The logger discards its output: these tests assert on HTTP status codes, and
// a passing run would otherwise fill the test log with request lines.
log := logging.New(logging.Options{Level: "error", Writer: io.Discard})
authenticator, err := auth.New(cfg, resolver, nil)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
return httpapi.NewRouter(httpapi.Deps{
Config: cfg,
Store: st,
Cache: httpapi.NewThemeCache(),
Authenticator: authenticator,
Log: log,
Version: "test",
RuntimeDefaults: func() (string, domain.Appearance) {
return "", ""
},
}), st
}
// get performs a request and returns the recorder.
func get(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil))
return rec
}
// TestSessionEndpointsAreReachableWithoutASession is the regression test for the
// mistake that made the admin UI unable to start a login: session introspection sat
// inside the authenticated subtree, so the UI received 401 for the one request it
// needs in order to discover that it is not logged in.
func TestSessionEndpointsAreReachableWithoutASession(t *testing.T) {
router := newUnauthenticatedRouter(t)
for _, path := range []string{"/api/v1/auth/me", "/api/v1/auth/config"} {
t.Run(path, func(t *testing.T) {
rec := get(t, router, path)
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200: %s", rec.Code, rec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
if _, ok := body["mode"]; !ok {
t.Error("response does not report the auth mode")
}
})
}
}
// TestMeReportsAnonymityRatherThanFailing checks that an absent session is a 200
// with an explicit flag, not an error status. The UI treats this as the normal
// first-load state.
func TestMeReportsAnonymityRatherThanFailing(t *testing.T) {
rec := get(t, newUnauthenticatedRouter(t), "/api/v1/auth/me")
if rec.Code != http.StatusOK {
t.Fatalf("got %d, want 200", rec.Code)
}
var body struct {
Authenticated bool `json:"authenticated"`
Mode string `json:"mode"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Authenticated {
t.Error("anonymous request reported as authenticated")
}
if body.Mode == "" {
t.Error("auth mode is empty")
}
}
// TestAdminAPIRequiresASession is the other half of the contract: moving the two
// session endpoints out of the guarded subtree must not have opened the rest of
// /api/v1. Each route is checked individually, because a single unguarded route is
// the whole vulnerability and testing the group as a whole would not localise it.
func TestAdminAPIRequiresASession(t *testing.T) {
router := newUnauthenticatedRouter(t)
protected := []struct {
method string
path string
}{
{http.MethodGet, "/api/v1/meta"},
{http.MethodGet, "/api/v1/stats"},
{http.MethodGet, "/api/v1/themes"},
{http.MethodGet, "/api/v1/themes/midnight"},
{http.MethodGet, "/api/v1/themes/midnight/export"},
{http.MethodGet, "/api/v1/themes/midnight/versions"},
{http.MethodGet, "/api/v1/themes/midnight/diff"},
{http.MethodPost, "/api/v1/themes"},
{http.MethodPost, "/api/v1/tokens/validate"},
{http.MethodGet, "/api/v1/tokens/schema"},
{http.MethodGet, "/api/v1/keys"},
{http.MethodGet, "/api/v1/admin/users"},
{http.MethodGet, "/api/v1/admin/audit"},
{http.MethodGet, "/api/v1/admin/settings"},
}
for _, tc := range protected {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), tc.method, tc.path, nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("got %d, want 401: %s", rec.Code, rec.Body.String())
}
})
}
}
// TestRuntimeIsPublic checks that the runtime API is not behind authentication. A
// theme is a public asset by design: the whole point is that a browser can link to
// it with no credentials, which is also why it has its own rate limiter.
//
// The assertion is deliberately "not 401" rather than "200". With no published
// theme these endpoints legitimately answer 400 or 404, and both are fine here —
// what would not be fine is a 401, which would mean the request never reached the
// handler. Checking for the absence of the failure mode rather than the presence of
// a success keeps the test meaningful whether or not a theme exists.
func TestRuntimeIsPublic(t *testing.T) {
router := newTestRouter(t)
for _, path := range []string{
"/rt/v1/manifest.json",
"/rt/v1/theme.css",
"/rt/v1/theme.json",
"/rt/v1/embed",
"/rt/v1/themes/midnight/theme.css",
} {
t.Run(path, func(t *testing.T) {
rec := get(t, router, path)
if rec.Code == http.StatusUnauthorized {
t.Errorf("runtime endpoint demanded authentication: %s", rec.Body.String())
}
if rec.Code == http.StatusNotFound && strings.Contains(rec.Body.String(), "endpoint") {
t.Errorf("runtime route is not registered: %s", rec.Body.String())
}
})
}
}
// TestRuntimeIsReadableCrossOrigin checks that a consuming application on another
// host can read the styling contract.
//
// This is not a hypothetical: every sibling application is on its own host, so in a
// real deployment all of them read this tree cross-origin, and two of those reads are
// enforced by the platform rather than negotiated. An ES module <script> is always
// fetched under CORS rules, and fetch() from another origin needs the header. The
// failure this guards against is silent and total: the control renders as an empty
// box and reports no palettes, while every same-origin test still passes.
func TestRuntimeIsReadableCrossOrigin(t *testing.T) {
router := newTestRouter(t)
for _, path := range []string{
"/rt/v1/contract.json",
"/rt/v1/contract.css",
"/rt/v1/components.js",
"/rt/v1/manifest.json",
} {
t.Run(path, func(t *testing.T) {
// The Origin header is what makes this a cross-origin read at all. Without
// it the middleware deliberately adds nothing, so a test that omits it is
// not testing the policy — it is testing the same-origin path under a
// misleading name.
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, path, nil)
req.Header.Set("Origin", "https://tell-dev.urpi.be")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin = %q, want \"*\" so another host can read it", got)
}
if !strings.Contains(rec.Header().Get("Vary"), "Origin") {
t.Error("Vary must include Origin, or a shared cache can serve one caller's entry to another")
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
// Browsers reject a credentialed wildcard, and the runtime needs no
// credentials: the SDK sends credentials:'omit'.
t.Errorf("Access-Control-Allow-Credentials = %q, want it absent alongside a wildcard", got)
}
})
}
}
// TestAdminAPIIsNotOpenedByTheRuntimePolicy is the counterpart to the test above.
//
// Opening the runtime tree to any origin is only safe because that tree is public
// styling. The admin API sits on the same router and is per-user and credentialed, so
// it must stay on the allow-list. A fix that simply sent "*" everywhere would make the
// runtime work and quietly expose the admin API, and the runtime tests alone would
// still pass.
//
// The router in this test has no configured origins, which is the default deployment
// shape, so the expectation is that nothing is allowed.
func TestAdminAPIIsNotOpenedByTheRuntimePolicy(t *testing.T) {
router := newTestRouter(t)
for _, path := range []string{"/api/v1/session", "/api/v1/themes"} {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, nil)
req.Header.Set("Origin", "https://tell-dev.urpi.be")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Access-Control-Allow-Origin = %q on %s, want none: the admin API is per-user and must stay restricted", got, path)
}
})
}
}
// TestAdminAPIAllowsAConfiguredOrigin checks that the restriction is an allow-list and
// not a blanket refusal: an operator who configures an origin for their own tooling
// should get it working, otherwise the safe default becomes permanent breakage with no
// way out.
func TestAdminAPIAllowsAConfiguredOrigin(t *testing.T) {
cfg := testConfig(t)
cfg.AuthMode = config.AuthModeNone
cfg.CORSAllowedOrigins = []string{"https://admin.example"}
router := buildRouter(t, cfg, nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/session", nil)
req.Header.Set("Origin", "https://admin.example")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://admin.example" {
t.Errorf("Access-Control-Allow-Origin = %q, want the configured origin echoed back", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "true" {
t.Errorf("Access-Control-Allow-Credentials = %q, want \"true\" for a credentialed allow-listed origin", got)
}
}
// TestRuntimeAnswersPreflight checks the OPTIONS branch. The runtime is read-only, so
// no consumer needs a preflight today; answering it is still correct, and refusing it
// would be a confusing way to express that.
//
// It goes through the router rather than the middleware directly because the router is
// what a real preflight arrives at, and the branch could be mounted wrongly without
// that ever showing up in a unit test of the middleware alone.
func TestRuntimeAnswersPreflight(t *testing.T) {
req := httptest.NewRequestWithContext(
context.Background(), http.MethodOptions, "/rt/v1/contract.json", nil)
req.Header.Set("Origin", "https://tell-dev.urpi.be")
// A real preflight names the method it wants to use. Without this header the
// request is not a preflight, and asserting 204 on it would be asserting on a
// case no browser ever sends — which is how this test passed against a 405.
req.Header.Set("Access-Control-Request-Method", "GET")
rec := httptest.NewRecorder()
newTestRouter(t).ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status %d, want %d", rec.Code, http.StatusNoContent)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin = %q on the preflight, want \"*\"", got)
}
}
// TestUnknownAPIEndpointIsJSONNotTheSPA checks that a typo under /api/v1 returns a
// machine-readable error. Falling through to the single-page app there would hand
// an HTML document to a fetch() call, and the client would report a JSON parse
// error instead of the 404 that actually happened.
func TestUnknownAPIEndpointIsJSONNotTheSPA(t *testing.T) {
rec := get(t, newTestRouter(t), "/api/v1/does-not-exist")
if rec.Code != http.StatusNotFound {
t.Fatalf("got %d, want 404", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json; charset=utf-8" {
t.Errorf("content type %q, want JSON", ct)
}
}
// TestProbesArePublic checks the health endpoints, which a kubelet must be able to
// reach without credentials or they would restart a healthy pod forever.
func TestProbesArePublic(t *testing.T) {
router := newTestRouter(t)
for _, path := range []string{"/healthz", "/livez", "/readyz"} {
t.Run(path, func(t *testing.T) {
if rec := get(t, router, path); rec.Code != http.StatusOK {
t.Errorf("got %d, want 200: %s", rec.Code, rec.Body.String())
}
})
}
}
// rejectingResolver is an OIDC resolver that authenticates nobody.
//
// It exists so the router can be built in OIDC mode without contacting a real
// provider: New refuses to start in OIDC mode without a resolver, and the
// authentication tests need a mode where a missing session is actually rejected.
type rejectingResolver struct{}
func (rejectingResolver) Begin(context.Context, *http.Request, http.ResponseWriter, string) error {
return nil
}
func (rejectingResolver) Complete(context.Context, *http.Request) (*auth.Login, error) {
return nil, auth.ErrNoSession
}
func (rejectingResolver) End(context.Context, string) string { return "" }
func (rejectingResolver) Enabled() bool { return true }
func (rejectingResolver) ClearState(http.ResponseWriter) {}
func (rejectingResolver) LogoutURL(string) string { return "" }

761
internal/httpapi/runtime.go Normal file
View file

@ -0,0 +1,761 @@
package httpapi
import (
"context"
"html"
"net/http"
"strconv"
"strings"
"time"
"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"
)
// RuntimeHandlers serves the public, cacheable theming endpoints.
//
// Everything here is designed around one fact: the most common consumer is a
// browser following a <link rel="stylesheet"> tag, often on a page the developer
// has never seen go-bananas before. That means the URLs must be predictable, the
// responses must be immutable-by-URL so a CDN can cache them indefinitely, and a
// request must never require a credential.
type RuntimeHandlers struct {
store *store.Store
cache *ThemeCache
config config.Config
log Logger
// defaults is the manifest default, resolved from instance settings.
defaults func() (slug string, appearance domain.Appearance)
}
// NewRuntimeHandlers builds the runtime handler set.
func NewRuntimeHandlers(
store *store.Store,
cache *ThemeCache,
cfg config.Config,
log Logger,
defaults func() (string, domain.Appearance),
) *RuntimeHandlers {
return &RuntimeHandlers{
store: store,
cache: cache,
config: cfg,
log: log,
defaults: defaults,
}
}
// ThemeCSS serves a theme's stylesheet.
//
// This is the endpoint the product exists for. It answers with immutable-by-URL
// caching: the URL contains the theme slug and its version, so a publish changes
// the URL and every cached copy becomes unreachable rather than stale.
func (h *RuntimeHandlers) ThemeCSS(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
if slug == "" {
slug = themeSlugFromPath(r)
}
if slug == "" {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"a theme slug is required, either as ?slug= or as a path segment", nil)
return
}
// An absent appearance is left empty rather than defaulted to light, so that
// the renderer falls back to the theme's own default. Hard-coding light here
// would silently serve a dark-first theme's light scheme to every consumer
// that did not pass the parameter.
appearance, err := parseOptionalAppearance(r.URL.Query().Get("appearance"))
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil)
return
}
variant := strings.TrimSpace(r.URL.Query().Get("variant"))
if variant == "" {
variant = tokenengine.VariantFull
}
if !validVariant(variant) {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"unknown variant "+variant+"; expected one of "+strings.Join(tokenengine.VariantNames(), ", "), nil)
return
}
// A pinned version lets a consumer resolve a specific published revision
// even after newer ones ship, which is what makes a safe deploy possible.
pinned := int64(0)
if raw := strings.TrimSpace(r.URL.Query().Get("v")); raw != "" {
parsed, convErr := strconv.ParseInt(raw, 10, 64)
if convErr != nil || parsed < 1 {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "v must be a positive integer", nil)
return
}
pinned = parsed
}
theme, err := h.resolve(r.Context(), slug, pinned)
if err != nil {
writeProblem(w, r, err)
return
}
if theme.Status == domain.StatusDraft {
// A draft is not part of the runtime contract. Returning 404 rather than
// 403 avoids confirming to a prober that the slug exists.
writeError(w, r, http.StatusNotFound, CodeNotFound, "no such published theme", nil)
return
}
opts := tokenengine.OptionsForVariant(variant, theme.Slug, appearance)
opts.SourceURL = h.sourceURL(r, theme, variant, pinned)
version := theme.Version
if pinned > 0 {
version = pinned
}
css, etag := h.cache.CSS(theme, opts, func() string {
return tokenengine.Render(theme, opts)
})
h.writeCacheable(w, r, []byte(css), "text/css; charset=utf-8", etag, version, theme.UpdatedAt)
}
// ContractCSS serves the shared token contract for every published theme at once.
//
// It exists because a theme selector that has to fetch a stylesheet per palette
// flashes on every switch, and because a consumer wanting one theme should not
// download the other five. So they are two documents:
//
// /rt/v1/contract.css every palette, both appearances. For the selector.
// /rt/v1/theme.css one theme in full. For a single-theme consumer.
//
// With contract.css in place, choosing a palette is a change to one attribute on the
// root element and costs no request at all.
func (h *RuntimeHandlers) ContractCSS(w http.ResponseWriter, r *http.Request) {
themes, err := h.store.ListRuntime(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
if len(themes) == 0 {
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
return
}
body := tokenengine.ContractAll(themes)
header := w.Header()
header.Set("Content-Type", "text/css; charset=utf-8")
header.Set("Cache-Control", h.config.RuntimeCacheControl)
header.Set("Vary", "Accept-Encoding")
// The contract changes only when a theme is published, so the aggregate version
// is what a cache should revalidate against.
header.Set("X-Theme-Version", strconv.Itoa(len(themes)))
_, _ = w.Write([]byte(body))
}
// ContractJSON lists the palettes and appearances a selector can offer, so a consumer
// does not have to parse CSS to populate a menu.
func (h *RuntimeHandlers) ContractJSON(w http.ResponseWriter, r *http.Request) {
themes, err := h.store.ListRuntime(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
if len(themes) == 0 {
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
return
}
writeJSON(w, r, http.StatusOK, map[string]any{
"themes": tokenengine.ContractManifest(themes),
"modes": []map[string]string{
{"value": string(domain.AppearanceLight), "label": "Light"},
{"value": string(domain.AppearanceDark), "label": "Dark"},
},
// The attributes a consumer sets. Emitted rather than hard-coded in the SDK,
// because a host application may already have committed to different ones.
"themeAttribute": tokenengine.ContractThemeAttribute,
"modeAttribute": tokenengine.ContractModeAttribute,
"legacyThemeAttribute": tokenengine.ThemeAttribute,
"legacyModeAttribute": tokenengine.AppearanceAttribute,
"variablePrefix": tokenengine.VariablePrefix,
"stylesheet": h.config.CDNBasis() + "/contract.css",
})
}
// ThemeJSON serves a theme's resolved tokens as JSON.
//
// The payload carries both a flat custom-property map and the nested token
// document, so a consumer can read either without a transformation step.
func (h *RuntimeHandlers) ThemeJSON(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
if slug == "" {
slug = themeSlugFromPath(r)
}
if slug == "" {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "a theme slug is required", nil)
return
}
appearance, err := parseOptionalAppearance(r.URL.Query().Get("appearance"))
if err != nil {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, err.Error(), nil)
return
}
theme, err := h.store.Get(r.Context(), slug)
if err != nil {
writeProblem(w, r, err)
return
}
if theme.Status == domain.StatusDraft {
writeError(w, r, http.StatusNotFound, CodeNotFound, "no such published theme", nil)
return
}
if appearance == "" {
appearance = theme.DefaultAppearance
}
payload, etag := h.cache.JSON(theme, appearance, func() tokenengine.ResolvedTheme {
return tokenengine.JSONPayload(theme, appearance)
})
// The JSON payload varies per appearance, so the ETag must too.
etag = `"` + shortHash(etag+string(appearance)) + `"`
h.writeCacheableJSON(w, r, payload, etag, theme.Version, theme.UpdatedAt)
}
// Manifest serves the theme discovery document.
//
// A loader fetches this once at start-up, shows a theme picker, and then fetches
// only the CSS for the chosen theme. It is deliberately small: no token data, so
// that a page with a theme picker does not download every theme's variables.
func (h *RuntimeHandlers) Manifest(w http.ResponseWriter, r *http.Request) {
themes, err := h.store.ListRuntime(r.Context())
if err != nil {
writeProblem(w, r, err)
return
}
// The fallback is applied at the point of use, because resolveDefaults returns
// "" for a deployment with no default theme and assigning here produced an
// ineffectual value that the linter was right to flag.
defaultSlug, _ := h.resolveDefaults()
base := h.config.CDNBasis()
manifest := tokenengine.Manifest{
APIVersion: tokenengine.RuntimeAPIVersion,
Default: defaultSlug,
BaseURL: base,
Themes: make([]tokenengine.ManifestEntry, 0, len(themes)),
}
for _, theme := range themes {
if theme.Status != domain.StatusPublished {
continue
}
manifest.Themes = append(manifest.Themes, tokenengine.ManifestEntry{
Slug: theme.Slug,
Name: theme.Name,
Description: theme.Description,
DefaultAppearance: theme.DefaultAppearance,
Version: theme.Version,
Tags: tokenengine.Tags(theme.Meta),
CSS: base + "/theme.css?slug=" + theme.Slug,
Tokens: base + "/theme.json?slug=" + theme.Slug,
})
}
if len(manifest.Themes) == 0 {
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
return
}
// The manifest changes whenever a theme is published, so it is revalidated
// on every request rather than cached hard.
w.Header().Set("Cache-Control", "public, max-age=30, stale-while-revalidate=120")
writeJSON(w, r, http.StatusOK, manifest)
}
// resolveDefaults returns the instance-level default theme and appearance.
func (h *RuntimeHandlers) resolveDefaults() (string, domain.Appearance) {
if h.defaults == nil {
return "", ""
}
return h.defaults()
}
// resolve loads a theme, optionally at a pinned version.
func (h *RuntimeHandlers) resolve(c context.Context, slug string, pinned int64) (*domain.Theme, error) {
if pinned == 0 {
return h.store.Get(c, slug)
}
version, err := h.store.Version(c, slug, pinned)
if err != nil {
return nil, err
}
// Reconstruct a theme from the snapshot so a pinned request renders exactly
// what that version rendered.
return &domain.Theme{
ID: version.ThemeID,
Slug: version.Slug,
Name: version.Name,
Description: version.Description,
Status: version.Status,
DefaultAppearance: version.DefaultAppearance,
Tokens: version.Tokens,
Version: version.Version,
UpdatedAt: version.CreatedAt,
}, nil
}
// writeCacheable emits a cacheable asset response, honouring conditional
// requests.
//
// Answering 304 for an unchanged ETag is what keeps a re-deploy cheap for
// consumers: the browser revalidates in a few milliseconds and transfers no body.
func (h *RuntimeHandlers) writeCacheable(
w http.ResponseWriter, r *http.Request,
body []byte, contentType, etag string, version int64, updatedAt time.Time,
) {
header := w.Header()
header.Set("Content-Type", contentType)
header.Set("ETag", etag)
header.Set("Cache-Control", h.config.RuntimeCacheControl)
header.Set("Vary", "Accept-Encoding")
header.Set("X-Theme-Version", strconv.FormatInt(version, 10))
header.Set("X-Theme-Updated-At", updatedAt.UTC().Format(domain.TimeFormat))
// Revalidation: any of these headers may carry the client's cached copy.
if etagMatches(r.Header.Get("If-None-Match"), etag) {
w.WriteHeader(http.StatusNotModified)
return
}
if modifiedSince := r.Header.Get("If-Modified-Since"); modifiedSince != "" {
if since, err := http.ParseTime(modifiedSince); err == nil {
if !updatedAt.UTC().Truncate(time.Second).After(since) {
w.WriteHeader(http.StatusNotModified)
return
}
}
}
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(body)
}
// writeCacheableJSON emits a cacheable JSON response.
func (h *RuntimeHandlers) writeCacheableJSON(
w http.ResponseWriter, r *http.Request,
payload tokenengine.ResolvedTheme, etag string, version int64, updatedAt time.Time,
) {
body, err := marshalJSON(payload)
if err != nil {
writeError(w, r, http.StatusInternalServerError, CodeInternal, "could not encode the theme payload", nil)
return
}
header := w.Header()
header.Set("Content-Type", "application/json; charset=utf-8")
header.Set("ETag", etag)
header.Set("Cache-Control", h.config.RuntimeCacheControl)
header.Set("X-Theme-Version", strconv.FormatInt(version, 10))
header.Set("X-Theme-Updated-At", updatedAt.UTC().Format(domain.TimeFormat))
if etagMatches(r.Header.Get("If-None-Match"), etag) {
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
//nolint:gosec // G705: body is a marshalled JSON theme document.
_, _ = w.Write(body)
}
// etagMatches implements If-None-Match comparison, including the wildcard and
// the weak-validator prefix.
func etagMatches(header, etag string) bool {
header = strings.TrimSpace(header)
if header == "" {
return false
}
if header == "*" {
return true
}
for _, candidate := range strings.Split(header, ",") {
candidate = strings.TrimSpace(candidate)
candidate = strings.TrimPrefix(candidate, "W/")
if candidate == etag {
return true
}
}
return false
}
// sourceURL builds the sourceURL comment embedded in a generated stylesheet so
// that browser devtools can navigate back to the endpoint that produced it.
func (h *RuntimeHandlers) sourceURL(r *http.Request, theme *domain.Theme, variant string, pinned int64) string {
url := *r.URL
query := url.Query()
query.Set("slug", theme.Slug)
query.Set("variant", variant)
if pinned > 0 {
query.Set("v", strconv.FormatInt(pinned, 10))
}
url.RawQuery = query.Encode()
return url.RequestURI()
}
// validVariant reports whether a variant name is known.
func validVariant(variant string) bool {
for _, known := range tokenengine.VariantNames() {
if known == variant {
return true
}
}
return false
}
// parseOptionalAppearance validates an appearance parameter, allowing it to be
// absent so that callers can distinguish "not specified" from "specified as
// light".
func parseOptionalAppearance(raw string) (domain.Appearance, error) {
if strings.TrimSpace(raw) == "" {
return "", nil
}
return domain.ParseAppearance(raw)
}
// themeSlugFromPath extracts a slug from a path such as /rt/v1/themes/{slug}/theme.css.
func themeSlugFromPath(r *http.Request) string {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
for i, part := range parts {
if part == "themes" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
// EmbedSnippet serves a ready-to-paste integration snippet for a theme.
//
// This exists because the single hardest moment for a developer adopting a runtime
// theming service is the very first integration: knowing exactly what to paste.
// Generating it from the deployment's own base URL means the snippet is correct by
// construction.
//
// Three properties this endpoint has to hold, each of which was a bug first:
//
// - It always answers JSON, whatever the target. It used to answer text/html for
// the default target, which meant the admin UI's fetch() could not parse the
// response for the one target the UI defaults to.
// - It validates the slug against the store. It used to echo whatever slug it was
// given, so it would cheerfully generate a snippet for a theme that does not
// exist.
// - It escapes every interpolated value for the language of the target. The HTML
// target escaped; the JavaScript and TSX targets did not, so a slug containing
// a quote produced a snippet that broke out of its string literal. Since the
// output is copy-pasted into someone's application, that was a code injection
// into the consuming app, reachable unauthenticated.
func (h *RuntimeHandlers) EmbedSnippet(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
if slug == "" {
slug = themeSlugFromPath(r)
}
if slug == "" {
if defaultSlug, _ := h.resolveDefaults(); defaultSlug != "" {
slug = defaultSlug
}
}
if slug == "" {
// No slug and no configured default: pick one that exists rather than
// guessing a name, so the snippet is always about a real theme.
themes, err := h.store.ListRuntime(r.Context())
if err != nil || len(themes) == 0 {
writeError(w, r, http.StatusNotFound, CodeNotFound,
"no themes have been published, so there is nothing to generate a snippet for", nil)
return
}
slug = themes[0].Slug
}
// The existence check doubles as the injection guard's precondition: only a slug
// that a theme actually owns can reach the template.
if !isSlugShaped(slug) {
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"a theme slug may contain only lowercase letters, digits and single hyphens", nil)
return
}
theme, err := h.store.Get(r.Context(), slug)
if err != nil {
writeError(w, r, http.StatusNotFound, CodeNotFound,
"no theme named "+strconv.Quote(slug)+" exists", nil)
return
}
target := strings.TrimSpace(r.URL.Query().Get("target"))
if target == "" {
target = "html"
}
base := h.config.CDNBasis()
var payload map[string]any
switch target {
case "html":
payload = map[string]any{
"language": "html",
"snippet": htmlIntegrationSnippet(base, slug),
"note": "Paste into <head>. No build step and no npm dependency: the stylesheet is fetched from this deployment.",
}
case "react":
payload = map[string]any{
"language": "tsx",
"snippet": reactIntegrationSnippet(base, slug),
"note": "Add an import map entry mapping 'go-bananas/runtime' to " +
jsString(base+"/react.js") + ", then wrap your tree in <BananasProvider>.",
}
case "javascript":
payload = map[string]any{
"language": "js",
"snippet": javascriptIntegrationSnippet(base, slug),
"note": "Import from the absolute URL, or add an import map entry for 'go-bananas/runtime'.",
}
default:
// An unknown target used to fall through to the HTML branch, so a typo
// silently returned a page instead of saying so.
writeError(w, r, http.StatusBadRequest, CodeBadRequest,
"target must be html, react or javascript", nil)
return
}
payload["slug"] = theme.Slug
payload["name"] = theme.Name
payload["version"] = theme.Version
payload["baseUrl"] = base
// Snippets embed this deployment's absolute URL, so a CDN in front of it or a
// different public hostname changes every snippet. Nothing here may be cached.
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, r, http.StatusOK, payload)
}
// isSlugShaped reports whether a slug is made only of characters this service
// accepts in one. It is a shape check, not an existence check: it exists so that a
// crafted slug is rejected before it reaches a template, whatever the store would
// have said about it.
func isSlugShaped(slug string) bool {
if slug == "" || len(slug) > 64 {
return false
}
for i, r := range slug {
switch {
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
case r == '-' && i > 0 && i < len(slug)-1:
default:
return false
}
}
return true
}
// jsString quotes a value as a JavaScript single-quoted string literal, or returns
// the empty string when there is no value.
//
// This is the guard the old endpoint was missing. Everything interpolated into a
// JavaScript or TSX snippet goes through here, so no caller-supplied value can
// terminate the literal it sits in.
func jsString(s string) string {
if s == "" {
return ""
}
return "'" + strings.NewReplacer(
`\`, `\\`,
`'`, `\'`,
"\n", `\n`,
"\r", `\r`,
"\t", `\t`,
"<", `<`,
">", `>`,
"&", `&`,
).Replace(s) + "'"
}
// htmlIntegrationSnippet is the smallest useful integration: a stylesheet link and
// one module script.
func htmlIntegrationSnippet(base, slug string) string {
return strings.Join([]string{
`<link rel="stylesheet" href="` + htmlEscape(base) + `/theme.css?slug=` + htmlEscape(slug) + `">`,
`<script type="module">`,
` import { applyTheme, watchSystemAppearance } from ` + jsString(base+"/runtime.js") + `;`,
` watchSystemAppearance();`,
` applyTheme(` + jsString(slug) + `);`,
`</script>`,
}, "\n")
}
// javascriptIntegrationSnippet is the same thing without the stylesheet link.
func javascriptIntegrationSnippet(base, slug string) string {
return strings.Join([]string{
`import { applyTheme, watchSystemAppearance } from ` + jsString(base+"/runtime.js") + `;`,
``,
`applyTheme(` + jsString(slug) + `);`,
`watchSystemAppearance();`,
}, "\n")
}
// reactIntegrationSnippet wires the provider component.
func reactIntegrationSnippet(base, slug string) string {
return strings.Join([]string{
`<script type="importmap">`,
`{ "imports": {`,
` "react": "https://esm.sh/react@19",`,
` "react-dom/client": "https://esm.sh/react-dom@19/client",`,
` "go-bananas/runtime": "` + htmlEscape(base+"/react.js") + `"`,
`} }`,
`</script>`,
`<script type="module">`,
` import { BananasProvider } from 'go-bananas/runtime';`,
` import { createRoot } from 'react-dom/client';`,
` createRoot(document.getElementById('root')).render(`,
` <BananasProvider slug=` + jsString(slug) + ` autoDark><App /></BananasProvider>`,
` );`,
`</script>`,
}, "\n")
}
// EmbedPage serves a standalone, shareable page that shows the snippet and renders
// a live preview of the theme.
//
// It is separate from EmbedSnippet because the two have different callers: this one
// is a page a human opens, the other is data the admin UI fetches. Having one serve
// both is what produced the parse failure that motivated the split.
//
// Every code sample on the page comes from the same builders EmbedSnippet uses, so
// what a developer copies here is byte-for-byte what the admin UI shows.
func (h *RuntimeHandlers) EmbedPage(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
if slug == "" {
slug = themeSlugFromPath(r)
}
if slug == "" {
if themes, err := h.store.ListRuntime(r.Context()); err == nil && len(themes) > 0 {
slug = themes[0].Slug
}
}
if slug == "" {
writeError(w, r, http.StatusNotFound, CodeNotFound, "no themes have been published", nil)
return
}
if !isSlugShaped(slug) {
writeError(w, r, http.StatusBadRequest, CodeBadRequest, "invalid theme slug", nil)
return
}
base := h.config.CDNBasis()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
//nolint:gosec // G705: embedPageHTML escapes the slug with htmlEscape at every
// interpolation point, and the code samples it embeds come from the same builders
// the JSON endpoint uses. gosec's taint analysis does not follow those calls.
_, _ = w.Write([]byte(embedPageHTML(base, slug)))
}
// embedPageHTML builds the standalone integration page.
func embedPageHTML(base, slug string) string {
samples := []struct {
title string
body string
}{
{"Any HTML page", htmlIntegrationSnippet(base, slug)},
{"Plain JavaScript", javascriptIntegrationSnippet(base, slug)},
{"React", reactIntegrationSnippet(base, slug)},
}
sections := make([]string, 0, len(samples))
for i, sample := range samples {
sections = append(sections, ` <section>
<h2>`+htmlEscape(sample.title)+`</h2>
<pre><code>`+htmlEscape(sample.body)+`</code></pre>
<button class="copy" data-index="`+strconv.Itoa(i)+`">Copy</button>
</section>`)
}
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>go-bananas &middot; integrate ` + htmlEscape(slug) + `</title>
<link rel="stylesheet" href="` + htmlEscape(base) + `/theme.css?slug=` + htmlEscape(slug) + `">
<style>
body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #0b0d12; color: #e6e8ef; }
main { max-width: 60rem; margin: 0 auto; padding: 2.5rem 1.5rem 4rem; }
h1 { font-size: 1.5rem; margin: 0 0 .25rem; letter-spacing: -0.02em; }
p.lede { color: #9aa3b5; margin: 0 0 2rem; }
section { margin-bottom: 2.5rem; }
h2 { font-size: .8125rem; text-transform: uppercase; letter-spacing: .08em; color: #6b7488; margin: 0 0 .75rem; }
pre { background: #11141c; border: 1px solid #232838; border-radius: .625rem; padding: 1rem; overflow-x: auto;
font: 13px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace; color: #cbd2e0; margin: 0; }
button.copy { margin-top: .625rem; background: #1b2130; color: #cfd6e4; border: 1px solid #2a3145;
border-radius: .5rem; padding: .4375rem .75rem; font: inherit; font-size: .8125rem; cursor: pointer; }
button.copy:hover { background: #222a3c; }
.preview { background: #0b0d12; border: 1px solid #232838; border-radius: .625rem; padding: 1.5rem; }
</style>
</head>
<body>
<main>
<h1>Integrate <code>` + htmlEscape(slug) + `</code></h1>
<p class="lede">Copy one of these into your application. Nothing is installed: the
theme is fetched from this deployment at run time.</p>
` + strings.Join(sections, "\n") + `
<section>
<h2>Live preview</h2>
<div class="preview" data-bananas-theme="` + htmlEscape(slug) + `">
<button class="bn-btn">Primary action</button>
<button class="bn-btn bn-btn--secondary">Secondary</button>
<button class="bn-btn bn-btn--ghost">Ghost</button>
<div class="bn-card" style="margin-top:1rem">
<div class="bn-card__header">Card heading</div>
<p class="bn-card__body">Every value here comes from a design token in the
theme you are editing.</p>
</div>
<p style="margin-top:1rem"><span class="bn-badge bn-badge--success">published</span>
<span class="bn-badge bn-badge--warning">draft</span>
<span class="bn-badge bn-badge--danger">archived</span></p>
<input class="bn-input" placeholder="Input field">
</div>
</section>
</main>
<script type="module">
const codes = [...document.querySelectorAll('pre code')];
for (const button of document.querySelectorAll('button.copy')) {
button.addEventListener('click', async () => {
await navigator.clipboard.writeText(codes[Number(button.dataset.index)]?.textContent ?? '');
button.textContent = 'Copied';
setTimeout(() => { button.textContent = 'Copy' }, 1200);
});
}
</script>
</body>
</html>`
}
// htmlEscape escapes a string for interpolation into HTML text or an attribute.
//
// Every interpolated value here is a slug or a base URL, both of which the
// operator controls, but escaping them anyway means a malformed configuration
// cannot turn the snippet page into an injection vector.
func htmlEscape(s string) string {
return html.EscapeString(s)
}