An unticked checkbox on registration subscribes the verified address to the listmonk list (preconfirmed) once the account exists. The profile page gets a subscribe/unsubscribe toggle, and verified email changes follow through to listmonk. Newsletter outages never block accounts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
9.4 KiB
Python
Executable File
163 lines
9.4 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.")
|
|
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)
|