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}