package blobs import ( "Coves/internal/core/communities" "bytes" "context" "encoding/json" "fmt" "io" "log" "net/http" "time" ) // 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 { pdsURL string } // NewBlobService creates a new blob service func NewBlobService(pdsURL string) Service { return &blobService{ pdsURL: pdsURL, } } // UploadBlobFromURL fetches an image from a URL and uploads it to PDS // Flow: // 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) { // Input validation if imageURL == "" { 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) if err != nil { return nil, fmt.Errorf("failed to create request for image URL: %w", err) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch image from URL: %w", err) } defer func() { if closeErr := resp.Body.Close(); closeErr != nil { log.Printf("Warning: failed to close image response body: %v", closeErr) } }() // Check HTTP status 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") if mimeType == "" { 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) } // Read image data data, err := io.ReadAll(resp.Body) if err != nil { 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) } // Upload to PDS return s.UploadBlob(ctx, community, data, mimeType) } // UploadBlob uploads binary data to the community's PDS // Flow: // 1. Validate inputs // 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) { // Input validation if community == nil { return nil, fmt.Errorf("community cannot be nil") } if len(data) == 0 { return nil, fmt.Errorf("data cannot be empty") } if mimeType == "" { return nil, fmt.Errorf("mimeType cannot be empty") } // Validate MIME type 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 if pdsURL == "" { // Fallback to service default if community doesn't have a PDS URL pdsURL = s.pdsURL } // 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)) if err != nil { 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, } // Execute request resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("PDS request failed: %w", err) } defer func() { if closeErr := resp.Body.Close(); closeErr != nil { log.Printf("Warning: failed to close PDS response body: %v", closeErr) } }() // Read response body body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read PDS response: %w", err) } // Check for errors 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) } // Parse response // The response from com.atproto.repo.uploadBlob is a BlobRef object var result struct { 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 { switch mimeType { case "image/jpg": return "image/jpeg" default: return mimeType } } // isValidMimeType checks if the MIME type is allowed for blob uploads func isValidMimeType(mimeType string) bool { switch mimeType { case "image/jpeg", "image/png", "image/webp": return true default: return false } }