The Dex LDAP connector publishes uid, cn and mail as claims, so the profile page now offers just Name (cn), the verified email change and the password. The photo endpoint, SSH keys, addresses, phones and other extended attributes are no longer read or written; existing LDAP values are left untouched. Apache's profile upload limit drops back to the 256 KiB used by other member routes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
552 lines
33 KiB
Python
Executable File
552 lines
33 KiB
Python
Executable File
import hashlib
|
|
import logging
|
|
import re
|
|
import secrets
|
|
import uuid
|
|
from datetime import timedelta
|
|
from functools import wraps
|
|
from urllib.parse import urlsplit
|
|
|
|
import stripe
|
|
from authlib.integrations.django_client import OAuth
|
|
from django.conf import settings
|
|
from django.core.validators import validate_email
|
|
from django.core.mail import send_mail
|
|
from django.db import ProgrammingError, transaction
|
|
from django.http import HttpResponse
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
|
from django.db.models import Q
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django.utils.crypto import salted_hmac
|
|
from django.views.decorators.csrf import csrf_exempt
|
|
from django.views.decorators.debug import sensitive_post_parameters
|
|
from django.views.decorators.http import require_GET, require_POST, require_http_methods
|
|
|
|
from . import billing, directory, newsletter, profiles, shkeeper
|
|
from .forms import PasswordForm, RegistrationForm
|
|
from .service_groups import configured_groups, service_access_catalog
|
|
from .profile_forms import CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm
|
|
from .models import Account, CryptoInvoice, ManagedGroup, ProfileEmailChange, RateWindow, Registration, Subscription, WebhookEvent
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def page(request, **context):
|
|
return render(request, "members.html", context)
|
|
|
|
|
|
def limited(scope, value, limit, seconds=3600):
|
|
now = timezone.now()
|
|
window = int(now.timestamp()) // seconds
|
|
key = salted_hmac("membership-rate", f"{scope}:{value}:{window}").hexdigest()
|
|
with transaction.atomic():
|
|
entry, _ = RateWindow.objects.get_or_create(key=key, defaults={"expires_at": now + timedelta(seconds=seconds)})
|
|
entry = RateWindow.objects.select_for_update().get(pk=entry.pk)
|
|
entry.count += 1
|
|
entry.save(update_fields=["count"])
|
|
return entry.count > limit
|
|
|
|
|
|
def client_ip(request):
|
|
if getattr(settings, "SECURE_PROXY_SSL_HEADER", None):
|
|
return request.META.get("HTTP_X_MEMBERSHIP_CLIENT_IP", request.META.get("REMOTE_ADDR", ""))
|
|
return request.META.get("REMOTE_ADDR", "")
|
|
|
|
|
|
def account_required(view):
|
|
@wraps(view)
|
|
def wrapped(request, *args, **kwargs):
|
|
try:
|
|
request.member = Account.objects.get(pk=request.session.get("account_id"))
|
|
except (Account.DoesNotExist, ValueError, TypeError):
|
|
return redirect("members:home")
|
|
return view(request, *args, **kwargs)
|
|
return wrapped
|
|
|
|
|
|
def remember_groups(account):
|
|
for dn in configured_groups(settings.MEMBERSHIP_TIERS, settings.MEMBERSHIP_SERVICE_GROUPS):
|
|
ManagedGroup.objects.get_or_create(account=account, dn=dn)
|
|
|
|
|
|
@require_GET
|
|
def home(request):
|
|
account = Account.objects.filter(pk=request.session.get("account_id")).first() if request.session.get("account_id") else None
|
|
subscriptions = Subscription.objects.filter(account=account) if account else []
|
|
invoices = CryptoInvoice.objects.filter(account=account).order_by("-created_at")[:20] if account else []
|
|
# Presentation copies only: never mutate authorization/payment settings.
|
|
tiers = settings.MEMBERSHIP_TIERS
|
|
services = service_access_catalog(tiers, settings.MEMBERSHIP_SERVICE_GROUPS)
|
|
# Public total across every level (free and paid); never a per-tier breakdown.
|
|
member_count = Account.objects.count()
|
|
return page(request, account=account, tiers=tiers, services=services, subscriptions=subscriptions, crypto_invoices=invoices, shkeeper_enabled=settings.SHKEEPER_ENABLED, now=timezone.now(), member_count=member_count)
|
|
|
|
|
|
@require_http_methods(["GET", "POST"])
|
|
def register(request):
|
|
form = RegistrationForm(request.POST or None)
|
|
if request.method == "POST" and form.is_valid():
|
|
email = form.cleaned_data["email"]
|
|
if limited("signup-ip", client_ip(request), 20) or limited("signup-email", email, 3):
|
|
return HttpResponse("Please try again later.", status=429)
|
|
token = secrets.token_urlsafe(32)
|
|
digest = hashlib.sha256(token.encode()).hexdigest()
|
|
Registration.objects.create(token_hash=digest, uid=form.cleaned_data["uid"], email=email, newsletter=form.cleaned_data.get("newsletter", False), expires_at=timezone.now() + timedelta(hours=1))
|
|
url = settings.PUBLIC_URL + reverse("members:verify", args=[token])
|
|
try:
|
|
send_mail("Verify your eom.dev account", f"Finish creating your account (expires in one hour):\n\n{url}\n\nIf you did not request this, ignore this message. Existing accounts are never changed by this link.", settings.DEFAULT_FROM_EMAIL, [email])
|
|
except Exception as exc:
|
|
Registration.objects.filter(pk=digest).delete()
|
|
logger.error("Registration email failed kind=%s", type(exc).__name__)
|
|
return page(request, title="Email unavailable", message="Account email could not be sent. Please try again later.")
|
|
return page(request, title="Check your inbox", message="Follow the confirmation link in your email to choose a password and finish creating your free eom.dev account. If you already have an account, sign in instead.")
|
|
return page(request, title="Create your free account", form=form, submit="Send verification email", registration=True)
|
|
|
|
|
|
@sensitive_post_parameters("password", "confirm_password")
|
|
@require_http_methods(["GET", "POST"])
|
|
def verify(request, token):
|
|
if limited("verify-ip", client_ip(request), 60):
|
|
return HttpResponse("Please try again later.", status=429)
|
|
digest = hashlib.sha256(token.encode()).hexdigest()
|
|
# Exchange the emailed bearer token for server-side session state, then
|
|
# redirect so passwords are never posted to a URL containing that token.
|
|
if request.method == "GET":
|
|
if not Registration.objects.filter(pk=digest, expires_at__gt=timezone.now()).exists():
|
|
return page(request, title="Link expired", message="Request a new verification email.")
|
|
request.session["registration_hash"] = digest
|
|
return redirect("members:finish_registration")
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
@sensitive_post_parameters("password", "confirm_password")
|
|
@require_http_methods(["GET", "POST"])
|
|
def finish_registration(request):
|
|
digest = request.session.get("registration_hash")
|
|
if not digest or not Registration.objects.filter(pk=digest, expires_at__gt=timezone.now()).exists():
|
|
return page(request, title="Link expired", message="Request a new verification email.")
|
|
form = PasswordForm(request.POST or None)
|
|
if request.method == "POST" and form.is_valid():
|
|
if limited("provision-ip", client_ip(request), 10):
|
|
return HttpResponse("Please try again later.", status=429)
|
|
try:
|
|
with transaction.atomic():
|
|
registration = Registration.objects.select_for_update().get(pk=digest, expires_at__gt=timezone.now())
|
|
identity = directory.create_account(registration.uid, registration.email, form.cleaned_data["password"])
|
|
account = Account.objects.create(**identity)
|
|
remember_groups(account)
|
|
Registration.objects.filter(uid=registration.uid).delete()
|
|
request.session.pop("registration_hash", None)
|
|
subscribed = registration.newsletter and _subscribe_newsletter(account)
|
|
# Server-side, short-lived proof permits first binding of ONLY the
|
|
# account just provisioned in this browser, never an email match.
|
|
request.session["registration_identity"] = {"account_id": str(account.pk), "expires_at": (timezone.now() + timedelta(hours=1)).timestamp()}
|
|
message = "Your free eom.dev account is ready. Sign in to explore the community and its services, or choose an optional paid membership for additional benefits."
|
|
if subscribed:
|
|
message += " You're subscribed to premiere emails; you can change that from your profile."
|
|
elif registration.newsletter:
|
|
message += " We couldn't sign you up for premiere emails just now; you can try again from your profile."
|
|
return page(request, title="Account created", message=message)
|
|
except ValueError:
|
|
form.add_error(None, "An account with those details may already exist. Try signing in or contact us for help.")
|
|
except Exception as exc:
|
|
logger.error("Account provisioning failed kind=%s", type(exc).__name__)
|
|
form.add_error(None, "Account creation could not finish. Try signing in first, or contact us for help before retrying.")
|
|
return page(request, title="Choose your password", form=form, submit="Create free account")
|
|
|
|
|
|
def _subscribe_newsletter(account):
|
|
# The address was verified by the registration link, so it is preconfirmed.
|
|
# Best effort: the account already exists whatever happens here.
|
|
try:
|
|
return newsletter.subscribe(account.email, uid=account.uid) == newsletter.SUBSCRIBED
|
|
except Exception as exc:
|
|
logger.error("Newsletter subscribe failed account=%s kind=%s", account.pk, type(exc).__name__)
|
|
return False
|
|
|
|
|
|
def _newsletter_status(account):
|
|
if not newsletter.enabled():
|
|
return None
|
|
try:
|
|
return newsletter.status(account.email)
|
|
except Exception as exc:
|
|
logger.error("Newsletter status failed account=%s kind=%s", account.pk, type(exc).__name__)
|
|
return "unavailable"
|
|
|
|
|
|
def dex():
|
|
oauth = OAuth()
|
|
remote = oauth.register("dex", client_id=settings.DEX_CLIENT_ID, client_secret=settings.DEX_CLIENT_SECRET, server_metadata_url=settings.DEX_ISSUER.rstrip("/") + "/.well-known/openid-configuration", client_kwargs={"scope": "openid email profile groups federated:id", "code_challenge_method": "S256", "timeout": 10})
|
|
metadata = remote.load_server_metadata()
|
|
if metadata.get("issuer") != settings.DEX_ISSUER:
|
|
raise ValueError("Dex discovery issuer mismatch.")
|
|
metadata["id_token_signing_alg_values_supported"] = ["RS256"]
|
|
for endpoint in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
|
|
url = urlsplit(metadata.get(endpoint, ""))
|
|
if url.scheme != "https" or url.netloc != urlsplit(settings.DEX_ISSUER).netloc or url.username:
|
|
raise ValueError("Dex endpoints must be HTTPS URLs on the configured issuer origin.")
|
|
return remote
|
|
|
|
|
|
@require_POST
|
|
def login(request):
|
|
if limited("login-ip", client_ip(request), 60):
|
|
return HttpResponse(status=429)
|
|
try:
|
|
return dex().authorize_redirect(request, settings.PUBLIC_URL + reverse("members:callback"), nonce=secrets.token_urlsafe(32), connector_id=settings.DEX_CONNECTOR_ID)
|
|
except Exception as exc:
|
|
logger.error("Dex login unavailable kind=%s", type(exc).__name__)
|
|
return page(request, title="Sign-in unavailable", message="Please try again later.")
|
|
|
|
|
|
def link_identity(claims, *, registration_account_id=None):
|
|
if claims.get("iss") != settings.DEX_ISSUER or claims.get("email_verified") is not True or not isinstance(claims.get("sub"), str) or not claims["sub"] or len(claims["sub"]) > 512:
|
|
raise ValueError("A verified Dex identity is required.")
|
|
uid, email = claims.get("preferred_username"), claims.get("email")
|
|
if not isinstance(uid, str) or not re.fullmatch(r"[a-z][a-z0-9._-]{2,47}", uid):
|
|
raise ValueError("Dex must return a valid uid as preferred_username.")
|
|
if not isinstance(email, str) or len(email) > 254:
|
|
raise ValueError("A verified Dex email is required.")
|
|
validate_email(email)
|
|
federated = claims.get("federated_claims") or {}
|
|
if not isinstance(federated, dict) or federated.get("connector_id") != settings.DEX_CONNECTOR_ID or federated.get("user_id") != uid:
|
|
raise ValueError("Dex must authenticate the configured LDAP connector with uid as idAttr.")
|
|
# Authentication is entirely OIDC. Never contact LDAP or silently rebind an
|
|
# existing account to a different subject based on username/email matches.
|
|
with transaction.atomic():
|
|
account = Account.objects.select_for_update().filter(oidc_issuer=claims["iss"], oidc_sub=claims["sub"]).first()
|
|
if account:
|
|
if account.uid != uid:
|
|
raise ValueError("Dex identity username changed; an explicit migration is required.")
|
|
else:
|
|
account, created = Account.objects.select_for_update().get_or_create(uid=uid, defaults={"email": email, "oidc_issuer": claims["iss"], "oidc_sub": claims["sub"]})
|
|
if not created:
|
|
if account.oidc_sub:
|
|
if account.oidc_issuer != claims["iss"] or account.oidc_sub != claims["sub"]:
|
|
raise ValueError("Existing account requires an explicit Dex identity mapping.")
|
|
elif str(account.pk) != registration_account_id:
|
|
raise ValueError("Existing account requires an explicit Dex identity mapping.")
|
|
account.oidc_issuer = claims["iss"]
|
|
account.oidc_sub = claims["sub"]
|
|
account.email = email
|
|
account.save(update_fields=["oidc_issuer", "oidc_sub", "email"])
|
|
remember_groups(account)
|
|
return account
|
|
|
|
|
|
@require_GET
|
|
def callback(request):
|
|
try:
|
|
token = dex().authorize_access_token(request, claims_options={"iss": {"essential": True, "value": settings.DEX_ISSUER}}, leeway=30)
|
|
if not token.get("id_token"):
|
|
raise ValueError("ID token is required.")
|
|
proof = request.session.get("registration_identity") or {}
|
|
expires_at = proof.get("expires_at") if isinstance(proof, dict) else None
|
|
registration_account_id = proof.get("account_id") if isinstance(expires_at, (int, float)) and expires_at > timezone.now().timestamp() else None
|
|
account = link_identity(token.get("userinfo") or {}, registration_account_id=registration_account_id)
|
|
except ProgrammingError as exc:
|
|
# PostgreSQL diagnostics and SQL text can contain identity data. Log
|
|
# only a validated SQLSTATE, never the exception message or traceback.
|
|
sqlstate = getattr(exc.__cause__, "sqlstate", None)
|
|
sqlstate = sqlstate if isinstance(sqlstate, str) and re.fullmatch(r"[0-9A-Z]{5}", sqlstate) else "unknown"
|
|
hint = {
|
|
"42703": "undefined_column: check pending migrations, especially members.0004_dex_login_identity, against the web database",
|
|
"42P01": "undefined_table: check migrations and the configured database/schema",
|
|
"42501": "insufficient_privilege: check database role permissions",
|
|
}.get(sqlstate, "check database schema, permissions and matching application/migration images")
|
|
logger.error("Membership database failure during Dex callback kind=ProgrammingError sqlstate=%s; %s", sqlstate, hint)
|
|
return page(request, title="Sign-in could not be verified", message="Try signing in again, or contact the administrator.")
|
|
except Exception as exc:
|
|
logger.error("Dex callback rejected kind=%s", type(exc).__name__)
|
|
return page(request, title="Sign-in could not be verified", message="Try signing in again, or contact the administrator.")
|
|
# Do not persist access/refresh/ID tokens or stale group claims in sessions.
|
|
request.session.flush()
|
|
request.session["account_id"] = str(account.pk)
|
|
return redirect("members:home")
|
|
|
|
|
|
@require_POST
|
|
def logout(request):
|
|
request.session.flush()
|
|
return redirect("members:home")
|
|
|
|
|
|
def _profile_context(account, values):
|
|
return {"account": account, "profile_loaded": True, "form": ProfileForm(initial=profiles.initial(values)), "email_form": ProfileEmailForm(), "password_form": ProfilePasswordForm(), "email_values": values["mail"], "revision": profiles.revision(account, values)}
|
|
|
|
|
|
@sensitive_post_parameters()
|
|
@require_http_methods(["GET", "POST"])
|
|
@account_required
|
|
def profile(request):
|
|
member = request.member
|
|
# Bound before any password verification; never log posted values or LDAP
|
|
# diagnostic messages. Account and IP limits protect directory binds.
|
|
if limited("profile-read", str(member.pk), 120):
|
|
return HttpResponse("Please try again later.", status=429)
|
|
try:
|
|
values = profiles.read_profile(member)
|
|
except Exception as exc:
|
|
logger.error("Profile read failed account=%s kind=%s", member.pk, type(exc).__name__)
|
|
return render(request, "profile.html", {"account": member, "profile_loaded": False}, status=503)
|
|
context = _profile_context(member, values)
|
|
context.update(saved=request.GET.get("saved") == "1", email_sent=request.GET.get("email_sent") == "1", newsletter_status=_newsletter_status(member))
|
|
if request.method == "POST":
|
|
if limited("profile-write", str(member.pk), 20) or limited("profile-write-ip", client_ip(request), 40):
|
|
return HttpResponse("Please try again later.", status=429)
|
|
action = request.POST.get("action")
|
|
forms = {"profile": ("form", ProfileForm), "email": ("email_form", ProfileEmailForm), "password": ("password_form", ProfilePasswordForm)}
|
|
if action not in forms:
|
|
return HttpResponse(status=400)
|
|
form_key, form_class = forms[action]
|
|
form = form_class(request.POST)
|
|
context[form_key] = form
|
|
if form.is_valid():
|
|
try:
|
|
# Serialize directory pinning and edits against the worker.
|
|
with transaction.atomic():
|
|
member = Account.objects.select_for_update().get(pk=member.pk)
|
|
if action == "profile":
|
|
profiles.update_profile(member, form.cleaned_data, request.POST.get("revision", ""))
|
|
elif action == "password":
|
|
profiles.change_password(member, form.cleaned_data["current_password"], form.cleaned_data["password"])
|
|
else:
|
|
profiles.verify_password(member, form.cleaned_data["current_password"])
|
|
token = secrets.token_urlsafe(32)
|
|
digest = hashlib.sha256(token.encode()).hexdigest()
|
|
ProfileEmailChange.objects.filter(account=member).delete()
|
|
ProfileEmailChange.objects.create(token_hash=digest, account=member, email=form.cleaned_data["email"], ldap_uuid=member.ldap_uuid, expires_at=timezone.now() + timedelta(hours=1))
|
|
url = settings.PUBLIC_URL + reverse("members:verify_profile_email", args=[token])
|
|
send_mail("Verify your new eom.dev email", f"Confirm this email address for your account (expires in one hour):\n\n{url}\n\nYou must sign in to the same account and enter your current password to confirm. If you did not request this, ignore this message.", settings.DEFAULT_FROM_EMAIL, [form.cleaned_data["email"]])
|
|
logger.info("Profile change completed account=%s action=%s", member.pk, action)
|
|
if action == "password":
|
|
request.session.flush()
|
|
return page(request, title="Password changed", message="Your password was changed. Sign in again. Existing Dex and other service sessions are not automatically signed out.")
|
|
return redirect(reverse("members:profile") + ("?email_sent=1" if action == "email" else "?saved=1"))
|
|
except Exception as exc:
|
|
logger.error("Profile change failed account=%s kind=%s", member.pk, type(exc).__name__)
|
|
form.add_error(None, "Changes could not be completed. Check your current password and reload your profile before retrying. Contact the administrator if this continues.")
|
|
# Keep the original revision on validation errors, never silently allow
|
|
# a stale bound form to overwrite concurrently updated directory data.
|
|
if action == "profile":
|
|
context["revision"] = request.POST.get("revision", "")
|
|
return render(request, "profile.html", context)
|
|
|
|
|
|
@require_GET
|
|
@account_required
|
|
def verify_profile_email(request, token):
|
|
if limited("profile-email-verify", client_ip(request), 60):
|
|
return HttpResponse(status=429)
|
|
digest = hashlib.sha256(token.encode()).hexdigest()
|
|
if not ProfileEmailChange.objects.filter(pk=digest, account=request.member, expires_at__gt=timezone.now()).exists():
|
|
return page(request, title="Link unavailable", message="Request a new email change from your profile.")
|
|
request.session["profile_email_hash"] = digest
|
|
return redirect("members:confirm_profile_email")
|
|
|
|
|
|
@sensitive_post_parameters("current_password")
|
|
@require_http_methods(["GET", "POST"])
|
|
@account_required
|
|
def confirm_profile_email(request):
|
|
digest = request.session.get("profile_email_hash")
|
|
pending = ProfileEmailChange.objects.filter(pk=digest, account=request.member, expires_at__gt=timezone.now()).first()
|
|
if not pending:
|
|
return page(request, title="Link unavailable", message="Request a new email change from your profile.")
|
|
form = CurrentPasswordForm(request.POST if request.method == "POST" else None)
|
|
if request.method == "POST":
|
|
if limited("profile-write", str(request.member.pk), 20) or limited("profile-write-ip", client_ip(request), 40):
|
|
return HttpResponse("Please try again later.", status=429)
|
|
if form.is_valid():
|
|
try:
|
|
with transaction.atomic():
|
|
member = Account.objects.select_for_update().get(pk=request.member.pk)
|
|
pending = ProfileEmailChange.objects.select_for_update().get(pk=digest, account=member, expires_at__gt=timezone.now())
|
|
profiles.read_profile(member)
|
|
if member.ldap_uuid != pending.ldap_uuid:
|
|
raise ValueError("Directory identity changed.")
|
|
profiles.change_email(member, form.cleaned_data["current_password"], pending.email)
|
|
old_email = member.email
|
|
member.email = pending.email
|
|
member.save(update_fields=["email"])
|
|
ProfileEmailChange.objects.filter(account=member).delete()
|
|
request.session.pop("profile_email_hash", None)
|
|
if newsletter.enabled():
|
|
try:
|
|
newsletter.change_email(old_email, member.email)
|
|
except Exception as exc:
|
|
logger.error("Newsletter email change failed account=%s kind=%s", member.pk, type(exc).__name__)
|
|
request.session.cycle_key()
|
|
return redirect(reverse("members:profile") + "?saved=1")
|
|
except Exception as exc:
|
|
logger.error("Profile email confirmation failed account=%s kind=%s", request.member.pk, type(exc).__name__)
|
|
form.add_error(None, "Email change could not be completed. Check your current password or request a new verification link.")
|
|
return render(request, "profile.html", {"account": request.member, "confirm_email": True, "confirmation_email": pending.email, "confirmation_form": form})
|
|
|
|
|
|
@require_POST
|
|
@account_required
|
|
def newsletter_preference(request):
|
|
member = request.member
|
|
action = request.POST.get("action")
|
|
if not newsletter.enabled() or action not in ("subscribe", "unsubscribe"):
|
|
return HttpResponse(status=400)
|
|
if limited("newsletter", str(member.pk), 20) or limited("newsletter-ip", client_ip(request), 40):
|
|
return HttpResponse("Please try again later.", status=429)
|
|
try:
|
|
if action == "subscribe":
|
|
result = newsletter.subscribe(member.email, uid=member.uid)
|
|
else:
|
|
result = newsletter.unsubscribe(member.email)
|
|
logger.info("Newsletter preference changed account=%s action=%s result=%s", member.pk, action, result)
|
|
except Exception as exc:
|
|
logger.error("Newsletter preference failed account=%s kind=%s", member.pk, type(exc).__name__)
|
|
return redirect(reverse("members:profile") + "?newsletter=error#newsletter")
|
|
return redirect(reverse("members:profile") + "?newsletter=saved#newsletter")
|
|
|
|
|
|
def stripe_redirect(url, host):
|
|
parsed = urlsplit(url or "")
|
|
if parsed.scheme != "https" or parsed.hostname != host or parsed.username:
|
|
raise ValueError("Unexpected Stripe redirect host.")
|
|
return redirect(url)
|
|
|
|
|
|
@require_POST
|
|
@account_required
|
|
def checkout(request, tier):
|
|
config = settings.MEMBERSHIP_TIERS.get(tier)
|
|
if not config:
|
|
return HttpResponse(status=404)
|
|
if limited("checkout", str(request.member.pk), 20):
|
|
return HttpResponse(status=429)
|
|
try:
|
|
with transaction.atomic():
|
|
account = Account.objects.select_for_update().get(pk=request.member.pk)
|
|
if CryptoInvoice.objects.filter(account=account).filter(Q(credited_at__isnull=True) | Q(access_end__gt=timezone.now())).exists():
|
|
return page(request, title="Prepaid membership already exists", message="Finish your SHKeeper invoice or wait for prepaid membership to expire before starting a recurring Stripe subscription.")
|
|
api = billing.client()
|
|
if not account.stripe_customer:
|
|
customer = api.customers.create({"email": account.email, "metadata": {"membership_account": str(account.pk)}}, options={"idempotency_key": f"member-customer-{account.pk}"})
|
|
account.stripe_customer = customer["id"]
|
|
account.save(update_fields=["stripe_customer"])
|
|
# Canonical check prevents duplicate subscriptions even before sync.
|
|
existing = api.subscriptions.list({"customer": account.stripe_customer, "status": "all", "limit": 100})
|
|
if any(sub.get("status") not in {"canceled", "incomplete_expired"} for sub in existing.auto_paging_iter()):
|
|
return page(request, title="You already have a subscription", message="Use Manage billing to change your plan or payment details.")
|
|
if account.checkout_id:
|
|
session = api.checkout.sessions.retrieve(account.checkout_id)
|
|
if session.get("status") == "open":
|
|
return stripe_redirect(session["url"], "checkout.stripe.com")
|
|
account.checkout_key = uuid.uuid4()
|
|
session = api.checkout.sessions.create({
|
|
"mode": "subscription", "customer": account.stripe_customer,
|
|
"line_items": [{"price": config["price_id"], "quantity": 1}],
|
|
"client_reference_id": str(account.pk),
|
|
"subscription_data": {"metadata": {"membership_account": str(account.pk)}},
|
|
"success_url": settings.PUBLIC_URL + reverse("members:home") + "?checkout=complete",
|
|
"cancel_url": settings.PUBLIC_URL + reverse("members:home"),
|
|
}, options={"idempotency_key": f"member-checkout-{account.pk}-{account.checkout_key}"})
|
|
account.checkout_id = session["id"]
|
|
account.save(update_fields=["checkout_id", "checkout_key"])
|
|
return stripe_redirect(session["url"], "checkout.stripe.com")
|
|
except Exception as exc:
|
|
logger.error("Checkout failed account=%s kind=%s", request.member.pk, type(exc).__name__)
|
|
return page(request, title="Checkout unavailable", message="Please try again later. Membership is only granted after Stripe confirms payment.")
|
|
|
|
|
|
@require_POST
|
|
@account_required
|
|
def portal(request):
|
|
if not request.member.stripe_customer or not settings.STRIPE_PORTAL_CONFIGURATION:
|
|
return page(request, title="Billing unavailable", message="No billing account or billing portal configuration is available.")
|
|
if limited("portal", str(request.member.pk), 30):
|
|
return HttpResponse(status=429)
|
|
try:
|
|
session = billing.client().billing_portal.sessions.create({"customer": request.member.stripe_customer, "configuration": settings.STRIPE_PORTAL_CONFIGURATION, "return_url": settings.PUBLIC_URL + reverse("members:home")})
|
|
return stripe_redirect(session["url"], "billing.stripe.com")
|
|
except Exception as exc:
|
|
logger.error("Billing portal failed kind=%s", type(exc).__name__)
|
|
return page(request, title="Billing unavailable", message="Please try again later.")
|
|
|
|
|
|
@csrf_exempt
|
|
@require_POST
|
|
def webhook(request):
|
|
if not settings.STRIPE_WEBHOOK_SECRET:
|
|
return HttpResponse(status=503)
|
|
try:
|
|
event = stripe.Webhook.construct_event(request.body, request.headers.get("Stripe-Signature", ""), settings.STRIPE_WEBHOOK_SECRET, tolerance=300)
|
|
if event.get("livemode") != settings.STRIPE_LIVE_MODE or event.get("account"):
|
|
return HttpResponse(status=400)
|
|
obj = event["data"]["object"]
|
|
customer = billing._id(obj.get("customer"))
|
|
account = Account.objects.filter(stripe_customer=customer).first() if customer else None
|
|
# Store no raw payload/PII. The worker rereads canonical Stripe state;
|
|
# duplicate, delayed and reordered events can never restore expired time.
|
|
WebhookEvent.objects.get_or_create(stripe_id=event["id"], defaults={"account": account, "processed_at": None if account else timezone.now()})
|
|
except (ValueError, KeyError, TypeError, stripe.SignatureVerificationError):
|
|
return HttpResponse(status=400)
|
|
return HttpResponse(status=200)
|
|
|
|
|
|
@require_http_methods(["GET", "POST"])
|
|
@account_required
|
|
def crypto_checkout(request, tier):
|
|
config = settings.MEMBERSHIP_TIERS.get(tier)
|
|
if not settings.SHKEEPER_ENABLED or not config or not config.get("usd_amount"):
|
|
return HttpResponse(status=404)
|
|
if request.method == "GET":
|
|
return render(request, "crypto_checkout.html", {"tier": config, "tier_key": tier, "currencies": settings.SHKEEPER_CURRENCIES})
|
|
crypto = request.POST.get("crypto", "")
|
|
if crypto not in settings.SHKEEPER_CURRENCIES:
|
|
return HttpResponse("Choose an available cryptocurrency.", status=400)
|
|
if limited("crypto-checkout", str(request.member.pk), 20):
|
|
return HttpResponse(status=429)
|
|
try:
|
|
with transaction.atomic():
|
|
account = Account.objects.select_for_update().get(pk=request.member.pk)
|
|
invoice = CryptoInvoice.objects.filter(account=account, credited_at__isnull=True).first()
|
|
if invoice:
|
|
# Reuse the existing asset/tier even if a stale tab asks for a
|
|
# different one. Never orphan an address a user may have paid.
|
|
return redirect("members:crypto_invoice", invoice_id=invoice.pk)
|
|
if account.stripe_customer:
|
|
api = billing.client()
|
|
subs = api.subscriptions.list({"customer": account.stripe_customer, "status": "all", "limit": 100})
|
|
if any(sub.get("status") not in {"canceled", "incomplete_expired"} for sub in subs.auto_paging_iter()):
|
|
return page(request, title="Recurring membership already exists", message="Use Stripe billing to cancel renewal and wait until that subscription ends before paying through SHKeeper. This avoids double payments.")
|
|
if account.checkout_id:
|
|
checkout_session = api.checkout.sessions.retrieve(account.checkout_id)
|
|
if checkout_session.get("status") == "open":
|
|
return page(request, title="Stripe checkout is still open", message="Finish or let the existing Stripe Checkout expire before changing payment providers.")
|
|
if CryptoInvoice.objects.filter(account=account, access_end__gt=timezone.now()).exclude(tier=tier).exists():
|
|
return page(request, title="Another prepaid tier is active", message="Renew the same tier, or wait for the current prepaid membership to expire before changing tiers.")
|
|
invoice = CryptoInvoice.objects.create(account=account, tier=tier, crypto=crypto, usd_amount=config["usd_amount"])
|
|
remember_groups(account)
|
|
# Reserve/commit UUID BEFORE API call, so retries and worker recovery
|
|
# use the same external ID if the process dies after invoice creation.
|
|
shkeeper.prepare_invoice(account.pk, invoice.pk)
|
|
return redirect("members:crypto_invoice", invoice_id=invoice.pk)
|
|
except Exception as exc:
|
|
logger.error("SHKeeper checkout failed account=%s kind=%s", request.member.pk, type(exc).__name__)
|
|
return page(request, title="Crypto checkout unavailable", message="Please try again later. If an invoice was reserved, it remains on your membership page and will be retried automatically. Do not send funds until payment details appear.")
|
|
|
|
|
|
@require_GET
|
|
@account_required
|
|
def crypto_invoice(request, invoice_id):
|
|
invoice = get_object_or_404(CryptoInvoice, pk=invoice_id, account=request.member)
|
|
return render(request, "crypto_invoice.html", {"invoice": invoice, "tiers": settings.MEMBERSHIP_TIERS, "now": timezone.now()})
|
|
|
|
|
|
@csrf_exempt
|
|
@require_POST
|
|
def shkeeper_callback(request):
|
|
# SHKeeper documents no callback signature. Treat ALL incoming payloads as
|
|
# untrusted hints: do not parse them, write billing data, trigger network
|
|
# requests or grant access. The durable worker polls known invoice UUIDs.
|
|
return HttpResponse(status=200 if settings.SHKEEPER_ENABLED else 503)
|