Files
www/membership/config/settings.py
T
2026-10-29 14:17:16 -04:00

154 lines
8.7 KiB
Python
Executable File

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