137 lines
7.7 KiB
Python
Executable File
137 lines
7.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
|
|
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()
|
|
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"])
|
|
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
|