detached 3c04c30

Merge pull request #142 from bnema/fix/localhost-downloads

fix: localhost URL normalization and download handler clean architecture refactor
commit
3c04c30ffa8ee1841eb910a71c9f4307cb99efe0
author
bnema · · 2026-01-24 16:08 UTC
committer
GitHub · · 2026-01-24 16:08 UTC
merge of
9e989c0 + 8df2acd · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +1136 -206
M .mockery.yaml +1 -0
@@ -30,6 +30,7 @@ packages:
3030 FontDetector: {}
3131 KeybindingsProvider: {}
3232 KeybindingsSaver: {}
33+ DownloadResponse: {}
3334 github.com/bnema/dumber/internal/domain/repository:
3435 config:
3536 dir: '{{.InterfaceDir}}/mocks'
@@ -30,6 +30,7 @@ packages:
30 FontDetector: {}30 FontDetector: {}
31 KeybindingsProvider: {}31 KeybindingsProvider: {}
32 KeybindingsSaver: {}32 KeybindingsSaver: {}
33+ DownloadResponse: {}
33 github.com/bnema/dumber/internal/domain/repository:34 github.com/bnema/dumber/internal/domain/repository:
34 config:35 config:
35 dir: '{{.InterfaceDir}}/mocks'36 dir: '{{.InterfaceDir}}/mocks'
M internal/application/port/download.go +8 -0
@@ -26,3 +26,11 @@ type DownloadEvent struct {
2626 type DownloadEventHandler interface {
2727 OnDownloadEvent(ctx context.Context, event DownloadEvent)
2828 }
29+
30+// DownloadResponse provides access to response metadata for downloads.
31+// This abstracts the WebKit URIResponse to allow testing without CGO.
32+type DownloadResponse interface {
33+ GetMimeType() string
34+ GetSuggestedFilename() string
35+ GetUri() string
36+}
@@ -26,3 +26,11 @@ type DownloadEvent struct {
26 type DownloadEventHandler interface {26 type DownloadEventHandler interface {
27 OnDownloadEvent(ctx context.Context, event DownloadEvent)27 OnDownloadEvent(ctx context.Context, event DownloadEvent)
28 }28 }
29+
30+// DownloadResponse provides access to response metadata for downloads.
31+// This abstracts the WebKit URIResponse to allow testing without CGO.
32+type DownloadResponse interface {
33+ GetMimeType() string
34+ GetSuggestedFilename() string
35+ GetUri() string
36+}
A internal/application/usecase/prepare_download.go +111 -0
@@ -0,0 +1,111 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "path/filepath"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/download"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// PrepareDownloadInput contains the inputs for preparing a download destination.
13+type PrepareDownloadInput struct {
14+ // SuggestedFilename is the filename suggested by WebKit (from Content-Disposition or other sources).
15+ SuggestedFilename string
16+ // Response provides additional metadata (MIME type, URI) for filename resolution.
17+ // May be nil if not available.
18+ Response port.DownloadResponse
19+ // DownloadDir is the directory where downloads should be saved.
20+ DownloadDir string
21+}
22+
23+// PrepareDownloadOutput contains the resolved download destination.
24+type PrepareDownloadOutput struct {
25+ // Filename is the sanitized, safe filename to use.
26+ Filename string
27+ // DestinationPath is the full path where the download should be saved.
28+ DestinationPath string
29+}
30+
31+// PrepareDownloadUseCase orchestrates filename resolution for downloads.
32+// It combines multiple sources (suggested name, response headers, URI) to
33+// determine the best filename, then sanitizes it to prevent path traversal.
34+type PrepareDownloadUseCase struct {
35+ fs port.FileSystem
36+}
37+
38+// NewPrepareDownloadUseCase creates a new PrepareDownloadUseCase.
39+// If fs is nil, filename deduplication is disabled.
40+func NewPrepareDownloadUseCase(fs port.FileSystem) *PrepareDownloadUseCase {
41+ return &PrepareDownloadUseCase{fs: fs}
42+}
43+
44+// Execute resolves the download filename and destination path.
45+func (u *PrepareDownloadUseCase) Execute(ctx context.Context, input PrepareDownloadInput) *PrepareDownloadOutput {
46+ log := logging.FromContext(ctx)
47+
48+ // Resolve filename from multiple sources (priority: suggested > response suggested > URI)
49+ resolvedName := u.resolveSuggestedFilename(input.SuggestedFilename, input.Response)
50+
51+ // Get MIME type for extension inference
52+ mimeType := ""
53+ if input.Response != nil {
54+ mimeType = input.Response.GetMimeType()
55+ }
56+
57+ // Sanitize filename and add extension if needed
58+ safeName := download.SanitizeFilenameWithExtension(resolvedName, mimeType)
59+
60+ // Make filename unique if file already exists
61+ if u.fs != nil {
62+ safeName = download.MakeUniqueFilename(input.DownloadDir, safeName, func(path string) bool {
63+ exists, err := u.fs.Exists(ctx, path)
64+ if err != nil {
65+ log.Warn().Err(err).Str("path", path).
66+ Msg("failed to check file existence; assuming exists")
67+ return true // Conservative: assume exists to avoid overwrite
68+ }
69+ return exists
70+ })
71+ }
72+
73+ destPath := filepath.Join(input.DownloadDir, safeName)
74+
75+ log.Debug().
76+ Str("suggested", input.SuggestedFilename).
77+ Str("resolved", resolvedName).
78+ Str("sanitized", safeName).
79+ Str("destPath", destPath).
80+ Msg("prepared download destination")
81+
82+ return &PrepareDownloadOutput{
83+ Filename: safeName,
84+ DestinationPath: destPath,
85+ }
86+}
87+
88+// resolveSuggestedFilename determines the best filename from available sources.
89+func (*PrepareDownloadUseCase) resolveSuggestedFilename(name string, response port.DownloadResponse) string {
90+ // Priority 1: Use provided suggested filename
91+ if name != "" {
92+ return name
93+ }
94+
95+ if response == nil {
96+ return name
97+ }
98+
99+ // Priority 2: Use response's suggested filename (from Content-Disposition)
100+ if suggested := response.GetSuggestedFilename(); suggested != "" {
101+ return suggested
102+ }
103+
104+ // Priority 3: Extract from URI path
105+ uri := response.GetUri()
106+ if uri == "" {
107+ return name
108+ }
109+
110+ return download.ExtractFilenameFromURI(uri)
111+}
@@ -0,0 +1,111 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "path/filepath"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/download"
9+ "github.com/bnema/dumber/internal/logging"
10+)
11+
12+// PrepareDownloadInput contains the inputs for preparing a download destination.
13+type PrepareDownloadInput struct {
14+ // SuggestedFilename is the filename suggested by WebKit (from Content-Disposition or other sources).
15+ SuggestedFilename string
16+ // Response provides additional metadata (MIME type, URI) for filename resolution.
17+ // May be nil if not available.
18+ Response port.DownloadResponse
19+ // DownloadDir is the directory where downloads should be saved.
20+ DownloadDir string
21+}
22+
23+// PrepareDownloadOutput contains the resolved download destination.
24+type PrepareDownloadOutput struct {
25+ // Filename is the sanitized, safe filename to use.
26+ Filename string
27+ // DestinationPath is the full path where the download should be saved.
28+ DestinationPath string
29+}
30+
31+// PrepareDownloadUseCase orchestrates filename resolution for downloads.
32+// It combines multiple sources (suggested name, response headers, URI) to
33+// determine the best filename, then sanitizes it to prevent path traversal.
34+type PrepareDownloadUseCase struct {
35+ fs port.FileSystem
36+}
37+
38+// NewPrepareDownloadUseCase creates a new PrepareDownloadUseCase.
39+// If fs is nil, filename deduplication is disabled.
40+func NewPrepareDownloadUseCase(fs port.FileSystem) *PrepareDownloadUseCase {
41+ return &PrepareDownloadUseCase{fs: fs}
42+}
43+
44+// Execute resolves the download filename and destination path.
45+func (u *PrepareDownloadUseCase) Execute(ctx context.Context, input PrepareDownloadInput) *PrepareDownloadOutput {
46+ log := logging.FromContext(ctx)
47+
48+ // Resolve filename from multiple sources (priority: suggested > response suggested > URI)
49+ resolvedName := u.resolveSuggestedFilename(input.SuggestedFilename, input.Response)
50+
51+ // Get MIME type for extension inference
52+ mimeType := ""
53+ if input.Response != nil {
54+ mimeType = input.Response.GetMimeType()
55+ }
56+
57+ // Sanitize filename and add extension if needed
58+ safeName := download.SanitizeFilenameWithExtension(resolvedName, mimeType)
59+
60+ // Make filename unique if file already exists
61+ if u.fs != nil {
62+ safeName = download.MakeUniqueFilename(input.DownloadDir, safeName, func(path string) bool {
63+ exists, err := u.fs.Exists(ctx, path)
64+ if err != nil {
65+ log.Warn().Err(err).Str("path", path).
66+ Msg("failed to check file existence; assuming exists")
67+ return true // Conservative: assume exists to avoid overwrite
68+ }
69+ return exists
70+ })
71+ }
72+
73+ destPath := filepath.Join(input.DownloadDir, safeName)
74+
75+ log.Debug().
76+ Str("suggested", input.SuggestedFilename).
77+ Str("resolved", resolvedName).
78+ Str("sanitized", safeName).
79+ Str("destPath", destPath).
80+ Msg("prepared download destination")
81+
82+ return &PrepareDownloadOutput{
83+ Filename: safeName,
84+ DestinationPath: destPath,
85+ }
86+}
87+
88+// resolveSuggestedFilename determines the best filename from available sources.
89+func (*PrepareDownloadUseCase) resolveSuggestedFilename(name string, response port.DownloadResponse) string {
90+ // Priority 1: Use provided suggested filename
91+ if name != "" {
92+ return name
93+ }
94+
95+ if response == nil {
96+ return name
97+ }
98+
99+ // Priority 2: Use response's suggested filename (from Content-Disposition)
100+ if suggested := response.GetSuggestedFilename(); suggested != "" {
101+ return suggested
102+ }
103+
104+ // Priority 3: Extract from URI path
105+ uri := response.GetUri()
106+ if uri == "" {
107+ return name
108+ }
109+
110+ return download.ExtractFilenameFromURI(uri)
111+}
A internal/application/usecase/prepare_download_test.go +273 -0
@@ -0,0 +1,273 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+// mockDownloadResponse implements port.DownloadResponse for testing.
12+type mockDownloadResponse struct {
13+ mimeType string
14+ suggestedFilename string
15+ uri string
16+}
17+
18+func (m *mockDownloadResponse) GetMimeType() string { return m.mimeType }
19+func (m *mockDownloadResponse) GetSuggestedFilename() string { return m.suggestedFilename }
20+func (m *mockDownloadResponse) GetUri() string { return m.uri }
21+
22+// mockFileSystem implements port.FileSystem for testing.
23+type mockFileSystem struct {
24+ existingFiles map[string]bool
25+ existsErr error
26+}
27+
28+func (m *mockFileSystem) Exists(_ context.Context, path string) (bool, error) {
29+ if m.existsErr != nil {
30+ return false, m.existsErr
31+ }
32+ return m.existingFiles[path], nil
33+}
34+
35+func (*mockFileSystem) IsDirectory(_ context.Context, _ string) (bool, error) {
36+ return false, nil
37+}
38+
39+func (*mockFileSystem) GetSize(_ context.Context, _ string) (int64, error) {
40+ return 0, nil
41+}
42+
43+func (*mockFileSystem) RemoveAll(_ context.Context, _ string) error {
44+ return nil
45+}
46+
47+func TestPrepareDownloadUseCase_Execute(t *testing.T) {
48+ ctx := context.Background()
49+ uc := NewPrepareDownloadUseCase(nil) // nil FileSystem disables deduplication
50+
51+ tests := []struct {
52+ name string
53+ input PrepareDownloadInput
54+ expectedFilename string
55+ expectedPath string
56+ }{
57+ {
58+ name: "uses suggested filename directly",
59+ input: PrepareDownloadInput{
60+ SuggestedFilename: "document.pdf",
61+ DownloadDir: "/tmp/downloads",
62+ },
63+ expectedFilename: "document.pdf",
64+ expectedPath: "/tmp/downloads/document.pdf",
65+ },
66+ {
67+ name: "sanitizes path traversal in suggested filename",
68+ input: PrepareDownloadInput{
69+ SuggestedFilename: "../../../etc/passwd",
70+ DownloadDir: "/tmp/downloads",
71+ },
72+ expectedFilename: "passwd",
73+ expectedPath: "/tmp/downloads/passwd",
74+ },
75+ {
76+ name: "falls back to response suggested filename",
77+ input: PrepareDownloadInput{
78+ SuggestedFilename: "",
79+ Response: &mockDownloadResponse{
80+ suggestedFilename: "from-header.pdf",
81+ },
82+ DownloadDir: "/tmp/downloads",
83+ },
84+ expectedFilename: "from-header.pdf",
85+ expectedPath: "/tmp/downloads/from-header.pdf",
86+ },
87+ {
88+ name: "falls back to URI path",
89+ input: PrepareDownloadInput{
90+ SuggestedFilename: "",
91+ Response: &mockDownloadResponse{
92+ suggestedFilename: "",
93+ uri: "https://example.com/files/report.pdf",
94+ },
95+ DownloadDir: "/tmp/downloads",
96+ },
97+ expectedFilename: "report.pdf",
98+ expectedPath: "/tmp/downloads/report.pdf",
99+ },
100+ {
101+ name: "adds extension from MIME type when missing",
102+ input: PrepareDownloadInput{
103+ SuggestedFilename: "report",
104+ Response: &mockDownloadResponse{
105+ mimeType: "application/pdf",
106+ },
107+ DownloadDir: "/tmp/downloads",
108+ },
109+ expectedFilename: "report.pdf",
110+ expectedPath: "/tmp/downloads/report.pdf",
111+ },
112+ {
113+ name: "preserves existing extension even with MIME type",
114+ input: PrepareDownloadInput{
115+ SuggestedFilename: "report.doc",
116+ Response: &mockDownloadResponse{
117+ mimeType: "application/pdf",
118+ },
119+ DownloadDir: "/tmp/downloads",
120+ },
121+ expectedFilename: "report.doc",
122+ expectedPath: "/tmp/downloads/report.doc",
123+ },
124+ {
125+ name: "handles nil response",
126+ input: PrepareDownloadInput{
127+ SuggestedFilename: "file.txt",
128+ Response: nil,
129+ DownloadDir: "/tmp/downloads",
130+ },
131+ expectedFilename: "file.txt",
132+ expectedPath: "/tmp/downloads/file.txt",
133+ },
134+ {
135+ name: "uses default filename for empty inputs",
136+ input: PrepareDownloadInput{
137+ SuggestedFilename: "",
138+ Response: nil,
139+ DownloadDir: "/tmp/downloads",
140+ },
141+ expectedFilename: "download",
142+ expectedPath: "/tmp/downloads/download",
143+ },
144+ {
145+ name: "sanitizes windows path traversal",
146+ input: PrepareDownloadInput{
147+ SuggestedFilename: "..\\..\\Windows\\System32\\config",
148+ DownloadDir: "/tmp/downloads",
149+ },
150+ expectedFilename: "config",
151+ expectedPath: "/tmp/downloads/config",
152+ },
153+ }
154+
155+ for _, tt := range tests {
156+ t.Run(tt.name, func(t *testing.T) {
157+ result := uc.Execute(ctx, tt.input)
158+
159+ assert.NotNil(t, result)
160+ assert.Equal(t, tt.expectedFilename, result.Filename)
161+ assert.Equal(t, tt.expectedPath, result.DestinationPath)
162+ })
163+ }
164+}
165+
166+func TestPrepareDownloadUseCase_ResolvePriority(t *testing.T) {
167+ ctx := context.Background()
168+ uc := NewPrepareDownloadUseCase(nil)
169+
170+ t.Run("suggested filename takes priority over response", func(t *testing.T) {
171+ input := PrepareDownloadInput{
172+ SuggestedFilename: "priority.pdf",
173+ Response: &mockDownloadResponse{
174+ suggestedFilename: "response.pdf",
175+ uri: "https://example.com/uri.pdf",
176+ },
177+ DownloadDir: "/tmp",
178+ }
179+
180+ result := uc.Execute(ctx, input)
181+ assert.Equal(t, "priority.pdf", result.Filename)
182+ })
183+
184+ t.Run("response suggested takes priority over URI", func(t *testing.T) {
185+ input := PrepareDownloadInput{
186+ SuggestedFilename: "",
187+ Response: &mockDownloadResponse{
188+ suggestedFilename: "response.pdf",
189+ uri: "https://example.com/uri.pdf",
190+ },
191+ DownloadDir: "/tmp",
192+ }
193+
194+ result := uc.Execute(ctx, input)
195+ assert.Equal(t, "response.pdf", result.Filename)
196+ })
197+}
198+
199+func TestPrepareDownloadUseCase_Deduplication(t *testing.T) {
200+ ctx := context.Background()
201+
202+ t.Run("existing file triggers _(1) suffix", func(t *testing.T) {
203+ fs := &mockFileSystem{
204+ existingFiles: map[string]bool{
205+ "/tmp/downloads/document.pdf": true,
206+ },
207+ }
208+ uc := NewPrepareDownloadUseCase(fs)
209+
210+ input := PrepareDownloadInput{
211+ SuggestedFilename: "document.pdf",
212+ DownloadDir: "/tmp/downloads",
213+ }
214+
215+ result := uc.Execute(ctx, input)
216+ assert.Equal(t, "document_(1).pdf", result.Filename)
217+ assert.Equal(t, "/tmp/downloads/document_(1).pdf", result.DestinationPath)
218+ })
219+
220+ t.Run("multiple existing files increment suffix", func(t *testing.T) {
221+ fs := &mockFileSystem{
222+ existingFiles: map[string]bool{
223+ "/tmp/downloads/document.pdf": true,
224+ "/tmp/downloads/document_(1).pdf": true,
225+ },
226+ }
227+ uc := NewPrepareDownloadUseCase(fs)
228+
229+ input := PrepareDownloadInput{
230+ SuggestedFilename: "document.pdf",
231+ DownloadDir: "/tmp/downloads",
232+ }
233+
234+ result := uc.Execute(ctx, input)
235+ assert.Equal(t, "document_(2).pdf", result.Filename)
236+ assert.Equal(t, "/tmp/downloads/document_(2).pdf", result.DestinationPath)
237+ })
238+
239+ t.Run("fs.Exists error assumes file exists (conservative)", func(t *testing.T) {
240+ fs := &mockFileSystem{
241+ existingFiles: map[string]bool{},
242+ existsErr: errors.New("permission denied"),
243+ }
244+ uc := NewPrepareDownloadUseCase(fs)
245+
246+ input := PrepareDownloadInput{
247+ SuggestedFilename: "document.pdf",
248+ DownloadDir: "/tmp/downloads",
249+ }
250+
251+ result := uc.Execute(ctx, input)
252+ // When fs.Exists returns an error, we conservatively assume file exists
253+ // This triggers deduplication to use timestamp fallback (after 1000 attempts)
254+ assert.NotEqual(t, "document.pdf", result.Filename)
255+ assert.Contains(t, result.Filename, "document_")
256+ })
257+
258+ t.Run("non-existing file uses original name", func(t *testing.T) {
259+ fs := &mockFileSystem{
260+ existingFiles: map[string]bool{},
261+ }
262+ uc := NewPrepareDownloadUseCase(fs)
263+
264+ input := PrepareDownloadInput{
265+ SuggestedFilename: "document.pdf",
266+ DownloadDir: "/tmp/downloads",
267+ }
268+
269+ result := uc.Execute(ctx, input)
270+ assert.Equal(t, "document.pdf", result.Filename)
271+ assert.Equal(t, "/tmp/downloads/document.pdf", result.DestinationPath)
272+ })
273+}
@@ -0,0 +1,273 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+)
10+
11+// mockDownloadResponse implements port.DownloadResponse for testing.
12+type mockDownloadResponse struct {
13+ mimeType string
14+ suggestedFilename string
15+ uri string
16+}
17+
18+func (m *mockDownloadResponse) GetMimeType() string { return m.mimeType }
19+func (m *mockDownloadResponse) GetSuggestedFilename() string { return m.suggestedFilename }
20+func (m *mockDownloadResponse) GetUri() string { return m.uri }
21+
22+// mockFileSystem implements port.FileSystem for testing.
23+type mockFileSystem struct {
24+ existingFiles map[string]bool
25+ existsErr error
26+}
27+
28+func (m *mockFileSystem) Exists(_ context.Context, path string) (bool, error) {
29+ if m.existsErr != nil {
30+ return false, m.existsErr
31+ }
32+ return m.existingFiles[path], nil
33+}
34+
35+func (*mockFileSystem) IsDirectory(_ context.Context, _ string) (bool, error) {
36+ return false, nil
37+}
38+
39+func (*mockFileSystem) GetSize(_ context.Context, _ string) (int64, error) {
40+ return 0, nil
41+}
42+
43+func (*mockFileSystem) RemoveAll(_ context.Context, _ string) error {
44+ return nil
45+}
46+
47+func TestPrepareDownloadUseCase_Execute(t *testing.T) {
48+ ctx := context.Background()
49+ uc := NewPrepareDownloadUseCase(nil) // nil FileSystem disables deduplication
50+
51+ tests := []struct {
52+ name string
53+ input PrepareDownloadInput
54+ expectedFilename string
55+ expectedPath string
56+ }{
57+ {
58+ name: "uses suggested filename directly",
59+ input: PrepareDownloadInput{
60+ SuggestedFilename: "document.pdf",
61+ DownloadDir: "/tmp/downloads",
62+ },
63+ expectedFilename: "document.pdf",
64+ expectedPath: "/tmp/downloads/document.pdf",
65+ },
66+ {
67+ name: "sanitizes path traversal in suggested filename",
68+ input: PrepareDownloadInput{
69+ SuggestedFilename: "../../../etc/passwd",
70+ DownloadDir: "/tmp/downloads",
71+ },
72+ expectedFilename: "passwd",
73+ expectedPath: "/tmp/downloads/passwd",
74+ },
75+ {
76+ name: "falls back to response suggested filename",
77+ input: PrepareDownloadInput{
78+ SuggestedFilename: "",
79+ Response: &mockDownloadResponse{
80+ suggestedFilename: "from-header.pdf",
81+ },
82+ DownloadDir: "/tmp/downloads",
83+ },
84+ expectedFilename: "from-header.pdf",
85+ expectedPath: "/tmp/downloads/from-header.pdf",
86+ },
87+ {
88+ name: "falls back to URI path",
89+ input: PrepareDownloadInput{
90+ SuggestedFilename: "",
91+ Response: &mockDownloadResponse{
92+ suggestedFilename: "",
93+ uri: "https://example.com/files/report.pdf",
94+ },
95+ DownloadDir: "/tmp/downloads",
96+ },
97+ expectedFilename: "report.pdf",
98+ expectedPath: "/tmp/downloads/report.pdf",
99+ },
100+ {
101+ name: "adds extension from MIME type when missing",
102+ input: PrepareDownloadInput{
103+ SuggestedFilename: "report",
104+ Response: &mockDownloadResponse{
105+ mimeType: "application/pdf",
106+ },
107+ DownloadDir: "/tmp/downloads",
108+ },
109+ expectedFilename: "report.pdf",
110+ expectedPath: "/tmp/downloads/report.pdf",
111+ },
112+ {
113+ name: "preserves existing extension even with MIME type",
114+ input: PrepareDownloadInput{
115+ SuggestedFilename: "report.doc",
116+ Response: &mockDownloadResponse{
117+ mimeType: "application/pdf",
118+ },
119+ DownloadDir: "/tmp/downloads",
120+ },
121+ expectedFilename: "report.doc",
122+ expectedPath: "/tmp/downloads/report.doc",
123+ },
124+ {
125+ name: "handles nil response",
126+ input: PrepareDownloadInput{
127+ SuggestedFilename: "file.txt",
128+ Response: nil,
129+ DownloadDir: "/tmp/downloads",
130+ },
131+ expectedFilename: "file.txt",
132+ expectedPath: "/tmp/downloads/file.txt",
133+ },
134+ {
135+ name: "uses default filename for empty inputs",
136+ input: PrepareDownloadInput{
137+ SuggestedFilename: "",
138+ Response: nil,
139+ DownloadDir: "/tmp/downloads",
140+ },
141+ expectedFilename: "download",
142+ expectedPath: "/tmp/downloads/download",
143+ },
144+ {
145+ name: "sanitizes windows path traversal",
146+ input: PrepareDownloadInput{
147+ SuggestedFilename: "..\\..\\Windows\\System32\\config",
148+ DownloadDir: "/tmp/downloads",
149+ },
150+ expectedFilename: "config",
151+ expectedPath: "/tmp/downloads/config",
152+ },
153+ }
154+
155+ for _, tt := range tests {
156+ t.Run(tt.name, func(t *testing.T) {
157+ result := uc.Execute(ctx, tt.input)
158+
159+ assert.NotNil(t, result)
160+ assert.Equal(t, tt.expectedFilename, result.Filename)
161+ assert.Equal(t, tt.expectedPath, result.DestinationPath)
162+ })
163+ }
164+}
165+
166+func TestPrepareDownloadUseCase_ResolvePriority(t *testing.T) {
167+ ctx := context.Background()
168+ uc := NewPrepareDownloadUseCase(nil)
169+
170+ t.Run("suggested filename takes priority over response", func(t *testing.T) {
171+ input := PrepareDownloadInput{
172+ SuggestedFilename: "priority.pdf",
173+ Response: &mockDownloadResponse{
174+ suggestedFilename: "response.pdf",
175+ uri: "https://example.com/uri.pdf",
176+ },
177+ DownloadDir: "/tmp",
178+ }
179+
180+ result := uc.Execute(ctx, input)
181+ assert.Equal(t, "priority.pdf", result.Filename)
182+ })
183+
184+ t.Run("response suggested takes priority over URI", func(t *testing.T) {
185+ input := PrepareDownloadInput{
186+ SuggestedFilename: "",
187+ Response: &mockDownloadResponse{
188+ suggestedFilename: "response.pdf",
189+ uri: "https://example.com/uri.pdf",
190+ },
191+ DownloadDir: "/tmp",
192+ }
193+
194+ result := uc.Execute(ctx, input)
195+ assert.Equal(t, "response.pdf", result.Filename)
196+ })
197+}
198+
199+func TestPrepareDownloadUseCase_Deduplication(t *testing.T) {
200+ ctx := context.Background()
201+
202+ t.Run("existing file triggers _(1) suffix", func(t *testing.T) {
203+ fs := &mockFileSystem{
204+ existingFiles: map[string]bool{
205+ "/tmp/downloads/document.pdf": true,
206+ },
207+ }
208+ uc := NewPrepareDownloadUseCase(fs)
209+
210+ input := PrepareDownloadInput{
211+ SuggestedFilename: "document.pdf",
212+ DownloadDir: "/tmp/downloads",
213+ }
214+
215+ result := uc.Execute(ctx, input)
216+ assert.Equal(t, "document_(1).pdf", result.Filename)
217+ assert.Equal(t, "/tmp/downloads/document_(1).pdf", result.DestinationPath)
218+ })
219+
220+ t.Run("multiple existing files increment suffix", func(t *testing.T) {
221+ fs := &mockFileSystem{
222+ existingFiles: map[string]bool{
223+ "/tmp/downloads/document.pdf": true,
224+ "/tmp/downloads/document_(1).pdf": true,
225+ },
226+ }
227+ uc := NewPrepareDownloadUseCase(fs)
228+
229+ input := PrepareDownloadInput{
230+ SuggestedFilename: "document.pdf",
231+ DownloadDir: "/tmp/downloads",
232+ }
233+
234+ result := uc.Execute(ctx, input)
235+ assert.Equal(t, "document_(2).pdf", result.Filename)
236+ assert.Equal(t, "/tmp/downloads/document_(2).pdf", result.DestinationPath)
237+ })
238+
239+ t.Run("fs.Exists error assumes file exists (conservative)", func(t *testing.T) {
240+ fs := &mockFileSystem{
241+ existingFiles: map[string]bool{},
242+ existsErr: errors.New("permission denied"),
243+ }
244+ uc := NewPrepareDownloadUseCase(fs)
245+
246+ input := PrepareDownloadInput{
247+ SuggestedFilename: "document.pdf",
248+ DownloadDir: "/tmp/downloads",
249+ }
250+
251+ result := uc.Execute(ctx, input)
252+ // When fs.Exists returns an error, we conservatively assume file exists
253+ // This triggers deduplication to use timestamp fallback (after 1000 attempts)
254+ assert.NotEqual(t, "document.pdf", result.Filename)
255+ assert.Contains(t, result.Filename, "document_")
256+ })
257+
258+ t.Run("non-existing file uses original name", func(t *testing.T) {
259+ fs := &mockFileSystem{
260+ existingFiles: map[string]bool{},
261+ }
262+ uc := NewPrepareDownloadUseCase(fs)
263+
264+ input := PrepareDownloadInput{
265+ SuggestedFilename: "document.pdf",
266+ DownloadDir: "/tmp/downloads",
267+ }
268+
269+ result := uc.Execute(ctx, input)
270+ assert.Equal(t, "document.pdf", result.Filename)
271+ assert.Equal(t, "/tmp/downloads/document.pdf", result.DestinationPath)
272+ })
273+}
A internal/domain/download/filename.go +135 -0
@@ -0,0 +1,135 @@
1+package download
2+
3+import (
4+ "fmt"
5+ "mime"
6+ "net/url"
7+ "path/filepath"
8+ "strings"
9+ "time"
10+)
11+
12+const (
13+ // DefaultFilename is used when no valid filename can be determined.
14+ DefaultFilename = "download"
15+)
16+
17+// SanitizeFilename sanitizes a filename to prevent path traversal attacks.
18+// It extracts only the base name and handles edge cases like "." or "..".
19+func SanitizeFilename(name string) string {
20+ // Normalize Windows-style separators to forward slashes.
21+ // filepath.Base only handles the OS-native separator, so on Linux
22+ // backslashes would not be treated as path separators.
23+ name = strings.ReplaceAll(name, "\\", "/")
24+
25+ // Get only the base name (removes any directory components).
26+ clean := filepath.Base(name)
27+
28+ // If Base returns "." or ".." (edge cases), use fallback.
29+ if clean == "." || clean == ".." || clean == "" {
30+ return DefaultFilename
31+ }
32+
33+ return clean
34+}
35+
36+// SanitizeFilenameWithExtension sanitizes a filename and adds an extension
37+// inferred from the MIME type if the filename has no extension.
38+func SanitizeFilenameWithExtension(name, mimeType string) string {
39+ clean := SanitizeFilename(name)
40+ if filepath.Ext(clean) == "" {
41+ if ext := GetExtensionFromMimeType(mimeType); ext != "" {
42+ return clean + ext
43+ }
44+ }
45+
46+ return clean
47+}
48+
49+// GetExtensionFromMimeType returns a file extension for a given MIME type.
50+// Returns empty string if MIME type is unknown or empty.
51+// Handles MIME types with parameters (e.g., "application/pdf; charset=binary").
52+func GetExtensionFromMimeType(mimeType string) string {
53+ if mimeType == "" {
54+ return ""
55+ }
56+
57+ // Parse media type to extract bare type without parameters.
58+ // mime.ExtensionsByType expects "application/pdf", not "application/pdf; charset=binary".
59+ mediaType, _, err := mime.ParseMediaType(mimeType)
60+ if err != nil || mediaType == "" {
61+ return ""
62+ }
63+
64+ exts, err := mime.ExtensionsByType(mediaType)
65+ if err != nil || len(exts) == 0 {
66+ return ""
67+ }
68+
69+ return exts[0]
70+}
71+
72+// ExtractFilenameFromURI extracts the filename from a URI path component.
73+// Handles both URIs and plain paths. Returns DefaultFilename for edge cases.
74+func ExtractFilenameFromURI(uri string) string {
75+ if uri == "" {
76+ return DefaultFilename
77+ }
78+
79+ parsed, err := url.Parse(uri)
80+ if err != nil {
81+ // Fall back to treating as plain path
82+ return extractFromPath(uri)
83+ }
84+
85+ return extractFromPath(parsed.Path)
86+}
87+
88+// ExtractFilenameFromDestination extracts the filename from a file:// URI or path.
89+// Used to get filename from WebKit download destination.
90+func ExtractFilenameFromDestination(dest string) string {
91+ // Remove file:// prefix if present.
92+ path := strings.TrimPrefix(dest, "file://")
93+ base := filepath.Base(path)
94+
95+ // Handle edge cases consistently with SanitizeFilename.
96+ if base == "." || base == "" {
97+ return DefaultFilename
98+ }
99+ return base
100+}
101+
102+// extractFromPath extracts filename from a path string.
103+func extractFromPath(path string) string {
104+ base := filepath.Base(path)
105+ if base == "." || base == "" || base == "/" {
106+ return DefaultFilename
107+ }
108+ return base
109+}
110+
111+// MakeUniqueFilename generates a unique filename by appending _(N) if needed.
112+// The exists function should return true if the given path already exists.
113+func MakeUniqueFilename(dir, filename string, exists func(path string) bool) string {
114+ destPath := filepath.Join(dir, filename)
115+ if !exists(destPath) {
116+ return filename
117+ }
118+
119+ // Split filename into base and extension
120+ ext := filepath.Ext(filename)
121+ base := strings.TrimSuffix(filename, ext)
122+
123+ // Try incrementing numbers until we find a unique name
124+ for i := 1; i < 1000; i++ {
125+ candidate := fmt.Sprintf("%s_(%d)%s", base, i, ext)
126+ candidatePath := filepath.Join(dir, candidate)
127+ if !exists(candidatePath) {
128+ return candidate
129+ }
130+ }
131+
132+ // Fallback: use timestamp
133+ candidate := fmt.Sprintf("%s_%d%s", base, time.Now().UnixNano(), ext)
134+ return candidate
135+}
@@ -0,0 +1,135 @@
1+package download
2+
3+import (
4+ "fmt"
5+ "mime"
6+ "net/url"
7+ "path/filepath"
8+ "strings"
9+ "time"
10+)
11+
12+const (
13+ // DefaultFilename is used when no valid filename can be determined.
14+ DefaultFilename = "download"
15+)
16+
17+// SanitizeFilename sanitizes a filename to prevent path traversal attacks.
18+// It extracts only the base name and handles edge cases like "." or "..".
19+func SanitizeFilename(name string) string {
20+ // Normalize Windows-style separators to forward slashes.
21+ // filepath.Base only handles the OS-native separator, so on Linux
22+ // backslashes would not be treated as path separators.
23+ name = strings.ReplaceAll(name, "\\", "/")
24+
25+ // Get only the base name (removes any directory components).
26+ clean := filepath.Base(name)
27+
28+ // If Base returns "." or ".." (edge cases), use fallback.
29+ if clean == "." || clean == ".." || clean == "" {
30+ return DefaultFilename
31+ }
32+
33+ return clean
34+}
35+
36+// SanitizeFilenameWithExtension sanitizes a filename and adds an extension
37+// inferred from the MIME type if the filename has no extension.
38+func SanitizeFilenameWithExtension(name, mimeType string) string {
39+ clean := SanitizeFilename(name)
40+ if filepath.Ext(clean) == "" {
41+ if ext := GetExtensionFromMimeType(mimeType); ext != "" {
42+ return clean + ext
43+ }
44+ }
45+
46+ return clean
47+}
48+
49+// GetExtensionFromMimeType returns a file extension for a given MIME type.
50+// Returns empty string if MIME type is unknown or empty.
51+// Handles MIME types with parameters (e.g., "application/pdf; charset=binary").
52+func GetExtensionFromMimeType(mimeType string) string {
53+ if mimeType == "" {
54+ return ""
55+ }
56+
57+ // Parse media type to extract bare type without parameters.
58+ // mime.ExtensionsByType expects "application/pdf", not "application/pdf; charset=binary".
59+ mediaType, _, err := mime.ParseMediaType(mimeType)
60+ if err != nil || mediaType == "" {
61+ return ""
62+ }
63+
64+ exts, err := mime.ExtensionsByType(mediaType)
65+ if err != nil || len(exts) == 0 {
66+ return ""
67+ }
68+
69+ return exts[0]
70+}
71+
72+// ExtractFilenameFromURI extracts the filename from a URI path component.
73+// Handles both URIs and plain paths. Returns DefaultFilename for edge cases.
74+func ExtractFilenameFromURI(uri string) string {
75+ if uri == "" {
76+ return DefaultFilename
77+ }
78+
79+ parsed, err := url.Parse(uri)
80+ if err != nil {
81+ // Fall back to treating as plain path
82+ return extractFromPath(uri)
83+ }
84+
85+ return extractFromPath(parsed.Path)
86+}
87+
88+// ExtractFilenameFromDestination extracts the filename from a file:// URI or path.
89+// Used to get filename from WebKit download destination.
90+func ExtractFilenameFromDestination(dest string) string {
91+ // Remove file:// prefix if present.
92+ path := strings.TrimPrefix(dest, "file://")
93+ base := filepath.Base(path)
94+
95+ // Handle edge cases consistently with SanitizeFilename.
96+ if base == "." || base == "" {
97+ return DefaultFilename
98+ }
99+ return base
100+}
101+
102+// extractFromPath extracts filename from a path string.
103+func extractFromPath(path string) string {
104+ base := filepath.Base(path)
105+ if base == "." || base == "" || base == "/" {
106+ return DefaultFilename
107+ }
108+ return base
109+}
110+
111+// MakeUniqueFilename generates a unique filename by appending _(N) if needed.
112+// The exists function should return true if the given path already exists.
113+func MakeUniqueFilename(dir, filename string, exists func(path string) bool) string {
114+ destPath := filepath.Join(dir, filename)
115+ if !exists(destPath) {
116+ return filename
117+ }
118+
119+ // Split filename into base and extension
120+ ext := filepath.Ext(filename)
121+ base := strings.TrimSuffix(filename, ext)
122+
123+ // Try incrementing numbers until we find a unique name
124+ for i := 1; i < 1000; i++ {
125+ candidate := fmt.Sprintf("%s_(%d)%s", base, i, ext)
126+ candidatePath := filepath.Join(dir, candidate)
127+ if !exists(candidatePath) {
128+ return candidate
129+ }
130+ }
131+
132+ // Fallback: use timestamp
133+ candidate := fmt.Sprintf("%s_%d%s", base, time.Now().UnixNano(), ext)
134+ return candidate
135+}
A internal/domain/download/filename_test.go +322 -0
@@ -0,0 +1,322 @@
1+package download
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSanitizeFilename(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ expected string
14+ }{
15+ {
16+ name: "normal filename",
17+ input: "document.pdf",
18+ expected: "document.pdf",
19+ },
20+ {
21+ name: "filename with spaces",
22+ input: "my document.pdf",
23+ expected: "my document.pdf",
24+ },
25+ {
26+ name: "path traversal with parent dirs",
27+ input: "../../../etc/passwd",
28+ expected: "passwd",
29+ },
30+ {
31+ name: "path traversal hidden file",
32+ input: "../.ssh/id_rsa",
33+ expected: "id_rsa",
34+ },
35+ {
36+ name: "nested path",
37+ input: "foo/bar/baz.txt",
38+ expected: "baz.txt",
39+ },
40+ {
41+ name: "absolute path",
42+ input: "/etc/passwd",
43+ expected: "passwd",
44+ },
45+ {
46+ name: "dot only",
47+ input: ".",
48+ expected: "download",
49+ },
50+ {
51+ name: "double dot only",
52+ input: "..",
53+ expected: "download",
54+ },
55+ {
56+ name: "empty string",
57+ input: "",
58+ expected: "download",
59+ },
60+ {
61+ name: "hidden file",
62+ input: ".bashrc",
63+ expected: ".bashrc",
64+ },
65+ {
66+ name: "windows style path",
67+ input: "..\\..\\Windows\\System32\\config",
68+ expected: "config",
69+ },
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ result := SanitizeFilename(tt.input)
75+ assert.Equal(t, tt.expected, result)
76+ })
77+ }
78+}
79+
80+func TestSanitizeFilenameWithExtension(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ input string
84+ mimeType string
85+ expected string
86+ }{
87+ {
88+ name: "no extension adds inferred",
89+ input: "download",
90+ mimeType: "application/pdf",
91+ expected: "download.pdf",
92+ },
93+ {
94+ name: "existing extension preserved",
95+ input: "report.pdf",
96+ mimeType: "application/pdf",
97+ expected: "report.pdf",
98+ },
99+ {
100+ name: "path traversal with inferred extension",
101+ input: "../report",
102+ mimeType: "application/pdf",
103+ expected: "report.pdf",
104+ },
105+ {
106+ name: "no mime type returns sanitized",
107+ input: "report",
108+ mimeType: "",
109+ expected: "report",
110+ },
111+ }
112+
113+ for _, tt := range tests {
114+ t.Run(tt.name, func(t *testing.T) {
115+ result := SanitizeFilenameWithExtension(tt.input, tt.mimeType)
116+ assert.Equal(t, tt.expected, result)
117+ })
118+ }
119+}
120+
121+func TestGetExtensionFromMimeType(t *testing.T) {
122+ tests := []struct {
123+ name string
124+ input string
125+ expected string
126+ }{
127+ {
128+ name: "empty mime type",
129+ input: "",
130+ expected: "",
131+ },
132+ {
133+ name: "unknown mime type",
134+ input: "application/unknown",
135+ expected: "",
136+ },
137+ {
138+ name: "pdf mime type",
139+ input: "application/pdf",
140+ expected: ".pdf",
141+ },
142+ {
143+ name: "pdf mime type with charset param",
144+ input: "application/pdf; charset=binary",
145+ expected: ".pdf",
146+ },
147+ {
148+ name: "text html with charset",
149+ input: "text/html; charset=utf-8",
150+ expected: ".htm",
151+ },
152+ {
153+ name: "mime type with multiple params",
154+ input: "application/json; charset=utf-8; boundary=something",
155+ expected: ".json",
156+ },
157+ {
158+ name: "invalid mime type",
159+ input: "not-a-valid-mime",
160+ expected: "",
161+ },
162+ }
163+
164+ for _, tt := range tests {
165+ t.Run(tt.name, func(t *testing.T) {
166+ result := GetExtensionFromMimeType(tt.input)
167+ assert.Equal(t, tt.expected, result)
168+ })
169+ }
170+}
171+
172+func TestExtractFilenameFromDestination(t *testing.T) {
173+ tests := []struct {
174+ name string
175+ input string
176+ expected string
177+ }{
178+ {
179+ name: "file URI with path",
180+ input: "file:///home/user/Downloads/document.pdf",
181+ expected: "document.pdf",
182+ },
183+ {
184+ name: "file URI root",
185+ input: "file:///document.pdf",
186+ expected: "document.pdf",
187+ },
188+ {
189+ name: "plain path",
190+ input: "/home/user/Downloads/image.png",
191+ expected: "image.png",
192+ },
193+ {
194+ name: "filename only",
195+ input: "video.mp4",
196+ expected: "video.mp4",
197+ },
198+ {
199+ name: "empty string",
200+ input: "",
201+ expected: "download",
202+ },
203+ {
204+ name: "file URI with spaces",
205+ input: "file:///home/user/My Documents/file.txt",
206+ expected: "file.txt",
207+ },
208+ }
209+
210+ for _, tt := range tests {
211+ t.Run(tt.name, func(t *testing.T) {
212+ result := ExtractFilenameFromDestination(tt.input)
213+ assert.Equal(t, tt.expected, result)
214+ })
215+ }
216+}
217+
218+func TestExtractFilenameFromURI(t *testing.T) {
219+ tests := []struct {
220+ name string
221+ input string
222+ expected string
223+ }{
224+ {
225+ name: "http URL with filename",
226+ input: "https://example.com/files/document.pdf",
227+ expected: "document.pdf",
228+ },
229+ {
230+ name: "http URL with query params",
231+ input: "https://example.com/files/document.pdf?token=abc",
232+ expected: "document.pdf",
233+ },
234+ {
235+ name: "http URL without filename",
236+ input: "https://example.com/",
237+ expected: "download",
238+ },
239+ {
240+ name: "empty string",
241+ input: "",
242+ expected: "download",
243+ },
244+ {
245+ name: "plain path",
246+ input: "/path/to/file.txt",
247+ expected: "file.txt",
248+ },
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ result := ExtractFilenameFromURI(tt.input)
254+ assert.Equal(t, tt.expected, result)
255+ })
256+ }
257+}
258+
259+func TestMakeUniqueFilename(t *testing.T) {
260+ tests := []struct {
261+ name string
262+ dir string
263+ filename string
264+ existingFiles map[string]bool
265+ expected string
266+ }{
267+ {
268+ name: "file does not exist",
269+ dir: "/tmp",
270+ filename: "document.pdf",
271+ existingFiles: map[string]bool{},
272+ expected: "document.pdf",
273+ },
274+ {
275+ name: "file exists, adds _(1)",
276+ dir: "/tmp",
277+ filename: "document.pdf",
278+ existingFiles: map[string]bool{
279+ "/tmp/document.pdf": true,
280+ },
281+ expected: "document_(1).pdf",
282+ },
283+ {
284+ name: "file and _(1) exist, adds _(2)",
285+ dir: "/tmp",
286+ filename: "document.pdf",
287+ existingFiles: map[string]bool{
288+ "/tmp/document.pdf": true,
289+ "/tmp/document_(1).pdf": true,
290+ },
291+ expected: "document_(2).pdf",
292+ },
293+ {
294+ name: "no extension",
295+ dir: "/tmp",
296+ filename: "download",
297+ existingFiles: map[string]bool{
298+ "/tmp/download": true,
299+ },
300+ expected: "download_(1)",
301+ },
302+ {
303+ name: "hidden file with extension",
304+ dir: "/tmp",
305+ filename: ".config.bak",
306+ existingFiles: map[string]bool{
307+ "/tmp/.config.bak": true,
308+ },
309+ expected: ".config_(1).bak",
310+ },
311+ }
312+
313+ for _, tt := range tests {
314+ t.Run(tt.name, func(t *testing.T) {
315+ exists := func(path string) bool {
316+ return tt.existingFiles[path]
317+ }
318+ result := MakeUniqueFilename(tt.dir, tt.filename, exists)
319+ assert.Equal(t, tt.expected, result)
320+ })
321+ }
322+}
@@ -0,0 +1,322 @@
1+package download
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestSanitizeFilename(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ input string
13+ expected string
14+ }{
15+ {
16+ name: "normal filename",
17+ input: "document.pdf",
18+ expected: "document.pdf",
19+ },
20+ {
21+ name: "filename with spaces",
22+ input: "my document.pdf",
23+ expected: "my document.pdf",
24+ },
25+ {
26+ name: "path traversal with parent dirs",
27+ input: "../../../etc/passwd",
28+ expected: "passwd",
29+ },
30+ {
31+ name: "path traversal hidden file",
32+ input: "../.ssh/id_rsa",
33+ expected: "id_rsa",
34+ },
35+ {
36+ name: "nested path",
37+ input: "foo/bar/baz.txt",
38+ expected: "baz.txt",
39+ },
40+ {
41+ name: "absolute path",
42+ input: "/etc/passwd",
43+ expected: "passwd",
44+ },
45+ {
46+ name: "dot only",
47+ input: ".",
48+ expected: "download",
49+ },
50+ {
51+ name: "double dot only",
52+ input: "..",
53+ expected: "download",
54+ },
55+ {
56+ name: "empty string",
57+ input: "",
58+ expected: "download",
59+ },
60+ {
61+ name: "hidden file",
62+ input: ".bashrc",
63+ expected: ".bashrc",
64+ },
65+ {
66+ name: "windows style path",
67+ input: "..\\..\\Windows\\System32\\config",
68+ expected: "config",
69+ },
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, func(t *testing.T) {
74+ result := SanitizeFilename(tt.input)
75+ assert.Equal(t, tt.expected, result)
76+ })
77+ }
78+}
79+
80+func TestSanitizeFilenameWithExtension(t *testing.T) {
81+ tests := []struct {
82+ name string
83+ input string
84+ mimeType string
85+ expected string
86+ }{
87+ {
88+ name: "no extension adds inferred",
89+ input: "download",
90+ mimeType: "application/pdf",
91+ expected: "download.pdf",
92+ },
93+ {
94+ name: "existing extension preserved",
95+ input: "report.pdf",
96+ mimeType: "application/pdf",
97+ expected: "report.pdf",
98+ },
99+ {
100+ name: "path traversal with inferred extension",
101+ input: "../report",
102+ mimeType: "application/pdf",
103+ expected: "report.pdf",
104+ },
105+ {
106+ name: "no mime type returns sanitized",
107+ input: "report",
108+ mimeType: "",
109+ expected: "report",
110+ },
111+ }
112+
113+ for _, tt := range tests {
114+ t.Run(tt.name, func(t *testing.T) {
115+ result := SanitizeFilenameWithExtension(tt.input, tt.mimeType)
116+ assert.Equal(t, tt.expected, result)
117+ })
118+ }
119+}
120+
121+func TestGetExtensionFromMimeType(t *testing.T) {
122+ tests := []struct {
123+ name string
124+ input string
125+ expected string
126+ }{
127+ {
128+ name: "empty mime type",
129+ input: "",
130+ expected: "",
131+ },
132+ {
133+ name: "unknown mime type",
134+ input: "application/unknown",
135+ expected: "",
136+ },
137+ {
138+ name: "pdf mime type",
139+ input: "application/pdf",
140+ expected: ".pdf",
141+ },
142+ {
143+ name: "pdf mime type with charset param",
144+ input: "application/pdf; charset=binary",
145+ expected: ".pdf",
146+ },
147+ {
148+ name: "text html with charset",
149+ input: "text/html; charset=utf-8",
150+ expected: ".htm",
151+ },
152+ {
153+ name: "mime type with multiple params",
154+ input: "application/json; charset=utf-8; boundary=something",
155+ expected: ".json",
156+ },
157+ {
158+ name: "invalid mime type",
159+ input: "not-a-valid-mime",
160+ expected: "",
161+ },
162+ }
163+
164+ for _, tt := range tests {
165+ t.Run(tt.name, func(t *testing.T) {
166+ result := GetExtensionFromMimeType(tt.input)
167+ assert.Equal(t, tt.expected, result)
168+ })
169+ }
170+}
171+
172+func TestExtractFilenameFromDestination(t *testing.T) {
173+ tests := []struct {
174+ name string
175+ input string
176+ expected string
177+ }{
178+ {
179+ name: "file URI with path",
180+ input: "file:///home/user/Downloads/document.pdf",
181+ expected: "document.pdf",
182+ },
183+ {
184+ name: "file URI root",
185+ input: "file:///document.pdf",
186+ expected: "document.pdf",
187+ },
188+ {
189+ name: "plain path",
190+ input: "/home/user/Downloads/image.png",
191+ expected: "image.png",
192+ },
193+ {
194+ name: "filename only",
195+ input: "video.mp4",
196+ expected: "video.mp4",
197+ },
198+ {
199+ name: "empty string",
200+ input: "",
201+ expected: "download",
202+ },
203+ {
204+ name: "file URI with spaces",
205+ input: "file:///home/user/My Documents/file.txt",
206+ expected: "file.txt",
207+ },
208+ }
209+
210+ for _, tt := range tests {
211+ t.Run(tt.name, func(t *testing.T) {
212+ result := ExtractFilenameFromDestination(tt.input)
213+ assert.Equal(t, tt.expected, result)
214+ })
215+ }
216+}
217+
218+func TestExtractFilenameFromURI(t *testing.T) {
219+ tests := []struct {
220+ name string
221+ input string
222+ expected string
223+ }{
224+ {
225+ name: "http URL with filename",
226+ input: "https://example.com/files/document.pdf",
227+ expected: "document.pdf",
228+ },
229+ {
230+ name: "http URL with query params",
231+ input: "https://example.com/files/document.pdf?token=abc",
232+ expected: "document.pdf",
233+ },
234+ {
235+ name: "http URL without filename",
236+ input: "https://example.com/",
237+ expected: "download",
238+ },
239+ {
240+ name: "empty string",
241+ input: "",
242+ expected: "download",
243+ },
244+ {
245+ name: "plain path",
246+ input: "/path/to/file.txt",
247+ expected: "file.txt",
248+ },
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ result := ExtractFilenameFromURI(tt.input)
254+ assert.Equal(t, tt.expected, result)
255+ })
256+ }
257+}
258+
259+func TestMakeUniqueFilename(t *testing.T) {
260+ tests := []struct {
261+ name string
262+ dir string
263+ filename string
264+ existingFiles map[string]bool
265+ expected string
266+ }{
267+ {
268+ name: "file does not exist",
269+ dir: "/tmp",
270+ filename: "document.pdf",
271+ existingFiles: map[string]bool{},
272+ expected: "document.pdf",
273+ },
274+ {
275+ name: "file exists, adds _(1)",
276+ dir: "/tmp",
277+ filename: "document.pdf",
278+ existingFiles: map[string]bool{
279+ "/tmp/document.pdf": true,
280+ },
281+ expected: "document_(1).pdf",
282+ },
283+ {
284+ name: "file and _(1) exist, adds _(2)",
285+ dir: "/tmp",
286+ filename: "document.pdf",
287+ existingFiles: map[string]bool{
288+ "/tmp/document.pdf": true,
289+ "/tmp/document_(1).pdf": true,
290+ },
291+ expected: "document_(2).pdf",
292+ },
293+ {
294+ name: "no extension",
295+ dir: "/tmp",
296+ filename: "download",
297+ existingFiles: map[string]bool{
298+ "/tmp/download": true,
299+ },
300+ expected: "download_(1)",
301+ },
302+ {
303+ name: "hidden file with extension",
304+ dir: "/tmp",
305+ filename: ".config.bak",
306+ existingFiles: map[string]bool{
307+ "/tmp/.config.bak": true,
308+ },
309+ expected: ".config_(1).bak",
310+ },
311+ }
312+
313+ for _, tt := range tests {
314+ t.Run(tt.name, func(t *testing.T) {
315+ exists := func(path string) bool {
316+ return tt.existingFiles[path]
317+ }
318+ result := MakeUniqueFilename(tt.dir, tt.filename, exists)
319+ assert.Equal(t, tt.expected, result)
320+ })
321+ }
322+}
M internal/domain/url/normalize.go +6 -0
@@ -82,6 +82,9 @@ func Normalize(input string) string {
8282 }
8383
8484 // Looks like a URL (contains . and no spaces)
85+ if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
86+ return "http://" + input
87+ }
8588 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
8689 return "https://" + input
8790 }
@@ -112,6 +115,9 @@ func LooksLikeURL(input string) bool {
112115 }
113116
114117 // Contains a dot and no spaces = likely a URL
118+ if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
119+ return true
120+ }
115121 return strings.Contains(input, ".") && !strings.Contains(input, " ")
116122 }
117123
@@ -82,6 +82,9 @@ func Normalize(input string) string {
82 }82 }
83 83
84 // Looks like a URL (contains . and no spaces)84 // Looks like a URL (contains . and no spaces)
85+ if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
86+ return "http://" + input
87+ }
85 if strings.Contains(input, ".") && !strings.Contains(input, " ") {88 if strings.Contains(input, ".") && !strings.Contains(input, " ") {
86 return "https://" + input89 return "https://" + input
87 }90 }
@@ -112,6 +115,9 @@ func LooksLikeURL(input string) bool {
112 }115 }
113 116
114 // Contains a dot and no spaces = likely a URL117 // Contains a dot and no spaces = likely a URL
118+ if input == "localhost" || strings.HasPrefix(input, "localhost:") || strings.HasPrefix(input, "localhost/") {
119+ return true
120+ }
115 return strings.Contains(input, ".") && !strings.Contains(input, " ")121 return strings.Contains(input, ".") && !strings.Contains(input, " ")
116 }122 }
117 123
M internal/domain/url/normalize_test.go +60 -0
@@ -62,6 +62,36 @@ func TestNormalize(t *testing.T) {
6262 input: "hello",
6363 want: "hello",
6464 },
65+ {
66+ name: "localhost",
67+ input: "localhost",
68+ want: "http://localhost",
69+ },
70+ {
71+ name: "localhost with port",
72+ input: "localhost:5173",
73+ want: "http://localhost:5173",
74+ },
75+ {
76+ name: "localhost with alt port",
77+ input: "localhost:8080",
78+ want: "http://localhost:8080",
79+ },
80+ {
81+ name: "localhost with path",
82+ input: "localhost/api/v1",
83+ want: "http://localhost/api/v1",
84+ },
85+ {
86+ name: "localhost.com is a domain not localhost",
87+ input: "localhost.com",
88+ want: "https://localhost.com",
89+ },
90+ {
91+ name: "localhostevil.com is a domain not localhost",
92+ input: "localhostevil.com",
93+ want: "https://localhostevil.com",
94+ },
6595 }
6696
6797 for _, tt := range tests {
@@ -257,6 +287,36 @@ func TestLooksLikeURL(t *testing.T) {
257287 input: "hello",
258288 want: false,
259289 },
290+ {
291+ name: "localhost",
292+ input: "localhost",
293+ want: true,
294+ },
295+ {
296+ name: "localhost with port",
297+ input: "localhost:5173",
298+ want: true,
299+ },
300+ {
301+ name: "localhost with alt port",
302+ input: "localhost:8080",
303+ want: true,
304+ },
305+ {
306+ name: "localhost with path",
307+ input: "localhost/api/v1",
308+ want: true,
309+ },
310+ {
311+ name: "localhost.com is a domain not localhost",
312+ input: "localhost.com",
313+ want: true,
314+ },
315+ {
316+ name: "localhostevil.com is a domain not localhost",
317+ input: "localhostevil.com",
318+ want: true,
319+ },
260320 }
261321
262322 for _, tt := range tests {
@@ -62,6 +62,36 @@ func TestNormalize(t *testing.T) {
62 input: "hello",62 input: "hello",
63 want: "hello",63 want: "hello",
64 },64 },
65+ {
66+ name: "localhost",
67+ input: "localhost",
68+ want: "http://localhost",
69+ },
70+ {
71+ name: "localhost with port",
72+ input: "localhost:5173",
73+ want: "http://localhost:5173",
74+ },
75+ {
76+ name: "localhost with alt port",
77+ input: "localhost:8080",
78+ want: "http://localhost:8080",
79+ },
80+ {
81+ name: "localhost with path",
82+ input: "localhost/api/v1",
83+ want: "http://localhost/api/v1",
84+ },
85+ {
86+ name: "localhost.com is a domain not localhost",
87+ input: "localhost.com",
88+ want: "https://localhost.com",
89+ },
90+ {
91+ name: "localhostevil.com is a domain not localhost",
92+ input: "localhostevil.com",
93+ want: "https://localhostevil.com",
94+ },
65 }95 }
66 96
67 for _, tt := range tests {97 for _, tt := range tests {
@@ -257,6 +287,36 @@ func TestLooksLikeURL(t *testing.T) {
257 input: "hello",287 input: "hello",
258 want: false,288 want: false,
259 },289 },
290+ {
291+ name: "localhost",
292+ input: "localhost",
293+ want: true,
294+ },
295+ {
296+ name: "localhost with port",
297+ input: "localhost:5173",
298+ want: true,
299+ },
300+ {
301+ name: "localhost with alt port",
302+ input: "localhost:8080",
303+ want: true,
304+ },
305+ {
306+ name: "localhost with path",
307+ input: "localhost/api/v1",
308+ want: true,
309+ },
310+ {
311+ name: "localhost.com is a domain not localhost",
312+ input: "localhost.com",
313+ want: true,
314+ },
315+ {
316+ name: "localhostevil.com is a domain not localhost",
317+ input: "localhostevil.com",
318+ want: true,
319+ },
260 }320 }
261 321
262 for _, tt := range tests {322 for _, tt := range tests {
M internal/infrastructure/webkit/download_handler.go +58 -44
@@ -4,11 +4,11 @@ import (
44 "context"
55 "fmt"
66 "os"
7- "path/filepath"
8- "strings"
97 "sync"
108
119 "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ downloadutil "github.com/bnema/dumber/internal/domain/download"
1212 "github.com/bnema/dumber/internal/logging"
1313 "github.com/bnema/puregotk-webkit/webkit"
1414 )
@@ -20,16 +20,26 @@ const (
2020
2121 // DownloadHandler manages WebKit downloads and notifies the UI layer.
2222 type DownloadHandler struct {
23- downloadPath string
24- eventHandler port.DownloadEventHandler
25- mu sync.RWMutex
23+ downloadPath string
24+ eventHandler port.DownloadEventHandler
25+ prepareDownloadUC *usecase.PrepareDownloadUseCase
26+ mu sync.RWMutex
2627 }
2728
2829 // NewDownloadHandler creates a new download handler.
29-func NewDownloadHandler(downloadPath string, handler port.DownloadEventHandler) *DownloadHandler {
30+// Panics if prepareDownloadUC is nil (fail fast on misconfiguration).
31+func NewDownloadHandler(
32+ downloadPath string,
33+ handler port.DownloadEventHandler,
34+ prepareDownloadUC *usecase.PrepareDownloadUseCase,
35+) *DownloadHandler {
36+ if prepareDownloadUC == nil {
37+ panic("prepareDownloadUC is required")
38+ }
3039 return &DownloadHandler{
31- downloadPath: downloadPath,
32- eventHandler: handler,
40+ downloadPath: downloadPath,
41+ eventHandler: handler,
42+ prepareDownloadUC: prepareDownloadUC,
3343 }
3444 }
3545
@@ -47,6 +57,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
4757 h.mu.RLock()
4858 downloadPath := h.downloadPath
4959 eventHandler := h.eventHandler
60+ prepareDownloadUC := h.prepareDownloadUC
5061 h.mu.RUnlock()
5162
5263 // Track download state in a dedicated struct to ensure proper isolation.
@@ -63,31 +74,40 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
6374 return false
6475 }
6576
66- // Sanitize filename to prevent path traversal attacks.
67- safeName := sanitizeFilename(suggestedFilename)
68- destPath := filepath.Join(downloadPath, safeName)
77+ // Wrap WebKit response as port.DownloadResponse
78+ var response port.DownloadResponse
79+ if resp := d.GetResponse(); resp != nil {
80+ response = &uriResponseAdapter{resp: resp}
81+ }
82+
83+ // Use PrepareDownloadUseCase to resolve filename
84+ output := prepareDownloadUC.Execute(ctx, usecase.PrepareDownloadInput{
85+ SuggestedFilename: suggestedFilename,
86+ Response: response,
87+ DownloadDir: downloadPath,
88+ })
6989
7090 log.Debug().
7191 Str("suggested", suggestedFilename).
72- Str("sanitized", safeName).
73- Str("destPath", destPath).
92+ Str("sanitized", output.Filename).
93+ Str("destPath", output.DestinationPath).
7494 Msg("setting download destination")
7595
7696 // WebKit expects an absolute file path for the destination.
77- d.SetDestination(destPath)
97+ d.SetDestination(output.DestinationPath)
7898
7999 // Notify: download started.
80100 if eventHandler != nil {
81101 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
82102 Type: port.DownloadEventStarted,
83- Filename: safeName,
84- Destination: destPath,
103+ Filename: output.Filename,
104+ Destination: output.DestinationPath,
85105 })
86106 }
87107
88108 log.Info().
89- Str("filename", safeName).
90- Str("destination", destPath).
109+ Str("filename", output.Filename).
110+ Str("destination", output.DestinationPath).
91111 Msg("download started")
92112
93113 return false // false = we handled it synchronously
@@ -101,7 +121,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
101121 state.mu.Unlock()
102122
103123 dest := d.GetDestination()
104- filename := extractFilename(dest)
124+ filename := downloadutil.ExtractFilenameFromDestination(dest)
105125
106126 // Create error for the failed download.
107127 downloadErr := fmt.Errorf("download failed: %s", filename)
@@ -131,7 +151,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
131151 }
132152
133153 dest := d.GetDestination()
134- filename := extractFilename(dest)
154+ filename := downloadutil.ExtractFilenameFromDestination(dest)
135155
136156 if eventHandler != nil {
137157 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
@@ -153,34 +173,28 @@ func (h *DownloadHandler) SetDownloadPath(path string) {
153173 h.downloadPath = path
154174 }
155175
156-// extractFilename extracts the filename from a file:// URI or path.
157-func extractFilename(dest string) string {
158- // Remove file:// prefix if present.
159- path := strings.TrimPrefix(dest, "file://")
160- base := filepath.Base(path)
176+// uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.
177+type uriResponseAdapter struct {
178+ resp *webkit.URIResponse
179+}
161180
162- // Handle edge cases consistently with sanitizeFilename.
163- if base == "." || base == "" {
164- return "download"
181+func (a *uriResponseAdapter) GetMimeType() string {
182+ if a.resp == nil {
183+ return ""
165184 }
166- return base
185+ return a.resp.GetMimeType()
167186 }
168187
169-// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.
170-// It extracts only the base name and handles edge cases like "." or "..".
171-func sanitizeFilename(name string) string {
172- // Normalize Windows-style separators to forward slashes.
173- // filepath.Base only handles the OS-native separator, so on Linux
174- // backslashes would not be treated as path separators.
175- name = strings.ReplaceAll(name, "\\", "/")
176-
177- // Get only the base name (removes any directory components).
178- clean := filepath.Base(name)
179-
180- // If Base returns "." or ".." (edge cases), use fallback.
181- if clean == "." || clean == ".." || clean == "" {
182- return "download"
188+func (a *uriResponseAdapter) GetSuggestedFilename() string {
189+ if a.resp == nil {
190+ return ""
183191 }
192+ return a.resp.GetSuggestedFilename()
193+}
184194
185- return clean
195+func (a *uriResponseAdapter) GetUri() string {
196+ if a.resp == nil {
197+ return ""
198+ }
199+ return a.resp.GetUri()
186200 }
@@ -4,11 +4,11 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "os"6 "os"
7- "path/filepath"
8- "strings"
9 "sync"7 "sync"
10 8
11 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ downloadutil "github.com/bnema/dumber/internal/domain/download"
12 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/puregotk-webkit/webkit"13 "github.com/bnema/puregotk-webkit/webkit"
14 )14 )
@@ -20,16 +20,26 @@ const (
20 20
21 // DownloadHandler manages WebKit downloads and notifies the UI layer.21 // DownloadHandler manages WebKit downloads and notifies the UI layer.
22 type DownloadHandler struct {22 type DownloadHandler struct {
23- downloadPath string23+ downloadPath string
24- eventHandler port.DownloadEventHandler24+ eventHandler port.DownloadEventHandler
25- mu sync.RWMutex25+ prepareDownloadUC *usecase.PrepareDownloadUseCase
26+ mu sync.RWMutex
26 }27 }
27 28
28 // NewDownloadHandler creates a new download handler.29 // NewDownloadHandler creates a new download handler.
29-func NewDownloadHandler(downloadPath string, handler port.DownloadEventHandler) *DownloadHandler {30+// Panics if prepareDownloadUC is nil (fail fast on misconfiguration).
31+func NewDownloadHandler(
32+ downloadPath string,
33+ handler port.DownloadEventHandler,
34+ prepareDownloadUC *usecase.PrepareDownloadUseCase,
35+) *DownloadHandler {
36+ if prepareDownloadUC == nil {
37+ panic("prepareDownloadUC is required")
38+ }
30 return &DownloadHandler{39 return &DownloadHandler{
31- downloadPath: downloadPath,40+ downloadPath: downloadPath,
32- eventHandler: handler,41+ eventHandler: handler,
42+ prepareDownloadUC: prepareDownloadUC,
33 }43 }
34 }44 }
35 45
@@ -47,6 +57,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
47 h.mu.RLock()57 h.mu.RLock()
48 downloadPath := h.downloadPath58 downloadPath := h.downloadPath
49 eventHandler := h.eventHandler59 eventHandler := h.eventHandler
60+ prepareDownloadUC := h.prepareDownloadUC
50 h.mu.RUnlock()61 h.mu.RUnlock()
51 62
52 // Track download state in a dedicated struct to ensure proper isolation.63 // Track download state in a dedicated struct to ensure proper isolation.
@@ -63,31 +74,40 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
63 return false74 return false
64 }75 }
65 76
66- // Sanitize filename to prevent path traversal attacks.77+ // Wrap WebKit response as port.DownloadResponse
67- safeName := sanitizeFilename(suggestedFilename)78+ var response port.DownloadResponse
68- destPath := filepath.Join(downloadPath, safeName)79+ if resp := d.GetResponse(); resp != nil {
80+ response = &uriResponseAdapter{resp: resp}
81+ }
82+
83+ // Use PrepareDownloadUseCase to resolve filename
84+ output := prepareDownloadUC.Execute(ctx, usecase.PrepareDownloadInput{
85+ SuggestedFilename: suggestedFilename,
86+ Response: response,
87+ DownloadDir: downloadPath,
88+ })
69 89
70 log.Debug().90 log.Debug().
71 Str("suggested", suggestedFilename).91 Str("suggested", suggestedFilename).
72- Str("sanitized", safeName).92+ Str("sanitized", output.Filename).
73- Str("destPath", destPath).93+ Str("destPath", output.DestinationPath).
74 Msg("setting download destination")94 Msg("setting download destination")
75 95
76 // WebKit expects an absolute file path for the destination.96 // WebKit expects an absolute file path for the destination.
77- d.SetDestination(destPath)97+ d.SetDestination(output.DestinationPath)
78 98
79 // Notify: download started.99 // Notify: download started.
80 if eventHandler != nil {100 if eventHandler != nil {
81 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{101 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
82 Type: port.DownloadEventStarted,102 Type: port.DownloadEventStarted,
83- Filename: safeName,103+ Filename: output.Filename,
84- Destination: destPath,104+ Destination: output.DestinationPath,
85 })105 })
86 }106 }
87 107
88 log.Info().108 log.Info().
89- Str("filename", safeName).109+ Str("filename", output.Filename).
90- Str("destination", destPath).110+ Str("destination", output.DestinationPath).
91 Msg("download started")111 Msg("download started")
92 112
93 return false // false = we handled it synchronously113 return false // false = we handled it synchronously
@@ -101,7 +121,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
101 state.mu.Unlock()121 state.mu.Unlock()
102 122
103 dest := d.GetDestination()123 dest := d.GetDestination()
104- filename := extractFilename(dest)124+ filename := downloadutil.ExtractFilenameFromDestination(dest)
105 125
106 // Create error for the failed download.126 // Create error for the failed download.
107 downloadErr := fmt.Errorf("download failed: %s", filename)127 downloadErr := fmt.Errorf("download failed: %s", filename)
@@ -131,7 +151,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
131 }151 }
132 152
133 dest := d.GetDestination()153 dest := d.GetDestination()
134- filename := extractFilename(dest)154+ filename := downloadutil.ExtractFilenameFromDestination(dest)
135 155
136 if eventHandler != nil {156 if eventHandler != nil {
137 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{157 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
@@ -153,34 +173,28 @@ func (h *DownloadHandler) SetDownloadPath(path string) {
153 h.downloadPath = path173 h.downloadPath = path
154 }174 }
155 175
156-// extractFilename extracts the filename from a file:// URI or path.176+// uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.
157-func extractFilename(dest string) string {177+type uriResponseAdapter struct {
158- // Remove file:// prefix if present.178+ resp *webkit.URIResponse
159- path := strings.TrimPrefix(dest, "file://")179+}
160- base := filepath.Base(path)
161 180
162- // Handle edge cases consistently with sanitizeFilename.181+func (a *uriResponseAdapter) GetMimeType() string {
163- if base == "." || base == "" {182+ if a.resp == nil {
164- return "download"183+ return ""
165 }184 }
166- return base185+ return a.resp.GetMimeType()
167 }186 }
168 187
169-// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.188+func (a *uriResponseAdapter) GetSuggestedFilename() string {
170-// It extracts only the base name and handles edge cases like "." or "..".189+ if a.resp == nil {
171-func sanitizeFilename(name string) string {190+ return ""
172- // Normalize Windows-style separators to forward slashes.
173- // filepath.Base only handles the OS-native separator, so on Linux
174- // backslashes would not be treated as path separators.
175- name = strings.ReplaceAll(name, "\\", "/")
176-
177- // Get only the base name (removes any directory components).
178- clean := filepath.Base(name)
179-
180- // If Base returns "." or ".." (edge cases), use fallback.
181- if clean == "." || clean == ".." || clean == "" {
182- return "download"
183 }191 }
192+ return a.resp.GetSuggestedFilename()
193+}
184 194
185- return clean195+func (a *uriResponseAdapter) GetUri() string {
196+ if a.resp == nil {
197+ return ""
198+ }
199+ return a.resp.GetUri()
186 }200 }
M internal/infrastructure/webkit/download_handler_test.go +18 -120
@@ -3,146 +3,44 @@ package webkit
33 import (
44 "testing"
55
6+ "github.com/bnema/dumber/internal/application/usecase"
67 "github.com/stretchr/testify/assert"
78 )
89
9-func TestSanitizeFilename(t *testing.T) {
10- tests := []struct {
11- name string
12- input string
13- expected string
14- }{
15- {
16- name: "normal filename",
17- input: "document.pdf",
18- expected: "document.pdf",
19- },
20- {
21- name: "filename with spaces",
22- input: "my document.pdf",
23- expected: "my document.pdf",
24- },
25- {
26- name: "path traversal with parent dirs",
27- input: "../../../etc/passwd",
28- expected: "passwd",
29- },
30- {
31- name: "path traversal hidden file",
32- input: "../.ssh/id_rsa",
33- expected: "id_rsa",
34- },
35- {
36- name: "nested path",
37- input: "foo/bar/baz.txt",
38- expected: "baz.txt",
39- },
40- {
41- name: "absolute path",
42- input: "/etc/passwd",
43- expected: "passwd",
44- },
45- {
46- name: "dot only",
47- input: ".",
48- expected: "download",
49- },
50- {
51- name: "double dot only",
52- input: "..",
53- expected: "download",
54- },
55- {
56- name: "empty string",
57- input: "",
58- expected: "download",
59- },
60- {
61- name: "hidden file",
62- input: ".bashrc",
63- expected: ".bashrc",
64- },
65- {
66- name: "windows style path",
67- input: "..\\..\\Windows\\System32\\config",
68- expected: "config",
69- },
70- }
71-
72- for _, tt := range tests {
73- t.Run(tt.name, func(t *testing.T) {
74- result := sanitizeFilename(tt.input)
75- assert.Equal(t, tt.expected, result)
76- })
77- }
78-}
79-
80-func TestExtractFilename(t *testing.T) {
81- tests := []struct {
82- name string
83- input string
84- expected string
85- }{
86- {
87- name: "file URI with path",
88- input: "file:///home/user/Downloads/document.pdf",
89- expected: "document.pdf",
90- },
91- {
92- name: "file URI root",
93- input: "file:///document.pdf",
94- expected: "document.pdf",
95- },
96- {
97- name: "plain path",
98- input: "/home/user/Downloads/image.png",
99- expected: "image.png",
100- },
101- {
102- name: "filename only",
103- input: "video.mp4",
104- expected: "video.mp4",
105- },
106- {
107- name: "empty string",
108- input: "",
109- expected: "download",
110- },
111- {
112- name: "file URI with spaces",
113- input: "file:///home/user/My Documents/file.txt",
114- expected: "file.txt",
115- },
116- }
117-
118- for _, tt := range tests {
119- t.Run(tt.name, func(t *testing.T) {
120- result := extractFilename(tt.input)
121- assert.Equal(t, tt.expected, result)
122- })
123- }
124-}
125-
12610 func TestNewDownloadHandler(t *testing.T) {
11+ prepareUC := usecase.NewPrepareDownloadUseCase(nil)
12+
12713 t.Run("creates handler with path and nil event handler", func(t *testing.T) {
128- handler := NewDownloadHandler("/tmp/downloads", nil)
14+ handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC)
12915
13016 assert.NotNil(t, handler)
13117 assert.Equal(t, "/tmp/downloads", handler.downloadPath)
13218 assert.Nil(t, handler.eventHandler)
19+ assert.NotNil(t, handler.prepareDownloadUC)
13320 })
13421
13522 t.Run("creates handler with custom path", func(t *testing.T) {
136- handler := NewDownloadHandler("/custom/path", nil)
23+ handler := NewDownloadHandler("/custom/path", nil, prepareUC)
13724
13825 assert.Equal(t, "/custom/path", handler.downloadPath)
13926 })
14027 }
14128
14229 func TestDownloadHandler_SetDownloadPath(t *testing.T) {
143- handler := NewDownloadHandler("/initial/path", nil)
30+ prepareUC := usecase.NewPrepareDownloadUseCase(nil)
31+ handler := NewDownloadHandler("/initial/path", nil, prepareUC)
14432
14533 handler.SetDownloadPath("/new/path")
14634
14735 assert.Equal(t, "/new/path", handler.downloadPath)
14836 }
37+
38+func TestURIResponseAdapter(t *testing.T) {
39+ t.Run("handles nil response", func(t *testing.T) {
40+ adapter := &uriResponseAdapter{resp: nil}
41+
42+ assert.Empty(t, adapter.GetMimeType())
43+ assert.Empty(t, adapter.GetSuggestedFilename())
44+ assert.Empty(t, adapter.GetUri())
45+ })
46+}
@@ -3,146 +3,44 @@ package webkit
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/bnema/dumber/internal/application/usecase"
6 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
7 )8 )
8 9
9-func TestSanitizeFilename(t *testing.T) {
10- tests := []struct {
11- name string
12- input string
13- expected string
14- }{
15- {
16- name: "normal filename",
17- input: "document.pdf",
18- expected: "document.pdf",
19- },
20- {
21- name: "filename with spaces",
22- input: "my document.pdf",
23- expected: "my document.pdf",
24- },
25- {
26- name: "path traversal with parent dirs",
27- input: "../../../etc/passwd",
28- expected: "passwd",
29- },
30- {
31- name: "path traversal hidden file",
32- input: "../.ssh/id_rsa",
33- expected: "id_rsa",
34- },
35- {
36- name: "nested path",
37- input: "foo/bar/baz.txt",
38- expected: "baz.txt",
39- },
40- {
41- name: "absolute path",
42- input: "/etc/passwd",
43- expected: "passwd",
44- },
45- {
46- name: "dot only",
47- input: ".",
48- expected: "download",
49- },
50- {
51- name: "double dot only",
52- input: "..",
53- expected: "download",
54- },
55- {
56- name: "empty string",
57- input: "",
58- expected: "download",
59- },
60- {
61- name: "hidden file",
62- input: ".bashrc",
63- expected: ".bashrc",
64- },
65- {
66- name: "windows style path",
67- input: "..\\..\\Windows\\System32\\config",
68- expected: "config",
69- },
70- }
71-
72- for _, tt := range tests {
73- t.Run(tt.name, func(t *testing.T) {
74- result := sanitizeFilename(tt.input)
75- assert.Equal(t, tt.expected, result)
76- })
77- }
78-}
79-
80-func TestExtractFilename(t *testing.T) {
81- tests := []struct {
82- name string
83- input string
84- expected string
85- }{
86- {
87- name: "file URI with path",
88- input: "file:///home/user/Downloads/document.pdf",
89- expected: "document.pdf",
90- },
91- {
92- name: "file URI root",
93- input: "file:///document.pdf",
94- expected: "document.pdf",
95- },
96- {
97- name: "plain path",
98- input: "/home/user/Downloads/image.png",
99- expected: "image.png",
100- },
101- {
102- name: "filename only",
103- input: "video.mp4",
104- expected: "video.mp4",
105- },
106- {
107- name: "empty string",
108- input: "",
109- expected: "download",
110- },
111- {
112- name: "file URI with spaces",
113- input: "file:///home/user/My Documents/file.txt",
114- expected: "file.txt",
115- },
116- }
117-
118- for _, tt := range tests {
119- t.Run(tt.name, func(t *testing.T) {
120- result := extractFilename(tt.input)
121- assert.Equal(t, tt.expected, result)
122- })
123- }
124-}
125-
126 func TestNewDownloadHandler(t *testing.T) {10 func TestNewDownloadHandler(t *testing.T) {
11+ prepareUC := usecase.NewPrepareDownloadUseCase(nil)
12+
127 t.Run("creates handler with path and nil event handler", func(t *testing.T) {13 t.Run("creates handler with path and nil event handler", func(t *testing.T) {
128- handler := NewDownloadHandler("/tmp/downloads", nil)14+ handler := NewDownloadHandler("/tmp/downloads", nil, prepareUC)
129 15
130 assert.NotNil(t, handler)16 assert.NotNil(t, handler)
131 assert.Equal(t, "/tmp/downloads", handler.downloadPath)17 assert.Equal(t, "/tmp/downloads", handler.downloadPath)
132 assert.Nil(t, handler.eventHandler)18 assert.Nil(t, handler.eventHandler)
19+ assert.NotNil(t, handler.prepareDownloadUC)
133 })20 })
134 21
135 t.Run("creates handler with custom path", func(t *testing.T) {22 t.Run("creates handler with custom path", func(t *testing.T) {
136- handler := NewDownloadHandler("/custom/path", nil)23+ handler := NewDownloadHandler("/custom/path", nil, prepareUC)
137 24
138 assert.Equal(t, "/custom/path", handler.downloadPath)25 assert.Equal(t, "/custom/path", handler.downloadPath)
139 })26 })
140 }27 }
141 28
142 func TestDownloadHandler_SetDownloadPath(t *testing.T) {29 func TestDownloadHandler_SetDownloadPath(t *testing.T) {
143- handler := NewDownloadHandler("/initial/path", nil)30+ prepareUC := usecase.NewPrepareDownloadUseCase(nil)
31+ handler := NewDownloadHandler("/initial/path", nil, prepareUC)
144 32
145 handler.SetDownloadPath("/new/path")33 handler.SetDownloadPath("/new/path")
146 34
147 assert.Equal(t, "/new/path", handler.downloadPath)35 assert.Equal(t, "/new/path", handler.downloadPath)
148 }36 }
37+
38+func TestURIResponseAdapter(t *testing.T) {
39+ t.Run("handles nil response", func(t *testing.T) {
40+ adapter := &uriResponseAdapter{resp: nil}
41+
42+ assert.Empty(t, adapter.GetMimeType())
43+ assert.Empty(t, adapter.GetSuggestedFilename())
44+ assert.Empty(t, adapter.GetUri())
45+ })
46+}
M internal/infrastructure/webkit/webview.go +139 -41
@@ -3,6 +3,8 @@ package webkit
33 import (
44 "context"
55 "fmt"
6+ "net/url"
7+ "strings"
68 "sync"
79 "sync/atomic"
810 "time"
@@ -552,60 +554,156 @@ func (wv *WebView) connectProgressSignal() {
552554
553555 func (wv *WebView) connectDecidePolicySignal() {
554556 decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool {
555- if decisionType != webkit.PolicyDecisionTypeNavigationActionValue {
556- return false // Let WebKit handle
557- }
558-
559- navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
560- if navDecision == nil {
557+ switch decisionType {
558+ case webkit.PolicyDecisionTypeResponseValue:
559+ return wv.handleResponsePolicyDecision(decisionPtr)
560+ case webkit.PolicyDecisionTypeNavigationActionValue:
561+ return wv.handleNavigationPolicyDecision(decisionPtr)
562+ default:
561563 return false
562564 }
565+ }
566+ sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
567+ wv.signalIDs = append(wv.signalIDs, sigID)
568+}
563569
564- navAction := navDecision.GetNavigationAction()
565- if navAction == nil {
566- return false
567- }
570+// handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).
571+func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool {
572+ responseDecision := webkit.ResponsePolicyDecisionNewFromInternalPtr(decisionPtr)
573+ if responseDecision == nil {
574+ return false
575+ }
568576
569- if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
570- return false
571- }
577+ if !responseDecision.IsMainFrameMainResource() {
578+ return false
579+ }
572580
573- mouseButton := navAction.GetMouseButton()
574- modifiers := navAction.GetModifiers()
575- isMiddleClick := mouseButton == 2
576- isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
581+ if !shouldForceDownload(responseDecision) {
582+ return false
583+ }
577584
578- if !isMiddleClick && !isCtrlClick {
579- return false
580- }
585+ policyDecision := webkit.PolicyDecisionNewFromInternalPtr(decisionPtr)
586+ if policyDecision == nil {
587+ return false
588+ }
581589
582- request := navAction.GetRequest()
583- if request == nil {
584- return false
585- }
590+ response := responseDecision.GetResponse()
591+ mimeType := ""
592+ uri := ""
593+ if response != nil {
594+ mimeType = response.GetMimeType()
595+ uri = response.GetUri()
596+ }
586597
587- linkURI := request.GetUri()
588- if linkURI == "" {
589- return false
590- }
598+ wv.logger.Debug().
599+ Str("mime_type", mimeType).
600+ Str("uri", uri).
601+ Msg("forcing download for response")
602+
603+ // Start a fresh download instead of converting the in-progress response.
604+ // Using policyDecision.Download() can fail due to race conditions where
605+ // the network connection is released before the download can use it.
606+ // By ignoring the policy and starting a new download, we avoid this issue.
607+ // We defer the download to the next idle cycle to let WebKit clean up
608+ // the ignored navigation first.
609+ if uri != "" {
610+ policyDecision.Ignore()
611+ downloadURI := uri
612+ inner := wv.inner
613+ cb := glib.SourceFunc(func(_ uintptr) bool {
614+ inner.DownloadUri(downloadURI)
615+ return false // Don't repeat
616+ })
617+ // Store callback reference to prevent GC before GTK calls it
618+ wv.mu.Lock()
619+ wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
620+ wv.mu.Unlock()
621+ glib.IdleAdd(&cb, 0)
622+ return true
623+ }
591624
592- wv.logger.Debug().
593- Str("uri", linkURI).
594- Uint("button", mouseButton).
595- Bool("ctrl", isCtrlClick).
596- Msg("middle-click/ctrl+click on link detected")
597-
598- if wv.OnLinkMiddleClick != nil {
599- if wv.OnLinkMiddleClick(linkURI) {
600- navDecision.Ignore()
601- return true
602- }
625+ // Fallback to old behavior if URI is not available
626+ policyDecision.Download()
627+ return true
628+}
629+
630+// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click).
631+func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
632+ navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
633+ if navDecision == nil {
634+ return false
635+ }
636+
637+ navAction := navDecision.GetNavigationAction()
638+ if navAction == nil {
639+ return false
640+ }
641+
642+ if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
643+ return false
644+ }
645+
646+ mouseButton := navAction.GetMouseButton()
647+ modifiers := navAction.GetModifiers()
648+ isMiddleClick := mouseButton == 2
649+ isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
650+
651+ if !isMiddleClick && !isCtrlClick {
652+ return false
653+ }
654+
655+ request := navAction.GetRequest()
656+ if request == nil {
657+ return false
658+ }
659+
660+ linkURI := request.GetUri()
661+ if linkURI == "" {
662+ return false
663+ }
664+
665+ wv.logger.Debug().
666+ Str("uri", linkURI).
667+ Uint("button", mouseButton).
668+ Bool("ctrl", isCtrlClick).
669+ Msg("middle-click/ctrl+click on link detected")
670+
671+ if wv.OnLinkMiddleClick != nil {
672+ if wv.OnLinkMiddleClick(linkURI) {
673+ navDecision.Ignore()
674+ return true
603675 }
676+ }
604677
678+ return false
679+}
680+
681+func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool {
682+ if responseDecision == nil {
605683 return false
606684 }
607- sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
608- wv.signalIDs = append(wv.signalIDs, sigID)
685+
686+ response := responseDecision.GetResponse()
687+ if response == nil {
688+ return false
689+ }
690+
691+ mimeType := strings.ToLower(response.GetMimeType())
692+ if strings.HasPrefix(mimeType, "application/pdf") {
693+ return true
694+ }
695+
696+ uri := response.GetUri()
697+ if uri == "" {
698+ return false
699+ }
700+
701+ parsed, err := url.Parse(uri)
702+ if err != nil {
703+ return strings.Contains(strings.ToLower(uri), ".pdf")
704+ }
705+
706+ return strings.HasSuffix(strings.ToLower(parsed.Path), ".pdf")
609707 }
610708
611709 func (wv *WebView) connectEnterFullscreenSignal() {
@@ -3,6 +3,8 @@ package webkit
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "net/url"
7+ "strings"
6 "sync"8 "sync"
7 "sync/atomic"9 "sync/atomic"
8 "time"10 "time"
@@ -552,60 +554,156 @@ func (wv *WebView) connectProgressSignal() {
552 554
553 func (wv *WebView) connectDecidePolicySignal() {555 func (wv *WebView) connectDecidePolicySignal() {
554 decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool {556 decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool {
555- if decisionType != webkit.PolicyDecisionTypeNavigationActionValue {557+ switch decisionType {
556- return false // Let WebKit handle558+ case webkit.PolicyDecisionTypeResponseValue:
557- }559+ return wv.handleResponsePolicyDecision(decisionPtr)
558-560+ case webkit.PolicyDecisionTypeNavigationActionValue:
559- navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)561+ return wv.handleNavigationPolicyDecision(decisionPtr)
560- if navDecision == nil {562+ default:
561 return false563 return false
562 }564 }
565+ }
566+ sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
567+ wv.signalIDs = append(wv.signalIDs, sigID)
568+}
563 569
564- navAction := navDecision.GetNavigationAction()570+// handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).
565- if navAction == nil {571+func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool {
566- return false572+ responseDecision := webkit.ResponsePolicyDecisionNewFromInternalPtr(decisionPtr)
567- }573+ if responseDecision == nil {
574+ return false
575+ }
568 576
569- if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {577+ if !responseDecision.IsMainFrameMainResource() {
570- return false578+ return false
571- }579+ }
572 580
573- mouseButton := navAction.GetMouseButton()581+ if !shouldForceDownload(responseDecision) {
574- modifiers := navAction.GetModifiers()582+ return false
575- isMiddleClick := mouseButton == 2583+ }
576- isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
577 584
578- if !isMiddleClick && !isCtrlClick {585+ policyDecision := webkit.PolicyDecisionNewFromInternalPtr(decisionPtr)
579- return false586+ if policyDecision == nil {
580- }587+ return false
588+ }
581 589
582- request := navAction.GetRequest()590+ response := responseDecision.GetResponse()
583- if request == nil {591+ mimeType := ""
584- return false592+ uri := ""
585- }593+ if response != nil {
594+ mimeType = response.GetMimeType()
595+ uri = response.GetUri()
596+ }
586 597
587- linkURI := request.GetUri()598+ wv.logger.Debug().
588- if linkURI == "" {599+ Str("mime_type", mimeType).
589- return false600+ Str("uri", uri).
590- }601+ Msg("forcing download for response")
602+
603+ // Start a fresh download instead of converting the in-progress response.
604+ // Using policyDecision.Download() can fail due to race conditions where
605+ // the network connection is released before the download can use it.
606+ // By ignoring the policy and starting a new download, we avoid this issue.
607+ // We defer the download to the next idle cycle to let WebKit clean up
608+ // the ignored navigation first.
609+ if uri != "" {
610+ policyDecision.Ignore()
611+ downloadURI := uri
612+ inner := wv.inner
613+ cb := glib.SourceFunc(func(_ uintptr) bool {
614+ inner.DownloadUri(downloadURI)
615+ return false // Don't repeat
616+ })
617+ // Store callback reference to prevent GC before GTK calls it
618+ wv.mu.Lock()
619+ wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
620+ wv.mu.Unlock()
621+ glib.IdleAdd(&cb, 0)
622+ return true
623+ }
591 624
592- wv.logger.Debug().625+ // Fallback to old behavior if URI is not available
593- Str("uri", linkURI).626+ policyDecision.Download()
594- Uint("button", mouseButton).627+ return true
595- Bool("ctrl", isCtrlClick).628+}
596- Msg("middle-click/ctrl+click on link detected")629+
597-630+// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click).
598- if wv.OnLinkMiddleClick != nil {631+func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
599- if wv.OnLinkMiddleClick(linkURI) {632+ navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
600- navDecision.Ignore()633+ if navDecision == nil {
601- return true634+ return false
602- }635+ }
636+
637+ navAction := navDecision.GetNavigationAction()
638+ if navAction == nil {
639+ return false
640+ }
641+
642+ if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
643+ return false
644+ }
645+
646+ mouseButton := navAction.GetMouseButton()
647+ modifiers := navAction.GetModifiers()
648+ isMiddleClick := mouseButton == 2
649+ isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
650+
651+ if !isMiddleClick && !isCtrlClick {
652+ return false
653+ }
654+
655+ request := navAction.GetRequest()
656+ if request == nil {
657+ return false
658+ }
659+
660+ linkURI := request.GetUri()
661+ if linkURI == "" {
662+ return false
663+ }
664+
665+ wv.logger.Debug().
666+ Str("uri", linkURI).
667+ Uint("button", mouseButton).
668+ Bool("ctrl", isCtrlClick).
669+ Msg("middle-click/ctrl+click on link detected")
670+
671+ if wv.OnLinkMiddleClick != nil {
672+ if wv.OnLinkMiddleClick(linkURI) {
673+ navDecision.Ignore()
674+ return true
603 }675 }
676+ }
604 677
678+ return false
679+}
680+
681+func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool {
682+ if responseDecision == nil {
605 return false683 return false
606 }684 }
607- sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)685+
608- wv.signalIDs = append(wv.signalIDs, sigID)686+ response := responseDecision.GetResponse()
687+ if response == nil {
688+ return false
689+ }
690+
691+ mimeType := strings.ToLower(response.GetMimeType())
692+ if strings.HasPrefix(mimeType, "application/pdf") {
693+ return true
694+ }
695+
696+ uri := response.GetUri()
697+ if uri == "" {
698+ return false
699+ }
700+
701+ parsed, err := url.Parse(uri)
702+ if err != nil {
703+ return strings.Contains(strings.ToLower(uri), ".pdf")
704+ }
705+
706+ return strings.HasSuffix(strings.ToLower(parsed.Path), ".pdf")
609 }707 }
610 708
611 func (wv *WebView) connectEnterFullscreenSignal() {709 func (wv *WebView) connectEnterFullscreenSignal() {
M internal/ui/app.go +5 -1
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/dumber/internal/domain/entity"
1414 "github.com/bnema/dumber/internal/infrastructure/config"
1515 "github.com/bnema/dumber/internal/infrastructure/desktop"
16+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
1617 "github.com/bnema/dumber/internal/infrastructure/filtering"
1718 "github.com/bnema/dumber/internal/infrastructure/snapshot"
1819 "github.com/bnema/dumber/internal/infrastructure/textinput"
@@ -495,8 +496,11 @@ func (a *App) initDownloadHandler(ctx context.Context) {
495496 // Create download event adapter to show toasts.
496497 eventAdapter := &downloadEventAdapter{app: a}
497498
499+ // Create use case for preparing download destinations with file deduplication.
500+ prepareDownloadUC := usecase.NewPrepareDownloadUseCase(filesystem.New())
501+
498502 // Create and wire the download handler.
499- handler := webkit.NewDownloadHandler(downloadPath, eventAdapter)
503+ handler := webkit.NewDownloadHandler(downloadPath, eventAdapter, prepareDownloadUC)
500504 a.deps.WebContext.SetDownloadHandler(ctx, handler)
501505
502506 log.Info().Str("path", downloadPath).Msg("download handler initialized")
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/infrastructure/config"14 "github.com/bnema/dumber/internal/infrastructure/config"
15 "github.com/bnema/dumber/internal/infrastructure/desktop"15 "github.com/bnema/dumber/internal/infrastructure/desktop"
16+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
16 "github.com/bnema/dumber/internal/infrastructure/filtering"17 "github.com/bnema/dumber/internal/infrastructure/filtering"
17 "github.com/bnema/dumber/internal/infrastructure/snapshot"18 "github.com/bnema/dumber/internal/infrastructure/snapshot"
18 "github.com/bnema/dumber/internal/infrastructure/textinput"19 "github.com/bnema/dumber/internal/infrastructure/textinput"
@@ -495,8 +496,11 @@ func (a *App) initDownloadHandler(ctx context.Context) {
495 // Create download event adapter to show toasts.496 // Create download event adapter to show toasts.
496 eventAdapter := &downloadEventAdapter{app: a}497 eventAdapter := &downloadEventAdapter{app: a}
497 498
499+ // Create use case for preparing download destinations with file deduplication.
500+ prepareDownloadUC := usecase.NewPrepareDownloadUseCase(filesystem.New())
501+
498 // Create and wire the download handler.502 // Create and wire the download handler.
499- handler := webkit.NewDownloadHandler(downloadPath, eventAdapter)503+ handler := webkit.NewDownloadHandler(downloadPath, eventAdapter, prepareDownloadUC)
500 a.deps.WebContext.SetDownloadHandler(ctx, handler)504 a.deps.WebContext.SetDownloadHandler(ctx, handler)
501 505
502 log.Info().Str("path", downloadPath).Msg("download handler initialized")506 log.Info().Str("path", downloadPath).Msg("download handler initialized")

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close