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

@@ -1,36 +1,25 @@
"""
Base Django settings for thrillwiki project.
Common settings shared across all environments.
This file contains only core Django settings that are common across all
environments. Environment-specific settings are in local.py, production.py,
and test.py. Modular configuration is imported from config/settings/.
Structure:
- Core settings (SECRET_KEY, DEBUG, ALLOWED_HOSTS)
- Application definition (INSTALLED_APPS, MIDDLEWARE)
- URL and template configuration
- Internationalization
- Imports from modular settings (database, cache, security, etc.)
"""
from datetime import timedelta
import sys
from pathlib import Path
from decouple import config
# Initialize environment variables with better defaults
DEBUG = config("DEBUG", default=True)
SECRET_KEY = config("SECRET_KEY")
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="localhost,127.0.0.1", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()])
DATABASE_URL = config("DATABASE_URL")
CACHE_URL = config("CACHE_URL", default="locmem://")
EMAIL_URL = config("EMAIL_URL", default="console://")
REDIS_URL = config("REDIS_URL", default="redis://127.0.0.1:6379/1")
CORS_ALLOWED_ORIGINS = config("CORS_ALLOWED_ORIGINS", default="", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()])
API_RATE_LIMIT_PER_MINUTE = config("API_RATE_LIMIT_PER_MINUTE", default=60)
API_RATE_LIMIT_PER_HOUR = config("API_RATE_LIMIT_PER_HOUR", default=1000)
CACHE_MIDDLEWARE_SECONDS = config("CACHE_MIDDLEWARE_SECONDS", default=300)
CACHE_MIDDLEWARE_KEY_PREFIX = config(
"CACHE_MIDDLEWARE_KEY_PREFIX", default="thrillwiki"
)
GDAL_LIBRARY_PATH = config(
"GDAL_LIBRARY_PATH", default="/opt/homebrew/lib/libgdal.dylib"
)
GEOS_LIBRARY_PATH = config(
"GEOS_LIBRARY_PATH", default="/opt/homebrew/lib/libgeos_c.dylib"
)
# =============================================================================
# Path Configuration
# =============================================================================
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent.parent
@@ -40,17 +29,34 @@ apps_dir = BASE_DIR / "apps"
if apps_dir.exists() and str(apps_dir) not in sys.path:
sys.path.insert(0, str(apps_dir))
# =============================================================================
# Core Settings
# =============================================================================
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = config("SECRET_KEY")
# Allowed hosts (already configured above)
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = config("DEBUG", default=True, cast=bool)
# CSRF trusted origins
CSRF_TRUSTED_ORIGINS = config(
"CSRF_TRUSTED_ORIGINS", default="", cast=lambda v: [s.strip() for s in v.split(',') if s.strip()]
# Allowed hosts (comma-separated in .env)
ALLOWED_HOSTS = config(
"ALLOWED_HOSTS",
default="localhost,127.0.0.1",
cast=lambda v: [s.strip() for s in v.split(",") if s.strip()]
)
# Application definition
# CSRF trusted origins (comma-separated in .env)
CSRF_TRUSTED_ORIGINS = config(
"CSRF_TRUSTED_ORIGINS",
default="",
cast=lambda v: [s.strip() for s in v.split(",") if s.strip()]
)
# =============================================================================
# Application Definition
# =============================================================================
DJANGO_APPS = [
"django.contrib.admin",
"django.contrib.auth",
@@ -111,32 +117,46 @@ LOCAL_APPS = [
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
# =============================================================================
# Middleware Configuration
# =============================================================================
MIDDLEWARE = [
"django.middleware.cache.UpdateCacheMiddleware",
"django.middleware.cache.UpdateCacheMiddleware", # Must be first for cache middleware
"django.middleware.gzip.GZipMiddleware", # Response compression
"corsheaders.middleware.CorsMiddleware", # CORS middleware for API
"django.middleware.security.SecurityMiddleware",
"apps.core.middleware.security_headers.SecurityHeadersMiddleware", # Custom security headers (CSP, Permissions-Policy)
"apps.core.middleware.rate_limiting.AuthRateLimitMiddleware", # Rate limiting for auth endpoints
"apps.core.middleware.security_headers.SecurityHeadersMiddleware", # Custom security headers
"apps.core.middleware.rate_limiting.AuthRateLimitMiddleware", # Rate limiting
"whitenoise.middleware.WhiteNoiseMiddleware",
"apps.core.middleware.performance_middleware.PerformanceMiddleware", # Performance monitoring
"apps.core.middleware.performance_middleware.QueryCountMiddleware", # Database query monitoring
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"apps.core.middleware.analytics.PgHistoryContextMiddleware", # Add history context tracking
"apps.core.middleware.analytics.PgHistoryContextMiddleware", # History context tracking
"allauth.account.middleware.AccountMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware", # Must be last for cache middleware
]
ROOT_URLCONF = "thrillwiki.urls"
# =============================================================================
# URL Configuration
# =============================================================================
# Add a toggle to enable/disable Django template support via env var
# Use a distinct environment variable name so it doesn't collide with Django's TEMPLATES setting
ROOT_URLCONF = "thrillwiki.urls"
WSGI_APPLICATION = "thrillwiki.wsgi.application"
# =============================================================================
# Template Configuration
# =============================================================================
# Toggle to enable/disable Django template support via env var
TEMPLATES_ENABLED = config("TEMPLATES_ENABLED", default=True, cast=bool)
# Conditional TEMPLATES configuration
if TEMPLATES_ENABLED:
TEMPLATES = [
{
@@ -158,11 +178,11 @@ if TEMPLATES_ENABLED:
}
]
else:
# When templates are disabled, we still need APP_DIRS=True for DRF Spectacular to work
# When templates are disabled, still need APP_DIRS=True for DRF Spectacular
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"APP_DIRS": True, # Changed from False to True to support DRF Spectacular templates
"APP_DIRS": True,
"DIRS": [BASE_DIR / "templates/" / "404"],
"OPTIONS": {
"context_processors": [
@@ -179,462 +199,82 @@ else:
}
]
WSGI_APPLICATION = "thrillwiki.wsgi.application"
# =============================================================================
# Custom User Model
# =============================================================================
# Cloudflare Images Settings - Updated for django-cloudflareimages-toolkit
CLOUDFLARE_IMAGES = {
'ACCOUNT_ID': config("CLOUDFLARE_IMAGES_ACCOUNT_ID"),
'API_TOKEN': config("CLOUDFLARE_IMAGES_API_TOKEN"),
'ACCOUNT_HASH': config("CLOUDFLARE_IMAGES_ACCOUNT_HASH"),
# Optional settings
'DEFAULT_VARIANT': 'public',
'UPLOAD_TIMEOUT': 300,
'WEBHOOK_SECRET': config("CLOUDFLARE_IMAGES_WEBHOOK_SECRET", default=""),
'CLEANUP_EXPIRED_HOURS': 24,
'MAX_FILE_SIZE': 10 * 1024 * 1024, # 10MB
'ALLOWED_FORMATS': ['jpeg', 'png', 'gif', 'webp'],
'REQUIRE_SIGNED_URLS': False,
'DEFAULT_METADATA': {},
}
# Storage configuration
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
"location": str(BASE_DIR.parent / "shared" / "media"),
},
},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
"OPTIONS": {
"location": str(BASE_DIR / "staticfiles"),
},
},
}
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": (
"django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
),
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
AUTH_USER_MODEL = "accounts.User"
# =============================================================================
# Internationalization
# =============================================================================
LANGUAGE_CODE = "en-us"
TIME_ZONE = "America/New_York"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
STATIC_URL = "static/"
STATICFILES_DIRS = [BASE_DIR / "static"]
STATIC_ROOT = BASE_DIR / "staticfiles"
# =============================================================================
# Default Primary Key
# =============================================================================
# Media files - point to shared/media directory
MEDIA_URL = "/media/"
MEDIA_ROOT = BASE_DIR.parent / "shared" / "media"
# Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Authentication settings
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
# =============================================================================
# Test Runner
# =============================================================================
# django-allauth settings
SITE_ID = 1
# CORRECTED: Django allauth still expects the old format with asterisks for required fields
# The deprecation warnings are from dj_rest_auth, not our configuration
ACCOUNT_SIGNUP_FIELDS = ["email*", "username*", "password1*", "password2*"]
ACCOUNT_LOGIN_METHODS = {"email", "username"}
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True
ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_RESEND = True
ACCOUNT_REAUTHENTICATION_REQUIRED = True
ACCOUNT_EMAIL_NOTIFICATIONS = True
ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
LOGIN_REDIRECT_URL = "/"
ACCOUNT_LOGOUT_REDIRECT_URL = "/"
# Custom adapters
ACCOUNT_ADAPTER = "apps.accounts.adapters.CustomAccountAdapter"
SOCIALACCOUNT_ADAPTER = "apps.accounts.adapters.CustomSocialAccountAdapter"
# Social account settings
SOCIALACCOUNT_PROVIDERS = {
"google": {
"SCOPE": [
"profile",
"email",
],
"AUTH_PARAMS": {"access_type": "online"},
},
"discord": {
"SCOPE": ["identify", "email"],
"OAUTH_PKCE_ENABLED": True,
},
}
# Additional social account settings
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_AUTO_SIGNUP = False
SOCIALACCOUNT_STORE_TOKENS = True
# Custom User Model
AUTH_USER_MODEL = "accounts.User"
# Autocomplete configuration
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED = False
# Tailwind configuration
TAILWIND_CLI_CONFIG_FILE = "tailwind.config.js"
TAILWIND_CLI_SRC_CSS = "static/css/src/input.css"
TAILWIND_CLI_DIST_CSS = "css/tailwind.css"
# Test runner
TEST_RUNNER = "django.test.runner.DiscoverRunner"
# Road Trip Service Settings
ROADTRIP_CACHE_TIMEOUT = 3600 * 24 # 24 hours for geocoding
ROADTRIP_ROUTE_CACHE_TIMEOUT = 3600 * 6 # 6 hours for routes
ROADTRIP_MAX_REQUESTS_PER_SECOND = 1 # Respect OSM rate limits
ROADTRIP_USER_AGENT = config("ROADTRIP_USER_AGENT")
ROADTRIP_REQUEST_TIMEOUT = 10 # seconds
ROADTRIP_MAX_RETRIES = 3
ROADTRIP_BACKOFF_FACTOR = 2
# Frontend URL Configuration
FRONTEND_DOMAIN = config("FRONTEND_DOMAIN", default="https://thrillwiki.com")
# ForwardEmail Configuration
FORWARD_EMAIL_BASE_URL = config(
"FORWARD_EMAIL_BASE_URL", default="https://api.forwardemail.net")
FORWARD_EMAIL_API_KEY = config("FORWARD_EMAIL_API_KEY", default="")
FORWARD_EMAIL_DOMAIN = config("FORWARD_EMAIL_DOMAIN", default="")
# Django REST Framework Settings
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication", # Kept for backward compatibility
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
"MAX_PAGE_SIZE": 100,
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.AcceptHeaderVersioning",
"DEFAULT_VERSION": "v1",
"ALLOWED_VERSIONS": ["v1"],
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
"rest_framework.renderers.BrowsableAPIRenderer",
],
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
"rest_framework.parsers.FormParser",
"rest_framework.parsers.MultiPartParser",
],
"EXCEPTION_HANDLER": "apps.core.api.exceptions.custom_exception_handler",
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
"DEFAULT_THROTTLE_CLASSES": [
"rest_framework.throttling.AnonRateThrottle",
"rest_framework.throttling.UserRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {
"anon": "60/minute",
"user": "1000/hour",
},
"TEST_REQUEST_DEFAULT_FORMAT": "json",
"NON_FIELD_ERRORS_KEY": "non_field_errors",
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
# CORS Settings for API
# https://github.com/adamchainz/django-cors-headers
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = config(
"CORS_ALLOW_ALL_ORIGINS", default=False, cast=bool
) # type: ignore[arg-type]
# Allowed HTTP headers for CORS requests
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
"x-api-version",
]
# HTTP methods allowed for CORS requests
CORS_ALLOW_METHODS = [
"DELETE",
"GET",
"OPTIONS",
"PATCH",
"POST",
"PUT",
]
# Expose rate limit headers to browsers
CORS_EXPOSE_HEADERS = [
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
"X-API-Version",
]
API_RATE_LIMIT_PER_MINUTE = config(
"API_RATE_LIMIT_PER_MINUTE", default=60, cast=int
) # type: ignore[arg-type]
API_RATE_LIMIT_PER_HOUR = config(
"API_RATE_LIMIT_PER_HOUR", default=1000, cast=int
) # type: ignore[arg-type]
SPECTACULAR_SETTINGS = {
"TITLE": "ThrillWiki API",
"DESCRIPTION": """Comprehensive theme park and ride information API.
## API Conventions
### Response Format
All successful responses include a `success: true` field with data nested under `data`.
All error responses include an `error` object with `code` and `message` fields.
### Pagination
List endpoints support pagination with `page` and `page_size` parameters.
Default page size is 20, maximum is 100.
### Filtering
Range filters use `{field}_min` and `{field}_max` naming convention.
Search uses the `search` parameter.
Ordering uses the `ordering` parameter (prefix with `-` for descending).
### Field Naming
All field names use snake_case convention (e.g., `image_url`, `created_at`).
""",
"VERSION": "1.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"COMPONENT_SPLIT_REQUEST": True,
"TAGS": [
{"name": "Parks", "description": "Theme park operations"},
{"name": "Rides", "description": "Ride information and management"},
{"name": "Park Media", "description": "Park photos and media management"},
{"name": "Ride Media", "description": "Ride photos and media management"},
{"name": "Authentication", "description": "User authentication and session management"},
{"name": "Social Authentication", "description": "Social provider login and account linking"},
{"name": "User Profile", "description": "User profile management"},
{"name": "User Settings", "description": "User preferences and settings"},
{"name": "User Notifications", "description": "User notification management"},
{"name": "User Content", "description": "User-generated content (top lists, reviews)"},
{"name": "User Management", "description": "Admin user management operations"},
{"name": "Self-Service Account Management", "description": "User account deletion and management"},
{"name": "Core", "description": "Core utility endpoints (search, suggestions)"},
{
"name": "Statistics",
"description": "Statistical endpoints providing aggregated data and insights",
},
],
"SCHEMA_PATH_PREFIX": "/api/",
"DEFAULT_GENERATOR_CLASS": "drf_spectacular.generators.SchemaGenerator",
"DEFAULT_AUTO_SCHEMA": "drf_spectacular.openapi.AutoSchema",
"PREPROCESSING_HOOKS": [
"api.v1.schema.custom_preprocessing_hook",
],
# "POSTPROCESSING_HOOKS": [
# "api.v1.schema.custom_postprocessing_hook",
# ],
"SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"],
"SWAGGER_UI_SETTINGS": {
"deepLinking": True,
"persistAuthorization": True,
"displayOperationId": False,
"displayRequestDuration": True,
},
"REDOC_UI_SETTINGS": {
"hideDownloadButton": False,
"hideHostname": False,
"hideLoading": False,
"hideSchemaPattern": True,
"scrollYOffset": 0,
"theme": {"colors": {"primary": {"main": "#1976d2"}}},
},
}
# Health Check Configuration
HEALTH_CHECK = {
"DISK_USAGE_MAX": 90, # Fail if disk usage is over 90%
"MEMORY_MIN": 100, # Fail if less than 100MB available memory
}
# Custom health check backends
HEALTH_CHECK_BACKENDS = [
"health_check.db",
"health_check.cache",
"health_check.storage",
"core.health_checks.custom_checks.CacheHealthCheck",
"core.health_checks.custom_checks.DatabasePerformanceCheck",
"core.health_checks.custom_checks.ApplicationHealthCheck",
"core.health_checks.custom_checks.ExternalServiceHealthCheck",
"core.health_checks.custom_checks.DiskSpaceHealthCheck",
]
# Enhanced Cache Configuration
DJANGO_REDIS_CACHE_BACKEND = "django_redis.cache.RedisCache"
DJANGO_REDIS_CLIENT_CLASS = "django_redis.client.DefaultClient"
CACHES = {
"default": {
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
# pyright: ignore[reportArgumentType]
# type: ignore
"LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/1"),
"OPTIONS": {
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
"PARSER_CLASS": "redis.connection.HiredisParser",
"CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool",
"CONNECTION_POOL_CLASS_KWARGS": {
"max_connections": 50,
"timeout": 20,
},
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
"IGNORE_EXCEPTIONS": True,
},
"KEY_PREFIX": "thrillwiki",
"VERSION": 1,
},
"sessions": {
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
"LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/2"),
"OPTIONS": {
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
},
},
"api": {
"BACKEND": DJANGO_REDIS_CACHE_BACKEND,
"LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/3"),
"OPTIONS": {
"CLIENT_CLASS": DJANGO_REDIS_CLIENT_CLASS,
},
},
}
# Use Redis for sessions
# =============================================================================
# Session Security Settings
# Import Modular Settings
# =============================================================================
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "sessions"
SESSION_COOKIE_AGE = 3600 # 1 hour (reduced from 24 hours for security)
SESSION_SAVE_EVERY_REQUEST = True # Update session on each request (sliding expiry)
SESSION_COOKIE_HTTPONLY = True # Prevent JavaScript access to session cookie
SESSION_EXPIRE_AT_BROWSER_CLOSE = False # Session persists until cookie expires
# Import settings from modular configuration files.
# These imports add/override settings defined above.
# Cache middleware settings
CACHE_MIDDLEWARE_SECONDS = 300 # 5 minutes
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki"
# Database configuration (DATABASES, GDAL_LIBRARY_PATH, GEOS_LIBRARY_PATH)
from config.settings.database import * # noqa: F401,F403,E402
# Cache configuration (CACHES, SESSION_*, CACHE_MIDDLEWARE_*)
from config.settings.cache import * # noqa: F401,F403,E402
# Security configuration (SECURE_*, CSRF_*, SESSION_COOKIE_*, AUTH_PASSWORD_VALIDATORS)
from config.settings.security import * # noqa: F401,F403,E402
# Email configuration (EMAIL_*, FORWARD_EMAIL_*)
from config.settings.email import * # noqa: F401,F403,E402
# Logging configuration (LOGGING)
from config.settings.logging import * # noqa: F401,F403,E402
# REST Framework configuration (REST_FRAMEWORK, CORS_*, SIMPLE_JWT, REST_AUTH, SPECTACULAR_SETTINGS)
from config.settings.rest_framework import * # noqa: F401,F403,E402
# Third-party configuration (ACCOUNT_*, SOCIALACCOUNT_*, CLOUDFLARE_IMAGES, etc.)
from config.settings.third_party import * # noqa: F401,F403,E402
# Storage configuration (STATIC_*, MEDIA_*, STORAGES, WHITENOISE_*, FILE_UPLOAD_*)
from config.settings.storage import * # noqa: F401,F403,E402
# =============================================================================
# JWT Settings
# Post-Import Overrides
# =============================================================================
# Security considerations:
# - Short access token lifetime reduces window of vulnerability
# - Refresh token rotation prevents token reuse after refresh
# - Token blacklisting allows revocation of compromised tokens
# - JTI claim enables unique token identification for logging
# Settings that need to reference values from imported modules
SIMPLE_JWT = {
# Token lifetimes
# Security: Shorter access tokens (15 min) provide better security
# but may require more frequent refreshes
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), # 15 minutes (reduced from 60)
"REFRESH_TOKEN_LIFETIME": timedelta(days=7), # 7 days
# Token rotation and blacklisting
# Security: Rotate refresh tokens on each use and blacklist old ones
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
# Update last login on token refresh
"UPDATE_LAST_LOGIN": True,
# Cryptographic settings
"ALGORITHM": "HS256",
"SIGNING_KEY": SECRET_KEY,
"VERIFYING_KEY": None,
# Token validation
"AUDIENCE": None,
"ISSUER": "thrillwiki", # Added issuer for token validation
"JWK_URL": None,
"LEEWAY": 0, # No leeway for token expiration
# Authentication header
"AUTH_HEADER_TYPES": ("Bearer",),
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
# User identification
"USER_ID_FIELD": "id",
"USER_ID_CLAIM": "user_id",
"USER_AUTHENTICATION_RULE": "rest_framework_simplejwt.authentication.default_user_authentication_rule",
# Token classes
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
"TOKEN_TYPE_CLAIM": "token_type",
"TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser",
# JTI claim for unique token identification
# Security: Enables token tracking and revocation
"JTI_CLAIM": "jti",
# Sliding token settings (if using sliding tokens)
"SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp",
"SLIDING_TOKEN_LIFETIME": timedelta(minutes=15),
"SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=1),
}
# Update SimpleJWT to use the SECRET_KEY
SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY # noqa: F405
# =============================================================================
# dj-rest-auth Settings
# Startup Validation
# =============================================================================
REST_AUTH = {
"USE_JWT": True,
"JWT_AUTH_COOKIE": "thrillwiki-auth",
"JWT_AUTH_REFRESH_COOKIE": "thrillwiki-refresh",
# Security: Only send cookies over HTTPS in production
"JWT_AUTH_SECURE": not DEBUG,
# Security: Prevent JavaScript access to cookies
"JWT_AUTH_HTTPONLY": True,
# Security: SameSite cookie attribute (Lax is compatible with OAuth flows)
"JWT_AUTH_SAMESITE": "Lax",
"JWT_AUTH_RETURN_EXPIRATION": True,
"JWT_TOKEN_CLAIMS_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer",
}
# Run configuration validation after all settings are loaded.
# These validations catch configuration errors early during Django startup.
from config.settings.secrets import run_startup_validation as validate_secrets # noqa: E402
from config.settings.validation import run_startup_validation as validate_config # noqa: E402
# Run secret validation (fails fast in production, warns in development)
validate_secrets()
# Run configuration validation (fails fast in production, warns in development)
validate_config()

