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}