v0.27.1 c78ce83

manager.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close