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
This commit is contained in:
yuanyuanxianganddeepseek-v4-pro committed 2026-10-15 19:43:58 +02:00
1 parent 77b688c981
commit 4ff965a2b4
3 files changed
+127 -12

No files matched your search

+9
View File
@@ -998,6 +998,14 @@ func main() {
if basePath != "" { if basePath != "" {
log.Info("Serving web UI under base path %q (subpath deployment)", 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 // SSO: an optional detector bridges an already-authenticated portal
// session into a YAMA web session via /sso — but only for portal // session into a YAMA web session via /sso — but only for portal
// administrators. Any other portal role (viewer, unknown) still has to // administrators. Any other portal role (viewer, unknown) still has to
@@ -1017,6 +1025,7 @@ func main() {
LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute), LoginUserLimit: wsauth.NewRateLimiter(5, 15*time.Minute),
TrustForwardedFor: trustProxy, TrustForwardedFor: trustProxy,
BasePath: basePath, BasePath: basePath,
BrandName: brandName,
SSO: ssoDetector, SSO: ssoDetector,
} }
+111 -9
View File
@@ -6,12 +6,15 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html"
"net" "net"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode"
"unicode/utf8"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub" "github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger" "github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
@@ -33,6 +36,7 @@ type Server struct {
loginUserLimit *wsauth.RateLimiter loginUserLimit *wsauth.RateLimiter
trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only) trustForwardedFor bool // honor X-Forwarded-For (behind trusted proxy only)
basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root basePath string // web UI mount prefix (e.g. "/remoter"); "" = domain root
brandName string // white-label title; "" = "SimpleRemoter" (official branding)
sso SSODetector // nil = SSO disabled sso SSODetector // nil = SSO disabled
sweepDone chan struct{} // closes to stop the login-limiter sweeper sweepDone chan struct{} // closes to stop the login-limiter sweeper
sweepStopOnce sync.Once sweepStopOnce sync.Once
@@ -64,6 +68,11 @@ type Config struct {
// https://example.com/remoter). Must be empty or a leading-slash, // https://example.com/remoter). Must be empty or a leading-slash,
// no-trailing-slash path; "" (default) serves from the domain root. // no-trailing-slash path; "" (default) serves from the domain root.
BasePath string 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 // SSO is an optional detector that maps an already-authenticated portal
// session (cookie/header) into a YAMA web session via /sso. nil disables // 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. // 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.loginUserLimit = cfg.LoginUserLimit
s.trustForwardedFor = cfg.TrustForwardedFor s.trustForwardedFor = cfg.TrustForwardedFor
s.basePath = cfg.BasePath s.basePath = cfg.BasePath
s.brandName = resolveBrandName(cfg.BrandName, s.basePath)
s.sso = cfg.SSO s.sso = cfg.SSO
return s return s
} }
@@ -182,6 +192,10 @@ func (s *Server) Stop() {
// configured base path at serve time. Empty base path → "" → root URLs. // configured base path at serve time. Empty base path → "" → root URLs.
const basePathPlaceholder = "__BASE_PATH__" 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 // 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 // is empty the mux is returned unchanged (domain-root serving, 方案A). Otherwise
// the bare prefix redirects to prefix+"/", paths outside the prefix 404, and // 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 return
} }
body := bytes.ReplaceAll(IndexHTML, []byte(basePathPlaceholder), []byte(s.basePath)) 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("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(body) _, _ = w.Write(body)
@@ -273,15 +292,36 @@ func (s *Server) manifestJSON() []byte {
if s.basePath != "" { if s.basePath != "" {
start = s.basePath + "/" start = s.basePath + "/"
} }
return []byte(fmt.Sprintf(`{ name := s.displayName()
"name": "SimpleRemoter", shortName := "Remoter"
"short_name": "Remoter", if s.isWhiteLabel() {
"start_url": "%s", shortName = name
"display": "standalone", }
"orientation": "any", // Marshal via encoding/json so an operator-supplied brand name containing
"background_color": "#1a1a2e", // quotes or backslashes can't produce invalid JSON.
"theme_color": "#1a1a2e" manifest := struct {
}`, start)) 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) { 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()) _, _ = 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
// <!--BRAND-<name>-START--> … <!--BRAND-<name>-END--> (markers included).
func stripBrandBlock(body []byte, name string) []byte {
start := []byte("<!--BRAND-" + name + "-START-->")
end := []byte("<!--BRAND-" + name + "-END-->")
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 // 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.) // with the given content-type. Used for embedded third-party assets (xterm.js etc.)
// that change infrequently — 1-day browser cache is fine. // that change infrequently — 1-day browser cache is fine.
+7 -3
View File
@@ -3,11 +3,11 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>SimpleRemoter</title> <title>__BRAND_NAME__</title>
<!-- PWA / iOS Standalone App Support --> <!-- PWA / iOS Standalone App Support -->
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Remoter"> <meta name="apple-mobile-web-app-title" content="__BRAND_NAME__">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1a1a2e"> <meta name="theme-color" content="#1a1a2e">
<link rel="manifest" href="__BASE_PATH__/manifest.json"> <link rel="manifest" href="__BASE_PATH__/manifest.json">
@@ -1154,7 +1154,7 @@
<div id="login-page" class="page active"> <div id="login-page" class="page active">
<div class="login-form"> <div class="login-form">
<h1>SimpleRemoter</h1> <h1>__BRAND_NAME__</h1>
<div id="ws-status" class="conn-status disconnected">Connecting...</div> <div id="ws-status" class="conn-status disconnected">Connecting...</div>
<input type="text" id="username" placeholder="Username" value="admin"> <input type="text" id="username" placeholder="Username" value="admin">
<div class="password-wrapper"> <div class="password-wrapper">
@@ -1164,14 +1164,18 @@
<button id="login-btn" onclick="login()" disabled>Login</button> <button id="login-btn" onclick="login()" disabled>Login</button>
<div id="login-error" class="error-msg"></div> <div id="login-error" class="error-msg"></div>
<div class="login-footer"> <div class="login-footer">
<!--BRAND-NOTICE-START-->
<div class="security-notice"> <div class="security-notice">
<strong>&#x26A0; Security Notice</strong> <strong>&#x26A0; Security Notice</strong>
This software is for lawful, authorized, and legitimate use only. You are solely responsible for your use and for complying with applicable law. The developer assumes no liability. This software is for lawful, authorized, and legitimate use only. You are solely responsible for your use and for complying with applicable law. The developer assumes no liability.
</div> </div>
<!--BRAND-NOTICE-END-->
<!--BRAND-LINKS-START-->
<div class="login-links"> <div class="login-links">
<a href="https://simpleremoter.com/" target="_blank">&#x1F310; Website</a> <a href="https://simpleremoter.com/" target="_blank">&#x1F310; Website</a>
<a href="https://git.simpleremoter.com/" target="_blank">&#x1F4E6; Source Code</a> <a href="https://git.simpleremoter.com/" target="_blank">&#x1F4E6; Source Code</a>
</div> </div>
<!--BRAND-LINKS-END-->
</div> </div>
</div> </div>
</div> </div>