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)