fix(sub): keep serverDescription literal in external link fragments (#6580)

* fix(panel): accept 2FA codes from adjacent TOTP windows

CheckUser compared only gotp.Now(), so a code submitted at the end of
its 30s window (or with slight client/server clock drift) failed with
'invalid 2fa code', while the immediate retry in the next window
succeeded. Accept current +/-1 window, the standard TOTP skew
tolerance.

Fixes MHSanaei/3x-ui#6535

* fix(panel): share TOTP skew tolerance with VerifyTwoFactorCode

Move the +/-1 window helper to internal/util/totp so both 2FA
acceptance points use it: login (CheckUser) and disable/rebind plus
username/password changes (VerifyTwoFactorCode). Also shrink comments
to the 2-line house rule and anchor the unit test mid-window to avoid
a step-boundary flake.

Addresses review on #6546 (MEDIUM + 2 LOWs).

* fix(sub): keep serverDescription literal in external link fragments

Client external links escaped the whole remark, turning
?serverDescription=<base64> into %3F...%2F... so Happ lost its
subtitle. Split on ?serverDescription= like appendQueryAndFragment
(#6488): escape only the display name, keep a clean base64 tail
literal, fall back to full escaping otherwise.

Fixes MHSanaei/3x-ui#6575

* refactor(sub): share one serverDescription fragment split across link paths

#6488 fixed the split in appendQueryAndFragment and #6575 was the same
bug on the external-link path, which had its own copy. Both now call
escapeLinkFragment with their own escaper, so a later change to the tail
check cannot reach one path and miss the other.

---------

Co-authored-by: sdhfsl <sdhfsl@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
sdhfsl
2026-09-27 04:00:09 +08:00
committed by GitHub
parent a2ca023336
commit d42e2133c7
3 changed files with 60 additions and 13 deletions
+19 -12
View File
@@ -2330,22 +2330,29 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
if fragment != "" {
sb.WriteByte('#')
if before, after, ok := strings.Cut(fragment, "?serverDescription="); ok {
if _, err := base64.StdEncoding.DecodeString(after); err == nil && len(after) > 0 && !strings.ContainsAny(after, " \r\n\t#&") {
sb.WriteString(strings.ReplaceAll(url.QueryEscape(before), "+", "%20"))
sb.WriteString("?serverDescription=")
sb.WriteString(after)
} else {
sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
}
} else {
// Match the frontend's encodeURIComponent(remark): spaces become %20.
sb.WriteString(strings.ReplaceAll(url.QueryEscape(fragment), "+", "%20"))
}
sb.WriteString(escapeLinkFragment(fragment, encodeURIComponent))
}
return sb.String()
}
// encodeURIComponent matches the frontend's escaping of a remark: spaces become %20.
func encodeURIComponent(s string) string {
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
}
// escapeLinkFragment escapes a remark but keeps a valid ?serverDescription=<base64>
// tail literal, which Happ reads as the subtitle (#6488, #6575).
func escapeLinkFragment(fragment string, escape func(string) string) string {
before, after, ok := strings.Cut(fragment, "?serverDescription=")
if !ok || after == "" || strings.ContainsAny(after, " \r\n\t#&") {
return escape(fragment)
}
if _, err := base64.StdEncoding.DecodeString(after); err != nil {
return escape(fragment)
}
return escape(before) + "?serverDescription=" + after
}
// buildExternalProxyURLLinks is a thin adapter: it maps the legacy externalProxy
// entries to []ShareEndpoint and renders them through the unified endpoint path.
// Kept so the genVless/genTrojan/genShadowsocks call sites are unchanged.