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}