v0.30.0 eb3068f

favicon.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"sync"
8	"time"
9
10	appport "github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/domain/favicon"
12)
13
14var ErrFaviconMiss = appport.ErrFaviconMiss
15
16type ResolvePurpose = appport.FaviconResolvePurpose
17
18const (
19	ResolvePurposeUI         = appport.FaviconResolvePurposeUI
20	ResolvePurposeSystemview = appport.FaviconResolvePurposeSystemview
21	ResolvePurposeRefresh    = appport.FaviconResolvePurposeRefresh
22)
23
24type ResolveOptions = appport.FaviconResolveOptions
25type ResolvedFavicon = appport.ResolvedFavicon
26
27// SystemviewIconSize is the fixed PNG size served to internal systemview pages.
28const SystemviewIconSize = 32
29
30type faviconInvalidatorRegistry struct {
31	mu           sync.RWMutex
32	fallback     appport.FaviconInvalidators
33	invalidators []appport.FaviconInvalidator
34}
35
36func (r *faviconInvalidatorRegistry) Register(invalidator appport.FaviconInvalidator) {
37	if r == nil || invalidator == nil {
38		return
39	}
40	r.mu.Lock()
41	defer r.mu.Unlock()
42	r.invalidators = append(r.invalidators, invalidator)
43}
44
45func (r *faviconInvalidatorRegistry) InvalidateAll(ctx context.Context, key favicon.Key) error {
46	if r == nil {
47		return nil
48	}
49	r.mu.RLock()
50	fallback := r.fallback
51	invalidators := append([]appport.FaviconInvalidator(nil), r.invalidators...)
52	r.mu.RUnlock()
53	// The fallback invalidator owns pre-registered invalidation sets and is
54	// treated as fatal; dynamic invalidators below are best-effort and joined.
55	if fallback != nil {
56		if err := fallback.InvalidateAll(ctx, key); err != nil {
57			return err
58		}
59	}
60	var errs []error
61	for _, invalidator := range invalidators {
62		if err := invalidator.Invalidate(ctx, key); err != nil {
63			errs = append(errs, err)
64		}
65	}
66	return errors.Join(errs...)
67}
68
69type FaviconUseCase struct {
70	repo         appport.FaviconRepository
71	blobs        appport.FaviconBlobStore
72	fetcher      appport.FaviconFetcher
73	discoverer   appport.FaviconDiscoverer
74	converter    appport.FaviconImageConverter
75	scheduler    appport.FaviconRefreshScheduler
76	invalidators *faviconInvalidatorRegistry
77	now          func() time.Time
78	ttl          time.Duration
79	background   context.Context
80}
81
82type FaviconDeps struct {
83	Repository   appport.FaviconRepository
84	BlobStore    appport.FaviconBlobStore
85	Fetcher      appport.FaviconFetcher
86	Discoverer   appport.FaviconDiscoverer
87	Converter    appport.FaviconImageConverter
88	Scheduler    appport.FaviconRefreshScheduler
89	Invalidators appport.FaviconInvalidators
90	Now          func() time.Time
91	TTL          time.Duration
92	Background   context.Context
93}
94
95func NewFaviconUseCase(deps FaviconDeps) *FaviconUseCase {
96	if deps.Now == nil {
97		deps.Now = time.Now
98	}
99	if deps.TTL == 0 {
100		deps.TTL = favicon.DefaultTTL
101	}
102	if deps.Background == nil {
103		deps.Background = context.Background()
104	}
105	registry := &faviconInvalidatorRegistry{}
106	if deps.Invalidators != nil {
107		registry.fallback = deps.Invalidators
108	}
109	return &FaviconUseCase{
110		repo:         deps.Repository,
111		blobs:        deps.BlobStore,
112		fetcher:      deps.Fetcher,
113		discoverer:   deps.Discoverer,
114		converter:    deps.Converter,
115		scheduler:    deps.Scheduler,
116		invalidators: registry,
117		now:          deps.Now,
118		ttl:          deps.TTL,
119		background:   deps.Background,
120	}
121}
122
123func (uc *FaviconUseCase) RegisterFaviconInvalidator(invalidator appport.FaviconInvalidator) {
124	if uc == nil || uc.invalidators == nil || invalidator == nil {
125		return
126	}
127	uc.invalidators.Register(invalidator)
128}
129
130func (uc *FaviconUseCase) Resolve(
131	ctx context.Context,
132	rawURLOrDomain string,
133	size int,
134	options ResolveOptions,
135) (*appport.ResolvedFavicon, error) {
136	keys := favicon.Candidates(rawURLOrDomain)
137	if len(keys) == 0 {
138		return nil, ErrFaviconMiss
139	}
140
141	for _, key := range keys {
142		if err := ctx.Err(); err != nil {
143			return nil, err
144		}
145		meta, err := uc.repo.Get(ctx, key)
146		if err != nil {
147			return nil, err
148		}
149		if meta == nil {
150			repaired, repairErr := uc.repairOrphanBlobMetadata(ctx, key, rawURLOrDomain, size)
151			if repairErr != nil {
152				if !errors.Is(repairErr, ErrFaviconMiss) {
153					return nil, repairErr
154				}
155				continue
156			}
157			return repaired, nil
158		}
159		bytes, contentType, err := uc.readForSize(ctx, key, size)
160		if err != nil {
161			if !errors.Is(err, ErrFaviconMiss) {
162				return nil, err
163			}
164			continue
165		}
166		res := &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: meta}
167		if favicon.ShouldRefresh(meta, uc.now(), uc.ttl) && options.ScheduleBackgroundRefresh {
168			res.BackgroundRefreshScheduled = uc.scheduleRefresh(key, meta.PageURL)
169		}
170		return res, nil
171	}
172
173	if options.AllowBlockingRefresh {
174		if err := uc.refresh(ctx, rawURLOrDomain, true); err != nil {
175			return nil, err
176		}
177		return uc.Resolve(ctx, rawURLOrDomain, size, ResolveOptions{Purpose: options.Purpose})
178	}
179	if options.ScheduleBackgroundRefresh {
180		uc.scheduleRefresh(keys[0], rawURLOrDomain)
181	}
182	return nil, ErrFaviconMiss
183}
184
185func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain string, size int) (*appport.ResolvedFavicon, error) {
186	if size != SystemviewIconSize {
187		return nil, ErrFaviconMiss
188	}
189	return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview})
190}
191
192func (uc *FaviconUseCase) repairOrphanBlobMetadata(
193	ctx context.Context,
194	key favicon.Key,
195	rawURLOrDomain string,
196	size int,
197) (*appport.ResolvedFavicon, error) {
198	bytes, contentType, err := uc.readForSize(ctx, key, size)
199	if err != nil {
200		return nil, err
201	}
202	if contentType == "" {
203		contentType = "image/png"
204	}
205	now := uc.now()
206	meta := favicon.Metadata{
207		Key:           key,
208		SourceURL:     rawURLOrDomain,
209		PageURL:       rawURLOrDomain,
210		Source:        favicon.SourceImported,
211		ContentHash:   favicon.Hash(bytes),
212		ContentType:   contentType,
213		UpdatedAt:     now,
214		LastCheckedAt: now,
215	}
216	if err := uc.repo.Upsert(ctx, meta); err != nil {
217		return nil, err
218	}
219	return &appport.ResolvedFavicon{Key: key, Bytes: bytes, ContentType: contentType, Metadata: &meta}, nil
220}
221
222func (uc *FaviconUseCase) Observe(
223	ctx context.Context,
224	pageURL, iconURL string,
225	bytes []byte,
226	source favicon.Source,
227	contentType string,
228) (*favicon.Metadata, error) {
229	key, ok := favicon.CanonicalKey(pageURL)
230	if !ok || len(bytes) == 0 {
231		return nil, ErrFaviconMiss
232	}
233	return uc.observeKey(ctx, key, pageURL, iconURL, bytes, source, contentType)
234}
235
236func (uc *FaviconUseCase) observeKey(
237	ctx context.Context,
238	key favicon.Key,
239	pageURL, iconURL string,
240	bytes []byte,
241	source favicon.Source,
242	contentType string,
243) (*favicon.Metadata, error) {
244	if key == "" || len(bytes) == 0 {
245		return nil, ErrFaviconMiss
246	}
247	old, err := uc.repo.Get(ctx, key)
248	if err != nil {
249		return nil, err
250	}
251	now := uc.now()
252	if !favicon.HasContentChanged(old, bytes) {
253		if err := uc.repo.UpdateLastChecked(ctx, key, old.ContentHash, now); err != nil {
254			return nil, err
255		}
256		old.LastCheckedAt = now
257		return old, nil
258	}
259
260	var converted *appport.ConvertedFavicon
261	if uc.converter != nil {
262		var err error
263		converted, err = uc.converter.Convert(ctx, bytes, contentType, []int{SystemviewIconSize})
264		if err != nil {
265			return nil, err
266		}
267	}
268
269	if err := uc.blobs.WriteOriginal(ctx, key, bytes, contentType); err != nil {
270		return nil, err
271	}
272	if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
273		return nil, err
274	}
275	if converted != nil {
276		if len(converted.PNG) > 0 {
277			if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
278				return nil, err
279			}
280		}
281		if b := converted.SizedPNG[SystemviewIconSize]; len(b) > 0 {
282			if err := uc.blobs.WriteSizedPNG(ctx, key, SystemviewIconSize, b); err != nil {
283				return nil, err
284			}
285		}
286	}
287	meta := favicon.Metadata{
288		Key:           key,
289		SourceURL:     iconURL,
290		PageURL:       pageURL,
291		Source:        source,
292		ContentHash:   favicon.Hash(bytes),
293		ContentType:   contentType,
294		UpdatedAt:     now,
295		LastCheckedAt: now,
296	}
297	if err := uc.repo.Upsert(ctx, meta); err != nil {
298		return nil, err
299	}
300	if err := uc.invalidateCaches(ctx, key); err != nil {
301		return nil, err
302	}
303	return &meta, nil
304}
305
306func (uc *FaviconUseCase) RefreshIfStale(ctx context.Context, pageURL string) error {
307	return uc.refresh(ctx, pageURL, false)
308}
309
310func (uc *FaviconUseCase) RefreshFromIconURLs(ctx context.Context, pageURL string, iconURLs []string) error {
311	if uc.fetcher == nil {
312		return ErrFaviconMiss
313	}
314	var lastMiss error
315	for _, iconURL := range iconURLs {
316		if err := ctx.Err(); err != nil {
317			return err
318		}
319		fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL, IconURL: iconURL})
320		if errors.Is(err, ErrFaviconMiss) {
321			lastMiss = err
322			continue
323		}
324		if err != nil {
325			return err
326		}
327		err = uc.observeFetched(ctx, pageURL, fetched)
328		if errors.Is(err, ErrFaviconMiss) {
329			lastMiss = err
330			continue
331		}
332		return err
333	}
334	if lastMiss != nil {
335		return lastMiss
336	}
337	return uc.RefreshIfStale(ctx, pageURL)
338}
339
340func (uc *FaviconUseCase) refresh(ctx context.Context, pageURL string, force bool) error {
341	keys := favicon.Candidates(pageURL)
342	if len(keys) == 0 {
343		return ErrFaviconMiss
344	}
345	if !force {
346		fresh, err := uc.anyCandidateFresh(ctx, keys)
347		if err != nil {
348			return err
349		}
350		if fresh {
351			return nil
352		}
353	}
354	return uc.fetchAndObserve(ctx, pageURL)
355}
356
357func (uc *FaviconUseCase) refreshKey(ctx context.Context, key favicon.Key, pageURL string) error {
358	meta, err := uc.repo.Get(ctx, key)
359	if err != nil {
360		return err
361	}
362	if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
363		return nil
364	}
365	return uc.fetchAndObserve(ctx, pageURL)
366}
367
368func (uc *FaviconUseCase) anyCandidateFresh(ctx context.Context, keys []favicon.Key) (bool, error) {
369	for _, key := range keys {
370		meta, err := uc.repo.Get(ctx, key)
371		if err != nil {
372			return false, err
373		}
374		if meta != nil && !favicon.ShouldRefresh(meta, uc.now(), uc.ttl) {
375			return true, nil
376		}
377	}
378	return false, nil
379}
380
381func (uc *FaviconUseCase) fetchAndObserve(ctx context.Context, pageURL string) error {
382	if uc.fetcher == nil {
383		return ErrFaviconMiss
384	}
385	fetched, err := uc.fetcher.Fetch(ctx, appport.FaviconFetchRequest{PageURL: pageURL})
386	if err != nil {
387		return err
388	}
389	err = uc.observeFetched(ctx, pageURL, fetched)
390	return err
391}
392
393func (uc *FaviconUseCase) observeFetched(ctx context.Context, requestedPageURL string, fetched *appport.FaviconFetchedIcon) error {
394	if fetched == nil {
395		return ErrFaviconMiss
396	}
397	if fetched.ResolvedKey != "" {
398		allowed := false
399		for _, candidate := range favicon.Candidates(requestedPageURL) {
400			if candidate == fetched.ResolvedKey {
401				allowed = true
402				break
403			}
404		}
405		if !allowed {
406			return ErrFaviconMiss
407		}
408		_, err := uc.observeKey(ctx, fetched.ResolvedKey, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
409		return err
410	}
411	_, err := uc.Observe(ctx, requestedPageURL, fetched.IconURL, fetched.Bytes, fetched.Source, fetched.ContentType)
412	return err
413}
414
415func (uc *FaviconUseCase) EnsureSized(ctx context.Context, key favicon.Key, size int) error {
416	if _, _, err := uc.blobs.ReadSizedPNG(ctx, key, size); err == nil {
417		return nil
418	} else if !errors.Is(err, ErrFaviconMiss) {
419		return err
420	}
421	orig, ct, err := uc.blobs.ReadOriginal(ctx, key)
422	if err != nil {
423		return err
424	}
425	if uc.converter == nil {
426		return ErrFaviconMiss
427	}
428	converted, err := uc.converter.Convert(ctx, orig, ct, []int{size})
429	if err != nil {
430		return err
431	}
432	if len(converted.PNG) > 0 {
433		if err := uc.blobs.WritePNG(ctx, key, converted.PNG); err != nil {
434			return err
435		}
436	}
437	if b := converted.SizedPNG[size]; len(b) > 0 {
438		return uc.blobs.WriteSizedPNG(ctx, key, size, b)
439	}
440	return ErrFaviconMiss
441}
442
443func (uc *FaviconUseCase) Invalidate(ctx context.Context, key favicon.Key) error {
444	if uc.blobs != nil {
445		if err := uc.blobs.RemoveDerived(ctx, key); err != nil {
446			return err
447		}
448	}
449	return uc.invalidateCaches(ctx, key)
450}
451
452func (uc *FaviconUseCase) invalidateCaches(ctx context.Context, key favicon.Key) error {
453	if uc.invalidators != nil {
454		return uc.invalidators.InvalidateAll(ctx, key)
455	}
456	return nil
457}
458
459func (uc *FaviconUseCase) readForSize(ctx context.Context, key favicon.Key, size int) ([]byte, string, error) {
460	if size < 0 {
461		return nil, "", fmt.Errorf("invalid favicon size: %d", size)
462	}
463	if size > 0 {
464		if err := uc.EnsureSized(ctx, key, size); err != nil {
465			return nil, "", err
466		}
467		return uc.blobs.ReadSizedPNG(ctx, key, size)
468	}
469	return uc.blobs.ReadPNG(ctx, key)
470}
471
472func (uc *FaviconUseCase) scheduleRefresh(key favicon.Key, pageURL string) bool {
473	if uc.scheduler == nil {
474		return false
475	}
476	return uc.scheduler.Schedule(uc.background, key, func(ctx context.Context) { _ = uc.refreshKey(ctx, key, pageURL) })
477}

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