feat(filtering): add downloader, store, and filter state types
- commit
- 7505dfa8ebe957b7d16add671317a2ac8288461f
- author
- brice · · 2025-12-18 22:00 UTC
- committer
- brice · · 2025-12-18 22:52 UTC
- parent
- ded032c
- signature
- unsigned
3 files · +598 -0
A internal/infrastructure/filtering/downloader.go +272 -0
| @@ -0,0 +1,272 @@ | |||
| 1 | + | package filtering | |
| 2 | + | ||
| 3 | + | import ( | |
| 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 | + | ||
| 16 | + | // Downloader handles downloading filter files from GitHub releases. | |
| 17 | + | type Downloader struct { | |
| 18 | + | baseURL string | |
| 19 | + | cacheDir string | |
| 20 | + | httpClient *http.Client | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // NewDownloader creates a new Downloader. | |
| 24 | + | // cacheDir is where downloaded JSON files will be stored. | |
| 25 | + | func NewDownloader(cacheDir string) *Downloader { | |
| 26 | + | return &Downloader{ | |
| 27 | + | baseURL: GitHubReleaseURL, | |
| 28 | + | cacheDir: cacheDir, | |
| 29 | + | httpClient: &http.Client{ | |
| 30 | + | Timeout: 60 * time.Second, | |
| 31 | + | }, | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // GetCachedManifest reads the locally cached manifest.json. | |
| 36 | + | // Returns nil if no cached manifest exists. | |
| 37 | + | func (d *Downloader) GetCachedManifest() (*Manifest, error) { | |
| 38 | + | path := filepath.Join(d.cacheDir, FilterFiles.Manifest) | |
| 39 | + | data, err := os.ReadFile(path) | |
| 40 | + | if err != nil { | |
| 41 | + | if os.IsNotExist(err) { | |
| 42 | + | return nil, nil | |
| 43 | + | } | |
| 44 | + | return nil, err | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | var manifest Manifest | |
| 48 | + | if err := json.Unmarshal(data, &manifest); err != nil { | |
| 49 | + | return nil, fmt.Errorf("failed to parse cached manifest: %w", err) | |
| 50 | + | } | |
| 51 | + | return &manifest, nil | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // FetchManifest downloads the latest manifest.json from GitHub. | |
| 55 | + | func (d *Downloader) FetchManifest(ctx context.Context) (*Manifest, error) { | |
| 56 | + | log := logging.FromContext(ctx).With(). | |
| 57 | + | Str("component", "filter-downloader"). | |
| 58 | + | Logger() | |
| 59 | + | ||
| 60 | + | url := fmt.Sprintf("%s/%s", d.baseURL, FilterFiles.Manifest) | |
| 61 | + | log.Debug().Str("url", url).Msg("fetching manifest") | |
| 62 | + | ||
| 63 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |
| 64 | + | if err != nil { | |
| 65 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | resp, err := d.httpClient.Do(req) | |
| 69 | + | if err != nil { | |
| 70 | + | return nil, fmt.Errorf("failed to fetch manifest: %w", err) | |
| 71 | + | } | |
| 72 | + | defer resp.Body.Close() | |
| 73 | + | ||
| 74 | + | if resp.StatusCode != http.StatusOK { | |
| 75 | + | return nil, fmt.Errorf("manifest fetch failed with status %d", resp.StatusCode) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | data, err := io.ReadAll(resp.Body) | |
| 79 | + | if err != nil { | |
| 80 | + | return nil, fmt.Errorf("failed to read manifest body: %w", err) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | var manifest Manifest | |
| 84 | + | if err := json.Unmarshal(data, &manifest); err != nil { | |
| 85 | + | return nil, fmt.Errorf("failed to parse manifest: %w", err) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | log.Debug().Str("version", manifest.Version).Msg("fetched manifest") | |
| 89 | + | return &manifest, nil | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // DownloadProgress reports download progress. | |
| 93 | + | type DownloadProgress struct { | |
| 94 | + | File string | |
| 95 | + | Current int | |
| 96 | + | Total int | |
| 97 | + | BytesTotal int64 | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // DownloadFilters downloads filter JSON files from GitHub releases. | |
| 101 | + | // Returns the paths to the downloaded files. | |
| 102 | + | func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(DownloadProgress)) ([]string, error) { | |
| 103 | + | log := logging.FromContext(ctx).With(). | |
| 104 | + | Str("component", "filter-downloader"). | |
| 105 | + | Logger() | |
| 106 | + | ||
| 107 | + | // Ensure cache directory exists | |
| 108 | + | if err := os.MkdirAll(d.cacheDir, 0o755); err != nil { | |
| 109 | + | return nil, fmt.Errorf("failed to create cache dir: %w", err) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | files := FilterFiles.Combined | |
| 113 | + | paths := make([]string, 0, len(files)) | |
| 114 | + | var totalBytes int64 | |
| 115 | + | ||
| 116 | + | for i, filename := range files { | |
| 117 | + | select { | |
| 118 | + | case <-ctx.Done(): | |
| 119 | + | return nil, ctx.Err() | |
| 120 | + | default: | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | if onProgress != nil { | |
| 124 | + | onProgress(DownloadProgress{ | |
| 125 | + | File: filename, | |
| 126 | + | Current: i + 1, | |
| 127 | + | Total: len(files), | |
| 128 | + | BytesTotal: totalBytes, | |
| 129 | + | }) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | path, bytesDownloaded, err := d.downloadFile(ctx, filename) | |
| 133 | + | if err != nil { | |
| 134 | + | log.Error().Err(err).Str("file", filename).Msg("failed to download filter file") | |
| 135 | + | return nil, err | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | totalBytes += bytesDownloaded | |
| 139 | + | paths = append(paths, path) | |
| 140 | + | log.Debug().Str("file", filename).Int64("bytes", bytesDownloaded).Msg("downloaded filter file") | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // Also download and cache the manifest | |
| 144 | + | if err := d.downloadAndCacheManifest(ctx); err != nil { | |
| 145 | + | log.Warn().Err(err).Msg("failed to cache manifest") | |
| 146 | + | // Non-fatal, continue | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | log.Info().Int("files", len(paths)).Int64("total_bytes", totalBytes).Msg("filter download complete") | |
| 150 | + | return paths, nil | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // downloadFile downloads a single file and returns its local path. | |
| 154 | + | func (d *Downloader) downloadFile(ctx context.Context, filename string) (string, int64, error) { | |
| 155 | + | url := fmt.Sprintf("%s/%s", d.baseURL, filename) | |
| 156 | + | localPath := filepath.Join(d.cacheDir, filename) | |
| 157 | + | ||
| 158 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |
| 159 | + | if err != nil { | |
| 160 | + | return "", 0, fmt.Errorf("failed to create request: %w", err) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | resp, err := d.httpClient.Do(req) | |
| 164 | + | if err != nil { | |
| 165 | + | return "", 0, fmt.Errorf("failed to download %s: %w", filename, err) | |
| 166 | + | } | |
| 167 | + | defer resp.Body.Close() | |
| 168 | + | ||
| 169 | + | if resp.StatusCode != http.StatusOK { | |
| 170 | + | return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode) | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // Write to temp file first, then atomic rename | |
| 174 | + | tmpPath := localPath + ".tmp" | |
| 175 | + | file, err := os.Create(tmpPath) | |
| 176 | + | if err != nil { | |
| 177 | + | return "", 0, fmt.Errorf("failed to create temp file: %w", err) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | bytesWritten, err := io.Copy(file, resp.Body) | |
| 181 | + | if err != nil { | |
| 182 | + | file.Close() | |
| 183 | + | os.Remove(tmpPath) | |
| 184 | + | return "", 0, fmt.Errorf("failed to write %s: %w", filename, err) | |
| 185 | + | } | |
| 186 | + | file.Close() | |
| 187 | + | ||
| 188 | + | // Atomic rename | |
| 189 | + | if err := os.Rename(tmpPath, localPath); err != nil { | |
| 190 | + | os.Remove(tmpPath) | |
| 191 | + | return "", 0, fmt.Errorf("failed to rename temp file: %w", err) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | return localPath, bytesWritten, nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // downloadAndCacheManifest downloads and caches the manifest. | |
| 198 | + | func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error { | |
| 199 | + | manifest, err := d.FetchManifest(ctx) | |
| 200 | + | if err != nil { | |
| 201 | + | return err | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | data, err := json.MarshalIndent(manifest, "", " ") | |
| 205 | + | if err != nil { | |
| 206 | + | return fmt.Errorf("failed to marshal manifest: %w", err) | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | path := filepath.Join(d.cacheDir, FilterFiles.Manifest) | |
| 210 | + | if err := os.WriteFile(path, data, 0o644); err != nil { | |
| 211 | + | return fmt.Errorf("failed to write manifest: %w", err) | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | return nil | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | // GetCachedFilterPaths returns paths to cached filter JSON files. | |
| 218 | + | // Returns nil if any files are missing. | |
| 219 | + | func (d *Downloader) GetCachedFilterPaths() []string { | |
| 220 | + | paths := make([]string, 0, len(FilterFiles.Combined)) | |
| 221 | + | for _, filename := range FilterFiles.Combined { | |
| 222 | + | path := filepath.Join(d.cacheDir, filename) | |
| 223 | + | if _, err := os.Stat(path); err != nil { | |
| 224 | + | return nil | |
| 225 | + | } | |
| 226 | + | paths = append(paths, path) | |
| 227 | + | } | |
| 228 | + | return paths | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // HasCachedFilters checks if all filter JSON files are cached. | |
| 232 | + | func (d *Downloader) HasCachedFilters() bool { | |
| 233 | + | return d.GetCachedFilterPaths() != nil | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // NeedsUpdate checks if filters need to be updated based on manifest version. | |
| 237 | + | func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) { | |
| 238 | + | log := logging.FromContext(ctx).With(). | |
| 239 | + | Str("component", "filter-downloader"). | |
| 240 | + | Logger() | |
| 241 | + | ||
| 242 | + | cached, err := d.GetCachedManifest() | |
| 243 | + | if err != nil { | |
| 244 | + | return true, nil // If we can't read cached, assume we need update | |
| 245 | + | } | |
| 246 | + | if cached == nil { | |
| 247 | + | log.Debug().Msg("no cached manifest, update needed") | |
| 248 | + | return true, nil | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | latest, err := d.FetchManifest(ctx) | |
| 252 | + | if err != nil { | |
| 253 | + | return false, err // Can't check, return error | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | needsUpdate := cached.Version != latest.Version | |
| 257 | + | if needsUpdate { | |
| 258 | + | log.Info(). | |
| 259 | + | Str("cached_version", cached.Version). | |
| 260 | + | Str("latest_version", latest.Version). | |
| 261 | + | Msg("filter update available") | |
| 262 | + | } else { | |
| 263 | + | log.Debug().Str("version", cached.Version).Msg("filters up to date") | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | return needsUpdate, nil | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // ClearCache removes all cached filter files. | |
| 270 | + | func (d *Downloader) ClearCache() error { | |
| 271 | + | return os.RemoveAll(d.cacheDir) | |
| 272 | + | } | |
| @@ -0,0 +1,272 @@ | |||||
| 1 | + | package filtering | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 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 | + | ||||
| 16 | + | // Downloader handles downloading filter files from GitHub releases. | |||
| 17 | + | type Downloader struct { | |||
| 18 | + | baseURL string | |||
| 19 | + | cacheDir string | |||
| 20 | + | httpClient *http.Client | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // NewDownloader creates a new Downloader. | |||
| 24 | + | // cacheDir is where downloaded JSON files will be stored. | |||
| 25 | + | func NewDownloader(cacheDir string) *Downloader { | |||
| 26 | + | return &Downloader{ | |||
| 27 | + | baseURL: GitHubReleaseURL, | |||
| 28 | + | cacheDir: cacheDir, | |||
| 29 | + | httpClient: &http.Client{ | |||
| 30 | + | Timeout: 60 * time.Second, | |||
| 31 | + | }, | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // GetCachedManifest reads the locally cached manifest.json. | |||
| 36 | + | // Returns nil if no cached manifest exists. | |||
| 37 | + | func (d *Downloader) GetCachedManifest() (*Manifest, error) { | |||
| 38 | + | path := filepath.Join(d.cacheDir, FilterFiles.Manifest) | |||
| 39 | + | data, err := os.ReadFile(path) | |||
| 40 | + | if err != nil { | |||
| 41 | + | if os.IsNotExist(err) { | |||
| 42 | + | return nil, nil | |||
| 43 | + | } | |||
| 44 | + | return nil, err | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | var manifest Manifest | |||
| 48 | + | if err := json.Unmarshal(data, &manifest); err != nil { | |||
| 49 | + | return nil, fmt.Errorf("failed to parse cached manifest: %w", err) | |||
| 50 | + | } | |||
| 51 | + | return &manifest, nil | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // FetchManifest downloads the latest manifest.json from GitHub. | |||
| 55 | + | func (d *Downloader) FetchManifest(ctx context.Context) (*Manifest, error) { | |||
| 56 | + | log := logging.FromContext(ctx).With(). | |||
| 57 | + | Str("component", "filter-downloader"). | |||
| 58 | + | Logger() | |||
| 59 | + | ||||
| 60 | + | url := fmt.Sprintf("%s/%s", d.baseURL, FilterFiles.Manifest) | |||
| 61 | + | log.Debug().Str("url", url).Msg("fetching manifest") | |||
| 62 | + | ||||
| 63 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |||
| 64 | + | if err != nil { | |||
| 65 | + | return nil, fmt.Errorf("failed to create request: %w", err) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | resp, err := d.httpClient.Do(req) | |||
| 69 | + | if err != nil { | |||
| 70 | + | return nil, fmt.Errorf("failed to fetch manifest: %w", err) | |||
| 71 | + | } | |||
| 72 | + | defer resp.Body.Close() | |||
| 73 | + | ||||
| 74 | + | if resp.StatusCode != http.StatusOK { | |||
| 75 | + | return nil, fmt.Errorf("manifest fetch failed with status %d", resp.StatusCode) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | data, err := io.ReadAll(resp.Body) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return nil, fmt.Errorf("failed to read manifest body: %w", err) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | var manifest Manifest | |||
| 84 | + | if err := json.Unmarshal(data, &manifest); err != nil { | |||
| 85 | + | return nil, fmt.Errorf("failed to parse manifest: %w", err) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | log.Debug().Str("version", manifest.Version).Msg("fetched manifest") | |||
| 89 | + | return &manifest, nil | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // DownloadProgress reports download progress. | |||
| 93 | + | type DownloadProgress struct { | |||
| 94 | + | File string | |||
| 95 | + | Current int | |||
| 96 | + | Total int | |||
| 97 | + | BytesTotal int64 | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // DownloadFilters downloads filter JSON files from GitHub releases. | |||
| 101 | + | // Returns the paths to the downloaded files. | |||
| 102 | + | func (d *Downloader) DownloadFilters(ctx context.Context, onProgress func(DownloadProgress)) ([]string, error) { | |||
| 103 | + | log := logging.FromContext(ctx).With(). | |||
| 104 | + | Str("component", "filter-downloader"). | |||
| 105 | + | Logger() | |||
| 106 | + | ||||
| 107 | + | // Ensure cache directory exists | |||
| 108 | + | if err := os.MkdirAll(d.cacheDir, 0o755); err != nil { | |||
| 109 | + | return nil, fmt.Errorf("failed to create cache dir: %w", err) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | files := FilterFiles.Combined | |||
| 113 | + | paths := make([]string, 0, len(files)) | |||
| 114 | + | var totalBytes int64 | |||
| 115 | + | ||||
| 116 | + | for i, filename := range files { | |||
| 117 | + | select { | |||
| 118 | + | case <-ctx.Done(): | |||
| 119 | + | return nil, ctx.Err() | |||
| 120 | + | default: | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | if onProgress != nil { | |||
| 124 | + | onProgress(DownloadProgress{ | |||
| 125 | + | File: filename, | |||
| 126 | + | Current: i + 1, | |||
| 127 | + | Total: len(files), | |||
| 128 | + | BytesTotal: totalBytes, | |||
| 129 | + | }) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | path, bytesDownloaded, err := d.downloadFile(ctx, filename) | |||
| 133 | + | if err != nil { | |||
| 134 | + | log.Error().Err(err).Str("file", filename).Msg("failed to download filter file") | |||
| 135 | + | return nil, err | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | totalBytes += bytesDownloaded | |||
| 139 | + | paths = append(paths, path) | |||
| 140 | + | log.Debug().Str("file", filename).Int64("bytes", bytesDownloaded).Msg("downloaded filter file") | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // Also download and cache the manifest | |||
| 144 | + | if err := d.downloadAndCacheManifest(ctx); err != nil { | |||
| 145 | + | log.Warn().Err(err).Msg("failed to cache manifest") | |||
| 146 | + | // Non-fatal, continue | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | log.Info().Int("files", len(paths)).Int64("total_bytes", totalBytes).Msg("filter download complete") | |||
| 150 | + | return paths, nil | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // downloadFile downloads a single file and returns its local path. | |||
| 154 | + | func (d *Downloader) downloadFile(ctx context.Context, filename string) (string, int64, error) { | |||
| 155 | + | url := fmt.Sprintf("%s/%s", d.baseURL, filename) | |||
| 156 | + | localPath := filepath.Join(d.cacheDir, filename) | |||
| 157 | + | ||||
| 158 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |||
| 159 | + | if err != nil { | |||
| 160 | + | return "", 0, fmt.Errorf("failed to create request: %w", err) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | resp, err := d.httpClient.Do(req) | |||
| 164 | + | if err != nil { | |||
| 165 | + | return "", 0, fmt.Errorf("failed to download %s: %w", filename, err) | |||
| 166 | + | } | |||
| 167 | + | defer resp.Body.Close() | |||
| 168 | + | ||||
| 169 | + | if resp.StatusCode != http.StatusOK { | |||
| 170 | + | return "", 0, fmt.Errorf("download %s failed with status %d", filename, resp.StatusCode) | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // Write to temp file first, then atomic rename | |||
| 174 | + | tmpPath := localPath + ".tmp" | |||
| 175 | + | file, err := os.Create(tmpPath) | |||
| 176 | + | if err != nil { | |||
| 177 | + | return "", 0, fmt.Errorf("failed to create temp file: %w", err) | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | bytesWritten, err := io.Copy(file, resp.Body) | |||
| 181 | + | if err != nil { | |||
| 182 | + | file.Close() | |||
| 183 | + | os.Remove(tmpPath) | |||
| 184 | + | return "", 0, fmt.Errorf("failed to write %s: %w", filename, err) | |||
| 185 | + | } | |||
| 186 | + | file.Close() | |||
| 187 | + | ||||
| 188 | + | // Atomic rename | |||
| 189 | + | if err := os.Rename(tmpPath, localPath); err != nil { | |||
| 190 | + | os.Remove(tmpPath) | |||
| 191 | + | return "", 0, fmt.Errorf("failed to rename temp file: %w", err) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | return localPath, bytesWritten, nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | // downloadAndCacheManifest downloads and caches the manifest. | |||
| 198 | + | func (d *Downloader) downloadAndCacheManifest(ctx context.Context) error { | |||
| 199 | + | manifest, err := d.FetchManifest(ctx) | |||
| 200 | + | if err != nil { | |||
| 201 | + | return err | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | data, err := json.MarshalIndent(manifest, "", " ") | |||
| 205 | + | if err != nil { | |||
| 206 | + | return fmt.Errorf("failed to marshal manifest: %w", err) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | path := filepath.Join(d.cacheDir, FilterFiles.Manifest) | |||
| 210 | + | if err := os.WriteFile(path, data, 0o644); err != nil { | |||
| 211 | + | return fmt.Errorf("failed to write manifest: %w", err) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | return nil | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | // GetCachedFilterPaths returns paths to cached filter JSON files. | |||
| 218 | + | // Returns nil if any files are missing. | |||
| 219 | + | func (d *Downloader) GetCachedFilterPaths() []string { | |||
| 220 | + | paths := make([]string, 0, len(FilterFiles.Combined)) | |||
| 221 | + | for _, filename := range FilterFiles.Combined { | |||
| 222 | + | path := filepath.Join(d.cacheDir, filename) | |||
| 223 | + | if _, err := os.Stat(path); err != nil { | |||
| 224 | + | return nil | |||
| 225 | + | } | |||
| 226 | + | paths = append(paths, path) | |||
| 227 | + | } | |||
| 228 | + | return paths | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // HasCachedFilters checks if all filter JSON files are cached. | |||
| 232 | + | func (d *Downloader) HasCachedFilters() bool { | |||
| 233 | + | return d.GetCachedFilterPaths() != nil | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // NeedsUpdate checks if filters need to be updated based on manifest version. | |||
| 237 | + | func (d *Downloader) NeedsUpdate(ctx context.Context) (bool, error) { | |||
| 238 | + | log := logging.FromContext(ctx).With(). | |||
| 239 | + | Str("component", "filter-downloader"). | |||
| 240 | + | Logger() | |||
| 241 | + | ||||
| 242 | + | cached, err := d.GetCachedManifest() | |||
| 243 | + | if err != nil { | |||
| 244 | + | return true, nil // If we can't read cached, assume we need update | |||
| 245 | + | } | |||
| 246 | + | if cached == nil { | |||
| 247 | + | log.Debug().Msg("no cached manifest, update needed") | |||
| 248 | + | return true, nil | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | latest, err := d.FetchManifest(ctx) | |||
| 252 | + | if err != nil { | |||
| 253 | + | return false, err // Can't check, return error | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | needsUpdate := cached.Version != latest.Version | |||
| 257 | + | if needsUpdate { | |||
| 258 | + | log.Info(). | |||
| 259 | + | Str("cached_version", cached.Version). | |||
| 260 | + | Str("latest_version", latest.Version). | |||
| 261 | + | Msg("filter update available") | |||
| 262 | + | } else { | |||
| 263 | + | log.Debug().Str("version", cached.Version).Msg("filters up to date") | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | return needsUpdate, nil | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // ClearCache removes all cached filter files. | |||
| 270 | + | func (d *Downloader) ClearCache() error { | |||
| 271 | + | return os.RemoveAll(d.cacheDir) | |||
| 272 | + | } | |||
A internal/infrastructure/filtering/store.go +257 -0
| @@ -0,0 +1,257 @@ | |||
| 1 | + | package filtering | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | "github.com/bnema/puregotk-webkit/webkit" | |
| 10 | + | "github.com/jwijenbergh/puregotk/v4/gio" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Store wraps webkit.UserContentFilterStore with Go-friendly async operations. | |
| 14 | + | type Store struct { | |
| 15 | + | inner *webkit.UserContentFilterStore | |
| 16 | + | path string | |
| 17 | + | mu sync.Mutex | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // NewStore creates a new Store at the given path. | |
| 21 | + | // The path is where WebKit will store compiled filter bytecode. | |
| 22 | + | func NewStore(storagePath string) *Store { | |
| 23 | + | inner := webkit.NewUserContentFilterStore(storagePath) | |
| 24 | + | if inner == nil { | |
| 25 | + | return nil | |
| 26 | + | } | |
| 27 | + | return &Store{ | |
| 28 | + | inner: inner, | |
| 29 | + | path: storagePath, | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Path returns the storage path for compiled filters. | |
| 34 | + | func (s *Store) Path() string { | |
| 35 | + | return s.path | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // CompileResult holds the result of a filter compilation. | |
| 39 | + | type CompileResult struct { | |
| 40 | + | Filter *webkit.UserContentFilter | |
| 41 | + | Err error | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Compile compiles a JSON filter file and stores it with the given identifier. | |
| 45 | + | // This is an async operation that may take several seconds for large filter sets. | |
| 46 | + | // The JSON file must be in Safari Content Blocker format. | |
| 47 | + | func (s *Store) Compile(ctx context.Context, identifier string, jsonPath string) (*webkit.UserContentFilter, error) { | |
| 48 | + | log := logging.FromContext(ctx).With(). | |
| 49 | + | Str("component", "filter-store"). | |
| 50 | + | Str("identifier", identifier). | |
| 51 | + | Str("json_path", jsonPath). | |
| 52 | + | Logger() | |
| 53 | + | ||
| 54 | + | s.mu.Lock() | |
| 55 | + | defer s.mu.Unlock() | |
| 56 | + | ||
| 57 | + | // Create GFile from path | |
| 58 | + | file := gio.FileNewForPath(jsonPath) | |
| 59 | + | if file == nil { | |
| 60 | + | return nil, fmt.Errorf("failed to create GFile for path: %s", jsonPath) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | log.Debug().Msg("starting filter compilation") | |
| 64 | + | ||
| 65 | + | // Use a channel to receive the async result | |
| 66 | + | resultCh := make(chan CompileResult, 1) | |
| 67 | + | ||
| 68 | + | // Create async callback | |
| 69 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |
| 70 | + | if resPtr == 0 { | |
| 71 | + | resultCh <- CompileResult{Err: fmt.Errorf("nil async result")} | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |
| 76 | + | filter, err := s.inner.SaveFromFileFinish(res) | |
| 77 | + | resultCh <- CompileResult{Filter: filter, Err: err} | |
| 78 | + | }) | |
| 79 | + | ||
| 80 | + | // Start async compilation | |
| 81 | + | s.inner.SaveFromFile(identifier, file, nil, &cb, 0) | |
| 82 | + | ||
| 83 | + | // Wait for result or context cancellation | |
| 84 | + | select { | |
| 85 | + | case result := <-resultCh: | |
| 86 | + | if result.Err != nil { | |
| 87 | + | log.Error().Err(result.Err).Msg("filter compilation failed") | |
| 88 | + | return nil, result.Err | |
| 89 | + | } | |
| 90 | + | log.Info().Msg("filter compilation completed") | |
| 91 | + | return result.Filter, nil | |
| 92 | + | case <-ctx.Done(): | |
| 93 | + | log.Warn().Msg("filter compilation cancelled") | |
| 94 | + | return nil, ctx.Err() | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // LoadResult holds the result of loading a compiled filter. | |
| 99 | + | type LoadResult struct { | |
| 100 | + | Filter *webkit.UserContentFilter | |
| 101 | + | Err error | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Load loads a previously compiled filter by its identifier. | |
| 105 | + | // This is fast (~50ms) as it loads pre-compiled bytecode. | |
| 106 | + | func (s *Store) Load(ctx context.Context, identifier string) (*webkit.UserContentFilter, error) { | |
| 107 | + | log := logging.FromContext(ctx).With(). | |
| 108 | + | Str("component", "filter-store"). | |
| 109 | + | Str("identifier", identifier). | |
| 110 | + | Logger() | |
| 111 | + | ||
| 112 | + | s.mu.Lock() | |
| 113 | + | defer s.mu.Unlock() | |
| 114 | + | ||
| 115 | + | log.Debug().Msg("loading compiled filter") | |
| 116 | + | ||
| 117 | + | // Use a channel to receive the async result | |
| 118 | + | resultCh := make(chan LoadResult, 1) | |
| 119 | + | ||
| 120 | + | // Create async callback | |
| 121 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |
| 122 | + | if resPtr == 0 { | |
| 123 | + | resultCh <- LoadResult{Err: fmt.Errorf("nil async result")} | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |
| 128 | + | filter, err := s.inner.LoadFinish(res) | |
| 129 | + | resultCh <- LoadResult{Filter: filter, Err: err} | |
| 130 | + | }) | |
| 131 | + | ||
| 132 | + | // Start async load | |
| 133 | + | s.inner.Load(identifier, nil, &cb, 0) | |
| 134 | + | ||
| 135 | + | // Wait for result or context cancellation | |
| 136 | + | select { | |
| 137 | + | case result := <-resultCh: | |
| 138 | + | if result.Err != nil { | |
| 139 | + | log.Debug().Err(result.Err).Msg("filter load failed (may not exist)") | |
| 140 | + | return nil, result.Err | |
| 141 | + | } | |
| 142 | + | log.Debug().Msg("filter loaded successfully") | |
| 143 | + | return result.Filter, nil | |
| 144 | + | case <-ctx.Done(): | |
| 145 | + | log.Warn().Msg("filter load cancelled") | |
| 146 | + | return nil, ctx.Err() | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // FetchIdentifiersResult holds the result of fetching filter identifiers. | |
| 151 | + | type FetchIdentifiersResult struct { | |
| 152 | + | Identifiers []string | |
| 153 | + | Err error | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // FetchIdentifiers returns all stored filter identifiers. | |
| 157 | + | func (s *Store) FetchIdentifiers(ctx context.Context) ([]string, error) { | |
| 158 | + | log := logging.FromContext(ctx).With(). | |
| 159 | + | Str("component", "filter-store"). | |
| 160 | + | Logger() | |
| 161 | + | ||
| 162 | + | s.mu.Lock() | |
| 163 | + | defer s.mu.Unlock() | |
| 164 | + | ||
| 165 | + | log.Debug().Msg("fetching filter identifiers") | |
| 166 | + | ||
| 167 | + | // Use a channel to receive the async result | |
| 168 | + | resultCh := make(chan FetchIdentifiersResult, 1) | |
| 169 | + | ||
| 170 | + | // Create async callback | |
| 171 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |
| 172 | + | if resPtr == 0 { | |
| 173 | + | resultCh <- FetchIdentifiersResult{Err: fmt.Errorf("nil async result")} | |
| 174 | + | return | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |
| 178 | + | identifiers := s.inner.FetchIdentifiersFinish(res) | |
| 179 | + | resultCh <- FetchIdentifiersResult{Identifiers: identifiers} | |
| 180 | + | }) | |
| 181 | + | ||
| 182 | + | // Start async fetch | |
| 183 | + | s.inner.FetchIdentifiers(nil, &cb, 0) | |
| 184 | + | ||
| 185 | + | // Wait for result or context cancellation | |
| 186 | + | select { | |
| 187 | + | case result := <-resultCh: | |
| 188 | + | if result.Err != nil { | |
| 189 | + | log.Error().Err(result.Err).Msg("failed to fetch identifiers") | |
| 190 | + | return nil, result.Err | |
| 191 | + | } | |
| 192 | + | log.Debug().Int("count", len(result.Identifiers)).Msg("fetched filter identifiers") | |
| 193 | + | return result.Identifiers, nil | |
| 194 | + | case <-ctx.Done(): | |
| 195 | + | log.Warn().Msg("fetch identifiers cancelled") | |
| 196 | + | return nil, ctx.Err() | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Remove removes a compiled filter by its identifier. | |
| 201 | + | func (s *Store) Remove(ctx context.Context, identifier string) error { | |
| 202 | + | log := logging.FromContext(ctx).With(). | |
| 203 | + | Str("component", "filter-store"). | |
| 204 | + | Str("identifier", identifier). | |
| 205 | + | Logger() | |
| 206 | + | ||
| 207 | + | s.mu.Lock() | |
| 208 | + | defer s.mu.Unlock() | |
| 209 | + | ||
| 210 | + | log.Debug().Msg("removing filter") | |
| 211 | + | ||
| 212 | + | // Use a channel to receive the async result | |
| 213 | + | resultCh := make(chan error, 1) | |
| 214 | + | ||
| 215 | + | // Create async callback | |
| 216 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |
| 217 | + | if resPtr == 0 { | |
| 218 | + | resultCh <- fmt.Errorf("nil async result") | |
| 219 | + | return | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |
| 223 | + | _, err := s.inner.RemoveFinish(res) | |
| 224 | + | resultCh <- err | |
| 225 | + | }) | |
| 226 | + | ||
| 227 | + | // Start async remove | |
| 228 | + | s.inner.Remove(identifier, nil, &cb, 0) | |
| 229 | + | ||
| 230 | + | // Wait for result or context cancellation | |
| 231 | + | select { | |
| 232 | + | case err := <-resultCh: | |
| 233 | + | if err != nil { | |
| 234 | + | log.Error().Err(err).Msg("failed to remove filter") | |
| 235 | + | return err | |
| 236 | + | } | |
| 237 | + | log.Info().Msg("filter removed") | |
| 238 | + | return nil | |
| 239 | + | case <-ctx.Done(): | |
| 240 | + | log.Warn().Msg("remove filter cancelled") | |
| 241 | + | return ctx.Err() | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | // HasCompiledFilter checks if a compiled filter exists for the given identifier. | |
| 246 | + | func (s *Store) HasCompiledFilter(ctx context.Context, identifier string) bool { | |
| 247 | + | identifiers, err := s.FetchIdentifiers(ctx) | |
| 248 | + | if err != nil { | |
| 249 | + | return false | |
| 250 | + | } | |
| 251 | + | for _, id := range identifiers { | |
| 252 | + | if id == identifier { | |
| 253 | + | return true | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | return false | |
| 257 | + | } | |
| @@ -0,0 +1,257 @@ | |||||
| 1 | + | package filtering | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "sync" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |||
| 9 | + | "github.com/bnema/puregotk-webkit/webkit" | |||
| 10 | + | "github.com/jwijenbergh/puregotk/v4/gio" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Store wraps webkit.UserContentFilterStore with Go-friendly async operations. | |||
| 14 | + | type Store struct { | |||
| 15 | + | inner *webkit.UserContentFilterStore | |||
| 16 | + | path string | |||
| 17 | + | mu sync.Mutex | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // NewStore creates a new Store at the given path. | |||
| 21 | + | // The path is where WebKit will store compiled filter bytecode. | |||
| 22 | + | func NewStore(storagePath string) *Store { | |||
| 23 | + | inner := webkit.NewUserContentFilterStore(storagePath) | |||
| 24 | + | if inner == nil { | |||
| 25 | + | return nil | |||
| 26 | + | } | |||
| 27 | + | return &Store{ | |||
| 28 | + | inner: inner, | |||
| 29 | + | path: storagePath, | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // Path returns the storage path for compiled filters. | |||
| 34 | + | func (s *Store) Path() string { | |||
| 35 | + | return s.path | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // CompileResult holds the result of a filter compilation. | |||
| 39 | + | type CompileResult struct { | |||
| 40 | + | Filter *webkit.UserContentFilter | |||
| 41 | + | Err error | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Compile compiles a JSON filter file and stores it with the given identifier. | |||
| 45 | + | // This is an async operation that may take several seconds for large filter sets. | |||
| 46 | + | // The JSON file must be in Safari Content Blocker format. | |||
| 47 | + | func (s *Store) Compile(ctx context.Context, identifier string, jsonPath string) (*webkit.UserContentFilter, error) { | |||
| 48 | + | log := logging.FromContext(ctx).With(). | |||
| 49 | + | Str("component", "filter-store"). | |||
| 50 | + | Str("identifier", identifier). | |||
| 51 | + | Str("json_path", jsonPath). | |||
| 52 | + | Logger() | |||
| 53 | + | ||||
| 54 | + | s.mu.Lock() | |||
| 55 | + | defer s.mu.Unlock() | |||
| 56 | + | ||||
| 57 | + | // Create GFile from path | |||
| 58 | + | file := gio.FileNewForPath(jsonPath) | |||
| 59 | + | if file == nil { | |||
| 60 | + | return nil, fmt.Errorf("failed to create GFile for path: %s", jsonPath) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | log.Debug().Msg("starting filter compilation") | |||
| 64 | + | ||||
| 65 | + | // Use a channel to receive the async result | |||
| 66 | + | resultCh := make(chan CompileResult, 1) | |||
| 67 | + | ||||
| 68 | + | // Create async callback | |||
| 69 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |||
| 70 | + | if resPtr == 0 { | |||
| 71 | + | resultCh <- CompileResult{Err: fmt.Errorf("nil async result")} | |||
| 72 | + | return | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |||
| 76 | + | filter, err := s.inner.SaveFromFileFinish(res) | |||
| 77 | + | resultCh <- CompileResult{Filter: filter, Err: err} | |||
| 78 | + | }) | |||
| 79 | + | ||||
| 80 | + | // Start async compilation | |||
| 81 | + | s.inner.SaveFromFile(identifier, file, nil, &cb, 0) | |||
| 82 | + | ||||
| 83 | + | // Wait for result or context cancellation | |||
| 84 | + | select { | |||
| 85 | + | case result := <-resultCh: | |||
| 86 | + | if result.Err != nil { | |||
| 87 | + | log.Error().Err(result.Err).Msg("filter compilation failed") | |||
| 88 | + | return nil, result.Err | |||
| 89 | + | } | |||
| 90 | + | log.Info().Msg("filter compilation completed") | |||
| 91 | + | return result.Filter, nil | |||
| 92 | + | case <-ctx.Done(): | |||
| 93 | + | log.Warn().Msg("filter compilation cancelled") | |||
| 94 | + | return nil, ctx.Err() | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // LoadResult holds the result of loading a compiled filter. | |||
| 99 | + | type LoadResult struct { | |||
| 100 | + | Filter *webkit.UserContentFilter | |||
| 101 | + | Err error | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // Load loads a previously compiled filter by its identifier. | |||
| 105 | + | // This is fast (~50ms) as it loads pre-compiled bytecode. | |||
| 106 | + | func (s *Store) Load(ctx context.Context, identifier string) (*webkit.UserContentFilter, error) { | |||
| 107 | + | log := logging.FromContext(ctx).With(). | |||
| 108 | + | Str("component", "filter-store"). | |||
| 109 | + | Str("identifier", identifier). | |||
| 110 | + | Logger() | |||
| 111 | + | ||||
| 112 | + | s.mu.Lock() | |||
| 113 | + | defer s.mu.Unlock() | |||
| 114 | + | ||||
| 115 | + | log.Debug().Msg("loading compiled filter") | |||
| 116 | + | ||||
| 117 | + | // Use a channel to receive the async result | |||
| 118 | + | resultCh := make(chan LoadResult, 1) | |||
| 119 | + | ||||
| 120 | + | // Create async callback | |||
| 121 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |||
| 122 | + | if resPtr == 0 { | |||
| 123 | + | resultCh <- LoadResult{Err: fmt.Errorf("nil async result")} | |||
| 124 | + | return | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |||
| 128 | + | filter, err := s.inner.LoadFinish(res) | |||
| 129 | + | resultCh <- LoadResult{Filter: filter, Err: err} | |||
| 130 | + | }) | |||
| 131 | + | ||||
| 132 | + | // Start async load | |||
| 133 | + | s.inner.Load(identifier, nil, &cb, 0) | |||
| 134 | + | ||||
| 135 | + | // Wait for result or context cancellation | |||
| 136 | + | select { | |||
| 137 | + | case result := <-resultCh: | |||
| 138 | + | if result.Err != nil { | |||
| 139 | + | log.Debug().Err(result.Err).Msg("filter load failed (may not exist)") | |||
| 140 | + | return nil, result.Err | |||
| 141 | + | } | |||
| 142 | + | log.Debug().Msg("filter loaded successfully") | |||
| 143 | + | return result.Filter, nil | |||
| 144 | + | case <-ctx.Done(): | |||
| 145 | + | log.Warn().Msg("filter load cancelled") | |||
| 146 | + | return nil, ctx.Err() | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // FetchIdentifiersResult holds the result of fetching filter identifiers. | |||
| 151 | + | type FetchIdentifiersResult struct { | |||
| 152 | + | Identifiers []string | |||
| 153 | + | Err error | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | // FetchIdentifiers returns all stored filter identifiers. | |||
| 157 | + | func (s *Store) FetchIdentifiers(ctx context.Context) ([]string, error) { | |||
| 158 | + | log := logging.FromContext(ctx).With(). | |||
| 159 | + | Str("component", "filter-store"). | |||
| 160 | + | Logger() | |||
| 161 | + | ||||
| 162 | + | s.mu.Lock() | |||
| 163 | + | defer s.mu.Unlock() | |||
| 164 | + | ||||
| 165 | + | log.Debug().Msg("fetching filter identifiers") | |||
| 166 | + | ||||
| 167 | + | // Use a channel to receive the async result | |||
| 168 | + | resultCh := make(chan FetchIdentifiersResult, 1) | |||
| 169 | + | ||||
| 170 | + | // Create async callback | |||
| 171 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |||
| 172 | + | if resPtr == 0 { | |||
| 173 | + | resultCh <- FetchIdentifiersResult{Err: fmt.Errorf("nil async result")} | |||
| 174 | + | return | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |||
| 178 | + | identifiers := s.inner.FetchIdentifiersFinish(res) | |||
| 179 | + | resultCh <- FetchIdentifiersResult{Identifiers: identifiers} | |||
| 180 | + | }) | |||
| 181 | + | ||||
| 182 | + | // Start async fetch | |||
| 183 | + | s.inner.FetchIdentifiers(nil, &cb, 0) | |||
| 184 | + | ||||
| 185 | + | // Wait for result or context cancellation | |||
| 186 | + | select { | |||
| 187 | + | case result := <-resultCh: | |||
| 188 | + | if result.Err != nil { | |||
| 189 | + | log.Error().Err(result.Err).Msg("failed to fetch identifiers") | |||
| 190 | + | return nil, result.Err | |||
| 191 | + | } | |||
| 192 | + | log.Debug().Int("count", len(result.Identifiers)).Msg("fetched filter identifiers") | |||
| 193 | + | return result.Identifiers, nil | |||
| 194 | + | case <-ctx.Done(): | |||
| 195 | + | log.Warn().Msg("fetch identifiers cancelled") | |||
| 196 | + | return nil, ctx.Err() | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // Remove removes a compiled filter by its identifier. | |||
| 201 | + | func (s *Store) Remove(ctx context.Context, identifier string) error { | |||
| 202 | + | log := logging.FromContext(ctx).With(). | |||
| 203 | + | Str("component", "filter-store"). | |||
| 204 | + | Str("identifier", identifier). | |||
| 205 | + | Logger() | |||
| 206 | + | ||||
| 207 | + | s.mu.Lock() | |||
| 208 | + | defer s.mu.Unlock() | |||
| 209 | + | ||||
| 210 | + | log.Debug().Msg("removing filter") | |||
| 211 | + | ||||
| 212 | + | // Use a channel to receive the async result | |||
| 213 | + | resultCh := make(chan error, 1) | |||
| 214 | + | ||||
| 215 | + | // Create async callback | |||
| 216 | + | cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) { | |||
| 217 | + | if resPtr == 0 { | |||
| 218 | + | resultCh <- fmt.Errorf("nil async result") | |||
| 219 | + | return | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | res := &gio.AsyncResultBase{Ptr: resPtr} | |||
| 223 | + | _, err := s.inner.RemoveFinish(res) | |||
| 224 | + | resultCh <- err | |||
| 225 | + | }) | |||
| 226 | + | ||||
| 227 | + | // Start async remove | |||
| 228 | + | s.inner.Remove(identifier, nil, &cb, 0) | |||
| 229 | + | ||||
| 230 | + | // Wait for result or context cancellation | |||
| 231 | + | select { | |||
| 232 | + | case err := <-resultCh: | |||
| 233 | + | if err != nil { | |||
| 234 | + | log.Error().Err(err).Msg("failed to remove filter") | |||
| 235 | + | return err | |||
| 236 | + | } | |||
| 237 | + | log.Info().Msg("filter removed") | |||
| 238 | + | return nil | |||
| 239 | + | case <-ctx.Done(): | |||
| 240 | + | log.Warn().Msg("remove filter cancelled") | |||
| 241 | + | return ctx.Err() | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // HasCompiledFilter checks if a compiled filter exists for the given identifier. | |||
| 246 | + | func (s *Store) HasCompiledFilter(ctx context.Context, identifier string) bool { | |||
| 247 | + | identifiers, err := s.FetchIdentifiers(ctx) | |||
| 248 | + | if err != nil { | |||
| 249 | + | return false | |||
| 250 | + | } | |||
| 251 | + | for _, id := range identifiers { | |||
| 252 | + | if id == identifier { | |||
| 253 | + | return true | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | return false | |||
| 257 | + | } | |||
A internal/infrastructure/filtering/types.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package filtering | |
| 2 | + | ||
| 3 | + | import "time" | |
| 4 | + | ||
| 5 | + | // FilterState represents the current state of the content filter system. | |
| 6 | + | type FilterState string | |
| 7 | + | ||
| 8 | + | const ( | |
| 9 | + | // StateUninitialized means the filter manager has not been initialized yet. | |
| 10 | + | StateUninitialized FilterState = "uninitialized" | |
| 11 | + | // StateLoading means filters are being downloaded or compiled. | |
| 12 | + | StateLoading FilterState = "loading" | |
| 13 | + | // StateActive means filters are loaded and active. | |
| 14 | + | StateActive FilterState = "active" | |
| 15 | + | // StateDisabled means filtering is disabled by configuration. | |
| 16 | + | StateDisabled FilterState = "disabled" | |
| 17 | + | // StateError means an error occurred during filter loading. | |
| 18 | + | StateError FilterState = "error" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // FilterStatus represents the current status of the content filter system. | |
| 22 | + | // Used for UI toast notifications and status reporting. | |
| 23 | + | type FilterStatus struct { | |
| 24 | + | State FilterState | |
| 25 | + | Message string | |
| 26 | + | Version string | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // Manifest represents the manifest.json from ublock-webkit-filters releases. | |
| 30 | + | // It contains metadata about the filter lists and their rule counts. | |
| 31 | + | type Manifest struct { | |
| 32 | + | Version string `json:"version"` | |
| 33 | + | GeneratedAt time.Time `json:"generated_at"` | |
| 34 | + | Lists map[string]ListInfo `json:"lists"` | |
| 35 | + | Combined CombinedInfo `json:"combined"` | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // ListInfo contains metadata for a single filter list. | |
| 39 | + | type ListInfo struct { | |
| 40 | + | Name string `json:"name"` | |
| 41 | + | SourceURL string `json:"source_url"` | |
| 42 | + | RulesCount int `json:"rules_count"` | |
| 43 | + | SkippedCount int `json:"skipped_count"` | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // CombinedInfo contains metadata for the combined (merged) filter set. | |
| 47 | + | type CombinedInfo struct { | |
| 48 | + | TotalRules int `json:"total_rules"` | |
| 49 | + | Files []string `json:"files"` | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // FilterFiles defines the files to download from GitHub releases. | |
| 53 | + | var FilterFiles = struct { | |
| 54 | + | Manifest string | |
| 55 | + | Combined []string | |
| 56 | + | }{ | |
| 57 | + | Manifest: "manifest.json", | |
| 58 | + | Combined: []string{ | |
| 59 | + | "combined-part1.json", | |
| 60 | + | "combined-part2.json", | |
| 61 | + | "combined-part3.json", | |
| 62 | + | }, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | // FilterIdentifier is the identifier used when storing/loading compiled filters. | |
| 66 | + | const FilterIdentifier = "ublock-combined" | |
| 67 | + | ||
| 68 | + | // GitHubReleaseURL is the base URL for downloading filter files. | |
| 69 | + | const GitHubReleaseURL = "https://github.com/bnema/ublock-webkit-filters/releases/latest/download" | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package filtering | |||
| 2 | + | ||||
| 3 | + | import "time" | |||
| 4 | + | ||||
| 5 | + | // FilterState represents the current state of the content filter system. | |||
| 6 | + | type FilterState string | |||
| 7 | + | ||||
| 8 | + | const ( | |||
| 9 | + | // StateUninitialized means the filter manager has not been initialized yet. | |||
| 10 | + | StateUninitialized FilterState = "uninitialized" | |||
| 11 | + | // StateLoading means filters are being downloaded or compiled. | |||
| 12 | + | StateLoading FilterState = "loading" | |||
| 13 | + | // StateActive means filters are loaded and active. | |||
| 14 | + | StateActive FilterState = "active" | |||
| 15 | + | // StateDisabled means filtering is disabled by configuration. | |||
| 16 | + | StateDisabled FilterState = "disabled" | |||
| 17 | + | // StateError means an error occurred during filter loading. | |||
| 18 | + | StateError FilterState = "error" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // FilterStatus represents the current status of the content filter system. | |||
| 22 | + | // Used for UI toast notifications and status reporting. | |||
| 23 | + | type FilterStatus struct { | |||
| 24 | + | State FilterState | |||
| 25 | + | Message string | |||
| 26 | + | Version string | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // Manifest represents the manifest.json from ublock-webkit-filters releases. | |||
| 30 | + | // It contains metadata about the filter lists and their rule counts. | |||
| 31 | + | type Manifest struct { | |||
| 32 | + | Version string `json:"version"` | |||
| 33 | + | GeneratedAt time.Time `json:"generated_at"` | |||
| 34 | + | Lists map[string]ListInfo `json:"lists"` | |||
| 35 | + | Combined CombinedInfo `json:"combined"` | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // ListInfo contains metadata for a single filter list. | |||
| 39 | + | type ListInfo struct { | |||
| 40 | + | Name string `json:"name"` | |||
| 41 | + | SourceURL string `json:"source_url"` | |||
| 42 | + | RulesCount int `json:"rules_count"` | |||
| 43 | + | SkippedCount int `json:"skipped_count"` | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // CombinedInfo contains metadata for the combined (merged) filter set. | |||
| 47 | + | type CombinedInfo struct { | |||
| 48 | + | TotalRules int `json:"total_rules"` | |||
| 49 | + | Files []string `json:"files"` | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // FilterFiles defines the files to download from GitHub releases. | |||
| 53 | + | var FilterFiles = struct { | |||
| 54 | + | Manifest string | |||
| 55 | + | Combined []string | |||
| 56 | + | }{ | |||
| 57 | + | Manifest: "manifest.json", | |||
| 58 | + | Combined: []string{ | |||
| 59 | + | "combined-part1.json", | |||
| 60 | + | "combined-part2.json", | |||
| 61 | + | "combined-part3.json", | |||
| 62 | + | }, | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | // FilterIdentifier is the identifier used when storing/loading compiled filters. | |||
| 66 | + | const FilterIdentifier = "ublock-combined" | |||
| 67 | + | ||||
| 68 | + | // GitHubReleaseURL is the base URL for downloading filter files. | |||
| 69 | + | const GitHubReleaseURL = "https://github.com/bnema/ublock-webkit-filters/releases/latest/download" | |||