feat: add configurable appearance settings (#1384)

Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com>
This commit is contained in:
nguyenha935andnguyenha935 authored and GitHub committed 2026-07-07 19:54:37 +07:00
1 parent 4d3f0a4e1d
commit 086532484d
24 files changed
+1318 -14

No files matched your search

+2
View File
@@ -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))
+43
View File
@@ -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 {
+32
View File
@@ -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 ---
+54
View File
@@ -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)",
+6 -1
View File
@@ -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) }
+215
View File
@@ -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, "<svg") {
return false
}
blocked := []string{"<script", "javascript:", " onload=", " onerror=", " onclick=", " onmouseover="}
for _, needle := range blocked {
if strings.Contains(lower, needle) {
return false
}
}
return true
}
+123
View File
@@ -0,0 +1,123 @@
package http
import (
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func brandingMultipartBody(t *testing.T, filename string, content []byte) (*bytes.Buffer, string) {
t.Helper()
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
part, err := w.CreateFormFile("file", filename)
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write multipart content: %v", err)
}
if err := w.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
return body, w.FormDataContentType()
}
func masterOwnerContext() context.Context {
ctx := store.WithRole(context.Background(), store.RoleOwner)
return store.WithTenantID(ctx, store.MasterTenantID)
}
func TestBrandingAssetsUploadStoresAllowedImage(t *testing.T) {
dataDir := t.TempDir()
h := NewBrandingAssetsHandler(dataDir)
body, contentType := brandingMultipartBody(t, "../My Logo.PNG", []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d,
})
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.StatusCreated {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var resp struct {
URL string `json:"url"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if !strings.HasPrefix(resp.URL, "/branding-assets/") {
t.Fatalf("url = %q, want /branding-assets/ prefix", resp.URL)
}
if !strings.HasSuffix(resp.Filename, ".png") {
t.Fatalf("filename = %q, want .png suffix", resp.Filename)
}
if strings.Contains(resp.Filename, "..") || strings.ContainsAny(resp.Filename, `/\`) {
t.Fatalf("filename was not sanitized: %q", resp.Filename)
}
if resp.ContentType != "image/png" {
t.Fatalf("content_type = %q, want image/png", resp.ContentType)
}
stored := filepath.Join(dataDir, "branding-assets", resp.Filename)
if _, err := os.Stat(stored); err != nil {
t.Fatalf("uploaded file not stored at %s: %v", stored, err)
}
serveReq := httptest.NewRequest(http.MethodGet, resp.URL, nil)
serveReq.SetPathValue("name", resp.Filename)
serveRec := httptest.NewRecorder()
h.handleServe(serveRec, serveReq)
if serveRec.Code != http.StatusOK {
t.Fatalf("serve status = %d, body = %s", serveRec.Code, serveRec.Body.String())
}
if got := serveRec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
}
}
func TestBrandingAssetsUploadRejectsOversizedFile(t *testing.T) {
h := NewBrandingAssetsHandler(t.TempDir())
body, contentType := brandingMultipartBody(t, "logo.png", bytes.Repeat([]byte{'a'}, MaxBrandingAssetUploadBytes+1))
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.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
}
func TestBrandingAssetsUploadRejectsUnsafeSVG(t *testing.T) {
h := NewBrandingAssetsHandler(t.TempDir())
body, contentType := brandingMultipartBody(t, "logo.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`))
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())
}
}
+180 -4
View File
@@ -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 `<script id="goclaw-branding" type="application/json">` + string(data) + `</script>`
}
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)<title[^>]*>.*?</title>`)
replacement := "<title>" + html.EscapeString(title) + "</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(`<meta name="%s" content="%s">`, html.EscapeString(name), html.EscapeString(strings.TrimSpace(content)))
}
func metaProperty(property, content string) string {
if strings.TrimSpace(content) == "" {
return ""
}
return fmt.Sprintf(`<meta property="%s" content="%s">`, html.EscapeString(property), html.EscapeString(strings.TrimSpace(content)))
}
func linkRel(rel, href string) string {
if strings.TrimSpace(href) == "" {
return ""
}
return fmt.Sprintf(`<link rel="%s" href="%s">`, 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)<meta\b[^>]*\bname=["']` + value + `["'][^>]*>`
case "property":
return `(?is)<meta\b[^>]*\bproperty=["']` + value + `["'][^>]*>`
case "rel":
return `(?is)<link\b[^>]*\brel=["']` + value + `["'][^>]*>`
}
}
return ""
}
func insertBeforeHeadClose(doc, tag string) string {
re := regexp.MustCompile(`(?i)</head>`)
if re.MatchString(doc) {
return re.ReplaceAllString(doc, tag+"\n</head>")
}
return tag + "\n" + doc
}
+86
View File
@@ -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(`<!doctype html><html><head><title>GoClaw</title><meta name="description" content=""><meta property="og:title" content="GoClaw"><link rel="icon" href="/favicon.ico"><meta name="theme-color" content="#000000"></head><body><div id="root"></div></body></html>`)},
}
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{
`<title>Acme Agents Console</title>`,
`<script id="goclaw-branding" type="application/json">`,
`"app_name":"Acme Agents"`,
`"logo_url":"/branding-assets/logo.png"`,
`<meta name="application-name" content="Acme Agents">`,
`<meta name="description" content="Private agent dashboard">`,
`<meta name="keywords" content="agents, automation">`,
`<meta property="og:title" content="Acme Social Title">`,
`<meta property="og:description" content="Acme social description">`,
`<meta property="og:image" content="/branding-assets/og.webp">`,
`<link rel="icon" href="/branding-assets/favicon.ico">`,
`<link rel="apple-touch-icon" href="/branding-assets/apple.png">`,
`<meta name="theme-color" content="#123456">`,
} {
if !strings.Contains(body, want) {
t.Fatalf("index response missing %q in:\n%s", want, body)
}
}
if got := rec.Header().Get("Cache-Control"); got != "no-cache, no-store, must-revalidate" {
t.Fatalf("Cache-Control = %q", got)
}
}
func TestHandlerKeepsHashedAssetsImmutable(t *testing.T) {
fsys := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte(`<!doctype html><title>GoClaw</title>`)},
"assets/app-abc123.js": &fstest.MapFile{Data: []byte(`console.log("ok")`)},
}
h := NewHandlerForFS(fsys, config.Default())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/assets/app-abc123.js", nil))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
t.Fatalf("Cache-Control = %q", got)
}
if strings.Contains(rec.Body.String(), "Acme") {
t.Fatalf("asset response should not be rewritten: %q", rec.Body.String())
}
}
var _ fs.FS = fstest.MapFS{}
@@ -5,6 +5,7 @@ import { useWsCall } from "@/hooks/use-ws-call";
import { Methods } from "@/api/protocol";
import type { HealthPayload } from "@/pages/overview/types";
import { cleanVersion } from "@/lib/clean-version";
import { getRuntimeBranding } from "@/lib/branding";
import {
Dialog,
DialogContent,
@@ -49,13 +50,14 @@ export function AboutDialog({ open, onOpenChange }: AboutDialogProps) {
const updateAvailable = health?.updateAvailable ?? false;
const updateUrl = health?.updateUrl;
const releaseNotes = health?.releaseNotes;
const branding = getRuntimeBranding();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2.5">
<img src="/goclaw-icon.svg" alt="GoClaw" className="h-7 w-7" />
<img src={branding.logoUrl} alt={branding.appName} className="h-7 w-7" />
{t("about.title")}
{updateAvailable && latestVersion && (
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-xs font-medium text-amber-600 dark:text-amber-400">
+5 -3
View File
@@ -42,6 +42,7 @@ import { cn } from "@/lib/utils";
import { usePendingPairingsCount } from "@/hooks/use-pending-pairings-count";
import { useAuthStore } from "@/stores/use-auth-store";
import { useTenants } from "@/hooks/use-tenants";
import { getRuntimeBranding } from "@/lib/branding";
interface SidebarProps {
collapsed: boolean;
@@ -54,6 +55,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
const role = useAuthStore((s) => s.role);
const { isOwner } = useTenants();
const isAdmin = role === "admin" || role === "owner";
const branding = getRuntimeBranding();
return (
<aside
@@ -72,14 +74,14 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
<div className="flex h-14 items-center border-b px-4">
{!collapsed && (
<div className="flex items-center gap-2.5">
<img src="/goclaw-icon.svg" alt="GoClaw" className="h-8 w-8" />
<img src={branding.logoUrl} alt={branding.appName} className="h-8 w-8" />
<span className="text-lg font-bold tracking-tight text-sidebar-primary">
GoClaw
{branding.appShortName}
</span>
</div>
)}
{collapsed && (
<img src="/goclaw-icon.svg" alt="GoClaw" className="mx-auto h-7 w-7" />
<img src={branding.logoUrl} alt={branding.appName} className="mx-auto h-7 w-7" />
)}
</div>
+32
View File
@@ -21,6 +21,38 @@
"server.title": "Gateway Server",
"server.description": "Network binding, authentication token, and access control",
"branding.title": "Appearance Settings",
"branding.description": "Public app name, metadata, icons, logo, favicon, and social preview assets",
"branding.appName": "App Name",
"branding.appNameTip": "Full application name shown in metadata and future branded UI surfaces.",
"branding.appShortName": "Short Name",
"branding.appShortNameTip": "Compact application name for small UI or install surfaces.",
"branding.themeColor": "Theme Color",
"branding.themeColorTip": "Browser theme color used by supported clients.",
"branding.metaTitle": "Meta Title",
"branding.metaTitleTip": "HTML document title. Leave empty to use the built-in default.",
"branding.metaDescription": "Meta Description",
"branding.metaDescriptionTip": "HTML meta description for search previews.",
"branding.metaDescriptionPlaceholder": "Private AI agent gateway",
"branding.metaKeywords": "Meta Keywords",
"branding.metaKeywordsTip": "Optional comma-separated keywords.",
"branding.ogTitle": "Open Graph Title",
"branding.ogTitleTip": "Title used when the site is shared on social platforms.",
"branding.ogDescription": "Open Graph Description",
"branding.ogDescriptionTip": "Description used when the site is shared on social platforms.",
"branding.logoUrl": "Logo URL",
"branding.logoUrlTip": "External URL or uploaded /branding-assets/* logo path.",
"branding.faviconUrl": "Favicon URL",
"branding.faviconUrlTip": "External URL or uploaded /branding-assets/* favicon path.",
"branding.appleTouchIconUrl": "Apple Touch Icon URL",
"branding.appleTouchIconUrlTip": "External URL or uploaded /branding-assets/* touch icon path.",
"branding.ogImageUrl": "Open Graph Image URL",
"branding.ogImageUrlTip": "External URL or uploaded /branding-assets/* image for social previews.",
"branding.upload": "Upload asset",
"branding.uploaded": "Branding asset uploaded",
"branding.uploadFailed": "Branding asset upload failed",
"branding.uploadErrors.too_large": "File must be 2 MB or smaller.",
"branding.uploadErrors.unsupported_type": "Use SVG, PNG, JPG, WEBP, or ICO files.",
"gateway.host": "Host",
"gateway.hostTip": "The network interface the gateway listens on. Use 0.0.0.0 to listen on all interfaces.",
+32
View File
@@ -21,6 +21,38 @@
"server.title": "게이트웨이 서버",
"server.description": "네트워크 바인딩, 인증 토큰 및 접근 제어",
"branding.title": "화면 설정",
"branding.description": "공개 앱 이름, 메타데이터, 아이콘, 로고, 파비콘, 소셜 미리보기 자산",
"branding.appName": "앱 이름",
"branding.appNameTip": "메타데이터와 향후 브랜드 UI에 표시할 전체 애플리케이션 이름입니다.",
"branding.appShortName": "짧은 이름",
"branding.appShortNameTip": "작은 UI나 설치 화면에 사용할 짧은 애플리케이션 이름입니다.",
"branding.themeColor": "테마 색상",
"branding.themeColorTip": "지원되는 클라이언트에서 사용하는 브라우저 테마 색상입니다.",
"branding.metaTitle": "Meta 제목",
"branding.metaTitleTip": "HTML 문서 제목입니다. 비워 두면 기본값을 사용합니다.",
"branding.metaDescription": "Meta 설명",
"branding.metaDescriptionTip": "검색 미리보기에 사용할 HTML meta description입니다.",
"branding.metaDescriptionPlaceholder": "개인 AI agent gateway",
"branding.metaKeywords": "Meta 키워드",
"branding.metaKeywordsTip": "선택 사항인 쉼표 구분 키워드입니다.",
"branding.ogTitle": "Open Graph 제목",
"branding.ogTitleTip": "소셜 플랫폼에 공유될 때 사용할 제목입니다.",
"branding.ogDescription": "Open Graph 설명",
"branding.ogDescriptionTip": "소셜 플랫폼에 공유될 때 사용할 설명입니다.",
"branding.logoUrl": "Logo URL",
"branding.logoUrlTip": "외부 URL 또는 업로드된 /branding-assets/* 로고 경로입니다.",
"branding.faviconUrl": "Favicon URL",
"branding.faviconUrlTip": "외부 URL 또는 업로드된 /branding-assets/* favicon 경로입니다.",
"branding.appleTouchIconUrl": "Apple Touch Icon URL",
"branding.appleTouchIconUrlTip": "외부 URL 또는 업로드된 /branding-assets/* touch icon 경로입니다.",
"branding.ogImageUrl": "Open Graph 이미지 URL",
"branding.ogImageUrlTip": "소셜 미리보기에 사용할 외부 URL 또는 업로드된 /branding-assets/* 이미지입니다.",
"branding.upload": "자산 업로드",
"branding.uploaded": "브랜딩 자산이 업로드되었습니다",
"branding.uploadFailed": "브랜딩 자산 업로드 실패",
"branding.uploadErrors.too_large": "파일은 2 MB 이하여야 합니다.",
"branding.uploadErrors.unsupported_type": "SVG, PNG, JPG, WEBP 또는 ICO 파일을 사용하세요.",
"gateway.host": "호스트",
"gateway.hostTip": "게이트웨이가 수신하는 네트워크 인터페이스입니다. 모든 인터페이스에서 수신하려면 0.0.0.0을 사용하세요.",
+32
View File
@@ -21,6 +21,38 @@
"server.title": "Máy chủ Gateway",
"server.description": "Cài đặt mạng, token xác thực và kiểm soát truy cập",
"branding.title": "Cài đặt giao diện",
"branding.description": "Tên ứng dụng, metadata, biểu tượng, logo, favicon và ảnh xem trước khi chia sẻ",
"branding.appName": "Tên ứng dụng",
"branding.appNameTip": "Tên đầy đủ của ứng dụng dùng trong metadata và các bề mặt giao diện có thương hiệu.",
"branding.appShortName": "Tên ngắn",
"branding.appShortNameTip": "Tên rút gọn cho các khu vực giao diện nhỏ hoặc bề mặt cài đặt.",
"branding.themeColor": "Màu theme",
"branding.themeColorTip": "Màu theme trình duyệt dùng bởi các client hỗ trợ.",
"branding.metaTitle": "Tiêu đề meta",
"branding.metaTitleTip": "Tiêu đề tài liệu HTML. Để trống để dùng mặc định tích hợp.",
"branding.metaDescription": "Mô tả meta",
"branding.metaDescriptionTip": "Mô tả meta HTML cho phần xem trước tìm kiếm.",
"branding.metaDescriptionPlaceholder": "Gateway agent AI riêng tư",
"branding.metaKeywords": "Từ khóa meta",
"branding.metaKeywordsTip": "Danh sách từ khóa tùy chọn, phân tách bằng dấu phẩy.",
"branding.ogTitle": "Tiêu đề Open Graph",
"branding.ogTitleTip": "Tiêu đề dùng khi chia sẻ trang lên nền tảng xã hội.",
"branding.ogDescription": "Mô tả Open Graph",
"branding.ogDescriptionTip": "Mô tả dùng khi chia sẻ trang lên nền tảng xã hội.",
"branding.logoUrl": "URL logo",
"branding.logoUrlTip": "URL bên ngoài hoặc đường dẫn logo đã upload dạng /branding-assets/*.",
"branding.faviconUrl": "URL favicon",
"branding.faviconUrlTip": "URL bên ngoài hoặc đường dẫn favicon đã upload dạng /branding-assets/*.",
"branding.appleTouchIconUrl": "URL Apple Touch Icon",
"branding.appleTouchIconUrlTip": "URL bên ngoài hoặc đường dẫn touch icon đã upload dạng /branding-assets/*.",
"branding.ogImageUrl": "URL ảnh Open Graph",
"branding.ogImageUrlTip": "URL bên ngoài hoặc ảnh đã upload dạng /branding-assets/* cho xem trước khi chia sẻ.",
"branding.upload": "Tải asset lên",
"branding.uploaded": "Đã tải asset thương hiệu lên",
"branding.uploadFailed": "Tải asset thương hiệu thất bại",
"branding.uploadErrors.too_large": "Tệp phải nhỏ hơn hoặc bằng 2 MB.",
"branding.uploadErrors.unsupported_type": "Dùng tệp SVG, PNG, JPG, WEBP hoặc ICO.",
"gateway.host": "Host",
"gateway.hostTip": "Giao diện mạng mà gateway lắng nghe. Dùng 0.0.0.0 để lắng nghe trên tất cả giao diện.",
+32
View File
@@ -21,6 +21,38 @@
"server.title": "网关服务器",
"server.description": "网络绑定、认证令牌和访问控制",
"branding.title": "界面设置",
"branding.description": "公开应用名称、元数据、图标、Logo、favicon 和社交分享预览资源",
"branding.appName": "应用名称",
"branding.appNameTip": "用于元数据和品牌界面的完整应用名称。",
"branding.appShortName": "短名称",
"branding.appShortNameTip": "用于紧凑界面或安装场景的短应用名称。",
"branding.themeColor": "主题颜色",
"branding.themeColorTip": "支持的客户端使用的浏览器主题颜色。",
"branding.metaTitle": "Meta 标题",
"branding.metaTitleTip": "HTML 文档标题。留空则使用内置默认值。",
"branding.metaDescription": "Meta 描述",
"branding.metaDescriptionTip": "用于搜索预览的 HTML meta description。",
"branding.metaDescriptionPlaceholder": "私有 AI agent 网关",
"branding.metaKeywords": "Meta 关键词",
"branding.metaKeywordsTip": "可选的逗号分隔关键词。",
"branding.ogTitle": "Open Graph 标题",
"branding.ogTitleTip": "页面分享到社交平台时使用的标题。",
"branding.ogDescription": "Open Graph 描述",
"branding.ogDescriptionTip": "页面分享到社交平台时使用的描述。",
"branding.logoUrl": "Logo URL",
"branding.logoUrlTip": "外部 URL 或上传后的 /branding-assets/* Logo 路径。",
"branding.faviconUrl": "Favicon URL",
"branding.faviconUrlTip": "外部 URL 或上传后的 /branding-assets/* favicon 路径。",
"branding.appleTouchIconUrl": "Apple Touch Icon URL",
"branding.appleTouchIconUrlTip": "外部 URL 或上传后的 /branding-assets/* touch icon 路径。",
"branding.ogImageUrl": "Open Graph 图片 URL",
"branding.ogImageUrlTip": "用于社交分享预览的外部 URL 或上传后的 /branding-assets/* 图片。",
"branding.upload": "上传资源",
"branding.uploaded": "品牌资源已上传",
"branding.uploadFailed": "品牌资源上传失败",
"branding.uploadErrors.too_large": "文件必须不超过 2 MB。",
"branding.uploadErrors.unsupported_type": "请使用 SVG、PNG、JPG、WEBP 或 ICO 文件。",
"gateway.host": "主机",
"gateway.hostTip": "网关监听的网络接口。使用 0.0.0.0 监听所有接口。",
+35
View File
@@ -0,0 +1,35 @@
import { afterEach, describe, expect, it } from "vitest";
import { getRuntimeBranding } from "./branding";
describe("runtime branding", () => {
afterEach(() => {
document.body.innerHTML = "";
document.head.innerHTML = "";
});
it("falls back to built-in GoClaw branding", () => {
expect(getRuntimeBranding()).toEqual({
appName: "GoClaw",
appShortName: "GoClaw",
logoUrl: "/goclaw-icon.svg",
});
});
it("reads branding injected by the server", () => {
const script = document.createElement("script");
script.id = "goclaw-branding";
script.type = "application/json";
script.textContent = JSON.stringify({
app_name: "Acme Agents",
app_short_name: "Acme",
logo_url: "/branding-assets/logo.png",
});
document.head.appendChild(script);
expect(getRuntimeBranding()).toEqual({
appName: "Acme Agents",
appShortName: "Acme",
logoUrl: "/branding-assets/logo.png",
});
});
});
+38
View File
@@ -0,0 +1,38 @@
export interface RuntimeBranding {
appName: string;
appShortName: string;
logoUrl: string;
}
const FALLBACK_BRANDING: RuntimeBranding = {
appName: "GoClaw",
appShortName: "GoClaw",
logoUrl: "/goclaw-icon.svg",
};
interface RuntimeBrandingPayload {
app_name?: unknown;
app_short_name?: unknown;
logo_url?: unknown;
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim() !== "" ? value.trim() : undefined;
}
export function getRuntimeBranding(): RuntimeBranding {
if (typeof document === "undefined") return FALLBACK_BRANDING;
const node = document.getElementById("goclaw-branding");
if (!node?.textContent) return FALLBACK_BRANDING;
try {
const payload = JSON.parse(node.textContent) as RuntimeBrandingPayload;
const appName = stringValue(payload.app_name) ?? FALLBACK_BRANDING.appName;
return {
appName,
appShortName: stringValue(payload.app_short_name) ?? appName,
logoUrl: stringValue(payload.logo_url) ?? FALLBACK_BRANDING.logoUrl,
};
} catch {
return FALLBACK_BRANDING;
}
}
+6
View File
@@ -11,6 +11,7 @@ import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useIsMobile } from "@/hooks/use-media-query";
import { ServerSection } from "./sections/server-section";
import { BrandingSection } from "./sections/branding-section";
import { BehaviorSection } from "./sections/behavior-section";
import { AiDefaultsSection } from "./sections/ai-defaults-section";
import { QuotaSection } from "./sections/quota-section";
@@ -109,6 +110,11 @@ export function ConfigPage() {
onSave={(v) => patch({ gateway: v })}
saving={saving}
/>
<BrandingSection
data={config.branding as any}
onSave={(v) => patch({ branding: v })}
saving={saving}
/>
</TabsContent>
<TabsContent value="behavior" className="space-y-4">
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
BRANDING_ASSET_ACCEPT,
MAX_BRANDING_ASSET_BYTES,
buildBrandingPatch,
normalizeBrandingDraft,
validateBrandingAssetFile,
withUploadedBrandingAssetURL,
} from "./branding-section-utils";
describe("branding-section-utils", () => {
it("builds a stable branding payload and preserves cleared fields", () => {
const draft = normalizeBrandingDraft({
app_name: "Acme AI",
meta_title: "Acme AI Console",
logo_url: "/branding-assets/logo.png",
favicon_url: "",
});
expect(buildBrandingPatch(draft)).toEqual({
app_name: "Acme AI",
meta_title: "Acme AI Console",
logo_url: "/branding-assets/logo.png",
favicon_url: "",
});
});
it("applies uploaded asset URLs to the requested media field only", () => {
const next = withUploadedBrandingAssetURL(
{ logo_url: "/old.png", og_image_url: "/og.png" },
"logo_url",
"/branding-assets/new-logo.png",
);
expect(next).toEqual({
logo_url: "/branding-assets/new-logo.png",
og_image_url: "/og.png",
});
});
it("validates branding asset extension and size before upload", () => {
expect(BRANDING_ASSET_ACCEPT).toContain(".webp");
expect(validateBrandingAssetFile({ name: "logo.png", size: MAX_BRANDING_ASSET_BYTES })).toBeNull();
expect(validateBrandingAssetFile({ name: "logo.gif", size: 10 })).toBe("unsupported_type");
expect(validateBrandingAssetFile({ name: "logo.svg", size: MAX_BRANDING_ASSET_BYTES + 1 })).toBe("too_large");
});
});
@@ -0,0 +1,60 @@
export interface BrandingData {
app_name?: string;
app_short_name?: string;
meta_title?: string;
meta_description?: string;
meta_keywords?: string;
logo_url?: string;
favicon_url?: string;
apple_touch_icon_url?: string;
og_title?: string;
og_description?: string;
og_image_url?: string;
theme_color?: string;
}
export type BrandingAssetField = "logo_url" | "favicon_url" | "apple_touch_icon_url" | "og_image_url";
export const BRANDING_ASSET_ACCEPT = ".svg,.png,.jpg,.jpeg,.webp,.ico";
export const MAX_BRANDING_ASSET_BYTES = 2 * 1024 * 1024;
const BRANDING_KEYS: Array<keyof BrandingData> = [
"app_name",
"app_short_name",
"meta_title",
"meta_description",
"meta_keywords",
"logo_url",
"favicon_url",
"apple_touch_icon_url",
"og_title",
"og_description",
"og_image_url",
"theme_color",
];
const ALLOWED_EXTENSIONS = new Set(["svg", "png", "jpg", "jpeg", "webp", "ico"]);
export function normalizeBrandingDraft(data: BrandingData | undefined): BrandingData {
const out: BrandingData = {};
for (const key of BRANDING_KEYS) {
const value = data?.[key];
if (typeof value === "string") out[key] = value;
}
return out;
}
export function buildBrandingPatch(draft: BrandingData): BrandingData {
return normalizeBrandingDraft(draft);
}
export function withUploadedBrandingAssetURL(draft: BrandingData, field: BrandingAssetField, url: string): BrandingData {
return { ...draft, [field]: url };
}
export function validateBrandingAssetFile(file: Pick<File, "name" | "size">): string | null {
if (file.size > MAX_BRANDING_ASSET_BYTES) return "too_large";
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
if (!ALLOWED_EXTENSIONS.has(ext)) return "unsupported_type";
return null;
}
@@ -0,0 +1,244 @@
import { useEffect, useRef, useState } from "react";
import { Image, Save, Upload } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { InfoLabel } from "@/components/shared/info-label";
import { useHttp } from "@/hooks/use-ws";
import { toast } from "@/stores/use-toast-store";
import {
BRANDING_ASSET_ACCEPT,
type BrandingAssetField,
type BrandingData,
buildBrandingPatch,
normalizeBrandingDraft,
validateBrandingAssetFile,
withUploadedBrandingAssetURL,
} from "./branding-section-utils";
interface Props {
data: BrandingData | undefined;
onSave: (value: BrandingData) => Promise<void>;
saving: boolean;
}
interface UploadResponse {
url: string;
}
const ASSET_FIELDS: Array<{ key: BrandingAssetField; labelKey: string; tipKey: string; placeholder: string }> = [
{
key: "logo_url",
labelKey: "branding.logoUrl",
tipKey: "branding.logoUrlTip",
placeholder: "/branding-assets/logo.png",
},
{
key: "favicon_url",
labelKey: "branding.faviconUrl",
tipKey: "branding.faviconUrlTip",
placeholder: "/branding-assets/favicon.ico",
},
{
key: "apple_touch_icon_url",
labelKey: "branding.appleTouchIconUrl",
tipKey: "branding.appleTouchIconUrlTip",
placeholder: "/branding-assets/apple-touch-icon.png",
},
{
key: "og_image_url",
labelKey: "branding.ogImageUrl",
tipKey: "branding.ogImageUrlTip",
placeholder: "/branding-assets/og-image.png",
},
];
export function BrandingSection({ data, onSave, saving }: Props) {
const { t } = useTranslation("config");
const http = useHttp();
const [draft, setDraft] = useState<BrandingData>(() => normalizeBrandingDraft(data));
const [dirty, setDirty] = useState(false);
const [uploadingField, setUploadingField] = useState<BrandingAssetField | null>(null);
const inputRefs = useRef<Record<string, HTMLInputElement | null>>({});
useEffect(() => {
setDraft(normalizeBrandingDraft(data));
setDirty(false);
}, [data]);
const update = (patch: Partial<BrandingData>) => {
setDraft((prev) => ({ ...prev, ...patch }));
setDirty(true);
};
const handleUpload = async (field: BrandingAssetField, file?: File) => {
if (!file) return;
const validation = validateBrandingAssetFile(file);
if (validation) {
toast.error(t("branding.uploadFailed"), t(`branding.uploadErrors.${validation}`));
return;
}
const form = new FormData();
form.append("file", file);
setUploadingField(field);
try {
const result = await http.upload<UploadResponse>("/v1/branding/assets", form);
setDraft((prev) => withUploadedBrandingAssetURL(prev, field, result.url));
setDirty(true);
toast.success(t("branding.uploaded"));
} catch (err) {
toast.error(t("branding.uploadFailed"), err instanceof Error ? err.message : "");
} finally {
setUploadingField(null);
const input = inputRefs.current[field];
if (input) input.value = "";
}
};
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t("branding.title")}</CardTitle>
<CardDescription>{t("branding.description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<TextField
label={t("branding.appName")}
tip={t("branding.appNameTip")}
value={draft.app_name}
onChange={(value) => update({ app_name: value })}
placeholder="GoClaw"
/>
<TextField
label={t("branding.appShortName")}
tip={t("branding.appShortNameTip")}
value={draft.app_short_name}
onChange={(value) => update({ app_short_name: value })}
placeholder="GoClaw"
/>
<TextField
label={t("branding.themeColor")}
tip={t("branding.themeColorTip")}
value={draft.theme_color}
onChange={(value) => update({ theme_color: value })}
placeholder="#111827"
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<TextField
label={t("branding.metaTitle")}
tip={t("branding.metaTitleTip")}
value={draft.meta_title}
onChange={(value) => update({ meta_title: value })}
placeholder="GoClaw"
/>
<TextField
label={t("branding.metaKeywords")}
tip={t("branding.metaKeywordsTip")}
value={draft.meta_keywords}
onChange={(value) => update({ meta_keywords: value })}
placeholder="ai, agent, gateway"
/>
</div>
<TextField
label={t("branding.metaDescription")}
tip={t("branding.metaDescriptionTip")}
value={draft.meta_description}
onChange={(value) => update({ meta_description: value })}
placeholder={t("branding.metaDescriptionPlaceholder")}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<TextField
label={t("branding.ogTitle")}
tip={t("branding.ogTitleTip")}
value={draft.og_title}
onChange={(value) => update({ og_title: value })}
placeholder="GoClaw"
/>
<TextField
label={t("branding.ogDescription")}
tip={t("branding.ogDescriptionTip")}
value={draft.og_description}
onChange={(value) => update({ og_description: value })}
placeholder={t("branding.metaDescriptionPlaceholder")}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{ASSET_FIELDS.map((field) => {
const current = draft[field.key] ?? "";
return (
<div key={field.key} className="grid gap-1.5">
<InfoLabel tip={t(field.tipKey)}>{t(field.labelKey)}</InfoLabel>
<div className="flex gap-2">
<Input
value={current}
onChange={(event) => update({ [field.key]: event.target.value })}
placeholder={field.placeholder}
/>
<input
ref={(node) => { inputRefs.current[field.key] = node; }}
type="file"
accept={BRANDING_ASSET_ACCEPT}
className="hidden"
onChange={(event) => handleUpload(field.key, event.target.files?.[0])}
/>
<Button
type="button"
variant="outline"
size="icon"
title={t("branding.upload")}
disabled={uploadingField !== null}
onClick={() => inputRefs.current[field.key]?.click()}
>
<Upload className={"h-4 w-4" + (uploadingField === field.key ? " animate-pulse" : "")} />
</Button>
</div>
{current && (
<div className="flex items-center gap-2 rounded-md border bg-muted/30 px-2 py-1.5 text-xs text-muted-foreground">
<Image className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{current}</span>
</div>
)}
</div>
);
})}
</div>
{dirty && (
<div className="flex justify-end pt-2">
<Button size="sm" onClick={() => onSave(buildBrandingPatch(draft))} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" /> {saving ? t("saving") : t("save")}
</Button>
</div>
)}
</CardContent>
</Card>
);
}
function TextField({
label,
tip,
value,
onChange,
placeholder,
}: {
label: string;
tip: string;
value: string | undefined;
onChange: (value: string) => void;
placeholder?: string;
}) {
return (
<div className="grid gap-1.5">
<InfoLabel tip={tip}>{label}</InfoLabel>
<Input value={value ?? ""} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} />
</div>
);
}
+4 -2
View File
@@ -1,6 +1,7 @@
import { Moon, Sun } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useUiStore } from "@/stores/use-ui-store";
import { getRuntimeBranding } from "@/lib/branding";
interface LoginLayoutProps {
children: React.ReactNode;
@@ -11,6 +12,7 @@ export function LoginLayout({ children, subtitle }: LoginLayoutProps) {
const { t } = useTranslation("topbar");
const theme = useUiStore((s) => s.theme);
const setTheme = useUiStore((s) => s.setTheme);
const branding = getRuntimeBranding();
const isDark =
theme === "dark" ||
(theme === "system" &&
@@ -29,8 +31,8 @@ export function LoginLayout({ children, subtitle }: LoginLayoutProps) {
</button>
<div className="w-full max-w-sm space-y-6 rounded-lg border bg-card p-6 shadow-sm sm:p-8">
<div className="text-center">
<img src="/goclaw-icon.svg" alt="GoClaw" className="mx-auto mb-3 h-20 w-20" />
<h1 className="text-3xl font-bold tracking-tight">GoClaw</h1>
<img src={branding.logoUrl} alt={branding.appName} className="mx-auto mb-3 h-20 w-20" />
<h1 className="text-3xl font-bold tracking-tight">{branding.appName}</h1>
{subtitle && (
<p className="mt-2 text-sm text-muted-foreground">{subtitle}</p>
)}
+3 -1
View File
@@ -12,6 +12,7 @@ import { usePackageRuntimes } from "./hooks/use-package-runtimes";
import { RuntimesStickyHeader } from "./runtimes-sticky-header";
import { useUpdates } from "./hooks/use-updates";
import { UpdatesList } from "./components/updates-list";
import { getRuntimeBranding } from "@/lib/branding";
// --- Lazy tab bodies (each is a separate chunk) ---
const SystemPackagesTab = lazy(() =>
@@ -46,9 +47,10 @@ function isValidTab(v: string | null): v is TabId {
// --- Tab fallback skeleton ---
function TabLoader() {
const branding = getRuntimeBranding();
return (
<div className="py-8 flex justify-center text-muted-foreground">
<img src="/goclaw-icon.svg" alt="" className="h-6 w-6 animate-pulse opacity-40" />
<img src={branding.logoUrl} alt="" className="h-6 w-6 animate-pulse opacity-40" />
</div>
);
}
+4 -2
View File
@@ -1,14 +1,16 @@
import { useTranslation } from "react-i18next";
import { getRuntimeBranding } from "@/lib/branding";
export function SetupLayout({ children }: { children: React.ReactNode }) {
const { t } = useTranslation("setup");
const branding = getRuntimeBranding();
return (
<div className="flex min-h-dvh items-start justify-center bg-background px-4 py-8 sm:items-center">
<div className="w-full max-w-2xl space-y-6 overflow-y-auto max-h-dvh sm:max-h-none">
<div className="text-center">
<img src="/goclaw-icon.svg" alt="GoClaw" className="mx-auto mb-4 h-16 w-16" />
<h1 className="text-4xl font-bold tracking-tight">GoClaw Setup</h1>
<img src={branding.logoUrl} alt={branding.appName} className="mx-auto mb-4 h-16 w-16" />
<h1 className="text-4xl font-bold tracking-tight">{branding.appName} Setup</h1>
<p className="mt-2 text-sm text-muted-foreground">
{t("layout.subtitle", "Let's get your gateway up and running")}
</p>