blob_store.go RawHistoryFolder

1package favicon
2
3import (
4	"context"
5	"errors"
6	"fmt"
7	"os"
8	"path/filepath"
9	"strings"
10
11	appport "github.com/bnema/dumber/internal/application/port"
12	domainfavicon "github.com/bnema/dumber/internal/domain/favicon"
13)
14
15const contentTypeSuffix = ".content-type"
16
17// BlobStore stores favicon originals and derived PNG files on disk.
18type BlobStore struct {
19	cache *Cache
20}
21
22func NewBlobStore(diskDir string) *BlobStore        { return &BlobStore{cache: NewCache(diskDir)} }
23func NewBlobStoreFromCache(cache *Cache) *BlobStore { return &BlobStore{cache: cache} }
24
25func (s *BlobStore) ReadOriginal(ctx context.Context, key domainfavicon.Key) ([]byte, string, error) {
26	_ = ctx
27	path := s.cache.DiskPath(string(key))
28	data, err := readNonEmpty(path)
29	if err != nil {
30		return nil, "", err
31	}
32	ct, err := os.ReadFile(path + contentTypeSuffix)
33	if err != nil && !errors.Is(err, os.ErrNotExist) {
34		return nil, "", err
35	}
36	return data, strings.TrimSpace(string(ct)), nil
37}
38
39func (s *BlobStore) WriteOriginal(ctx context.Context, key domainfavicon.Key, data []byte, contentType string) error {
40	_ = ctx
41	path := s.cache.DiskPath(string(key))
42	if err := atomicWrite(path, data); err != nil {
43		return err
44	}
45	if contentType != "" {
46		return atomicWrite(path+contentTypeSuffix, []byte(contentType))
47	}
48	_ = os.Remove(path + contentTypeSuffix)
49	return nil
50}
51
52func (s *BlobStore) ReadPNG(ctx context.Context, key domainfavicon.Key) ([]byte, string, error) {
53	_ = ctx
54	data, err := readNonEmpty(s.cache.DiskPathPNG(string(key)))
55	if err != nil {
56		return nil, "", err
57	}
58	return data, "image/png", nil
59}
60func (s *BlobStore) WritePNG(ctx context.Context, key domainfavicon.Key, data []byte) error {
61	_ = ctx
62	return atomicWrite(s.cache.DiskPathPNG(string(key)), data)
63}
64func (s *BlobStore) ReadSizedPNG(ctx context.Context, key domainfavicon.Key, size int) ([]byte, string, error) {
65	_ = ctx
66	data, err := readNonEmpty(s.cache.DiskPathPNGSized(string(key), size))
67	if err != nil {
68		return nil, "", err
69	}
70	return data, "image/png", nil
71}
72func (s *BlobStore) WriteSizedPNG(ctx context.Context, key domainfavicon.Key, size int, data []byte) error {
73	_ = ctx
74	return atomicWrite(s.cache.DiskPathPNGSized(string(key), size), data)
75}
76
77func (s *BlobStore) RemoveDerived(ctx context.Context, key domainfavicon.Key) error {
78	_ = ctx
79	if s.cache == nil || s.cache.diskDir == "" {
80		return nil
81	}
82	pngPath := s.cache.DiskPathPNG(string(key))
83	paths := []string{pngPath}
84	var errs []error
85	basePrefix := strings.TrimSuffix(filepath.Base(pngPath), ".png") + "."
86	pattern := filepath.Join(s.cache.diskDir, basePrefix+"*.png")
87	matches, globErr := filepath.Glob(pattern)
88	if globErr != nil {
89		errs = append(errs, globErr)
90	}
91	for _, match := range matches {
92		if isSizedPNGForKey(filepath.Base(match), basePrefix) {
93			paths = append(paths, match)
94		}
95	}
96	for _, p := range paths {
97		if p != "" {
98			if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) {
99				errs = append(errs, err)
100			}
101		}
102	}
103	return errors.Join(errs...)
104}
105
106func isSizedPNGForKey(baseName, basePrefix string) bool {
107	if !strings.HasPrefix(baseName, basePrefix) || !strings.HasSuffix(baseName, ".png") {
108		return false
109	}
110	sizePart := strings.TrimSuffix(strings.TrimPrefix(baseName, basePrefix), ".png")
111	if sizePart == "" {
112		return false
113	}
114	for _, r := range sizePart {
115		if r < '0' || r > '9' {
116			return false
117		}
118	}
119	return true
120}
121
122func readNonEmpty(path string) ([]byte, error) {
123	if path == "" {
124		return nil, appport.ErrFaviconMiss
125	}
126	data, err := os.ReadFile(path)
127	if errors.Is(err, os.ErrNotExist) {
128		return nil, appport.ErrFaviconMiss
129	}
130	if err != nil {
131		return nil, err
132	}
133	if len(data) == 0 {
134		return nil, appport.ErrFaviconMiss
135	}
136	return data, nil
137}
138
139func atomicWrite(path string, data []byte) error {
140	if path == "" || len(data) == 0 {
141		return appport.ErrFaviconInvalidInput
142	}
143	if err := os.MkdirAll(filepath.Dir(path), diskCacheDirPerm); err != nil {
144		return err
145	}
146	tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*.tmp")
147	if err != nil {
148		return err
149	}
150	tmpName := tmp.Name()
151	defer func() { _ = os.Remove(tmpName) }()
152	if _, err := tmp.Write(data); err != nil {
153		_ = tmp.Close()
154		return err
155	}
156	if err := tmp.Chmod(diskCacheFilePerm); err != nil {
157		_ = tmp.Close()
158		return err
159	}
160	if err := tmp.Close(); err != nil {
161		return err
162	}
163	if err := os.Rename(tmpName, path); err != nil {
164		return fmt.Errorf("atomic write %s: %w", path, err)
165	}
166	return nil
167}

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