diff --git a/config/urls.py b/config/urls.py index 75a4b2cb..169962aa 100755 --- a/config/urls.py +++ b/config/urls.py @@ -39,6 +39,9 @@ from libraries.api import LibrarySearchView from libraries.views import ( LibraryDetail, LibraryListDispatcher, + CommitAuthorEmailCreateView, + VerifyCommitEmailView, + CommitEmailResendView, ) from news.feeds import AtomNewsFeed, RSSNewsFeed from news.views import ( @@ -231,6 +234,21 @@ urlpatterns = ( LibraryDetail.as_view(), name="library-detail", ), + path( + "libraries/commit_author_email_create/", + CommitAuthorEmailCreateView.as_view(), + name="commit-author-email-create", + ), + path( + "libraries/commit_author_email_verify//", + VerifyCommitEmailView.as_view(), + name="commit-author-email-verify", + ), + path( + "libraries/resend_author_email_verify//", + CommitEmailResendView.as_view(), + name="commit-author-email-verify-resend", + ), # Redirect for '/libs/' legacy boost.org urls. re_path( r"^libs/(?P[-\w]+)/?$", diff --git a/libraries/forms.py b/libraries/forms.py index c4681d6c..8cb4ac9b 100644 --- a/libraries/forms.py +++ b/libraries/forms.py @@ -4,7 +4,7 @@ from operator import attrgetter from dataclasses import dataclass, field from datetime import date, timedelta - +from django import forms from django.template.loader import render_to_string from django.db.models import F, Q, Count, OuterRef, Sum, When, Value, Case from django.forms import Form, ModelChoiceField, ModelForm, BooleanField @@ -23,6 +23,7 @@ from .models import ( Issue, Library, LibraryVersion, + CommitAuthorEmail, ) from libraries.constants import SUB_LIBRARIES from mailing_list.models import EmailData @@ -861,3 +862,41 @@ class CreateReportForm(CreateReportFullForm): "slack_channels": slack_channels, "slack": slack_stats, } + + +class CommitAuthorEmailForm(Form): + """ + This model is used to allow developers to claim a commit author + by email address. + """ + + email = forms.EmailField() + + class Meta: + fields = ["email"] + + def clean_email(self): + """Emails should have been created by the commit import process, so we need to + ensure the email exists.""" + email = self.cleaned_data.get("email") + commit_author_email = CommitAuthorEmail.objects.filter( + email_iexact=email + ).first() + msg = None + + if not commit_author_email: + msg = "Email address is not associated with any commits." + elif commit_author_email.author.user is not None: + msg = ( + "This email address is already associated with a user. Report an " + "issue if this is incorrect." + ) + elif commit_author_email.claim_verified: + msg = ( + "This email address has already been claimed and verified. Report an" + " issue if this is incorrect." + ) + if msg: + raise forms.ValidationError(msg) + + return email diff --git a/libraries/migrations/0031_commitauthoremail_claim_hash_and_more.py b/libraries/migrations/0031_commitauthoremail_claim_hash_and_more.py new file mode 100644 index 00000000..0b11e708 --- /dev/null +++ b/libraries/migrations/0031_commitauthoremail_claim_hash_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.16 on 2025-07-25 21:56 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ("libraries", "0030_commitauthor_user"), + ] + + operations = [ + migrations.AddField( + model_name="commitauthoremail", + name="claim_hash", + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name="commitauthoremail", + name="claim_hash_expiration", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AddField( + model_name="commitauthoremail", + name="claim_verified", + field=models.BooleanField(default=False), + ), + ] diff --git a/libraries/migrations/0032_merge_20250905_1825.py b/libraries/migrations/0032_merge_20250905_1825.py new file mode 100644 index 00000000..f8d85e92 --- /dev/null +++ b/libraries/migrations/0032_merge_20250905_1825.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2.16 on 2025-09-05 18:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("libraries", "0031_commitauthoremail_claim_hash_and_more"), + ("libraries", "0031_remove_library_active_development"), + ] + + operations = [] diff --git a/libraries/models.py b/libraries/models.py index d5fa43fc..a4f861ba 100644 --- a/libraries/models.py +++ b/libraries/models.py @@ -1,10 +1,14 @@ import re +import uuid +from datetime import timedelta from typing import Self from urllib.parse import urlparse from django.core.cache import caches from django.db import models, transaction from django.db.models import Sum +from django.urls import reverse +from django.utils import timezone from django.utils.functional import cached_property from django.utils.text import slugify from django.db.models.functions import Upper @@ -111,6 +115,32 @@ class CommitAuthor(models.Model): class CommitAuthorEmail(models.Model): author = models.ForeignKey(CommitAuthor, on_delete=models.CASCADE) email = models.CharField(unique=True) + claim_hash = models.UUIDField(null=True, blank=True) + claim_hash_expiration = models.DateTimeField(default=timezone.now) + claim_verified = models.BooleanField(default=False) + + def is_verification_email_expired(self): + return timezone.now() > self.claim_hash_expiration + + def trigger_verification_email(self, request): + self.author.user = request.user + self.author.save(update_fields=["user"]) + self.claim_hash = uuid.uuid4() + self.claim_hash_expiration = timezone.now() + timedelta(days=1) + self.save() + + url = request.build_absolute_uri( + reverse( + "commit-author-email-verify", + kwargs={"token": self.claim_hash}, + ) + ) + # here to avoid circular import + from .tasks import send_commit_author_email_verify_mail + + send_commit_author_email_verify_mail.delay(self.email, url) + + return CommitAuthorEmail.objects.filter(author__user=self.author.user) def __str__(self): return f"{self.author.name}: {self.email}" diff --git a/libraries/tasks.py b/libraries/tasks.py index 8078c4f9..9239812b 100644 --- a/libraries/tasks.py +++ b/libraries/tasks.py @@ -1,4 +1,5 @@ from celery import shared_task, chain +from django.core.mail import EmailMultiAlternatives from django.core.management import call_command import structlog @@ -359,3 +360,29 @@ def update_commit_author_user(author_id: int): email.author.user = user email.author.save() logger.info(f"Linked {user=} {user.pk=} to {email=} {email.author.pk=}") + + +@shared_task +def send_commit_author_email_verify_mail(commit_author_email, url): + logger.info(f"Sending verification email to {commit_author_email} with {url=}") + + text_content = ( + "Please verify your email address by clicking the following link: \n" + f"\n\n {url}\n\n If you did not request a commit author verification " + "you can safely ignore this email.\n" + ) + html_content = ( + "

Please verify your email address at the following link:

" + f"

Verify Email

" + "

If you did not request a commit author verification you can safely ignore " + "this email.

" + ) + msg = EmailMultiAlternatives( + subject="Please verify your email address", + body=text_content, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[commit_author_email], + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + logger.info(f"Verification email to {commit_author_email} sent") diff --git a/libraries/views.py b/libraries/views.py index 8471e181..b95424f1 100644 --- a/libraries/views.py +++ b/libraries/views.py @@ -3,23 +3,27 @@ import structlog from django.contrib import messages from django.db.models import F, Count, Prefetch -from django.http import Http404 +from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect +from django.template.response import TemplateResponse +from django.utils import timezone from django.utils.decorators import method_decorator from django.views import View from django.views.decorators.csrf import csrf_exempt -from django.views.generic import DetailView, ListView +from django.views.generic import DetailView, ListView, FormView, TemplateView from core.githubhelper import GithubAPIClient from versions.exceptions import BoostImportedDataException from versions.models import Version from .constants import README_MISSING +from .forms import CommitAuthorEmailForm from .mixins import VersionAlertMixin, BoostVersionMixin, ContributorMixin from .models import ( Category, Library, LibraryVersion, + CommitAuthorEmail, ) from .utils import ( get_view_from_cookie, @@ -334,3 +338,80 @@ class LibraryDetail(VersionAlertMixin, BoostVersionMixin, ContributorMixin, Deta self.kwargs.get("version_slug", LATEST_RELEASE_URL_PATH_STR), response ) return response + + +class CommitAuthorEmailCreateView(FormView): + template_name = "libraries/profile_commit_email_address_form.html" + form_class = CommitAuthorEmailForm + + def post(self, request, *args, **kwargs): + form = self.form_class(request.POST) + if not form.is_valid(): + return self.form_invalid(form) + + email = form.cleaned_data["email"] + commit_author_email = get_object_or_404(CommitAuthorEmail, email=email) + commit_email_addresses = commit_author_email.trigger_verification_email(request) + + return TemplateResponse( + request, + "libraries/profile_commit_email_addresses.html", + {"commit_email_addresses": commit_email_addresses}, + ) + + def form_invalid(self, form): + context = self.get_context_data(form=form) + return self.render_to_response(context, status=422) + + +class VerifyCommitEmailView(TemplateView): + """ + View to verify commit email addresses. + This is used to ensure that commit authors have verified their email addresses. + """ + + template_name = "libraries/profile_confirm_email_address.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + token = self.kwargs.get("token") + commit_author_email = ( + CommitAuthorEmail.objects.filter( + claim_hash=token, + claim_hash_expiration__gt=timezone.now(), + claim_verified=False, + ) + .prefetch_related("author") + .first() + ) + if not commit_author_email: + context["reason_failed"] = ( + "No valid commit author found or the token has expired. Please request " + "a new verification email." + ) + else: + commit_author_email.claim_hash_expiration = timezone.now() + commit_author_email.claim_verified = True + commit_author_email.author.user = self.request.user + commit_author_email.author.save() + commit_author_email.save() + context["commit_email"] = commit_author_email.email + context["confirmed"] = True + + return context + + +class CommitEmailResendView(TemplateView): + def post(self, request, *args, **kwargs): + commit_author_email = ( + CommitAuthorEmail.objects.filter( + claim_hash=self.kwargs.get("claim_hash"), + claim_verified=False, + author__user=self.request.user, + ) + .prefetch_related("author") + .first() + ) + commit_author_email.trigger_verification_email(request) + + return HttpResponse('') diff --git a/templates/includes/_header.html b/templates/includes/_header.html index b0926c18..9d121850 100644 --- a/templates/includes/_header.html +++ b/templates/includes/_header.html @@ -94,4 +94,7 @@ guide.classList.remove("hidden"); } } + document.body.addEventListener('htmx:configRequest', function(event) { + event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}'; + }); diff --git a/templates/libraries/profile_commit_email_address_form.html b/templates/libraries/profile_commit_email_address_form.html new file mode 100644 index 00000000..8749a797 --- /dev/null +++ b/templates/libraries/profile_commit_email_address_form.html @@ -0,0 +1,24 @@ +{% load static %} + +
+ {% csrf_token %} +

Add Commit Email Address

+
You must have access to the email address you enter here, as a verification email will be sent to it.
+ {% for field in form.visible_fields %} +
+ {% include 'includes/_form_input.html' with form=form field=field %} +
+ {% endfor %} + +
+ +
+ + +
diff --git a/templates/libraries/profile_commit_email_addresses.html b/templates/libraries/profile_commit_email_addresses.html new file mode 100644 index 00000000..34079db7 --- /dev/null +++ b/templates/libraries/profile_commit_email_addresses.html @@ -0,0 +1,34 @@ +{% load i18n %} + +
+

{% trans 'Commit Author Email Addresses' %}

+ {% if commit_email_addresses %} +
Email addresses used in library commits which are associated with your profile:
+
    + {% for cea in commit_email_addresses %} +
  • + {{ cea.email }} + {% if cea.claim_verified == False %} + {% if cea.is_verification_email_expired %} + - + {% else %} + - + {% endif %} + - + {% endif %} +
  • + {% endfor %} +
+ {% else %} +
There are currently no commit author email addresses associated with your profile. Click the button below to add one.
+ {% endif %} +
+ +
+
diff --git a/templates/libraries/profile_confirm_email_address.html b/templates/libraries/profile_confirm_email_address.html new file mode 100644 index 00000000..a789ab3e --- /dev/null +++ b/templates/libraries/profile_confirm_email_address.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} + +{% load static %} + +{% block content %} +

Commit Author Email Address Confirmation

+{% if confirmed %} +
Your email address has been successfully confirmed, and your account is now associated with {{commit_email}}
+{% else %} +
Your email address could not be confirmed. {{ reason_failed }}
+{% endif %} +{% endblock %} +{% block footer_js %} +{% endblock %} diff --git a/templates/modal.html b/templates/modal.html new file mode 100644 index 00000000..66f31729 --- /dev/null +++ b/templates/modal.html @@ -0,0 +1,41 @@ +
+ +
+ + diff --git a/templates/users/profile.html b/templates/users/profile.html index b7959c73..13b1dd7d 100644 --- a/templates/users/profile.html +++ b/templates/users/profile.html @@ -127,18 +127,8 @@ {% endif %} -
-

{% trans 'Commit Email Addresses' %}

-
This is a list of email addresses associated with your profile that have been used in commits.
-
    - {% for email in commit_email_addresses %} -
  • {{ email }}
  • - {% endfor %} -
- -
+ + {% include 'libraries/profile_commit_email_addresses.html' %}

{% trans 'Delete Account' %}

@@ -160,4 +150,6 @@
+ + {% include "modal.html" %} {% endblock %} diff --git a/users/views.py b/users/views.py index f8fc34c2..c2035cb0 100644 --- a/users/views.py +++ b/users/views.py @@ -100,7 +100,9 @@ class CurrentUserProfileView(LoginRequiredMixin, SuccessMessageMixin, TemplateVi instance=self.request.user.preferences ) context["social_accounts"] = self.get_social_accounts() - context["commit_email_addresses"] = self.get_commit_author_email_addresses() + context["commit_email_addresses"] = CommitAuthorEmail.objects.filter( + author__user=self.request.user + ) return context def get_social_accounts(self): @@ -116,11 +118,6 @@ class CurrentUserProfileView(LoginRequiredMixin, SuccessMessageMixin, TemplateVi ) return account_data - def get_commit_author_email_addresses(self): - return CommitAuthorEmail.objects.filter( - author__user=self.request.user - ).values_list("email", flat=True) - def post(self, request, *args, **kwargs): """ Process each form submission individually if present