From b73648651d37cbef72f389a149d892f3dba5ba73 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sun, 6 Sep 2026 12:26:29 +0200 Subject: [PATCH] feat(console): add Client Branding for desktop remotes Admin page and Go /api/branding schema v1 with logo/contact fields, RustDesk-safe heartbeat projections, i18n, and handler tests. Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow) --- betterdesk-server/api/auth_handlers.go | 2 +- betterdesk-server/api/branding_handlers.go | 278 +++++++++++++++--- .../api/branding_handlers_test.go | 126 ++++++++ betterdesk-server/api/client_api_handlers.go | 64 ++-- docs/features/CLIENT_BRANDING.md | 46 +++ web-nodejs/lang/ar.json | 26 +- web-nodejs/lang/cs.json | 26 +- web-nodejs/lang/da.json | 26 +- web-nodejs/lang/de.json | 26 +- web-nodejs/lang/en.json | 24 ++ web-nodejs/lang/es.json | 26 +- web-nodejs/lang/fi.json | 26 +- web-nodejs/lang/fr.json | 26 +- web-nodejs/lang/hi.json | 26 +- web-nodejs/lang/hu.json | 26 +- web-nodejs/lang/id.json | 26 +- web-nodejs/lang/it.json | 26 +- web-nodejs/lang/ja.json | 26 +- web-nodejs/lang/ko.json | 26 +- web-nodejs/lang/nb.json | 26 +- web-nodejs/lang/nl.json | 26 +- web-nodejs/lang/pl.json | 26 +- web-nodejs/lang/pt.json | 26 +- web-nodejs/lang/ro.json | 26 +- web-nodejs/lang/sv.json | 26 +- web-nodejs/lang/th.json | 26 +- web-nodejs/lang/tr.json | 26 +- web-nodejs/lang/uk.json | 26 +- web-nodejs/lang/vi.json | 26 +- web-nodejs/lang/zh-TW.json | 26 +- web-nodejs/lang/zh.json | 26 +- web-nodejs/public/css/client-branding.css | 115 ++++++++ web-nodejs/public/js/client-branding.js | 232 +++++++++++++++ web-nodejs/routes/client-branding.routes.js | 92 ++++++ web-nodejs/routes/index.js | 1 + web-nodejs/views/client-branding.ejs | 104 +++++++ web-nodejs/views/partials/sidebar.ejs | 8 +- web-nodejs/views/partials/ux35-sidebar.ejs | 6 + 38 files changed, 1665 insertions(+), 83 deletions(-) create mode 100644 betterdesk-server/api/branding_handlers_test.go create mode 100644 docs/features/CLIENT_BRANDING.md create mode 100644 web-nodejs/public/css/client-branding.css create mode 100644 web-nodejs/public/js/client-branding.js create mode 100644 web-nodejs/routes/client-branding.routes.js create mode 100644 web-nodejs/views/client-branding.ejs diff --git a/betterdesk-server/api/auth_handlers.go b/betterdesk-server/api/auth_handlers.go index bedf426f..7a9870a4 100644 --- a/betterdesk-server/api/auth_handlers.go +++ b/betterdesk-server/api/auth_handlers.go @@ -1171,7 +1171,7 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { path == "/api/login" || path == "/api/login-options" || path == "/api/logout" || path == "/api/oidc/auth" || path == "/api/oidc/auth-query" || path == "/api/oidc/callback" || path == "/api/heartbeat" || path == "/api/sysinfo" || path == "/api/sysinfo_ver" || - path == "/api/branding" || + (path == "/api/branding" && r.Method == http.MethodGet) || path == "/api/server-key" || path == "/api/server-key/fingerprint" || path == "/api/software" || path == "/api/software/client-download-link" || path == "/api/audit/conn" && r.Method == http.MethodPost || diff --git a/betterdesk-server/api/branding_handlers.go b/betterdesk-server/api/branding_handlers.go index 1b8f09be..2c83890a 100644 --- a/betterdesk-server/api/branding_handlers.go +++ b/betterdesk-server/api/branding_handlers.go @@ -6,9 +6,11 @@ import ( "fmt" "log" "net/http" + "regexp" "strconv" "strings" "time" + "unicode/utf8" "github.com/unitronix/betterdesk-server/db" "github.com/unitronix/betterdesk-server/events" @@ -18,13 +20,51 @@ import ( // Branding configuration — served to desktop clients (public, no auth) // --------------------------------------------------------------------------- +const ( + brandingSchemaVersion = 1 + brandingMaxFieldLen = 256 + brandingMaxWebsiteLen = 512 + brandingMaxLogoBytes = 512 * 1024 +) + +// BrandingLogo is an optional image payload for BetterDesk desktop clients. +type BrandingLogo struct { + Mime string `json:"mime,omitempty"` + DataBase64 string `json:"data_base64,omitempty"` + URL string `json:"url,omitempty"` +} + +// BrandingBetterDeskProfile lists fields BetterDesk clients should apply. +type BrandingBetterDeskProfile struct { + Apply []string `json:"apply"` +} + +// BrandingRustDeskProfile is the heartbeat-safe subset for stock RustDesk. +type BrandingRustDeskProfile struct { + ConfigOptions map[string]string `json:"config_options,omitempty"` +} + +// BrandingProfiles separates BetterDesk-rich and RustDesk-compatible projections. +type BrandingProfiles struct { + BetterDesk BrandingBetterDeskProfile `json:"betterdesk"` + RustDesk BrandingRustDeskProfile `json:"rustdesk"` +} + // BrandingConfig is the payload returned by GET /api/branding. // Desktop clients fetch this to apply company theming. +// Legacy fields are preserved for older clients / enrollment payloads. type BrandingConfig struct { + SchemaVersion int `json:"schema_version"` + Revision string `json:"revision"` CompanyName string `json:"company_name"` + Phone string `json:"phone,omitempty"` + Email string `json:"email,omitempty"` + Website string `json:"website,omitempty"` AccentColor string `json:"accent_color"` SupportContact string `json:"support_contact"` + Logo *BrandingLogo `json:"logo,omitempty"` Colors map[string]string `json:"colors,omitempty"` + Profiles BrandingProfiles `json:"profiles"` SyncModes []SyncModeOption `json:"sync_modes"` } @@ -41,21 +81,51 @@ var defaultSyncModes = []SyncModeOption{ {ID: "turbo", Label: "Turbo", Description: "Aggressive — 10s telemetry, 1min disk, 30min software"}, } -// handleGetBranding returns the branding configuration. -// Public endpoint — no authentication required. -// GET /api/branding -func (s *Server) handleGetBranding(w http.ResponseWriter, r *http.Request) { +var ( + accentColorRegexp = regexp.MustCompile(`(?i)^#([0-9a-f]{6}|[0-9a-f]{3})$`) + allowedLogoMimes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/jpg": true, + "image/webp": true, + } +) + +func defaultBetterDeskApply() []string { + return []string{"company_name", "phone", "email", "website", "logo", "accent_color"} +} + +// loadBrandingConfig reads Client Branding from server_config with safe defaults. +func (s *Server) loadBrandingConfig() BrandingConfig { cfg := BrandingConfig{ + SchemaVersion: brandingSchemaVersion, + Revision: "0", CompanyName: "BetterDesk", AccentColor: "#4f6ef7", SupportContact: "", SyncModes: defaultSyncModes, + Profiles: BrandingProfiles{ + BetterDesk: BrandingBetterDeskProfile{Apply: defaultBetterDeskApply()}, + RustDesk: BrandingRustDeskProfile{ConfigOptions: map[string]string{}}, + }, + } + + if s == nil || s.db == nil { + return cfg } - // Load overrides from server_config if v, err := s.db.GetConfig("branding_company_name"); err == nil && v != "" { cfg.CompanyName = v } + if v, err := s.db.GetConfig("branding_phone"); err == nil && v != "" { + cfg.Phone = v + } + if v, err := s.db.GetConfig("branding_email"); err == nil && v != "" { + cfg.Email = v + } + if v, err := s.db.GetConfig("branding_website"); err == nil && v != "" { + cfg.Website = v + } if v, err := s.db.GetConfig("branding_accent_color"); err == nil && v != "" { cfg.AccentColor = v } @@ -68,7 +138,116 @@ func (s *Server) handleGetBranding(w http.ResponseWriter, r *http.Request) { cfg.Colors = colors } } + if v, err := s.db.GetConfig("branding_logo"); err == nil && v != "" { + var logo BrandingLogo + if json.Unmarshal([]byte(v), &logo) == nil && (logo.DataBase64 != "" || logo.URL != "") { + cfg.Logo = &logo + } + } + if v, err := s.db.GetConfig("branding_revision"); err == nil && v != "" { + cfg.Revision = v + } + cfg.Profiles.RustDesk.ConfigOptions = rustDeskConfigOptionsFromBranding(cfg) + return cfg +} + +func rustDeskConfigOptionsFromBranding(cfg BrandingConfig) map[string]string { + out := map[string]string{} + name := strings.TrimSpace(cfg.CompanyName) + if name != "" && !strings.EqualFold(name, "BetterDesk") { + out["display-name"] = name + } + return out +} + +func brandingRevisionMillis(cfg BrandingConfig) int64 { + if cfg.Revision == "" || cfg.Revision == "0" { + return 0 + } + if ms, err := strconv.ParseInt(cfg.Revision, 10, 64); err == nil { + return ms + } + return 0 +} + +func stripHTMLLike(s string) string { + s = strings.ReplaceAll(s, "<", "") + s = strings.ReplaceAll(s, ">", "") + return strings.TrimSpace(s) +} + +func validateAccentColor(color string) error { + color = strings.TrimSpace(color) + if color == "" { + return nil + } + if !accentColorRegexp.MatchString(color) { + return fmt.Errorf("accent_color must be #RGB or #RRGGBB") + } + return nil +} + +func validateBrandingLogo(logo *BrandingLogo) error { + if logo == nil { + return nil + } + logo.Mime = strings.ToLower(strings.TrimSpace(logo.Mime)) + logo.DataBase64 = strings.TrimSpace(logo.DataBase64) + logo.URL = strings.TrimSpace(logo.URL) + + if logo.DataBase64 == "" && logo.URL == "" { + return nil + } + if logo.DataBase64 != "" { + if logo.Mime == "" { + return fmt.Errorf("logo.mime is required with data_base64") + } + if !allowedLogoMimes[logo.Mime] { + return fmt.Errorf("logo.mime must be image/png, image/jpeg, or image/webp") + } + raw, err := base64.StdEncoding.DecodeString(logo.DataBase64) + if err != nil { + // Accept URL-safe / raw without padding variants used by browsers. + raw, err = base64.RawStdEncoding.DecodeString(logo.DataBase64) + if err != nil { + return fmt.Errorf("logo.data_base64 is invalid") + } + } + if len(raw) == 0 || len(raw) > brandingMaxLogoBytes { + return fmt.Errorf("logo must be between 1 byte and 512 KiB") + } + logo.URL = "" // prefer embedded payload when both present + } + if logo.URL != "" { + lower := strings.ToLower(logo.URL) + if !strings.HasPrefix(lower, "https://") && !strings.HasPrefix(lower, "http://") { + return fmt.Errorf("logo.url must be http(s)") + } + if strings.ContainsAny(logo.URL, "<>\"'") { + return fmt.Errorf("logo.url contains invalid characters") + } + if utf8.RuneCountInString(logo.URL) > brandingMaxWebsiteLen { + return fmt.Errorf("logo.url is too long") + } + } + return nil +} + +func clipBrandingField(s string, max int) string { + s = stripHTMLLike(s) + if utf8.RuneCountInString(s) <= max { + return s + } + runes := []rune(s) + return string(runes[:max]) +} + +// handleGetBranding returns the branding configuration. +// Public endpoint — no authentication required. +// GET /api/branding +func (s *Server) handleGetBranding(w http.ResponseWriter, r *http.Request) { + cfg := s.loadBrandingConfig() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(cfg) } @@ -78,36 +257,89 @@ func (s *Server) handleGetBranding(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSaveBranding(w http.ResponseWriter, r *http.Request) { var req struct { CompanyName *string `json:"company_name"` + Phone *string `json:"phone"` + Email *string `json:"email"` + Website *string `json:"website"` AccentColor *string `json:"accent_color"` SupportContact *string `json:"support_contact"` Colors map[string]string `json:"colors"` + Logo *BrandingLogo `json:"logo"` + ClearLogo *bool `json:"clear_logo"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return } + if req.AccentColor != nil { + if err := validateAccentColor(*req.AccentColor); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + if req.Logo != nil { + if err := validateBrandingLogo(req.Logo); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + if req.CompanyName != nil { - s.db.SetConfig("branding_company_name", *req.CompanyName) + s.db.SetConfig("branding_company_name", clipBrandingField(*req.CompanyName, brandingMaxFieldLen)) + } + if req.Phone != nil { + s.db.SetConfig("branding_phone", clipBrandingField(*req.Phone, brandingMaxFieldLen)) + } + if req.Email != nil { + s.db.SetConfig("branding_email", clipBrandingField(*req.Email, brandingMaxFieldLen)) + } + if req.Website != nil { + s.db.SetConfig("branding_website", clipBrandingField(*req.Website, brandingMaxWebsiteLen)) } if req.AccentColor != nil { - s.db.SetConfig("branding_accent_color", *req.AccentColor) + s.db.SetConfig("branding_accent_color", strings.TrimSpace(*req.AccentColor)) } if req.SupportContact != nil { - s.db.SetConfig("branding_support_contact", *req.SupportContact) + s.db.SetConfig("branding_support_contact", clipBrandingField(*req.SupportContact, brandingMaxFieldLen)) } if req.Colors != nil { - if data, err := json.Marshal(req.Colors); err == nil { + cleaned := make(map[string]string, len(req.Colors)) + for k, v := range req.Colors { + k = clipBrandingField(k, 64) + v = clipBrandingField(v, 64) + if k == "" { + continue + } + cleaned[k] = v + } + if data, err := json.Marshal(cleaned); err == nil { s.db.SetConfig("branding_colors", string(data)) } } + if req.ClearLogo != nil && *req.ClearLogo { + _ = s.db.DeleteConfig("branding_logo") + } else if req.Logo != nil { + if req.Logo.DataBase64 == "" && req.Logo.URL == "" { + _ = s.db.DeleteConfig("branding_logo") + } else if data, err := json.Marshal(req.Logo); err == nil { + s.db.SetConfig("branding_logo", string(data)) + } + } + + revision := strconv.FormatInt(time.Now().UnixMilli(), 10) + s.db.SetConfig("branding_revision", revision) if s.auditLog != nil { s.auditLog.Log("branding_updated", s.remoteIP(r), getUsernameFromCtx(r), nil) } + cfg := s.loadBrandingConfig() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "revision": revision, + "branding": cfg, + }) } // --------------------------------------------------------------------------- @@ -1129,29 +1361,9 @@ func (s *Server) buildEnrollmentResponse(status, deviceID, syncMode, displayName HeartbeatSec: 15, } - // Inline branding - branding := &BrandingConfig{ - CompanyName: "BetterDesk", - AccentColor: "#4f6ef7", - SupportContact: "", - SyncModes: defaultSyncModes, - } - if v, _ := s.db.GetConfig("branding_company_name"); v != "" { - branding.CompanyName = v - } - if v, _ := s.db.GetConfig("branding_accent_color"); v != "" { - branding.AccentColor = v - } - if v, _ := s.db.GetConfig("branding_support_contact"); v != "" { - branding.SupportContact = v - } - if v, _ := s.db.GetConfig("branding_colors"); v != "" { - var colors map[string]string - if json.Unmarshal([]byte(v), &colors) == nil { - branding.Colors = colors - } - } - resp.Branding = branding + // Inline branding (same source as GET /api/branding) + branding := s.loadBrandingConfig() + resp.Branding = &branding // Server public key if s.keyPair != nil { diff --git a/betterdesk-server/api/branding_handlers_test.go b/betterdesk-server/api/branding_handlers_test.go new file mode 100644 index 00000000..937e4da7 --- /dev/null +++ b/betterdesk-server/api/branding_handlers_test.go @@ -0,0 +1,126 @@ +package api + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/unitronix/betterdesk-server/config" + "github.com/unitronix/betterdesk-server/peer" +) + +func TestBrandingGetDefaultsAndSave(t *testing.T) { + cfg := config.DefaultConfig() + database := testSetupDB(t) + defer database.Close() + + cfg.APIPort = 19890 + srv := New(cfg, database, peer.NewMap(), nil, "1.0.0-test") + if err := srv.Start(t.Context()); err != nil { + t.Fatal(err) + } + defer srv.Stop() + time.Sleep(80 * time.Millisecond) + + base := fmt.Sprintf("http://127.0.0.1:%d/api", cfg.APIPort) + + resp, err := http.Get(base + "/branding") + if err != nil { + t.Fatalf("GET /branding: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET status %d", resp.StatusCode) + } + var got BrandingConfig + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatal(err) + } + if got.SchemaVersion != brandingSchemaVersion { + t.Fatalf("schema_version=%d", got.SchemaVersion) + } + if got.CompanyName != "BetterDesk" { + t.Fatalf("company=%q", got.CompanyName) + } + if len(got.Profiles.BetterDesk.Apply) == 0 { + t.Fatal("expected betterdesk apply list") + } + + png1x1 := base64.StdEncoding.EncodeToString([]byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, + 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xff, 0xff, 0x3f, + 0x00, 0x05, 0xfe, 0x02, 0xfe, 0xdc, 0xcc, 0x59, 0xe7, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + }) + body := map[string]any{ + "company_name": "Acme Corp", + "phone": "+48 111", + "email": "help@acme.example", + "website": "https://acme.example", + "accent_color": "#112233", + "support_contact": "Desk", + "logo": map[string]string{ + "mime": "image/png", + "data_base64": png1x1, + }, + } + raw, _ := json.Marshal(body) + req, err := http.NewRequest(http.MethodPost, base+"/branding", bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req = testAuthReq(req) + saveResp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer saveResp.Body.Close() + if saveResp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(saveResp.Body) + t.Fatalf("POST status %d body=%s", saveResp.StatusCode, string(b)) + } + + resp2, err := http.Get(base + "/branding") + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + var got2 BrandingConfig + if err := json.NewDecoder(resp2.Body).Decode(&got2); err != nil { + t.Fatal(err) + } + if got2.CompanyName != "Acme Corp" || got2.Phone != "+48 111" || got2.Email != "help@acme.example" { + t.Fatalf("unexpected branding: %+v", got2) + } + if got2.Revision == "" || got2.Revision == "0" { + t.Fatal("expected non-zero revision") + } + if got2.Logo == nil || got2.Logo.DataBase64 == "" { + t.Fatal("expected logo payload") + } + if got2.Profiles.RustDesk.ConfigOptions["display-name"] != "Acme Corp" { + t.Fatalf("rustdesk profile: %+v", got2.Profiles.RustDesk) + } +} + +func TestValidateAccentAndLogo(t *testing.T) { + t.Parallel() + if err := validateAccentColor("#abc"); err != nil { + t.Fatal(err) + } + if err := validateAccentColor("red"); err == nil { + t.Fatal("expected invalid color") + } + bad := &BrandingLogo{Mime: "image/gif", DataBase64: base64.StdEncoding.EncodeToString([]byte("x"))} + if err := validateBrandingLogo(bad); err == nil { + t.Fatal("expected gif rejected") + } +} diff --git a/betterdesk-server/api/client_api_handlers.go b/betterdesk-server/api/client_api_handlers.go index 0d11e7c4..b7a0e48d 100644 --- a/betterdesk-server/api/client_api_handlers.go +++ b/betterdesk-server/api/client_api_handlers.go @@ -849,27 +849,26 @@ func (s *Server) handleClientGroupPeers(w http.ResponseWriter, r *http.Request) // handleClientHeartbeat accepts heartbeat pings from RustDesk clients. // POST /api/heartbeat -// Request: { "id": "DEVICE_ID", "uuid": "...", "cpu": 42, "memory": 55, "disk": 30 } -// Response: { "modified_at": "2026-...", "sysinfo": true } (if sysinfo needed) -// -// { "modified_at": "2026-..." } (normal ACK) +// Request: { "id": "DEVICE_ID", "uuid": "...", "modified_at": 0, ... } +// Response: { "modified_at": , "strategy"?: { "config_options": {...} }, "sysinfo"?: true } func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) { // BD-2026-001: Rate-limit heartbeat requests per IP clientIP := s.remoteIP(r) if !s.heartbeatLimiter.Allow(clientIP) { - writeJSON(w, http.StatusOK, map[string]string{"modified_at": time.Now().UTC().Format(time.RFC3339)}) + writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()}) return } var body struct { - ID string `json:"id"` - UUID string `json:"uuid"` - CPU float64 `json:"cpu"` - Memory float64 `json:"memory"` - Disk float64 `json:"disk"` + ID string `json:"id"` + UUID string `json:"uuid"` + CPU float64 `json:"cpu"` + Memory float64 `json:"memory"` + Disk float64 `json:"disk"` + ModifiedAt int64 `json:"modified_at"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - writeJSON(w, http.StatusOK, map[string]string{"modified_at": time.Now().UTC().Format(time.RFC3339)}) + writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()}) return } @@ -878,19 +877,19 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) { deviceID = body.UUID } if deviceID == "" || !peerIDRegexp.MatchString(deviceID) { - writeJSON(w, http.StatusOK, map[string]string{"modified_at": time.Now().UTC().Format(time.RFC3339)}) + writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()}) return } // Verify peer exists peer, err := s.db.GetPeer(deviceID) if err != nil || peer == nil { - writeJSON(w, http.StatusOK, map[string]string{"modified_at": time.Now().UTC().Format(time.RFC3339)}) + writeJSON(w, http.StatusOK, map[string]any{"modified_at": s.clientBrandingModifiedAt()}) return } if peer.Banned { - writeJSON(w, http.StatusOK, map[string]string{"error": "BANNED"}) + writeJSON(w, http.StatusOK, map[string]any{"error": "BANNED", "modified_at": s.clientBrandingModifiedAt()}) return } @@ -907,18 +906,27 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) { } } - // Request sysinfo if hostname is empty (never received) - if peer.Hostname == "" { - writeJSON(w, http.StatusOK, map[string]any{ - "modified_at": time.Now().UTC().Format(time.RFC3339), - "sysinfo": true, - }) - return + serverModifiedAt := s.clientBrandingModifiedAt() + resp := map[string]any{ + "modified_at": serverModifiedAt, } - resp := map[string]any{ - "modified_at": time.Now().UTC().Format(time.RFC3339), + // Request sysinfo if hostname is empty (never received) + if peer.Hostname == "" { + resp["sysinfo"] = true } + + // Push RustDesk-compatible branding subset when client cursor is stale. + if body.ModifiedAt != serverModifiedAt { + branding := s.loadBrandingConfig() + opts := branding.Profiles.RustDesk.ConfigOptions + if len(opts) > 0 { + resp["strategy"] = map[string]any{ + "config_options": opts, + } + } + } + if policy, err := s.db.GetAccessPolicy(deviceID); err == nil && policy != nil { resp["access_policy"] = map[string]any{ "unattended_enabled": policy.UnattendedEnabled, @@ -929,6 +937,16 @@ func (s *Server) handleClientHeartbeat(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +// clientBrandingModifiedAt returns branding revision as Unix ms for heartbeat cursors. +func (s *Server) clientBrandingModifiedAt() int64 { + cfg := s.loadBrandingConfig() + if ms := brandingRevisionMillis(cfg); ms > 0 { + return ms + } + // Stable non-zero when no branding saved yet so clients can latch a cursor. + return 1 +} + // handleClientSysinfo receives hardware/software info from RustDesk clients. // POST /api/sysinfo // Request: { "id": "DEVICE_ID", "hostname": "...", "platform": "...", "os": "...", "version": "..." ... } diff --git a/docs/features/CLIENT_BRANDING.md b/docs/features/CLIENT_BRANDING.md new file mode 100644 index 00000000..62feabc4 --- /dev/null +++ b/docs/features/CLIENT_BRANDING.md @@ -0,0 +1,46 @@ +# Client Branding + +White-label profile for **desktop remote clients** connected to a BetterDesk server. + +## Source of truth + +- Stored in the **Go** API (`server_config` keys `branding_*`). +- Edited in the web console: **Main → Client Branding** (`/client-branding`). +- Distinct from **Settings → Branding** (console / RdClient appearance via `brandingService`). + +## Public read API + +`GET /api/branding` — no authentication (rate-limited). + +Additive schema (`schema_version: 1`): + +- Legacy: `company_name`, `accent_color`, `support_contact`, `colors`, `sync_modes` +- BetterDesk: `phone`, `email`, `website`, `logo` (`mime` + `data_base64` or `url`), `revision` +- `profiles.betterdesk.apply` — fields BetterDesk desktop applies +- `profiles.rustdesk.config_options` — subset safe for stock RustDesk via heartbeat + +`POST /api/branding` — admin (JWT / `X-API-Key`); validates colors, logo size (≤ 512 KiB), MIME. + +## Client behaviour + +| Client | Path | +|--------|------| +| **BetterDesk desktop** | Polls `GET /api/branding` from the sync loop; writes LocalConfig; clears when `api-server` is removed/changed or `revision` is `0` | +| **Stock RustDesk** | Does **not** call `/api/branding`. On heartbeat, when `modified_at` (i64 ms) differs, may receive `strategy.config_options` (e.g. `display-name`). Unknown JSON fields are ignored | + +## Heartbeat + +`POST /api/heartbeat` returns `modified_at` as **Unix milliseconds (int64)** so RustDesk/BetterDesk strategy cursors advance. When the client cursor is stale and Client Branding has a RustDesk projection, the response includes: + +```json +{ + "modified_at": 1725620400123, + "strategy": { "config_options": { "display-name": "Acme" } } +} +``` + +## Related files + +- Go: `betterdesk-server/api/branding_handlers.go`, `client_api_handlers.go` +- Panel: `web-nodejs/routes/client-branding.routes.js`, `views/client-branding.ejs` +- Desktop client: `BetterDesk-Client/src/hbbs_http/betterdesk.rs`, `sync.rs` diff --git a/web-nodejs/lang/ar.json b/web-nodejs/lang/ar.json index 4e6a4e1f..289d5395 100644 --- a/web-nodejs/lang/ar.json +++ b/web-nodejs/lang/ar.json @@ -86,7 +86,8 @@ "server_management": "إدارة الخادم", "server_attestation": "اعتماد أداء الخادم", "commercialization": "التسويق", - "remote_open_tab": "فتح عميل سطح المكتب البعيد في علامة تبويب جديدة" + "remote_open_tab": "فتح عميل سطح المكتب البعيد في علامة تبويب جديدة", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4322,5 +4323,28 @@ "link_sponsors_md": "قائمة الرعاة الكاملة", "close": "إغلاق" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/cs.json b/web-nodejs/lang/cs.json index 5239a13e..a75dbcf5 100644 --- a/web-nodejs/lang/cs.json +++ b/web-nodejs/lang/cs.json @@ -79,7 +79,8 @@ "server_management": "Správa serveru", "server_attestation": "Atestace serveru", "commercialization": "Komerce", - "remote_open_tab": "Otevřít klienta vzdálené plochy v nové kartě" + "remote_open_tab": "Otevřít klienta vzdálené plochy v nové kartě", + "client_branding": "Client Branding" }, "auth": { "login": "Přihlášení", @@ -4315,5 +4316,28 @@ "link_sponsors_md": "Úplný seznam sponzorů", "close": "Zavřít" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/da.json b/web-nodejs/lang/da.json index 39be4bd4..2411cda8 100644 --- a/web-nodejs/lang/da.json +++ b/web-nodejs/lang/da.json @@ -85,7 +85,8 @@ "server_management": "Serveradministration", "server_attestation": "Serverattest", "commercialization": "Kommercialisering", - "remote_open_tab": "Åbn fjernskrivebordsklient i ny fane" + "remote_open_tab": "Åbn fjernskrivebordsklient i ny fane", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "Fuld sponsorliste", "close": "Luk" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/de.json b/web-nodejs/lang/de.json index e3945943..b3d590f0 100644 --- a/web-nodejs/lang/de.json +++ b/web-nodejs/lang/de.json @@ -79,7 +79,8 @@ "server_management": "Server-Verwaltung", "server_attestation": "Server-Attestierung", "commercialization": "Kommerzialisierung", - "remote_open_tab": "Remote-Desktop-Client in neuem Tab öffnen" + "remote_open_tab": "Remote-Desktop-Client in neuem Tab öffnen", + "client_branding": "Client Branding" }, "auth": { "login": "Anmelden", @@ -4315,5 +4316,28 @@ "link_sponsors_md": "Vollständige Sponsorenliste", "close": "Schließen" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index 1176ef16..ee5d26b3 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -54,6 +54,7 @@ "reports": "Reports", "tenants": "Tenants", "registrations": "Enrollment Requests", + "client_branding": "Client Branding", "management": "Management", "toggle_sidebar": "Toggle sidebar", "toggle_theme": "Toggle theme", @@ -4313,5 +4314,28 @@ "link_sponsors_md": "Full sponsors list", "close": "Close" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/es.json b/web-nodejs/lang/es.json index bfe2f4ca..aa9f1956 100644 --- a/web-nodejs/lang/es.json +++ b/web-nodejs/lang/es.json @@ -79,7 +79,8 @@ "server_management": "Gestión del servidor", "server_attestation": "Certificación del servidor", "commercialization": "Comercialización", - "remote_open_tab": "Abrir cliente de escritorio remoto en una pestaña nueva" + "remote_open_tab": "Abrir cliente de escritorio remoto en una pestaña nueva", + "client_branding": "Client Branding" }, "auth": { "login": "Iniciar sesión", @@ -4315,5 +4316,28 @@ "link_sponsors_md": "Lista completa de patrocinadores", "close": "Cerrar" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/fi.json b/web-nodejs/lang/fi.json index 463abdd6..d21e6868 100644 --- a/web-nodejs/lang/fi.json +++ b/web-nodejs/lang/fi.json @@ -85,7 +85,8 @@ "server_management": "Palvelimen hallinta", "server_attestation": "Palvelintodistus", "commercialization": "Kaupallistaminen", - "remote_open_tab": "Avaa etätyöpöytäasiakas uuteen välilehteen" + "remote_open_tab": "Avaa etätyöpöytäasiakas uuteen välilehteen", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "Täydellinen sponsorilista", "close": "Sulje" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/fr.json b/web-nodejs/lang/fr.json index 36dd4306..c6f3dcca 100644 --- a/web-nodejs/lang/fr.json +++ b/web-nodejs/lang/fr.json @@ -83,7 +83,8 @@ "server_management": "Server-Verwaltung", "server_attestation": "Server-Attestierung", "commercialization": "Kommerzialisierung", - "remote_open_tab": "Ouvrir le client Bureau à distance dans un nouvel onglet" + "remote_open_tab": "Ouvrir le client Bureau à distance dans un nouvel onglet", + "client_branding": "Client Branding" }, "auth": { "login": "Connexion", @@ -4319,5 +4320,28 @@ "link_sponsors_md": "Liste complète des sponsors", "close": "Fermer" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/hi.json b/web-nodejs/lang/hi.json index a6100ae8..f8739820 100644 --- a/web-nodejs/lang/hi.json +++ b/web-nodejs/lang/hi.json @@ -85,7 +85,8 @@ "server_management": "सर्वर प्रबंधन", "server_attestation": "सर्वर प्रमाणीकरण", "commercialization": "व्यावसायीकरण", - "remote_open_tab": "नए टैब में रिमोट डेस्कटॉप क्लाइंट खोलें" + "remote_open_tab": "नए टैब में रिमोट डेस्कटॉप क्लाइंट खोलें", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "पूर्ण प्रायोजक सूची", "close": "बंद करें" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/hu.json b/web-nodejs/lang/hu.json index 7ec2259b..4dd01f2c 100644 --- a/web-nodejs/lang/hu.json +++ b/web-nodejs/lang/hu.json @@ -85,7 +85,8 @@ "server_management": "Kiszolgáló-kezelés", "server_attestation": "Szerver tanúsítás", "commercialization": "Kereskedelmi modul", - "remote_open_tab": "Távoli asztal kliens megnyitása új lapon" + "remote_open_tab": "Távoli asztal kliens megnyitása új lapon", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "Teljes szponzorlista", "close": "Bezárás" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/id.json b/web-nodejs/lang/id.json index dcd612d3..bf993c62 100644 --- a/web-nodejs/lang/id.json +++ b/web-nodejs/lang/id.json @@ -85,7 +85,8 @@ "server_management": "Manajemen Server", "server_attestation": "Sertifikasi Server", "commercialization": "Komersialisasi", - "remote_open_tab": "Buka klien desktop jarak jauh di tab baru" + "remote_open_tab": "Buka klien desktop jarak jauh di tab baru", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "Daftar sponsor lengkap", "close": "Tutup" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/it.json b/web-nodejs/lang/it.json index f096d84d..a3a974a0 100644 --- a/web-nodejs/lang/it.json +++ b/web-nodejs/lang/it.json @@ -79,7 +79,8 @@ "server_management": "Gestione server", "server_attestation": "Attestazione server", "commercialization": "Commercializzazione", - "remote_open_tab": "Apri client desktop remoto in una nuova scheda" + "remote_open_tab": "Apri client desktop remoto in una nuova scheda", + "client_branding": "Client Branding" }, "auth": { "login": "Accedi", @@ -4315,5 +4316,28 @@ "link_sponsors_md": "Elenco completo dei sponsor", "close": "Chiudi" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/ja.json b/web-nodejs/lang/ja.json index be48e622..a243ca58 100644 --- a/web-nodejs/lang/ja.json +++ b/web-nodejs/lang/ja.json @@ -79,7 +79,8 @@ "server_management": "サーバー管理", "server_attestation": "サーバー性能認定", "commercialization": "商用化", - "remote_open_tab": "新しいタブでリモートデスクトップクライアントを開く" + "remote_open_tab": "新しいタブでリモートデスクトップクライアントを開く", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4315,5 +4316,28 @@ "link_sponsors_md": "スポンサー一覧", "close": "閉じる" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/ko.json b/web-nodejs/lang/ko.json index 05e19380..76f2ed39 100644 --- a/web-nodejs/lang/ko.json +++ b/web-nodejs/lang/ko.json @@ -79,7 +79,8 @@ "server_management": "서버 관리", "server_attestation": "서버 성능 인증", "commercialization": "상용화", - "remote_open_tab": "새 탭에서 원격 데스크톱 클라이언트 열기" + "remote_open_tab": "새 탭에서 원격 데스크톱 클라이언트 열기", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4315,5 +4316,28 @@ "link_sponsors_md": "전체 스폰서 목록", "close": "닫기" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/nb.json b/web-nodejs/lang/nb.json index 3f061ae4..7c564522 100644 --- a/web-nodejs/lang/nb.json +++ b/web-nodejs/lang/nb.json @@ -85,7 +85,8 @@ "server_management": "Serveradministrasjon", "server_attestation": "Serverattest", "commercialization": "Kommersialisering", - "remote_open_tab": "Åpne eksternt skrivebordsklient i ny fane" + "remote_open_tab": "Åpne eksternt skrivebordsklient i ny fane", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "Full sponsorliste", "close": "Lukk" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/nl.json b/web-nodejs/lang/nl.json index 741ea0c3..44d69faf 100644 --- a/web-nodejs/lang/nl.json +++ b/web-nodejs/lang/nl.json @@ -79,7 +79,8 @@ "server_management": "Serverbeheer", "server_attestation": "Serverattestering", "commercialization": "Commercialisatie", - "remote_open_tab": "Externe-bureaubladclient openen in nieuw tabblad" + "remote_open_tab": "Externe-bureaubladclient openen in nieuw tabblad", + "client_branding": "Client Branding" }, "auth": { "login": "Inloggen", @@ -4315,5 +4316,28 @@ "link_sponsors_md": "Volledige sponsorslijst", "close": "Sluiten" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index 336262fb..6ff75fca 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -79,7 +79,8 @@ "server_management": "Zarządzanie serwerem", "server_attestation": "Atest serwera", "commercialization": "Komercjalizacja", - "remote_open_tab": "Otwórz klienta pulpitu zdalnego w nowej karcie" + "remote_open_tab": "Otwórz klienta pulpitu zdalnego w nowej karcie", + "client_branding": "Branding klienta" }, "auth": { "login": "Zaloguj", @@ -4315,5 +4316,28 @@ "link_sponsors_md": "Pełna lista sponsorów", "close": "Zamknij" } + }, + "client_branding": { + "subtitle": "White-label dla klientów desktop BetterDesk i kompatybilny podzbiór dla RustDesk.", + "form_title": "Profil Client Branding", + "hint": "Zapis trafia na serwer BetterDesk (Go). Klienci BetterDesk pobierają pełny branding automatycznie; stock RustDesk dostaje tylko bezpieczny podzbiór przez heartbeat.", + "company_name": "Nazwa firmy", + "support_contact": "Kontakt wsparcia", + "phone": "Telefon", + "email": "E-mail", + "website": "Strona WWW", + "accent_color": "Kolor akcentu", + "logo": "Logo", + "logo_hint": "PNG, JPEG lub WebP, max 512 KiB.", + "clear_logo": "Usuń logo", + "preview_betterdesk": "Podgląd BetterDesk", + "preview_rustdesk": "Podzbiór RustDesk (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk nie woła /api/branding — te opcje idą w strategy.config_options.", + "revision": "Rewizja", + "saved": "Branding klienta zapisany", + "save_failed": "Nie udało się zapisać brandingu", + "load_failed": "Nie udało się wczytać brandingu", + "logo_too_large": "Logo może mieć co najwyżej 512 KiB", + "logo_invalid": "Nieprawidłowy format logo" } } diff --git a/web-nodejs/lang/pt.json b/web-nodejs/lang/pt.json index 26a24a23..973844d9 100644 --- a/web-nodejs/lang/pt.json +++ b/web-nodejs/lang/pt.json @@ -79,7 +79,8 @@ "server_management": "Gestão do servidor", "server_attestation": "Atestação do servidor", "commercialization": "Comercialização", - "remote_open_tab": "Abrir cliente de ambiente de trabalho remoto num novo separador" + "remote_open_tab": "Abrir cliente de ambiente de trabalho remoto num novo separador", + "client_branding": "Client Branding" }, "auth": { "login": "Entrar", @@ -4315,5 +4316,28 @@ "link_sponsors_md": "Lista completa de patrocinadores", "close": "Fechar" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/ro.json b/web-nodejs/lang/ro.json index 62b7f442..d71c229b 100644 --- a/web-nodejs/lang/ro.json +++ b/web-nodejs/lang/ro.json @@ -85,7 +85,8 @@ "server_management": "Gestionare server", "server_attestation": "Atestare server", "commercialization": "Comercializare", - "remote_open_tab": "Deschide clientul desktop la distanță într-o filă nouă" + "remote_open_tab": "Deschide clientul desktop la distanță într-o filă nouă", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "Lista completă de sponsori", "close": "Închide" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/sv.json b/web-nodejs/lang/sv.json index 10a32343..f6e8344c 100644 --- a/web-nodejs/lang/sv.json +++ b/web-nodejs/lang/sv.json @@ -85,7 +85,8 @@ "server_management": "Serverhantering", "server_attestation": "Serverattest", "commercialization": "Kommersialisering", - "remote_open_tab": "Öppna fjärrskrivbordsklient i ny flik" + "remote_open_tab": "Öppna fjärrskrivbordsklient i ny flik", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "Fullständig sponsorlista", "close": "Stäng" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/th.json b/web-nodejs/lang/th.json index f704d98a..516c1f1e 100644 --- a/web-nodejs/lang/th.json +++ b/web-nodejs/lang/th.json @@ -85,7 +85,8 @@ "server_management": "จัดการเซิร์ฟเวอร์", "server_attestation": "การรับรองประสิทธิภาพเซิร์ฟเวอร์", "commercialization": "การทำให้เป็นเชิงพาณิชย์", - "remote_open_tab": "เปิดไคลเอนต์เดสก์ท็อประยะไกลในแท็บใหม่" + "remote_open_tab": "เปิดไคลเอนต์เดสก์ท็อประยะไกลในแท็บใหม่", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "รายชื่อสปอนเซอร์ทั้งหมด", "close": "ปิด" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/tr.json b/web-nodejs/lang/tr.json index b5ba276a..2a60a15f 100644 --- a/web-nodejs/lang/tr.json +++ b/web-nodejs/lang/tr.json @@ -85,7 +85,8 @@ "server_management": "Sunucu Yönetimi", "server_attestation": "Sunucu Performans Onayı", "commercialization": "Ticarileştirme", - "remote_open_tab": "Uzak masaüstü istemcisini yeni sekmede aç" + "remote_open_tab": "Uzak masaüstü istemcisini yeni sekmede aç", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "Tam sponsor listesi", "close": "Kapat" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/uk.json b/web-nodejs/lang/uk.json index e7d7904e..27236d5b 100644 --- a/web-nodejs/lang/uk.json +++ b/web-nodejs/lang/uk.json @@ -85,7 +85,8 @@ "server_management": "Керування сервером", "server_attestation": "Атестація сервера", "commercialization": "Комерціалізація", - "remote_open_tab": "Відкрити клієнт віддаленого робочого столу в новій вкладці" + "remote_open_tab": "Відкрити клієнт віддаленого робочого столу в новій вкладці", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "Повний список спонсорів", "close": "Закрити" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/vi.json b/web-nodejs/lang/vi.json index 8a408a4d..e2e0487f 100644 --- a/web-nodejs/lang/vi.json +++ b/web-nodejs/lang/vi.json @@ -85,7 +85,8 @@ "server_management": "Quản lý máy chủ", "server_attestation": "Chứng nhận máy chủ", "commercialization": "Thương mại hóa", - "remote_open_tab": "Mở client máy tính để bàn từ xa trong tab mới" + "remote_open_tab": "Mở client máy tính để bàn từ xa trong tab mới", + "client_branding": "Client Branding" }, "auth": { "login": "Login", @@ -4321,5 +4322,28 @@ "link_sponsors_md": "Danh sách nhà tài trợ đầy đủ", "close": "Đóng" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/zh-TW.json b/web-nodejs/lang/zh-TW.json index c905624b..ecdb5d99 100644 --- a/web-nodejs/lang/zh-TW.json +++ b/web-nodejs/lang/zh-TW.json @@ -83,7 +83,8 @@ "server_management": "服務器管理", "server_attestation": "服務器性能認證", "commercialization": "商業化", - "remote_open_tab": "在新分頁中開啟遠端桌面用戶端" + "remote_open_tab": "在新分頁中開啟遠端桌面用戶端", + "client_branding": "Client Branding" }, "auth": { "login": "登錄", @@ -4319,5 +4320,28 @@ "link_sponsors_md": "完整贊助者清單", "close": "關閉" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index 5d377d0a..ec39ca0c 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -79,7 +79,8 @@ "server_management": "服务器管理", "server_attestation": "服务器性能认证", "commercialization": "商业化", - "remote_open_tab": "在新标签页中打开远程桌面客户端" + "remote_open_tab": "在新标签页中打开远程桌面客户端", + "client_branding": "Client Branding" }, "auth": { "login": "登录", @@ -4315,5 +4316,28 @@ "link_sponsors_md": "完整赞助者列表", "close": "关闭" } + }, + "client_branding": { + "subtitle": "White-label for BetterDesk desktop clients and a compatible subset for RustDesk.", + "form_title": "Client Branding profile", + "hint": "Saved to the BetterDesk (Go) server. BetterDesk clients fetch full branding automatically; stock RustDesk receives only a safe subset via heartbeat.", + "company_name": "Company name", + "support_contact": "Support contact", + "phone": "Phone", + "email": "Email", + "website": "Website", + "accent_color": "Accent color", + "logo": "Logo", + "logo_hint": "PNG, JPEG, or WebP, max 512 KiB.", + "clear_logo": "Clear logo", + "preview_betterdesk": "BetterDesk preview", + "preview_rustdesk": "RustDesk subset (heartbeat)", + "preview_rustdesk_hint": "Stock RustDesk does not call /api/branding — these options go in strategy.config_options.", + "revision": "Revision", + "saved": "Client branding saved", + "save_failed": "Failed to save branding", + "load_failed": "Failed to load branding", + "logo_too_large": "Logo must be at most 512 KiB", + "logo_invalid": "Invalid logo format" } } diff --git a/web-nodejs/public/css/client-branding.css b/web-nodejs/public/css/client-branding.css new file mode 100644 index 00000000..50853141 --- /dev/null +++ b/web-nodejs/public/css/client-branding.css @@ -0,0 +1,115 @@ +/* Client Branding page */ + +.client-branding-layout { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr); + gap: 1.25rem; + align-items: start; +} + +.client-branding-form .form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem 1.25rem; +} + +.client-branding-form .form-field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.client-branding-form .form-field-wide { + grid-column: 1 / -1; +} + +.client-branding-form .form-field input[type="text"], +.client-branding-form .form-field input[type="email"], +.client-branding-form .form-field input[type="url"], +.client-branding-form .form-field input[type="file"] { + width: 100%; +} + +.client-branding-form .color-row { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.client-branding-form .color-row input[type="color"] { + width: 2.5rem; + height: 2.5rem; + padding: 0; + border: none; + background: transparent; +} + +.client-branding-form .field-hint, +.client-branding-form .settings-row-hint { + font-size: 0.85rem; + opacity: 0.75; +} + +.client-branding-previews { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.cb-preview-pane { + border-radius: 8px; + padding: 1.25rem; + background: color-mix(in srgb, var(--cb-accent, #4f6ef7) 12%, var(--surface, #161b22)); + border: 1px solid color-mix(in srgb, var(--cb-accent, #4f6ef7) 35%, transparent); + min-height: 160px; +} + +.cb-preview-logo-wrap { + width: 64px; + height: 64px; + border-radius: 8px; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.2); + margin-bottom: 0.75rem; +} + +.cb-preview-logo-wrap img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.cb-preview-company { + font-size: 1.15rem; + font-weight: 600; + color: var(--cb-accent, #4f6ef7); +} + +.cb-preview-contact { + margin-top: 0.5rem; + font-size: 0.9rem; + opacity: 0.85; + word-break: break-word; +} + +.cb-preview-code { + margin: 0; + padding: 0.75rem; + overflow: auto; + font-size: 0.8rem; + border-radius: 6px; + background: rgba(0, 0, 0, 0.25); +} + +@media (max-width: 960px) { + .client-branding-layout { + grid-template-columns: 1fr; + } + + .client-branding-form .form-grid { + grid-template-columns: 1fr; + } +} diff --git a/web-nodejs/public/js/client-branding.js b/web-nodejs/public/js/client-branding.js new file mode 100644 index 00000000..66e305f3 --- /dev/null +++ b/web-nodejs/public/js/client-branding.js @@ -0,0 +1,232 @@ +/* BetterDesk Console — Client Branding page */ + +(function () { + 'use strict'; + + const t = (k, def) => { + const tr = window.t ? window.t(k) : k; + return (tr && tr !== k) ? tr : (def != null ? def : k); + }; + const notify = window.Notifications || { + success: console.log, + error: console.error, + warning: console.warn, + info: console.info, + }; + const csrf = () => (window.BetterDesk && window.BetterDesk.csrfToken) || ''; + + const MAX_LOGO = 512 * 1024; + const state = { + logo: null, + clearLogo: false, + revision: '', + }; + + async function api(method, url, body) { + const headers = { Accept: 'application/json' }; + const write = method === 'POST' || method === 'PUT' || method === 'PATCH'; + if (write) headers['Content-Type'] = 'application/json'; + if (method !== 'GET' && method !== 'HEAD') headers['X-CSRF-Token'] = csrf(); + const opts = { method, headers, credentials: 'same-origin' }; + if (body !== undefined) opts.body = JSON.stringify(body); + else if (write) opts.body = '{}'; + const res = await fetch(url, opts); + const ct = res.headers.get('content-type') || ''; + const data = ct.includes('application/json') ? await res.json() : null; + if (!res.ok || (data && data.success === false)) { + throw new Error((data && data.error) || `HTTP ${res.status}`); + } + return data; + } + + function $(id) { + return document.getElementById(id); + } + + function normalizeColor(raw) { + const v = String(raw || '').trim(); + if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(v)) return v; + return '#4f6ef7'; + } + + function syncAccentInputs(fromPicker) { + const picker = $('cb-accent-picker'); + const text = $('cb-accent'); + if (!picker || !text) return; + if (fromPicker) { + text.value = picker.value; + } else { + const c = normalizeColor(text.value); + text.value = c; + picker.value = c.length === 4 + ? '#' + c[1] + c[1] + c[2] + c[2] + c[3] + c[3] + : c; + } + updatePreview(); + } + + function contactLines() { + const parts = []; + const phone = ($('cb-phone')?.value || '').trim(); + const email = ($('cb-email')?.value || '').trim(); + const website = ($('cb-website')?.value || '').trim(); + const support = ($('cb-support')?.value || '').trim(); + if (phone) parts.push(phone); + if (email) parts.push(email); + if (website) parts.push(website); + if (support) parts.push(support); + return parts; + } + + function updatePreview() { + const company = ($('cb-company')?.value || '').trim() || 'BetterDesk'; + const accent = normalizeColor($('cb-accent')?.value); + const companyEl = $('cb-preview-company'); + const contactEl = $('cb-preview-contact'); + const pane = $('cb-preview-bd'); + if (companyEl) companyEl.textContent = company; + if (contactEl) contactEl.textContent = contactLines().join(' · '); + if (pane) pane.style.setProperty('--cb-accent', accent); + + const logoImg = $('cb-preview-logo'); + const fallback = $('cb-preview-logo-fallback'); + const src = state.logo && state.logo.data_base64 + ? `data:${state.logo.mime};base64,${state.logo.data_base64}` + : (state.logo && state.logo.url ? state.logo.url : ''); + if (logoImg && fallback) { + if (src && !state.clearLogo) { + logoImg.src = src; + logoImg.hidden = false; + fallback.hidden = true; + } else { + logoImg.removeAttribute('src'); + logoImg.hidden = true; + fallback.hidden = false; + } + } + + const rd = {}; + if (company && company.toLowerCase() !== 'betterdesk') { + rd['display-name'] = company; + } + const rdEl = $('cb-preview-rd'); + if (rdEl) { + rdEl.textContent = JSON.stringify({ config_options: rd }, null, 2); + } + const rev = $('cb-revision'); + if (rev) { + rev.textContent = state.revision + ? `${t('client_branding.revision', 'Revision')}: ${state.revision}` + : ''; + } + } + + function fillForm(data) { + data = data || {}; + $('cb-company').value = data.company_name || ''; + $('cb-phone').value = data.phone || ''; + $('cb-email').value = data.email || ''; + $('cb-website').value = data.website || ''; + $('cb-support').value = data.support_contact || ''; + $('cb-accent').value = data.accent_color || '#4f6ef7'; + state.revision = data.revision || ''; + state.clearLogo = false; + state.logo = data.logo || null; + syncAccentInputs(false); + updatePreview(); + } + + function readFileAsLogo(file) { + return new Promise((resolve, reject) => { + if (!file) return resolve(null); + if (file.size <= 0 || file.size > MAX_LOGO) { + return reject(new Error(t('client_branding.logo_too_large', 'Logo must be at most 512 KiB'))); + } + const mime = (file.type || '').toLowerCase(); + if (!['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(mime)) { + return reject(new Error(t('client_branding.logo_invalid', 'Invalid logo format'))); + } + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result || ''); + const comma = result.indexOf(','); + const dataBase64 = comma >= 0 ? result.slice(comma + 1) : result; + resolve({ mime: mime === 'image/jpg' ? 'image/jpeg' : mime, data_base64: dataBase64 }); + }; + reader.onerror = () => reject(new Error(t('client_branding.logo_invalid', 'Invalid logo format'))); + reader.readAsDataURL(file); + }); + } + + async function load() { + const result = await api('GET', '/api/client-branding'); + fillForm(result.data || {}); + } + + async function save() { + const payload = { + company_name: ($('cb-company')?.value || '').trim(), + phone: ($('cb-phone')?.value || '').trim(), + email: ($('cb-email')?.value || '').trim(), + website: ($('cb-website')?.value || '').trim(), + support_contact: ($('cb-support')?.value || '').trim(), + accent_color: normalizeColor($('cb-accent')?.value), + }; + if (state.clearLogo) payload.clear_logo = true; + else if (state.logo && (state.logo.data_base64 || state.logo.url)) { + payload.logo = state.logo; + } + const result = await api('POST', '/api/client-branding', payload); + const branding = (result.data && result.data.branding) || result.data || {}; + if (result.revision) branding.revision = result.revision; + if (result.data && result.data.revision) branding.revision = result.data.revision; + fillForm(branding); + notify.success(t('client_branding.saved', 'Client branding saved')); + } + + function bind() { + ['cb-company', 'cb-phone', 'cb-email', 'cb-website', 'cb-support'].forEach((id) => { + $(id)?.addEventListener('input', updatePreview); + }); + $('cb-accent')?.addEventListener('input', () => syncAccentInputs(false)); + $('cb-accent-picker')?.addEventListener('input', () => syncAccentInputs(true)); + $('cb-logo-file')?.addEventListener('change', async (ev) => { + const file = ev.target.files && ev.target.files[0]; + try { + const logo = await readFileAsLogo(file); + if (logo) { + state.logo = logo; + state.clearLogo = false; + updatePreview(); + } + } catch (err) { + notify.error(err.message); + ev.target.value = ''; + } + }); + $('cb-clear-logo')?.addEventListener('click', () => { + state.logo = null; + state.clearLogo = true; + const input = $('cb-logo-file'); + if (input) input.value = ''; + updatePreview(); + }); + $('cb-save')?.addEventListener('click', async () => { + try { + await save(); + } catch (err) { + notify.error(err.message || t('client_branding.save_failed', 'Save failed')); + } + }); + } + + document.addEventListener('DOMContentLoaded', async () => { + bind(); + try { + await load(); + } catch (err) { + notify.error(err.message || t('client_branding.load_failed', 'Failed to load branding')); + updatePreview(); + } + }); +})(); diff --git a/web-nodejs/routes/client-branding.routes.js b/web-nodejs/routes/client-branding.routes.js new file mode 100644 index 00000000..88a8eb5c --- /dev/null +++ b/web-nodejs/routes/client-branding.routes.js @@ -0,0 +1,92 @@ +/** + * BetterDesk Console — Client Branding (desktop clients) + * + * Main → Client Branding edits the Go-server source of truth used by + * BetterDesk desktop (GET /api/branding) and stock RustDesk (heartbeat subset). + * Distinct from Settings → Branding (console appearance / brandingService). + */ + +'use strict'; + +const express = require('express'); +const router = express.Router(); +const { requireAuth, requirePermission } = require('../middleware/auth'); +const betterdeskApi = require('../services/betterdeskApi'); + +const largeJson = express.json({ limit: '2mb' }); + +router.get( + '/client-branding', + requireAuth, + requirePermission('branding.edit'), + (req, res) => { + res.render('client-branding', { + title: req.t('nav.client_branding'), + }); + } +); + +router.get( + '/api/client-branding', + requireAuth, + requirePermission('branding.edit'), + async (req, res) => { + try { + const result = await betterdeskApi.getBranding(); + if (!result.success) { + return res.status(502).json({ + success: false, + error: result.error || 'Failed to load branding from BetterDesk server', + }); + } + return res.json({ success: true, data: result.data }); + } catch (err) { + console.error('[client-branding] GET failed:', err.message); + return res.status(500).json({ success: false, error: err.message }); + } + } +); + +router.post( + '/api/client-branding', + requireAuth, + requirePermission('branding.edit'), + largeJson, + async (req, res) => { + try { + const body = req.body || {}; + const payload = { + company_name: body.company_name, + phone: body.phone, + email: body.email, + website: body.website, + accent_color: body.accent_color, + support_contact: body.support_contact, + colors: body.colors, + }; + if (body.clear_logo) { + payload.clear_logo = true; + } else if (body.logo) { + payload.logo = body.logo; + } + + const result = await betterdeskApi.saveBranding(payload); + if (!result.success) { + return res.status(502).json({ + success: false, + error: result.error || 'Failed to save branding on BetterDesk server', + }); + } + return res.json({ + success: true, + data: result.data, + revision: result.data?.revision || result.revision, + }); + } catch (err) { + console.error('[client-branding] POST failed:', err.message); + return res.status(500).json({ success: false, error: err.message }); + } + } +); + +module.exports = router; diff --git a/web-nodejs/routes/index.js b/web-nodejs/routes/index.js index 4f4f16f6..2c203a5d 100644 --- a/web-nodejs/routes/index.js +++ b/web-nodejs/routes/index.js @@ -105,6 +105,7 @@ router.use('/', devicesRoutes); router.use('/', keysRoutes); router.use('/', settingsRoutes); router.use('/', generatorRoutes); +router.use('/', require('./client-branding.routes')); router.use('/', usersRoutes); router.use('/', foldersRoutes); router.use('/', remoteRoutes); diff --git a/web-nodejs/views/client-branding.ejs b/web-nodejs/views/client-branding.ejs new file mode 100644 index 00000000..659bf0b9 --- /dev/null +++ b/web-nodejs/views/client-branding.ejs @@ -0,0 +1,104 @@ +<%- include('layouts/main', { + title: _('nav.client_branding'), + pageStyles: ['pages', 'client-branding'], + pageScripts: ['client-branding'], + currentPage: 'client-branding', + breadcrumb: [{ label: _('nav.client_branding') }], + body: ` + + +
+
+
+

+ edit + ${_('client_branding.form_title')} +

+
+ + + +
+
+
+

${_('client_branding.hint')}

+
+
+ + + + + + + +
+
+
+
+ + +
+ ` +}) %> diff --git a/web-nodejs/views/partials/sidebar.ejs b/web-nodejs/views/partials/sidebar.ejs index 8c6c09c6..d05990e4 100644 --- a/web-nodejs/views/partials/sidebar.ejs +++ b/web-nodejs/views/partials/sidebar.ejs @@ -21,7 +21,7 @@