A community based topic aggregation platform built on atproto

feat(imageproxy): add image proxy service for AT Protocol blob transformations

Implement a comprehensive image proxy service that fetches, resizes, and caches
images from AT Protocol Personal Data Servers. This enables optimized image
delivery with preset-based transformations for avatars, banners, and content.

Changes:
- Add imageproxy core package with multi-tier architecture (service, cache, fetcher, processor)
- Implement disk-based LRU cache with configurable TTL and background cleanup
- Add HTTP handler at /img/{preset}/plain/{did}/{cid} with ETag support
- Define 6 presets: avatar, avatar_small, banner, content_preview, content_full, embed_thumbnail
- Add DID and CID validation with proper error handling
- Integrate with communities to serve optimized avatar/banner URLs
- Add HydrateImageURL helper with smart proxy/direct URL selection
- Add comprehensive E2E and unit tests
- Configure via environment variables (IMAGE_PROXY_*)
- Add disintegration/imaging dependency for image processing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+6096 -39
+14
.env.dev.example
··· 91 SKIP_DID_WEB_VERIFICATION=true 92 AUTH_SKIP_VERIFY=true 93 HS256_ISSUERS=http://localhost:3001
··· 91 SKIP_DID_WEB_VERIFICATION=true 92 AUTH_SKIP_VERIFY=true 93 HS256_ISSUERS=http://localhost:3001 94 + 95 + # ============================================================================= 96 + # Image Proxy Configuration 97 + # ============================================================================= 98 + # On-the-fly image resizing with disk caching 99 + # Defaults to enabled - falls back to direct PDS URLs if proxy fails 100 + IMAGE_PROXY_ENABLED=true 101 + IMAGE_PROXY_BASE_URL=http://127.0.0.1:8081 102 + IMAGE_PROXY_CACHE_PATH=./cache/images 103 + IMAGE_PROXY_CACHE_MAX_GB=5 104 + # Optional: CDN URL for production (leave empty for local dev) 105 + # IMAGE_PROXY_CDN_URL= 106 + IMAGE_PROXY_FETCH_TIMEOUT_SECONDS=30 107 + IMAGE_PROXY_MAX_SOURCE_SIZE_MB=10
+14
.env.prod.example
··· 170 SKIP_DID_WEB_VERIFICATION=false 171 172 # ============================================================================= 173 # Optional: Versioning 174 # ============================================================================= 175 VERSION=latest
··· 170 SKIP_DID_WEB_VERIFICATION=false 171 172 # ============================================================================= 173 + # Image Proxy Configuration 174 + # ============================================================================= 175 + # On-the-fly image resizing with disk caching 176 + # Enabled by default - gracefully falls back to direct PDS URLs on failure 177 + IMAGE_PROXY_ENABLED=true 178 + IMAGE_PROXY_BASE_URL=https://coves.social 179 + IMAGE_PROXY_CACHE_PATH=/var/cache/coves/images 180 + IMAGE_PROXY_CACHE_MAX_GB=10 181 + # Optional: CDN URL for edge caching (recommended for production) 182 + # IMAGE_PROXY_CDN_URL=https://cdn.coves.social 183 + IMAGE_PROXY_FETCH_TIMEOUT_SECONDS=30 184 + IMAGE_PROXY_MAX_SOURCE_SIZE_MB=10 185 + 186 + # ============================================================================= 187 # Optional: Versioning 188 # ============================================================================= 189 VERSION=latest
+3
.gitignore
··· 38 /local_dev_data/ 39 /test_db_data/ 40 41 # Logs 42 *.log 43
··· 38 /local_dev_data/ 39 /test_db_data/ 40 41 + # Image proxy cache 42 + /cache/ 43 + 44 # Logs 45 *.log 46
+60
cmd/server/main.go
··· 24 "Coves/internal/atproto/jetstream" 25 "Coves/internal/atproto/oauth" 26 27 indigoauth "github.com/bluesky-social/indigo/atproto/auth" 28 indigoidentity "github.com/bluesky-social/indigo/atproto/identity" 29 "Coves/internal/core/aggregators" ··· 578 discoverService := discover.NewDiscoverService(discoverRepo) 579 log.Println("✅ Discover service initialized") 580 581 // Start Jetstream consumer for posts 582 // This consumer indexes posts created in community repositories via the firehose 583 // Currently handles only CREATE operations - UPDATE/DELETE deferred until those features exist ··· 829 // Stop background jobs 830 cleanupCancel() 831 tokenRefreshCancel() 832 833 if err := server.Shutdown(ctx); err != nil { 834 log.Fatalf("Server shutdown error: %v", err)
··· 24 "Coves/internal/atproto/jetstream" 25 "Coves/internal/atproto/oauth" 26 27 + imageproxyhandlers "Coves/internal/api/handlers/imageproxy" 28 + "Coves/internal/core/imageproxy" 29 + 30 indigoauth "github.com/bluesky-social/indigo/atproto/auth" 31 indigoidentity "github.com/bluesky-social/indigo/atproto/identity" 32 "Coves/internal/core/aggregators" ··· 581 discoverService := discover.NewDiscoverService(discoverRepo) 582 log.Println("✅ Discover service initialized") 583 584 + // Initialize image proxy (optional service for resizing/caching images) 585 + imageProxyConfig := imageproxy.ConfigFromEnv() 586 + var imageProxyCacheCleanupCancel context.CancelFunc = func() {} // No-op default 587 + if imageProxyConfig.Enabled { 588 + // Validate configuration at startup - fail fast if misconfigured 589 + if err := imageProxyConfig.Validate(); err != nil { 590 + log.Fatalf("Image proxy configuration error: %v", err) 591 + } 592 + 593 + imageProxyCache, err := imageproxy.NewDiskCache( 594 + imageProxyConfig.CachePath, 595 + imageProxyConfig.CacheMaxGB, 596 + imageProxyConfig.CacheTTLDays, 597 + ) 598 + if err != nil { 599 + log.Fatalf("Failed to create image proxy cache: %v", err) 600 + } 601 + 602 + // Start background cache cleanup job 603 + imageProxyCacheCleanupCancel = imageProxyCache.StartCleanupJob(imageProxyConfig.CleanupInterval) 604 + 605 + imageProxyProcessor := imageproxy.NewProcessor() 606 + imageProxyFetcher := imageproxy.NewPDSFetcher(imageProxyConfig.FetchTimeout, imageProxyConfig.MaxSourceSizeMB) 607 + imageProxyService, err := imageproxy.NewService( 608 + imageProxyCache, 609 + imageProxyProcessor, 610 + imageProxyFetcher, 611 + imageProxyConfig, 612 + ) 613 + if err != nil { 614 + log.Fatalf("Failed to create image proxy service: %v", err) 615 + } 616 + imageProxyHandler := imageproxyhandlers.NewHandler(imageProxyService, identityResolver) 617 + routes.RegisterImageProxyRoutes(r, imageProxyHandler) 618 + log.Println("✅ Image proxy enabled at /img/{preset}/plain/{did}/{cid}") 619 + slog.Info("[IMAGE-PROXY] service started", 620 + "base_url", imageProxyConfig.BaseURL, 621 + "cdn_url", imageProxyConfig.CDNURL, 622 + "cache_path", imageProxyConfig.CachePath, 623 + "cache_max_gb", imageProxyConfig.CacheMaxGB, 624 + "cache_ttl_days", imageProxyConfig.CacheTTLDays, 625 + "cleanup_interval", imageProxyConfig.CleanupInterval, 626 + "fetch_timeout_seconds", int(imageProxyConfig.FetchTimeout.Seconds()), 627 + "max_source_size_mb", imageProxyConfig.MaxSourceSizeMB, 628 + ) 629 + } 630 + 631 + // Initialize image proxy config for URL generation in communities package 632 + // This is called once at startup and is thread-safe for concurrent access 633 + communities.SetImageProxyConfig(blobs.ImageURLConfig{ 634 + ProxyEnabled: imageProxyConfig.Enabled, 635 + ProxyBaseURL: imageProxyConfig.BaseURL, 636 + CDNURL: imageProxyConfig.CDNURL, 637 + }) 638 + log.Printf("Image proxy URL generation config set (enabled: %v)", imageProxyConfig.Enabled) 639 + 640 // Start Jetstream consumer for posts 641 // This consumer indexes posts created in community repositories via the firehose 642 // Currently handles only CREATE operations - UPDATE/DELETE deferred until those features exist ··· 888 // Stop background jobs 889 cleanupCancel() 890 tokenRefreshCancel() 891 + imageProxyCacheCleanupCancel() 892 893 if err := server.Shutdown(ctx); err != nil { 894 log.Fatalf("Server shutdown error: %v", err)
+2
go.mod
··· 23 github.com/beorn7/perks v1.0.1 // indirect 24 github.com/cespare/xxhash/v2 v2.2.0 // indirect 25 github.com/davecgh/go-spew v1.1.1 // indirect 26 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 27 github.com/felixge/httpsnoop v1.0.4 // indirect 28 github.com/go-logr/logr v1.4.1 // indirect ··· 81 go.uber.org/multierr v1.11.0 // indirect 82 go.uber.org/zap v1.26.0 // indirect 83 golang.org/x/crypto v0.43.0 // indirect 84 golang.org/x/sync v0.10.0 // indirect 85 golang.org/x/sys v0.37.0 // indirect 86 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
··· 23 github.com/beorn7/perks v1.0.1 // indirect 24 github.com/cespare/xxhash/v2 v2.2.0 // indirect 25 github.com/davecgh/go-spew v1.1.1 // indirect 26 + github.com/disintegration/imaging v1.6.2 // indirect 27 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 28 github.com/felixge/httpsnoop v1.0.4 // indirect 29 github.com/go-logr/logr v1.4.1 // indirect ··· 82 go.uber.org/multierr v1.11.0 // indirect 83 go.uber.org/zap v1.26.0 // indirect 84 golang.org/x/crypto v0.43.0 // indirect 85 + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect 86 golang.org/x/sync v0.10.0 // indirect 87 golang.org/x/sys v0.37.0 // indirect 88 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
+4
go.sum
··· 10 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 14 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 15 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= ··· 211 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 212 golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 213 golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 214 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 215 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 216 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
··· 10 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 + github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= 14 + github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= 15 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 16 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 17 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= ··· 213 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 214 golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= 215 golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= 216 + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= 217 + golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 218 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 219 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= 220 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+189
internal/api/handlers/imageproxy/handler.go
···
··· 1 + // Package imageproxy provides HTTP handlers for the image proxy service. 2 + // It handles requests for proxied and transformed images from AT Protocol PDSes. 3 + package imageproxy 4 + 5 + import ( 6 + "context" 7 + "errors" 8 + "fmt" 9 + "log/slog" 10 + "net/http" 11 + 12 + "github.com/go-chi/chi/v5" 13 + 14 + "Coves/internal/atproto/identity" 15 + "Coves/internal/core/imageproxy" 16 + ) 17 + 18 + // Service defines the interface for the image proxy service. 19 + // This interface is implemented by the imageproxy package's service layer. 20 + type Service interface { 21 + // GetImage retrieves and processes an image from a PDS. 22 + // preset: the image transformation preset (e.g., "avatar", "banner") 23 + // did: the DID of the user who owns the blob 24 + // cid: the content identifier of the blob 25 + // pdsURL: the URL of the user's PDS 26 + GetImage(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) 27 + } 28 + 29 + // Handler handles HTTP requests for the image proxy. 30 + type Handler struct { 31 + service Service 32 + identityResolver identity.Resolver 33 + } 34 + 35 + // NewHandler creates a new image proxy handler. 36 + func NewHandler(service Service, resolver identity.Resolver) *Handler { 37 + return &Handler{ 38 + service: service, 39 + identityResolver: resolver, 40 + } 41 + } 42 + 43 + // HandleImage handles GET /img/{preset}/plain/{did}/{cid} 44 + // It fetches the image from the user's PDS, transforms it according to the preset, 45 + // and returns the result with appropriate caching headers. 46 + func (h *Handler) HandleImage(w http.ResponseWriter, r *http.Request) { 47 + // Parse URL parameters 48 + preset := chi.URLParam(r, "preset") 49 + did := chi.URLParam(r, "did") 50 + cid := chi.URLParam(r, "cid") 51 + 52 + // Validate required parameters 53 + if preset == "" || did == "" || cid == "" { 54 + writeErrorResponse(w, http.StatusBadRequest, "missing required parameters") 55 + return 56 + } 57 + 58 + // Validate preset exists before proceeding 59 + if _, err := imageproxy.GetPreset(preset); err != nil { 60 + if errors.Is(err, imageproxy.ErrInvalidPreset) { 61 + writeErrorResponse(w, http.StatusBadRequest, "invalid preset: "+preset) 62 + return 63 + } 64 + writeErrorResponse(w, http.StatusBadRequest, "invalid preset") 65 + return 66 + } 67 + 68 + // Validate DID format (must be did:plc: or did:web:) 69 + if err := imageproxy.ValidateDID(did); err != nil { 70 + writeErrorResponse(w, http.StatusBadRequest, "invalid DID format") 71 + return 72 + } 73 + 74 + // Validate CID format (must be valid base32/base58 CID) 75 + if err := imageproxy.ValidateCID(cid); err != nil { 76 + writeErrorResponse(w, http.StatusBadRequest, "invalid CID format") 77 + return 78 + } 79 + 80 + // Generate ETag for caching 81 + etag := fmt.Sprintf(`"%s-%s"`, preset, cid) 82 + 83 + // Check If-None-Match header for 304 response 84 + if r.Header.Get("If-None-Match") == etag { 85 + w.WriteHeader(http.StatusNotModified) 86 + return 87 + } 88 + 89 + // Resolve DID to get PDS URL 90 + didDoc, err := h.identityResolver.ResolveDID(r.Context(), did) 91 + if err != nil { 92 + slog.Warn("[IMAGE-PROXY] failed to resolve DID", 93 + "did", did, 94 + "error", err, 95 + ) 96 + writeErrorResponse(w, http.StatusBadGateway, "failed to resolve DID") 97 + return 98 + } 99 + 100 + // Extract PDS URL from DID document 101 + pdsURL := getPDSEndpoint(didDoc) 102 + if pdsURL == "" { 103 + slog.Warn("[IMAGE-PROXY] no PDS endpoint found in DID document", 104 + "did", did, 105 + ) 106 + writeErrorResponse(w, http.StatusBadGateway, "no PDS endpoint found") 107 + return 108 + } 109 + 110 + // Fetch and process the image 111 + imageData, err := h.service.GetImage(r.Context(), preset, did, cid, pdsURL) 112 + if err != nil { 113 + handleServiceError(w, err) 114 + return 115 + } 116 + 117 + // Set response headers 118 + w.Header().Set("Content-Type", "image/jpeg") 119 + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 120 + w.Header().Set("ETag", etag) 121 + 122 + // Write image data 123 + w.WriteHeader(http.StatusOK) 124 + if _, err := w.Write(imageData); err != nil { 125 + slog.Warn("[IMAGE-PROXY] failed to write image response", 126 + "preset", preset, 127 + "did", did, 128 + "cid", cid, 129 + "error", err, 130 + ) 131 + } 132 + } 133 + 134 + // getPDSEndpoint extracts the PDS service endpoint from a DID document. 135 + func getPDSEndpoint(doc *identity.DIDDocument) string { 136 + if doc == nil { 137 + return "" 138 + } 139 + for _, service := range doc.Service { 140 + if service.Type == "AtprotoPersonalDataServer" { 141 + return service.ServiceEndpoint 142 + } 143 + } 144 + return "" 145 + } 146 + 147 + // handleServiceError converts service errors to appropriate HTTP responses. 148 + func handleServiceError(w http.ResponseWriter, err error) { 149 + switch { 150 + case errors.Is(err, imageproxy.ErrPDSNotFound): 151 + writeErrorResponse(w, http.StatusNotFound, "blob not found") 152 + case errors.Is(err, imageproxy.ErrPDSTimeout): 153 + writeErrorResponse(w, http.StatusGatewayTimeout, "request timed out") 154 + case errors.Is(err, imageproxy.ErrPDSFetchFailed): 155 + writeErrorResponse(w, http.StatusBadGateway, "failed to fetch blob from PDS") 156 + case errors.Is(err, imageproxy.ErrInvalidPreset): 157 + writeErrorResponse(w, http.StatusBadRequest, "invalid preset") 158 + case errors.Is(err, imageproxy.ErrInvalidDID): 159 + writeErrorResponse(w, http.StatusBadRequest, "invalid DID format") 160 + case errors.Is(err, imageproxy.ErrInvalidCID): 161 + writeErrorResponse(w, http.StatusBadRequest, "invalid CID format") 162 + case errors.Is(err, imageproxy.ErrUnsupportedFormat): 163 + writeErrorResponse(w, http.StatusBadRequest, "unsupported image format") 164 + case errors.Is(err, imageproxy.ErrImageTooLarge): 165 + writeErrorResponse(w, http.StatusBadRequest, "image too large") 166 + case errors.Is(err, imageproxy.ErrProcessingFailed): 167 + writeErrorResponse(w, http.StatusInternalServerError, "image processing failed") 168 + default: 169 + slog.Error("[IMAGE-PROXY] unhandled service error", 170 + "error", err, 171 + ) 172 + writeErrorResponse(w, http.StatusInternalServerError, "internal server error") 173 + } 174 + } 175 + 176 + // writeErrorResponse writes a plain text error response. 177 + // For the image proxy, we use simple text responses rather than JSON 178 + // since the expected response is binary image data. 179 + func writeErrorResponse(w http.ResponseWriter, status int, message string) { 180 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") 181 + w.WriteHeader(status) 182 + if _, err := w.Write([]byte(message)); err != nil { 183 + slog.Warn("[IMAGE-PROXY] failed to write error response", 184 + "status", status, 185 + "message", message, 186 + "error", err, 187 + ) 188 + } 189 + }
+663
internal/api/handlers/imageproxy/handler_test.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + 10 + "github.com/go-chi/chi/v5" 11 + 12 + "Coves/internal/atproto/identity" 13 + "Coves/internal/core/imageproxy" 14 + ) 15 + 16 + // Valid test constants that pass validation 17 + const ( 18 + // validTestDID is a valid did:plc identifier (24 lowercase base32 chars after did:plc:) 19 + validTestDID = "did:plc:z72i7hdynmk6r22z27h6tvur" 20 + // validTestCID is a valid CIDv1 base32 identifier 21 + validTestCID = "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi" 22 + ) 23 + 24 + // mockService implements imageproxy.Service for testing 25 + type mockService struct { 26 + getImageFunc func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) 27 + } 28 + 29 + func (m *mockService) GetImage(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 30 + if m.getImageFunc != nil { 31 + return m.getImageFunc(ctx, preset, did, cid, pdsURL) 32 + } 33 + return nil, errors.New("not implemented") 34 + } 35 + 36 + // mockIdentityResolver implements identity.Resolver for testing 37 + type mockIdentityResolver struct { 38 + resolveFunc func(ctx context.Context, identifier string) (*identity.Identity, error) 39 + resolveDIDFunc func(ctx context.Context, did string) (*identity.DIDDocument, error) 40 + } 41 + 42 + func (m *mockIdentityResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 43 + if m.resolveFunc != nil { 44 + return m.resolveFunc(ctx, identifier) 45 + } 46 + return nil, errors.New("not implemented") 47 + } 48 + 49 + func (m *mockIdentityResolver) ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error) { 50 + return "", "", errors.New("not implemented") 51 + } 52 + 53 + func (m *mockIdentityResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 54 + if m.resolveDIDFunc != nil { 55 + return m.resolveDIDFunc(ctx, did) 56 + } 57 + return nil, errors.New("not implemented") 58 + } 59 + 60 + func (m *mockIdentityResolver) Purge(ctx context.Context, identifier string) error { 61 + return nil 62 + } 63 + 64 + // createTestRequest creates an HTTP request with chi URL params 65 + func createTestRequest(method, path string, params map[string]string) *http.Request { 66 + req := httptest.NewRequest(method, path, nil) 67 + rctx := chi.NewRouteContext() 68 + for k, v := range params { 69 + rctx.URLParams.Add(k, v) 70 + } 71 + return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) 72 + } 73 + 74 + func TestHandler_HandleImage_Success(t *testing.T) { 75 + expectedImage := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes 76 + testPDSURL := "https://pds.example.com" 77 + testPreset := "avatar" 78 + 79 + mockSvc := &mockService{ 80 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 81 + if preset != testPreset { 82 + t.Errorf("Expected preset %q, got %q", testPreset, preset) 83 + } 84 + if did != validTestDID { 85 + t.Errorf("Expected DID %q, got %q", validTestDID, did) 86 + } 87 + if cid != validTestCID { 88 + t.Errorf("Expected CID %q, got %q", validTestCID, cid) 89 + } 90 + if pdsURL != testPDSURL { 91 + t.Errorf("Expected PDS URL %q, got %q", testPDSURL, pdsURL) 92 + } 93 + return expectedImage, nil 94 + }, 95 + } 96 + 97 + mockResolver := &mockIdentityResolver{ 98 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 99 + return &identity.DIDDocument{ 100 + DID: did, 101 + Service: []identity.Service{ 102 + { 103 + ID: "#atproto_pds", 104 + Type: "AtprotoPersonalDataServer", 105 + ServiceEndpoint: testPDSURL, 106 + }, 107 + }, 108 + }, nil 109 + }, 110 + } 111 + 112 + handler := NewHandler(mockSvc, mockResolver) 113 + 114 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 115 + "preset": testPreset, 116 + "did": validTestDID, 117 + "cid": validTestCID, 118 + }) 119 + 120 + w := httptest.NewRecorder() 121 + handler.HandleImage(w, req) 122 + 123 + if w.Code != http.StatusOK { 124 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 125 + } 126 + 127 + // Verify Content-Type 128 + contentType := w.Header().Get("Content-Type") 129 + if contentType != "image/jpeg" { 130 + t.Errorf("Expected Content-Type image/jpeg, got %s", contentType) 131 + } 132 + 133 + // Verify Cache-Control 134 + cacheControl := w.Header().Get("Cache-Control") 135 + expectedCacheControl := "public, max-age=31536000, immutable" 136 + if cacheControl != expectedCacheControl { 137 + t.Errorf("Expected Cache-Control %q, got %q", expectedCacheControl, cacheControl) 138 + } 139 + 140 + // Verify ETag format 141 + etag := w.Header().Get("ETag") 142 + expectedETag := `"avatar-` + validTestCID + `"` 143 + if etag != expectedETag { 144 + t.Errorf("Expected ETag %q, got %q", expectedETag, etag) 145 + } 146 + 147 + // Verify body 148 + if w.Body.Len() != len(expectedImage) { 149 + t.Errorf("Expected body length %d, got %d", len(expectedImage), w.Body.Len()) 150 + } 151 + } 152 + 153 + func TestHandler_HandleImage_ETagMatch_Returns304(t *testing.T) { 154 + testPreset := "avatar" 155 + 156 + mockSvc := &mockService{ 157 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 158 + t.Error("Service should not be called when ETag matches") 159 + return nil, nil 160 + }, 161 + } 162 + 163 + mockResolver := &mockIdentityResolver{ 164 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 165 + t.Error("Resolver should not be called when ETag matches") 166 + return nil, nil 167 + }, 168 + } 169 + 170 + handler := NewHandler(mockSvc, mockResolver) 171 + 172 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 173 + "preset": testPreset, 174 + "did": validTestDID, 175 + "cid": validTestCID, 176 + }) 177 + // Set If-None-Match header with matching ETag 178 + req.Header.Set("If-None-Match", `"avatar-`+validTestCID+`"`) 179 + 180 + w := httptest.NewRecorder() 181 + handler.HandleImage(w, req) 182 + 183 + if w.Code != http.StatusNotModified { 184 + t.Errorf("Expected status 304, got %d. Body: %s", w.Code, w.Body.String()) 185 + } 186 + 187 + // Verify no body in 304 response 188 + if w.Body.Len() != 0 { 189 + t.Errorf("Expected empty body for 304 response, got %d bytes", w.Body.Len()) 190 + } 191 + } 192 + 193 + func TestHandler_HandleImage_ETagMismatch_ReturnsImage(t *testing.T) { 194 + expectedImage := []byte{0xFF, 0xD8, 0xFF, 0xE0} 195 + testPreset := "avatar" 196 + testPDSURL := "https://pds.example.com" 197 + 198 + serviceCalled := false 199 + mockSvc := &mockService{ 200 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 201 + serviceCalled = true 202 + return expectedImage, nil 203 + }, 204 + } 205 + 206 + mockResolver := &mockIdentityResolver{ 207 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 208 + return &identity.DIDDocument{ 209 + DID: did, 210 + Service: []identity.Service{ 211 + { 212 + ID: "#atproto_pds", 213 + Type: "AtprotoPersonalDataServer", 214 + ServiceEndpoint: testPDSURL, 215 + }, 216 + }, 217 + }, nil 218 + }, 219 + } 220 + 221 + handler := NewHandler(mockSvc, mockResolver) 222 + 223 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 224 + "preset": testPreset, 225 + "did": validTestDID, 226 + "cid": validTestCID, 227 + }) 228 + // Set If-None-Match header with different ETag 229 + req.Header.Set("If-None-Match", `"other-preset-somecid"`) 230 + 231 + w := httptest.NewRecorder() 232 + handler.HandleImage(w, req) 233 + 234 + if w.Code != http.StatusOK { 235 + t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) 236 + } 237 + 238 + if !serviceCalled { 239 + t.Error("Service should have been called when ETag doesn't match") 240 + } 241 + } 242 + 243 + func TestHandler_HandleImage_InvalidPreset_Returns400(t *testing.T) { 244 + mockSvc := &mockService{} 245 + mockResolver := &mockIdentityResolver{} 246 + 247 + handler := NewHandler(mockSvc, mockResolver) 248 + 249 + req := createTestRequest(http.MethodGet, "/img/invalid_preset/plain/did:plc:test/somecid", map[string]string{ 250 + "preset": "invalid_preset", 251 + "did": "did:plc:test", 252 + "cid": "somecid", 253 + }) 254 + 255 + w := httptest.NewRecorder() 256 + handler.HandleImage(w, req) 257 + 258 + if w.Code != http.StatusBadRequest { 259 + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 260 + } 261 + 262 + // Verify error response contains error info 263 + body := w.Body.String() 264 + if body == "" { 265 + t.Error("Expected error message in response body") 266 + } 267 + } 268 + 269 + func TestHandler_HandleImage_DIDResolutionFailed_Returns502(t *testing.T) { 270 + mockSvc := &mockService{} 271 + mockResolver := &mockIdentityResolver{ 272 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 273 + return nil, errors.New("failed to resolve DID") 274 + }, 275 + } 276 + 277 + handler := NewHandler(mockSvc, mockResolver) 278 + 279 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 280 + "preset": "avatar", 281 + "did": validTestDID, 282 + "cid": validTestCID, 283 + }) 284 + 285 + w := httptest.NewRecorder() 286 + handler.HandleImage(w, req) 287 + 288 + if w.Code != http.StatusBadGateway { 289 + t.Errorf("Expected status 502, got %d. Body: %s", w.Code, w.Body.String()) 290 + } 291 + } 292 + 293 + func TestHandler_HandleImage_BlobNotFound_Returns404(t *testing.T) { 294 + testPDSURL := "https://pds.example.com" 295 + 296 + mockSvc := &mockService{ 297 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 298 + return nil, imageproxy.ErrPDSNotFound 299 + }, 300 + } 301 + 302 + mockResolver := &mockIdentityResolver{ 303 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 304 + return &identity.DIDDocument{ 305 + DID: did, 306 + Service: []identity.Service{ 307 + { 308 + ID: "#atproto_pds", 309 + Type: "AtprotoPersonalDataServer", 310 + ServiceEndpoint: testPDSURL, 311 + }, 312 + }, 313 + }, nil 314 + }, 315 + } 316 + 317 + handler := NewHandler(mockSvc, mockResolver) 318 + 319 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 320 + "preset": "avatar", 321 + "did": validTestDID, 322 + "cid": validTestCID, 323 + }) 324 + 325 + w := httptest.NewRecorder() 326 + handler.HandleImage(w, req) 327 + 328 + if w.Code != http.StatusNotFound { 329 + t.Errorf("Expected status 404, got %d. Body: %s", w.Code, w.Body.String()) 330 + } 331 + } 332 + 333 + func TestHandler_HandleImage_Timeout_Returns504(t *testing.T) { 334 + testPDSURL := "https://pds.example.com" 335 + 336 + mockSvc := &mockService{ 337 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 338 + return nil, imageproxy.ErrPDSTimeout 339 + }, 340 + } 341 + 342 + mockResolver := &mockIdentityResolver{ 343 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 344 + return &identity.DIDDocument{ 345 + DID: did, 346 + Service: []identity.Service{ 347 + { 348 + ID: "#atproto_pds", 349 + Type: "AtprotoPersonalDataServer", 350 + ServiceEndpoint: testPDSURL, 351 + }, 352 + }, 353 + }, nil 354 + }, 355 + } 356 + 357 + handler := NewHandler(mockSvc, mockResolver) 358 + 359 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 360 + "preset": "avatar", 361 + "did": validTestDID, 362 + "cid": validTestCID, 363 + }) 364 + 365 + w := httptest.NewRecorder() 366 + handler.HandleImage(w, req) 367 + 368 + if w.Code != http.StatusGatewayTimeout { 369 + t.Errorf("Expected status 504, got %d. Body: %s", w.Code, w.Body.String()) 370 + } 371 + } 372 + 373 + func TestHandler_HandleImage_InternalError_Returns500(t *testing.T) { 374 + testPDSURL := "https://pds.example.com" 375 + 376 + mockSvc := &mockService{ 377 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 378 + return nil, errors.New("unexpected internal error") 379 + }, 380 + } 381 + 382 + mockResolver := &mockIdentityResolver{ 383 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 384 + return &identity.DIDDocument{ 385 + DID: did, 386 + Service: []identity.Service{ 387 + { 388 + ID: "#atproto_pds", 389 + Type: "AtprotoPersonalDataServer", 390 + ServiceEndpoint: testPDSURL, 391 + }, 392 + }, 393 + }, nil 394 + }, 395 + } 396 + 397 + handler := NewHandler(mockSvc, mockResolver) 398 + 399 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 400 + "preset": "avatar", 401 + "did": validTestDID, 402 + "cid": validTestCID, 403 + }) 404 + 405 + w := httptest.NewRecorder() 406 + handler.HandleImage(w, req) 407 + 408 + if w.Code != http.StatusInternalServerError { 409 + t.Errorf("Expected status 500, got %d. Body: %s", w.Code, w.Body.String()) 410 + } 411 + } 412 + 413 + func TestHandler_HandleImage_PDSFetchFailed_Returns502(t *testing.T) { 414 + testPDSURL := "https://pds.example.com" 415 + 416 + mockSvc := &mockService{ 417 + getImageFunc: func(ctx context.Context, preset, did, cid, pdsURL string) ([]byte, error) { 418 + return nil, imageproxy.ErrPDSFetchFailed 419 + }, 420 + } 421 + 422 + mockResolver := &mockIdentityResolver{ 423 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 424 + return &identity.DIDDocument{ 425 + DID: did, 426 + Service: []identity.Service{ 427 + { 428 + ID: "#atproto_pds", 429 + Type: "AtprotoPersonalDataServer", 430 + ServiceEndpoint: testPDSURL, 431 + }, 432 + }, 433 + }, nil 434 + }, 435 + } 436 + 437 + handler := NewHandler(mockSvc, mockResolver) 438 + 439 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 440 + "preset": "avatar", 441 + "did": validTestDID, 442 + "cid": validTestCID, 443 + }) 444 + 445 + w := httptest.NewRecorder() 446 + handler.HandleImage(w, req) 447 + 448 + if w.Code != http.StatusBadGateway { 449 + t.Errorf("Expected status 502, got %d. Body: %s", w.Code, w.Body.String()) 450 + } 451 + } 452 + 453 + func TestHandler_HandleImage_MissingParams(t *testing.T) { 454 + mockSvc := &mockService{} 455 + mockResolver := &mockIdentityResolver{} 456 + 457 + handler := NewHandler(mockSvc, mockResolver) 458 + 459 + tests := []struct { 460 + name string 461 + params map[string]string 462 + }{ 463 + { 464 + name: "missing preset", 465 + params: map[string]string{"did": "did:plc:test", "cid": "somecid"}, 466 + }, 467 + { 468 + name: "missing did", 469 + params: map[string]string{"preset": "avatar", "cid": "somecid"}, 470 + }, 471 + { 472 + name: "missing cid", 473 + params: map[string]string{"preset": "avatar", "did": "did:plc:test"}, 474 + }, 475 + { 476 + name: "empty preset", 477 + params: map[string]string{"preset": "", "did": "did:plc:test", "cid": "somecid"}, 478 + }, 479 + { 480 + name: "empty did", 481 + params: map[string]string{"preset": "avatar", "did": "", "cid": "somecid"}, 482 + }, 483 + { 484 + name: "empty cid", 485 + params: map[string]string{"preset": "avatar", "did": "did:plc:test", "cid": ""}, 486 + }, 487 + } 488 + 489 + for _, tc := range tests { 490 + t.Run(tc.name, func(t *testing.T) { 491 + req := createTestRequest(http.MethodGet, "/img/test/plain/did:plc:test/cid", tc.params) 492 + 493 + w := httptest.NewRecorder() 494 + handler.HandleImage(w, req) 495 + 496 + if w.Code != http.StatusBadRequest { 497 + t.Errorf("Expected status 400, got %d. Body: %s", w.Code, w.Body.String()) 498 + } 499 + }) 500 + } 501 + } 502 + 503 + func TestHandler_HandleImage_AllPresets(t *testing.T) { 504 + expectedImage := []byte{0xFF, 0xD8, 0xFF, 0xE0} 505 + testPDSURL := "https://pds.example.com" 506 + 507 + // Test all valid presets 508 + validPresets := []string{"avatar", "avatar_small", "banner", "content_preview", "content_full", "embed_thumbnail"} 509 + 510 + for _, preset := range validPresets { 511 + t.Run(preset, func(t *testing.T) { 512 + mockSvc := &mockService{ 513 + getImageFunc: func(ctx context.Context, p, did, cid, pdsURL string) ([]byte, error) { 514 + if p != preset { 515 + t.Errorf("Expected preset %q, got %q", preset, p) 516 + } 517 + return expectedImage, nil 518 + }, 519 + } 520 + 521 + mockResolver := &mockIdentityResolver{ 522 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 523 + return &identity.DIDDocument{ 524 + DID: did, 525 + Service: []identity.Service{ 526 + { 527 + ID: "#atproto_pds", 528 + Type: "AtprotoPersonalDataServer", 529 + ServiceEndpoint: testPDSURL, 530 + }, 531 + }, 532 + }, nil 533 + }, 534 + } 535 + 536 + handler := NewHandler(mockSvc, mockResolver) 537 + 538 + req := createTestRequest(http.MethodGet, "/img/"+preset+"/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 539 + "preset": preset, 540 + "did": validTestDID, 541 + "cid": validTestCID, 542 + }) 543 + 544 + w := httptest.NewRecorder() 545 + handler.HandleImage(w, req) 546 + 547 + if w.Code != http.StatusOK { 548 + t.Errorf("Expected status 200 for preset %q, got %d. Body: %s", preset, w.Code, w.Body.String()) 549 + } 550 + 551 + // Verify ETag matches preset 552 + etag := w.Header().Get("ETag") 553 + expectedETag := `"` + preset + `-` + validTestCID + `"` 554 + if etag != expectedETag { 555 + t.Errorf("Expected ETag %q, got %q", expectedETag, etag) 556 + } 557 + }) 558 + } 559 + } 560 + 561 + func TestHandler_HandleImage_NoPDSEndpoint_Returns502(t *testing.T) { 562 + mockSvc := &mockService{} 563 + mockResolver := &mockIdentityResolver{ 564 + resolveDIDFunc: func(ctx context.Context, did string) (*identity.DIDDocument, error) { 565 + // Return document without PDS service 566 + return &identity.DIDDocument{ 567 + DID: did, 568 + Service: []identity.Service{}, 569 + }, nil 570 + }, 571 + } 572 + 573 + handler := NewHandler(mockSvc, mockResolver) 574 + 575 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+validTestCID, map[string]string{ 576 + "preset": "avatar", 577 + "did": validTestDID, 578 + "cid": validTestCID, 579 + }) 580 + 581 + w := httptest.NewRecorder() 582 + handler.HandleImage(w, req) 583 + 584 + if w.Code != http.StatusBadGateway { 585 + t.Errorf("Expected status 502, got %d. Body: %s", w.Code, w.Body.String()) 586 + } 587 + } 588 + 589 + // TestHandler_HandleImage_InvalidDID tests that invalid DIDs are rejected 590 + // Note: We use Indigo's syntax.ParseDID for validation consistency with the codebase. 591 + // Some DIDs that look "wrong" (like did:plc:abc) are actually valid per Indigo's parser. 592 + func TestHandler_HandleImage_InvalidDID(t *testing.T) { 593 + mockSvc := &mockService{} 594 + mockResolver := &mockIdentityResolver{} 595 + 596 + handler := NewHandler(mockSvc, mockResolver) 597 + 598 + // These DIDs are invalid per Indigo's syntax.ParseDID (or fail our security checks) 599 + // Note: null bytes can't be tested at HTTP layer - Go's HTTP library rejects them first 600 + invalidDIDs := []struct { 601 + name string 602 + did string 603 + }{ 604 + {"missing method", "did:abc123"}, 605 + {"path traversal", "did:plc:../../../etc/passwd"}, 606 + {"not a DID", "notadid"}, 607 + {"forward slash", "did:plc:abc/def"}, 608 + {"backslash", "did:plc:abc\\def"}, 609 + {"empty string", ""}, 610 + } 611 + 612 + for _, tc := range invalidDIDs { 613 + t.Run(tc.name, func(t *testing.T) { 614 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+tc.did+"/"+validTestCID, map[string]string{ 615 + "preset": "avatar", 616 + "did": tc.did, 617 + "cid": validTestCID, 618 + }) 619 + 620 + w := httptest.NewRecorder() 621 + handler.HandleImage(w, req) 622 + 623 + if w.Code != http.StatusBadRequest { 624 + t.Errorf("Expected status 400 for invalid DID %q, got %d. Body: %s", tc.did, w.Code, w.Body.String()) 625 + } 626 + }) 627 + } 628 + } 629 + 630 + // TestHandler_HandleImage_InvalidCID tests that invalid CIDs are rejected 631 + func TestHandler_HandleImage_InvalidCID(t *testing.T) { 632 + mockSvc := &mockService{} 633 + mockResolver := &mockIdentityResolver{} 634 + 635 + handler := NewHandler(mockSvc, mockResolver) 636 + 637 + invalidCIDs := []struct { 638 + name string 639 + cid string 640 + }{ 641 + {"too short", "bafyabc"}, 642 + {"path traversal", "../../../etc/passwd"}, 643 + {"contains slash", "bafy/path/to/file"}, 644 + {"random string", "this_is_not_a_cid"}, 645 + } 646 + 647 + for _, tc := range invalidCIDs { 648 + t.Run(tc.name, func(t *testing.T) { 649 + req := createTestRequest(http.MethodGet, "/img/avatar/plain/"+validTestDID+"/"+tc.cid, map[string]string{ 650 + "preset": "avatar", 651 + "did": validTestDID, 652 + "cid": tc.cid, 653 + }) 654 + 655 + w := httptest.NewRecorder() 656 + handler.HandleImage(w, req) 657 + 658 + if w.Code != http.StatusBadRequest { 659 + t.Errorf("Expected status 400 for invalid CID %q, got %d. Body: %s", tc.cid, w.Code, w.Body.String()) 660 + } 661 + }) 662 + } 663 + }
+22
internal/api/routes/imageproxy.go
···
··· 1 + package routes 2 + 3 + import ( 4 + "github.com/go-chi/chi/v5" 5 + 6 + imageproxyhandlers "Coves/internal/api/handlers/imageproxy" 7 + ) 8 + 9 + // RegisterImageProxyRoutes registers image proxy endpoints on the router. 10 + // The image proxy serves transformed images from AT Protocol PDSes. 11 + // 12 + // Route: GET /img/{preset}/plain/{did}/{cid} 13 + // 14 + // Parameters: 15 + // - preset: Image transformation preset (e.g., "avatar", "banner", "content_preview") 16 + // - did: DID of the user who owns the blob 17 + // - cid: Content identifier of the blob 18 + // 19 + // The endpoint supports ETag-based caching with If-None-Match headers. 20 + func RegisterImageProxyRoutes(r chi.Router, handler *imageproxyhandlers.Handler) { 21 + r.Get("/img/{preset}/plain/{did}/{cid}", handler.HandleImage) 22 + }
+56
internal/core/blobs/types.go
··· 1 package blobs 2 3 import ( 4 "net/url" 5 "strings" 6 ) ··· 23 return strings.TrimSuffix(pdsURL, "/") + "/xrpc/com.atproto.sync.getBlob?did=" + 24 url.QueryEscape(did) + "&cid=" + url.QueryEscape(cid) 25 }
··· 1 package blobs 2 3 import ( 4 + "log/slog" 5 "net/url" 6 "strings" 7 ) ··· 24 return strings.TrimSuffix(pdsURL, "/") + "/xrpc/com.atproto.sync.getBlob?did=" + 25 url.QueryEscape(did) + "&cid=" + url.QueryEscape(cid) 26 } 27 + 28 + // HydrateImageProxyURL generates a URL for the image proxy with the specified preset. 29 + // Format: {proxyBaseURL}/img/{preset}/plain/{did}/{cid} 30 + // If proxyBaseURL is empty, generates a relative URL: /img/{preset}/plain/{did}/{cid} 31 + // Returns empty string if preset, did, or cid are empty. 32 + // DID and CID are URL-escaped for safety in path segments. 33 + func HydrateImageProxyURL(proxyBaseURL, preset, did, cid string) string { 34 + if preset == "" || did == "" || cid == "" { 35 + return "" 36 + } 37 + return strings.TrimSuffix(proxyBaseURL, "/") + "/img/" + preset + "/plain/" + 38 + url.PathEscape(did) + "/" + url.PathEscape(cid) 39 + } 40 + 41 + // ImageURLConfig holds configuration for image URL generation. 42 + type ImageURLConfig struct { 43 + ProxyEnabled bool // Whether the image proxy is enabled 44 + ProxyBaseURL string // Base URL for the image proxy (e.g., "https://coves.social") 45 + CDNURL string // Optional CDN override URL 46 + } 47 + 48 + // HydrateImageURL generates the appropriate image URL based on config. 49 + // If proxy is disabled, returns direct PDS URL via HydrateBlobURL. 50 + // If CDN URL is set and proxy is enabled, uses CDN instead of ProxyBaseURL. 51 + // Returns empty string if the generated URL would be invalid. 52 + func HydrateImageURL(config ImageURLConfig, pdsURL, did, cid, preset string) string { 53 + if !config.ProxyEnabled { 54 + return HydrateBlobURL(pdsURL, did, cid) 55 + } 56 + 57 + // Determine which base URL to use 58 + baseURL := config.ProxyBaseURL 59 + if config.CDNURL != "" { 60 + baseURL = config.CDNURL 61 + } 62 + 63 + // Generate proxy URL 64 + proxyURL := HydrateImageProxyURL(baseURL, preset, did, cid) 65 + 66 + // If proxy URL generation failed (e.g., empty preset or base URL), fall back to direct URL 67 + // Log this as it indicates a configuration problem when proxy is enabled 68 + if proxyURL == "" { 69 + slog.Warn("[IMAGE-PROXY] proxy URL generation failed, falling back to direct PDS URL", 70 + "proxy_enabled", config.ProxyEnabled, 71 + "proxy_base_url", config.ProxyBaseURL, 72 + "cdn_url", config.CDNURL, 73 + "preset", preset, 74 + "did", did, 75 + "cid", cid, 76 + ) 77 + return HydrateBlobURL(pdsURL, did, cid) 78 + } 79 + 80 + return proxyURL 81 + }
+202 -1
internal/core/blobs/types_test.go
··· 1 package blobs 2 3 - import "testing" 4 5 func TestHydrateBlobURL(t *testing.T) { 6 tests := []struct { ··· 71 }) 72 } 73 }
··· 1 package blobs 2 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 8 func TestHydrateBlobURL(t *testing.T) { 9 tests := []struct { ··· 74 }) 75 } 76 } 77 + 78 + func TestHydrateImageProxyURL(t *testing.T) { 79 + tests := []struct { 80 + name string 81 + proxyBaseURL string 82 + preset string 83 + did string 84 + cid string 85 + expected string 86 + }{ 87 + { 88 + name: "generates correct format", 89 + proxyBaseURL: "https://coves.social", 90 + preset: "avatar", 91 + did: "did:plc:abc123", 92 + cid: "bafyreiabc123", 93 + expected: "https://coves.social/img/avatar/plain/did:plc:abc123/bafyreiabc123", 94 + }, 95 + { 96 + name: "trailing slash on proxy URL removed", 97 + proxyBaseURL: "https://coves.social/", 98 + preset: "thumb", 99 + did: "did:plc:abc123", 100 + cid: "bafyreiabc123", 101 + expected: "https://coves.social/img/thumb/plain/did:plc:abc123/bafyreiabc123", 102 + }, 103 + { 104 + name: "empty proxyBaseURL generates relative URL", 105 + proxyBaseURL: "", 106 + preset: "avatar", 107 + did: "did:plc:abc123", 108 + cid: "bafyreiabc123", 109 + expected: "/img/avatar/plain/did:plc:abc123/bafyreiabc123", 110 + }, 111 + { 112 + name: "empty preset returns empty", 113 + proxyBaseURL: "https://coves.social", 114 + preset: "", 115 + did: "did:plc:abc123", 116 + cid: "bafyreiabc123", 117 + expected: "", 118 + }, 119 + { 120 + name: "empty did returns empty", 121 + proxyBaseURL: "https://coves.social", 122 + preset: "avatar", 123 + did: "", 124 + cid: "bafyreiabc123", 125 + expected: "", 126 + }, 127 + { 128 + name: "empty cid returns empty", 129 + proxyBaseURL: "https://coves.social", 130 + preset: "avatar", 131 + did: "did:plc:abc123", 132 + cid: "", 133 + expected: "", 134 + }, 135 + { 136 + name: "all empty returns empty", 137 + proxyBaseURL: "", 138 + preset: "", 139 + did: "", 140 + cid: "", 141 + expected: "", 142 + }, 143 + { 144 + name: "DID with colons preserved in path", 145 + proxyBaseURL: "https://coves.social", 146 + preset: "avatar", 147 + did: "did:web:example.com:user", 148 + cid: "bafyreiabc123", 149 + // Colons are allowed in path segments per RFC 3986 150 + expected: "https://coves.social/img/avatar/plain/did:web:example.com:user/bafyreiabc123", 151 + }, 152 + { 153 + name: "forward slashes escaped in CID", 154 + proxyBaseURL: "https://coves.social", 155 + preset: "avatar", 156 + did: "did:plc:abc123", 157 + cid: "bafyrei+special/chars", 158 + // Forward slashes must be escaped; plus signs allowed per RFC 3986 159 + expected: "https://coves.social/img/avatar/plain/did:plc:abc123/bafyrei+special%2Fchars", 160 + }, 161 + } 162 + 163 + for _, tt := range tests { 164 + t.Run(tt.name, func(t *testing.T) { 165 + result := HydrateImageProxyURL(tt.proxyBaseURL, tt.preset, tt.did, tt.cid) 166 + if result != tt.expected { 167 + t.Errorf("HydrateImageProxyURL(%q, %q, %q, %q) = %q, want %q", 168 + tt.proxyBaseURL, tt.preset, tt.did, tt.cid, result, tt.expected) 169 + } 170 + }) 171 + } 172 + } 173 + 174 + func TestHydrateImageURL_ProxyDisabled(t *testing.T) { 175 + config := ImageURLConfig{ 176 + ProxyEnabled: false, 177 + ProxyBaseURL: "https://coves.social", 178 + } 179 + pdsURL := "https://pds.example.com" 180 + did := "did:plc:abc123" 181 + cid := "bafyreiabc123" 182 + preset := "avatar" 183 + 184 + result := HydrateImageURL(config, pdsURL, did, cid, preset) 185 + 186 + // Should return direct PDS URL when proxy is disabled 187 + expected := HydrateBlobURL(pdsURL, did, cid) 188 + if result != expected { 189 + t.Errorf("HydrateImageURL with proxy disabled = %q, want %q", result, expected) 190 + } 191 + } 192 + 193 + func TestHydrateImageURL_ProxyEnabled(t *testing.T) { 194 + config := ImageURLConfig{ 195 + ProxyEnabled: true, 196 + ProxyBaseURL: "https://coves.social", 197 + } 198 + pdsURL := "https://pds.example.com" 199 + did := "did:plc:abc123" 200 + cid := "bafyreiabc123" 201 + preset := "avatar" 202 + 203 + result := HydrateImageURL(config, pdsURL, did, cid, preset) 204 + 205 + // Should return proxy URL when proxy is enabled 206 + expected := HydrateImageProxyURL(config.ProxyBaseURL, preset, did, cid) 207 + if result != expected { 208 + t.Errorf("HydrateImageURL with proxy enabled = %q, want %q", result, expected) 209 + } 210 + } 211 + 212 + func TestHydrateImageURL_CDNOverride(t *testing.T) { 213 + config := ImageURLConfig{ 214 + ProxyEnabled: true, 215 + ProxyBaseURL: "https://coves.social", 216 + CDNURL: "https://cdn.coves.social", 217 + } 218 + pdsURL := "https://pds.example.com" 219 + did := "did:plc:abc123" 220 + cid := "bafyreiabc123" 221 + preset := "avatar" 222 + 223 + result := HydrateImageURL(config, pdsURL, did, cid, preset) 224 + 225 + // Should use CDN URL instead of proxy base URL 226 + expected := HydrateImageProxyURL(config.CDNURL, preset, did, cid) 227 + if result != expected { 228 + t.Errorf("HydrateImageURL with CDN URL = %q, want %q", result, expected) 229 + } 230 + 231 + // Verify CDN URL is actually in the result 232 + if !strings.HasPrefix(result, "https://cdn.coves.social/") { 233 + t.Errorf("Expected CDN URL prefix, got %q", result) 234 + } 235 + } 236 + 237 + func TestHydrateImageURL_EmptyPresetUsesDirectURL(t *testing.T) { 238 + config := ImageURLConfig{ 239 + ProxyEnabled: true, 240 + ProxyBaseURL: "https://coves.social", 241 + } 242 + pdsURL := "https://pds.example.com" 243 + did := "did:plc:abc123" 244 + cid := "bafyreiabc123" 245 + preset := "" // empty preset 246 + 247 + result := HydrateImageURL(config, pdsURL, did, cid, preset) 248 + 249 + // With empty preset, proxy URL will return empty, so fall back to direct URL 250 + // This tests the behavior when preset is not specified 251 + expected := HydrateBlobURL(pdsURL, did, cid) 252 + if result != expected { 253 + t.Errorf("HydrateImageURL with empty preset = %q, want %q", result, expected) 254 + } 255 + } 256 + 257 + func TestImageURLConfig(t *testing.T) { 258 + // Test that ImageURLConfig holds correct fields 259 + config := ImageURLConfig{ 260 + ProxyEnabled: true, 261 + ProxyBaseURL: "https://coves.social", 262 + CDNURL: "https://cdn.coves.social", 263 + } 264 + 265 + if !config.ProxyEnabled { 266 + t.Error("ProxyEnabled should be true") 267 + } 268 + if config.ProxyBaseURL != "https://coves.social" { 269 + t.Errorf("ProxyBaseURL = %q, want %q", config.ProxyBaseURL, "https://coves.social") 270 + } 271 + if config.CDNURL != "https://cdn.coves.social" { 272 + t.Errorf("CDNURL = %q, want %q", config.CDNURL, "https://cdn.coves.social") 273 + } 274 + }
+47 -3
internal/core/communities/community.go
··· 4 "fmt" 5 "log" 6 "strings" 7 "time" 8 9 "Coves/internal/core/blobs" 10 ) 11 12 // Community represents a Coves community indexed from the firehose 13 // Communities are federated, instance-scoped forums built on atProto ··· 248 } 249 250 // ToCommunityView converts a Community to a CommunityView for API responses 251 func (c *Community) ToCommunityView() *CommunityView { 252 view := &CommunityView{ 253 DID: c.DID, ··· 255 Name: c.Name, 256 DisplayName: c.DisplayName, 257 DisplayHandle: c.GetDisplayHandle(), 258 - Avatar: blobs.HydrateBlobURL(c.PDSURL, c.DID, c.AvatarCID), 259 Visibility: c.Visibility, 260 SubscriberCount: c.SubscriberCount, 261 MemberCount: c.MemberCount, ··· 267 } 268 269 // ToCommunityViewDetailed converts a Community to a CommunityViewDetailed for API responses 270 func (c *Community) ToCommunityViewDetailed() *CommunityViewDetailed { 271 view := &CommunityViewDetailed{ 272 DID: c.DID, ··· 275 DisplayName: c.DisplayName, 276 DisplayHandle: c.GetDisplayHandle(), 277 Description: c.Description, 278 - Avatar: blobs.HydrateBlobURL(c.PDSURL, c.DID, c.AvatarCID), 279 - Banner: blobs.HydrateBlobURL(c.PDSURL, c.DID, c.BannerCID), 280 CreatedByDID: c.CreatedByDID, 281 HostedByDID: c.HostedByDID, 282 Visibility: c.Visibility,
··· 4 "fmt" 5 "log" 6 "strings" 7 + "sync" 8 "time" 9 10 "Coves/internal/core/blobs" 11 ) 12 + 13 + // imageProxyConfigOnce ensures thread-safe initialization of the image proxy config. 14 + var imageProxyConfigOnce sync.Once 15 + 16 + // imageProxyConfig holds the immutable configuration after initialization. 17 + // Access only through GetImageProxyConfig(). 18 + var imageProxyConfig = blobs.ImageURLConfig{ 19 + ProxyEnabled: false, // Default to disabled until configured 20 + } 21 + 22 + // imageProxyConfigInitialized tracks whether SetImageProxyConfig has been called. 23 + var imageProxyConfigInitialized bool 24 + 25 + // SetImageProxyConfig initializes the image proxy configuration. 26 + // This should be called once during server startup. Subsequent calls are no-ops 27 + // and will log a warning. This design ensures thread-safety and prevents 28 + // accidental config changes during runtime. 29 + func SetImageProxyConfig(config blobs.ImageURLConfig) { 30 + imageProxyConfigOnce.Do(func() { 31 + imageProxyConfig = config 32 + imageProxyConfigInitialized = true 33 + }) 34 + // Log warning if called multiple times (indicates a programming error) 35 + if imageProxyConfigInitialized && config != imageProxyConfig { 36 + log.Printf("WARN: SetImageProxyConfig called multiple times with different config (ignored)") 37 + } 38 + } 39 + 40 + // GetImageProxyConfig returns the current image proxy configuration. 41 + // Thread-safe for concurrent access. 42 + func GetImageProxyConfig() blobs.ImageURLConfig { 43 + return imageProxyConfig 44 + } 45 + 46 + // ResetImageProxyConfigForTesting resets the config state for testing purposes. 47 + // This should ONLY be used in tests, never in production code. 48 + func ResetImageProxyConfigForTesting() { 49 + imageProxyConfigOnce = sync.Once{} 50 + imageProxyConfig = blobs.ImageURLConfig{ProxyEnabled: false} 51 + imageProxyConfigInitialized = false 52 + } 53 54 // Community represents a Coves community indexed from the firehose 55 // Communities are federated, instance-scoped forums built on atProto ··· 290 } 291 292 // ToCommunityView converts a Community to a CommunityView for API responses 293 + // Uses avatar_small preset (24px) for list views 294 func (c *Community) ToCommunityView() *CommunityView { 295 view := &CommunityView{ 296 DID: c.DID, ··· 298 Name: c.Name, 299 DisplayName: c.DisplayName, 300 DisplayHandle: c.GetDisplayHandle(), 301 + Avatar: blobs.HydrateImageURL(GetImageProxyConfig(), c.PDSURL, c.DID, c.AvatarCID, "avatar_small"), 302 Visibility: c.Visibility, 303 SubscriberCount: c.SubscriberCount, 304 MemberCount: c.MemberCount, ··· 310 } 311 312 // ToCommunityViewDetailed converts a Community to a CommunityViewDetailed for API responses 313 + // Uses avatar preset (80px) for detail views and banner preset for banners 314 func (c *Community) ToCommunityViewDetailed() *CommunityViewDetailed { 315 view := &CommunityViewDetailed{ 316 DID: c.DID, ··· 319 DisplayName: c.DisplayName, 320 DisplayHandle: c.GetDisplayHandle(), 321 Description: c.Description, 322 + Avatar: blobs.HydrateImageURL(GetImageProxyConfig(), c.PDSURL, c.DID, c.AvatarCID, "avatar"), 323 + Banner: blobs.HydrateImageURL(GetImageProxyConfig(), c.PDSURL, c.DID, c.BannerCID, "banner"), 324 CreatedByDID: c.CreatedByDID, 325 HostedByDID: c.HostedByDID, 326 Visibility: c.Visibility,
+539
internal/core/imageproxy/cache.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "io/fs" 7 + "log/slog" 8 + "os" 9 + "path/filepath" 10 + "sort" 11 + "strings" 12 + "time" 13 + ) 14 + 15 + var ( 16 + // ErrEmptyParameter is returned when a required parameter is empty 17 + ErrEmptyParameter = errors.New("required parameter is empty") 18 + // ErrInvalidCacheBasePath is returned when the cache base path is empty 19 + ErrInvalidCacheBasePath = errors.New("cache base path cannot be empty") 20 + // ErrInvalidCacheMaxSize is returned when maxSizeGB is not positive 21 + ErrInvalidCacheMaxSize = errors.New("cache max size must be positive") 22 + ) 23 + 24 + // Cache defines the interface for image proxy caching 25 + type Cache interface { 26 + // Get retrieves cached image data for the given preset, DID, and CID. 27 + // Returns the data, whether it was found, and any error. 28 + Get(preset, did, cid string) ([]byte, bool, error) 29 + 30 + // Set stores image data in the cache for the given preset, DID, and CID. 31 + Set(preset, did, cid string, data []byte) error 32 + 33 + // Delete removes cached image data for the given preset, DID, and CID. 34 + Delete(preset, did, cid string) error 35 + 36 + // Cleanup runs both LRU eviction and TTL cleanup. 37 + // Returns the number of entries removed and any error. 38 + Cleanup() (int, error) 39 + } 40 + 41 + // DiskCache implements Cache using the filesystem for storage. 42 + // Cache key format: {basePath}/{preset}/{did_safe}/{cid} 43 + // where did_safe has colons replaced with underscores for filesystem safety. 44 + type DiskCache struct { 45 + basePath string 46 + maxSizeGB int 47 + ttlDays int 48 + } 49 + 50 + // NewDiskCache creates a new DiskCache with the specified base path, maximum size, and TTL. 51 + // Returns an error if basePath is empty or maxSizeGB is not positive. 52 + // ttlDays of 0 disables TTL-based cleanup (only LRU eviction applies). 53 + func NewDiskCache(basePath string, maxSizeGB int, ttlDays int) (*DiskCache, error) { 54 + if basePath == "" { 55 + return nil, ErrInvalidCacheBasePath 56 + } 57 + if maxSizeGB <= 0 { 58 + return nil, ErrInvalidCacheMaxSize 59 + } 60 + if ttlDays < 0 { 61 + return nil, errors.New("ttlDays cannot be negative") 62 + } 63 + return &DiskCache{ 64 + basePath: basePath, 65 + maxSizeGB: maxSizeGB, 66 + ttlDays: ttlDays, 67 + }, nil 68 + } 69 + 70 + // makeDIDSafe converts a DID to a filesystem-safe directory name. 71 + // It sanitizes the input to prevent path traversal attacks by: 72 + // - Replacing colons with underscores 73 + // - Removing path separators (/ and \) 74 + // - Removing path traversal sequences (..) 75 + // - Removing null bytes 76 + func makeDIDSafe(did string) string { 77 + // Replace colons with underscores 78 + s := strings.ReplaceAll(did, ":", "_") 79 + 80 + // Remove path separators to prevent directory escape 81 + s = strings.ReplaceAll(s, "/", "") 82 + s = strings.ReplaceAll(s, "\\", "") 83 + 84 + // Remove path traversal sequences 85 + s = strings.ReplaceAll(s, "..", "") 86 + 87 + // Remove null bytes (could be used to terminate strings early) 88 + s = strings.ReplaceAll(s, "\x00", "") 89 + 90 + return s 91 + } 92 + 93 + // makeCIDSafe sanitizes a CID for use in filesystem paths. 94 + // It removes characters that could be used for path traversal attacks. 95 + func makeCIDSafe(cid string) string { 96 + // Remove path separators to prevent directory escape 97 + s := strings.ReplaceAll(cid, "/", "") 98 + s = strings.ReplaceAll(s, "\\", "") 99 + 100 + // Remove path traversal sequences 101 + s = strings.ReplaceAll(s, "..", "") 102 + 103 + // Remove null bytes 104 + s = strings.ReplaceAll(s, "\x00", "") 105 + 106 + return s 107 + } 108 + 109 + // makePresetSafe sanitizes a preset name for use in filesystem paths. 110 + func makePresetSafe(preset string) string { 111 + // Remove path separators 112 + s := strings.ReplaceAll(preset, "/", "") 113 + s = strings.ReplaceAll(s, "\\", "") 114 + 115 + // Remove path traversal sequences 116 + s = strings.ReplaceAll(s, "..", "") 117 + 118 + // Remove null bytes 119 + s = strings.ReplaceAll(s, "\x00", "") 120 + 121 + return s 122 + } 123 + 124 + // cachePath constructs the full filesystem path for a cached item. 125 + // All components are sanitized to prevent path traversal attacks. 126 + func (c *DiskCache) cachePath(preset, did, cid string) string { 127 + presetSafe := makePresetSafe(preset) 128 + didSafe := makeDIDSafe(did) 129 + cidSafe := makeCIDSafe(cid) 130 + return filepath.Join(c.basePath, presetSafe, didSafe, cidSafe) 131 + } 132 + 133 + // validateParams checks that all required parameters are non-empty. 134 + func validateParams(preset, did, cid string) error { 135 + if preset == "" || did == "" || cid == "" { 136 + return ErrEmptyParameter 137 + } 138 + return nil 139 + } 140 + 141 + // Get retrieves cached image data for the given preset, DID, and CID. 142 + // Returns the data, whether it was found, and any error. 143 + // If the item is not in cache, returns (nil, false, nil). 144 + // Updates the file's modification time on access for LRU tracking. 145 + func (c *DiskCache) Get(preset, did, cid string) ([]byte, bool, error) { 146 + if err := validateParams(preset, did, cid); err != nil { 147 + return nil, false, err 148 + } 149 + 150 + path := c.cachePath(preset, did, cid) 151 + 152 + data, err := os.ReadFile(path) 153 + if err != nil { 154 + if os.IsNotExist(err) { 155 + return nil, false, nil 156 + } 157 + return nil, false, err 158 + } 159 + 160 + // Update mtime for LRU tracking 161 + // Log errors as warnings since failed mtime updates degrade LRU accuracy 162 + now := time.Now() 163 + if chtimesErr := os.Chtimes(path, now, now); chtimesErr != nil { 164 + slog.Warn("[IMAGE-PROXY] failed to update mtime for LRU tracking", 165 + "path", path, 166 + "error", chtimesErr, 167 + ) 168 + } 169 + 170 + return data, true, nil 171 + } 172 + 173 + // Set stores image data in the cache for the given preset, DID, and CID. 174 + // Creates necessary directories if they don't exist. 175 + func (c *DiskCache) Set(preset, did, cid string, data []byte) error { 176 + if err := validateParams(preset, did, cid); err != nil { 177 + return err 178 + } 179 + 180 + path := c.cachePath(preset, did, cid) 181 + dir := filepath.Dir(path) 182 + 183 + // Create directory structure if it doesn't exist 184 + if err := os.MkdirAll(dir, 0755); err != nil { 185 + return err 186 + } 187 + 188 + // Write the file atomically by writing to a temp file first 189 + // then renaming (to avoid partial writes on crash) 190 + tmpPath := path + ".tmp" 191 + if err := os.WriteFile(tmpPath, data, 0644); err != nil { 192 + return err 193 + } 194 + 195 + return os.Rename(tmpPath, path) 196 + } 197 + 198 + // Delete removes cached image data for the given preset, DID, and CID. 199 + // Returns nil if the item doesn't exist (idempotent delete). 200 + func (c *DiskCache) Delete(preset, did, cid string) error { 201 + if err := validateParams(preset, did, cid); err != nil { 202 + return err 203 + } 204 + 205 + path := c.cachePath(preset, did, cid) 206 + 207 + err := os.Remove(path) 208 + if err != nil && !os.IsNotExist(err) { 209 + return err 210 + } 211 + 212 + return nil 213 + } 214 + 215 + // cacheEntry represents a cached file with its metadata. 216 + type cacheEntry struct { 217 + path string 218 + size int64 219 + modTime time.Time 220 + } 221 + 222 + // scanCache walks the cache directory and returns all cache entries. 223 + func (c *DiskCache) scanCache() ([]cacheEntry, int64, error) { 224 + var entries []cacheEntry 225 + var totalSize int64 226 + 227 + err := filepath.WalkDir(c.basePath, func(path string, d fs.DirEntry, err error) error { 228 + if err != nil { 229 + return err 230 + } 231 + if d.IsDir() { 232 + return nil 233 + } 234 + 235 + info, err := d.Info() 236 + if err != nil { 237 + slog.Warn("[IMAGE-PROXY] failed to stat file during cache scan, cache size may be inaccurate", 238 + "path", path, 239 + "error", err, 240 + ) 241 + return nil // Skip files we can't stat 242 + } 243 + 244 + entries = append(entries, cacheEntry{ 245 + path: path, 246 + size: info.Size(), 247 + modTime: info.ModTime(), 248 + }) 249 + totalSize += info.Size() 250 + 251 + return nil 252 + }) 253 + 254 + if err != nil && !os.IsNotExist(err) { 255 + return nil, 0, err 256 + } 257 + 258 + return entries, totalSize, nil 259 + } 260 + 261 + // GetCacheSize returns the current cache size in bytes. 262 + func (c *DiskCache) GetCacheSize() (int64, error) { 263 + _, totalSize, err := c.scanCache() 264 + return totalSize, err 265 + } 266 + 267 + // EvictLRU removes the least recently used entries until the cache is under the size limit. 268 + // Returns the number of entries removed. 269 + func (c *DiskCache) EvictLRU() (int, error) { 270 + entries, totalSize, err := c.scanCache() 271 + if err != nil { 272 + return 0, err 273 + } 274 + 275 + maxSizeBytes := int64(c.maxSizeGB) * 1024 * 1024 * 1024 276 + if totalSize <= maxSizeBytes { 277 + return 0, nil // Under limit, nothing to do 278 + } 279 + 280 + // Sort by modification time (oldest first for LRU) 281 + sort.Slice(entries, func(i, j int) bool { 282 + return entries[i].modTime.Before(entries[j].modTime) 283 + }) 284 + 285 + removed := 0 286 + for _, entry := range entries { 287 + if totalSize <= maxSizeBytes { 288 + break 289 + } 290 + 291 + if err := os.Remove(entry.path); err != nil { 292 + if !os.IsNotExist(err) { 293 + slog.Warn("[IMAGE-PROXY] failed to remove cache entry during LRU eviction", 294 + "path", entry.path, 295 + "error", err, 296 + ) 297 + } 298 + continue 299 + } 300 + 301 + totalSize -= entry.size 302 + removed++ 303 + 304 + slog.Debug("[IMAGE-PROXY] evicted cache entry (LRU)", 305 + "path", entry.path, 306 + "size_bytes", entry.size, 307 + ) 308 + } 309 + 310 + if removed > 0 { 311 + slog.Info("[IMAGE-PROXY] LRU eviction completed", 312 + "entries_removed", removed, 313 + "new_size_bytes", totalSize, 314 + "max_size_bytes", maxSizeBytes, 315 + ) 316 + } 317 + 318 + return removed, nil 319 + } 320 + 321 + // CleanExpired removes cache entries older than the configured TTL. 322 + // Returns the number of entries removed. 323 + // If TTL is 0 (disabled), returns 0 without scanning. 324 + func (c *DiskCache) CleanExpired() (int, error) { 325 + if c.ttlDays <= 0 { 326 + return 0, nil // TTL disabled 327 + } 328 + 329 + entries, _, err := c.scanCache() 330 + if err != nil { 331 + return 0, err 332 + } 333 + 334 + cutoff := time.Now().AddDate(0, 0, -c.ttlDays) 335 + removed := 0 336 + 337 + for _, entry := range entries { 338 + if entry.modTime.After(cutoff) { 339 + continue // Not expired 340 + } 341 + 342 + if err := os.Remove(entry.path); err != nil { 343 + if !os.IsNotExist(err) { 344 + slog.Warn("[IMAGE-PROXY] failed to remove expired cache entry", 345 + "path", entry.path, 346 + "mod_time", entry.modTime, 347 + "error", err, 348 + ) 349 + } 350 + continue 351 + } 352 + 353 + removed++ 354 + 355 + slog.Debug("[IMAGE-PROXY] removed expired cache entry", 356 + "path", entry.path, 357 + "mod_time", entry.modTime, 358 + "ttl_days", c.ttlDays, 359 + ) 360 + } 361 + 362 + if removed > 0 { 363 + slog.Info("[IMAGE-PROXY] TTL cleanup completed", 364 + "entries_removed", removed, 365 + "ttl_days", c.ttlDays, 366 + ) 367 + } 368 + 369 + return removed, nil 370 + } 371 + 372 + // Cleanup runs both TTL cleanup and LRU eviction. 373 + // TTL cleanup runs first (removes definitely stale entries), 374 + // then LRU eviction runs if still over size limit. 375 + // Returns the total number of entries removed. 376 + func (c *DiskCache) Cleanup() (int, error) { 377 + totalRemoved := 0 378 + 379 + // First, remove expired entries (definitely stale) 380 + ttlRemoved, err := c.CleanExpired() 381 + if err != nil { 382 + return 0, err 383 + } 384 + totalRemoved += ttlRemoved 385 + 386 + // Then, run LRU eviction if still over limit 387 + lruRemoved, err := c.EvictLRU() 388 + if err != nil { 389 + return totalRemoved, err 390 + } 391 + totalRemoved += lruRemoved 392 + 393 + return totalRemoved, nil 394 + } 395 + 396 + // cleanEmptyDirs removes empty directories under the cache base path. 397 + // This is useful after eviction/cleanup to remove orphaned preset/DID directories. 398 + func (c *DiskCache) cleanEmptyDirs() error { 399 + // Walk in reverse depth order to clean leaf directories first 400 + var dirs []string 401 + 402 + var walkErrors []error 403 + err := filepath.WalkDir(c.basePath, func(path string, d fs.DirEntry, err error) error { 404 + if err != nil { 405 + // Log WalkDir errors but continue scanning to clean as much as possible 406 + slog.Warn("[IMAGE-PROXY] error during empty dir cleanup scan", 407 + "path", path, 408 + "error", err, 409 + ) 410 + walkErrors = append(walkErrors, err) 411 + return nil // Continue scanning despite errors 412 + } 413 + if d.IsDir() && path != c.basePath { 414 + dirs = append(dirs, path) 415 + } 416 + return nil 417 + }) 418 + 419 + if err != nil { 420 + return err 421 + } 422 + 423 + if len(walkErrors) > 0 { 424 + slog.Warn("[IMAGE-PROXY] encountered errors during empty dir cleanup scan", 425 + "error_count", len(walkErrors), 426 + ) 427 + } 428 + 429 + // Sort by length descending (deepest paths first) 430 + sort.Slice(dirs, func(i, j int) bool { 431 + return len(dirs[i]) > len(dirs[j]) 432 + }) 433 + 434 + var removeErrors int 435 + for _, dir := range dirs { 436 + entries, err := os.ReadDir(dir) 437 + if err != nil { 438 + slog.Warn("[IMAGE-PROXY] failed to read directory during cleanup", 439 + "path", dir, 440 + "error", err, 441 + ) 442 + continue 443 + } 444 + if len(entries) == 0 { 445 + if removeErr := os.Remove(dir); removeErr != nil { 446 + slog.Warn("[IMAGE-PROXY] failed to remove empty directory", 447 + "path", dir, 448 + "error", removeErr, 449 + ) 450 + removeErrors++ 451 + } 452 + } 453 + } 454 + 455 + if removeErrors > 0 { 456 + slog.Warn("[IMAGE-PROXY] some empty directories could not be removed", 457 + "failed_count", removeErrors, 458 + ) 459 + } 460 + 461 + return nil 462 + } 463 + 464 + // StartCleanupJob starts a background goroutine that periodically runs cache cleanup. 465 + // Returns a cancel function that should be called during graceful shutdown. 466 + // If interval is 0 or negative, no cleanup job is started and the cancel function is a no-op. 467 + func (c *DiskCache) StartCleanupJob(interval time.Duration) context.CancelFunc { 468 + if interval <= 0 { 469 + slog.Info("[IMAGE-PROXY] cache cleanup job disabled (interval=0)") 470 + return func() {} // No-op cancel 471 + } 472 + 473 + ctx, cancel := context.WithCancel(context.Background()) 474 + 475 + go func() { 476 + defer func() { 477 + if r := recover(); r != nil { 478 + slog.Error("[IMAGE-PROXY] CRITICAL: cache cleanup job panicked", 479 + "panic", r, 480 + ) 481 + } 482 + }() 483 + 484 + ticker := time.NewTicker(interval) 485 + defer ticker.Stop() 486 + 487 + slog.Info("[IMAGE-PROXY] cache cleanup job started", 488 + "interval", interval, 489 + "ttl_days", c.ttlDays, 490 + "max_size_gb", c.maxSizeGB, 491 + ) 492 + 493 + cycleCount := 0 494 + for { 495 + select { 496 + case <-ctx.Done(): 497 + slog.Info("[IMAGE-PROXY] cache cleanup job stopped") 498 + return 499 + case <-ticker.C: 500 + cycleCount++ 501 + 502 + removed, err := c.Cleanup() 503 + if err != nil { 504 + slog.Error("[IMAGE-PROXY] cache cleanup error", 505 + "error", err, 506 + "cycle", cycleCount, 507 + ) 508 + continue 509 + } 510 + 511 + // Also clean up empty directories after removing files 512 + if removed > 0 { 513 + if err := c.cleanEmptyDirs(); err != nil { 514 + slog.Warn("[IMAGE-PROXY] failed to clean empty directories", 515 + "error", err, 516 + ) 517 + } 518 + } 519 + 520 + // Log activity or heartbeat every 6 cycles (6 hours if interval is 1h) 521 + if removed > 0 { 522 + slog.Info("[IMAGE-PROXY] cache cleanup completed", 523 + "entries_removed", removed, 524 + "cycle", cycleCount, 525 + ) 526 + } else if cycleCount%6 == 0 { 527 + // Get cache size for heartbeat log 528 + size, _ := c.GetCacheSize() 529 + slog.Debug("[IMAGE-PROXY] cache cleanup heartbeat", 530 + "cycle", cycleCount, 531 + "cache_size_bytes", size, 532 + ) 533 + } 534 + } 535 + } 536 + }() 537 + 538 + return cancel 539 + }
+610
internal/core/imageproxy/cache_test.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "errors" 5 + "os" 6 + "path/filepath" 7 + "testing" 8 + "time" 9 + ) 10 + 11 + // mustNewDiskCache is a test helper that creates a DiskCache or fails the test 12 + // Uses 0 for TTL (disabled) by default for backward compatibility 13 + func mustNewDiskCache(t *testing.T, basePath string, maxSizeGB int) *DiskCache { 14 + t.Helper() 15 + cache, err := NewDiskCache(basePath, maxSizeGB, 0) 16 + if err != nil { 17 + t.Fatalf("NewDiskCache failed: %v", err) 18 + } 19 + return cache 20 + } 21 + 22 + func TestDiskCache_SetAndGet(t *testing.T) { 23 + // Create a temporary directory for the cache 24 + tmpDir := t.TempDir() 25 + 26 + cache := mustNewDiskCache(t, tmpDir, 1) 27 + 28 + testData := []byte("test image data") 29 + preset := "thumb" 30 + did := "did:plc:abc123" 31 + cid := "bafyreiabc123" 32 + 33 + // Set the data 34 + err := cache.Set(preset, did, cid, testData) 35 + if err != nil { 36 + t.Fatalf("Set failed: %v", err) 37 + } 38 + 39 + // Get the data back 40 + data, found, err := cache.Get(preset, did, cid) 41 + if err != nil { 42 + t.Fatalf("Get failed: %v", err) 43 + } 44 + if !found { 45 + t.Fatal("Expected data to be found in cache") 46 + } 47 + if string(data) != string(testData) { 48 + t.Errorf("Get returned %q, want %q", string(data), string(testData)) 49 + } 50 + } 51 + 52 + func TestDiskCache_GetMissingKey(t *testing.T) { 53 + tmpDir := t.TempDir() 54 + cache := mustNewDiskCache(t, tmpDir, 1) 55 + 56 + data, found, err := cache.Get("thumb", "did:plc:notexist", "bafynotexist") 57 + if err != nil { 58 + t.Fatalf("Get should not error for missing key: %v", err) 59 + } 60 + if found { 61 + t.Error("Expected found to be false for missing key") 62 + } 63 + if data != nil { 64 + t.Error("Expected data to be nil for missing key") 65 + } 66 + } 67 + 68 + func TestDiskCache_Delete(t *testing.T) { 69 + tmpDir := t.TempDir() 70 + cache := mustNewDiskCache(t, tmpDir, 1) 71 + 72 + testData := []byte("data to delete") 73 + preset := "medium" 74 + did := "did:plc:todelete" 75 + cid := "bafyreitodelete" 76 + 77 + // Set data 78 + err := cache.Set(preset, did, cid, testData) 79 + if err != nil { 80 + t.Fatalf("Set failed: %v", err) 81 + } 82 + 83 + // Verify it exists 84 + _, found, _ := cache.Get(preset, did, cid) 85 + if !found { 86 + t.Fatal("Expected data to exist before delete") 87 + } 88 + 89 + // Delete 90 + err = cache.Delete(preset, did, cid) 91 + if err != nil { 92 + t.Fatalf("Delete failed: %v", err) 93 + } 94 + 95 + // Verify it's gone 96 + _, found, _ = cache.Get(preset, did, cid) 97 + if found { 98 + t.Error("Expected data to be gone after delete") 99 + } 100 + } 101 + 102 + func TestDiskCache_DeleteNonExistent(t *testing.T) { 103 + tmpDir := t.TempDir() 104 + cache := mustNewDiskCache(t, tmpDir, 1) 105 + 106 + // Deleting a non-existent key should not error 107 + err := cache.Delete("thumb", "did:plc:notexist", "bafynotexist") 108 + if err != nil { 109 + t.Errorf("Delete of non-existent key should not error: %v", err) 110 + } 111 + } 112 + 113 + func TestDiskCache_PathConstruction(t *testing.T) { 114 + tmpDir := t.TempDir() 115 + cache := mustNewDiskCache(t, tmpDir, 1) 116 + 117 + testData := []byte("path test data") 118 + preset := "thumb" 119 + did := "did:plc:abc123" 120 + cid := "bafyreiabc123" 121 + 122 + err := cache.Set(preset, did, cid, testData) 123 + if err != nil { 124 + t.Fatalf("Set failed: %v", err) 125 + } 126 + 127 + // Verify the path structure: {basePath}/{preset}/{did_safe}/{cid} 128 + // did_safe should have colons replaced with underscores 129 + expectedPath := filepath.Join(tmpDir, preset, "did_plc_abc123", cid) 130 + if _, err := os.Stat(expectedPath); os.IsNotExist(err) { 131 + t.Errorf("Expected cache file at %s to exist", expectedPath) 132 + } 133 + } 134 + 135 + func TestDiskCache_HandlesSpecialCharactersInDID(t *testing.T) { 136 + tmpDir := t.TempDir() 137 + cache := mustNewDiskCache(t, tmpDir, 1) 138 + 139 + tests := []struct { 140 + name string 141 + did string 142 + wantDir string 143 + }{ 144 + { 145 + name: "plc DID with colons", 146 + did: "did:plc:abc123", 147 + wantDir: "did_plc_abc123", 148 + }, 149 + { 150 + name: "web DID with multiple colons", 151 + did: "did:web:example.com:user", 152 + wantDir: "did_web_example.com_user", 153 + }, 154 + { 155 + name: "DID with many segments", 156 + did: "did:plc:a:b:c:d", 157 + wantDir: "did_plc_a_b_c_d", 158 + }, 159 + } 160 + 161 + for _, tt := range tests { 162 + t.Run(tt.name, func(t *testing.T) { 163 + testData := []byte("test data for " + tt.name) 164 + preset := "thumb" 165 + cid := "bafytest123" 166 + 167 + err := cache.Set(preset, tt.did, cid, testData) 168 + if err != nil { 169 + t.Fatalf("Set failed: %v", err) 170 + } 171 + 172 + expectedPath := filepath.Join(tmpDir, preset, tt.wantDir, cid) 173 + if _, err := os.Stat(expectedPath); os.IsNotExist(err) { 174 + t.Errorf("Expected cache file at %s to exist for DID %s", expectedPath, tt.did) 175 + } 176 + 177 + // Also verify we can read it back 178 + data, found, err := cache.Get(preset, tt.did, cid) 179 + if err != nil { 180 + t.Fatalf("Get failed: %v", err) 181 + } 182 + if !found { 183 + t.Error("Expected to find cached data") 184 + } 185 + if string(data) != string(testData) { 186 + t.Errorf("Get returned %q, want %q", string(data), string(testData)) 187 + } 188 + }) 189 + } 190 + } 191 + 192 + func TestDiskCache_DifferentPresetsAreSeparate(t *testing.T) { 193 + tmpDir := t.TempDir() 194 + cache := mustNewDiskCache(t, tmpDir, 1) 195 + 196 + did := "did:plc:same" 197 + cid := "bafysame" 198 + thumbData := []byte("thumbnail data") 199 + fullData := []byte("full size data") 200 + 201 + // Set different data for different presets 202 + err := cache.Set("thumb", did, cid, thumbData) 203 + if err != nil { 204 + t.Fatalf("Set thumb failed: %v", err) 205 + } 206 + 207 + err = cache.Set("full", did, cid, fullData) 208 + if err != nil { 209 + t.Fatalf("Set full failed: %v", err) 210 + } 211 + 212 + // Verify they're separate 213 + data, found, _ := cache.Get("thumb", did, cid) 214 + if !found { 215 + t.Fatal("Expected thumb data to be found") 216 + } 217 + if string(data) != string(thumbData) { 218 + t.Errorf("thumb preset returned wrong data: got %q, want %q", string(data), string(thumbData)) 219 + } 220 + 221 + data, found, _ = cache.Get("full", did, cid) 222 + if !found { 223 + t.Fatal("Expected full data to be found") 224 + } 225 + if string(data) != string(fullData) { 226 + t.Errorf("full preset returned wrong data: got %q, want %q", string(data), string(fullData)) 227 + } 228 + } 229 + 230 + func TestDiskCache_EmptyParametersHandled(t *testing.T) { 231 + tmpDir := t.TempDir() 232 + cache := mustNewDiskCache(t, tmpDir, 1) 233 + 234 + // Empty preset 235 + err := cache.Set("", "did:plc:abc", "bafytest", []byte("data")) 236 + if err == nil { 237 + t.Error("Expected error when preset is empty") 238 + } 239 + 240 + // Empty DID 241 + err = cache.Set("thumb", "", "bafytest", []byte("data")) 242 + if err == nil { 243 + t.Error("Expected error when DID is empty") 244 + } 245 + 246 + // Empty CID 247 + err = cache.Set("thumb", "did:plc:abc", "", []byte("data")) 248 + if err == nil { 249 + t.Error("Expected error when CID is empty") 250 + } 251 + } 252 + 253 + func TestNewDiskCache(t *testing.T) { 254 + cache, err := NewDiskCache("/some/path", 5, 30) 255 + if err != nil { 256 + t.Fatalf("NewDiskCache failed: %v", err) 257 + } 258 + 259 + if cache == nil { 260 + t.Fatal("NewDiskCache returned nil") 261 + } 262 + if cache.basePath != "/some/path" { 263 + t.Errorf("basePath = %q, want %q", cache.basePath, "/some/path") 264 + } 265 + if cache.maxSizeGB != 5 { 266 + t.Errorf("maxSizeGB = %d, want %d", cache.maxSizeGB, 5) 267 + } 268 + if cache.ttlDays != 30 { 269 + t.Errorf("ttlDays = %d, want %d", cache.ttlDays, 30) 270 + } 271 + } 272 + 273 + func TestNewDiskCache_Errors(t *testing.T) { 274 + t.Run("empty base path", func(t *testing.T) { 275 + _, err := NewDiskCache("", 5, 0) 276 + if !errors.Is(err, ErrInvalidCacheBasePath) { 277 + t.Errorf("expected ErrInvalidCacheBasePath, got: %v", err) 278 + } 279 + }) 280 + 281 + t.Run("zero max size", func(t *testing.T) { 282 + _, err := NewDiskCache("/some/path", 0, 0) 283 + if !errors.Is(err, ErrInvalidCacheMaxSize) { 284 + t.Errorf("expected ErrInvalidCacheMaxSize, got: %v", err) 285 + } 286 + }) 287 + 288 + t.Run("negative max size", func(t *testing.T) { 289 + _, err := NewDiskCache("/some/path", -1, 0) 290 + if !errors.Is(err, ErrInvalidCacheMaxSize) { 291 + t.Errorf("expected ErrInvalidCacheMaxSize, got: %v", err) 292 + } 293 + }) 294 + 295 + t.Run("negative TTL", func(t *testing.T) { 296 + _, err := NewDiskCache("/some/path", 5, -1) 297 + if err == nil { 298 + t.Error("expected error for negative TTL") 299 + } 300 + }) 301 + } 302 + 303 + func TestCache_InterfaceImplementation(t *testing.T) { 304 + // Compile-time check that DiskCache implements Cache 305 + var _ Cache = (*DiskCache)(nil) 306 + } 307 + 308 + func TestDiskCache_GetCacheSize(t *testing.T) { 309 + tmpDir := t.TempDir() 310 + cache := mustNewDiskCache(t, tmpDir, 1) 311 + 312 + // Empty cache should be 0 313 + size, err := cache.GetCacheSize() 314 + if err != nil { 315 + t.Fatalf("GetCacheSize failed: %v", err) 316 + } 317 + if size != 0 { 318 + t.Errorf("Expected 0 for empty cache, got %d", size) 319 + } 320 + 321 + // Add some data 322 + data := make([]byte, 1000) // 1KB 323 + if err := cache.Set("avatar", "did:plc:test1", "cid1", data); err != nil { 324 + t.Fatalf("Set failed: %v", err) 325 + } 326 + if err := cache.Set("avatar", "did:plc:test2", "cid2", data); err != nil { 327 + t.Fatalf("Set failed: %v", err) 328 + } 329 + 330 + size, err = cache.GetCacheSize() 331 + if err != nil { 332 + t.Fatalf("GetCacheSize failed: %v", err) 333 + } 334 + if size != 2000 { 335 + t.Errorf("Expected 2000 bytes, got %d", size) 336 + } 337 + } 338 + 339 + func TestDiskCache_EvictLRU(t *testing.T) { 340 + tmpDir := t.TempDir() 341 + // Use a very small max size (1 byte) so any data triggers eviction 342 + cache, err := NewDiskCache(tmpDir, 1, 0) // 1GB but we'll add more than that won't fit 343 + if err != nil { 344 + t.Fatalf("NewDiskCache failed: %v", err) 345 + } 346 + 347 + // Add some files with different modification times 348 + data := make([]byte, 100) 349 + 350 + // Create old file 351 + if err := cache.Set("avatar", "did:plc:old", "cid_old", data); err != nil { 352 + t.Fatalf("Set failed: %v", err) 353 + } 354 + oldPath := cache.cachePath("avatar", "did:plc:old", "cid_old") 355 + oldTime := time.Now().Add(-24 * time.Hour) 356 + if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil { 357 + t.Fatalf("Chtimes failed: %v", err) 358 + } 359 + 360 + // Create new file 361 + if err := cache.Set("avatar", "did:plc:new", "cid_new", data); err != nil { 362 + t.Fatalf("Set failed: %v", err) 363 + } 364 + 365 + // Cache is under 1GB so eviction shouldn't remove anything 366 + removed, err := cache.EvictLRU() 367 + if err != nil { 368 + t.Fatalf("EvictLRU failed: %v", err) 369 + } 370 + if removed != 0 { 371 + t.Errorf("Expected 0 entries removed (under limit), got %d", removed) 372 + } 373 + 374 + // Both files should still exist 375 + if _, found, _ := cache.Get("avatar", "did:plc:old", "cid_old"); !found { 376 + t.Error("Old entry should still exist") 377 + } 378 + if _, found, _ := cache.Get("avatar", "did:plc:new", "cid_new"); !found { 379 + t.Error("New entry should still exist") 380 + } 381 + } 382 + 383 + func TestDiskCache_CleanExpired(t *testing.T) { 384 + tmpDir := t.TempDir() 385 + // TTL of 1 day 386 + cache, err := NewDiskCache(tmpDir, 1, 1) 387 + if err != nil { 388 + t.Fatalf("NewDiskCache failed: %v", err) 389 + } 390 + 391 + data := make([]byte, 100) 392 + 393 + // Create fresh file 394 + if err := cache.Set("avatar", "did:plc:fresh", "cid_fresh", data); err != nil { 395 + t.Fatalf("Set failed: %v", err) 396 + } 397 + 398 + // Create expired file (manually set old mtime) 399 + if err := cache.Set("avatar", "did:plc:expired", "cid_expired", data); err != nil { 400 + t.Fatalf("Set failed: %v", err) 401 + } 402 + expiredPath := cache.cachePath("avatar", "did:plc:expired", "cid_expired") 403 + oldTime := time.Now().Add(-48 * time.Hour) // 2 days old, TTL is 1 day 404 + if err := os.Chtimes(expiredPath, oldTime, oldTime); err != nil { 405 + t.Fatalf("Chtimes failed: %v", err) 406 + } 407 + 408 + // Clean expired entries 409 + removed, err := cache.CleanExpired() 410 + if err != nil { 411 + t.Fatalf("CleanExpired failed: %v", err) 412 + } 413 + if removed != 1 { 414 + t.Errorf("Expected 1 expired entry removed, got %d", removed) 415 + } 416 + 417 + // Fresh file should still exist 418 + if _, found, _ := cache.Get("avatar", "did:plc:fresh", "cid_fresh"); !found { 419 + t.Error("Fresh entry should still exist") 420 + } 421 + 422 + // Expired file should be gone 423 + if _, found, _ := cache.Get("avatar", "did:plc:expired", "cid_expired"); found { 424 + t.Error("Expired entry should be removed") 425 + } 426 + } 427 + 428 + func TestDiskCache_CleanExpired_TTLDisabled(t *testing.T) { 429 + tmpDir := t.TempDir() 430 + // TTL of 0 = disabled 431 + cache, err := NewDiskCache(tmpDir, 1, 0) 432 + if err != nil { 433 + t.Fatalf("NewDiskCache failed: %v", err) 434 + } 435 + 436 + data := make([]byte, 100) 437 + 438 + // Create a file with old mtime 439 + if err := cache.Set("avatar", "did:plc:old", "cid_old", data); err != nil { 440 + t.Fatalf("Set failed: %v", err) 441 + } 442 + path := cache.cachePath("avatar", "did:plc:old", "cid_old") 443 + oldTime := time.Now().Add(-365 * 24 * time.Hour) // 1 year old 444 + if err := os.Chtimes(path, oldTime, oldTime); err != nil { 445 + t.Fatalf("Chtimes failed: %v", err) 446 + } 447 + 448 + // Clean expired should do nothing when TTL is disabled 449 + removed, err := cache.CleanExpired() 450 + if err != nil { 451 + t.Fatalf("CleanExpired failed: %v", err) 452 + } 453 + if removed != 0 { 454 + t.Errorf("Expected 0 removed with TTL disabled, got %d", removed) 455 + } 456 + 457 + // File should still exist 458 + if _, found, _ := cache.Get("avatar", "did:plc:old", "cid_old"); !found { 459 + t.Error("Entry should still exist when TTL is disabled") 460 + } 461 + } 462 + 463 + func TestDiskCache_Cleanup(t *testing.T) { 464 + tmpDir := t.TempDir() 465 + // TTL of 1 day 466 + cache, err := NewDiskCache(tmpDir, 1, 1) 467 + if err != nil { 468 + t.Fatalf("NewDiskCache failed: %v", err) 469 + } 470 + 471 + data := make([]byte, 100) 472 + 473 + // Create fresh file 474 + if err := cache.Set("avatar", "did:plc:fresh", "cid_fresh", data); err != nil { 475 + t.Fatalf("Set failed: %v", err) 476 + } 477 + 478 + // Create expired file 479 + if err := cache.Set("avatar", "did:plc:expired", "cid_expired", data); err != nil { 480 + t.Fatalf("Set failed: %v", err) 481 + } 482 + expiredPath := cache.cachePath("avatar", "did:plc:expired", "cid_expired") 483 + oldTime := time.Now().Add(-48 * time.Hour) 484 + if err := os.Chtimes(expiredPath, oldTime, oldTime); err != nil { 485 + t.Fatalf("Chtimes failed: %v", err) 486 + } 487 + 488 + // Cleanup should remove expired entry 489 + removed, err := cache.Cleanup() 490 + if err != nil { 491 + t.Fatalf("Cleanup failed: %v", err) 492 + } 493 + if removed != 1 { 494 + t.Errorf("Expected 1 entry removed, got %d", removed) 495 + } 496 + 497 + // Fresh file should still exist 498 + if _, found, _ := cache.Get("avatar", "did:plc:fresh", "cid_fresh"); !found { 499 + t.Error("Fresh entry should still exist") 500 + } 501 + } 502 + 503 + func TestDiskCache_GetUpdatesMtime(t *testing.T) { 504 + tmpDir := t.TempDir() 505 + cache := mustNewDiskCache(t, tmpDir, 1) 506 + 507 + data := []byte("test data") 508 + if err := cache.Set("avatar", "did:plc:test", "cid1", data); err != nil { 509 + t.Fatalf("Set failed: %v", err) 510 + } 511 + 512 + path := cache.cachePath("avatar", "did:plc:test", "cid1") 513 + 514 + // Set an old mtime 515 + oldTime := time.Now().Add(-24 * time.Hour) 516 + if err := os.Chtimes(path, oldTime, oldTime); err != nil { 517 + t.Fatalf("Chtimes failed: %v", err) 518 + } 519 + 520 + // Get the file - this should update mtime 521 + _, found, err := cache.Get("avatar", "did:plc:test", "cid1") 522 + if err != nil { 523 + t.Fatalf("Get failed: %v", err) 524 + } 525 + if !found { 526 + t.Fatal("Expected to find entry") 527 + } 528 + 529 + // Check that mtime was updated 530 + info, err := os.Stat(path) 531 + if err != nil { 532 + t.Fatalf("Stat failed: %v", err) 533 + } 534 + 535 + // Mtime should be recent (within last minute) 536 + if time.Since(info.ModTime()) > time.Minute { 537 + t.Errorf("Expected mtime to be updated to now, but it's %v old", time.Since(info.ModTime())) 538 + } 539 + } 540 + 541 + func TestDiskCache_StartCleanupJob(t *testing.T) { 542 + tmpDir := t.TempDir() 543 + // Create cache with 1 day TTL 544 + cache, err := NewDiskCache(tmpDir, 1, 1) 545 + if err != nil { 546 + t.Fatalf("NewDiskCache failed: %v", err) 547 + } 548 + 549 + data := make([]byte, 100) 550 + 551 + // Create an expired file 552 + if err := cache.Set("avatar", "did:plc:expired", "cid_expired", data); err != nil { 553 + t.Fatalf("Set failed: %v", err) 554 + } 555 + expiredPath := cache.cachePath("avatar", "did:plc:expired", "cid_expired") 556 + oldTime := time.Now().Add(-48 * time.Hour) 557 + if err := os.Chtimes(expiredPath, oldTime, oldTime); err != nil { 558 + t.Fatalf("Chtimes failed: %v", err) 559 + } 560 + 561 + // Start cleanup job with very short interval 562 + cancel := cache.StartCleanupJob(50 * time.Millisecond) 563 + defer cancel() 564 + 565 + // Wait for at least one cleanup cycle 566 + time.Sleep(100 * time.Millisecond) 567 + 568 + // Expired file should be gone 569 + if _, found, _ := cache.Get("avatar", "did:plc:expired", "cid_expired"); found { 570 + t.Error("Expired entry should have been cleaned up by background job") 571 + } 572 + } 573 + 574 + func TestDiskCache_StartCleanupJob_ZeroInterval(t *testing.T) { 575 + tmpDir := t.TempDir() 576 + cache := mustNewDiskCache(t, tmpDir, 1) 577 + 578 + // Starting with 0 interval should return a no-op cancel 579 + cancel := cache.StartCleanupJob(0) 580 + defer cancel() 581 + 582 + // Should not panic when called 583 + cancel() 584 + cancel() // Multiple calls should be safe 585 + } 586 + 587 + func TestDiskCache_StartCleanupJob_GracefulShutdown(t *testing.T) { 588 + tmpDir := t.TempDir() 589 + cache := mustNewDiskCache(t, tmpDir, 1) 590 + 591 + // Start cleanup job 592 + cancel := cache.StartCleanupJob(10 * time.Millisecond) 593 + 594 + // Let it run briefly 595 + time.Sleep(30 * time.Millisecond) 596 + 597 + // Cancel should not hang or panic 598 + done := make(chan struct{}) 599 + go func() { 600 + cancel() 601 + close(done) 602 + }() 603 + 604 + select { 605 + case <-done: 606 + // Good, cancel returned 607 + case <-time.After(1 * time.Second): 608 + t.Error("Cancel took too long, may be stuck") 609 + } 610 + }
+232
internal/core/imageproxy/config.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "log/slog" 7 + "os" 8 + "strconv" 9 + "time" 10 + ) 11 + 12 + // Config validation errors 13 + var ( 14 + // ErrInvalidCacheMaxGB is returned when CacheMaxGB is not positive 15 + ErrInvalidCacheMaxGB = errors.New("CacheMaxGB must be positive") 16 + // ErrInvalidFetchTimeout is returned when FetchTimeout is not positive 17 + ErrInvalidFetchTimeout = errors.New("FetchTimeout must be positive") 18 + // ErrInvalidMaxSourceSize is returned when MaxSourceSizeMB is not positive 19 + ErrInvalidMaxSourceSize = errors.New("MaxSourceSizeMB must be positive") 20 + // ErrMissingCachePath is returned when CachePath is empty while Enabled is true 21 + ErrMissingCachePath = errors.New("CachePath is required when proxy is enabled") 22 + // ErrInvalidCacheTTL is returned when CacheTTLDays is negative 23 + ErrInvalidCacheTTL = errors.New("CacheTTLDays cannot be negative") 24 + ) 25 + 26 + // Config holds the configuration for the image proxy service. 27 + type Config struct { 28 + // Enabled determines whether the image proxy service is active. 29 + Enabled bool 30 + 31 + // BaseURL is the origin/domain for the image proxy service (e.g., "https://coves.social"). 32 + // Empty string generates relative URLs (e.g., "/img/avatar/plain/did/cid"). 33 + // The /img path prefix is added automatically by the URL generation function. 34 + BaseURL string 35 + 36 + // CachePath is the filesystem path where cached images are stored. 37 + CachePath string 38 + 39 + // CacheMaxGB is the maximum cache size in gigabytes. 40 + CacheMaxGB int 41 + 42 + // CacheTTLDays is the maximum age in days for cached entries. 43 + // Entries older than this are eligible for cleanup regardless of cache size. 44 + // Set to 0 to disable TTL-based cleanup (only LRU eviction applies). 45 + CacheTTLDays int 46 + 47 + // CleanupInterval is how often to run cache cleanup (TTL + LRU eviction). 48 + // Set to 0 to disable background cleanup. 49 + CleanupInterval time.Duration 50 + 51 + // CDNURL is the optional CDN URL prefix for serving cached images. 52 + CDNURL string 53 + 54 + // FetchTimeout is the maximum time allowed for fetching images from PDS. 55 + FetchTimeout time.Duration 56 + 57 + // MaxSourceSizeMB is the maximum allowed size for source images in megabytes. 58 + MaxSourceSizeMB int 59 + } 60 + 61 + // NewConfig creates a new Config with the provided values and validates it. 62 + // This is the recommended way to create a Config, as it ensures all invariants are satisfied. 63 + // Use DefaultConfig() or ConfigFromEnv() for convenient config creation with sensible defaults. 64 + func NewConfig( 65 + enabled bool, 66 + baseURL string, 67 + cachePath string, 68 + cacheMaxGB int, 69 + cacheTTLDays int, 70 + cleanupInterval time.Duration, 71 + cdnURL string, 72 + fetchTimeout time.Duration, 73 + maxSourceSizeMB int, 74 + ) (Config, error) { 75 + cfg := Config{ 76 + Enabled: enabled, 77 + BaseURL: baseURL, 78 + CachePath: cachePath, 79 + CacheMaxGB: cacheMaxGB, 80 + CacheTTLDays: cacheTTLDays, 81 + CleanupInterval: cleanupInterval, 82 + CDNURL: cdnURL, 83 + FetchTimeout: fetchTimeout, 84 + MaxSourceSizeMB: maxSourceSizeMB, 85 + } 86 + 87 + if err := cfg.Validate(); err != nil { 88 + return Config{}, err 89 + } 90 + 91 + return cfg, nil 92 + } 93 + 94 + // Validate checks the configuration for invalid values. 95 + // Returns nil if the configuration is valid, or an error describing the problem. 96 + // When Enabled is false, only numeric constraints are validated (for safety). 97 + // When Enabled is true, all required fields must be set. 98 + func (c Config) Validate() error { 99 + // Always validate numeric constraints regardless of enabled state 100 + if c.CacheMaxGB <= 0 { 101 + return fmt.Errorf("%w: got %d", ErrInvalidCacheMaxGB, c.CacheMaxGB) 102 + } 103 + if c.FetchTimeout <= 0 { 104 + return fmt.Errorf("%w: got %v", ErrInvalidFetchTimeout, c.FetchTimeout) 105 + } 106 + if c.MaxSourceSizeMB <= 0 { 107 + return fmt.Errorf("%w: got %d", ErrInvalidMaxSourceSize, c.MaxSourceSizeMB) 108 + } 109 + if c.CacheTTLDays < 0 { 110 + return fmt.Errorf("%w: got %d", ErrInvalidCacheTTL, c.CacheTTLDays) 111 + } 112 + 113 + // When enabled, validate required fields 114 + if c.Enabled { 115 + if c.CachePath == "" { 116 + return ErrMissingCachePath 117 + } 118 + // BaseURL can be empty for relative URLs 119 + } 120 + 121 + return nil 122 + } 123 + 124 + // DefaultConfig returns a Config with sensible default values. 125 + func DefaultConfig() Config { 126 + return Config{ 127 + Enabled: true, 128 + BaseURL: "", 129 + CachePath: "/var/cache/coves/images", 130 + CacheMaxGB: 10, 131 + CacheTTLDays: 30, 132 + CleanupInterval: 1 * time.Hour, 133 + CDNURL: "", 134 + FetchTimeout: 30 * time.Second, 135 + MaxSourceSizeMB: 10, 136 + } 137 + } 138 + 139 + // ConfigFromEnv creates a Config from environment variables. 140 + // Uses defaults for any missing environment variables. 141 + // 142 + // Environment variables: 143 + // - IMAGE_PROXY_ENABLED: "true"/"1" to enable, "false"/"0" to disable (default: true) 144 + // - IMAGE_PROXY_BASE_URL: origin URL for image proxy (default: "" for relative URLs) 145 + // - IMAGE_PROXY_CACHE_PATH: filesystem cache path (default: "/var/cache/coves/images") 146 + // - IMAGE_PROXY_CACHE_MAX_GB: max cache size in GB (default: 10) 147 + // - IMAGE_PROXY_CACHE_TTL_DAYS: max age for cache entries in days, 0 to disable (default: 30) 148 + // - IMAGE_PROXY_CLEANUP_INTERVAL_MINUTES: cleanup job interval in minutes, 0 to disable (default: 60) 149 + // - IMAGE_PROXY_CDN_URL: optional CDN URL prefix (default: "") 150 + // - IMAGE_PROXY_FETCH_TIMEOUT_SECONDS: PDS fetch timeout in seconds (default: 30) 151 + // - IMAGE_PROXY_MAX_SOURCE_SIZE_MB: max source image size in MB (default: 10) 152 + func ConfigFromEnv() Config { 153 + cfg := DefaultConfig() 154 + 155 + if v := os.Getenv("IMAGE_PROXY_ENABLED"); v != "" { 156 + cfg.Enabled = v == "true" || v == "1" 157 + } 158 + 159 + if v := os.Getenv("IMAGE_PROXY_BASE_URL"); v != "" { 160 + cfg.BaseURL = v 161 + } 162 + 163 + if v := os.Getenv("IMAGE_PROXY_CACHE_PATH"); v != "" { 164 + cfg.CachePath = v 165 + } 166 + 167 + if v := os.Getenv("IMAGE_PROXY_CACHE_MAX_GB"); v != "" { 168 + if n, err := strconv.Atoi(v); err == nil && n > 0 { 169 + cfg.CacheMaxGB = n 170 + } else { 171 + slog.Warn("[IMAGE-PROXY] invalid IMAGE_PROXY_CACHE_MAX_GB value, using default", 172 + "value", v, 173 + "default", cfg.CacheMaxGB, 174 + "error", err, 175 + ) 176 + } 177 + } 178 + 179 + if v := os.Getenv("IMAGE_PROXY_CACHE_TTL_DAYS"); v != "" { 180 + if n, err := strconv.Atoi(v); err == nil && n >= 0 { 181 + cfg.CacheTTLDays = n 182 + } else { 183 + slog.Warn("[IMAGE-PROXY] invalid IMAGE_PROXY_CACHE_TTL_DAYS value, using default", 184 + "value", v, 185 + "default", cfg.CacheTTLDays, 186 + "error", err, 187 + ) 188 + } 189 + } 190 + 191 + if v := os.Getenv("IMAGE_PROXY_CLEANUP_INTERVAL_MINUTES"); v != "" { 192 + if n, err := strconv.Atoi(v); err == nil && n >= 0 { 193 + cfg.CleanupInterval = time.Duration(n) * time.Minute 194 + } else { 195 + slog.Warn("[IMAGE-PROXY] invalid IMAGE_PROXY_CLEANUP_INTERVAL_MINUTES value, using default", 196 + "value", v, 197 + "default_minutes", int(cfg.CleanupInterval.Minutes()), 198 + "error", err, 199 + ) 200 + } 201 + } 202 + 203 + if v := os.Getenv("IMAGE_PROXY_CDN_URL"); v != "" { 204 + cfg.CDNURL = v 205 + } 206 + 207 + if v := os.Getenv("IMAGE_PROXY_FETCH_TIMEOUT_SECONDS"); v != "" { 208 + if n, err := strconv.Atoi(v); err == nil && n > 0 { 209 + cfg.FetchTimeout = time.Duration(n) * time.Second 210 + } else { 211 + slog.Warn("[IMAGE-PROXY] invalid IMAGE_PROXY_FETCH_TIMEOUT_SECONDS value, using default", 212 + "value", v, 213 + "default_seconds", int(cfg.FetchTimeout.Seconds()), 214 + "error", err, 215 + ) 216 + } 217 + } 218 + 219 + if v := os.Getenv("IMAGE_PROXY_MAX_SOURCE_SIZE_MB"); v != "" { 220 + if n, err := strconv.Atoi(v); err == nil && n > 0 { 221 + cfg.MaxSourceSizeMB = n 222 + } else { 223 + slog.Warn("[IMAGE-PROXY] invalid IMAGE_PROXY_MAX_SOURCE_SIZE_MB value, using default", 224 + "value", v, 225 + "default", cfg.MaxSourceSizeMB, 226 + "error", err, 227 + ) 228 + } 229 + } 230 + 231 + return cfg 232 + }
+245
internal/core/imageproxy/config_test.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "errors" 5 + "testing" 6 + "time" 7 + ) 8 + 9 + func TestConfig_Validate(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + config Config 13 + wantErr error 14 + }{ 15 + { 16 + name: "valid default config", 17 + config: DefaultConfig(), 18 + wantErr: nil, 19 + }, 20 + { 21 + name: "valid enabled config", 22 + config: Config{ 23 + Enabled: true, 24 + BaseURL: "/img", 25 + CachePath: "/var/cache/images", 26 + CacheMaxGB: 10, 27 + FetchTimeout: 30 * time.Second, 28 + MaxSourceSizeMB: 10, 29 + }, 30 + wantErr: nil, 31 + }, 32 + { 33 + name: "invalid CacheMaxGB zero", 34 + config: Config{ 35 + Enabled: false, 36 + BaseURL: "/img", 37 + CachePath: "/var/cache/images", 38 + CacheMaxGB: 0, 39 + FetchTimeout: 30 * time.Second, 40 + MaxSourceSizeMB: 10, 41 + }, 42 + wantErr: ErrInvalidCacheMaxGB, 43 + }, 44 + { 45 + name: "invalid CacheMaxGB negative", 46 + config: Config{ 47 + Enabled: false, 48 + BaseURL: "/img", 49 + CachePath: "/var/cache/images", 50 + CacheMaxGB: -5, 51 + FetchTimeout: 30 * time.Second, 52 + MaxSourceSizeMB: 10, 53 + }, 54 + wantErr: ErrInvalidCacheMaxGB, 55 + }, 56 + { 57 + name: "invalid FetchTimeout zero", 58 + config: Config{ 59 + Enabled: false, 60 + BaseURL: "/img", 61 + CachePath: "/var/cache/images", 62 + CacheMaxGB: 10, 63 + FetchTimeout: 0, 64 + MaxSourceSizeMB: 10, 65 + }, 66 + wantErr: ErrInvalidFetchTimeout, 67 + }, 68 + { 69 + name: "invalid FetchTimeout negative", 70 + config: Config{ 71 + Enabled: false, 72 + BaseURL: "/img", 73 + CachePath: "/var/cache/images", 74 + CacheMaxGB: 10, 75 + FetchTimeout: -1 * time.Second, 76 + MaxSourceSizeMB: 10, 77 + }, 78 + wantErr: ErrInvalidFetchTimeout, 79 + }, 80 + { 81 + name: "invalid MaxSourceSizeMB zero", 82 + config: Config{ 83 + Enabled: false, 84 + BaseURL: "/img", 85 + CachePath: "/var/cache/images", 86 + CacheMaxGB: 10, 87 + FetchTimeout: 30 * time.Second, 88 + MaxSourceSizeMB: 0, 89 + }, 90 + wantErr: ErrInvalidMaxSourceSize, 91 + }, 92 + { 93 + name: "invalid MaxSourceSizeMB negative", 94 + config: Config{ 95 + Enabled: false, 96 + BaseURL: "/img", 97 + CachePath: "/var/cache/images", 98 + CacheMaxGB: 10, 99 + FetchTimeout: 30 * time.Second, 100 + MaxSourceSizeMB: -5, 101 + }, 102 + wantErr: ErrInvalidMaxSourceSize, 103 + }, 104 + { 105 + name: "enabled but missing CachePath", 106 + config: Config{ 107 + Enabled: true, 108 + BaseURL: "/img", 109 + CachePath: "", 110 + CacheMaxGB: 10, 111 + FetchTimeout: 30 * time.Second, 112 + MaxSourceSizeMB: 10, 113 + }, 114 + wantErr: ErrMissingCachePath, 115 + }, 116 + { 117 + name: "enabled allows empty BaseURL for relative URLs", 118 + config: Config{ 119 + Enabled: true, 120 + BaseURL: "", 121 + CachePath: "/var/cache/images", 122 + CacheMaxGB: 10, 123 + FetchTimeout: 30 * time.Second, 124 + MaxSourceSizeMB: 10, 125 + }, 126 + wantErr: nil, 127 + }, 128 + { 129 + name: "disabled allows empty CachePath", 130 + config: Config{ 131 + Enabled: false, 132 + BaseURL: "/img", 133 + CachePath: "", 134 + CacheMaxGB: 10, 135 + FetchTimeout: 30 * time.Second, 136 + MaxSourceSizeMB: 10, 137 + }, 138 + wantErr: nil, 139 + }, 140 + { 141 + name: "disabled allows empty BaseURL", 142 + config: Config{ 143 + Enabled: false, 144 + BaseURL: "", 145 + CachePath: "/var/cache/images", 146 + CacheMaxGB: 10, 147 + FetchTimeout: 30 * time.Second, 148 + MaxSourceSizeMB: 10, 149 + }, 150 + wantErr: nil, 151 + }, 152 + { 153 + name: "valid TTL zero (disabled)", 154 + config: Config{ 155 + Enabled: true, 156 + BaseURL: "", 157 + CachePath: "/var/cache/images", 158 + CacheMaxGB: 10, 159 + CacheTTLDays: 0, 160 + FetchTimeout: 30 * time.Second, 161 + MaxSourceSizeMB: 10, 162 + }, 163 + wantErr: nil, 164 + }, 165 + { 166 + name: "valid TTL positive", 167 + config: Config{ 168 + Enabled: true, 169 + BaseURL: "", 170 + CachePath: "/var/cache/images", 171 + CacheMaxGB: 10, 172 + CacheTTLDays: 30, 173 + FetchTimeout: 30 * time.Second, 174 + MaxSourceSizeMB: 10, 175 + }, 176 + wantErr: nil, 177 + }, 178 + { 179 + name: "invalid TTL negative", 180 + config: Config{ 181 + Enabled: true, 182 + BaseURL: "", 183 + CachePath: "/var/cache/images", 184 + CacheMaxGB: 10, 185 + CacheTTLDays: -1, 186 + FetchTimeout: 30 * time.Second, 187 + MaxSourceSizeMB: 10, 188 + }, 189 + wantErr: ErrInvalidCacheTTL, 190 + }, 191 + } 192 + 193 + for _, tt := range tests { 194 + t.Run(tt.name, func(t *testing.T) { 195 + err := tt.config.Validate() 196 + if tt.wantErr == nil { 197 + if err != nil { 198 + t.Errorf("expected no error, got: %v", err) 199 + } 200 + } else { 201 + if !errors.Is(err, tt.wantErr) { 202 + t.Errorf("expected %v, got: %v", tt.wantErr, err) 203 + } 204 + } 205 + }) 206 + } 207 + } 208 + 209 + func TestDefaultConfig(t *testing.T) { 210 + cfg := DefaultConfig() 211 + 212 + // Verify default values 213 + if !cfg.Enabled { 214 + t.Error("expected Enabled to be true by default") 215 + } 216 + if cfg.BaseURL != "" { 217 + t.Errorf("expected empty BaseURL for relative URLs, got %q", cfg.BaseURL) 218 + } 219 + if cfg.CachePath != "/var/cache/coves/images" { 220 + t.Errorf("expected CachePath '/var/cache/coves/images', got %q", cfg.CachePath) 221 + } 222 + if cfg.CacheMaxGB != 10 { 223 + t.Errorf("expected CacheMaxGB 10, got %d", cfg.CacheMaxGB) 224 + } 225 + if cfg.CacheTTLDays != 30 { 226 + t.Errorf("expected CacheTTLDays 30, got %d", cfg.CacheTTLDays) 227 + } 228 + if cfg.CleanupInterval != 1*time.Hour { 229 + t.Errorf("expected CleanupInterval 1h, got %v", cfg.CleanupInterval) 230 + } 231 + if cfg.CDNURL != "" { 232 + t.Errorf("expected empty CDNURL, got %q", cfg.CDNURL) 233 + } 234 + if cfg.FetchTimeout != 30*time.Second { 235 + t.Errorf("expected FetchTimeout 30s, got %v", cfg.FetchTimeout) 236 + } 237 + if cfg.MaxSourceSizeMB != 10 { 238 + t.Errorf("expected MaxSourceSizeMB 10, got %d", cfg.MaxSourceSizeMB) 239 + } 240 + 241 + // Default config should be valid 242 + if err := cfg.Validate(); err != nil { 243 + t.Errorf("default config should be valid, got error: %v", err) 244 + } 245 + }
+35
internal/core/imageproxy/errors.go
···
··· 1 + package imageproxy 2 + 3 + import "errors" 4 + 5 + var ( 6 + // ErrInvalidPreset is returned when a preset name is not found in the preset registry. 7 + ErrInvalidPreset = errors.New("invalid image preset") 8 + 9 + // ErrInvalidDID is returned when a DID string does not match expected atproto DID format. 10 + ErrInvalidDID = errors.New("invalid DID format") 11 + 12 + // ErrInvalidCID is returned when a CID string is not a valid content identifier. 13 + ErrInvalidCID = errors.New("invalid CID format") 14 + 15 + // ErrPDSFetchFailed is returned when fetching a blob from a PDS fails for any reason. 16 + ErrPDSFetchFailed = errors.New("failed to fetch blob from PDS") 17 + 18 + // ErrPDSNotFound is returned when the requested blob does not exist on the PDS. 19 + ErrPDSNotFound = errors.New("blob not found on PDS") 20 + 21 + // ErrPDSTimeout is returned when a PDS request exceeds the configured timeout. 22 + ErrPDSTimeout = errors.New("PDS request timed out") 23 + 24 + // ErrUnsupportedFormat is returned when the source image format cannot be processed. 25 + ErrUnsupportedFormat = errors.New("unsupported image format") 26 + 27 + // ErrImageTooLarge is returned when the source image exceeds the maximum allowed size. 28 + ErrImageTooLarge = errors.New("source image exceeds size limit") 29 + 30 + // ErrProcessingFailed is returned when image processing fails for any reason. 31 + ErrProcessingFailed = errors.New("image processing failed") 32 + 33 + // ErrNilDependency is returned when a required dependency is nil. 34 + ErrNilDependency = errors.New("required dependency is nil") 35 + )
+158
internal/core/imageproxy/fetcher.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "net/url" 10 + "strings" 11 + "time" 12 + ) 13 + 14 + // Fetcher defines the interface for fetching blobs from a PDS. 15 + type Fetcher interface { 16 + // Fetch retrieves a blob from the specified PDS. 17 + // Returns the blob bytes or an error if the fetch fails. 18 + Fetch(ctx context.Context, pdsURL, did, cid string) ([]byte, error) 19 + } 20 + 21 + // PDSFetcher implements the Fetcher interface for fetching blobs from atproto PDS servers. 22 + type PDSFetcher struct { 23 + client *http.Client 24 + timeout time.Duration 25 + maxSizeBytes int64 26 + } 27 + 28 + // DefaultMaxSourceSizeMB is the default maximum source image size if not configured. 29 + const DefaultMaxSourceSizeMB = 10 30 + 31 + // NewPDSFetcher creates a new PDSFetcher with the specified timeout. 32 + // maxSizeMB specifies the maximum allowed image size in megabytes (0 uses default of 10MB). 33 + func NewPDSFetcher(timeout time.Duration, maxSizeMB int) *PDSFetcher { 34 + if maxSizeMB <= 0 { 35 + maxSizeMB = DefaultMaxSourceSizeMB 36 + } 37 + return &PDSFetcher{ 38 + client: &http.Client{ 39 + Timeout: timeout, 40 + }, 41 + timeout: timeout, 42 + maxSizeBytes: int64(maxSizeMB) * 1024 * 1024, 43 + } 44 + } 45 + 46 + // Fetch retrieves a blob from the specified PDS using the com.atproto.sync.getBlob endpoint. 47 + // Returns: 48 + // - ErrPDSNotFound if the blob does not exist (404 response) 49 + // - ErrPDSTimeout if the request times out or context is cancelled 50 + // - ErrPDSFetchFailed for any other error 51 + func (f *PDSFetcher) Fetch(ctx context.Context, pdsURL, did, cid string) ([]byte, error) { 52 + // Construct the request URL 53 + endpoint, err := url.Parse(pdsURL) 54 + if err != nil { 55 + return nil, fmt.Errorf("%w: invalid PDS URL: %v", ErrPDSFetchFailed, err) 56 + } 57 + endpoint.Path = "/xrpc/com.atproto.sync.getBlob" 58 + 59 + query := url.Values{} 60 + query.Set("did", did) 61 + query.Set("cid", cid) 62 + endpoint.RawQuery = query.Encode() 63 + 64 + // Create the request with context 65 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) 66 + if err != nil { 67 + return nil, fmt.Errorf("%w: failed to create request: %v", ErrPDSFetchFailed, err) 68 + } 69 + 70 + // Set User-Agent header for identification 71 + req.Header.Set("User-Agent", "Coves-ImageProxy/1.0") 72 + 73 + // Execute the request 74 + resp, err := f.client.Do(req) 75 + if err != nil { 76 + // Check if the error is due to context cancellation or timeout 77 + if ctx.Err() != nil { 78 + return nil, fmt.Errorf("%w: %v", ErrPDSTimeout, ctx.Err()) 79 + } 80 + // Check if it's a timeout error from the http client 81 + if isTimeoutError(err) { 82 + return nil, fmt.Errorf("%w: request timed out", ErrPDSTimeout) 83 + } 84 + return nil, fmt.Errorf("%w: %v", ErrPDSFetchFailed, err) 85 + } 86 + defer resp.Body.Close() 87 + 88 + // Handle response status codes 89 + switch resp.StatusCode { 90 + case http.StatusOK: 91 + // Check Content-Length header if available 92 + if resp.ContentLength > 0 && resp.ContentLength > f.maxSizeBytes { 93 + return nil, fmt.Errorf("%w: content length %d exceeds maximum %d bytes", 94 + ErrImageTooLarge, resp.ContentLength, f.maxSizeBytes) 95 + } 96 + 97 + // Use a limited reader to prevent memory exhaustion even if Content-Length is missing or wrong. 98 + // We read maxSizeBytes + 1 to detect if the response exceeds the limit. 99 + limitedReader := io.LimitReader(resp.Body, f.maxSizeBytes+1) 100 + data, err := io.ReadAll(limitedReader) 101 + if err != nil { 102 + return nil, fmt.Errorf("%w: failed to read response body: %v", ErrPDSFetchFailed, err) 103 + } 104 + 105 + // Check if we hit the limit (meaning there was more data) 106 + if int64(len(data)) > f.maxSizeBytes { 107 + return nil, fmt.Errorf("%w: response body exceeds maximum %d bytes", 108 + ErrImageTooLarge, f.maxSizeBytes) 109 + } 110 + 111 + return data, nil 112 + 113 + case http.StatusNotFound: 114 + return nil, ErrPDSNotFound 115 + 116 + case http.StatusBadRequest: 117 + // AT Protocol PDS may return 400 with "Blob not found" for missing blobs 118 + // We need to check the error message to distinguish from actual bad requests 119 + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024)) 120 + if readErr == nil && isBlobNotFoundError(body) { 121 + return nil, ErrPDSNotFound 122 + } 123 + return nil, fmt.Errorf("%w: bad request (status 400)", ErrPDSFetchFailed) 124 + 125 + default: 126 + return nil, fmt.Errorf("%w: unexpected status code %d", ErrPDSFetchFailed, resp.StatusCode) 127 + } 128 + } 129 + 130 + // pdsErrorResponse represents the error response structure from AT Protocol PDS 131 + type pdsErrorResponse struct { 132 + Error string `json:"error"` 133 + Message string `json:"message"` 134 + } 135 + 136 + // isBlobNotFoundError checks if the error response indicates a blob was not found. 137 + // AT Protocol PDS returns 400 with {"error":"InvalidRequest","message":"Blob not found"} 138 + // for missing blobs instead of a proper 404. 139 + func isBlobNotFoundError(body []byte) bool { 140 + var errResp pdsErrorResponse 141 + if err := json.Unmarshal(body, &errResp); err != nil { 142 + return false 143 + } 144 + // Check for "Blob not found" message (case-insensitive) 145 + return strings.Contains(strings.ToLower(errResp.Message), "blob not found") 146 + } 147 + 148 + // isTimeoutError checks if the error is a timeout-related error. 149 + func isTimeoutError(err error) bool { 150 + if err == nil { 151 + return false 152 + } 153 + // Check for timeout interface 154 + if te, ok := err.(interface{ Timeout() bool }); ok { 155 + return te.Timeout() 156 + } 157 + return false 158 + }
+231
internal/core/imageproxy/fetcher_test.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + "time" 10 + ) 11 + 12 + func TestPDSFetcher_Fetch_Success(t *testing.T) { 13 + // Setup test server that returns blob data 14 + expectedData := []byte("test image data") 15 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 + // Verify the request path and query parameters 17 + if r.URL.Path != "/xrpc/com.atproto.sync.getBlob" { 18 + t.Errorf("unexpected path: %s", r.URL.Path) 19 + } 20 + if r.URL.Query().Get("did") != "did:plc:test123" { 21 + t.Errorf("unexpected did: %s", r.URL.Query().Get("did")) 22 + } 23 + if r.URL.Query().Get("cid") != "bafyreicid123" { 24 + t.Errorf("unexpected cid: %s", r.URL.Query().Get("cid")) 25 + } 26 + w.WriteHeader(http.StatusOK) 27 + w.Write(expectedData) 28 + })) 29 + defer server.Close() 30 + 31 + fetcher := NewPDSFetcher(5 * time.Second, 10) 32 + ctx := context.Background() 33 + 34 + data, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 35 + if err != nil { 36 + t.Fatalf("expected no error, got: %v", err) 37 + } 38 + if string(data) != string(expectedData) { 39 + t.Errorf("expected data %q, got %q", expectedData, data) 40 + } 41 + } 42 + 43 + func TestPDSFetcher_Fetch_NotFound(t *testing.T) { 44 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 45 + w.WriteHeader(http.StatusNotFound) 46 + })) 47 + defer server.Close() 48 + 49 + fetcher := NewPDSFetcher(5 * time.Second, 10) 50 + ctx := context.Background() 51 + 52 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 53 + if !errors.Is(err, ErrPDSNotFound) { 54 + t.Errorf("expected ErrPDSNotFound, got: %v", err) 55 + } 56 + } 57 + 58 + func TestPDSFetcher_Fetch_Timeout(t *testing.T) { 59 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 60 + // Sleep longer than the timeout 61 + time.Sleep(200 * time.Millisecond) 62 + w.WriteHeader(http.StatusOK) 63 + })) 64 + defer server.Close() 65 + 66 + // Use a very short timeout 67 + fetcher := NewPDSFetcher(50 * time.Millisecond, 10) 68 + ctx := context.Background() 69 + 70 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 71 + if !errors.Is(err, ErrPDSTimeout) { 72 + t.Errorf("expected ErrPDSTimeout, got: %v", err) 73 + } 74 + } 75 + 76 + func TestPDSFetcher_Fetch_NetworkError(t *testing.T) { 77 + fetcher := NewPDSFetcher(5 * time.Second, 10) 78 + ctx := context.Background() 79 + 80 + // Use an invalid URL that will cause a network error 81 + _, err := fetcher.Fetch(ctx, "http://localhost:99999", "did:plc:test123", "bafyreicid123") 82 + if !errors.Is(err, ErrPDSFetchFailed) { 83 + t.Errorf("expected ErrPDSFetchFailed, got: %v", err) 84 + } 85 + } 86 + 87 + func TestPDSFetcher_Fetch_ContextCancellation(t *testing.T) { 88 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 89 + // Sleep to allow context cancellation 90 + time.Sleep(100 * time.Millisecond) 91 + w.WriteHeader(http.StatusOK) 92 + })) 93 + defer server.Close() 94 + 95 + fetcher := NewPDSFetcher(5 * time.Second, 10) 96 + ctx, cancel := context.WithCancel(context.Background()) 97 + 98 + // Cancel the context immediately 99 + cancel() 100 + 101 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 102 + if err == nil { 103 + t.Error("expected error due to context cancellation") 104 + } 105 + // Context cancellation should return ErrPDSTimeout 106 + if !errors.Is(err, ErrPDSTimeout) { 107 + t.Errorf("expected ErrPDSTimeout for context cancellation, got: %v", err) 108 + } 109 + } 110 + 111 + func TestPDSFetcher_Fetch_ServerError(t *testing.T) { 112 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 113 + w.WriteHeader(http.StatusInternalServerError) 114 + })) 115 + defer server.Close() 116 + 117 + fetcher := NewPDSFetcher(5 * time.Second, 10) 118 + ctx := context.Background() 119 + 120 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 121 + if !errors.Is(err, ErrPDSFetchFailed) { 122 + t.Errorf("expected ErrPDSFetchFailed, got: %v", err) 123 + } 124 + } 125 + 126 + func TestPDSFetcher_Fetch_URLConstruction(t *testing.T) { 127 + var capturedURL string 128 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 129 + capturedURL = r.URL.String() 130 + w.WriteHeader(http.StatusOK) 131 + w.Write([]byte("data")) 132 + })) 133 + defer server.Close() 134 + 135 + fetcher := NewPDSFetcher(5 * time.Second, 10) 136 + ctx := context.Background() 137 + 138 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:abc123", "bafyreicid456") 139 + if err != nil { 140 + t.Fatalf("unexpected error: %v", err) 141 + } 142 + 143 + expectedPath := "/xrpc/com.atproto.sync.getBlob?cid=bafyreicid456&did=did%3Aplc%3Aabc123" 144 + if capturedURL != expectedPath { 145 + t.Errorf("expected URL %q, got %q", expectedPath, capturedURL) 146 + } 147 + } 148 + 149 + func TestPDSFetcher_Fetch_ImageTooLarge_ContentLength(t *testing.T) { 150 + // Server returns Content-Length header indicating size exceeds limit 151 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 152 + // Set Content-Length larger than the max (1MB) 153 + w.Header().Set("Content-Length", "2097152") // 2MB 154 + w.WriteHeader(http.StatusOK) 155 + // Don't actually write 2MB of data 156 + })) 157 + defer server.Close() 158 + 159 + // Use 1MB max size 160 + fetcher := NewPDSFetcher(5*time.Second, 1) 161 + ctx := context.Background() 162 + 163 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 164 + if !errors.Is(err, ErrImageTooLarge) { 165 + t.Errorf("expected ErrImageTooLarge, got: %v", err) 166 + } 167 + } 168 + 169 + func TestPDSFetcher_Fetch_ImageTooLarge_StreamingBody(t *testing.T) { 170 + // Server doesn't send Content-Length but streams more data than allowed 171 + largeData := make([]byte, 2*1024*1024) // 2MB of zeros 172 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 173 + w.WriteHeader(http.StatusOK) 174 + w.Write(largeData) 175 + })) 176 + defer server.Close() 177 + 178 + // Use 1MB max size 179 + fetcher := NewPDSFetcher(5*time.Second, 1) 180 + ctx := context.Background() 181 + 182 + _, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 183 + if !errors.Is(err, ErrImageTooLarge) { 184 + t.Errorf("expected ErrImageTooLarge, got: %v", err) 185 + } 186 + } 187 + 188 + func TestPDSFetcher_Fetch_SizeWithinLimit(t *testing.T) { 189 + // Server returns data within the limit 190 + testData := make([]byte, 512*1024) // 512KB 191 + for i := range testData { 192 + testData[i] = byte(i % 256) 193 + } 194 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 195 + w.WriteHeader(http.StatusOK) 196 + w.Write(testData) 197 + })) 198 + defer server.Close() 199 + 200 + // Use 1MB max size 201 + fetcher := NewPDSFetcher(5*time.Second, 1) 202 + ctx := context.Background() 203 + 204 + data, err := fetcher.Fetch(ctx, server.URL, "did:plc:test123", "bafyreicid123") 205 + if err != nil { 206 + t.Fatalf("expected no error, got: %v", err) 207 + } 208 + if len(data) != len(testData) { 209 + t.Errorf("expected %d bytes, got %d", len(testData), len(data)) 210 + } 211 + } 212 + 213 + func TestPDSFetcher_Fetch_DefaultMaxSize(t *testing.T) { 214 + // Test that 0 for maxSizeMB uses the default 215 + fetcher := NewPDSFetcher(5*time.Second, 0) 216 + expectedDefault := int64(DefaultMaxSourceSizeMB) * 1024 * 1024 217 + 218 + if fetcher.maxSizeBytes != expectedDefault { 219 + t.Errorf("expected default maxSizeBytes %d, got %d", expectedDefault, fetcher.maxSizeBytes) 220 + } 221 + } 222 + 223 + func TestPDSFetcher_Fetch_NegativeMaxSize(t *testing.T) { 224 + // Test that negative maxSizeMB uses the default 225 + fetcher := NewPDSFetcher(5*time.Second, -5) 226 + expectedDefault := int64(DefaultMaxSourceSizeMB) * 1024 * 1024 227 + 228 + if fetcher.maxSizeBytes != expectedDefault { 229 + t.Errorf("expected default maxSizeBytes %d, got %d", expectedDefault, fetcher.maxSizeBytes) 230 + } 231 + }
+117
internal/core/imageproxy/presets.go
···
··· 1 + package imageproxy 2 + 3 + // FitMode defines how an image should be fitted to the target dimensions. 4 + type FitMode string 5 + 6 + const ( 7 + // FitCover scales the image to cover the target dimensions, cropping if necessary. 8 + FitCover FitMode = "cover" 9 + // FitContain scales the image to fit within the target dimensions, preserving aspect ratio. 10 + FitContain FitMode = "contain" 11 + ) 12 + 13 + // String returns the string representation of the FitMode. 14 + func (f FitMode) String() string { 15 + return string(f) 16 + } 17 + 18 + // Preset defines the configuration for an image transformation preset. 19 + type Preset struct { 20 + Name string 21 + Width int 22 + Height int 23 + Fit FitMode 24 + Quality int 25 + } 26 + 27 + // Validate checks that the preset has valid configuration values. 28 + // Returns nil if valid, or an error describing what is wrong. 29 + func (p Preset) Validate() error { 30 + if p.Name == "" { 31 + return ErrInvalidPreset 32 + } 33 + if p.Width <= 0 { 34 + return ErrInvalidPreset 35 + } 36 + // Height can be 0 for FitContain (proportional scaling) 37 + if p.Fit == FitCover && p.Height <= 0 { 38 + return ErrInvalidPreset 39 + } 40 + // Quality must be in JPEG range (1-100) 41 + if p.Quality < 1 || p.Quality > 100 { 42 + return ErrInvalidPreset 43 + } 44 + // Validate FitMode is a known value 45 + if p.Fit != FitCover && p.Fit != FitContain { 46 + return ErrInvalidPreset 47 + } 48 + return nil 49 + } 50 + 51 + // presets is the registry of all available image presets. 52 + var presets = map[string]Preset{ 53 + "avatar": { 54 + Name: "avatar", 55 + Width: 1000, 56 + Height: 1000, 57 + Fit: FitCover, 58 + Quality: 85, 59 + }, 60 + "avatar_small": { 61 + Name: "avatar_small", 62 + Width: 360, 63 + Height: 360, 64 + Fit: FitCover, 65 + Quality: 80, 66 + }, 67 + "banner": { 68 + Name: "banner", 69 + Width: 640, 70 + Height: 300, 71 + Fit: FitCover, 72 + Quality: 85, 73 + }, 74 + "content_preview": { 75 + Name: "content_preview", 76 + Width: 800, 77 + Height: 0, 78 + Fit: FitContain, 79 + Quality: 80, 80 + }, 81 + "content_full": { 82 + Name: "content_full", 83 + Width: 1600, 84 + Height: 0, 85 + Fit: FitContain, 86 + Quality: 90, 87 + }, 88 + "embed_thumbnail": { 89 + Name: "embed_thumbnail", 90 + Width: 720, 91 + Height: 360, 92 + Fit: FitCover, 93 + Quality: 80, 94 + }, 95 + } 96 + 97 + // GetPreset returns the preset configuration for the given name. 98 + // Returns ErrInvalidPreset if the preset name is not found. 99 + func GetPreset(name string) (Preset, error) { 100 + if name == "" { 101 + return Preset{}, ErrInvalidPreset 102 + } 103 + preset, exists := presets[name] 104 + if !exists { 105 + return Preset{}, ErrInvalidPreset 106 + } 107 + return preset, nil 108 + } 109 + 110 + // ListPresets returns all available presets. 111 + func ListPresets() []Preset { 112 + result := make([]Preset, 0, len(presets)) 113 + for _, p := range presets { 114 + result = append(result, p) 115 + } 116 + return result 117 + }
+271
internal/core/imageproxy/presets_test.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + "github.com/stretchr/testify/require" 8 + ) 9 + 10 + func TestGetPreset(t *testing.T) { 11 + tests := []struct { 12 + name string 13 + presetName string 14 + wantWidth int 15 + wantHeight int 16 + wantFit FitMode 17 + wantQuality int 18 + wantErr error 19 + }{ 20 + { 21 + name: "avatar preset returns correct dimensions", 22 + presetName: "avatar", 23 + wantWidth: 1000, 24 + wantHeight: 1000, 25 + wantFit: FitCover, 26 + wantQuality: 85, 27 + wantErr: nil, 28 + }, 29 + { 30 + name: "avatar_small preset returns correct dimensions", 31 + presetName: "avatar_small", 32 + wantWidth: 360, 33 + wantHeight: 360, 34 + wantFit: FitCover, 35 + wantQuality: 80, 36 + wantErr: nil, 37 + }, 38 + { 39 + name: "banner preset returns correct dimensions", 40 + presetName: "banner", 41 + wantWidth: 640, 42 + wantHeight: 300, 43 + wantFit: FitCover, 44 + wantQuality: 85, 45 + wantErr: nil, 46 + }, 47 + { 48 + name: "content_preview preset returns correct dimensions", 49 + presetName: "content_preview", 50 + wantWidth: 800, 51 + wantHeight: 0, 52 + wantFit: FitContain, 53 + wantQuality: 80, 54 + wantErr: nil, 55 + }, 56 + { 57 + name: "content_full preset returns correct dimensions", 58 + presetName: "content_full", 59 + wantWidth: 1600, 60 + wantHeight: 0, 61 + wantFit: FitContain, 62 + wantQuality: 90, 63 + wantErr: nil, 64 + }, 65 + { 66 + name: "embed_thumbnail preset returns correct dimensions", 67 + presetName: "embed_thumbnail", 68 + wantWidth: 720, 69 + wantHeight: 360, 70 + wantFit: FitCover, 71 + wantQuality: 80, 72 + wantErr: nil, 73 + }, 74 + { 75 + name: "invalid preset returns error", 76 + presetName: "invalid", 77 + wantErr: ErrInvalidPreset, 78 + }, 79 + { 80 + name: "empty preset name returns error", 81 + presetName: "", 82 + wantErr: ErrInvalidPreset, 83 + }, 84 + { 85 + name: "case sensitive - AVATAR should not match", 86 + presetName: "AVATAR", 87 + wantErr: ErrInvalidPreset, 88 + }, 89 + } 90 + 91 + for _, tt := range tests { 92 + t.Run(tt.name, func(t *testing.T) { 93 + preset, err := GetPreset(tt.presetName) 94 + 95 + if tt.wantErr != nil { 96 + require.Error(t, err) 97 + assert.ErrorIs(t, err, tt.wantErr) 98 + return 99 + } 100 + 101 + require.NoError(t, err) 102 + assert.Equal(t, tt.presetName, preset.Name) 103 + assert.Equal(t, tt.wantWidth, preset.Width) 104 + assert.Equal(t, tt.wantHeight, preset.Height) 105 + assert.Equal(t, tt.wantFit, preset.Fit) 106 + assert.Equal(t, tt.wantQuality, preset.Quality) 107 + }) 108 + } 109 + } 110 + 111 + func TestAllPresetsHaveValidDimensions(t *testing.T) { 112 + presetNames := []string{ 113 + "avatar", 114 + "avatar_small", 115 + "banner", 116 + "content_preview", 117 + "content_full", 118 + "embed_thumbnail", 119 + } 120 + 121 + for _, name := range presetNames { 122 + t.Run(name, func(t *testing.T) { 123 + preset, err := GetPreset(name) 124 + require.NoError(t, err) 125 + 126 + // Width must always be positive 127 + assert.Greater(t, preset.Width, 0, "preset %s must have positive width", name) 128 + 129 + // Height can be 0 for contain fit (proportional scaling) 130 + if preset.Fit == FitCover { 131 + assert.Greater(t, preset.Height, 0, "cover fit preset %s must have positive height", name) 132 + } 133 + 134 + // Quality must be between 1 and 100 135 + assert.GreaterOrEqual(t, preset.Quality, 1, "preset %s quality must be >= 1", name) 136 + assert.LessOrEqual(t, preset.Quality, 100, "preset %s quality must be <= 100", name) 137 + 138 + // Name must match 139 + assert.Equal(t, name, preset.Name) 140 + }) 141 + } 142 + } 143 + 144 + func TestFitModeString(t *testing.T) { 145 + tests := []struct { 146 + mode FitMode 147 + want string 148 + }{ 149 + {FitCover, "cover"}, 150 + {FitContain, "contain"}, 151 + } 152 + 153 + for _, tt := range tests { 154 + t.Run(tt.want, func(t *testing.T) { 155 + assert.Equal(t, tt.want, tt.mode.String()) 156 + }) 157 + } 158 + } 159 + 160 + func TestListPresets(t *testing.T) { 161 + presets := ListPresets() 162 + 163 + // Should have all 6 presets 164 + assert.Len(t, presets, 6) 165 + 166 + // Verify all expected presets are present 167 + expectedNames := map[string]bool{ 168 + "avatar": false, 169 + "avatar_small": false, 170 + "banner": false, 171 + "content_preview": false, 172 + "content_full": false, 173 + "embed_thumbnail": false, 174 + } 175 + 176 + for _, p := range presets { 177 + if _, exists := expectedNames[p.Name]; exists { 178 + expectedNames[p.Name] = true 179 + } 180 + } 181 + 182 + for name, found := range expectedNames { 183 + assert.True(t, found, "expected preset %s to be in list", name) 184 + } 185 + } 186 + 187 + func TestPreset_Validate(t *testing.T) { 188 + tests := []struct { 189 + name string 190 + preset Preset 191 + wantErr bool 192 + }{ 193 + { 194 + name: "valid avatar preset", 195 + preset: Preset{Name: "avatar", Width: 1000, Height: 1000, Fit: FitCover, Quality: 85}, 196 + wantErr: false, 197 + }, 198 + { 199 + name: "valid contain preset with zero height", 200 + preset: Preset{Name: "content", Width: 800, Height: 0, Fit: FitContain, Quality: 80}, 201 + wantErr: false, 202 + }, 203 + { 204 + name: "invalid empty name", 205 + preset: Preset{Name: "", Width: 160, Height: 160, Fit: FitCover, Quality: 85}, 206 + wantErr: true, 207 + }, 208 + { 209 + name: "invalid zero width", 210 + preset: Preset{Name: "test", Width: 0, Height: 160, Fit: FitCover, Quality: 85}, 211 + wantErr: true, 212 + }, 213 + { 214 + name: "invalid negative width", 215 + preset: Preset{Name: "test", Width: -100, Height: 160, Fit: FitCover, Quality: 85}, 216 + wantErr: true, 217 + }, 218 + { 219 + name: "invalid cover fit with zero height", 220 + preset: Preset{Name: "test", Width: 160, Height: 0, Fit: FitCover, Quality: 85}, 221 + wantErr: true, 222 + }, 223 + { 224 + name: "invalid quality zero", 225 + preset: Preset{Name: "test", Width: 160, Height: 160, Fit: FitCover, Quality: 0}, 226 + wantErr: true, 227 + }, 228 + { 229 + name: "invalid quality over 100", 230 + preset: Preset{Name: "test", Width: 160, Height: 160, Fit: FitCover, Quality: 101}, 231 + wantErr: true, 232 + }, 233 + { 234 + name: "valid quality at boundary 1", 235 + preset: Preset{Name: "test", Width: 160, Height: 160, Fit: FitCover, Quality: 1}, 236 + wantErr: false, 237 + }, 238 + { 239 + name: "valid quality at boundary 100", 240 + preset: Preset{Name: "test", Width: 160, Height: 160, Fit: FitCover, Quality: 100}, 241 + wantErr: false, 242 + }, 243 + { 244 + name: "invalid unknown fit mode", 245 + preset: Preset{Name: "test", Width: 160, Height: 160, Fit: FitMode("unknown"), Quality: 85}, 246 + wantErr: true, 247 + }, 248 + } 249 + 250 + for _, tt := range tests { 251 + t.Run(tt.name, func(t *testing.T) { 252 + err := tt.preset.Validate() 253 + if tt.wantErr { 254 + assert.Error(t, err) 255 + assert.ErrorIs(t, err, ErrInvalidPreset) 256 + } else { 257 + assert.NoError(t, err) 258 + } 259 + }) 260 + } 261 + } 262 + 263 + func TestAllPresetsValidate(t *testing.T) { 264 + // All built-in presets should pass validation 265 + for _, preset := range ListPresets() { 266 + t.Run(preset.Name, func(t *testing.T) { 267 + err := preset.Validate() 268 + assert.NoError(t, err, "built-in preset %s should be valid", preset.Name) 269 + }) 270 + } 271 + }
+117
internal/core/imageproxy/processor.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "bytes" 5 + "fmt" 6 + "image" 7 + "image/jpeg" 8 + _ "image/png" // Register PNG decoder 9 + 10 + "github.com/disintegration/imaging" 11 + _ "golang.org/x/image/webp" // Register WebP decoder 12 + ) 13 + 14 + // Processor defines the interface for image processing operations. 15 + type Processor interface { 16 + // Process transforms image data according to the preset configuration. 17 + // Returns the processed image as JPEG bytes, or an error if processing fails. 18 + Process(data []byte, preset Preset) ([]byte, error) 19 + } 20 + 21 + // ImageProcessor implements the Processor interface using the imaging library. 22 + type ImageProcessor struct{} 23 + 24 + // NewProcessor creates a new ImageProcessor instance. 25 + func NewProcessor() Processor { 26 + return &ImageProcessor{} 27 + } 28 + 29 + // Process transforms the input image data according to the preset configuration. 30 + // It handles both cover fit (crops to exact dimensions) and contain fit (preserves 31 + // aspect ratio within bounds). Output is always JPEG format. 32 + func (p *ImageProcessor) Process(data []byte, preset Preset) ([]byte, error) { 33 + // Check for empty or nil data 34 + if len(data) == 0 { 35 + return nil, fmt.Errorf("%w: empty image data", ErrUnsupportedFormat) 36 + } 37 + 38 + // Decode the source image 39 + img, format, err := image.Decode(bytes.NewReader(data)) 40 + if err != nil { 41 + // Determine if this is a format issue or a corruption issue 42 + if isUnsupportedFormatError(err) { 43 + return nil, fmt.Errorf("%w: %v", ErrUnsupportedFormat, err) 44 + } 45 + return nil, fmt.Errorf("%w: failed to decode image: %v", ErrProcessingFailed, err) 46 + } 47 + 48 + // Validate that we decoded a supported format 49 + if format != "jpeg" && format != "png" && format != "webp" { 50 + return nil, fmt.Errorf("%w: format %s", ErrUnsupportedFormat, format) 51 + } 52 + 53 + // Process the image based on fit mode 54 + var processed image.Image 55 + switch preset.Fit { 56 + case FitCover: 57 + processed = processCover(img, preset.Width, preset.Height) 58 + case FitContain: 59 + processed = processContain(img, preset.Width, preset.Height) 60 + default: 61 + return nil, fmt.Errorf("%w: unknown fit mode", ErrProcessingFailed) 62 + } 63 + 64 + // Encode as JPEG 65 + var buf bytes.Buffer 66 + if err := jpeg.Encode(&buf, processed, &jpeg.Options{Quality: preset.Quality}); err != nil { 67 + return nil, fmt.Errorf("%w: failed to encode JPEG: %v", ErrProcessingFailed, err) 68 + } 69 + 70 + return buf.Bytes(), nil 71 + } 72 + 73 + // processCover scales and crops the image to exactly fill the target dimensions. 74 + // The image is scaled to cover the entire target area, then cropped to exact size. 75 + func processCover(img image.Image, width, height int) image.Image { 76 + // Use imaging.Fill which scales to cover and crops to exact dimensions 77 + return imaging.Fill(img, width, height, imaging.Center, imaging.Lanczos) 78 + } 79 + 80 + // processContain scales the image to fit within the target width while preserving 81 + // aspect ratio. If the source image is smaller than the target, it is not upscaled. 82 + // Height of 0 means scale proportionally based on width only. 83 + func processContain(img image.Image, maxWidth, maxHeight int) image.Image { 84 + bounds := img.Bounds() 85 + srcWidth := bounds.Dx() 86 + srcHeight := bounds.Dy() 87 + 88 + // Don't upscale images smaller than target 89 + if srcWidth <= maxWidth { 90 + return img 91 + } 92 + 93 + // Calculate new dimensions preserving aspect ratio 94 + newWidth := maxWidth 95 + newHeight := int(float64(srcHeight) * (float64(maxWidth) / float64(srcWidth))) 96 + 97 + // If maxHeight is specified and calculated height exceeds it, 98 + // scale based on height instead 99 + if maxHeight > 0 && newHeight > maxHeight { 100 + newHeight = maxHeight 101 + newWidth = int(float64(srcWidth) * (float64(maxHeight) / float64(srcHeight))) 102 + } 103 + 104 + return imaging.Resize(img, newWidth, newHeight, imaging.Lanczos) 105 + } 106 + 107 + // isUnsupportedFormatError checks if the error indicates an unsupported image format. 108 + func isUnsupportedFormatError(err error) bool { 109 + if err == nil { 110 + return false 111 + } 112 + errStr := err.Error() 113 + return errStr == "image: unknown format" || 114 + errStr == "invalid JPEG format: missing SOI marker" || 115 + errStr == "invalid JPEG format: short segment" || 116 + bytes.Contains([]byte(errStr), []byte("unknown format")) 117 + }
+299
internal/core/imageproxy/processor_test.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "bytes" 5 + "image" 6 + "image/color" 7 + "image/jpeg" 8 + "image/png" 9 + "testing" 10 + 11 + "github.com/stretchr/testify/assert" 12 + "github.com/stretchr/testify/require" 13 + ) 14 + 15 + // createTestJPEG creates a test JPEG image with the specified dimensions. 16 + func createTestJPEG(t *testing.T, width, height int) []byte { 17 + t.Helper() 18 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 19 + // Fill with a solid color 20 + for y := 0; y < height; y++ { 21 + for x := 0; x < width; x++ { 22 + img.Set(x, y, color.RGBA{R: 255, G: 128, B: 64, A: 255}) 23 + } 24 + } 25 + var buf bytes.Buffer 26 + err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}) 27 + require.NoError(t, err) 28 + return buf.Bytes() 29 + } 30 + 31 + // createTestPNG creates a test PNG image with the specified dimensions. 32 + func createTestPNG(t *testing.T, width, height int) []byte { 33 + t.Helper() 34 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 35 + // Fill with a solid color 36 + for y := 0; y < height; y++ { 37 + for x := 0; x < width; x++ { 38 + img.Set(x, y, color.RGBA{R: 64, G: 128, B: 255, A: 255}) 39 + } 40 + } 41 + var buf bytes.Buffer 42 + err := png.Encode(&buf, img) 43 + require.NoError(t, err) 44 + return buf.Bytes() 45 + } 46 + 47 + func TestProcessor_Process_CoverFit(t *testing.T) { 48 + proc := NewProcessor() 49 + 50 + tests := []struct { 51 + name string 52 + srcWidth int 53 + srcHeight int 54 + preset Preset 55 + wantWidth int 56 + wantHeight int 57 + description string 58 + }{ 59 + { 60 + name: "landscape image to square avatar", 61 + srcWidth: 800, 62 + srcHeight: 600, 63 + preset: Preset{Name: "avatar", Width: 1000, Height: 1000, Fit: FitCover, Quality: 85}, 64 + wantWidth: 1000, 65 + wantHeight: 1000, 66 + description: "landscape cropped to square", 67 + }, 68 + { 69 + name: "portrait image to square avatar", 70 + srcWidth: 600, 71 + srcHeight: 800, 72 + preset: Preset{Name: "avatar", Width: 1000, Height: 1000, Fit: FitCover, Quality: 85}, 73 + wantWidth: 1000, 74 + wantHeight: 1000, 75 + description: "portrait cropped to square", 76 + }, 77 + { 78 + name: "square image to smaller square", 79 + srcWidth: 500, 80 + srcHeight: 500, 81 + preset: Preset{Name: "avatar_small", Width: 360, Height: 360, Fit: FitCover, Quality: 80}, 82 + wantWidth: 360, 83 + wantHeight: 360, 84 + description: "square scaled down", 85 + }, 86 + { 87 + name: "landscape to banner dimensions", 88 + srcWidth: 1920, 89 + srcHeight: 1080, 90 + preset: Preset{Name: "banner", Width: 640, Height: 300, Fit: FitCover, Quality: 85}, 91 + wantWidth: 640, 92 + wantHeight: 300, 93 + description: "banner crop", 94 + }, 95 + { 96 + name: "embed thumbnail dimensions", 97 + srcWidth: 1600, 98 + srcHeight: 900, 99 + preset: Preset{Name: "embed_thumbnail", Width: 720, Height: 360, Fit: FitCover, Quality: 80}, 100 + wantWidth: 720, 101 + wantHeight: 360, 102 + description: "embed thumbnail crop", 103 + }, 104 + } 105 + 106 + for _, tt := range tests { 107 + t.Run(tt.name, func(t *testing.T) { 108 + srcData := createTestJPEG(t, tt.srcWidth, tt.srcHeight) 109 + 110 + result, err := proc.Process(srcData, tt.preset) 111 + require.NoError(t, err) 112 + require.NotNil(t, result) 113 + 114 + // Decode the result to verify dimensions 115 + img, _, err := image.Decode(bytes.NewReader(result)) 116 + require.NoError(t, err) 117 + 118 + bounds := img.Bounds() 119 + assert.Equal(t, tt.wantWidth, bounds.Dx(), "width mismatch for %s", tt.description) 120 + assert.Equal(t, tt.wantHeight, bounds.Dy(), "height mismatch for %s", tt.description) 121 + }) 122 + } 123 + } 124 + 125 + func TestProcessor_Process_ContainFit(t *testing.T) { 126 + proc := NewProcessor() 127 + 128 + tests := []struct { 129 + name string 130 + srcWidth int 131 + srcHeight int 132 + preset Preset 133 + wantMaxWidth int 134 + wantMaxHeight int 135 + description string 136 + }{ 137 + { 138 + name: "landscape image scaled to content_preview width", 139 + srcWidth: 1600, 140 + srcHeight: 900, 141 + preset: Preset{Name: "content_preview", Width: 800, Height: 0, Fit: FitContain, Quality: 80}, 142 + wantMaxWidth: 800, 143 + wantMaxHeight: 450, // 800 * (900/1600) = 450 (aspect ratio preserved) 144 + description: "landscape scaled proportionally", 145 + }, 146 + { 147 + name: "portrait image scaled to content_preview width", 148 + srcWidth: 900, 149 + srcHeight: 1600, 150 + preset: Preset{Name: "content_preview", Width: 800, Height: 0, Fit: FitContain, Quality: 80}, 151 + wantMaxWidth: 800, 152 + wantMaxHeight: 1422, // 800 * (1600/900) ~= 1422 153 + description: "portrait scaled proportionally", 154 + }, 155 + { 156 + name: "wide panorama to content_full", 157 + srcWidth: 3200, 158 + srcHeight: 800, 159 + preset: Preset{Name: "content_full", Width: 1600, Height: 0, Fit: FitContain, Quality: 90}, 160 + wantMaxWidth: 1600, 161 + wantMaxHeight: 400, // 1600 * (800/3200) = 400 162 + description: "panorama scaled proportionally", 163 + }, 164 + { 165 + name: "image smaller than target width stays same size", 166 + srcWidth: 400, 167 + srcHeight: 300, 168 + preset: Preset{Name: "content_preview", Width: 800, Height: 0, Fit: FitContain, Quality: 80}, 169 + wantMaxWidth: 400, // Don't upscale 170 + wantMaxHeight: 300, 171 + description: "small image not upscaled", 172 + }, 173 + } 174 + 175 + for _, tt := range tests { 176 + t.Run(tt.name, func(t *testing.T) { 177 + srcData := createTestJPEG(t, tt.srcWidth, tt.srcHeight) 178 + 179 + result, err := proc.Process(srcData, tt.preset) 180 + require.NoError(t, err) 181 + require.NotNil(t, result) 182 + 183 + // Decode the result to verify dimensions 184 + img, _, err := image.Decode(bytes.NewReader(result)) 185 + require.NoError(t, err) 186 + 187 + bounds := img.Bounds() 188 + // For contain fit, verify width doesn't exceed max and aspect ratio is preserved 189 + assert.LessOrEqual(t, bounds.Dx(), tt.wantMaxWidth, "width should not exceed max for %s", tt.description) 190 + assert.Equal(t, tt.wantMaxWidth, bounds.Dx(), "width mismatch for %s", tt.description) 191 + assert.Equal(t, tt.wantMaxHeight, bounds.Dy(), "height mismatch for %s", tt.description) 192 + }) 193 + } 194 + } 195 + 196 + func TestProcessor_Process_InvalidImageData(t *testing.T) { 197 + proc := NewProcessor() 198 + 199 + tests := []struct { 200 + name string 201 + data []byte 202 + wantErr error 203 + }{ 204 + { 205 + name: "empty data", 206 + data: []byte{}, 207 + wantErr: ErrUnsupportedFormat, 208 + }, 209 + { 210 + name: "nil data", 211 + data: nil, 212 + wantErr: ErrUnsupportedFormat, 213 + }, 214 + { 215 + name: "random garbage data", 216 + data: []byte("not an image at all"), 217 + wantErr: ErrUnsupportedFormat, 218 + }, 219 + { 220 + name: "truncated JPEG header", 221 + data: []byte{0xFF, 0xD8, 0xFF, 0xE0}, // Partial JPEG magic 222 + wantErr: ErrProcessingFailed, 223 + }, 224 + } 225 + 226 + preset, _ := GetPreset("avatar") 227 + 228 + for _, tt := range tests { 229 + t.Run(tt.name, func(t *testing.T) { 230 + result, err := proc.Process(tt.data, preset) 231 + require.Error(t, err) 232 + assert.ErrorIs(t, err, tt.wantErr) 233 + assert.Nil(t, result) 234 + }) 235 + } 236 + } 237 + 238 + func TestProcessor_Process_SupportsJPEG(t *testing.T) { 239 + proc := NewProcessor() 240 + srcData := createTestJPEG(t, 500, 500) 241 + preset, _ := GetPreset("avatar") 242 + 243 + result, err := proc.Process(srcData, preset) 244 + require.NoError(t, err) 245 + require.NotNil(t, result) 246 + 247 + // Verify output is valid JPEG 248 + img, format, err := image.Decode(bytes.NewReader(result)) 249 + require.NoError(t, err) 250 + assert.Equal(t, "jpeg", format) 251 + assert.Equal(t, 1000, img.Bounds().Dx()) 252 + assert.Equal(t, 1000, img.Bounds().Dy()) 253 + } 254 + 255 + func TestProcessor_Process_SupportsPNG(t *testing.T) { 256 + proc := NewProcessor() 257 + srcData := createTestPNG(t, 500, 500) 258 + preset, _ := GetPreset("avatar") 259 + 260 + result, err := proc.Process(srcData, preset) 261 + require.NoError(t, err) 262 + require.NotNil(t, result) 263 + 264 + // Verify output is valid JPEG (always output JPEG) 265 + img, format, err := image.Decode(bytes.NewReader(result)) 266 + require.NoError(t, err) 267 + assert.Equal(t, "jpeg", format) 268 + assert.Equal(t, 1000, img.Bounds().Dx()) 269 + assert.Equal(t, 1000, img.Bounds().Dy()) 270 + } 271 + 272 + func TestProcessor_Process_AlwaysOutputsJPEG(t *testing.T) { 273 + proc := NewProcessor() 274 + preset, _ := GetPreset("avatar") 275 + 276 + // Test with PNG input 277 + pngData := createTestPNG(t, 300, 300) 278 + result, err := proc.Process(pngData, preset) 279 + require.NoError(t, err) 280 + 281 + // Verify output is JPEG even when input is PNG 282 + _, format, err := image.Decode(bytes.NewReader(result)) 283 + require.NoError(t, err) 284 + assert.Equal(t, "jpeg", format, "output should always be JPEG") 285 + } 286 + 287 + func TestProcessor_Interface(t *testing.T) { 288 + // Compile-time check that ImageProcessor implements Processor 289 + var _ Processor = (*ImageProcessor)(nil) 290 + } 291 + 292 + func TestNewProcessor(t *testing.T) { 293 + proc := NewProcessor() 294 + require.NotNil(t, proc) 295 + 296 + // Verify it's an *ImageProcessor 297 + _, ok := proc.(*ImageProcessor) 298 + assert.True(t, ok, "NewProcessor should return *ImageProcessor") 299 + }
+141
internal/core/imageproxy/service.go
···
··· 1 + // Package imageproxy provides image proxy functionality for AT Protocol applications. 2 + // It handles fetching, caching, and transforming images from Personal Data Servers (PDS). 3 + // 4 + // The package implements a multi-tier architecture: 5 + // - Service: Orchestrates caching, fetching, and processing 6 + // - Cache: Disk-based LRU cache with TTL-based expiration 7 + // - Fetcher: Retrieves blobs from AT Protocol PDSes 8 + // - Processor: Transforms images according to preset configurations 9 + // 10 + // Presets define image transformation parameters (dimensions, fit mode, quality) 11 + // for common use cases like avatars, banners, and feed thumbnails. 12 + package imageproxy 13 + 14 + import ( 15 + "context" 16 + "fmt" 17 + "log/slog" 18 + "sync/atomic" 19 + ) 20 + 21 + // cacheWriteErrors tracks the number of async cache write failures. 22 + // This provides observability for cache write issues until proper metrics are implemented. 23 + var cacheWriteErrors atomic.Int64 24 + 25 + // CacheWriteErrorCount returns the total number of async cache write errors. 26 + // This is useful for monitoring and alerting on cache health. 27 + func CacheWriteErrorCount() int64 { 28 + return cacheWriteErrors.Load() 29 + } 30 + 31 + // Service defines the interface for the image proxy service. 32 + type Service interface { 33 + // GetImage retrieves an image for the given preset, DID, and CID. 34 + // It checks the cache first, then fetches from the PDS if not cached, 35 + // processes the image according to the preset, and stores in cache. 36 + GetImage(ctx context.Context, preset, did, cid string, pdsURL string) ([]byte, error) 37 + } 38 + 39 + // ImageProxyService implements the Service interface and orchestrates 40 + // caching, fetching, and processing of images. 41 + type ImageProxyService struct { 42 + cache Cache 43 + processor Processor 44 + fetcher Fetcher 45 + config Config 46 + } 47 + 48 + // NewService creates a new ImageProxyService with the provided dependencies. 49 + // Returns an error if any required dependency is nil. 50 + func NewService(cache Cache, processor Processor, fetcher Fetcher, config Config) (*ImageProxyService, error) { 51 + if cache == nil { 52 + return nil, fmt.Errorf("%w: cache", ErrNilDependency) 53 + } 54 + if processor == nil { 55 + return nil, fmt.Errorf("%w: processor", ErrNilDependency) 56 + } 57 + if fetcher == nil { 58 + return nil, fmt.Errorf("%w: fetcher", ErrNilDependency) 59 + } 60 + 61 + return &ImageProxyService{ 62 + cache: cache, 63 + processor: processor, 64 + fetcher: fetcher, 65 + config: config, 66 + }, nil 67 + } 68 + 69 + // GetImage retrieves an image for the given preset, DID, and CID. 70 + // The service flow is: 71 + // 1. Validate preset exists 72 + // 2. Check cache for (preset, did, cid) - return if hit 73 + // 3. Fetch blob from PDS using pdsURL 74 + // 4. Process image with preset 75 + // 5. Store in cache (async, don't block response) 76 + // 6. Return processed image 77 + func (s *ImageProxyService) GetImage(ctx context.Context, presetName, did, cid string, pdsURL string) ([]byte, error) { 78 + // Step 1: Validate preset exists 79 + preset, err := GetPreset(presetName) 80 + if err != nil { 81 + return nil, err 82 + } 83 + 84 + // Step 2: Check cache for (preset, did, cid) 85 + cachedData, found, err := s.cache.Get(presetName, did, cid) 86 + if err != nil { 87 + // Log cache read error but continue - cache miss is acceptable 88 + slog.Warn("[IMAGE-PROXY] cache read error, falling back to fetch", 89 + "preset", presetName, 90 + "did", did, 91 + "cid", cid, 92 + "error", err, 93 + ) 94 + } 95 + if found { 96 + slog.Debug("[IMAGE-PROXY] cache hit", 97 + "preset", presetName, 98 + "did", did, 99 + "cid", cid, 100 + ) 101 + return cachedData, nil 102 + } 103 + 104 + // Step 3: Fetch blob from PDS 105 + rawData, err := s.fetcher.Fetch(ctx, pdsURL, did, cid) 106 + if err != nil { 107 + return nil, err 108 + } 109 + 110 + // Step 4: Process image with preset 111 + processedData, err := s.processor.Process(rawData, preset) 112 + if err != nil { 113 + return nil, err 114 + } 115 + 116 + // Step 5: Store in cache (async, don't block response) 117 + go func() { 118 + // Use a background context since the original request context may be cancelled 119 + if cacheErr := s.cache.Set(presetName, did, cid, processedData); cacheErr != nil { 120 + // Increment error counter for monitoring 121 + cacheWriteErrors.Add(1) 122 + slog.Error("[IMAGE-PROXY] async cache write failed", 123 + "preset", presetName, 124 + "did", did, 125 + "cid", cid, 126 + "error", cacheErr, 127 + "total_cache_write_errors", cacheWriteErrors.Load(), 128 + ) 129 + } else { 130 + slog.Debug("[IMAGE-PROXY] cached processed image", 131 + "preset", presetName, 132 + "did", did, 133 + "cid", cid, 134 + "size_bytes", len(processedData), 135 + ) 136 + } 137 + }() 138 + 139 + // Step 6: Return processed image 140 + return processedData, nil 141 + }
+401
internal/core/imageproxy/service_test.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "sync" 7 + "testing" 8 + "time" 9 + ) 10 + 11 + // MockCache implements Cache for testing 12 + type MockCache struct { 13 + mu sync.Mutex 14 + data map[string][]byte 15 + getCalls int 16 + setCalls int 17 + setData map[string][]byte // Track what was set 18 + } 19 + 20 + func NewMockCache() *MockCache { 21 + return &MockCache{ 22 + data: make(map[string][]byte), 23 + setData: make(map[string][]byte), 24 + } 25 + } 26 + 27 + func (m *MockCache) cacheKey(preset, did, cid string) string { 28 + return preset + ":" + did + ":" + cid 29 + } 30 + 31 + func (m *MockCache) Get(preset, did, cid string) ([]byte, bool, error) { 32 + m.mu.Lock() 33 + defer m.mu.Unlock() 34 + m.getCalls++ 35 + key := m.cacheKey(preset, did, cid) 36 + data, found := m.data[key] 37 + return data, found, nil 38 + } 39 + 40 + func (m *MockCache) Set(preset, did, cid string, data []byte) error { 41 + m.mu.Lock() 42 + defer m.mu.Unlock() 43 + m.setCalls++ 44 + key := m.cacheKey(preset, did, cid) 45 + m.data[key] = data 46 + m.setData[key] = data 47 + return nil 48 + } 49 + 50 + func (m *MockCache) Delete(preset, did, cid string) error { 51 + m.mu.Lock() 52 + defer m.mu.Unlock() 53 + key := m.cacheKey(preset, did, cid) 54 + delete(m.data, key) 55 + return nil 56 + } 57 + 58 + func (m *MockCache) Cleanup() (int, error) { 59 + // Mock implementation - no-op for tests 60 + return 0, nil 61 + } 62 + 63 + func (m *MockCache) SetCacheData(preset, did, cid string, data []byte) { 64 + m.mu.Lock() 65 + defer m.mu.Unlock() 66 + key := m.cacheKey(preset, did, cid) 67 + m.data[key] = data 68 + } 69 + 70 + func (m *MockCache) GetCalls() int { 71 + m.mu.Lock() 72 + defer m.mu.Unlock() 73 + return m.getCalls 74 + } 75 + 76 + func (m *MockCache) SetCalls() int { 77 + m.mu.Lock() 78 + defer m.mu.Unlock() 79 + return m.setCalls 80 + } 81 + 82 + func (m *MockCache) GetSetData(preset, did, cid string) ([]byte, bool) { 83 + m.mu.Lock() 84 + defer m.mu.Unlock() 85 + key := m.cacheKey(preset, did, cid) 86 + data, found := m.setData[key] 87 + return data, found 88 + } 89 + 90 + // MockProcessor implements Processor for testing 91 + type MockProcessor struct { 92 + returnData []byte 93 + returnErr error 94 + calls int 95 + mu sync.Mutex 96 + } 97 + 98 + func NewMockProcessor(returnData []byte, returnErr error) *MockProcessor { 99 + return &MockProcessor{ 100 + returnData: returnData, 101 + returnErr: returnErr, 102 + } 103 + } 104 + 105 + func (m *MockProcessor) Process(data []byte, preset Preset) ([]byte, error) { 106 + m.mu.Lock() 107 + m.calls++ 108 + m.mu.Unlock() 109 + if m.returnErr != nil { 110 + return nil, m.returnErr 111 + } 112 + return m.returnData, nil 113 + } 114 + 115 + func (m *MockProcessor) Calls() int { 116 + m.mu.Lock() 117 + defer m.mu.Unlock() 118 + return m.calls 119 + } 120 + 121 + // MockFetcher implements Fetcher for testing 122 + type MockFetcher struct { 123 + returnData []byte 124 + returnErr error 125 + calls int 126 + mu sync.Mutex 127 + } 128 + 129 + func NewMockFetcher(returnData []byte, returnErr error) *MockFetcher { 130 + return &MockFetcher{ 131 + returnData: returnData, 132 + returnErr: returnErr, 133 + } 134 + } 135 + 136 + func (m *MockFetcher) Fetch(ctx context.Context, pdsURL, did, cid string) ([]byte, error) { 137 + m.mu.Lock() 138 + m.calls++ 139 + m.mu.Unlock() 140 + if m.returnErr != nil { 141 + return nil, m.returnErr 142 + } 143 + return m.returnData, nil 144 + } 145 + 146 + func (m *MockFetcher) Calls() int { 147 + m.mu.Lock() 148 + defer m.mu.Unlock() 149 + return m.calls 150 + } 151 + 152 + // mustNewService is a test helper that creates a service or fails the test 153 + func mustNewService(t *testing.T, cache Cache, processor Processor, fetcher Fetcher, config Config) *ImageProxyService { 154 + t.Helper() 155 + service, err := NewService(cache, processor, fetcher, config) 156 + if err != nil { 157 + t.Fatalf("NewService failed: %v", err) 158 + } 159 + return service 160 + } 161 + 162 + func TestImageProxyService_GetImage_CacheHit(t *testing.T) { 163 + cache := NewMockCache() 164 + processor := NewMockProcessor(nil, nil) 165 + fetcher := NewMockFetcher(nil, nil) 166 + config := DefaultConfig() 167 + 168 + // Pre-populate the cache 169 + cachedData := []byte("cached image data") 170 + cache.SetCacheData("avatar", "did:plc:test123", "bafyreicid123", cachedData) 171 + 172 + service := mustNewService(t, cache, processor, fetcher, config) 173 + ctx := context.Background() 174 + 175 + data, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 176 + if err != nil { 177 + t.Fatalf("expected no error, got: %v", err) 178 + } 179 + if string(data) != string(cachedData) { 180 + t.Errorf("expected cached data %q, got %q", cachedData, data) 181 + } 182 + 183 + // Verify fetcher was not called 184 + if fetcher.Calls() != 0 { 185 + t.Errorf("expected fetcher to not be called on cache hit, got %d calls", fetcher.Calls()) 186 + } 187 + 188 + // Verify processor was not called 189 + if processor.Calls() != 0 { 190 + t.Errorf("expected processor to not be called on cache hit, got %d calls", processor.Calls()) 191 + } 192 + } 193 + 194 + func TestImageProxyService_GetImage_CacheMiss(t *testing.T) { 195 + cache := NewMockCache() 196 + rawImageData := []byte("raw image from PDS") 197 + processedData := []byte("processed image") 198 + processor := NewMockProcessor(processedData, nil) 199 + fetcher := NewMockFetcher(rawImageData, nil) 200 + config := DefaultConfig() 201 + 202 + service := mustNewService(t, cache, processor, fetcher, config) 203 + ctx := context.Background() 204 + 205 + data, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 206 + if err != nil { 207 + t.Fatalf("expected no error, got: %v", err) 208 + } 209 + if string(data) != string(processedData) { 210 + t.Errorf("expected processed data %q, got %q", processedData, data) 211 + } 212 + 213 + // Verify fetcher was called 214 + if fetcher.Calls() != 1 { 215 + t.Errorf("expected fetcher to be called once, got %d calls", fetcher.Calls()) 216 + } 217 + 218 + // Verify processor was called 219 + if processor.Calls() != 1 { 220 + t.Errorf("expected processor to be called once, got %d calls", processor.Calls()) 221 + } 222 + 223 + // Wait a bit for async cache write 224 + time.Sleep(50 * time.Millisecond) 225 + 226 + // Verify cache was written 227 + if cache.SetCalls() < 1 { 228 + t.Errorf("expected cache to be written, got %d set calls", cache.SetCalls()) 229 + } 230 + 231 + // Verify the correct data was cached 232 + setData, found := cache.GetSetData("avatar", "did:plc:test123", "bafyreicid123") 233 + if !found { 234 + t.Error("expected data to be set in cache") 235 + } 236 + if string(setData) != string(processedData) { 237 + t.Errorf("expected cached data %q, got %q", processedData, setData) 238 + } 239 + } 240 + 241 + func TestImageProxyService_GetImage_InvalidPreset(t *testing.T) { 242 + cache := NewMockCache() 243 + processor := NewMockProcessor(nil, nil) 244 + fetcher := NewMockFetcher(nil, nil) 245 + config := DefaultConfig() 246 + 247 + service := mustNewService(t, cache, processor, fetcher, config) 248 + ctx := context.Background() 249 + 250 + _, err := service.GetImage(ctx, "invalid_preset", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 251 + if !errors.Is(err, ErrInvalidPreset) { 252 + t.Errorf("expected ErrInvalidPreset, got: %v", err) 253 + } 254 + } 255 + 256 + func TestImageProxyService_GetImage_PDSFetchError(t *testing.T) { 257 + cache := NewMockCache() 258 + processor := NewMockProcessor(nil, nil) 259 + fetcher := NewMockFetcher(nil, ErrPDSNotFound) 260 + config := DefaultConfig() 261 + 262 + service := mustNewService(t, cache, processor, fetcher, config) 263 + ctx := context.Background() 264 + 265 + _, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 266 + if !errors.Is(err, ErrPDSNotFound) { 267 + t.Errorf("expected ErrPDSNotFound, got: %v", err) 268 + } 269 + } 270 + 271 + func TestImageProxyService_GetImage_ProcessingError(t *testing.T) { 272 + cache := NewMockCache() 273 + processor := NewMockProcessor(nil, ErrProcessingFailed) 274 + fetcher := NewMockFetcher([]byte("raw data"), nil) 275 + config := DefaultConfig() 276 + 277 + service := mustNewService(t, cache, processor, fetcher, config) 278 + ctx := context.Background() 279 + 280 + _, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 281 + if !errors.Is(err, ErrProcessingFailed) { 282 + t.Errorf("expected ErrProcessingFailed, got: %v", err) 283 + } 284 + } 285 + 286 + func TestImageProxyService_GetImage_CacheWriteIsAsync(t *testing.T) { 287 + cache := NewMockCache() 288 + rawImageData := []byte("raw image from PDS") 289 + processedData := []byte("processed image") 290 + processor := NewMockProcessor(processedData, nil) 291 + fetcher := NewMockFetcher(rawImageData, nil) 292 + config := DefaultConfig() 293 + 294 + service := mustNewService(t, cache, processor, fetcher, config) 295 + ctx := context.Background() 296 + 297 + // Call GetImage 298 + startTime := time.Now() 299 + data, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 300 + elapsed := time.Since(startTime) 301 + 302 + if err != nil { 303 + t.Fatalf("expected no error, got: %v", err) 304 + } 305 + if string(data) != string(processedData) { 306 + t.Errorf("expected processed data %q, got %q", processedData, data) 307 + } 308 + 309 + // The response should come back quickly, not blocked by cache write 310 + // (This is a soft assertion - just ensures we're not blocking) 311 + if elapsed > 100*time.Millisecond { 312 + t.Logf("warning: GetImage took %v, expected faster response", elapsed) 313 + } 314 + 315 + // Wait for async cache write to complete 316 + time.Sleep(100 * time.Millisecond) 317 + 318 + // Now verify cache was written 319 + if cache.SetCalls() < 1 { 320 + t.Errorf("expected cache to be written asynchronously, got %d set calls", cache.SetCalls()) 321 + } 322 + } 323 + 324 + func TestImageProxyService_GetImage_EmptyPreset(t *testing.T) { 325 + cache := NewMockCache() 326 + processor := NewMockProcessor(nil, nil) 327 + fetcher := NewMockFetcher(nil, nil) 328 + config := DefaultConfig() 329 + 330 + service := mustNewService(t, cache, processor, fetcher, config) 331 + ctx := context.Background() 332 + 333 + _, err := service.GetImage(ctx, "", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 334 + if !errors.Is(err, ErrInvalidPreset) { 335 + t.Errorf("expected ErrInvalidPreset for empty preset, got: %v", err) 336 + } 337 + } 338 + 339 + func TestImageProxyService_GetImage_AllPresets(t *testing.T) { 340 + // Test that all predefined presets work 341 + presets := []string{"avatar", "avatar_small", "banner", "content_preview", "content_full", "embed_thumbnail"} 342 + 343 + for _, presetName := range presets { 344 + t.Run(presetName, func(t *testing.T) { 345 + cache := NewMockCache() 346 + processedData := []byte("processed image") 347 + processor := NewMockProcessor(processedData, nil) 348 + fetcher := NewMockFetcher([]byte("raw data"), nil) 349 + config := DefaultConfig() 350 + 351 + service := mustNewService(t, cache, processor, fetcher, config) 352 + ctx := context.Background() 353 + 354 + data, err := service.GetImage(ctx, presetName, "did:plc:test123", "bafyreicid123", "https://pds.example.com") 355 + if err != nil { 356 + t.Errorf("expected no error for preset %s, got: %v", presetName, err) 357 + } 358 + if string(data) != string(processedData) { 359 + t.Errorf("expected processed data for preset %s", presetName) 360 + } 361 + }) 362 + } 363 + } 364 + 365 + func TestNewService_NilDependencies(t *testing.T) { 366 + config := DefaultConfig() 367 + cache := NewMockCache() 368 + processor := NewMockProcessor(nil, nil) 369 + fetcher := NewMockFetcher(nil, nil) 370 + 371 + t.Run("nil cache", func(t *testing.T) { 372 + _, err := NewService(nil, processor, fetcher, config) 373 + if !errors.Is(err, ErrNilDependency) { 374 + t.Errorf("expected ErrNilDependency, got: %v", err) 375 + } 376 + }) 377 + 378 + t.Run("nil processor", func(t *testing.T) { 379 + _, err := NewService(cache, nil, fetcher, config) 380 + if !errors.Is(err, ErrNilDependency) { 381 + t.Errorf("expected ErrNilDependency, got: %v", err) 382 + } 383 + }) 384 + 385 + t.Run("nil fetcher", func(t *testing.T) { 386 + _, err := NewService(cache, processor, nil, config) 387 + if !errors.Is(err, ErrNilDependency) { 388 + t.Errorf("expected ErrNilDependency, got: %v", err) 389 + } 390 + }) 391 + 392 + t.Run("all valid", func(t *testing.T) { 393 + service, err := NewService(cache, processor, fetcher, config) 394 + if err != nil { 395 + t.Errorf("expected no error with valid dependencies, got: %v", err) 396 + } 397 + if service == nil { 398 + t.Error("expected non-nil service") 399 + } 400 + }) 401 + }
+87
internal/core/imageproxy/validation.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "strings" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + // ValidateDID validates that a DID string matches expected atproto DID formats. 10 + // It uses the Indigo library's syntax.ParseDID for consistent validation across the codebase. 11 + // Returns ErrInvalidDID if the DID is invalid. 12 + func ValidateDID(did string) error { 13 + // Check for path traversal attempts before parsing 14 + if strings.Contains(did, "..") || strings.Contains(did, "/") || strings.Contains(did, "\\") || strings.Contains(did, "\x00") { 15 + return ErrInvalidDID 16 + } 17 + 18 + // Use Indigo's DID parser for consistent validation with the rest of the codebase 19 + _, err := syntax.ParseDID(did) 20 + if err != nil { 21 + return ErrInvalidDID 22 + } 23 + 24 + return nil 25 + } 26 + 27 + // ValidateCID validates that a CID string is a valid content identifier. 28 + // It uses the Indigo library's syntax.ParseCID for consistent validation across the codebase. 29 + // Returns ErrInvalidCID if the CID is invalid. 30 + func ValidateCID(cid string) error { 31 + // Check for path traversal attempts before parsing 32 + if strings.Contains(cid, "..") || strings.Contains(cid, "/") || strings.Contains(cid, "\\") || strings.Contains(cid, "\x00") { 33 + return ErrInvalidCID 34 + } 35 + 36 + // Use Indigo's CID parser for consistent validation with the rest of the codebase 37 + _, err := syntax.ParseCID(cid) 38 + if err != nil { 39 + return ErrInvalidCID 40 + } 41 + 42 + return nil 43 + } 44 + 45 + // SanitizePathComponent ensures a string is safe to use as a filesystem path component. 46 + // It removes or replaces characters that could be used for path traversal attacks. 47 + // This is used as an additional safety layer beyond DID/CID validation. 48 + func SanitizePathComponent(s string) string { 49 + // Replace any path separators 50 + s = strings.ReplaceAll(s, "/", "_") 51 + s = strings.ReplaceAll(s, "\\", "_") 52 + 53 + // Remove any path traversal sequences 54 + s = strings.ReplaceAll(s, "..", "") 55 + 56 + // Replace colons for filesystem compatibility (Windows and general safety) 57 + s = strings.ReplaceAll(s, ":", "_") 58 + 59 + // Remove null bytes 60 + s = strings.ReplaceAll(s, "\x00", "") 61 + 62 + return s 63 + } 64 + 65 + // ValidatePreset validates that a preset name is safe and exists. 66 + // This combines format validation with registry lookup. 67 + func ValidatePreset(preset string) error { 68 + // Check for empty preset 69 + if preset == "" { 70 + return ErrInvalidPreset 71 + } 72 + 73 + // Check for path separators (dangerous characters) 74 + // Note: We use ContainsAny for individual chars and Contains for substrings 75 + if strings.ContainsAny(preset, "/\\") { 76 + return ErrInvalidPreset 77 + } 78 + 79 + // Check for path traversal sequences (must check ".." as a substring, not individual dots) 80 + if strings.Contains(preset, "..") { 81 + return ErrInvalidPreset 82 + } 83 + 84 + // Verify preset exists in registry 85 + _, err := GetPreset(preset) 86 + return err 87 + }
+342
internal/core/imageproxy/validation_test.go
···
··· 1 + package imageproxy 2 + 3 + import ( 4 + "errors" 5 + "testing" 6 + ) 7 + 8 + func TestValidateDID(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + did string 12 + wantErr error 13 + }{ 14 + // Valid DIDs - uses Indigo's syntax.ParseDID for consistency with codebase 15 + { 16 + name: "valid did:plc", 17 + did: "did:plc:z72i7hdynmk6r22z27h6tvur", 18 + wantErr: nil, 19 + }, 20 + { 21 + name: "valid did:web simple", 22 + did: "did:web:example.com", 23 + wantErr: nil, 24 + }, 25 + { 26 + name: "valid did:web with subdomain", 27 + did: "did:web:bsky.social", 28 + wantErr: nil, 29 + }, 30 + { 31 + name: "valid did:web with path", 32 + did: "did:web:example.com:user:alice", 33 + wantErr: nil, 34 + }, 35 + // did:key is valid per Indigo library (used in other atproto contexts) 36 + { 37 + name: "valid did:key", 38 + did: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", 39 + wantErr: nil, 40 + }, 41 + // Invalid DIDs 42 + { 43 + name: "empty string", 44 + did: "", 45 + wantErr: ErrInvalidDID, 46 + }, 47 + { 48 + name: "missing did: prefix", 49 + did: "plc:z72i7hdynmk6r22z27h6tvur", 50 + wantErr: ErrInvalidDID, 51 + }, 52 + { 53 + name: "path traversal attempt in did", 54 + did: "did:plc:../../../etc/passwd", 55 + wantErr: ErrInvalidDID, 56 + }, 57 + { 58 + name: "null byte injection", 59 + did: "did:plc:abc\x00def", 60 + wantErr: ErrInvalidDID, 61 + }, 62 + { 63 + name: "forward slash injection", 64 + did: "did:plc:abc/def", 65 + wantErr: ErrInvalidDID, 66 + }, 67 + { 68 + name: "backslash injection", 69 + did: "did:plc:abc\\def", 70 + wantErr: ErrInvalidDID, 71 + }, 72 + { 73 + name: "just did prefix", 74 + did: "did:", 75 + wantErr: ErrInvalidDID, 76 + }, 77 + { 78 + name: "random gibberish", 79 + did: "not-a-did-at-all", 80 + wantErr: ErrInvalidDID, 81 + }, 82 + } 83 + 84 + for _, tt := range tests { 85 + t.Run(tt.name, func(t *testing.T) { 86 + err := ValidateDID(tt.did) 87 + if !errors.Is(err, tt.wantErr) { 88 + t.Errorf("ValidateDID(%q) = %v, want %v", tt.did, err, tt.wantErr) 89 + } 90 + }) 91 + } 92 + } 93 + 94 + func TestValidateCID(t *testing.T) { 95 + tests := []struct { 96 + name string 97 + cid string 98 + wantErr error 99 + }{ 100 + // Valid CIDs 101 + { 102 + name: "valid CIDv1 base32 bafy", 103 + cid: "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi", 104 + wantErr: nil, 105 + }, 106 + { 107 + name: "valid CIDv1 base32 bafk", 108 + cid: "bafkreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi", 109 + wantErr: nil, 110 + }, 111 + { 112 + name: "valid CIDv0", 113 + cid: "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG", 114 + wantErr: nil, 115 + }, 116 + // Invalid CIDs 117 + { 118 + name: "empty string", 119 + cid: "", 120 + wantErr: ErrInvalidCID, 121 + }, 122 + { 123 + name: "too short", 124 + cid: "bafyabc", 125 + wantErr: ErrInvalidCID, 126 + }, 127 + { 128 + name: "path traversal attempt", 129 + cid: "../../../etc/passwd", 130 + wantErr: ErrInvalidCID, 131 + }, 132 + { 133 + name: "contains slash", 134 + cid: "bafyrei/abc/def", 135 + wantErr: ErrInvalidCID, 136 + }, 137 + { 138 + name: "contains backslash", 139 + cid: "bafyrei\\abc", 140 + wantErr: ErrInvalidCID, 141 + }, 142 + { 143 + name: "contains double dot", 144 + cid: "bafyrei..abc", 145 + wantErr: ErrInvalidCID, 146 + }, 147 + { 148 + name: "invalid base32 chars", 149 + cid: "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi!@#", 150 + wantErr: ErrInvalidCID, 151 + }, 152 + { 153 + name: "random string not matching any CID pattern", 154 + cid: "this_is_not_a_valid_cid_at_all_12345", 155 + wantErr: ErrInvalidCID, 156 + }, 157 + { 158 + name: "too long", 159 + cid: "bafyrei" + string(make([]byte, 200)), 160 + wantErr: ErrInvalidCID, 161 + }, 162 + } 163 + 164 + for _, tt := range tests { 165 + t.Run(tt.name, func(t *testing.T) { 166 + err := ValidateCID(tt.cid) 167 + if !errors.Is(err, tt.wantErr) { 168 + t.Errorf("ValidateCID(%q) = %v, want %v", tt.cid, err, tt.wantErr) 169 + } 170 + }) 171 + } 172 + } 173 + 174 + func TestSanitizePathComponent(t *testing.T) { 175 + tests := []struct { 176 + name string 177 + input string 178 + want string 179 + }{ 180 + { 181 + name: "clean string unchanged", 182 + input: "abc123", 183 + want: "abc123", 184 + }, 185 + { 186 + name: "forward slashes removed", 187 + input: "path/to/file", 188 + want: "path_to_file", 189 + }, 190 + { 191 + name: "backslashes removed", 192 + input: "path\\to\\file", 193 + want: "path_to_file", 194 + }, 195 + { 196 + name: "path traversal removed", 197 + input: "../../../etc/passwd", 198 + want: "___etc_passwd", 199 + }, 200 + { 201 + name: "colons replaced", 202 + input: "did:plc:abc123", 203 + want: "did_plc_abc123", 204 + }, 205 + { 206 + name: "null bytes removed", 207 + input: "abc\x00def", 208 + want: "abcdef", 209 + }, 210 + { 211 + name: "multiple dangerous chars", 212 + input: "../path:to\\file\x00.txt", 213 + want: "_path_to_file.txt", 214 + }, 215 + } 216 + 217 + for _, tt := range tests { 218 + t.Run(tt.name, func(t *testing.T) { 219 + got := SanitizePathComponent(tt.input) 220 + if got != tt.want { 221 + t.Errorf("SanitizePathComponent(%q) = %q, want %q", tt.input, got, tt.want) 222 + } 223 + }) 224 + } 225 + } 226 + 227 + func TestMakeDIDSafe_PathTraversal(t *testing.T) { 228 + tests := []struct { 229 + name string 230 + did string 231 + check func(result string) bool 232 + }{ 233 + { 234 + name: "normal did:plc is safe", 235 + did: "did:plc:abc123", 236 + check: func(r string) bool { 237 + return r == "did_plc_abc123" 238 + }, 239 + }, 240 + { 241 + name: "path traversal sequences removed", 242 + did: "did:plc:../../../etc/passwd", 243 + check: func(r string) bool { 244 + // Should not contain .. or / 245 + return !contains(r, "..") && !contains(r, "/") && !contains(r, "\\") 246 + }, 247 + }, 248 + { 249 + name: "forward slashes removed", 250 + did: "did:plc:abc/def", 251 + check: func(r string) bool { 252 + return !contains(r, "/") 253 + }, 254 + }, 255 + { 256 + name: "backslashes removed", 257 + did: "did:plc:abc\\def", 258 + check: func(r string) bool { 259 + return !contains(r, "\\") 260 + }, 261 + }, 262 + { 263 + name: "null bytes removed", 264 + did: "did:plc:abc\x00def", 265 + check: func(r string) bool { 266 + return !contains(r, "\x00") 267 + }, 268 + }, 269 + } 270 + 271 + for _, tt := range tests { 272 + t.Run(tt.name, func(t *testing.T) { 273 + result := makeDIDSafe(tt.did) 274 + if !tt.check(result) { 275 + t.Errorf("makeDIDSafe(%q) = %q, failed safety check", tt.did, result) 276 + } 277 + }) 278 + } 279 + } 280 + 281 + func TestMakeCIDSafe_PathTraversal(t *testing.T) { 282 + tests := []struct { 283 + name string 284 + cid string 285 + check func(result string) bool 286 + }{ 287 + { 288 + name: "normal CID unchanged", 289 + cid: "bafyreiabc123", 290 + check: func(r string) bool { 291 + return r == "bafyreiabc123" 292 + }, 293 + }, 294 + { 295 + name: "path traversal removed", 296 + cid: "../../../etc/passwd", 297 + check: func(r string) bool { 298 + return !contains(r, "..") && !contains(r, "/") 299 + }, 300 + }, 301 + { 302 + name: "forward slashes removed", 303 + cid: "abc/def/ghi", 304 + check: func(r string) bool { 305 + return !contains(r, "/") 306 + }, 307 + }, 308 + { 309 + name: "backslashes removed", 310 + cid: "abc\\def\\ghi", 311 + check: func(r string) bool { 312 + return !contains(r, "\\") 313 + }, 314 + }, 315 + { 316 + name: "null bytes removed", 317 + cid: "abc\x00def", 318 + check: func(r string) bool { 319 + return !contains(r, "\x00") 320 + }, 321 + }, 322 + } 323 + 324 + for _, tt := range tests { 325 + t.Run(tt.name, func(t *testing.T) { 326 + result := makeCIDSafe(tt.cid) 327 + if !tt.check(result) { 328 + t.Errorf("makeCIDSafe(%q) = %q, failed safety check", tt.cid, result) 329 + } 330 + }) 331 + } 332 + } 333 + 334 + // helper function for checking string containment 335 + func contains(s, substr string) bool { 336 + for i := 0; i <= len(s)-len(substr); i++ { 337 + if s[i:i+len(substr)] == substr { 338 + return true 339 + } 340 + } 341 + return false 342 + }
+7 -11
internal/core/users/service.go
··· 2 3 import ( 4 "Coves/internal/atproto/identity" 5 "bytes" 6 "context" 7 "encoding/json" ··· 294 Bio: user.Bio, 295 } 296 297 - // Transform avatar CID to URL if both CID and PDS URL are present 298 - if user.AvatarCID != "" && user.PDSURL != "" { 299 - profile.Avatar = fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 300 - strings.TrimSuffix(user.PDSURL, "/"), user.DID, user.AvatarCID) 301 - } 302 - 303 - // Transform banner CID to URL if both CID and PDS URL are present 304 - if user.BannerCID != "" && user.PDSURL != "" { 305 - profile.Banner = fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", 306 - strings.TrimSuffix(user.PDSURL, "/"), user.DID, user.BannerCID) 307 - } 308 309 return profile, nil 310 }
··· 2 3 import ( 4 "Coves/internal/atproto/identity" 5 + "Coves/internal/core/blobs" 6 + "Coves/internal/core/communities" 7 "bytes" 8 "context" 9 "encoding/json" ··· 296 Bio: user.Bio, 297 } 298 299 + // Transform avatar/banner CIDs to URLs using image proxy config 300 + // Uses 'avatar' preset (160x160) for profile detail view 301 + config := communities.GetImageProxyConfig() 302 + profile.Avatar = blobs.HydrateImageURL(config, user.PDSURL, user.DID, user.AvatarCID, "avatar") 303 + profile.Banner = blobs.HydrateImageURL(config, user.PDSURL, user.DID, user.BannerCID, "banner") 304 305 return profile, nil 306 }
+5 -5
internal/core/users/service_test.go
··· 513 assert.Equal(t, "Avatar User", profile.DisplayName) 514 assert.Equal(t, "Test bio for avatar user", profile.Bio) 515 516 - // Verify CID-to-URL transformation 517 - expectedAvatarURL := "https://test.pds/xrpc/com.atproto.sync.getBlob?did=did:plc:avataruser&cid=bafkreiabc123avatar" 518 - expectedBannerURL := "https://test.pds/xrpc/com.atproto.sync.getBlob?did=did:plc:avataruser&cid=bafkreixyz789banner" 519 assert.Equal(t, expectedAvatarURL, profile.Avatar) 520 assert.Equal(t, expectedBannerURL, profile.Banner) 521 ··· 549 profile, err := service.GetProfile(ctx, testDID) 550 require.NoError(t, err) 551 552 - // Avatar should be transformed to URL 553 - expectedAvatarURL := "https://test.pds/xrpc/com.atproto.sync.getBlob?did=did:plc:avataronly&cid=bafkreiavataronly" 554 assert.Equal(t, expectedAvatarURL, profile.Avatar) 555 556 // Banner should be empty
··· 513 assert.Equal(t, "Avatar User", profile.DisplayName) 514 assert.Equal(t, "Test bio for avatar user", profile.Bio) 515 516 + // Verify CID-to-URL transformation (DID is URL-encoded in query params) 517 + expectedAvatarURL := "https://test.pds/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aavataruser&cid=bafkreiabc123avatar" 518 + expectedBannerURL := "https://test.pds/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aavataruser&cid=bafkreixyz789banner" 519 assert.Equal(t, expectedAvatarURL, profile.Avatar) 520 assert.Equal(t, expectedBannerURL, profile.Banner) 521 ··· 549 profile, err := service.GetProfile(ctx, testDID) 550 require.NoError(t, err) 551 552 + // Avatar should be transformed to URL (DID is URL-encoded in query params) 553 + expectedAvatarURL := "https://test.pds/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aavataronly&cid=bafkreiavataronly" 554 assert.Equal(t, expectedAvatarURL, profile.Avatar) 555 556 // Banner should be empty
+30 -15
internal/db/postgres/feed_repo_base.go
··· 8 "encoding/hex" 9 "encoding/json" 10 "fmt" 11 "strings" 12 "time" 13 14 "Coves/internal/core/blobs" 15 "Coves/internal/core/posts" 16 ) 17 ··· 347 if communityHandle.Valid { 348 communityRef.Handle = communityHandle.String 349 } 350 - // Hydrate avatar CID to URL (instead of returning raw CID) 351 - if avatarURL := blobs.HydrateBlobURL(communityPDSURL.String, communityRef.DID, communityAvatar.String); avatarURL != "" { 352 communityRef.Avatar = &avatarURL 353 } 354 if communityPDSURL.Valid { ··· 361 postView.Text = nullStringPtr(content) 362 363 // Parse facets JSON 364 if facets.Valid { 365 var facetArray []interface{} 366 - if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil { 367 postView.TextFacets = facetArray 368 } 369 } 370 371 // Parse embed JSON 372 if embed.Valid { 373 var embedData interface{} 374 - if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil { 375 postView.Embed = embedData 376 } 377 } ··· 399 if content.Valid { 400 record["content"] = content.String 401 } 402 - if facets.Valid { 403 - var facetArray []interface{} 404 - if err := json.Unmarshal([]byte(facets.String), &facetArray); err == nil { 405 - record["facets"] = facetArray 406 - } 407 } 408 - if embed.Valid { 409 - var embedData interface{} 410 - if err := json.Unmarshal([]byte(embed.String), &embedData); err == nil { 411 - record["embed"] = embedData 412 - } 413 } 414 if labelsJSON.Valid { 415 // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 416 // Deserialize and include in record 417 var selfLabels posts.SelfLabels 418 - if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err == nil { 419 record["labels"] = selfLabels 420 } 421 }
··· 8 "encoding/hex" 9 "encoding/json" 10 "fmt" 11 + "log/slog" 12 "strings" 13 "time" 14 15 "Coves/internal/core/blobs" 16 + "Coves/internal/core/communities" 17 "Coves/internal/core/posts" 18 ) 19 ··· 349 if communityHandle.Valid { 350 communityRef.Handle = communityHandle.String 351 } 352 + // Hydrate avatar CID to URL using image proxy config (avatar_small preset for feed lists) 353 + if avatarURL := blobs.HydrateImageURL(communities.GetImageProxyConfig(), communityPDSURL.String, communityRef.DID, communityAvatar.String, "avatar_small"); avatarURL != "" { 354 communityRef.Avatar = &avatarURL 355 } 356 if communityPDSURL.Valid { ··· 363 postView.Text = nullStringPtr(content) 364 365 // Parse facets JSON 366 + // Log errors but continue - a single malformed post shouldn't break the entire feed 367 if facets.Valid { 368 var facetArray []interface{} 369 + if err := json.Unmarshal([]byte(facets.String), &facetArray); err != nil { 370 + slog.Warn("[FEED] failed to parse facets JSON", 371 + "post_uri", postView.URI, 372 + "error", err, 373 + ) 374 + } else { 375 postView.TextFacets = facetArray 376 } 377 } 378 379 // Parse embed JSON 380 + // Log errors but continue - a single malformed post shouldn't break the entire feed 381 if embed.Valid { 382 var embedData interface{} 383 + if err := json.Unmarshal([]byte(embed.String), &embedData); err != nil { 384 + slog.Warn("[FEED] failed to parse embed JSON", 385 + "post_uri", postView.URI, 386 + "error", err, 387 + ) 388 + } else { 389 postView.Embed = embedData 390 } 391 } ··· 413 if content.Valid { 414 record["content"] = content.String 415 } 416 + // Reuse already-parsed facets and embed from PostView (parsed above with logging) 417 + // This avoids double parsing and ensures consistent error handling 418 + if postView.TextFacets != nil { 419 + record["facets"] = postView.TextFacets 420 } 421 + if postView.Embed != nil { 422 + record["embed"] = postView.Embed 423 } 424 if labelsJSON.Valid { 425 // Labels are stored as JSONB containing full com.atproto.label.defs#selfLabels structure 426 // Deserialize and include in record 427 var selfLabels posts.SelfLabels 428 + if err := json.Unmarshal([]byte(labelsJSON.String), &selfLabels); err != nil { 429 + slog.Warn("[FEED] failed to parse labels JSON", 430 + "post_uri", postView.URI, 431 + "error", err, 432 + ) 433 + } else { 434 record["labels"] = selfLabels 435 } 436 }
+3 -2
internal/db/postgres/post_repo.go
··· 11 "time" 12 13 "Coves/internal/core/blobs" 14 "Coves/internal/core/posts" 15 ) 16 ··· 335 if communityHandle.Valid { 336 communityRef.Handle = communityHandle.String 337 } 338 - // Hydrate avatar CID to URL (instead of returning raw CID) 339 - if avatarURL := blobs.HydrateBlobURL(communityPDSURL.String, communityRef.DID, communityAvatar.String); avatarURL != "" { 340 communityRef.Avatar = &avatarURL 341 } 342 if communityPDSURL.Valid {
··· 11 "time" 12 13 "Coves/internal/core/blobs" 14 + "Coves/internal/core/communities" 15 "Coves/internal/core/posts" 16 ) 17 ··· 336 if communityHandle.Valid { 337 communityRef.Handle = communityHandle.String 338 } 339 + // Hydrate avatar CID to URL using image proxy config (avatar_small preset for post views) 340 + if avatarURL := blobs.HydrateImageURL(communities.GetImageProxyConfig(), communityPDSURL.String, communityRef.DID, communityAvatar.String, "avatar_small"); avatarURL != "" { 341 communityRef.Avatar = &avatarURL 342 } 343 if communityPDSURL.Valid {
+943
tests/integration/image_proxy_e2e_test.go
···
··· 1 + package integration 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "image" 9 + "image/color" 10 + "image/png" 11 + "net/http" 12 + "net/http/httptest" 13 + "os" 14 + "strings" 15 + "testing" 16 + "time" 17 + 18 + "github.com/disintegration/imaging" 19 + "github.com/go-chi/chi/v5" 20 + "github.com/stretchr/testify/assert" 21 + "github.com/stretchr/testify/require" 22 + 23 + "Coves/internal/api/handlers/imageproxy" 24 + "Coves/internal/api/routes" 25 + "Coves/internal/atproto/identity" 26 + "Coves/internal/core/blobs" 27 + "Coves/internal/core/communities" 28 + imageproxycore "Coves/internal/core/imageproxy" 29 + "Coves/internal/db/postgres" 30 + ) 31 + 32 + // TestImageProxy_E2E tests the complete image proxy flow including: 33 + // - Creating a community with an avatar 34 + // - Fetching the avatar via the image proxy 35 + // - Verifying response headers, status codes, and image dimensions 36 + // - Testing ETag-based caching (304 responses) 37 + // - Error handling for invalid presets and missing blobs 38 + func TestImageProxy_E2E(t *testing.T) { 39 + if testing.Short() { 40 + t.Skip("Skipping E2E integration test in short mode") 41 + } 42 + 43 + // Check if PDS is running 44 + pdsURL := getTestPDSURL() 45 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 46 + if err != nil { 47 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 48 + } 49 + _ = healthResp.Body.Close() 50 + 51 + // Setup test database 52 + db := setupTestDB(t) 53 + defer func() { _ = db.Close() }() 54 + 55 + ctx := context.Background() 56 + 57 + // Setup repositories and services 58 + communityRepo := postgres.NewCommunityRepository(db) 59 + 60 + // Setup identity resolver with local PLC 61 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 62 + if plcURL == "" { 63 + plcURL = "http://localhost:3002" 64 + } 65 + identityConfig := identity.DefaultConfig() 66 + identityConfig.PLCURL = plcURL 67 + identityResolver := identity.NewResolver(db, identityConfig) 68 + 69 + // Create a real community WITH an avatar using the community service 70 + // This ensures the blob is referenced by the community profile record 71 + // (blobs must be referenced to be stored by PDS) 72 + instanceDID := "did:web:coves.social" 73 + provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 74 + blobService := blobs.NewBlobService(pdsURL) 75 + 76 + communityService := communities.NewCommunityServiceWithPDSFactory( 77 + communityRepo, 78 + pdsURL, 79 + instanceDID, 80 + "coves.social", 81 + provisioner, 82 + nil, // No custom PDS factory 83 + blobService, 84 + ) 85 + 86 + // Create avatar image data 87 + avatarData := createTestImageForProxy(t, 200, 200, color.RGBA{R: 100, G: 150, B: 200, A: 255}) 88 + 89 + uniqueID := time.Now().UnixNano() % 100000000 // Keep shorter for handle limit 90 + communityName := fmt.Sprintf("ip%d", uniqueID) 91 + creatorDID := fmt.Sprintf("did:plc:c%d", uniqueID) 92 + 93 + t.Logf("Creating community with avatar: %s", communityName) 94 + community, err := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 95 + Name: communityName, 96 + DisplayName: "Image Proxy Test Community", 97 + Description: "Testing image proxy with avatar", 98 + Visibility: "public", 99 + CreatedByDID: creatorDID, 100 + HostedByDID: instanceDID, 101 + AllowExternalDiscovery: true, 102 + AvatarBlob: avatarData, 103 + AvatarMimeType: "image/png", 104 + }) 105 + require.NoError(t, err, "Failed to create community with avatar") 106 + 107 + // Get the avatar CID from the created community 108 + avatarCID := community.AvatarCID 109 + require.NotEmpty(t, avatarCID, "Avatar CID should not be empty") 110 + t.Logf("Created community: DID=%s, AvatarCID=%s", community.DID, avatarCID) 111 + 112 + // Verify blob exists on PDS before starting proxy tests 113 + directBlobURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s", pdsURL, community.DID, avatarCID) 114 + t.Logf("Verifying blob exists at: %s", directBlobURL) 115 + verifyResp, verifyErr := http.Get(directBlobURL) 116 + if verifyErr != nil { 117 + t.Logf("Warning: Failed to verify blob: %v", verifyErr) 118 + } else { 119 + t.Logf("Direct blob fetch status: %d", verifyResp.StatusCode) 120 + if verifyResp.StatusCode != http.StatusOK { 121 + var errBuf bytes.Buffer 122 + _, _ = errBuf.ReadFrom(verifyResp.Body) 123 + t.Logf("Direct blob fetch error: %s", errBuf.String()) 124 + } 125 + _ = verifyResp.Body.Close() 126 + } 127 + 128 + // Create the test server with image proxy routes 129 + testServer := createImageProxyTestServer(t, pdsURL, identityResolver) 130 + defer testServer.Close() 131 + 132 + t.Run("fetch avatar via proxy returns valid JPEG", func(t *testing.T) { 133 + // Build request URL 134 + proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 135 + t.Logf("Requesting: %s", proxyURL) 136 + 137 + resp, err := http.Get(proxyURL) 138 + require.NoError(t, err, "Request should succeed") 139 + defer func() { _ = resp.Body.Close() }() 140 + 141 + // Log error details if not 200 142 + if resp.StatusCode != http.StatusOK { 143 + var errBuf bytes.Buffer 144 + _, _ = errBuf.ReadFrom(resp.Body) 145 + t.Logf("Error response (status %d): %s", resp.StatusCode, errBuf.String()) 146 + } 147 + 148 + // Verify status code 149 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200 OK") 150 + 151 + // Verify Content-Type header 152 + contentType := resp.Header.Get("Content-Type") 153 + assert.Equal(t, "image/jpeg", contentType, "Content-Type should be image/jpeg") 154 + 155 + // Read the response body 156 + var buf bytes.Buffer 157 + _, err = buf.ReadFrom(resp.Body) 158 + require.NoError(t, err, "Should read response body") 159 + 160 + // Verify it's valid image data 161 + img, err := imaging.Decode(&buf) 162 + require.NoError(t, err, "Response should be valid image data") 163 + 164 + // Verify dimensions match avatar_small preset (360x360) 165 + bounds := img.Bounds() 166 + assert.Equal(t, 360, bounds.Dx(), "Width should be 360 (avatar_small preset)") 167 + assert.Equal(t, 360, bounds.Dy(), "Height should be 360 (avatar_small preset)") 168 + 169 + t.Logf("Successfully fetched and verified avatar: %dx%d", bounds.Dx(), bounds.Dy()) 170 + }) 171 + 172 + t.Run("returns correct cache headers", func(t *testing.T) { 173 + proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 174 + 175 + resp, err := http.Get(proxyURL) 176 + require.NoError(t, err, "Request should succeed") 177 + defer func() { _ = resp.Body.Close() }() 178 + 179 + // Verify Cache-Control header 180 + cacheControl := resp.Header.Get("Cache-Control") 181 + expectedCacheControl := "public, max-age=31536000, immutable" 182 + assert.Equal(t, expectedCacheControl, cacheControl, "Cache-Control header should be correct") 183 + 184 + // Verify ETag header is present and matches expected format 185 + etag := resp.Header.Get("ETag") 186 + expectedETag := fmt.Sprintf(`"avatar_small-%s"`, avatarCID) 187 + assert.Equal(t, expectedETag, etag, "ETag should match preset-cid format") 188 + 189 + t.Logf("Cache headers verified: Cache-Control=%s, ETag=%s", cacheControl, etag) 190 + }) 191 + 192 + t.Run("ETag returns 304 on match", func(t *testing.T) { 193 + proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 194 + 195 + // First, get the ETag 196 + resp, err := http.Get(proxyURL) 197 + require.NoError(t, err, "Initial request should succeed") 198 + etag := resp.Header.Get("ETag") 199 + _ = resp.Body.Close() 200 + require.NotEmpty(t, etag, "ETag should be present") 201 + 202 + // Now make a conditional request with If-None-Match 203 + req, err := http.NewRequest(http.MethodGet, proxyURL, nil) 204 + require.NoError(t, err, "Should create request") 205 + req.Header.Set("If-None-Match", etag) 206 + 207 + resp, err = http.DefaultClient.Do(req) 208 + require.NoError(t, err, "Conditional request should succeed") 209 + defer func() { _ = resp.Body.Close() }() 210 + 211 + // Verify 304 Not Modified 212 + assert.Equal(t, http.StatusNotModified, resp.StatusCode, "Should return 304 Not Modified") 213 + 214 + // Verify no body in 304 response 215 + var buf bytes.Buffer 216 + _, _ = buf.ReadFrom(resp.Body) 217 + assert.Equal(t, 0, buf.Len(), "304 response should have empty body") 218 + 219 + t.Log("ETag conditional request correctly returned 304 Not Modified") 220 + }) 221 + 222 + t.Run("ETag mismatch returns full image", func(t *testing.T) { 223 + proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, avatarCID) 224 + 225 + // Make request with non-matching ETag 226 + req, err := http.NewRequest(http.MethodGet, proxyURL, nil) 227 + require.NoError(t, err, "Should create request") 228 + req.Header.Set("If-None-Match", `"wrong-etag-value"`) 229 + 230 + resp, err := http.DefaultClient.Do(req) 231 + require.NoError(t, err, "Request should succeed") 232 + defer func() { _ = resp.Body.Close() }() 233 + 234 + // Should return 200 with full image 235 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200 OK when ETag doesn't match") 236 + 237 + // Verify body is present 238 + var buf bytes.Buffer 239 + _, _ = buf.ReadFrom(resp.Body) 240 + assert.Greater(t, buf.Len(), 0, "Response should have body") 241 + 242 + t.Log("Non-matching ETag correctly returned full image") 243 + }) 244 + 245 + t.Run("invalid preset returns 400", func(t *testing.T) { 246 + proxyURL := fmt.Sprintf("%s/img/not_a_valid_preset/plain/%s/%s", testServer.URL, community.DID, avatarCID) 247 + 248 + resp, err := http.Get(proxyURL) 249 + require.NoError(t, err, "Request should succeed") 250 + defer func() { _ = resp.Body.Close() }() 251 + 252 + // Verify 400 Bad Request 253 + assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "Should return 400 for invalid preset") 254 + 255 + // Verify error message 256 + var buf bytes.Buffer 257 + _, _ = buf.ReadFrom(resp.Body) 258 + body := buf.String() 259 + assert.Contains(t, body, "invalid preset", "Error message should mention invalid preset") 260 + 261 + t.Logf("Invalid preset correctly returned 400: %s", body) 262 + }) 263 + 264 + t.Run("non-existent CID returns 404", func(t *testing.T) { 265 + // Use a valid CIDv1 (raw codec, sha256) that doesn't exist on the PDS 266 + // This is a properly formatted CID that will pass validation but won't exist 267 + fakeCID := "bafkreiemeosfdll427qzow5tipvctigjebyvi6ketznqrau2ydhzyggt7i" 268 + proxyURL := fmt.Sprintf("%s/img/avatar_small/plain/%s/%s", testServer.URL, community.DID, fakeCID) 269 + 270 + resp, err := http.Get(proxyURL) 271 + require.NoError(t, err, "Request should succeed") 272 + defer func() { _ = resp.Body.Close() }() 273 + 274 + // Verify 404 Not Found (blob not found on PDS) 275 + assert.Equal(t, http.StatusNotFound, resp.StatusCode, "Should return 404 for non-existent CID") 276 + 277 + t.Log("Non-existent CID correctly returned 404") 278 + }) 279 + 280 + t.Run("all valid presets work correctly", func(t *testing.T) { 281 + // Test a subset of presets with fixed dimensions (cover fit) 282 + presetTests := []struct { 283 + preset string 284 + expectWidth int 285 + expectHeight int 286 + }{ 287 + {"avatar", 1000, 1000}, 288 + {"avatar_small", 360, 360}, 289 + // banner has 640x300 but input is 200x200, so it will be scaled+cropped 290 + {"banner", 640, 300}, 291 + // embed_thumbnail is 720x360, will also be scaled+cropped 292 + {"embed_thumbnail", 720, 360}, 293 + } 294 + 295 + for _, tc := range presetTests { 296 + t.Run(tc.preset, func(t *testing.T) { 297 + proxyURL := fmt.Sprintf("%s/img/%s/plain/%s/%s", testServer.URL, tc.preset, community.DID, avatarCID) 298 + 299 + resp, err := http.Get(proxyURL) 300 + require.NoError(t, err, "Request should succeed for preset %s", tc.preset) 301 + defer func() { _ = resp.Body.Close() }() 302 + 303 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200 for valid preset %s", tc.preset) 304 + 305 + // Verify ETag format 306 + etag := resp.Header.Get("ETag") 307 + expectedETag := fmt.Sprintf(`"%s-%s"`, tc.preset, avatarCID) 308 + assert.Equal(t, expectedETag, etag, "ETag should match for preset %s", tc.preset) 309 + 310 + // Verify image dimensions 311 + var buf bytes.Buffer 312 + _, _ = buf.ReadFrom(resp.Body) 313 + img, err := imaging.Decode(&buf) 314 + require.NoError(t, err, "Should decode image for preset %s", tc.preset) 315 + 316 + bounds := img.Bounds() 317 + assert.Equal(t, tc.expectWidth, bounds.Dx(), "Width should match for preset %s", tc.preset) 318 + assert.Equal(t, tc.expectHeight, bounds.Dy(), "Height should match for preset %s", tc.preset) 319 + 320 + t.Logf("Preset %s: verified %dx%d", tc.preset, bounds.Dx(), bounds.Dy()) 321 + }) 322 + } 323 + }) 324 + 325 + t.Run("missing parameters return 400", func(t *testing.T) { 326 + testCases := []struct { 327 + name string 328 + url string 329 + }{ 330 + {"missing CID", fmt.Sprintf("%s/img/avatar/plain/%s/", testServer.URL, community.DID)}, 331 + {"missing DID", fmt.Sprintf("%s/img/avatar/plain//%s", testServer.URL, avatarCID)}, 332 + } 333 + 334 + for _, tc := range testCases { 335 + t.Run(tc.name, func(t *testing.T) { 336 + resp, err := http.Get(tc.url) 337 + require.NoError(t, err, "Request should succeed") 338 + defer func() { _ = resp.Body.Close() }() 339 + 340 + // Should return 400 or 404 (depends on routing) 341 + assert.True(t, resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusNotFound, 342 + "Should return 400 or 404 for %s, got %d", tc.name, resp.StatusCode) 343 + }) 344 + } 345 + }) 346 + 347 + t.Run("content_preview preset preserves aspect ratio", func(t *testing.T) { 348 + // content_preview uses FitContain which preserves aspect ratio 349 + // Input is 200x200, max width is 800, so output should be 200x200 (no upscaling) 350 + proxyURL := fmt.Sprintf("%s/img/content_preview/plain/%s/%s", testServer.URL, community.DID, avatarCID) 351 + 352 + resp, err := http.Get(proxyURL) 353 + require.NoError(t, err, "Request should succeed") 354 + defer func() { _ = resp.Body.Close() }() 355 + 356 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 357 + 358 + var buf bytes.Buffer 359 + _, _ = buf.ReadFrom(resp.Body) 360 + img, err := imaging.Decode(&buf) 361 + require.NoError(t, err, "Should decode image") 362 + 363 + bounds := img.Bounds() 364 + // content_preview with FitContain doesn't upscale, so 200x200 stays 200x200 365 + assert.Equal(t, 200, bounds.Dx(), "Width should be preserved (no upscaling)") 366 + assert.Equal(t, 200, bounds.Dy(), "Height should be preserved (no upscaling)") 367 + 368 + t.Logf("content_preview preserved aspect ratio: %dx%d", bounds.Dx(), bounds.Dy()) 369 + }) 370 + } 371 + 372 + // TestImageProxy_CacheHit tests that cache hits are faster than cache misses 373 + func TestImageProxy_CacheHit(t *testing.T) { 374 + if testing.Short() { 375 + t.Skip("Skipping cache test in short mode") 376 + } 377 + 378 + // Check if PDS is running 379 + pdsURL := getTestPDSURL() 380 + healthResp, err := http.Get(pdsURL + "/xrpc/_health") 381 + if err != nil { 382 + t.Skipf("PDS not running at %s: %v. Run 'make dev-up' to start.", pdsURL, err) 383 + } 384 + _ = healthResp.Body.Close() 385 + 386 + // Setup test database 387 + db := setupTestDB(t) 388 + defer func() { _ = db.Close() }() 389 + 390 + ctx := context.Background() 391 + 392 + // Setup repositories and services 393 + communityRepo := postgres.NewCommunityRepository(db) 394 + 395 + // Setup identity resolver 396 + plcURL := os.Getenv("PLC_DIRECTORY_URL") 397 + if plcURL == "" { 398 + plcURL = "http://localhost:3002" 399 + } 400 + identityConfig := identity.DefaultConfig() 401 + identityConfig.PLCURL = plcURL 402 + identityResolver := identity.NewResolver(db, identityConfig) 403 + 404 + // Create a real community with avatar using the community service 405 + instanceDID := "did:web:coves.social" 406 + provisioner := communities.NewPDSAccountProvisioner("coves.social", pdsURL) 407 + blobService := blobs.NewBlobService(pdsURL) 408 + 409 + communityService := communities.NewCommunityServiceWithPDSFactory( 410 + communityRepo, 411 + pdsURL, 412 + instanceDID, 413 + "coves.social", 414 + provisioner, 415 + nil, 416 + blobService, 417 + ) 418 + 419 + avatarData := createTestImageForProxy(t, 150, 150, color.RGBA{R: 50, G: 100, B: 150, A: 255}) 420 + uniqueID := time.Now().UnixNano() % 100000000 // Keep shorter for handle limit 421 + communityName := fmt.Sprintf("ic%d", uniqueID) 422 + creatorDID := fmt.Sprintf("did:plc:cc%d", uniqueID) 423 + 424 + community, err := communityService.CreateCommunity(ctx, communities.CreateCommunityRequest{ 425 + Name: communityName, 426 + DisplayName: "Image Cache Test Community", 427 + Description: "Testing image proxy caching", 428 + Visibility: "public", 429 + CreatedByDID: creatorDID, 430 + HostedByDID: instanceDID, 431 + AllowExternalDiscovery: true, 432 + AvatarBlob: avatarData, 433 + AvatarMimeType: "image/png", 434 + }) 435 + require.NoError(t, err, "Failed to create community with avatar") 436 + 437 + avatarCID := community.AvatarCID 438 + require.NotEmpty(t, avatarCID, "Avatar CID should not be empty") 439 + t.Logf("Created community: DID=%s, AvatarCID=%s", community.DID, avatarCID) 440 + 441 + // Create temp directory for cache 442 + cacheDir := t.TempDir() 443 + 444 + // Create test server with caching enabled 445 + testServer := createImageProxyTestServerWithCache(t, pdsURL, identityResolver, cacheDir) 446 + defer testServer.Close() 447 + 448 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, community.DID, avatarCID) 449 + 450 + // First request (cache miss) 451 + startFirst := time.Now() 452 + resp, err := http.Get(proxyURL) 453 + require.NoError(t, err, "First request should succeed") 454 + _ = resp.Body.Close() 455 + firstDuration := time.Since(startFirst) 456 + 457 + // Second request (should hit cache) 458 + startSecond := time.Now() 459 + resp, err = http.Get(proxyURL) 460 + require.NoError(t, err, "Second request should succeed") 461 + _ = resp.Body.Close() 462 + secondDuration := time.Since(startSecond) 463 + 464 + t.Logf("First request (cache miss): %v", firstDuration) 465 + t.Logf("Second request (should hit cache): %v", secondDuration) 466 + 467 + // Note: Cache hit should generally be faster, but timing can be flaky in tests 468 + // So we just verify both requests succeed 469 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Cached request should return 200") 470 + } 471 + 472 + // createTestImageForProxy creates a test PNG image with specified dimensions and color 473 + func createTestImageForProxy(t *testing.T, width, height int, fillColor color.Color) []byte { 474 + t.Helper() 475 + 476 + img := image.NewRGBA(image.Rect(0, 0, width, height)) 477 + for y := 0; y < height; y++ { 478 + for x := 0; x < width; x++ { 479 + img.Set(x, y, fillColor) 480 + } 481 + } 482 + 483 + var buf bytes.Buffer 484 + err := png.Encode(&buf, img) 485 + require.NoError(t, err, "PNG encoding should succeed") 486 + 487 + return buf.Bytes() 488 + } 489 + 490 + // createImageProxyTestServer creates an httptest server with image proxy routes configured 491 + func createImageProxyTestServer(t *testing.T, pdsURL string, identityResolver identity.Resolver) *httptest.Server { 492 + t.Helper() 493 + 494 + // Create temp directory for cache 495 + cacheDir := t.TempDir() 496 + return createImageProxyTestServerWithCache(t, pdsURL, identityResolver, cacheDir) 497 + } 498 + 499 + // createImageProxyTestServerWithCache creates an httptest server with image proxy routes and specified cache directory 500 + func createImageProxyTestServerWithCache(t *testing.T, pdsURL string, identityResolver identity.Resolver, cacheDir string) *httptest.Server { 501 + t.Helper() 502 + 503 + // Create imageproxy service components 504 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 505 + require.NoError(t, err, "Failed to create disk cache") // 1GB max 506 + processor := imageproxycore.NewProcessor() 507 + fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 508 + config := imageproxycore.Config{ 509 + Enabled: true, 510 + CachePath: cacheDir, 511 + CacheMaxGB: 1, 512 + FetchTimeout: 30 * time.Second, 513 + MaxSourceSizeMB: 10, 514 + } 515 + 516 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 517 + require.NoError(t, err, "Failed to create imageproxy service") 518 + 519 + // Create handler 520 + handler := imageproxy.NewHandler(service, identityResolver) 521 + 522 + // Create router and register routes 523 + r := chi.NewRouter() 524 + routes.RegisterImageProxyRoutes(r, handler) 525 + 526 + return httptest.NewServer(r) 527 + } 528 + 529 + // TestImageProxy_MockPDS tests the image proxy with a mock PDS server 530 + // This allows testing image proxy behavior without a real PDS 531 + func TestImageProxy_MockPDS(t *testing.T) { 532 + // Create test image 533 + testImage := createTestImageForProxy(t, 100, 100, color.RGBA{R: 255, G: 128, B: 64, A: 255}) 534 + testCID := "bafybeimockimagetest123" 535 + testDID := "did:plc:mocktest123" 536 + 537 + // Create mock PDS server that returns the test image 538 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 539 + // Verify it's a getBlob request 540 + if !strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.sync.getBlob") { 541 + w.WriteHeader(http.StatusNotFound) 542 + return 543 + } 544 + 545 + // Check query parameters 546 + did := r.URL.Query().Get("did") 547 + cid := r.URL.Query().Get("cid") 548 + 549 + if did == testDID && cid == testCID { 550 + w.Header().Set("Content-Type", "image/png") 551 + w.WriteHeader(http.StatusOK) 552 + _, _ = w.Write(testImage) 553 + return 554 + } 555 + 556 + // Return 404 for unknown blobs 557 + w.WriteHeader(http.StatusNotFound) 558 + })) 559 + defer mockPDS.Close() 560 + 561 + // Create mock identity resolver that returns the mock PDS URL 562 + mockResolver := &mockIdentityResolverForImageProxy{ 563 + pdsURL: mockPDS.URL, 564 + } 565 + 566 + // Create test server 567 + cacheDir := t.TempDir() 568 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 569 + require.NoError(t, err, "Failed to create disk cache") 570 + processor := imageproxycore.NewProcessor() 571 + fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 572 + config := imageproxycore.Config{ 573 + Enabled: true, 574 + CachePath: cacheDir, 575 + CacheMaxGB: 1, 576 + FetchTimeout: 30 * time.Second, 577 + MaxSourceSizeMB: 10, 578 + } 579 + 580 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 581 + require.NoError(t, err, "Failed to create imageproxy service") 582 + handler := imageproxy.NewHandler(service, mockResolver) 583 + 584 + r := chi.NewRouter() 585 + routes.RegisterImageProxyRoutes(r, handler) 586 + testServer := httptest.NewServer(r) 587 + defer testServer.Close() 588 + 589 + t.Run("mock PDS returns valid image", func(t *testing.T) { 590 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, testDID, testCID) 591 + 592 + resp, err := http.Get(proxyURL) 593 + require.NoError(t, err, "Request should succeed") 594 + defer func() { _ = resp.Body.Close() }() 595 + 596 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 597 + assert.Equal(t, "image/jpeg", resp.Header.Get("Content-Type"), "Content-Type should be JPEG") 598 + 599 + // Verify processed dimensions (avatar is 1000x1000 per presets.go) 600 + var buf bytes.Buffer 601 + _, _ = buf.ReadFrom(resp.Body) 602 + img, err := imaging.Decode(&buf) 603 + require.NoError(t, err, "Should decode image") 604 + 605 + bounds := img.Bounds() 606 + assert.Equal(t, 1000, bounds.Dx(), "Width should be 1000 (avatar preset)") 607 + assert.Equal(t, 1000, bounds.Dy(), "Height should be 1000 (avatar preset)") 608 + }) 609 + 610 + t.Run("mock PDS 404 returns proxy 404", func(t *testing.T) { 611 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, testDID, "nonexistentcid") 612 + 613 + resp, err := http.Get(proxyURL) 614 + require.NoError(t, err, "Request should succeed") 615 + defer func() { _ = resp.Body.Close() }() 616 + 617 + assert.Equal(t, http.StatusNotFound, resp.StatusCode, "Should return 404") 618 + }) 619 + } 620 + 621 + // mockIdentityResolverForImageProxy is a mock identity resolver for testing 622 + type mockIdentityResolverForImageProxy struct { 623 + pdsURL string 624 + } 625 + 626 + func (m *mockIdentityResolverForImageProxy) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 627 + return nil, fmt.Errorf("not implemented") 628 + } 629 + 630 + func (m *mockIdentityResolverForImageProxy) ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error) { 631 + return "", "", fmt.Errorf("not implemented") 632 + } 633 + 634 + func (m *mockIdentityResolverForImageProxy) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 635 + return &identity.DIDDocument{ 636 + DID: did, 637 + Service: []identity.Service{ 638 + { 639 + ID: "#atproto_pds", 640 + Type: "AtprotoPersonalDataServer", 641 + ServiceEndpoint: m.pdsURL, 642 + }, 643 + }, 644 + }, nil 645 + } 646 + 647 + func (m *mockIdentityResolverForImageProxy) Purge(ctx context.Context, identifier string) error { 648 + return nil 649 + } 650 + 651 + // TestImageProxy_ErrorHandling tests various error conditions 652 + func TestImageProxy_ErrorHandling(t *testing.T) { 653 + // Create mock identity resolver 654 + mockResolver := &mockIdentityResolverForImageProxy{ 655 + pdsURL: "http://localhost:9999", // Non-existent server 656 + } 657 + 658 + // Create test server 659 + cacheDir := t.TempDir() 660 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 661 + require.NoError(t, err, "Failed to create disk cache") 662 + processor := imageproxycore.NewProcessor() 663 + fetcher := imageproxycore.NewPDSFetcher(1 * time.Second, 10) // Short timeout 664 + config := imageproxycore.Config{ 665 + Enabled: true, 666 + CachePath: cacheDir, 667 + CacheMaxGB: 1, 668 + FetchTimeout: 1 * time.Second, 669 + MaxSourceSizeMB: 10, 670 + } 671 + 672 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 673 + require.NoError(t, err, "Failed to create imageproxy service") 674 + handler := imageproxy.NewHandler(service, mockResolver) 675 + 676 + r := chi.NewRouter() 677 + routes.RegisterImageProxyRoutes(r, handler) 678 + testServer := httptest.NewServer(r) 679 + defer testServer.Close() 680 + 681 + t.Run("connection refused returns 502", func(t *testing.T) { 682 + // Use a valid CID format - this will pass validation but fail at the PDS fetch stage 683 + validCID := "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi" 684 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/did:plc:test/%s", testServer.URL, validCID) 685 + 686 + resp, err := http.Get(proxyURL) 687 + require.NoError(t, err, "Request should succeed") 688 + defer func() { _ = resp.Body.Close() }() 689 + 690 + // Should return 502 Bad Gateway when PDS fetch fails 691 + assert.Equal(t, http.StatusBadGateway, resp.StatusCode, "Should return 502 when PDS is unreachable") 692 + }) 693 + 694 + t.Run("invalid DID resolution returns 502", func(t *testing.T) { 695 + // Create resolver that returns error 696 + errorResolver := &errorMockResolver{} 697 + 698 + errorHandler := imageproxy.NewHandler(service, errorResolver) 699 + errorRouter := chi.NewRouter() 700 + routes.RegisterImageProxyRoutes(errorRouter, errorHandler) 701 + errorServer := httptest.NewServer(errorRouter) 702 + defer errorServer.Close() 703 + 704 + // Use a valid CID format - this will pass validation but fail at DID resolution 705 + validCID := "bafyreihgdyzzpkkzq2izfnhcmm77ycuacvkuziwbnqxfxtqsz7tmxwhnshi" 706 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/did:plc:test/%s", errorServer.URL, validCID) 707 + 708 + resp, err := http.Get(proxyURL) 709 + require.NoError(t, err, "Request should succeed") 710 + defer func() { _ = resp.Body.Close() }() 711 + 712 + assert.Equal(t, http.StatusBadGateway, resp.StatusCode, "Should return 502 when DID resolution fails") 713 + }) 714 + } 715 + 716 + // errorMockResolver is a mock resolver that always returns an error 717 + type errorMockResolver struct{} 718 + 719 + func (m *errorMockResolver) Resolve(ctx context.Context, identifier string) (*identity.Identity, error) { 720 + return nil, fmt.Errorf("resolution failed") 721 + } 722 + 723 + func (m *errorMockResolver) ResolveHandle(ctx context.Context, handle string) (did, pdsURL string, err error) { 724 + return "", "", fmt.Errorf("resolution failed") 725 + } 726 + 727 + func (m *errorMockResolver) ResolveDID(ctx context.Context, did string) (*identity.DIDDocument, error) { 728 + return nil, fmt.Errorf("resolution failed") 729 + } 730 + 731 + func (m *errorMockResolver) Purge(ctx context.Context, identifier string) error { 732 + return nil 733 + } 734 + 735 + // TestImageProxy_UnsupportedFormat tests behavior with unsupported image formats 736 + func TestImageProxy_UnsupportedFormat(t *testing.T) { 737 + // Create mock PDS that returns invalid image data 738 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 739 + if strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.sync.getBlob") { 740 + cid := r.URL.Query().Get("cid") 741 + 742 + if cid == "textdata" { 743 + // Return text data instead of image 744 + w.Header().Set("Content-Type", "text/plain") 745 + w.WriteHeader(http.StatusOK) 746 + _, _ = w.Write([]byte("this is not an image")) 747 + return 748 + } 749 + 750 + if cid == "corruptedimage" { 751 + // Return corrupted image data 752 + w.Header().Set("Content-Type", "image/png") 753 + w.WriteHeader(http.StatusOK) 754 + _, _ = w.Write([]byte{0x89, 0x50, 0x4E, 0x47, 0x00, 0x00}) // Incomplete PNG header 755 + return 756 + } 757 + 758 + if cid == "emptybody" { 759 + // Return empty body 760 + w.WriteHeader(http.StatusOK) 761 + return 762 + } 763 + } 764 + w.WriteHeader(http.StatusNotFound) 765 + })) 766 + defer mockPDS.Close() 767 + 768 + mockResolver := &mockIdentityResolverForImageProxy{ 769 + pdsURL: mockPDS.URL, 770 + } 771 + 772 + cacheDir := t.TempDir() 773 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 774 + require.NoError(t, err, "Failed to create disk cache") 775 + processor := imageproxycore.NewProcessor() 776 + fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 777 + config := imageproxycore.DefaultConfig() 778 + 779 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 780 + require.NoError(t, err, "Failed to create imageproxy service") 781 + handler := imageproxy.NewHandler(service, mockResolver) 782 + 783 + r := chi.NewRouter() 784 + routes.RegisterImageProxyRoutes(r, handler) 785 + testServer := httptest.NewServer(r) 786 + defer testServer.Close() 787 + 788 + testCases := []struct { 789 + name string 790 + cid string 791 + expectedStatus int 792 + }{ 793 + {"text data", "textdata", http.StatusBadRequest}, 794 + {"corrupted image", "corruptedimage", http.StatusInternalServerError}, 795 + {"empty body", "emptybody", http.StatusBadRequest}, 796 + } 797 + 798 + for _, tc := range testCases { 799 + t.Run(tc.name, func(t *testing.T) { 800 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/did:plc:test/%s", testServer.URL, tc.cid) 801 + 802 + resp, err := http.Get(proxyURL) 803 + require.NoError(t, err, "Request should succeed") 804 + defer func() { _ = resp.Body.Close() }() 805 + 806 + // Should return error status for invalid image data 807 + assert.True(t, resp.StatusCode >= 400, "Should return error status for %s", tc.name) 808 + t.Logf("%s returned status %d", tc.name, resp.StatusCode) 809 + }) 810 + } 811 + } 812 + 813 + // TestImageProxy_LargeImage tests behavior with large images 814 + func TestImageProxy_LargeImage(t *testing.T) { 815 + // Create a large test image (1000x1000) 816 + largeImage := createTestImageForProxy(t, 1000, 1000, color.RGBA{R: 200, G: 100, B: 50, A: 255}) 817 + testCID := "bafylargeimagecid" 818 + testDID := "did:plc:largetest" 819 + 820 + mockPDS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 821 + if strings.HasPrefix(r.URL.Path, "/xrpc/com.atproto.sync.getBlob") { 822 + did := r.URL.Query().Get("did") 823 + cid := r.URL.Query().Get("cid") 824 + 825 + if did == testDID && cid == testCID { 826 + w.Header().Set("Content-Type", "image/png") 827 + w.WriteHeader(http.StatusOK) 828 + _, _ = w.Write(largeImage) 829 + return 830 + } 831 + } 832 + w.WriteHeader(http.StatusNotFound) 833 + })) 834 + defer mockPDS.Close() 835 + 836 + mockResolver := &mockIdentityResolverForImageProxy{ 837 + pdsURL: mockPDS.URL, 838 + } 839 + 840 + cacheDir := t.TempDir() 841 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 842 + require.NoError(t, err, "Failed to create disk cache") 843 + processor := imageproxycore.NewProcessor() 844 + fetcher := imageproxycore.NewPDSFetcher(30 * time.Second, 10) 845 + config := imageproxycore.DefaultConfig() 846 + 847 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 848 + require.NoError(t, err, "Failed to create imageproxy service") 849 + handler := imageproxy.NewHandler(service, mockResolver) 850 + 851 + r := chi.NewRouter() 852 + routes.RegisterImageProxyRoutes(r, handler) 853 + testServer := httptest.NewServer(r) 854 + defer testServer.Close() 855 + 856 + t.Run("large image resized correctly", func(t *testing.T) { 857 + proxyURL := fmt.Sprintf("%s/img/avatar/plain/%s/%s", testServer.URL, testDID, testCID) 858 + 859 + resp, err := http.Get(proxyURL) 860 + require.NoError(t, err, "Request should succeed") 861 + defer func() { _ = resp.Body.Close() }() 862 + 863 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 864 + 865 + var buf bytes.Buffer 866 + _, _ = buf.ReadFrom(resp.Body) 867 + img, err := imaging.Decode(&buf) 868 + require.NoError(t, err, "Should decode image") 869 + 870 + bounds := img.Bounds() 871 + assert.Equal(t, 1000, bounds.Dx(), "Width should be 1000 (avatar preset)") 872 + assert.Equal(t, 1000, bounds.Dy(), "Height should be 1000 (avatar preset)") 873 + 874 + t.Logf("Large image correctly resized to %dx%d", bounds.Dx(), bounds.Dy()) 875 + }) 876 + 877 + t.Run("content_preview limits width for large image", func(t *testing.T) { 878 + proxyURL := fmt.Sprintf("%s/img/content_preview/plain/%s/%s", testServer.URL, testDID, testCID) 879 + 880 + resp, err := http.Get(proxyURL) 881 + require.NoError(t, err, "Request should succeed") 882 + defer func() { _ = resp.Body.Close() }() 883 + 884 + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should return 200") 885 + 886 + var buf bytes.Buffer 887 + _, _ = buf.ReadFrom(resp.Body) 888 + img, err := imaging.Decode(&buf) 889 + require.NoError(t, err, "Should decode image") 890 + 891 + bounds := img.Bounds() 892 + // content_preview max width is 800, preserves aspect ratio 893 + assert.Equal(t, 800, bounds.Dx(), "Width should be limited to 800") 894 + assert.Equal(t, 800, bounds.Dy(), "Height should be 800 (1:1 aspect ratio)") 895 + 896 + t.Logf("Large image correctly scaled to %dx%d for content_preview", bounds.Dx(), bounds.Dy()) 897 + }) 898 + } 899 + 900 + // TestImageProxy_ResponseJSON verifies no JSON is returned (should be plain text or image) 901 + func TestImageProxy_ResponseJSON(t *testing.T) { 902 + mockResolver := &mockIdentityResolverForImageProxy{ 903 + pdsURL: "http://localhost:9999", 904 + } 905 + 906 + cacheDir := t.TempDir() 907 + cache, err := imageproxycore.NewDiskCache(cacheDir, 1, 0) 908 + require.NoError(t, err, "Failed to create disk cache") 909 + processor := imageproxycore.NewProcessor() 910 + fetcher := imageproxycore.NewPDSFetcher(1 * time.Second, 10) 911 + config := imageproxycore.DefaultConfig() 912 + 913 + service, err := imageproxycore.NewService(cache, processor, fetcher, config) 914 + require.NoError(t, err, "Failed to create imageproxy service") 915 + handler := imageproxy.NewHandler(service, mockResolver) 916 + 917 + r := chi.NewRouter() 918 + routes.RegisterImageProxyRoutes(r, handler) 919 + testServer := httptest.NewServer(r) 920 + defer testServer.Close() 921 + 922 + t.Run("error responses are plain text not JSON", func(t *testing.T) { 923 + proxyURL := fmt.Sprintf("%s/img/invalid_preset/plain/did:plc:test/cid", testServer.URL) 924 + 925 + resp, err := http.Get(proxyURL) 926 + require.NoError(t, err, "Request should succeed") 927 + defer func() { _ = resp.Body.Close() }() 928 + 929 + contentType := resp.Header.Get("Content-Type") 930 + assert.Contains(t, contentType, "text/plain", "Error responses should be text/plain") 931 + 932 + // Verify body is not valid JSON 933 + var buf bytes.Buffer 934 + _, _ = buf.ReadFrom(resp.Body) 935 + body := buf.Bytes() 936 + 937 + var jsonCheck map[string]interface{} 938 + jsonErr := json.Unmarshal(body, &jsonCheck) 939 + assert.Error(t, jsonErr, "Error response should not be valid JSON") 940 + 941 + t.Logf("Error response correctly returned as plain text: %s", string(body)) 942 + }) 943 + }
+7 -2
tests/integration/user_profile_avatar_e2e_test.go
··· 19 "net" 20 "net/http" 21 "net/http/httptest" 22 "os" 23 "strings" 24 "testing" ··· 327 if finalProfile.Avatar != "" { 328 assert.Contains(t, finalProfile.Avatar, "/xrpc/com.atproto.sync.getBlob", 329 "Avatar URL should be a PDS blob URL") 330 - assert.Contains(t, finalProfile.Avatar, userDID, 331 "Avatar URL should contain user DID") 332 } 333 ··· 576 577 if finalProfile.Banner != "" { 578 assert.Contains(t, finalProfile.Banner, "/xrpc/com.atproto.sync.getBlob") 579 - assert.Contains(t, finalProfile.Banner, userDID) 580 } 581 582 t.Logf("\n TRUE E2E USER PROFILE BANNER UPDATE COMPLETE")
··· 19 "net" 20 "net/http" 21 "net/http/httptest" 22 + "net/url" 23 "os" 24 "strings" 25 "testing" ··· 328 if finalProfile.Avatar != "" { 329 assert.Contains(t, finalProfile.Avatar, "/xrpc/com.atproto.sync.getBlob", 330 "Avatar URL should be a PDS blob URL") 331 + // URL-decode the avatar URL before checking for DID (DIDs are URL-encoded in query params) 332 + decodedAvatarURL, _ := url.QueryUnescape(finalProfile.Avatar) 333 + assert.Contains(t, decodedAvatarURL, userDID, 334 "Avatar URL should contain user DID") 335 } 336 ··· 579 580 if finalProfile.Banner != "" { 581 assert.Contains(t, finalProfile.Banner, "/xrpc/com.atproto.sync.getBlob") 582 + // URL-decode the banner URL before checking for DID (DIDs are URL-encoded in query params) 583 + decodedBannerURL, _ := url.QueryUnescape(finalProfile.Banner) 584 + assert.Contains(t, decodedBannerURL, userDID) 585 } 586 587 t.Logf("\n TRUE E2E USER PROFILE BANNER UPDATE COMPLETE")