mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-24 18:11:08 -05:00
- 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.
227 lines
8.0 KiB
Markdown
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/)
|