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
931 lines
29 KiB
Go
931 lines
29 KiB
Go
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]
|
|
}
|
|
}
|
|
}
|