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
524
internal/app/app.go
Normal file
524
internal/app/app.go
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
// Package app wires the application together: configuration, store, auth,
|
||||
// HTTP, and the lifecycle that starts and stops them.
|
||||
//
|
||||
// Keeping the wiring in one place means main() is a dozen lines, the whole
|
||||
// object graph can be constructed in a test, and there is exactly one place to
|
||||
// look to answer "what actually runs in production".
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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/httpapi"
|
||||
"github.com/urpi/go-bananas/internal/logging"
|
||||
"github.com/urpi/go-bananas/internal/seed"
|
||||
"github.com/urpi/go-bananas/internal/store"
|
||||
)
|
||||
|
||||
// Build information, stamped at link time with -ldflags -X.
|
||||
//
|
||||
// Every value has a usable default so that `go run ./cmd/go-bananas` and a plain
|
||||
// `go build` both work without ceremony, and so that a binary built by hand still
|
||||
// reports something meaningful rather than an empty string in a log line.
|
||||
var (
|
||||
// Version is the release version, e.g. 1.4.2.
|
||||
Version = "dev"
|
||||
// Commit is the source revision.
|
||||
Commit = "unknown"
|
||||
// BuildDate is the RFC 3339 build timestamp.
|
||||
BuildDate = "unknown"
|
||||
)
|
||||
|
||||
// App is the assembled application.
|
||||
type App struct {
|
||||
Config config.Config
|
||||
Log logging.Logger
|
||||
Store *store.Store
|
||||
Cache *httpapi.ThemeCache
|
||||
Auth *auth.Authenticator
|
||||
Router http.Handler
|
||||
Server *http.Server
|
||||
|
||||
metrics *httpapi.Metrics
|
||||
// listener is bound before Serve so that the ready log line can report the
|
||||
// real address, which matters when the configured port is 0.
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
// Options are the inputs needed to build an App.
|
||||
type Options struct {
|
||||
// Config is the resolved configuration.
|
||||
Config config.Config
|
||||
// Version is the build version.
|
||||
Version string
|
||||
// Logger overrides the constructed logger, for tests.
|
||||
Logger logging.Logger
|
||||
}
|
||||
|
||||
// New builds the application without starting it.
|
||||
//
|
||||
// Construction validates the whole object graph and reconciles the built-in
|
||||
// themes, so a misconfigured deployment or a broken migration fails here rather
|
||||
// than on the first request.
|
||||
func New(ctx context.Context, opts Options) (*App, error) {
|
||||
cfg := opts.Config
|
||||
if cfg.StaticDir == "" && cfg.SDKDir == "" {
|
||||
return nil, errors.New("neither STATIC_DIR nor SDK_DIR is set")
|
||||
}
|
||||
|
||||
log := opts.Logger
|
||||
if cfg.EphemeralSessionSecret() {
|
||||
// Worth a warning: an operator debugging a session that "keeps logging
|
||||
// me out" in local development needs to know why.
|
||||
if log == nil {
|
||||
log = logging.New(logging.Options{
|
||||
Level: cfg.LogLevel, Format: cfg.LogFormat,
|
||||
Service: "Go Bananas!!", Environment: cfg.Environment, Version: opts.Version,
|
||||
})
|
||||
}
|
||||
log.Warn("SESSION_SECRET was not set; an ephemeral one was generated. " +
|
||||
"Sessions will not survive a restart. Set SESSION_SECRET to at least 32 bytes.")
|
||||
}
|
||||
if log == nil {
|
||||
log = logging.New(logging.Options{
|
||||
Level: cfg.LogLevel,
|
||||
Format: cfg.LogFormat,
|
||||
Service: "Go Bananas!!",
|
||||
Environment: cfg.Environment,
|
||||
Version: opts.Version,
|
||||
})
|
||||
}
|
||||
|
||||
st, err := store.Open(ctx, store.Options{
|
||||
Path: cfg.DatabasePath,
|
||||
BusyTimeout: cfg.DatabaseBusyTimeout,
|
||||
MaxConns: cfg.DatabaseMaxConns,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
|
||||
cache := httpapi.NewThemeCache()
|
||||
|
||||
if cfg.SeedOnStart {
|
||||
inserted, refreshed, err := st.Seed(ctx, seed.Themes())
|
||||
if err != nil {
|
||||
st.Close()
|
||||
return nil, fmt.Errorf("seed built-in themes: %w", err)
|
||||
}
|
||||
if inserted > 0 || refreshed > 0 {
|
||||
log.Info("built-in themes reconciled",
|
||||
"inserted", inserted, "refreshed", refreshed, "total", len(seed.Slugs()))
|
||||
}
|
||||
}
|
||||
|
||||
authenticator, err := buildAuthenticator(ctx, cfg, st, log)
|
||||
if err != nil {
|
||||
st.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Warming the render cache at start-up means the first request after a deploy
|
||||
// is already fast. Failure is not fatal: the cache is an optimisation.
|
||||
warmed := 0
|
||||
if themes, err := st.ListRuntime(ctx); err == nil {
|
||||
for _, theme := range themes {
|
||||
warmed += cache.Prefetch(theme, nil)
|
||||
}
|
||||
}
|
||||
if warmed > 0 {
|
||||
log.Debug("render cache warmed", "artefacts", warmed)
|
||||
}
|
||||
|
||||
metrics := httpapi.NewMetrics().WithCache(cache)
|
||||
metrics.RegisterCounter("bananas_theme_publishes_total", "Themes moved to the published status.")
|
||||
metrics.RegisterCounter("bananas_theme_updates_total", "Theme documents written.")
|
||||
metrics.RegisterCounter("bananas_auth_failures_total", "Requests rejected for want of a usable credential.")
|
||||
|
||||
app := &App{
|
||||
Config: cfg,
|
||||
Log: log,
|
||||
Store: st,
|
||||
Cache: cache,
|
||||
Auth: authenticator,
|
||||
metrics: metrics,
|
||||
}
|
||||
|
||||
app.Router = httpapi.NewRouter(httpapi.Deps{
|
||||
Config: cfg,
|
||||
Store: st,
|
||||
Cache: cache,
|
||||
Authenticator: authenticator,
|
||||
Log: log,
|
||||
Version: opts.Version,
|
||||
Metrics: metrics,
|
||||
StaticHandler: staticHandler(cfg.StaticDir),
|
||||
SDKHandler: sdkHandler(cfg.SDKDir),
|
||||
// Resolved per request rather than captured at start-up, so that changing the
|
||||
// default theme in the admin UI takes effect without a restart.
|
||||
//
|
||||
// A missing setting yields the supplied default, which is the normal case.
|
||||
// A database error is different: it would otherwise look like "no default
|
||||
// configured" and every runtime request would fall through to asking the
|
||||
// consumer for a theme. It is logged rather than propagated because this runs
|
||||
// inside a request handler whose job is to serve a theme, and failing the
|
||||
// whole request over a settings lookup would be a worse outcome than serving
|
||||
// the fallback.
|
||||
RuntimeDefaults: func() (string, domain.Appearance) {
|
||||
slug, err := st.Setting(ctx, domain.SettingDefaultTheme, "")
|
||||
if err != nil {
|
||||
log.Warn("could not read the default theme setting", "error", err)
|
||||
}
|
||||
appearance, err := st.Setting(ctx, domain.SettingDefaultAppearance, string(domain.AppearanceLight))
|
||||
if err != nil {
|
||||
log.Warn("could not read the default appearance setting", "error", err)
|
||||
}
|
||||
return slug, domain.Appearance(appearance)
|
||||
},
|
||||
})
|
||||
|
||||
app.Server = &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: app.Router,
|
||||
ReadTimeout: cfg.ReadTimeout,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: cfg.WriteTimeout,
|
||||
IdleTimeout: cfg.IdleTimeout,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
ErrorLog: nil,
|
||||
BaseContext: func(_ net.Listener) context.Context { return context.WithoutCancel(ctx) },
|
||||
}
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// buildAuthenticator constructs the auth stack for the configured mode.
|
||||
func buildAuthenticator(ctx context.Context, cfg config.Config, st *store.Store, log logging.Logger) (*auth.Authenticator, error) {
|
||||
switch cfg.AuthMode {
|
||||
case config.AuthModeOIDC:
|
||||
state := auth.NewStateStore(
|
||||
cfg.SessionSecret,
|
||||
"go-bananas-oidc-state",
|
||||
cfg.SessionSecure,
|
||||
sameSiteFrom(cfg.SessionSameSite),
|
||||
10*time.Minute,
|
||||
)
|
||||
provider, err := auth.NewOIDCProvider(ctx, cfg, state)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("identity provider: %w", err)
|
||||
}
|
||||
log.Info("identity provider configured",
|
||||
"issuer", cfg.OIDCIssuer,
|
||||
"clientId", cfg.OIDCClientID,
|
||||
"redirectUrl", cfg.RedirectURL(),
|
||||
"adminGroups", cfg.OIDCAdminGroups,
|
||||
"editorGroups", cfg.OIDCEditorGroups,
|
||||
)
|
||||
return auth.New(cfg, provider, st)
|
||||
|
||||
case config.AuthModeDev:
|
||||
log.Warn("AUTH MODE IS DEV: every request is authenticated as a local administrator. " +
|
||||
"This mode is refused outside a development environment.")
|
||||
return auth.New(cfg, nil, st)
|
||||
|
||||
case config.AuthModeNone:
|
||||
log.Warn("AUTH MODE IS NONE: every request is granted administrator access. " +
|
||||
"This mode is refused outside a development environment.")
|
||||
return auth.New(cfg, nil, st)
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown AUTH_MODE %q", cfg.AuthMode)
|
||||
}
|
||||
}
|
||||
|
||||
// sameSiteFrom maps a configuration value onto an http.SameSite.
|
||||
func sameSiteFrom(raw string) (result sameSite) {
|
||||
switch strings.ToLower(raw) {
|
||||
case "strict":
|
||||
return sameSiteStrict
|
||||
case "none":
|
||||
return sameSiteNone
|
||||
default:
|
||||
return sameSiteLax
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the server and blocks until ctx is cancelled.
|
||||
//
|
||||
// The shutdown sequence is the part worth reading: on cancellation the listener
|
||||
// is closed first so no new connection is accepted, then in-flight requests get
|
||||
// ShutdownTimeout to finish, and only then is the store closed. Closing the store
|
||||
// while a request is still reading would surface as a confusing 500 to a user
|
||||
// who did nothing wrong.
|
||||
func (a *App) Run(ctx context.Context) error {
|
||||
listenCfg := net.ListenConfig{}
|
||||
listener, err := listenCfg.Listen(ctx, "tcp", a.Config.HTTPAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", a.Config.HTTPAddr, err)
|
||||
}
|
||||
a.listener = listener
|
||||
|
||||
a.Log.Info("starting Go Bananas!!",
|
||||
"address", listener.Addr().String(),
|
||||
"publicUrl", a.Config.Origin(),
|
||||
"authMode", a.Config.AuthMode,
|
||||
"database", a.Config.DatabasePath,
|
||||
"environment", a.Config.Environment,
|
||||
"version", Version,
|
||||
"commit", Commit,
|
||||
"built", BuildDate,
|
||||
)
|
||||
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
err := a.Server.Serve(listener)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
serverErrors <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-serverErrors:
|
||||
if err != nil {
|
||||
return fmt.Errorf("serve: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
case <-ctx.Done():
|
||||
a.Log.Info("shutdown signal received", "graceful", true)
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), a.Config.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := a.Server.Shutdown(shutdownCtx); err != nil {
|
||||
// A timeout here means a request outlived the grace period. Closing hard
|
||||
// is better than hanging, and the error is logged so it is visible.
|
||||
a.Log.Error("graceful shutdown timed out; closing connections", "error", err)
|
||||
if closeErr := a.Server.Close(); closeErr != nil {
|
||||
a.Log.Error("force close failed", "error", closeErr)
|
||||
}
|
||||
}
|
||||
|
||||
if err := <-serverErrors; err != nil {
|
||||
return fmt.Errorf("serve: %w", err)
|
||||
}
|
||||
a.Log.Info("stopped cleanly")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Addr returns the bound address, useful when the configured port is 0.
|
||||
func (a *App) Addr() string {
|
||||
if a.listener == nil {
|
||||
return a.Config.HTTPAddr
|
||||
}
|
||||
return a.listener.Addr().String()
|
||||
}
|
||||
|
||||
// Close releases the application's resources.
|
||||
func (a *App) Close() error {
|
||||
if a.Store == nil {
|
||||
return nil
|
||||
}
|
||||
return a.Store.Close()
|
||||
}
|
||||
|
||||
// Run is the top-level entrypoint: load configuration, build, run, shut down.
|
||||
func Run(ctx context.Context) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
// Configuration errors are reported plainly rather than through the logger,
|
||||
// because the logger's own configuration may be part of what is wrong.
|
||||
return fmt.Errorf("%w", err)
|
||||
}
|
||||
|
||||
application, err := New(ctx, Options{Config: cfg, Version: Version})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer application.Close()
|
||||
|
||||
return application.Run(ctx)
|
||||
}
|
||||
|
||||
// staticHandler serves the built admin UI, or nil when no build is present.
|
||||
//
|
||||
// A missing UI is not fatal: the API is independently useful, and a deployment
|
||||
// that only serves the runtime endpoints has no reason to carry the SPA.
|
||||
func staticHandler(dir string) http.Handler {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
index := filepath.Join(dir, "index.html")
|
||||
if _, err := os.Stat(index); err != nil {
|
||||
return nil
|
||||
}
|
||||
fileServer := spaHandler{root: dir, files: http.FileServer(http.Dir(dir))}
|
||||
return fileServer
|
||||
}
|
||||
|
||||
// spaHandler serves the admin single-page application.
|
||||
//
|
||||
// Any GET that does not resolve to a real file falls through to index.html so
|
||||
// that client-side routes survive a page reload. An unknown /api or /rt path must
|
||||
// still 404 as JSON rather than returning HTML, which is handled by the router
|
||||
// before this handler is reached.
|
||||
type spaHandler struct {
|
||||
root string
|
||||
files http.Handler
|
||||
}
|
||||
|
||||
// withinRoot reports whether path is root or lives beneath it.
|
||||
//
|
||||
// It compares path components rather than string prefixes, because a prefix test
|
||||
// cannot tell "/srv/web" from "/srv/web-secrets": the first contains the second as
|
||||
// a leading substring, and a request for the second would pass a HasPrefix check
|
||||
// against the first.
|
||||
func withinRoot(root, path string) bool {
|
||||
absRoot, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// The root is resolved when it exists. When it does not, Abs still gives an
|
||||
// absolute path to compare against, which is the best available answer.
|
||||
if resolvedRoot, resolveErr := filepath.EvalSymlinks(absRoot); resolveErr == nil {
|
||||
absRoot = resolvedRoot
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(absRoot, absPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
// ".." is the only relative path that leaves the root. "." is the root itself,
|
||||
// which is allowed; a path equal to the root is not a file but reaching it is
|
||||
// not an escape.
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Anchoring the path with a leading slash and cleaning it removes every ".."
|
||||
// segment, so Join below cannot escape the root. The check after it is a
|
||||
// belt-and-braces assertion that the result really is inside, rather than a
|
||||
// prefix test: a plain HasPrefix against the root would accept a sibling
|
||||
// directory whose name merely starts with the root's.
|
||||
cleaned := filepath.Clean("/" + strings.TrimPrefix(r.URL.Path, "/"))
|
||||
candidate := filepath.Join(h.root, cleaned)
|
||||
|
||||
if !withinRoot(h.root, candidate) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// The static file server resolves symlinks, so a symlink inside the web
|
||||
// directory pointing at /etc would otherwise be served. Resolving the real
|
||||
// path and re-checking it is what actually closes that: the root itself is
|
||||
// resolved too, so a root reached through a symlink does not trip the check.
|
||||
resolved, err := filepath.EvalSymlinks(candidate)
|
||||
if err != nil {
|
||||
// A path that does not exist is not an error here: the SPA fallback below
|
||||
// serves the shell for it. Only a real failure is reported.
|
||||
resolved = candidate
|
||||
}
|
||||
if !withinRoot(h.root, resolved) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
//nolint:gosec // G703: as on the ServeFile call below; the taint source is
|
||||
// r.URL.Path and both paths it reaches are checked by withinRoot.
|
||||
if info, err := os.Stat(resolved); err == nil && !info.IsDir() {
|
||||
setStaticCacheHeaders(w, resolved)
|
||||
// ServeFile rather than the file server, because the request URL is the
|
||||
// uncleaned path and the file to serve is the resolved one. The directory
|
||||
// case cannot reach here, which is the only case in which ServeFile
|
||||
// redirects.
|
||||
//
|
||||
//nolint:gosec // G703: gosec's taint analysis cannot see withinRoot. The
|
||||
// containment predicate compares path components rather than prefixes, so a
|
||||
// sibling directory named "web-secrets" beside "web" is rejected, and
|
||||
// resolved above is the symlink-resolved path that was re-checked against it.
|
||||
// TestSPARejectsTraversal, TestSPARejectsSymlinkEscape and TestWithinRoot are
|
||||
// what hold this in place.
|
||||
http.ServeFile(w, r, resolved)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the shell for a client-side route. The request path is rewritten to
|
||||
// the directory root rather than to index.html: http.FileServer answers a
|
||||
// request for /index.html with a 301 to /, which would turn every deep link
|
||||
// into a redirect loop against the SPA router.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
h.files.ServeHTTP(w, indexRequest(r))
|
||||
}
|
||||
|
||||
// indexRequest rewrites a request onto the SPA entry point.
|
||||
func indexRequest(r *http.Request) *http.Request {
|
||||
clone := r.Clone(r.Context())
|
||||
clone.URL.Path = "/"
|
||||
return clone
|
||||
}
|
||||
|
||||
// staticAssetSuffixes are the file extensions that may be cached indefinitely,
|
||||
// because Vite emits content-hashed names for them.
|
||||
var staticAssetSuffixes = []string{".js", ".css", ".woff", ".woff2", ".svg", ".png", ".jpg", ".webp", ".avif"}
|
||||
|
||||
// setStaticCacheHeaders chooses a cache policy for a static asset.
|
||||
func setStaticCacheHeaders(w http.ResponseWriter, path string) {
|
||||
for _, suffix := range staticAssetSuffixes {
|
||||
if strings.HasSuffix(path, suffix) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=300")
|
||||
}
|
||||
|
||||
// sameSite aliases keep the http.SameSite constants out of this file's imports,
|
||||
// which keeps the mapping in one readable block.
|
||||
type sameSite = http.SameSite
|
||||
|
||||
// Cookie SameSite modes, re-exported for the configuration mapping above.
|
||||
const (
|
||||
sameSiteLax = http.SameSiteLaxMode
|
||||
sameSiteStrict = http.SameSiteStrictMode
|
||||
sameSiteNone = http.SameSiteNoneMode
|
||||
)
|
||||
|
||||
// sdkHandler serves the built runtime SDK bundles, or nil when no build is
|
||||
// present.
|
||||
//
|
||||
// The SDK is served from the same origin as the runtime API so that a consuming
|
||||
// application can load it with a plain URL and no build step, which is the whole
|
||||
// point of delivering themes at runtime rather than through a package registry.
|
||||
func sdkHandler(dir string) http.Handler {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "runtime.js")); err != nil {
|
||||
return nil
|
||||
}
|
||||
return http.StripPrefix("/rt/v1/", http.FileServer(http.Dir(dir)))
|
||||
}
|
||||
105
internal/app/backup.go
Normal file
105
internal/app/backup.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/config"
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
"github.com/urpi/go-bananas/internal/store"
|
||||
)
|
||||
|
||||
// RunBackup is the `go-bananas backup` subcommand: it writes a consistent copy of
|
||||
// the SQLite database and exits.
|
||||
//
|
||||
// It exists as a subcommand rather than as a separate tool, or as a shell script
|
||||
// copying the file, for two reasons.
|
||||
//
|
||||
// The first is correctness. `cp` on a live database produces a torn copy, and the
|
||||
// result can open without complaint while being corrupt. SQLite's VACUUM INTO
|
||||
// reads through the database's own backup machinery and produces a consistent
|
||||
// snapshot while the service keeps serving.
|
||||
//
|
||||
// The second is packaging. The alternative is a container that ships a `sqlite3`
|
||||
// binary, which means either a third-party image or installing a package into a
|
||||
// container whose root filesystem is deliberately read-only. The SQLite library is
|
||||
// already linked into this binary, at exactly the version the schema was written
|
||||
// for, so using it removes both problems.
|
||||
//
|
||||
// The Helm chart's snapshot CronJob runs this command. A deployment that does not
|
||||
// use the chart can run it from anywhere the binary is.
|
||||
func RunBackup(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("backup", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
||||
var (
|
||||
destination = fs.String("to", "", "Path to write the snapshot to. Required.")
|
||||
database = fs.String("database", "", "Path to the SQLite database. Defaults to DATABASE_PATH.")
|
||||
timeout = fs.Duration("timeout", 5*time.Minute, "Give up after this long.")
|
||||
)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"usage: go-bananas backup --to <path> [--database <path>]\n\n"+
|
||||
"Writes a consistent snapshot of the database using SQLite's online backup.\n"+
|
||||
"It is safe to run against a database that is being written to.\n\n")
|
||||
fs.PrintDefaults()
|
||||
}
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return fmt.Errorf("%w: %w", domain.ErrInvalid, err)
|
||||
}
|
||||
if *destination == "" {
|
||||
fs.Usage()
|
||||
return fmt.Errorf("%w: --to is required", domain.ErrInvalid)
|
||||
}
|
||||
if fs.NArg() > 0 {
|
||||
return fmt.Errorf("%w: unexpected argument %q", domain.ErrInvalid, fs.Arg(0))
|
||||
}
|
||||
|
||||
// The database path comes from the same environment variable the server reads,
|
||||
// so a CronJob and the service cannot disagree about which file is being
|
||||
// snapshotted. DATABASE_PATH alone is enough: the rest of the configuration is
|
||||
// irrelevant to a backup, and requiring it would mean a snapshot job failing
|
||||
// because an unrelated setting is absent.
|
||||
path := *database
|
||||
if path == "" {
|
||||
path = os.Getenv("DATABASE_PATH")
|
||||
}
|
||||
if path == "" {
|
||||
path = config.Defaults().DatabasePath
|
||||
}
|
||||
if !store.IsPersistentPath(path) {
|
||||
return fmt.Errorf("%w: cannot snapshot an ephemeral database (%s); it holds no data once the process exits",
|
||||
domain.ErrInvalid, path)
|
||||
}
|
||||
|
||||
// Bounded, because a snapshot that hangs holds the CronJob's slot forever and a
|
||||
// snapshot of a very large database is genuinely slow.
|
||||
ctx, cancel := context.WithTimeout(ctx, *timeout)
|
||||
defer cancel()
|
||||
|
||||
st, err := store.Open(ctx, store.Options{Path: path})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open database for snapshot: %w", err)
|
||||
}
|
||||
defer func() { _ = st.Close() }()
|
||||
|
||||
target := *destination
|
||||
if !filepath.IsAbs(target) {
|
||||
// Made absolute so the log line says where the file actually is, rather than
|
||||
// leaving the reader to resolve it against whatever the working directory was.
|
||||
abs, absErr := filepath.Abs(target)
|
||||
if absErr == nil {
|
||||
target = abs
|
||||
}
|
||||
}
|
||||
|
||||
if err := st.Snapshot(ctx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "snapshot written to %s\n", target)
|
||||
return nil
|
||||
}
|
||||
224
internal/app/spa_test.go
Normal file
224
internal/app/spa_test.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These tests cover the static file handler, which is the one place in the service
|
||||
// where a request path is turned into a filesystem path. Everything else operates on
|
||||
// database rows or JSON, so the containment logic here is the only traversal
|
||||
// defence in the codebase and it needs its own tests.
|
||||
|
||||
// newSPAFixture builds a web root with an index, a stylesheet and a secret file
|
||||
// outside the root.
|
||||
func newSPAFixture(t *testing.T) (root, outside string, handler http.Handler) {
|
||||
t.Helper()
|
||||
|
||||
base := t.TempDir()
|
||||
root = filepath.Join(base, "web")
|
||||
outside = filepath.Join(base, "secret.txt")
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(root, "index.html"), "<!doctype html><title>app</title>")
|
||||
writeFile(t, filepath.Join(root, "assets", "app.css"), "body{color:red}")
|
||||
// The name Vite actually emits: an extension from staticAssetSuffixes with a
|
||||
// content hash in it.
|
||||
writeFile(t, filepath.Join(root, "assets", "app-CyCU_ony.js"), "export const x=1")
|
||||
writeFile(t, outside, "top secret")
|
||||
|
||||
handler = spaHandler{root: root, files: http.FileServer(http.Dir(root))}
|
||||
return root, outside, handler
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// TestSPAServesTheShellForClientRoutes covers the single-page-app fallback: an
|
||||
// unknown path that is not a file is the router's problem, not a 404.
|
||||
func TestSPAServesTheShellForClientRoutes(t *testing.T) {
|
||||
_, _, handler := newSPAFixture(t)
|
||||
|
||||
for _, path := range []string{"/", "/themes/midnight", "/keys", "/a/deep/client/route"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
rec := get(t, handler, path)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "<title>app</title>") {
|
||||
t.Errorf("did not serve the shell: %q", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSPAServesRealFiles covers the assets themselves, including a nested path.
|
||||
func TestSPAServesRealFiles(t *testing.T) {
|
||||
_, _, handler := newSPAFixture(t)
|
||||
|
||||
rec := get(t, handler, "/assets/app.css")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d, want 200", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "color:red") {
|
||||
t.Errorf("unexpected body %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSPACacheHeaders pins the caching contract, which is what decides whether a
|
||||
// deploy reaches users holding a cached copy.
|
||||
//
|
||||
// The rule is extension-based: an asset extension is assumed to be content-hashed by
|
||||
// the frontend build, and is therefore immutable for a year. Everything else gets a
|
||||
// short max-age, because a document that is replaced under the same name must not
|
||||
// be cached indefinitely.
|
||||
func TestSPACacheHeaders(t *testing.T) {
|
||||
_, _, handler := newSPAFixture(t)
|
||||
|
||||
cases := []struct {
|
||||
path string
|
||||
immutable bool
|
||||
}{
|
||||
{"/assets/app-CyCU_ony.js", true},
|
||||
{"/assets/app.css", true},
|
||||
{"/index.html", false},
|
||||
{"/themes/midnight", false}, // the SPA shell for a client route
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.path, func(t *testing.T) {
|
||||
cc := get(t, handler, tc.path).Header().Get("Cache-Control")
|
||||
if immutable := strings.Contains(cc, "immutable"); immutable != tc.immutable {
|
||||
t.Errorf("Cache-Control %q, immutable=%v, want %v", cc, immutable, tc.immutable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSPARejectsTraversal is the security test. Every one of these paths is an
|
||||
// attempt to read a file outside the web root, and each must fail to return the
|
||||
// file's contents.
|
||||
//
|
||||
// The interesting cases are the encoded and mixed ones: a naive handler that
|
||||
// strips ".." once, or that compares paths with HasPrefix, passes at least one.
|
||||
func TestSPARejectsTraversal(t *testing.T) {
|
||||
root, _, handler := newSPAFixture(t)
|
||||
// A sibling directory whose name starts with the root's name. A HasPrefix
|
||||
// containment check accepts this; a component comparison does not.
|
||||
sibling := root + "-secrets"
|
||||
if err := os.MkdirAll(sibling, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sibling: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(sibling, "leak.txt"), "sibling secret")
|
||||
|
||||
cases := []string{
|
||||
"/../secret.txt",
|
||||
"/../../etc/passwd",
|
||||
"/assets/../../secret.txt",
|
||||
"/./../secret.txt",
|
||||
"/%2e%2e/secret.txt",
|
||||
"/..%2fsecret.txt",
|
||||
"/assets/%2e%2e%2f%2e%2e%2fsecret.txt",
|
||||
"/../web-secrets/leak.txt",
|
||||
}
|
||||
|
||||
for _, path := range cases {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
rec := get(t, handler, path)
|
||||
body := rec.Body.String()
|
||||
if strings.Contains(body, "top secret") || strings.Contains(body, "sibling secret") {
|
||||
t.Fatalf("leaked a file outside the root: %q", body)
|
||||
}
|
||||
// Serving the shell instead of a 404 is acceptable; that is what the SPA
|
||||
// fallback does for any path that is not a file.
|
||||
if rec.Code == http.StatusOK && !strings.Contains(body, "<title>app</title>") {
|
||||
t.Errorf("unexpected 200 body: %q", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSPARejectsSymlinkEscape covers the case a string comparison cannot: the path
|
||||
// is inside the root and contains no "..", but a symlink points outside it.
|
||||
func TestSPARejectsSymlinkEscape(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
root := filepath.Join(base, "web")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(root, "index.html"), "<!doctype html><title>app</title>")
|
||||
|
||||
target := filepath.Join(base, "secret.txt")
|
||||
writeFile(t, target, "top secret")
|
||||
|
||||
// Skipped where symlinks are unavailable, which is a Windows privilege rather
|
||||
// than a reason to fail the suite.
|
||||
if err := os.Symlink(target, filepath.Join(root, "link.txt")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
|
||||
handler := spaHandler{root: root, files: http.FileServer(http.Dir(root))}
|
||||
rec := get(t, handler, "/link.txt")
|
||||
if strings.Contains(rec.Body.String(), "top secret") {
|
||||
t.Errorf("served a file through a symlink out of the root: %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSPARejectsNonGETMethods keeps the handler read-only. It has no mutating route,
|
||||
// so a POST should not be answered at all rather than quietly treated as a GET.
|
||||
func TestSPARejectsNonGETMethods(t *testing.T) {
|
||||
_, _, handler := newSPAFixture(t)
|
||||
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequestWithContext(t.Context(), method, "/", nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("got %d, want 405", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithinRoot covers the containment predicate directly, including the sibling
|
||||
// directory that separates it from a prefix comparison.
|
||||
func TestWithinRoot(t *testing.T) {
|
||||
root := filepath.Join(t.TempDir(), "web")
|
||||
|
||||
inside := []string{root, filepath.Join(root, "a.txt"), filepath.Join(root, "a", "b", "c.txt")}
|
||||
for _, path := range inside {
|
||||
if !withinRoot(root, path) {
|
||||
t.Errorf("withinRoot(%q, %q) = false, want true", root, path)
|
||||
}
|
||||
}
|
||||
|
||||
outside := []string{
|
||||
filepath.Join(root, "..", "secret.txt"),
|
||||
root + "-secrets",
|
||||
filepath.Join(root, ".."),
|
||||
"/etc/passwd",
|
||||
root + "X",
|
||||
}
|
||||
for _, path := range outside {
|
||||
if withinRoot(root, path) {
|
||||
t.Errorf("withinRoot(%q, %q) = true, want false", root, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
715
internal/auth/auth.go
Normal file
715
internal/auth/auth.go
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
// Package auth authenticates callers and authorises their actions.
|
||||
//
|
||||
// Three mechanisms are supported and are resolved by Authenticator in a fixed
|
||||
// order, most specific first:
|
||||
//
|
||||
// 1. An API key, presented as a bearer token. Machine access.
|
||||
// 2. An OIDC session, carried in a signed cookie. Human access to the admin
|
||||
// API, with a full login redirect flow.
|
||||
// 3. A development bypass, available only when the configuration explicitly
|
||||
// enables it and the process is running in a development environment.
|
||||
//
|
||||
// The development bypass refuses to initialise outside development. That check
|
||||
// is the single most important line of defence in this package: an accidentally
|
||||
// deployed dev mode is a total authentication bypass, so it is made impossible
|
||||
// to construct rather than merely documented as unsupported.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/config"
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// Errors returned by this package. They wrap the domain sentinels so the HTTP
|
||||
// layer can map them to status codes without importing this package's internals.
|
||||
var (
|
||||
ErrNoSession = fmt.Errorf("%w: no session", domain.ErrUnauthorized)
|
||||
ErrSessionExpired = fmt.Errorf("%w: session expired", domain.ErrUnauthorized)
|
||||
ErrSessionInvalid = fmt.Errorf("%w: session is invalid", domain.ErrUnauthorized)
|
||||
ErrDevBypassDenied = fmt.Errorf("%w: development bypass is not available", domain.ErrUnauthorized)
|
||||
)
|
||||
|
||||
// Identity, Role and the role constants are re-exported so that callers of this
|
||||
// package need not also import the domain package.
|
||||
type (
|
||||
// Identity is the authenticated caller.
|
||||
Identity = domain.Identity
|
||||
// Role is a coarse authorisation level.
|
||||
Role = domain.Role
|
||||
)
|
||||
|
||||
// Role constants, re-exported for the same reason.
|
||||
const (
|
||||
// RoleViewer may read published themes.
|
||||
RoleViewer = domain.RoleViewer
|
||||
// RoleEditor may create, edit and publish themes.
|
||||
RoleEditor = domain.RoleEditor
|
||||
// RoleAdmin may additionally manage users, API keys and settings.
|
||||
RoleAdmin = domain.RoleAdmin
|
||||
)
|
||||
|
||||
// Resolver is the identity-provider half of the interface. It is an interface so
|
||||
// that the session store can be tested without a live provider, and so that a
|
||||
// future provider (SAML, a service mesh) can be added without touching the HTTP
|
||||
// layer.
|
||||
// Login is the outcome of a completed login.
|
||||
type Login struct {
|
||||
// Identity is the authenticated caller.
|
||||
Identity *Identity
|
||||
// ReturnTo is the sanitised local path to send the browser to. It is always
|
||||
// a path on this service, never an absolute URL.
|
||||
ReturnTo string
|
||||
}
|
||||
|
||||
// SafeReturnTo restricts a post-login redirect to a local path.
|
||||
//
|
||||
// Without this check an attacker could craft a login link that, after a
|
||||
// successful authentication, bounces the user to a site of their choosing with the
|
||||
// session cookie freshly minted: an open redirect that also primes a phishing flow.
|
||||
// Absolute URLs, protocol-relative URLs, backslashes and control characters are all
|
||||
// rejected, because browsers normalise several of those into an origin.
|
||||
func SafeReturnTo(returnTo string) string {
|
||||
trimmed := strings.TrimSpace(returnTo)
|
||||
if trimmed == "" {
|
||||
return "/"
|
||||
}
|
||||
if strings.ContainsAny(trimmed, "\\\r\n\t") ||
|
||||
strings.Contains(trimmed, "://") || strings.HasPrefix(trimmed, "//") {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "/") {
|
||||
return "/"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
type Resolver interface {
|
||||
// Begin starts a login by redirecting the browser to the provider.
|
||||
Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error
|
||||
// Complete finishes a login, returning the identity and where to send the
|
||||
// browser next.
|
||||
Complete(ctx context.Context, r *http.Request) (*Login, error)
|
||||
// End starts a logout.
|
||||
End(ctx context.Context, redirect string) string
|
||||
// Enabled reports whether the resolver can authenticate at all.
|
||||
Enabled() bool
|
||||
// ClearState discards any pending login transaction.
|
||||
ClearState(w http.ResponseWriter)
|
||||
// LogoutURL returns the provider's end-session endpoint.
|
||||
LogoutURL(redirect string) string
|
||||
}
|
||||
|
||||
// KeyStore is the persistence the authenticator needs beyond sessions: recording
|
||||
// the users it has seen, so that an operator can see who has access.
|
||||
//
|
||||
// It was once also the API-key store. Machine credentials were removed, so a bearer
|
||||
// token is no longer a credential: the runtime API is public and the admin API is
|
||||
// for people. A request carrying a bearer token is now simply unauthenticated,
|
||||
// rather than being given editor rights by whatever it presents.
|
||||
type KeyStore interface {
|
||||
UpsertUser(ctx context.Context, identity *Identity) error
|
||||
}
|
||||
|
||||
// Authenticator authenticates requests and issues sessions.
|
||||
type Authenticator struct {
|
||||
config config.Config
|
||||
resolver Resolver
|
||||
keys KeyStore
|
||||
sessions *SessionStore
|
||||
// now is the clock, overridable in tests.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New builds an Authenticator. resolver may be nil when authentication is
|
||||
// disabled; sessions are always constructed because the dev bypass and API keys
|
||||
// still benefit from a consistent identity shape.
|
||||
func New(cfg config.Config, resolver Resolver, keys KeyStore) (*Authenticator, error) {
|
||||
if cfg.AuthMode == config.AuthModeDev && !cfg.IsDevelopment() {
|
||||
return nil, fmt.Errorf("refusing to start: AUTH_MODE=dev outside a development environment")
|
||||
}
|
||||
if cfg.AuthMode == config.AuthModeNone && !cfg.IsDevelopment() {
|
||||
return nil, fmt.Errorf("refusing to start: AUTH_MODE=none outside a development environment")
|
||||
}
|
||||
|
||||
sessionStore, err := NewSessionStore(cfg.SessionSecret, cfg.SessionTTL, cfg.IsDevelopment())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.AuthMode == config.AuthModeOIDC && (resolver == nil || !resolver.Enabled()) {
|
||||
return nil, fmt.Errorf("AUTH_MODE=oidc but the identity provider is unavailable")
|
||||
}
|
||||
|
||||
return &Authenticator{
|
||||
config: cfg,
|
||||
resolver: resolver,
|
||||
keys: keys,
|
||||
sessions: sessionStore,
|
||||
now: time.Now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Sessions exposes the session store so the HTTP layer can set and clear
|
||||
// cookies without re-deriving the cookie attributes.
|
||||
func (a *Authenticator) Sessions() *SessionStore { return a.sessions }
|
||||
|
||||
// Config returns the configuration the authenticator was built with.
|
||||
func (a *Authenticator) Config() config.Config { return a.config }
|
||||
|
||||
// Mode returns the active authentication mode.
|
||||
func (a *Authenticator) Mode() string { return a.config.AuthMode }
|
||||
|
||||
// DevBypassEnabled reports whether the development bypass is available.
|
||||
func (a *Authenticator) DevBypassEnabled() bool {
|
||||
return a.config.AuthMode == config.AuthModeDev && a.config.IsDevelopment()
|
||||
}
|
||||
|
||||
// Authenticate resolves the caller from a request.
|
||||
//
|
||||
// The session cookie is the only credential. There is no bearer-token path: a
|
||||
// request with an Authorization header is treated exactly like one without, so a
|
||||
// stale or forged token cannot grant anything.
|
||||
func (a *Authenticator) Authenticate(r *http.Request) (*Identity, error) {
|
||||
if identity, err := a.sessions.Read(r); err == nil {
|
||||
return identity, nil
|
||||
} else if errors.Is(err, ErrSessionExpired) {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
|
||||
if a.DevBypassEnabled() {
|
||||
if identity := a.devIdentity(r); identity != nil {
|
||||
return identity, nil
|
||||
}
|
||||
}
|
||||
|
||||
if a.config.AuthMode == config.AuthModeNone {
|
||||
return &Identity{
|
||||
Subject: "anonymous",
|
||||
Name: "anonymous",
|
||||
Roles: []Role{RoleAdmin},
|
||||
Method: "none",
|
||||
}, nil
|
||||
}
|
||||
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
|
||||
// devIdentity builds an identity for the development bypass.
|
||||
//
|
||||
// The caller may name themselves with ?dev-user=email, which lets a developer
|
||||
// exercise the role model without a running identity provider. It is only
|
||||
// reachable when DevBypassEnabled is true, which config.Validate refuses to
|
||||
// allow outside development.
|
||||
func (a *Authenticator) devIdentity(r *http.Request) *Identity {
|
||||
email := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("dev-user")))
|
||||
if email == "" {
|
||||
email = "dev@go-bananas.local"
|
||||
}
|
||||
|
||||
role := a.config.DevFallbackRole
|
||||
if mapped, ok := a.config.DevUsers[email]; ok {
|
||||
role = mapped
|
||||
}
|
||||
|
||||
identity := &Identity{
|
||||
Subject: "dev:" + email,
|
||||
Email: email,
|
||||
Name: displayNameFor(email),
|
||||
Roles: []Role{domain.ParseRole(role)},
|
||||
Method: "dev",
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
// displayNameFor turns an email address into a plausible display name.
|
||||
func displayNameFor(email string) string {
|
||||
local, _, _ := strings.Cut(email, "@")
|
||||
if local == "" {
|
||||
return email
|
||||
}
|
||||
// Capitalise the first letter of each hyphen-separated segment.
|
||||
parts := strings.Split(local, "-")
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(p[:1]) + p[1:]
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// IssueSession records a successful login and writes the session cookie.
|
||||
func (a *Authenticator) IssueSession(ctx context.Context, w http.ResponseWriter, identity *Identity) error {
|
||||
if identity == nil {
|
||||
return fmt.Errorf("%w: cannot issue a session for a nil identity", domain.ErrInvalid)
|
||||
}
|
||||
if a.keys != nil && identity.Method != "dev" {
|
||||
// Recording the login lets an administrator see who has used the system.
|
||||
if err := a.keys.UpsertUser(ctx, identity); err != nil {
|
||||
// A login that cannot be recorded should still succeed; losing the
|
||||
// directory entry is far less harmful than locking the user out.
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
return a.sessions.Issue(w, identity)
|
||||
}
|
||||
|
||||
// ClearSession removes the session cookie.
|
||||
func (a *Authenticator) ClearSession(w http.ResponseWriter) {
|
||||
a.sessions.Clear(w)
|
||||
}
|
||||
|
||||
// ModeConfigured reports whether interactive login is available at all.
|
||||
func (a *Authenticator) ModeConfigured() bool {
|
||||
return a != nil && a.config.AuthMode != config.AuthModeNone
|
||||
}
|
||||
|
||||
// ProviderEnabled reports whether an OIDC provider is wired up.
|
||||
func (a *Authenticator) ProviderEnabled() bool {
|
||||
return a != nil && a.resolver != nil && a.resolver.Enabled()
|
||||
}
|
||||
|
||||
// Begin starts an interactive login.
|
||||
func (a *Authenticator) Begin(ctx context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error {
|
||||
if a == nil || a.resolver == nil || !a.resolver.Enabled() {
|
||||
return fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured)
|
||||
}
|
||||
return a.resolver.Begin(ctx, req, w, returnTo)
|
||||
}
|
||||
|
||||
// Complete finishes an interactive login.
|
||||
func (a *Authenticator) Complete(ctx context.Context, r *http.Request) (*Login, error) {
|
||||
if a == nil || a.resolver == nil || !a.resolver.Enabled() {
|
||||
return nil, fmt.Errorf("%w: no identity provider is configured", domain.ErrNotConfigured)
|
||||
}
|
||||
return a.resolver.Complete(ctx, r)
|
||||
}
|
||||
|
||||
// ClearState discards a pending login transaction.
|
||||
func (a *Authenticator) ClearState(w http.ResponseWriter) {
|
||||
if a != nil && a.resolver != nil {
|
||||
a.resolver.ClearState(w)
|
||||
}
|
||||
}
|
||||
|
||||
// LogoutURL returns the provider logout URL.
|
||||
func (a *Authenticator) LogoutURL(redirect string) string {
|
||||
if a.resolver == nil || !a.resolver.Enabled() {
|
||||
return ""
|
||||
}
|
||||
return a.resolver.LogoutURL(redirect)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sessions --
|
||||
|
||||
// SessionStore issues and verifies stateless signed session cookies.
|
||||
//
|
||||
// The session is a signed, encrypted-free JSON payload in a cookie: there is no
|
||||
// server-side session table to migrate, scale or invalidate. The trade is that
|
||||
// revoking a session before its expiry requires rotating the signing key, which
|
||||
// is acceptable for a session that lives eight hours and is documented as such.
|
||||
//
|
||||
// The signature is HMAC-SHA256 over the payload with a constant-time
|
||||
// comparison, which is what makes a forged cookie infeasible.
|
||||
type SessionStore struct {
|
||||
secret []byte
|
||||
ttl time.Duration
|
||||
secure bool
|
||||
name string
|
||||
sameSite http.SameSite
|
||||
now func() time.Time
|
||||
// inDevelopment relaxes cookie behaviour so that a local run over plain
|
||||
// HTTP still receives a cookie.
|
||||
inDevelopment bool
|
||||
}
|
||||
|
||||
// NewSessionStore builds a session store.
|
||||
func NewSessionStore(secret string, ttl time.Duration, inDevelopment bool) (*SessionStore, error) {
|
||||
if len(secret) < 32 {
|
||||
return nil, fmt.Errorf("%w: session secret must be at least 32 bytes, got %d", domain.ErrInvalid, len(secret))
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 8 * time.Hour
|
||||
}
|
||||
switch strings.ToLower(stringValue(sameSiteFromString("lax"))) {
|
||||
case "lax", "strict", "none":
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: unknown SameSite value", domain.ErrInvalid)
|
||||
}
|
||||
return &SessionStore{
|
||||
secret: []byte(secret),
|
||||
ttl: ttl,
|
||||
secure: true,
|
||||
name: "go-bananas-session",
|
||||
sameSite: http.SameSiteLaxMode,
|
||||
now: time.Now,
|
||||
inDevelopment: inDevelopment,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WithCookie configures the cookie attributes. It exists so that the
|
||||
// configuration can be applied in one place after construction.
|
||||
func (s *SessionStore) WithCookie(name string, secure bool, sameSite http.SameSite) *SessionStore {
|
||||
if name != "" {
|
||||
s.name = name
|
||||
}
|
||||
s.secure = secure
|
||||
s.sameSite = sameSite
|
||||
return s
|
||||
}
|
||||
|
||||
// WithClock overrides the clock, for tests.
|
||||
func (s *SessionStore) WithClock(now func() time.Time) *SessionStore {
|
||||
s.now = now
|
||||
return s
|
||||
}
|
||||
|
||||
// CookieName returns the configured cookie name.
|
||||
func (s *SessionStore) CookieName() string { return s.name }
|
||||
|
||||
// sessionPayload is the signed cookie body.
|
||||
type sessionPayload struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Roles []string `json:"roles"`
|
||||
Method string `json:"method"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
ExpiresAt int64 `json:"exp"`
|
||||
// Nonce lets a future rotation invalidate sessions issued before a cutover.
|
||||
Nonce string `json:"jti,omitempty"`
|
||||
}
|
||||
|
||||
// Issue writes a signed session cookie.
|
||||
func (s *SessionStore) Issue(w http.ResponseWriter, identity *Identity) error {
|
||||
now := s.now().UTC()
|
||||
roles := make([]string, 0, len(identity.Roles))
|
||||
for _, r := range identity.Roles {
|
||||
roles = append(roles, string(r))
|
||||
}
|
||||
|
||||
payload := sessionPayload{
|
||||
Subject: identity.Subject,
|
||||
Email: identity.Email,
|
||||
Name: identity.Name,
|
||||
Roles: roles,
|
||||
Method: identity.Method,
|
||||
IssuedAt: now.Unix(),
|
||||
ExpiresAt: now.Add(s.ttl).Unix(),
|
||||
}
|
||||
encoded, err := s.encode(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:gosec // G124: HttpOnly, Secure and SameSite are all set, from the
|
||||
// configured values rather than literals. gosec only recognises a literal
|
||||
// attribute, so a configurable cookie reads as an unprotected one.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
Expires: now.Add(s.ttl),
|
||||
MaxAge: int(s.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: s.sameSite,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear expires the session cookie.
|
||||
func (s *SessionStore) Clear(w http.ResponseWriter) {
|
||||
//nolint:gosec // G124: as above. Clearing a cookie needs the same attribute
|
||||
// triple as setting it, or a browser will not remove it.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: s.sameSite,
|
||||
})
|
||||
}
|
||||
|
||||
// Read verifies and decodes the session cookie.
|
||||
func (s *SessionStore) Read(r *http.Request) (*Identity, error) {
|
||||
cookie, err := r.Cookie(s.name)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
|
||||
payload, err := s.decode(cookie.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.now().UTC().Unix()
|
||||
if payload.ExpiresAt > 0 && now > payload.ExpiresAt {
|
||||
return nil, ErrSessionExpired
|
||||
}
|
||||
|
||||
identity := &Identity{
|
||||
Subject: payload.Subject,
|
||||
Email: payload.Email,
|
||||
Name: payload.Name,
|
||||
Method: payload.Method,
|
||||
}
|
||||
// Left nil when the payload carries no expiry, rather than becoming the Unix
|
||||
// epoch: a session without an expiry is a valid thing to represent, and 1970 is
|
||||
// a date every client would read as "already expired".
|
||||
if payload.ExpiresAt > 0 {
|
||||
identity.ExpiresAt = timePtr(time.Unix(payload.ExpiresAt, 0).UTC())
|
||||
}
|
||||
for _, r := range payload.Roles {
|
||||
identity.Roles = append(identity.Roles, Role(r))
|
||||
}
|
||||
if len(identity.Roles) == 0 {
|
||||
identity.Roles = []Role{RoleViewer}
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// encode serialises and signs a payload.
|
||||
func (s *SessionStore) encode(payload sessionPayload) (string, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode session: %w", err)
|
||||
}
|
||||
bodyEncoded := base64.RawURLEncoding.EncodeToString(body)
|
||||
mac := s.sign(bodyEncoded)
|
||||
return bodyEncoded + "." + base64.RawURLEncoding.EncodeToString(mac), nil
|
||||
}
|
||||
|
||||
// decode verifies and parses a payload.
|
||||
func (s *SessionStore) decode(value string) (sessionPayload, error) {
|
||||
var payload sessionPayload
|
||||
|
||||
bodyEncoded, signatureEncoded, ok := strings.Cut(value, ".")
|
||||
if !ok {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
|
||||
signature, err := base64.RawURLEncoding.DecodeString(signatureEncoded)
|
||||
if err != nil {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
|
||||
// Constant-time comparison: a byte-by-byte early return would leak the
|
||||
// correct prefix length to an attacker measuring responses.
|
||||
expected := s.sign(bodyEncoded)
|
||||
if subtle.ConstantTimeCompare(signature, expected) != 1 {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
|
||||
body, err := base64.RawURLEncoding.DecodeString(bodyEncoded)
|
||||
if err != nil {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
if payload.Subject == "" {
|
||||
return payload, ErrSessionInvalid
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// sign computes the HMAC over a value.
|
||||
func (s *SessionStore) sign(value string) []byte {
|
||||
mac := hmacSHA256(s.secret, []byte(value))
|
||||
return mac[:]
|
||||
}
|
||||
|
||||
// sameSiteFromString maps a configuration value to an http.SameSite.
|
||||
func sameSiteFromString(raw string) http.SameSite {
|
||||
switch strings.ToLower(raw) {
|
||||
case "strict":
|
||||
return http.SameSiteStrictMode
|
||||
case "none":
|
||||
return http.SameSiteNoneMode
|
||||
default:
|
||||
return http.SameSiteLaxMode
|
||||
}
|
||||
}
|
||||
|
||||
// stringValue renders a SameSite for logging.
|
||||
func stringValue(v http.SameSite) string {
|
||||
switch v {
|
||||
case http.SameSiteStrictMode:
|
||||
return "strict"
|
||||
case http.SameSiteNoneMode:
|
||||
return "none"
|
||||
default:
|
||||
return "lax"
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ state --
|
||||
|
||||
// StateStore keeps short-lived OIDC transaction state (the PKCE verifier and
|
||||
// the post-login return path) in a signed cookie.
|
||||
//
|
||||
// Keeping it in the cookie rather than a server-side map is what lets go-bananas
|
||||
// run multiple replicas behind a load balancer with no shared session storage.
|
||||
type StateStore struct {
|
||||
secret []byte
|
||||
name string
|
||||
ttl time.Duration
|
||||
secure bool
|
||||
sameSite http.SameSite
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// oauthState is the transaction cookie body.
|
||||
//
|
||||
// State is stored alongside the verifier and nonce so that the callback can be
|
||||
// bound to the browser that started the flow. Without it the state parameter is
|
||||
// merely present, not checked, and an attacker-supplied authorization code could
|
||||
// be redeemed into a session in someone else's browser (a login CSRF).
|
||||
type oauthState struct {
|
||||
Verifier string `json:"v"`
|
||||
ReturnTo string `json:"r"`
|
||||
Nonce string `json:"n"`
|
||||
State string `json:"s"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
}
|
||||
|
||||
// Transaction is a verified login transaction read back from the state cookie.
|
||||
type Transaction struct {
|
||||
// Verifier is the PKCE code verifier for the exchange.
|
||||
Verifier string
|
||||
// ReturnTo is the sanitised local path the browser should land on.
|
||||
ReturnTo string
|
||||
// Nonce must match the nonce in the returned ID token.
|
||||
Nonce string
|
||||
// State must match the state parameter the provider echoes back.
|
||||
State string
|
||||
}
|
||||
|
||||
// NewStateStore builds the transaction cookie store.
|
||||
func NewStateStore(secret, name string, secure bool, sameSite http.SameSite, ttl time.Duration) *StateStore {
|
||||
if ttl <= 0 {
|
||||
ttl = 10 * time.Minute
|
||||
}
|
||||
return &StateStore{
|
||||
secret: []byte(secret),
|
||||
name: name,
|
||||
secure: secure,
|
||||
sameSite: sameSite,
|
||||
ttl: ttl,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the state cookie name.
|
||||
func (s *StateStore) Name() string { return s.name }
|
||||
|
||||
// Put writes the transaction state cookie. It fails when any part of the
|
||||
// transaction is empty: a transaction missing its state or verifier cannot be
|
||||
// validated at the callback, so storing it would only defer the failure to a point
|
||||
// where the user is already mid-login.
|
||||
func (s *StateStore) Put(w http.ResponseWriter, verifier, returnTo, nonce, state string) error {
|
||||
if verifier == "" || nonce == "" || state == "" {
|
||||
return fmt.Errorf("%w: incomplete oauth transaction", domain.ErrInvalid)
|
||||
}
|
||||
now := s.now().UTC()
|
||||
body, err := json.Marshal(oauthState{
|
||||
Verifier: verifier, ReturnTo: returnTo, Nonce: nonce, State: state,
|
||||
IssuedAt: now.Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode oauth state: %w", err)
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(body)
|
||||
mac := hmacSHA256(s.secret, []byte(encoded))
|
||||
|
||||
//nolint:gosec // G124: as above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name,
|
||||
Value: encoded + "." + base64.RawURLEncoding.EncodeToString(mac[:]),
|
||||
Path: "/",
|
||||
Expires: now.Add(s.ttl),
|
||||
MaxAge: int(s.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get verifies the transaction state cookie and returns what it holds.
|
||||
//
|
||||
// "Consume" is the caller's job: the cookie is single-use, but only because the
|
||||
// HTTP layer expires it once the login has succeeded. Clearing it here instead
|
||||
// would expire it before the code exchange, and a user who mistyped their password
|
||||
// once at the provider would arrive back with no transaction to finish.
|
||||
func (s *StateStore) Get(r *http.Request) (Transaction, error) {
|
||||
cookie, err := r.Cookie(s.name)
|
||||
if err != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: missing oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
encoded, signatureEncoded, ok := strings.Cut(cookie.Value, ".")
|
||||
if !ok {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
signature, decodeErr := base64.RawURLEncoding.DecodeString(signatureEncoded)
|
||||
if decodeErr != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
expected := hmacSHA256(s.secret, []byte(encoded))
|
||||
if subtle.ConstantTimeCompare(signature, expected[:]) != 1 {
|
||||
return Transaction{}, fmt.Errorf("%w: oauth state signature mismatch", domain.ErrUnauthorized)
|
||||
}
|
||||
body, decodeErr := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if decodeErr != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
var state oauthState
|
||||
if json.Unmarshal(body, &state) != nil {
|
||||
return Transaction{}, fmt.Errorf("%w: malformed oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
if s.now().UTC().Sub(time.Unix(state.IssuedAt, 0).UTC()) > s.ttl {
|
||||
return Transaction{}, fmt.Errorf("%w: oauth state expired", domain.ErrUnauthorized)
|
||||
}
|
||||
if state.Verifier == "" || state.Nonce == "" || state.State == "" {
|
||||
return Transaction{}, fmt.Errorf("%w: incomplete oauth state", domain.ErrUnauthorized)
|
||||
}
|
||||
return Transaction{
|
||||
Verifier: state.Verifier,
|
||||
ReturnTo: SafeReturnTo(state.ReturnTo),
|
||||
Nonce: state.Nonce,
|
||||
State: state.State,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Clear expires the state cookie.
|
||||
func (s *StateStore) Clear(w http.ResponseWriter) {
|
||||
//nolint:gosec // G124: as above.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: s.name, Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, Secure: s.secure, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
// hmacSHA256 computes a signature. It is a free function so the session store
|
||||
// and the OIDC state store share one implementation and cannot diverge.
|
||||
func hmacSHA256(key, body []byte) [32]byte {
|
||||
return sha256HMAC(key, body)
|
||||
}
|
||||
|
||||
// timePtr returns a pointer to t. It exists because the session and identity types
|
||||
// carry an optional expiry, and taking the address of a conversion inline is not
|
||||
// something Go will let you write.
|
||||
func timePtr(t time.Time) *time.Time { return &t }
|
||||
417
internal/auth/auth_test.go
Normal file
417
internal/auth/auth_test.go
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/config"
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
const testSecret = "test-secret-that-is-at-least-32-bytes-long"
|
||||
|
||||
// ---------------------------------------------------------------- sessions --
|
||||
|
||||
func TestSessionRoundTrip(t *testing.T) {
|
||||
store, err := NewSessionStore(testSecret, time.Hour, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionStore: %v", err)
|
||||
}
|
||||
|
||||
identity := &domain.Identity{
|
||||
Subject: "alice",
|
||||
Email: "alice@example.com",
|
||||
Name: "Alice",
|
||||
Roles: []domain.Role{domain.RoleEditor},
|
||||
Method: "oidc",
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := store.Issue(rec, identity); err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
got, err := store.Read(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if got.Subject != "alice" || got.Email != "alice@example.com" {
|
||||
t.Errorf("identity = %+v", got)
|
||||
}
|
||||
if !got.HasRole(domain.RoleEditor) {
|
||||
t.Errorf("roles = %v, want editor", got.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCookieIsHttpOnly(t *testing.T) {
|
||||
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
rec := httptest.NewRecorder()
|
||||
if err := store.Issue(rec, &domain.Identity{Subject: "a"}); err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("got %d cookies, want 1", len(cookies))
|
||||
}
|
||||
if !cookies[0].HttpOnly {
|
||||
t.Error("session cookie must be HttpOnly so script cannot read it")
|
||||
}
|
||||
if !cookies[0].Secure {
|
||||
t.Error("session cookie must be Secure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRejectsShortSecret(t *testing.T) {
|
||||
if _, err := NewSessionStore("too-short", time.Hour, false); err == nil {
|
||||
t.Fatal("expected a short secret to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRejectsTamperedPayload(t *testing.T) {
|
||||
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
rec := httptest.NewRecorder()
|
||||
if err := store.Issue(rec, &domain.Identity{Subject: "alice", Roles: []domain.Role{domain.RoleViewer}}); err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
|
||||
var issued *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
issued = c
|
||||
}
|
||||
|
||||
// Re-encode the payload with an escalated role but keep the old signature.
|
||||
body, signature, _ := strings.Cut(issued.Value, ".")
|
||||
forged := base64RawURL(`{"sub":"alice","roles":["admin"],"method":"oidc","iat":1,"exp":9999999999}`)
|
||||
_ = body
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
req.AddCookie(&http.Cookie{Name: issued.Name, Value: forged + "." + signature})
|
||||
|
||||
if _, err := store.Read(req); err == nil {
|
||||
t.Fatal("a re-signed payload must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionRejectsForeignSecret(t *testing.T) {
|
||||
mine, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
theirs, _ := NewSessionStore("a-completely-different-32-byte-secret!", time.Hour, false)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := theirs.Issue(rec, &domain.Identity{Subject: "mallory", Roles: []domain.Role{domain.RoleAdmin}}); err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
if _, err := mine.Read(req); err == nil {
|
||||
t.Fatal("a cookie signed with another key must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpires(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
store.WithClock(func() time.Time { return now })
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := store.Issue(rec, &domain.Identity{Subject: "alice"}); err != nil {
|
||||
t.Fatalf("Issue: %v", err)
|
||||
}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
// Two hours later the session must be gone.
|
||||
store.WithClock(func() time.Time { return now.Add(2 * time.Hour) })
|
||||
if _, err := store.Read(req); err == nil {
|
||||
t.Fatal("expected an expired session to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionReadRejectsMissingCookie(t *testing.T) {
|
||||
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||
if _, err := store.Read(req); err == nil {
|
||||
t.Fatal("expected ErrNoSession")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionWithRoleDefaultsToViewer(t *testing.T) {
|
||||
// A cookie carrying no roles must never be treated as having privileges.
|
||||
store, _ := NewSessionStore(testSecret, time.Hour, false)
|
||||
encoded, err := store.encode(sessionPayload{Subject: "a", Method: "oidc", ExpiresAt: time.Now().Add(time.Hour).Unix()})
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
payload, err := store.decode(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(payload.Roles) != 0 {
|
||||
t.Errorf("payload roles = %v, want none", payload.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- oidc state --
|
||||
|
||||
func TestOAuthStateRoundTrip(t *testing.T) {
|
||||
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := state.Put(rec, "verifier-value", "/return", "nonce-value", "state-value"); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
tx, err := state.Get(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if tx.Verifier != "verifier-value" || tx.ReturnTo != "/return" ||
|
||||
tx.Nonce != "nonce-value" || tx.State != "state-value" {
|
||||
t.Errorf("round trip lost data: %+v", tx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStateRejectsTampering(t *testing.T) {
|
||||
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
var issued *http.Cookie
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
issued = c
|
||||
}
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
||||
req.AddCookie(&http.Cookie{Name: issued.Name, Value: base64RawURL(`{"v":"attacker","r":"/evil","n":"x","iat":1}`) + "." + strings.Split(issued.Value, ".")[1]})
|
||||
|
||||
if _, err := state.Get(req); err == nil {
|
||||
t.Fatal("a tampered state cookie must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthStateExpires(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
state := NewStateStore(testSecret, "bananas-oauth", true, http.SameSiteLaxMode, 10*time.Minute)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if err := state.Put(rec, "verifier", "/", "nonce", "state"); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
|
||||
// The StateStore uses time.Now directly; simulate staleness by minting an
|
||||
// old state value through the exported encoder path.
|
||||
stale := oauthState{Verifier: "v", ReturnTo: "/", IssuedAt: now.Add(-time.Hour).Unix()}
|
||||
body := base64RawURL(`{"v":"v","r":"/","n":"","iat":` + itoa(stale.IssuedAt) + `}`)
|
||||
mac := hmacSHA256([]byte(testSecret), []byte(body))
|
||||
req2 := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/auth/callback", nil)
|
||||
req2.AddCookie(&http.Cookie{Name: "bananas-oauth", Value: body + "." + base64RawURLBytes(mac[:])})
|
||||
|
||||
if _, err := state.Get(req2); err == nil {
|
||||
t.Fatal("an expired state cookie must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- sanitising URLs --
|
||||
|
||||
func TestSafeReturnToBlocksOpenRedirects(t *testing.T) {
|
||||
dangerous := []string{
|
||||
"https://evil.example.com/steal",
|
||||
"//evil.example.com/steal",
|
||||
"/\\evil.example.com",
|
||||
"javascript:alert(1)",
|
||||
"admin",
|
||||
"",
|
||||
}
|
||||
for _, input := range dangerous {
|
||||
got := SafeReturnTo(input)
|
||||
if got != "/" {
|
||||
t.Errorf("SafeReturnTo(%q) = %q, want /", input, got)
|
||||
}
|
||||
}
|
||||
|
||||
safe := map[string]string{
|
||||
"/admin": "/admin",
|
||||
"/admin/themes?x=1": "/admin/themes?x=1",
|
||||
"/": "/",
|
||||
}
|
||||
for input, want := range safe {
|
||||
if got := SafeReturnTo(input); got != want {
|
||||
t.Errorf("SafeReturnTo(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginURLIsNotAnOpenRedirect(t *testing.T) {
|
||||
// The redirect URI must always be one the provider has registered.
|
||||
cfg := config.Defaults()
|
||||
cfg.PublicBaseURL = "https://themes.example.com"
|
||||
if got := cfg.RedirectURL(); got != "https://themes.example.com/auth/callback" {
|
||||
t.Errorf("RedirectURL = %q", got)
|
||||
}
|
||||
|
||||
cfg.BasePath = "/bananas"
|
||||
if got := cfg.RedirectURL(); got != "https://themes.example.com/bananas/auth/callback" {
|
||||
t.Errorf("RedirectURL with base path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------- authenticator --
|
||||
|
||||
func TestDevBypassGrantsConfiguredRole(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.AuthMode = config.AuthModeDev
|
||||
cfg.Environment = "development"
|
||||
cfg.DevFallbackRole = "viewer"
|
||||
cfg.DevUsers = map[string]string{"boss@example.com": "admin"}
|
||||
|
||||
a := newTestAuthenticator(t, cfg, nil)
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil)
|
||||
identity, err := a.Authenticate(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
if identity.HighestRole() != domain.RoleViewer {
|
||||
t.Errorf("role = %v, want viewer", identity.Roles)
|
||||
}
|
||||
|
||||
req = httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes?dev-user=boss@example.com", nil)
|
||||
identity, err = a.Authenticate(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Authenticate: %v", err)
|
||||
}
|
||||
if identity.HighestRole() != domain.RoleAdmin {
|
||||
t.Errorf("role = %v, want admin", identity.Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevBypassRefusedOutsideDevelopment(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.AuthMode = config.AuthModeDev
|
||||
cfg.Environment = "production"
|
||||
cfg.SessionSecret = testSecret
|
||||
|
||||
if _, err := New(cfg, nil, nil); err == nil {
|
||||
t.Fatal("the development bypass must not be constructible in production")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthNoneRefusedOutsideDevelopment(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.AuthMode = config.AuthModeNone
|
||||
cfg.Environment = "production"
|
||||
cfg.SessionSecret = testSecret
|
||||
|
||||
if _, err := New(cfg, nil, nil); err == nil {
|
||||
t.Fatal("AUTH_MODE=none must not be constructible in production")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthenticatedRequestIsRejected(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.AuthMode = config.AuthModeOIDC
|
||||
cfg.Environment = "test"
|
||||
a := newTestAuthenticator(t, cfg, nil)
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/v1/themes", nil)
|
||||
if _, err := a.Authenticate(req); err == nil {
|
||||
t.Fatal("expected an unauthenticated request to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleMappingFailsClosed(t *testing.T) {
|
||||
// A user in no configured group gets viewer, never more.
|
||||
p := &OIDCProvider{config: config.Config{
|
||||
OIDCAdminGroups: []string{"platform-admins"},
|
||||
OIDCEditorGroups: []string{"designers"},
|
||||
}}
|
||||
|
||||
if got := p.rolesFor([]string{"some-other-group"}, nil); got[0] != domain.RoleViewer {
|
||||
t.Errorf("roles = %v, want viewer", got)
|
||||
}
|
||||
if got := p.rolesFor([]string{"designers"}, nil); got[0] != domain.RoleEditor {
|
||||
t.Errorf("roles = %v, want editor", got)
|
||||
}
|
||||
if got := p.rolesFor([]string{"designers", "platform-admins"}, nil); got[0] != domain.RoleAdmin {
|
||||
t.Errorf("roles = %v, want admin; the most privileged group must win", got)
|
||||
}
|
||||
if got := p.rolesFor(nil, []string{"platform-admins"}); got[0] != domain.RoleAdmin {
|
||||
t.Errorf("roles = %v, want admin from realm roles", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleMappingIsCaseInsensitive(t *testing.T) {
|
||||
p := &OIDCProvider{config: config.Config{OIDCAdminGroups: []string{"Platform-Admins"}}}
|
||||
if got := p.rolesFor([]string{"PLATFORM-ADMINS"}, nil); got[0] != domain.RoleAdmin {
|
||||
t.Errorf("roles = %v, want admin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeChallengeIsS256(t *testing.T) {
|
||||
// RFC 7636 test vector: verifier "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
// must produce challenge "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".
|
||||
got := codeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk")
|
||||
if got != "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" {
|
||||
t.Errorf("codeChallenge = %q, want the RFC 7636 value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsecureTLSConfigIsUsedOnlyWhenRequested(t *testing.T) {
|
||||
cfg := config.Defaults()
|
||||
cfg.OIDCSkipTLSVerify = true
|
||||
if client := newProviderHTTPClient(cfg); client == nil {
|
||||
t.Fatal("expected a client")
|
||||
}
|
||||
if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg == nil || !tlsCfg.InsecureSkipVerify {
|
||||
t.Error("expected TLS verification to be skipped when configured")
|
||||
}
|
||||
|
||||
cfg.OIDCSkipTLSVerify = false
|
||||
if tlsCfg := newProviderHTTPClient(cfg).Transport.(*http.Transport).TLSClientConfig; tlsCfg != nil && tlsCfg.InsecureSkipVerify {
|
||||
t.Error("TLS verification must be on by default")
|
||||
}
|
||||
var _ *tls.Config
|
||||
}
|
||||
|
||||
func TestProviderHTTPClientHasTimeout(t *testing.T) {
|
||||
// An unresponsive identity provider must not be able to hang a request
|
||||
// indefinitely.
|
||||
client := newProviderHTTPClient(config.Defaults())
|
||||
if client.Timeout == 0 {
|
||||
t.Error("provider HTTP client must have a timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLQueryEscape(t *testing.T) {
|
||||
if got := urlQueryEscape("https://x.example/y?a=1&b=2"); got != "https%3A%2F%2Fx.example%2Fy%3Fa%3D1%26b%3D2" {
|
||||
t.Errorf("urlQueryEscape = %q", got)
|
||||
}
|
||||
if _, err := url.Parse("https://x.example"); err != nil {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
}
|
||||
60
internal/auth/helpers_test.go
Normal file
60
internal/auth/helpers_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/config"
|
||||
)
|
||||
|
||||
// base64RawURL encodes without padding, matching how cookies are written.
|
||||
func base64RawURL(s string) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(s))
|
||||
}
|
||||
|
||||
// base64RawURLBytes encodes raw bytes without padding.
|
||||
func base64RawURLBytes(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// itoa renders an int64 for embedding in a JSON test fixture.
|
||||
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
|
||||
|
||||
// stubResolver is a Resolver that authenticates nobody. It exists so that tests
|
||||
// can construct an Authenticator in OIDC mode without a live provider, which
|
||||
// also documents that New refuses to start in OIDC mode without one.
|
||||
type stubResolver struct{}
|
||||
|
||||
func (stubResolver) Begin(context.Context, *http.Request, http.ResponseWriter, string) error {
|
||||
return nil
|
||||
}
|
||||
func (stubResolver) Complete(context.Context, *http.Request) (*Login, error) {
|
||||
return nil, ErrNoSession
|
||||
}
|
||||
func (stubResolver) End(context.Context, string) string { return "" }
|
||||
func (stubResolver) Enabled() bool { return true }
|
||||
func (stubResolver) ClearState(http.ResponseWriter) {}
|
||||
func (stubResolver) LogoutURL(string) string { return "https://idp.example/logout" }
|
||||
|
||||
// newTestAuthenticator builds an Authenticator for a configuration.
|
||||
//
|
||||
// The key store is optional and is passed straight through: with machine
|
||||
// credentials removed it only carries the user table, which most of these tests do
|
||||
// not exercise.
|
||||
func newTestAuthenticator(t *testing.T, cfg config.Config, keys KeyStore) *Authenticator {
|
||||
t.Helper()
|
||||
if cfg.SessionSecret == "" {
|
||||
cfg.SessionSecret = testSecret
|
||||
}
|
||||
// A stub resolver rather than nil: New refuses to construct in OIDC mode
|
||||
// without one, and the point of some of these tests is exactly that an
|
||||
// unauthenticated request is rejected even when a provider is wired up.
|
||||
a, err := New(cfg, stubResolver{}, keys)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
20
internal/auth/hmac.go
Normal file
20
internal/auth/hmac.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
)
|
||||
|
||||
// sha256HMAC computes an HMAC-SHA256 tag.
|
||||
//
|
||||
// It is a thin wrapper so that the session store and the OIDC state store
|
||||
// cannot accidentally diverge on how they sign. The stdlib hmac package already
|
||||
// performs a constant-time internal comparison; the constant-time requirement
|
||||
// in this codebase is about how *we* compare the resulting tags.
|
||||
func sha256HMAC(key, body []byte) [32]byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write(body)
|
||||
var out [32]byte
|
||||
copy(out[:], mac.Sum(nil))
|
||||
return out
|
||||
}
|
||||
375
internal/auth/oidc.go
Normal file
375
internal/auth/oidc.go
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/config"
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// OIDCProvider implements Resolver against any OpenID Connect provider,
|
||||
// including the auth-dev.urpi.be realm used for local development.
|
||||
//
|
||||
// Everything provider-specific comes from the issuer's discovery document, so
|
||||
// the same code works against Keycloak, Authentik, Zitadel or Google without a
|
||||
// configuration change. The only per-provider values are the client
|
||||
// credentials and the group-to-role mapping.
|
||||
type OIDCProvider struct {
|
||||
config config.Config
|
||||
provider *oidc.Provider
|
||||
verifier *oidc.IDTokenVerifier
|
||||
oauth *oauth2.Config
|
||||
state *StateStore
|
||||
}
|
||||
|
||||
// NewOIDCProvider performs discovery against the configured issuer and returns
|
||||
// a ready provider. Discovery happens eagerly so that a misconfigured issuer
|
||||
// fails at start-up rather than on the first login, when the failure would be
|
||||
// hardest to diagnose.
|
||||
func NewOIDCProvider(ctx context.Context, cfg config.Config, state *StateStore) (*OIDCProvider, error) {
|
||||
if cfg.OIDCIssuer == "" {
|
||||
return nil, fmt.Errorf("%w: OIDC_ISSUER is required", domain.ErrInvalid)
|
||||
}
|
||||
|
||||
provider, err := newProvider(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := &OIDCProvider{
|
||||
config: cfg,
|
||||
provider: provider,
|
||||
state: state,
|
||||
}
|
||||
|
||||
scopes := append([]string{oidc.ScopeOpenID}, cfg.OIDCScopes...)
|
||||
p.oauth = &oauth2.Config{
|
||||
ClientID: cfg.OIDCClientID,
|
||||
ClientSecret: cfg.OIDCClientSecret,
|
||||
RedirectURL: cfg.RedirectURL(),
|
||||
Scopes: scopes,
|
||||
Endpoint: provider.Endpoint(),
|
||||
}
|
||||
p.verifier = provider.Verifier(&oidc.Config{
|
||||
ClientID: cfg.OIDCClientID,
|
||||
// Keycloak and Authentik both support discovery of the JWKS; skipping
|
||||
// the audience check is not done because a token minted for another
|
||||
// client in the same realm must not be accepted here.
|
||||
})
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// newProvider runs discovery with a bounded timeout so that an unreachable
|
||||
// issuer produces a clear error instead of hanging start-up.
|
||||
func newProvider(ctx context.Context, cfg config.Config) (*oidc.Provider, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// ClientContext carries the custom HTTP client through to discovery, which
|
||||
// is how the TLS-skip development switch and the request timeout reach the
|
||||
// provider's HTTP calls.
|
||||
ctx = oidc.ClientContext(ctx, newProviderHTTPClient(cfg))
|
||||
provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OIDC discovery against %s failed: %w", cfg.OIDCIssuer, err)
|
||||
}
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// newProviderHTTPClient builds the HTTP client used for provider traffic.
|
||||
//
|
||||
// The timeout matters: without one, an unresponsive identity provider would hold
|
||||
// a request open until the client's own timeout, and a stalled provider would
|
||||
// look like a hung application.
|
||||
func newProviderHTTPClient(cfg config.Config) *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.MaxIdleConnsPerHost = 4
|
||||
if cfg.OIDCSkipTLSVerify {
|
||||
transport.TLSClientConfig = insecureTLSConfig()
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports that this resolver can authenticate.
|
||||
func (p *OIDCProvider) Enabled() bool { return p != nil && p.provider != nil }
|
||||
|
||||
// Begin starts a login by redirecting the browser to the provider. The return
|
||||
// path and PKCE verifier are carried in a signed cookie rather than
|
||||
// server-side state so that any replica can complete the flow.
|
||||
func (p *OIDCProvider) Begin(_ context.Context, req *http.Request, w http.ResponseWriter, returnTo string) error {
|
||||
verifier, err := randomVerifier()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce, err := randomNonce()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
state, err := randomState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The transaction is stored before the redirect, and stores the state it was
|
||||
// built with, so that the callback can prove the response belongs to this
|
||||
// browser's login attempt.
|
||||
if err := p.state.Put(w, verifier, SafeReturnTo(returnTo), nonce, state); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts := []oauth2.AuthCodeOption{
|
||||
// PKCE with S256. The verifier lives in a signed HttpOnly cookie, so the
|
||||
// code is bound to the browser that started the flow.
|
||||
oauth2.SetAuthURLParam("code_challenge", codeChallenge(verifier)),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||||
oauth2.SetAuthURLParam("nonce", nonce),
|
||||
oauth2.SetAuthURLParam("state", state),
|
||||
}
|
||||
http.Redirect(w, req, p.oauth.AuthCodeURL(state, opts...), http.StatusFound)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Complete finishes a login.
|
||||
//
|
||||
// The transaction cookie is the anchor: the state the provider echoes back is
|
||||
// compared against the state this service minted for this browser, and the PKCE
|
||||
// verifier and nonce both come from the same cookie. A callback that carries a
|
||||
// foreign code or a replayed state is rejected before the code is ever exchanged.
|
||||
func (p *OIDCProvider) Complete(ctx context.Context, req *http.Request) (*Login, error) {
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, fmt.Errorf("parse callback form: %w", err)
|
||||
}
|
||||
if providerErr := req.Form.Get("error"); providerErr != "" {
|
||||
desc := req.Form.Get("error_description")
|
||||
return nil, fmt.Errorf("identity provider returned %q: %s", providerErr, desc)
|
||||
}
|
||||
|
||||
tx, err := p.state.Get(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
returnedState := req.Form.Get("state")
|
||||
if returnedState == "" {
|
||||
return nil, fmt.Errorf("%w: callback is missing the state parameter", domain.ErrUnauthorized)
|
||||
}
|
||||
// Constant time, so that a mismatching state cannot be discovered a byte at a
|
||||
// time. The state is public, but the comparison is free to be careful.
|
||||
if subtle.ConstantTimeCompare([]byte(returnedState), []byte(tx.State)) != 1 {
|
||||
return nil, fmt.Errorf("%w: callback state does not match the login transaction", domain.ErrUnauthorized)
|
||||
}
|
||||
|
||||
code := req.Form.Get("code")
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("%w: callback is missing the authorization code", domain.ErrUnauthorized)
|
||||
}
|
||||
|
||||
token, err := p.oauth.Exchange(ctx, code, oauth2.VerifierOption(tx.Verifier))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("exchange authorization code: %w", err)
|
||||
}
|
||||
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok || rawIDToken == "" {
|
||||
return nil, fmt.Errorf("%w: provider returned no id_token", domain.ErrUnauthorized)
|
||||
}
|
||||
|
||||
idToken, err := p.verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify id_token: %w", err)
|
||||
}
|
||||
if idToken.Nonce != tx.Nonce {
|
||||
return nil, fmt.Errorf("%w: id_token nonce mismatch", domain.ErrUnauthorized)
|
||||
}
|
||||
|
||||
claims := struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
EmailVerified bool `json:"email_verified"`
|
||||
Name string `json:"name"`
|
||||
PreferredUser string `json:"preferred_username"`
|
||||
Groups []string `json:"groups"`
|
||||
RealmRoles []string `json:"realm_access.roles"`
|
||||
}{}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
return nil, fmt.Errorf("decode id_token claims: %w", err)
|
||||
}
|
||||
|
||||
identity := &Identity{
|
||||
Subject: claims.Subject,
|
||||
Email: claims.Email,
|
||||
Name: firstNonEmpty(claims.Name, claims.PreferredUser, claims.Email),
|
||||
Method: "oidc",
|
||||
}
|
||||
if identity.Subject == "" {
|
||||
return nil, fmt.Errorf("%w: id_token has no subject", domain.ErrUnauthorized)
|
||||
}
|
||||
identity.Roles = p.rolesFor(claims.Groups, claims.RealmRoles)
|
||||
|
||||
return &Login{Identity: identity, ReturnTo: tx.ReturnTo}, nil
|
||||
}
|
||||
|
||||
// rolesFor maps provider groups onto application roles.
|
||||
//
|
||||
// The most privileged matching group wins, and a user in no configured group
|
||||
// gets viewer. The default is deliberately the least privilege: a group mapping
|
||||
// mistake must fail closed.
|
||||
func (p *OIDCProvider) rolesFor(groups, realmRoles []string) []domain.Role {
|
||||
all := make(map[string]struct{}, len(groups)+len(realmRoles))
|
||||
for _, g := range groups {
|
||||
all[strings.ToLower(strings.TrimSpace(g))] = struct{}{}
|
||||
}
|
||||
for _, r := range realmRoles {
|
||||
all[strings.ToLower(strings.TrimSpace(r))] = struct{}{}
|
||||
}
|
||||
|
||||
matches := func(configured []string) bool {
|
||||
for _, want := range configured {
|
||||
if _, ok := all[strings.ToLower(strings.TrimSpace(want))]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
switch {
|
||||
case matches(p.config.OIDCAdminGroups):
|
||||
return []domain.Role{domain.RoleAdmin}
|
||||
case matches(p.config.OIDCEditorGroups):
|
||||
return []domain.Role{domain.RoleEditor}
|
||||
case matches(p.config.OIDCViewerGroups):
|
||||
return []domain.Role{domain.RoleViewer}
|
||||
default:
|
||||
return []domain.Role{domain.RoleViewer}
|
||||
}
|
||||
}
|
||||
|
||||
// End returns the provider's end-session URL.
|
||||
func (p *OIDCProvider) End(_ context.Context, redirect string) string {
|
||||
return p.LogoutURL(redirect)
|
||||
}
|
||||
|
||||
// LogoutURL builds the provider end-session URL.
|
||||
func (p *OIDCProvider) LogoutURL(redirect string) string {
|
||||
if redirect == "" {
|
||||
return ""
|
||||
}
|
||||
endSession := p.provider.Endpoint()
|
||||
// Keycloak, Authentik and Zitadel all accept the post_logout_redirect_uri
|
||||
// parameter on the end-session endpoint. A provider that does not
|
||||
// implement it ignores the parameter and lands the user on its own logout
|
||||
// confirmation page, which is a safe failure mode.
|
||||
if !strings.Contains(endSession.AuthURL, "/end-session") {
|
||||
return endSession.AuthURL
|
||||
}
|
||||
sep := "?"
|
||||
if strings.Contains(endSession.AuthURL, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return endSession.AuthURL + sep + "post_logout_redirect_uri=" + urlQueryEscape(redirect) +
|
||||
"&client_id=" + urlQueryEscape(p.config.OIDCClientID)
|
||||
}
|
||||
|
||||
// Issuer returns the configured issuer, used by the admin UI to show which
|
||||
// provider is in use.
|
||||
func (p *OIDCProvider) Issuer() string { return p.config.OIDCIssuer }
|
||||
|
||||
// RedirectURI returns the configured callback URL.
|
||||
func (p *OIDCProvider) RedirectURI() string { return p.config.RedirectURL() }
|
||||
|
||||
// Claims returns the claims supported by this provider, for documentation.
|
||||
func (p *OIDCProvider) Claims() []string {
|
||||
list := []string{"sub", "email", "name", "preferred_username", "groups", "realm_access.roles"}
|
||||
sort.Strings(list)
|
||||
return list
|
||||
}
|
||||
|
||||
// ClearState expires the transaction cookie. The HTTP layer calls it after
|
||||
// Complete so that a replayed callback cannot reuse a consumed verifier.
|
||||
func (p *OIDCProvider) ClearState(w http.ResponseWriter) { p.state.Clear(w) }
|
||||
|
||||
// ---------------------------------------------------------------- helpers --
|
||||
|
||||
// randomVerifier mints a PKCE verifier.
|
||||
func randomVerifier() (string, error) {
|
||||
return randomToken(32)
|
||||
}
|
||||
|
||||
// randomState mints an anti-forgery state value.
|
||||
func randomState() (string, error) {
|
||||
return randomToken(24)
|
||||
}
|
||||
|
||||
// randomNonce mints an ID token nonce.
|
||||
func randomNonce() (string, error) {
|
||||
return randomToken(16)
|
||||
}
|
||||
|
||||
// randomToken returns a URL-safe random string of n bytes.
|
||||
func randomToken(n int) (string, error) {
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("read random: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// codeChallenge returns the S256 PKCE challenge for a verifier.
|
||||
func codeChallenge(verifier string) string {
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty string.
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// urlQueryEscape percent-encodes a query parameter value.
|
||||
func urlQueryEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, b0 := range []byte(s) {
|
||||
switch {
|
||||
case b0 >= 'a' && b0 <= 'z', b0 >= 'A' && b0 <= 'Z', b0 >= '0' && b0 <= '9',
|
||||
b0 == '-', b0 == '_', b0 == '.', b0 == '~':
|
||||
b.WriteByte(b0)
|
||||
default:
|
||||
b.WriteByte('%')
|
||||
b.WriteString(strings.ToUpper(hex.EncodeToString([]byte{b0})))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// insecureTLSConfig returns a TLS configuration that skips verification.
|
||||
//
|
||||
// It exists so that a development-only identity provider presenting a
|
||||
// self-signed certificate can be used without wiring a CA into the image.
|
||||
// config.Validate refuses to start when this is enabled outside development.
|
||||
func insecureTLSConfig() *tls.Config {
|
||||
//nolint:gosec // InsecureSkipVerify is intentional and gated by config.Validate.
|
||||
return &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
572
internal/config/config.go
Normal file
572
internal/config/config.go
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
// Package config loads and validates every runtime setting from the
|
||||
// environment.
|
||||
//
|
||||
// Configuration is environment-only on purpose. The Helm chart is the single
|
||||
// source of truth for how a deployment is configured; letting the binary also
|
||||
// read a config file would create a second source that operators forget about.
|
||||
// Every value has a safe default except the secrets and the OIDC issuer, which
|
||||
// must be supplied explicitly when auth is enabled.
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Environment prefixes, tried in order. This lets the Helm chart mount a
|
||||
// ConfigMap under a distinct prefix without colliding with the pod's own
|
||||
// environment, which is what makes one image usable across environments.
|
||||
var envPrefixes = []string{"BANANAS_", "GO_BANANAS_"}
|
||||
|
||||
// Config is the fully resolved runtime configuration.
|
||||
type Config struct {
|
||||
// Environment is the deployment name, used in logs and the admin UI.
|
||||
Environment string
|
||||
// LogLevel is one of debug, info, warn, error.
|
||||
LogLevel string
|
||||
// LogFormat is json or text.
|
||||
LogFormat string
|
||||
|
||||
// HTTPAddr is the listen address, host:port.
|
||||
HTTPAddr string
|
||||
// PublicBaseURL is the externally reachable origin, without a trailing
|
||||
// slash. It is used to build absolute URLs in the runtime manifest and to
|
||||
// validate OIDC redirect targets.
|
||||
PublicBaseURL string
|
||||
// TrustedProxies lists the CIDRs or IPs whose X-Forwarded-For headers may be
|
||||
// believed. It is empty by default, which makes the service ignore
|
||||
// forwarding headers entirely and log the direct peer.
|
||||
TrustedProxies []string
|
||||
// ReadTimeout and WriteTimeout bound a single request. WriteTimeout is
|
||||
// generous because the runtime CSS endpoint is on the hot path of every page
|
||||
// load in every consuming application.
|
||||
ReadTimeout time.Duration
|
||||
WriteTimeout time.Duration
|
||||
IdleTimeout time.Duration
|
||||
// ShutdownTimeout is how long in-flight requests get to finish on SIGTERM.
|
||||
ShutdownTimeout time.Duration
|
||||
|
||||
// CORSAllowedOrigins are the browser origins allowed to call the admin API
|
||||
// from a different origin. The runtime API is always public and is not
|
||||
// subject to this list.
|
||||
CORSAllowedOrigins []string
|
||||
// RateLimitPerMinute caps requests per client for the runtime API.
|
||||
RateLimitPerMinute int
|
||||
|
||||
// DatabasePath is the SQLite file path. Use ":memory:" for tests.
|
||||
DatabasePath string
|
||||
// DatabaseBusyTimeout is how long a writer waits for the database lock
|
||||
// before failing.
|
||||
DatabaseBusyTimeout time.Duration
|
||||
// DatabaseMaxConns bounds the connection pool. SQLite serialises writers, so
|
||||
// a small pool with WAL is faster than a large one.
|
||||
DatabaseMaxConns int
|
||||
|
||||
// AuthMode is oidc, dev, or none.
|
||||
AuthMode string
|
||||
// OIDCIssuer is the issuer URL, e.g. https://auth-dev.urpi.be/realms/urpi.
|
||||
OIDCIssuer string
|
||||
// OIDCClientID is the client identifier registered with the provider.
|
||||
OIDCClientID string
|
||||
// OIDCClientSecret authenticates the confidential client.
|
||||
OIDCClientSecret string
|
||||
// OIDCScopes are requested in addition to openid.
|
||||
OIDCScopes []string
|
||||
// OIDCRedirectURL overrides the computed callback URL. It is only needed
|
||||
// when the public URL cannot be derived, for example behind a path-based
|
||||
// ingress.
|
||||
OIDCRedirectURL string
|
||||
// OIDCPostLogoutRedirectURL is where the provider sends the browser after
|
||||
// logout. It must be an allowed post-logout target on the provider side.
|
||||
OIDCPostLogoutRedirectURL string
|
||||
// OIDCAdminGroups are the group names that map to the admin role.
|
||||
OIDCAdminGroups []string
|
||||
// OIDCEditorGroups are the group names that map to the editor role.
|
||||
OIDCEditorGroups []string
|
||||
// OIDCViewerGroups are the group names that map to the viewer role.
|
||||
OIDCViewerGroups []string
|
||||
// OIDCSkipTLSVerify disables provider certificate verification. It exists
|
||||
// only so that a self-signed local provider can be used during development
|
||||
// and it is refused outside the development environment.
|
||||
OIDCSkipTLSVerify bool
|
||||
|
||||
// SessionSecret signs the session cookie. It must be at least 32 bytes and
|
||||
// is required whenever auth is enabled.
|
||||
SessionSecret string
|
||||
// SessionCookieName is the cookie carrying the session.
|
||||
SessionCookieName string
|
||||
// SessionSecure marks the session cookie Secure.
|
||||
SessionSecure bool
|
||||
// SessionTTL is the session lifetime.
|
||||
SessionTTL time.Duration
|
||||
// SessionSameSite is Lax, Strict or None.
|
||||
SessionSameSite string
|
||||
|
||||
// RuntimePublic controls whether the runtime API requires a credential. It
|
||||
// is enabled by default because a theming service's whole purpose is to be
|
||||
// reachable from browsers and CDN caches.
|
||||
RuntimePublic bool
|
||||
// RuntimeCacheControl is the Cache-Control header sent on runtime assets.
|
||||
// It is tuned for aggressive CDN caching with a revalidation window that
|
||||
// matches how often themes are published.
|
||||
RuntimeCacheControl string
|
||||
// RuntimeCDNBaseURL, when set, is used as the absolute base in the manifest
|
||||
// so consumers fetch assets from a CDN rather than from this service.
|
||||
RuntimeCDNBaseURL string
|
||||
|
||||
// SeedOnStart reconciles the built-in themes at start-up.
|
||||
SeedOnStart bool
|
||||
// DevFallbackRole is the role granted by the development auth bypass.
|
||||
DevFallbackRole string
|
||||
// DevUsers maps a development email to a role, overriding DevFallbackRole.
|
||||
DevUsers map[string]string
|
||||
|
||||
// EnableMetrics exposes /metrics in Prometheus format.
|
||||
EnableMetrics bool
|
||||
// EnableProfiler exposes the net/http/pprof handlers under /debug/pprof.
|
||||
EnableProfiler bool
|
||||
// StaticDir is the directory holding the built admin UI. When empty the UI
|
||||
// is not served.
|
||||
StaticDir string
|
||||
// SDKDir is the directory holding the built runtime SDK bundles.
|
||||
SDKDir string
|
||||
// BasePath prefixes every route, for deployments behind a path-based ingress.
|
||||
BasePath string
|
||||
|
||||
// ephemeralSessionSecret records that the secret was generated rather than
|
||||
// supplied. It is not part of the configuration surface; the application reads
|
||||
// it only to decide whether to warn.
|
||||
ephemeralSessionSecret bool
|
||||
}
|
||||
|
||||
// Auth mode values.
|
||||
const (
|
||||
AuthModeOIDC = "oidc"
|
||||
AuthModeDev = "dev"
|
||||
AuthModeNone = "none"
|
||||
)
|
||||
|
||||
// Defaults returns a Config populated with production-safe defaults. Load
|
||||
// applies the environment on top of this value.
|
||||
func Defaults() Config {
|
||||
return Config{
|
||||
Environment: "development",
|
||||
LogLevel: "info",
|
||||
LogFormat: "text",
|
||||
|
||||
HTTPAddr: ":8080",
|
||||
PublicBaseURL: "http://localhost:8080",
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ShutdownTimeout: 25 * time.Second,
|
||||
|
||||
CORSAllowedOrigins: []string{},
|
||||
RateLimitPerMinute: 3000,
|
||||
|
||||
DatabasePath: "./data/go-bananas.db",
|
||||
DatabaseBusyTimeout: 5 * time.Second,
|
||||
DatabaseMaxConns: 8,
|
||||
|
||||
AuthMode: AuthModeDev,
|
||||
OIDCScopes: []string{"profile", "email", "groups"},
|
||||
SessionCookieName: "go-bananas-session",
|
||||
SessionSecure: false,
|
||||
SessionTTL: 8 * time.Hour,
|
||||
SessionSameSite: "Lax",
|
||||
RuntimePublic: true,
|
||||
RuntimeCacheControl: "public, max-age=60, s-maxage=300, stale-while-revalidate=86400",
|
||||
SeedOnStart: true,
|
||||
DevFallbackRole: "admin",
|
||||
DevUsers: map[string]string{},
|
||||
EnableMetrics: true,
|
||||
EnableProfiler: false,
|
||||
StaticDir: "./web/dist",
|
||||
SDKDir: "./sdk/dist",
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads configuration from the environment and validates it.
|
||||
func Load() (Config, error) {
|
||||
return loadFrom(os.LookupEnv)
|
||||
}
|
||||
|
||||
// loadFrom is the testable core of Load.
|
||||
func loadFrom(lookup func(string) (string, bool)) (Config, error) {
|
||||
cfg := Defaults()
|
||||
|
||||
get := func(name string) (string, bool) {
|
||||
for _, prefix := range envPrefixes {
|
||||
if v, ok := lookup(prefix + name); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
str := func(name string, target *string) {
|
||||
if v, ok := get(name); ok {
|
||||
*target = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
|
||||
cfg.Environment = firstNonEmpty(cfg.Environment,
|
||||
func() (string, bool) { return get("ENVIRONMENT") },
|
||||
func() (string, bool) { return get("ENV") },
|
||||
)
|
||||
str("LOG_LEVEL", &cfg.LogLevel)
|
||||
str("LOG_FORMAT", &cfg.LogFormat)
|
||||
|
||||
str("HTTP_ADDR", &cfg.HTTPAddr)
|
||||
str("PUBLIC_BASE_URL", &cfg.PublicBaseURL)
|
||||
str("BASE_PATH", &cfg.BasePath)
|
||||
str("STATIC_DIR", &cfg.StaticDir)
|
||||
str("SDK_DIR", &cfg.SDKDir)
|
||||
|
||||
dur := func(name string, target *time.Duration) error {
|
||||
v, ok := get(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
parsed, err := time.ParseDuration(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
*target = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
num := func(name string, target *int) error {
|
||||
v, ok := get(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
*target = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
boolean := func(name string, target *bool) error {
|
||||
v, ok := get(name)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
*target = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
list := func(name string, target *[]string) {
|
||||
if v, ok := get(name); ok {
|
||||
*target = splitList(v)
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
for _, f := range []func() error{
|
||||
func() error { return dur("HTTP_READ_TIMEOUT", &cfg.ReadTimeout) },
|
||||
func() error { return dur("HTTP_WRITE_TIMEOUT", &cfg.WriteTimeout) },
|
||||
func() error { return dur("HTTP_IDLE_TIMEOUT", &cfg.IdleTimeout) },
|
||||
func() error { return dur("SHUTDOWN_TIMEOUT", &cfg.ShutdownTimeout) },
|
||||
func() error { return dur("SESSION_TTL", &cfg.SessionTTL) },
|
||||
func() error { return dur("DB_BUSY_TIMEOUT", &cfg.DatabaseBusyTimeout) },
|
||||
func() error { return num("RATE_LIMIT_PER_MINUTE", &cfg.RateLimitPerMinute) },
|
||||
func() error { return num("DB_MAX_CONNS", &cfg.DatabaseMaxConns) },
|
||||
func() error { return boolean("RUNTIME_PUBLIC", &cfg.RuntimePublic) },
|
||||
func() error { return boolean("SEED_ON_START", &cfg.SeedOnStart) },
|
||||
func() error { return boolean("ENABLE_METRICS", &cfg.EnableMetrics) },
|
||||
func() error { return boolean("ENABLE_PROFILER", &cfg.EnableProfiler) },
|
||||
func() error { return boolean("SESSION_SECURE", &cfg.SessionSecure) },
|
||||
func() error { return boolean("OIDC_SKIP_TLS_VERIFY", &cfg.OIDCSkipTLSVerify) },
|
||||
} {
|
||||
if err = f(); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
}
|
||||
|
||||
list("TRUSTED_PROXIES", &cfg.TrustedProxies)
|
||||
list("CORS_ALLOWED_ORIGINS", &cfg.CORSAllowedOrigins)
|
||||
list("OIDC_SCOPES", &cfg.OIDCScopes)
|
||||
list("OIDC_ADMIN_GROUPS", &cfg.OIDCAdminGroups)
|
||||
list("OIDC_EDITOR_GROUPS", &cfg.OIDCEditorGroups)
|
||||
list("OIDC_VIEWER_GROUPS", &cfg.OIDCViewerGroups)
|
||||
|
||||
str("AUTH_MODE", &cfg.AuthMode)
|
||||
str("OIDC_ISSUER", &cfg.OIDCIssuer)
|
||||
str("OIDC_CLIENT_ID", &cfg.OIDCClientID)
|
||||
str("OIDC_CLIENT_SECRET", &cfg.OIDCClientSecret)
|
||||
str("OIDC_REDIRECT_URL", &cfg.OIDCRedirectURL)
|
||||
str("OIDC_POST_LOGOUT_REDIRECT_URL", &cfg.OIDCPostLogoutRedirectURL)
|
||||
str("SESSION_SECRET", &cfg.SessionSecret)
|
||||
str("SESSION_COOKIE_NAME", &cfg.SessionCookieName)
|
||||
str("SESSION_SAMESITE", &cfg.SessionSameSite)
|
||||
str("DEV_FALLBACK_ROLE", &cfg.DevFallbackRole)
|
||||
str("RUNTIME_CACHE_CONTROL", &cfg.RuntimeCacheControl)
|
||||
str("RUNTIME_CDN_BASE_URL", &cfg.RuntimeCDNBaseURL)
|
||||
str("DATABASE_PATH", &cfg.DatabasePath)
|
||||
|
||||
if v, ok := get("DEV_USERS"); ok {
|
||||
cfg.DevUsers = parseDevUsers(v)
|
||||
}
|
||||
|
||||
// A development run should not require the operator to invent a 32-byte
|
||||
// secret before the first request. An ephemeral one is generated instead,
|
||||
// which is safe because nothing durable is signed with it: sessions simply do
|
||||
// not survive a restart, which a local run never notices.
|
||||
if cfg.AuthMode != AuthModeNone && cfg.SessionSecret == "" && cfg.IsDevelopment() {
|
||||
generated, genErr := randomSecret()
|
||||
if genErr != nil {
|
||||
return cfg, genErr
|
||||
}
|
||||
cfg.SessionSecret = generated
|
||||
cfg.ephemeralSessionSecret = true
|
||||
}
|
||||
|
||||
return cfg, cfg.Validate()
|
||||
}
|
||||
|
||||
// EphemeralSessionSecret reports whether the session secret was generated rather
|
||||
// than supplied. The caller logs it as a warning, because it means sessions
|
||||
// survive only until the process restarts.
|
||||
func (c Config) EphemeralSessionSecret() bool { return c.ephemeralSessionSecret }
|
||||
|
||||
// randomSecret returns a 48-byte cryptographically random value, base64 encoded.
|
||||
func randomSecret() (string, error) {
|
||||
buf := make([]byte, 48)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("generate session secret: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// Validate checks the configuration for internal consistency. It returns every
|
||||
// problem it finds rather than only the first, because fixing configuration one
|
||||
// error per deploy is a miserable loop.
|
||||
func (c Config) Validate() error {
|
||||
var problems []string
|
||||
add := func(format string, args ...any) {
|
||||
problems = append(problems, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
switch c.AuthMode {
|
||||
case AuthModeOIDC, AuthModeDev, AuthModeNone:
|
||||
default:
|
||||
add("AUTH_MODE %q must be one of oidc, dev or none", c.AuthMode)
|
||||
}
|
||||
|
||||
if c.AuthMode == AuthModeOIDC {
|
||||
if c.OIDCIssuer == "" {
|
||||
add("OIDC_ISSUER is required when AUTH_MODE=oidc")
|
||||
} else if parsed, err := url.Parse(c.OIDCIssuer); err != nil {
|
||||
add("OIDC_ISSUER is not a valid URL: %v", err)
|
||||
} else if parsed.Scheme != "https" && !isLocalHost(parsed.Hostname()) {
|
||||
add("OIDC_ISSUER must use https outside local development, got %q", parsed.Scheme)
|
||||
}
|
||||
if c.OIDCClientID == "" {
|
||||
add("OIDC_CLIENT_ID is required when AUTH_MODE=oidc")
|
||||
}
|
||||
if c.OIDCClientSecret == "" {
|
||||
add("OIDC_CLIENT_SECRET is required when AUTH_MODE=oidc")
|
||||
}
|
||||
if c.OIDCSkipTLSVerify && !c.IsDevelopment() {
|
||||
add("OIDC_SKIP_TLS_VERIFY is refused outside development; it disables certificate verification on the identity provider connection")
|
||||
}
|
||||
}
|
||||
|
||||
if c.AuthMode != AuthModeNone {
|
||||
if len(c.SessionSecret) < 32 {
|
||||
add("SESSION_SECRET must be at least 32 bytes when auth is enabled, got %d", len(c.SessionSecret))
|
||||
}
|
||||
}
|
||||
|
||||
if c.PublicBaseURL != "" {
|
||||
parsed, err := url.Parse(c.PublicBaseURL)
|
||||
switch {
|
||||
case err != nil:
|
||||
add("PUBLIC_BASE_URL is not a valid URL: %v", err)
|
||||
case parsed.Scheme == "":
|
||||
add("PUBLIC_BASE_URL must include a scheme, got %q", c.PublicBaseURL)
|
||||
case parsed.Host == "":
|
||||
add("PUBLIC_BASE_URL must include a host, got %q", c.PublicBaseURL)
|
||||
}
|
||||
}
|
||||
|
||||
if c.BasePath != "" && !strings.HasPrefix(c.BasePath, "/") {
|
||||
add("BASE_PATH must start with a slash, got %q", c.BasePath)
|
||||
}
|
||||
|
||||
switch strings.ToLower(c.SessionSameSite) {
|
||||
case "lax", "strict", "none":
|
||||
default:
|
||||
add("SESSION_SAMESITE %q must be lax, strict or none", c.SessionSameSite)
|
||||
}
|
||||
|
||||
if strings.EqualFold(c.SessionSameSite, "none") && !c.SessionSecure {
|
||||
// Browsers reject SameSite=None without Secure, so this combination
|
||||
// silently produces a session that never survives a redirect.
|
||||
add("SESSION_SAMESITE=none requires SESSION_SECURE=true")
|
||||
}
|
||||
|
||||
if !c.SessionSecure && !c.IsDevelopment() && c.PublicBaseURL != "" {
|
||||
if strings.HasPrefix(c.PublicBaseURL, "https://") {
|
||||
add("SESSION_SECURE must be true when PUBLIC_BASE_URL is https")
|
||||
}
|
||||
}
|
||||
|
||||
if c.DatabasePath == "" {
|
||||
add("DATABASE_PATH must not be empty")
|
||||
}
|
||||
if c.DatabaseMaxConns < 1 {
|
||||
add("DB_MAX_CONNS must be at least 1, got %d", c.DatabaseMaxConns)
|
||||
}
|
||||
if c.RateLimitPerMinute < 0 {
|
||||
add("RATE_LIMIT_PER_MINUTE must not be negative")
|
||||
}
|
||||
if !c.IsDevelopment() && c.AuthMode == AuthModeDev {
|
||||
add("AUTH_MODE=dev is refused outside development; it grants admin to anyone who can reach the service")
|
||||
}
|
||||
if c.AuthMode == AuthModeNone && !c.IsDevelopment() {
|
||||
add("AUTH_MODE=none is refused outside development")
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return errors.New("invalid configuration:\n - " + strings.Join(problems, "\n - "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDevelopment reports whether the service is running in development.
|
||||
func (c Config) IsDevelopment() bool {
|
||||
switch strings.ToLower(c.Environment) {
|
||||
case "development", "dev", "local", "test", "testing", "ci":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// RedirectURL returns the OIDC callback URL, derived from the public base when
|
||||
// not overridden.
|
||||
func (c Config) RedirectURL() string {
|
||||
if c.OIDCRedirectURL != "" {
|
||||
return c.OIDCRedirectURL
|
||||
}
|
||||
return c.origin() + c.path("/auth/callback")
|
||||
}
|
||||
|
||||
// PostLogoutRedirectURL returns the OIDC end-session target.
|
||||
func (c Config) PostLogoutRedirectURL() string {
|
||||
if c.OIDCPostLogoutRedirectURL != "" {
|
||||
return c.OIDCPostLogoutRedirectURL
|
||||
}
|
||||
return c.origin() + c.path("/")
|
||||
}
|
||||
|
||||
// Origin returns the public origin with no trailing slash.
|
||||
func (c Config) Origin() string { return c.origin() }
|
||||
|
||||
// origin normalises the public base URL.
|
||||
func (c Config) origin() string {
|
||||
return strings.TrimRight(c.PublicBaseURL, "/")
|
||||
}
|
||||
|
||||
// path prefixes a route with the configured base path.
|
||||
func (c Config) path(route string) string {
|
||||
base := strings.TrimRight(c.BasePath, "/")
|
||||
if base == "" {
|
||||
return route
|
||||
}
|
||||
return base + route
|
||||
}
|
||||
|
||||
// Path exposes base-path prefixing to the HTTP layer.
|
||||
func (c Config) Path(route string) string { return c.path(route) }
|
||||
|
||||
// CDNBasis returns the absolute base that runtime asset URLs are built from,
|
||||
// honouring the CDN override.
|
||||
func (c Config) CDNBasis() string {
|
||||
if c.RuntimeCDNBaseURL != "" {
|
||||
return strings.TrimRight(c.RuntimeCDNBaseURL, "/")
|
||||
}
|
||||
return c.origin() + c.path("/rt/v1")
|
||||
}
|
||||
|
||||
// splitList parses a comma or whitespace separated environment list.
|
||||
func splitList(raw string) []string {
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == '\n' || r == '\t' || r == ' '
|
||||
})
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if trimmed := strings.TrimSpace(f); trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseDevUsers parses the development role map in "email=role,email=role" form.
|
||||
func parseDevUsers(raw string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for _, pair := range splitList(raw) {
|
||||
email, role, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
role = strings.ToLower(strings.TrimSpace(role))
|
||||
if email == "" || role == "" {
|
||||
continue
|
||||
}
|
||||
out[email] = role
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty value from a default and a list of
|
||||
// candidate environment lookups.
|
||||
func firstNonEmpty(def string, lookups ...func() (string, bool)) string {
|
||||
for _, lookup := range lookups {
|
||||
if v, ok := lookup(); ok && strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// isLocalHost reports whether host refers to the local machine.
|
||||
func isLocalHost(host string) bool {
|
||||
switch strings.ToLower(host) {
|
||||
case "localhost", "127.0.0.1", "::1", "keycloak", "idp", "auth":
|
||||
return true
|
||||
default:
|
||||
return strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".localhost")
|
||||
}
|
||||
}
|
||||
|
||||
// Redacted returns a copy of the configuration with secrets masked, suitable
|
||||
// for logging or for the diagnostics endpoint.
|
||||
func (c Config) Redacted() Config {
|
||||
mask := func(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
return "***redacted***"
|
||||
}
|
||||
c.OIDCClientSecret = mask(c.OIDCClientSecret)
|
||||
c.SessionSecret = mask(c.SessionSecret)
|
||||
return c
|
||||
}
|
||||
466
internal/domain/domain.go
Normal file
466
internal/domain/domain.go
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
// Package domain holds the core entities of go-bananas. It has no knowledge of
|
||||
// HTTP, SQL or any delivery mechanism: it is the shared vocabulary that the
|
||||
// store, the HTTP API and the token engine all agree on.
|
||||
//
|
||||
// Coding standard: types in this package are plain data. Behaviour that is
|
||||
// genuinely domain logic (not transport or persistence) is allowed, but it must
|
||||
// be pure and deterministic.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TimeFormat is the single wire format used for every timestamp crossing a
|
||||
// process boundary. RFC 3339 with second precision keeps SQLite TEXT columns
|
||||
// lexicographically sortable, which the store relies on for ORDER BY.
|
||||
const TimeFormat = "2006-01-02T15:04:05Z07:00"
|
||||
|
||||
// ThemeStatus is the publication state of a theme.
|
||||
type ThemeStatus string
|
||||
|
||||
const (
|
||||
// StatusDraft is editable by editors but invisible to runtime consumers.
|
||||
StatusDraft ThemeStatus = "draft"
|
||||
// StatusPublished is served to runtime consumers.
|
||||
StatusPublished ThemeStatus = "published"
|
||||
// StatusArchived is hidden from listings and runtime by default, but the
|
||||
// last published revision stays resolvable for pinned consumers.
|
||||
StatusArchived ThemeStatus = "archived"
|
||||
)
|
||||
|
||||
// Valid reports whether s is a known status.
|
||||
func (s ThemeStatus) Valid() bool {
|
||||
switch s {
|
||||
case StatusDraft, StatusPublished, StatusArchived:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Publishable lists the statuses a theme may be transitioned into.
|
||||
func (s ThemeStatus) Publishable() bool {
|
||||
return s == StatusDraft || s == StatusPublished || s == StatusArchived
|
||||
}
|
||||
|
||||
// Appearance selects a colour scheme from a theme.
|
||||
type Appearance string
|
||||
|
||||
const (
|
||||
// AppearanceLight selects the light colour scheme.
|
||||
AppearanceLight Appearance = "light"
|
||||
// AppearanceDark selects the dark colour scheme.
|
||||
AppearanceDark Appearance = "dark"
|
||||
)
|
||||
|
||||
// Valid reports whether a is a known appearance.
|
||||
func (a Appearance) Valid() bool { return a == AppearanceLight || a == AppearanceDark }
|
||||
|
||||
// slugPattern constrains theme slugs to something safe for URLs, filenames and
|
||||
// CSS selectors without quoting.
|
||||
var slugPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$`)
|
||||
|
||||
// ValidateSlug returns an error if slug is not a valid theme slug.
|
||||
func ValidateSlug(slug string) error {
|
||||
if slug == "" {
|
||||
return errors.New("slug must not be empty")
|
||||
}
|
||||
if len(slug) > 64 {
|
||||
return errors.New("slug must be at most 64 characters")
|
||||
}
|
||||
if !slugPattern.MatchString(slug) {
|
||||
return fmt.Errorf("slug %q must match %s", slug, slugPattern)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReservedSlugs may not be used by user-created themes because the runtime
|
||||
// delivery API exposes them as fixed paths.
|
||||
var ReservedSlugs = map[string]struct{}{
|
||||
"api": {},
|
||||
"rt": {},
|
||||
"admin": {},
|
||||
"healthz": {},
|
||||
"readyz": {},
|
||||
"livez": {},
|
||||
"metrics": {},
|
||||
"assets": {},
|
||||
"sdk": {},
|
||||
}
|
||||
|
||||
// ValidateAppearance parses and validates a raw appearance string, falling back
|
||||
// to light when empty.
|
||||
func ParseAppearance(raw string) (Appearance, error) {
|
||||
if raw == "" {
|
||||
return AppearanceLight, nil
|
||||
}
|
||||
a := Appearance(strings.ToLower(raw))
|
||||
if !a.Valid() {
|
||||
return "", fmt.Errorf("unknown appearance %q: want %q or %q", raw, AppearanceLight, AppearanceDark)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// ThemeMeta is free-form, human-supplied metadata. It is deliberately opaque to
|
||||
// the engine so consumers can carry whatever they need (author, homepage,
|
||||
// licence, tags) without a schema migration.
|
||||
type ThemeMeta map[string]any
|
||||
|
||||
// Theme is the aggregate root: an identified, versioned bundle of design
|
||||
// tokens plus the metadata needed to present and govern it.
|
||||
type Theme struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status ThemeStatus `json:"status"`
|
||||
DefaultAppearance Appearance `json:"defaultAppearance"`
|
||||
// Tokens is the canonical design-token document. See the tokenengine
|
||||
// package for the schema, validation rules and rendering.
|
||||
Tokens TokenDoc `json:"tokens"`
|
||||
Meta ThemeMeta `json:"meta,omitempty"`
|
||||
// System marks built-in themes that ship with the binary. They may be
|
||||
// duplicated and re-published but never deleted, so consumers pinned to a
|
||||
// built-in slug never break on upgrade.
|
||||
System bool `json:"system"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreatedBy string `json:"createdBy,omitempty"`
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
}
|
||||
|
||||
// Clone returns a deep-enough copy that mutating the result's token leaves or
|
||||
// meta map cannot affect the receiver. Token leaf values are treated as opaque
|
||||
// scalars, which is how the engine guarantees they are.
|
||||
func (t *Theme) Clone() *Theme {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
out := *t
|
||||
out.Tokens = t.Tokens.Clone()
|
||||
if t.Meta != nil {
|
||||
out.Meta = make(ThemeMeta, len(t.Meta))
|
||||
for k, v := range t.Meta {
|
||||
out.Meta[k] = v
|
||||
}
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// ThemeVersion is an immutable snapshot of a theme taken on every mutation.
|
||||
type ThemeVersion struct {
|
||||
ThemeID string `json:"themeId"`
|
||||
Version int64 `json:"version"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status ThemeStatus `json:"status"`
|
||||
DefaultAppearance Appearance `json:"defaultAppearance"`
|
||||
Tokens TokenDoc `json:"tokens"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// TokenDoc is a design-token document. The outer map is a set of token groups;
|
||||
// each group is a nested tree whose leaves carry a "$type" and a "$value". It is
|
||||
// modelled as a plain map so that consumers can extend a theme without an API
|
||||
// change, while the engine enforces the parts it needs to render CSS.
|
||||
type TokenDoc map[string]any
|
||||
|
||||
// Clone deep-copies the token document.
|
||||
func (d TokenDoc) Clone() TokenDoc {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(TokenDoc, len(d))
|
||||
for k, v := range d {
|
||||
out[k] = cloneAny(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneAny(v any) any {
|
||||
switch t := v.(type) {
|
||||
case TokenDoc:
|
||||
return t.Clone()
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, vv := range t {
|
||||
out[k] = cloneAny(vv)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, len(t))
|
||||
for i, vv := range t {
|
||||
out[i] = cloneAny(vv)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// ThemeQuery narrows a theme listing. The zero value matches every theme.
|
||||
type ThemeQuery struct {
|
||||
// Search matches slug, name or description, case-insensitively.
|
||||
Search string
|
||||
// Status filters by publication state. Empty matches all states.
|
||||
Status ThemeStatus
|
||||
// System filters on the built-in flag. Nil matches both.
|
||||
System *bool
|
||||
// Tag requires the theme meta to contain the given tag.
|
||||
Tag string
|
||||
Limit int
|
||||
Offset int
|
||||
// Sort is one of "updated", "created", "name", "slug".
|
||||
Sort string
|
||||
// Order is "asc" or "desc".
|
||||
Order string
|
||||
}
|
||||
|
||||
// SortFields is the allow-list of sortable columns. Values are compared against
|
||||
// a fixed set so that caller input never reaches the SQL statement verbatim.
|
||||
var SortFields = map[string]string{
|
||||
"updated": "updated_at",
|
||||
"created": "created_at",
|
||||
"name": "name",
|
||||
"slug": "slug",
|
||||
}
|
||||
|
||||
// ThemePage is a page of themes plus the total match count.
|
||||
type ThemePage struct {
|
||||
Items []*Theme `json:"items"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
// Role is a coarse authorisation level, resolved from the identity provider.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
// RoleViewer may read published themes.
|
||||
RoleViewer Role = "viewer"
|
||||
// RoleEditor may create, edit and publish themes.
|
||||
RoleEditor Role = "editor"
|
||||
// RoleAdmin may additionally manage users, API keys and instance settings.
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
|
||||
// Rank returns a comparable privilege level.
|
||||
func (r Role) Rank() int {
|
||||
switch r {
|
||||
case RoleAdmin:
|
||||
return 3
|
||||
case RoleEditor:
|
||||
return 2
|
||||
case RoleViewer:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// AtLeast reports whether r carries at least the privilege of want.
|
||||
func (r Role) AtLeast(want Role) bool { return r.Rank() >= want.Rank() }
|
||||
|
||||
// ParseRole maps a raw claim value onto a Role, defaulting to viewer so that an
|
||||
// unexpected claim can never grant more than read access.
|
||||
func ParseRole(raw string) Role {
|
||||
switch Role(strings.ToLower(strings.TrimSpace(raw))) {
|
||||
case RoleAdmin:
|
||||
return RoleAdmin
|
||||
case RoleEditor:
|
||||
return RoleEditor
|
||||
case RoleViewer:
|
||||
return RoleViewer
|
||||
default:
|
||||
return RoleViewer
|
||||
}
|
||||
}
|
||||
|
||||
// Identity is the authenticated caller, normalised across every auth mode
|
||||
// (OIDC session, API key, or development bypass).
|
||||
type Identity struct {
|
||||
Subject string `json:"subject"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Roles []Role `json:"roles"`
|
||||
Method string `json:"method"`
|
||||
// ExpiresAt is a pointer because omitempty does not work on time.Time: a zero
|
||||
// time is not the empty value to encoding/json, so a plain time.Time here
|
||||
// serialised as "0001-01-01T00:00:00Z" on every identity that has no expiry.
|
||||
// That is a real value to a client, and an API key with no expiry is normal.
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// UserPreferences is what one person has chosen for their own interface.
|
||||
//
|
||||
// The preference names a published theme rather than a copy of it, so editing or
|
||||
// unpublishing that theme is reflected everywhere without touching a row here. An
|
||||
// empty SelectedTheme means nothing has been chosen and the client should apply
|
||||
// its own default; SelectedAppearance is then ignored.
|
||||
type UserPreferences struct {
|
||||
SelectedTheme string `json:"selectedTheme"`
|
||||
SelectedAppearance Appearance `json:"selectedAppearance"`
|
||||
}
|
||||
|
||||
// Validate reports whether p is storable. An empty theme is the absence of a
|
||||
// choice and is always valid; a named theme must be a syntactically valid slug and
|
||||
// the appearance must be one this build knows.
|
||||
func (p UserPreferences) Validate() error {
|
||||
if p.SelectedTheme == "" {
|
||||
if p.SelectedAppearance != "" && !p.SelectedAppearance.Valid() {
|
||||
return fmt.Errorf("%w: selectedAppearance %q is not light or dark", ErrInvalid, p.SelectedAppearance)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := ValidateSlug(p.SelectedTheme); err != nil {
|
||||
// ValidateSlug reports a bare error because it is also used for messages
|
||||
// that are not about a request. Wrapping it here is what makes a bad slug
|
||||
// a 400 through writeProblem rather than a 500.
|
||||
return fmt.Errorf("%w: %s", ErrInvalid, err.Error())
|
||||
}
|
||||
if p.SelectedAppearance != "" && !p.SelectedAppearance.Valid() {
|
||||
return fmt.Errorf("%w: selectedAppearance %q is not light or dark", ErrInvalid, p.SelectedAppearance)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HighestRole returns the caller's most privileged role.
|
||||
func (i *Identity) HighestRole() Role {
|
||||
var best Role
|
||||
for _, r := range i.Roles {
|
||||
if r.Rank() > best.Rank() {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return RoleViewer
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// HasRole reports whether the identity carries the given role.
|
||||
func (i *Identity) HasRole(r Role) bool {
|
||||
for _, have := range i.Roles {
|
||||
if have == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DisplayName returns the friendliest available label for the identity.
|
||||
func (i *Identity) DisplayName() string {
|
||||
if i == nil {
|
||||
return "anonymous"
|
||||
}
|
||||
switch {
|
||||
case i.Name != "":
|
||||
return i.Name
|
||||
case i.Email != "":
|
||||
return i.Email
|
||||
case i.Subject != "":
|
||||
return i.Subject
|
||||
default:
|
||||
return "anonymous"
|
||||
}
|
||||
}
|
||||
|
||||
// Setting is one instance-level configuration value.
|
||||
//
|
||||
// Settings live in the database rather than in the environment because they are
|
||||
// editable from the admin UI while the service is running. Changing the default
|
||||
// theme takes effect on the next request, with no restart.
|
||||
type Setting struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
}
|
||||
|
||||
// Setting keys. They are constants rather than free strings so a typo is a compile
|
||||
// error, and so the keys in the database have one definition.
|
||||
const (
|
||||
// SettingDefaultTheme is the slug served when a consumer names no theme.
|
||||
SettingDefaultTheme = "default.theme"
|
||||
// SettingDefaultAppearance is the appearance used when the default theme
|
||||
// declares none.
|
||||
SettingDefaultAppearance = "default.appearance"
|
||||
// SettingRuntimeAPIBase overrides the runtime API base URL advertised in the
|
||||
// manifest, for a deployment that serves it from a CDN.
|
||||
SettingRuntimeAPIBase = "runtime.apiBase"
|
||||
// SettingRuntimePublic lists the origins allowed to embed the runtime SDK.
|
||||
SettingRuntimePublic = "runtime.public"
|
||||
)
|
||||
|
||||
// AuditAction is the kind of change an audit entry records.
|
||||
type AuditAction string
|
||||
|
||||
// The audit actions this build records.
|
||||
const (
|
||||
ActionThemeCreate AuditAction = "theme.create"
|
||||
ActionThemeUpdate AuditAction = "theme.update"
|
||||
ActionThemeDelete AuditAction = "theme.delete"
|
||||
ActionThemePublish AuditAction = "theme.publish"
|
||||
ActionThemeArchive AuditAction = "theme.archive"
|
||||
ActionThemeDuplicate AuditAction = "theme.duplicate"
|
||||
ActionThemeRollback AuditAction = "theme.rollback"
|
||||
ActionThemeImport AuditAction = "theme.import"
|
||||
ActionAuthLogin AuditAction = "auth.login"
|
||||
ActionAuthLogout AuditAction = "auth.logout"
|
||||
)
|
||||
|
||||
// AuditEntry is one recorded action.
|
||||
//
|
||||
// The audit log is append-only and is the record of who changed what. The actor is
|
||||
// taken from the authenticated identity at the point of writing rather than from
|
||||
// the request body, so it cannot be forged by a client.
|
||||
type AuditEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
At time.Time `json:"at"`
|
||||
Actor string `json:"actor"`
|
||||
ActorEmail string `json:"actorEmail,omitempty"`
|
||||
Action AuditAction `json:"action"`
|
||||
EntityType string `json:"entityType,omitempty"`
|
||||
EntityID string `json:"entityId,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Detail map[string]any `json:"detail,omitempty"`
|
||||
RemoteIP string `json:"remoteIp,omitempty"`
|
||||
}
|
||||
|
||||
// Error sentinels.
|
||||
//
|
||||
// Handlers match on these with errors.Is rather than comparing strings, so the
|
||||
// mapping from a domain failure to an HTTP status lives in exactly one place and a
|
||||
// wrapped error still classifies correctly.
|
||||
var (
|
||||
// ErrInvalid means the request was malformed or failed validation.
|
||||
ErrInvalid = errors.New("invalid input")
|
||||
// ErrNotFound means the addressed thing does not exist.
|
||||
ErrNotFound = errors.New("not found")
|
||||
// ErrUnauthorized means the caller is not authenticated, or the credential
|
||||
// presented did not identify anyone.
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
// ErrForbidden means the caller is known but not permitted.
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
// ErrConflict means the change collides with existing state, such as a slug
|
||||
// that is already taken or a stale base version.
|
||||
ErrConflict = errors.New("conflict")
|
||||
// ErrImmutable means the target is one this service owns and will not let an
|
||||
// operator change, such as a built-in theme. It is distinct from ErrForbidden:
|
||||
// the caller is allowed to ask, and the answer is no.
|
||||
ErrImmutable = errors.New("immutable")
|
||||
)
|
||||
|
||||
// ErrNotConfigured is returned when a feature is unavailable because of how the
|
||||
// service was configured, as distinct from a request that was made wrongly.
|
||||
var ErrNotConfigured = errors.New("not configured")
|
||||
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)
|
||||
}
|
||||
130
internal/logging/logging.go
Normal file
130
internal/logging/logging.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// Package logging configures structured logging for the service.
|
||||
//
|
||||
// It wraps log/slog rather than a third-party logger so that the output is
|
||||
// plain JSON or plain text with no vendor in the dependency graph, and so that
|
||||
// the level and format can be chosen from the environment the Helm chart sets.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Logger is the logging interface the application depends on. Declaring it here
|
||||
// keeps handlers and middleware testable with a recorder, and keeps them from
|
||||
// importing slog directly.
|
||||
type Logger interface {
|
||||
Debug(msg string, args ...any)
|
||||
Info(msg string, args ...any)
|
||||
Warn(msg string, args ...any)
|
||||
Error(msg string, args ...any)
|
||||
With(args ...any) Logger
|
||||
}
|
||||
|
||||
// Options configures a logger.
|
||||
type Options struct {
|
||||
// Level is debug, info, warn or error.
|
||||
Level string
|
||||
// Format is json or text.
|
||||
Format string
|
||||
// Service is the value of the service.name attribute.
|
||||
Service string
|
||||
// Environment is the deployment name.
|
||||
Environment string
|
||||
// Version is the build version, reported in logs.
|
||||
Version string
|
||||
// Commit is the source revision, reported in logs.
|
||||
Commit string
|
||||
// BuildDate is the build timestamp, reported in logs.
|
||||
BuildDate string
|
||||
// Writer overrides the destination, for tests.
|
||||
Writer io.Writer
|
||||
}
|
||||
|
||||
// New builds a logger.
|
||||
func New(opts Options) Logger {
|
||||
writer := opts.Writer
|
||||
if writer == nil {
|
||||
writer = os.Stdout
|
||||
}
|
||||
|
||||
handlerOpts := &slog.HandlerOptions{Level: parseLevel(opts.Level)}
|
||||
|
||||
var handler slog.Handler
|
||||
if strings.EqualFold(opts.Format, "text") {
|
||||
handler = slog.NewTextHandler(writer, handlerOpts)
|
||||
} else {
|
||||
handler = slog.NewJSONHandler(writer, handlerOpts)
|
||||
}
|
||||
|
||||
logger := slog.New(handler).With(
|
||||
slog.String("service", orDefault(opts.Service, "Go Bananas!!")),
|
||||
slog.String("environment", opts.Environment),
|
||||
slog.String("version", opts.Version),
|
||||
slog.String("commit", orDefault(opts.Commit, "unknown")),
|
||||
slog.String("built", orDefault(opts.BuildDate, "unknown")),
|
||||
)
|
||||
return &slogLogger{logger: logger}
|
||||
}
|
||||
|
||||
// parseLevel maps a configuration string onto a slog level, defaulting to info
|
||||
// so that a typo never silences the service.
|
||||
func parseLevel(raw string) slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// orDefault returns value when it is not empty, otherwise fallback.
|
||||
func orDefault(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// slogLogger adapts *slog.Logger to the Logger interface.
|
||||
type slogLogger struct {
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func (l *slogLogger) Debug(msg string, args ...any) { l.logger.Debug(msg, args...) }
|
||||
func (l *slogLogger) Info(msg string, args ...any) { l.logger.Info(msg, args...) }
|
||||
func (l *slogLogger) Warn(msg string, args ...any) { l.logger.Warn(msg, args...) }
|
||||
func (l *slogLogger) Error(msg string, args ...any) { l.logger.Error(msg, args...) }
|
||||
|
||||
func (l *slogLogger) With(args ...any) Logger {
|
||||
return &slogLogger{logger: l.logger.With(args...)}
|
||||
}
|
||||
|
||||
// Discard returns a logger that drops everything. It exists so that tests and
|
||||
// one-off commands do not have to thread a logger through just to satisfy the
|
||||
// dependency.
|
||||
func Discard() Logger {
|
||||
return &slogLogger{logger: slog.New(slog.NewJSONHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError + 8}))}
|
||||
}
|
||||
|
||||
// FromContext returns the logger stored on a context, or a discarding logger.
|
||||
func FromContext(ctx context.Context) Logger {
|
||||
if logger, ok := ctx.Value(loggerKey{}).(Logger); ok {
|
||||
return logger
|
||||
}
|
||||
return Discard()
|
||||
}
|
||||
|
||||
type loggerKey struct{}
|
||||
|
||||
// WithContext stores a logger on a context.
|
||||
func WithContext(ctx context.Context, logger Logger) context.Context {
|
||||
return context.WithValue(ctx, loggerKey{}, logger)
|
||||
}
|
||||
605
internal/seed/consolidated.go
Normal file
605
internal/seed/consolidated.go
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
package seed
|
||||
|
||||
import (
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
"github.com/urpi/go-bananas/internal/tokenengine"
|
||||
)
|
||||
|
||||
// Palettes consolidated from the sibling applications.
|
||||
//
|
||||
// Each of these is a real palette lifted from an application that is already running
|
||||
// with it, rather than a generated ramp. That is the point of consolidating them: the
|
||||
// applications can adopt the shared contract and keep the look their users already
|
||||
// have, instead of being handed a new design.
|
||||
//
|
||||
// The colours are the roles those applications actually use, mapped onto the contract's
|
||||
// roles. A source application had only one appearance for some roles and a different
|
||||
// one for others, so both schemes are filled in here and the contract supplies the
|
||||
// second axis.
|
||||
//
|
||||
// Provenance is recorded in the description. A palette nobody can trace back to the
|
||||
// screen it came from becomes an orphan within a year.
|
||||
|
||||
// Colours and values repeated across the palettes below.
|
||||
//
|
||||
// A consolidated palette set is mostly repetition by nature — every dark scheme has
|
||||
// the same white for reversed text, every go-loose theme has the same card radius —
|
||||
// so these are named rather than repeated inline. That makes a change to "what white
|
||||
// means here" a one-line edit instead of a search.
|
||||
const (
|
||||
white = "#ffffff"
|
||||
paperLight = "#fffaf0"
|
||||
cardRadius = "16px"
|
||||
// tightRadius is the corner radius of the two palettes whose source application
|
||||
// uses tighter geometry than go-loose's 16px cards.
|
||||
tightRadius = "10px"
|
||||
|
||||
// interStack is the sans stack four of the six applications ask for by name. None
|
||||
// of them load the file, so in practice this resolves to the system UI font; it
|
||||
// is kept because it is what the applications declare, and a future font load
|
||||
// should not have to touch six stylesheets.
|
||||
interStack = "'Inter', ui-sans-serif, system-ui, sans-serif"
|
||||
)
|
||||
|
||||
// scheme is one appearance's worth of literal colours.
|
||||
type scheme struct {
|
||||
Background string
|
||||
Surface string
|
||||
SurfaceRaised string
|
||||
SurfaceSunken string
|
||||
Border string
|
||||
BorderStrong string
|
||||
Text string
|
||||
TextMuted string
|
||||
TextSubtle string
|
||||
TextInverted string
|
||||
Primary string
|
||||
PrimaryHover string
|
||||
PrimaryActive string
|
||||
OnPrimary string
|
||||
Secondary string
|
||||
OnSecondary string
|
||||
Success string
|
||||
Warning string
|
||||
Danger string
|
||||
Info string
|
||||
FocusRing string
|
||||
Overlay string
|
||||
Ramp tokenengine.RampOptions
|
||||
FontSans string
|
||||
RadiusCard string
|
||||
}
|
||||
|
||||
// consolidate assembles a seeded theme from two literal schemes.
|
||||
//
|
||||
// Ramps are still generated, for the palette and status families the contract exposes
|
||||
// as scales (`--bn-palette-*`). The semantic roles are literal, because those are the
|
||||
// values the source applications actually render and inventing a ramp here would
|
||||
// change the appearance the consolidation is meant to preserve.
|
||||
func consolidate(
|
||||
slug, name, description string,
|
||||
defaultAppearance domain.Appearance,
|
||||
tags []string,
|
||||
light, dark scheme,
|
||||
) SeededTheme {
|
||||
palettes := map[string]any{
|
||||
roleBrand: tokenengine.GenerateRamp(light.Ramp),
|
||||
roleNeutral: tokenengine.GenerateRamp(neutralRamp(light.Ramp)),
|
||||
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 55, Lightness: 42, HueSpread: 10}),
|
||||
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 88, Lightness: 50, HueSpread: 8}),
|
||||
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 68, Lightness: 50, HueSpread: 8}),
|
||||
}
|
||||
|
||||
fontSans := light.FontSans
|
||||
if fontSans == "" {
|
||||
fontSans = interStack
|
||||
}
|
||||
typography := typeScale(fontSans, 0.875, 1.25, 1.625)
|
||||
typography["family"] = map[string]any{
|
||||
"sans": family(fontSans),
|
||||
"mono": family("ui-monospace, SFMono-Regular, Menlo, monospace"),
|
||||
"display": family(fontSans),
|
||||
}
|
||||
|
||||
shadowSet := map[string]any{
|
||||
"none": shadow("none"),
|
||||
"sm": shadow("0 1px 2px rgba(0,0,0,0.06)"),
|
||||
"md": shadow("0 6px 20px rgba(0,0,0,0.08)"),
|
||||
"lg": shadow("0 18px 48px rgba(0,0,0,0.14)"),
|
||||
"xl": shadow("0 28px 80px rgba(0,0,0,0.20)"),
|
||||
"border": shadow("inset 0 0 0 1px rgba(0,0,0,0.08)"),
|
||||
}
|
||||
|
||||
doc := assemble(palettes,
|
||||
literalSemantics(light),
|
||||
literalSemantics(dark),
|
||||
typography,
|
||||
shadowSet,
|
||||
)
|
||||
|
||||
// The transcribed corner radius becomes a real token rather than a note in a
|
||||
// struct field. A consumer writing `border-radius: var(--radius-md)` then gets
|
||||
// the source application's actual radius, which is the whole reason for copying
|
||||
// its palette rather than approximating one.
|
||||
applyCardRadius(doc, light.RadiusCard)
|
||||
|
||||
return SeededTheme{
|
||||
Slug: slug,
|
||||
Name: name,
|
||||
Description: description,
|
||||
Appearance: defaultAppearance,
|
||||
Tags: tags,
|
||||
Tokens: doc,
|
||||
}
|
||||
}
|
||||
|
||||
// applyCardRadius sets the radius scale so that its `md` step is the source
|
||||
// application's card radius, with the surrounding steps kept in proportion.
|
||||
//
|
||||
// A palette whose corners are visibly rounder or squarer than the application it came
|
||||
// from does not read as that application, and the radius is one of the first things a
|
||||
// user notices.
|
||||
func applyCardRadius(doc domain.TokenDoc, card string) {
|
||||
if card == "" {
|
||||
return
|
||||
}
|
||||
group, ok := doc["radius"].(map[string]any)
|
||||
if !ok {
|
||||
group = map[string]any{}
|
||||
doc["radius"] = group
|
||||
}
|
||||
// Proportions taken from the scale the other built-in themes use, so a
|
||||
// consolidated palette and an original one are the same shape.
|
||||
for step, factor := range map[string]string{
|
||||
"sm": "0.5",
|
||||
"md": "1",
|
||||
"lg": "1.5",
|
||||
"xl": "2",
|
||||
} {
|
||||
if step == "md" {
|
||||
group[step] = dim(card)
|
||||
continue
|
||||
}
|
||||
group[step] = dim("calc(" + card + " * " + factor + ")")
|
||||
}
|
||||
}
|
||||
|
||||
// neutralRamp derives a low-saturation neutral from a brand ramp, so a palette and its
|
||||
// greys come from one description rather than two guesses.
|
||||
func neutralRamp(brand tokenengine.RampOptions) tokenengine.RampOptions {
|
||||
return tokenengine.RampOptions{
|
||||
Hue: brand.Hue,
|
||||
Saturation: minFloat(brand.Saturation*0.18, 22),
|
||||
Lightness: minFloat(brand.Lightness*0.72, 52),
|
||||
HueSpread: 4,
|
||||
}
|
||||
}
|
||||
|
||||
// minFloat returns the smaller of two values.
|
||||
func minFloat(a, b float64) float64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// literalSemantics turns a scheme into the contract's semantic colour roles.
|
||||
//
|
||||
// Every role is filled explicitly. A missing role would fall back to the engine's
|
||||
// light-mode default, which for a dark-first palette is a pale colour in the middle of
|
||||
// a dark interface — a bug that is easy to miss and impossible to debug from a
|
||||
// screenshot.
|
||||
func literalSemantics(s scheme) map[string]any {
|
||||
out := map[string]any{
|
||||
"background": color(s.Background),
|
||||
"surface": color(s.Surface),
|
||||
"surface-raised": color(s.SurfaceRaised),
|
||||
"surface-sunken": color(s.SurfaceSunken),
|
||||
"border": color(s.Border),
|
||||
"border-strong": color(s.BorderStrong),
|
||||
"text": color(s.Text),
|
||||
"text-muted": color(s.TextMuted),
|
||||
"text-subtle": color(s.TextSubtle),
|
||||
"text-inverted": color(s.TextInverted),
|
||||
"primary": color(s.Primary),
|
||||
"primary-hover": color(s.PrimaryHover),
|
||||
"primary-active": color(s.PrimaryActive),
|
||||
"on-primary": color(s.OnPrimary),
|
||||
"secondary": color(s.Secondary),
|
||||
"on-secondary": color(s.OnSecondary),
|
||||
"success": color(s.Success),
|
||||
"warning": color(s.Warning),
|
||||
"danger": color(s.Danger),
|
||||
"info": color(s.Info),
|
||||
"focus-ring": color(s.FocusRing),
|
||||
"overlay": color(s.Overlay),
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The four palettes below come from four different applications. The colours are
|
||||
// theirs, transcribed; where an application had only one appearance, the other is
|
||||
// derived by inverting the surface and text roles and keeping the accent family, which
|
||||
// is what its own stylesheet effectively does.
|
||||
|
||||
// tokyoNight is go-loose's default palette, the one its switcher opens on.
|
||||
//
|
||||
// The slug is `tokyo`, not `tokyo-night`, because that is the value go-loose already
|
||||
// writes into its data-theme attribute. Matching it is the whole point: adopting the
|
||||
// contract must not require the adopting application to change a single stored value.
|
||||
func tokyoNight() SeededTheme {
|
||||
blue := tokenengine.RampOptions{Hue: 224, Saturation: 60, Lightness: 58, HueSpread: 10}
|
||||
|
||||
return consolidate(
|
||||
"tokyo", "Tokyo Night",
|
||||
"go-loose's default palette. Low-contrast blue-grey with a bright accent; "+
|
||||
"chosen for long sessions against a terminal.",
|
||||
domain.AppearanceDark,
|
||||
[]string{appearanceDark, tagDefault, "blue", "go-loose"},
|
||||
scheme{
|
||||
Background: "#e6e7ed", Surface: white, SurfaceRaised: white,
|
||||
SurfaceSunken: "#dfe2ed", Border: "#b4b5c3", BorderStrong: "#8b8da1",
|
||||
Text: "#343b58", TextMuted: "#34548a", TextSubtle: "#4a5a80",
|
||||
TextInverted: white,
|
||||
Primary: "#0f4b6e", PrimaryHover: "#0c3d5a", PrimaryActive: "#093249",
|
||||
OnPrimary: white,
|
||||
Secondary: "#5a4a78", OnSecondary: white,
|
||||
Success: "#485e30", Warning: "#8a6a1f", Danger: "#8c4351", Info: "#2f6b8f",
|
||||
FocusRing: "#0f4b6e", Overlay: "rgba(52, 59, 88, 0.32)",
|
||||
Ramp: blue, FontSans: interStack,
|
||||
RadiusCard: cardRadius,
|
||||
},
|
||||
scheme{
|
||||
Background: "#1a1b26", Surface: "#20222f", SurfaceRaised: "#282a37",
|
||||
SurfaceSunken: "#16161e", Border: "#292e42", BorderStrong: "#3b4261",
|
||||
Text: "#c0caf5", TextMuted: "#9aa5ce", TextSubtle: "#7aa2f7",
|
||||
TextInverted: "#1a1b26",
|
||||
Primary: "#7aa2f7", PrimaryHover: "#93b1ff", PrimaryActive: "#aac7ff",
|
||||
OnPrimary: "#1a1b26",
|
||||
Secondary: "#bb9af7", OnSecondary: "#1a1b26",
|
||||
Success: "#9ece6a", Warning: "#e0af68", Danger: "#f7768e", Info: "#7dcfff",
|
||||
FocusRing: "#7aa2f7", Overlay: "rgba(0, 0, 0, 0.55)",
|
||||
Ramp: blue, FontSans: interStack,
|
||||
RadiusCard: cardRadius,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// catppuccin is go-loose's second palette, a soft pastel set.
|
||||
func catppuccin() SeededTheme {
|
||||
lavender := tokenengine.RampOptions{Hue: 232, Saturation: 62, Lightness: 62, HueSpread: 12}
|
||||
|
||||
return consolidate(
|
||||
"catppuccin", "Catppuccin",
|
||||
"go-loose's pastel palette. Softer contrast than Tokyo Night, which suits a "+
|
||||
"content-heavy screen better than a dense one.",
|
||||
domain.AppearanceDark,
|
||||
[]string{appearanceDark, "pastel", "lavender", "go-loose"},
|
||||
scheme{
|
||||
Background: "#eff1f5", Surface: white, SurfaceRaised: white,
|
||||
SurfaceSunken: "#e6e9ef", Border: "#bcc0cc", BorderStrong: "#9499b0",
|
||||
Text: "#4c4f69", TextMuted: "#5c61a8", TextSubtle: "#6c6f85",
|
||||
TextInverted: white,
|
||||
Primary: "#1e66f5", PrimaryHover: "#1a56db", PrimaryActive: "#1647b8",
|
||||
OnPrimary: white,
|
||||
Secondary: "#8839ef", OnSecondary: white,
|
||||
Success: "#40a02b", Warning: "#df8e1d", Danger: "#d20f39", Info: "#179299",
|
||||
FocusRing: "#1e66f5", Overlay: "rgba(76, 79, 105, 0.28)",
|
||||
Ramp: lavender, FontSans: interStack,
|
||||
RadiusCard: cardRadius,
|
||||
},
|
||||
scheme{
|
||||
Background: "#1e1e2e", Surface: "#181825", SurfaceRaised: "#313244",
|
||||
SurfaceSunken: "#11111b", Border: "#313244", BorderStrong: "#45475a",
|
||||
Text: "#cdd6f4", TextMuted: "#a6adc8", TextSubtle: "#9399b2",
|
||||
TextInverted: "#1e1e2e",
|
||||
Primary: "#89b4fa", PrimaryHover: "#a3c3f5", PrimaryActive: "#b8d0f6",
|
||||
OnPrimary: "#1e1e2e",
|
||||
Secondary: "#cba6f7", OnSecondary: "#1e1e2e",
|
||||
Success: "#a6e3a1", Warning: "#f9e2af", Danger: "#f38ba8", Info: "#94e2d5",
|
||||
FocusRing: "#89b4fa", Overlay: "rgba(0, 0, 0, 0.5)",
|
||||
Ramp: lavender, FontSans: interStack,
|
||||
RadiusCard: cardRadius,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// nordicMoss is go-tell's green palette, slugged `nordic` to match its own value. Its source theme is dark-only; the light
|
||||
// scheme is the inversion with the green family kept.
|
||||
func nordicMoss() SeededTheme {
|
||||
moss := tokenengine.RampOptions{Hue: 132, Saturation: 32, Lightness: 44, HueSpread: 12}
|
||||
|
||||
return consolidate(
|
||||
"nordic", "Nordic Moss",
|
||||
"go-tell's green palette. Desaturated and low-contrast; the light scheme is "+
|
||||
"derived, because the source theme is dark-only.",
|
||||
domain.AppearanceDark,
|
||||
[]string{appearanceDark, "green", "calm", "go-tell"},
|
||||
scheme{
|
||||
Background: "#e9f0e8", Surface: white, SurfaceRaised: "#f4f8f3",
|
||||
SurfaceSunken: "#dfe9de", Border: "#c2d6c4", BorderStrong: "#94a899",
|
||||
Text: "#16241c", TextMuted: "#3b5645", TextSubtle: "#588157",
|
||||
TextInverted: white,
|
||||
Primary: "#2e4435", PrimaryHover: "#25392d", PrimaryActive: "#1a2820",
|
||||
OnPrimary: "#e9f0e8",
|
||||
Secondary: "#6f894e", OnSecondary: white,
|
||||
Success: "#3f6b46", Warning: "#8a7434", Danger: "#a1453f", Info: "#3a6a70",
|
||||
FocusRing: "#2e4435", Overlay: "rgba(22, 36, 28, 0.28)",
|
||||
Ramp: moss, FontSans: interStack,
|
||||
RadiusCard: tightRadius,
|
||||
},
|
||||
scheme{
|
||||
Background: "#121c16", Surface: "#1e3025", SurfaceRaised: "#25392d",
|
||||
SurfaceSunken: "#0e1712", Border: "#2e4435", BorderStrong: "#3b5645",
|
||||
Text: "#e9f0e8", TextMuted: "#a3c9a8", TextSubtle: "#8fa193",
|
||||
TextInverted: "#121c16",
|
||||
Primary: "#a3c9a8", PrimaryHover: "#b8d8bb", PrimaryActive: "#c9e2cb",
|
||||
OnPrimary: "#121c16",
|
||||
Secondary: "#8fbc8f", OnSecondary: "#121c16",
|
||||
Success: "#8fbc8f", Warning: "#e2c044", Danger: "#e07a72", Info: "#7fb4ca",
|
||||
FocusRing: "#a3c9a8", Overlay: "rgba(0, 0, 0, 0.52)",
|
||||
Ramp: moss, FontSans: interStack,
|
||||
RadiusCard: tightRadius,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// deepSpace is go-tell's `obsidian` palette, and the one that most needs a light
|
||||
// scheme: its source is a near-black space theme, so an untouched inversion would be
|
||||
// unusable.
|
||||
func deepSpace() SeededTheme {
|
||||
sky := tokenengine.RampOptions{Hue: 199, Saturation: 88, Lightness: 58, HueSpread: 12}
|
||||
|
||||
return consolidate(
|
||||
"obsidian", "Deep Space Obsidian",
|
||||
"go-tell's near-black blue palette. The light scheme is authored rather than "+
|
||||
"inverted, because the source is a dark-first space theme.",
|
||||
domain.AppearanceDark,
|
||||
[]string{appearanceDark, "blue", "space", "go-tell"},
|
||||
scheme{
|
||||
Background: "#f1f5f9", Surface: white, SurfaceRaised: "#f8fafc",
|
||||
SurfaceSunken: "#e8eef5", Border: "#d3dce6", BorderStrong: "#a8b6c6",
|
||||
Text: "#0f172a", TextMuted: "#475569", TextSubtle: "#64748b",
|
||||
TextInverted: "#f8fafc",
|
||||
Primary: "#0284c7", PrimaryHover: "#0369a1", PrimaryActive: "#075985",
|
||||
OnPrimary: white,
|
||||
Secondary: "#1e293b", OnSecondary: "#f8fafc",
|
||||
Success: "#15803d", Warning: "#b45309", Danger: "#b91c1c", Info: "#0e7490",
|
||||
FocusRing: "#0284c7", Overlay: "rgba(15, 23, 42, 0.3)",
|
||||
Ramp: sky, FontSans: interStack,
|
||||
RadiusCard: tightRadius,
|
||||
},
|
||||
scheme{
|
||||
Background: "#060709", Surface: "#0f172a", SurfaceRaised: "#151d2a",
|
||||
SurfaceSunken: "#020617", Border: "#1e293b", BorderStrong: "#334155",
|
||||
Text: "#e2e8f0", TextMuted: "#94a3b8", TextSubtle: "#64748b",
|
||||
TextInverted: "#060709",
|
||||
Primary: "#38bdf8", PrimaryHover: "#7dd3fc", PrimaryActive: "#bae6fd",
|
||||
OnPrimary: "#060709",
|
||||
Secondary: "#60a5fa", OnSecondary: "#060709",
|
||||
Success: "#4ade80", Warning: "#fbbf24", Danger: "#f87171", Info: "#22d3ee",
|
||||
FocusRing: "#38bdf8", Overlay: "rgba(0, 0, 0, 0.6)",
|
||||
Ramp: sky, FontSans: interStack,
|
||||
RadiusCard: tightRadius,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// urpiStandard is go-guess's palette, which is the only light-first application of the
|
||||
// six and therefore the one whose light scheme is the authoritative one.
|
||||
func urpiStandard() SeededTheme {
|
||||
cyan := tokenengine.RampOptions{Hue: 190, Saturation: 78, Lightness: 40, HueSpread: 10}
|
||||
|
||||
return consolidate(
|
||||
"urpi-standard", "urpi Standard",
|
||||
"go-guess's palette: a cyan accent and lime highlight on a cool grey. The "+
|
||||
"light scheme is authoritative, being the application's only one.",
|
||||
domain.AppearanceLight,
|
||||
[]string{"light", tagDefault, "cyan", "go-guess"},
|
||||
scheme{
|
||||
Background: "#f3f7f8", Surface: white, SurfaceRaised: white,
|
||||
SurfaceSunken: "#edf2f3", Border: "#dce5e8", BorderStrong: "#c2d2d6",
|
||||
Text: "#142b3a", TextMuted: "#526872", TextSubtle: "#78909c",
|
||||
TextInverted: white,
|
||||
Primary: "#007f98", PrimaryHover: "#006b81", PrimaryActive: "#00586a",
|
||||
OnPrimary: white,
|
||||
Secondary: "#d9ef4d", OnSecondary: "#0b573c",
|
||||
Success: "#13704d", Warning: "#925a09", Danger: "#934141", Info: "#087187",
|
||||
FocusRing: "#007f98", Overlay: "rgba(14, 42, 56, 0.34)",
|
||||
Ramp: cyan, FontSans: "'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif",
|
||||
RadiusCard: cardRadius,
|
||||
},
|
||||
scheme{
|
||||
Background: "#081c2c", Surface: "#0e2a3f", SurfaceRaised: "#123449",
|
||||
SurfaceSunken: "#04131f", Border: "#1d4258", BorderStrong: "#2c5f7a",
|
||||
Text: "#e3ebed", TextMuted: "#a9bec8", TextSubtle: "#82949d",
|
||||
TextInverted: "#081c2c",
|
||||
Primary: "#00a9c8", PrimaryHover: "#33bcd6", PrimaryActive: "#66cde0",
|
||||
OnPrimary: "#04131f",
|
||||
Secondary: "#d9ef4d", OnSecondary: "#0b573c",
|
||||
Success: "#16a274", Warning: "#eda83c", Danger: "#e6988c", Info: "#43c99c",
|
||||
FocusRing: "#00a9c8", Overlay: "rgba(0, 0, 0, 0.55)",
|
||||
Ramp: cyan, FontSans: "'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif",
|
||||
RadiusCard: cardRadius,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// tomorrow is go-loose's third palette: a warm grey with a muted teal.
|
||||
func tomorrow() SeededTheme {
|
||||
warm := tokenengine.RampOptions{Hue: 40, Saturation: 8, Lightness: 42, HueSpread: 4}
|
||||
teal := tokenengine.RampOptions{Hue: 178, Saturation: 34, Lightness: 40, HueSpread: 8}
|
||||
|
||||
return consolidate(
|
||||
"tomorrow", "Tomorrow",
|
||||
"go-loose's warm grey palette. The least saturated of the four, and the "+
|
||||
"easiest to read for long periods.",
|
||||
domain.AppearanceDark,
|
||||
[]string{appearanceDark, "warm", "muted", "go-loose"},
|
||||
scheme{
|
||||
Background: "#f5f5f5", Surface: white, SurfaceRaised: white,
|
||||
SurfaceSunken: "#e8e8e8", Border: "#c9c9c9", BorderStrong: "#a3a3a3",
|
||||
Text: "#4d4d4c", TextMuted: "#6e6e6c", TextSubtle: "#8a8a88",
|
||||
TextInverted: white,
|
||||
Primary: "#3e999f", PrimaryHover: "#348389", PrimaryActive: "#2b6d72",
|
||||
OnPrimary: white,
|
||||
Secondary: "#8959a8", OnSecondary: white,
|
||||
Success: "#718c00", Warning: "#b58900", Danger: "#c82829", Info: "#3e999f",
|
||||
FocusRing: "#3e999f", Overlay: "rgba(77, 77, 76, 0.3)",
|
||||
Ramp: teal, FontSans: interStack,
|
||||
RadiusCard: cardRadius,
|
||||
},
|
||||
scheme{
|
||||
Background: "#1d1f21", Surface: "#282a2e", SurfaceRaised: "#373b41",
|
||||
SurfaceSunken: "#1a1c1e", Border: "#4d5057", BorderStrong: "#6b6f77",
|
||||
Text: "#c5c8c6", TextMuted: "#a0a4a2", TextSubtle: "#81a2be",
|
||||
TextInverted: "#1d1f21",
|
||||
Primary: "#8abeb7", PrimaryHover: "#a5d1cb", PrimaryActive: "#bcdfd9",
|
||||
OnPrimary: "#1d1f21",
|
||||
Secondary: "#b294bb", OnSecondary: "#1d1f21",
|
||||
Success: "#b5bd68", Warning: "#e0af68", Danger: "#cc6666", Info: "#81a2be",
|
||||
FocusRing: "#8abeb7", Overlay: "rgba(0, 0, 0, 0.52)",
|
||||
Ramp: warm, FontSans: interStack,
|
||||
RadiusCard: cardRadius,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// kanagawa is go-loose's fourth palette: a warm paper light scheme and a stone dark one.
|
||||
func kanagawa() SeededTheme {
|
||||
wave := tokenengine.RampOptions{Hue: 28, Saturation: 26, Lightness: 56, HueSpread: 10}
|
||||
stone := tokenengine.RampOptions{Hue: 20, Saturation: 10, Lightness: 46, HueSpread: 6}
|
||||
|
||||
return consolidate(
|
||||
"kanagawa", "Kanagawa",
|
||||
"go-loose's paper palette. A warm off-white light scheme, which is the "+
|
||||
"warmest surface in the set.",
|
||||
domain.AppearanceLight,
|
||||
[]string{"light", "warm", "paper", "go-loose"},
|
||||
scheme{
|
||||
Background: "#f2ecde", Surface: paperLight, SurfaceRaised: paperLight,
|
||||
SurfaceSunken: "#e7dba9", Border: "#c8c093", BorderStrong: "#a89b6e",
|
||||
Text: "#545464", TextMuted: "#5e5e70", TextSubtle: "#78788c",
|
||||
TextInverted: paperLight,
|
||||
Primary: "#4d7f8c", PrimaryHover: "#406d78", PrimaryActive: "#345c65",
|
||||
OnPrimary: paperLight,
|
||||
Secondary: "#624c83", OnSecondary: paperLight,
|
||||
Success: "#6f894e", Warning: "#9a7d2e", Danger: "#c34043", Info: "#4d7f8c",
|
||||
FocusRing: "#4d7f8c", Overlay: "rgba(84, 84, 100, 0.3)",
|
||||
Ramp: stone, FontSans: interStack,
|
||||
RadiusCard: cardRadius,
|
||||
},
|
||||
scheme{
|
||||
Background: "#1f1f28", Surface: "#2a2a37", SurfaceRaised: "#363646",
|
||||
SurfaceSunken: "#191921", Border: "#54546d", BorderStrong: "#6b6b85",
|
||||
Text: "#dcd7ba", TextMuted: "#b4b098", TextSubtle: "#a8a28c",
|
||||
TextInverted: "#1f1f28",
|
||||
Primary: "#7fb4ca", PrimaryHover: "#98c4d6", PrimaryActive: "#b0d2e0",
|
||||
OnPrimary: "#1f1f28",
|
||||
Secondary: "#957fb8", OnSecondary: "#1f1f28",
|
||||
Success: "#98bb6c", Warning: "#e0c07a", Danger: "#e46876", Info: "#7fb4ca",
|
||||
FocusRing: "#7fb4ca", Overlay: "rgba(0, 0, 0, 0.5)",
|
||||
Ramp: wave, FontSans: interStack,
|
||||
RadiusCard: cardRadius,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// cyberpunk is go-tell's default palette: a near-black violet field with a hot pink
|
||||
// and a bright cyan.
|
||||
//
|
||||
// It is the one palette in the set whose identity is two accents rather than one, and
|
||||
// that is why it is expressed with two status roles doing unusual work: pink carries
|
||||
// the brand, cyan carries interaction. Keeping pink on `--bn-accent` would lose the
|
||||
// pink-versus-cyan distinction that the whole theme is built on, so the secondary
|
||||
// carries cyan instead and the pair reads in the order the design intends.
|
||||
func cyberpunk() SeededTheme {
|
||||
violet := tokenengine.RampOptions{Hue: 288, Saturation: 72, Lightness: 44, HueSpread: 20}
|
||||
|
||||
return consolidate(
|
||||
"cyberpunk", "Cyberpunk",
|
||||
"go-tell's default palette. A hot-pink and cyan pair on near-black violet, "+
|
||||
"which is the one palette here built on two accents rather than one.",
|
||||
domain.AppearanceDark,
|
||||
[]string{appearanceDark, tagDefault, "pink", "cyan", "go-tell"},
|
||||
scheme{
|
||||
Background: "#f2e8ee", Surface: "#ffffff", SurfaceRaised: "#fdf5f9",
|
||||
SurfaceSunken: "#e9dde5", Border: "#c9b3c0", BorderStrong: "#a68b9c",
|
||||
Text: "#2a1226", TextMuted: "#5c4457", TextSubtle: "#7f6577",
|
||||
TextInverted: "#f5e0e8",
|
||||
Primary: "#c2185b", PrimaryHover: "#a5144d", PrimaryActive: "#8c1042",
|
||||
OnPrimary: white,
|
||||
// Cyan as the secondary: see the note above.
|
||||
Secondary: "#00838f", OnSecondary: white,
|
||||
Success: "#2e7d32", Warning: "#c77800", Danger: "#c62828", Info: "#0277bd",
|
||||
FocusRing: "#c2185b", Overlay: "rgba(42, 18, 38, 0.5)",
|
||||
Ramp: violet, FontSans: interStack, RadiusCard: tightRadius,
|
||||
},
|
||||
scheme{
|
||||
Background: "#120826", Surface: "#1b1035", SurfaceRaised: "#22153d",
|
||||
SurfaceSunken: "#0c0519", Border: "#3d2366", BorderStrong: "#4a2d7a",
|
||||
Text: "#f5e0e8", TextMuted: "#a6adc8", TextSubtle: "#7f849c",
|
||||
TextInverted: "#120826",
|
||||
// The source has no separate hover or active step; these are the two
|
||||
// lighter pinks it uses on hover and on the cursor.
|
||||
Primary: "#ff2a85", PrimaryHover: "#ff4d9a", PrimaryActive: "#ff6b9d",
|
||||
OnPrimary: "#120826",
|
||||
// Cyan as the secondary, so a consumer's --bn-secondary reaches the
|
||||
// interaction colour the theme is known for.
|
||||
Secondary: "#00f0ff", OnSecondary: "#120826",
|
||||
Success: "#00e5a0", Warning: "#ffd166", Danger: "#ef233c", Info: "#00f0ff",
|
||||
FocusRing: "#00f0ff", Overlay: "rgba(0, 0, 0, 0.62)",
|
||||
Ramp: violet, FontSans: interStack, RadiusCard: tightRadius,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// terminalRetro is go-tell's green-on-black palette.
|
||||
//
|
||||
// Two of its traits are not colours and cannot be expressed as tokens: it forces a
|
||||
// monospace face on every element, and it adds a text glow. Both are recorded in the
|
||||
// description and left in the application's own stylesheet, because a token is a
|
||||
// value and these are rules.
|
||||
func terminalRetro() SeededTheme {
|
||||
green := tokenengine.RampOptions{Hue: 138, Saturation: 100, Lightness: 45, HueSpread: 16}
|
||||
|
||||
return consolidate(
|
||||
"terminal", "Terminal Retro",
|
||||
"go-tell's green-on-black palette. The source theme also forces a monospace "+
|
||||
"face on every element and adds a text glow; those are rules rather than "+
|
||||
"values, so they stay in the application's stylesheet.",
|
||||
domain.AppearanceDark,
|
||||
[]string{"dark", "terminal", "mono", "green", "go-tell"},
|
||||
scheme{
|
||||
Background: "#e8f5e9", Surface: "#ffffff", SurfaceRaised: "#f2fbf3",
|
||||
SurfaceSunken: "#dcecdc", Border: "#a8d8ad", BorderStrong: "#7fbf86",
|
||||
Text: "#041404", TextMuted: "#1e5c22", TextSubtle: "#3d7a42",
|
||||
TextInverted: "#041404",
|
||||
Primary: "#007a12", PrimaryHover: "#006410", PrimaryActive: "#004e0d",
|
||||
OnPrimary: "#dcfce0",
|
||||
Secondary: "#7a5c00", OnSecondary: "#fff8dc",
|
||||
Success: "#00a32b", Warning: "#8a6d00", Danger: "#b30000", Info: "#007a12",
|
||||
FocusRing: "#00a32b", Overlay: "rgba(4, 20, 4, 0.55)",
|
||||
Ramp: green,
|
||||
FontSans: "'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
RadiusCard: "4px",
|
||||
},
|
||||
scheme{
|
||||
Background: "#040804", Surface: "#081608", SurfaceRaised: "#0e1a0e",
|
||||
SurfaceSunken: "#020402", Border: "#00aa44", BorderStrong: "#00ff66",
|
||||
Text: "#00ff66", TextMuted: "#33ff33", TextSubtle: "#00aa44",
|
||||
TextInverted: "#040804",
|
||||
// The source's brightest green is its interactive colour, and its mid green
|
||||
// is the hover step.
|
||||
Primary: "#00ff66", PrimaryHover: "#33ff33", PrimaryActive: "#00dd55",
|
||||
OnPrimary: "#040804",
|
||||
// The Mac traffic-light yellow the source uses in its window chrome.
|
||||
Secondary: "#ffcc00", OnSecondary: "#261e00",
|
||||
Success: "#00ff66", Warning: "#ffcc00", Danger: "#ff3333", Info: "#00aa44",
|
||||
FocusRing: "#00ff66", Overlay: "rgba(0, 0, 0, 0.7)",
|
||||
Ramp: green,
|
||||
FontSans: "'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
RadiusCard: "4px",
|
||||
},
|
||||
)
|
||||
}
|
||||
80
internal/seed/consolidated_test.go
Normal file
80
internal/seed/consolidated_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package seed_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
"github.com/urpi/go-bananas/internal/seed"
|
||||
"github.com/urpi/go-bananas/internal/tokenengine"
|
||||
)
|
||||
|
||||
// TestGoTellSlugsAreSeeded is the one that matters for adoption.
|
||||
//
|
||||
// go-tell's theme switcher offers cyberpunk, nordic, obsidian and terminal, and its
|
||||
// stylesheet keys on html[data-theme="<slug>"]. If any of those slugs is missing from
|
||||
// the service, selecting it sets an attribute that resolves to nothing and the
|
||||
// application falls back to whichever rule happens to match last. That is invisible in
|
||||
// a smoke test and obvious to a user, so it is asserted here.
|
||||
func TestGoTellSlugsAreSeeded(t *testing.T) {
|
||||
want := []string{"cyberpunk", "nordic", "obsidian", "terminal"}
|
||||
|
||||
have := map[string]bool{}
|
||||
for _, slug := range seed.Slugs() {
|
||||
have[slug] = true
|
||||
}
|
||||
|
||||
for _, slug := range want {
|
||||
if !have[slug] {
|
||||
t.Errorf("go-tell's %q palette is not seeded, so selecting it would do nothing", slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryConsolidatedPaletteResolvesInBothAppearances is the property the contract
|
||||
// depends on, and it is expressed through the engine rather than through the token
|
||||
// document's shape.
|
||||
//
|
||||
// The shape is deliberately not the assertion. In this schema the base document *is*
|
||||
// the light scheme and `$appearances` carries only the dark override, so a theme with
|
||||
// both schemes still has one key under `$appearances`. What matters is that resolving
|
||||
// a consolidated palette in each appearance yields a different background, which is
|
||||
// what makes the appearance control do anything at all.
|
||||
func TestEveryConsolidatedPaletteResolvesInBothAppearances(t *testing.T) {
|
||||
for _, def := range seed.All() {
|
||||
if !strings.Contains(def.Description, "go-") {
|
||||
continue // an original go-bananas theme, not a consolidated one
|
||||
}
|
||||
|
||||
theme := def.Theme()
|
||||
light := tokenengine.Resolve(theme, domain.AppearanceLight)
|
||||
dark := tokenengine.Resolve(theme, domain.AppearanceDark)
|
||||
|
||||
read := func(doc *tokenengine.Doc, path string) string {
|
||||
token, ok := doc.Lookup(path)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return token.CSS
|
||||
}
|
||||
|
||||
lightBg := read(light, "color.semantic.background")
|
||||
darkBg := read(dark, "color.semantic.background")
|
||||
|
||||
if lightBg == "" || darkBg == "" {
|
||||
t.Errorf("%s does not resolve a background in both appearances (light=%q dark=%q)",
|
||||
def.Slug, lightBg, darkBg)
|
||||
continue
|
||||
}
|
||||
if lightBg == darkBg {
|
||||
t.Errorf("%s resolves the same background in both appearances (%s), "+
|
||||
"so the appearance control would do nothing for it", def.Slug, lightBg)
|
||||
}
|
||||
|
||||
// Text has to differ too, or a palette with a dark background and dark text
|
||||
// would pass the background check and be unreadable.
|
||||
if read(light, "color.semantic.text") == read(dark, "color.semantic.text") {
|
||||
t.Errorf("%s resolves the same text colour in both appearances", def.Slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
586
internal/seed/seed.go
Normal file
586
internal/seed/seed.go
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
// Package seed holds the built-in themes that ship inside the binary.
|
||||
//
|
||||
// Reconciliation rule: a built-in theme is (re)created when missing, and
|
||||
// refreshed only while the stored copy is still at its seed version. Once an
|
||||
// operator edits a built-in theme its version moves past the seed and it is
|
||||
// never touched again. This gives operators a safe upgrade path without the
|
||||
// risk of a deploy silently reverting deliberate work.
|
||||
//
|
||||
// Themes are declared in Go rather than as embedded JSON so that palette values
|
||||
// stay type-checked and the seed stays in step with the engine's schema.
|
||||
package seed
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
"github.com/urpi/go-bananas/internal/tokenengine"
|
||||
)
|
||||
|
||||
// SeedVersion is bumped whenever a built-in theme's tokens change. The
|
||||
// reconciler compares it against the stored version to decide whether to
|
||||
// refresh a built-in copy.
|
||||
const SeedVersion int64 = 2
|
||||
|
||||
// SeededTheme is one built-in theme definition.
|
||||
type SeededTheme struct {
|
||||
Slug string
|
||||
Name string
|
||||
Description string
|
||||
Appearance domain.Appearance
|
||||
Tags []string
|
||||
Tokens domain.TokenDoc
|
||||
}
|
||||
|
||||
// All returns every built-in theme in a stable order.
|
||||
func All() []SeededTheme {
|
||||
return []SeededTheme{
|
||||
midnight(), daylight(), mono(), highContrast(),
|
||||
// The palettes consolidated from the sibling applications. They come after
|
||||
// the original four so the default listing still leads with the house
|
||||
// styles, and they are all dark-first or light-first as their source was.
|
||||
urpiStandard(), tokyoNight(), catppuccin(), tomorrow(), kanagawa(),
|
||||
nordicMoss(), deepSpace(), cyberpunk(), terminalRetro(),
|
||||
}
|
||||
}
|
||||
|
||||
// Slugs returns the slugs of every built-in theme.
|
||||
func Slugs() []string {
|
||||
defs := All()
|
||||
out := make([]string, len(defs))
|
||||
for i, d := range defs {
|
||||
out[i] = d.Slug
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsBuiltIn reports whether slug belongs to a built-in theme.
|
||||
func IsBuiltIn(slug string) bool {
|
||||
for _, s := range Slugs() {
|
||||
if s == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Vocabulary shared by every built-in theme.
|
||||
//
|
||||
// These are group names inside a token document and an appearance value, so they
|
||||
// repeat once per theme by construction. Naming them means the contract layer, the
|
||||
// renderer and the seeds all agree on one spelling, and a change to one is a
|
||||
// single-line diff rather than a search across every palette.
|
||||
const (
|
||||
roleSuccess = "success"
|
||||
roleWarning = "warning"
|
||||
roleDanger = "danger"
|
||||
roleBrand = "brand"
|
||||
roleNeutral = "neutral"
|
||||
|
||||
appearanceDark = "dark"
|
||||
// tagDefault marks a palette as a sensible starting point in the selector, rather
|
||||
// than one that happens to be published.
|
||||
tagDefault = "default"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------- builders --
|
||||
|
||||
func leaf(value any, kind tokenengine.Kind) map[string]any {
|
||||
return map[string]any{tokenengine.TypeKey: string(kind), tokenengine.ValueKey: value}
|
||||
}
|
||||
|
||||
func color(value string) map[string]any { return leaf(value, tokenengine.KindColor) }
|
||||
func dim(value any) map[string]any { return leaf(value, tokenengine.KindDimension) }
|
||||
func num(value any) map[string]any { return leaf(value, tokenengine.KindNumber) }
|
||||
func weight(value any) map[string]any { return leaf(value, tokenengine.KindFontWeight) }
|
||||
func leading(value any) map[string]any { return leaf(value, tokenengine.KindLineHeight) }
|
||||
func dur(ms any) map[string]any { return leaf(ms, tokenengine.KindDuration) }
|
||||
func family(v string) map[string]any { return leaf(v, tokenengine.KindFontFamily) }
|
||||
func shadow(v string) map[string]any { return leaf(v, tokenengine.KindShadow) }
|
||||
func bezier(a, b, c, d float64) map[string]any {
|
||||
return leaf([]any{a, b, c, d}, tokenengine.KindCubicBezier)
|
||||
}
|
||||
|
||||
// scales returns the non-colour groups shared by every built-in theme: the
|
||||
// spacing scale, radii, borders, motion, z-index, opacity and breakpoints.
|
||||
// Themes differ in colour and typography only.
|
||||
func scales() map[string]any {
|
||||
return map[string]any{
|
||||
"space": map[string]any{
|
||||
"0": dim(0), "px": dim(1),
|
||||
"1": dim(0.25), "2": dim(0.5), "3": dim(0.75),
|
||||
"4": dim(1), "5": dim(1.5), "6": dim(2),
|
||||
"8": dim(3), "10": dim(4), "12": dim(6), "16": dim(8),
|
||||
},
|
||||
"radius": map[string]any{
|
||||
"none": dim(0), "sm": dim(0.25), "md": dim(0.5),
|
||||
"lg": dim(0.75), "xl": dim(1.25), "2xl": dim(1.5), "full": dim(9999),
|
||||
},
|
||||
"border": map[string]any{
|
||||
"width": map[string]any{
|
||||
"thin": dim(1), "default": dim(1), "thick": dim(2), "focus": dim(3),
|
||||
},
|
||||
},
|
||||
"duration": map[string]any{
|
||||
"instant": dur(0), "fast": dur(120), "normal": dur(200), "slow": dur(320),
|
||||
},
|
||||
"ease": map[string]any{
|
||||
"linear": bezier(0, 0, 1, 1), "standard": bezier(0.2, 0, 0, 1),
|
||||
"entrance": bezier(0, 0, 0.2, 1), "exit": bezier(0.4, 0, 1, 1),
|
||||
},
|
||||
"zIndex": map[string]any{
|
||||
"base": num(0), "dropdown": num(1000), "sticky": num(1100),
|
||||
"overlay": num(1200), "modal": num(1300), "toast": num(1400),
|
||||
},
|
||||
"opacity": map[string]any{
|
||||
"disabled": num(0.45), "muted": num(0.7), "strong": num(0.9),
|
||||
},
|
||||
"breakpoint": map[string]any{
|
||||
"sm": dim(640), "md": dim(768), "lg": dim(1024),
|
||||
"xl": dim(1280), "2xl": dim(1536),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// typeScale builds the typography groups from a modular scale: each step is
|
||||
// baseSize * ratio^index, which keeps the ramp mathematically consistent in a
|
||||
// way that hand-tuned type ramps rarely are.
|
||||
func typeScale(sans string, baseSize, ratio, lineScale float64) map[string]any {
|
||||
sizes := make(map[string]any)
|
||||
steps := []struct {
|
||||
name string
|
||||
index float64
|
||||
}{
|
||||
{"xs", -2}, {"sm", -1}, {"md", 0}, {"lg", 1}, {"xl", 2}, {"2xl", 3}, {"3xl", 4},
|
||||
}
|
||||
for _, s := range steps {
|
||||
sizes[s.name] = leaf(round2(baseSize*powf(ratio, s.index)), tokenengine.KindFontSize)
|
||||
}
|
||||
return map[string]any{
|
||||
"family": map[string]any{
|
||||
"sans": family(sans),
|
||||
"mono": family("ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace"),
|
||||
"display": family(sans),
|
||||
},
|
||||
"size": sizes,
|
||||
"weight": map[string]any{
|
||||
"normal": weight(400), "medium": weight(500),
|
||||
"semibold": weight(600), "bold": weight(700),
|
||||
},
|
||||
"lineHeight": map[string]any{
|
||||
"none": leading(1), "tight": leading(1.2), "snug": leading(1.375),
|
||||
"normal": leading(1.5), "relaxed": leading(1.65), "loose": leading(2),
|
||||
},
|
||||
"letterSpacing": map[string]any{
|
||||
"tighter": dim(-0.05), "tight": dim(-0.025), "normal": dim(0),
|
||||
"wide": dim(0.025), "wider": dim(0.05),
|
||||
},
|
||||
"style": map[string]any{"normal": leaf("normal", tokenengine.KindFontStyle), "italic": leaf("italic", tokenengine.KindFontStyle)},
|
||||
"scale": num(round2(ratio)),
|
||||
"lineScale": num(round2(lineScale)),
|
||||
}
|
||||
}
|
||||
|
||||
// shadows builds a shadow ramp. Shadows tint towards slate rather than pure
|
||||
// black, which is the difference between elevation that looks natural and
|
||||
// elevation that looks like a drop shadow from 2004.
|
||||
func shadows(strength float64) map[string]any {
|
||||
tint := "15 23 42"
|
||||
a := func(multiplier float64) string { return fmt.Sprintf("%.2f", strength*multiplier) }
|
||||
return map[string]any{
|
||||
"none": shadow("none"),
|
||||
"xs": shadow(fmt.Sprintf("0 1px 2px 0 rgb(%s / %s)", tint, a(0.08))),
|
||||
"sm": shadow(fmt.Sprintf("0 1px 3px 0 rgb(%s / %s), 0 1px 2px -1px rgb(%s / %s)", tint, a(0.12), tint, a(0.08))),
|
||||
"md": shadow(fmt.Sprintf("0 4px 6px -1px rgb(%s / %s), 0 2px 4px -2px rgb(%s / %s)", tint, a(0.12), tint, a(0.1))),
|
||||
"lg": shadow(fmt.Sprintf("0 10px 15px -3px rgb(%s / %s), 0 4px 6px -4px rgb(%s / %s)", tint, a(0.12), tint, a(0.1))),
|
||||
"xl": shadow(fmt.Sprintf("0 20px 25px -5px rgb(%s / %s), 0 8px 10px -6px rgb(%s / %s)", tint, a(0.14), tint, a(0.1))),
|
||||
"focus": shadow("0 0 0 3px rgb(99 102 241 / 0.4)"),
|
||||
"color": leaf(tint, tokenengine.KindColor),
|
||||
}
|
||||
}
|
||||
|
||||
// semantics assembles the semantic colour group for one scheme.
|
||||
//
|
||||
// Every role points at a concrete palette step rather than at an alias token:
|
||||
// published themes must not be able to change colour underneath a consumer when
|
||||
// somebody later edits the underlying palette.
|
||||
func semantics(ramp tokenengine.RampOptions, scheme domain.Appearance, overrides map[string]any) map[string]any {
|
||||
step := func(shade string) map[string]any { return color(tokenengine.RampStep(ramp, shade)) }
|
||||
|
||||
// Scheme-dependent step selection. In a light scheme surfaces are near-white
|
||||
// and text is near-black; a dark scheme inverts both.
|
||||
light := scheme == domain.AppearanceLight
|
||||
|
||||
pick := func(lightShade, darkShade string) map[string]any {
|
||||
if light {
|
||||
return step(lightShade)
|
||||
}
|
||||
return step(darkShade)
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"background": pick("50", "950"),
|
||||
"surface": pick("50", "900"),
|
||||
"surface-raised": pick("100", "800"),
|
||||
"surface-sunken": pick("100", "950"),
|
||||
"border": pick("200", "800"),
|
||||
"border-strong": pick("300", "700"),
|
||||
"text": pick("900", "100"),
|
||||
"text-muted": pick("700", "300"),
|
||||
"text-subtle": pick("600", "400"),
|
||||
"text-inverted": pick("50", "950"),
|
||||
|
||||
"primary": step("600"),
|
||||
"primary-hover": step("700"),
|
||||
"primary-active": step("800"),
|
||||
"on-primary": color(foregroundFor(tokenengine.RampStep(ramp, "600"))),
|
||||
|
||||
"secondary": pick("200", "800"),
|
||||
"secondary-hover": pick("300", "700"),
|
||||
"on-secondary": color(foregroundFor(tokenengine.RampStep(ramp, pickShade(light, "200", "800")))),
|
||||
|
||||
"accent": step("500"),
|
||||
"on-accent": color(foregroundFor(tokenengine.RampStep(ramp, "500"))),
|
||||
"link": step("600"),
|
||||
"link-hover": step("700"),
|
||||
|
||||
// Status colours are fixed rather than generated: green must mean
|
||||
// success in every theme, or the interface lies to the user.
|
||||
"success": color("#16a34a"),
|
||||
"on-success": color("#f0fdf4"),
|
||||
"warning": color("#d97706"),
|
||||
"on-warning": color("#fffbeb"),
|
||||
"danger": color("#dc2626"),
|
||||
"on-danger": color("#fef2f2"),
|
||||
"info": color("#2563eb"),
|
||||
"on-info": color("#eff6ff"),
|
||||
"focus-ring": color(tokenengine.RampStep(ramp, "500")),
|
||||
"overlay": color(pickColor(light, "#0f172a66", "#020617b3")),
|
||||
"selection": color(tokenengine.RampStep(ramp, pickShade(light, "200", "800"))),
|
||||
"scrollbar-thumb": pick("300", "700"),
|
||||
}
|
||||
|
||||
for role, value := range overrides {
|
||||
out[role] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pickColor(cond bool, whenTrue, whenFalse string) string {
|
||||
if cond {
|
||||
return whenTrue
|
||||
}
|
||||
return whenFalse
|
||||
}
|
||||
|
||||
func pickShade(light bool, lightShade, darkShade string) string {
|
||||
if light {
|
||||
return lightShade
|
||||
}
|
||||
return darkShade
|
||||
}
|
||||
|
||||
// foregroundFor returns whichever of black or white has the higher WCAG
|
||||
// contrast ratio against the given colour.
|
||||
func foregroundFor(hex string) string {
|
||||
bg, err := tokenengine.ParseColor(hex)
|
||||
if err != nil || !bg.Numeric {
|
||||
// A colour that cannot be parsed cannot have a computed contrast, so white is
|
||||
// the safe default: it is the readable choice against every ramp in this file.
|
||||
return white
|
||||
}
|
||||
return tokenengine.BestForeground(bg).Hex()
|
||||
}
|
||||
|
||||
// assemble combines the shared scales with a theme's colour and typography.
|
||||
func assemble(palettes map[string]any, light, dark map[string]any, typography, shadowSet map[string]any) domain.TokenDoc {
|
||||
doc := domain.TokenDoc{
|
||||
tokenengine.SchemaKey: tokenengine.SchemaID,
|
||||
"color": map[string]any{"semantic": light},
|
||||
"font": typography,
|
||||
"shadow": shadowSet,
|
||||
tokenengine.AppearancesKey: map[string]any{
|
||||
appearanceDark: map[string]any{
|
||||
"color": map[string]any{"semantic": dark},
|
||||
},
|
||||
},
|
||||
}
|
||||
colorGroup := doc["color"].(map[string]any)
|
||||
for name, ramp := range palettes {
|
||||
colorGroup[name] = ramp
|
||||
}
|
||||
for k, v := range scales() {
|
||||
doc[k] = v
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ themes --
|
||||
|
||||
// midnight is the default theme: a violet-accented dark interface.
|
||||
func midnight() SeededTheme {
|
||||
brand := tokenengine.RampOptions{Hue: 258, Saturation: 84, Lightness: 58, HueSpread: 14}
|
||||
neutral := tokenengine.RampOptions{Hue: 224, Saturation: 28, Lightness: 46, HueSpread: 8}
|
||||
|
||||
palettes := map[string]any{
|
||||
roleBrand: tokenengine.GenerateRamp(brand),
|
||||
roleNeutral: tokenengine.GenerateRamp(neutral),
|
||||
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 62, Lightness: 44, HueSpread: 10}),
|
||||
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 92, Lightness: 50, HueSpread: 8}),
|
||||
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 0, Saturation: 74, Lightness: 52, HueSpread: 6}),
|
||||
}
|
||||
|
||||
// The dark scheme leads; the light variant is the derived inversion.
|
||||
return SeededTheme{
|
||||
Slug: "midnight",
|
||||
Name: "Midnight",
|
||||
Description: "Violet-accented dark interface with a slate neutral ramp. The Go Bananas!! default.",
|
||||
Appearance: domain.AppearanceDark,
|
||||
Tags: []string{appearanceDark, tagDefault, "violet", "slate"},
|
||||
Tokens: assemble(palettes,
|
||||
semantics(neutral, domain.AppearanceLight, nil),
|
||||
semantics(neutral, domain.AppearanceDark, map[string]any{
|
||||
"primary": color(tokenengine.RampStep(brand, "500")),
|
||||
"primary-hover": color(tokenengine.RampStep(brand, "400")),
|
||||
"primary-active": color(tokenengine.RampStep(brand, "300")),
|
||||
"on-primary": color(foregroundFor(tokenengine.RampStep(brand, "500"))),
|
||||
"accent": color(tokenengine.RampStep(brand, "400")),
|
||||
"on-accent": color(foregroundFor(tokenengine.RampStep(brand, "400"))),
|
||||
"link": color(tokenengine.RampStep(brand, "400")),
|
||||
"link-hover": color(tokenengine.RampStep(brand, "300")),
|
||||
"focus-ring": color(tokenengine.RampStep(brand, "400")),
|
||||
"selection": color(tokenengine.RampStep(brand, "800")),
|
||||
}),
|
||||
typeScale("Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", 0.875, 1.2, 1.5),
|
||||
shadows(1.0),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// daylight is a crisp light interface built on the same violet brand hue.
|
||||
func daylight() SeededTheme {
|
||||
brand := tokenengine.RampOptions{Hue: 258, Saturation: 78, Lightness: 55, HueSpread: 14}
|
||||
neutral := tokenengine.RampOptions{Hue: 220, Saturation: 18, Lightness: 48, HueSpread: 6}
|
||||
|
||||
palettes := map[string]any{
|
||||
roleBrand: tokenengine.GenerateRamp(brand),
|
||||
roleNeutral: tokenengine.GenerateRamp(neutral),
|
||||
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 70, Lightness: 40, HueSpread: 10}),
|
||||
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 94, Lightness: 48, HueSpread: 8}),
|
||||
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 76, Lightness: 50, HueSpread: 6}),
|
||||
}
|
||||
|
||||
return SeededTheme{
|
||||
Slug: "daylight",
|
||||
Name: "Daylight",
|
||||
Description: "High-contrast light interface with generous radii and soft shadows.",
|
||||
Appearance: domain.AppearanceLight,
|
||||
Tags: []string{"light", "default", "violet", "crisp"},
|
||||
Tokens: assemble(palettes,
|
||||
semantics(neutral, domain.AppearanceLight, nil),
|
||||
semantics(neutral, domain.AppearanceDark, map[string]any{
|
||||
"primary": color(tokenengine.RampStep(brand, "500")),
|
||||
"link": color(tokenengine.RampStep(brand, "600")),
|
||||
"focus-ring": color(tokenengine.RampStep(brand, "500")),
|
||||
"primary-hover": color(tokenengine.RampStep(brand, "700")),
|
||||
}),
|
||||
typeScale("Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", 0.9375, 1.2, 1.5),
|
||||
shadows(0.7),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// mono is a typographic, near-greyscale theme for dense interfaces.
|
||||
func mono() SeededTheme {
|
||||
brand := tokenengine.RampOptions{Hue: 210, Saturation: 12, Lightness: 45, HueSpread: 4}
|
||||
neutral := tokenengine.RampOptions{Hue: 220, Saturation: 8, Lightness: 48, HueSpread: 2}
|
||||
|
||||
palettes := map[string]any{
|
||||
roleBrand: tokenengine.GenerateRamp(brand),
|
||||
roleNeutral: tokenengine.GenerateRamp(neutral),
|
||||
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 145, Saturation: 45, Lightness: 38, HueSpread: 4}),
|
||||
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 40, Saturation: 60, Lightness: 46, HueSpread: 4}),
|
||||
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 62, Lightness: 48, HueSpread: 4}),
|
||||
}
|
||||
|
||||
// A tighter type scale and larger base suits data-dense screens.
|
||||
typography := typeScale("'IBM Plex Sans', Inter, ui-sans-serif, system-ui, sans-serif", 0.8125, 1.125, 1.5)
|
||||
typography["family"] = map[string]any{
|
||||
"sans": family("'IBM Plex Sans', Inter, ui-sans-serif, system-ui, sans-serif"),
|
||||
"mono": family("'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace"),
|
||||
"display": family("'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif"),
|
||||
}
|
||||
|
||||
return SeededTheme{
|
||||
Slug: "mono",
|
||||
Name: "Mono",
|
||||
Description: "Near-greyscale, tight and dense. Built for tables, consoles and data tooling.",
|
||||
Appearance: domain.AppearanceLight,
|
||||
Tags: []string{"light", "greyscale", "dense", "data"},
|
||||
Tokens: assemble(palettes,
|
||||
semantics(neutral, domain.AppearanceLight, map[string]any{
|
||||
// A greyscale theme still needs a distinguishable accent or
|
||||
// nothing on screen reads as interactive.
|
||||
"primary": color("#111827"),
|
||||
"link": color("#111827"),
|
||||
"focus-ring": color("#111827"),
|
||||
}),
|
||||
semantics(neutral, domain.AppearanceDark, map[string]any{
|
||||
"primary": color("#f8fafc"),
|
||||
"link": color("#f8fafc"),
|
||||
"focus-ring": color("#f8fafc"),
|
||||
}),
|
||||
typography,
|
||||
shadows(0.5),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// highContrast maximises legibility: near-black on white, thick borders, no
|
||||
// tints, no translucency. It is the theme to ship when accessibility is the
|
||||
// primary requirement.
|
||||
func highContrast() SeededTheme {
|
||||
black := "#000000"
|
||||
// Reserved high-contrast overrides. The light scheme is pure black on pure
|
||||
// white; the dark scheme is the exact inversion, because white-on-black is
|
||||
// just as legible and some users need it for photophobia or low-vision.
|
||||
hcLight := highContrastRoles(black, white, "#7a3e00", "#004d00", "#8c0000", "#00308c")
|
||||
hcDark := highContrastRoles(white, black, "#ffd8a8", "#b9f6ca", "#ffc9c9", "#b3d9ff")
|
||||
|
||||
brand := tokenengine.RampOptions{Hue: 220, Saturation: 0, Lightness: 50, HueSpread: 0}
|
||||
palettes := map[string]any{
|
||||
roleBrand: tokenengine.GenerateRamp(brand),
|
||||
roleNeutral: tokenengine.GenerateRamp(brand),
|
||||
}
|
||||
|
||||
// Thicker borders and no shadow blur: at maximum contrast a soft shadow
|
||||
// disappears, so separation must come from a stroke.
|
||||
typography := typeScale("Verdana, Tahoma, 'Segoe UI', system-ui, sans-serif", 1, 1.25, 1.6)
|
||||
shadowSet := map[string]any{
|
||||
"none": shadow("none"), "xs": shadow("none"), "sm": shadow("none"),
|
||||
"md": shadow("none"), "lg": shadow("none"), "xl": shadow("none"),
|
||||
"focus": shadow("0 0 0 3px " + black),
|
||||
}
|
||||
|
||||
return SeededTheme{
|
||||
Slug: "high-contrast",
|
||||
Name: "High Contrast",
|
||||
Description: "Maximum legibility. Pure black on pure white, thick strokes, no translucency. Inverts for a white-on-black variant.",
|
||||
Appearance: domain.AppearanceLight,
|
||||
Tags: []string{"light", "dark", "accessibility", "wcag", "aaa"},
|
||||
Tokens: assemble(palettes, hcLight, hcDark, typography, shadowSet),
|
||||
}
|
||||
}
|
||||
|
||||
// highContrastRoles builds the full semantic set for a maximum-contrast scheme
|
||||
// from two anchor colours plus four status colours that keep their hue but are
|
||||
// darkened or lightened to stay at AAA against the anchor background.
|
||||
func highContrastRoles(foreground, background, warning, success, danger, info string) map[string]any {
|
||||
return map[string]any{
|
||||
"background": color(background),
|
||||
"surface": color(background),
|
||||
"surface-raised": color(background),
|
||||
"surface-sunken": color(background),
|
||||
"border": color(foreground),
|
||||
"border-strong": color(foreground),
|
||||
"text": color(foreground),
|
||||
"text-muted": color(foreground),
|
||||
"text-subtle": color(foreground),
|
||||
"text-inverted": color(background),
|
||||
|
||||
"primary": color(foreground),
|
||||
"primary-hover": color(foreground),
|
||||
"primary-active": color(foreground),
|
||||
"on-primary": color(background),
|
||||
|
||||
"secondary": color(background),
|
||||
"secondary-hover": color(background),
|
||||
"on-secondary": color(foreground),
|
||||
|
||||
"accent": color(foreground),
|
||||
"on-accent": color(background),
|
||||
"link": color(foreground),
|
||||
"link-hover": color(foreground),
|
||||
"focus-ring": color(foreground),
|
||||
"overlay": color(foreground),
|
||||
"selection": color(foreground),
|
||||
"scrollbar-thumb": color(foreground),
|
||||
|
||||
"success": color(success),
|
||||
"on-success": color(background),
|
||||
"warning": color(warning),
|
||||
"on-warning": color(background),
|
||||
"danger": color(danger),
|
||||
"on-danger": color(background),
|
||||
"info": color(info),
|
||||
"on-info": color(background),
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- conversion --
|
||||
|
||||
// systemThemeID derives a stable identifier for a built-in theme so that the
|
||||
// reconciler never has to search by slug.
|
||||
func systemThemeID(slug string) string {
|
||||
return "sys_" + strings.ReplaceAll(slug, "-", "_")
|
||||
}
|
||||
|
||||
// Theme converts a seeded definition into a persistable domain theme.
|
||||
func (s SeededTheme) Theme() *domain.Theme {
|
||||
return &domain.Theme{
|
||||
ID: systemThemeID(s.Slug),
|
||||
Slug: s.Slug,
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
Status: domain.StatusPublished,
|
||||
DefaultAppearance: s.Appearance,
|
||||
Tokens: s.Tokens.Clone(),
|
||||
Meta: domain.ThemeMeta{
|
||||
"tags": s.Tags,
|
||||
"builtIn": true,
|
||||
"seedVersion": SeedVersion,
|
||||
"author": "go-bananas",
|
||||
},
|
||||
System: true,
|
||||
Version: SeedVersion,
|
||||
CreatedBy: "system",
|
||||
UpdatedBy: "system",
|
||||
}
|
||||
}
|
||||
|
||||
// Themes converts every seeded definition into persistable domain themes.
|
||||
func Themes() []*domain.Theme {
|
||||
defs := All()
|
||||
out := make([]*domain.Theme, 0, len(defs))
|
||||
for _, d := range defs {
|
||||
out = append(out, d.Theme())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- maths --
|
||||
|
||||
// round2 rounds to two decimal places, which is finer than any CSS length
|
||||
// resolution and keeps generated values readable.
|
||||
func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 }
|
||||
|
||||
// powf is integer-step exponentiation, which is all a modular scale needs.
|
||||
func powf(base, exp float64) float64 {
|
||||
if exp == 0 {
|
||||
return 1
|
||||
}
|
||||
neg := exp < 0
|
||||
n := int(exp)
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
result := 1.0
|
||||
for i := 0; i < n; i++ {
|
||||
result *= base
|
||||
}
|
||||
if neg {
|
||||
return 1 / result
|
||||
}
|
||||
return result
|
||||
}
|
||||
157
internal/seed/seed_test.go
Normal file
157
internal/seed/seed_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package seed
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
"github.com/urpi/go-bananas/internal/tokenengine"
|
||||
)
|
||||
|
||||
func TestEveryBuiltInThemeValidates(t *testing.T) {
|
||||
for _, def := range All() {
|
||||
t.Run(def.Slug, func(t *testing.T) {
|
||||
theme := def.Theme()
|
||||
result := tokenengine.Validate(theme.Tokens)
|
||||
if !result.Valid {
|
||||
for _, issue := range result.Errors() {
|
||||
t.Errorf("%s %s: %s (%s)", issue.Severity, issue.Path, issue.Message, issue.Hint)
|
||||
}
|
||||
t.Fatal("built-in theme must be valid")
|
||||
}
|
||||
if result.TokenCount < 50 {
|
||||
t.Errorf("theme has only %d tokens; a built-in should be complete", result.TokenCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryBuiltInThemeRendersBothAppearances(t *testing.T) {
|
||||
for _, def := range All() {
|
||||
t.Run(def.Slug, func(t *testing.T) {
|
||||
theme := def.Theme()
|
||||
for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
|
||||
css := tokenengine.Render(theme, tokenengine.RenderOptions{
|
||||
Slug: theme.Slug,
|
||||
Appearance: appearance,
|
||||
Components: true,
|
||||
Derive: tokenengine.DefaultDerive(),
|
||||
})
|
||||
if css == "" {
|
||||
t.Fatalf("%s/%s rendered an empty stylesheet", theme.Slug, appearance)
|
||||
}
|
||||
if !contains(css, "--bn-color-semantic-background") {
|
||||
t.Errorf("%s/%s is missing the background variable", theme.Slug, appearance)
|
||||
}
|
||||
if !contains(css, "--bn-color-semantic-text") {
|
||||
t.Errorf("%s/%s is missing the text variable", theme.Slug, appearance)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInThemesDeclareDistinctDarkOverrides(t *testing.T) {
|
||||
// A theme that declares a dark appearance but supplies no dark overrides
|
||||
// would render the light palette in dark mode, which is never intentional.
|
||||
for _, def := range All() {
|
||||
theme := def.Theme()
|
||||
light := tokenengine.Resolve(theme, domain.AppearanceLight)
|
||||
dark := tokenengine.Resolve(theme, domain.AppearanceDark)
|
||||
|
||||
lightBG, _ := light.Lookup("color.semantic.background")
|
||||
darkBG, _ := dark.Lookup("color.semantic.background")
|
||||
if lightBG.CSS == darkBG.CSS {
|
||||
t.Errorf("%s: light and dark backgrounds are identical (%s)", def.Slug, lightBG.CSS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextContrastMeetsAAForEveryBuiltIn(t *testing.T) {
|
||||
// This is the check that makes a built-in theme trustworthy: body text must
|
||||
// be readable on every surface it is likely to sit on.
|
||||
pairs := []struct {
|
||||
fgPath, bgPath string
|
||||
}{
|
||||
{"color.semantic.text", "color.semantic.background"},
|
||||
{"color.semantic.text", "color.semantic.surface"},
|
||||
{"color.semantic.text", "color.semantic.surface-raised"},
|
||||
{"color.semantic.text-muted", "color.semantic.background"},
|
||||
{"color.on-primary", "color.semantic.primary"},
|
||||
{"color.on-accent", "color.semantic.accent"},
|
||||
{"color.on-danger", "color.semantic.danger"},
|
||||
}
|
||||
|
||||
for _, def := range All() {
|
||||
theme := def.Theme()
|
||||
for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
|
||||
doc := tokenengine.Resolve(theme, appearance)
|
||||
|
||||
for _, pair := range pairs {
|
||||
fg, ok := doc.Lookup(pair.fgPath)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
bg, ok := doc.Lookup(pair.bgPath)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fgColor, err := tokenengine.ParseColor(fg.CSS)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
bgColor, err := tokenengine.ParseColor(bg.CSS)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ratio := tokenengine.ContrastRatio(fgColor, bgColor)
|
||||
minimum := tokenengine.ContrastAA
|
||||
// The high-contrast theme is the escape hatch and is held to the
|
||||
// stricter AAA bar instead.
|
||||
if def.Slug == "high-contrast" {
|
||||
minimum = tokenengine.ContrastAAA
|
||||
}
|
||||
if ratio < minimum {
|
||||
t.Errorf("%s/%s: %s (%s) on %s (%s) = %.2f:1, want at least %.1f:1",
|
||||
def.Slug, appearance, pair.fgPath, fg.CSS, pair.bgPath, bg.CSS, ratio, minimum)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInSlugsAreValidAndReservedSafe(t *testing.T) {
|
||||
for _, def := range All() {
|
||||
if err := domain.ValidateSlug(def.Slug); err != nil {
|
||||
t.Errorf("slug %q is invalid: %v", def.Slug, err)
|
||||
}
|
||||
if _, reserved := domain.ReservedSlugs[def.Slug]; reserved {
|
||||
t.Errorf("slug %q collides with a reserved runtime path", def.Slug)
|
||||
}
|
||||
if def.Appearance == "" {
|
||||
t.Errorf("theme %q has no default appearance", def.Slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInThemesHaveUniqueIDs(t *testing.T) {
|
||||
seen := make(map[string]string)
|
||||
for _, theme := range Themes() {
|
||||
if previous, dup := seen[theme.ID]; dup {
|
||||
t.Errorf("id %q is used by both %q and %q", theme.ID, previous, theme.Slug)
|
||||
}
|
||||
seen[theme.ID] = theme.Slug
|
||||
}
|
||||
}
|
||||
|
||||
func contains(haystack, needle string) bool {
|
||||
return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0
|
||||
}
|
||||
|
||||
func indexOf(haystack, needle string) int {
|
||||
for i := 0; i+len(needle) <= len(haystack); i++ {
|
||||
if haystack[i:i+len(needle)] == needle {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
62
internal/store/backup.go
Normal file
62
internal/store/backup.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// Snapshot writes a consistent copy of the database to dest.
|
||||
//
|
||||
// It uses SQLite's VACUUM INTO rather than copying the file. Copying a database
|
||||
// while a write is in flight yields a torn copy: the header can say one thing and
|
||||
// the pages say another, and the result may open successfully while being
|
||||
// corrupt. VACUUM INTO reads through SQLite's own backup machinery, so the result
|
||||
// is consistent even while the service is serving traffic, and it does not block
|
||||
// writers for the length of the copy.
|
||||
//
|
||||
// Two further properties come from the statement itself rather than from code
|
||||
// here: it refuses to overwrite an existing file, so a stale snapshot can never be
|
||||
// mistaken for a fresh one, and it writes to a temporary name and renames on
|
||||
// success, so a snapshot that fails halfway is not left looking complete.
|
||||
//
|
||||
// The connection must not be read-only: VACUUM INTO needs to read the schema and
|
||||
// pages through a normal connection even though it only ever writes to the new
|
||||
// file.
|
||||
func (s *Store) Snapshot(ctx context.Context, dest string) error {
|
||||
trimmed := strings.TrimSpace(dest)
|
||||
if trimmed == "" {
|
||||
return fmt.Errorf("%w: snapshot destination must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
if dir := filepath.Dir(trimmed); dir != "" && dir != "." {
|
||||
// Created by the process rather than by SQLite, which has no mkdir. The
|
||||
// snapshot directory normally already exists (the Helm chart's subPath mount
|
||||
// creates it), so this is a convenience for a manual invocation.
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create snapshot directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// VACUUM INTO takes an SQL string literal rather than a bound parameter on some
|
||||
// SQLite builds, so the path is escaped into one here. Doubling the quote is
|
||||
// the documented escape, and rejecting an embedded NUL keeps the literal
|
||||
// well-formed regardless.
|
||||
if strings.ContainsRune(trimmed, 0) {
|
||||
return fmt.Errorf("%w: snapshot destination contains a NUL byte", domain.ErrInvalid)
|
||||
}
|
||||
literal := "'" + strings.ReplaceAll(trimmed, "'", "''") + "'"
|
||||
|
||||
//nolint:gosec // G202: VACUUM INTO takes an SQL string literal rather than a
|
||||
// bound parameter on some SQLite builds. The literal is escaped above by doubling
|
||||
// any single quote, which is the documented escape, and NUL bytes are rejected so
|
||||
// the literal cannot be broken out of. TestSnapshotEscapesQuotesInThePath covers
|
||||
// a path containing a quote.
|
||||
if _, err := s.db.ExecContext(ctx, "VACUUM INTO "+literal); err != nil {
|
||||
return fmt.Errorf("write snapshot to %s: %w", trimmed, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
185
internal/store/backup_test.go
Normal file
185
internal/store/backup_test.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
"github.com/urpi/go-bananas/internal/store"
|
||||
)
|
||||
|
||||
// openTemp opens a file-backed store in a temporary directory, which is the only
|
||||
// kind worth snapshotting: an in-memory database has nothing to preserve.
|
||||
func openTemp(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "live.db")
|
||||
st, err := store.Open(context.Background(), store.Options{Path: path})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
// TestSnapshotProducesAReadableDatabase is the property the backup exists for: the
|
||||
// file that comes out must be a database, not merely a file with the right size.
|
||||
func TestSnapshotProducesAReadableDatabase(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
dest := filepath.Join(t.TempDir(), "snapshots", "go-bananas.db")
|
||||
|
||||
if err := st.Snapshot(context.Background(), dest); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("stat snapshot: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatal("snapshot is empty")
|
||||
}
|
||||
|
||||
// Reopening is the check that matters. A truncated or torn copy often fails
|
||||
// here rather than at the point of use, which is exactly the failure a restore
|
||||
// would hit at the worst possible moment.
|
||||
reopened, err := store.Open(context.Background(), store.Options{Path: dest, ReadOnly: true})
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot is not a readable database: %v", err)
|
||||
}
|
||||
defer func() { _ = reopened.Close() }()
|
||||
|
||||
// The schema must have come across, not just the bytes: an empty database with
|
||||
// no tables would also open cleanly.
|
||||
var count int
|
||||
if err := reopened.DB().QueryRowContext(context.Background(),
|
||||
"SELECT count(*) FROM sqlite_master WHERE type = 'table'").Scan(&count); err != nil {
|
||||
t.Fatalf("query snapshot schema: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Error("snapshot has no tables; the schema did not come across")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotRefusesToOverwrite guards the failure this feature is easiest to get
|
||||
// wrong: a snapshot that silently overwrites the previous good one turns a failed
|
||||
// run into two data-loss events instead of one.
|
||||
func TestSnapshotRefusesToOverwrite(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
dest := filepath.Join(t.TempDir(), "snap.db")
|
||||
|
||||
if err := st.Snapshot(context.Background(), dest); err != nil {
|
||||
t.Fatalf("first snapshot: %v", err)
|
||||
}
|
||||
first, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
|
||||
if err := st.Snapshot(context.Background(), dest); err == nil {
|
||||
t.Fatal("expected the second snapshot to be refused")
|
||||
}
|
||||
|
||||
second, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if !first.ModTime().Equal(second.ModTime()) {
|
||||
t.Error("the existing snapshot was modified by the refused write")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCreatesTheParentDirectory covers the manual-invocation path, where
|
||||
// the operator names a directory that does not exist yet.
|
||||
func TestSnapshotCreatesTheParentDirectory(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
base := t.TempDir()
|
||||
dest := filepath.Join(base, "a", "b", "c", "snap.db")
|
||||
|
||||
if err := st.Snapshot(context.Background(), dest); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
t.Errorf("snapshot not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotRejectsEmptyDestination checks the argument validation rather than
|
||||
// letting SQLite interpret an empty filename as the current directory.
|
||||
func TestSnapshotRejectsEmptyDestination(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
if err := st.Snapshot(context.Background(), " "); err == nil {
|
||||
t.Error("expected an error for a blank destination")
|
||||
} else if !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Errorf("got %v, want an invalid-input error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotEscapesQuotesInThePath guards the SQL-literal construction. A path
|
||||
// containing a single quote must not be able to terminate the literal and append
|
||||
// statements of its own.
|
||||
func TestSnapshotEscapesQuotesInThePath(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
dest := filepath.Join(t.TempDir(), "it's a snap.db")
|
||||
|
||||
if err := st.Snapshot(context.Background(), dest); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dest); err != nil {
|
||||
t.Errorf("snapshot not written to the quoted path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotOfAnInMemoryDatabaseIsRefused documents that the store-level guard
|
||||
// exists, rather than leaving it to the command line.
|
||||
func TestIsPersistentPath(t *testing.T) {
|
||||
for _, path := range []string{":memory:", "file::memory:?cache=shared", "file:x?mode=memory"} {
|
||||
if store.IsPersistentPath(path) {
|
||||
t.Errorf("%q should be reported as ephemeral", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/srv/data/go-bananas.db", "./data/db.sqlite"} {
|
||||
if !store.IsPersistentPath(path) {
|
||||
t.Errorf("%q should be reported as persistent", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotUnderLoadStaysConsistent is a smoke test for the reason VACUUM INTO
|
||||
// is used rather than a file copy: writing while the snapshot is taken must not
|
||||
// corrupt the result.
|
||||
func TestSnapshotUnderLoadStaysConsistent(t *testing.T) {
|
||||
st := openTemp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 20; i++ {
|
||||
if _, err := st.DB().ExecContext(ctx,
|
||||
`INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?)`,
|
||||
"concurrent.key", strconv.Itoa(i), time.Now().UTC().Format(time.RFC3339Nano), "test"); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "under-load.db")
|
||||
if err := st.Snapshot(ctx, dest); err != nil {
|
||||
t.Fatalf("Snapshot during writes: %v", err)
|
||||
}
|
||||
<-done
|
||||
|
||||
reopened, err := store.Open(ctx, store.Options{Path: dest, ReadOnly: true})
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot taken during writes is unreadable: %v", err)
|
||||
}
|
||||
defer func() { _ = reopened.Close() }()
|
||||
if err := reopened.DB().PingContext(ctx); err != nil {
|
||||
t.Errorf("snapshot failed an integrity check: %v", err)
|
||||
}
|
||||
}
|
||||
250
internal/store/migrations.go
Normal file
250
internal/store/migrations.go
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// migrationsFS holds the schema that ships inside the binary, so a fresh
|
||||
// container needs no migration job and no schema files mounted beside it.
|
||||
//
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Migration is one applied schema change.
|
||||
type Migration struct {
|
||||
Name string
|
||||
// Statements is the number of statements the file contains, reported for
|
||||
// logging only.
|
||||
Statements int
|
||||
}
|
||||
|
||||
// migrationLock serialises migration across goroutines within a process. SQLite
|
||||
// already serialises writers across processes, so no cross-process lock is
|
||||
// needed, but two replicas starting simultaneously would otherwise both log the
|
||||
// same applied migration.
|
||||
var migrationLock sync.Mutex
|
||||
|
||||
// migrate applies every embedded migration that has not yet been recorded.
|
||||
//
|
||||
// Migrations are idempotent by construction: each file uses CREATE ... IF NOT
|
||||
// EXISTS, so re-running is harmless. The applied set is tracked in a table so
|
||||
// that a future migration which is not idempotent can still be introduced
|
||||
// safely.
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
migrationLock.Lock()
|
||||
defer migrationLock.Unlock()
|
||||
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name TEXT PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
applied, err := s.appliedMigrations(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
entries, err := fs.ReadDir(migrationsFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read embedded migrations: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
if _, done := applied[entry.Name()]; done {
|
||||
continue
|
||||
}
|
||||
|
||||
body, err := fs.ReadFile(migrationsFS, "migrations/"+entry.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`,
|
||||
entry.Name(), s.now().UTC().Format(domain.TimeFormat),
|
||||
); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("record migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// appliedMigrations returns the set of recorded migration names.
|
||||
func (s *Store) appliedMigrations(ctx context.Context) (map[string]struct{}, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT name FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read schema_migrations: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := make(map[string]struct{})
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, fmt.Errorf("scan schema_migrations: %w", err)
|
||||
}
|
||||
applied[name] = struct{}{}
|
||||
}
|
||||
return applied, rows.Err()
|
||||
}
|
||||
|
||||
// Seed reconciles the built-in themes with what is stored.
|
||||
//
|
||||
// The rules are:
|
||||
// - a missing built-in is inserted;
|
||||
// - a built-in still at its seed version is refreshed, so an upgrade picks up
|
||||
// palette and schema fixes;
|
||||
// - a built-in an operator has edited (version above the seed) is left alone;
|
||||
// - a non-built-in theme is never touched.
|
||||
//
|
||||
// It returns how many themes were inserted and how many were refreshed.
|
||||
func (s *Store) Seed(ctx context.Context, builtins []*domain.Theme) (inserted, refreshed int, err error) {
|
||||
if len(builtins) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("begin seed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
now := s.now().UTC().Format(domain.TimeFormat)
|
||||
|
||||
for _, theme := range builtins {
|
||||
tokensJSON, err := marshalJSON(theme.Tokens)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("marshal tokens for %s: %w", theme.Slug, err)
|
||||
}
|
||||
metaJSON, err := marshalJSON(theme.Meta)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("marshal meta for %s: %w", theme.Slug, err)
|
||||
}
|
||||
|
||||
var existingVersion int64
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT version FROM themes WHERE id = ? OR slug = ?`, theme.ID, theme.Slug,
|
||||
).Scan(&existingVersion)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if err := insertTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil {
|
||||
return 0, 0, fmt.Errorf("insert built-in theme %s: %w", theme.Slug, err)
|
||||
}
|
||||
if err := insertVersion(ctx, tx, theme, tokensJSON, "built-in seed", "system", now); err != nil {
|
||||
return 0, 0, fmt.Errorf("insert built-in version %s: %w", theme.Slug, err)
|
||||
}
|
||||
inserted++
|
||||
|
||||
case err != nil:
|
||||
return 0, 0, fmt.Errorf("look up built-in theme %s: %w", theme.Slug, err)
|
||||
|
||||
default:
|
||||
// Refresh only when the stored copy predates this seed. An equal
|
||||
// version means the theme is already current; a higher version means
|
||||
// an operator has edited it, and their work wins either way.
|
||||
if existingVersion >= theme.Version {
|
||||
continue
|
||||
}
|
||||
if err := refreshTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil {
|
||||
return 0, 0, fmt.Errorf("refresh built-in theme %s: %w", theme.Slug, err)
|
||||
}
|
||||
if err := insertVersion(ctx, tx, theme, tokensJSON, "refreshed by Go Bananas!! upgrade", "system", now); err != nil {
|
||||
return 0, 0, fmt.Errorf("insert built-in version %s: %w", theme.Slug, err)
|
||||
}
|
||||
refreshed++
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
return 0, 0, fmt.Errorf("commit seed: %w", err)
|
||||
}
|
||||
return inserted, refreshed, nil
|
||||
}
|
||||
|
||||
// insertTheme writes a new theme row.
|
||||
func insertTheme(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, metaJSON, now string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO themes (
|
||||
id, slug, name, description, status, default_appearance,
|
||||
tokens_json, meta_json, is_system, version,
|
||||
created_at, updated_at, created_by, updated_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
theme.ID, theme.Slug, theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance),
|
||||
tokensJSON, metaJSON, boolToInt(theme.System), theme.Version,
|
||||
now, now, theme.CreatedBy, theme.UpdatedBy,
|
||||
)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// refreshTheme overwrites an untouched built-in theme in place.
|
||||
func refreshTheme(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, metaJSON, now string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE themes SET
|
||||
name = ?, description = ?, status = ?, default_appearance = ?,
|
||||
tokens_json = ?, meta_json = ?, version = ?, updated_at = ?, updated_by = ?
|
||||
WHERE slug = ?`,
|
||||
theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance),
|
||||
tokensJSON, metaJSON, theme.Version, now, "system",
|
||||
theme.Slug,
|
||||
)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// insertVersion appends an immutable snapshot to a theme's history.
|
||||
func insertVersion(ctx context.Context, tx *sql.Tx, theme *domain.Theme, tokensJSON, message, author, now string) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO theme_versions (
|
||||
theme_id, version, slug, name, description, status,
|
||||
default_appearance, tokens_json, message, author, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
theme.ID, theme.Version, theme.Slug, theme.Name, theme.Description, string(theme.Status),
|
||||
string(theme.DefaultAppearance), tokensJSON, message, author, now,
|
||||
)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// randomID returns a URL-safe random identifier.
|
||||
func randomID() (string, error) {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("read random: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
144
internal/store/migrations/0001_init.sql
Normal file
144
internal/store/migrations/0001_init.sql
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
-- go-bananas schema.
|
||||
--
|
||||
-- Design notes
|
||||
-- ------------
|
||||
-- * Timestamps are TEXT in RFC 3339 (see domain.TimeFormat). SQLite has no
|
||||
-- native date type, and storing a sortable string removes every date-parsing
|
||||
-- ambiguity from ORDER BY and range filters.
|
||||
-- * JSON columns are TEXT holding a JSON document. The token document is read
|
||||
-- and written as a whole value, never queried into, so a JSON column is the
|
||||
-- correct shape: it avoids a join table whose only purpose would be to
|
||||
-- reassemble a tree the application always wants whole.
|
||||
-- * Every table a consumer might read carries an explicit covering index.
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- ---------------------------------------------------------------- themes ----
|
||||
|
||||
CREATE TABLE IF NOT EXISTS themes (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'published', 'archived')),
|
||||
default_appearance TEXT NOT NULL DEFAULT 'light'
|
||||
CHECK (default_appearance IN ('light', 'dark')),
|
||||
tokens_json TEXT NOT NULL,
|
||||
meta_json TEXT NOT NULL DEFAULT '{}',
|
||||
is_system INTEGER NOT NULL DEFAULT 0 CHECK (is_system IN (0, 1)),
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version >= 1),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL DEFAULT '',
|
||||
updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- The runtime API resolves by slug on every page load, so this index is the
|
||||
-- single most important one in the schema.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_themes_slug ON themes (slug);
|
||||
|
||||
-- Listing is always ordered by recency within a status filter.
|
||||
CREATE INDEX IF NOT EXISTS idx_themes_status_updated
|
||||
ON themes (status, updated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_themes_updated ON themes (updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_themes_created ON themes (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_themes_name ON themes (name COLLATE NOCASE);
|
||||
|
||||
-- Free-text search over slug, name and description. SQLite's LIKE with a
|
||||
-- leading wildcard cannot use an index, so this stays a scan; the table is
|
||||
-- small by construction (themes are hand-authored, not machine-generated) and
|
||||
-- the scan is served from the page cache.
|
||||
CREATE INDEX IF NOT EXISTS idx_themes_search
|
||||
ON themes (name COLLATE NOCASE, slug, description);
|
||||
|
||||
-- ---------------------------------------------------------- theme_versions --
|
||||
|
||||
CREATE TABLE IF NOT EXISTS theme_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
theme_id TEXT NOT NULL REFERENCES themes (id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
slug TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
default_appearance TEXT NOT NULL,
|
||||
tokens_json TEXT NOT NULL,
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (theme_id, version)
|
||||
);
|
||||
|
||||
-- Rollback resolves a theme's history newest-first.
|
||||
CREATE INDEX IF NOT EXISTS idx_versions_theme
|
||||
ON theme_versions (theme_id, version DESC);
|
||||
|
||||
-- ---------------------------------------------------------------- api_keys --
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
-- prefix is the non-secret leading segment shown in the UI so a key can be
|
||||
-- identified after its secret has been forgotten.
|
||||
prefix TEXT NOT NULL,
|
||||
-- secret_hash is a SHA-256 of the full key. The plaintext is never stored,
|
||||
-- so a database disclosure does not yield usable credentials.
|
||||
secret_hash TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
identity TEXT NOT NULL DEFAULT '',
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT,
|
||||
revoked_at TEXT
|
||||
);
|
||||
|
||||
-- Lookup by secret hash happens on every machine request.
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys (secret_hash);
|
||||
-- The UI lists live keys newest-first and hides revoked ones by default.
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_live ON api_keys (revoked_at, created_at DESC);
|
||||
|
||||
-- ----------------------------------------------------------------- users ----
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
subject TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
roles TEXT NOT NULL DEFAULT '[]',
|
||||
method TEXT NOT NULL DEFAULT '',
|
||||
first_seen TEXT NOT NULL,
|
||||
last_login TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||
|
||||
-- ------------------------------------------------------------- audit_log ----
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
actor TEXT NOT NULL DEFAULT '',
|
||||
actor_email TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL DEFAULT '',
|
||||
entity_id TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
detail_json TEXT NOT NULL DEFAULT '{}',
|
||||
remote_ip TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_at ON audit_log (at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log (actor, at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_entity ON audit_log (entity_type, entity_id, at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log (action, at DESC);
|
||||
|
||||
-- -------------------------------------------------------------- settings ----
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
updated_by TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
19
internal/store/migrations/0002_drop_api_keys.sql
Normal file
19
internal/store/migrations/0002_drop_api_keys.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- Drop the machine-credential table.
|
||||
--
|
||||
-- API keys were removed: the runtime API is public by design and the admin API is
|
||||
-- for people, so there is no machine caller left to authenticate. The table is
|
||||
-- dropped rather than left behind, because an unused table holding SHA-256 digests
|
||||
-- of credentials that no longer authenticate anything is a liability with no
|
||||
-- upside, and its presence in the schema implies a capability that no longer exists.
|
||||
--
|
||||
-- The rows go with it. That is destructive by definition, which is why this is a
|
||||
-- migration rather than a code change: it happens once, in order, and is recorded in
|
||||
-- schema_migrations so a restore from an older backup is visible rather than silent.
|
||||
--
|
||||
-- A backup taken before this migration still contains the table and its rows. The
|
||||
-- credentials in it do not work against any version of this service, so restoring
|
||||
-- one is harmless.
|
||||
|
||||
DROP INDEX IF EXISTS idx_api_keys_hash;
|
||||
DROP INDEX IF EXISTS idx_api_keys_live;
|
||||
DROP TABLE IF EXISTS api_keys;
|
||||
26
internal/store/migrations/0002_user_preferences.sql
Normal file
26
internal/store/migrations/0002_user_preferences.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
-- Per-user interface preferences.
|
||||
--
|
||||
-- These live in their own table rather than as columns on users, because users is
|
||||
-- a projection of the identity provider's claims: UpsertUser rewrites it on every
|
||||
-- login. A row appears the first time somebody saves a preference and is keyed by
|
||||
-- the same subject, so the choice survives re-login and follows the account to
|
||||
-- another browser.
|
||||
--
|
||||
-- An empty selected_theme means the person has not chosen a theme, which the UI
|
||||
-- renders as its own default rather than as a stored value.
|
||||
--
|
||||
-- There is deliberately no foreign key to users. That table is a projection of the
|
||||
-- identity provider's claims and can be rebuilt from a backup, so a cascade from it
|
||||
-- would silently discard people's choices, and requiring the row to exist would make
|
||||
-- saving a preference depend on projection state that has nothing to do with the
|
||||
-- preference. A row whose subject never logs in again is inert and costs nothing.
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
subject TEXT NOT NULL PRIMARY KEY,
|
||||
selected_theme TEXT NOT NULL DEFAULT '',
|
||||
selected_appearance TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Lets an operator answer "who is using this theme?" without scanning every row.
|
||||
CREATE INDEX IF NOT EXISTS idx_user_preferences_theme
|
||||
ON user_preferences (selected_theme);
|
||||
369
internal/store/repositories.go
Normal file
369
internal/store/repositories.go
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------------- users --
|
||||
|
||||
// UpsertUser records a successful login. Roles are overwritten from the
|
||||
// identity provider on every login rather than merged, so that a user who is
|
||||
// removed from a group actually loses access.
|
||||
func (s *Store) UpsertUser(ctx context.Context, identity *domain.Identity) error {
|
||||
if identity == nil || identity.Subject == "" {
|
||||
return fmt.Errorf("%w: identity subject must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
roles, err := json.Marshal(identity.Roles)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode roles: %w", err)
|
||||
}
|
||||
|
||||
now := s.timestamp()
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO users (subject, email, display_name, roles, method, first_seen, last_login)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(subject) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
display_name = excluded.display_name,
|
||||
roles = excluded.roles,
|
||||
method = excluded.method,
|
||||
last_login = excluded.last_login`,
|
||||
identity.Subject, identity.Email, identity.Name, string(roles), identity.Method, now, now,
|
||||
)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// User is a stored account projection.
|
||||
type User struct {
|
||||
Subject string `json:"subject"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Roles []string `json:"roles"`
|
||||
Method string `json:"method"`
|
||||
FirstSeen time.Time `json:"firstSeen"`
|
||||
LastLogin time.Time `json:"lastLogin"`
|
||||
}
|
||||
|
||||
// ListUsers returns every known account, most recently seen first.
|
||||
func (s *Store) ListUsers(ctx context.Context, limit int) ([]User, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT subject, email, display_name, roles, method, first_seen, last_login
|
||||
FROM users ORDER BY last_login DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list users: %w", translateError(err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]User, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
u User
|
||||
rolesJSON string
|
||||
firstSeen string
|
||||
lastLogin string
|
||||
)
|
||||
if err := rows.Scan(&u.Subject, &u.Email, &u.Name, &rolesJSON, &u.Method, &firstSeen, &lastLogin); err != nil {
|
||||
return nil, fmt.Errorf("scan user: %w", err)
|
||||
}
|
||||
// Deliberately degraded, not accidentally ignored: a malformed roles blob
|
||||
// yields a user with no roles, which the authorisation layer treats as
|
||||
// read-only. Failing the whole listing because one row has a corrupt column
|
||||
// would be worse than degrading that single user, and it would mean one bad
|
||||
// row hides every other user from the admin.
|
||||
//
|
||||
//nolint:errcheck // see above
|
||||
_ = json.Unmarshal([]byte(rolesJSON), &u.Roles)
|
||||
if u.Roles == nil {
|
||||
u.Roles = []string{}
|
||||
}
|
||||
if u.FirstSeen, err = parseTime(firstSeen); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.LastLogin, err = parseTime(lastLogin); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UserCount returns the number of known accounts.
|
||||
func (s *Store) UserCount(ctx context.Context) (int, error) {
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("count users: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- settings --
|
||||
|
||||
// Setting returns a setting value, or fallback when it is absent.
|
||||
func (s *Store) Setting(ctx context.Context, key, fallback string) (string, error) {
|
||||
var value string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return fallback, nil
|
||||
case err != nil:
|
||||
return "", fmt.Errorf("read setting %s: %w", key, err)
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// SetSetting writes a setting.
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value, actor string) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value, updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
|
||||
key, value, s.timestamp(), actor)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// AllSettings returns every setting.
|
||||
func (s *Store) AllSettings(ctx context.Context) (map[string]domain.Setting, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT key, value, updated_at, updated_by FROM settings`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list settings: %w", translateError(err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]domain.Setting)
|
||||
for rows.Next() {
|
||||
var (
|
||||
setting domain.Setting
|
||||
updatedAt string
|
||||
)
|
||||
if err := rows.Scan(&setting.Key, &setting.Value, &updatedAt, &setting.UpdatedBy); err != nil {
|
||||
return nil, fmt.Errorf("scan setting: %w", err)
|
||||
}
|
||||
if setting.UpdatedAt, err = parseTime(updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[setting.Key] = setting
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- audit --
|
||||
|
||||
// AuditFilter narrows the activity log.
|
||||
type AuditFilter struct {
|
||||
Actor string
|
||||
Action string
|
||||
EntityType string
|
||||
EntityID string
|
||||
Since *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// AppendAudit writes one audit entry. Failures are returned but callers on the
|
||||
// request path generally log and continue: losing an audit row is bad, failing
|
||||
// a user's publish because of it is worse.
|
||||
func (s *Store) AppendAudit(ctx context.Context, entry *domain.AuditEntry) error {
|
||||
if entry == nil {
|
||||
return fmt.Errorf("%w: audit entry must not be nil", domain.ErrInvalid)
|
||||
}
|
||||
if entry.Action == "" {
|
||||
return fmt.Errorf("%w: audit entry requires an action", domain.ErrInvalid)
|
||||
}
|
||||
if entry.At.IsZero() {
|
||||
entry.At = s.now().UTC()
|
||||
}
|
||||
|
||||
detail, err := json.Marshal(entry.Detail)
|
||||
if err != nil {
|
||||
detail = []byte("{}")
|
||||
}
|
||||
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO audit_log (at, actor, actor_email, action, entity_type, entity_id, summary, detail_json, remote_ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
entry.At.UTC().Format(domain.TimeFormat), entry.Actor, entry.ActorEmail,
|
||||
string(entry.Action), entry.EntityType, entry.EntityID, entry.Summary,
|
||||
string(detail), entry.RemoteIP,
|
||||
)
|
||||
return translateError(err)
|
||||
}
|
||||
|
||||
// ListAudit returns audit entries newest first.
|
||||
func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) ([]domain.AuditEntry, int, error) {
|
||||
var (
|
||||
where []string
|
||||
args []any
|
||||
)
|
||||
if filter.Actor != "" {
|
||||
where = append(where, `actor = ?`)
|
||||
args = append(args, filter.Actor)
|
||||
}
|
||||
if filter.Action != "" {
|
||||
where = append(where, `action = ?`)
|
||||
args = append(args, filter.Action)
|
||||
}
|
||||
if filter.EntityType != "" {
|
||||
where = append(where, `entity_type = ?`)
|
||||
args = append(args, filter.EntityType)
|
||||
}
|
||||
if filter.EntityID != "" {
|
||||
where = append(where, `entity_id = ?`)
|
||||
args = append(args, filter.EntityID)
|
||||
}
|
||||
if filter.Since != nil {
|
||||
where = append(where, `at >= ?`)
|
||||
args = append(args, filter.Since.UTC().Format(domain.TimeFormat))
|
||||
}
|
||||
|
||||
clause := ""
|
||||
if len(where) > 0 {
|
||||
clause = " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_log`+clause, args...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count audit entries: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, at, actor, actor_email, action, entity_type, entity_id, summary, detail_json, remote_ip
|
||||
FROM audit_log`+clause+` ORDER BY id DESC LIMIT ? OFFSET ?`,
|
||||
append(append([]any{}, args...), limit, offset)...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list audit entries: %w", translateError(err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]domain.AuditEntry, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
entry domain.AuditEntry
|
||||
action string
|
||||
at string
|
||||
detailJSON string
|
||||
)
|
||||
if err := rows.Scan(&entry.ID, &at, &entry.Actor, &entry.ActorEmail, &action,
|
||||
&entry.EntityType, &entry.EntityID, &entry.Summary, &detailJSON, &entry.RemoteIP); err != nil {
|
||||
return nil, 0, fmt.Errorf("scan audit entry: %w", err)
|
||||
}
|
||||
entry.Action = domain.AuditAction(action)
|
||||
if entry.At, err = parseTime(at); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if detailJSON != "" {
|
||||
// Same reasoning as the roles column: an audit entry whose detail cannot
|
||||
// be decoded is still worth returning, because the point of the audit log
|
||||
// is that the record of the action exists.
|
||||
//
|
||||
//nolint:errcheck // see above
|
||||
_ = json.Unmarshal([]byte(detailJSON), &entry.Detail)
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// AuditRetention returns the age of the oldest audit entry, used by the
|
||||
// retention job.
|
||||
func (s *Store) AuditRetention(ctx context.Context) (time.Time, error) {
|
||||
var oldest sql.NullString
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT MIN(at) FROM audit_log`).Scan(&oldest); err != nil {
|
||||
return time.Time{}, fmt.Errorf("audit retention: %w", err)
|
||||
}
|
||||
if !oldest.Valid {
|
||||
return s.now().UTC(), nil
|
||||
}
|
||||
return parseTime(oldest.String)
|
||||
}
|
||||
|
||||
// PurgeAudit removes entries older than the cutoff and returns the count.
|
||||
func (s *Store) PurgeAudit(ctx context.Context, before time.Time) (int64, error) {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM audit_log WHERE at < ?`, before.UTC().Format(domain.TimeFormat))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("purge audit: %w", translateError(err))
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// Stats summarises the instance for the admin dashboard.
|
||||
type Stats struct {
|
||||
Themes int `json:"themes"`
|
||||
ThemesByStatus map[string]int `json:"themesByStatus"`
|
||||
Users int `json:"users"`
|
||||
AuditEntries int `json:"auditEntries"`
|
||||
DatabaseSize int64 `json:"databaseSizeBytes"`
|
||||
VersionsRetaind int `json:"versionsRetained"`
|
||||
}
|
||||
|
||||
// Stats gathers dashboard counters in a single pass.
|
||||
func (s *Store) Stats(ctx context.Context) (Stats, error) {
|
||||
stats := Stats{ThemesByStatus: map[string]int{}}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT status, COUNT(*) FROM themes GROUP BY status`)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("stats: %w", translateError(err))
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var (
|
||||
status string
|
||||
count int
|
||||
)
|
||||
if err := rows.Scan(&status, &count); err != nil {
|
||||
return stats, fmt.Errorf("stats: %w", err)
|
||||
}
|
||||
stats.ThemesByStatus[status] = count
|
||||
stats.Themes += count
|
||||
}
|
||||
// rows.Next() returning false means either no rows or an error. Checking Err
|
||||
// distinguishes them, and without it a failure part way through the result set
|
||||
// reports success with silently incomplete counters.
|
||||
if err := rows.Err(); err != nil {
|
||||
return stats, fmt.Errorf("stats: %w", err)
|
||||
}
|
||||
|
||||
for _, query := range []struct {
|
||||
sql string
|
||||
dest *int
|
||||
}{
|
||||
{`SELECT COUNT(*) FROM users`, &stats.Users},
|
||||
{`SELECT COUNT(*) FROM audit_log`, &stats.AuditEntries},
|
||||
{`SELECT COUNT(*) FROM theme_versions`, &stats.VersionsRetaind},
|
||||
} {
|
||||
if err := s.db.QueryRowContext(ctx, query.sql).Scan(query.dest); err != nil {
|
||||
return stats, fmt.Errorf("stats: %w", translateError(err))
|
||||
}
|
||||
}
|
||||
|
||||
// page_count * page_size is the on-disk size of the main database file. It
|
||||
// is a cheap approximation, which is all a dashboard needs.
|
||||
var pageCount, pageSize int64
|
||||
if err := s.db.QueryRowContext(ctx, `PRAGMA page_count`).Scan(&pageCount); err == nil {
|
||||
if err := s.db.QueryRowContext(ctx, `PRAGMA page_size`).Scan(&pageSize); err == nil {
|
||||
stats.DatabaseSize = pageCount * pageSize
|
||||
}
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
13
internal/store/sort.go
Normal file
13
internal/store/sort.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// sortStrings orders a string slice in place.
|
||||
func sortStrings(values []string) { sort.Strings(values) }
|
||||
|
||||
// sortSlice orders a slice in place using the supplied comparison.
|
||||
func sortSlice[T any](values []T, less func(a, b T) bool) {
|
||||
sort.Slice(values, func(i, j int) bool { return less(values[i], values[j]) })
|
||||
}
|
||||
307
internal/store/store.go
Normal file
307
internal/store/store.go
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
// Package store is the persistence layer. It owns the SQLite connection, the
|
||||
// schema and every query the application runs.
|
||||
//
|
||||
// # Why SQLite
|
||||
//
|
||||
// Go Bananas!! stores themes: a few thousand rows of JSON documents that are read
|
||||
// constantly and written rarely. That workload is exactly what an embedded
|
||||
// engine is good at, and a client/server database would add a network hop, a
|
||||
// connection pool, a second container, credentials to rotate and a schema
|
||||
// migration process to a service whose entire dataset comfortably fits in a few
|
||||
// megabytes.
|
||||
//
|
||||
// The configuration that makes this genuinely fast rather than merely adequate:
|
||||
//
|
||||
// - WAL journalling, so readers never block the writer and vice versa.
|
||||
// - synchronous=NORMAL, which in WAL mode is durable across process crashes
|
||||
// and only risks the last transactions on an OS crash. For a design-token
|
||||
// store that is the correct trade: a lost publish is re-doable, an outage
|
||||
// is not.
|
||||
// - A deliberately small connection pool. SQLite serialises writers, so many
|
||||
// connections buy contention, not throughput.
|
||||
// - Prepared statements cached by database/sql, so the hot runtime query is
|
||||
// compiled once at start-up.
|
||||
// - Rendered themes memoised in memory, so the steady-state runtime request
|
||||
// performs no database access at all.
|
||||
//
|
||||
// The engine is modernc.org/sqlite, a pure-Go translation of SQLite. It needs no
|
||||
// cgo, so the release image builds from a static base with no libc and the
|
||||
// cross-compiled binaries are genuinely portable.
|
||||
//
|
||||
// # Concurrency
|
||||
//
|
||||
// A Store is safe for concurrent use. The connection pool handles concurrency;
|
||||
// write transactions are serialised by SQLite's own locking, which at this write
|
||||
// volume is not a bottleneck. Callers must not hold a *sql.Tx across an HTTP
|
||||
// boundary; use the callback forms so that a panic rolls the transaction back.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go SQLite driver
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// marshalJSON encodes a value for a TEXT column. The store always writes JSON
|
||||
// through this helper so that a value which cannot be marshalled fails at the
|
||||
// boundary rather than producing a corrupt row.
|
||||
func marshalJSON(v any) (string, error) {
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal json: %w", err)
|
||||
}
|
||||
return string(raw), nil
|
||||
}
|
||||
|
||||
// boolToInt renders a bool for a SQLite INTEGER column.
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// intToBool is the inverse of boolToInt.
|
||||
func intToBool(v int) bool { return v != 0 }
|
||||
|
||||
// Store is the persistence facade.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
now func() time.Time
|
||||
// inMemory reports whether the store is backed by an ephemeral database.
|
||||
inMemory bool
|
||||
}
|
||||
|
||||
// Options configures a Store.
|
||||
type Options struct {
|
||||
// Path is the database file, or ":memory:" for an ephemeral database.
|
||||
Path string
|
||||
// BusyTimeout is how long a blocked writer waits before failing.
|
||||
BusyTimeout time.Duration
|
||||
// MaxConns bounds the connection pool.
|
||||
MaxConns int
|
||||
// ReadOnly opens the database without write access, for replicas.
|
||||
ReadOnly bool
|
||||
// Now overrides the clock, for deterministic tests.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// sentinelMapping translates the SQLite driver's typed errors into the domain
|
||||
// sentinels the HTTP layer matches on. Doing this in one place keeps every
|
||||
// repository from re-implementing driver-specific string matching.
|
||||
var sentinelMapping = map[string]error{
|
||||
"constraint failed: UNIQUE constraint failed": domain.ErrConflict,
|
||||
"constraint failed: CHECK constraint failed": domain.ErrInvalid,
|
||||
"constraint failed: NOT NULL constraint failed": domain.ErrInvalid,
|
||||
"constraint failed: FOREIGN KEY constraint failed": domain.ErrConflict,
|
||||
}
|
||||
|
||||
// Open connects to the database, applies the schema and verifies it.
|
||||
func Open(ctx context.Context, opts Options) (*Store, error) {
|
||||
if opts.Path == "" {
|
||||
return nil, fmt.Errorf("%w: database path must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
if opts.BusyTimeout <= 0 {
|
||||
opts.BusyTimeout = 5 * time.Second
|
||||
}
|
||||
if opts.MaxConns <= 0 {
|
||||
opts.MaxConns = 8
|
||||
}
|
||||
if opts.Now == nil {
|
||||
opts.Now = time.Now
|
||||
}
|
||||
|
||||
inMemory := isMemoryPath(opts.Path)
|
||||
dsn := buildDSN(opts.Path, opts.BusyTimeout, opts.ReadOnly)
|
||||
|
||||
if !inMemory && !opts.ReadOnly {
|
||||
if dir := filepath.Dir(opts.Path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("create database directory: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
// A small pool is deliberate. See the package comment.
|
||||
db.SetMaxOpenConns(opts.MaxConns)
|
||||
db.SetMaxIdleConns(opts.MaxConns)
|
||||
db.SetConnMaxIdleTime(time.Minute)
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
s := &Store{db: db, now: opts.Now, inMemory: inMemory}
|
||||
if !opts.ReadOnly {
|
||||
if err := s.migrate(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// buildDSN assembles the SQLite connection string.
|
||||
//
|
||||
// modernc.org/sqlite takes its PRAGMAs as query parameters. Using the _pragma
|
||||
// form rather than executing PRAGMA statements after opening is what makes the
|
||||
// settings apply to every pooled connection instead of only the first.
|
||||
func buildDSN(path string, busyTimeout time.Duration, readOnly bool) string {
|
||||
params := url.Values{}
|
||||
|
||||
// WAL lets readers and the writer proceed concurrently.
|
||||
if !readOnly && !isMemoryPath(path) {
|
||||
params.Add("_pragma", "journal_mode(WAL)")
|
||||
}
|
||||
// NORMAL is durable across application crashes in WAL mode. See the package
|
||||
// comment for why that is the right trade here.
|
||||
params.Add("_pragma", "synchronous(NORMAL)")
|
||||
params.Add("_pragma", "foreign_keys(ON)")
|
||||
params.Add("_pragma", "busy_timeout("+fmt.Sprint(busyTimeout.Milliseconds())+")")
|
||||
// A negative cache size is a KiB budget; 16 MiB comfortably holds the whole
|
||||
// dataset in memory.
|
||||
params.Add("_pragma", "cache_size(-16000)")
|
||||
// temp_store=MEMORY keeps sort and group-by spill out of the filesystem.
|
||||
params.Add("_pragma", "temp_store(MEMORY)")
|
||||
// mmap lets SQLite read the file through the page cache directly, which
|
||||
// removes a copy on the hot read path.
|
||||
params.Add("_pragma", "mmap_size(268435456)")
|
||||
if readOnly {
|
||||
params.Add("mode", "ro")
|
||||
}
|
||||
|
||||
base := path
|
||||
if isMemoryPath(path) {
|
||||
// A shared cache is required for an in-memory database to be visible
|
||||
// across the pooled connections, otherwise each connection would get its
|
||||
// own empty database.
|
||||
switch {
|
||||
case strings.Contains(path, "?"):
|
||||
// The caller supplied a full URI with its own query.
|
||||
base = path
|
||||
case nameFromPath(path) != "":
|
||||
base = "file:" + nameFromPath(path) + "?mode=memory&cache=shared"
|
||||
default:
|
||||
// A bare ":memory:" carries no name. Naming it uniquely keeps two
|
||||
// stores in one process isolated from each other, which is what a test
|
||||
// creating a second store expects; leaving it unnamed would hand every
|
||||
// caller the same database and let one test's rows appear in another's.
|
||||
base = "file:" + ephemeralMemoryName() + "?mode=memory&cache=shared"
|
||||
}
|
||||
}
|
||||
|
||||
// The separator depends on whether the base already carries a query. Joining
|
||||
// with "?" unconditionally produces "cache=shared?_pragma=...", which SQLite
|
||||
// reads as a cache mode literally named "shared?_pragma=..." and rejects.
|
||||
sep := "?"
|
||||
if strings.Contains(base, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
return base + sep + params.Encode()
|
||||
}
|
||||
|
||||
// ephemeralMemoryName returns a random name for an unnamed in-memory database.
|
||||
func ephemeralMemoryName() string {
|
||||
buf := make([]byte, 8)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
// A fixed name is wrong but not fatal: the store still opens and the only
|
||||
// consequence is that two concurrent unnamed stores would share a database.
|
||||
// Failing to open over an entropy hiccup would be worse than that.
|
||||
return "memory-default"
|
||||
}
|
||||
return "memory-" + hex.EncodeToString(buf)
|
||||
}
|
||||
|
||||
// IsPersistentPath reports whether a database path holds data that outlives the
|
||||
// process. An ephemeral database cannot be snapshotted, and a caller that tries is
|
||||
// better served by being told so than by producing an empty file.
|
||||
func IsPersistentPath(path string) bool { return !isMemoryPath(path) }
|
||||
|
||||
// isMemoryPath reports whether the path refers to an ephemeral database.
|
||||
func isMemoryPath(path string) bool {
|
||||
return path == ":memory:" || strings.HasPrefix(path, "file::memory:") || strings.Contains(path, "mode=memory")
|
||||
}
|
||||
|
||||
// nameFromPath derives a stable shared-cache identifier from a path, so that
|
||||
// two in-memory stores created with the same name are genuinely the same
|
||||
// database and two created with different names are isolated.
|
||||
func nameFromPath(path string) string {
|
||||
name := strings.TrimPrefix(path, ":memory:")
|
||||
name = strings.TrimPrefix(name, "file:")
|
||||
if idx := strings.IndexByte(name, '?'); idx >= 0 {
|
||||
name = name[:idx]
|
||||
}
|
||||
name = strings.Trim(name, "/")
|
||||
if name == "" {
|
||||
return "go-bananas-memory"
|
||||
}
|
||||
// Names are hashed rather than escaped so that any path can be used safely.
|
||||
return "go-bananas-" + fmt.Sprintf("%x", len(name)) + "-" + strings.ReplaceAll(name, "/", "_")
|
||||
}
|
||||
|
||||
// Close releases the database.
|
||||
func (s *Store) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// DB exposes the underlying handle for health checks and tests.
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// Ping verifies connectivity, used by the readiness probe.
|
||||
func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||
|
||||
// InMemory reports whether the store is ephemeral.
|
||||
func (s *Store) InMemory() bool { return s.inMemory }
|
||||
|
||||
// Now returns the store's clock.
|
||||
func (s *Store) Now() time.Time { return s.now() }
|
||||
|
||||
// translateError maps a driver error onto a domain sentinel.
|
||||
func translateError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: %s", domain.ErrNotFound, err.Error())
|
||||
}
|
||||
msg := err.Error()
|
||||
for fragment, sentinel := range sentinelMapping {
|
||||
if strings.Contains(msg, fragment) {
|
||||
return fmt.Errorf("%w: %s", sentinel, msg)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// wrapNotFound attaches context to an ErrNotFound result.
|
||||
func wrapNotFound(err error, what string) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: %s", domain.ErrNotFound, what)
|
||||
}
|
||||
return translateError(err)
|
||||
}
|
||||
717
internal/store/store_test.go
Normal file
717
internal/store/store_test.go
Normal file
|
|
@ -0,0 +1,717 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
"github.com/urpi/go-bananas/internal/seed"
|
||||
)
|
||||
|
||||
// newTestStore returns a store backed by a temporary on-disk database, so that
|
||||
// the test exercises the same file-based code path as production including WAL.
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
store, err := Open(context.Background(), Options{Path: path})
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
return store
|
||||
}
|
||||
|
||||
// sampleTheme returns a minimal valid theme for store tests.
|
||||
func sampleTheme(slug string) *domain.Theme {
|
||||
return &domain.Theme{
|
||||
Slug: slug,
|
||||
Name: "Sample " + slug,
|
||||
Description: "a theme used in tests",
|
||||
Status: domain.StatusDraft,
|
||||
DefaultAppearance: domain.AppearanceLight,
|
||||
Tokens: domain.TokenDoc{
|
||||
"color": map[string]any{
|
||||
"semantic": map[string]any{
|
||||
"background": map[string]any{"$type": "color", "$value": "#ffffff"},
|
||||
"primary": map[string]any{"$type": "color", "$value": "#4f46e5"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Meta: domain.ThemeMeta{"tags": []string{"test"}},
|
||||
CreatedBy: "tester",
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAppliesSchema(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
|
||||
for _, table := range []string{"themes", "theme_versions", "users", "audit_log", "settings", "schema_migrations"} {
|
||||
var name string
|
||||
err := store.DB().QueryRowContext(t.Context(),
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&name)
|
||||
if err != nil {
|
||||
t.Errorf("table %s missing: %v", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndGetTheme(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
theme := sampleTheme("acme")
|
||||
if err := store.Create(ctx, theme, "initial"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Get(ctx, "acme")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if loaded.ID == "" {
|
||||
t.Error("expected an assigned id")
|
||||
}
|
||||
if loaded.Version != 1 {
|
||||
t.Errorf("version = %d, want 1", loaded.Version)
|
||||
}
|
||||
if loaded.Tokens["color"] == nil {
|
||||
t.Error("tokens did not round-trip")
|
||||
}
|
||||
if loaded.System {
|
||||
t.Error("a user-created theme must not be flagged as built in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsDuplicateSlug(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("first Create: %v", err)
|
||||
}
|
||||
err := store.Create(ctx, sampleTheme("acme"), "")
|
||||
if !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("duplicate slug error = %v, want ErrConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsReservedSlug(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
err := store.Create(ctx, sampleTheme("api"), "")
|
||||
if !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Fatalf("reserved slug error = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRejectsInvalidStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
theme := sampleTheme("acme")
|
||||
theme.Status = "sideways"
|
||||
if err := store.Create(ctx, theme, ""); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Fatalf("invalid status error = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBumpsVersionAndRecordsHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), "created"); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
name := "Renamed"
|
||||
updated, err := store.Update(ctx, "acme", UpdateFields{Name: &name, Message: "renamed"})
|
||||
if err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
if updated.Version != 2 {
|
||||
t.Errorf("version = %d, want 2", updated.Version)
|
||||
}
|
||||
if updated.Name != name {
|
||||
t.Errorf("name = %q, want %q", updated.Name, name)
|
||||
}
|
||||
|
||||
versions, err := store.Versions(ctx, "acme", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Versions: %v", err)
|
||||
}
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("got %d versions, want 2", len(versions))
|
||||
}
|
||||
// Newest first.
|
||||
if versions[0].Version != 2 || versions[0].Message != "renamed" {
|
||||
t.Errorf("unexpected newest version: %+v", versions[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialUpdateLeavesOtherFieldsAlone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
description := "updated description"
|
||||
if _, err := store.Update(ctx, "acme", UpdateFields{Description: &description}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := store.Get(ctx, "acme")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if loaded.Name != "Sample acme" {
|
||||
t.Errorf("name changed to %q by a description-only update", loaded.Name)
|
||||
}
|
||||
if loaded.Description != description {
|
||||
t.Errorf("description = %q, want %q", loaded.Description, description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameChangesSlugAndKeepsHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
newSlug := "acme-v2"
|
||||
if _, err := store.Update(ctx, "acme", UpdateFields{Slug: &newSlug}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.Get(ctx, "acme"); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("old slug should be gone, got %v", err)
|
||||
}
|
||||
if _, err := store.Get(ctx, "acme-v2"); err != nil {
|
||||
t.Errorf("new slug should resolve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRefusesBuiltInThemes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
|
||||
t.Fatalf("Seed: %v", err)
|
||||
}
|
||||
err := store.Delete(ctx, "midnight")
|
||||
if !errors.Is(err, domain.ErrImmutable) {
|
||||
t.Fatalf("deleting a built-in theme = %v, want ErrImmutable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRemovesThemeAndCascadesHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := store.Delete(ctx, "acme"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := store.Get(ctx, "acme"); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("theme should be gone, got %v", err)
|
||||
}
|
||||
|
||||
var versions int
|
||||
if err := store.DB().QueryRowContext(t.Context(),
|
||||
`SELECT COUNT(*) FROM theme_versions WHERE slug = ?`, "acme").Scan(&versions); err != nil {
|
||||
t.Fatalf("count versions: %v", err)
|
||||
}
|
||||
if versions != 0 {
|
||||
t.Errorf("history rows survived deletion: %d", versions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUnknownThemeReportsNotFound(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
if err := store.Delete(context.Background(), "nope"); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Fatalf("error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuplicateProducesIndependentDraft(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
copied, err := store.Duplicate(ctx, "acme", "acme-copy", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("Duplicate: %v", err)
|
||||
}
|
||||
|
||||
if copied.Status != domain.StatusDraft {
|
||||
t.Errorf("status = %q, want draft", copied.Status)
|
||||
}
|
||||
if copied.Version != 1 {
|
||||
t.Errorf("version = %d, want 1", copied.Version)
|
||||
}
|
||||
if copied.System {
|
||||
t.Error("a duplicate must not remain flagged as built in")
|
||||
}
|
||||
|
||||
// Mutating the copy must not touch the original.
|
||||
name := "Changed"
|
||||
if _, err := store.Update(ctx, "acme-copy", UpdateFields{Name: &name}); err != nil {
|
||||
t.Fatalf("Update copy: %v", err)
|
||||
}
|
||||
original, err := store.Get(ctx, "acme")
|
||||
if err != nil {
|
||||
t.Fatalf("Get original: %v", err)
|
||||
}
|
||||
if original.Name == "Changed" {
|
||||
t.Error("mutating the duplicate changed the original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackAppendsRatherThanRewritesHistory(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
name := "Second"
|
||||
if _, err := store.Update(ctx, "acme", UpdateFields{Name: &name}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
restored, err := store.Rollback(ctx, "acme", 1, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("Rollback: %v", err)
|
||||
}
|
||||
if restored.Name != "Sample acme" {
|
||||
t.Errorf("name = %q, want the version 1 value", restored.Name)
|
||||
}
|
||||
if restored.Version != 3 {
|
||||
t.Errorf("version = %d, want 3; a rollback must append", restored.Version)
|
||||
}
|
||||
|
||||
versions, err := store.Versions(ctx, "acme", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("Versions: %v", err)
|
||||
}
|
||||
if len(versions) != 3 {
|
||||
t.Errorf("got %d versions, want 3 (history must be append-only)", len(versions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffVersions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
tokens := sampleTheme("acme").Tokens
|
||||
tokens["color"].(map[string]any)["semantic"].(map[string]any)["primary"] =
|
||||
map[string]any{"$type": "color", "$value": "#ff0000"}
|
||||
if _, err := store.Update(ctx, "acme", UpdateFields{Tokens: &tokens, Message: "recolour"}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
diff, err := store.DiffVersions(ctx, "acme", 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("DiffVersions: %v", err)
|
||||
}
|
||||
if len(diff.Changed) != 1 {
|
||||
t.Fatalf("got %d changed tokens, want 1: %+v", len(diff.Changed), diff.Changed)
|
||||
}
|
||||
change := diff.Changed[0]
|
||||
if change.Path != "color.semantic.primary" {
|
||||
t.Errorf("path = %q", change.Path)
|
||||
}
|
||||
if change.From != "#4f46e5" || change.To != "#ff0000" {
|
||||
t.Errorf("change = %s -> %s", change.From, change.To)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiffVersionsOfIdenticalRevisionsMarshalAsLists pins the wire contract for a
|
||||
// diff with nothing in it.
|
||||
//
|
||||
// Two revisions with no differences is an ordinary outcome, not an edge case: a save
|
||||
// that only changed a description, or an operator comparing the wrong pair. The JSON
|
||||
// still has to carry three lists, because that is what the API promises and what a
|
||||
// typed client reads the length of.
|
||||
func TestDiffVersionsOfIdenticalRevisionsMarshalAsLists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
// A second save of the same token document, so the versions differ only in their
|
||||
// message and the diff has nothing to report.
|
||||
tokens := sampleTheme("acme").Tokens
|
||||
if _, err := store.Update(ctx, "acme", UpdateFields{Tokens: &tokens, Message: "no-op save"}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
diff, err := store.DiffVersions(ctx, "acme", 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("DiffVersions: %v", err)
|
||||
}
|
||||
if diff.Added == nil || diff.Removed == nil || diff.Changed == nil {
|
||||
t.Fatalf("empty lists must be non-nil: %+v", diff)
|
||||
}
|
||||
if len(diff.Added)+len(diff.Removed)+len(diff.Changed) != 0 {
|
||||
t.Errorf("identical revisions should diff to nothing: %+v", diff)
|
||||
}
|
||||
|
||||
// The assertion that matters to a client: null would break every consumer that
|
||||
// reads a length, and JSON is the contract, not the Go struct.
|
||||
encoded, err := json.Marshal(diff)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
for _, field := range []string{`"added":[]`, `"removed":[]`, `"changed":[]`} {
|
||||
if !strings.Contains(string(encoded), field) {
|
||||
t.Errorf("diff JSON missing %s: %s", field, encoded)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{`"added":null`, `"removed":null`, `"changed":null`} {
|
||||
if strings.Contains(string(encoded), field) {
|
||||
t.Errorf("diff JSON has %s, which a typed client cannot read a length from: %s", field, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFiltersAndSorts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
for _, slug := range []string{"alpha", "beta", "gamma"} {
|
||||
if err := store.Create(ctx, sampleTheme(slug), ""); err != nil {
|
||||
t.Fatalf("Create %s: %v", slug, err)
|
||||
}
|
||||
}
|
||||
published := domain.StatusPublished
|
||||
if _, err := store.Update(ctx, "beta", UpdateFields{Status: &published}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
page, err := store.List(ctx, domain.ThemeQuery{Status: domain.StatusPublished})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].Slug != "beta" {
|
||||
t.Errorf("published filter returned %+v", page)
|
||||
}
|
||||
|
||||
page, err = store.List(ctx, domain.ThemeQuery{Search: "alph"})
|
||||
if err != nil {
|
||||
t.Fatalf("List search: %v", err)
|
||||
}
|
||||
if page.Total != 1 || page.Items[0].Slug != "alpha" {
|
||||
t.Errorf("search returned %+v", page)
|
||||
}
|
||||
|
||||
page, err = store.List(ctx, domain.ThemeQuery{Sort: "name", Order: "asc"})
|
||||
if err != nil {
|
||||
t.Fatalf("List sorted: %v", err)
|
||||
}
|
||||
if len(page.Items) != 3 || page.Items[0].Slug != "alpha" {
|
||||
t.Errorf("sorted page = %+v", page.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListIgnoresUnknownSortColumn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
if err := store.Create(ctx, sampleTheme("acme"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// An unknown sort must fall back to the default rather than reaching SQL.
|
||||
if _, err := store.List(ctx, domain.ThemeQuery{Sort: "slug; DROP TABLE themes"}); err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSearchTreatsWildcardsLiterally(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
for _, slug := range []string{"alpha", "beta"} {
|
||||
if err := store.Create(ctx, sampleTheme(slug), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
page, err := store.List(ctx, domain.ThemeQuery{Search: "%"})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if page.Total != 0 {
|
||||
t.Errorf("a literal %% matched %d themes; wildcards must be escaped", page.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRuntimeExcludesDrafts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("draft-one"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
published := domain.StatusPublished
|
||||
if err := store.Create(ctx, sampleTheme("live-one"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := store.Update(ctx, "live-one", UpdateFields{Status: &published}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
|
||||
themes, err := store.ListRuntime(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRuntime: %v", err)
|
||||
}
|
||||
if len(themes) != 1 || themes[0].Slug != "live-one" {
|
||||
t.Errorf("ListRuntime returned %d themes, want only the published one", len(themes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedIsIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
inserted, _, err := store.Seed(ctx, seed.Themes())
|
||||
if err != nil {
|
||||
t.Fatalf("Seed: %v", err)
|
||||
}
|
||||
if inserted == 0 {
|
||||
t.Fatal("first seed inserted nothing")
|
||||
}
|
||||
|
||||
// Declared here rather than alongside the first call, so the first call's
|
||||
// "refreshed" is not an assignment that is immediately overwritten.
|
||||
var refreshed int
|
||||
inserted, refreshed, err = store.Seed(ctx, seed.Themes())
|
||||
if err != nil {
|
||||
t.Fatalf("second Seed: %v", err)
|
||||
}
|
||||
if inserted != 0 || refreshed != 0 {
|
||||
t.Errorf("second seed inserted=%d refreshed=%d, want 0/0", inserted, refreshed)
|
||||
}
|
||||
|
||||
for _, slug := range seed.Slugs() {
|
||||
theme, err := store.Get(ctx, slug)
|
||||
if err != nil {
|
||||
t.Errorf("built-in %s missing: %v", slug, err)
|
||||
continue
|
||||
}
|
||||
if !theme.System {
|
||||
t.Errorf("theme %s is not flagged as built in", slug)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedLeavesEditedBuiltInsAlone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
|
||||
t.Fatalf("Seed: %v", err)
|
||||
}
|
||||
|
||||
// Simulate an operator having edited the built-in theme.
|
||||
custom := "operator's midnight"
|
||||
if _, err := store.Update(ctx, "midnight", UpdateFields{Name: &custom}); err != nil {
|
||||
t.Fatalf("Update: %v", err)
|
||||
}
|
||||
editedVersion, err := store.Get(ctx, "midnight")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
|
||||
// Re-seeding must not revert the operator's work.
|
||||
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
|
||||
t.Fatalf("re-Seed: %v", err)
|
||||
}
|
||||
after, err := store.Get(ctx, "midnight")
|
||||
if err != nil {
|
||||
t.Fatalf("Get after reseed: %v", err)
|
||||
}
|
||||
if after.Name != custom {
|
||||
t.Errorf("name = %q, want the operator's %q", after.Name, custom)
|
||||
}
|
||||
if after.Version != editedVersion.Version {
|
||||
t.Errorf("version = %d, want %d; a reseed must not bump an edited theme", after.Version, editedVersion.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuditRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
entry := &domain.AuditEntry{
|
||||
Actor: "alice",
|
||||
ActorEmail: "alice@example.com",
|
||||
Action: domain.ActionThemePublish,
|
||||
EntityType: "theme",
|
||||
EntityID: "acme",
|
||||
Summary: "published acme",
|
||||
Detail: map[string]any{"version": 3},
|
||||
RemoteIP: "10.0.0.1",
|
||||
}
|
||||
if err := store.AppendAudit(ctx, entry); err != nil {
|
||||
t.Fatalf("AppendAudit: %v", err)
|
||||
}
|
||||
|
||||
entries, total, err := store.ListAudit(ctx, AuditFilter{EntityID: "acme"})
|
||||
if err != nil {
|
||||
t.Fatalf("ListAudit: %v", err)
|
||||
}
|
||||
if total != 1 || len(entries) != 1 {
|
||||
t.Fatalf("got %d entries (total %d), want 1", len(entries), total)
|
||||
}
|
||||
if entries[0].Detail["version"] != float64(3) {
|
||||
t.Errorf("detail = %v", entries[0].Detail)
|
||||
}
|
||||
|
||||
if _, _, err := store.ListAudit(ctx, AuditFilter{Actor: "bob"}); err != nil {
|
||||
t.Fatalf("ListAudit by actor: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeAudit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
old := store.Now().Add(-90 * 24 * time.Hour)
|
||||
if err := store.AppendAudit(ctx, &domain.AuditEntry{At: old, Action: domain.ActionAuthLogin, Actor: "old"}); err != nil {
|
||||
t.Fatalf("AppendAudit: %v", err)
|
||||
}
|
||||
if err := store.AppendAudit(ctx, &domain.AuditEntry{Action: domain.ActionAuthLogin, Actor: "new"}); err != nil {
|
||||
t.Fatalf("AppendAudit: %v", err)
|
||||
}
|
||||
|
||||
removed, err := store.PurgeAudit(ctx, store.Now().Add(-30*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeAudit: %v", err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Errorf("removed %d entries, want 1", removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
value, err := store.Setting(ctx, domain.SettingDefaultTheme, "fallback")
|
||||
if err != nil || value != "fallback" {
|
||||
t.Fatalf("Setting default = %q, %v; want the fallback", value, err)
|
||||
}
|
||||
if err := store.SetSetting(ctx, domain.SettingDefaultTheme, "midnight", "tester"); err != nil {
|
||||
t.Fatalf("SetSetting: %v", err)
|
||||
}
|
||||
value, err = store.Setting(ctx, domain.SettingDefaultTheme, "fallback")
|
||||
if err != nil || value != "midnight" {
|
||||
t.Fatalf("Setting = %q, %v; want midnight", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertUserOverwritesRoles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
identity := &domain.Identity{
|
||||
Subject: "sub-1", Email: "a@example.com", Name: "A",
|
||||
Roles: []domain.Role{domain.RoleAdmin}, Method: "oidc",
|
||||
}
|
||||
if err := store.UpsertUser(ctx, identity); err != nil {
|
||||
t.Fatalf("UpsertUser: %v", err)
|
||||
}
|
||||
|
||||
identity.Roles = []domain.Role{domain.RoleViewer}
|
||||
if err := store.UpsertUser(ctx, identity); err != nil {
|
||||
t.Fatalf("UpsertUser again: %v", err)
|
||||
}
|
||||
|
||||
users, err := store.ListUsers(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListUsers: %v", err)
|
||||
}
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("got %d users, want 1 (upsert must not duplicate)", len(users))
|
||||
}
|
||||
if len(users[0].Roles) != 1 || users[0].Roles[0] != string(domain.RoleViewer) {
|
||||
t.Errorf("roles = %v, want the latest value", users[0].Roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if _, _, err := store.Seed(ctx, seed.Themes()); err != nil {
|
||||
t.Fatalf("Seed: %v", err)
|
||||
}
|
||||
|
||||
stats, err := store.Stats(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Stats: %v", err)
|
||||
}
|
||||
if stats.Themes != len(seed.Slugs()) {
|
||||
t.Errorf("themes = %d, want %d", stats.Themes, len(seed.Slugs()))
|
||||
}
|
||||
if stats.DatabaseSize <= 0 {
|
||||
t.Error("expected a non-zero database size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReadsAndWrites(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
if err := store.Create(ctx, sampleTheme("shared"), ""); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
// WAL is supposed to let readers run while a writer holds the lock. This
|
||||
// test would deadlock or error without it.
|
||||
errs := make(chan error, 20)
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for i := 0; i < 10; i++ {
|
||||
name := "name"
|
||||
if _, err := store.Update(ctx, "shared", UpdateFields{Name: &name}); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
if _, err := store.Get(ctx, "shared"); err != nil {
|
||||
errs <- err
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case err := <-errs:
|
||||
t.Fatalf("concurrent access failed: %v", err)
|
||||
}
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Errorf("concurrent access: %v", err)
|
||||
}
|
||||
}
|
||||
733
internal/store/themes.go
Normal file
733
internal/store/themes.go
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// themeColumns is the canonical SELECT list. Keeping it in one constant means
|
||||
// the scan helper and every query can never drift out of sync, which is the
|
||||
// classic source of "sql: expected N destination arguments" at runtime.
|
||||
const themeColumns = `
|
||||
id, slug, name, description, status, default_appearance,
|
||||
tokens_json, meta_json, is_system, version,
|
||||
created_at, updated_at, created_by, updated_by`
|
||||
|
||||
// themeRow is the flat representation used for scanning.
|
||||
type themeRow struct {
|
||||
domain.Theme
|
||||
tokensJSON string
|
||||
metaJSON string
|
||||
isSystem int
|
||||
}
|
||||
|
||||
// List returns a page of themes matching the query.
|
||||
func (s *Store) List(ctx context.Context, q domain.ThemeQuery) (domain.ThemePage, error) {
|
||||
var (
|
||||
where []string
|
||||
args []any
|
||||
)
|
||||
|
||||
if q.Search != "" {
|
||||
// LIKE with an escaped pattern. The ESCAPE clause stops a user-supplied
|
||||
// % or _ from turning the filter into a full scan of everything.
|
||||
where = append(where, `(slug LIKE ? ESCAPE '\' OR name LIKE ? ESCAPE '\' OR description LIKE ? ESCAPE '\')`)
|
||||
pattern := "%" + escapeLike(q.Search) + "%"
|
||||
args = append(args, pattern, pattern, pattern)
|
||||
}
|
||||
if q.Status != "" {
|
||||
where = append(where, `status = ?`)
|
||||
args = append(args, string(q.Status))
|
||||
}
|
||||
if q.System != nil {
|
||||
where = append(where, `is_system = ?`)
|
||||
args = append(args, boolToInt(*q.System))
|
||||
}
|
||||
if q.Tag != "" {
|
||||
// Tags live inside the opaque meta document. A substring match on the
|
||||
// serialised form is sufficient: the value is a short JSON string and
|
||||
// the row count is small.
|
||||
where = append(where, `meta_json LIKE ? ESCAPE '\'`)
|
||||
args = append(args, "%\""+escapeLike(q.Tag)+"\"%")
|
||||
}
|
||||
|
||||
clause := ""
|
||||
if len(where) > 0 {
|
||||
clause = " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
|
||||
// The sort column is resolved through an allow-list rather than
|
||||
// interpolated, so caller input can never reach the SQL text.
|
||||
sortColumn := domain.SortFields["updated"]
|
||||
if mapped, ok := domain.SortFields[q.Sort]; ok {
|
||||
sortColumn = mapped
|
||||
}
|
||||
direction := "DESC"
|
||||
if strings.EqualFold(q.Order, "asc") {
|
||||
direction = "ASC"
|
||||
}
|
||||
|
||||
limit := q.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
offset := q.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
total, err := s.countThemes(ctx, clause, args)
|
||||
if err != nil {
|
||||
return domain.ThemePage{}, err
|
||||
}
|
||||
|
||||
// The token document is the bulk of the row. A listing only needs metadata,
|
||||
// so it is excluded here and fetched by Get.
|
||||
//nolint:gosec // G202: sortColumn is validated against a fixed whitelist by the
|
||||
// caller and direction is one of two literals, so neither can carry SQL. The
|
||||
// values are still bound for the LIMIT and OFFSET, which do support parameters.
|
||||
query := `SELECT` + themeColumns + ` FROM themes` + clause +
|
||||
fmt.Sprintf(" ORDER BY %s %s, slug ASC LIMIT ? OFFSET ?", sortColumn, direction)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, append(append([]any{}, args...), limit, offset)...)
|
||||
if err != nil {
|
||||
return domain.ThemePage{}, fmt.Errorf("list themes: %w", translateError(err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
page := domain.ThemePage{Items: []*domain.Theme{}, Limit: limit, Offset: offset}
|
||||
for rows.Next() {
|
||||
row, err := scanThemeRow(rows)
|
||||
if err != nil {
|
||||
return domain.ThemePage{}, err
|
||||
}
|
||||
theme, err := row.theme()
|
||||
if err != nil {
|
||||
return domain.ThemePage{}, err
|
||||
}
|
||||
page.Items = append(page.Items, theme)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.ThemePage{}, fmt.Errorf("iterate themes: %w", err)
|
||||
}
|
||||
page.Total = total
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// countThemes counts the rows matching a WHERE clause.
|
||||
func (s *Store) countThemes(ctx context.Context, clause string, args []any) (int, error) {
|
||||
var total int
|
||||
query := `SELECT COUNT(*) FROM themes` + clause
|
||||
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("count themes: %w", translateError(err))
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// ListRuntime returns the themes a runtime consumer may resolve. Drafts are
|
||||
// excluded; archived themes are included because a pinned consumer must keep
|
||||
// working after a theme is archived.
|
||||
func (s *Store) ListRuntime(ctx context.Context) ([]*domain.Theme, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT`+themeColumns+
|
||||
` FROM themes WHERE status IN ('published', 'archived') ORDER BY name ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list runtime themes: %w", translateError(err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*domain.Theme
|
||||
for rows.Next() {
|
||||
row, err := scanThemeRow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
theme, err := row.theme()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, theme)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Get loads a theme by slug.
|
||||
func (s *Store) Get(ctx context.Context, slug string) (*domain.Theme, error) {
|
||||
row, err := scanThemeRow(s.db.QueryRowContext(ctx,
|
||||
`SELECT`+themeColumns+` FROM themes WHERE slug = ?`, slug))
|
||||
if err != nil {
|
||||
return nil, wrapNotFound(err, "theme "+slug)
|
||||
}
|
||||
return row.theme()
|
||||
}
|
||||
|
||||
// GetByID loads a theme by identifier.
|
||||
func (s *Store) GetByID(ctx context.Context, id string) (*domain.Theme, error) {
|
||||
row, err := scanThemeRow(s.db.QueryRowContext(ctx,
|
||||
`SELECT`+themeColumns+` FROM themes WHERE id = ?`, id))
|
||||
if err != nil {
|
||||
return nil, wrapNotFound(err, "theme "+id)
|
||||
}
|
||||
return row.theme()
|
||||
}
|
||||
|
||||
// Create inserts a new theme and its first history entry.
|
||||
func (s *Store) Create(ctx context.Context, theme *domain.Theme, message string) error {
|
||||
if err := domain.ValidateSlug(theme.Slug); err != nil {
|
||||
return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
|
||||
}
|
||||
if _, reserved := domain.ReservedSlugs[theme.Slug]; reserved {
|
||||
return fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, theme.Slug)
|
||||
}
|
||||
if theme.Name == "" {
|
||||
return fmt.Errorf("%w: theme name must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
if theme.Status == "" {
|
||||
theme.Status = domain.StatusDraft
|
||||
}
|
||||
if !theme.Status.Valid() {
|
||||
return fmt.Errorf("%w: unknown status %q", domain.ErrInvalid, theme.Status)
|
||||
}
|
||||
if theme.DefaultAppearance == "" {
|
||||
theme.DefaultAppearance = domain.AppearanceLight
|
||||
}
|
||||
if !theme.DefaultAppearance.Valid() {
|
||||
return fmt.Errorf("%w: unknown appearance %q", domain.ErrInvalid, theme.DefaultAppearance)
|
||||
}
|
||||
if theme.Version < 1 {
|
||||
theme.Version = 1
|
||||
}
|
||||
if theme.ID == "" {
|
||||
id, err := randomID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
theme.ID = "thm_" + id
|
||||
}
|
||||
|
||||
tokensJSON, err := marshalJSON(theme.Tokens)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
|
||||
}
|
||||
metaJSON, err := marshalJSON(theme.Meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
|
||||
}
|
||||
|
||||
now := s.now().UTC().Format(domain.TimeFormat)
|
||||
theme.CreatedAt = s.now().UTC()
|
||||
theme.UpdatedAt = theme.CreatedAt
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin create theme: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if err := insertTheme(ctx, tx, theme, tokensJSON, metaJSON, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertVersion(ctx, tx, theme, tokensJSON, defaultMessage(message, theme, "created"), theme.CreatedBy, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit create theme: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateFields carries the mutable attributes of a theme. A nil pointer leaves
|
||||
// the field unchanged, which is what lets the API accept partial edits without
|
||||
// a read-modify-write race in the handler.
|
||||
type UpdateFields struct {
|
||||
Slug *string
|
||||
Name *string
|
||||
Description *string
|
||||
Status *domain.ThemeStatus
|
||||
DefaultAppearance *domain.Appearance
|
||||
Tokens *domain.TokenDoc
|
||||
Meta *domain.ThemeMeta
|
||||
Message string
|
||||
Editor string
|
||||
}
|
||||
|
||||
// Update applies a partial update and records a new version.
|
||||
func (s *Store) Update(ctx context.Context, slug string, fields UpdateFields) (*domain.Theme, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin update theme: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
current, err := scanThemeRow(tx.QueryRowContext(ctx,
|
||||
`SELECT`+themeColumns+` FROM themes WHERE slug = ?`, slug))
|
||||
if err != nil {
|
||||
return nil, wrapNotFound(err, "theme "+slug)
|
||||
}
|
||||
theme, err := current.theme()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if fields.Slug != nil && *fields.Slug != theme.Slug {
|
||||
if err := domain.ValidateSlug(*fields.Slug); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
|
||||
}
|
||||
if _, reserved := domain.ReservedSlugs[*fields.Slug]; reserved {
|
||||
return nil, fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, *fields.Slug)
|
||||
}
|
||||
theme.Slug = *fields.Slug
|
||||
}
|
||||
if fields.Name != nil {
|
||||
theme.Name = *fields.Name
|
||||
}
|
||||
if fields.Description != nil {
|
||||
theme.Description = *fields.Description
|
||||
}
|
||||
if fields.Status != nil {
|
||||
if !fields.Status.Valid() {
|
||||
return nil, fmt.Errorf("%w: unknown status %q", domain.ErrInvalid, *fields.Status)
|
||||
}
|
||||
theme.Status = *fields.Status
|
||||
}
|
||||
if fields.DefaultAppearance != nil {
|
||||
if !fields.DefaultAppearance.Valid() {
|
||||
return nil, fmt.Errorf("%w: unknown appearance %q", domain.ErrInvalid, *fields.DefaultAppearance)
|
||||
}
|
||||
theme.DefaultAppearance = *fields.DefaultAppearance
|
||||
}
|
||||
if fields.Tokens != nil {
|
||||
theme.Tokens = *fields.Tokens
|
||||
}
|
||||
if fields.Meta != nil {
|
||||
theme.Meta = *fields.Meta
|
||||
}
|
||||
if theme.Name == "" {
|
||||
return nil, fmt.Errorf("%w: theme name must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
|
||||
tokensJSON, err := marshalJSON(theme.Tokens)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
|
||||
}
|
||||
metaJSON, err := marshalJSON(theme.Meta)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
|
||||
}
|
||||
|
||||
// Every write bumps the version: the version is the cache key for the
|
||||
// rendered CSS, so it must change whenever the rendered output could.
|
||||
theme.Version++
|
||||
theme.UpdatedAt = s.now().UTC()
|
||||
theme.UpdatedBy = fields.Editor
|
||||
now := theme.UpdatedAt.Format(domain.TimeFormat)
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE themes SET
|
||||
slug = ?, name = ?, description = ?, status = ?, default_appearance = ?,
|
||||
tokens_json = ?, meta_json = ?, version = ?, updated_at = ?, updated_by = ?
|
||||
WHERE id = ?`,
|
||||
theme.Slug, theme.Name, theme.Description, string(theme.Status), string(theme.DefaultAppearance),
|
||||
tokensJSON, metaJSON, theme.Version, now, fields.Editor, theme.ID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("update theme: %w", translateError(err))
|
||||
}
|
||||
|
||||
if err := insertVersion(ctx, tx, theme, tokensJSON, defaultMessage(fields.Message, theme, "updated"), fields.Editor, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update theme: %w", err)
|
||||
}
|
||||
return theme, nil
|
||||
}
|
||||
|
||||
// Delete removes a theme and its history. Built-in themes cannot be deleted.
|
||||
func (s *Store) Delete(ctx context.Context, slug string) error {
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM themes WHERE slug = ? AND is_system = 0`, slug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete theme: %w", translateError(err))
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete theme: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
// Distinguish "does not exist" from "is built in" so the API can return
|
||||
// the right status code.
|
||||
var isSystem int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT is_system FROM themes WHERE slug = ?`, slug).Scan(&isSystem)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return fmt.Errorf("%w: theme %s", domain.ErrNotFound, slug)
|
||||
case err != nil:
|
||||
return fmt.Errorf("delete theme: %w", err)
|
||||
case intToBool(isSystem):
|
||||
return fmt.Errorf("%w: theme %s is built in; duplicate it instead", domain.ErrImmutable, slug)
|
||||
default:
|
||||
return fmt.Errorf("%w: theme %s", domain.ErrNotFound, slug)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Duplicate copies a theme under a new slug.
|
||||
func (s *Store) Duplicate(ctx context.Context, slug, newSlug, editor string) (*domain.Theme, error) {
|
||||
source, err := s.Get(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := domain.ValidateSlug(newSlug); err != nil {
|
||||
return nil, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
|
||||
}
|
||||
if _, reserved := domain.ReservedSlugs[newSlug]; reserved {
|
||||
return nil, fmt.Errorf("%w: slug %q is reserved by the runtime API", domain.ErrInvalid, newSlug)
|
||||
}
|
||||
|
||||
copied := source.Clone()
|
||||
copied.ID = ""
|
||||
copied.Slug = newSlug
|
||||
copied.Name = source.Name + " (copy)"
|
||||
copied.Status = domain.StatusDraft
|
||||
// A duplicate starts at version 1: its history is its own, and copying the
|
||||
// version number would collide with nothing but would confuse the rollback
|
||||
// UI by implying revisions that never existed for this slug.
|
||||
copied.Version = 1
|
||||
copied.System = false
|
||||
copied.CreatedBy = editor
|
||||
copied.UpdatedBy = editor
|
||||
|
||||
// The copy is no longer a built-in even though it starts from one.
|
||||
if copied.Meta != nil {
|
||||
delete(copied.Meta, "builtIn")
|
||||
delete(copied.Meta, "seedVersion")
|
||||
}
|
||||
|
||||
if err := s.Create(ctx, copied, "duplicated from "+slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Get(ctx, newSlug)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- history --
|
||||
|
||||
// Versions returns a theme's history, newest first.
|
||||
func (s *Store) Versions(ctx context.Context, slug string, limit int) ([]domain.ThemeVersion, error) {
|
||||
if _, err := s.Get(ctx, slug); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT theme_id, version, slug, name, description, status,
|
||||
default_appearance, tokens_json, message, author, created_at
|
||||
FROM theme_versions WHERE theme_id = (SELECT id FROM themes WHERE slug = ?)
|
||||
ORDER BY version DESC LIMIT ?`, slug, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list versions: %w", translateError(err))
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]domain.ThemeVersion, 0, limit)
|
||||
for rows.Next() {
|
||||
var (
|
||||
v domain.ThemeVersion
|
||||
status string
|
||||
appearance string
|
||||
tokensJSON string
|
||||
createdAt string
|
||||
)
|
||||
if err := rows.Scan(&v.ThemeID, &v.Version, &v.Slug, &v.Name, &v.Description, &status,
|
||||
&appearance, &tokensJSON, &v.Message, &v.Author, &createdAt); err != nil {
|
||||
return nil, fmt.Errorf("scan version: %w", err)
|
||||
}
|
||||
v.Status = domain.ThemeStatus(status)
|
||||
v.DefaultAppearance = domain.Appearance(appearance)
|
||||
if err := json.Unmarshal([]byte(tokensJSON), &v.Tokens); err != nil {
|
||||
return nil, fmt.Errorf("decode version tokens: %w", err)
|
||||
}
|
||||
if v.CreatedAt, err = parseTime(createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Version loads one historical snapshot.
|
||||
func (s *Store) Version(ctx context.Context, slug string, version int64) (*domain.ThemeVersion, error) {
|
||||
var (
|
||||
v domain.ThemeVersion
|
||||
status string
|
||||
appearance string
|
||||
tokensJSON string
|
||||
createdAt string
|
||||
)
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT theme_id, version, slug, name, description, status,
|
||||
default_appearance, tokens_json, message, author, created_at
|
||||
FROM theme_versions
|
||||
WHERE theme_id = (SELECT id FROM themes WHERE slug = ?) AND version = ?`,
|
||||
slug, version,
|
||||
).Scan(&v.ThemeID, &v.Version, &v.Slug, &v.Name, &v.Description, &status,
|
||||
&appearance, &tokensJSON, &v.Message, &v.Author, &createdAt)
|
||||
if err != nil {
|
||||
return nil, wrapNotFound(err, fmt.Sprintf("version %d of theme %s", version, slug))
|
||||
}
|
||||
|
||||
v.Status = domain.ThemeStatus(status)
|
||||
v.DefaultAppearance = domain.Appearance(appearance)
|
||||
if err := json.Unmarshal([]byte(tokensJSON), &v.Tokens); err != nil {
|
||||
return nil, fmt.Errorf("decode version tokens: %w", err)
|
||||
}
|
||||
if v.CreatedAt, err = parseTime(createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// Rollback restores a historical snapshot as a new version, which keeps history
|
||||
// append-only. Rewriting history would break the guarantee that a pinned
|
||||
// consumer can always resolve the version it was built against.
|
||||
func (s *Store) Rollback(ctx context.Context, slug string, version int64, editor string) (*domain.Theme, error) {
|
||||
snapshot, err := s.Version(ctx, slug, version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current, err := s.Get(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fields := UpdateFields{
|
||||
Name: &snapshot.Name,
|
||||
Description: &snapshot.Description,
|
||||
Status: &snapshot.Status,
|
||||
DefaultAppearance: &snapshot.DefaultAppearance,
|
||||
Tokens: &snapshot.Tokens,
|
||||
Message: fmt.Sprintf("rolled back to version %d", version),
|
||||
Editor: editor,
|
||||
}
|
||||
if current.System {
|
||||
// Rolling a built-in theme back must not clear its built-in flag, but
|
||||
// the store's update path already leaves it untouched.
|
||||
fields.Editor = editor
|
||||
}
|
||||
return s.Update(ctx, slug, fields)
|
||||
}
|
||||
|
||||
// Diff compares two versions of a theme. Only the token tree is compared;
|
||||
// metadata changes are reported as a boolean rather than field by field.
|
||||
type Diff struct {
|
||||
FromVersion int64 `json:"fromVersion"`
|
||||
ToVersion int64 `json:"toVersion"`
|
||||
Added []string `json:"added"`
|
||||
Removed []string `json:"removed"`
|
||||
Changed []TokenDiff `json:"changed"`
|
||||
MetaChanged bool `json:"metaChanged"`
|
||||
}
|
||||
|
||||
// TokenDiff is one changed token.
|
||||
type TokenDiff struct {
|
||||
Path string `json:"path"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
// DiffVersions compares two snapshots of the same theme.
|
||||
func (s *Store) DiffVersions(ctx context.Context, slug string, from, to int64) (*Diff, error) {
|
||||
fromVersion, err := s.Version(ctx, slug, from)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toVersion, err := s.Version(ctx, slug, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The three lists are allocated empty rather than left nil. A nil slice marshals
|
||||
// to JSON null, and the contract declares these as arrays -- so comparing two
|
||||
// revisions that differ in nothing, which is an ordinary outcome rather than an
|
||||
// edge case, would hand every client a null where it expects a list. The same
|
||||
// reason ListUsers normalises an absent roles column.
|
||||
result := &Diff{
|
||||
FromVersion: from,
|
||||
ToVersion: to,
|
||||
Added: []string{},
|
||||
Removed: []string{},
|
||||
Changed: []TokenDiff{},
|
||||
}
|
||||
fromTokens := flattenForDiff(fromVersion.Tokens)
|
||||
toTokens := flattenForDiff(toVersion.Tokens)
|
||||
|
||||
for path, fromValue := range fromTokens {
|
||||
toValue, ok := toTokens[path]
|
||||
if !ok {
|
||||
result.Removed = append(result.Removed, path)
|
||||
continue
|
||||
}
|
||||
if fromValue != toValue {
|
||||
result.Changed = append(result.Changed, TokenDiff{Path: path, From: fromValue, To: toValue})
|
||||
}
|
||||
}
|
||||
for path := range toTokens {
|
||||
if _, ok := fromTokens[path]; !ok {
|
||||
result.Added = append(result.Added, path)
|
||||
}
|
||||
}
|
||||
|
||||
sortStrings(result.Added)
|
||||
sortStrings(result.Removed)
|
||||
sortSlice(result.Changed, func(a, b TokenDiff) bool { return a.Path < b.Path })
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// flattenForDiff reduces a token document to a path to rendered value map.
|
||||
//
|
||||
// The walk is deliberately local to the store rather than delegating to the
|
||||
// token engine: a diff must compare exactly what was stored, including tokens
|
||||
// the current engine schema no longer recognises, so that rolling back across a
|
||||
// schema change still shows a faithful comparison.
|
||||
func flattenForDiff(doc domain.TokenDoc) map[string]string {
|
||||
flat := make(map[string]string, 128)
|
||||
walkForDiff(doc, "", flat)
|
||||
return flat
|
||||
}
|
||||
|
||||
// walkForDiff recurses through a token group.
|
||||
func walkForDiff(node map[string]any, prefix string, out map[string]string) {
|
||||
for key, raw := range node {
|
||||
if strings.HasPrefix(key, "$") {
|
||||
continue
|
||||
}
|
||||
child, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path := key
|
||||
if prefix != "" {
|
||||
path = prefix + "." + key
|
||||
}
|
||||
if value, hasValue := child["$value"]; hasValue {
|
||||
out[path] = stringifyTokenValue(value)
|
||||
continue
|
||||
}
|
||||
walkForDiff(child, path, out)
|
||||
}
|
||||
}
|
||||
|
||||
// stringifyTokenValue renders a token value for comparison.
|
||||
func stringifyTokenValue(value any) string {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return v
|
||||
case float64:
|
||||
// JSON numbers decode as float64; format them without an exponent so
|
||||
// that 1 and 1.0 do not show up as a spurious change.
|
||||
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||
case bool:
|
||||
return strconv.FormatBool(v)
|
||||
case []any:
|
||||
parts := make([]string, len(v))
|
||||
for i, p := range v {
|
||||
parts[i] = stringifyTokenValue(p)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
default:
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- helpers --
|
||||
|
||||
// scanThemeRow reads one theme row from a scanner.
|
||||
func scanThemeRow(scanner interface{ Scan(...any) error }) (*themeRow, error) {
|
||||
var (
|
||||
row themeRow
|
||||
status string
|
||||
appearance string
|
||||
createdAt string
|
||||
updatedAt string
|
||||
)
|
||||
err := scanner.Scan(
|
||||
&row.ID, &row.Slug, &row.Name, &row.Description, &status, &appearance,
|
||||
&row.tokensJSON, &row.metaJSON, &row.isSystem, &row.Version,
|
||||
&createdAt, &updatedAt, &row.CreatedBy, &row.UpdatedBy,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row.Status = domain.ThemeStatus(status)
|
||||
row.DefaultAppearance = domain.Appearance(appearance)
|
||||
row.System = intToBool(row.isSystem)
|
||||
row.CreatedAt, err = parseTime(createdAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.UpdatedAt, err = parseTime(updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// theme materialises a scanned row into a domain theme.
|
||||
func (r *themeRow) theme() (*domain.Theme, error) {
|
||||
tokens := domain.TokenDoc{}
|
||||
if r.tokensJSON != "" {
|
||||
if err := json.Unmarshal([]byte(r.tokensJSON), &tokens); err != nil {
|
||||
return nil, fmt.Errorf("decode tokens for theme %s: %w", r.Slug, err)
|
||||
}
|
||||
}
|
||||
meta := domain.ThemeMeta{}
|
||||
if r.metaJSON != "" {
|
||||
if err := json.Unmarshal([]byte(r.metaJSON), &meta); err != nil {
|
||||
return nil, fmt.Errorf("decode meta for theme %s: %w", r.Slug, err)
|
||||
}
|
||||
}
|
||||
out := r.Theme
|
||||
out.Tokens = tokens
|
||||
out.Meta = meta
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// parseTime converts a stored timestamp back into a time.Time.
|
||||
func parseTime(raw string) (time.Time, error) {
|
||||
if raw == "" {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
parsed, err := time.Parse(domain.TimeFormat, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("parse timestamp %q: %w", raw, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// escapeLike neutralises LIKE wildcards in user input.
|
||||
func escapeLike(s string) string {
|
||||
replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
|
||||
// defaultMessage returns the supplied message or a generated one.
|
||||
func defaultMessage(message string, theme *domain.Theme, verb string) string {
|
||||
if trimmed := strings.TrimSpace(message); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return verb + " " + theme.Name
|
||||
}
|
||||
|
||||
// Now is exposed for the audit log, which shares the store's clock.
|
||||
func (s *Store) timestamp() string { return s.now().UTC().Format(domain.TimeFormat) }
|
||||
152
internal/store/user_preferences.go
Normal file
152
internal/store/user_preferences.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// Preferences returns what the subject has chosen for their own interface.
|
||||
//
|
||||
// A subject with no row, which is everybody until they save a preference once,
|
||||
// reads as empty rather than as an error: the zero value is the documented "no
|
||||
// choice made" state and every caller renders a default for it.
|
||||
//
|
||||
// A stored theme that has since been deleted or archived is reported as no
|
||||
// choice, for the same reason. Resolving the name against the themes table on
|
||||
// read keeps a stale preference from pinning the UI to something that no longer
|
||||
// exists, and it means an unpublish is reflected without a migration.
|
||||
func (s *Store) Preferences(ctx context.Context, subject string) (domain.UserPreferences, error) {
|
||||
if subject == "" {
|
||||
return domain.UserPreferences{}, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
|
||||
var stored domain.UserPreferences
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT selected_theme, selected_appearance
|
||||
FROM user_preferences
|
||||
WHERE subject = ?`, subject).
|
||||
Scan(&stored.SelectedTheme, &stored.SelectedAppearance)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.UserPreferences{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.UserPreferences{}, translateError(err)
|
||||
}
|
||||
|
||||
if stored.SelectedTheme != "" {
|
||||
usable, err := s.themeUsable(ctx, stored.SelectedTheme)
|
||||
if err != nil {
|
||||
return domain.UserPreferences{}, err
|
||||
}
|
||||
if !usable {
|
||||
return domain.UserPreferences{}, nil
|
||||
}
|
||||
}
|
||||
if stored.SelectedAppearance != "" && !stored.SelectedAppearance.Valid() {
|
||||
// A build that no longer knows an appearance must not hand it to a
|
||||
// client, which would set an attribute nothing styles.
|
||||
return domain.UserPreferences{}, nil
|
||||
}
|
||||
return stored, nil
|
||||
}
|
||||
|
||||
// SetPreferences records the subject's choice and returns what was stored.
|
||||
//
|
||||
// The theme must already exist and be published or archived. Drafts are rejected
|
||||
// because they are invisible to runtime consumers, so a preference pointing at one
|
||||
// would leave the UI rendering a theme it cannot load.
|
||||
func (s *Store) SetPreferences(ctx context.Context, subject string, prefs domain.UserPreferences) (domain.UserPreferences, error) {
|
||||
if subject == "" {
|
||||
return domain.UserPreferences{}, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
if err := prefs.Validate(); err != nil {
|
||||
return domain.UserPreferences{}, err
|
||||
}
|
||||
|
||||
if prefs.SelectedTheme != "" {
|
||||
usable, err := s.themeUsable(ctx, prefs.SelectedTheme)
|
||||
if err != nil {
|
||||
return domain.UserPreferences{}, err
|
||||
}
|
||||
if !usable {
|
||||
return domain.UserPreferences{}, fmt.Errorf(
|
||||
"%w: selectedTheme %q is not a published theme", domain.ErrInvalid, prefs.SelectedTheme)
|
||||
}
|
||||
} else {
|
||||
// An appearance without a theme has nothing to select a scheme from, so it
|
||||
// is dropped rather than stored. Clearing a choice by sending an empty
|
||||
// theme therefore works even if the client leaves the appearance set.
|
||||
prefs.SelectedAppearance = ""
|
||||
}
|
||||
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO user_preferences (subject, selected_theme, selected_appearance, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(subject) DO UPDATE SET
|
||||
selected_theme = excluded.selected_theme,
|
||||
selected_appearance = excluded.selected_appearance,
|
||||
updated_at = excluded.updated_at`,
|
||||
subject, prefs.SelectedTheme, string(prefs.SelectedAppearance), s.timestamp())
|
||||
if err != nil {
|
||||
return domain.UserPreferences{}, translateError(err)
|
||||
}
|
||||
return prefs, nil
|
||||
}
|
||||
|
||||
// ClearPreferences forgets the subject's choice, which is how the UI returns to
|
||||
// its own default. It reports whether a row was removed.
|
||||
func (s *Store) ClearPreferences(ctx context.Context, subject string) (bool, error) {
|
||||
if subject == "" {
|
||||
return false, fmt.Errorf("%w: subject must not be empty", domain.ErrInvalid)
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `DELETE FROM user_preferences WHERE subject = ?`, subject)
|
||||
if err != nil {
|
||||
return false, translateError(err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
// Drivers that cannot report this are not a reason to fail the request:
|
||||
// the row is gone either way, which is all the caller asked for.
|
||||
return true, nil //nolint:nilerr // see comment above
|
||||
}
|
||||
return affected > 0, nil
|
||||
}
|
||||
|
||||
// ThemePreferenceCount reports how many subjects have chosen a theme, which the
|
||||
// admin UI shows so an editor can see who a change would affect before making it.
|
||||
func (s *Store) ThemePreferenceCount(ctx context.Context, slug string) (int, error) {
|
||||
if err := domain.ValidateSlug(slug); err != nil {
|
||||
return 0, fmt.Errorf("%w: %s", domain.ErrInvalid, err.Error())
|
||||
}
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT count(*) FROM user_preferences WHERE selected_theme = ?`, slug).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, translateError(err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// themeUsable reports whether a slug names a theme a runtime consumer may load.
|
||||
// Archived themes qualify because the last published revision of an archived theme
|
||||
// stays resolvable for consumers pinned to it.
|
||||
func (s *Store) themeUsable(ctx context.Context, slug string) (bool, error) {
|
||||
var status string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT status FROM themes WHERE slug = ?`, slug).Scan(&status)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, translateError(err)
|
||||
}
|
||||
switch domain.ThemeStatus(status) {
|
||||
case domain.StatusPublished, domain.StatusArchived:
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
262
internal/store/user_preferences_test.go
Normal file
262
internal/store/user_preferences_test.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
func TestPreferencesDefaultToNoChoice(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// A subject nobody has saved anything for reads as empty rather than failing,
|
||||
// because empty is the documented "no choice" state.
|
||||
prefs, err := s.Preferences(ctx, "nobody")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if prefs.SelectedTheme != "" || prefs.SelectedAppearance != "" {
|
||||
t.Fatalf("preferences = %+v, want the zero value", prefs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferencesRoundTrip(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
published := publishTheme(t, s, "midnight")
|
||||
|
||||
stored, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: published.Slug,
|
||||
SelectedAppearance: domain.AppearanceDark,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("set preferences: %v", err)
|
||||
}
|
||||
if stored.SelectedTheme != "midnight" || stored.SelectedAppearance != domain.AppearanceDark {
|
||||
t.Fatalf("stored = %+v", stored)
|
||||
}
|
||||
|
||||
read, err := s.Preferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if read != stored {
|
||||
t.Fatalf("read = %+v, want %+v", read, stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferencesCanBeClearedAndOverwritten(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
publishTheme(t, s, "midnight")
|
||||
|
||||
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: "midnight",
|
||||
SelectedAppearance: domain.AppearanceLight,
|
||||
}); err != nil {
|
||||
t.Fatalf("set preferences: %v", err)
|
||||
}
|
||||
|
||||
// Writing again replaces rather than accumulating.
|
||||
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: "midnight",
|
||||
SelectedAppearance: domain.AppearanceDark,
|
||||
}); err != nil {
|
||||
t.Fatalf("overwrite preferences: %v", err)
|
||||
}
|
||||
read, err := s.Preferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if read.SelectedAppearance != domain.AppearanceDark {
|
||||
t.Fatalf("appearance = %q, want dark", read.SelectedAppearance)
|
||||
}
|
||||
|
||||
// An empty theme is the absence of a choice and is storable.
|
||||
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{}); err != nil {
|
||||
t.Fatalf("clear by writing empty: %v", err)
|
||||
}
|
||||
read, err = s.Preferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if read.SelectedTheme != "" {
|
||||
t.Fatalf("theme = %q, want empty", read.SelectedTheme)
|
||||
}
|
||||
|
||||
removed, err := s.ClearPreferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("clear preferences: %v", err)
|
||||
}
|
||||
if !removed {
|
||||
t.Error("clear reported no row removed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferencesRejectUnknownAndDraftThemes covers the two ways a name can fail:
|
||||
// it does not exist, or it exists but runtime consumers cannot load it.
|
||||
func TestPreferencesRejectUnknownAndDraftThemes(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Create(ctx, sampleTheme("draft-one"), ""); err != nil {
|
||||
t.Fatalf("create theme: %v", err)
|
||||
}
|
||||
|
||||
for _, slug := range []string{"no-such-theme", "draft-one"} {
|
||||
_, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: slug,
|
||||
SelectedAppearance: domain.AppearanceLight,
|
||||
})
|
||||
if !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Errorf("SetPreferences(%q) = %v, want ErrInvalid", slug, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferencesFallBackWhenTheThemeDisappears is the reason Preferences resolves
|
||||
// the name on read: a theme that is later deleted must not leave somebody pinned to
|
||||
// a selection the UI cannot load.
|
||||
func TestPreferencesFallBackWhenTheThemeDisappears(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
theme := publishTheme(t, s, "midnight")
|
||||
|
||||
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: theme.Slug,
|
||||
SelectedAppearance: domain.AppearanceDark,
|
||||
}); err != nil {
|
||||
t.Fatalf("set preferences: %v", err)
|
||||
}
|
||||
if err := s.Delete(ctx, theme.Slug); err != nil {
|
||||
t.Fatalf("delete theme: %v", err)
|
||||
}
|
||||
|
||||
read, err := s.Preferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if read.SelectedTheme != "" || read.SelectedAppearance != "" {
|
||||
t.Fatalf("preferences = %+v, want the zero value once the theme is gone", read)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferencesRejectMalformedInput(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
publishTheme(t, s, "midnight")
|
||||
|
||||
rejected := map[string]domain.UserPreferences{
|
||||
"uppercase slug": {SelectedTheme: "Midnight"},
|
||||
"slug with a space": {SelectedTheme: "mid night"},
|
||||
"leading dash": {SelectedTheme: "-midnight"},
|
||||
"unknown appearance": {
|
||||
SelectedTheme: "midnight",
|
||||
SelectedAppearance: "sepia",
|
||||
},
|
||||
}
|
||||
for name, prefs := range rejected {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := s.SetPreferences(ctx, "user-1", prefs); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Fatalf("SetPreferences(%+v) = %v, want ErrInvalid", prefs, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Clearing with an empty theme is valid, and an appearance sent alongside it
|
||||
// is dropped rather than stored as a scheme with nothing to apply it to.
|
||||
stored, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: "",
|
||||
SelectedAppearance: domain.AppearanceDark,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("clearing with a stray appearance: %v", err)
|
||||
}
|
||||
if stored.SelectedAppearance != "" {
|
||||
t.Fatalf("stored = %+v, want the appearance dropped", stored)
|
||||
}
|
||||
|
||||
if _, err := s.Preferences(ctx, ""); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Errorf("Preferences(\"\") = %v, want ErrInvalid", err)
|
||||
}
|
||||
if _, err := s.SetPreferences(ctx, "", domain.UserPreferences{}); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Errorf("SetPreferences with empty subject = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreferencesSurviveLogin matters because UpsertUser rewrites the users row on
|
||||
// every login, which is exactly why preferences are not columns on it.
|
||||
func TestPreferencesSurviveLogin(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
publishTheme(t, s, "midnight")
|
||||
|
||||
if _, err := s.SetPreferences(ctx, "user-1", domain.UserPreferences{
|
||||
SelectedTheme: "midnight",
|
||||
SelectedAppearance: domain.AppearanceDark,
|
||||
}); err != nil {
|
||||
t.Fatalf("set preferences: %v", err)
|
||||
}
|
||||
for range 2 {
|
||||
if err := s.UpsertUser(ctx, &domain.Identity{
|
||||
Subject: "user-1", Email: "u@example.test", Roles: []domain.Role{domain.RoleViewer},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
read, err := s.Preferences(ctx, "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("read preferences: %v", err)
|
||||
}
|
||||
if read.SelectedTheme != "midnight" || read.SelectedAppearance != domain.AppearanceDark {
|
||||
t.Fatalf("preferences = %+v, want them preserved across logins", read)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemePreferenceCount(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
publishTheme(t, s, "midnight")
|
||||
|
||||
for _, subject := range []string{"a", "b"} {
|
||||
if _, err := s.SetPreferences(ctx, subject, domain.UserPreferences{
|
||||
SelectedTheme: "midnight",
|
||||
SelectedAppearance: domain.AppearanceLight,
|
||||
}); err != nil {
|
||||
t.Fatalf("set preferences for %s: %v", subject, err)
|
||||
}
|
||||
}
|
||||
|
||||
count, err := s.ThemePreferenceCount(ctx, "midnight")
|
||||
if err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("count = %d, want 2", count)
|
||||
}
|
||||
if _, err := s.ThemePreferenceCount(ctx, "Not A Slug"); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Errorf("ThemePreferenceCount with a bad slug = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
// publishTheme creates a theme and publishes it, returning the stored value.
|
||||
//
|
||||
// StatusPublished is a constant rather than a variable, so the pointer
|
||||
// UpdateFields wants has to be taken from a local.
|
||||
func publishTheme(t *testing.T, s *Store, slug string) *domain.Theme {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := s.Create(ctx, sampleTheme(slug), ""); err != nil {
|
||||
t.Fatalf("create theme %s: %v", slug, err)
|
||||
}
|
||||
published := domain.StatusPublished
|
||||
theme, err := s.Update(ctx, slug, UpdateFields{Status: &published})
|
||||
if err != nil {
|
||||
t.Fatalf("publish theme %s: %v", slug, err)
|
||||
}
|
||||
return theme
|
||||
}
|
||||
426
internal/tokenengine/color.go
Normal file
426
internal/tokenengine/color.go
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RGB is an sRGB colour with an alpha channel in the range 0..1.
|
||||
type RGB struct {
|
||||
R, G, B float64
|
||||
A float64
|
||||
}
|
||||
|
||||
// Color is a parsed CSS colour. Only the subset that can be reasoned about
|
||||
// numerically is modelled; any other valid CSS colour syntax is kept verbatim
|
||||
// in Raw so that it can still be emitted, but comparisons and contrast
|
||||
// computations fall back to Neutral when it is not parseable.
|
||||
type Color struct {
|
||||
RGB
|
||||
// Raw is the original author-written token, preserved so that rendering
|
||||
// never rewrites what a human typed.
|
||||
Raw string
|
||||
// Numeric reports whether RGB is meaningful.
|
||||
Numeric bool
|
||||
}
|
||||
|
||||
// Neutral is the fallback colour used when a token cannot be parsed. It is a
|
||||
// mid grey: it is intentionally low-contrast against both light and dark
|
||||
// backgrounds so that a broken token is visible rather than silently plausible.
|
||||
var Neutral = Color{RGB: RGB{R: 0.5, G: 0.5, B: 0.5, A: 1}, Raw: "#80808080", Numeric: false}
|
||||
|
||||
// NeutralHex is the plain-text form of Neutral.
|
||||
const NeutralHex = "#808080"
|
||||
|
||||
const (
|
||||
// alphaEpsilon is the rounding tolerance used when snapping alpha to 0 or 1
|
||||
// so that #ffffff00 and #ffffff render identically.
|
||||
alphaEpsilon = 1e-6
|
||||
// channelEpsilon guards against float drift when comparing components.
|
||||
channelEpsilon = 1e-9
|
||||
)
|
||||
|
||||
// ParseColor parses the CSS colour syntaxes a theme author is realistically
|
||||
// going to use: #hex in 3/4/6/8 digit forms, rgb()/rgba(), hsl()/hsla() and the
|
||||
// functional-notation whitespace/comma variants. Anything else is returned as a
|
||||
// non-numeric colour that still round-trips.
|
||||
func ParseColor(input string) (Color, error) {
|
||||
raw := strings.TrimSpace(input)
|
||||
if raw == "" {
|
||||
return Color{}, fmt.Errorf("empty colour")
|
||||
}
|
||||
lower := strings.ToLower(raw)
|
||||
|
||||
// Modern CSS colour spaces are valid CSS but cannot be interpreted with
|
||||
// sRGB math. Pass them through untouched.
|
||||
for _, fn := range []string{"oklch(", "oklab(", "lab(", "lch(", "color(", "color-mix(", "light-dark("} {
|
||||
if strings.HasPrefix(lower, fn) {
|
||||
return Color{Raw: raw}, nil
|
||||
}
|
||||
}
|
||||
// CSS-wide keywords resolve to unpredictable values depending on the host
|
||||
// page; treating them as opaque keeps rendering faithful.
|
||||
if lower == "inherit" || lower == "initial" || lower == "unset" || lower == "currentcolor" || lower == "transparent" {
|
||||
return Color{Raw: raw}, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "#") {
|
||||
return parseHex(raw, lower[1:])
|
||||
}
|
||||
|
||||
open := strings.IndexByte(lower, '(')
|
||||
if open < 0 || !strings.HasSuffix(lower, ")") {
|
||||
return Color{Raw: raw}, nil
|
||||
}
|
||||
name := strings.TrimSpace(lower[:open])
|
||||
body := lower[open+1 : len(lower)-1]
|
||||
|
||||
args := splitArgs(body)
|
||||
switch name {
|
||||
case "rgb", "rgba":
|
||||
return parseRGBFunc(raw, args)
|
||||
case "hsl", "hsla":
|
||||
return parseHSLFunc(raw, args)
|
||||
default:
|
||||
return Color{Raw: raw}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseHex decodes #rgb, #rgba, #rrggbb and #rrggbbaa.
|
||||
func parseHex(raw, digits string) (Color, error) {
|
||||
expand := func(s string) (float64, error) {
|
||||
n, err := strconv.ParseUint(s, 16, 8)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return float64(n) / 255, nil
|
||||
}
|
||||
|
||||
switch len(digits) {
|
||||
case 3, 4:
|
||||
var comps [4]float64
|
||||
for i := 0; i < len(digits); i++ {
|
||||
v, err := expand(string(digits[i]) + string(digits[i]))
|
||||
if err != nil {
|
||||
return Color{}, fmt.Errorf("invalid hex colour %q", raw)
|
||||
}
|
||||
comps[i] = v
|
||||
}
|
||||
if len(digits) == 3 {
|
||||
comps[3] = 1
|
||||
}
|
||||
return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil
|
||||
case 6, 8:
|
||||
var comps [4]float64
|
||||
for i := 0; i < len(digits)/2; i++ {
|
||||
v, err := expand(digits[i*2 : i*2+2])
|
||||
if err != nil {
|
||||
return Color{}, fmt.Errorf("invalid hex colour %q", raw)
|
||||
}
|
||||
comps[i] = v
|
||||
}
|
||||
if len(digits) == 6 {
|
||||
comps[3] = 1
|
||||
}
|
||||
return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil
|
||||
default:
|
||||
return Color{}, fmt.Errorf("invalid hex colour %q: expected 3, 4, 6 or 8 digits, got %d", raw, len(digits))
|
||||
}
|
||||
}
|
||||
|
||||
// splitArgs splits a CSS function argument list on commas or whitespace,
|
||||
// ignoring separators nested inside parentheses.
|
||||
func splitArgs(body string) []string {
|
||||
var (
|
||||
out []string
|
||||
depth int
|
||||
cur strings.Builder
|
||||
)
|
||||
for _, r := range body {
|
||||
switch {
|
||||
case r == '(':
|
||||
depth++
|
||||
cur.WriteRune(r)
|
||||
case r == ')':
|
||||
depth--
|
||||
cur.WriteRune(r)
|
||||
case (r == ',' || r == '/' || r == ' ' || r == '\t') && depth == 0:
|
||||
if s := strings.TrimSpace(cur.String()); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
cur.Reset()
|
||||
default:
|
||||
cur.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if s := strings.TrimSpace(cur.String()); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseRGBFunc handles rgb(255 0 0 / 50%) and rgba(255, 0, 0, 0.5).
|
||||
func parseRGBFunc(raw string, args []string) (Color, error) {
|
||||
if len(args) < 3 || len(args) > 4 {
|
||||
return Color{}, fmt.Errorf("invalid rgb() colour %q: expected 3 or 4 components", raw)
|
||||
}
|
||||
var comps [4]float64
|
||||
for i := 0; i < 3; i++ {
|
||||
v, err := parseChannel(args[i])
|
||||
if err != nil {
|
||||
return Color{}, fmt.Errorf("invalid rgb() colour %q: %w", raw, err)
|
||||
}
|
||||
comps[i] = v
|
||||
}
|
||||
comps[3] = 1
|
||||
if len(args) == 4 {
|
||||
v, err := parseAlpha(args[3])
|
||||
if err != nil {
|
||||
return Color{}, fmt.Errorf("invalid rgb() colour %q: %w", raw, err)
|
||||
}
|
||||
comps[3] = v
|
||||
}
|
||||
return numericColor(comps[0], comps[1], comps[2], comps[3], raw), nil
|
||||
}
|
||||
|
||||
// parseHSLFunc handles both comma and space separated hsl notation.
|
||||
func parseHSLFunc(raw string, args []string) (Color, error) {
|
||||
if len(args) < 3 || len(args) > 4 {
|
||||
return Color{}, fmt.Errorf("invalid hsl() colour %q: expected 3 or 4 components", raw)
|
||||
}
|
||||
h, err := parseChannel(args[0])
|
||||
if err != nil {
|
||||
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
|
||||
}
|
||||
s, err := parseChannel(args[1])
|
||||
if err != nil {
|
||||
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
|
||||
}
|
||||
l, err := parseChannel(args[2])
|
||||
if err != nil {
|
||||
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
|
||||
}
|
||||
r, g, b := hslToRGB(h, s, l)
|
||||
a := 1.0
|
||||
if len(args) == 4 {
|
||||
v, err := parseAlpha(args[3])
|
||||
if err != nil {
|
||||
return Color{}, fmt.Errorf("invalid hsl() colour %q: %w", raw, err)
|
||||
}
|
||||
a = v
|
||||
}
|
||||
return numericColor(r, g, b, a, raw), nil
|
||||
}
|
||||
|
||||
// parseChannel accepts a bare number or a percentage and normalises to 0..1.
|
||||
func parseChannel(s string) (float64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasSuffix(s, "%") {
|
||||
v, err := strconv.ParseFloat(strings.TrimSuffix(s, "%"), 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return clamp01(v / 100), nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return clamp01(v / 255), nil
|
||||
}
|
||||
|
||||
// parseAlpha accepts a bare 0..1 number or a percentage.
|
||||
func parseAlpha(s string) (float64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasSuffix(s, "%") {
|
||||
v, err := strconv.ParseFloat(strings.TrimSuffix(s, "%"), 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return clamp01(v / 100), nil
|
||||
}
|
||||
v, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return clamp01(v), nil
|
||||
}
|
||||
|
||||
// numericColor clamps and normalises a parsed colour, snapping alpha that is
|
||||
// effectively 0 or 1 so that round-tripping is stable.
|
||||
func numericColor(r, g, b, a float64, raw string) Color {
|
||||
a = clamp01(a)
|
||||
if a < alphaEpsilon {
|
||||
a = 0
|
||||
}
|
||||
if a > 1-alphaEpsilon {
|
||||
a = 1
|
||||
}
|
||||
return Color{RGB: RGB{R: clamp01(r), G: clamp01(g), B: clamp01(b), A: a}, Raw: raw, Numeric: true}
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 { return math.Max(0, math.Min(1, v)) }
|
||||
|
||||
// hslToRGB converts normalised hue (0..1), saturation and lightness to sRGB.
|
||||
func hslToRGB(h, s, l float64) (float64, float64, float64) {
|
||||
if s <= channelEpsilon {
|
||||
return l, l, l
|
||||
}
|
||||
var q float64
|
||||
if l < 0.5 {
|
||||
q = l * (1 + s)
|
||||
} else {
|
||||
q = l + s - l*s
|
||||
}
|
||||
p := 2*l - q
|
||||
return hueToChannel(p, q, h+1.0/3.0), hueToChannel(p, q, h), hueToChannel(p, q, h-1.0/3.0)
|
||||
}
|
||||
|
||||
// hueToChannel is the standard hue-to-channel helper from the CSS Color spec.
|
||||
func hueToChannel(p, q, t float64) float64 {
|
||||
if t < 0 {
|
||||
t++
|
||||
}
|
||||
if t > 1 {
|
||||
t--
|
||||
}
|
||||
switch {
|
||||
case t < 1.0/6.0:
|
||||
return p + (q-p)*6*t
|
||||
case t < 1.0/2.0:
|
||||
return q
|
||||
case t < 2.0/3.0:
|
||||
return p + (q-p)*(2.0/3.0-t)*6
|
||||
default:
|
||||
return p
|
||||
}
|
||||
}
|
||||
|
||||
// Hex renders the colour as #rrggbb, or #rrggbbaa when translucent.
|
||||
func (c Color) Hex() string {
|
||||
if !c.Numeric {
|
||||
return c.Raw
|
||||
}
|
||||
r := int(math.Round(c.R * 255))
|
||||
g := int(math.Round(c.G * 255))
|
||||
b := int(math.Round(c.B * 255))
|
||||
if c.A >= 1-alphaEpsilon {
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
return fmt.Sprintf("#%02x%02x%02x%02x", r, g, b, int(math.Round(c.A*255)))
|
||||
}
|
||||
|
||||
// WithAlpha returns the colour with a replaced alpha channel.
|
||||
func (c Color) WithAlpha(a float64) Color {
|
||||
if !c.Numeric {
|
||||
return Color{Raw: c.Raw}
|
||||
}
|
||||
return numericColor(c.R, c.G, c.B, a, c.Raw)
|
||||
}
|
||||
|
||||
// Mix blends this colour toward other by t in 0..1, carrying alpha through the
|
||||
// CSS compositing rules so that translucent tokens do not darken incorrectly.
|
||||
func (c Color) Mix(other Color, t float64) Color {
|
||||
if !c.Numeric || !other.Numeric {
|
||||
return Neutral
|
||||
}
|
||||
t = clamp01(t)
|
||||
// Straight (non-premultiplied) alpha compositing.
|
||||
alpha := c.A*(1-t) + other.A*t
|
||||
if alpha < alphaEpsilon {
|
||||
return numericColor(0, 0, 0, 0, "")
|
||||
}
|
||||
r := (c.R*c.A*(1-t) + other.R*other.A*t) / alpha
|
||||
g := (c.G*c.A*(1-t) + other.G*other.A*t) / alpha
|
||||
b := (c.B*c.A*(1-t) + other.B*other.A*t) / alpha
|
||||
return numericColor(r, g, b, alpha, "")
|
||||
}
|
||||
|
||||
// Lighten mixes toward white by t.
|
||||
func (c Color) Lighten(t float64) Color {
|
||||
return c.Mix(Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}, t)
|
||||
}
|
||||
|
||||
// Darken mixes toward black by t.
|
||||
func (c Color) Darken(t float64) Color { return c.Mix(Color{RGB: RGB{A: 1}, Numeric: true}, t) }
|
||||
|
||||
// LightenDark adapts a lightening or darkening step for a dark colour scheme,
|
||||
// where "more prominent" means lighter rather than darker.
|
||||
func (c Color) LightenDark(t float64, dark bool) Color {
|
||||
if dark {
|
||||
return c.Lighten(t)
|
||||
}
|
||||
return c.Darken(t)
|
||||
}
|
||||
|
||||
// RelativeLuminance implements WCAG 2.1 relative luminance.
|
||||
func (c Color) RelativeLuminance() float64 {
|
||||
if !c.Numeric {
|
||||
return 0.5
|
||||
}
|
||||
return 0.2126*linearize(c.R) + 0.7152*linearize(c.G) + 0.0722*linearize(c.B)
|
||||
}
|
||||
|
||||
func linearize(v float64) float64 {
|
||||
if v <= 0.04045 {
|
||||
return v / 12.92
|
||||
}
|
||||
return math.Pow((v+0.055)/1.055, 2.4)
|
||||
}
|
||||
|
||||
// ContrastRatio returns the WCAG 2.1 contrast ratio between the two colours,
|
||||
// always >= 1.
|
||||
func ContrastRatio(a, b Color) float64 {
|
||||
la, lb := a.RelativeLuminance(), b.RelativeLuminance()
|
||||
if la < lb {
|
||||
la, lb = lb, la
|
||||
}
|
||||
return (la + 0.05) / (lb + 0.05)
|
||||
}
|
||||
|
||||
// WCAG contrast thresholds for normal-weight text.
|
||||
const (
|
||||
// ContrastAA is the WCAG 2.1 AA minimum for body text (4.5:1).
|
||||
ContrastAA = 4.5
|
||||
// ContrastAAA is the WCAG 2.1 AAA minimum for body text (7:1).
|
||||
ContrastAAA = 7.0
|
||||
// ContrastAALarge is the AA minimum for large text and UI components (3:1).
|
||||
ContrastAALarge = 3.0
|
||||
)
|
||||
|
||||
// BestForeground picks whichever of the light or dark anchor contrasts more
|
||||
// strongly with bg. This is how the engine derives readable "on-colour"
|
||||
// tokens without asking the author to maintain a second palette.
|
||||
func BestForeground(bg Color) Color {
|
||||
light := Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}
|
||||
dark := Color{RGB: RGB{A: 1}, Numeric: true}
|
||||
if ContrastRatio(bg, light) >= ContrastRatio(bg, dark) {
|
||||
return light
|
||||
}
|
||||
return dark
|
||||
}
|
||||
|
||||
// IsDark reports whether a colour is perceived as dark, using the WCAG
|
||||
// luminance threshold that matches ContrastRatio's midpoint.
|
||||
func IsDark(c Color) bool { return c.RelativeLuminance() < 0.4 }
|
||||
|
||||
// FormatNumber renders a float for CSS output: no trailing zeros, no
|
||||
// exponent for the small magnitudes we emit, and a hard cap on precision so
|
||||
// that generated CSS is byte-stable across platforms.
|
||||
func FormatNumber(v float64) string {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return "0"
|
||||
}
|
||||
rounded := math.Round(v*10000) / 10000
|
||||
if rounded == 0 {
|
||||
return "0"
|
||||
}
|
||||
s := strconv.FormatFloat(rounded, 'f', -1, 64)
|
||||
if strings.Contains(s, ".") {
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimSuffix(s, ".")
|
||||
}
|
||||
return s
|
||||
}
|
||||
216
internal/tokenengine/color_test.go
Normal file
216
internal/tokenengine/color_test.go
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseColorFormats(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want Color
|
||||
}{
|
||||
{"hex6", "#1a2b3c", Color{RGB: RGB{R: 0x1a / 255.0, G: 0x2b / 255.0, B: 0x3c / 255.0, A: 1}, Raw: "#1a2b3c", Numeric: true}},
|
||||
{"hex3", "#abc", Color{RGB: RGB{R: 0xaa / 255.0, G: 0xbb / 255.0, B: 0xcc / 255.0, A: 1}, Raw: "#abc", Numeric: true}},
|
||||
{"hex8 alpha", "#00000080", Color{RGB: RGB{R: 0, G: 0, B: 0, A: 128 / 255.0}, Raw: "#00000080", Numeric: true}},
|
||||
{"rgb legacy comma", "rgb(255, 0, 0)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "rgb(255, 0, 0)", Numeric: true}},
|
||||
{"rgb modern space", "rgb(255 0 0)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "rgb(255 0 0)", Numeric: true}},
|
||||
{"rgba alpha", "rgba(0, 0, 0, 0.5)", Color{RGB: RGB{A: 0.5}, Raw: "rgba(0, 0, 0, 0.5)", Numeric: true}},
|
||||
{"hsl", "hsl(0, 100%, 50%)", Color{RGB: RGB{R: 1, G: 0, B: 0, A: 1}, Raw: "hsl(0, 100%, 50%)", Numeric: true}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := ParseColor(tc.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseColor(%q) error: %v", tc.input, err)
|
||||
}
|
||||
if !got.Numeric {
|
||||
t.Fatalf("ParseColor(%q) returned a non-numeric colour", tc.input)
|
||||
}
|
||||
assertClose(t, got.R, tc.want.R, 0.002)
|
||||
assertClose(t, got.G, tc.want.G, 0.002)
|
||||
assertClose(t, got.B, tc.want.B, 0.002)
|
||||
assertClose(t, got.A, tc.want.A, 0.002)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseColorRejectsGarbage(t *testing.T) {
|
||||
for _, bad := range []string{"", "#", "#12", "#1234567", "rgb(1,2)", "#gggggg"} {
|
||||
if _, err := ParseColor(bad); err == nil {
|
||||
t.Errorf("ParseColor(%q) should have failed", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseColorPassesThroughModernSyntax(t *testing.T) {
|
||||
// These are valid CSS the engine cannot reason about numerically, but they
|
||||
// must round-trip rather than be rejected.
|
||||
for _, ok := range []string{"oklch(0.7 0.1 200)", "color(display-p3 1 0 0)", "light-dark(#fff, #000)", "currentColor", "transparent"} {
|
||||
got, err := ParseColor(ok)
|
||||
if err != nil {
|
||||
t.Errorf("ParseColor(%q) error: %v", ok, err)
|
||||
}
|
||||
if got.Numeric {
|
||||
t.Errorf("ParseColor(%q) should not be numeric", ok)
|
||||
}
|
||||
if got.Raw != ok {
|
||||
t.Errorf("ParseColor(%q).Raw = %q, want the original", ok, got.Raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContrastRatioMatchesWCAGReferenceValues(t *testing.T) {
|
||||
// Reference values from the WCAG 2.1 relative-luminance definition.
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want float64
|
||||
}{
|
||||
{"#ffffff", "#000000", 21},
|
||||
{"#ffffff", "#ffffff", 1},
|
||||
{"#000000", "#000000", 1},
|
||||
{"#777777", "#ffffff", 4.48},
|
||||
{"#767676", "#ffffff", 4.54}, // the canonical AA boundary grey
|
||||
}
|
||||
for _, tc := range cases {
|
||||
a, err := ParseColor(tc.a)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
b, err := ParseColor(tc.b)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
got := ContrastRatio(a, b)
|
||||
if math.Abs(got-tc.want) > 0.05 {
|
||||
t.Errorf("ContrastRatio(%s, %s) = %.3f, want %.3f", tc.a, tc.b, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBestForegroundPicksHigherContrast(t *testing.T) {
|
||||
light := Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}
|
||||
dark := Color{RGB: RGB{A: 1}, Numeric: true}
|
||||
|
||||
if got := BestForeground(Color{RGB: RGB{A: 1}, Numeric: true}); got.R != light.R {
|
||||
t.Errorf("foreground for black should be white, got %s", got.Hex())
|
||||
}
|
||||
if got := BestForeground(Color{RGB: RGB{R: 1, G: 1, B: 1, A: 1}, Numeric: true}); got.R != dark.R {
|
||||
t.Errorf("foreground for white should be black, got %s", got.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHexRoundTrip(t *testing.T) {
|
||||
for _, hex := range []string{"#000000", "#ffffff", "#4f46e5", "#4f46e580"} {
|
||||
c, err := ParseColor(hex)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
if got := c.Hex(); got != hex {
|
||||
t.Errorf("round trip of %q produced %q", hex, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMixProducesIntermediateColour(t *testing.T) {
|
||||
black, _ := ParseColor("#000000")
|
||||
white, _ := ParseColor("#ffffff")
|
||||
|
||||
mid := black.Mix(white, 0.5)
|
||||
if got := mid.Hex(); got != "#808080" && got != "#7f7f7f" {
|
||||
t.Errorf("50%% black/white mix = %s, want roughly #808080", got)
|
||||
}
|
||||
if got := black.Mix(white, 0).Hex(); got != "#000000" {
|
||||
t.Errorf("0%% mix = %s, want #000000", got)
|
||||
}
|
||||
if got := black.Mix(white, 1).Hex(); got != "#ffffff" {
|
||||
t.Errorf("100%% mix = %s, want #ffffff", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightenDarkInvertsDirectionForDarkSchemes(t *testing.T) {
|
||||
base, _ := ParseColor("#4f46e5")
|
||||
|
||||
light := base.LightenDark(0.1, false)
|
||||
dark := base.LightenDark(0.1, true)
|
||||
|
||||
if light.RelativeLuminance() >= base.RelativeLuminance() {
|
||||
t.Error("light scheme emphasis should reduce luminance")
|
||||
}
|
||||
if dark.RelativeLuminance() <= base.RelativeLuminance() {
|
||||
t.Error("dark scheme emphasis should increase luminance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRampIsOrderedAndComplete(t *testing.T) {
|
||||
ramp := GenerateRamp(DefaultRampOptions())
|
||||
|
||||
shades := StandardPaletteShades()
|
||||
if len(ramp) != len(shades) {
|
||||
t.Fatalf("ramp has %d steps, want %d", len(ramp), len(shades))
|
||||
}
|
||||
|
||||
previous := math.Inf(1)
|
||||
for _, shade := range shades {
|
||||
leaf, ok := ramp[shade].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("shade %s is not a token leaf", shade)
|
||||
}
|
||||
value, ok := leaf[ValueKey].(string)
|
||||
if !ok {
|
||||
t.Fatalf("shade %s has no string value", shade)
|
||||
}
|
||||
col, err := ParseColor(value)
|
||||
if err != nil {
|
||||
t.Fatalf("shade %s value %q is not a colour: %v", shade, value, err)
|
||||
}
|
||||
luminance := col.RelativeLuminance()
|
||||
if luminance > previous {
|
||||
t.Errorf("shade %s is lighter than the preceding shade", shade)
|
||||
}
|
||||
previous = luminance
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRampNormalisesOutOfRangeInput(t *testing.T) {
|
||||
opts := RampOptions{Hue: 400, Saturation: -20, Lightness: 999, HueSpread: 1000}.Normalize()
|
||||
|
||||
if opts.Hue < 0 || opts.Hue >= 360 {
|
||||
t.Errorf("hue = %f, want it wrapped into 0..360", opts.Hue)
|
||||
}
|
||||
if opts.Saturation != 0 {
|
||||
t.Errorf("saturation = %f, want it clamped to 0", opts.Saturation)
|
||||
}
|
||||
if opts.Lightness != 80 {
|
||||
t.Errorf("lightness = %f, want it clamped to 80", opts.Lightness)
|
||||
}
|
||||
if opts.HueSpread != 40 {
|
||||
t.Errorf("hueSpread = %f, want it clamped to 40", opts.HueSpread)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatNumberIsStable(t *testing.T) {
|
||||
cases := map[float64]string{
|
||||
0: "0",
|
||||
1: "1",
|
||||
1.5: "1.5",
|
||||
0.1250001: "0.125",
|
||||
-0.5: "-0.5",
|
||||
100: "100",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := FormatNumber(in); got != want {
|
||||
t.Errorf("FormatNumber(%v) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertClose fails the test when want and got differ by more than tolerance.
|
||||
func assertClose(t *testing.T, got, want, tolerance float64) {
|
||||
t.Helper()
|
||||
if math.Abs(got-want) > tolerance {
|
||||
t.Errorf("got %v, want %v (tolerance %v)", got, want, tolerance)
|
||||
}
|
||||
}
|
||||
311
internal/tokenengine/contract.go
Normal file
311
internal/tokenengine/contract.go
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// The two-attribute contract.
|
||||
//
|
||||
// Every application this service feeds has its own way of naming "which palette" and
|
||||
// "which appearance", and only two of them have both. Rather than ask each of them to
|
||||
// rename its call sites, the engine emits one contract and an alias layer under it:
|
||||
//
|
||||
// :root[data-theme='tokyo-night'][data-mode='dark'] {
|
||||
// --bn-bg: #1a1b26;
|
||||
// --bn-surface: #20222f;
|
||||
// /* ... the short names the applications already use ... */
|
||||
// }
|
||||
//
|
||||
// /* Compatibility: applications that already use these names keep working. */
|
||||
// :root[data-theme='tokyo-night'][data-mode='dark'] {
|
||||
// --bg: var(--bn-bg);
|
||||
// --surface: var(--bn-surface);
|
||||
// /* ... */
|
||||
// }
|
||||
//
|
||||
// Two attribute names are accepted, because two generations of applications exist. The
|
||||
// short pair (`data-theme`, `data-mode`) is what go-loose and go-mad already put on
|
||||
// <html>; the prefixed pair is what this engine has always emitted. A rule matching
|
||||
// either is the whole point: an application adopts the contract by adding one link.
|
||||
const (
|
||||
// ContractThemeAttribute is the short theme attribute.
|
||||
ContractThemeAttribute = "data-theme"
|
||||
// ContractModeAttribute is the short appearance attribute.
|
||||
ContractModeAttribute = "data-mode"
|
||||
)
|
||||
|
||||
// ContractMode is a storage key and DOM value for the appearance axis. It matches the
|
||||
// attribute so there is one word for the concept in the CSS, the DOM and localStorage.
|
||||
type ContractMode = domain.Appearance
|
||||
|
||||
// contractRole is one short name standing for a full token path.
|
||||
type contractRole struct {
|
||||
// Name is the short custom property, without the prefix.
|
||||
Name string
|
||||
// Path is the dotted token path whose value it carries.
|
||||
Path string
|
||||
// Fallback is emitted when the theme does not define Path, so the contract is
|
||||
// never silently incomplete. A missing alias makes an application render with an
|
||||
// inherited value from the host page, which is worse than an explicit default.
|
||||
Fallback string
|
||||
}
|
||||
|
||||
// contractRoles is the shared vocabulary. The names are chosen to match the tokens the
|
||||
// applications already use, so the alias layer below is a rename rather than a
|
||||
// translation.
|
||||
//
|
||||
// The colour roles are spelled out rather than derived, because a derived name
|
||||
// (`--bn-color-semantic-background` shortened by rule) would be guessable but not
|
||||
// predictable, and a consumer cannot be asked to remember which abbreviation applies.
|
||||
var contractRoles = []contractRole{
|
||||
// Surfaces, from the page background up to the most raised.
|
||||
{"bg", "color.semantic.background", "#ffffff"},
|
||||
{"surface", "color.semantic.surface", "#ffffff"},
|
||||
{"surface-raised", "color.semantic.surface-raised", "#f8fafc"},
|
||||
{"surface-sunken", "color.semantic.surface-sunken", "#eef2f6"},
|
||||
{"overlay", "color.semantic.overlay", "rgba(15, 23, 42, 0.5)"},
|
||||
|
||||
// Text, in descending emphasis.
|
||||
{"text", "color.semantic.text", "#0f172a"},
|
||||
{"muted", "color.semantic.text-muted", "#475569"},
|
||||
{"subtle", "color.semantic.text-subtle", "#64748b"},
|
||||
{"inverted", "color.semantic.text-inverted", "#ffffff"},
|
||||
|
||||
// Borders.
|
||||
{"border", "color.semantic.border", "#e2e8f0"},
|
||||
{"border-strong", "color.semantic.border-strong", "#cbd5e1"},
|
||||
|
||||
// The accent. Two names for one role, because "primary" is what a token author
|
||||
// calls it and "accent" is what a component author looks for.
|
||||
{"accent", "color.semantic.primary", "#2563eb"},
|
||||
{"accent-hover", "color.semantic.primary-hover", "#1d4ed8"},
|
||||
{"accent-active", "color.semantic.primary-active", "#1e40af"},
|
||||
{"on-accent", "color.semantic.on-primary", "#ffffff"},
|
||||
{"secondary", "color.semantic.secondary", "#e2e8f0"},
|
||||
{"on-secondary", "color.semantic.on-secondary", "#0f172a"},
|
||||
|
||||
// Status.
|
||||
{"good", "color.semantic.success", "#16a34a"},
|
||||
{"warning", "color.semantic.warning", "#d97706"},
|
||||
{"danger", "color.semantic.danger", "#dc2626"},
|
||||
{"info", "color.semantic.info", "#0284c7"},
|
||||
{"focus", "color.semantic.focus-ring", "#2563eb"},
|
||||
}
|
||||
|
||||
// contractFamilies are the token groups that are already short enough to alias
|
||||
// wholesale. Geometry, spacing and typography have no sensible per-role renaming, so
|
||||
// the whole group is re-exported under its own prefix rather than picked apart.
|
||||
var contractFamilies = []string{"radius", "space", "font", "shadow", "border.width", "duration", "ease", "zIndex", "opacity", "breakpoint"}
|
||||
|
||||
// legacyAliases are the unprefixed names the applications already use, mapped to the
|
||||
// contract name that replaces them.
|
||||
//
|
||||
// This is the compatibility half of the contract. Emitting it means an application
|
||||
// adopts the shared styling by adding one <link> and deleting its own :root block, with
|
||||
// no call site edited. Dropping it means the unprefixed custom properties stop existing
|
||||
// at all, which is a coordinated change across every application.
|
||||
var legacyAliases = map[string]string{
|
||||
"bg": "bg",
|
||||
"surface": "surface",
|
||||
"surface2": "surface-raised",
|
||||
"text": "text",
|
||||
"muted": "muted",
|
||||
"line": "border",
|
||||
"accent": "accent",
|
||||
"accent2": "secondary",
|
||||
"good": "good",
|
||||
"danger": "danger",
|
||||
}
|
||||
|
||||
// contractSelector returns the selector matching a theme and mode under either
|
||||
// attribute convention.
|
||||
//
|
||||
// The two groups are comma-separated rather than a compound selector, so an
|
||||
// application that uses one convention still gets the values.
|
||||
//
|
||||
// Each group appears twice: once rooted at :root, and once bare. The bare form is
|
||||
// what makes a palette adoptable by any element rather than only by the document
|
||||
// root, which is what a theme preview is. A preview has to sit next to the palette
|
||||
// it is previewing, inside a control that renders into a shadow root, where :root
|
||||
// matches nothing -- a stylesheet in the document does not select into a shadow tree
|
||||
// at all. Emitting the bare form lets the preview carry data-theme and pick the
|
||||
// palette up, so it shows the theme rather than a swatch of it.
|
||||
func contractSelector(slug string, mode domain.Appearance) string {
|
||||
return fmt.Sprintf(":root[%s=%s][%s=%s], :root[%s=%s][%s=%s], [%s=%s][%s=%s], [%s=%s][%s=%s]",
|
||||
ContractThemeAttribute, selectorValue(slug),
|
||||
ContractModeAttribute, selectorValue(string(mode)),
|
||||
ThemeAttribute, selectorValue(slug),
|
||||
AppearanceAttribute, selectorValue(string(mode)),
|
||||
ContractThemeAttribute, selectorValue(slug),
|
||||
ContractModeAttribute, selectorValue(string(mode)),
|
||||
ThemeAttribute, selectorValue(slug),
|
||||
AppearanceAttribute, selectorValue(string(mode)))
|
||||
}
|
||||
|
||||
// contractDefaultSelector matches a palette with no explicit appearance.
|
||||
//
|
||||
// This exists because requiring both attributes is a trap. An application that
|
||||
// declares only `data-theme` -- which is what go-tell did, and what any application
|
||||
// written before the appearance axis existed still does -- would match no rule at all
|
||||
// and render entirely on its fallbacks, with no error anywhere. Matching the palette
|
||||
// alone and treating the absence of an appearance as "the designer's choice" is what
|
||||
// the axis means anyway, and it is what the theme's own default appearance says.
|
||||
//
|
||||
// As with contractSelector, the bare form follows the :root one so a preview can
|
||||
// carry the palette without an appearance, and inherit the designer's choice with it.
|
||||
func contractDefaultSelector(slug string) string {
|
||||
return fmt.Sprintf(":root[%s=%s], :root[%s=%s], [%s=%s], [%s=%s]",
|
||||
ContractThemeAttribute, selectorValue(slug),
|
||||
ThemeAttribute, selectorValue(slug),
|
||||
ContractThemeAttribute, selectorValue(slug),
|
||||
ThemeAttribute, selectorValue(slug))
|
||||
}
|
||||
|
||||
// selectorValue quotes a value for use inside an attribute selector.
|
||||
func selectorValue(v string) string {
|
||||
return fmt.Sprintf("%q", v)
|
||||
}
|
||||
|
||||
// Contract renders the contract layer for one theme and appearance.
|
||||
//
|
||||
// It emits the short --bn-* names and the unprefixed aliases. It does not emit the
|
||||
// theme's own tokens: those are already in the stylesheet, and duplicating them would
|
||||
// double the payload for no gain. This is the layer that makes a long token path
|
||||
// reachable under a name a component author would guess.
|
||||
func Contract(theme *domain.Theme, mode domain.Appearance) string {
|
||||
doc := Resolve(theme, mode)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "@layer %s.base;\n", LayerName)
|
||||
writeContractRule(&b, contractSelector(theme.Slug, mode), doc)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ContractAll renders the contract layer for every theme at once, for both modes.
|
||||
//
|
||||
// This is what makes a theme selector instant. Selecting a palette becomes a change to
|
||||
// one attribute rather than a fetch, because every palette is already in the document.
|
||||
// The cost is that the file grows with the number of published themes, which is why it
|
||||
// is served separately from theme.css: a single-theme consumer should not download the
|
||||
// others.
|
||||
func ContractAll(themes []*domain.Theme) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "/*!\n * Go Bananas!! shared token contract\n")
|
||||
fmt.Fprintf(&b, " * %d theme(s), both appearances each.\n", len(themes))
|
||||
fmt.Fprintf(&b, " * Set %s and %s on <html>; nothing is fetched when you switch.\n",
|
||||
ContractThemeAttribute, ContractModeAttribute)
|
||||
fmt.Fprintf(&b, " */\n")
|
||||
fmt.Fprintf(&b, "@layer %s.base;\n", LayerName)
|
||||
|
||||
for _, theme := range themes {
|
||||
// The default first, then the explicit overrides. Order matters only as a
|
||||
// readability property: the mode selectors carry two attributes against the
|
||||
// default's one, so they win on specificity regardless of source order.
|
||||
def := theme.DefaultAppearance
|
||||
if def == "" {
|
||||
def = domain.AppearanceDark
|
||||
}
|
||||
writeContractRule(&b, contractDefaultSelector(theme.Slug), Resolve(theme, def))
|
||||
|
||||
for _, mode := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
|
||||
writeContractRule(&b, contractSelector(theme.Slug, mode), Resolve(theme, mode))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// writeContractRule writes one selector's declarations and its alias block.
|
||||
func writeContractRule(b *strings.Builder, selector string, doc *Doc) {
|
||||
decls := make([]string, 0, len(contractRoles)*2)
|
||||
|
||||
for _, role := range contractRoles {
|
||||
value := role.Fallback
|
||||
if token, ok := doc.Lookup(role.Path); ok && token.CSS != "" {
|
||||
value = token.CSS
|
||||
}
|
||||
decls = append(decls, VariablePrefix+"-"+role.Name+": "+value+";")
|
||||
}
|
||||
|
||||
// The families whose names are already short are re-exported rather than
|
||||
// re-derived, so `--bn-radius-md` is the same variable the theme defines and an
|
||||
// override in the theme's own stylesheet still wins.
|
||||
for _, family := range contractFamilies {
|
||||
prefix := family + "."
|
||||
for _, token := range doc.Tokens {
|
||||
if !strings.HasPrefix(token.Path, prefix) {
|
||||
continue
|
||||
}
|
||||
name := VariablePrefix + "-" + strings.ReplaceAll(token.Path, ".", "-")
|
||||
if token.CSS != "" {
|
||||
decls = append(decls, name+": "+token.CSS+";")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(decls) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
b.WriteString(selector + " {\n")
|
||||
for _, d := range decls {
|
||||
b.WriteString(" " + d + "\n")
|
||||
}
|
||||
|
||||
// The alias block, in the same rule so it is impossible to emit one without the
|
||||
// other. Aliases reference the contract names rather than repeating the values, so
|
||||
// there is exactly one place a value is written.
|
||||
aliases := make([]string, 0, len(legacyAliases))
|
||||
for legacy, target := range legacyAliases {
|
||||
aliases = append(aliases, fmt.Sprintf("--%s: var(%s-%s);", legacy, VariablePrefix, target))
|
||||
}
|
||||
// Sorted, because a map iteration order would make the output differ between
|
||||
// builds and defeat any caching or diffing of this file.
|
||||
sortStrings(aliases)
|
||||
for _, a := range aliases {
|
||||
b.WriteString(" " + a + "\n")
|
||||
}
|
||||
|
||||
b.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
// sortStrings sorts in place. It exists so this file does not need to import sort for
|
||||
// one call.
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
||||
s[j], s[j-1] = s[j-1], s[j]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ContractManifestEntry describes one theme in the contract, for a selector to render
|
||||
// without having to parse CSS.
|
||||
type ContractManifestEntry struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Version int64 `json:"version"`
|
||||
// DefaultMode is the appearance the theme was designed for. It is what a
|
||||
// consumer shows before the user expresses a preference.
|
||||
DefaultMode string `json:"defaultMode"`
|
||||
}
|
||||
|
||||
// ContractManifest returns the entries a theme selector needs.
|
||||
func ContractManifest(themes []*domain.Theme) []ContractManifestEntry {
|
||||
out := make([]ContractManifestEntry, 0, len(themes))
|
||||
for _, t := range themes {
|
||||
mode := t.DefaultAppearance
|
||||
if mode == "" {
|
||||
mode = domain.AppearanceDark
|
||||
}
|
||||
out = append(out, ContractManifestEntry{
|
||||
Slug: t.Slug,
|
||||
Name: t.Name,
|
||||
Version: t.Version,
|
||||
DefaultMode: string(mode),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
82
internal/tokenengine/contract_test.go
Normal file
82
internal/tokenengine/contract_test.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// The aggregate contract is consumed two ways: as a stylesheet the application
|
||||
// links, which can only be styled from :root, and as a stylesheet a control adopts
|
||||
// into its own shadow root, where :root matches nothing because a stylesheet in the
|
||||
// document does not select into a shadow tree. The bare selector forms are what make
|
||||
// the second use possible, and a palette preview depends on them, so their absence is
|
||||
// a silent failure: the selector still lists every palette and every preview renders
|
||||
// in whatever the host's fallbacks happen to be.
|
||||
|
||||
// assertSelectorPair asserts that a selector list contains both halves of a pair as
|
||||
// distinct comma-separated entries.
|
||||
//
|
||||
// The assertion is on the joined pair rather than on each half alone, because one half
|
||||
// cannot tell the two forms apart: the string `[data-theme="acme"][data-mode="light"]`
|
||||
// occurs inside `:root[data-theme="acme"][data-mode="light"]`, so a bare Contains
|
||||
// reports a bare selector as present when only the rooted form was emitted. Joining
|
||||
// them is what discriminates, because in the rooted-only list the two halves are
|
||||
// separated by `:root`.
|
||||
func assertSelectorPair(t *testing.T, css, want string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(css, want) {
|
||||
t.Errorf("contract does not emit %q as a pair of entries, so a palette preview cannot adopt it", want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContractEmitsBareSelectorsForPreviewScope(t *testing.T) {
|
||||
theme := testTheme()
|
||||
theme.Slug = "acme"
|
||||
theme.DefaultAppearance = domain.AppearanceDark
|
||||
css := ContractAll([]*domain.Theme{theme})
|
||||
|
||||
// The palette on its own, so a preview that declares no appearance still adopts
|
||||
// the designer's choice.
|
||||
assertSelectorPair(t, css, `[data-theme="acme"], [data-bananas-theme="acme"]`)
|
||||
|
||||
// Each appearance, in both attribute conventions.
|
||||
assertSelectorPair(t, css, `[data-theme="acme"][data-mode="light"], [data-bananas-theme="acme"][data-bananas-appearance="light"]`)
|
||||
assertSelectorPair(t, css, `[data-theme="acme"][data-mode="dark"], [data-bananas-theme="acme"][data-bananas-appearance="dark"]`)
|
||||
|
||||
// The rooted forms have to survive: every application that links the contract
|
||||
// rather than adopting it styles itself from :root.
|
||||
assertSelectorPair(t, css, `:root[data-theme="acme"], :root[data-bananas-theme="acme"]`)
|
||||
assertSelectorPair(t, css, `:root[data-theme="acme"][data-mode="light"], :root[data-bananas-theme="acme"][data-bananas-appearance="light"]`)
|
||||
}
|
||||
|
||||
// TestContractBareSelectorsCarryTheSameValues guards against the two forms drifting
|
||||
// apart, which would mean a preview shows a different palette from the one applying
|
||||
// to the page. Both forms are emitted from the same declarations, so this is a
|
||||
// statement that the pairing is not an accident worth breaking.
|
||||
func TestContractBareSelectorsCarryTheSameValues(t *testing.T) {
|
||||
css := ContractAll([]*domain.Theme{testTheme()})
|
||||
|
||||
for _, want := range []string{
|
||||
`--bn-bg: #ffffff;`,
|
||||
`--bn-text: #0f172a;`,
|
||||
`--bn-accent: #4f46e5;`,
|
||||
} {
|
||||
if !strings.Contains(css, want) {
|
||||
t.Errorf("contract no longer emits %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestContractLegacyAliasesStillExist protects the compatibility promise: an
|
||||
// application that already uses the unprefixed names must keep working.
|
||||
func TestContractLegacyAliasesStillExist(t *testing.T) {
|
||||
css := ContractAll([]*domain.Theme{testTheme()})
|
||||
|
||||
for legacy := range legacyAliases {
|
||||
if !strings.Contains(css, "--"+legacy+":") {
|
||||
t.Errorf("legacy alias --%s is no longer emitted", legacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
617
internal/tokenengine/css.go
Normal file
617
internal/tokenengine/css.go
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// LayerName is the cascade layer the generated component styles are placed in.
|
||||
// Everything the engine emits is layered so that a consuming application's own
|
||||
// unlayered CSS always wins the cascade without needing !important.
|
||||
const LayerName = "bananas"
|
||||
|
||||
// RenderOptions controls stylesheet generation.
|
||||
type RenderOptions struct {
|
||||
// Slug scopes the theme to an attribute selector so that several themes can
|
||||
// coexist on one page. When empty the tokens are emitted on :root instead.
|
||||
Slug string
|
||||
// Appearance is the scheme to render as the default for the selector.
|
||||
Appearance domain.Appearance
|
||||
// ApplyToRoot also emits the tokens on :root, making the theme the page
|
||||
// default. Ignored when Slug is set unless ForceRoot is true.
|
||||
ApplyToRoot bool
|
||||
// ForceRoot emits :root rules even when Slug is set.
|
||||
ForceRoot bool
|
||||
// AutoDark adds a prefers-color-scheme: dark block that swaps in the dark
|
||||
// tokens when the slug selector is not explicitly pinned to light.
|
||||
AutoDark bool
|
||||
// Components appends the ready-made component layer.
|
||||
Components bool
|
||||
// Derive computes engine-supplied tokens (foreground pairs, interaction
|
||||
// states, tinted surfaces) for values the author did not define.
|
||||
Derive DeriveOptions
|
||||
// Minify strips comments and collapses whitespace.
|
||||
Minify bool
|
||||
// SourceURL appends a source comment pointing at the runtime URL the
|
||||
// stylesheet was generated from, which makes browser devtools navigable.
|
||||
SourceURL string
|
||||
}
|
||||
|
||||
// DeriveOptions selects which engine-computed token families to emit.
|
||||
type DeriveOptions struct {
|
||||
// Foreground adds `--bn-color-on-*` roles derived by WCAG contrast.
|
||||
Foreground bool
|
||||
// Interaction adds `-hover` and `-active` variants for interactive roles.
|
||||
Interaction bool
|
||||
// Tint adds subtle tinted surfaces derived from a role's own hue.
|
||||
Tint bool
|
||||
}
|
||||
|
||||
// DefaultDerive returns the derivation set used when a caller does not care.
|
||||
func DefaultDerive() DeriveOptions {
|
||||
return DeriveOptions{Foreground: true, Interaction: true, Tint: true}
|
||||
}
|
||||
|
||||
// RenderOptionsFrom builds options from a theme with sensible defaults for the
|
||||
// given delivery channel.
|
||||
func RenderOptionsFrom(theme *domain.Theme, appearance domain.Appearance) RenderOptions {
|
||||
return RenderOptions{
|
||||
Slug: theme.Slug,
|
||||
Appearance: appearance,
|
||||
Derive: DefaultDerive(),
|
||||
Components: true,
|
||||
}
|
||||
}
|
||||
|
||||
// pair is one CSS custom property declaration.
|
||||
type pair struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
// Render produces the stylesheet for a theme.
|
||||
func Render(theme *domain.Theme, opts RenderOptions) string {
|
||||
appearance := opts.Appearance
|
||||
if appearance == "" {
|
||||
appearance = theme.DefaultAppearance
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if !opts.Minify {
|
||||
writeHeader(&b, theme, opts, appearance)
|
||||
}
|
||||
|
||||
light := Resolve(theme, domain.AppearanceLight)
|
||||
dark := Resolve(theme, domain.AppearanceDark)
|
||||
|
||||
// The scoped rule carries the theme's *default* appearance, so that setting a
|
||||
// single attribute is enough to get the theme the author designed. The
|
||||
// alternate scheme is emitted under its own appearance attribute.
|
||||
base := light
|
||||
alternate := dark
|
||||
if appearance == domain.AppearanceDark {
|
||||
base, alternate = dark, light
|
||||
}
|
||||
baseDecls := renderDeclarations(base, opts, appearance)
|
||||
alternateDecls := renderDeclarations(alternate, opts, opposite(appearance))
|
||||
darkDecls := renderDeclarations(dark, opts, domain.AppearanceDark)
|
||||
|
||||
rootSelector := opts.ApplyToRoot || opts.ForceRoot || opts.Slug == ""
|
||||
scope := themeSelector(opts.Slug)
|
||||
|
||||
if !opts.Minify {
|
||||
fmt.Fprintf(&b, "@layer %s.base;\n", LayerName)
|
||||
}
|
||||
|
||||
// The default scheme.
|
||||
switch {
|
||||
case rootSelector && opts.Slug != "":
|
||||
// Emit on :root so the theme works with zero configuration, and again on
|
||||
// the scoped selector so a page can host several themes side by side.
|
||||
writeRule(&b, ":root", baseDecls, opts.Minify)
|
||||
writeRule(&b, scope, baseDecls, opts.Minify)
|
||||
case rootSelector:
|
||||
writeRule(&b, ":root", baseDecls, opts.Minify)
|
||||
default:
|
||||
writeRule(&b, scope, baseDecls, opts.Minify)
|
||||
}
|
||||
|
||||
// The alternate scheme, selected explicitly. This is what a consumer's theme
|
||||
// switcher sets, and it is the only selector needed to override the default.
|
||||
if opts.Slug != "" {
|
||||
writeRule(&b, darkScopeSelector(opts.Slug, opposite(appearance)), alternateDecls, opts.Minify)
|
||||
} else {
|
||||
writeRule(&b, ":root["+AppearanceAttribute+"="+string(opposite(appearance))+"], :root."+darkClass,
|
||||
alternateDecls, opts.Minify)
|
||||
}
|
||||
|
||||
// A `.dark` ancestor class covers the common Tailwind and shadcn convention
|
||||
// where dark mode is toggled by a class on <html> rather than an attribute.
|
||||
// It always means dark, regardless of the theme's default.
|
||||
if opts.Slug != "" && appearance != domain.AppearanceDark {
|
||||
fmt.Fprintf(&b, ".dark %s, %s.%s {\n", scope, scope, darkClass)
|
||||
writeDeclarations(&b, darkDecls, opts.Minify, "")
|
||||
b.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
if opts.AutoDark {
|
||||
writeAutoDark(&b, scope, darkDecls, opts.Minify)
|
||||
}
|
||||
|
||||
if opts.Components {
|
||||
writeComponents(&b, opts)
|
||||
}
|
||||
|
||||
if !opts.Minify && opts.SourceURL != "" {
|
||||
fmt.Fprintf(&b, "/*# sourceURL=%s */\n", opts.SourceURL)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n") + "\n"
|
||||
}
|
||||
|
||||
// opposite returns the other colour scheme.
|
||||
func opposite(a domain.Appearance) domain.Appearance {
|
||||
if a == domain.AppearanceDark {
|
||||
return domain.AppearanceLight
|
||||
}
|
||||
return domain.AppearanceDark
|
||||
}
|
||||
|
||||
// writeHeader emits a provenance comment. It is what makes a generated
|
||||
// stylesheet debuggable: it names the theme, version and generation time.
|
||||
func writeHeader(b *strings.Builder, theme *domain.Theme, opts RenderOptions, appearance domain.Appearance) {
|
||||
fmt.Fprintf(b, "/*!\n")
|
||||
fmt.Fprintf(b, " * Go Bananas!! theme: %s\n", theme.Name)
|
||||
fmt.Fprintf(b, " * slug: %s | version: %d | status: %s | appearance: %s\n",
|
||||
theme.Slug, theme.Version, theme.Status, appearance)
|
||||
fmt.Fprintf(b, " * variables are prefixed %q; override them in an unlayered rule to win the cascade\n", VariablePrefix)
|
||||
fmt.Fprintf(b, " * generated: %s\n", theme.UpdatedAt.UTC().Format(domain.TimeFormat))
|
||||
fmt.Fprintf(b, " */\n")
|
||||
}
|
||||
|
||||
// themeSelector returns the attribute selector that scopes a theme.
|
||||
func themeSelector(slug string) string {
|
||||
return fmt.Sprintf("[%s=%q]", ThemeAttribute, selectorSafe(slug))
|
||||
}
|
||||
|
||||
// darkScopeSelector returns the selector for the dark scheme of a scoped theme.
|
||||
func darkScopeSelector(slug string, appearance domain.Appearance) string {
|
||||
base := themeSelector(slug)
|
||||
return fmt.Sprintf("%s[%s=%s]", base, AppearanceAttribute, appearance)
|
||||
}
|
||||
|
||||
// writeAutoDark emits the prefers-color-scheme block. The `:not()` guard makes
|
||||
// the media query yield to an explicit appearance attribute so that a manual
|
||||
// override always beats the OS preference.
|
||||
func writeAutoDark(b *strings.Builder, scope string, decls []pair, minify bool) {
|
||||
b.WriteString("@media (prefers-color-scheme: dark) {\n")
|
||||
var selector string
|
||||
if scope == ":root" {
|
||||
selector = fmt.Sprintf(":root:not([%s=%s])", AppearanceAttribute, domain.AppearanceLight)
|
||||
} else {
|
||||
selector = fmt.Sprintf("%s:not([%s=%s]):not(.dark)", scope, AppearanceAttribute, domain.AppearanceLight)
|
||||
}
|
||||
inner := " "
|
||||
writeRuleIndent(b, selector, decls, minify, inner)
|
||||
b.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
// renderDeclarations builds the ordered declaration list for one appearance.
|
||||
func renderDeclarations(doc *Doc, opts RenderOptions, appearance domain.Appearance) []pair {
|
||||
decls := make([]pair, 0, len(doc.Tokens)+32)
|
||||
seen := make(map[string]struct{}, len(doc.Tokens))
|
||||
|
||||
add := func(name, value string) {
|
||||
if _, dup := seen[name]; dup {
|
||||
return
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
decls = append(decls, pair{name: name, value: value})
|
||||
}
|
||||
|
||||
for _, t := range doc.Tokens {
|
||||
add(VariableName(t.Path), t.CSS)
|
||||
}
|
||||
|
||||
if opts.Derive.Foreground {
|
||||
for _, d := range deriveForeground(doc, appearance) {
|
||||
add(d.name, d.value)
|
||||
}
|
||||
}
|
||||
if opts.Derive.Interaction {
|
||||
for _, d := range deriveInteraction(doc, appearance) {
|
||||
add(d.name, d.value)
|
||||
}
|
||||
}
|
||||
if opts.Derive.Tint {
|
||||
for _, d := range deriveTints(doc, appearance) {
|
||||
add(d.name, d.value)
|
||||
}
|
||||
}
|
||||
return decls
|
||||
}
|
||||
|
||||
// deriveForeground produces the `--bn-color-on-*` family. For every palette
|
||||
// step and every semantic role that is a solid colour, it picks black or white
|
||||
// whichever has the higher WCAG contrast ratio. Authors who want an explicit
|
||||
// value simply define it: derived names are only emitted when absent.
|
||||
func deriveForeground(doc *Doc, appearance domain.Appearance) []pair {
|
||||
out := make([]pair, 0, 48)
|
||||
for _, t := range doc.Tokens {
|
||||
if t.Kind != KindColor || !strings.HasPrefix(t.Path, "color.") {
|
||||
continue
|
||||
}
|
||||
// Skip roles that are not solid backgrounds and skip anything already
|
||||
// spelled `on-*`.
|
||||
rest := strings.TrimPrefix(t.Path, "color.")
|
||||
if strings.HasPrefix(rest, "on-") {
|
||||
continue
|
||||
}
|
||||
col, err := ParseColor(t.CSS)
|
||||
if err != nil || !col.Numeric || col.A < 0.5 {
|
||||
continue
|
||||
}
|
||||
if _, exists := doc.Lookup("color.on-" + rest); exists {
|
||||
continue
|
||||
}
|
||||
out = append(out, pair{
|
||||
name: VariableName("color.on-" + rest),
|
||||
value: BestForeground(col).Hex(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// interactionRatios are the mix steps used to derive hover and active states.
|
||||
// Hover moves 8% toward the foreground, active 16%: subtle enough to read as
|
||||
// the same colour, strong enough to be perceptible.
|
||||
var (
|
||||
hoverRatio = 0.08
|
||||
activeRatio = 0.16
|
||||
)
|
||||
|
||||
// interactiveRoles are the semantic roles that get state variants. They are the
|
||||
// ones a component stylesheet can reasonably apply :hover and :active to.
|
||||
var interactiveRoles = []string{
|
||||
"semantic.primary",
|
||||
"semantic.secondary",
|
||||
"semantic.accent",
|
||||
"semantic.success",
|
||||
"semantic.warning",
|
||||
"semantic.danger",
|
||||
"semantic.info",
|
||||
"semantic.surface",
|
||||
"semantic.surface-raised",
|
||||
}
|
||||
|
||||
// deriveInteraction produces `-hover`, `-active` and `-subtle` companions for
|
||||
// the interactive semantic roles that exist in the document.
|
||||
func deriveInteraction(doc *Doc, appearance domain.Appearance) []pair {
|
||||
out := make([]pair, 0, 48)
|
||||
for _, role := range interactiveRoles {
|
||||
t, ok := doc.Lookup("color." + role)
|
||||
if !ok || t.Kind != KindColor {
|
||||
continue
|
||||
}
|
||||
col, err := ParseColor(t.CSS)
|
||||
if err != nil || !col.Numeric {
|
||||
continue
|
||||
}
|
||||
// In a dark scheme the base colour is already light, so "emphasis" means
|
||||
// getting lighter, not darker.
|
||||
for _, suffix := range []struct {
|
||||
name string
|
||||
amount float64
|
||||
}{{"hover", hoverRatio}, {"active", activeRatio}} {
|
||||
path := "color." + role + "-" + suffix.name
|
||||
if _, exists := doc.Lookup(path); exists {
|
||||
continue
|
||||
}
|
||||
out = append(out, pair{
|
||||
name: VariableName(path),
|
||||
value: col.LightenDark(suffix.amount, appearance == domain.AppearanceDark).Hex(),
|
||||
})
|
||||
}
|
||||
if _, exists := doc.Lookup("color." + role + "-subtle"); !exists {
|
||||
out = append(out, pair{
|
||||
name: VariableName("color." + role + "-subtle"),
|
||||
value: col.WithAlpha(0.12).Hex(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tintRoles are the solid roles that get very light/dark companions for badge,
|
||||
// callout and hover-row backgrounds.
|
||||
var tintRoles = []string{
|
||||
"semantic.primary",
|
||||
"semantic.success",
|
||||
"semantic.warning",
|
||||
"semantic.danger",
|
||||
"semantic.info",
|
||||
}
|
||||
|
||||
// deriveTints mixes a role toward its scheme's background, producing a surface
|
||||
// that reads as "this role, but quiet".
|
||||
func deriveTints(doc *Doc, appearance domain.Appearance) []pair {
|
||||
background, ok := doc.Lookup("color.semantic.background")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
bg, err := ParseColor(background.CSS)
|
||||
if err != nil || !bg.Numeric {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]pair, 0, 16)
|
||||
for _, role := range tintRoles {
|
||||
t, exists := doc.Lookup("color." + role)
|
||||
if !exists || t.Kind != KindColor {
|
||||
continue
|
||||
}
|
||||
col, err := ParseColor(t.CSS)
|
||||
if err != nil || !col.Numeric {
|
||||
continue
|
||||
}
|
||||
for _, suffix := range []struct {
|
||||
name string
|
||||
amount float64
|
||||
}{{"tint", 0.14}, {"tint-strong", 0.26}} {
|
||||
path := "color." + role + "-" + suffix.name
|
||||
if _, exists := doc.Lookup(path); exists {
|
||||
continue
|
||||
}
|
||||
out = append(out, pair{
|
||||
name: VariableName(path),
|
||||
value: bg.Mix(col, suffix.amount).Hex(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeRule emits a complete CSS rule.
|
||||
func writeRule(b *strings.Builder, selector string, decls []pair, minify bool) {
|
||||
writeRuleIndent(b, selector, decls, minify, "")
|
||||
}
|
||||
|
||||
// writeRuleIndent emits a CSS rule with every line prefixed by indent.
|
||||
func writeRuleIndent(b *strings.Builder, selector string, decls []pair, minify bool, indent string) {
|
||||
if len(decls) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString(indent)
|
||||
b.WriteString(selector)
|
||||
b.WriteString(" {\n")
|
||||
writeDeclarations(b, decls, minify, indent+" ")
|
||||
b.WriteString(indent)
|
||||
b.WriteString("}\n")
|
||||
if !minify {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// writeDeclarations emits the body of a rule. In minified mode the whole body is
|
||||
// written on one line with no indentation, which is what a minifier would
|
||||
// produce and keeps the output byte-identical to what a CDN would serve.
|
||||
func writeDeclarations(b *strings.Builder, decls []pair, minify bool, indent string) {
|
||||
if minify {
|
||||
indent = ""
|
||||
}
|
||||
for _, d := range decls {
|
||||
b.WriteString(indent)
|
||||
b.WriteString(d.name)
|
||||
b.WriteString(":")
|
||||
if !minify {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteString(d.value)
|
||||
b.WriteString(";")
|
||||
if !minify {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeComponents appends the ready-made component layer. It is intentionally
|
||||
// small: enough for a host application to look correct immediately, expressed
|
||||
// entirely in terms of the generated variables so that re-theming is free.
|
||||
func writeComponents(b *strings.Builder, opts RenderOptions) {
|
||||
fmt.Fprintf(b, "@layer %s.components {\n", LayerName)
|
||||
componentCSS(b, opts.Minify)
|
||||
b.WriteString("}\n")
|
||||
}
|
||||
|
||||
// componentRules is the body of the component layer. It uses only generated
|
||||
// variables plus layout primitives that carry no colour of their own.
|
||||
const componentRules = `
|
||||
.bn-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--bn-space-2, 0.5rem);
|
||||
padding: var(--bn-space-2, 0.5rem) var(--bn-space-4, 1rem);
|
||||
border: var(--bn-border-width-default, 1px) solid transparent;
|
||||
border-radius: var(--bn-radius-md, 0.375rem);
|
||||
background: var(--bn-color-semantic-primary, currentColor);
|
||||
color: var(--bn-color-on-primary, #fff);
|
||||
font-family: var(--bn-font-family-sans, system-ui, sans-serif);
|
||||
font-size: var(--bn-font-size-sm, 0.875rem);
|
||||
font-weight: var(--bn-font-weight-medium, 500);
|
||||
line-height: var(--bn-line-height-normal, 1.5);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background-color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease),
|
||||
color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease),
|
||||
border-color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease);
|
||||
}
|
||||
.bn-btn:hover { background: var(--bn-color-semantic-primary-hover, var(--bn-color-semantic-primary)); }
|
||||
.bn-btn:active { background: var(--bn-color-semantic-primary-active, var(--bn-color-semantic-primary)); }
|
||||
.bn-btn:focus-visible {
|
||||
outline: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-focus-ring, currentColor);
|
||||
outline-offset: var(--bn-space-1, 0.25rem);
|
||||
}
|
||||
.bn-btn[disabled] { opacity: var(--bn-opacity-disabled, 0.5); cursor: not-allowed; }
|
||||
.bn-btn--secondary {
|
||||
background: var(--bn-color-semantic-secondary, transparent);
|
||||
color: var(--bn-color-semantic-text, inherit);
|
||||
border-color: var(--bn-color-semantic-border, currentColor);
|
||||
}
|
||||
.bn-btn--secondary:hover { background: var(--bn-color-semantic-secondary-hover, transparent); }
|
||||
.bn-btn--ghost { background: transparent; color: var(--bn-color-semantic-primary, inherit); }
|
||||
.bn-btn--ghost:hover { background: var(--bn-color-semantic-primary-subtle, transparent); }
|
||||
.bn-btn--danger { background: var(--bn-color-semantic-danger, currentColor); color: var(--bn-color-on-danger, #fff); }
|
||||
.bn-btn--sm { padding: var(--bn-space-1, 0.25rem) var(--bn-space-3, 0.75rem); font-size: var(--bn-font-size-xs, 0.75rem); }
|
||||
.bn-btn--lg { padding: var(--bn-space-3, 0.75rem) var(--bn-space-6, 1.5rem); font-size: var(--bn-font-size-md, 1rem); }
|
||||
|
||||
.bn-card {
|
||||
background: var(--bn-color-semantic-surface, Canvas);
|
||||
color: var(--bn-color-semantic-text, CanvasText);
|
||||
border: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, transparent);
|
||||
border-radius: var(--bn-radius-lg, 0.5rem);
|
||||
box-shadow: var(--bn-shadow-sm, none);
|
||||
padding: var(--bn-space-6, 1.5rem);
|
||||
font-family: var(--bn-font-family-sans, system-ui, sans-serif);
|
||||
}
|
||||
.bn-card--raised { background: var(--bn-color-semantic-surface-raised, Canvas); box-shadow: var(--bn-shadow-md, none); }
|
||||
.bn-card__header { margin-bottom: var(--bn-space-4, 1rem); font-size: var(--bn-font-size-lg, 1.125rem); font-weight: var(--bn-font-weight-semibold, 600); }
|
||||
.bn-card__body { font-size: var(--bn-font-size-sm, 0.875rem); color: var(--bn-color-semantic-text-muted, inherit); }
|
||||
|
||||
.bn-input,
|
||||
.bn-select,
|
||||
.bn-textarea {
|
||||
width: 100%;
|
||||
padding: var(--bn-space-2, 0.5rem) var(--bn-space-3, 0.75rem);
|
||||
border: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, currentColor);
|
||||
border-radius: var(--bn-radius-md, 0.375rem);
|
||||
background: var(--bn-color-semantic-background, Canvas);
|
||||
color: var(--bn-color-semantic-text, CanvasText);
|
||||
font-family: inherit;
|
||||
font-size: var(--bn-font-size-sm, 0.875rem);
|
||||
line-height: var(--bn-line-height-normal, 1.5);
|
||||
}
|
||||
.bn-input:focus-visible,
|
||||
.bn-select:focus-visible,
|
||||
.bn-textarea:focus-visible {
|
||||
outline: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-focus-ring, currentColor);
|
||||
outline-offset: var(--bn-space-1, 0.25rem);
|
||||
border-color: var(--bn-color-semantic-primary, currentColor);
|
||||
}
|
||||
.bn-input::placeholder { color: var(--bn-color-semantic-text-subtle, inherit); opacity: 1; }
|
||||
.bn-input:disabled { opacity: var(--bn-opacity-disabled, 0.5); cursor: not-allowed; }
|
||||
.bn-label { display: block; margin-bottom: var(--bn-space-1, 0.25rem); font-size: var(--bn-font-size-xs, 0.75rem); font-weight: var(--bn-font-weight-medium, 500); color: var(--bn-color-semantic-text-muted, inherit); }
|
||||
.bn-field { margin-bottom: var(--bn-space-4, 1rem); }
|
||||
.bn-field__error { margin-top: var(--bn-space-1, 0.25rem); font-size: var(--bn-font-size-xs, 0.75rem); color: var(--bn-color-semantic-danger, red); }
|
||||
|
||||
.bn-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--bn-space-1, 0.25rem);
|
||||
padding: var(--bn-space-1, 0.25rem) var(--bn-space-2, 0.5rem);
|
||||
border-radius: var(--bn-radius-full, 9999px);
|
||||
font-size: var(--bn-font-size-xs, 0.75rem);
|
||||
font-weight: var(--bn-font-weight-medium, 500);
|
||||
background: var(--bn-color-semantic-primary-subtle, transparent);
|
||||
color: var(--bn-color-semantic-primary, inherit);
|
||||
}
|
||||
.bn-badge--success { background: var(--bn-color-semantic-success-tint, transparent); color: var(--bn-color-semantic-success, inherit); }
|
||||
.bn-badge--warning { background: var(--bn-color-semantic-warning-tint, transparent); color: var(--bn-color-semantic-warning, inherit); }
|
||||
.bn-badge--danger { background: var(--bn-color-semantic-danger-tint, transparent); color: var(--bn-color-semantic-danger, inherit); }
|
||||
.bn-badge--neutral { background: var(--bn-color-semantic-surface-raised, transparent); color: var(--bn-color-semantic-text-muted, inherit); }
|
||||
|
||||
.bn-alert {
|
||||
display: flex;
|
||||
gap: var(--bn-space-3, 0.75rem);
|
||||
padding: var(--bn-space-4, 1rem);
|
||||
border-radius: var(--bn-radius-md, 0.375rem);
|
||||
border-left: var(--bn-border-width-thick, 3px) solid var(--bn-color-semantic-info, currentColor);
|
||||
background: var(--bn-color-semantic-info-tint, transparent);
|
||||
color: var(--bn-color-semantic-text, inherit);
|
||||
font-size: var(--bn-font-size-sm, 0.875rem);
|
||||
}
|
||||
.bn-alert--success { border-color: var(--bn-color-semantic-success, currentColor); background: var(--bn-color-semantic-success-tint, transparent); }
|
||||
.bn-alert--warning { border-color: var(--bn-color-semantic-warning, currentColor); background: var(--bn-color-semantic-warning-tint, transparent); }
|
||||
.bn-alert--danger { border-color: var(--bn-color-semantic-danger, currentColor); background: var(--bn-color-semantic-danger-tint, transparent); }
|
||||
|
||||
.bn-link { color: var(--bn-color-semantic-primary, currentColor); text-decoration: underline; text-underline-offset: var(--bn-space-1, 0.25rem); }
|
||||
.bn-link:hover { color: var(--bn-color-semantic-primary-hover, inherit); }
|
||||
|
||||
.bn-table { width: 100%; border-collapse: collapse; font-size: var(--bn-font-size-sm, 0.875rem); }
|
||||
.bn-table th { text-align: left; font-weight: var(--bn-font-weight-semibold, 600); color: var(--bn-color-semantic-text-muted, inherit); padding: var(--bn-space-2, 0.5rem) var(--bn-space-3, 0.75rem); border-bottom: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, transparent); }
|
||||
.bn-table td { padding: var(--bn-space-3, 0.75rem); border-bottom: var(--bn-border-width-thin, 1px) solid var(--bn-color-semantic-border, transparent); }
|
||||
.bn-table tbody tr:hover { background: var(--bn-color-semantic-surface-raised, transparent); }
|
||||
|
||||
.bn-stack { display: flex; flex-direction: column; gap: var(--bn-space-4, 1rem); }
|
||||
.bn-cluster { display: flex; flex-wrap: wrap; align-items: center; gap: var(--bn-space-2, 0.5rem); }
|
||||
.bn-divider { height: var(--bn-border-width-thin, 1px); background: var(--bn-color-semantic-border, transparent); border: 0; }
|
||||
`
|
||||
|
||||
// componentCSS writes the component layer, optionally stripped of comments and
|
||||
// blank lines.
|
||||
func componentCSS(b *strings.Builder, minify bool) {
|
||||
css := componentRules
|
||||
if minify {
|
||||
var out strings.Builder
|
||||
for _, line := range strings.Split(css, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
out.WriteString(trimmed)
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
css = out.String()
|
||||
}
|
||||
b.WriteString(css)
|
||||
}
|
||||
|
||||
// Render variant identifiers. A variant is a named, cacheable option set: the
|
||||
// stylesheet a browser fetches and the compact stylesheet a server-side
|
||||
// renderer inlines are different artefacts and must not share a cache entry.
|
||||
const (
|
||||
// VariantFull is the complete stylesheet: tokens, derived tokens and the
|
||||
// component layer.
|
||||
VariantFull = "full"
|
||||
// VariantTokens is tokens and derived tokens only, for applications that
|
||||
// ship their own components.
|
||||
VariantTokens = "tokens"
|
||||
// VariantMinified is the compact, comment-free stylesheet for production.
|
||||
VariantMinified = "minified"
|
||||
)
|
||||
|
||||
// VariantNames lists the render variants in a stable order.
|
||||
func VariantNames() []string { return []string{VariantFull, VariantTokens, VariantMinified} }
|
||||
|
||||
// Variant identifies the option set that produced a stylesheet. It is part of
|
||||
// the cache key so two variants can never be confused.
|
||||
func (o RenderOptions) Variant() string {
|
||||
switch {
|
||||
case o.Minify:
|
||||
return VariantMinified
|
||||
case o.Components:
|
||||
return VariantFull
|
||||
default:
|
||||
return VariantTokens
|
||||
}
|
||||
}
|
||||
|
||||
// OptionsForVariant returns the render options for a named variant.
|
||||
func OptionsForVariant(variant string, slug string, appearance domain.Appearance) RenderOptions {
|
||||
opts := RenderOptions{
|
||||
Slug: slug,
|
||||
Appearance: appearance,
|
||||
Derive: DefaultDerive(),
|
||||
}
|
||||
switch variant {
|
||||
case VariantMinified:
|
||||
opts.Minify = true
|
||||
opts.Components = true
|
||||
case VariantFull:
|
||||
opts.Components = true
|
||||
default:
|
||||
opts.Components = false
|
||||
}
|
||||
return opts
|
||||
}
|
||||
165
internal/tokenengine/json.go
Normal file
165
internal/tokenengine/json.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// ResolvedTheme is the JSON payload served to runtime consumers. It carries
|
||||
// both a flat variable map (what a browser or a design tool wants) and the
|
||||
// nested resolved document (what an application that maps tokens onto component
|
||||
// props wants), so consumers never have to choose.
|
||||
type ResolvedTheme struct {
|
||||
Theme ThemeRef `json:"theme"`
|
||||
Tokens domain.TokenDoc `json:"tokens"`
|
||||
Variable map[string]string `json:"variables"`
|
||||
Meta domain.ThemeMeta `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// ThemeRef is the identity block repeated in every runtime payload.
|
||||
type ThemeRef struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
Appearance domain.Appearance `json:"appearance"`
|
||||
DefaultAppearance domain.Appearance `json:"defaultAppearance"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// JSONPayload builds the runtime JSON payload for one appearance.
|
||||
func JSONPayload(theme *domain.Theme, appearance domain.Appearance) ResolvedTheme {
|
||||
if appearance == "" {
|
||||
appearance = theme.DefaultAppearance
|
||||
}
|
||||
doc := Resolve(theme, appearance)
|
||||
return ResolvedTheme{
|
||||
Theme: ThemeRef{
|
||||
Slug: theme.Slug,
|
||||
Name: theme.Name,
|
||||
Description: theme.Description,
|
||||
Version: theme.Version,
|
||||
Appearance: appearance,
|
||||
DefaultAppearance: theme.DefaultAppearance,
|
||||
UpdatedAt: theme.UpdatedAt.UTC().Format(domain.TimeFormat),
|
||||
},
|
||||
Tokens: resolvedDocument(doc),
|
||||
Variable: doc.Variables(),
|
||||
Meta: theme.Meta,
|
||||
}
|
||||
}
|
||||
|
||||
// resolvedDocument renders the resolved tokens back into nested form, keeping
|
||||
// the DTCG `$type`/`$value` shape so that the output can be fed straight into
|
||||
// any DTCG-aware tool.
|
||||
func resolvedDocument(doc *Doc) domain.TokenDoc {
|
||||
out := make(domain.TokenDoc, len(Groups)+1)
|
||||
for _, t := range doc.Tokens {
|
||||
insertResolved(out, strings.Split(t.Path, "."), t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// insertResolved places a token into the nested output document, creating the
|
||||
// intermediate group objects on the way down.
|
||||
func insertResolved(root domain.TokenDoc, segments []string, t FlatToken) {
|
||||
current := root
|
||||
for _, seg := range segments[:len(segments)-1] {
|
||||
child, ok := current[seg].(map[string]any)
|
||||
if !ok {
|
||||
child = map[string]any{}
|
||||
current[seg] = child
|
||||
}
|
||||
// Re-widen so that the next iteration can index it as a TokenDoc.
|
||||
current = child
|
||||
}
|
||||
leaf := map[string]any{ValueKey: t.Value, TypeKey: string(t.Kind)}
|
||||
if t.Description != "" {
|
||||
leaf[DescKey] = t.Description
|
||||
}
|
||||
current[segments[len(segments)-1]] = leaf
|
||||
}
|
||||
|
||||
// ExportDocument produces a portable, self-describing token document for a
|
||||
// theme. Unlike the runtime payload it keeps the appearance overrides intact,
|
||||
// because the point of an export is that it can be re-imported losslessly.
|
||||
func ExportDocument(theme *domain.Theme) domain.TokenDoc {
|
||||
out := domain.TokenDoc{SchemaKey: SchemaID}
|
||||
merged := make(domain.TokenDoc, len(theme.Tokens)+1)
|
||||
for k, v := range theme.Tokens {
|
||||
merged[k] = v
|
||||
}
|
||||
if _, ok := merged[SchemaKey]; !ok {
|
||||
out[SchemaKey] = SchemaID
|
||||
}
|
||||
delete(merged, SchemaKey)
|
||||
for k, v := range merged {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ManifestEntry describes one theme in the runtime manifest: the small payload
|
||||
// a loader needs in order to build a theme picker without fetching every theme.
|
||||
type ManifestEntry struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DefaultAppearance domain.Appearance `json:"defaultAppearance"`
|
||||
Version int64 `json:"version"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CSS string `json:"css"`
|
||||
Tokens string `json:"tokens"`
|
||||
}
|
||||
|
||||
// Manifest is the discovery document served from the runtime API.
|
||||
type Manifest struct {
|
||||
// APIVersion is the runtime contract version. Consumers should refuse to
|
||||
// load a manifest whose major version they do not understand.
|
||||
APIVersion string `json:"apiVersion"`
|
||||
// Default is the slug applied when a consumer does not choose.
|
||||
Default string `json:"default,omitempty"`
|
||||
// BaseURL is the absolute runtime base the entries are relative to.
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Themes []ManifestEntry `json:"themes"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// RuntimeAPIVersion is the current runtime contract version.
|
||||
const RuntimeAPIVersion = "1"
|
||||
|
||||
// Tags extracts theme tags from the opaque meta block. Tags are the one piece
|
||||
// of meta the engine understands, because the manifest needs them.
|
||||
func Tags(meta domain.ThemeMeta) []string {
|
||||
raw, ok := meta["tags"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch t := raw.(type) {
|
||||
case []string:
|
||||
return t
|
||||
case []any:
|
||||
out := make([]string, 0, len(t))
|
||||
for _, v := range t {
|
||||
if s, ok := v.(string); ok && strings.TrimSpace(s) != "" {
|
||||
out = append(out, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
case string:
|
||||
parts := strings.Split(t, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if s := strings.TrimSpace(p); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
147
internal/tokenengine/palette.go
Normal file
147
internal/tokenengine/palette.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// RampOptions describes a generated colour ramp. It is the programmatic form of
|
||||
// "here is a brand colour, give me the other ten shades", which the editor UI
|
||||
// exposes as a one-click palette builder and which the built-in themes use to
|
||||
// stay tonally consistent.
|
||||
type RampOptions struct {
|
||||
// Hue in degrees, 0..360. Defaults to 250 (indigo) when zero-value.
|
||||
Hue float64
|
||||
// Saturation at the mid steps, in percent, 0..100.
|
||||
Saturation float64
|
||||
// Lightness at step 500, in percent, 0..100.
|
||||
Lightness float64
|
||||
// HueSpread drifts the hue from the lightest to the darkest step. Modern
|
||||
// perceptual ramps lean warm at the light end and cool at the dark end;
|
||||
// a value of 12 reproduces that effect.
|
||||
HueSpread float64
|
||||
}
|
||||
|
||||
// DefaultRampOptions returns the ramp the palette builder starts from.
|
||||
func DefaultRampOptions() RampOptions {
|
||||
return RampOptions{Hue: 250, Saturation: 78, Lightness: 55, HueSpread: 12}
|
||||
}
|
||||
|
||||
// Normalize clamps the options into usable ranges and fills in defaults.
|
||||
func (o RampOptions) Normalize() RampOptions {
|
||||
out := o
|
||||
out.Hue = math.Mod(math.Mod(out.Hue, 360)+360, 360)
|
||||
out.Saturation = clampRange(out.Saturation, 0, 100)
|
||||
out.Lightness = clampRange(out.Lightness, 20, 80)
|
||||
out.HueSpread = clampRange(out.HueSpread, 0, 40)
|
||||
return out
|
||||
}
|
||||
|
||||
func clampRange(v, lo, hi float64) float64 {
|
||||
if math.IsNaN(v) {
|
||||
return lo
|
||||
}
|
||||
return math.Max(lo, math.Min(hi, v))
|
||||
}
|
||||
|
||||
// RampEndpoints are the lightness percentages used for shade 50 and shade 950.
|
||||
// They sit just short of pure white and pure black so that the lightest step is
|
||||
// still usable as a surface and the darkest still holds text.
|
||||
const (
|
||||
RampLightest = 97.0
|
||||
RampDarkest = 12.0
|
||||
)
|
||||
|
||||
// GenerateRamp builds a canonical 11-step colour ramp as token leaves.
|
||||
//
|
||||
// Lightness is interpolated along a curve rather than linearly: the middle steps
|
||||
// carry the overwhelming majority of UI usage (a button's resting colour is
|
||||
// step 500 or 600), so they get the most separation. Saturation tapers at both
|
||||
// ends because a fully saturated near-white or near-black reads as a fluorescent
|
||||
// smear rather than a tint.
|
||||
func GenerateRamp(opts RampOptions) map[string]any {
|
||||
opts = opts.Normalize()
|
||||
shades := StandardPaletteShades()
|
||||
out := make(map[string]any, len(shades))
|
||||
|
||||
for i, shade := range shades {
|
||||
pos := float64(i) / float64(len(shades)-1)
|
||||
|
||||
light := rampLightness(pos, opts.Lightness)
|
||||
sat := opts.Saturation * saturationEnvelope(pos)
|
||||
hue := opts.Hue + opts.HueSpread*(0.5-pos)
|
||||
|
||||
out[shade] = map[string]any{
|
||||
TypeKey: string(KindColor),
|
||||
ValueKey: HSLHex(hue, sat, light),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rampLightness returns the lightness percentage for a ramp position.
|
||||
//
|
||||
// The curve blends a full linear sweep across the ramp endpoints with a sweep
|
||||
// anchored on the caller's requested step-500 lightness. The anchored sweep
|
||||
// guarantees step 500 lands exactly where the caller asked, while the linear
|
||||
// term keeps the extremes reaching near-white and near-black instead of
|
||||
// bunching up in the middle.
|
||||
func rampLightness(pos, midLight float64) float64 {
|
||||
linear := RampLightest + (RampDarkest-RampLightest)*pos
|
||||
|
||||
// Anchored sweep: walk from midLight out to the nearest endpoint.
|
||||
var anchored float64
|
||||
if pos <= 0.5 {
|
||||
anchored = midLight + (RampLightest-midLight)*2*(0.5-pos)
|
||||
} else {
|
||||
anchored = midLight - (midLight-RampDarkest)*2*(pos-0.5)
|
||||
}
|
||||
return linear*0.35 + anchored*0.65
|
||||
}
|
||||
|
||||
// saturationEnvelope tapers saturation towards both ends of the ramp.
|
||||
func saturationEnvelope(pos float64) float64 {
|
||||
// 1 at the midpoint, ~0.5 at the extremes.
|
||||
return 0.5 + 0.5*math.Sin(math.Pi*pos)
|
||||
}
|
||||
|
||||
// HSLHex converts hue/saturation/lightness (hue in degrees, the others in
|
||||
// percent) to a hex colour.
|
||||
func HSLHex(hue, saturation, light float64) string {
|
||||
h := math.Mod(math.Mod(hue, 360)+360, 360) / 360
|
||||
s := clampRange(saturation, 0, 100) / 100
|
||||
l := clampRange(light, 0, 100) / 100
|
||||
r, g, b := hslToRGB(h, s, l)
|
||||
return fmt.Sprintf("#%02x%02x%02x", int(r*255+0.5), int(g*255+0.5), int(b*255+0.5))
|
||||
}
|
||||
|
||||
// RampStep returns the hex colour at one step of a generated ramp. It exists so
|
||||
// that callers such as the semantic-group builder can reference a concrete
|
||||
// colour without materialising the whole ramp.
|
||||
func RampStep(opts RampOptions, shade string) string {
|
||||
opts = opts.Normalize()
|
||||
shades := StandardPaletteShades()
|
||||
for i, s := range shades {
|
||||
if s == shade {
|
||||
pos := float64(i) / float64(len(shades)-1)
|
||||
light := rampLightness(pos, opts.Lightness)
|
||||
sat := opts.Saturation * saturationEnvelope(pos)
|
||||
hue := opts.Hue + opts.HueSpread*(0.5-pos)
|
||||
return HSLHex(hue, sat, light)
|
||||
}
|
||||
}
|
||||
// Unknown shade: fall back to the mid lightness rather than failing, so a
|
||||
// typo in a configuration file cannot produce an invalid colour.
|
||||
return HSLHex(opts.Hue, opts.Saturation, opts.Lightness)
|
||||
}
|
||||
|
||||
// RampShades lists the canonical shade steps.
|
||||
func RampShades() []string { return StandardPaletteShades() }
|
||||
|
||||
// ParseShade extracts the numeric step from a shade name, reporting whether it
|
||||
// is a plain number.
|
||||
func ParseShade(shade string) (int, bool) {
|
||||
n, err := strconv.Atoi(shade)
|
||||
return n, err == nil
|
||||
}
|
||||
508
internal/tokenengine/schema.go
Normal file
508
internal/tokenengine/schema.go
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
// Package tokenengine turns a Go Bananas!! design-token document into the
|
||||
// artefacts that browsers and applications consume: CSS custom properties,
|
||||
// resolved JSON tokens, and a flat variable map.
|
||||
//
|
||||
// The package is deliberately pure. It performs no I/O, holds no global state
|
||||
// and is safe for concurrent use, which lets the HTTP layer cache rendered
|
||||
// themes aggressively and pre-warm them at start-up.
|
||||
package tokenengine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// Kind is a token value type. The set mirrors the W3C Design Tokens Community
|
||||
// Group format so that exported JSON is portable to other token tooling.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindColor Kind = "color"
|
||||
KindDimension Kind = "dimension"
|
||||
KindFontFamily Kind = "fontFamily"
|
||||
KindFontSize Kind = "fontSize"
|
||||
KindFontWeight Kind = "fontWeight"
|
||||
KindLineHeight Kind = "lineHeight"
|
||||
KindLetterSpacing Kind = "letterSpacing"
|
||||
KindDuration Kind = "duration"
|
||||
KindCubicBezier Kind = "cubicBezier"
|
||||
KindNumber Kind = "number"
|
||||
KindShadow Kind = "shadow"
|
||||
KindFontStyle Kind = "fontStyle"
|
||||
)
|
||||
|
||||
// Valid reports whether k is a known kind.
|
||||
func (k Kind) Valid() bool {
|
||||
switch k {
|
||||
case KindColor, KindDimension, KindFontFamily, KindFontSize, KindFontWeight,
|
||||
KindLineHeight, KindLetterSpacing, KindDuration, KindCubicBezier,
|
||||
KindNumber, KindShadow, KindFontStyle:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// UnitSuffix is appended to raw numeric values of unit-bearing kinds when
|
||||
// rendering CSS, so a bare `4` in a `space` group becomes `4px`.
|
||||
func (k Kind) UnitSuffix() string {
|
||||
switch k {
|
||||
case KindDimension, KindFontSize, KindLetterSpacing:
|
||||
return "px"
|
||||
case KindDuration:
|
||||
return "ms"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ValueKey and TypeKey are the DTCG property names.
|
||||
const (
|
||||
ValueKey = "$value"
|
||||
TypeKey = "$type"
|
||||
// DescKey carries human documentation for a token.
|
||||
DescKey = "$description"
|
||||
// ExtensionsKey holds non-standard metadata that the engine ignores.
|
||||
ExtensionsKey = "$extensions"
|
||||
// SchemaKey identifies the token document format.
|
||||
SchemaKey = "$schema"
|
||||
// AppearancesKey holds per-appearance token overrides. It is the only
|
||||
// structural key that is not itself a token group.
|
||||
AppearancesKey = "$appearances"
|
||||
)
|
||||
|
||||
// SchemaID identifies the canonical document format.
|
||||
const SchemaID = "https://urpi.be/schemas/go-bananas/tokens/v1"
|
||||
|
||||
// VariablePrefix namespaces every custom property this engine emits so that a
|
||||
// consuming application can never collide with go-bananas' own CSS.
|
||||
const VariablePrefix = "--bn"
|
||||
|
||||
// ThemeAttribute is the data attribute that scopes a theme to a subtree.
|
||||
// Setting it on <html> or <body> is the supported way to apply a theme.
|
||||
const ThemeAttribute = "data-bananas-theme"
|
||||
|
||||
// AppearanceAttribute selects the colour scheme within a themed subtree.
|
||||
const AppearanceAttribute = "data-bananas-appearance"
|
||||
|
||||
// darkClass is the conventional class name used by Tailwind and most design
|
||||
// systems for "this subtree is dark". The generated CSS honours it in addition
|
||||
// to the explicit appearance attribute so that consumers do not have to choose.
|
||||
const darkClass = "dark"
|
||||
|
||||
// GroupSpec describes one top-level token group.
|
||||
type GroupSpec struct {
|
||||
// Name is the group key as it appears in the document.
|
||||
Name string
|
||||
// Kind is the default type applied to leaves that omit "$type".
|
||||
Kind Kind
|
||||
// Freeform allows an arbitrary number of nested levels below the group.
|
||||
Freeform bool
|
||||
// Required is the minimum depth: paths listed here must exist.
|
||||
Required []string
|
||||
// Recommended paths are validated when present but never required. They
|
||||
// drive warnings in the editor rather than hard failures.
|
||||
Recommended []string
|
||||
// Description is shown in the editor UI and emitted as $description.
|
||||
Description string
|
||||
}
|
||||
|
||||
// paletteShades is the canonical 11-step scale every generated palette defines.
|
||||
var paletteShades = []string{"50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"}
|
||||
|
||||
// StandardPaletteShades returns a copy of the canonical scale steps.
|
||||
func StandardPaletteShades() []string {
|
||||
out := make([]string, len(paletteShades))
|
||||
copy(out, paletteShades)
|
||||
return out
|
||||
}
|
||||
|
||||
// Groups is the canonical group registry, in the order the editor renders them.
|
||||
var Groups = []GroupSpec{
|
||||
{
|
||||
Name: "color",
|
||||
Kind: KindColor,
|
||||
Freeform: true,
|
||||
Description: "Raw palettes and semantic colour roles. Semantic roles are what components consume.",
|
||||
Recommended: []string{
|
||||
"palette.brand." + "500",
|
||||
"palette.brand.600",
|
||||
"palette.neutral.500",
|
||||
"palette.neutral.900",
|
||||
"semantic.background",
|
||||
"semantic.surface",
|
||||
"semantic.surface-raised",
|
||||
"semantic.border",
|
||||
"semantic.border-strong",
|
||||
"semantic.text",
|
||||
"semantic.text-muted",
|
||||
"semantic.text-subtle",
|
||||
"semantic.text-inverted",
|
||||
"semantic.primary",
|
||||
"semantic.primary-hover",
|
||||
"semantic.primary-active",
|
||||
"semantic.on-primary",
|
||||
"semantic.secondary",
|
||||
"semantic.accent",
|
||||
"semantic.on-accent",
|
||||
"semantic.success",
|
||||
"semantic.warning",
|
||||
"semantic.danger",
|
||||
"semantic.info",
|
||||
"semantic.overlay",
|
||||
"semantic.focus-ring",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "font",
|
||||
Kind: KindDimension,
|
||||
Freeform: true,
|
||||
Recommended: []string{"family.sans", "family.mono", "size.sm", "size.md", "size.lg", "size.xl", "weight.normal", "weight.medium", "weight.semibold", "weight.bold", "lineHeight.tight", "lineHeight.normal", "lineHeight.relaxed", "letterSpacing.tight", "letterSpacing.normal", "letterSpacing.wide"},
|
||||
},
|
||||
{
|
||||
Name: "space",
|
||||
Kind: KindDimension,
|
||||
Freeform: true,
|
||||
Recommended: []string{"0", "1", "2", "3", "4", "5", "6", "8", "10", "12", "16"},
|
||||
},
|
||||
{
|
||||
Name: "radius",
|
||||
Kind: KindDimension,
|
||||
Freeform: true,
|
||||
Recommended: []string{"none", "sm", "md", "lg", "xl", "full"},
|
||||
},
|
||||
{
|
||||
Name: "border",
|
||||
Kind: KindDimension,
|
||||
Freeform: true,
|
||||
Recommended: []string{"width.thin", "width.default", "width.thick", "color.default", "color.strong"},
|
||||
},
|
||||
{
|
||||
Name: "shadow",
|
||||
Kind: KindShadow,
|
||||
Freeform: true,
|
||||
Recommended: []string{"none", "sm", "md", "lg", "xl"},
|
||||
},
|
||||
{
|
||||
Name: "duration",
|
||||
Kind: KindDuration,
|
||||
Freeform: true,
|
||||
Recommended: []string{"instant", "fast", "normal", "slow"},
|
||||
},
|
||||
{
|
||||
Name: "ease",
|
||||
Kind: KindCubicBezier,
|
||||
Freeform: true,
|
||||
Recommended: []string{"linear", "standard", "entrance", "exit"},
|
||||
},
|
||||
{
|
||||
Name: "zIndex",
|
||||
Kind: KindNumber,
|
||||
Freeform: true,
|
||||
Recommended: []string{"base", "dropdown", "sticky", "overlay", "modal", "toast"},
|
||||
},
|
||||
{
|
||||
Name: "opacity",
|
||||
Kind: KindNumber,
|
||||
Freeform: true,
|
||||
Recommended: []string{"disabled", "muted", "strong"},
|
||||
},
|
||||
{
|
||||
Name: "breakpoint",
|
||||
Kind: KindDimension,
|
||||
Freeform: true,
|
||||
Recommended: []string{"sm", "md", "lg", "xl", "2xl"},
|
||||
},
|
||||
}
|
||||
|
||||
// ReservedKeys are document-level keys that are not token groups.
|
||||
var ReservedKeys = map[string]struct{}{
|
||||
SchemaKey: {},
|
||||
AppearancesKey: {},
|
||||
DescKey: {},
|
||||
ExtensionsKey: {},
|
||||
}
|
||||
|
||||
// LookupGroup returns the spec for a group name.
|
||||
func LookupGroup(name string) (GroupSpec, bool) {
|
||||
for _, g := range Groups {
|
||||
if g.Name == name {
|
||||
return g, true
|
||||
}
|
||||
}
|
||||
return GroupSpec{}, false
|
||||
}
|
||||
|
||||
// GroupNames returns the canonical group names in render order.
|
||||
func GroupNames() []string {
|
||||
out := make([]string, len(Groups))
|
||||
for i, g := range Groups {
|
||||
out[i] = g.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// paletteNames returns the palette identifiers defined in a document, sorted so
|
||||
// that generated CSS is byte-stable.
|
||||
func paletteNames(doc map[string]any) []string {
|
||||
colorGroup, ok := asMap(doc["color"])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var names []string
|
||||
for key := range colorGroup {
|
||||
if key == "semantic" {
|
||||
continue
|
||||
}
|
||||
if _, isGroup := asMap(colorGroup[key]); isGroup {
|
||||
names = append(names, key)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// violation is an internal validation finding.
|
||||
type violation struct {
|
||||
// Path is the dotted token path, e.g. "color.semantic.background".
|
||||
Path string
|
||||
// Severity separates blocking problems from style advice.
|
||||
Severity Severity
|
||||
// Message explains the problem in one sentence.
|
||||
Message string
|
||||
// Hint suggests a concrete fix.
|
||||
Hint string
|
||||
}
|
||||
|
||||
// Severity classifies a validation finding.
|
||||
type Severity string
|
||||
|
||||
const (
|
||||
// SeverityError blocks publication.
|
||||
SeverityError Severity = "error"
|
||||
// SeverityWarning does not block publication but should be addressed.
|
||||
SeverityWarning Severity = "warning"
|
||||
// SeverityInfo is purely advisory.
|
||||
SeverityInfo Severity = "info"
|
||||
)
|
||||
|
||||
// Issue is a single validation finding, safe to serialise straight to the UI.
|
||||
type Issue struct {
|
||||
Path string `json:"path"`
|
||||
Severity Severity `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
}
|
||||
|
||||
// ValidationResult aggregates every finding for a document.
|
||||
type ValidationResult struct {
|
||||
// Valid is true when there are no error-severity issues.
|
||||
Valid bool `json:"valid"`
|
||||
// TokenCount is the number of leaf tokens discovered.
|
||||
TokenCount int `json:"tokenCount"`
|
||||
// Issues is ordered by severity then path so output is deterministic.
|
||||
Issues []Issue `json:"issues"`
|
||||
}
|
||||
|
||||
// Errors returns only the blocking issues.
|
||||
func (v ValidationResult) Errors() []Issue {
|
||||
out := make([]Issue, 0, len(v.Issues))
|
||||
for _, i := range v.Issues {
|
||||
if i.Severity == SeverityError {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Warnings returns only the advisory issues.
|
||||
func (v ValidationResult) Warnings() []Issue {
|
||||
out := make([]Issue, 0, len(v.Issues))
|
||||
for _, i := range v.Issues {
|
||||
if i.Severity == SeverityWarning {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// severityRank orders severities for deterministic sorting.
|
||||
func severityRank(s Severity) int {
|
||||
switch s {
|
||||
case SeverityError:
|
||||
return 0
|
||||
case SeverityWarning:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// asMap extracts a nested object from an untyped token document.
|
||||
//
|
||||
// Both a plain map and a domain.TokenDoc are accepted. The distinction matters:
|
||||
// a type assertion to map[string]any fails for a named map type, so without this
|
||||
// second case every document handed to the engine as a domain.TokenDoc would be
|
||||
// silently reported as "not a group".
|
||||
func asMap(v any) (map[string]any, bool) {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
return t, true
|
||||
case domain.TokenDoc:
|
||||
return map[string]any(t), true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// keyAliases maps authored camelCase token keys onto their kebab-case CSS
|
||||
// equivalents. Only keys the DTCG spec spells in camelCase need an entry; the
|
||||
// mapping is an explicit allow-list rather than a generic camelCase splitter so
|
||||
// that a token genuinely named "brandDark" cannot be silently rewritten.
|
||||
var keyAliases = map[string]string{
|
||||
"lineHeight": "line-height",
|
||||
"fontSize": "font-size",
|
||||
"letterSpacing": "letter-spacing",
|
||||
"fontFamily": "font-family",
|
||||
"fontWeight": "font-weight",
|
||||
"cubicBezier": "cubic-bezier",
|
||||
"borderRadius": "border-radius",
|
||||
"zIndex": "z-index",
|
||||
"surfaceMuted": "surface-muted",
|
||||
"fontStyle": "font-style",
|
||||
"lineScale": "line-scale",
|
||||
"seedVersion": "seed-version",
|
||||
}
|
||||
|
||||
// normalizeKey applies the alias table to one authored token key.
|
||||
func normalizeKey(key string) string {
|
||||
if alias, ok := keyAliases[key]; ok {
|
||||
return alias
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
// isLeaf reports whether a node is a token leaf rather than a nested group.
|
||||
func isLeaf(node map[string]any) bool {
|
||||
_, hasValue := node[ValueKey]
|
||||
return hasValue
|
||||
}
|
||||
|
||||
// cssVarSegment normalises one path segment for use in a custom property name.
|
||||
func cssVarSegment(s string) string {
|
||||
s = normalizeKey(s)
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
prevDash := false
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
prevDash = false
|
||||
case r == '-' || r == '_' || r == ' ' || r == '.' || r == '/':
|
||||
// Collapse every separator class into a single dash so that
|
||||
// "line Height" and "line-height" cannot produce two variables.
|
||||
if !prevDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
prevDash = true
|
||||
}
|
||||
default:
|
||||
if !prevDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
prevDash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
// VariableName builds the CSS custom property for a dotted token path.
|
||||
func VariableName(path string) string {
|
||||
segments := strings.Split(path, ".")
|
||||
cleaned := make([]string, 0, len(segments))
|
||||
for _, s := range segments {
|
||||
if c := cssVarSegment(s); c != "" {
|
||||
cleaned = append(cleaned, c)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return VariablePrefix
|
||||
}
|
||||
return VariablePrefix + "-" + strings.Join(cleaned, "-")
|
||||
}
|
||||
|
||||
// selectorSafe converts a token path into something usable inside a CSS
|
||||
// attribute selector value.
|
||||
func selectorSafe(s string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
|
||||
return r
|
||||
default:
|
||||
return '-'
|
||||
}
|
||||
}, s)
|
||||
}
|
||||
|
||||
// NumberHint returns the canonical shade list, exposed for the editor's palette
|
||||
// builder so the UI and the validator never disagree.
|
||||
func NumberHint() []string { return StandardPaletteShades() }
|
||||
|
||||
// validateKindValue checks a single leaf value against its kind, returning an
|
||||
// error describing the problem or nil when the value is acceptable.
|
||||
func validateKindValue(kind Kind, value any) error {
|
||||
switch kind {
|
||||
case KindColor:
|
||||
s, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected a colour string, got %T", value)
|
||||
}
|
||||
if _, err := ParseColor(s); err != nil {
|
||||
return err
|
||||
}
|
||||
case KindDimension, KindFontSize, KindLetterSpacing:
|
||||
if err := validateCSSLength(value); err != nil {
|
||||
return err
|
||||
}
|
||||
case KindDuration:
|
||||
if err := validateCSSLength(value); err != nil {
|
||||
return err
|
||||
}
|
||||
case KindNumber, KindFontWeight, KindLineHeight:
|
||||
if err := validateNumeric(value); err != nil {
|
||||
return err
|
||||
}
|
||||
case KindCubicBezier:
|
||||
parts, ok := value.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected a 4-number array, got %T", value)
|
||||
}
|
||||
if len(parts) != 4 {
|
||||
return fmt.Errorf("expected 4 numbers, got %d", len(parts))
|
||||
}
|
||||
for i, p := range parts {
|
||||
if err := validateNumeric(p); err != nil {
|
||||
return fmt.Errorf("component %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
case KindShadow:
|
||||
if _, ok := value.(string); !ok {
|
||||
return fmt.Errorf("expected a CSS box-shadow string, got %T", value)
|
||||
}
|
||||
case KindFontFamily:
|
||||
if _, ok := value.(string); !ok {
|
||||
return fmt.Errorf("expected a font stack string, got %T", value)
|
||||
}
|
||||
case KindFontStyle:
|
||||
if _, ok := value.(string); !ok {
|
||||
return fmt.Errorf("expected a font style keyword, got %T", value)
|
||||
}
|
||||
default:
|
||||
// Unknown kinds are tolerated so that consumers can introduce their own
|
||||
// token types without the engine needing a release first.
|
||||
}
|
||||
return nil
|
||||
}
|
||||
751
internal/tokenengine/tokens.go
Normal file
751
internal/tokenengine/tokens.go
Normal file
|
|
@ -0,0 +1,751 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// FlatToken is one resolved token: its dotted path, its resolved type and the
|
||||
// exact CSS-ready value.
|
||||
type FlatToken struct {
|
||||
// Path is the dotted token path, e.g. "color.semantic.background".
|
||||
Path string
|
||||
// Kind is the resolved token type.
|
||||
Kind Kind
|
||||
// Value is the raw author-supplied value.
|
||||
Value any
|
||||
// CSS is the value as it appears in a custom property declaration.
|
||||
CSS string
|
||||
// Description is the token's $description, if any.
|
||||
Description string
|
||||
// Derived marks tokens computed by the engine rather than authored.
|
||||
Derived bool
|
||||
}
|
||||
|
||||
// Doc is a parsed, resolved token document for one appearance.
|
||||
type Doc struct {
|
||||
// Theme is the theme the document was derived from.
|
||||
Theme *domain.Theme
|
||||
// Appearance is the colour scheme that was resolved.
|
||||
Appearance domain.Appearance
|
||||
// Tokens are the leaves in deterministic path order.
|
||||
Tokens []FlatToken
|
||||
// byPath indexes Tokens for O(1) lookup.
|
||||
byPath map[string]FlatToken
|
||||
// palettes lists palette names discovered in the document.
|
||||
palettes []string
|
||||
}
|
||||
|
||||
// Lookup returns a token by dotted path.
|
||||
func (d *Doc) Lookup(path string) (FlatToken, bool) {
|
||||
t, ok := d.byPath[path]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// Palettes returns the palette names present in the document.
|
||||
func (d *Doc) Palettes() []string { return d.palettes }
|
||||
|
||||
// PalettesOf returns the shade steps defined for one palette, sorted so that
|
||||
// scale output is deterministic.
|
||||
func (d *Doc) PalettesOf(palette string) []string {
|
||||
var steps []string
|
||||
prefix := "color." + palette + "."
|
||||
for _, t := range d.Tokens {
|
||||
if strings.HasPrefix(t.Path, prefix) {
|
||||
steps = append(steps, strings.TrimPrefix(t.Path, prefix))
|
||||
}
|
||||
}
|
||||
sort.Slice(steps, func(i, j int) bool { return shadeLess(steps[i], steps[j]) })
|
||||
return steps
|
||||
}
|
||||
|
||||
// Variables renders the document as CSS custom property name to value.
|
||||
func (d *Doc) Variables() map[string]string {
|
||||
out := make(map[string]string, len(d.Tokens))
|
||||
for _, t := range d.Tokens {
|
||||
out[VariableName(t.Path)] = t.CSS
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VariableOrder returns the CSS variable names in deterministic order, which is
|
||||
// the order the generated stylesheet uses.
|
||||
func (d *Doc) VariableOrder() []string {
|
||||
out := make([]string, 0, len(d.Tokens))
|
||||
for _, t := range d.Tokens {
|
||||
out = append(out, VariableName(t.Path))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// shadeLess orders numeric shades numerically and anything else lexically, so
|
||||
// 50, 100, 200 ... 950 and custom names both come out sensibly.
|
||||
func shadeLess(a, b string) bool {
|
||||
an, aerr := strconv.Atoi(a)
|
||||
bn, berr := strconv.Atoi(b)
|
||||
if aerr == nil && berr == nil {
|
||||
return an < bn
|
||||
}
|
||||
if aerr == nil {
|
||||
return true
|
||||
}
|
||||
if berr == nil {
|
||||
return false
|
||||
}
|
||||
return a < b
|
||||
}
|
||||
|
||||
// Resolve merges the base token document with the overrides for the requested
|
||||
// appearance and returns a flat, ordered token list.
|
||||
//
|
||||
// Overrides are a partial document of the same shape as the base, deep-merged
|
||||
// one level at a time. A leaf in the override replaces the corresponding leaf
|
||||
// in the base; a nested object in the override merges with its counterpart.
|
||||
func Resolve(theme *domain.Theme, appearance domain.Appearance) *Doc {
|
||||
base := theme.Tokens.Clone()
|
||||
if base == nil {
|
||||
base = domain.TokenDoc{}
|
||||
}
|
||||
merged := applyOverrides(base, overridesFor(base, appearance))
|
||||
|
||||
doc := &Doc{
|
||||
Theme: theme,
|
||||
Appearance: appearance,
|
||||
byPath: make(map[string]FlatToken),
|
||||
palettes: paletteNames(merged),
|
||||
}
|
||||
collect(merged, "", nil, doc)
|
||||
sort.Slice(doc.Tokens, func(i, j int) bool { return doc.Tokens[i].Path < doc.Tokens[j].Path })
|
||||
for _, t := range doc.Tokens {
|
||||
doc.byPath[t.Path] = t
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// overridesFor extracts the override sub-document for one appearance.
|
||||
func overridesFor(doc domain.TokenDoc, appearance domain.Appearance) map[string]any {
|
||||
appearances, ok := asMap(doc[AppearancesKey])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
override, _ := asMap(appearances[string(appearance)])
|
||||
return override
|
||||
}
|
||||
|
||||
// applyOverrides deep-merges override into base, returning a new document.
|
||||
func applyOverrides(base domain.TokenDoc, override map[string]any) domain.TokenDoc {
|
||||
out := make(domain.TokenDoc, len(base))
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range override {
|
||||
if overrideMap, ok := asMap(v); ok {
|
||||
if baseMap, ok := asMap(out[k]); ok {
|
||||
out[k] = map[string]any(applyOverrides(domain.TokenDoc(baseMap), overrideMap))
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// inheritedKind carries the type down the tree so that a group may declare
|
||||
// "$type" once and have every leaf inherit it, as DTCG specifies.
|
||||
func collect(node map[string]any, prefix string, inherited *Kind, doc *Doc) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if isLeaf(node) {
|
||||
collectLeaf(node, prefix, inherited, doc)
|
||||
return
|
||||
}
|
||||
if raw, ok := node[TypeKey].(string); ok {
|
||||
k := Kind(raw)
|
||||
inherited = &k
|
||||
}
|
||||
keys := make([]string, 0, len(node))
|
||||
for k := range node {
|
||||
if strings.HasPrefix(k, "$") {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
child, ok := asMap(node[k])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
path := k
|
||||
if prefix != "" {
|
||||
path = prefix + "." + k
|
||||
}
|
||||
collect(child, path, inherited, doc)
|
||||
}
|
||||
}
|
||||
|
||||
// collectLeaf records a single token leaf.
|
||||
func collectLeaf(node map[string]any, path string, inherited *Kind, doc *Doc) {
|
||||
kind := Kind("")
|
||||
if inherited != nil {
|
||||
kind = *inherited
|
||||
}
|
||||
if raw, ok := node[TypeKey].(string); ok {
|
||||
kind = Kind(raw)
|
||||
}
|
||||
value := node[ValueKey]
|
||||
|
||||
if kind == "" {
|
||||
kind = inferKind(value)
|
||||
}
|
||||
if kind == "" {
|
||||
return
|
||||
}
|
||||
|
||||
t := FlatToken{
|
||||
Path: path,
|
||||
Kind: kind,
|
||||
Value: value,
|
||||
CSS: renderValue(kind, value),
|
||||
}
|
||||
if d, ok := node[DescKey].(string); ok {
|
||||
t.Description = d
|
||||
}
|
||||
doc.Tokens = append(doc.Tokens, t)
|
||||
doc.byPath[path] = t
|
||||
}
|
||||
|
||||
// inferKind guesses a leaf's type when neither the leaf nor an ancestor
|
||||
// declared one. Guessing keeps hand-written documents usable; validation still
|
||||
// reports the inferred type so authors can make it explicit.
|
||||
func inferKind(v any) Kind {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if _, err := ParseColor(t); err == nil && strings.HasPrefix(strings.TrimSpace(t), "#") {
|
||||
return KindColor
|
||||
}
|
||||
if strings.ContainsAny(t, "()") {
|
||||
return KindShadow
|
||||
}
|
||||
return KindFontFamily
|
||||
case float64, int, json.Number:
|
||||
return KindNumber
|
||||
case []any:
|
||||
return KindCubicBezier
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// renderValue converts a token value into its CSS form, appending the
|
||||
// canonical unit for unit-bearing kinds when the author omitted it.
|
||||
func renderValue(kind Kind, value any) string {
|
||||
switch kind {
|
||||
case KindDimension, KindFontSize, KindLetterSpacing, KindDuration:
|
||||
if n, ok := toFloat(value); ok {
|
||||
return FormatNumber(n) + kind.UnitSuffix()
|
||||
}
|
||||
case KindNumber, KindFontWeight, KindLineHeight:
|
||||
if n, ok := toFloat(value); ok {
|
||||
return FormatNumber(n)
|
||||
}
|
||||
}
|
||||
return stringify(value)
|
||||
}
|
||||
|
||||
// stringify renders any supported value as a CSS literal.
|
||||
func stringify(value any) string {
|
||||
switch t := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return t
|
||||
case bool:
|
||||
return strconv.FormatBool(t)
|
||||
case float64:
|
||||
return FormatNumber(t)
|
||||
case int:
|
||||
return FormatNumber(float64(t))
|
||||
case json.Number:
|
||||
return t.String()
|
||||
case []any:
|
||||
parts := make([]string, len(t))
|
||||
for i, p := range t {
|
||||
parts[i] = stringify(p)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
default:
|
||||
// Complex values are stored as pre-formatted CSS strings by the editor,
|
||||
// so this is the only safe rendering.
|
||||
return fmt.Sprint(t)
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat(v any) (float64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t, true
|
||||
case int:
|
||||
return float64(t), true
|
||||
case int64:
|
||||
return float64(t), true
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
return f, err == nil
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
|
||||
return f, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// validateNumeric accepts a JSON number or a numeric string.
|
||||
func validateNumeric(v any) error {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(t) || math.IsInf(t, 0) {
|
||||
return fmt.Errorf("value must be finite")
|
||||
}
|
||||
return nil
|
||||
case int, int64:
|
||||
return nil
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
if err != nil {
|
||||
return fmt.Errorf("value %q is not numeric", t.String())
|
||||
}
|
||||
if math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return fmt.Errorf("value must be finite")
|
||||
}
|
||||
return nil
|
||||
case string:
|
||||
if _, err := strconv.ParseFloat(strings.TrimSpace(t), 64); err != nil {
|
||||
return fmt.Errorf("value %q is not numeric", t)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("expected a number, got %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
// cssLengthUnits are the units accepted for a bare dimension value.
|
||||
var cssLengthUnits = []string{"px", "rem", "em", "%", "vh", "vw", "vmin", "vmax", "ch", "ex", "cm", "mm", "in", "pt", "pc", "svh", "lvh", "dvh", "ms", "s", "fr"}
|
||||
|
||||
// validateCSSLength accepts a number, a numeric string with a unit, or one of
|
||||
// the CSS global keywords.
|
||||
func validateCSSLength(v any) error {
|
||||
if err := validateNumeric(v); err != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
trimmed := strings.ToLower(strings.TrimSpace(s))
|
||||
switch trimmed {
|
||||
case "auto", "min-content", "max-content", "fit-content", "inherit", "initial", "unset", "normal":
|
||||
return nil
|
||||
}
|
||||
for _, unit := range cssLengthUnits {
|
||||
if strings.HasSuffix(trimmed, unit) {
|
||||
if _, err := strconv.ParseFloat(strings.TrimSuffix(trimmed, unit), 64); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "clamp(") || strings.HasPrefix(trimmed, "calc(") ||
|
||||
strings.HasPrefix(trimmed, "min(") || strings.HasPrefix(trimmed, "max(") {
|
||||
// Function-based values are opaque to the validator; the browser
|
||||
// is the authority and will discard anything malformed.
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("value %q is not a valid length", s)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks a token document and returns every finding.
|
||||
func Validate(doc domain.TokenDoc) ValidationResult {
|
||||
result := ValidationResult{Valid: true, Issues: []Issue{}}
|
||||
|
||||
issues := make([]violation, 0, 32)
|
||||
known := make(map[string]GroupSpec, len(Groups))
|
||||
for _, g := range Groups {
|
||||
known[g.Name] = g
|
||||
}
|
||||
|
||||
for key, raw := range doc {
|
||||
if _, reserved := ReservedKeys[key]; reserved {
|
||||
continue
|
||||
}
|
||||
node, ok := asMap(raw)
|
||||
if !ok {
|
||||
issues = append(issues, violation{
|
||||
Path: key, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("group %q must be an object", key),
|
||||
Hint: "top-level keys are token groups, or one of " + strings.Join(sortedReservedKeys(), ", "),
|
||||
})
|
||||
continue
|
||||
}
|
||||
spec, ok := known[key]
|
||||
if !ok {
|
||||
issues = append(issues, violation{
|
||||
Path: key, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("unknown token group %q", key),
|
||||
Hint: "known groups: " + strings.Join(GroupNames(), ", "),
|
||||
})
|
||||
continue
|
||||
}
|
||||
validateGroup(spec, node, key, &issues)
|
||||
}
|
||||
|
||||
if appearances, ok := asMap(doc[AppearancesKey]); ok {
|
||||
for name, raw := range appearances {
|
||||
if name != string(domain.AppearanceLight) && name != string(domain.AppearanceDark) {
|
||||
issues = append(issues, violation{
|
||||
Path: AppearancesKey + "." + name, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("unknown appearance %q", name),
|
||||
Hint: "use \"light\" or \"dark\"",
|
||||
})
|
||||
continue
|
||||
}
|
||||
node, ok := asMap(raw)
|
||||
if !ok {
|
||||
issues = append(issues, violation{
|
||||
Path: AppearancesKey + "." + name, Severity: SeverityError,
|
||||
Message: "appearance overrides must be an object",
|
||||
})
|
||||
continue
|
||||
}
|
||||
for group, groupRaw := range node {
|
||||
spec, ok := known[group]
|
||||
if !ok {
|
||||
issues = append(issues, violation{
|
||||
Path: AppearancesKey + "." + name + "." + group, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("unknown token group %q", group),
|
||||
})
|
||||
continue
|
||||
}
|
||||
groupNode, ok := asMap(groupRaw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Overrides are partial by design: only value validity is
|
||||
// checked, never completeness.
|
||||
validateLeaves(spec, groupNode, spec.Kind, group, AppearancesKey+"."+name+"."+group, &issues)
|
||||
}
|
||||
}
|
||||
} else if _, present := doc[AppearancesKey]; present {
|
||||
issues = append(issues, violation{
|
||||
Path: AppearancesKey, Severity: SeverityError,
|
||||
Message: AppearancesKey + " must be an object keyed by appearance",
|
||||
Hint: `example: {"dark": {"color": {"semantic": {"background": {"$value": "#0b0f19"}}}}}`,
|
||||
})
|
||||
}
|
||||
|
||||
// A theme with no dark overrides will render identically in both schemes,
|
||||
// which is almost always a mistake for a theme that declares a dark
|
||||
// appearance as its default.
|
||||
appearancesMap, _ := asMap(doc[AppearancesKey])
|
||||
if _, hasDark := asMap(appearancesMap[string(domain.AppearanceDark)]); !hasDark {
|
||||
issues = append(issues, violation{
|
||||
Path: AppearancesKey, Severity: SeverityWarning,
|
||||
Message: "no dark appearance overrides are defined",
|
||||
Hint: `add {"$appearances": {"dark": {...}}} so the dark scheme is intentional rather than accidental`,
|
||||
})
|
||||
}
|
||||
|
||||
resolved := countLeaves(doc)
|
||||
result.TokenCount = resolved
|
||||
result.Issues = toIssues(issues)
|
||||
for _, i := range result.Issues {
|
||||
if i.Severity == SeverityError {
|
||||
result.Valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// toIssues converts and deterministically orders internal violations.
|
||||
func toIssues(in []violation) []Issue {
|
||||
out := make([]Issue, 0, len(in))
|
||||
for _, v := range in {
|
||||
out = append(out, Issue(v))
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if ri, rj := severityRank(out[i].Severity), severityRank(out[j].Severity); ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return out[i].Path < out[j].Path
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedReservedKeys returns the reserved document keys in sorted order.
|
||||
func sortedReservedKeys() []string {
|
||||
out := make([]string, 0, len(ReservedKeys))
|
||||
for k := range ReservedKeys {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// validateGroup checks one group's required and recommended tokens.
|
||||
func validateGroup(spec GroupSpec, node map[string]any, prefix string, issues *[]violation) {
|
||||
validateLeaves(spec, node, spec.Kind, prefix, prefix, issues)
|
||||
for _, path := range spec.Recommended {
|
||||
if _, err := LookupIn(node, path); err != nil {
|
||||
*issues = append(*issues, violation{
|
||||
Path: prefix + "." + path, Severity: SeverityWarning,
|
||||
Message: fmt.Sprintf("recommended token %q is missing", path),
|
||||
Hint: spec.Description,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateLeaves walks a group tree checking every leaf value's validity.
|
||||
func validateLeaves(spec GroupSpec, node map[string]any, kindDefault Kind, prefix, path string, issues *[]violation) {
|
||||
if isLeaf(node) {
|
||||
kind := kindDefault
|
||||
if raw, ok := node[TypeKey].(string); ok {
|
||||
kind = Kind(raw)
|
||||
} else if inferred := inferKind(node[ValueKey]); inferred != "" && kind == kindDefault {
|
||||
kind = inferred
|
||||
}
|
||||
if !kind.Valid() {
|
||||
*issues = append(*issues, violation{
|
||||
Path: path, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("unknown $type %q", kind),
|
||||
Hint: "omit $type to inherit the group's type",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := validateKindValue(kind, node[ValueKey]); err != nil {
|
||||
*issues = append(*issues, violation{
|
||||
Path: path, Severity: SeverityError,
|
||||
Message: err.Error(),
|
||||
Hint: fmt.Sprintf("expected a %s value", kind),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
inherited := kindDefault
|
||||
if raw, ok := node[TypeKey].(string); ok {
|
||||
inherited = Kind(raw)
|
||||
}
|
||||
for _, key := range sortedMapKeys(node) {
|
||||
if strings.HasPrefix(key, "$") {
|
||||
continue
|
||||
}
|
||||
child, ok := asMap(node[key])
|
||||
if !ok {
|
||||
*issues = append(*issues, violation{
|
||||
Path: path + "." + key, Severity: SeverityError,
|
||||
Message: fmt.Sprintf("token must be an object with a %q property, got %T", ValueKey, node[key]),
|
||||
Hint: `example: {"$type": "color", "$value": "#6366f1"}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
validateLeaves(spec, child, inherited, prefix, path+"."+key, issues)
|
||||
}
|
||||
}
|
||||
|
||||
// sortedMapKeys returns the keys of m in sorted order.
|
||||
func sortedMapKeys(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// LookupIn walks a token document by dotted path and returns the leaf.
|
||||
func LookupIn(doc map[string]any, path string) (map[string]any, error) {
|
||||
var current any = doc
|
||||
for _, segment := range strings.Split(path, ".") {
|
||||
node, ok := asMap(current)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("path %q: %q is not a group", path, segment)
|
||||
}
|
||||
next, exists := node[segment]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("path %q: %q not found", path, segment)
|
||||
}
|
||||
current = next
|
||||
}
|
||||
leaf, ok := asMap(current)
|
||||
if !ok || !isLeaf(leaf) {
|
||||
return nil, fmt.Errorf("path %q is not a token leaf", path)
|
||||
}
|
||||
return leaf, nil
|
||||
}
|
||||
|
||||
// SetIn writes a token leaf at a dotted path, creating intermediate groups.
|
||||
func SetIn(doc domain.TokenDoc, path string, value any, kind Kind, description string) error {
|
||||
segments := strings.Split(path, ".")
|
||||
if len(segments) == 0 || segments[0] == "" {
|
||||
return fmt.Errorf("%w: empty token path", domain.ErrInvalid)
|
||||
}
|
||||
// Reject paths that would collide with structural keys.
|
||||
for i, seg := range segments {
|
||||
if _, reserved := ReservedKeys[seg]; reserved && i == 0 {
|
||||
return fmt.Errorf("%w: %q is a reserved key", domain.ErrInvalid, seg)
|
||||
}
|
||||
if seg == "" {
|
||||
return fmt.Errorf("%w: token path %q has an empty segment", domain.ErrInvalid, path)
|
||||
}
|
||||
}
|
||||
if _, ok := LookupGroup(segments[0]); !ok {
|
||||
return fmt.Errorf("%w: unknown token group %q", domain.ErrInvalid, segments[0])
|
||||
}
|
||||
|
||||
current := doc
|
||||
for _, seg := range segments[:len(segments)-1] {
|
||||
child, ok := asMap(current[seg])
|
||||
if !ok {
|
||||
child = map[string]any{}
|
||||
current[seg] = child
|
||||
}
|
||||
current = child
|
||||
}
|
||||
leaf := map[string]any{ValueKey: value, TypeKey: string(kind)}
|
||||
if description != "" {
|
||||
leaf[DescKey] = description
|
||||
}
|
||||
current[segments[len(segments)-1]] = leaf
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteIn removes a token leaf at a dotted path. It reports whether the token
|
||||
// existed so callers can distinguish a no-op delete from a real one.
|
||||
func DeleteIn(doc domain.TokenDoc, path string) (bool, error) {
|
||||
segments := strings.Split(path, ".")
|
||||
if len(segments) < 2 {
|
||||
return false, fmt.Errorf("%w: refusing to delete top-level group %q", domain.ErrInvalid, path)
|
||||
}
|
||||
current := any(doc)
|
||||
for _, seg := range segments[:len(segments)-1] {
|
||||
node, ok := asMap(current)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
child, exists := node[seg]
|
||||
if !exists {
|
||||
return false, nil
|
||||
}
|
||||
current = child
|
||||
}
|
||||
node, ok := asMap(current)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
last := segments[len(segments)-1]
|
||||
if _, exists := node[last]; !exists {
|
||||
return false, nil
|
||||
}
|
||||
delete(node, last)
|
||||
pruneEmpty(node)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// pruneEmpty removes group objects left empty by a delete, so that the stored
|
||||
// document does not accumulate `{}` noise that would fail validation.
|
||||
func pruneEmpty(node map[string]any) {
|
||||
for key, raw := range node {
|
||||
child, ok := asMap(raw)
|
||||
if !ok || len(child) == 0 {
|
||||
if ok && len(child) == 0 {
|
||||
delete(node, key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
pruneEmpty(child)
|
||||
if len(child) == 0 {
|
||||
delete(node, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// countLeaves counts leaf tokens, excluding appearance overrides.
|
||||
func countLeaves(node map[string]any) int {
|
||||
total := 0
|
||||
for key, raw := range node {
|
||||
if key == AppearancesKey || strings.HasPrefix(key, "$") {
|
||||
continue
|
||||
}
|
||||
child, ok := asMap(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if isLeaf(child) {
|
||||
total++
|
||||
continue
|
||||
}
|
||||
total += countLeaves(child)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// RenameToken moves a token leaf to a new dotted path, preserving its value and
|
||||
// description. It refuses to clobber an existing token unless overwrite is set.
|
||||
func RenameToken(doc domain.TokenDoc, from, to string, overwrite bool) error {
|
||||
leaf, err := LookupIn(doc, from)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", domain.ErrNotFound, err.Error())
|
||||
}
|
||||
if _, err := LookupIn(doc, to); err == nil && !overwrite {
|
||||
return fmt.Errorf("%w: token %q already exists", domain.ErrConflict, to)
|
||||
}
|
||||
if _, err := DeleteIn(doc, from); err != nil {
|
||||
return err
|
||||
}
|
||||
value := leaf[ValueKey]
|
||||
description, _ := leaf[DescKey].(string)
|
||||
kind, ok := leaf[TypeKey].(string)
|
||||
if !ok {
|
||||
kind = string(inferKind(value))
|
||||
}
|
||||
return SetIn(doc, to, value, Kind(kind), description)
|
||||
}
|
||||
|
||||
// EnsureDefaults fills any missing recommended token with the supplied default.
|
||||
// It is used when duplicating a theme so the copy is immediately usable, and is
|
||||
// deliberately not used on the request path for existing themes.
|
||||
func EnsureDefaults(doc domain.TokenDoc, defaults domain.TokenDoc) int {
|
||||
applied := 0
|
||||
for key, raw := range defaults {
|
||||
if _, exists := doc[key]; exists {
|
||||
continue
|
||||
}
|
||||
if err := deepMergeMissing(doc, key, raw); err == nil {
|
||||
applied++
|
||||
}
|
||||
}
|
||||
return applied
|
||||
}
|
||||
|
||||
// deepMergeMissing merges src into dst only where dst has no value.
|
||||
func deepMergeMissing(dst domain.TokenDoc, key string, src any) error {
|
||||
srcMap, srcOK := asMap(src)
|
||||
dstMap, dstOK := asMap(dst[key])
|
||||
if srcOK && dstOK && !isLeaf(srcMap) {
|
||||
for k, v := range srcMap {
|
||||
if err := deepMergeMissing(dstMap, k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
dst[key] = src
|
||||
return nil
|
||||
}
|
||||
251
internal/tokenengine/tokens_test.go
Normal file
251
internal/tokenengine/tokens_test.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
package tokenengine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urpi/go-bananas/internal/domain"
|
||||
)
|
||||
|
||||
// testTheme builds a minimal but complete theme for engine tests.
|
||||
func testTheme() *domain.Theme {
|
||||
return &domain.Theme{
|
||||
Slug: "acme",
|
||||
Name: "Acme",
|
||||
Status: domain.StatusPublished,
|
||||
DefaultAppearance: domain.AppearanceLight,
|
||||
Version: 3,
|
||||
Tokens: domain.TokenDoc{
|
||||
"color": map[string]any{
|
||||
"palette": map[string]any{
|
||||
"brand": map[string]any{
|
||||
"500": map[string]any{TypeKey: string(KindColor), ValueKey: "#4f46e5"},
|
||||
"600": map[string]any{TypeKey: string(KindColor), ValueKey: "#4338ca"},
|
||||
},
|
||||
},
|
||||
"semantic": map[string]any{
|
||||
"background": map[string]any{TypeKey: string(KindColor), ValueKey: "#ffffff"},
|
||||
"surface": map[string]any{TypeKey: string(KindColor), ValueKey: "#f8fafc"},
|
||||
"text": map[string]any{TypeKey: string(KindColor), ValueKey: "#0f172a"},
|
||||
"primary": map[string]any{TypeKey: string(KindColor), ValueKey: "#4f46e5"},
|
||||
},
|
||||
},
|
||||
"font": map[string]any{
|
||||
"family": map[string]any{
|
||||
"sans": map[string]any{TypeKey: string(KindFamilyAlias()), ValueKey: "Inter, sans-serif"},
|
||||
},
|
||||
},
|
||||
"space": map[string]any{
|
||||
"4": map[string]any{TypeKey: string(KindDimension), ValueKey: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// KindFamilyAlias exists only so the fixture reads clearly; it resolves to the
|
||||
// canonical font-family kind.
|
||||
func KindFamilyAlias() Kind { return KindFontFamily }
|
||||
|
||||
func TestResolveFlattensNestedTokens(t *testing.T) {
|
||||
doc := Resolve(testTheme(), domain.AppearanceLight)
|
||||
|
||||
if got, ok := doc.Lookup("color.semantic.background"); !ok {
|
||||
t.Fatalf("expected color.semantic.background to resolve, got %d tokens", len(doc.Tokens))
|
||||
} else if got.CSS != "#ffffff" {
|
||||
t.Errorf("background = %q, want #ffffff", got.CSS)
|
||||
}
|
||||
|
||||
space, ok := doc.Lookup("space.4")
|
||||
if !ok {
|
||||
t.Fatal("expected space.4 to resolve")
|
||||
}
|
||||
// A bare number in a dimension group must pick up the px suffix.
|
||||
if space.CSS != "1px" {
|
||||
t.Errorf("space.4 = %q, want 1px", space.CSS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIsDeterministic(t *testing.T) {
|
||||
first := Resolve(testTheme(), domain.AppearanceLight)
|
||||
second := Resolve(testTheme(), domain.AppearanceLight)
|
||||
|
||||
if len(first.Tokens) != len(second.Tokens) {
|
||||
t.Fatalf("token count differs: %d vs %d", len(first.Tokens), len(second.Tokens))
|
||||
}
|
||||
for i := range first.Tokens {
|
||||
if first.Tokens[i].Path != second.Tokens[i].Path {
|
||||
t.Fatalf("token %d out of order: %q vs %q", i, first.Tokens[i].Path, second.Tokens[i].Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppearanceOverridesWinOverBase(t *testing.T) {
|
||||
theme := testTheme()
|
||||
theme.Tokens[AppearancesKey] = map[string]any{
|
||||
"dark": map[string]any{
|
||||
"color": map[string]any{
|
||||
"semantic": map[string]any{
|
||||
"background": map[string]any{TypeKey: string(KindColor), ValueKey: "#0b1220"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
light := Resolve(theme, domain.AppearanceLight)
|
||||
dark := Resolve(theme, domain.AppearanceDark)
|
||||
|
||||
if got, _ := light.Lookup("color.semantic.background"); got.CSS != "#ffffff" {
|
||||
t.Errorf("light background = %q, want the base value #ffffff", got.CSS)
|
||||
}
|
||||
if got, _ := dark.Lookup("color.semantic.background"); got.CSS != "#0b1220" {
|
||||
t.Errorf("dark background = %q, want the override #0b1220", got.CSS)
|
||||
}
|
||||
// Tokens not overridden must fall through to the base value.
|
||||
if got, _ := dark.Lookup("color.semantic.primary"); got.CSS != "#4f46e5" {
|
||||
t.Errorf("dark primary = %q, want the base value #4f46e5", got.CSS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderEmitsScopedSelectorsAndDerivedTokens(t *testing.T) {
|
||||
css := Render(testTheme(), RenderOptions{
|
||||
Slug: "acme",
|
||||
Appearance: domain.AppearanceLight,
|
||||
Derive: DefaultDerive(),
|
||||
Components: true,
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
`[data-bananas-theme="acme"]`,
|
||||
`[data-bananas-theme="acme"][data-bananas-appearance=dark]`,
|
||||
"--bn-color-semantic-background: #ffffff;",
|
||||
// Derived foreground must pick white on a dark primary.
|
||||
"--bn-color-on-semantic-primary: #ffffff;",
|
||||
"--bn-color-semantic-primary-hover:",
|
||||
"--bn-space-4: 1px;",
|
||||
"@layer bananas",
|
||||
".bn-btn",
|
||||
`.dark [data-bananas-theme="acme"]`,
|
||||
} {
|
||||
if !strings.Contains(css, want) {
|
||||
t.Errorf("rendered CSS missing %q\n---\n%s", want, css)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMinifiedOmitsCommentsAndWhitespace(t *testing.T) {
|
||||
css := Render(testTheme(), RenderOptions{Slug: "acme", Minify: true, Derive: DefaultDerive()})
|
||||
|
||||
if strings.Contains(css, "/*") {
|
||||
t.Error("minified CSS must not contain comments")
|
||||
}
|
||||
if strings.Contains(css, "\n\n") {
|
||||
t.Error("minified CSS must not contain blank lines")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsACompleteTheme(t *testing.T) {
|
||||
result := Validate(testTheme().Tokens)
|
||||
for _, issue := range result.Errors() {
|
||||
// The fixture is intentionally sparse; it must only trip the "unknown
|
||||
// group" rule if the schema drifted, never on the tokens it defines.
|
||||
if strings.Contains(issue.Message, "unknown token group") {
|
||||
t.Errorf("unexpected schema error: %+v", issue)
|
||||
}
|
||||
}
|
||||
if result.TokenCount != 8 {
|
||||
t.Errorf("TokenCount = %d, want 8", result.TokenCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsMalformedColour(t *testing.T) {
|
||||
doc := testTheme().Tokens
|
||||
doc["color"].(map[string]any)["semantic"].(map[string]any)["primary"] = map[string]any{
|
||||
TypeKey: string(KindColor), ValueKey: "#nothex",
|
||||
}
|
||||
|
||||
result := Validate(doc)
|
||||
if result.Valid {
|
||||
t.Fatal("expected validation to fail on a malformed colour")
|
||||
}
|
||||
found := false
|
||||
for _, issue := range result.Issues {
|
||||
if issue.Path == "color.semantic.primary" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected an issue on color.semantic.primary, got %+v", result.Issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsUnknownGroup(t *testing.T) {
|
||||
doc := testTheme().Tokens
|
||||
doc["colour"] = map[string]any{"typo": map[string]any{ValueKey: "#fff"}}
|
||||
|
||||
result := Validate(doc)
|
||||
if result.Valid {
|
||||
t.Fatal("expected validation to reject an unknown top-level group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariableNameNormalisation(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"color.semantic.background": "--bn-color-semantic-background",
|
||||
"space.4": "--bn-space-4",
|
||||
"font.lineHeight.tight": "--bn-font-line-height-tight",
|
||||
"font.line height": "--bn-font-line-height",
|
||||
"weird.Name_Here": "--bn-weird-name-here",
|
||||
}
|
||||
for path, want := range cases {
|
||||
if got := VariableName(path); got != want {
|
||||
t.Errorf("VariableName(%q) = %q, want %q", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetAndDeleteToken(t *testing.T) {
|
||||
doc := testTheme().Tokens
|
||||
|
||||
if err := SetIn(doc, "space.6", 1.5, KindDimension, ""); err != nil {
|
||||
t.Fatalf("SetIn: %v", err)
|
||||
}
|
||||
if got, err := LookupIn(doc, "space.6"); err != nil {
|
||||
t.Fatalf("LookupIn: %v", err)
|
||||
} else if got[ValueKey] != 1.5 {
|
||||
t.Errorf("space.6 = %v, want 1.5", got[ValueKey])
|
||||
}
|
||||
|
||||
deleted, err := DeleteIn(doc, "space.6")
|
||||
if err != nil || !deleted {
|
||||
t.Fatalf("DeleteIn = %v, %v; want true, nil", deleted, err)
|
||||
}
|
||||
if _, err := LookupIn(doc, "space.6"); err == nil {
|
||||
t.Error("token should be gone after DeleteIn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetInRejectsUnknownGroup(t *testing.T) {
|
||||
doc := domain.TokenDoc{}
|
||||
err := SetIn(doc, "colour.typo", "#fff", KindColor, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected SetIn to reject an unknown group")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONPayloadCarriesFlatAndNestedForms(t *testing.T) {
|
||||
payload := JSONPayload(testTheme(), domain.AppearanceDark)
|
||||
|
||||
if payload.Theme.Slug != "acme" {
|
||||
t.Errorf("slug = %q, want acme", payload.Theme.Slug)
|
||||
}
|
||||
if payload.Variable["--bn-color-semantic-primary"] != "#4f46e5" {
|
||||
t.Errorf("flat map missing primary: %v", payload.Variable)
|
||||
}
|
||||
semantic, ok := payload.Tokens["color"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("nested tokens missing the color group")
|
||||
}
|
||||
if _, ok := semantic["semantic"]; !ok {
|
||||
t.Error("nested tokens missing the semantic group")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue