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 partIDs = m.compiledIDsForCachedManifest(partIDs)
212 if len(partIDs) == 0 {
213 return false
214 }
215
216 log.Debug().Int("parts", len(partIDs)).Msg("found compiled filters, loading from cache")
217 var filters []*webkit.UserContentFilter
218 for _, id := range partIDs {
219 filter, loadErr := m.store.Load(ctx, id)
220 if loadErr != nil || filter == nil {
221 log.Warn().Err(loadErr).Str("id", id).Msg("failed to load cached filter part, will download")
222 return false
223 }
224 filters = append(filters, filter)
225 }
226
227 version := m.setActiveFilters(filters, "Filters active")
228 log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters loaded from cache")
229 return true
230}
231
232func (m *Manager) downloadCompileAndActivate(ctx context.Context) {
233 log := logging.FromContext(ctx).With().
234 Str("component", "filter-manager").
235 Logger()
236
237 paths, err := m.downloadFiltersWithProgress(ctx)
238 if err != nil {
239 log.Error().Err(err).Msg("failed to download filters")
240 m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
241 return
242 }
243
244 validateErr := m.validateDownloadedFiles(ctx, paths)
245 if validateErr != nil {
246 log.Error().Err(validateErr).Msg("downloaded filter validation failed")
247 m.setStatus(FilterStatus{State: StateError, Message: "Invalid downloaded filters"})
248 return
249 }
250
251 filters, err := m.compileFilterParts(ctx, paths)
252 if err != nil {
253 log.Error().Err(err).Msg("failed to compile filters")
254 m.handleCompilationFailure()
255 return
256 }
257
258 version := m.setActiveFilters(filters, "Filters active")
259 log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters compiled and active")
260}
261
262func (m *Manager) downloadFiltersWithProgress(ctx context.Context) ([]string, error) {
263 m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
264 return m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
265 msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
266 m.setStatus(FilterStatus{State: StateLoading, Message: msg})
267 })
268}
269
270// compileFilterParts compiles each downloaded part file as a separate WebKit filter.
271// Each part gets a unique identifier (e.g., "ublock-combined-0", "ublock-combined-1").
272func (m *Manager) compileFilterParts(ctx context.Context, paths []string) ([]*webkit.UserContentFilter, error) {
273 log := logging.FromContext(ctx).With().
274 Str("component", "filter-manager").
275 Logger()
276
277 m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
278
279 filters := make([]*webkit.UserContentFilter, 0, len(paths))
280 for i, path := range paths {
281 identifier := fmt.Sprintf("%s-%d", FilterIdentifierPrefix, i)
282 log.Debug().Str("id", identifier).Str("path", path).Msg("compiling filter part")
283
284 filter, err := m.store.Compile(ctx, identifier, path)
285 if err != nil {
286 return nil, fmt.Errorf("compile %s: %w", identifier, err)
287 }
288 if filter == nil {
289 return nil, fmt.Errorf("compile %s returned nil filter", identifier)
290 }
291 filters = append(filters, filter)
292 }
293
294 return filters, nil
295}
296
297func (m *Manager) handleCompilationFailure() {
298 if m.hasActiveFilter() {
299 version := m.getCachedVersion()
300 m.setStatus(FilterStatus{
301 State: StateActive,
302 Message: "Filters active (update skipped)",
303 Version: version,
304 })
305 return
306 }
307 m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
308}
309
310func (m *Manager) setActiveFilters(filters []*webkit.UserContentFilter, message string) string {
311 m.filterMu.Lock()
312 m.filters = filters
313 m.filterMu.Unlock()
314
315 version := m.getCachedVersion()
316 m.setStatus(FilterStatus{
317 State: StateActive,
318 Message: message,
319 Version: version,
320 })
321 return version
322}
323
324func (m *Manager) compiledIDsForCachedManifest(storedIDs []string) []string {
325 manifest, err := m.downloader.GetCachedManifest()
326 if err != nil || manifest == nil || len(manifest.Combined.Files) == 0 {
327 return storedIDs
328 }
329
330 available := make(map[string]struct{}, len(storedIDs))
331 for _, id := range storedIDs {
332 available[id] = struct{}{}
333 }
334
335 expectedIDs := expectedCompiledFilterIDs(len(manifest.Combined.Files))
336 ids := make([]string, 0, len(expectedIDs))
337 for _, id := range expectedIDs {
338 if _, ok := available[id]; !ok {
339 return nil
340 }
341 ids = append(ids, id)
342 }
343 return ids
344}
345
346// checkStaleCacheAndUpdate checks for updates after loading cached filters.
347func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
348 log := logging.FromContext(ctx)
349 log.Info().Msg("checking for filter updates in background")
350 go func() {
351 if err := m.CheckForUpdates(ctx); err != nil {
352 log.Warn().Err(err).Msg("background filter update check failed")
353 }
354 }()
355}
356
357func expectedCompiledFilterIDs(count int) []string {
358 ids := make([]string, 0, count)
359 for i := range count {
360 ids = append(ids, fmt.Sprintf("%s-%d", FilterIdentifierPrefix, i))
361 }
362 return ids
363}
364
365func (m *Manager) pruneObsoleteCompiledFilters(ctx context.Context, expectedCount int) {
366 log := logging.FromContext(ctx).With().
367 Str("component", "filter-manager").
368 Logger()
369
370 identifiers, err := m.store.FetchIdentifiers(ctx)
371 if err != nil {
372 log.Warn().Err(err).Msg("failed to fetch compiled filter identifiers for pruning")
373 return
374 }
375
376 expected := make(map[string]struct{}, expectedCount)
377 for _, id := range expectedCompiledFilterIDs(expectedCount) {
378 expected[id] = struct{}{}
379 }
380
381 for _, id := range identifiers {
382 if !strings.HasPrefix(id, FilterIdentifierPrefix) {
383 continue
384 }
385 if _, ok := expected[id]; ok {
386 continue
387 }
388 if err := m.store.Remove(ctx, id); err != nil {
389 log.Warn().Err(err).Str("id", id).Msg("failed to remove obsolete compiled filter")
390 }
391 }
392}
393
394// getCachedVersion returns the version from the cached manifest.
395func (m *Manager) getCachedVersion() string {
396 manifest, err := m.downloader.GetCachedManifest()
397 if err != nil || manifest == nil {
398 return "unknown"
399 }
400 return manifest.Version
401}
402
403// ApplyTo adds the active filters to a WebView's UserContentManager.
404// Safe to call even if filters are not yet loaded (no-op in that case).
405func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
406 log := logging.FromContext(ctx).With().
407 Str("component", "filter-manager").
408 Logger()
409
410 if ucm == nil {
411 return
412 }
413
414 m.filterMu.RLock()
415 filters := m.filters
416 m.filterMu.RUnlock()
417
418 if len(filters) > 0 {
419 for _, filter := range filters {
420 ucm.AddFilter(filter)
421 }
422 log.Debug().Int("parts", len(filters)).Msg("content filters applied to webview")
423 } else {
424 log.Debug().Msg("no filter available to apply (filters still loading?)")
425 }
426}
427
428// GetFilters returns the currently loaded filters, or nil if none.
429func (m *Manager) GetFilters() []*webkit.UserContentFilter {
430 m.filterMu.RLock()
431 defer m.filterMu.RUnlock()
432 return m.filters
433}
434
435// CheckForUpdates checks if newer filters are available and downloads them.
436// This should be called periodically in the background.
437func (m *Manager) CheckForUpdates(ctx context.Context) error {
438 if !m.enabled || !m.autoUpdate {
439 return nil
440 }
441
442 log := logging.FromContext(ctx).With().
443 Str("component", "filter-manager").
444 Logger()
445
446 needsUpdate, err := m.downloader.NeedsUpdate(ctx)
447 if err != nil {
448 log.Warn().Err(err).Msg("failed to check for filter updates")
449 return err
450 }
451
452 if !needsUpdate {
453 log.Debug().Msg("filters are up to date")
454 return nil
455 }
456
457 log.Info().Msg("filter update available, downloading")
458
459 // Download new filters
460 paths, err := m.downloader.DownloadFilters(ctx, nil)
461 if err != nil {
462 return fmt.Errorf("failed to download filter update: %w", err)
463 }
464 validateErr := m.validateDownloadedFiles(ctx, paths)
465 if validateErr != nil {
466 log.Warn().Err(validateErr).Msg("invalid downloaded filters, keeping existing active filter")
467 return fmt.Errorf("%w: validation failed: %w", ErrUpdateSkipped, validateErr)
468 }
469
470 filters, err := m.compileFilterParts(ctx, paths)
471 if err != nil {
472 log.Warn().Err(err).Msg("failed to compile updated filters, keeping existing active filter")
473 // Restore status if we still have an active filter
474 if m.hasActiveFilter() {
475 version := m.getCachedVersion()
476 m.setStatus(FilterStatus{
477 State: StateActive,
478 Message: "Filters active (update skipped)",
479 Version: version,
480 })
481 }
482 return fmt.Errorf("%w: compile failed: %w", ErrUpdateSkipped, err)
483 }
484
485 m.pruneObsoleteCompiledFilters(ctx, len(paths))
486
487 version := m.setActiveFilters(filters, "Filters updated")
488 log.Info().Str("version", version).Int("parts", len(filters)).Msg("filters updated successfully")
489
490 return nil
491}
492
493func (m *Manager) hasActiveFilter() bool {
494 m.filterMu.RLock()
495 defer m.filterMu.RUnlock()
496 return len(m.filters) > 0
497}
498
499func (m *Manager) validateDownloadedFiles(ctx context.Context, paths []string) error {
500 var validationErrs []error
501 var quarantineErrs []error
502
503 for _, path := range paths {
504 if err := validateRuleFile(path); err != nil {
505 if quarantineErr := m.quarantineInvalidFile(ctx, path, "rule_validation_failed"); quarantineErr != nil {
506 quarantineErrs = append(quarantineErrs, fmt.Errorf("file=%s: %w", path, quarantineErr))
507 }
508 validationErrs = append(validationErrs, fmt.Errorf("file=%s: %w", path, err))
509 }
510 }
511
512 if len(validationErrs) == 0 && len(quarantineErrs) == 0 {
513 return nil
514 }
515
516 var parts []string
517 if len(validationErrs) > 0 {
518 parts = append(parts, fmt.Sprintf("%d invalid file(s)", len(validationErrs)))
519 }
520 if len(quarantineErrs) > 0 {
521 parts = append(parts, fmt.Sprintf("%d quarantine failure(s)", len(quarantineErrs)))
522 }
523
524 return fmt.Errorf(
525 "downloaded filter validation failed (%s): %w",
526 strings.Join(parts, ", "),
527 errors.Join(append(validationErrs, quarantineErrs...)...),
528 )
529}
530
531func validateRuleFile(path string) error {
532 data, err := os.ReadFile(path)
533 if err != nil {
534 return fmt.Errorf("read failed: %w", err)
535 }
536
537 var rules []map[string]json.RawMessage
538 if err := json.Unmarshal(data, &rules); err != nil {
539 return fmt.Errorf("json parse failed: %w", err)
540 }
541 if len(rules) == 0 {
542 return fmt.Errorf("empty rule set")
543 }
544
545 for i, rule := range rules {
546 trigger, ok := rule["trigger"]
547 if !ok || len(trigger) == 0 || string(trigger) == "null" {
548 return fmt.Errorf("rule[%d] missing trigger", i)
549 }
550 action, ok := rule["action"]
551 if !ok || len(action) == 0 || string(action) == "null" {
552 return fmt.Errorf("rule[%d] missing action", i)
553 }
554 var actionObj struct {
555 Type string `json:"type"`
556 }
557 if err := json.Unmarshal(action, &actionObj); err != nil {
558 return fmt.Errorf("rule[%d] action parse failed: %w", i, err)
559 }
560 if actionObj.Type == "" {
561 return fmt.Errorf("rule[%d] action.type missing", i)
562 }
563 }
564
565 return nil
566}
567
568func (m *Manager) quarantineInvalidFile(ctx context.Context, srcPath, cause string) error {
569 log := logging.FromContext(ctx).With().
570 Str("component", "filter-manager").
571 Logger()
572
573 quarantineDir := filepath.Join(m.jsonDir, "quarantine")
574 if err := os.MkdirAll(quarantineDir, quarantinePerm); err != nil {
575 return fmt.Errorf("create quarantine dir failed: %w", err)
576 }
577
578 baseName := filepath.Base(srcPath)
579 quarantinePath := filepath.Join(
580 quarantineDir,
581 fmt.Sprintf("%d-%s", time.Now().UnixNano(), baseName),
582 )
583 if err := os.Rename(srcPath, quarantinePath); err != nil {
584 return fmt.Errorf("move to quarantine failed: %w", err)
585 }
586
587 log.Error().
588 Str("path", srcPath).
589 Str("quarantine_path", quarantinePath).
590 Str("cause", cause).
591 Msg("quarantined invalid filter file")
592
593 return nil
594}
595
596// Enable enables content filtering.
597func (m *Manager) Enable(ctx context.Context) {
598 log := logging.FromContext(ctx).With().
599 Str("component", "filter-manager").
600 Logger()
601
602 m.enabled = true
603 log.Info().Msg("content filtering enabled")
604
605 // Try to load filters if not already loaded
606 m.filterMu.RLock()
607 hasFilters := len(m.filters) > 0
608 m.filterMu.RUnlock()
609
610 if !hasFilters {
611 m.LoadAsync(ctx)
612 } else {
613 m.setStatus(FilterStatus{
614 State: StateActive,
615 Message: "Filters active",
616 Version: m.getCachedVersion(),
617 })
618 }
619}
620
621// Disable disables content filtering.
622func (m *Manager) Disable(ctx context.Context) {
623 log := logging.FromContext(ctx).With().
624 Str("component", "filter-manager").
625 Logger()
626
627 m.enabled = false
628 m.setStatus(FilterStatus{State: StateDisabled})
629 log.Info().Msg("content filtering disabled")
630}
631
632// IsEnabled returns whether content filtering is enabled.
633func (m *Manager) IsEnabled() bool {
634 return m.enabled
635}
636
637// Clear removes all compiled and cached filters.
638func (m *Manager) Clear(ctx context.Context) error {
639 log := logging.FromContext(ctx).With().
640 Str("component", "filter-manager").
641 Logger()
642
643 m.filterMu.Lock()
644 m.filters = nil
645 m.filterMu.Unlock()
646
647 // Remove all compiled filter parts
648 identifiers, err := m.store.FetchIdentifiers(ctx)
649 if err != nil {
650 log.Warn().Err(err).Msg("failed to fetch identifiers for cleanup")
651 }
652 for _, id := range identifiers {
653 if strings.HasPrefix(id, FilterIdentifierPrefix) {
654 if err := m.store.Remove(ctx, id); err != nil {
655 log.Warn().Err(err).Str("id", id).Msg("failed to remove compiled filter")
656 }
657 }
658 }
659
660 if err := m.downloader.ClearCache(); err != nil {
661 log.Warn().Err(err).Msg("failed to clear download cache")
662 }
663
664 m.setStatus(FilterStatus{State: StateUninitialized})
665 log.Info().Msg("filters cleared")
666 return nil
667}