go-bananas/internal/httpapi/admin.go

932 lines
29 KiB
Go
Raw Normal View History

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