detached 92eeb35

fix: localhost URL normalization and download handler clean architecture refactor

commit
92eeb359f5aa292b19f6565d2c9242642a637306
author
brice · · 2026-01-24 15:16 UTC
parent
9e989c0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +967 -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 +106 -0
@@ -0,0 +1,106 @@
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+ return err == nil && exists
65+ })
66+ }
67+
68+ destPath := filepath.Join(input.DownloadDir, safeName)
69+
70+ log.Debug().
71+ Str("suggested", input.SuggestedFilename).
72+ Str("resolved", resolvedName).
73+ Str("sanitized", safeName).
74+ Str("destPath", destPath).
75+ Msg("prepared download destination")
76+
77+ return &PrepareDownloadOutput{
78+ Filename: safeName,
79+ DestinationPath: destPath,
80+ }
81+}
82+
83+// resolveSuggestedFilename determines the best filename from available sources.
84+func (*PrepareDownloadUseCase) resolveSuggestedFilename(name string, response port.DownloadResponse) string {
85+ // Priority 1: Use provided suggested filename
86+ if name != "" {
87+ return name
88+ }
89+
90+ if response == nil {
91+ return name
92+ }
93+
94+ // Priority 2: Use response's suggested filename (from Content-Disposition)
95+ if suggested := response.GetSuggestedFilename(); suggested != "" {
96+ return suggested
97+ }
98+
99+ // Priority 3: Extract from URI path
100+ uri := response.GetUri()
101+ if uri == "" {
102+ return name
103+ }
104+
105+ return download.ExtractFilenameFromURI(uri)
106+}
@@ -0,0 +1,106 @@
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+ return err == nil && exists
65+ })
66+ }
67+
68+ destPath := filepath.Join(input.DownloadDir, safeName)
69+
70+ log.Debug().
71+ Str("suggested", input.SuggestedFilename).
72+ Str("resolved", resolvedName).
73+ Str("sanitized", safeName).
74+ Str("destPath", destPath).
75+ Msg("prepared download destination")
76+
77+ return &PrepareDownloadOutput{
78+ Filename: safeName,
79+ DestinationPath: destPath,
80+ }
81+}
82+
83+// resolveSuggestedFilename determines the best filename from available sources.
84+func (*PrepareDownloadUseCase) resolveSuggestedFilename(name string, response port.DownloadResponse) string {
85+ // Priority 1: Use provided suggested filename
86+ if name != "" {
87+ return name
88+ }
89+
90+ if response == nil {
91+ return name
92+ }
93+
94+ // Priority 2: Use response's suggested filename (from Content-Disposition)
95+ if suggested := response.GetSuggestedFilename(); suggested != "" {
96+ return suggested
97+ }
98+
99+ // Priority 3: Extract from URI path
100+ uri := response.GetUri()
101+ if uri == "" {
102+ return name
103+ }
104+
105+ return download.ExtractFilenameFromURI(uri)
106+}
A internal/application/usecase/prepare_download_test.go +171 -0
@@ -0,0 +1,171 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+// mockDownloadResponse implements port.DownloadResponse for testing.
11+type mockDownloadResponse struct {
12+ mimeType string
13+ suggestedFilename string
14+ uri string
15+}
16+
17+func (m *mockDownloadResponse) GetMimeType() string { return m.mimeType }
18+func (m *mockDownloadResponse) GetSuggestedFilename() string { return m.suggestedFilename }
19+func (m *mockDownloadResponse) GetUri() string { return m.uri }
20+
21+func TestPrepareDownloadUseCase_Execute(t *testing.T) {
22+ ctx := context.Background()
23+ uc := NewPrepareDownloadUseCase(nil) // nil FileSystem disables deduplication
24+
25+ tests := []struct {
26+ name string
27+ input PrepareDownloadInput
28+ expectedFilename string
29+ expectedPath string
30+ }{
31+ {
32+ name: "uses suggested filename directly",
33+ input: PrepareDownloadInput{
34+ SuggestedFilename: "document.pdf",
35+ DownloadDir: "/tmp/downloads",
36+ },
37+ expectedFilename: "document.pdf",
38+ expectedPath: "/tmp/downloads/document.pdf",
39+ },
40+ {
41+ name: "sanitizes path traversal in suggested filename",
42+ input: PrepareDownloadInput{
43+ SuggestedFilename: "../../../etc/passwd",
44+ DownloadDir: "/tmp/downloads",
45+ },
46+ expectedFilename: "passwd",
47+ expectedPath: "/tmp/downloads/passwd",
48+ },
49+ {
50+ name: "falls back to response suggested filename",
51+ input: PrepareDownloadInput{
52+ SuggestedFilename: "",
53+ Response: &mockDownloadResponse{
54+ suggestedFilename: "from-header.pdf",
55+ },
56+ DownloadDir: "/tmp/downloads",
57+ },
58+ expectedFilename: "from-header.pdf",
59+ expectedPath: "/tmp/downloads/from-header.pdf",
60+ },
61+ {
62+ name: "falls back to URI path",
63+ input: PrepareDownloadInput{
64+ SuggestedFilename: "",
65+ Response: &mockDownloadResponse{
66+ suggestedFilename: "",
67+ uri: "https://example.com/files/report.pdf",
68+ },
69+ DownloadDir: "/tmp/downloads",
70+ },
71+ expectedFilename: "report.pdf",
72+ expectedPath: "/tmp/downloads/report.pdf",
73+ },
74+ {
75+ name: "adds extension from MIME type when missing",
76+ input: PrepareDownloadInput{
77+ SuggestedFilename: "report",
78+ Response: &mockDownloadResponse{
79+ mimeType: "application/pdf",
80+ },
81+ DownloadDir: "/tmp/downloads",
82+ },
83+ expectedFilename: "report.pdf",
84+ expectedPath: "/tmp/downloads/report.pdf",
85+ },
86+ {
87+ name: "preserves existing extension even with MIME type",
88+ input: PrepareDownloadInput{
89+ SuggestedFilename: "report.doc",
90+ Response: &mockDownloadResponse{
91+ mimeType: "application/pdf",
92+ },
93+ DownloadDir: "/tmp/downloads",
94+ },
95+ expectedFilename: "report.doc",
96+ expectedPath: "/tmp/downloads/report.doc",
97+ },
98+ {
99+ name: "handles nil response",
100+ input: PrepareDownloadInput{
101+ SuggestedFilename: "file.txt",
102+ Response: nil,
103+ DownloadDir: "/tmp/downloads",
104+ },
105+ expectedFilename: "file.txt",
106+ expectedPath: "/tmp/downloads/file.txt",
107+ },
108+ {
109+ name: "uses default filename for empty inputs",
110+ input: PrepareDownloadInput{
111+ SuggestedFilename: "",
112+ Response: nil,
113+ DownloadDir: "/tmp/downloads",
114+ },
115+ expectedFilename: "download",
116+ expectedPath: "/tmp/downloads/download",
117+ },
118+ {
119+ name: "sanitizes windows path traversal",
120+ input: PrepareDownloadInput{
121+ SuggestedFilename: "..\\..\\Windows\\System32\\config",
122+ DownloadDir: "/tmp/downloads",
123+ },
124+ expectedFilename: "config",
125+ expectedPath: "/tmp/downloads/config",
126+ },
127+ }
128+
129+ for _, tt := range tests {
130+ t.Run(tt.name, func(t *testing.T) {
131+ result := uc.Execute(ctx, tt.input)
132+
133+ assert.NotNil(t, result)
134+ assert.Equal(t, tt.expectedFilename, result.Filename)
135+ assert.Equal(t, tt.expectedPath, result.DestinationPath)
136+ })
137+ }
138+}
139+
140+func TestPrepareDownloadUseCase_ResolvePriority(t *testing.T) {
141+ ctx := context.Background()
142+ uc := NewPrepareDownloadUseCase(nil)
143+
144+ t.Run("suggested filename takes priority over response", func(t *testing.T) {
145+ input := PrepareDownloadInput{
146+ SuggestedFilename: "priority.pdf",
147+ Response: &mockDownloadResponse{
148+ suggestedFilename: "response.pdf",
149+ uri: "https://example.com/uri.pdf",
150+ },
151+ DownloadDir: "/tmp",
152+ }
153+
154+ result := uc.Execute(ctx, input)
155+ assert.Equal(t, "priority.pdf", result.Filename)
156+ })
157+
158+ t.Run("response suggested takes priority over URI", func(t *testing.T) {
159+ input := PrepareDownloadInput{
160+ SuggestedFilename: "",
161+ Response: &mockDownloadResponse{
162+ suggestedFilename: "response.pdf",
163+ uri: "https://example.com/uri.pdf",
164+ },
165+ DownloadDir: "/tmp",
166+ }
167+
168+ result := uc.Execute(ctx, input)
169+ assert.Equal(t, "response.pdf", result.Filename)
170+ })
171+}
@@ -0,0 +1,171 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+// mockDownloadResponse implements port.DownloadResponse for testing.
11+type mockDownloadResponse struct {
12+ mimeType string
13+ suggestedFilename string
14+ uri string
15+}
16+
17+func (m *mockDownloadResponse) GetMimeType() string { return m.mimeType }
18+func (m *mockDownloadResponse) GetSuggestedFilename() string { return m.suggestedFilename }
19+func (m *mockDownloadResponse) GetUri() string { return m.uri }
20+
21+func TestPrepareDownloadUseCase_Execute(t *testing.T) {
22+ ctx := context.Background()
23+ uc := NewPrepareDownloadUseCase(nil) // nil FileSystem disables deduplication
24+
25+ tests := []struct {
26+ name string
27+ input PrepareDownloadInput
28+ expectedFilename string
29+ expectedPath string
30+ }{
31+ {
32+ name: "uses suggested filename directly",
33+ input: PrepareDownloadInput{
34+ SuggestedFilename: "document.pdf",
35+ DownloadDir: "/tmp/downloads",
36+ },
37+ expectedFilename: "document.pdf",
38+ expectedPath: "/tmp/downloads/document.pdf",
39+ },
40+ {
41+ name: "sanitizes path traversal in suggested filename",
42+ input: PrepareDownloadInput{
43+ SuggestedFilename: "../../../etc/passwd",
44+ DownloadDir: "/tmp/downloads",
45+ },
46+ expectedFilename: "passwd",
47+ expectedPath: "/tmp/downloads/passwd",
48+ },
49+ {
50+ name: "falls back to response suggested filename",
51+ input: PrepareDownloadInput{
52+ SuggestedFilename: "",
53+ Response: &mockDownloadResponse{
54+ suggestedFilename: "from-header.pdf",
55+ },
56+ DownloadDir: "/tmp/downloads",
57+ },
58+ expectedFilename: "from-header.pdf",
59+ expectedPath: "/tmp/downloads/from-header.pdf",
60+ },
61+ {
62+ name: "falls back to URI path",
63+ input: PrepareDownloadInput{
64+ SuggestedFilename: "",
65+ Response: &mockDownloadResponse{
66+ suggestedFilename: "",
67+ uri: "https://example.com/files/report.pdf",
68+ },
69+ DownloadDir: "/tmp/downloads",
70+ },
71+ expectedFilename: "report.pdf",
72+ expectedPath: "/tmp/downloads/report.pdf",
73+ },
74+ {
75+ name: "adds extension from MIME type when missing",
76+ input: PrepareDownloadInput{
77+ SuggestedFilename: "report",
78+ Response: &mockDownloadResponse{
79+ mimeType: "application/pdf",
80+ },
81+ DownloadDir: "/tmp/downloads",
82+ },
83+ expectedFilename: "report.pdf",
84+ expectedPath: "/tmp/downloads/report.pdf",
85+ },
86+ {
87+ name: "preserves existing extension even with MIME type",
88+ input: PrepareDownloadInput{
89+ SuggestedFilename: "report.doc",
90+ Response: &mockDownloadResponse{
91+ mimeType: "application/pdf",
92+ },
93+ DownloadDir: "/tmp/downloads",
94+ },
95+ expectedFilename: "report.doc",
96+ expectedPath: "/tmp/downloads/report.doc",
97+ },
98+ {
99+ name: "handles nil response",
100+ input: PrepareDownloadInput{
101+ SuggestedFilename: "file.txt",
102+ Response: nil,
103+ DownloadDir: "/tmp/downloads",
104+ },
105+ expectedFilename: "file.txt",
106+ expectedPath: "/tmp/downloads/file.txt",
107+ },
108+ {
109+ name: "uses default filename for empty inputs",
110+ input: PrepareDownloadInput{
111+ SuggestedFilename: "",
112+ Response: nil,
113+ DownloadDir: "/tmp/downloads",
114+ },
115+ expectedFilename: "download",
116+ expectedPath: "/tmp/downloads/download",
117+ },
118+ {
119+ name: "sanitizes windows path traversal",
120+ input: PrepareDownloadInput{
121+ SuggestedFilename: "..\\..\\Windows\\System32\\config",
122+ DownloadDir: "/tmp/downloads",
123+ },
124+ expectedFilename: "config",
125+ expectedPath: "/tmp/downloads/config",
126+ },
127+ }
128+
129+ for _, tt := range tests {
130+ t.Run(tt.name, func(t *testing.T) {
131+ result := uc.Execute(ctx, tt.input)
132+
133+ assert.NotNil(t, result)
134+ assert.Equal(t, tt.expectedFilename, result.Filename)
135+ assert.Equal(t, tt.expectedPath, result.DestinationPath)
136+ })
137+ }
138+}
139+
140+func TestPrepareDownloadUseCase_ResolvePriority(t *testing.T) {
141+ ctx := context.Background()
142+ uc := NewPrepareDownloadUseCase(nil)
143+
144+ t.Run("suggested filename takes priority over response", func(t *testing.T) {
145+ input := PrepareDownloadInput{
146+ SuggestedFilename: "priority.pdf",
147+ Response: &mockDownloadResponse{
148+ suggestedFilename: "response.pdf",
149+ uri: "https://example.com/uri.pdf",
150+ },
151+ DownloadDir: "/tmp",
152+ }
153+
154+ result := uc.Execute(ctx, input)
155+ assert.Equal(t, "priority.pdf", result.Filename)
156+ })
157+
158+ t.Run("response suggested takes priority over URI", func(t *testing.T) {
159+ input := PrepareDownloadInput{
160+ SuggestedFilename: "",
161+ Response: &mockDownloadResponse{
162+ suggestedFilename: "response.pdf",
163+ uri: "https://example.com/uri.pdf",
164+ },
165+ DownloadDir: "/tmp",
166+ }
167+
168+ result := uc.Execute(ctx, input)
169+ assert.Equal(t, "response.pdf", result.Filename)
170+ })
171+}
A internal/domain/download/filename.go +127 -0
@@ -0,0 +1,127 @@
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+func GetExtensionFromMimeType(mimeType string) string {
52+ if mimeType == "" {
53+ return ""
54+ }
55+
56+ exts, err := mime.ExtensionsByType(mimeType)
57+ if err != nil || len(exts) == 0 {
58+ return ""
59+ }
60+
61+ return exts[0]
62+}
63+
64+// ExtractFilenameFromURI extracts the filename from a URI path component.
65+// Handles both URIs and plain paths. Returns DefaultFilename for edge cases.
66+func ExtractFilenameFromURI(uri string) string {
67+ if uri == "" {
68+ return DefaultFilename
69+ }
70+
71+ parsed, err := url.Parse(uri)
72+ if err != nil {
73+ // Fall back to treating as plain path
74+ return extractFromPath(uri)
75+ }
76+
77+ return extractFromPath(parsed.Path)
78+}
79+
80+// ExtractFilenameFromDestination extracts the filename from a file:// URI or path.
81+// Used to get filename from WebKit download destination.
82+func ExtractFilenameFromDestination(dest string) string {
83+ // Remove file:// prefix if present.
84+ path := strings.TrimPrefix(dest, "file://")
85+ base := filepath.Base(path)
86+
87+ // Handle edge cases consistently with SanitizeFilename.
88+ if base == "." || base == "" {
89+ return DefaultFilename
90+ }
91+ return base
92+}
93+
94+// extractFromPath extracts filename from a path string.
95+func extractFromPath(path string) string {
96+ base := filepath.Base(path)
97+ if base == "." || base == "" || base == "/" {
98+ return DefaultFilename
99+ }
100+ return base
101+}
102+
103+// MakeUniqueFilename generates a unique filename by appending _(N) if needed.
104+// The exists function should return true if the given path already exists.
105+func MakeUniqueFilename(dir, filename string, exists func(path string) bool) string {
106+ destPath := filepath.Join(dir, filename)
107+ if !exists(destPath) {
108+ return filename
109+ }
110+
111+ // Split filename into base and extension
112+ ext := filepath.Ext(filename)
113+ base := strings.TrimSuffix(filename, ext)
114+
115+ // Try incrementing numbers until we find a unique name
116+ for i := 1; i < 1000; i++ {
117+ candidate := fmt.Sprintf("%s_(%d)%s", base, i, ext)
118+ candidatePath := filepath.Join(dir, candidate)
119+ if !exists(candidatePath) {
120+ return candidate
121+ }
122+ }
123+
124+ // Fallback: use timestamp
125+ candidate := fmt.Sprintf("%s_%d%s", base, time.Now().UnixNano(), ext)
126+ return candidate
127+}
@@ -0,0 +1,127 @@
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+func GetExtensionFromMimeType(mimeType string) string {
52+ if mimeType == "" {
53+ return ""
54+ }
55+
56+ exts, err := mime.ExtensionsByType(mimeType)
57+ if err != nil || len(exts) == 0 {
58+ return ""
59+ }
60+
61+ return exts[0]
62+}
63+
64+// ExtractFilenameFromURI extracts the filename from a URI path component.
65+// Handles both URIs and plain paths. Returns DefaultFilename for edge cases.
66+func ExtractFilenameFromURI(uri string) string {
67+ if uri == "" {
68+ return DefaultFilename
69+ }
70+
71+ parsed, err := url.Parse(uri)
72+ if err != nil {
73+ // Fall back to treating as plain path
74+ return extractFromPath(uri)
75+ }
76+
77+ return extractFromPath(parsed.Path)
78+}
79+
80+// ExtractFilenameFromDestination extracts the filename from a file:// URI or path.
81+// Used to get filename from WebKit download destination.
82+func ExtractFilenameFromDestination(dest string) string {
83+ // Remove file:// prefix if present.
84+ path := strings.TrimPrefix(dest, "file://")
85+ base := filepath.Base(path)
86+
87+ // Handle edge cases consistently with SanitizeFilename.
88+ if base == "." || base == "" {
89+ return DefaultFilename
90+ }
91+ return base
92+}
93+
94+// extractFromPath extracts filename from a path string.
95+func extractFromPath(path string) string {
96+ base := filepath.Base(path)
97+ if base == "." || base == "" || base == "/" {
98+ return DefaultFilename
99+ }
100+ return base
101+}
102+
103+// MakeUniqueFilename generates a unique filename by appending _(N) if needed.
104+// The exists function should return true if the given path already exists.
105+func MakeUniqueFilename(dir, filename string, exists func(path string) bool) string {
106+ destPath := filepath.Join(dir, filename)
107+ if !exists(destPath) {
108+ return filename
109+ }
110+
111+ // Split filename into base and extension
112+ ext := filepath.Ext(filename)
113+ base := strings.TrimSuffix(filename, ext)
114+
115+ // Try incrementing numbers until we find a unique name
116+ for i := 1; i < 1000; i++ {
117+ candidate := fmt.Sprintf("%s_(%d)%s", base, i, ext)
118+ candidatePath := filepath.Join(dir, candidate)
119+ if !exists(candidatePath) {
120+ return candidate
121+ }
122+ }
123+
124+ // Fallback: use timestamp
125+ candidate := fmt.Sprintf("%s_%d%s", base, time.Now().UnixNano(), ext)
126+ return candidate
127+}
A internal/domain/download/filename_test.go +302 -0
@@ -0,0 +1,302 @@
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+
144+ for _, tt := range tests {
145+ t.Run(tt.name, func(t *testing.T) {
146+ result := GetExtensionFromMimeType(tt.input)
147+ assert.Equal(t, tt.expected, result)
148+ })
149+ }
150+}
151+
152+func TestExtractFilenameFromDestination(t *testing.T) {
153+ tests := []struct {
154+ name string
155+ input string
156+ expected string
157+ }{
158+ {
159+ name: "file URI with path",
160+ input: "file:///home/user/Downloads/document.pdf",
161+ expected: "document.pdf",
162+ },
163+ {
164+ name: "file URI root",
165+ input: "file:///document.pdf",
166+ expected: "document.pdf",
167+ },
168+ {
169+ name: "plain path",
170+ input: "/home/user/Downloads/image.png",
171+ expected: "image.png",
172+ },
173+ {
174+ name: "filename only",
175+ input: "video.mp4",
176+ expected: "video.mp4",
177+ },
178+ {
179+ name: "empty string",
180+ input: "",
181+ expected: "download",
182+ },
183+ {
184+ name: "file URI with spaces",
185+ input: "file:///home/user/My Documents/file.txt",
186+ expected: "file.txt",
187+ },
188+ }
189+
190+ for _, tt := range tests {
191+ t.Run(tt.name, func(t *testing.T) {
192+ result := ExtractFilenameFromDestination(tt.input)
193+ assert.Equal(t, tt.expected, result)
194+ })
195+ }
196+}
197+
198+func TestExtractFilenameFromURI(t *testing.T) {
199+ tests := []struct {
200+ name string
201+ input string
202+ expected string
203+ }{
204+ {
205+ name: "http URL with filename",
206+ input: "https://example.com/files/document.pdf",
207+ expected: "document.pdf",
208+ },
209+ {
210+ name: "http URL with query params",
211+ input: "https://example.com/files/document.pdf?token=abc",
212+ expected: "document.pdf",
213+ },
214+ {
215+ name: "http URL without filename",
216+ input: "https://example.com/",
217+ expected: "download",
218+ },
219+ {
220+ name: "empty string",
221+ input: "",
222+ expected: "download",
223+ },
224+ {
225+ name: "plain path",
226+ input: "/path/to/file.txt",
227+ expected: "file.txt",
228+ },
229+ }
230+
231+ for _, tt := range tests {
232+ t.Run(tt.name, func(t *testing.T) {
233+ result := ExtractFilenameFromURI(tt.input)
234+ assert.Equal(t, tt.expected, result)
235+ })
236+ }
237+}
238+
239+func TestMakeUniqueFilename(t *testing.T) {
240+ tests := []struct {
241+ name string
242+ dir string
243+ filename string
244+ existingFiles map[string]bool
245+ expected string
246+ }{
247+ {
248+ name: "file does not exist",
249+ dir: "/tmp",
250+ filename: "document.pdf",
251+ existingFiles: map[string]bool{},
252+ expected: "document.pdf",
253+ },
254+ {
255+ name: "file exists, adds _(1)",
256+ dir: "/tmp",
257+ filename: "document.pdf",
258+ existingFiles: map[string]bool{
259+ "/tmp/document.pdf": true,
260+ },
261+ expected: "document_(1).pdf",
262+ },
263+ {
264+ name: "file and _(1) exist, adds _(2)",
265+ dir: "/tmp",
266+ filename: "document.pdf",
267+ existingFiles: map[string]bool{
268+ "/tmp/document.pdf": true,
269+ "/tmp/document_(1).pdf": true,
270+ },
271+ expected: "document_(2).pdf",
272+ },
273+ {
274+ name: "no extension",
275+ dir: "/tmp",
276+ filename: "download",
277+ existingFiles: map[string]bool{
278+ "/tmp/download": true,
279+ },
280+ expected: "download_(1)",
281+ },
282+ {
283+ name: "hidden file with extension",
284+ dir: "/tmp",
285+ filename: ".config.bak",
286+ existingFiles: map[string]bool{
287+ "/tmp/.config.bak": true,
288+ },
289+ expected: ".config_(1).bak",
290+ },
291+ }
292+
293+ for _, tt := range tests {
294+ t.Run(tt.name, func(t *testing.T) {
295+ exists := func(path string) bool {
296+ return tt.existingFiles[path]
297+ }
298+ result := MakeUniqueFilename(tt.dir, tt.filename, exists)
299+ assert.Equal(t, tt.expected, result)
300+ })
301+ }
302+}
@@ -0,0 +1,302 @@
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+
144+ for _, tt := range tests {
145+ t.Run(tt.name, func(t *testing.T) {
146+ result := GetExtensionFromMimeType(tt.input)
147+ assert.Equal(t, tt.expected, result)
148+ })
149+ }
150+}
151+
152+func TestExtractFilenameFromDestination(t *testing.T) {
153+ tests := []struct {
154+ name string
155+ input string
156+ expected string
157+ }{
158+ {
159+ name: "file URI with path",
160+ input: "file:///home/user/Downloads/document.pdf",
161+ expected: "document.pdf",
162+ },
163+ {
164+ name: "file URI root",
165+ input: "file:///document.pdf",
166+ expected: "document.pdf",
167+ },
168+ {
169+ name: "plain path",
170+ input: "/home/user/Downloads/image.png",
171+ expected: "image.png",
172+ },
173+ {
174+ name: "filename only",
175+ input: "video.mp4",
176+ expected: "video.mp4",
177+ },
178+ {
179+ name: "empty string",
180+ input: "",
181+ expected: "download",
182+ },
183+ {
184+ name: "file URI with spaces",
185+ input: "file:///home/user/My Documents/file.txt",
186+ expected: "file.txt",
187+ },
188+ }
189+
190+ for _, tt := range tests {
191+ t.Run(tt.name, func(t *testing.T) {
192+ result := ExtractFilenameFromDestination(tt.input)
193+ assert.Equal(t, tt.expected, result)
194+ })
195+ }
196+}
197+
198+func TestExtractFilenameFromURI(t *testing.T) {
199+ tests := []struct {
200+ name string
201+ input string
202+ expected string
203+ }{
204+ {
205+ name: "http URL with filename",
206+ input: "https://example.com/files/document.pdf",
207+ expected: "document.pdf",
208+ },
209+ {
210+ name: "http URL with query params",
211+ input: "https://example.com/files/document.pdf?token=abc",
212+ expected: "document.pdf",
213+ },
214+ {
215+ name: "http URL without filename",
216+ input: "https://example.com/",
217+ expected: "download",
218+ },
219+ {
220+ name: "empty string",
221+ input: "",
222+ expected: "download",
223+ },
224+ {
225+ name: "plain path",
226+ input: "/path/to/file.txt",
227+ expected: "file.txt",
228+ },
229+ }
230+
231+ for _, tt := range tests {
232+ t.Run(tt.name, func(t *testing.T) {
233+ result := ExtractFilenameFromURI(tt.input)
234+ assert.Equal(t, tt.expected, result)
235+ })
236+ }
237+}
238+
239+func TestMakeUniqueFilename(t *testing.T) {
240+ tests := []struct {
241+ name string
242+ dir string
243+ filename string
244+ existingFiles map[string]bool
245+ expected string
246+ }{
247+ {
248+ name: "file does not exist",
249+ dir: "/tmp",
250+ filename: "document.pdf",
251+ existingFiles: map[string]bool{},
252+ expected: "document.pdf",
253+ },
254+ {
255+ name: "file exists, adds _(1)",
256+ dir: "/tmp",
257+ filename: "document.pdf",
258+ existingFiles: map[string]bool{
259+ "/tmp/document.pdf": true,
260+ },
261+ expected: "document_(1).pdf",
262+ },
263+ {
264+ name: "file and _(1) exist, adds _(2)",
265+ dir: "/tmp",
266+ filename: "document.pdf",
267+ existingFiles: map[string]bool{
268+ "/tmp/document.pdf": true,
269+ "/tmp/document_(1).pdf": true,
270+ },
271+ expected: "document_(2).pdf",
272+ },
273+ {
274+ name: "no extension",
275+ dir: "/tmp",
276+ filename: "download",
277+ existingFiles: map[string]bool{
278+ "/tmp/download": true,
279+ },
280+ expected: "download_(1)",
281+ },
282+ {
283+ name: "hidden file with extension",
284+ dir: "/tmp",
285+ filename: ".config.bak",
286+ existingFiles: map[string]bool{
287+ "/tmp/.config.bak": true,
288+ },
289+ expected: ".config_(1).bak",
290+ },
291+ }
292+
293+ for _, tt := range tests {
294+ t.Run(tt.name, func(t *testing.T) {
295+ exists := func(path string) bool {
296+ return tt.existingFiles[path]
297+ }
298+ result := MakeUniqueFilename(tt.dir, tt.filename, exists)
299+ assert.Equal(t, tt.expected, result)
300+ })
301+ }
302+}
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 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 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 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 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 +30 -0
@@ -62,6 +62,21 @@ 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+ },
6580 }
6681
6782 for _, tt := range tests {
@@ -257,6 +272,21 @@ func TestLooksLikeURL(t *testing.T) {
257272 input: "hello",
258273 want: false,
259274 },
275+ {
276+ name: "localhost",
277+ input: "localhost",
278+ want: true,
279+ },
280+ {
281+ name: "localhost with port",
282+ input: "localhost:5173",
283+ want: true,
284+ },
285+ {
286+ name: "localhost with alt port",
287+ input: "localhost:8080",
288+ want: true,
289+ },
260290 }
261291
262292 for _, tt := range tests {
@@ -62,6 +62,21 @@ 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+ },
65 }80 }
66 81
67 for _, tt := range tests {82 for _, tt := range tests {
@@ -257,6 +272,21 @@ func TestLooksLikeURL(t *testing.T) {
257 input: "hello",272 input: "hello",
258 want: false,273 want: false,
259 },274 },
275+ {
276+ name: "localhost",
277+ input: "localhost",
278+ want: true,
279+ },
280+ {
281+ name: "localhost with port",
282+ input: "localhost:5173",
283+ want: true,
284+ },
285+ {
286+ name: "localhost with alt port",
287+ input: "localhost:8080",
288+ want: true,
289+ },
260 }290 }
261 291
262 for _, tt := range tests {292 for _, tt := range tests {
M internal/infrastructure/webkit/download_handler.go +54 -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,22 @@ 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+func NewDownloadHandler(
31+ downloadPath string,
32+ handler port.DownloadEventHandler,
33+ prepareDownloadUC *usecase.PrepareDownloadUseCase,
34+) *DownloadHandler {
3035 return &DownloadHandler{
31- downloadPath: downloadPath,
32- eventHandler: handler,
36+ downloadPath: downloadPath,
37+ eventHandler: handler,
38+ prepareDownloadUC: prepareDownloadUC,
3339 }
3440 }
3541
@@ -47,6 +53,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
4753 h.mu.RLock()
4854 downloadPath := h.downloadPath
4955 eventHandler := h.eventHandler
56+ prepareDownloadUC := h.prepareDownloadUC
5057 h.mu.RUnlock()
5158
5259 // Track download state in a dedicated struct to ensure proper isolation.
@@ -63,31 +70,40 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
6370 return false
6471 }
6572
66- // Sanitize filename to prevent path traversal attacks.
67- safeName := sanitizeFilename(suggestedFilename)
68- destPath := filepath.Join(downloadPath, safeName)
73+ // Wrap WebKit response as port.DownloadResponse
74+ var response port.DownloadResponse
75+ if resp := d.GetResponse(); resp != nil {
76+ response = &uriResponseAdapter{resp: resp}
77+ }
78+
79+ // Use PrepareDownloadUseCase to resolve filename
80+ output := prepareDownloadUC.Execute(ctx, usecase.PrepareDownloadInput{
81+ SuggestedFilename: suggestedFilename,
82+ Response: response,
83+ DownloadDir: downloadPath,
84+ })
6985
7086 log.Debug().
7187 Str("suggested", suggestedFilename).
72- Str("sanitized", safeName).
73- Str("destPath", destPath).
88+ Str("sanitized", output.Filename).
89+ Str("destPath", output.DestinationPath).
7490 Msg("setting download destination")
7591
7692 // WebKit expects an absolute file path for the destination.
77- d.SetDestination(destPath)
93+ d.SetDestination(output.DestinationPath)
7894
7995 // Notify: download started.
8096 if eventHandler != nil {
8197 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
8298 Type: port.DownloadEventStarted,
83- Filename: safeName,
84- Destination: destPath,
99+ Filename: output.Filename,
100+ Destination: output.DestinationPath,
85101 })
86102 }
87103
88104 log.Info().
89- Str("filename", safeName).
90- Str("destination", destPath).
105+ Str("filename", output.Filename).
106+ Str("destination", output.DestinationPath).
91107 Msg("download started")
92108
93109 return false // false = we handled it synchronously
@@ -101,7 +117,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
101117 state.mu.Unlock()
102118
103119 dest := d.GetDestination()
104- filename := extractFilename(dest)
120+ filename := downloadutil.ExtractFilenameFromDestination(dest)
105121
106122 // Create error for the failed download.
107123 downloadErr := fmt.Errorf("download failed: %s", filename)
@@ -131,7 +147,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
131147 }
132148
133149 dest := d.GetDestination()
134- filename := extractFilename(dest)
150+ filename := downloadutil.ExtractFilenameFromDestination(dest)
135151
136152 if eventHandler != nil {
137153 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
@@ -153,34 +169,28 @@ func (h *DownloadHandler) SetDownloadPath(path string) {
153169 h.downloadPath = path
154170 }
155171
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)
172+// uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.
173+type uriResponseAdapter struct {
174+ resp *webkit.URIResponse
175+}
161176
162- // Handle edge cases consistently with sanitizeFilename.
163- if base == "." || base == "" {
164- return "download"
177+func (a *uriResponseAdapter) GetMimeType() string {
178+ if a.resp == nil {
179+ return ""
165180 }
166- return base
181+ return a.resp.GetMimeType()
167182 }
168183
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"
184+func (a *uriResponseAdapter) GetSuggestedFilename() string {
185+ if a.resp == nil {
186+ return ""
183187 }
188+ return a.resp.GetSuggestedFilename()
189+}
184190
185- return clean
191+func (a *uriResponseAdapter) GetUri() string {
192+ if a.resp == nil {
193+ return ""
194+ }
195+ return a.resp.GetUri()
186196 }
@@ -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,22 @@ 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+func NewDownloadHandler(
31+ downloadPath string,
32+ handler port.DownloadEventHandler,
33+ prepareDownloadUC *usecase.PrepareDownloadUseCase,
34+) *DownloadHandler {
30 return &DownloadHandler{35 return &DownloadHandler{
31- downloadPath: downloadPath,36+ downloadPath: downloadPath,
32- eventHandler: handler,37+ eventHandler: handler,
38+ prepareDownloadUC: prepareDownloadUC,
33 }39 }
34 }40 }
35 41
@@ -47,6 +53,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
47 h.mu.RLock()53 h.mu.RLock()
48 downloadPath := h.downloadPath54 downloadPath := h.downloadPath
49 eventHandler := h.eventHandler55 eventHandler := h.eventHandler
56+ prepareDownloadUC := h.prepareDownloadUC
50 h.mu.RUnlock()57 h.mu.RUnlock()
51 58
52 // Track download state in a dedicated struct to ensure proper isolation.59 // Track download state in a dedicated struct to ensure proper isolation.
@@ -63,31 +70,40 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
63 return false70 return false
64 }71 }
65 72
66- // Sanitize filename to prevent path traversal attacks.73+ // Wrap WebKit response as port.DownloadResponse
67- safeName := sanitizeFilename(suggestedFilename)74+ var response port.DownloadResponse
68- destPath := filepath.Join(downloadPath, safeName)75+ if resp := d.GetResponse(); resp != nil {
76+ response = &uriResponseAdapter{resp: resp}
77+ }
78+
79+ // Use PrepareDownloadUseCase to resolve filename
80+ output := prepareDownloadUC.Execute(ctx, usecase.PrepareDownloadInput{
81+ SuggestedFilename: suggestedFilename,
82+ Response: response,
83+ DownloadDir: downloadPath,
84+ })
69 85
70 log.Debug().86 log.Debug().
71 Str("suggested", suggestedFilename).87 Str("suggested", suggestedFilename).
72- Str("sanitized", safeName).88+ Str("sanitized", output.Filename).
73- Str("destPath", destPath).89+ Str("destPath", output.DestinationPath).
74 Msg("setting download destination")90 Msg("setting download destination")
75 91
76 // WebKit expects an absolute file path for the destination.92 // WebKit expects an absolute file path for the destination.
77- d.SetDestination(destPath)93+ d.SetDestination(output.DestinationPath)
78 94
79 // Notify: download started.95 // Notify: download started.
80 if eventHandler != nil {96 if eventHandler != nil {
81 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{97 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
82 Type: port.DownloadEventStarted,98 Type: port.DownloadEventStarted,
83- Filename: safeName,99+ Filename: output.Filename,
84- Destination: destPath,100+ Destination: output.DestinationPath,
85 })101 })
86 }102 }
87 103
88 log.Info().104 log.Info().
89- Str("filename", safeName).105+ Str("filename", output.Filename).
90- Str("destination", destPath).106+ Str("destination", output.DestinationPath).
91 Msg("download started")107 Msg("download started")
92 108
93 return false // false = we handled it synchronously109 return false // false = we handled it synchronously
@@ -101,7 +117,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
101 state.mu.Unlock()117 state.mu.Unlock()
102 118
103 dest := d.GetDestination()119 dest := d.GetDestination()
104- filename := extractFilename(dest)120+ filename := downloadutil.ExtractFilenameFromDestination(dest)
105 121
106 // Create error for the failed download.122 // Create error for the failed download.
107 downloadErr := fmt.Errorf("download failed: %s", filename)123 downloadErr := fmt.Errorf("download failed: %s", filename)
@@ -131,7 +147,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
131 }147 }
132 148
133 dest := d.GetDestination()149 dest := d.GetDestination()
134- filename := extractFilename(dest)150+ filename := downloadutil.ExtractFilenameFromDestination(dest)
135 151
136 if eventHandler != nil {152 if eventHandler != nil {
137 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{153 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
@@ -153,34 +169,28 @@ func (h *DownloadHandler) SetDownloadPath(path string) {
153 h.downloadPath = path169 h.downloadPath = path
154 }170 }
155 171
156-// extractFilename extracts the filename from a file:// URI or path.172+// uriResponseAdapter wraps webkit.URIResponse to implement port.DownloadResponse.
157-func extractFilename(dest string) string {173+type uriResponseAdapter struct {
158- // Remove file:// prefix if present.174+ resp *webkit.URIResponse
159- path := strings.TrimPrefix(dest, "file://")175+}
160- base := filepath.Base(path)
161 176
162- // Handle edge cases consistently with sanitizeFilename.177+func (a *uriResponseAdapter) GetMimeType() string {
163- if base == "." || base == "" {178+ if a.resp == nil {
164- return "download"179+ return ""
165 }180 }
166- return base181+ return a.resp.GetMimeType()
167 }182 }
168 183
169-// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.184+func (a *uriResponseAdapter) GetSuggestedFilename() string {
170-// It extracts only the base name and handles edge cases like "." or "..".185+ if a.resp == nil {
171-func sanitizeFilename(name string) string {186+ 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 }187 }
188+ return a.resp.GetSuggestedFilename()
189+}
184 190
185- return clean191+func (a *uriResponseAdapter) GetUri() string {
192+ if a.resp == nil {
193+ return ""
194+ }
195+ return a.resp.GetUri()
186 }196 }
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