diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go index eba9dac4..c5d180ac 100644 --- a/cmd/gateway_http_wiring.go +++ b/cmd/gateway_http_wiring.go @@ -141,6 +141,8 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( }) } + d.server.SetBrandingAssetsHandler(httpapi.NewBrandingAssetsHandler(d.dataDir)) + // Usage analytics API if d.pgStores.Snapshots != nil { d.server.SetUsageHandler(httpapi.NewUsageHandler(d.pgStores.Snapshots, d.pgStores.UsageEvents, d.pgStores.DB)) diff --git a/internal/config/config.go b/internal/config/config.go index 8c7447f4..767094a6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,7 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { // Config is the root configuration for the GoClaw Gateway. type Config struct { DataDir string `json:"data_dir,omitempty"` // persistent data directory (default: ~/.goclaw/data) + Branding BrandingConfig `json:"branding,omitempty"` Agents AgentsConfig `json:"agents"` Channels ChannelsConfig `json:"channels"` Providers ProvidersConfig `json:"providers"` @@ -64,6 +65,39 @@ type Config struct { mu sync.RWMutex } +// BrandingConfig customizes public app metadata and media used by the web UI. +// URL fields may point to external URLs or to uploaded /branding-assets/* files. +type BrandingConfig struct { + AppName string `json:"app_name,omitempty"` + AppShortName string `json:"app_short_name,omitempty"` + MetaTitle string `json:"meta_title,omitempty"` + MetaDescription string `json:"meta_description,omitempty"` + MetaKeywords string `json:"meta_keywords,omitempty"` + LogoURL string `json:"logo_url,omitempty"` + FaviconURL string `json:"favicon_url,omitempty"` + AppleTouchIconURL string `json:"apple_touch_icon_url,omitempty"` + OGTitle string `json:"og_title,omitempty"` + OGDescription string `json:"og_description,omitempty"` + OGImageURL string `json:"og_image_url,omitempty"` + ThemeColor string `json:"theme_color,omitempty"` +} + +// HasValues reports whether any branding override is configured. +func (b BrandingConfig) HasValues() bool { + return strings.TrimSpace(b.AppName) != "" || + strings.TrimSpace(b.AppShortName) != "" || + strings.TrimSpace(b.MetaTitle) != "" || + strings.TrimSpace(b.MetaDescription) != "" || + strings.TrimSpace(b.MetaKeywords) != "" || + strings.TrimSpace(b.LogoURL) != "" || + strings.TrimSpace(b.FaviconURL) != "" || + strings.TrimSpace(b.AppleTouchIconURL) != "" || + strings.TrimSpace(b.OGTitle) != "" || + strings.TrimSpace(b.OGDescription) != "" || + strings.TrimSpace(b.OGImageURL) != "" || + strings.TrimSpace(b.ThemeColor) != "" +} + // PackagesConfig tunes the runtime package update flow (Phase 1: GitHub // binaries). GitHubToken is RESERVED for Phase 2 (authenticated rate-limit // bump); currently unwired. @@ -603,6 +637,7 @@ func (c *Config) ReplaceFrom(src *Config) { c.mu.Lock() defer c.mu.Unlock() c.DataDir = src.DataDir + c.Branding = src.Branding c.Agents = src.Agents c.Channels = src.Channels c.Providers = src.Providers @@ -635,6 +670,14 @@ func (c *Config) Clone() *Config { return cp } +// BrandingSnapshot returns the current branding overrides without exposing the +// mutable root config to HTTP handlers. +func (c *Config) BrandingSnapshot() BrandingConfig { + c.mu.RLock() + defer c.mu.RUnlock() + return c.Branding +} + // ShellDenyGroupsSnapshot returns a copy of the current global shell deny-group // overrides. Callers can safely resolve patterns without racing config reloads. func (c *Config) ShellDenyGroupsSnapshot() map[string]bool { diff --git a/internal/config/config_extras_test.go b/internal/config/config_extras_test.go index e453e77d..fc6c6d8f 100644 --- a/internal/config/config_extras_test.go +++ b/internal/config/config_extras_test.go @@ -321,6 +321,13 @@ func TestSaveLoad_RoundTrip(t *testing.T) { cfg := Default() cfg.Gateway.Port = 7654 cfg.DataDir = "/test/data" + cfg.Branding = BrandingConfig{ + AppName: "Acme AI", + MetaTitle: "Acme AI Console", + MetaDescription: "Private AI agent gateway", + LogoURL: "/branding-assets/logo.png", + FaviconURL: "/branding-assets/favicon.ico", + } if err := Save(path, cfg); err != nil { t.Fatalf("Save error: %v", err) @@ -337,6 +344,31 @@ func TestSaveLoad_RoundTrip(t *testing.T) { if loaded.DataDir != "/test/data" { t.Errorf("DataDir round-trip: got %q", loaded.DataDir) } + if loaded.Branding.AppName != "Acme AI" { + t.Errorf("branding app name round-trip: got %q", loaded.Branding.AppName) + } + if loaded.Branding.MetaTitle != "Acme AI Console" { + t.Errorf("branding meta title round-trip: got %q", loaded.Branding.MetaTitle) + } + if loaded.Branding.LogoURL != "/branding-assets/logo.png" { + t.Errorf("branding logo URL round-trip: got %q", loaded.Branding.LogoURL) + } +} + +func TestConfigExtras_CloneAndReplaceFrom(t *testing.T) { + src := Default() + src.Branding = BrandingConfig{AppName: "Custom GoClaw", ThemeColor: "#123456"} + + clone := src.Clone() + if clone.Branding.AppName != "Custom GoClaw" || clone.Branding.ThemeColor != "#123456" { + t.Fatalf("Clone branding = %#v", clone.Branding) + } + + dst := Default() + dst.ReplaceFrom(src) + if dst.Branding.AppName != "Custom GoClaw" || dst.Branding.ThemeColor != "#123456" { + t.Fatalf("ReplaceFrom branding = %#v", dst.Branding) + } } // --- Env fallback: GOCLAW_PROVIDER / GOCLAW_MODEL only used when config is empty --- diff --git a/internal/gateway/methods/config.go b/internal/gateway/methods/config.go index 56b70c6e..67b355b7 100644 --- a/internal/gateway/methods/config.go +++ b/internal/gateway/methods/config.go @@ -256,6 +256,60 @@ func (m *ConfigMethods) handleSchema(_ context.Context, client *gateway.Client, "type": "object", "description": "Gateway server settings (host, port, token)", }, + "branding": map[string]any{ + "type": "object", + "description": "Public app branding and SEO metadata", + "properties": map[string]any{ + "app_name": map[string]any{ + "type": "string", + "description": "Application name shown in browser metadata and UI chrome", + }, + "app_short_name": map[string]any{ + "type": "string", + "description": "Short application name for compact surfaces", + }, + "meta_title": map[string]any{ + "type": "string", + "description": "HTML document title override", + }, + "meta_description": map[string]any{ + "type": "string", + "description": "HTML meta description override", + }, + "meta_keywords": map[string]any{ + "type": "string", + "description": "HTML meta keywords override", + }, + "logo_url": map[string]any{ + "type": "string", + "description": "Logo URL or uploaded /branding-assets/* path", + }, + "favicon_url": map[string]any{ + "type": "string", + "description": "Favicon URL or uploaded /branding-assets/* path", + }, + "apple_touch_icon_url": map[string]any{ + "type": "string", + "description": "Apple touch icon URL or uploaded /branding-assets/* path", + }, + "og_title": map[string]any{ + "type": "string", + "description": "Open Graph title override", + }, + "og_description": map[string]any{ + "type": "string", + "description": "Open Graph description override", + }, + "og_image_url": map[string]any{ + "type": "string", + "description": "Open Graph image URL or uploaded /branding-assets/* path", + }, + "theme_color": map[string]any{ + "type": "string", + "description": "Browser theme-color value", + }, + }, + }, "tools": map[string]any{ "type": "object", "description": "Tool configuration (browser, exec, web search)", diff --git a/internal/gateway/server.go b/internal/gateway/server.go index bec05a01..9ead51aa 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -229,7 +229,7 @@ func (s *Server) BuildMux() *http.ServeMux { // browser to check the service. Install a minimal JSON index handler in that // case so the root responds with something useful (and any unmatched path // still returns 404, just with a JSON body). - if h := webui.Handler(); h != nil { + if h := webui.Handler(s.cfg); h != nil { mux.Handle("/", h) slog.Info("serving embedded web UI") } else { @@ -654,6 +654,11 @@ func (s *Server) SetSystemConfigsHandler(h *httpapi.SystemConfigsHandler) { s.handlers = append(s.handlers, h) } +// SetBrandingAssetsHandler sets the public branding asset upload/serve handler. +func (s *Server) SetBrandingAssetsHandler(h *httpapi.BrandingAssetsHandler) { + s.handlers = append(s.handlers, h) +} + // SetUsageHandler sets the usage analytics handler. func (s *Server) SetUsageHandler(h *httpapi.UsageHandler) { s.handlers = append(s.handlers, h) } diff --git a/internal/http/branding_assets.go b/internal/http/branding_assets.go new file mode 100644 index 00000000..2bed6072 --- /dev/null +++ b/internal/http/branding_assets.go @@ -0,0 +1,215 @@ +package http + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/permissions" +) + +const MaxBrandingAssetUploadBytes = 2 * 1024 * 1024 + +var brandingAssetNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +// BrandingAssetsHandler handles global branding media uploads and public serving. +type BrandingAssetsHandler struct { + dir string +} + +func NewBrandingAssetsHandler(dataDir string) *BrandingAssetsHandler { + if strings.TrimSpace(dataDir) == "" { + dataDir = "." + } + return &BrandingAssetsHandler{dir: filepath.Join(dataDir, "branding-assets")} +} + +func (h *BrandingAssetsHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /v1/branding/assets", requireAuth(permissions.RoleAdmin, h.handleUpload)) + mux.HandleFunc("GET /branding-assets/{name}", h.handleServe) +} + +func (h *BrandingAssetsHandler) handleUpload(w http.ResponseWriter, r *http.Request) { + if !requireMasterScope(w, r) { + return + } + locale := extractLocale(r) + r.Body = http.MaxBytesReader(w, r.Body, int64(MaxBrandingAssetUploadBytes+(1<<20))) + if err := r.ParseMultipartForm(int64(MaxBrandingAssetUploadBytes + (1 << 20))); err != nil { + status := http.StatusBadRequest + if strings.Contains(strings.ToLower(err.Error()), "too large") { + status = http.StatusRequestEntityTooLarge + } + writeJSON(w, status, map[string]string{"error": i18n.T(locale, i18n.MsgFileTooLarge)}) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgMissingFileField)}) + return + } + defer file.Close() + + filename := sanitizeBrandingAssetFilename(header.Filename) + if filename == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidFilename)}) + return + } + + data, err := io.ReadAll(io.LimitReader(file, int64(MaxBrandingAssetUploadBytes)+1)) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to read upload")}) + return + } + if len(data) > MaxBrandingAssetUploadBytes { + writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": i18n.T(locale, i18n.MsgFileTooLarge)}) + return + } + + contentType, err := classifyBrandingAsset(filename, data) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + storedName := uniqueBrandingAssetName(filename) + if err := os.MkdirAll(h.dir, 0755); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to create asset directory")}) + return + } + path := filepath.Join(h.dir, storedName) + if err := os.WriteFile(path, data, 0644); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "failed to save asset")}) + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "url": "/branding-assets/" + storedName, + "filename": storedName, + "content_type": contentType, + "size": len(data), + }) +} + +func (h *BrandingAssetsHandler) handleServe(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("name") + if name == "" || filepath.Base(name) != name || strings.Contains(name, "..") { + http.NotFound(w, r) + return + } + path := filepath.Join(h.dir, name) + data, err := os.ReadFile(path) + if err != nil { + http.NotFound(w, r) + return + } + contentType, err := classifyBrandingAsset(name, data) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:") + w.Header().Set("Content-Type", contentType) + w.Header().Set("X-Content-Type-Options", "nosniff") + http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data)) +} + +func sanitizeBrandingAssetFilename(name string) string { + base := filepath.Base(strings.TrimSpace(name)) + ext := strings.ToLower(filepath.Ext(base)) + if !isAllowedBrandingAssetExt(ext) { + return "" + } + stem := strings.TrimSuffix(base, filepath.Ext(base)) + stem = strings.ToLower(brandingAssetNameRe.ReplaceAllString(stem, "-")) + stem = strings.Trim(stem, ".-_") + if stem == "" { + stem = "asset" + } + if len(stem) > 64 { + stem = strings.Trim(stem[:64], ".-_") + if stem == "" { + stem = "asset" + } + } + return stem + ext +} + +func uniqueBrandingAssetName(name string) string { + ext := filepath.Ext(name) + stem := strings.TrimSuffix(name, ext) + return fmt.Sprintf("%s-%s%s", stem, randomHex(6), ext) +} + +func randomHex(n int) string { + buf := make([]byte, n) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(buf) +} + +func classifyBrandingAsset(name string, data []byte) (string, error) { + if len(data) == 0 { + return "", fmt.Errorf("empty file") + } + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".png": + if http.DetectContentType(data) == "image/png" { + return "image/png", nil + } + case ".jpg", ".jpeg": + if http.DetectContentType(data) == "image/jpeg" { + return "image/jpeg", nil + } + case ".webp": + if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" { + return "image/webp", nil + } + case ".ico": + if len(data) >= 4 && data[0] == 0 && data[1] == 0 && data[2] == 1 && data[3] == 0 { + return "image/x-icon", nil + } + case ".svg": + if isSafeSVG(data) { + return "image/svg+xml", nil + } + return "", fmt.Errorf("unsafe svg content") + } + return "", fmt.Errorf("unsupported or invalid branding asset") +} + +func isAllowedBrandingAssetExt(ext string) bool { + switch ext { + case ".svg", ".png", ".jpg", ".jpeg", ".webp", ".ico": + return true + default: + return false + } +} + +func isSafeSVG(data []byte) bool { + lower := strings.ToLower(string(data)) + if !strings.Contains(lower, "`)) + req := httptest.NewRequest(http.MethodPost, "/v1/branding/assets", body) + req.Header.Set("Content-Type", contentType) + req = req.WithContext(masterOwnerContext()) + rec := httptest.NewRecorder() + + h.handleUpload(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/webui/handler.go b/internal/webui/handler.go index 4e70f8a3..ffb4da65 100644 --- a/internal/webui/handler.go +++ b/internal/webui/handler.go @@ -1,9 +1,15 @@ package webui import ( + "encoding/json" + "fmt" + "html" "io/fs" "net/http" + "regexp" "strings" + + "github.com/nextlevelbuilder/goclaw/internal/config" ) // apiPrefixes are URL prefixes reserved for backend APIs. @@ -12,18 +18,26 @@ var apiPrefixes = []string{"/v1/", "/ws", "/health", "/mcp/"} // Handler returns an http.Handler that serves the embedded SPA. // Returns nil if no assets are embedded (built without embedui tag). -func Handler() http.Handler { +func Handler(cfg *config.Config) http.Handler { fsys := Assets() if fsys == nil { return nil } + return NewHandlerForFS(fsys, cfg) +} + +// NewHandlerForFS returns a SPA handler for the provided filesystem. +// It is exported for tests so embedded-asset behavior can be verified without +// requiring an embedui build. +func NewHandlerForFS(fsys fs.FS, cfg *config.Config) http.Handler { fileServer := http.FileServer(http.FS(fsys)) - return &spaHandler{fs: fsys, fileServer: fileServer} + return &spaHandler{fs: fsys, fileServer: fileServer, cfg: cfg} } type spaHandler struct { fs fs.FS fileServer http.Handler + cfg *config.Config } func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -49,15 +63,32 @@ func (h *spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } else { setIndexCacheHeaders(w) } + if path == "index.html" { + h.serveIndex(w, r) + return + } h.fileServer.ServeHTTP(w, r) return } // SPA fallback: serve index.html for any unmatched route. // This handles client-side routing (React Router). + h.serveIndex(w, r) +} + +func (h *spaHandler) serveIndex(w http.ResponseWriter, r *http.Request) { setIndexCacheHeaders(w) - r.URL.Path = "/" - h.fileServer.ServeHTTP(w, r) + data, err := fs.ReadFile(h.fs, "index.html") + if err != nil { + http.NotFound(w, r) + return + } + data = injectBrandingMetadata(data, h.cfg) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(data) } func setIndexCacheHeaders(w http.ResponseWriter) { @@ -65,3 +96,148 @@ func setIndexCacheHeaders(w http.ResponseWriter) { // always fetches the latest Vite asset manifest after an embedded UI rebuild. w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") } + +func injectBrandingMetadata(index []byte, cfg *config.Config) []byte { + if cfg == nil { + return index + } + branding := cfg.BrandingSnapshot() + if !branding.HasValues() { + return index + } + + htmlDoc := string(index) + title := firstNonEmpty(branding.MetaTitle, branding.AppName) + ogTitle := firstNonEmpty(branding.OGTitle, title) + ogDescription := firstNonEmpty(branding.OGDescription, branding.MetaDescription) + + htmlDoc = replaceTitle(htmlDoc, title) + tags := []string{ + metaName("application-name", branding.AppName), + metaName("description", branding.MetaDescription), + metaName("keywords", branding.MetaKeywords), + metaProperty("og:title", ogTitle), + metaProperty("og:description", ogDescription), + metaProperty("og:image", branding.OGImageURL), + linkRel("icon", branding.FaviconURL), + linkRel("apple-touch-icon", branding.AppleTouchIconURL), + metaName("theme-color", branding.ThemeColor), + } + for _, tag := range tags { + if tag != "" { + htmlDoc = insertOrReplaceHeadTag(htmlDoc, tag) + } + } + if script := runtimeBrandingScript(branding); script != "" { + htmlDoc = insertOrReplaceHeadTag(htmlDoc, script) + } + return []byte(htmlDoc) +} + +func runtimeBrandingScript(branding config.BrandingConfig) string { + payload := map[string]string{} + for key, value := range map[string]string{ + "app_name": branding.AppName, + "app_short_name": branding.AppShortName, + "logo_url": branding.LogoURL, + } { + if trimmed := strings.TrimSpace(value); trimmed != "" { + payload[key] = trimmed + } + } + if len(payload) == 0 { + return "" + } + data, err := json.Marshal(payload) + if err != nil { + return "" + } + return `` +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} + +func replaceTitle(doc, title string) string { + if strings.TrimSpace(title) == "" { + return doc + } + re := regexp.MustCompile(`(?is)]*>.*?`) + replacement := "" + html.EscapeString(title) + "" + if re.MatchString(doc) { + return re.ReplaceAllStringFunc(doc, func(string) string { return replacement }) + } + return insertBeforeHeadClose(doc, replacement) +} + +func metaName(name, content string) string { + if strings.TrimSpace(content) == "" { + return "" + } + return fmt.Sprintf(``, html.EscapeString(name), html.EscapeString(strings.TrimSpace(content))) +} + +func metaProperty(property, content string) string { + if strings.TrimSpace(content) == "" { + return "" + } + return fmt.Sprintf(``, html.EscapeString(property), html.EscapeString(strings.TrimSpace(content))) +} + +func linkRel(rel, href string) string { + if strings.TrimSpace(href) == "" { + return "" + } + return fmt.Sprintf(``, html.EscapeString(rel), html.EscapeString(strings.TrimSpace(href))) +} + +func insertOrReplaceHeadTag(doc, tag string) string { + selector := headTagSelector(tag) + if selector == "" { + return insertBeforeHeadClose(doc, tag) + } + re := regexp.MustCompile(selector) + if re.MatchString(doc) { + return re.ReplaceAllStringFunc(doc, func(string) string { return tag }) + } + return insertBeforeHeadClose(doc, tag) +} + +func headTagSelector(tag string) string { + for _, attr := range []string{"name", "property", "rel"} { + prefix := attr + `="` + start := strings.Index(tag, prefix) + if start < 0 { + continue + } + start += len(prefix) + end := strings.Index(tag[start:], `"`) + if end < 0 { + return "" + } + value := regexp.QuoteMeta(tag[start : start+end]) + switch attr { + case "name": + return `(?is)]*\bname=["']` + value + `["'][^>]*>` + case "property": + return `(?is)]*\bproperty=["']` + value + `["'][^>]*>` + case "rel": + return `(?is)]*\brel=["']` + value + `["'][^>]*>` + } + } + return "" +} + +func insertBeforeHeadClose(doc, tag string) string { + re := regexp.MustCompile(`(?i)`) + if re.MatchString(doc) { + return re.ReplaceAllString(doc, tag+"\n") + } + return tag + "\n" + doc +} diff --git a/internal/webui/handler_test.go b/internal/webui/handler_test.go new file mode 100644 index 00000000..780184e3 --- /dev/null +++ b/internal/webui/handler_test.go @@ -0,0 +1,86 @@ +package webui + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + + "github.com/nextlevelbuilder/goclaw/internal/config" +) + +func TestHandlerInjectsBrandingMetadataIntoIndex(t *testing.T) { + fsys := fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte(`GoClaw
`)}, + } + cfg := config.Default() + cfg.Branding = config.BrandingConfig{ + AppName: "Acme Agents", + MetaTitle: "Acme Agents Console", + MetaDescription: "Private agent dashboard", + MetaKeywords: "agents, automation", + LogoURL: "/branding-assets/logo.png", + FaviconURL: "/branding-assets/favicon.ico", + AppleTouchIconURL: "/branding-assets/apple.png", + OGTitle: "Acme Social Title", + OGDescription: "Acme social description", + OGImageURL: "/branding-assets/og.webp", + ThemeColor: "#123456", + } + + h := NewHandlerForFS(fsys, cfg) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/settings", nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body := rec.Body.String() + for _, want := range []string{ + `Acme Agents Console`, + `