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, `

`+htmlEscape(sample.title)+`

`+htmlEscape(sample.body)+`
`) } return ` go-bananas · integrate ` + htmlEscape(slug) + `

Integrate ` + htmlEscape(slug) + `

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.

published draft archived

` } // 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) }