Bluesky avatar proxy thing

feat: add hash bucket storage and thumbnail caching

- Store avatars in 3-level deep hash bucket directories based on
SHA256 of DID to prevent large flat directories
- Cache thumbnails alongside full-size images to avoid repeated scaling
- Return placeholder avatar for invalid/unresolvable identifiers
instead of 404 errors
- Support did:web identifiers (already handled by Indigo library)

Changed files
+84 -20
internal
cache
server
+59 -8
internal/cache/cache.go
··· 1 1 package cache 2 2 3 3 import ( 4 + "crypto/sha256" 5 + "encoding/hex" 4 6 "os" 5 7 "path/filepath" 6 8 "strings" ··· 19 21 return nil, err 20 22 } 21 23 return &Cache{storePath: storePath}, nil 24 + } 25 + 26 + // hashBucketPath returns a 3-level deep directory path based on SHA256 of the DID 27 + func hashBucketPath(did string) string { 28 + hash := sha256.Sum256([]byte(did)) 29 + hexHash := hex.EncodeToString(hash[:]) 30 + return filepath.Join(hexHash[0:2], hexHash[2:4], hexHash[4:6]) 22 31 } 23 32 24 33 // didToFilename converts a DID to a safe filename ··· 26 35 return strings.ReplaceAll(did, ":", "_") + ".jpg" 27 36 } 28 37 38 + // didToThumbFilename converts a DID to a thumbnail filename 39 + func didToThumbFilename(did string) string { 40 + return strings.ReplaceAll(did, ":", "_") + "_thumb.jpg" 41 + } 42 + 29 43 // Get retrieves an avatar from the cache 30 44 func (c *Cache) Get(did string) ([]byte, bool) { 31 45 c.mu.RLock() 32 46 defer c.mu.RUnlock() 33 47 34 - path := filepath.Join(c.storePath, didToFilename(did)) 48 + path := filepath.Join(c.storePath, hashBucketPath(did), didToFilename(did)) 49 + data, err := os.ReadFile(path) 50 + if err != nil { 51 + return nil, false 52 + } 53 + return data, true 54 + } 55 + 56 + // GetThumbnail retrieves a cached thumbnail from the cache 57 + func (c *Cache) GetThumbnail(did string) ([]byte, bool) { 58 + c.mu.RLock() 59 + defer c.mu.RUnlock() 60 + 61 + path := filepath.Join(c.storePath, hashBucketPath(did), didToThumbFilename(did)) 35 62 data, err := os.ReadFile(path) 36 63 if err != nil { 37 64 return nil, false ··· 44 71 c.mu.Lock() 45 72 defer c.mu.Unlock() 46 73 47 - path := filepath.Join(c.storePath, didToFilename(did)) 74 + bucketDir := filepath.Join(c.storePath, hashBucketPath(did)) 75 + if err := os.MkdirAll(bucketDir, 0755); err != nil { 76 + return err 77 + } 78 + path := filepath.Join(bucketDir, didToFilename(did)) 79 + return os.WriteFile(path, data, 0644) 80 + } 81 + 82 + // SetThumbnail stores a thumbnail in the cache 83 + func (c *Cache) SetThumbnail(did string, data []byte) error { 84 + c.mu.Lock() 85 + defer c.mu.Unlock() 86 + 87 + bucketDir := filepath.Join(c.storePath, hashBucketPath(did)) 88 + if err := os.MkdirAll(bucketDir, 0755); err != nil { 89 + return err 90 + } 91 + path := filepath.Join(bucketDir, didToThumbFilename(did)) 48 92 return os.WriteFile(path, data, 0644) 49 93 } 50 94 51 - // Delete removes an avatar from the cache 95 + // Delete removes an avatar and its thumbnail from the cache 52 96 func (c *Cache) Delete(did string) error { 53 97 c.mu.Lock() 54 98 defer c.mu.Unlock() 55 99 56 - path := filepath.Join(c.storePath, didToFilename(did)) 57 - err := os.Remove(path) 58 - if os.IsNotExist(err) { 59 - return nil 100 + bucketDir := filepath.Join(c.storePath, hashBucketPath(did)) 101 + 102 + avatarPath := filepath.Join(bucketDir, didToFilename(did)) 103 + if err := os.Remove(avatarPath); err != nil && !os.IsNotExist(err) { 104 + return err 60 105 } 61 - return err 106 + 107 + thumbPath := filepath.Join(bucketDir, didToThumbFilename(did)) 108 + if err := os.Remove(thumbPath); err != nil && !os.IsNotExist(err) { 109 + return err 110 + } 111 + 112 + return nil 62 113 }
+25 -12
internal/server/server.go
··· 81 81 did, err := s.fetcher.ResolveDID(ctx, identifier) 82 82 if err != nil { 83 83 log.Printf("Error resolving DID for %s: %v", identifier, err) 84 - http.Error(w, "Not found", http.StatusNotFound) 84 + s.serveAvatar(w, GetDefaultAvatar(), false) 85 85 return 86 86 } 87 87 ··· 93 93 result, err := s.fetcher.Fetch(ctx, identifier) 94 94 if err != nil { 95 95 log.Printf("Error fetching avatar for %s: %v", identifier, err) 96 - http.Error(w, "Internal server error", http.StatusInternalServerError) 96 + s.serveAvatar(w, GetDefaultAvatar(), false) 97 97 return 98 98 } 99 99 ··· 128 128 did, err := s.fetcher.ResolveDID(ctx, identifier) 129 129 if err != nil { 130 130 log.Printf("Error resolving DID for %s: %v", identifier, err) 131 - http.Error(w, "Not found", http.StatusNotFound) 131 + s.serveDefaultThumbnail(w) 132 + return 133 + } 134 + 135 + if data, ok := s.cache.GetThumbnail(did); ok { 136 + s.serveAvatar(w, data, true) 132 137 return 133 138 } 134 139 ··· 139 144 result, err := s.fetcher.Fetch(ctx, identifier) 140 145 if err != nil { 141 146 log.Printf("Error fetching avatar for %s: %v", identifier, err) 142 - http.Error(w, "Internal server error", http.StatusInternalServerError) 147 + s.serveDefaultThumbnail(w) 143 148 return 144 149 } 145 150 146 151 if !result.HasAvatar { 147 - thumbnail, err := avatar.ScaleToSize(GetDefaultAvatar(), 128, 128) 148 - if err != nil { 149 - log.Printf("Error scaling default avatar: %v", err) 150 - http.Error(w, "Internal server error", http.StatusInternalServerError) 151 - return 152 - } 153 - s.serveAvatar(w, thumbnail, false) 152 + s.serveDefaultThumbnail(w) 154 153 return 155 154 } 156 155 ··· 163 162 thumbnail, err := avatar.ScaleToSize(avatarData, 128, 128) 164 163 if err != nil { 165 164 log.Printf("Error scaling avatar to thumbnail: %v", err) 166 - http.Error(w, "Internal server error", http.StatusInternalServerError) 165 + s.serveDefaultThumbnail(w) 167 166 return 168 167 } 169 168 169 + if err := s.cache.SetThumbnail(did, thumbnail); err != nil { 170 + log.Printf("Error caching thumbnail for %s: %v", did, err) 171 + } 172 + 170 173 s.serveAvatar(w, thumbnail, true) 171 174 } 172 175 ··· 178 181 w.Header().Set("Cache-Control", "no-cache") 179 182 } 180 183 w.Write(data) 184 + } 185 + 186 + func (s *Server) serveDefaultThumbnail(w http.ResponseWriter) { 187 + thumbnail, err := avatar.ScaleToSize(GetDefaultAvatar(), 128, 128) 188 + if err != nil { 189 + log.Printf("Error scaling default avatar to thumbnail: %v", err) 190 + http.Error(w, "Internal server error", http.StatusInternalServerError) 191 + return 192 + } 193 + s.serveAvatar(w, thumbnail, false) 181 194 } 182 195 183 196 func handleHealthcheck(w http.ResponseWriter, r *http.Request) {