main 5041353

favicon.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"slices"
8	"sync"
9	"time"
10
11	appport "github.com/bnema/dumber/internal/application/port"
12	"github.com/bnema/dumber/internal/domain/favicon"
13)
14
15var ErrFaviconMiss = appport.ErrFaviconMiss
16
17type ResolvePurpose = appport.FaviconResolvePurpose
18
19const (
20	ResolvePurposeUI         = appport.FaviconResolvePurposeUI
21	ResolvePurposeSystemview = appport.FaviconResolvePurposeSystemview
22	ResolvePurposeRefresh    = appport.FaviconResolvePurposeRefresh
23)
24
25type ResolveOptions = appport.FaviconResolveOptions
26type ResolvedFavicon = appport.ResolvedFavicon
27
28// SystemviewIconSize is the fixed PNG size served to internal systemview pages.
29const SystemviewIconSize = 32
30
31type faviconInvalidatorRegistry struct {
32	mu           sync.RWMutex
33	fallback     appport.FaviconInvalidators
34	invalidators []appport.FaviconInvalidator
35}
36
37func (r *faviconInvalidatorRegistry) Register(invalidator appport.FaviconInvalidator) {
38	if r == nil || invalidator == nil {
39		return
40	}
41	r.mu.Lock()
42	defer r.mu.Unlock()
43	r.invalidators = append(r.invalidators, invalidator)
44}
45
46func (r *faviconInvalidatorRegistry) InvalidateAll(ctx context.Context, key favicon.Key) error {
47	if r == nil {
48		return nil
49	}
50	r.mu.RLock()
51	fallback := r.fallback
52	invalidators := append([]appport.FaviconInvalidator(nil), r.invalidators...)
53	r.mu.RUnlock()
54	// The fallback invalidator owns pre-registered invalidation sets and is
55	// treated as fatal; dynamic invalidators below are best-effort and joined.
56	if fallback != nil {
57		if err := fallback.InvalidateAll(ctx, key); err != nil {
58			return err
59		}
60	}
61	var errs []error
62	for _, invalidator := range invalidators {
63		if err := invalidator.Invalidate(ctx, key); err != nil {
64			errs = append(errs, err)
65		}
66	}
67	return errors.Join(errs...)
68}
69
70type FaviconUseCase struct {
71	repo         appport.FaviconRepository
72	blobs        appport.FaviconBlobStore
73	fetcher      appport.FaviconFetcher
74	discoverer   appport.FaviconDiscoverer
75	converter    appport.FaviconImageConverter
76	scheduler    appport.FaviconRefreshScheduler
77	invalidators *faviconInvalidatorRegistry
78	now          func() time.Time
79	ttl          time.Duration
80	background   context.Context
81	bgCancel     context.CancelFunc
82	shared       *refreshCoordinator
83}
84
85type FaviconDeps struct {
86	Repository   appport.FaviconRepository
87	BlobStore    appport.FaviconBlobStore
88	Fetcher      appport.FaviconFetcher
89	Discoverer   appport.FaviconDiscoverer
90	Converter    appport.FaviconImageConverter
91	Scheduler    appport.FaviconRefreshScheduler
92	Invalidators appport.FaviconInvalidators
93	Now          func() time.Time
94	TTL          time.Duration
95	// Background scopes background refresh work to the application
96	// lifetime. The use case derives its own cancelable scope from it,
97	// aborted by Close; callers pass the owning context (nil defaults to
98	// context.Background with shutdown drain only).
99	Background context.Context
100}
101
102func NewFaviconUseCase(deps FaviconDeps) *FaviconUseCase {
103	if deps.Now == nil {
104		deps.Now = time.Now
105	}
106	if deps.TTL == 0 {
107		deps.TTL = favicon.DefaultTTL
108	}
109	background := deps.Background
110	if background == nil {
111		background = context.Background()
112	}
113	// Owned cancelable scope for background refresh: parent cancellation
114	// propagates, and Close aborts it before draining shared operations.
115	background, bgCancel := context.WithCancel(background)
116	registry := &faviconInvalidatorRegistry{}
117	if deps.Invalidators != nil {
118		registry.fallback = deps.Invalidators
119	}
120	return &FaviconUseCase{
121		repo:         deps.Repository,
122		blobs:        deps.BlobStore,
123		fetcher:      deps.Fetcher,
124		discoverer:   deps.Discoverer,
125		converter:    deps.Converter,
126		scheduler:    deps.Scheduler,
127		invalidators: registry,
128		now:          deps.Now,
129		ttl:          deps.TTL,
130		background:   background,
131		bgCancel:     bgCancel,
132		shared:       newRefreshCoordinator(),
133	}
134}
135
136func (uc *FaviconUseCase) RegisterFaviconInvalidator(invalidator appport.FaviconInvalidator) {
137	if uc == nil || uc.invalidators == nil || invalidator == nil {
138		return
139	}
140	uc.invalidators.Register(invalidator)
141}
142
143func (uc *FaviconUseCase) Resolve(
144	ctx context.Context,
145	rawURLOrDomain string,
146	size int,
147	options ResolveOptions,
148) (*appport.ResolvedFavicon, error) {
149	keys := favicon.Candidates(rawURLOrDomain)
150	if len(keys) == 0 {
151		return nil, ErrFaviconMiss
152	}
153
154	for _, key := range keys {
155		if err := ctx.Err(); err != nil {
156			return nil, err
157		}
158		meta, err := uc.repo.Get(ctx, key)
159		if err != nil {
160			return nil, err
161		}
162		if meta == nil {
163			repaired, repairErr := uc.repairOrphanBlobMetadata(ctx, key, rawURLOrDomain, size)
164			if repairErr != nil {
165				if !errors.Is(repairErr, ErrFaviconMiss) {
166					return nil, repairErr
167				}
168				continue
169			}
170			return repaired, nil
171		}
172		bytes, contentType, err := uc.readForSize(ctx, key, size)
173		if err != nil {
174			if !errors.Is(err, ErrFaviconMiss) {
175				return nil, err
176			}
177			continue
178		}
179		res := &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: meta}
180		if favicon.ShouldRefresh(meta, uc.now(), uc.ttl) && options.ScheduleBackgroundRefresh {
181			res.BackgroundRefreshScheduled = uc.scheduleRefresh(key, meta.PageURL)
182		}
183		return res, nil
184	}
185
186	if options.AllowBlockingRefresh {
187		if err := uc.refresh(ctx, rawURLOrDomain, true); err != nil {
188			return nil, err
189		}
190		return uc.Resolve(ctx, rawURLOrDomain, size, ResolveOptions{Purpose: options.Purpose})
191	}
192	if options.ScheduleBackgroundRefresh {
193		uc.scheduleRefresh(keys[0], rawURLOrDomain)
194	}
195	return nil, ErrFaviconMiss
196}
197
198func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*appport.ResolvedFavicon, error) {
199	if size != SystemviewIconSize {
200		return nil, ErrFaviconMiss
201	}
202	return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview})
203}
204
205func (uc *FaviconUseCase) repairOrphanBlobMetadata(
206	ctx context.Context,
207	key favicon.Key,
208	rawURLOrDomain string,
209	size int,
210) (*appport.ResolvedFavicon, error) {
211	bytes, contentType, err := uc.readForSize(ctx, key, size)
212	if err != nil {
213		return nil, err
214	}
215	if contentType == "" {
216		contentType = "image/png"
217	}
218	now := uc.now()
219	meta := favicon.Metadata{
220		Key:           key,
221		SourceURL:     rawURLOrDomain,
222		PageURL:       rawURLOrDomain,
223		Source:        favicon.SourceImported,
224		ContentHash:   favicon.Hash(bytes),
225		ContentType:   contentType,
226		UpdatedAt:     now,
227		LastCheckedAt: now,
228	}
229	if err := uc.repo.Upsert(ctx, meta); err != nil {
230		return nil, err
231	}
232	return &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: &meta}, nil
233}
234
235func (uc *FaviconUseCase) Observe(
236	ctx context.Context,
237	pageURL, iconURL string,
238	bytes []byte,
239	source favicon.Source,
240	contentType string,
241) (*favicon.Metadata, error) {
242	key, ok := favicon.CanonicalKey(pageURL)
243	if !ok || len(bytes) == 0 {
244		return nil, ErrFaviconMiss
245	}
246	return uc.observeKey(ctx, key, pageURL, iconURL, bytes, source, contentType)
247}
248
249func (uc *FaviconUseCase) observeKey(
250	ctx context.Context,
251	key favicon.Key,
252	pageURL, iconURL string,
253	bytes []byte,
254	source favicon.Source,
255	contentType string,
256) (*favicon.Metadata, error) {
257	if key == "" || len(bytes) == 0 {
258		return nil, ErrFaviconMiss
259	}
260	old, err := uc.repo.Get(ctx, key)
261	if err != nil {
262		return nil, err
263	}
264	now := uc.now()
265	if !favicon.HasContentChanged(old, bytes) {
266		if err := uc.repo.UpdateLastChecked(ctx, key, old.ContentHash, now); err != nil {
267			return nil, err
268		}
269		old.LastCheckedAt = now
270		return old, nil
271	}
272
273	var converted *appport.ConvertedFavicon
274	if uc.converter != nil {
275		var err error
276		converted, err = uc.converter.Convert(ctx, bytes, contentType, []int{SystemviewIconSize})
277		if err != nil {
278			return nil, err
279		}
280	}
281
282	if err := uc.blobs.WriteOriginal(ctx, key, bytes, contentType); err != nil {
283		return nil, err
284	}
285	if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
286		return nil, err
287	}
288	if converted != nil {
289		if len(converted.PNG) > 0 {
290			if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
291				return nil, err
292			}
293		}
294		if b := converted.SizedPNG[SystemviewIconSize]; len(b) > 0 {
295			if err := uc.blobs.WriteSizedPNG(ctx, key, SystemviewIconSize, b); err != nil {
296				return nil, err
297			}
298		}
299	}
300	meta := favicon.Metadata{
301		Key:           key,
302		SourceURL:     iconURL,
303		PageURL:       pageURL,
304		Source:        source,
305		ContentHash:   favicon.Hash(bytes),
306		ContentType:   contentType,
307		UpdatedAt:     now,
308		LastCheckedAt: now,
309	}
310	if err := uc.repo.Upsert(ctx, meta); err != nil {
311		return nil, err
312	}
313	if err := uc.invalidateCaches(ctx, key); err != nil {
314		return nil, err
315	}
316	return &meta, nil
317}
318
319func (uc *FaviconUseCase) RefreshIfStale(ctx context.Context, pageURL string) error {
320	return uc.refresh(ctx, pageURL, false)
321}
322
323func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error {
324	if uc.fetcher == nil {
325		return ErrFaviconMiss
326	}
327	key, candidates, ok := refreshRequestKey(pageURL, iconURLs)
328	if !ok {
329		return ErrFaviconMiss
330	}
331	// Sealed admission wins over a cached absence: one atomic check so a
332	// refresh arriving after Close reports shutdown, never a stale miss.
333	if closed, miss := uc.sealedOrMiss(key); closed {
334		return appport.ErrFaviconShutdown
335	} else if miss {
336		return ErrFaviconMiss
337	}
338	return uc.joinRefreshOp(ctx, key, func(opCtx context.Context, startEpoch uint64) error {
339		var lastMiss error
340		// allMissesCacheable tracks whether EVERY attempted candidate
341		// failed with a cacheable genuine absence (verified 404/410).
342		// A single non-cacheable miss (401/403, 5xx, validation,
343		// transport) poisons the batch: the next call must retry the
344		// transient candidate instead of reading a cached absence.
345		allMissesCacheable := true
346		for _, iconURL := range candidates {
347			if err := opCtx.Err(); err != nil {
348				return err
349			}
350			fetched, err := uc.fetcher.Fetch(opCtx, appport.FaviconFetchRequest{PageURL: pageURL, IconURL: iconURL})
351			if err != nil {
352				if status, classified := appport.FetchStatusCode(err); classified {
353					if status != 404 && status != 410 {
354						allMissesCacheable = false
355					}
356					lastMiss = err
357					continue
358				}
359				if errors.Is(err, ErrFaviconMiss) {
360					allMissesCacheable = false
361					lastMiss = err
362					continue
363				}
364				return err
365			}
366			if uc.epochValue() != startEpoch {
367				return nil
368			}
369			err = uc.observeFetched(opCtx, pageURL, fetched)
370			if errors.Is(err, ErrFaviconMiss) {
371				allMissesCacheable = false
372				lastMiss = err
373				continue
374			}
375			if err == nil {
376				// Success contradicts any earlier absence record for
377				// this request: drop it instead of letting it linger.
378				uc.clearMiss(key)
379			}
380			return err
381		}
382		if lastMiss != nil {
383			// Only a fully missed request populates the negative cache:
384			// recording per candidate would poison later candidates
385			// that were never tried, and a mixed batch must never
386			// suppress retry of its transient candidates.
387			if allMissesCacheable {
388				uc.noteMiss(key, lastMiss)
389			}
390			return lastMiss
391		}
392		if uc.epochValue() != startEpoch {
393			return nil
394		}
395		return uc.RefreshIfStale(opCtx, pageURL)
396	})
397}
398
399func (uc *FaviconUseCase) refresh(ctx context.Context, pageURL string, force bool) error {
400	keys := favicon.Candidates(pageURL)
401	if len(keys) == 0 {
402		return ErrFaviconMiss
403	}
404	if !force {
405		fresh, err := uc.anyCandidateFresh(ctx, keys)
406		if err != nil {
407			return err
408		}
409		if fresh {
410			return nil
411		}
412	}
413	return uc.fetchAndObserve(ctx, pageURL)
414}
415
416func (uc *FaviconUseCase) refreshKey(ctx context.Context, key favicon.Key, pageURL string) error {
417	meta, err := uc.repo.Get(ctx, key)
418	if err != nil {
419		return err
420	}
421	if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
422		return nil
423	}
424	// Scheduler-backed work joins the same admission budget as direct
425	// refreshes; discovery has no upfront candidates, so it shares the
426	// page-scoped discovery identity.
427	discoveryKey, _, ok := refreshRequestKey(pageURL, nil)
428	if !ok {
429		return ErrFaviconMiss
430	}
431	return uc.joinRefreshOp(ctx, discoveryKey, func(opCtx context.Context, _ uint64) error {
432		return uc.fetchAndObserve(opCtx, pageURL)
433	})
434}
435
436func (uc *FaviconUseCase) anyCandidateFresh(ctx context.Context, keys []favicon.Key) (bool, error) {
437	for _, key := range keys {
438		meta, err := uc.repo.Get(ctx, key)
439		if err != nil {
440			return false, err
441		}
442		if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
443			return true, nil
444		}
445	}
446	return false, nil
447}
448
449func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) error {
450	if uc.fetcher == nil {
451		return ErrFaviconMiss
452	}
453	fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL})
454	if err != nil {
455		return err
456	}
457	err = uc.observeFetched(ctx, pageURL, fetched)
458	return err
459}
460
461func (uc *FaviconUseCase) observeFetched(ctx context.Context, requestedPageURL string, fetched *appport.FaviconFetchedIcon) error {
462	if fetched == nil {
463		return ErrFaviconMiss
464	}
465	if fetched.ResolvedKey != "" {
466		allowed := slices.Contains(favicon.Candidates(requestedPageURL), fetched.ResolvedKey)
467		if !allowed {
468			return ErrFaviconMiss
469		}
470		_, err := uc.observeKey(ctx, fetched.ResolvedKey, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
471		return err
472	}
473	_, err := uc.Observe(ctx, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
474	return err
475}
476
477func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size int) error {
478	if _, _, err := uc.blobs.ReadSizedPNG(ctx, key, size); err == nil {
479		return nil
480	} else if !errors.Is(err, ErrFaviconMiss) {
481		return err
482	}
483	orig, ct, err := uc.blobs.ReadOriginal(ctx, key)
484	if err != nil {
485		return err
486	}
487	if uc.converter == nil {
488		return ErrFaviconMiss
489	}
490	converted, err := uc.converter.Convert(ctx, orig, ct, []int{size})
491	if err != nil {
492		return err
493	}
494	if len(converted.PNG) > 0 {
495		if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
496			return err
497		}
498	}
499	if b := converted.SizedPNG[size]; len(b) > 0 {
500		return uc.blobs.WriteSizedPNG(ctx, key, size, b)
501	}
502	return ErrFaviconMiss
503}
504
505func (uc *FaviconUseCase) Invalidate(ctx context.Context, key favicon.Key) error {
506	// Invalidation advances the epoch so in-flight completions that have not
507	// yet passed their pre-commit epoch check cannot repopulate the cleared
508	// entries. This is best-effort suppression, not a transactional commit:
509	// a completion that already passed its check may still write after the
510	// clear (a transient stale icon until the next refresh), because the
511	// check and the blob/repository writes are not atomic. Closing that
512	// residual window would require versioned commits in the repository
513	// layer and is out of scope for this stack.
514	uc.bumpEpoch()
515	if uc.blobs != nil {
516		if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
517			return err
518		}
519	}
520	return uc.invalidateCaches(ctx, key)
521}
522
523func (uc *FaviconUseCase) invalidateCaches(ctx context.Context, key favicon.Key) error {
524	if uc.invalidators != nil {
525		return uc.invalidators.InvalidateAll(ctx, key)
526	}
527	return nil
528}
529
530func (uc *FaviconUseCase) readForSize(ctx context.Context, key favicon.Key, size int) ([]byte, string, error) {
531	if size < 0 {
532		return nil, "", fmt.Errorf("invalid favicon size: %d", size)
533	}
534	if size > 0 {
535		if err := uc.EnsureSized(ctx, key, size); err != nil {
536			return nil, "", err
537		}
538		return uc.blobs.ReadSizedPNG(ctx, key, size)
539	}
540	return uc.blobs.ReadPNG(ctx, key)
541}
542
543func (uc *FaviconUseCase) scheduleRefresh(key favicon.Key, pageURL string) bool {
544	if uc.scheduler == nil {
545		return false
546	}
547	return uc.scheduler.Schedule(uc.background, key, func(ctx context.Context) { _ = uc.refreshKey(ctx, key, pageURL) })
548}
549
550// Close aborts owned background refresh work and drains shared in-flight
551// operations. Admission is sealed first so no refresh can start after the
552// drain observes zero active operations. It is safe to call on a nil
553// receiver and more than once.
554func (uc *FaviconUseCase) Close() {
555	if uc == nil {
556		return
557	}
558	uc.shared.mu.Lock()
559	uc.shared.closed = true
560	uc.shared.mu.Unlock()
561	if uc.bgCancel != nil {
562		uc.bgCancel()
563	}
564	uc.drainRefreshOps()
565}

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