Coverage for cookie / settings.py: 95%
83 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-12 10:49 +0000
1"""
2Django settings for cookie project.
3Single settings file for simplicity.
4"""
6import os
7from pathlib import Path
9import dj_database_url
10from django.core.exceptions import ImproperlyConfigured
12BASE_DIR = Path(__file__).resolve().parent.parent
14# ===========================================
15# Environment-based Configuration
16# ===========================================
18DEBUG = os.environ.get("DEBUG", "False").lower() == "true"
21def get_secret_key():
22 """Get secret key from environment or generate one."""
23 env_key = os.environ.get("SECRET_KEY")
24 if env_key:
25 return env_key
26 if DEBUG:
27 return "django-insecure-dev-key-change-in-production"
28 from django.core.management.utils import get_random_secret_key
30 return get_random_secret_key()
33SECRET_KEY = get_secret_key()
35ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
37# Use X-Forwarded-Host header (preserves port when behind nginx proxy)
38USE_X_FORWARDED_HOST = True
40# CSRF trusted origins (for reverse proxies)
41csrf_origins = os.environ.get("CSRF_TRUSTED_ORIGINS", "")
42CSRF_TRUSTED_ORIGINS = [o.strip() for o in csrf_origins.split(",") if o.strip()]
44# ===========================================
45# Authentication Mode
46# ===========================================
47# "home" (default): Profile-based sessions, no user accounts, no login required.
48# "passkey": WebAuthn passkey-only authentication with device code flow for legacy devices.
49_raw_auth_mode = os.environ.get("AUTH_MODE", "home")
50if _raw_auth_mode not in ("home", "passkey"):
51 import logging as _logging
52 import re as _re
54 # Sanitise for log output: strip control characters to prevent log injection
55 _safe_mode = _re.sub(r"[\x00-\x1f\x7f]", "", _raw_auth_mode)[:50]
56 _logging.getLogger("cookie.settings").warning(
57 "Unrecognised AUTH_MODE=%r — falling back to 'home'. Valid modes: 'home', 'passkey'.",
58 _safe_mode,
59 )
60 _raw_auth_mode = "home"
61AUTH_MODE = _raw_auth_mode
62COOKIE_VERSION = os.environ.get("COOKIE_VERSION", "dev")
64# ===========================================
65# WebAuthn / Passkey Configuration (Passkey Mode)
66# ===========================================
67WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "") # Derived from request hostname if empty
68WEBAUTHN_RP_NAME = os.environ.get("WEBAUTHN_RP_NAME", "Cookie")
69DEVICE_CODE_EXPIRY_SECONDS = int(os.environ.get("DEVICE_CODE_EXPIRY_SECONDS", "600"))
70DEVICE_CODE_MAX_ATTEMPTS = int(os.environ.get("DEVICE_CODE_MAX_ATTEMPTS", "5"))
72INSTALLED_APPS = [
73 "apps.core", # before django.contrib.auth to override createsuperuser
74 "django.contrib.auth",
75 "django.contrib.contenttypes",
76 "django.contrib.sessions",
77 "django.contrib.staticfiles",
78 "apps.profiles",
79 "apps.recipes",
80 "apps.ai",
81 "apps.legacy",
82]
84MIDDLEWARE = [
85 "django.middleware.security.SecurityMiddleware",
86 "whitenoise.middleware.WhiteNoiseMiddleware",
87 "django.contrib.sessions.middleware.SessionMiddleware",
88 "django.middleware.csrf.CsrfViewMiddleware",
89 "django.middleware.common.CommonMiddleware",
90 "django.middleware.clickjacking.XFrameOptionsMiddleware",
91 "apps.core.middleware.RequestIDMiddleware",
92 "apps.core.middleware.DeviceDetectionMiddleware",
93]
95# Add Django auth middleware in passkey mode (user accounts required)
96if AUTH_MODE == "passkey":
97 _session_idx = MIDDLEWARE.index("django.contrib.sessions.middleware.SessionMiddleware")
98 MIDDLEWARE.insert(_session_idx + 1, "django.contrib.auth.middleware.AuthenticationMiddleware")
100ROOT_URLCONF = "cookie.urls"
102TEMPLATES = [
103 {
104 "BACKEND": "django.template.backends.django.DjangoTemplates",
105 "DIRS": [],
106 "APP_DIRS": True,
107 "OPTIONS": {
108 "context_processors": [
109 "django.template.context_processors.request",
110 "apps.core.context_processors.app_context",
111 ],
112 },
113 },
114]
116WSGI_APPLICATION = "cookie.wsgi.application"
118# Database configuration
119# PostgreSQL is required — no SQLite fallback.
120# conn_max_age=60 and conn_health_checks=True are appropriate for single-server
121# deployment with Gunicorn. Upgrade path: use pgbouncer for multi-server.
122DATABASE_URL = os.environ.get("DATABASE_URL")
124if not DATABASE_URL:
125 raise ImproperlyConfigured(
126 "DATABASE_URL environment variable is required. "
127 "Set it to a PostgreSQL connection string, "
128 "e.g. postgres://user:pass@host:5432/dbname" # pragma: allowlist secret
129 )
131DATABASES = {
132 "default": dj_database_url.parse(
133 DATABASE_URL,
134 conn_max_age=60,
135 conn_health_checks=True,
136 )
137}
139LANGUAGE_CODE = "en-us"
140TIME_ZONE = "UTC"
141USE_I18N = True
142USE_TZ = True
144STATIC_URL = "static/"
145STATIC_ROOT = BASE_DIR / "staticfiles"
147# Include built frontend assets in static files (only if directory exists)
148_frontend_dist = BASE_DIR / "frontend" / "dist"
149STATICFILES_DIRS = [_frontend_dist] if _frontend_dist.exists() else []
151# WhiteNoise configuration for efficient static file serving
152STORAGES = {
153 "default": {
154 "BACKEND": "django.core.files.storage.FileSystemStorage",
155 },
156 "staticfiles": {
157 "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
158 },
159}
161WHITENOISE_ROOT = BASE_DIR / "rootfiles"
163MEDIA_URL = "/media/"
164data_dir = os.environ.get("DATA_DIR", str(BASE_DIR))
165MEDIA_ROOT = Path(data_dir) / "data" / "media" if not DEBUG else BASE_DIR / "media"
167DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
169# Cache configuration — database-backed for sharing across Gunicorn workers
170CACHES = {
171 "default": {
172 "BACKEND": "apps.core.cache.PostgreSafeDatabaseCache",
173 "LOCATION": "django_cache",
174 "OPTIONS": {
175 "MAX_ENTRIES": 5000,
176 },
177 }
178}
180# Search result cache: 5 days (shared globally across all profiles)
181SEARCH_CACHE_TIMEOUT = 432000 # 5 days in seconds
183# Session settings
184# Database-backed sessions: intentional for single-server deployment.
185# Upgrade path: switch to django.contrib.sessions.backends.cache with Redis
186# for multi-server deployments.
187SESSION_ENGINE = "django.contrib.sessions.backends.db"
188SESSION_COOKIE_AGE = 43200 # 12 hours
189SESSION_COOKIE_SECURE = not DEBUG
190SESSION_COOKIE_HTTPONLY = True
191SESSION_COOKIE_SAMESITE = "Lax"
192CSRF_COOKIE_SECURE = not DEBUG
193CSRF_COOKIE_HTTPONLY = False # SPA reads CSRF cookie via JavaScript
195CSRF_FAILURE_VIEW = "apps.core.views.csrf_failure"
197# Cross-Origin-Opener-Policy: applies to all Django-served responses including WhiteNoise static files
198SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin"
200# Referrer-Policy: strict-origin-when-cross-origin is the browser default and suits an SPA
201# (sends origin on cross-origin requests, full URL on same-origin). Set explicitly to match
202# Cloudflare's header and avoid duplicate Referrer-Policy values in responses.
203SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin"
205# X-Frame-Options: DENY is correct (CSP frame-ancestors 'self' is the modern equivalent).
206# Set explicitly to avoid Cloudflare adding a conflicting SAMEORIGIN value.
207X_FRAME_OPTIONS = "DENY"
209# Production security hardening (inactive in development)
210if not DEBUG:
211 SECURE_HSTS_SECONDS = 63072000 # 2 years, matching nginx.prod.conf
212 SECURE_HSTS_INCLUDE_SUBDOMAINS = True
213 SECURE_HSTS_PRELOAD = True
214 # Default false: when behind a TLS-terminating proxy (Cloudflare, Traefik),
215 # Django sees plain HTTP and redirect-loops. The proxy enforces HTTPS at the edge.
216 SECURE_SSL_REDIRECT = os.environ.get("SECURE_SSL_REDIRECT", "false").lower() == "true"
217 SECURE_REDIRECT_EXEMPT = [r"^api/system/health/$", r"^api/system/ready/$"]
218 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
220# Rate limiting (django-ratelimit)
221# Use a callable that safely extracts the first IP from X-Forwarded-For,
222# handling multi-proxy chains (Cloudflare → Traefik → nginx → Django).
223RATELIMIT_IP_META_KEY = "apps.core.middleware.get_client_ip"
225# Logging configuration
226LOG_FORMAT = os.environ.get("LOG_FORMAT", "text")
227LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
229_log_formatter = "json" if LOG_FORMAT == "json" else "verbose"
231LOGGING = {
232 "version": 1,
233 "disable_existing_loggers": False,
234 "formatters": {
235 "verbose": {
236 "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}",
237 "style": "{",
238 },
239 "simple": {
240 "format": "{levelname} {message}",
241 "style": "{",
242 },
243 "json": {
244 "()": "apps.core.logging.JSONFormatter",
245 },
246 },
247 "handlers": {
248 "console": {
249 "class": "logging.StreamHandler",
250 "formatter": _log_formatter,
251 },
252 },
253 "loggers": {
254 "apps.recipes": {
255 "handlers": ["console"],
256 "level": "INFO",
257 "propagate": False,
258 },
259 "apps.recipes.services": {
260 "handlers": ["console"],
261 "level": "INFO",
262 "propagate": False,
263 },
264 "apps.ai": {
265 "handlers": ["console"],
266 "level": "INFO",
267 "propagate": False,
268 },
269 "django.security": {
270 "handlers": ["console"],
271 "level": "WARNING",
272 "propagate": False,
273 },
274 "security": {
275 "handlers": ["console"],
276 "level": "INFO",
277 "propagate": False,
278 },
279 },
280 "root": {
281 "handlers": ["console"],
282 "level": LOG_LEVEL,
283 },
284}