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}