"""Django settings. All deployment-specific values come from environment variables (see .env.example).""" import ipaddress import os from pathlib import Path from urllib.parse import urlsplit from django.core.exceptions import ImproperlyConfigured BASE_DIR = Path(__file__).resolve().parent.parent def env(name, default=""): return os.environ.get(name, default).strip() def env_bool(name, default=False): value = env(name) if not value: return default return value.lower() in {"1", "true", "yes", "on"} def env_int(name, default): value = env(name) return int(value) if value else default def env_list(name, default=""): return [item.strip() for item in env(name, default).split(",") if item.strip()] DEBUG = env_bool("DEBUG") SECRET_KEY = env("SECRET_KEY") if not SECRET_KEY: raise ImproperlyConfigured("SECRET_KEY ist nicht gesetzt (siehe .env.example).") APP_BASE_URL = env("APP_BASE_URL", "http://localhost:8000").rstrip("/") _base = urlsplit(APP_BASE_URL) if _base.scheme not in {"http", "https"} or not _base.hostname: raise ImproperlyConfigured("APP_BASE_URL muss eine absolute http(s)-URL sein.") # 127.0.0.1/localhost are needed for the container healthcheck. ALLOWED_HOSTS = [_base.hostname, "localhost", "127.0.0.1", *env_list("ALLOWED_HOSTS")] CSRF_TRUSTED_ORIGINS = [f"{_base.scheme}://{_base.netloc}"] TRUSTED_PROXIES = [ ipaddress.ip_network(net, strict=False) for net in env_list("TRUSTED_PROXIES", "127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16") ] CLIENT_IP_HEADER = env("CLIENT_IP_HEADER") INSTALLED_APPS = [ "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "core", "accounts", "recipes", ] MIDDLEWARE = [ # Must run first: sanitises X-Forwarded-* before anything reads REMOTE_ADDR or is_secure(). "core.middleware.ProxyMiddleware", "django.middleware.security.SecurityMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware", "core.middleware.SecurityHeadersMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", # Every view requires login unless decorated with @login_not_required. "django.contrib.auth.middleware.LoginRequiredMiddleware", "accounts.middleware.ForcePasswordChangeMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] ROOT_URLCONF = "config.urls" WSGI_APPLICATION = "config.wsgi.application" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "recipes.context_processors.ai_status", ], }, }, ] if env("POSTGRES_HOST"): DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "HOST": env("POSTGRES_HOST"), "PORT": env("POSTGRES_PORT", "5432"), "NAME": env("POSTGRES_DB", "kochbuch"), "USER": env("POSTGRES_USER", "kochbuch"), "PASSWORD": env("POSTGRES_PASSWORD"), "CONN_MAX_AGE": 60, "CONN_HEALTH_CHECKS": True, } } else: # Local development and tests without Docker. DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": BASE_DIR / "db.sqlite3"}} DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "accounts.User" LOGIN_URL = "accounts:login" LOGIN_REDIRECT_URL = "recipes:list" LOGOUT_REDIRECT_URL = "accounts:login" PASSWORD_HASHERS = [ "django.contrib.auth.hashers.Argon2PasswordHasher", "django.contrib.auth.hashers.PBKDF2PasswordHasher", ] AUTH_PASSWORD_VALIDATORS = [ {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 10}}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] LOGIN_MAX_FAILURES_PER_EMAIL = env_int("LOGIN_MAX_FAILURES_PER_EMAIL", 5) LOGIN_MAX_FAILURES_PER_IP = env_int("LOGIN_MAX_FAILURES_PER_IP", 30) LOGIN_LOCKOUT_MINUTES = env_int("LOGIN_LOCKOUT_MINUTES", 15) IMAGE_MAX_UPLOAD_MB = env_int("IMAGE_MAX_UPLOAD_MB", 15) IMAGE_MAX_PER_RECIPE = env_int("IMAGE_MAX_PER_RECIPE", 10) COMMENT_RATE_LIMIT_PER_HOUR = env_int("COMMENT_RATE_LIMIT_PER_HOUR", 30) # Request body limit for non-file fields; files are limited per image above. DATA_UPLOAD_MAX_MEMORY_SIZE = 5 * 1024 * 1024 # --- AI (OpenAI) ------------------------------------------------------------------- OPENAI_API_KEY = env("OPENAI_API_KEY") OPENAI_MODEL = env("OPENAI_MODEL", "gpt-5-mini") # For reasoning models (gpt-5*, o*): minimal|low|medium|high. Leave empty for other models. OPENAI_REASONING_EFFORT = env("OPENAI_REASONING_EFFORT", "low") OPENAI_TIMEOUT = env_int("OPENAI_TIMEOUT", 90) # 0 = unlimited (default). Counted per call, including failed ones. AI_RATE_LIMIT_PER_USER_PER_DAY = env_int("AI_RATE_LIMIT_PER_USER_PER_DAY", 0) AI_RATE_LIMIT_GLOBAL_PER_DAY = env_int("AI_RATE_LIMIT_GLOBAL_PER_DAY", 0) INVITATION_VALID_DAYS = 7 PASSWORD_RESET_VALID_HOURS = 24 # --- Cookies & security headers --------------------------------------------- COOKIE_SECURE = env_bool("COOKIE_SECURE", default=True) SESSION_COOKIE_SECURE = COOKIE_SECURE CSRF_COOKIE_SECURE = COOKIE_SECURE # The __Host- prefix pins the cookie to this exact host; browsers only accept it over HTTPS. SESSION_COOKIE_NAME = "__Host-kb_session" if COOKIE_SECURE else "kb_session" CSRF_COOKIE_NAME = "__Host-kb_csrf" if COOKIE_SECURE else "kb_csrf" SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Lax" CSRF_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_AGE = 60 * 60 * 24 * 30 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_HSTS_SECONDS = env_int("HSTS_SECONDS", 31536000) SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_REFERRER_POLICY = "same-origin" SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin" X_FRAME_OPTIONS = "DENY" # The reverse proxy redirects to HTTPS; HSTS for other subdomains/preload is the domain owner's call. SILENCED_SYSTEM_CHECKS = ["security.W005", "security.W008", "security.W021"] CONTENT_SECURITY_POLICY = ( "default-src 'self'; img-src 'self' data: blob:; style-src 'self'; script-src 'self'; " "font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; " "form-action 'self'; frame-ancestors 'none'" ) PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), screen-wake-lock=(self)" # --- I18N ---------------------------------------------------------------------- LANGUAGE_CODE = "de" TIME_ZONE = env("TIME_ZONE", "Europe/Berlin") USE_I18N = True USE_TZ = True # --- Static & media -------------------------------------------------------------- STATIC_URL = "/static/" STATIC_ROOT = BASE_DIR / "staticfiles" STATICFILES_DIRS = [BASE_DIR / "static"] STORAGES = { "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, "staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}, } # Uploaded files are never served directly; they go through permission-checked views. MEDIA_ROOT = Path(env("MEDIA_ROOT", str(BASE_DIR / "data" / "media"))) LOGGING = { "version": 1, "disable_existing_loggers": False, "handlers": {"console": {"class": "logging.StreamHandler"}}, "root": {"handlers": ["console"], "level": "INFO"}, "loggers": {"django.request": {"level": "WARNING"}}, }