mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
feat: add configurable appearance settings (#1384)
Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com>
This commit is contained in:
1 parent
4d3f0a4e1d
commit
086532484d
24 files changed
+1318
-14
No files matched your search
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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) }
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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을 사용하세요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 监听所有接口。",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user