A community based topic aggregation platform built on atproto
at main 250 lines 7.5 kB view raw
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}