762 lines
26 KiB
Go
762 lines
26 KiB
Go
|
|
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)
|
||
|
|
}
|