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 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
. 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 .",
}
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{
``,
``,
}, "\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{
``,
``,
}, "\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, `
Copy one of these into your application. Nothing is installed: the
theme is fetched from this deployment at run time.
` + strings.Join(sections, "\n") + `
Live preview
Card heading
Every value here comes from a design token in the
theme you are editing.
publisheddraftarchived
`
}
// 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)
}