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 "Coves/internal/core/communities" 14) 15 16// Service defines the interface for blob operations 17type Service interface { 18 // UploadBlobFromURL fetches an image from a URL and uploads it to the community's PDS 19 UploadBlobFromURL(ctx context.Context, community *communities.Community, imageURL string) (*BlobRef, error) 20 21 // UploadBlob uploads binary data to the community's PDS 22 UploadBlob(ctx context.Context, community *communities.Community, data []byte, mimeType string) (*BlobRef, error) 23} 24 25type blobService struct { 26 pdsURL string 27} 28 29// NewBlobService creates a new blob service 30func NewBlobService(pdsURL string) Service { 31 return &blobService{ 32 pdsURL: pdsURL, 33 } 34} 35 36// UploadBlobFromURL fetches an image from a URL and uploads it to PDS 37// Flow: 38// 1. Fetch image from URL with timeout 39// 2. Validate size (<1MB) 40// 3. Validate MIME type (image/jpeg, image/png, image/webp) 41// 4. Call UploadBlob to upload to PDS 42func (s *blobService) UploadBlobFromURL(ctx context.Context, community *communities.Community, imageURL string) (*BlobRef, error) { 43 // Input validation 44 if imageURL == "" { 45 return nil, fmt.Errorf("image URL cannot be empty") 46 } 47 48 // Create HTTP client with timeout 49 client := &http.Client{ 50 Timeout: 10 * time.Second, 51 } 52 53 // Fetch image from URL 54 req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil) 55 if err != nil { 56 return nil, fmt.Errorf("failed to create request for image URL: %w", err) 57 } 58 59 resp, err := client.Do(req) 60 if err != nil { 61 return nil, fmt.Errorf("failed to fetch image from URL: %w", err) 62 } 63 defer func() { 64 if closeErr := resp.Body.Close(); closeErr != nil { 65 log.Printf("Warning: failed to close image response body: %v", closeErr) 66 } 67 }() 68 69 // Check HTTP status 70 if resp.StatusCode != http.StatusOK { 71 return nil, fmt.Errorf("failed to fetch image: HTTP %d", resp.StatusCode) 72 } 73 74 // Get MIME type from Content-Type header 75 mimeType := resp.Header.Get("Content-Type") 76 if mimeType == "" { 77 return nil, fmt.Errorf("image URL response missing Content-Type header") 78 } 79 80 // Normalize MIME type (e.g., image/jpg → image/jpeg) 81 mimeType = normalizeMimeType(mimeType) 82 83 // Validate MIME type before reading data 84 if !isValidMimeType(mimeType) { 85 return nil, fmt.Errorf("unsupported MIME type: %s (allowed: image/jpeg, image/png, image/webp)", mimeType) 86 } 87 88 // Read image data 89 data, err := io.ReadAll(resp.Body) 90 if err != nil { 91 return nil, fmt.Errorf("failed to read image data: %w", err) 92 } 93 94 // Validate size (1MB = 1048576 bytes) 95 const maxSize = 1048576 96 if len(data) > maxSize { 97 return nil, fmt.Errorf("image size %d bytes exceeds maximum of %d bytes (1MB)", len(data), maxSize) 98 } 99 100 // Upload to PDS 101 return s.UploadBlob(ctx, community, data, mimeType) 102} 103 104// UploadBlob uploads binary data to the community's PDS 105// Flow: 106// 1. Validate inputs 107// 2. POST to {PDSURL}/xrpc/com.atproto.repo.uploadBlob 108// 3. Use community's PDSAccessToken for auth 109// 4. Set Content-Type header to mimeType 110// 5. Parse response and extract blob reference 111func (s *blobService) UploadBlob(ctx context.Context, community *communities.Community, data []byte, mimeType string) (*BlobRef, error) { 112 // Input validation 113 if community == nil { 114 return nil, fmt.Errorf("community cannot be nil") 115 } 116 if len(data) == 0 { 117 return nil, fmt.Errorf("data cannot be empty") 118 } 119 if mimeType == "" { 120 return nil, fmt.Errorf("mimeType cannot be empty") 121 } 122 123 // Validate MIME type 124 if !isValidMimeType(mimeType) { 125 return nil, fmt.Errorf("unsupported MIME type: %s (allowed: image/jpeg, image/png, image/webp)", mimeType) 126 } 127 128 // Validate size (1MB = 1048576 bytes) 129 const maxSize = 1048576 130 if len(data) > maxSize { 131 return nil, fmt.Errorf("data size %d bytes exceeds maximum of %d bytes (1MB)", len(data), maxSize) 132 } 133 134 // Use community's PDS URL (for federated communities) 135 pdsURL := community.PDSURL 136 if pdsURL == "" { 137 // Fallback to service default if community doesn't have a PDS URL 138 pdsURL = s.pdsURL 139 } 140 141 // Build PDS endpoint URL 142 endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", pdsURL) 143 144 // Create HTTP request with blob data 145 req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(data)) 146 if err != nil { 147 return nil, fmt.Errorf("failed to create PDS request: %w", err) 148 } 149 150 // Set headers (auth + content type) 151 req.Header.Set("Content-Type", mimeType) 152 req.Header.Set("Authorization", "Bearer "+community.PDSAccessToken) 153 154 // Create HTTP client with timeout 155 client := &http.Client{ 156 Timeout: 30 * time.Second, 157 } 158 159 // Execute request 160 resp, err := client.Do(req) 161 if err != nil { 162 return nil, fmt.Errorf("PDS request failed: %w", err) 163 } 164 defer func() { 165 if closeErr := resp.Body.Close(); closeErr != nil { 166 log.Printf("Warning: failed to close PDS response body: %v", closeErr) 167 } 168 }() 169 170 // Read response body 171 body, err := io.ReadAll(resp.Body) 172 if err != nil { 173 return nil, fmt.Errorf("failed to read PDS response: %w", err) 174 } 175 176 // Check for errors 177 if resp.StatusCode != http.StatusOK { 178 // Sanitize error body for logging (prevent sensitive data leakage) 179 bodyPreview := string(body) 180 if len(bodyPreview) > 200 { 181 bodyPreview = bodyPreview[:200] + "... (truncated)" 182 } 183 log.Printf("[BLOB-UPLOAD-ERROR] PDS Status: %d, Body: %s", resp.StatusCode, bodyPreview) 184 185 // Return truncated error (defense in depth - handler will mask this further) 186 return nil, fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, bodyPreview) 187 } 188 189 // Parse response 190 // The response from com.atproto.repo.uploadBlob is a BlobRef object 191 var result struct { 192 Blob BlobRef `json:"blob"` 193 } 194 if err := json.Unmarshal(body, &result); err != nil { 195 return nil, fmt.Errorf("failed to parse PDS response: %w", err) 196 } 197 198 return &result.Blob, nil 199} 200 201// normalizeMimeType converts non-standard MIME types to their standard equivalents 202// Common case: Many CDNs return image/jpg instead of the standard image/jpeg 203func normalizeMimeType(mimeType string) string { 204 switch mimeType { 205 case "image/jpg": 206 return "image/jpeg" 207 default: 208 return mimeType 209 } 210} 211 212// isValidMimeType checks if the MIME type is allowed for blob uploads 213func isValidMimeType(mimeType string) bool { 214 switch mimeType { 215 case "image/jpeg", "image/png", "image/webp": 216 return true 217 default: 218 return false 219 } 220}