fix(sub): default https:// for scheme-less support and profile URLs

A support URL saved without a scheme (e.g. "t.me/handle") is served
verbatim in the subscription Support-Url header and page data, and client
apps resolve it relative to the subscription domain — clicking it lands
on "https://panel.example/t.me/handle". Same hazard for the profile URL.

Default the scheme to https:// when none is present, both when saving the
settings and when reading already-stored values, so existing databases are
covered without a migration. Deliberate non-http schemes (tg://, mailto:,
tel:) pass through untouched, which is why these two fields don't go
through SanitizeHTTPURL's http(s)-only validation.

Closes #5738
This commit is contained in:
MHSanaei
2026-07-02 13:47:10 +02:00
parent a335456cd3
commit fb3a1559b2
3 changed files with 60 additions and 2 deletions
+21
View File
@@ -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
}
+29
View File
@@ -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)
}
})
}
}
+10 -2
View File
@@ -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
}