diff --git a/internal/util/common/url.go b/internal/util/common/url.go new file mode 100644 index 000000000..1608d72db --- /dev/null +++ b/internal/util/common/url.go @@ -0,0 +1,21 @@ +package common + +import "strings" + +// EnsureURLScheme prepends https:// to a URL that carries no scheme, so +// subscription apps and browsers don't resolve it relative to the panel's own +// domain (e.g. "t.me/support" turning into "https://panel.example/t.me/support"). +// Values with an explicit scheme (https://, tg://, mailto:, tel:) and empty +// strings pass through untouched. +func EnsureURLScheme(raw string) string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return "" + } + if strings.Contains(trimmed, "://") || + strings.HasPrefix(trimmed, "mailto:") || + strings.HasPrefix(trimmed, "tel:") { + return trimmed + } + return "https://" + trimmed +} diff --git a/internal/util/common/url_test.go b/internal/util/common/url_test.go new file mode 100644 index 000000000..c6a5ea937 --- /dev/null +++ b/internal/util/common/url_test.go @@ -0,0 +1,29 @@ +package common + +import "testing" + +func TestEnsureURLScheme(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"whitespace only", " ", ""}, + {"bare telegram handle", "t.me/xui_support", "https://t.me/xui_support"}, + {"bare domain with path", "example.com/help", "https://example.com/help"}, + {"already https", "https://t.me/xui_support", "https://t.me/xui_support"}, + {"already http", "http://example.com", "http://example.com"}, + {"telegram deep link", "tg://resolve?domain=xui_support", "tg://resolve?domain=xui_support"}, + {"mailto", "mailto:support@example.com", "mailto:support@example.com"}, + {"tel", "tel:+1234567890", "tel:+1234567890"}, + {"trims whitespace", " t.me/xui_support ", "https://t.me/xui_support"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EnsureURLScheme(tt.in); got != tt.want { + t.Errorf("EnsureURLScheme(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index d3eedea17..2e3ff0626 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -710,11 +710,13 @@ func (s *SettingService) GetSubTitle() (string, error) { } func (s *SettingService) GetSubSupportUrl() (string, error) { - return s.getString("subSupportUrl") + value, err := s.getString("subSupportUrl") + return common.EnsureURLScheme(value), err } func (s *SettingService) GetSubProfileUrl() (string, error) { - return s.getString("subProfileUrl") + value, err := s.getString("subProfileUrl") + return common.EnsureURLScheme(value), err } func (s *SettingService) GetSubAnnounce() (string, error) { @@ -1177,6 +1179,12 @@ func validateSettingsURLs(allSetting *entity.AllSetting) error { } allSetting.TgBotAPIServer = u } + // Support/profile links land in subscription headers and page data, where + // client apps resolve a scheme-less value against the panel's own domain. + // Non-http schemes (tg://, mailto:) are legitimate here, so only default + // the scheme instead of forcing SanitizeHTTPURL's http(s)-only rule. + allSetting.SubSupportUrl = common.EnsureURLScheme(allSetting.SubSupportUrl) + allSetting.SubProfileUrl = common.EnsureURLScheme(allSetting.SubProfileUrl) return nil }