"""Production defaults; development HTTP must be explicitly enabled.""" import json import os import re from decimal import Decimal, InvalidOperation from pathlib import Path from urllib.parse import urlsplit from django.core.exceptions import ImproperlyConfigured BASE_DIR = Path(__file__).absolute().parent.parent DEBUG = False ALLOW_HTTP = os.environ.get("MEMBERSHIP_ALLOW_HTTP", "0") == "1" SECRET_KEY = os.environ.get("MEMBERSHIP_SECRET_KEY", "") if len(SECRET_KEY) < 32: raise ImproperlyConfigured("Set MEMBERSHIP_SECRET_KEY to at least 32 random characters.") PUBLIC_URL = os.environ.get("MEMBERSHIP_PUBLIC_URL", "https://eom.dev").rstrip("/") public = urlsplit(PUBLIC_URL) if public.scheme not in ({"http", "https"} if ALLOW_HTTP else {"https"}) or not public.hostname or public.path or public.query or public.fragment or public.username: raise ImproperlyConfigured("MEMBERSHIP_PUBLIC_URL must be an HTTPS origin (no path).") ALLOWED_HOSTS = [public.hostname] ROOT_URLCONF = "config.urls" WSGI_APPLICATION = "config.wsgi.application" INSTALLED_APPS = ["django.contrib.contenttypes", "django.contrib.sessions", "members"] MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "members.middleware.SecurityHeaders", ] TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, "OPTIONS": {"context_processors": ["django.template.context_processors.request"]}}] DATABASES = {"default": { "ENGINE": "django.db.backends.postgresql", "NAME": os.environ.get("POSTGRES_DB", "membership"), "USER": os.environ.get("POSTGRES_USER", "membership"), "PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""), "HOST": os.environ.get("POSTGRES_HOST", "membership-db"), "PORT": os.environ.get("POSTGRES_PORT", "5432"), "OPTIONS": {"sslmode": os.environ.get("POSTGRES_SSLMODE", "prefer")}, }} if os.environ.get("MEMBERSHIP_SQLITE_PATH"): DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": os.environ["MEMBERSHIP_SQLITE_PATH"]}} USE_TZ = True TIME_ZONE = "UTC" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" SESSION_COOKIE_NAME = "eom_membership" SESSION_COOKIE_PATH = "/members/" SESSION_COOKIE_SECURE = not ALLOW_HTTP SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_SAMESITE = "Lax" SESSION_COOKIE_AGE = 3600 CSRF_COOKIE_NAME = "eom_membership_csrf" CSRF_COOKIE_PATH = "/members/" CSRF_COOKIE_SECURE = not ALLOW_HTTP SECURE_SSL_REDIRECT = not ALLOW_HTTP # Only enable behind an isolated proxy which OVERWRITES X-Forwarded-Proto. if os.environ.get("MEMBERSHIP_TRUST_PROXY", "0") == "1": SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_HSTS_SECONDS = 31536000 if not ALLOW_HTTP else 0 SECURE_CONTENT_TYPE_NOSNIFF = True X_FRAME_OPTIONS = "DENY" DATA_UPLOAD_MAX_MEMORY_SIZE = 262144 FILE_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024 DATA_UPLOAD_MAX_NUMBER_FILES = 1 AUTH_PASSWORD_VALIDATORS = [ {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 12}}, {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend") EMAIL_HOST = os.environ.get("EMAIL_HOST", "localhost") EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "587")) EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1" EMAIL_TIMEOUT = 10 DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "accounts@eom.dev") # OIDC issuer identifiers are exact strings, including a trailing slash. # Never normalize this value: discovery and ID-token iss must match it exactly. DEX_ISSUER = os.environ.get("DEX_ISSUER", "https://dex.eom.dev/") if urlsplit(DEX_ISSUER).scheme != "https" or not urlsplit(DEX_ISSUER).hostname: raise ImproperlyConfigured("DEX_ISSUER must use HTTPS.") DEX_CLIENT_ID = os.environ.get("DEX_CLIENT_ID", "www-membership") DEX_CLIENT_SECRET = os.environ.get("DEX_CLIENT_SECRET", "") DEX_CONNECTOR_ID = os.environ.get("DEX_CONNECTOR_ID", "ldap") LDAP_URL = os.environ.get("LDAP_URL", "") # Explicit opt-in for trusted internal networks; never a TLS-error fallback. LDAP_ALLOW_INSECURE = os.environ.get("LDAP_ALLOW_INSECURE", "0") == "1" LDAP_BIND_DN = os.environ.get("LDAP_BIND_DN", "") LDAP_BIND_PASSWORD = os.environ.get("LDAP_BIND_PASSWORD", "") LDAP_CA_CERT_FILE = os.environ.get("LDAP_CA_CERT_FILE") or None LDAP_ACCOUNT_BASE_DN = os.environ.get("LDAP_ACCOUNT_BASE_DN", "") LDAP_SEARCH_BASE_DN = os.environ.get("LDAP_SEARCH_BASE_DN", LDAP_ACCOUNT_BASE_DN) LDAP_PASSWORD_SCHEME = os.environ.get("LDAP_PASSWORD_SCHEME", "") LDAP_GROUP_PLACEHOLDER_DN = os.environ.get("LDAP_GROUP_PLACEHOLDER_DN", "") STRIPE_SECRET_KEY = os.environ.get("STRIPE_SECRET_KEY", "") STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "") STRIPE_LIVE_MODE = os.environ.get("STRIPE_LIVE_MODE", "0") == "1" STRIPE_PORTAL_CONFIGURATION = os.environ.get("STRIPE_PORTAL_CONFIGURATION", "") STRIPE_API_VERSION = "2025-03-31.basil" SHKEEPER_URL = os.environ.get("SHKEEPER_URL", "").rstrip("/") SHKEEPER_API_KEY = os.environ.get("SHKEEPER_API_KEY", "") SHKEEPER_CA_CERT_FILE = os.environ.get("SHKEEPER_CA_CERT_FILE", "") SHKEEPER_CURRENCIES = [value.strip() for value in os.environ.get("SHKEEPER_CURRENCIES", "").split(",") if value.strip()] SHKEEPER_ENABLED = bool(SHKEEPER_URL and SHKEEPER_API_KEY and SHKEEPER_CURRENCIES) if SHKEEPER_URL: shkeeper_origin = urlsplit(SHKEEPER_URL) if shkeeper_origin.scheme != "https" or not shkeeper_origin.hostname or shkeeper_origin.username or shkeeper_origin.path or shkeeper_origin.query or shkeeper_origin.fragment: raise ImproperlyConfigured("SHKEEPER_URL must be a certificate-verified HTTPS origin.") if any(not re.fullmatch(r"[A-Z0-9][A-Z0-9-]{0,31}", value) for value in SHKEEPER_CURRENCIES): raise ImproperlyConfigured("SHKEEPER_CURRENCIES must contain API crypto identifiers such as BTC or ETH-USDT.") LISTMONK_URL = os.environ.get("LISTMONK_URL", "").rstrip("/") LISTMONK_API_USER = os.environ.get("LISTMONK_API_USER", "") LISTMONK_API_TOKEN = os.environ.get("LISTMONK_API_TOKEN", "") LISTMONK_LIST_NAME = os.environ.get("LISTMONK_LIST_NAME", "eom.dev") LISTMONK_ENABLED = bool(LISTMONK_URL and LISTMONK_API_USER and LISTMONK_API_TOKEN and LISTMONK_LIST_NAME) if LISTMONK_URL: listmonk_origin = urlsplit(LISTMONK_URL) if listmonk_origin.scheme != "https" or not listmonk_origin.hostname or listmonk_origin.username or listmonk_origin.path or listmonk_origin.query or listmonk_origin.fragment: raise ImproperlyConfigured("LISTMONK_URL must be a certificate-verified HTTPS origin.") tier_file = os.environ.get("MEMBERSHIP_TIERS_FILE", "") MEMBERSHIP_TIERS = json.loads(os.environ["MEMBERSHIP_TIERS_JSON"]) if "MEMBERSHIP_TIERS_JSON" in os.environ else json.loads(Path(tier_file).read_text(encoding="utf-8")) if tier_file else {} if not isinstance(MEMBERSHIP_TIERS, dict): raise ImproperlyConfigured("MEMBERSHIP_TIERS_JSON must be an object.") prices = set() levels = set() for key, tier in MEMBERSHIP_TIERS.items(): if not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,47}", key) or not isinstance(tier, dict): raise ImproperlyConfigured("Invalid membership tier.") for field in ("label", "price_id", "group_dn", "price_label"): if not isinstance(tier.get(field), str) or not tier[field].strip(): raise ImproperlyConfigured(f"Tier {key} requires {field}.") if not tier["price_id"].startswith("price_") or tier["price_id"] in prices: raise ImproperlyConfigured("Each tier must have a unique Stripe Price ID.") prices.add(tier["price_id"]) level = tier.get("level") if not isinstance(level, int) or isinstance(level, bool) or level < 1 or level in levels: raise ImproperlyConfigured(f"Tier {key} must have a unique positive integer level; level 0 is reserved for free accounts.") levels.add(level) if "usd_amount" in tier: try: amount = Decimal(tier["usd_amount"]) if not amount.is_finite() or amount <= 0 or amount > 1000000 or amount != amount.quantize(Decimal("0.01")): raise ValueError except (InvalidOperation, ValueError, TypeError): raise ImproperlyConfigured(f"Tier {key} usd_amount must be a positive USD amount with at most two decimals.") from None # Separate from Stripe tiers so free accounts can receive service benefits too. from members.service_groups import validate_service_groups service_groups_file = os.environ.get("MEMBERSHIP_SERVICE_GROUPS_FILE", "") try: service_groups = json.loads(os.environ["MEMBERSHIP_SERVICE_GROUPS_JSON"]) if "MEMBERSHIP_SERVICE_GROUPS_JSON" in os.environ else json.loads(Path(service_groups_file).read_text(encoding="utf-8")) if service_groups_file else {} except (ValueError, OSError): raise ImproperlyConfigured("Cannot load membership service group configuration.") from None MEMBERSHIP_SERVICE_GROUPS = validate_service_groups(service_groups, levels)