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