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] } } }