From 6649b70d5e1bcb18c6aed51ccc2dd557b59751b2 Mon Sep 17 00:00:00 2001 From: Astra Date: Mon, 14 Sep 2026 11:37:36 +0100 Subject: [PATCH] branding: make product identity runtime-configurable Adopt upstream owpengram/owpengram-server's branding.Config/Configure/ Current in place of the old package-level constants. This is the piece the earlier merge attempt was blocked on (internal/branding failed to import during that merge). The default identity is unchanged -- every existing ProductName/ProductUsername/... default still reads "OwpenGram" -- so this is a pure capability add: nothing currently calls Configure, and every call site now reads the current snapshot via a function instead of a compile-time constant. Five string templates that concatenated branding.ProductName into a `const` had to become `var`, since a func call is no longer a valid const operand. Co-Authored-By: Claude Sonnet 5 --- internal/app/account/lifecycle.go | 2 +- internal/app/auth/service.go | 4 +- internal/app/bots/botfather.go | 2 +- internal/app/bots/verifierbot.go | 10 +- internal/app/bots/verifybot.go | 4 +- internal/app/botverification/seed.go | 2 +- internal/app/langpack/service.go | 2 +- internal/app/passkey/service.go | 2 +- internal/branding/branding.go | 164 +++++++++++++++++++++---- internal/branding/branding_test.go | 65 +++++++++- internal/domain/login_code_delivery.go | 4 +- internal/domain/system.go | 4 +- internal/rpc/aicompose_webpage.go | 2 +- internal/rpc/help.go | 6 +- internal/webauthn/webauthn.go | 2 +- 15 files changed, 224 insertions(+), 51 deletions(-) diff --git a/internal/app/account/lifecycle.go b/internal/app/account/lifecycle.go index e59954f0..60168bb2 100644 --- a/internal/app/account/lifecycle.go +++ b/internal/app/account/lifecycle.go @@ -80,7 +80,7 @@ func (s *Service) DeleteAccount(ctx context.Context, userID int64, authKeyID [8] } executeAt := now.Add(accountDeletionDelay) message := fmt.Sprintf( - "A request was made to delete your "+branding.ProductName+" account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s", + "A request was made to delete your "+branding.ProductName()+" account. If this wasn't you, cancel the request: tg://confirmphone?phone=%s&hash=%s", url.QueryEscape(snapshot.User.Phone), url.QueryEscape(rawToken), ) pending, _, err := s.lifecycle.ScheduleAccountDeletion(ctx, domain.ScheduleAccountDeletion{ diff --git a/internal/app/auth/service.go b/internal/app/auth/service.go index 8330f31e..dc5d9d8a 100644 --- a/internal/app/auth/service.go +++ b/internal/app/auth/service.go @@ -1535,9 +1535,9 @@ func (s *Service) passwordNeeded(ctx context.Context, userID int64) (bool, error return found && settings.HasPassword, nil } -const loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `! +var loginMessageTpl = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName() + `! -This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else. +This code can be used to log in to your ` + branding.ProductName() + ` account. We never ask it for anything else. If you didn't request this code by trying to log in on another device, simply ignore this message.` diff --git a/internal/app/bots/botfather.go b/internal/app/bots/botfather.go index 07784fd0..4036c9a2 100644 --- a/internal/app/bots/botfather.go +++ b/internal/app/bots/botfather.go @@ -51,7 +51,7 @@ const ( maxTelegramLoginCommandsPerMessage = 32 ) -const botFatherHelpText = `I can help you create and manage ` + branding.ProductName + ` bots. +var botFatherHelpText = `I can help you create and manage ` + branding.ProductName() + ` bots. You can control me by sending these commands: diff --git a/internal/app/bots/verifierbot.go b/internal/app/bots/verifierbot.go index d12c637a..96d855d6 100644 --- a/internal/app/bots/verifierbot.go +++ b/internal/app/bots/verifierbot.go @@ -194,13 +194,13 @@ const ( // verifierBotWhatText is the part of /start that is true whether or not an // operator has activated this bot, so it is said first and unconditionally. -const verifierBotWhatText = `I hand out THIRD-PARTY verification. +var verifierBotWhatText = `I hand out THIRD-PARTY verification. A third-party mark is a verifier's own icon, shown right before the name of a bot, a channel or an account, plus one line of description in its profile. It means "this verifier vouches for this peer" -- nothing more. -It is NOT the official ` + branding.ProductName + ` checkmark. The platform badge is granted by the platform itself (@verifybot collects those applications); a third-party mark is granted by the company running a verifier bot. The two are stored, shown and taken away separately, and neither one implies the other.` +It is NOT the official ` + branding.ProductName() + ` checkmark. The platform badge is granted by the platform itself (@verifybot collects those applications); a third-party mark is granted by the company running a verifier bot. The two are stored, shown and taken away separately, and neither one implies the other.` -const verifierBotHelpText = `I am a verifier bot. I grant third-party marks: my icon before the name of your bot, channel or account, plus a description in its profile. This is not the official ` + branding.ProductName + ` checkmark. +var verifierBotHelpText = `I am a verifier bot. I grant third-party marks: my icon before the name of your bot, channel or account, plus a description in its profile. This is not the official ` + branding.ProductName() + ` checkmark. /start - what a third-party mark is and who grants it /verify - apply for the mark @@ -1121,7 +1121,7 @@ func verifierSummaryText(state domain.BotChatState, settings domain.BotVerifierS b.WriteString("\n\nWhy:\n") b.WriteString(state.Draft[verifierDraftReason]) b.WriteString("\n\nThis is a third-party mark, not the official ") - b.WriteString(branding.ProductName) + b.WriteString(branding.ProductName()) b.WriteString(" checkmark, and I do not decide: an operator reads the application and either grants the mark or refuses it. I will message you here either way.") return b.String() } @@ -1170,7 +1170,7 @@ func verifierDecisionText(req domain.CustomVerificationRequest) (string, bool) { switch req.Status { case domain.CustomVerificationApproved: return fmt.Sprintf("Application #%d is approved: %s now carries my mark -- my icon before the name and my description in the profile.\n\nThis is a third-party mark, not the official %s checkmark. Send /revoke if you ever want it removed.", - req.ID, label, branding.ProductName), true + req.ID, label, branding.ProductName()), true case domain.CustomVerificationRejected: text := fmt.Sprintf("Application #%d for %s was not approved, so no mark was granted.", req.ID, label) if reason := strings.TrimSpace(req.DecisionReason); reason != "" { diff --git a/internal/app/bots/verifybot.go b/internal/app/bots/verifybot.go index a0e35e7e..a6bb95cc 100644 --- a/internal/app/bots/verifybot.go +++ b/internal/app/bots/verifybot.go @@ -114,7 +114,7 @@ const ( verifyChoiceBlockedPrefix = "no:" ) -const verifyBotStartText = `I collect applications for official ` + branding.ProductName + ` verification: the badge shown next to the name of a channel, supergroup or bot whose identity has been confirmed. +var verifyBotStartText = `I collect applications for official ` + branding.ProductName() + ` verification: the badge shown next to the name of a channel, supergroup or bot whose identity has been confirmed. Before you apply, check that the subject of the application: - is a channel, supergroup or bot with a public @username; @@ -126,7 +126,7 @@ This badge is never sold and never granted automatically. A person reads every a Tap the button below, or send /new, to start. Send /help for the full list of commands.` -const verifyBotHelpText = `I collect official ` + branding.ProductName + ` verification applications. +var verifyBotHelpText = `I collect official ` + branding.ProductName() + ` verification applications. /new - file a verification application /status - list your applications and their status diff --git a/internal/app/botverification/seed.go b/internal/app/botverification/seed.go index ad9839a7..5ed2936f 100644 --- a/internal/app/botverification/seed.go +++ b/internal/app/botverification/seed.go @@ -42,7 +42,7 @@ func (s *Service) SeedDefaultVerifier(ctx context.Context) (bool, error) { if _, err := s.GrantVerifier(ctx, domain.BotVerifierSettings{ BotID: domain.VerifierBotUserID, IconDocumentID: icon.DocumentID, - CompanyName: branding.ProductName, + CompanyName: branding.ProductName(), DefaultDescription: "Bundled reference verifier -- auto-granted on first boot.", CanModifyCustomDescription: false, Enabled: true, diff --git a/internal/app/langpack/service.go b/internal/app/langpack/service.go index 2514fe88..feb1a56c 100644 --- a/internal/app/langpack/service.go +++ b/internal/app/langpack/service.go @@ -54,7 +54,7 @@ func newServiceWithCacheLimits(packs store.LangPackStore, maxBytes int64, maxEnt packs: packs, packCache: newLangPackCache(maxBytes, maxEntries), languageCache: newLanguageListCache(languageEntries), - publicBaseURL: branding.DefaultPublicURL, + publicBaseURL: branding.PublicBaseURL(), } } diff --git a/internal/app/passkey/service.go b/internal/app/passkey/service.go index bb25262a..f4e5f736 100644 --- a/internal/app/passkey/service.go +++ b/internal/app/passkey/service.go @@ -71,7 +71,7 @@ func NewService(creds store.PasskeyStore, challenges store.PasskeyChallengeStore creds: creds, challenges: challenges, rpID: rpID, - rpName: branding.ProductName, + rpName: branding.ProductName(), dcID: dcID, challengeTTL: defaultChallengeTTL, now: time.Now, diff --git a/internal/branding/branding.go b/internal/branding/branding.go index 0a08920d..ea88d8e3 100644 --- a/internal/branding/branding.go +++ b/internal/branding/branding.go @@ -6,44 +6,133 @@ package branding import ( + "fmt" "net/url" "regexp" "strings" + "sync/atomic" + "unicode" + + "telesrv/internal/links" ) -const ( - ProductName = "OwpenGram" - ProductUsername = "owpengram" - DesktopAppName = "OwpenGram Desktop" - AndroidAppName = "OwpenGram Android" - IOSAppName = "OwpenGram iOS" - MacOSAppName = "OwpenGram macOS" - WebAAppName = "OwpenGram Web A" - WebKAppName = "OwpenGram Web K" - PremiumName = "OwpenGram Premium" - StarsName = "OwpenGram Stars" - DefaultPublicURL = "https://owpengram.org" +// Config is the deployment-wide, user-visible product identity. It is loaded +// once during process startup; protocol identifiers and client detection +// tokens deliberately remain outside this structure. +type Config struct { + ProductName string + ProductUsername string + DesktopAppName string + AndroidAppName string + IOSAppName string + MacOSAppName string + WebAAppName string + WebKAppName string + PremiumName string + StarsName string + PublicBaseURL string +} + +var ( + defaultConfig = Config{ + ProductName: "OwpenGram", + ProductUsername: "owpengram", + DesktopAppName: "OwpenGram Desktop", + AndroidAppName: "OwpenGram Android", + IOSAppName: "OwpenGram iOS", + MacOSAppName: "OwpenGram macOS", + WebAAppName: "OwpenGram Web A", + WebKAppName: "OwpenGram Web K", + PremiumName: "OwpenGram Premium", + StarsName: "OwpenGram Stars", + PublicBaseURL: links.DefaultDownloadURL, + } + configured atomic.Pointer[Config] ) +// DefaultConfig returns a copy of the default product identity. +func DefaultConfig() Config { return defaultConfig } + +// Validate normalizes and validates a product identity without installing it. +func Validate(cfg Config) (Config, error) { + for _, field := range []struct { + name string + value *string + }{ + {name: "product name", value: &cfg.ProductName}, + {name: "desktop app name", value: &cfg.DesktopAppName}, + {name: "Android app name", value: &cfg.AndroidAppName}, + {name: "iOS app name", value: &cfg.IOSAppName}, + {name: "macOS app name", value: &cfg.MacOSAppName}, + {name: "Web A app name", value: &cfg.WebAAppName}, + {name: "Web K app name", value: &cfg.WebKAppName}, + {name: "Premium name", value: &cfg.PremiumName}, + {name: "Stars name", value: &cfg.StarsName}, + } { + normalized, err := validateDisplayName(*field.value) + if err != nil { + return Config{}, fmt.Errorf("%s: %w", field.name, err) + } + *field.value = normalized + } + cfg.ProductUsername = strings.TrimPrefix(strings.TrimSpace(cfg.ProductUsername), "@") + if !validProductUsername(cfg.ProductUsername) { + return Config{}, fmt.Errorf("product username must be 5-32 ASCII username characters and start with a letter") + } + cfg.ProductUsername = strings.ToLower(cfg.ProductUsername) + var err error + cfg.PublicBaseURL, err = links.ValidateBaseURL(cfg.PublicBaseURL) + if err != nil { + return Config{}, fmt.Errorf("public base URL: %w", err) + } + return cfg, nil +} + +// Configure installs the validated process-wide identity before services are +// constructed. Readers only ever observe complete immutable snapshots. +func Configure(cfg Config) error { + normalized, err := Validate(cfg) + if err != nil { + return err + } + configured.Store(&normalized) + return nil +} + +// Current returns a copy of the installed product identity. +func Current() Config { + if cfg := configured.Load(); cfg != nil { + return *cfg + } + return defaultConfig +} + +func ProductName() string { return Current().ProductName } +func ProductUsername() string { return Current().ProductUsername } +func PremiumName() string { return Current().PremiumName } +func StarsName() string { return Current().StarsName } +func PublicBaseURL() string { return Current().PublicBaseURL } + // ClientAppName returns the branded display name for a stored client platform. // Stored detection tokens remain unchanged; this is only used at presentation // boundaries such as account.getAuthorizations. func ClientAppName(platform string) string { + cfg := Current() switch strings.ToLower(strings.TrimSpace(platform)) { case "android": - return AndroidAppName + return cfg.AndroidAppName case "ios": - return IOSAppName + return cfg.IOSAppName case "macos": - return MacOSAppName + return cfg.MacOSAppName case "telegram-tt", "weba": - return WebAAppName + return cfg.WebAAppName case "tweb", "webk": - return WebKAppName + return cfg.WebKAppName case "tdesktop", "desktop", "windows": - return DesktopAppName + return cfg.DesktopAppName default: - return ProductName + return cfg.ProductName } } @@ -78,18 +167,49 @@ func UserVisibleText(value, publicBaseURL string) string { if technicalIDRE.MatchString(value) { return value } - return officialBrandRE.ReplaceAllString(value, ProductName) + return officialBrandRE.ReplaceAllString(value, ProductName()) } func publicDestination(raw string) (string, string) { raw = strings.TrimRight(strings.TrimSpace(raw), "/") if raw == "" { - raw = DefaultPublicURL + raw = PublicBaseURL() } parsed, err := url.Parse(raw) if err != nil || parsed.Scheme == "" || parsed.Hostname() == "" { - raw = DefaultPublicURL + raw = PublicBaseURL() parsed, _ = url.Parse(raw) } return raw, parsed.Host } + +func validateDisplayName(raw string) (string, error) { + name := strings.TrimSpace(raw) + if name == "" { + return "", fmt.Errorf("must not be empty") + } + if len([]rune(name)) > 64 { + return "", fmt.Errorf("must not exceed 64 characters") + } + for _, r := range name { + if unicode.IsControl(r) { + return "", fmt.Errorf("must not contain control characters") + } + } + return name, nil +} + +func validProductUsername(username string) bool { + if len(username) < 5 || len(username) > 32 { + return false + } + for i, r := range username { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': + case i > 0 && (r >= '0' && r <= '9' || r == '_'): + default: + return false + } + } + return true +} diff --git a/internal/branding/branding_test.go b/internal/branding/branding_test.go index a4606635..58086aa0 100644 --- a/internal/branding/branding_test.go +++ b/internal/branding/branding_test.go @@ -46,13 +46,14 @@ func TestUserVisibleTextRebrandsLocalizedProductNames(t *testing.T) { } func TestClientPresentationNames(t *testing.T) { + cfg := Current() for platform, want := range map[string]string{ - "tdesktop": DesktopAppName, - "android": AndroidAppName, - "ios": IOSAppName, - "macos": MacOSAppName, - "telegram-tt": WebAAppName, - "tweb": WebKAppName, + "tdesktop": cfg.DesktopAppName, + "android": cfg.AndroidAppName, + "ios": cfg.IOSAppName, + "macos": cfg.MacOSAppName, + "telegram-tt": cfg.WebAAppName, + "tweb": cfg.WebKAppName, } { if got := ClientAppName(platform); got != want { t.Fatalf("ClientAppName(%q) = %q, want %q", platform, got, want) @@ -62,3 +63,55 @@ func TestClientPresentationNames(t *testing.T) { t.Fatalf("UserVisibleClientPlatform() = %q, want weba", got) } } + +func TestConfigureInstallsCompleteBrandSnapshot(t *testing.T) { + previous := Current() + t.Cleanup(func() { + if err := Configure(previous); err != nil { + t.Fatalf("restore branding: %v", err) + } + }) + + cfg := Config{ + ProductName: "Example Chat", + ProductUsername: "@Example_Chat", + DesktopAppName: "Example Workstation", + AndroidAppName: "Example Droid", + IOSAppName: "Example Phone", + MacOSAppName: "Example Mac", + WebAAppName: "Example Web Alpha", + WebKAppName: "Example Web Kappa", + PremiumName: "Example Plus", + StarsName: "Example Credits", + PublicBaseURL: "https://links.example.test/root/", + } + if err := Configure(cfg); err != nil { + t.Fatalf("Configure: %v", err) + } + if got := Current(); got.ProductUsername != "example_chat" || got.PublicBaseURL != "https://links.example.test/root" { + t.Fatalf("Current() = %+v", got) + } + if got := ClientAppName("android"); got != "Example Droid" { + t.Fatalf("ClientAppName(android) = %q", got) + } + if got := UserVisibleText("Telegram at t.me/example", ""); got != "Example Chat at links.example.test/example" { + t.Fatalf("UserVisibleText() = %q", got) + } +} + +func TestValidateRejectsIncompleteOrUnsafeBranding(t *testing.T) { + for name, mutate := range map[string]func(*Config){ + "blank product": func(cfg *Config) { cfg.ProductName = " " }, + "control": func(cfg *Config) { cfg.StarsName = "bad\nname" }, + "username": func(cfg *Config) { cfg.ProductUsername = "3bad" }, + "public URL": func(cfg *Config) { cfg.PublicBaseURL = "file:///tmp/brand" }, + } { + t.Run(name, func(t *testing.T) { + cfg := DefaultConfig() + mutate(&cfg) + if _, err := Validate(cfg); err == nil { + t.Fatal("Validate accepted invalid branding") + } + }) + } +} diff --git a/internal/domain/login_code_delivery.go b/internal/domain/login_code_delivery.go index 6f4ff134..bed9b41d 100644 --- a/internal/domain/login_code_delivery.go +++ b/internal/domain/login_code_delivery.go @@ -8,9 +8,9 @@ import ( "telesrv/internal/branding" ) -const officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName + `! +var officialLoginCodeMessageTemplate = `Login code: %s. Do not give this code to anyone, even if they say they are from ` + branding.ProductName() + `! -This code can be used to log in to your ` + branding.ProductName + ` account. We never ask it for anything else. +This code can be used to log in to your ` + branding.ProductName() + ` account. We never ask it for anything else. If you didn't request this code by trying to log in on another device, simply ignore this message.` diff --git a/internal/domain/system.go b/internal/domain/system.go index 30468fce..7537afcb 100644 --- a/internal/domain/system.go +++ b/internal/domain/system.go @@ -172,8 +172,8 @@ func OfficialSystemUser() User { ID: OfficialSystemUserID, AccessHash: 6599886787491911851, Phone: "42777", - FirstName: branding.ProductName, - Username: branding.ProductUsername, + FirstName: branding.ProductName(), + Username: branding.ProductUsername(), Verified: true, Support: true, } diff --git a/internal/rpc/aicompose_webpage.go b/internal/rpc/aicompose_webpage.go index 081fea46..148b0824 100644 --- a/internal/rpc/aicompose_webpage.go +++ b/internal/rpc/aicompose_webpage.go @@ -43,7 +43,7 @@ func (r *Router) resolveAIComposeStyleWebPage(ctx context.Context, rawURL string Hash: aiComposeToneWebPageHash(tone), Date: int(now.Unix()), Type: aiComposeToneWebPageType, - SiteName: branding.ProductName, + SiteName: branding.ProductName(), Title: tone.Title, Description: tone.Prompt, ComposeToneEmojiID: tone.EmojiID, diff --git a/internal/rpc/help.go b/internal/rpc/help.go index 22baf36a..67cf33c4 100644 --- a/internal/rpc/help.go +++ b/internal/rpc/help.go @@ -26,7 +26,7 @@ func (r *Router) registerHelp(d *tlprofile.Dispatcher) { }, nil }) registerRPC[*tg.HelpGetInviteTextRequest](d, tlprofile.SemanticMethodHelpGetInviteText, func(ctx context.Context, layerRequest *tg.HelpGetInviteTextRequest) (any, error) { - return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName + "."}, nil + return &tg.HelpInviteText{Message: "Join me on " + branding.ProductName() + "."}, nil }) registerRPC[*tg.HelpSaveAppLogRequest](d, tlprofile.SemanticMethodHelpSaveAppLog, func(ctx context.Context, _ *tg.HelpSaveAppLogRequest) (any, error) { return r.onHelpSaveAppLog(ctx) @@ -186,7 +186,7 @@ func (r *Router) onHelpDismissSuggestion(ctx context.Context, req *tg.HelpDismis // dead payment URLs. All six TL fields are mandatory. func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumPromo, error) { promo := &tg.HelpPremiumPromo{ - StatusText: branding.PremiumName + " is not active on this account.", + StatusText: branding.PremiumName() + " is not active on this account.", StatusEntities: []tg.MessageEntityClass{}, VideoSections: []string{}, Videos: []tg.DocumentClass{}, @@ -215,7 +215,7 @@ func (r *Router) onHelpGetPremiumPromo(ctx context.Context) (*tg.HelpPremiumProm } if u.PremiumActiveAt(r.clock.Now().Unix()) { until := time.Unix(int64(u.PremiumUntil), 0) - promo.StatusText = branding.PremiumName + " is active until " + until.Format("2006-01-02") + "." + promo.StatusText = branding.PremiumName() + " is active until " + until.Format("2006-01-02") + "." } if r.deps.PremiumPromo != nil { catalog, found, err := r.deps.PremiumPromo.PremiumPromo(ctx) diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go index 7c1bfd99..82d44bd5 100644 --- a/internal/webauthn/webauthn.go +++ b/internal/webauthn/webauthn.go @@ -101,7 +101,7 @@ func BuildRegistrationOptions(p RegistrationParams) ([]byte, error) { exclude = append(exclude, map[string]any{"type": "public-key", "id": b64.EncodeToString(id)}) } pub := map[string]any{ - "rp": map[string]any{"id": p.RPID, "name": orDefault(p.RPName, branding.ProductName)}, + "rp": map[string]any{"id": p.RPID, "name": orDefault(p.RPName, branding.ProductName())}, "user": map[string]any{"id": b64.EncodeToString(p.UserID), "name": p.UserName, "displayName": orDefault(p.UserDisplay, p.UserName)}, "challenge": b64.EncodeToString(p.Challenge), "pubKeyCredParams": []map[string]any{