Coverage for cookie / settings.py: 95%

83 statements  

« 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""" 

5 

6import os 

7from pathlib import Path 

8 

9import dj_database_url 

10from django.core.exceptions import ImproperlyConfigured 

11 

12BASE_DIR = Path(__file__).resolve().parent.parent 

13 

14# =========================================== 

15# Environment-based Configuration 

16# =========================================== 

17 

18DEBUG = os.environ.get("DEBUG", "False").lower() == "true" 

19 

20 

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 

29 

30 return get_random_secret_key() 

31 

32 

33SECRET_KEY = get_secret_key() 

34 

35ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1").split(",") 

36 

37# Use X-Forwarded-Host header (preserves port when behind nginx proxy) 

38USE_X_FORWARDED_HOST = True 

39 

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()] 

43 

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 

53 

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") 

63 

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")) 

71 

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] 

83 

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] 

94 

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") 

99 

100ROOT_URLCONF = "cookie.urls" 

101 

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] 

115 

116WSGI_APPLICATION = "cookie.wsgi.application" 

117 

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") 

123 

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 ) 

130 

131DATABASES = { 

132 "default": dj_database_url.parse( 

133 DATABASE_URL, 

134 conn_max_age=60, 

135 conn_health_checks=True, 

136 ) 

137} 

138 

139LANGUAGE_CODE = "en-us" 

140TIME_ZONE = "UTC" 

141USE_I18N = True 

142USE_TZ = True 

143 

144STATIC_URL = "static/" 

145STATIC_ROOT = BASE_DIR / "staticfiles" 

146 

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 [] 

150 

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} 

160 

161WHITENOISE_ROOT = BASE_DIR / "rootfiles" 

162 

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" 

166 

167DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" 

168 

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} 

179 

180# Search result cache: 5 days (shared globally across all profiles) 

181SEARCH_CACHE_TIMEOUT = 432000 # 5 days in seconds 

182 

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 

194 

195CSRF_FAILURE_VIEW = "apps.core.views.csrf_failure" 

196 

197# Cross-Origin-Opener-Policy: applies to all Django-served responses including WhiteNoise static files 

198SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin" 

199 

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" 

204 

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" 

208 

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") 

219 

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" 

224 

225# Logging configuration 

226LOG_FORMAT = os.environ.get("LOG_FORMAT", "text") 

227LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") 

228 

229_log_formatter = "json" if LOG_FORMAT == "json" else "verbose" 

230 

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} 

← Back to Dashboard