View File

@@ -1,15 +1,21 @@
"""
Local development settings for thrillwiki project.
This module extends base.py with development-specific configurations:
- Debug mode enabled
- Local memory cache (no Redis required)
- Console email backend option
- Development middleware (nplusone, debug toolbar)
- Enhanced logging for debugging
"""
from ..settings import database
import logging
from .base import *
from .base import * # noqa: F401,F403
# Import database configuration
DATABASES = database.DATABASES
# =============================================================================
# Development Settings
# =============================================================================
# Development-specific settings
DEBUG = True
# For local development, allow all hosts
@@ -22,10 +28,18 @@ CSRF_TRUSTED_ORIGINS = [
"https://beta.thrillwiki.com",
]
# =============================================================================
# GeoDjango Library Paths (macOS with Homebrew)
# =============================================================================
GDAL_LIBRARY_PATH = "/opt/homebrew/lib/libgdal.dylib"
GEOS_LIBRARY_PATH = "/opt/homebrew/lib/libgeos_c.dylib"
# Local cache configuration
# =============================================================================
# Local Cache Configuration
# =============================================================================
# Use local memory cache for development (no Redis required)
LOC_MEM_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
CACHES = {
@@ -38,7 +52,7 @@ CACHES = {
"sessions": {
"BACKEND": LOC_MEM_CACHE_BACKEND,
"LOCATION": "sessions-cache",
"TIMEOUT": 86400, # 24 hours (same as SESSION_COOKIE_AGE)
"TIMEOUT": 86400, # 24 hours
"OPTIONS": {"MAX_ENTRIES": 5000},
},
"api": {
@@ -53,16 +67,29 @@ CACHES = {
CACHE_MIDDLEWARE_SECONDS = 1 # Very short cache for development
CACHE_MIDDLEWARE_KEY_PREFIX = "thrillwiki_dev"
# Development email backend - Use ForwardEmail for actual email sending
# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" # Console for debugging
EMAIL_BACKEND = "django_forwardemail.backends.ForwardEmailBackend" # Actual email sending
# =============================================================================
# Email Backend
# =============================================================================
# Use ForwardEmail for actual sending, or console for debugging
# Console backend for debugging (uncomment to use):
# EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# ForwardEmail backend for actual email sending:
EMAIL_BACKEND = "django_forwardemail.backends.ForwardEmailBackend"
# =============================================================================
# Security Settings (Relaxed for Development)
# =============================================================================
# Security settings for development
SECURE_SSL_REDIRECT = False
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
# Development monitoring tools
# =============================================================================
# Development Apps
# =============================================================================
DEVELOPMENT_APPS = [
# "silk", # Disabled for performance
"nplusone.ext.django",
@@ -70,36 +97,47 @@ DEVELOPMENT_APPS = [
"widget_tweaks",
]
# Add development apps if available
# Add development apps if not already present
for app in DEVELOPMENT_APPS:
if app not in INSTALLED_APPS:
INSTALLED_APPS.append(app)
if app not in INSTALLED_APPS: # noqa: F405
INSTALLED_APPS.append(app) # noqa: F405
# =============================================================================
# Development Middleware
# =============================================================================
# Development middleware
DEVELOPMENT_MIDDLEWARE = [
# "silk.middleware.SilkyMiddleware", # Disabled for performance
"nplusone.ext.django.NPlusOneMiddleware",
"core.middleware.performance_middleware.PerformanceMiddleware",
"core.middleware.performance_middleware.QueryCountMiddleware",
"core.middleware.nextjs.APIResponseMiddleware", # Add this
"core.middleware.request_logging.RequestLoggingMiddleware", # Request logging
# Note: PerformanceMiddleware and QueryCountMiddleware are already in base.py MIDDLEWARE
"apps.core.middleware.nextjs.APIResponseMiddleware",
"apps.core.middleware.request_logging.RequestLoggingMiddleware",
]
# Add development middleware
# Add development middleware if not already present
for middleware in DEVELOPMENT_MIDDLEWARE:
if middleware not in MIDDLEWARE:
MIDDLEWARE.insert(1, middleware) # Insert after security middleware
if middleware not in MIDDLEWARE: # noqa: F405
MIDDLEWARE.insert(1, middleware) # noqa: F405
# =============================================================================
# Debug Toolbar Configuration
# =============================================================================
# Debug toolbar configuration
INTERNAL_IPS = ["127.0.0.1", "::1"]
# Silk configuration disabled for performance
# =============================================================================
# NPlusOne Configuration
# =============================================================================
# Detects N+1 query issues during development
# NPlusOne configuration
NPLUSONE_LOGGER = logging.getLogger("nplusone")
NPLUSONE_LOG_LEVEL = logging.WARN
# Enhanced development logging
# =============================================================================
# Development Logging Configuration
# =============================================================================
# Extended logging for debugging with reduced noise
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
@@ -123,14 +161,14 @@ LOGGING = {
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "thrillwiki.log",
"filename": BASE_DIR / "logs" / "thrillwiki.log", # noqa: F405
"maxBytes": 1024 * 1024 * 10, # 10MB
"backupCount": 5,
"formatter": "json",
},
"performance": {
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "performance.log",
"filename": BASE_DIR / "logs" / "performance.log", # noqa: F405
"maxBytes": 1024 * 1024 * 10, # 10MB
"backupCount": 5,
"formatter": "json",
@@ -143,22 +181,22 @@ LOGGING = {
"loggers": {
"django": {
"handlers": ["file"],
"level": "WARNING", # Reduced from INFO
"level": "WARNING",
"propagate": False,
},
"django.db.backends": {
"handlers": ["console"],
"level": "WARNING", # Reduced from DEBUG
"level": "WARNING",
"propagate": False,
},
"thrillwiki": {
"handlers": ["console", "file"],
"level": "INFO", # Reduced from DEBUG
"level": "INFO",
"propagate": False,
},
"performance": {
"handlers": ["performance"],
"level": "WARNING", # Reduced from INFO
"level": "WARNING",
"propagate": False,
},
"query_optimization": {
@@ -168,7 +206,7 @@ LOGGING = {
},
"nplusone": {
"handlers": ["console"],
"level": "ERROR", # Reduced from WARNING
"level": "ERROR",
"propagate": False,
},
"request_logging": {

View File

@@ -1,28 +1,34 @@
"""
Production settings for thrillwiki project.
This module extends base.py with production-specific configurations:
- Debug mode disabled
- Strict security settings (HSTS, secure cookies, SSL redirect)
- Redis caching (required in production)
- Structured JSON logging
- Production-optimized static file serving
"""
# Import the module and use its members, e.g., base.BASE_DIR, base***REMOVED***
from . import base
from decouple import config
from .base import * # noqa: F401,F403
# Import the module and use its members, e.g., database.DATABASES
# =============================================================================
# Production Core Settings
# =============================================================================
# Import the module and use its members, e.g., email.EMAIL_HOST
# Import the module and use its members, e.g., security.SECURE_HSTS_SECONDS
# Import the module and use its members, e.g., email.EMAIL_HOST
# Import the module and use its members, e.g., security.SECURE_HSTS_SECONDS
# Production settings
DEBUG = False
# Allowed hosts must be explicitly set in production
ALLOWED_HOSTS = base.config("ALLOWED_HOSTS")
ALLOWED_HOSTS = config(
"ALLOWED_HOSTS",
cast=lambda v: [s.strip() for s in v.split(",") if s.strip()]
)
# CSRF trusted origins for production
CSRF_TRUSTED_ORIGINS = base.config("CSRF_TRUSTED_ORIGINS")
CSRF_TRUSTED_ORIGINS = config(
"CSRF_TRUSTED_ORIGINS",
cast=lambda v: [s.strip() for s in v.split(",") if s.strip()]
)
# =============================================================================
# Security Settings for Production
@@ -50,7 +56,86 @@ SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
# Production logging
# Proxy SSL header (for reverse proxies like nginx)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# =============================================================================
# Production Cache Configuration (Redis Required)
# =============================================================================
redis_url = config("REDIS_URL", default=None)
if redis_url:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": redis_url,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"PARSER_CLASS": "redis.connection.HiredisParser",
"CONNECTION_POOL_CLASS": "redis.BlockingConnectionPool",
"CONNECTION_POOL_CLASS_KWARGS": {
"max_connections": config(
"REDIS_MAX_CONNECTIONS", default=100, cast=int
),
"timeout": 20,
"socket_keepalive": True,
"retry_on_timeout": True,
},
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
"IGNORE_EXCEPTIONS": False, # Fail loudly in production
},
"KEY_PREFIX": "thrillwiki",
},
"sessions": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": config("REDIS_SESSIONS_URL", default=redis_url),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"PARSER_CLASS": "redis.connection.HiredisParser",
},
"KEY_PREFIX": "sessions",
},
"api": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": config("REDIS_API_URL", default=redis_url),
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"PARSER_CLASS": "redis.connection.HiredisParser",
"COMPRESSOR": "django_redis.compressors.zlib.ZlibCompressor",
},
"KEY_PREFIX": "api",
},
}
# Use Redis for sessions in production
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "sessions"
# =============================================================================
# Production Static Files Configuration
# =============================================================================
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Update STORAGES for Django 4.2+
STORAGES["staticfiles"]["BACKEND"] = ( # noqa: F405
"whitenoise.storage.CompressedManifestStaticFilesStorage"
)
# =============================================================================
# Production REST Framework Settings
# =============================================================================
# Only JSON renderer in production (no browsable API)
REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] = [ # noqa: F405
"rest_framework.renderers.JSONRenderer",
]
# =============================================================================
# Production Logging Configuration
# =============================================================================
# Structured JSON logging for log aggregation services
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
@@ -59,69 +144,121 @@ LOGGING = {
"format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
"style": "{",
},
"json": {
"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": (
"%(levelname)s %(asctime)s %(module)s %(process)d "
"%(thread)d %(message)s %(pathname)s %(lineno)d"
),
},
"simple": {
"format": "{levelname} {message}",
"style": "{",
},
},
"filters": {
"require_debug_false": {
"()": "django.utils.log.RequireDebugFalse",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "json", # JSON for container environments
},
"file": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"filename": base.BASE_DIR / "logs" / "django.log",
"filename": BASE_DIR / "logs" / "django.log", # noqa: F405
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "verbose",
"formatter": "json",
},
"error_file": {
"level": "ERROR",
"class": "logging.handlers.RotatingFileHandler",
"filename": base.BASE_DIR / "logs" / "django_error.log",
"filename": BASE_DIR / "logs" / "django_error.log", # noqa: F405
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "verbose",
"formatter": "json",
},
"security_file": {
"level": "INFO",
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "security.log", # noqa: F405
"maxBytes": 1024 * 1024 * 10, # 10MB
"backupCount": 10,
"formatter": "json",
},
"mail_admins": {
"level": "ERROR",
"filters": ["require_debug_false"],
"class": "django.utils.log.AdminEmailHandler",
"include_html": True,
},
},
"root": {
"handlers": ["file"],
"handlers": ["console", "file"],
"level": "INFO",
},
"loggers": {
"django": {
"handlers": ["file", "error_file"],
"handlers": ["console", "file", "error_file"],
"level": "INFO",
"propagate": False,
},
"django.request": {
"handlers": ["console", "error_file", "mail_admins"],
"level": "ERROR",
"propagate": False,
},
"django.security": {
"handlers": ["console", "security_file"],
"level": "WARNING",
"propagate": False,
},
"thrillwiki": {
"handlers": ["file", "error_file"],
"handlers": ["console", "file", "error_file"],
"level": "INFO",
"propagate": False,
},
"security": {
"handlers": ["console", "security_file"],
"level": "INFO",
"propagate": False,
},
"celery": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": False,
},
},
}
# Static files collection for production
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# =============================================================================
# Sentry Integration (Optional)
# =============================================================================
# Configure Sentry for error tracking in production
# Cache settings for production (Redis recommended)
redis_url = base.config("REDIS_URL", default=None)
if redis_url:
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": redis_url,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
},
}
}
SENTRY_DSN = config("SENTRY_DSN", default="")
# Use Redis for sessions in production
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
if SENTRY_DSN:
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.redis import RedisIntegration
REST_FRAMEWORK = {
"DEFAULT_RENDERER_CLASSES": [
"rest_framework.renderers.JSONRenderer",
],
}
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[
DjangoIntegration(),
CeleryIntegration(),
RedisIntegration(),
],
environment=config("SENTRY_ENVIRONMENT", default="production"),
traces_sample_rate=config(
"SENTRY_TRACES_SAMPLE_RATE", default=0.1, cast=float
),
send_default_pii=False, # Don't send PII to Sentry
attach_stacktrace=True,
)

