# 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/)