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