Files
thrillwiki_django_no_react/docs/architecture/adr-006-media-handling-cloudflare.md
pacnpal edcd8f2076 Add secret management guide, client-side performance monitoring, and search accessibility enhancements
- Introduced a comprehensive Secret Management Guide detailing best practices, secret classification, development setup, production management, rotation procedures, and emergency protocols.
- Implemented a client-side performance monitoring script to track various metrics including page load performance, paint metrics, layout shifts, and memory usage.
- Enhanced search accessibility with keyboard navigation support for search results, ensuring compliance with WCAG standards and improving user experience.
2025-12-23 16:41:42 -05:00

8.0 KiB

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

# 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

# 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

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

# 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

# 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

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