v0.31.0 bf0756b

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}
82
83type FaviconDeps struct {
84	Repository   appport.FaviconRepository
85	BlobStore    appport.FaviconBlobStore
86	Fetcher      appport.FaviconFetcher
87	Discoverer   appport.FaviconDiscoverer
88	Converter    appport.FaviconImageConverter
89	Scheduler    appport.FaviconRefreshScheduler
90	Invalidators appport.FaviconInvalidators
91	Now          func() time.Time
92	TTL          time.Duration
93	Background   context.Context
94}
95
96func NewFaviconUseCase(deps FaviconDeps) *FaviconUseCase {
97	if deps.Now == nil {
98		deps.Now = time.Now
99	}
100	if deps.TTL == 0 {
101		deps.TTL = favicon.DefaultTTL
102	}
103	if deps.Background == nil {
104		deps.Background = context.Background()
105	}
106	registry := &faviconInvalidatorRegistry{}
107	if deps.Invalidators != nil {
108		registry.fallback = deps.Invalidators
109	}
110	return &FaviconUseCase{
111		repo:         deps.Repository,
112		blobs:        deps.BlobStore,
113		fetcher:      deps.Fetcher,
114		discoverer:   deps.Discoverer,
115		converter:    deps.Converter,
116		scheduler:    deps.Scheduler,
117		invalidators: registry,
118		now:          deps.Now,
119		ttl:          deps.TTL,
120		background:   deps.Background,
121	}
122}
123
124func (uc *FaviconUseCase) RegisterFaviconInvalidator(invalidator appport.FaviconInvalidator) {
125	if uc == nil || uc.invalidators == nil || invalidator == nil {
126		return
127	}
128	uc.invalidators.Register(invalidator)
129}
130
131func (uc *FaviconUseCase) Resolve(
132	ctx context.Context,
133	rawURLOrDomain string,
134	size int,
135	options ResolveOptions,
136) (*appport.ResolvedFavicon, error) {
137	keys := favicon.Candidates(rawURLOrDomain)
138	if len(keys) == 0 {
139		return nil, ErrFaviconMiss
140	}
141
142	for _, key := range keys {
143		if err := ctx.Err(); err != nil {
144			return nil, err
145		}
146		meta, err := uc.repo.Get(ctx, key)
147		if err != nil {
148			return nil, err
149		}
150		if meta == nil {
151			repaired, repairErr := uc.repairOrphanBlobMetadata(ctx, key, rawURLOrDomain, size)
152			if repairErr != nil {
153				if !errors.Is(repairErr, ErrFaviconMiss) {
154					return nil, repairErr
155				}
156				continue
157			}
158			return repaired, nil
159		}
160		bytes, contentType, err := uc.readForSize(ctx, key, size)
161		if err != nil {
162			if !errors.Is(err, ErrFaviconMiss) {
163				return nil, err
164			}
165			continue
166		}
167		res := &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: meta}
168		if favicon.ShouldRefresh(meta, uc.now(), uc.ttl) && options.ScheduleBackgroundRefresh {
169			res.BackgroundRefreshScheduled = uc.scheduleRefresh(key, meta.PageURL)
170		}
171		return res, nil
172	}
173
174	if options.AllowBlockingRefresh {
175		if err := uc.refresh(ctx, rawURLOrDomain, true); err != nil {
176			return nil, err
177		}
178		return uc.Resolve(ctx, rawURLOrDomain, size, ResolveOptions{Purpose: options.Purpose})
179	}
180	if options.ScheduleBackgroundRefresh {
181		uc.scheduleRefresh(keys[0], rawURLOrDomain)
182	}
183	return nil, ErrFaviconMiss
184}
185
186func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*appport.ResolvedFavicon, error) {
187	if size != SystemviewIconSize {
188		return nil, ErrFaviconMiss
189	}
190	return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview})
191}
192
193func (uc *FaviconUseCase) repairOrphanBlobMetadata(
194	ctx context.Context,
195	key favicon.Key,
196	rawURLOrDomain string,
197	size int,
198) (*appport.ResolvedFavicon, error) {
199	bytes, contentType, err := uc.readForSize(ctx, key, size)
200	if err != nil {
201		return nil, err
202	}
203	if contentType == "" {
204		contentType = "image/png"
205	}
206	now := uc.now()
207	meta := favicon.Metadata{
208		Key:           key,
209		SourceURL:     rawURLOrDomain,
210		PageURL:       rawURLOrDomain,
211		Source:        favicon.SourceImported,
212		ContentHash:   favicon.Hash(bytes),
213		ContentType:   contentType,
214		UpdatedAt:     now,
215		LastCheckedAt: now,
216	}
217	if err := uc.repo.Upsert(ctx, meta); err != nil {
218		return nil, err
219	}
220	return &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: &meta}, nil
221}
222
223func (uc *FaviconUseCase) Observe(
224	ctx context.Context,
225	pageURL, iconURL string,
226	bytes []byte,
227	source favicon.Source,
228	contentType string,
229) (*favicon.Metadata, error) {
230	key, ok := favicon.CanonicalKey(pageURL)
231	if !ok || len(bytes) == 0 {
232		return nil, ErrFaviconMiss
233	}
234	return uc.observeKey(ctx, key, pageURL, iconURL, bytes, source, contentType)
235}
236
237func (uc *FaviconUseCase) observeKey(
238	ctx context.Context,
239	key favicon.Key,
240	pageURL, iconURL string,
241	bytes []byte,
242	source favicon.Source,
243	contentType string,
244) (*favicon.Metadata, error) {
245	if key == "" || len(bytes) == 0 {
246		return nil, ErrFaviconMiss
247	}
248	old, err := uc.repo.Get(ctx, key)
249	if err != nil {
250		return nil, err
251	}
252	now := uc.now()
253	if !favicon.HasContentChanged(old, bytes) {
254		if err := uc.repo.UpdateLastChecked(ctx, key, old.ContentHash, now); err != nil {
255			return nil, err
256		}
257		old.LastCheckedAt = now
258		return old, nil
259	}
260
261	var converted *appport.ConvertedFavicon
262	if uc.converter != nil {
263		var err error
264		converted, err = uc.converter.Convert(ctx, bytes, contentType, []int{SystemviewIconSize})
265		if err != nil {
266			return nil, err
267		}
268	}
269
270	if err := uc.blobs.WriteOriginal(ctx, key, bytes, contentType); err != nil {
271		return nil, err
272	}
273	if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
274		return nil, err
275	}
276	if converted != nil {
277		if len(converted.PNG) > 0 {
278			if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
279				return nil, err
280			}
281		}
282		if b := converted.SizedPNG[SystemviewIconSize]; len(b) > 0 {
283			if err := uc.blobs.WriteSizedPNG(ctx, key, SystemviewIconSize, b); err != nil {
284				return nil, err
285			}
286		}
287	}
288	meta := favicon.Metadata{
289		Key:           key,
290		SourceURL:     iconURL,
291		PageURL:       pageURL,
292		Source:        source,
293		ContentHash:   favicon.Hash(bytes),
294		ContentType:   contentType,
295		UpdatedAt:     now,
296		LastCheckedAt: now,
297	}
298	if err := uc.repo.Upsert(ctx, meta); err != nil {
299		return nil, err
300	}
301	if err := uc.invalidateCaches(ctx, key); err != nil {
302		return nil, err
303	}
304	return &meta, nil
305}
306
307func (uc *FaviconUseCase) RefreshIfStale(ctx context.Context, pageURL string) error {
308	return uc.refresh(ctx, pageURL, false)
309}
310
311func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error {
312	if uc.fetcher == nil {
313		return ErrFaviconMiss
314	}
315	var lastMiss error
316	for _, iconURL := range iconURLs {
317		if err := ctx.Err(); err != nil {
318			return err
319		}
320		fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL, IconURL: iconURL})
321		if errors.Is(err, ErrFaviconMiss) {
322			lastMiss = err
323			continue
324		}
325		if err != nil {
326			return err
327		}
328		err = uc.observeFetched(ctx, pageURL, fetched)
329		if errors.Is(err, ErrFaviconMiss) {
330			lastMiss = err
331			continue
332		}
333		return err
334	}
335	if lastMiss != nil {
336		return lastMiss
337	}
338	return uc.RefreshIfStale(ctx, pageURL)
339}
340
341func (uc *FaviconUseCase) refresh(ctx context.Context, pageURL string, force bool) error {
342	keys := favicon.Candidates(pageURL)
343	if len(keys) == 0 {
344		return ErrFaviconMiss
345	}
346	if !force {
347		fresh, err := uc.anyCandidateFresh(ctx, keys)
348		if err != nil {
349			return err
350		}
351		if fresh {
352			return nil
353		}
354	}
355	return uc.fetchAndObserve(ctx, pageURL)
356}
357
358func (uc *FaviconUseCase) refreshKey(ctx context.Context, key favicon.Key, pageURL string) error {
359	meta, err := uc.repo.Get(ctx, key)
360	if err != nil {
361		return err
362	}
363	if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
364		return nil
365	}
366	return uc.fetchAndObserve(ctx, pageURL)
367}
368
369func (uc *FaviconUseCase) anyCandidateFresh(ctx context.Context, keys []favicon.Key) (bool, error) {
370	for _, key := range keys {
371		meta, err := uc.repo.Get(ctx, key)
372		if err != nil {
373			return false, err
374		}
375		if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
376			return true, nil
377		}
378	}
379	return false, nil
380}
381
382func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) error {
383	if uc.fetcher == nil {
384		return ErrFaviconMiss
385	}
386	fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL})
387	if err != nil {
388		return err
389	}
390	err = uc.observeFetched(ctx, pageURL, fetched)
391	return err
392}
393
394func (uc *FaviconUseCase) observeFetched(ctx context.Context, requestedPageURL string, fetched *appport.FaviconFetchedIcon) error {
395	if fetched == nil {
396		return ErrFaviconMiss
397	}
398	if fetched.ResolvedKey != "" {
399		allowed := slices.Contains(favicon.Candidates(requestedPageURL), fetched.ResolvedKey)
400		if !allowed {
401			return ErrFaviconMiss
402		}
403		_, err := uc.observeKey(ctx, fetched.ResolvedKey, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
404		return err
405	}
406	_, err := uc.Observe(ctx, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
407	return err
408}
409
410func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size int) error {
411	if _, _, err := uc.blobs.ReadSizedPNG(ctx, key, size); err == nil {
412		return nil
413	} else if !errors.Is(err, ErrFaviconMiss) {
414		return err
415	}
416	orig, ct, err := uc.blobs.ReadOriginal(ctx, key)
417	if err != nil {
418		return err
419	}
420	if uc.converter == nil {
421		return ErrFaviconMiss
422	}
423	converted, err := uc.converter.Convert(ctx, orig, ct, []int{size})
424	if err != nil {
425		return err
426	}
427	if len(converted.PNG) > 0 {
428		if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
429			return err
430		}
431	}
432	if b := converted.SizedPNG[size]; len(b) > 0 {
433		return uc.blobs.WriteSizedPNG(ctx, key, size, b)
434	}
435	return ErrFaviconMiss
436}
437
438func (uc *FaviconUseCase) Invalidate(ctx context.Context, key favicon.Key) error {
439	if uc.blobs != nil {
440		if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
441			return err
442		}
443	}
444	return uc.invalidateCaches(ctx, key)
445}
446
447func (uc *FaviconUseCase) invalidateCaches(ctx context.Context, key favicon.Key) error {
448	if uc.invalidators != nil {
449		return uc.invalidators.InvalidateAll(ctx, key)
450	}
451	return nil
452}
453
454func (uc *FaviconUseCase) readForSize(ctx context.Context, key favicon.Key, size int) ([]byte, string, error) {
455	if size < 0 {
456		return nil, "", fmt.Errorf("invalid favicon size: %d", size)
457	}
458	if size > 0 {
459		if err := uc.EnsureSized(ctx, key, size); err != nil {
460			return nil, "", err
461		}
462		return uc.blobs.ReadSizedPNG(ctx, key, size)
463	}
464	return uc.blobs.ReadPNG(ctx, key)
465}
466
467func (uc *FaviconUseCase) scheduleRefresh(key favicon.Key, pageURL string) bool {
468	if uc.scheduler == nil {
469		return false
470	}
471	return uc.scheduler.Schedule(uc.background, key, func(ctx context.Context) { _ = uc.refreshKey(ctx, key, pageURL) })
472}

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