A community based topic aggregation platform built on atproto

feat: add blob upload service for embed thumbnail management

Implement blob upload service to fetch images from URLs and upload them to
PDS as atproto blobs, enabling proper thumbnail storage for external embeds.

**Service Features:**
- UploadBlobFromURL: Fetch image from URL → validate → upload to PDS
- UploadBlob: Upload raw binary data to PDS with authentication
- Size limit: 1MB per image (atproto recommendation)
- Supported MIME types: image/jpeg, image/png, image/webp
- MIME type normalization (image/jpg → image/jpeg)
- Timeout handling (10s for fetch, 30s for upload)

**Security & Validation:**
- Input validation (empty checks, nil guards)
- Size validation before network calls
- MIME type validation before reading data
- HTTP status code checking with sanitized error logs
- Proper error wrapping for debugging

**Federated Support:**
- Uses community's PDS URL when available
- Fallback to service default PDS
- Community authentication via PDSAccessToken

**Flow:**
1. Client posts external embed with URI (no thumb)
2. Unfurl service fetches metadata from oEmbed/OpenGraph
3. Blob service downloads thumbnail from metadata.thumbnailURL
4. Upload to community's PDS via com.atproto.repo.uploadBlob
5. Return BlobRef with CID for inclusion in post record

**BlobRef Type:**
```go
type BlobRef struct {
Type string `json:"$type"` // "blob"
Ref map[string]string `json:"ref"` // {"$link": "bafyrei..."}
MimeType string `json:"mimeType"` // "image/jpeg"
Size int `json:"size"` // bytes
}
```

This enables automatic thumbnail upload when users post links to
Streamable, YouTube, Reddit, Kagi Kite, or any URL with OpenGraph metadata.

+228
+219
internal/core/blobs/service.go
··· 1 + package blobs 2 + 3 + import ( 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 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) 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 + 24 + type blobService struct { 25 + pdsURL string 26 + } 27 + 28 + // NewBlobService creates a new blob service 29 + func 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 41 + func (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 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") 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 202 + func 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 212 + func 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 + }
+9
internal/core/blobs/types.go
··· 1 + package blobs 2 + 3 + // BlobRef represents a blob reference for atproto records 4 + type BlobRef struct { 5 + Type string `json:"$type"` 6 + Ref map[string]string `json:"ref"` 7 + MimeType string `json:"mimeType"` 8 + Size int `json:"size"` 9 + }