1package filtering
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sync"
10 "sync/atomic"
11 "time"
12
13 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/puregotk-webkit/webkit"
15)
16
17const (
18 storeDirPerm = 0o755
19 jsonDirPerm = 0o755
20 mergedFilePerm = 0o644
21
22 // CacheMaxAge is the maximum age of the filter cache before it's considered stale.
23 CacheMaxAge = 24 * time.Hour
24)
25
26// Manager orchestrates the content filter lifecycle.
27// It handles downloading, compiling, loading, and applying filters to WebViews.
28type Manager struct {
29 store FilterStore
30 downloader FilterDownloader
31
32 storeDir string
33 jsonDir string
34
35 filter *webkit.UserContentFilter
36 filterMu sync.RWMutex
37 status atomic.Value // FilterStatus
38 enabled bool
39 autoUpdate bool
40
41 // Callbacks for status updates (e.g., toast notifications)
42 onStatusChange func(FilterStatus)
43}
44
45// ManagerConfig holds configuration for the filter manager.
46type ManagerConfig struct {
47 StoreDir string // Where to store compiled filter bytecode
48 JSONDir string // Where to cache downloaded JSON files
49 Enabled bool // Whether filtering is enabled
50 AutoUpdate bool // Whether to auto-update filters
51
52 // Optional: custom implementations for testing
53 Store FilterStore // If nil, creates default Store
54 Downloader FilterDownloader // If nil, creates default Downloader
55}
56
57// NewManager creates a new filter Manager.
58func NewManager(cfg ManagerConfig) (*Manager, error) {
59 // Ensure directories exist
60 if err := os.MkdirAll(cfg.StoreDir, storeDirPerm); err != nil {
61 return nil, fmt.Errorf("failed to create store dir: %w", err)
62 }
63 if err := os.MkdirAll(cfg.JSONDir, jsonDirPerm); err != nil {
64 return nil, fmt.Errorf("failed to create json dir: %w", err)
65 }
66
67 // Use provided implementations or create defaults
68 store := cfg.Store
69 if store == nil {
70 newStore := NewStore(cfg.StoreDir)
71 if newStore == nil {
72 return nil, fmt.Errorf("failed to create filter store at %s", cfg.StoreDir)
73 }
74 store = newStore
75 }
76
77 downloader := cfg.Downloader
78 if downloader == nil {
79 downloader = NewDownloader(cfg.JSONDir)
80 }
81
82 m := &Manager{
83 store: store,
84 downloader: downloader,
85 storeDir: cfg.StoreDir,
86 jsonDir: cfg.JSONDir,
87 enabled: cfg.Enabled,
88 autoUpdate: cfg.AutoUpdate,
89 }
90
91 m.setStatus(FilterStatus{State: StateUninitialized})
92 return m, nil
93}
94
95// SetStatusCallback sets a callback for status changes (e.g., for toast notifications).
96func (m *Manager) SetStatusCallback(cb func(FilterStatus)) {
97 m.onStatusChange = cb
98}
99
100// setStatus updates the current status and notifies callback.
101func (m *Manager) setStatus(status FilterStatus) {
102 m.status.Store(status)
103 if m.onStatusChange != nil {
104 m.onStatusChange(status)
105 }
106}
107
108// Status returns the current filter status.
109func (m *Manager) Status() FilterStatus {
110 if s, ok := m.status.Load().(FilterStatus); ok {
111 return s
112 }
113 return FilterStatus{State: StateUninitialized}
114}
115
116// Initialize performs fast initialization.
117// This is called BEFORE GTK starts, so we cannot use GIO async operations.
118// Just sets up state - actual filter loading happens in LoadAsync after GTK starts.
119func (m *Manager) Initialize(ctx context.Context) error {
120 log := logging.FromContext(ctx).With().
121 Str("component", "filter-manager").
122 Logger()
123
124 if !m.enabled {
125 log.Info().Msg("content filtering disabled")
126 m.setStatus(FilterStatus{State: StateDisabled})
127 return nil
128 }
129
130 log.Debug().Msg("filter manager initialized (filters will load after GTK starts)")
131 m.setStatus(FilterStatus{State: StateUninitialized, Message: "Filters pending"})
132 return nil
133}
134
135// LoadAsync loads or downloads filters in the background.
136// Call this after the browser window is shown (GTK main loop running).
137// First tries to load from cache, then downloads if needed.
138func (m *Manager) LoadAsync(ctx context.Context) {
139 if !m.enabled {
140 return
141 }
142
143 go func() {
144 log := logging.FromContext(ctx).With().
145 Str("component", "filter-manager").
146 Logger()
147
148 // If already loaded, skip
149 m.filterMu.RLock()
150 if m.filter != nil {
151 m.filterMu.RUnlock()
152 log.Debug().Msg("filters already loaded, skipping async load")
153 return
154 }
155 m.filterMu.RUnlock()
156
157 // Try to load from cache first (fast path)
158 m.setStatus(FilterStatus{State: StateLoading, Message: "Loading filters..."})
159 if m.store.HasCompiledFilter(ctx, FilterIdentifier) {
160 log.Debug().Msg("found compiled filter, loading from cache")
161 filter, err := m.store.Load(ctx, FilterIdentifier)
162 if err == nil && filter != nil {
163 m.filterMu.Lock()
164 m.filter = filter
165 m.filterMu.Unlock()
166
167 version := m.getCachedVersion()
168 m.setStatus(FilterStatus{
169 State: StateActive,
170 Message: "Filters active",
171 Version: version,
172 })
173 log.Info().Str("version", version).Msg("filters loaded from cache")
174
175 m.checkStaleCacheAndUpdate(ctx)
176 return
177 }
178 log.Warn().Err(err).Msg("failed to load cached filter, will download")
179 }
180
181 // Download filters
182 m.setStatus(FilterStatus{State: StateLoading, Message: "Downloading filters..."})
183 paths, err := m.downloader.DownloadFilters(ctx, func(p DownloadProgress) {
184 msg := fmt.Sprintf("Downloading filters (%d/%d)...", p.Current, p.Total)
185 m.setStatus(FilterStatus{State: StateLoading, Message: msg})
186 })
187 if err != nil {
188 log.Error().Err(err).Msg("failed to download filters")
189 m.setStatus(FilterStatus{State: StateError, Message: "Download failed"})
190 return
191 }
192
193 m.setStatus(FilterStatus{State: StateLoading, Message: "Compiling filters..."})
194
195 // Merge JSON files into one for compilation
196 mergedPath, err := m.mergeJSONFiles(ctx, paths)
197 if err != nil {
198 log.Error().Err(err).Msg("failed to merge filter files")
199 m.setStatus(FilterStatus{State: StateError, Message: "Filter merge failed"})
200 return
201 }
202
203 // Compile the merged filter
204 filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
205 if err != nil {
206 log.Error().Err(err).Msg("failed to compile filters")
207 m.setStatus(FilterStatus{State: StateError, Message: "Compilation failed"})
208 return
209 }
210
211 m.filterMu.Lock()
212 m.filter = filter
213 m.filterMu.Unlock()
214
215 version := m.getCachedVersion()
216 m.setStatus(FilterStatus{
217 State: StateActive,
218 Message: "Filters active",
219 Version: version,
220 })
221 log.Info().Str("version", version).Msg("filters compiled and active")
222 }()
223}
224
225// mergeJSONFiles combines multiple JSON rule files into one.
226// WebKit's UserContentFilterStore expects a single JSON array.
227func (m *Manager) mergeJSONFiles(ctx context.Context, paths []string) (string, error) {
228 log := logging.FromContext(ctx).With().
229 Str("component", "filter-manager").
230 Logger()
231
232 var allRules []json.RawMessage
233
234 for _, path := range paths {
235 log.Debug().Str("path", path).Msg("reading filter file")
236
237 data, err := os.ReadFile(path)
238 if err != nil {
239 return "", fmt.Errorf("failed to read %s: %w", path, err)
240 }
241
242 var rules []json.RawMessage
243 if err := json.Unmarshal(data, &rules); err != nil {
244 return "", fmt.Errorf("failed to parse %s: %w", path, err)
245 }
246
247 allRules = append(allRules, rules...)
248 }
249
250 log.Debug().Int("total_rules", len(allRules)).Msg("merging filter rules")
251
252 merged, err := json.Marshal(allRules)
253 if err != nil {
254 return "", fmt.Errorf("failed to marshal merged rules: %w", err)
255 }
256
257 // Write merged file
258 mergedPath := filepath.Join(m.jsonDir, "merged.json")
259 if err := os.WriteFile(mergedPath, merged, mergedFilePerm); err != nil {
260 return "", fmt.Errorf("failed to write merged file: %w", err)
261 }
262
263 return mergedPath, nil
264}
265
266// checkStaleCacheAndUpdate checks if the cache is stale and triggers a background update.
267func (m *Manager) checkStaleCacheAndUpdate(ctx context.Context) {
268 if !m.downloader.IsCacheStale(CacheMaxAge) {
269 return
270 }
271 log := logging.FromContext(ctx)
272 log.Info().Msg("filter cache is stale, checking for updates in background")
273 go func() {
274 if err := m.CheckForUpdates(ctx); err != nil {
275 log.Warn().Err(err).Msg("background filter update check failed")
276 }
277 }()
278}
279
280// getCachedVersion returns the version from the cached manifest.
281func (m *Manager) getCachedVersion() string {
282 manifest, err := m.downloader.GetCachedManifest()
283 if err != nil || manifest == nil {
284 return "unknown"
285 }
286 return manifest.Version
287}
288
289// ApplyTo adds the active filter to a WebView's UserContentManager.
290// Safe to call even if filters are not yet loaded (no-op in that case).
291func (m *Manager) ApplyTo(ctx context.Context, ucm *webkit.UserContentManager) {
292 log := logging.FromContext(ctx).With().
293 Str("component", "filter-manager").
294 Logger()
295
296 if ucm == nil {
297 return
298 }
299
300 m.filterMu.RLock()
301 filter := m.filter
302 m.filterMu.RUnlock()
303
304 if filter != nil {
305 ucm.AddFilter(filter)
306 log.Debug().Msg("content filter applied to webview")
307 } else {
308 log.Debug().Msg("no filter available to apply (filters still loading?)")
309 }
310}
311
312// GetFilter returns the currently loaded filter, or nil if none.
313func (m *Manager) GetFilter() *webkit.UserContentFilter {
314 m.filterMu.RLock()
315 defer m.filterMu.RUnlock()
316 return m.filter
317}
318
319// CheckForUpdates checks if newer filters are available and downloads them.
320// This should be called periodically in the background.
321func (m *Manager) CheckForUpdates(ctx context.Context) error {
322 if !m.enabled || !m.autoUpdate {
323 return nil
324 }
325
326 log := logging.FromContext(ctx).With().
327 Str("component", "filter-manager").
328 Logger()
329
330 needsUpdate, err := m.downloader.NeedsUpdate(ctx)
331 if err != nil {
332 log.Warn().Err(err).Msg("failed to check for filter updates")
333 return err
334 }
335
336 if !needsUpdate {
337 log.Debug().Msg("filters are up to date")
338 return nil
339 }
340
341 log.Info().Msg("filter update available, downloading")
342
343 // Download new filters
344 paths, err := m.downloader.DownloadFilters(ctx, nil)
345 if err != nil {
346 return fmt.Errorf("failed to download filter update: %w", err)
347 }
348
349 // Merge and compile
350 mergedPath, err := m.mergeJSONFiles(ctx, paths)
351 if err != nil {
352 return fmt.Errorf("failed to merge updated filters: %w", err)
353 }
354
355 filter, err := m.store.Compile(ctx, FilterIdentifier, mergedPath)
356 if err != nil {
357 return fmt.Errorf("failed to compile updated filters: %w", err)
358 }
359
360 m.filterMu.Lock()
361 m.filter = filter
362 m.filterMu.Unlock()
363
364 version := m.getCachedVersion()
365 m.setStatus(FilterStatus{
366 State: StateActive,
367 Message: fmt.Sprintf("Filters updated to %s", version),
368 Version: version,
369 })
370 log.Info().Str("version", version).Msg("filters updated successfully")
371
372 return nil
373}
374
375// Enable enables content filtering.
376func (m *Manager) Enable(ctx context.Context) {
377 log := logging.FromContext(ctx).With().
378 Str("component", "filter-manager").
379 Logger()
380
381 m.enabled = true
382 log.Info().Msg("content filtering enabled")
383
384 // Try to load filters if not already loaded
385 m.filterMu.RLock()
386 hasFilter := m.filter != nil
387 m.filterMu.RUnlock()
388
389 if !hasFilter {
390 m.LoadAsync(ctx)
391 } else {
392 m.setStatus(FilterStatus{
393 State: StateActive,
394 Message: "Filters active",
395 Version: m.getCachedVersion(),
396 })
397 }
398}
399
400// Disable disables content filtering.
401func (m *Manager) Disable(ctx context.Context) {
402 log := logging.FromContext(ctx).With().
403 Str("component", "filter-manager").
404 Logger()
405
406 m.enabled = false
407 m.setStatus(FilterStatus{State: StateDisabled})
408 log.Info().Msg("content filtering disabled")
409}
410
411// IsEnabled returns whether content filtering is enabled.
412func (m *Manager) IsEnabled() bool {
413 return m.enabled
414}
415
416// Clear removes all compiled and cached filters.
417func (m *Manager) Clear(ctx context.Context) error {
418 log := logging.FromContext(ctx).With().
419 Str("component", "filter-manager").
420 Logger()
421
422 m.filterMu.Lock()
423 m.filter = nil
424 m.filterMu.Unlock()
425
426 if err := m.store.Remove(ctx, FilterIdentifier); err != nil {
427 log.Warn().Err(err).Msg("failed to remove compiled filter")
428 }
429
430 if err := m.downloader.ClearCache(); err != nil {
431 log.Warn().Err(err).Msg("failed to clear download cache")
432 }
433
434 m.setStatus(FilterStatus{State: StateUninitialized})
435 log.Info().Msg("filters cleared")
436 return nil
437}