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:
1 parent
77b688c981
commit
4ff965a2b4
3 files changed
+127
-12
No files matched your search
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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>⚠ Security Notice</strong>
|
<strong>⚠ 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">🌐 Website</a>
|
<a href="https://simpleremoter.com/" target="_blank">🌐 Website</a>
|
||||||
<a href="https://git.simpleremoter.com/" target="_blank">📦 Source Code</a>
|
<a href="https://git.simpleremoter.com/" target="_blank">📦 Source Code</a>
|
||||||
</div>
|
</div>
|
||||||
|
<!--BRAND-LINKS-END-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user