瀏覽代碼

fix(sub): use a fullwidth percent in USAGE_PERCENTAGE (#6174)

* fix(sub): use a fullwidth percent in USAGE_PERCENTAGE

A remark is placed in the share link fragment, so an ASCII percent is
percent-encoded to %25. Happ treats such a fragment as malformed, discards the
whole remark and falls back to showing the server hostname, which defeats the
point of a remark template and leaks the host into the client's server list.

Emit U+FF05 FULLWIDTH PERCENT SIGN instead. It renders the same to a reader,
never produces %25, and round-trips through url.Parse unchanged.

* test(sub): exercise production fragment encoding

---------

Co-authored-by: n0ctal <[email protected]>
n0ctal 18 小時之前
父節點
當前提交
ad32144c42
共有 3 個文件被更改,包括 26 次插入10 次删除
  1. 1 1
      frontend/src/lib/remark/remarkVariables.ts
  2. 3 3
      internal/sub/remark_vars.go
  3. 22 6
      internal/sub/remark_vars_test.go

+ 1 - 1
frontend/src/lib/remark/remarkVariables.ts

@@ -39,7 +39,7 @@ export const REMARK_VARIABLES: RemarkVar[] = [
   { token: 'STATUS_EMOJI', group: 'time', sample: '✅' },
   { token: 'DAYS_LEFT', group: 'time', sample: '12' },
   { token: 'TIME_LEFT', group: 'time', sample: '12d 4h 30m' },
-  { token: 'USAGE_PERCENTAGE', group: 'time', sample: '52.3%' },
+  { token: 'USAGE_PERCENTAGE', group: 'time', sample: '52.3' },
   { token: 'EXPIRE_DATE', group: 'time', sample: '2026-09-01' },
   { token: 'JALALI_EXPIRE_DATE', group: 'time', sample: '1405/06/10' },
   { token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },

+ 3 - 3
internal/sub/remark_vars.go

@@ -350,8 +350,8 @@ func statusEmoji(st xray.ClientTraffic) string {
 	}
 }
 
-// usagePercentage computes the traffic usage as a percentage string (e.g. "52.3%").
-// Returns "" when the client has no traffic limit.
+// usagePercentage computes the traffic usage as a percentage string (e.g. "52.3").
+// Uses U+FF05: an ASCII percent encodes to %25, which Happ rejects, dropping the remark.
 func usagePercentage(st xray.ClientTraffic) string {
 	if st.Total <= 0 {
 		return ""
@@ -361,7 +361,7 @@ func usagePercentage(st xray.ClientTraffic) string {
 	if pct > 100 {
 		pct = 100 // clamp over-quota usage, consistent with TRAFFIC_LEFT
 	}
-	return fmt.Sprintf("%.1f%%", pct)
+	return fmt.Sprintf("%.1f", pct)
 }
 
 // timeLeftLabel renders remaining time as "Xd Xh Xm" (or shorter when days/hours

+ 22 - 6
internal/sub/remark_vars_test.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"net/url"
 	"strings"
 	"testing"
 
@@ -480,18 +481,33 @@ func TestStatusEmoji(t *testing.T) {
 }
 
 func TestUsagePercentage(t *testing.T) {
-	if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0%" {
+	if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0" {
 		t.Errorf("usagePercentage 50%% = %q", got)
 	}
 	if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
 		t.Errorf("usagePercentage unlimited = %q, want empty", got)
 	}
-	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0%" {
+	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0" {
 		t.Errorf("usagePercentage 100%% = %q", got)
 	}
 	// Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
-	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
-		t.Errorf("usagePercentage over-quota = %q, want 100.0%%", got)
+	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
+		t.Errorf("usagePercentage over-quota = %q, want 100.0%", got)
+	}
+}
+
+func TestUsagePercentageSurvivesFragmentEncoding(t *testing.T) {
+	remark := "node " + usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 50 * gb})
+	link := buildLinkWithParams("vless://[email protected]:443", nil, remark)
+	if strings.Contains(link, "%25") {
+		t.Fatalf("encoded remark contains %%25, Happ drops such remarks: %s", link)
+	}
+	u, err := url.Parse(link)
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if u.Fragment != remark {
+		t.Fatalf("fragment = %q, want %q", u.Fragment, remark)
 	}
 }
 
@@ -546,7 +562,7 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
 
 	cases := []struct{ tmpl, want string }{
 		{"{{STATUS_EMOJI}}", "✅"},
-		{"{{USAGE_PERCENTAGE}}", "50.0%"},
+		{"{{USAGE_PERCENTAGE}}", "50.0"},
 		{"{{PROTOCOL}}", "VLESS"},
 		{"{{TRANSPORT}}", "ws"},
 		{"{{SECURITY}}", "REALITY"},
@@ -619,7 +635,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
 		{"{DATA_USAGE}", "50.00GB"},
 		{"{DATA_LIMIT}", "100.00GB"},
 		{"{STATUS_EMOJI}", "✅"},
-		{"{USAGE_PERCENTAGE}", "50.0%"},
+		{"{USAGE_PERCENTAGE}", "50.0"},
 		{"{PROTOCOL}", "VLESS"},
 		{"{TRANSPORT}", "ws"},
 		{"{SECURITY}", "TLS"},