1package webkit
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/puregotk-webkit/webkit"
14)
15
16const (
17 // dirPerm is the permission mode for creating download directories.
18 dirPerm = 0755
19)
20
21// DownloadHandler manages WebKit downloads and notifies the UI layer.
22type DownloadHandler struct {
23 downloadPath string
24 eventHandler port.DownloadEventHandler
25 mu sync.RWMutex
26}
27
28// NewDownloadHandler creates a new download handler.
29func NewDownloadHandler(downloadPath string, handler port.DownloadEventHandler) *DownloadHandler {
30 return &DownloadHandler{
31 downloadPath: downloadPath,
32 eventHandler: handler,
33 }
34}
35
36// downloadState tracks the state of a single download to prevent duplicate notifications.
37// This is allocated per-download to ensure proper isolation between concurrent downloads.
38type downloadState struct {
39 failed bool
40 mu sync.Mutex
41}
42
43// HandleDownload sets up signal handlers for a new download.
44func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.Download) {
45 log := logging.FromContext(ctx)
46
47 h.mu.RLock()
48 downloadPath := h.downloadPath
49 eventHandler := h.eventHandler
50 h.mu.RUnlock()
51
52 // Track download state in a dedicated struct to ensure proper isolation.
53 // Each download gets its own state to prevent interference between concurrent downloads.
54 state := &downloadState{}
55
56 // Handle decide-destination signal to set download path.
57 //nolint:unparam // bool return is required by WebKit's signal signature (false = handled synchronously)
58 decideDestCb := func(d webkit.Download, suggestedFilename string) bool {
59 // Ensure download directory exists.
60 if err := os.MkdirAll(downloadPath, dirPerm); err != nil {
61 log.Error().Err(err).Str("path", downloadPath).Msg("failed to create download directory")
62 d.Cancel()
63 return false
64 }
65
66 // Sanitize filename to prevent path traversal attacks.
67 safeName := sanitizeFilename(suggestedFilename)
68 destPath := filepath.Join(downloadPath, safeName)
69
70 log.Debug().
71 Str("suggested", suggestedFilename).
72 Str("sanitized", safeName).
73 Str("destPath", destPath).
74 Msg("setting download destination")
75
76 // WebKit expects an absolute file path for the destination.
77 d.SetDestination(destPath)
78
79 // Notify: download started.
80 if eventHandler != nil {
81 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
82 Type: port.DownloadEventStarted,
83 Filename: safeName,
84 Destination: destPath,
85 })
86 }
87
88 log.Info().
89 Str("filename", safeName).
90 Str("destination", destPath).
91 Msg("download started")
92
93 return false // false = we handled it synchronously
94 }
95 download.ConnectDecideDestination(&decideDestCb)
96
97 // Handle failed signal.
98 failedCb := func(d webkit.Download, _ uintptr) {
99 state.mu.Lock()
100 state.failed = true
101 state.mu.Unlock()
102
103 dest := d.GetDestination()
104 filename := extractFilename(dest)
105
106 // Create error for the failed download.
107 downloadErr := fmt.Errorf("download failed: %s", filename)
108
109 if eventHandler != nil {
110 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
111 Type: port.DownloadEventFailed,
112 Filename: filename,
113 Destination: dest,
114 Error: downloadErr,
115 })
116 }
117
118 log.Warn().Str("filename", filename).Msg("download failed")
119 }
120 download.ConnectFailed(&failedCb)
121
122 // Handle finished signal.
123 finishedCb := func(d webkit.Download) {
124 state.mu.Lock()
125 failed := state.failed
126 state.mu.Unlock()
127
128 // Don't send finished event if we already sent a failed event.
129 if failed {
130 return
131 }
132
133 dest := d.GetDestination()
134 filename := extractFilename(dest)
135
136 if eventHandler != nil {
137 eventHandler.OnDownloadEvent(ctx, port.DownloadEvent{
138 Type: port.DownloadEventFinished,
139 Filename: filename,
140 Destination: dest,
141 })
142 }
143
144 log.Info().Str("filename", filename).Msg("download finished")
145 }
146 download.ConnectFinished(&finishedCb)
147}
148
149// SetDownloadPath updates the download directory.
150func (h *DownloadHandler) SetDownloadPath(path string) {
151 h.mu.Lock()
152 defer h.mu.Unlock()
153 h.downloadPath = path
154}
155
156// extractFilename extracts the filename from a file:// URI or path.
157func extractFilename(dest string) string {
158 // Remove file:// prefix if present.
159 path := strings.TrimPrefix(dest, "file://")
160 base := filepath.Base(path)
161
162 // Handle edge cases consistently with sanitizeFilename.
163 if base == "." || base == "" {
164 return "download"
165 }
166 return base
167}
168
169// sanitizeFilename sanitizes a suggested filename to prevent path traversal attacks.
170// It extracts only the base name and handles edge cases like "." or "..".
171func 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"
183 }
184
185 return clean
186}