1package filtering
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11 "sync"
12 "sync/atomic"
13 "time"
14
15 "github.com/bnema/dumber/internal/application/port"
16 "github.com/bnema/dumber/internal/logging"
17 "github.com/bnema/puregotk/v4/webkit"
18)
19
20// Compile-time check that Manager satisfies port.FilterManager.
21var _ port.FilterManager = (*Manager)(nil)
22
23const (
24 storeDirPerm = 0o755
25 jsonDirPerm = 0o755
26 quarantinePerm = 0o755
27 mergedFilePerm = 0o644
28
29 // CacheMaxAge is the maximum age of the filter cache before it's considered stale.
30 CacheMaxAge = 24 * time.Hour
31)
32
33// ErrUpdateSkipped indicates a filter update was intentionally skipped after a recoverable
34// update-path failure (e.g. invalid payload, merge failure, compile failure).
35var ErrUpdateSkipped = errors.New("filter update skipped")
36
37// Manager orchestrates the content filter lifecycle.
38// It handles downloading, compiling, loading, and applying filters to WebViews.
39type Manager struct {
40 store FilterStore
41 downloader FilterDownloader
42
43 storeDir string
44 jsonDir string
45
46 filters []*webkit.UserContentFilter
47 filterMu sync.RWMutex
48 status atomic.Value // FilterStatus
49 enabled bool
50 autoUpdate bool
51
52 // Callbacks for status updates (e.g., toast notifications)
53 onStatusChange func(FilterStatus)
54}
55
56// ManagerConfig holds configuration for the filter manager.
57type ManagerConfig struct {
58 StoreDir string // Where to store compiled filter bytecode
59 JSONDir string // Where to cache downloaded JSON files
60 Enabled bool // Whether filtering is enabled
61 AutoUpdate bool // Whether to auto-update filters
62
63 // Optional: custom implementations for testing
64 Store FilterStore // If nil, creates default Store
65 Downloader FilterDownloader // If nil, creates default Downloader
66}
67
68// NewManager creates a new filter Manager.
69func NewManager(cfg ManagerConfig) (*Manager, error) {
70 // Ensure directories exist
71 if err := os.MkdirAll(cfg.StoreDir, storeDirPerm); err != nil {
72 return nil, fmt.Errorf("failed to create store dir: %w", err)
73 }
74 if err := os.MkdirAll(cfg.JSONDir, jsonDirPerm); err != nil {
75 return nil, fmt.Errorf("failed to create json dir: %w", err)
76 }
77
78 // Use provided implementations or create defaults
79 store := cfg.Store
80 if store == nil {
81 newStore := NewStore(cfg.StoreDir)
82 if newStore == nil {
83 return nil, fmt.Errorf("failed to create filter store at %s", cfg.StoreDir)
84 }
85 store = newStore
86 }
87
88 downloader := cfg.Downloader
89 if downloader == nil {
90 downloader = NewDownloader(cfg.JSONDir)
91 }
92
93 m := &Manager{
94 store: store,
95 downloader: downloader,
96 storeDir: cfg.StoreDir,
97 jsonDir: cfg.JSONDir,
98 enabled: cfg.Enabled,
99 autoUpdate: cfg.AutoUpdate,
100 }
101
102 m.setStatus(FilterStatus{State: StateUninitialized})
103 return m, nil
104}
105
106// SetStatusCallback sets a callback for status changes (e.g., for toast notifications).
107func (m *Manager) SetStatusCallback(cb func(FilterStatus)) {
108 m.onStatusChange = cb
109}
110
111// setStatus updates the current status and notifies callback.
112func (m *Manager) setStatus(status FilterStatus) {
113 m.status.Store(status)
114 if m.onStatusChange != nil {
115 m.onStatusChange(status)
116 }
117}
118
119// Status returns the current filter status.
120func (m *Manager) Status() FilterStatus {
121 if s, ok := m.status.Load().(FilterStatus); ok {
122 return s
123 }
124 return FilterStatus{State: StateUninitialized}
125}
126
127// Initialize performs fast initialization.
128// This is called BEFORE GTK starts, so we cannot use GIO async operations.
129// Just sets up state - actual filter loading happens in LoadAsync after GTK starts.
130func (m *Manager) Initialize(ctx context.Context) error {
131 log := logging.FromContext(ctx).With().
132 Str("component", "filter-manager").
133 Logger()
134
135 if !m.enabled {
136 log.Info().Msg("content filtering disabled")
137 m.setStatus(FilterStatus{State: StateDisabled})
138 return nil
139 }
140
141 log.Debug().Msg("filter manager initialized (filters will load after GTK starts)")
142 m.setStatus(FilterStatus{State: StateUninitialized, Message: "Filters pending"})
143 return nil
144}
145
146// LoadAsync loads or downloads filters in the background.
147// Call this after the browser window is shown (GTK main loop running).
148// First tries to load from cache, then downloads if needed.
149func (m *Manager) LoadAsync(ctx context.Context) {
150 if !m.enabled {
151 return
152 }
153
154 go m.loadAsyncWorker(ctx)
155}
156
157func (m *Manager) loadAsyncWorker(ctx context.Context) {
158 log := logging.FromContext(ctx).With().
159 Str("component", "filter-manager").
160 Logger()
161
162 if m.hasActiveFilter() {
163 log.Debug().Msg("filters already loaded, skipping async load")
164 return
165 }
166
167 if m.loadFromCache(ctx) {
168 m.checkStaleCacheAndUpdate(ctx)
169 return
170 }
171
172 // If local JSON files exist on disk, compile from them directly instead
173 // of downloading. This respects auto_update=false on cold start with an
174 // empty compiled store and allows testing with patched local JSON.
175 if paths := m.downloader.GetCachedFilterPaths(); len(paths) > 0 {
176 log.Info().Int("files", len(paths)).Msg("compiling filters from local JSON (skipping download)")
177 filters, err := m.compileFilterParts(ctx, paths)
178 if err == nil {
179 version := m.setActiveFilters(filters, "Filters active")
180 log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters compiled from local JSON")
181 return
182 }
183 log.Warn().Err(err).Msg("failed to compile local JSON, falling back to download")
184 }
185
186 m.downloadCompileAndActivate(ctx)
187}
188
189func (m *Manager) loadFromCache(ctx context.Context) bool {
190 log := logging.FromContext(ctx).With().
191 Str("component", "filter-manager").
192 Logger()
193
194 m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
195
196 // Discover how many compiled parts exist via stored identifiers
197 identifiers, err := m.store.FetchIdentifiers(ctx)
198 if err != nil {
199 return false
200 }
201 var partIDs []string
202 for _, id := range identifiers {
203 if strings.HasPrefix(id, FilterIdentifierPrefix) {
204 partIDs = append(partIDs, id)
205 }
206 }
207 if len(partIDs) == 0 {
208 return false
209 }
210
211 log.Debug().Int("parts", len(partIDs)).Msg("found compiled filters, loading from cache")
212 var filters []*webkit.UserContentFilter
213 for _, id := range partIDs {
214 filter, loadErr := m.store.Load(ctx, id)
215 if loadErr != nil || filter == nil {
216 log.Warn().Err(loadErr).Str("id", id).Msg("failed to load cached filter part, will download")
217 return false
218 }
219 filters = append(filters, filter)
220 }
221
222 version := m.setActiveFilters(filters, "Filters active")
223 log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters loaded from cache")
224 return true
225}
226
227func (m *Manager) downloadCompileAndActivate(ctx context.Context) {
228 log := logging.FromContext(ctx).With().
229 Str("component", "filter-manager").
230 Logger()
231
232 paths, err := m.downloadFiltersWithProgress(ctx)
233 if err != nil {
234 log.Error().Err(err).Msg("failed to download filters")
235 m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
236 return
237 }
238
239 validateErr := m.validateDownloadedFiles(ctx, paths)
240 if validateErr != nil {
241 log.Error().Err(validateErr).Msg("downloaded filter validation failed")
242 m.setStatus(FilterStatus{State: StateError, Message: "Invalid downloaded filters"})
243 return
244 }
245
246 filters, err := m.compileFilterParts(ctx, paths)
247 if err != nil {
248 log.Error().Err(err).Msg("failed to compile filters")
249 m.handleCompilationFailure()
250 return
251 }
252
253 version := m.setActiveFilters(filters, "Filters active")
254 log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters compiled and active")
255}
256
257func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, error) {
258 m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
259 return m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
260 msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
261 m.setStatus(FilterStatus{State: StateLoading, Message: msg})
262 })
263}
264
265// compileFilterParts compiles each downloaded part file as a separate WebKit filter.
266// Each part gets a unique identifier (e.g., "ublock-combined-0", "ublock-combined-1").
267func (m *Manager) compileFilterParts(ctx context.Context, paths []string) ([]*webkit.UserContentFilter, error) {
268 log := logging.FromContext(ctx).With().
269 Str("component", "filter-manager").
270 Logger()
271
272 m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
273
274 filters := make([]*webkit.UserContentFilter, 0, len(paths))
275 for i, path := range paths {
276 identifier := fmt.Sprintf("%s-%d", FilterIdentifierPrefix, i)
277 log.Debug().Str("id", identifier).Str("path", path).Msg("compiling filter part")
278
279 filter, err := m.store.Compile(ctx, identifier, path)
280 if err != nil {
281 return nil, fmt.Errorf("compile %s: %w", identifier, err)
282 }
283 if filter == nil {
284 return nil, fmt.Errorf("compile %s returned nil filter", identifier)
285 }
286 filters = append(filters, filter)
287 }
288
289 return filters, nil
290}
291
292func (m *Manager) handleCompilationFailure() {
293 if m.hasActiveFilter() {
294 version := m.getCachedVersion()
295 m.setStatus(FilterStatus{
296 State: StateActive,
297 Message: "Filters active (update skipped)",
298 Version: version,
299 })
300 return
301 }
302 m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
303}
304
305func (m *Manager) setActiveFilters(filters []*webkit.UserContentFilter, message string) string {
306 m.filterMu.Lock()
307 m.filters = filters
308 m.filterMu.Unlock()
309
310 version := m.getCachedVersion()
311 m.setStatus(FilterStatus{
312 State: StateActive,
313 Message: message,
314 Version: version,
315 })
316 return version
317}
318
319// checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update.
320func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
321 if !m.downloader.IsCacheStale(CacheMaxAge) {
322 return
323 }
324 log := logging.FromContext(ctx)
325 log.Info().Msg("filter cache is stale, checking for updates in background")
326 go func() {
327 if err := m.CheckForUpdates(ctx); err != nil {
328 log.Warn().Err(err).Msg("background filter update check failed")
329 }
330 }()
331}
332
333// getCachedVersion returns the version from the cached manifest.
334func (m *Manager) getCachedVersion() string {
335 manifest, err := m.downloader.GetCachedManifest()
336 if err != nil || manifest == nil {
337 return "unknown"
338 }
339 return manifest.Version
340}
341
342// ApplyTo adds the active filters to a WebView's UserContentManager.
343// Safe to call even if filters are not yet loaded (no-op in that case).
344func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
345 log := logging.FromContext(ctx).With().
346 Str("component", "filter-manager").
347 Logger()
348
349 if ucm == nil {
350 return
351 }
352
353 m.filterMu.RLock()
354 filters := m.filters
355 m.filterMu.RUnlock()
356
357 if len(filters) > 0 {
358 for _, filter := range filters {
359 ucm.AddFilter(filter)
360 }
361 log.Debug().Int("parts", len(filters)).Msg("content filters applied to webview")
362 } else {
363 log.Debug().Msg("no filter available to apply (filters still loading?)")
364 }
365}
366
367// GetFilters returns the currently loaded filters, or nil if none.
368func (m *Manager) GetFilters() []*webkit.UserContentFilter {
369 m.filterMu.RLock()
370 defer m.filterMu.RUnlock()
371 return m.filters
372}
373
374// CheckForUpdates checks if newer filters are available and downloads them.
375// This should be called periodically in the background.
376func (m *Manager) CheckForUpdates(ctx context.Context) error {
377 if !m.enabled || !m.autoUpdate {
378 return nil
379 }
380
381 log := logging.FromContext(ctx).With().
382 Str("component", "filter-manager").
383 Logger()
384
385 needsUpdate, err := m.downloader.NeedsUpdate(ctx)
386 if err != nil {
387 log.Warn().Err(err).Msg("failed to check for filter updates")
388 return err
389 }
390
391 if !needsUpdate {
392 log.Debug().Msg("filters are up to date")
393 return nil
394 }
395
396 log.Info().Msg("filter update available, downloading")
397
398 // Download new filters
399 paths, err := m.downloader.DownloadFilters(ctx, nil)
400 if err != nil {
401 return fmt.Errorf("failed to download filter update: %w", err)
402 }
403 validateErr := m.validateDownloadedFiles(ctx, paths)
404 if validateErr != nil {
405 log.Warn().Err(validateErr).Msg("invalid downloaded filters, keeping existing active filter")
406 return fmt.Errorf("%w: validation failed: %w", ErrUpdateSkipped, validateErr)
407 }
408
409 filters, err := m.compileFilterParts(ctx, paths)
410 if err != nil {
411 log.Warn().Err(err).Msg("failed to compile updated filters, keeping existing active filter")
412 // Restore status if we still have an active filter
413 if m.hasActiveFilter() {
414 version := m.getCachedVersion()
415 m.setStatus(FilterStatus{
416 State: StateActive,
417 Message: "Filters active (update skipped)",
418 Version: version,
419 })
420 }
421 return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err)
422 }
423
424 version := m.setActiveFilters(filters, "Filters updated")
425 log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters updated successfully")
426
427 return nil
428}
429
430func (m *Manager) hasActiveFilter() bool {
431 m.filterMu.RLock()
432 defer m.filterMu.RUnlock()
433 return len(m.filters) > 0
434}
435
436func (m *Manager) validateDownloadedFiles(ctx context.Context, paths []string) error {
437 var validationErrs []error
438 var quarantineErrs []error
439
440 for _, path := range paths {
441 if err := validateRuleFile(path); err != nil {
442 if quarantineErr := m.quarantineInvalidFile(ctx, path, "rule_validation_failed"); quarantineErr != nil {
443 quarantineErrs = append(quarantineErrs, fmt.Errorf("file=%s: %w", path, quarantineErr))
444 }
445 validationErrs = append(validationErrs, fmt.Errorf("file=%s: %w", path, err))
446 }
447 }
448
449 if len(validationErrs) == 0 && len(quarantineErrs) == 0 {
450 return nil
451 }
452
453 var parts []string
454 if len(validationErrs) > 0 {
455 parts = append(parts, fmt.Sprintf("%d invalid file(s)", len(validationErrs)))
456 }
457 if len(quarantineErrs) > 0 {
458 parts = append(parts, fmt.Sprintf("%d quarantine failure(s)", len(quarantineErrs)))
459 }
460
461 return fmt.Errorf(
462 "downloaded filter validation failed (%s): %w",
463 strings.Join(parts, ", "),
464 errors.Join(append(validationErrs, quarantineErrs...)...),
465 )
466}
467
468func validateRuleFile(path string) error {
469 data, err := os.ReadFile(path)
470 if err != nil {
471 return fmt.Errorf("read failed: %w", err)
472 }
473
474 var rules []map[string]json.RawMessage
475 if err := json.Unmarshal(data, &rules); err != nil {
476 return fmt.Errorf("json parse failed: %w", err)
477 }
478 if len(rules) == 0 {
479 return fmt.Errorf("empty rule set")
480 }
481
482 for i, rule := range rules {
483 trigger, ok := rule["trigger"]
484 if !ok || len(trigger) == 0 || string(trigger) == "null" {
485 return fmt.Errorf("rule[%d] missing trigger", i)
486 }
487 action, ok := rule["action"]
488 if !ok || len(action) == 0 || string(action) == "null" {
489 return fmt.Errorf("rule[%d] missing action", i)
490 }
491 var actionObj struct {
492 Type string `json:"type"`
493 }
494 if err := json.Unmarshal(action, &actionObj); err != nil {
495 return fmt.Errorf("rule[%d] action parse failed: %w", i, err)
496 }
497 if actionObj.Type == "" {
498 return fmt.Errorf("rule[%d] action.type missing", i)
499 }
500 }
501
502 return nil
503}
504
505func (m *Manager) quarantineInvalidFile(ctx context.Context, srcPath, cause string) error {
506 log := logging.FromContext(ctx).With().
507 Str("component", "filter-manager").
508 Logger()
509
510 quarantineDir := filepath.Join(m.jsonDir, "quarantine")
511 if err := os.MkdirAll(quarantineDir, quarantinePerm); err != nil {
512 return fmt.Errorf("create quarantine dir failed: %w", err)
513 }
514
515 baseName := filepath.Base(srcPath)
516 quarantinePath := filepath.Join(
517 quarantineDir,
518 fmt.Sprintf("%d-%s", time.Now().UnixNano(), baseName),
519 )
520 if err := os.Rename(srcPath, quarantinePath); err != nil {
521 return fmt.Errorf("move to quarantine failed: %w", err)
522 }
523
524 log.Error().
525 Str("path", srcPath).
526 Str("quarantine_path", quarantinePath).
527 Str("cause", cause).
528 Msg("quarantined invalid filter file")
529
530 return nil
531}
532
533// Enable enables content filtering.
534func (m *Manager) Enable(ctx context.Context) {
535 log := logging.FromContext(ctx).With().
536 Str("component", "filter-manager").
537 Logger()
538
539 m.enabled = true
540 log.Info().Msg("content filtering enabled")
541
542 // Try to load filters if not already loaded
543 m.filterMu.RLock()
544 hasFilters := len(m.filters) > 0
545 m.filterMu.RUnlock()
546
547 if !hasFilters {
548 m.LoadAsync(ctx)
549 } else {
550 m.setStatus(FilterStatus{
551 State: StateActive,
552 Message: "Filters active",
553 Version: m.getCachedVersion(),
554 })
555 }
556}
557
558// Disable disables content filtering.
559func (m *Manager) Disable(ctx context.Context) {
560 log := logging.FromContext(ctx).With().
561 Str("component", "filter-manager").
562 Logger()
563
564 m.enabled = false
565 m.setStatus(FilterStatus{State: StateDisabled})
566 log.Info().Msg("content filtering disabled")
567}
568
569// IsEnabled returns whether content filtering is enabled.
570func (m *Manager) IsEnabled() bool {
571 return m.enabled
572}
573
574// Clear removes all compiled and cached filters.
575func (m *Manager) Clear(ctx context.Context) error {
576 log := logging.FromContext(ctx).With().
577 Str("component", "filter-manager").
578 Logger()
579
580 m.filterMu.Lock()
581 m.filters = nil
582 m.filterMu.Unlock()
583
584 // Remove all compiled filter parts
585 identifiers, err := m.store.FetchIdentifiers(ctx)
586 if err != nil {
587 log.Warn().Err(err).Msg("failed to fetch identifiers for cleanup")
588 }
589 for _, id := range identifiers {
590 if strings.HasPrefix(id, FilterIdentifierPrefix) {
591 if err := m.store.Remove(ctx, id); err != nil {
592 log.Warn().Err(err).Str("id", id).Msg("failed to remove compiled filter")
593 }
594 }
595 }
596
597 if err := m.downloader.ClearCache(); err != nil {
598 log.Warn().Err(err).Msg("failed to clear download cache")
599 }
600
601 m.setStatus(FilterStatus{State: StateUninitialized})
602 log.Info().Msg("filters cleared")
603 return nil
604}