A community based topic aggregation platform built on atproto
1package blobs
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "time"
12)
13
14// BlobOwner represents any entity that can own blobs on a PDS.
15// This interface breaks the import cycle between blobs and communities packages.
16// communities.Community implements this interface.
17type BlobOwner interface {
18 // GetPDSURL returns the PDS URL for this entity
19 GetPDSURL() string
20 // GetPDSAccessToken returns the access token for authenticating with the PDS
21 GetPDSAccessToken() string
22}
23
24// Service defines the interface for blob operations
25type Service interface {
26 // UploadBlobFromURL fetches an image from a URL and uploads it to the owner's PDS
27 UploadBlobFromURL(ctx context.Context, owner BlobOwner, imageURL string) (*BlobRef, error)
28
29 // UploadBlob uploads binary data to the owner's PDS
30 UploadBlob(ctx context.Context, owner BlobOwner, data []byte, mimeType string) (*BlobRef, error)
31}
32
33type blobService struct {
34 pdsURL string
35}
36
37// NewBlobService creates a new blob service
38func NewBlobService(pdsURL string) Service {
39 return &blobService{
40 pdsURL: pdsURL,
41 }
42}
43
44// UploadBlobFromURL fetches an image from a URL and uploads it to PDS
45// Flow:
46// 1. Fetch image from URL with timeout
47// 2. Validate size (max 6MB)
48// 3. Validate MIME type (image/jpeg, image/png, image/webp)
49// 4. Call UploadBlob to upload to PDS
50func (s *blobService) UploadBlobFromURL(ctx context.Context, owner BlobOwner, imageURL string) (*BlobRef, error) {
51 // Input validation
52 if imageURL == "" {
53 return nil, fmt.Errorf("image URL cannot be empty")
54 }
55
56 // Create HTTP client with timeout (30s to handle slow CDNs and large images)
57 client := &http.Client{
58 Timeout: 30 * time.Second,
59 }
60
61 // Fetch image from URL
62 req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
63 if err != nil {
64 return nil, fmt.Errorf("failed to create request for image URL: %w", err)
65 }
66
67 // Set User-Agent to avoid being blocked by CDNs that filter bot traffic
68 req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; CovesBot/1.0; +https://coves.social)")
69
70 resp, err := client.Do(req)
71 if err != nil {
72 return nil, fmt.Errorf("failed to fetch image from URL: %w", err)
73 }
74 defer func() {
75 if closeErr := resp.Body.Close(); closeErr != nil {
76 log.Printf("Warning: failed to close image response body: %v", closeErr)
77 }
78 }()
79
80 // Check HTTP status
81 if resp.StatusCode != http.StatusOK {
82 return nil, fmt.Errorf("failed to fetch image: HTTP %d", resp.StatusCode)
83 }
84
85 // Get MIME type from Content-Type header
86 mimeType := resp.Header.Get("Content-Type")
87 if mimeType == "" {
88 return nil, fmt.Errorf("image URL response missing Content-Type header")
89 }
90
91 // Normalize MIME type (e.g., image/jpg → image/jpeg)
92 mimeType = normalizeMimeType(mimeType)
93
94 // Validate MIME type before reading data
95 if !isValidMimeType(mimeType) {
96 return nil, fmt.Errorf("unsupported MIME type: %s (allowed: image/jpeg, image/png, image/webp)", mimeType)
97 }
98
99 // Read image data
100 data, err := io.ReadAll(resp.Body)
101 if err != nil {
102 return nil, fmt.Errorf("failed to read image data: %w", err)
103 }
104
105 // Validate size (6MB = 6291456 bytes)
106 const maxSize = 6291456
107 if len(data) > maxSize {
108 return nil, fmt.Errorf("image size %d bytes exceeds maximum of %d bytes (6MB)", len(data), maxSize)
109 }
110
111 // Upload to PDS
112 return s.UploadBlob(ctx, owner, data, mimeType)
113}
114
115// UploadBlob uploads binary data to the owner's PDS
116// Flow:
117// 1. Validate inputs
118// 2. POST to {PDSURL}/xrpc/com.atproto.repo.uploadBlob
119// 3. Use owner's PDSAccessToken for auth
120// 4. Set Content-Type header to mimeType
121// 5. Parse response and extract blob reference
122func (s *blobService) UploadBlob(ctx context.Context, owner BlobOwner, data []byte, mimeType string) (*BlobRef, error) {
123 // Input validation
124 if owner == nil {
125 return nil, fmt.Errorf("owner cannot be nil")
126 }
127 if len(data) == 0 {
128 return nil, fmt.Errorf("data cannot be empty")
129 }
130 if mimeType == "" {
131 return nil, fmt.Errorf("mimeType cannot be empty")
132 }
133
134 // Validate MIME type
135 if !isValidMimeType(mimeType) {
136 return nil, fmt.Errorf("unsupported MIME type: %s (allowed: image/jpeg, image/png, image/webp)", mimeType)
137 }
138
139 // Validate size (6MB = 6291456 bytes)
140 const maxSize = 6291456
141 if len(data) > maxSize {
142 return nil, fmt.Errorf("data size %d bytes exceeds maximum of %d bytes (6MB)", len(data), maxSize)
143 }
144
145 // Use owner's PDS URL (for federated communities)
146 pdsURL := owner.GetPDSURL()
147 if pdsURL == "" {
148 return nil, fmt.Errorf("owner has no PDS URL configured")
149 }
150
151 // Validate access token before making request
152 accessToken := owner.GetPDSAccessToken()
153 if accessToken == "" {
154 return nil, fmt.Errorf("owner has no PDS access token")
155 }
156
157 // Build PDS endpoint URL
158 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", pdsURL)
159
160 // Create HTTP request with blob data
161 req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(data))
162 if err != nil {
163 return nil, fmt.Errorf("failed to create PDS request: %w", err)
164 }
165
166 // Set headers (auth + content type)
167 req.Header.Set("Content-Type", mimeType)
168 req.Header.Set("Authorization", "Bearer "+accessToken)
169
170 // Create HTTP client with timeout
171 client := &http.Client{
172 Timeout: 30 * time.Second,
173 }
174
175 // Execute request
176 resp, err := client.Do(req)
177 if err != nil {
178 return nil, fmt.Errorf("PDS request failed: %w", err)
179 }
180 defer func() {
181 if closeErr := resp.Body.Close(); closeErr != nil {
182 log.Printf("Warning: failed to close PDS response body: %v", closeErr)
183 }
184 }()
185
186 // Read response body
187 body, err := io.ReadAll(resp.Body)
188 if err != nil {
189 return nil, fmt.Errorf("failed to read PDS response: %w", err)
190 }
191
192 // Check for errors
193 if resp.StatusCode != http.StatusOK {
194 // Sanitize error body for logging (prevent sensitive data leakage)
195 bodyPreview := string(body)
196 if len(bodyPreview) > 200 {
197 bodyPreview = bodyPreview[:200] + "... (truncated)"
198 }
199 log.Printf("[BLOB-UPLOAD-ERROR] PDS Status: %d, Body: %s", resp.StatusCode, bodyPreview)
200
201 // Return truncated error (defense in depth - handler will mask this further)
202 return nil, fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, bodyPreview)
203 }
204
205 // Parse response
206 // The response from com.atproto.repo.uploadBlob is a BlobRef object
207 var result struct {
208 Blob BlobRef `json:"blob"`
209 }
210 if err := json.Unmarshal(body, &result); err != nil {
211 return nil, fmt.Errorf("failed to parse PDS response: %w", err)
212 }
213
214 // Validate required fields in PDS response
215 if result.Blob.Type == "" {
216 return nil, fmt.Errorf("PDS response missing required field: $type")
217 }
218 if result.Blob.Ref == nil || result.Blob.Ref["$link"] == "" {
219 return nil, fmt.Errorf("PDS response missing required field: ref.$link (CID)")
220 }
221 if result.Blob.MimeType == "" {
222 return nil, fmt.Errorf("PDS response missing required field: mimeType")
223 }
224 if result.Blob.Size == 0 {
225 return nil, fmt.Errorf("PDS response missing required field: size")
226 }
227
228 return &result.Blob, nil
229}
230
231// normalizeMimeType converts non-standard MIME types to their standard equivalents
232// Common case: Many CDNs return image/jpg instead of the standard image/jpeg
233func normalizeMimeType(mimeType string) string {
234 switch mimeType {
235 case "image/jpg":
236 return "image/jpeg"
237 default:
238 return mimeType
239 }
240}
241
242// isValidMimeType checks if the MIME type is allowed for blob uploads
243func isValidMimeType(mimeType string) bool {
244 switch mimeType {
245 case "image/jpeg", "image/png", "image/webp":
246 return true
247 default:
248 return false
249 }
250}