# ADR-006: Media Handling with Cloudflare Images

## Status

Accepted

## Context

ThrillWiki handles user-uploaded images for:
- Park photos
- Ride photos
- User avatars

We needed a media handling solution that would:
- Handle image uploads efficiently
- Optimize images for different devices
- Provide CDN delivery globally
- Support image transformations (resizing, cropping)
- Minimize storage costs

## Decision

We chose **Cloudflare Images** as our image hosting and transformation service.

### Architecture

```
┌─────────────────────────────────────────────────────────┐
│                    User Upload                          │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│                 Django Backend                          │
│  ┌─────────────────────────────────────────────────┐   │
│  │              MediaService                        │   │
│  │  - Validate upload                              │   │
│  │  - Generate upload URL                          │   │
│  │  - Store Cloudflare ID                          │   │
│  └─────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────┐
│               Cloudflare Images                         │
│  ┌─────────────────────────────────────────────────┐   │
│  │  - Image storage                                │   │
│  │  - On-the-fly transformations                   │   │
│  │  - Global CDN delivery                          │   │
│  │  - Multiple variants                            │   │
│  └─────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘
```

### Image Variants

| Variant | Dimensions | Use Case |
|---------|------------|----------|
| `thumbnail` | 150x150 | List views, avatars |
| `card` | 400x300 | Card components |
| `hero` | 1200x600 | Park/ride headers |
| `public` | Original (max 2000px) | Full size view |

### Configuration

```python
# Environment variables
CLOUDFLARE_IMAGES_ACCOUNT_ID = config('CLOUDFLARE_IMAGES_ACCOUNT_ID')
CLOUDFLARE_IMAGES_API_TOKEN = config('CLOUDFLARE_IMAGES_API_TOKEN')
CLOUDFLARE_IMAGES_ACCOUNT_HASH = config('CLOUDFLARE_IMAGES_ACCOUNT_HASH')
```

### Upload Flow

```python
# backend/apps/core/services/media_service.py
class CloudflareImagesService:
    """Service for handling image uploads to Cloudflare Images."""

    def get_direct_upload_url(self):
        """Get a one-time upload URL for direct uploads."""
        response = self._api_request('POST', 'direct_upload')
        return {
            'upload_url': response['uploadURL'],
            'image_id': response['id'],
        }

    def get_image_url(self, image_id, variant='public'):
        """Get the delivery URL for an image."""
        return f"https://imagedelivery.net/{self.account_hash}/{image_id}/{variant}"

    def delete_image(self, image_id):
        """Delete an image from Cloudflare."""
        return self._api_request('DELETE', f'images/{image_id}')
```

## Consequences

### Benefits

1. **Global CDN**: Fast image delivery worldwide
2. **Automatic Optimization**: WebP/AVIF conversion as appropriate
3. **On-Demand Transformations**: Resize/crop without pre-generating
4. **Cost Effective**: Pay per image stored, not per transformation
5. **Reduced Server Load**: Direct uploads bypass our servers
6. **Security**: Signed URLs for private content if needed

### Trade-offs

1. **Vendor Lock-in**: Cloudflare-specific API
2. **External Dependency**: Service availability dependency
3. **Cost Scaling**: Costs increase with storage volume
4. **Migration Complexity**: Moving away requires re-uploading

### URL Structure

```
https://imagedelivery.net/{account_hash}/{image_id}/{variant}

Examples:
https://imagedelivery.net/abc123/img-xyz/thumbnail
https://imagedelivery.net/abc123/img-xyz/hero
https://imagedelivery.net/abc123/img-xyz/public
```

## Alternatives Considered

### Django + S3 + CloudFront

**Rejected because:**
- Requires pre-generating all sizes
- More complex infrastructure
- Higher storage costs for variants
- Manual CDN configuration

### Self-Hosted with ImageMagick

**Rejected because:**
- Server CPU overhead for transformations
- No built-in CDN
- Storage management complexity
- Scaling challenges

### Imgix

**Rejected because:**
- Higher costs for our volume
- Already using Cloudflare for other services
- Similar feature set to Cloudflare Images

## Implementation Details

### Photo Model

```python
class Photo(models.Model):
    """Base photo model using Cloudflare Images."""

    cloudflare_id = models.CharField(max_length=100, unique=True)
    original_filename = models.CharField(max_length=255)
    uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
    uploaded_at = models.DateTimeField(auto_now_add=True)
    is_approved = models.BooleanField(default=False)
    caption = models.TextField(blank=True)

    @property
    def thumbnail_url(self):
        return get_image_url(self.cloudflare_id, 'thumbnail')

    @property
    def public_url(self):
        return get_image_url(self.cloudflare_id, 'public')
```

### Direct Upload API

```python
# backend/apps/api/v1/views/upload.py
class DirectUploadView(APIView):
    """Get a direct upload URL for Cloudflare Images."""

    permission_classes = [IsAuthenticated]

    def post(self, request):
        service = CloudflareImagesService()
        upload_data = service.get_direct_upload_url()
        return Response({
            'upload_url': upload_data['upload_url'],
            'image_id': upload_data['image_id'],
        })
```

### Cleanup Task

```python
# Celery task for cleaning up orphaned images
@shared_task
def cleanup_orphaned_images():
    """Delete images not referenced by any model."""
    cutoff = timezone.now() - timedelta(hours=24)
    orphaned = CloudflareImage.objects.filter(
        created_at__lt=cutoff,
        park_photos__isnull=True,
        ride_photos__isnull=True,
        user_avatars__isnull=True,
    )
    for image in orphaned:
        service.delete_image(image.cloudflare_id)
        image.delete()
```

### Fallback Strategy

```python
def get_image_url(image_id, variant='public', fallback='/static/images/placeholder.jpg'):
    """Get image URL with fallback for missing images."""
    if not image_id:
        return fallback
    return f"https://imagedelivery.net/{ACCOUNT_HASH}/{image_id}/{variant}"
```

## References

- [Cloudflare Images Documentation](https://developers.cloudflare.com/images/)
- [Direct Creator Upload](https://developers.cloudflare.com/images/cloudflare-images/upload-images/direct-creator-upload/)
- [Image Variants](https://developers.cloudflare.com/images/cloudflare-images/transform/flexible-variants/)
