From 32ca51718769cf400a982dce7f98cf8de4df448d Mon Sep 17 00:00:00 2001 From: phj233 <2780990934@qq.com> Date: Sat, 27 Jun 2026 16:26:56 +0800 Subject: [PATCH] fix(sub): add content disposition for subscriptions --- sub/subHandler.go | 33 ++++++++++++++++++++++ sub/subHandler_test.go | 62 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 sub/subHandler_test.go diff --git a/sub/subHandler.go b/sub/subHandler.go index 1a8cdc2..34b118c 100644 --- a/sub/subHandler.go +++ b/sub/subHandler.go @@ -1,6 +1,10 @@ package sub import ( + "fmt" + "net/url" + "strings" + "github.com/alireza0/s-ui/logger" "github.com/alireza0/s-ui/service" @@ -75,4 +79,33 @@ func (s *SubHandler) addHeaders(c *gin.Context, headers []string) { c.Writer.Header().Set("Subscription-Userinfo", headers[0]) c.Writer.Header().Set("Profile-Update-Interval", headers[1]) c.Writer.Header().Set("Profile-Title", headers[2]) + c.Writer.Header().Set("Content-Disposition", contentDispositionHeader(headers[2])) +} + +func contentDispositionHeader(name string) string { + filename := strings.TrimSpace(name) + if filename == "" { + filename = "subscription" + } + + return fmt.Sprintf("attachment; filename=\"%s\"; filename*=UTF-8''%s", asciiSafeFilename(filename), url.PathEscape(filename)) +} + +func asciiSafeFilename(filename string) string { + var builder strings.Builder + for _, r := range filename { + switch { + case r == '"' || r == '\\': + builder.WriteByte('_') + case r >= 0x20 && r <= 0x7e: + builder.WriteRune(r) + } + } + + fallback := strings.TrimSpace(builder.String()) + if fallback == "" { + return "subscription" + } + + return fallback } diff --git a/sub/subHandler_test.go b/sub/subHandler_test.go new file mode 100644 index 0000000..0629571 --- /dev/null +++ b/sub/subHandler_test.go @@ -0,0 +1,62 @@ +package sub + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestAddHeadersPreservesSubscriptionHeadersAndAddsContentDisposition(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + context, _ := gin.CreateTestContext(recorder) + + handler := &SubHandler{} + handler.addHeaders(context, []string{ + "upload=1; download=2; total=3; expire=4", + "12", + "phj233_vpn", + }) + + headers := recorder.Header() + tests := map[string]string{ + "Subscription-Userinfo": "upload=1; download=2; total=3; expire=4", + "Profile-Update-Interval": "12", + "Profile-Title": "phj233_vpn", + "Content-Disposition": "attachment; filename=\"phj233_vpn\"; filename*=UTF-8''phj233_vpn", + } + + for key, want := range tests { + if got := headers.Get(key); got != want { + t.Fatalf("header %s = %q, want %q", key, got, want) + } + } +} + +func TestContentDispositionHeaderUsesSubscriptionNameWithoutExtension(t *testing.T) { + got := contentDispositionHeader("phj233_vpn") + want := "attachment; filename=\"phj233_vpn\"; filename*=UTF-8''phj233_vpn" + + if got != want { + t.Fatalf("contentDispositionHeader() = %q, want %q", got, want) + } +} + +func TestContentDispositionHeaderEscapesUTF8Name(t *testing.T) { + got := contentDispositionHeader("蓝胖云 LanPangYun") + want := "attachment; filename=\"LanPangYun\"; filename*=UTF-8''%E8%93%9D%E8%83%96%E4%BA%91%20LanPangYun" + + if got != want { + t.Fatalf("contentDispositionHeader() = %q, want %q", got, want) + } +} + +func TestContentDispositionHeaderFallsBackWhenNameIsEmpty(t *testing.T) { + got := contentDispositionHeader(" ") + want := "attachment; filename=\"subscription\"; filename*=UTF-8''subscription" + + if got != want { + t.Fatalf("contentDispositionHeader() = %q, want %q", got, want) + } +}