From 4ff965a2b4faa2e56e834de091cc505ea4b67a02 Mon Sep 17 00:00:00 2001 From: yuanyuanxiang <962914132@qq.com> Date: Thu, 15 Oct 2026 16:59:50 +0200 Subject: [PATCH] Feat: White-label web UI branding for subpath deployments When the web remote desktop is mounted under another domain's subpath (YAMA_WEB_BASE_PATH non-empty), read the product title from YAMA_WEB_BRAND_NAME, substitute it for the SimpleRemoter titles and PWA manifest name, and strip the security notice and website links. The brand name is JSON- and HTML-escaped so operator input can't produce malformed output. Served from the domain root, the official SimpleRemoter branding is left unchanged. Co-Authored-By: deepseek-v4-pro --- server/go/cmd/main.go | 9 +++ server/go/web/server.go | 120 +++++++++++++++++++++++++++++++++++++--- server/web/index.html | 10 +++- 3 files changed, 127 insertions(+), 12 deletions(-) diff --git a/server/go/cmd/main.go b/server/go/cmd/main.go index af6a717..0a55ccd 100644 --- a/server/go/cmd/main.go +++ b/server/go/cmd/main.go @@ -998,6 +998,14 @@ func main() { if basePath != "" { log.Info("Serving web UI under base path %q (subpath deployment)", basePath) } + // White-label title: when the web UI is mounted under another domain + // (basePath != ""), replace the SimpleRemoter branding with this name. + // YAMA_WEB_BRAND_NAME is human-editable; empty falls back to the base + // path's last segment (e.g. "/remoter" → "Remoter"). + brandName := strings.TrimSpace(os.Getenv("YAMA_WEB_BRAND_NAME")) + if brandName != "" { + log.Info("White-label web UI title %q (YAMA_WEB_BRAND_NAME)", brandName) + } // SSO: an optional detector bridges an already-authenticated portal // session into a YAMA web session via /sso — but only for portal // administrators. Any other portal role (viewer, unknown) still has to @@ -1017,6 +1025,7 @@ func main() { LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute), TrustForwardedFor: trustProxy, BasePath: basePath, + BrandName: brandName, SSO: ssoDetector, } diff --git a/server/go/web/server.go b/server/go/web/server.go index 2c41217..4fea6e6 100644 --- a/server/go/web/server.go +++ b/server/go/web/server.go @@ -6,12 +6,15 @@ import ( "encoding/json" "errors" "fmt" + "html" "net" "net/http" "strconv" "strings" "sync" "time" + "unicode" + "unicode/utf8" "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" "github.com/yuanyuanxiang/SimpleRemoter/server/go/logger" @@ -33,6 +36,7 @@ type Server struct { loginUserLimit *wsauth.RateLimiter trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only) basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root + brandName string // white-label title; "" = "SimpleRemoter" (official branding) sso SSODetector // nil = SSO disabled sweepDone chan struct{} // closes to stop the login-limiter sweeper sweepStopOnce sync.Once @@ -64,6 +68,11 @@ type Config struct { // https://example.com/remoter). Must be empty or a leading-slash, // no-trailing-slash path; "" (default) serves from the domain root. BasePath string + // BrandName is the white-label product title shown in index.html and the + // PWA manifest when BasePath is non-empty (i.e. mounted under another + // domain). Read from the YAMA_WEB_BRAND_NAME env var by cmd. Empty leaves + // the official "SimpleRemoter" branding in place. + BrandName string // SSO is an optional detector that maps an already-authenticated portal // session (cookie/header) into a YAMA web session via /sso. nil disables // SSO and the front-end falls back to the built-in login form. @@ -85,6 +94,7 @@ func (s *Server) WithConfig(cfg Config) *Server { s.loginUserLimit = cfg.LoginUserLimit s.trustForwardedFor = cfg.TrustForwardedFor s.basePath = cfg.BasePath + s.brandName = resolveBrandName(cfg.BrandName, s.basePath) s.sso = cfg.SSO return s } @@ -182,6 +192,10 @@ func (s *Server) Stop() { // configured base path at serve time. Empty base path → "" → root URLs. const basePathPlaceholder = "__BASE_PATH__" +// brandPlaceholder is the token in index.html substituted with the product +// title at serve time (official or white-label, depending on BasePath). +const brandPlaceholder = "__BRAND_NAME__" + // mount wraps the root-path mux with the configured base path. When basePath // is empty the mux is returned unchanged (domain-root serving, 方案A). Otherwise // the bare prefix redirects to prefix+"/", paths outside the prefix 404, and @@ -210,6 +224,11 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { return } body := bytes.ReplaceAll(IndexHTML, []byte(basePathPlaceholder), []byte(s.basePath)) + body = bytes.ReplaceAll(body, []byte(brandPlaceholder), []byte(html.EscapeString(s.displayName()))) + if s.isWhiteLabel() { + body = stripBrandBlock(body, "NOTICE") + body = stripBrandBlock(body, "LINKS") + } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") _, _ = w.Write(body) @@ -273,15 +292,36 @@ func (s *Server) manifestJSON() []byte { if s.basePath != "" { start = s.basePath + "/" } - return []byte(fmt.Sprintf(`{ - "name": "SimpleRemoter", - "short_name": "Remoter", - "start_url": "%s", - "display": "standalone", - "orientation": "any", - "background_color": "#1a1a2e", - "theme_color": "#1a1a2e" -}`, start)) + name := s.displayName() + shortName := "Remoter" + if s.isWhiteLabel() { + shortName = name + } + // Marshal via encoding/json so an operator-supplied brand name containing + // quotes or backslashes can't produce invalid JSON. + manifest := struct { + Name string `json:"name"` + ShortName string `json:"short_name"` + StartURL string `json:"start_url"` + Display string `json:"display"` + Orientation string `json:"orientation"` + BackgroundColor string `json:"background_color"` + ThemeColor string `json:"theme_color"` + }{ + Name: name, + ShortName: shortName, + StartURL: start, + Display: "standalone", + Orientation: "any", + BackgroundColor: "#1a1a2e", + ThemeColor: "#1a1a2e", + } + b, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + // A struct of plain strings cannot fail to marshal. + return []byte(`{"start_url":"/","display":"standalone"}`) + } + return b } func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { @@ -290,6 +330,68 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(s.manifestJSON()) } +// displayName returns the product title shown in the UI: the configured brand +// name when set, otherwise the official "SimpleRemoter". +func (s *Server) displayName() string { + if s.brandName != "" { + return s.brandName + } + return "SimpleRemoter" +} + +// isWhiteLabel reports whether the web UI is mounted under another domain's +// subpath (BasePath non-empty). In that case the official SimpleRemoter +// branding (security notice, website links) is stripped and the configured +// brand name is shown instead. +func (s *Server) isWhiteLabel() bool { + return s.basePath != "" +} + +// resolveBrandName picks the effective white-label title: the configured value +// when non-empty, else a fallback derived from the base path's last segment +// (e.g. "/remoter" → "Remoter"). Empty base path means the UI is served at the +// domain root, so it returns "" (official "SimpleRemoter") and ignores any +// configured name — white-label branding only applies to subpath deployment. +func resolveBrandName(configured, basePath string) string { + if basePath == "" { + return "" + } + if configured != "" { + return configured + } + seg := strings.Trim(basePath, "/") + if i := strings.LastIndex(seg, "/"); i >= 0 { + seg = seg[i+1:] + } + return capitalizeFirst(seg) +} + +// capitalizeFirst upper-cases the first rune, UTF-8 safe (no byte slicing). +func capitalizeFirst(s string) string { + if s == "" { + return s + } + r, size := utf8.DecodeRuneInString(s) + return string(unicode.ToUpper(r)) + s[size:] +} + +// stripBrandBlock removes the index.html block delimited by +// … (markers included). +func stripBrandBlock(body []byte, name string) []byte { + start := []byte("") + end := []byte("") + i := bytes.Index(body, start) + if i < 0 { + return body + } + j := bytes.Index(body[i:], end) + if j < 0 { + return body + } + j += i + len(end) + return append(body[:i], body[j:]...) +} + // staticHandler returns an http.HandlerFunc that serves a fixed byte slice // with the given content-type. Used for embedded third-party assets (xterm.js etc.) // that change infrequently — 1-day browser cache is fine. diff --git a/server/web/index.html b/server/web/index.html index 11af653..63631d1 100644 --- a/server/web/index.html +++ b/server/web/index.html @@ -3,11 +3,11 @@ - SimpleRemoter + __BRAND_NAME__ - + @@ -1154,7 +1154,7 @@
-

SimpleRemoter

+

__BRAND_NAME__

Connecting...
@@ -1164,14 +1164,18 @@