A community based topic aggregation platform built on atproto
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.
12package imageproxy
13
14import (
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.
23var 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.
27func CacheWriteErrorCount() int64 {
28 return cacheWriteErrors.Load()
29}
30
31// Service defines the interface for the image proxy service.
32type 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.
41type 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.
50func 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
77func (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}