Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions config/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ def __getitem__(self, item):

EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"

# Tests must not share the dev server's Redis: waffle caches Flag rows from
# whichever database wrote last, so a test run would poison the dev cache
# with test-database flag state (e.g. the v3 flag going inactive after every
# pytest run until re-saved in the admin)
CACHES = {
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
"static_content": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "static_content",
},
}

MIGRATION_MODULES = DisableMigrations()

# User a faster password hasher
Expand Down
18 changes: 18 additions & 0 deletions config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@
CommitAuthorEmailCreateView,
VerifyCommitEmailView,
CommitEmailResendView,
V3CommitAuthorEmailCreateView,
V3CommitAuthorEmailWithdrawView,
V3CommitAuthorEmailResendView,
)
from news.feeds import AtomNewsFeed, RSSNewsFeed
from news.views import (
Expand Down Expand Up @@ -264,6 +267,21 @@
CommitEmailResendView.as_view(),
name="commit-author-email-verify-resend",
),
path(
"libraries/commit_author_email_create_v3/",
V3CommitAuthorEmailCreateView.as_view(),
name="v3-commit-author-email-create",
),
path(
"libraries/commit_author_email_v3/<int:pk>/withdraw/",
V3CommitAuthorEmailWithdrawView.as_view(),
name="v3-commit-author-email-withdraw",
),
path(
"libraries/commit_author_email_v3/<int:pk>/resend/",
V3CommitAuthorEmailResendView.as_view(),
name="v3-commit-author-email-resend",
),
# Redirect for '/libs/' legacy boost.org urls.
re_path(
r"^libs/(?P<library_slug>[-\w]+)/?$",
Expand Down
3 changes: 3 additions & 0 deletions libraries/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,6 @@
DOCKER_CONTAINER_URL_WEB = "http://web:8000"

RELEASE_REPORT_AUTHORS_PER_PAGE_THRESHOLD = 6

# How long a commit email verification link stays valid (seconds).
COMMIT_EMAIL_CLAIM_MAX_AGE = 24 * 60 * 60 # 24 hours
76 changes: 76 additions & 0 deletions libraries/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -601,3 +601,79 @@ def clean_email(self):
raise forms.ValidationError(msg)

return email


class V3CommitAuthorEmailForm(Form):
"""The v3 claim form: same field as the legacy CommitAuthorEmailForm
above (which is preserved untouched), but validating against the
claimed_by claim model where attribution is only bound at verification.
"""

email = forms.EmailField()

class Meta:
fields = ["email"]

def __init__(self, *args, user=None, **kwargs):
self.user = user
self.commit_author_email = None
super().__init__(*args, **kwargs)

def clean_email(self):
"""Emails should have been created by the commit import process, so we
need to ensure the email exists, then check the claim state of the row
itself before the author-level binding: author.user is also set by the
email/github matching heuristics, so on its own it only blocks a claim
when a different user has verified a sibling email - otherwise
verification is what settles ownership."""
email = self.cleaned_data.get("email")
commit_author_email = CommitAuthorEmail.objects.filter(
email__iexact=email
).first()

if not commit_author_email:
raise forms.ValidationError(
"Email address is not associated with any commits."
)

claimant = commit_author_email.claimed_by
if commit_author_email.claim_verified:
owner = claimant or commit_author_email.author.user
if self.user is not None and owner == self.user:
msg = "This email address is already associated with your account."
else:
msg = (
"This email address has already been claimed by another user. "
"Report an issue if this is incorrect."
)
raise forms.ValidationError(msg)

has_open_claim = (
commit_author_email.claim_hash is not None
and claimant is not None
and not commit_author_email.is_verification_email_expired()
)
if has_open_claim:
if self.user is not None and claimant == self.user:
msg = "A verification email is already pending for this address."
else:
msg = "This email address has a verification pending by another user."
raise forms.ValidationError(msg)

author_user = commit_author_email.author.user
if author_user is not None and author_user != self.user:
other_verified_sibling = (
commit_author_email.author.commitauthoremail_set.filter(
claim_verified=True
)
.exclude(pk=commit_author_email.pk)
.exists()
)
if other_verified_sibling:
raise forms.ValidationError(
"This email address is already associated with another user. "
"Report an issue if this is incorrect."
)

self.commit_author_email = commit_author_email
return email
28 changes: 28 additions & 0 deletions libraries/migrations/0042_commitauthoremail_claimed_by.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 6.0.2 on 2026-07-15 13:30

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("libraries", "0041_category_short_description"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.AddField(
model_name="commitauthoremail",
name="claimed_by",
field=models.ForeignKey(
blank=True,
help_text="Who asked to claim this email. Public attribution (author.user) is only bound at verification.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="commit_email_claims",
to=settings.AUTH_USER_MODEL,
),
),
]
30 changes: 30 additions & 0 deletions libraries/migrations/0043_backfill_commitauthoremail_claimed_by.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from django.db import migrations
from django.db.models import OuterRef, Subquery


def backfill_claimed_by(apps, schema_editor):
"""Historically a claim was recorded by binding author.user at ask time,
so for every row that has a claim token and a bound author, that user is
the best available signal of who asked. Covers verified claims and open
pending ones. Rows bound only by the email/github matching heuristics
have no claim_hash and are deliberately left alone.
"""
CommitAuthorEmail = apps.get_model("libraries", "CommitAuthorEmail")
CommitAuthor = apps.get_model("libraries", "CommitAuthor")
CommitAuthorEmail.objects.filter(
author__user__isnull=False, claim_hash__isnull=False
).update(
claimed_by_id=Subquery(
CommitAuthor.objects.filter(pk=OuterRef("author_id")).values("user_id")[:1]
)
)


class Migration(migrations.Migration):
dependencies = [
("libraries", "0042_commitauthoremail_claimed_by"),
]

operations = [
migrations.RunPython(backfill_claimed_by, migrations.RunPython.noop),
]
Loading
Loading