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

227 lines
8.0 KiB
Markdown

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