This commit is contained in:
2026-10-29 13:28:11 -04:00
parent 7ec64194e5
commit 0bffd7d993
19 changed files with 1750 additions and 7 deletions
+137 -2
View File
@@ -11,6 +11,7 @@ import stripe
from authlib.integrations.django_client import OAuth
from django.conf import settings
from django.core.validators import validate_email
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.mail import send_mail
from django.db import ProgrammingError, transaction
from django.http import HttpResponse
@@ -23,9 +24,10 @@ 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, shkeeper
from . import billing, directory, profiles, shkeeper
from .forms import PasswordForm, RegistrationForm
from .models import Account, CryptoInvoice, ManagedGroup, RateWindow, Registration, Subscription, WebhookEvent
from .profile_forms import CurrentPasswordForm, ProfileEmailForm, ProfileForm, ProfilePasswordForm
from .models import Account, CryptoInvoice, ManagedGroup, ProfileEmailChange, RateWindow, Registration, Subscription, WebhookEvent
logger = logging.getLogger(__name__)
@@ -240,6 +242,139 @@ def logout(request):
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(), "has_photo": bool(values["jpegPhoto"]), "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")
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, request.FILES) if action == "profile" else 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 profile_photo(request):
if limited("profile-photo", str(request.member.pk), 120):
return HttpResponse(status=429)
try:
values = profiles.read_profile(request.member)
if not values["jpegPhoto"]:
return HttpResponse(status=404)
# Existing LDAP photos are untrusted too: rewrite before browser delivery.
raw = values["jpegPhoto"][0]
if not isinstance(raw, bytes):
raise ValueError("Invalid stored image.")
form = ProfileForm(files={"jpegPhoto": SimpleUploadedFile("photo.jpg", raw, content_type="image/jpeg")})
form.is_valid()
image = form.cleaned_data.get("jpegPhoto")
if not image:
return HttpResponse(status=404)
response = HttpResponse(image, content_type="image/jpeg")
response["Content-Disposition"] = 'inline; filename="profile.jpg"'
return response
except Exception as exc:
logger.error("Profile photo failed account=%s kind=%s", request.member.pk, type(exc).__name__)
return HttpResponse(status=404)
@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)
member.email = pending.email
member.save(update_fields=["email"])
ProfileEmailChange.objects.filter(account=member).delete()
request.session.pop("profile_email_hash", None)
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})
def stripe_redirect(url, host):
parsed = urlsplit(url or "")
if parsed.scheme != "https" or parsed.hostname != host or parsed.username: