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.
This commit is contained in:
pacnpal
2025-12-23 16:41:42 -05:00
parent ae31e889d7
commit edcd8f2076
155 changed files with 22046 additions and 4645 deletions

View File

@@ -0,0 +1,57 @@
# Architecture Decision Records (ADRs)
This directory contains Architecture Decision Records (ADRs) documenting the key architectural decisions made in the ThrillWiki project.
## What is an ADR?
An Architecture Decision Record is a document that captures an important architectural decision made along with its context and consequences.
## Related Documentation
- [Future Work](../FUTURE_WORK.md) - Deferred features and implementation plans
## ADR Index
| ADR | Title | Status | Date |
|-----|-------|--------|------|
| [ADR-001](./adr-001-django-htmx-architecture.md) | Django + HTMX Architecture | Accepted | 2025-01 |
| [ADR-002](./adr-002-hybrid-api-design.md) | Hybrid API Design Pattern | Accepted | 2025-01 |
| [ADR-003](./adr-003-state-machine-pattern.md) | State Machine Pattern | Accepted | 2025-01 |
| [ADR-004](./adr-004-caching-strategy.md) | Caching Strategy | Accepted | 2025-01 |
| [ADR-005](./adr-005-authentication-approach.md) | Authentication Approach | Accepted | 2025-01 |
| [ADR-006](./adr-006-media-handling-cloudflare.md) | Media Handling with Cloudflare | Accepted | 2025-01 |
## ADR Template
New ADRs should follow this template:
```markdown
# ADR-XXX: Title
## Status
[Proposed | Accepted | Deprecated | Superseded]
## Context
What is the issue that we're seeing that is motivating this decision or change?
## Decision
What is the change that we're proposing and/or doing?
## Consequences
What becomes easier or more difficult to do because of this change?
## Alternatives Considered
What other options were considered and why were they rejected?
```
## Decision Status
- **Proposed**: The decision is under discussion
- **Accepted**: The decision has been accepted and implemented
- **Deprecated**: The decision is no longer relevant
- **Superseded**: The decision has been replaced by a newer ADR

View File

@@ -0,0 +1,108 @@
# ADR-001: Django + HTMX Architecture
## Status
Accepted
## Context
ThrillWiki needed to choose a frontend architecture for building an interactive web application. The options considered were:
1. **Single Page Application (SPA)** with React/Vue.js and a separate API backend
2. **Django monolith with HTMX** for server-driven interactivity
3. **Traditional Multi-Page Application (MPA)** with full page reloads
The team needed an architecture that would:
- Minimize development complexity
- Provide good SEO out of the box
- Enable fast initial page loads
- Support dynamic interactions without full page reloads
- Be maintainable by a small team
## Decision
We chose to build ThrillWiki as a **Django monolith with HTMX** for dynamic interactivity, supplemented by minimal Alpine.js for client-side UI state.
### Key Components
1. **Django Templates**: Server-side rendering for all pages
2. **HTMX**: Dynamic partial updates without full page reloads
3. **Alpine.js**: Minimal client-side state (form validation, UI toggles)
4. **Tailwind CSS**: Utility-first styling
5. **REST API**: Available for programmatic access (mobile apps, integrations)
### Architecture Pattern
```
Browser Request
┌─────────────┐
│ Django │
│ Views │
└─────────────┘
┌─────────────────────────────────┐
│ HTMX Request? │
│ ├── Yes: Render partial │
│ └── No: Render full page │
└─────────────────────────────────┘
┌─────────────┐
│ Response │
│ (HTML) │
└─────────────┘
```
## Consequences
### Benefits
1. **Reduced Complexity**: Single codebase, no separate frontend build process
2. **SEO-Friendly**: Server-rendered HTML by default
3. **Fast Initial Load**: No JavaScript bundle to download before content appears
4. **Progressive Enhancement**: Works without JavaScript, enhanced with HTMX
5. **Easier Debugging**: Server logs show all application state
6. **Simpler Deployment**: Single Django container
7. **Django Ecosystem**: Full access to Django's batteries-included features
### Trade-offs
1. **Learning Curve**: Developers need to learn HTMX patterns
2. **Limited Offline Support**: No client-side data caching
3. **Network Dependency**: Every interaction requires a server round-trip
4. **Complex Client State**: Harder to manage complex client-side state (mitigated by Alpine.js)
### HTMX Patterns Adopted
1. **Partial Templates**: Views return partial HTML for HTMX requests
2. **HX-Trigger Events**: Cross-component communication via custom events
3. **Loading Indicators**: Skeleton loaders shown during requests
4. **Field Validation**: Real-time form validation via HTMX
## Alternatives Considered
### React/Vue.js SPA
**Rejected because:**
- Increased development complexity with separate codebases
- SEO requires server-side rendering setup (Next.js, Nuxt.js)
- Larger bundle sizes for initial load
- More complex deployment with API + frontend containers
- Overkill for this application's interactivity needs
### Traditional MPA
**Rejected because:**
- Poor user experience with full page reloads
- Higher server load for every interaction
- Slower perceived performance
## References
- [HTMX Documentation](https://htmx.org/docs/)
- [Django + HTMX Guide](https://htmx.org/essays/hypermedia-applications/)
- [Alpine.js Documentation](https://alpinejs.dev/)
- [ThrillWiki HTMX Patterns](../htmx-patterns.md)

View File

@@ -0,0 +1,160 @@
# ADR-002: Hybrid API Design Pattern
## Status
Accepted
## Context
ThrillWiki serves two types of clients:
1. **Web browsers**: Need HTML responses for rendering pages
2. **API clients**: Need JSON responses for mobile apps and integrations
We needed to decide how to handle these different client types efficiently without duplicating business logic.
## Decision
We implemented a **Hybrid API Design Pattern** where views can serve both HTML and JSON responses based on content negotiation.
### Implementation Strategy
1. **API-first approach**: All business logic is implemented in serializers and services
2. **Content negotiation**: Views check request headers to determine response format
3. **Dual endpoints**: Some resources are available at both web and API URLs
4. **Shared serializers**: Same serializers used for both API responses and template context
### URL Structure
```
# Web URLs (HTML responses)
/parks/ # Park list page
/parks/cedar-point/ # Park detail page
# API URLs (JSON responses)
/api/v1/parks/ # Park list (JSON)
/api/v1/parks/cedar-point/ # Park detail (JSON)
```
### View Implementation
```python
class HybridViewMixin:
"""
Mixin that enables views to serve both HTML and JSON responses.
"""
serializer_class = None
def get_response_format(self, request):
"""Determine response format from Accept header or query param."""
if request.htmx:
return 'html'
if 'application/json' in request.headers.get('Accept', ''):
return 'json'
if request.GET.get('format') == 'json':
return 'json'
return 'html'
def render_response(self, request, context, **kwargs):
"""Render appropriate response based on format."""
format = self.get_response_format(request)
if format == 'json':
serializer = self.serializer_class(context['object'])
return JsonResponse(serializer.data)
return super().render_to_response(context, **kwargs)
```
### Serializer Patterns
```python
# API serializers use camelCase for JSON responses
class ParkSerializer(serializers.ModelSerializer):
operatorName = serializers.CharField(source='operator.name')
rideCount = serializers.IntegerField(source='ride_count')
class Meta:
model = Park
fields = ['id', 'name', 'slug', 'operatorName', 'rideCount']
```
## Consequences
### Benefits
1. **Code Reuse**: Single set of business logic serves both web and API
2. **Consistency**: API and web views always return consistent data
3. **Flexibility**: Easy to add new response formats
4. **Progressive Enhancement**: API available without duplicating work
5. **Mobile-Ready**: API ready for mobile app development
### Trade-offs
1. **Complexity**: Views need to handle multiple response formats
2. **Testing**: Need to test both HTML and JSON responses
3. **Documentation**: Must document both web and API interfaces
### Response Format Decision
| Request Type | Response Format |
|-------------|-----------------|
| HTMX request | HTML partial |
| Browser (Accept: text/html) | Full HTML page |
| API (Accept: application/json) | JSON |
| Query param (?format=json) | JSON |
## Alternatives Considered
### Separate API and Web Views
**Rejected because:**
- Duplicate business logic
- Risk of divergence between API and web
- More code to maintain
### API-Only with JavaScript Frontend
**Rejected because:**
- Conflicts with ADR-001 (Django + HTMX architecture)
- Poor SEO without SSR
- Increased complexity
### GraphQL
**Rejected because:**
- Overkill for current requirements
- Steeper learning curve
- Less mature Django ecosystem support
## Implementation Details
### Hybrid Loader Services
For complex data loading scenarios, we use hybrid loader services:
```python
# backend/apps/parks/services/hybrid_loader.py
class ParkHybridLoader:
"""
Loads park data optimized for both API and web contexts.
"""
def load_park_detail(self, slug, context='web'):
park = Park.objects.optimized_for_detail().get(slug=slug)
if context == 'api':
return ParkSerializer(park).data
return {'park': park}
```
### API Versioning
API endpoints are versioned to allow breaking changes:
```
/api/v1/parks/ # Current version
/api/v2/parks/ # Future version (if needed)
```
## References
- [Django REST Framework](https://www.django-rest-framework.org/)
- [Content Negotiation](https://www.django-rest-framework.org/api-guide/content-negotiation/)
- [ThrillWiki API Documentation](../THRILLWIKI_API_DOCUMENTATION.md)

View File

@@ -0,0 +1,186 @@
# ADR-003: State Machine Pattern
## Status
Accepted
## Context
Parks and rides in ThrillWiki go through various operational states:
- Parks: Operating, Closed Temporarily, Closed Permanently, Under Construction
- Rides: Operating, Closed, Under Construction, Removed, Relocated
Managing these state transitions requires:
- Valid state transition enforcement
- Audit trail of state changes
- Business logic tied to state changes (notifications, cache invalidation)
## Decision
We implemented a **Finite State Machine (FSM) pattern** for managing entity states, using django-fsm with custom enhancements.
### State Model
```python
from django_fsm import FSMField, transition
class Park(models.Model):
status = FSMField(default='OPERATING')
@transition(
field=status,
source=['OPERATING', 'CLOSED_TEMP'],
target='CLOSED_PERM'
)
def close_permanently(self, reason=None):
"""Close the park permanently."""
self.closure_reason = reason
self.closure_date = timezone.now()
@transition(
field=status,
source='CLOSED_TEMP',
target='OPERATING'
)
def reopen(self):
"""Reopen a temporarily closed park."""
self.closure_reason = None
```
### State Diagram
```
Park States:
┌──────────────┐
│ PLANNED │
└──────┬───────┘
┌──────────────────────────────────────┐
│ UNDER_CONSTRUCTION │
└──────────────────┬───────────────────┘
┌──────────────────────────────────────┐
│ OPERATING │◄────┐
└──────────────────┬───────────────────┘ │
│ │
┌───────────┼───────────┐ │
│ │ │ │
▼ ▼ ▼ │
┌────────────┐ ┌────────┐ ┌────────────┐ │
│CLOSED_TEMP │ │SEASONAL│ │CLOSED_PERM │ │
└─────┬──────┘ └────────┘ └────────────┘ │
│ │
└───────────────────────────────────────┘
```
### Transition Validation
```python
class ParkStateTransition(models.Model):
"""Audit log for park state transitions."""
park = models.ForeignKey(Park, on_delete=models.CASCADE)
from_state = models.CharField(max_length=20)
to_state = models.CharField(max_length=20)
transition_date = models.DateTimeField(auto_now_add=True)
transitioned_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
reason = models.TextField(blank=True)
```
## Consequences
### Benefits
1. **Valid Transitions Only**: Invalid state changes are rejected at the model level
2. **Audit Trail**: All transitions are logged with timestamps and users
3. **Business Logic Encapsulation**: Transition methods contain related logic
4. **Testability**: State machines are easy to unit test
5. **Documentation**: State diagrams document valid workflows
### Trade-offs
1. **Learning Curve**: Developers need to understand FSM concepts
2. **Migration Complexity**: Adding new states requires careful migration
3. **Flexibility**: Rigid state transitions can be limiting for edge cases
### State Change Hooks
```python
from django.db.models.signals import pre_save
from django.dispatch import receiver
@receiver(pre_save, sender=Park)
def park_state_change(sender, instance, **kwargs):
if instance.pk:
old_instance = Park.objects.get(pk=instance.pk)
if old_instance.status != instance.status:
# Log transition
ParkStateTransition.objects.create(
park=instance,
from_state=old_instance.status,
to_state=instance.status,
)
# Invalidate caches
invalidate_park_caches(instance)
# Send notifications
notify_state_change(instance, old_instance.status)
```
## Alternatives Considered
### Simple Status Field
**Rejected because:**
- No validation of state transitions
- Business logic scattered across codebase
- No built-in audit trail
### Event Sourcing
**Rejected because:**
- Overkill for current requirements
- Significant complexity increase
- Steeper learning curve
### Workflow Engine
**Rejected because:**
- External dependency overhead
- More complex than needed
- FSM sufficient for current use cases
## Implementation Details
### Ride Status States
```python
class RideStatus(models.TextChoices):
OPERATING = 'OPERATING', 'Operating'
CLOSED_TEMP = 'CLOSED_TEMP', 'Temporarily Closed'
CLOSED_PERM = 'CLOSED_PERM', 'Permanently Closed'
UNDER_CONSTRUCTION = 'UNDER_CONSTRUCTION', 'Under Construction'
REMOVED = 'REMOVED', 'Removed'
RELOCATED = 'RELOCATED', 'Relocated'
```
### Testing State Transitions
```python
class ParkStateTransitionTest(TestCase):
def test_cannot_reopen_permanently_closed_park(self):
park = ParkFactory(status='CLOSED_PERM')
with self.assertRaises(TransitionNotAllowed):
park.reopen()
def test_can_close_operating_park_temporarily(self):
park = ParkFactory(status='OPERATING')
park.close_temporarily(reason='Maintenance')
self.assertEqual(park.status, 'CLOSED_TEMP')
```
## References
- [django-fsm Documentation](https://github.com/viewflow/django-fsm)
- [State Machine Diagrams](../state_machines/diagrams.md)
- [Finite State Machine Wikipedia](https://en.wikipedia.org/wiki/Finite-state_machine)

View File

@@ -0,0 +1,222 @@
# ADR-004: Caching Strategy
## Status
Accepted
## Context
ThrillWiki serves data that is:
- Read-heavy (browsing parks and rides)
- Moderately updated (user contributions, moderation)
- Geographically queried (map views, location searches)
We needed a caching strategy that would:
- Reduce database load for common queries
- Provide fast response times for users
- Handle cache invalidation correctly
- Support different caching needs (sessions, API, geographic)
## Decision
We implemented a **Multi-Layer Caching Strategy** using Redis with multiple cache backends for different purposes.
### Cache Architecture
```
┌─────────────────────────────────────────────────────┐
│ Application │
└─────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Default Cache │ │ Session Cache │ │ API Cache │
│ (General data) │ │ (User sessions)│ │ (API responses)│
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└────────────────┼────────────────┘
┌─────────────────┐
│ Redis │
│ (with pools) │
└─────────────────┘
```
### Cache Configuration
```python
# backend/config/django/production.py
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": redis_url,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"PARSER_CLASS": "redis.connection.HiredisParser",
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
"CONNECTION_POOL_CLASS_KWARGS": {
"max_connections": 100,
"timeout": 20,
},
},
"KEY_PREFIX": "thrillwiki",
},
"sessions": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": redis_sessions_url,
"KEY_PREFIX": "sessions",
},
"api": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": redis_api_url,
"OPTIONS": {
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
},
"KEY_PREFIX": "api",
},
}
```
### Caching Layers
| Layer | Purpose | TTL | Invalidation |
|-------|---------|-----|--------------|
| QuerySet | Expensive database queries | 1 hour | On model save |
| API Response | Serialized API responses | 30 min | On data change |
| Geographic | Map data and location queries | 30 min | On location update |
| Template Fragment | Rendered template parts | 15 min | On context change |
## Consequences
### Benefits
1. **Reduced Database Load**: Common queries served from cache
2. **Fast Response Times**: Sub-millisecond cache hits
3. **Scalability**: Cache can be distributed across Redis cluster
4. **Flexibility**: Different TTLs for different data types
5. **Compression**: Reduced memory usage with zlib compression
### Trade-offs
1. **Cache Invalidation**: Must carefully invalidate on data changes
2. **Memory Usage**: Redis memory must be monitored
3. **Consistency**: Potential for stale data during TTL window
4. **Complexity**: Multiple cache backends to manage
### Cache Key Naming Convention
```
{prefix}:{entity_type}:{identifier}:{context}
Examples:
thrillwiki:park:123:detail
thrillwiki:park:123:rides
api:parks:list:page1:filter_operating
geo:bounds:40.7:-74.0:41.0:-73.5:z10
```
### Cache Invalidation Patterns
```python
# Model signal for cache invalidation
@receiver(post_save, sender=Park)
def invalidate_park_cache(sender, instance, **kwargs):
cache_service = EnhancedCacheService()
# Invalidate specific park cache
cache_service.invalidate_model_cache('park', instance.id)
# Invalidate list caches
cache_service.invalidate_pattern('api:parks:list:*')
# Invalidate geographic caches if location changed
if instance.location_changed:
cache_service.invalidate_pattern('geo:*')
```
## Alternatives Considered
### Database-Only (No Caching)
**Rejected because:**
- High database load for read-heavy traffic
- Slower response times
- Database as bottleneck for scaling
### Memcached
**Rejected because:**
- Less feature-rich than Redis
- No data persistence
- No built-in data structures
### Application-Level Caching Only
**Rejected because:**
- Not shared across application instances
- Memory per-instance overhead
- Cache cold on restart
## Implementation Details
### EnhancedCacheService
```python
# backend/apps/core/services/enhanced_cache_service.py
class EnhancedCacheService:
"""Comprehensive caching service with multiple cache backends."""
def cache_queryset(self, cache_key, queryset_func, timeout=3600, **kwargs):
"""Cache expensive querysets with logging."""
cached = self.default_cache.get(cache_key)
if cached is None:
result = queryset_func(**kwargs)
self.default_cache.set(cache_key, result, timeout)
return result
return cached
def invalidate_pattern(self, pattern):
"""Invalidate cache keys matching pattern."""
if hasattr(self.default_cache, 'delete_pattern'):
return self.default_cache.delete_pattern(pattern)
```
### Cache Warming
```python
# Proactive cache warming for common queries
class CacheWarmer:
"""Context manager for batch cache warming."""
def warm_popular_parks(self):
parks = Park.objects.operating()[:100]
for park in parks:
self.cache_service.warm_cache(
f'park:{park.id}:detail',
lambda: ParkSerializer(park).data,
timeout=3600
)
```
### Cache Monitoring
```python
class CacheMonitor:
"""Monitor cache performance and statistics."""
def get_cache_stats(self):
redis_client = self.cache_service.default_cache._cache.get_client()
info = redis_client.info()
hits = info.get('keyspace_hits', 0)
misses = info.get('keyspace_misses', 0)
return {
'used_memory': info.get('used_memory_human'),
'hit_rate': hits / (hits + misses) * 100 if hits + misses > 0 else 0,
}
```
## References
- [Django Redis](https://github.com/jazzband/django-redis)
- [Redis Documentation](https://redis.io/documentation)
- [Cache Invalidation Strategies](https://en.wikipedia.org/wiki/Cache_invalidation)

View File

@@ -0,0 +1,216 @@
# ADR-005: Authentication Approach
## Status
Accepted
## Context
ThrillWiki needs to authenticate users for:
- Web browsing (session-based)
- API access (token-based)
- Social login (Google, Discord)
We needed an authentication approach that would:
- Support multiple authentication methods
- Provide secure token handling for API
- Enable social authentication
- Work seamlessly with Django + HTMX architecture
## Decision
We implemented a **Hybrid Authentication System** using django-allauth for social auth and djangorestframework-simplejwt for API tokens.
### Authentication Methods
| Context | Method | Library |
|---------|--------|---------|
| Web browsing | Session-based | Django sessions |
| API access | JWT tokens | djangorestframework-simplejwt |
| Social login | OAuth2 | django-allauth |
| Password reset | Email tokens | Django built-in |
### Architecture
```
┌─────────────────────────────────────────────────────────┐
│ User Request │
└─────────────────────────────────────────────────────────┘
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Web Request │ │ API Request │ │ Social Auth │
│ │ │ │ │ │
│ Session Cookie │ │ Bearer Token │ │ OAuth Flow │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────┼───────────────┘
┌─────────────────────┐
│ Django User │
│ (Authenticated) │
└─────────────────────┘
```
### JWT Token Configuration
```python
# backend/config/settings/rest_framework.py
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
'AUTH_HEADER_TYPES': ('Bearer',),
}
```
### Social Authentication
```python
# backend/config/settings/third_party.py
SOCIALACCOUNT_PROVIDERS = {
'google': {
'SCOPE': ['profile', 'email'],
'AUTH_PARAMS': {'access_type': 'online'},
},
'discord': {
'SCOPE': ['identify', 'email'],
},
}
```
## Consequences
### Benefits
1. **Flexibility**: Multiple auth methods for different use cases
2. **Security**: JWT with short-lived access tokens
3. **User Experience**: Social login reduces friction
4. **Standards-Based**: OAuth2 and JWT are industry standards
5. **Django Integration**: Seamless with Django's user model
### Trade-offs
1. **Complexity**: Multiple auth systems to maintain
2. **Token Management**: Must handle token refresh client-side
3. **Social Provider Dependency**: Reliance on third-party OAuth providers
### Authentication Flow
#### Web Session Authentication
```
1. User visits /login/
2. User submits credentials
3. Django validates credentials
4. Session created, cookie set
5. Subsequent requests include session cookie
```
#### API JWT Authentication
```
1. Client POST /api/v1/auth/login/
{username, password}
2. Server validates, returns tokens
{access: "...", refresh: "..."}
3. Client includes in requests:
Authorization: Bearer <access_token>
4. On 401, client refreshes:
POST /api/v1/auth/token/refresh/
{refresh: "..."}
```
#### Social Authentication
```
1. User clicks "Login with Google"
2. Redirect to Google OAuth
3. User authorizes application
4. Google redirects with auth code
5. Server exchanges code for tokens
6. Server creates/updates user
7. Session created
```
## Alternatives Considered
### Session-Only Authentication
**Rejected because:**
- Not suitable for mobile apps
- Not RESTful for API access
- CSRF complexity for API clients
### JWT-Only Authentication
**Rejected because:**
- More complex for web browsing
- Token storage in browser has security concerns
- Session logout not immediate
### OAuth2 Server (Self-Hosted)
**Rejected because:**
- Significant complexity for current needs
- django-oauth-toolkit overkill
- django-allauth sufficient for social auth
## Implementation Details
### Permission Classes
```python
# API views use JWT or session authentication
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
}
```
### Custom User Model
```python
# backend/apps/accounts/models.py
class User(AbstractUser):
email = models.EmailField(unique=True)
display_name = models.CharField(max_length=50, blank=True)
avatar_url = models.URLField(blank=True)
email_verified = models.BooleanField(default=False)
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username']
```
### Email Verification
```python
# Required before full access
ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_AUTHENTICATION_METHOD = 'email'
```
### Security Measures
1. **Password Hashing**: Django's PBKDF2 with SHA256
2. **Token Blacklisting**: Invalidated refresh tokens stored
3. **Rate Limiting**: Login attempts limited
4. **HTTPS Required**: Tokens only sent over secure connections
## References
- [django-allauth Documentation](https://django-allauth.readthedocs.io/)
- [djangorestframework-simplejwt](https://django-rest-framework-simplejwt.readthedocs.io/)
- [OAuth 2.0 Specification](https://oauth.net/2/)
- [JWT Best Practices](https://auth0.com/blog/jwt-security-best-practices/)

View File

@@ -0,0 +1,226 @@
# 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/)