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.

Changed files
+228
internal
core
+219
internal/core/blobs/service.go
···
···
+
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
+
}
+
}
+9
internal/core/blobs/types.go
···
···
+
package blobs
+
+
// BlobRef represents a blob reference for atproto records
+
type BlobRef struct {
+
Type string `json:"$type"`
+
Ref map[string]string `json:"ref"`
+
MimeType string `json:"mimeType"`
+
Size int `json:"size"`
+
}