···
4
+
"Coves/internal/core/communities"
15
+
// Service defines the interface for blob operations
16
+
type 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)
20
+
// UploadBlob uploads binary data to the community's PDS
21
+
UploadBlob(ctx context.Context, community *communities.Community, data []byte, mimeType string) (*BlobRef, error)
24
+
type blobService struct {
28
+
// NewBlobService creates a new blob service
29
+
func NewBlobService(pdsURL string) Service {
30
+
return &blobService{
35
+
// UploadBlobFromURL fetches an image from a URL and uploads it to PDS
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
41
+
func (s *blobService) UploadBlobFromURL(ctx context.Context, community *communities.Community, imageURL string) (*BlobRef, error) {
44
+
return nil, fmt.Errorf("image URL cannot be empty")
47
+
// Create HTTP client with timeout
48
+
client := &http.Client{
49
+
Timeout: 10 * time.Second,
52
+
// Fetch image from URL
53
+
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
55
+
return nil, fmt.Errorf("failed to create request for image URL: %w", err)
58
+
resp, err := client.Do(req)
60
+
return nil, fmt.Errorf("failed to fetch image from URL: %w", err)
63
+
if closeErr := resp.Body.Close(); closeErr != nil {
64
+
log.Printf("Warning: failed to close image response body: %v", closeErr)
68
+
// Check HTTP status
69
+
if resp.StatusCode != http.StatusOK {
70
+
return nil, fmt.Errorf("failed to fetch image: HTTP %d", resp.StatusCode)
73
+
// Get MIME type from Content-Type header
74
+
mimeType := resp.Header.Get("Content-Type")
76
+
return nil, fmt.Errorf("image URL response missing Content-Type header")
79
+
// Normalize MIME type (e.g., image/jpg → image/jpeg)
80
+
mimeType = normalizeMimeType(mimeType)
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)
88
+
data, err := io.ReadAll(resp.Body)
90
+
return nil, fmt.Errorf("failed to read image data: %w", err)
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)
100
+
return s.UploadBlob(ctx, community, data, mimeType)
103
+
// UploadBlob uploads binary data to the community's PDS
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
110
+
func (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")
115
+
if len(data) == 0 {
116
+
return nil, fmt.Errorf("data cannot be empty")
118
+
if mimeType == "" {
119
+
return nil, fmt.Errorf("mimeType cannot be empty")
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)
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)
133
+
// Use community's PDS URL (for federated communities)
134
+
pdsURL := community.PDSURL
136
+
// Fallback to service default if community doesn't have a PDS URL
140
+
// Build PDS endpoint URL
141
+
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", pdsURL)
143
+
// Create HTTP request with blob data
144
+
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(data))
146
+
return nil, fmt.Errorf("failed to create PDS request: %w", err)
149
+
// Set headers (auth + content type)
150
+
req.Header.Set("Content-Type", mimeType)
151
+
req.Header.Set("Authorization", "Bearer "+community.PDSAccessToken)
153
+
// Create HTTP client with timeout
154
+
client := &http.Client{
155
+
Timeout: 30 * time.Second,
159
+
resp, err := client.Do(req)
161
+
return nil, fmt.Errorf("PDS request failed: %w", err)
164
+
if closeErr := resp.Body.Close(); closeErr != nil {
165
+
log.Printf("Warning: failed to close PDS response body: %v", closeErr)
169
+
// Read response body
170
+
body, err := io.ReadAll(resp.Body)
172
+
return nil, fmt.Errorf("failed to read PDS response: %w", err)
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)"
182
+
log.Printf("[BLOB-UPLOAD-ERROR] PDS Status: %d, Body: %s", resp.StatusCode, bodyPreview)
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)
189
+
// The response from com.atproto.repo.uploadBlob is a BlobRef object
190
+
var result struct {
191
+
Blob BlobRef `json:"blob"`
193
+
if err := json.Unmarshal(body, &result); err != nil {
194
+
return nil, fmt.Errorf("failed to parse PDS response: %w", err)
197
+
return &result.Blob, nil
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
202
+
func normalizeMimeType(mimeType string) string {
205
+
return "image/jpeg"
211
+
// isValidMimeType checks if the MIME type is allowed for blob uploads
212
+
func isValidMimeType(mimeType string) bool {
214
+
case "image/jpeg", "image/png", "image/webp":