From 04b9aa18eca7526a13956656f6a5d7e82f24a8eb Mon Sep 17 00:00:00 2001 From: Wu Shuwen Date: Fri, 11 Sep 2026 08:01:32 +0800 Subject: [PATCH] fix(http): infer extension for opaque URLs (#1495) --- internal/protocol/http/fetcher.go | 1 + internal/protocol/http/fetcher_test.go | 24 +++++++++ internal/protocol/http/filename_parse_test.go | 49 +++++++++++++++++++ internal/protocol/http/helper.go | 23 +++++++++ 4 files changed, 97 insertions(+) diff --git a/internal/protocol/http/fetcher.go b/internal/protocol/http/fetcher.go index cb168cf3..6dfd9855 100644 --- a/internal/protocol/http/fetcher.go +++ b/internal/protocol/http/fetcher.go @@ -459,6 +459,7 @@ func (f *Fetcher) Resolve(req *base.Request, opts *base.Options) error { if file.Name == "" || file.Name == "/" || file.Name == "." { file.Name = httpReq.URL.Hostname() } + file.Name = appendFilenameExtension(file.Name, resp.Header.Get("Content-Type")) res.Files = append(res.Files, file) f.meta.Res = res diff --git a/internal/protocol/http/fetcher_test.go b/internal/protocol/http/fetcher_test.go index 7f07bedb..16d8403c 100644 --- a/internal/protocol/http/fetcher_test.go +++ b/internal/protocol/http/fetcher_test.go @@ -221,6 +221,30 @@ func TestFetcher_Resolve(t *testing.T) { }) } +func TestFetcher_ResolveFilenameFromContentType(t *testing.T) { + server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + w.Header().Set("Content-Type", "image/webp") + w.WriteHeader(gohttp.StatusOK) + })) + defer server.Close() + + fetcher := buildFetcher() + defer fetcher.Pause() + + const opaqueName = "OIP-C.u5nEXTXND3PJF0gNTgw5YgHaFP" + err := fetcher.Resolve(&base.Request{URL: server.URL + "/" + opaqueName}, &base.Options{ + Name: test.DownloadName, + Path: test.Dir, + }) + if err != nil { + t.Fatal(err) + } + + if got := fetcher.meta.Res.Files[0].Name; got != opaqueName+".webp" { + t.Fatalf("resolved filename = %q, want %q", got, opaqueName+".webp") + } +} + func TestFetcher_ResolveWithHostHeader(t *testing.T) { listener := test.StartTestHostHeaderServer() defer listener.Close() diff --git a/internal/protocol/http/filename_parse_test.go b/internal/protocol/http/filename_parse_test.go index 7ce406f5..6945250f 100644 --- a/internal/protocol/http/filename_parse_test.go +++ b/internal/protocol/http/filename_parse_test.go @@ -136,3 +136,52 @@ func TestUnescapeHTMLEntities(t *testing.T) { }) } } + +func TestAppendFilenameExtension(t *testing.T) { + tests := []struct { + name string + filename string + contentType string + want string + }{ + { + name: "adds extension from content type", + filename: "download", + contentType: "image/png", + want: "download.png", + }, + { + name: "parses MIME parameters", + filename: "download", + contentType: "image/png; charset=binary", + want: "download.png", + }, + { + name: "preserves existing extension", + filename: "download.zip", + contentType: "application/zip", + want: "download.zip", + }, + { + name: "ignores invalid content type", + filename: "download", + contentType: "not a content type", + want: "download", + }, + { + name: "ignores unknown content type", + filename: "download", + contentType: "application/x-gopeed-unknown", + want: "download", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := appendFilenameExtension(tt.filename, tt.contentType) + if got != tt.want { + t.Errorf("appendFilenameExtension() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/protocol/http/helper.go b/internal/protocol/http/helper.go index 1292723e..30a27bac 100644 --- a/internal/protocol/http/helper.go +++ b/internal/protocol/http/helper.go @@ -12,6 +12,7 @@ import ( "net/http" "net/http/cookiejar" "net/url" + "path" "strings" "time" "unicode/utf8" @@ -314,6 +315,28 @@ func parseFilename(contentDisposition string) string { return parseFilenameFallback(contentDisposition) } +// appendFilenameExtension adds a MIME-derived extension when the resolved +// filename has no usable extension. Some image hosts use opaque path segments +// that look like extensions, even though the response Content-Type is the +// only reliable source for the file type. +func appendFilenameExtension(filename, contentType string) string { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil { + return filename + } + + extensions, err := mime.ExtensionsByType(mediaType) + if err != nil || len(extensions) == 0 { + return filename + } + + ext := path.Ext(filename) + if len(ext) > 1 && len(ext) <= 20 { + return filename + } + return filename + extensions[0] +} + // parseFilenameExtended handles RFC 5987 extended notation (filename*=) // Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip) func parseFilenameExtended(cd string) string {