Bluesky avatar proxy thing
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add image scaling endpoints

- Main endpoint /{identifier} now returns avatars scaled to max 1000x1000
(preserving aspect ratio, unchanged if smaller)
- New /thumbnail/{identifier} endpoint returns avatars scaled to 128x128

+106 -3
+43 -3
internal/avatar/fetcher.go
··· 93 93 return nil, fmt.Errorf("failed to get avatar blob: %w", err) 94 94 } 95 95 96 - scaled, err := scaleImage(avatarBytes, 128, 128) 96 + scaled, err := ScaleToMax(avatarBytes, 1000) 97 97 if err != nil { 98 98 return nil, fmt.Errorf("failed to scale avatar: %w", err) 99 99 } ··· 105 105 }, nil 106 106 } 107 107 108 - // scaleImage scales an image to the specified dimensions 109 - func scaleImage(data []byte, width, height int) ([]byte, error) { 108 + // ScaleToMax scales an image to fit within maxSize x maxSize, preserving aspect ratio. 109 + // If the image is already smaller than maxSize, it is returned unchanged (re-encoded as JPEG). 110 + func ScaleToMax(data []byte, maxSize int) ([]byte, error) { 111 + img, _, err := image.Decode(bytes.NewReader(data)) 112 + if err != nil { 113 + return nil, err 114 + } 115 + 116 + bounds := img.Bounds() 117 + origWidth := bounds.Dx() 118 + origHeight := bounds.Dy() 119 + 120 + if origWidth <= maxSize && origHeight <= maxSize { 121 + var buf bytes.Buffer 122 + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err != nil { 123 + return nil, err 124 + } 125 + return buf.Bytes(), nil 126 + } 127 + 128 + var newWidth, newHeight int 129 + if origWidth > origHeight { 130 + newWidth = maxSize 131 + newHeight = (origHeight * maxSize) / origWidth 132 + } else { 133 + newHeight = maxSize 134 + newWidth = (origWidth * maxSize) / origHeight 135 + } 136 + 137 + scaled := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight)) 138 + draw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), draw.Over, nil) 139 + 140 + var buf bytes.Buffer 141 + if err := jpeg.Encode(&buf, scaled, &jpeg.Options{Quality: 95}); err != nil { 142 + return nil, err 143 + } 144 + 145 + return buf.Bytes(), nil 146 + } 147 + 148 + // ScaleToSize scales an image to exact dimensions (width x height). 149 + func ScaleToSize(data []byte, width, height int) ([]byte, error) { 110 150 img, _, err := image.Decode(bytes.NewReader(data)) 111 151 if err != nil { 112 152 return nil, err
+63
internal/server/server.go
··· 13 13 14 14 const ( 15 15 avatarPathPattern = "GET /{identifier}" 16 + thumbnailPathPattern = "GET /thumbnail/{identifier}" 16 17 healthcheckPathPattern = "GET /" 17 18 ) 18 19 ··· 37 38 func (s *Server) Start() error { 38 39 mux := http.NewServeMux() 39 40 mux.HandleFunc(healthcheckPathPattern, handleHealthcheck) 41 + mux.HandleFunc(thumbnailPathPattern, s.handleThumbnail) 40 42 mux.HandleFunc(avatarPathPattern, s.handleAvatar) 41 43 42 44 s.server = &http.Server{ ··· 101 103 } 102 104 103 105 s.serveAvatar(w, result.Avatar, true) 106 + } 107 + 108 + func (s *Server) handleThumbnail(w http.ResponseWriter, r *http.Request) { 109 + filename := r.PathValue("identifier") 110 + if !strings.HasSuffix(filename, ".jpg") { 111 + http.Error(w, "Not found", http.StatusNotFound) 112 + return 113 + } 114 + 115 + identifier := strings.TrimSuffix(filename, ".jpg") 116 + if identifier == "" { 117 + http.Error(w, "Not found", http.StatusNotFound) 118 + return 119 + } 120 + 121 + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) 122 + defer cancel() 123 + 124 + did, err := s.fetcher.ResolveDID(ctx, identifier) 125 + if err != nil { 126 + log.Printf("Error resolving DID for %s: %v", identifier, err) 127 + http.Error(w, "Not found", http.StatusNotFound) 128 + return 129 + } 130 + 131 + var avatarData []byte 132 + if data, ok := s.cache.Get(did); ok { 133 + avatarData = data 134 + } else { 135 + result, err := s.fetcher.Fetch(ctx, identifier) 136 + if err != nil { 137 + log.Printf("Error fetching avatar for %s: %v", identifier, err) 138 + http.Error(w, "Internal server error", http.StatusInternalServerError) 139 + return 140 + } 141 + 142 + if !result.HasAvatar { 143 + thumbnail, err := avatar.ScaleToSize(GetDefaultAvatar(), 128, 128) 144 + if err != nil { 145 + log.Printf("Error scaling default avatar: %v", err) 146 + http.Error(w, "Internal server error", http.StatusInternalServerError) 147 + return 148 + } 149 + s.serveAvatar(w, thumbnail, false) 150 + return 151 + } 152 + 153 + if err := s.cache.Set(result.DID, result.Avatar); err != nil { 154 + log.Printf("Error caching avatar for %s: %v", result.DID, err) 155 + } 156 + avatarData = result.Avatar 157 + } 158 + 159 + thumbnail, err := avatar.ScaleToSize(avatarData, 128, 128) 160 + if err != nil { 161 + log.Printf("Error scaling avatar to thumbnail: %v", err) 162 + http.Error(w, "Internal server error", http.StatusInternalServerError) 163 + return 164 + } 165 + 166 + s.serveAvatar(w, thumbnail, true) 104 167 } 105 168 106 169 func (s *Server) serveAvatar(w http.ResponseWriter, data []byte, cacheable bool) {