···
+
"Coves/internal/core/communities"
+
// Service defines the interface for blob operations
+
type Service interface {
+
// UploadBlobFromURL fetches an image from a URL and uploads it to the community's PDS
+
UploadBlobFromURL(ctx context.Context, community *communities.Community, imageURL string) (*BlobRef, error)
+
// UploadBlob uploads binary data to the community's PDS
+
UploadBlob(ctx context.Context, community *communities.Community, data []byte, mimeType string) (*BlobRef, error)
+
type blobService struct {
+
// NewBlobService creates a new blob service
+
func NewBlobService(pdsURL string) Service {
+
// UploadBlobFromURL fetches an image from a URL and uploads it to PDS
+
// 1. Fetch image from URL with timeout
+
// 2. Validate size (<1MB)
+
// 3. Validate MIME type (image/jpeg, image/png, image/webp)
+
// 4. Call UploadBlob to upload to PDS
+
func (s *blobService) UploadBlobFromURL(ctx context.Context, community *communities.Community, imageURL string) (*BlobRef, error) {
+
return nil, fmt.Errorf("image URL cannot be empty")
+
// Create HTTP client with timeout
+
client := &http.Client{
+
Timeout: 10 * time.Second,
+
// Fetch image from URL
+
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
+
return nil, fmt.Errorf("failed to create request for image URL: %w", err)
+
resp, err := client.Do(req)
+
return nil, fmt.Errorf("failed to fetch image from URL: %w", err)
+
if closeErr := resp.Body.Close(); closeErr != nil {
+
log.Printf("Warning: failed to close image response body: %v", closeErr)
+
if resp.StatusCode != http.StatusOK {
+
return nil, fmt.Errorf("failed to fetch image: HTTP %d", resp.StatusCode)
+
// Get MIME type from Content-Type header
+
mimeType := resp.Header.Get("Content-Type")
+
return nil, fmt.Errorf("image URL response missing Content-Type header")
+
// Normalize MIME type (e.g., image/jpg → image/jpeg)
+
mimeType = normalizeMimeType(mimeType)
+
// Validate MIME type before reading data
+
if !isValidMimeType(mimeType) {
+
return nil, fmt.Errorf("unsupported MIME type: %s (allowed: image/jpeg, image/png, image/webp)", mimeType)
+
data, err := io.ReadAll(resp.Body)
+
return nil, fmt.Errorf("failed to read image data: %w", err)
+
// Validate size (1MB = 1048576 bytes)
+
const maxSize = 1048576
+
if len(data) > maxSize {
+
return nil, fmt.Errorf("image size %d bytes exceeds maximum of %d bytes (1MB)", len(data), maxSize)
+
return s.UploadBlob(ctx, community, data, mimeType)
+
// UploadBlob uploads binary data to the community's PDS
+
// 2. POST to {PDSURL}/xrpc/com.atproto.repo.uploadBlob
+
// 3. Use community's PDSAccessToken for auth
+
// 4. Set Content-Type header to mimeType
+
// 5. Parse response and extract blob reference
+
func (s *blobService) UploadBlob(ctx context.Context, community *communities.Community, data []byte, mimeType string) (*BlobRef, error) {
+
return nil, fmt.Errorf("community cannot be nil")
+
return nil, fmt.Errorf("data cannot be empty")
+
return nil, fmt.Errorf("mimeType cannot be empty")
+
if !isValidMimeType(mimeType) {
+
return nil, fmt.Errorf("unsupported MIME type: %s (allowed: image/jpeg, image/png, image/webp)", mimeType)
+
// Validate size (1MB = 1048576 bytes)
+
const maxSize = 1048576
+
if len(data) > maxSize {
+
return nil, fmt.Errorf("data size %d bytes exceeds maximum of %d bytes (1MB)", len(data), maxSize)
+
// Use community's PDS URL (for federated communities)
+
pdsURL := community.PDSURL
+
// Fallback to service default if community doesn't have a PDS URL
+
// Build PDS endpoint URL
+
endpoint := fmt.Sprintf("%s/xrpc/com.atproto.repo.uploadBlob", pdsURL)
+
// Create HTTP request with blob data
+
req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewBuffer(data))
+
return nil, fmt.Errorf("failed to create PDS request: %w", err)
+
// Set headers (auth + content type)
+
req.Header.Set("Content-Type", mimeType)
+
req.Header.Set("Authorization", "Bearer "+community.PDSAccessToken)
+
// Create HTTP client with timeout
+
client := &http.Client{
+
Timeout: 30 * time.Second,
+
resp, err := client.Do(req)
+
return nil, fmt.Errorf("PDS request failed: %w", err)
+
if closeErr := resp.Body.Close(); closeErr != nil {
+
log.Printf("Warning: failed to close PDS response body: %v", closeErr)
+
body, err := io.ReadAll(resp.Body)
+
return nil, fmt.Errorf("failed to read PDS response: %w", err)
+
if resp.StatusCode != http.StatusOK {
+
// Sanitize error body for logging (prevent sensitive data leakage)
+
bodyPreview := string(body)
+
if len(bodyPreview) > 200 {
+
bodyPreview = bodyPreview[:200] + "... (truncated)"
+
log.Printf("[BLOB-UPLOAD-ERROR] PDS Status: %d, Body: %s", resp.StatusCode, bodyPreview)
+
// Return truncated error (defense in depth - handler will mask this further)
+
return nil, fmt.Errorf("PDS returned error %d: %s", resp.StatusCode, bodyPreview)
+
// The response from com.atproto.repo.uploadBlob is a BlobRef object
+
Blob BlobRef `json:"blob"`
+
if err := json.Unmarshal(body, &result); err != nil {
+
return nil, fmt.Errorf("failed to parse PDS response: %w", err)
+
return &result.Blob, nil
+
// normalizeMimeType converts non-standard MIME types to their standard equivalents
+
// Common case: Many CDNs return image/jpg instead of the standard image/jpeg
+
func normalizeMimeType(mimeType string) string {
+
// isValidMimeType checks if the MIME type is allowed for blob uploads
+
func isValidMimeType(mimeType string) bool {
+
case "image/jpeg", "image/png", "image/webp":