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