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}