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