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 first, then atomic rename
187 tmpPath := localPath + ".tmp"
188 file, err := os.Create(tmpPath)
189 if err != nil {
190 return "", 0, fmt.Errorf("failed to create temp file: %w", err)
191 }
192
193 bytesWritten, err := io.Copy(file, resp.Body)
194 if err != nil {
195 _ = file.Close()
196 _ = os.Remove(tmpPath)
197 return "", 0, fmt.Errorf("failed to write %s: %w", filename, err)
198 }
199 if err := file.Close(); err != nil {
200 _ = os.Remove(tmpPath)
201 return "", 0, fmt.Errorf("failed to close temp file: %w", err)
202 }
203
204 // Atomic rename
205 if err := os.Rename(tmpPath, localPath); err != nil {
206 _ = os.Remove(tmpPath)
207 return "", 0, fmt.Errorf("failed to rename temp file: %w", err)
208 }
209
210 return localPath, bytesWritten, nil
211}
212
213// downloadAndCacheManifest downloads and caches the manifest.
214func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error {
215 manifest, err := d.FetchManifest(ctx)
216 if err != nil {
217 return err
218 }
219
220 data, err := json.MarshalIndent(manifest, "", " ")
221 if err != nil {
222 return fmt.Errorf("failed to marshal manifest: %w", err)
223 }
224
225 path := filepath.Join(d.cacheDir, FilterFiles.Manifest)
226 if err := os.WriteFile(path, data, manifestFilePerm); err != nil {
227 return fmt.Errorf("failed to write manifest: %w", err)
228 }
229
230 return nil
231}
232
233// GetCachedFilterPaths returns paths to cached filter JSON files.
234// Returns nil if any files are missing.
235func (d *Downloader) GetCachedFilterPaths() []string {
236 paths := make([]string, 0, len(FilterFiles.Combined))
237 for _, filename := range FilterFiles.Combined {
238 path := filepath.Join(d.cacheDir, filename)
239 if _, err := os.Stat(path); err != nil {
240 return nil
241 }
242 paths = append(paths, path)
243 }
244 return paths
245}
246
247// HasCachedFilters checks if all filter JSON files are cached.
248func (d *Downloader) HasCachedFilters() bool {
249 return d.GetCachedFilterPaths() != nil
250}
251
252// NeedsUpdate checks if filters need to be updated based on manifest version.
253func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) {
254 log := logging.FromContext(ctx).With().
255 Str("component", "filter-downloader").
256 Logger()
257
258 cached, err := d.GetCachedManifest()
259 if err != nil {
260 return true, nil // If we can't read cached, assume we need update
261 }
262 if cached == nil {
263 log.Debug().Msg("no cached manifest, update needed")
264 return true, nil
265 }
266
267 latest, err := d.FetchManifest(ctx)
268 if err != nil {
269 return false, err // Can't check, return error
270 }
271
272 needsUpdate := cached.Version != latest.Version
273 if needsUpdate {
274 log.Info().
275 Str("cached_version", cached.Version).
276 Str("latest_version", latest.Version).
277 Msg("filter update available")
278 } else {
279 log.Debug().Str("version", cached.Version).Msg("filters up to date")
280 }
281
282 return needsUpdate, nil
283}
284
285// ClearCache removes all cached filter files.
286func (d *Downloader) ClearCache() error {
287 return os.RemoveAll(d.cacheDir)
288}
289
290// IsCacheStale checks if the cached manifest is older than maxAge.
291// Returns true if cache is stale or doesn't exist.
292func (d *Downloader) IsCacheStale(maxAge time.Duration) bool {
293 path := filepath.Join(d.cacheDir, FilterFiles.Manifest)
294 info, err := os.Stat(path)
295 if err != nil {
296 return true
297 }
298 return time.Since(info.ModTime()) > maxAge
299}