1package filtering
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "os"
10 "path/filepath"
11 "time"
12
13 "github.com/bnema/dumber/internal/logging"
14)
15
16const (
17 cacheDirPerm = 0o755
18 manifestFilePerm = 0o644
19)
20
21// Downloader handles downloading filter files from GitHub releases.
22type Downloader struct {
23 baseURL string
24 cacheDir string
25 httpClient *http.Client
26}
27
28// NewDownloader creates a new Downloader.
29// cacheDir is where downloaded JSON files will be stored.
30func NewDownloader(cacheDir string) *Downloader {
31 return &Downloader{
32 baseURL: GitHubReleaseURL,
33 cacheDir: cacheDir,
34 httpClient: &http.Client{
35 Timeout: 60 * time.Second,
36 },
37 }
38}
39
40// GetCachedManifest reads the locally cached manifest.json.
41// Returns nil if no cached manifest exists.
42func (d *Downloader) GetCachedManifest() (*Manifest, error) {
43 path := filepath.Join(d.cacheDir, FilterFiles.Manifest)
44 data, err := os.ReadFile(path)
45 if err != nil {
46 if os.IsNotExist(err) {
47 return nil, nil
48 }
49 return nil, err
50 }
51
52 var manifest Manifest
53 if err := json.Unmarshal(data, &manifest); err != nil {
54 return nil, fmt.Errorf("failed to parse cached manifest: %w", err)
55 }
56 return &manifest, nil
57}
58
59// FetchManifest downloads the latest manifest.json from GitHub.
60func (d *Downloader) FetchManifest(ctx context.Context) (*Manifest, error) {
61 log := logging.FromContext(ctx).With().
62 Str("component", "filter-downloader").
63 Logger()
64
65 url := fmt.Sprintf("%s/%s", d.baseURL, FilterFiles.Manifest)
66 log.Debug().Str("url", url).Msg("fetching manifest")
67
68 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
69 if err != nil {
70 return nil, fmt.Errorf("failed to create request: %w", err)
71 }
72
73 resp, err := d.httpClient.Do(req)
74 if err != nil {
75 return nil, fmt.Errorf("failed to fetch manifest: %w", err)
76 }
77 defer func() {
78 if closeErr := resp.Body.Close(); closeErr != nil {
79 log.Debug().Err(closeErr).Msg("failed to close manifest response body")
80 }
81 }()
82
83 if resp.StatusCode != http.StatusOK {
84 return nil, fmt.Errorf("manifest fetch failed with status %d", resp.StatusCode)
85 }
86
87 data, err := io.ReadAll(resp.Body)
88 if err != nil {
89 return nil, fmt.Errorf("failed to read manifest body: %w", err)
90 }
91
92 var manifest Manifest
93 if err := json.Unmarshal(data, &manifest); err != nil {
94 return nil, fmt.Errorf("failed to parse manifest: %w", err)
95 }
96
97 log.Debug().Str("version", manifest.Version).Msg("fetched manifest")
98 return &manifest, nil
99}
100
101// DownloadProgress reports download progress.
102type DownloadProgress struct {
103 File string
104 Current int
105 Total int
106 BytesTotal int64
107}
108
109// DownloadFilters downloads filter JSON files from GitHub releases.
110// Returns the paths to the downloaded files.
111func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(DownloadProgress)) ([]string, error) {
112 log := logging.FromContext(ctx).With().
113 Str("component", "filter-downloader").
114 Logger()
115
116 // Ensure cache directory exists
117 if err := os.MkdirAll(d.cacheDir, cacheDirPerm); err != nil {
118 return nil, fmt.Errorf("failed to create cache dir: %w", err)
119 }
120
121 files := FilterFiles.Combined
122 paths := make([]string, 0, len(files))
123 var totalBytes int64
124
125 for i, filename := range files {
126 select {
127 case <-ctx.Done():
128 return nil, ctx.Err()
129 default:
130 }
131
132 if onProgress != nil {
133 onProgress(DownloadProgress{
134 File: filename,
135 Current: i + 1,
136 Total: len(files),
137 BytesTotal: totalBytes,
138 })
139 }
140
141 path, bytesDownloaded, err := d.downloadFile(ctx, filename)
142 if err != nil {
143 log.Error().Err(err).Str("file", filename).Msg("failed to download filter file")
144 return nil, err
145 }
146
147 totalBytes += bytesDownloaded
148 paths = append(paths, path)
149 log.Debug().Str("file", filename).Int64("bytes", bytesDownloaded).Msg("downloaded filter file")
150 }
151
152 // Also download and cache the manifest
153 if err := d.downloadAndCacheManifest(ctx); err != nil {
154 log.Warn().Err(err).Msg("failed to cache manifest")
155 // Non-fatal, continue
156 }
157
158 log.Info().Int("files", len(paths)).Int64("total_bytes", totalBytes).Msg("filter download complete")
159 return paths, nil
160}
161
162// downloadFile downloads a single file and returns its local path.
163func (d *Downloader) downloadFile(ctx context.Context, filename string) (string, int64, error) {
164 url := fmt.Sprintf("%s/%s", d.baseURL, filename)
165 localPath := filepath.Join(d.cacheDir, filename)
166
167 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
168 if err != nil {
169 return "", 0, fmt.Errorf("failed to create request: %w", err)
170 }
171
172 resp, err := d.httpClient.Do(req)
173 if err != nil {
174 return "", 0, fmt.Errorf("failed to download %s: %w", filename, err)
175 }
176 defer func() {
177 if closeErr := resp.Body.Close(); closeErr != nil {
178 logging.FromContext(ctx).Debug().Err(closeErr).Str("file", filename).Msg("failed to close filter response body")
179 }
180 }()
181
182 if resp.StatusCode != http.StatusOK {
183 return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode)
184 }
185
186 // Write to temp file in cacheDir, then atomic rename into destination.
187 tmpFile, err := os.CreateTemp(d.cacheDir, filepath.Base(filename)+".*.tmp")
188 if err != nil {
189 return "", 0, fmt.Errorf("failed to create temp file: %w", err)
190 }
191 tmpPath := tmpFile.Name()
192
193 bytesWritten, err := io.Copy(tmpFile, resp.Body)
194 if err != nil {
195 _ = tmpFile.Close()
196 _ = os.Remove(tmpPath)
197 return "", 0, fmt.Errorf("failed to write %s: %w", filename, err)
198 }
199 if err := tmpFile.Close(); err != nil {
200 _ = os.Remove(tmpPath)
201 return "", 0, fmt.Errorf("failed to close temp file: %w", err)
202 }
203
204 if err := d.renameTempFile(tmpPath, localPath); err != nil {
205 _ = os.Remove(tmpPath)
206 return "", 0, err
207 }
208
209 return localPath, bytesWritten, nil
210}
211
212func (*Downloader) renameTempFile(tmpPath, localPath string) error {
213 parentDir := filepath.Dir(localPath)
214
215 if err := os.MkdirAll(parentDir, cacheDirPerm); err != nil {
216 return fmt.Errorf("failed to create target dir: %w", err)
217 }
218
219 if err := os.Rename(tmpPath, localPath); err != nil {
220 return fmt.Errorf("failed to rename temp file: %w", err)
221 }
222
223 return nil
224}
225
226// downloadAndCacheManifest downloads and caches the manifest.
227func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {
228 manifest, err := d.FetchManifest(ctx)
229 if err != nil {
230 return err
231 }
232
233 data, err := json.MarshalIndent(manifest, "", " ")
234 if err != nil {
235 return fmt.Errorf("failed to marshal manifest: %w", err)
236 }
237
238 path := filepath.Join(d.cacheDir, FilterFiles.Manifest)
239 if err := os.WriteFile(path, data, manifestFilePerm); err != nil {
240 return fmt.Errorf("failed to write manifest: %w", err)
241 }
242
243 return nil
244}
245
246// GetCachedFilterPaths returns paths to cached filter JSON files.
247// Returns nil if any files are missing.
248func (d *Downloader) GetCachedFilterPaths() []string {
249 paths := make([]string, 0, len(FilterFiles.Combined))
250 for _, filename := range FilterFiles.Combined {
251 path := filepath.Join(d.cacheDir, filename)
252 if _, err := os.Stat(path); err != nil {
253 return nil
254 }
255 paths = append(paths, path)
256 }
257 return paths
258}
259
260// HasCachedFilters checks if all filter JSON files are cached.
261func (d *Downloader) HasCachedFilters() bool {
262 return d.GetCachedFilterPaths() != nil
263}
264
265// NeedsUpdate checks if filters need to be updated based on manifest version.
266func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) {
267 log := logging.FromContext(ctx).With().
268 Str("component", "filter-downloader").
269 Logger()
270
271 cached, err := d.GetCachedManifest()
272 if err != nil {
273 return true, nil // If we can't read cached, assume we need update
274 }
275 if cached == nil {
276 log.Debug().Msg("no cached manifest, update needed")
277 return true, nil
278 }
279
280 latest, err := d.FetchManifest(ctx)
281 if err != nil {
282 return false, err // Can't check, return error
283 }
284
285 needsUpdate := cached.Version != latest.Version
286 if needsUpdate {
287 log.Info().
288 Str("cached_version", cached.Version).
289 Str("latest_version", latest.Version).
290 Msg("filter update available")
291 } else {
292 log.Debug().Str("version", cached.Version).Msg("filters up to date")
293 }
294
295 return needsUpdate, nil
296}
297
298// ClearCache removes all cached filter files.
299func (d *Downloader) ClearCache() error {
300 return os.RemoveAll(d.cacheDir)
301}
302
303// IsCacheStale checks if the cached manifest is older than maxAge.
304// Returns true if cache is stale or doesn't exist.
305func (d *Downloader) IsCacheStale(maxAge time.Duration) bool {
306 path := filepath.Join(d.cacheDir, FilterFiles.Manifest)
307 info, err := os.Stat(path)
308 if err != nil {
309 return true
310 }
311 return time.Since(info.ModTime()) > maxAge
312}