detached 9f6779c

fix(favicon): repair orphan canonical blob metadata

commit
9f6779c4b930166e6afcd3de47290ecb523a34d5
author
brice · · 2026-06-07 18:48 UTC
parent
88cfbd5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +60 -1
M internal/application/usecase/favicon.go +38 -1
@@ -147,7 +147,14 @@ func (uc *FaviconUseCase) Resolve(
147147 return nil, err
148148 }
149149 if meta == nil {
150- continue
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
151158 }
152159 bytes, contentType, err := uc.readForSize(ctx, key, size)
153160 if err != nil {
@@ -182,6 +189,36 @@ func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain s
182189 return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview})
183190 }
184191
192+func (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+
185222 func (uc *FaviconUseCase) Observe(
186223 ctx context.Context,
187224 pageURL, iconURL string,
@@ -147,7 +147,14 @@ func (uc *FaviconUseCase) Resolve(
147 return nil, err147 return nil, err
148 }148 }
149 if meta == nil {149 if meta == nil {
150- continue150+ 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
151 }158 }
152 bytes, contentType, err := uc.readForSize(ctx, key, size)159 bytes, contentType, err := uc.readForSize(ctx, key, size)
153 if err != nil {160 if err != nil {
@@ -182,6 +189,36 @@ func (uc *FaviconUseCase) ResolveSystemviewIcon(ctx context.Context, rawDomain s
182 return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview})189 return uc.Resolve(ctx, rawDomain, size, ResolveOptions{Purpose: ResolvePurposeSystemview})
183 }190 }
184 191
192+func (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+
185 func (uc *FaviconUseCase) Observe(222 func (uc *FaviconUseCase) Observe(
186 ctx context.Context,223 ctx context.Context,
187 pageURL, iconURL string,224 pageURL, iconURL string,
M internal/application/usecase/favicon_test.go +21 -0
@@ -34,6 +34,27 @@ func TestFaviconResolveParentFallback(t *testing.T) {
3434 }
3535 }
3636
37+func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) {
38+ fx := newFaviconFixture()
39+ fx.blobs.png["example.com"] = []byte("png")
40+ fx.blobs.sized["example.com"] = map[int][]byte{SystemviewIconSize: []byte("png32")}
41+
42+ got, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ if got.Key != "example.com" || string(got.Bytes) != "png32" {
47+ t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes)
48+ }
49+ meta := fx.repo.byKey["example.com"]
50+ if meta == nil {
51+ t.Fatal("expected metadata to be repaired")
52+ }
53+ if meta.Source != favicon.SourceImported || meta.ContentType != "image/png" || meta.ContentHash != favicon.Hash([]byte("png32")) {
54+ t.Fatalf("unexpected repaired metadata: %#v", meta)
55+ }
56+}
57+
3758 func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) {
3859 fx := newFaviconFixture()
3960 fx.seed("example.com", []byte("old"), true)
@@ -34,6 +34,27 @@ func TestFaviconResolveParentFallback(t *testing.T) {
34 }34 }
35 }35 }
36 36
37+func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) {
38+ fx := newFaviconFixture()
39+ fx.blobs.png["example.com"] = []byte("png")
40+ fx.blobs.sized["example.com"] = map[int][]byte{SystemviewIconSize: []byte("png32")}
41+
42+ got, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ if got.Key != "example.com" || string(got.Bytes) != "png32" {
47+ t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes)
48+ }
49+ meta := fx.repo.byKey["example.com"]
50+ if meta == nil {
51+ t.Fatal("expected metadata to be repaired")
52+ }
53+ if meta.Source != favicon.SourceImported || meta.ContentType != "image/png" || meta.ContentHash != favicon.Hash([]byte("png32")) {
54+ t.Fatalf("unexpected repaired metadata: %#v", meta)
55+ }
56+}
57+
37 func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) {58 func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) {
38 fx := newFaviconFixture()59 fx := newFaviconFixture()
39 fx.seed("example.com", []byte("old"), true)60 fx.seed("example.com", []byte("old"), true)
M internal/domain/favicon/metadata.go +1 -0
@@ -9,6 +9,7 @@ const (
99 SourcePageDiscovery Source = "page_discovery"
1010 SourceDuckDuckGo Source = "duckduckgo"
1111 SourceInternal Source = "internal"
12+ SourceImported Source = "imported"
1213 SourceManual Source = "manual"
1314 )
1415
@@ -9,6 +9,7 @@ const (
9 SourcePageDiscovery Source = "page_discovery"9 SourcePageDiscovery Source = "page_discovery"
10 SourceDuckDuckGo Source = "duckduckgo"10 SourceDuckDuckGo Source = "duckduckgo"
11 SourceInternal Source = "internal"11 SourceInternal Source = "internal"
12+ SourceImported Source = "imported"
12 SourceManual Source = "manual"13 SourceManual Source = "manual"
13 )14 )
14 15

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