View File

@@ -1,65 +1,113 @@
"""
Test settings for thrillwiki project.
This module extends base.py with test-specific configurations:
- Debug disabled for realistic testing
- PostGIS database for GeoDjango support
- In-memory cache for isolation
- Simplified password hashing for speed
- Disabled logging to reduce noise
"""
from .base import * # noqa: F403,F405
import os
from .base import * # noqa: F401,F403
# =============================================================================
# Test Core Settings
# =============================================================================
# Test-specific settings
DEBUG = False
# Use in-memory database for faster tests
# =============================================================================
# Test Database Configuration
# =============================================================================
# Use PostGIS for GeoDjango support - required for spatial queries in tests
DATABASES = {
"default": {
"ENGINE": "django.contrib.gis.db.backends.spatialite",
"NAME": ":memory:",
"ENGINE": "django.contrib.gis.db.backends.postgis",
"NAME": os.environ.get("TEST_DB_NAME", "test_thrillwiki"),
"USER": os.environ.get("TEST_DB_USER", "postgres"),
"PASSWORD": os.environ.get("TEST_DB_PASSWORD", "postgres"),
"HOST": os.environ.get("TEST_DB_HOST", "localhost"),
"PORT": os.environ.get("TEST_DB_PORT", "5432"),
"TEST": {
"NAME": os.environ.get("TEST_DB_NAME", "test_thrillwiki"),
},
}
}
# Use in-memory cache for tests
# =============================================================================
# Test Cache Configuration
# =============================================================================
# Use in-memory cache for test isolation
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "test-cache",
}
},
"sessions": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "test-sessions",
},
"api": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "test-api",
},
}
# Disable migrations for faster tests
# =============================================================================
# Email Configuration
# =============================================================================
# Use in-memory email backend for test assertions
class DisableMigrations:
def __contains__(self, item):
return True
def __getitem__(self, item):
return None
MIGRATION_MODULES = DisableMigrations()
# Email backend for tests
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
# Password hashers for faster tests
# =============================================================================
# Password Hashing
# =============================================================================
# Use fast MD5 hashing for tests (never use in production!)
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]
# Disable logging during tests
# =============================================================================
# Logging Configuration
# =============================================================================
# Disable logging during tests to reduce noise
LOGGING_CONFIG = None
# Media files for tests
MEDIA_ROOT = BASE_DIR / "test_media"
# =============================================================================
# Static and Media Files
# =============================================================================
# Use test-specific directories
# Static files for tests
STATIC_ROOT = BASE_DIR / "test_static"
MEDIA_ROOT = BASE_DIR / "test_media" # noqa: F405
STATIC_ROOT = BASE_DIR / "test_static" # noqa: F405
# =============================================================================
# Security Settings
# =============================================================================
# Disable security features that interfere with testing
# Disable Turnstile for tests
TURNSTILE_SITE_KEY = "test-key"
TURNSTILE_SECRET_KEY = "test-secret"
# Test-specific middleware (remove caching middleware)
MIDDLEWARE = [m for m in MIDDLEWARE if "cache" not in m.lower()]
# =============================================================================
# Middleware Configuration
# =============================================================================
# Remove caching middleware for test isolation
MIDDLEWARE = [m for m in MIDDLEWARE if "cache" not in m.lower()] # noqa: F405
# =============================================================================
# Celery Configuration
# =============================================================================
# Run tasks synchronously during tests
# Celery settings for tests (if Celery is used)
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True