diff --git a/apps/dashboard/views.py b/apps/dashboard/views.py index fe23de1..7a4f22d 100644 --- a/apps/dashboard/views.py +++ b/apps/dashboard/views.py @@ -8,7 +8,7 @@ from django.utils.html import format_html from django.views import View from django.views.generic import ListView -from apps.submissions.models import Submission +from apps.submissions.models import Submission, VerifiedEmail class IndexView(ListView): @@ -119,6 +119,12 @@ class ConfirmEmailView(View): sub.confirmation_token = "" sub.save() + # Refresh / record the trust-list entry for this email so subsequent + # guest submissions from the same address (or any +tag variant of + # it) skip the `identifying` step for the next 30 days (plan.md §6). + if sub.guest_email: + VerifiedEmail.record_verification(sub.guest_email) + messages.success( request, format_html( diff --git a/apps/submissions/admin.py b/apps/submissions/admin.py index 2db0ad4..d14f5b3 100644 --- a/apps/submissions/admin.py +++ b/apps/submissions/admin.py @@ -1,7 +1,15 @@ from django.contrib import admin from django.utils.html import format_html -from .models import Filament, Submission +from .models import Filament, Submission, VerifiedEmail + + +@admin.register(VerifiedEmail) +class VerifiedEmailAdmin(admin.ModelAdmin): + list_display = ("email", "validated_at") + search_fields = ("email",) + readonly_fields = ("validated_at",) + ordering = ("-validated_at",) @admin.register(Filament) diff --git a/apps/submissions/forms.py b/apps/submissions/forms.py index 7667334..8e5f3a7 100644 --- a/apps/submissions/forms.py +++ b/apps/submissions/forms.py @@ -81,6 +81,9 @@ class SubmissionForm(forms.ModelForm): def __init__(self, *args, user=None, **kwargs): super().__init__(*args, **kwargs) + # Stash for `clean()` -- the per-email cap needs to know whether the + # incoming submission is OAuth (user.email) or guest (guest_email). + self.user = user # Filament dropdown shows only operator-curated, currently-loaded rows. self.fields["requested_filament"].queryset = Filament.objects.filter( @@ -118,6 +121,26 @@ class SubmissionForm(forms.ModelForm): def clean(self): cleaned = super().clean() + + # Per-email cap (plan.md §6). Run this BEFORE the source-type checks + # so a user already at quota doesn't get a misleading "pick a source" + # error message; the cap is the real reason their submission failed. + owner_email = ( + self.user.email + if (self.user and self.user.is_authenticated) + else cleaned.get("guest_email") + ) + if owner_email: + active = Submission.active_count_for_email(owner_email) + cap = Submission.MAX_ACTIVE_SUBMISSIONS_PER_EMAIL + if active >= cap: + raise forms.ValidationError( + f"You already have {active} active submission(s) -- " + f"that's the per-email cap of {cap}. Wait for some to " + f"finish printing (or be cleaned up after rejection) " + f"before submitting another." + ) + source_type = cleaned.get("source_type") stl_file = cleaned.get("stl_file") source_url = cleaned.get("source_url") diff --git a/apps/submissions/migrations/0002_verifiedemail.py b/apps/submissions/migrations/0002_verifiedemail.py new file mode 100644 index 0000000..1d4e9bf --- /dev/null +++ b/apps/submissions/migrations/0002_verifiedemail.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.5 on 2026-05-14 20:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("submissions", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="VerifiedEmail", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "email", + models.EmailField(db_index=True, max_length=254, unique=True), + ), + ("verified_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "ordering": ("-updated_at",), + }, + ), + ] diff --git a/apps/submissions/migrations/0003_backfill_verified_emails.py b/apps/submissions/migrations/0003_backfill_verified_emails.py new file mode 100644 index 0000000..3ade651 --- /dev/null +++ b/apps/submissions/migrations/0003_backfill_verified_emails.py @@ -0,0 +1,39 @@ +"""Backfill `VerifiedEmail` from any historical submission that already had +`email_confirmed=True` (i.e. the user clicked the confirmation link before +the trust list existed). One-shot; safe to re-run thanks to +`update_or_create`.""" + +from django.db import migrations + + +def backfill_verified_emails(apps, schema_editor): + Submission = apps.get_model("submissions", "Submission") + VerifiedEmail = apps.get_model("submissions", "VerifiedEmail") + + # `update_or_create` keeps the migration idempotent. + seen: set[str] = set() + qs = Submission.objects.filter(email_confirmed=True).exclude( + guest_email__isnull=True + ).exclude(guest_email="").values_list("guest_email", flat=True) + for email in qs: + email = email.strip().lower() + if not email or email in seen: + continue + seen.add(email) + VerifiedEmail.objects.update_or_create(email=email) + + +def noop_reverse(apps, schema_editor): + """We don't try to undo the backfill -- the trust list is a forward-only + derived artefact; rolling back the migration leaves the rows alone.""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("submissions", "0002_verifiedemail"), + ] + + operations = [ + migrations.RunPython(backfill_verified_emails, noop_reverse), + ] diff --git a/apps/submissions/migrations/0004_email_normalization.py b/apps/submissions/migrations/0004_email_normalization.py new file mode 100644 index 0000000..9ee2cb0 --- /dev/null +++ b/apps/submissions/migrations/0004_email_normalization.py @@ -0,0 +1,51 @@ +"""Schema changes for the email normalisation / TTL / cap work. + +- `Submission.canonical_email`: new indexed column populated by + `Submission.save()`. Used to count active submissions per email for the + 10-cap, and to look up the `VerifiedEmail` trust list. +- `VerifiedEmail.verified_at` -> `validated_at`: keeps the data, drops the + `auto_now_add` so `update_or_create` can roll the timestamp forward on + every re-confirmation (rolling 30-day TTL). +- `VerifiedEmail.updated_at`: removed -- `validated_at` IS the most recent + confirmation timestamp now, no need for a second column. + +The data backfill (populate canonical_email, re-normalise existing +VerifiedEmail rows) lives in 0005_normalize_existing_data so this +migration stays a clean schema-only change. +""" + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("submissions", "0003_backfill_verified_emails"), + ] + + operations = [ + migrations.AddField( + model_name="submission", + name="canonical_email", + field=models.EmailField(blank=True, db_index=True, max_length=254), + ), + migrations.RenameField( + model_name="verifiedemail", + old_name="verified_at", + new_name="validated_at", + ), + migrations.RemoveField( + model_name="verifiedemail", + name="updated_at", + ), + migrations.AlterField( + model_name="verifiedemail", + name="validated_at", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + migrations.AlterModelOptions( + name="verifiedemail", + options={"ordering": ("-validated_at",)}, + ), + ] diff --git a/apps/submissions/migrations/0005_normalize_existing_data.py b/apps/submissions/migrations/0005_normalize_existing_data.py new file mode 100644 index 0000000..c4ecd4c --- /dev/null +++ b/apps/submissions/migrations/0005_normalize_existing_data.py @@ -0,0 +1,92 @@ +"""One-shot data backfill for the schema change in 0004: + +1. Populate `Submission.canonical_email` for every existing row by deriving + it from `submitted_by.email` (OAuth) or `guest_email` (guest) and + running through the same normaliser the live `save()` uses. +2. Re-normalise every `VerifiedEmail.email` already in the table. Rows that + collapse to the same canonical form are deduped: we keep the row with + the most recent `validated_at` and delete the others. + +Defensive: both passes use `update_fields=` and `update_or_create`-style +logic so re-running the migration is a no-op once it's been applied. +""" + +from django.db import migrations + + +def _normalize_email(email): + if not email or "@" not in email: + return (email or "").lower() + local, _, domain = email.lower().rpartition("@") + if "+" in local: + local = local.split("+", 1)[0] + return f"{local}@{domain}" + + +def forward(apps, schema_editor): + Submission = apps.get_model("submissions", "Submission") + VerifiedEmail = apps.get_model("submissions", "VerifiedEmail") + User = apps.get_model("auth", "User") + + # ---- Submission.canonical_email ----------------------------------------- + # Pull all related users up front so we don't do an O(N) round-trip + # per submission. + user_emails = dict( + User.objects.exclude(email="").values_list("pk", "email") + ) + to_update = [] + for sub in Submission.objects.all().only( + "pk", "submitted_by_id", "guest_email", "canonical_email" + ): + owner_email = "" + if sub.submitted_by_id and user_emails.get(sub.submitted_by_id): + owner_email = user_emails[sub.submitted_by_id] + elif sub.guest_email: + owner_email = sub.guest_email + new = _normalize_email(owner_email) + if new != sub.canonical_email: + sub.canonical_email = new + to_update.append(sub) + if to_update: + Submission.objects.bulk_update(to_update, ["canonical_email"], batch_size=500) + + # ---- VerifiedEmail re-normalisation + dedup ---------------------------- + # First pass: pick the surviving row per normalised form (most recent + # validated_at wins). Delete the losers. + survivors: dict[str, tuple[int, object]] = {} # norm -> (pk, validated_at) + for row in VerifiedEmail.objects.all().only("pk", "email", "validated_at"): + norm = _normalize_email(row.email) + if not norm: + row.delete() + continue + prev = survivors.get(norm) + if prev is None: + survivors[norm] = (row.pk, row.validated_at) + else: + prev_pk, prev_at = prev + if row.validated_at > prev_at: + VerifiedEmail.objects.filter(pk=prev_pk).delete() + survivors[norm] = (row.pk, row.validated_at) + else: + row.delete() + + # Second pass: rewrite the surviving row's email to its normalised form + # (no-op when already normalised; safe because all duplicates are gone). + for norm, (pk, _at) in survivors.items(): + VerifiedEmail.objects.filter(pk=pk).update(email=norm) + + +def reverse(apps, schema_editor): + """The forward pass is a derived backfill; there's nothing meaningful + to undo. Leaving rows alone is the right thing on rollback.""" + + +class Migration(migrations.Migration): + + dependencies = [ + ("submissions", "0004_email_normalization"), + ] + + operations = [ + migrations.RunPython(forward, reverse), + ] diff --git a/apps/submissions/models.py b/apps/submissions/models.py index b83dd6d..19eaeeb 100644 --- a/apps/submissions/models.py +++ b/apps/submissions/models.py @@ -7,12 +7,38 @@ are documented in plan.md §7. from __future__ import annotations import uuid +from datetime import timedelta import namesgenerator from django.conf import settings from django.core.validators import FileExtensionValidator from django.db import models from django.urls import reverse +from django.utils import timezone + + +def normalize_email(email: str) -> str: + """Canonicalise an email for per-email accounting (the trust list AND + the active-submission cap). + + - Lowercase the entire address. + - Strip Gmail-style `+tag` from the local part: + `user+anything@host.com` -> `user@host.com`. + + Same return value for `User@Gmail.com`, `user+a@gmail.com`, + `user+b@gmail.com`. Returns `""` for falsy / malformed input. + """ + if not email or "@" not in email: + return (email or "").lower() + local, _, domain = email.lower().rpartition("@") + if "+" in local: + local = local.split("+", 1)[0] + return f"{local}@{domain}" + + +# Email trust lasts 30 days from `validated_at`; after that the user has to +# click a fresh confirmation link to re-prove inbox ownership. +EMAIL_TRUST_TTL = timedelta(days=30) def _validate_stl_size(uploaded_file) -> None: @@ -27,6 +53,61 @@ def _validate_stl_size(uploaded_file) -> None: ) +class VerifiedEmail(models.Model): + """An email address that has confirmed at least one hamprint submission. + + Stored **normalised** (lowercased, `+tag` stripped) so + `user@gmail.com`, `user+a@gmail.com`, and `user+b@gmail.com` all + collapse to a single row. + + Each entry carries a single `validated_at` timestamp. Trust expires + after `EMAIL_TRUST_TTL` (30 days); after that the next guest submission + from the same address falls back to `identifying` and gets a fresh + confirmation link, which on success bumps `validated_at` back to now + via `record_verification()`. + + Trade-off worth knowing: anyone who knows a verified address can use + it to bypass confirmation for the next 30 days. We accept that for + hamlab's small-scale, operator-moderated workflow -- the operator + still has to manually approve every submission before it prints. + Operators can also revoke an entry via the admin to force the next + submission back through the welcome-email flow. + """ + + email = models.EmailField(unique=True, db_index=True) + validated_at = models.DateTimeField(default=timezone.now) + + class Meta: + ordering = ("-validated_at",) + + def __str__(self) -> str: + return self.email + + @classmethod + def is_trusted(cls, email: str) -> bool: + """True if `email` (after normalisation) has a fresh trust entry -- + i.e., `validated_at` is within `EMAIL_TRUST_TTL` of now.""" + norm = normalize_email(email) + if not norm: + return False + cutoff = timezone.now() - EMAIL_TRUST_TTL + return cls.objects.filter(email=norm, validated_at__gte=cutoff).exists() + + @classmethod + def record_verification(cls, email: str) -> "VerifiedEmail | None": + """Mark `email` as freshly verified. Normalises first, then + `update_or_create`-s with a current `validated_at`. Returns the + row (or None if `email` was falsy).""" + norm = normalize_email(email) + if not norm: + return None + obj, _ = cls.objects.update_or_create( + email=norm, + defaults={"validated_at": timezone.now()}, + ) + return obj + + class Filament(models.Model): """Operator-curated filament inventory (plan.md §5). @@ -98,6 +179,15 @@ class Submission(models.Model): ) TERMINAL_STATUSES = (Status.COMPLETED, Status.FAILED, Status.REJECTED) + # Per-email rate limit. A single email address (after normalisation -- + # see `normalize_email`) is allowed at most this many submissions + # whose status is NOT in `STATUSES_EXCLUDED_FROM_LIMIT`. The exclusion + # is intentional: `printing` jobs are short-lived and operator-driven, + # and `rejected` jobs are already cleaned up after 24 h, so neither + # should count against the user's quota. + MAX_ACTIVE_SUBMISSIONS_PER_EMAIL = 10 + STATUSES_EXCLUDED_FROM_LIMIT = (Status.PRINTING, Status.REJECTED) + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) slug = models.CharField(max_length=64, unique=True, db_index=True) @@ -159,6 +249,13 @@ class Submission(models.Model): created_at = models.DateTimeField(auto_now_add=True, db_index=True) updated_at = models.DateTimeField(auto_now=True) + # Normalised email (lowercased, +tag stripped) of whoever owns this row + # -- the OAuth user if `submitted_by` is set, otherwise the guest. Used + # for the active-submissions cap + the `VerifiedEmail` trust lookup, + # both of which need to treat `user+a@gmail.com` and `user@gmail.com` + # as the same mailbox. Populated in `save()`. + canonical_email = models.EmailField(blank=True, db_index=True) + class Meta: ordering = ("-created_at",) constraints = [ @@ -182,11 +279,38 @@ class Submission(models.Model): def save(self, *args, **kwargs): """Auto-generate `slug` on first save so any creation path -- admin, `SubmitView`, fixtures, `objects.create()` -- gets a Docker-style - codename without callers having to remember to set one.""" + codename without callers having to remember to set one. Also keeps + `canonical_email` in sync with whichever side (OAuth user / guest) + currently owns the row, so the per-email cap and trust list don't + depend on the caller remembering to set it.""" if not self.slug: self.slug = self._generate_unique_slug() + # Re-derive canonical_email every save: cheap, and survives an + # operator flipping `submitted_by` / `guest_email` in admin. + owner_email = "" + if self.submitted_by_id and self.submitted_by and self.submitted_by.email: + owner_email = self.submitted_by.email + elif self.guest_email: + owner_email = self.guest_email + self.canonical_email = normalize_email(owner_email) super().save(*args, **kwargs) + @classmethod + def active_count_for_email(cls, email: str) -> int: + """Return how many of this email's submissions count against the + `MAX_ACTIVE_SUBMISSIONS_PER_EMAIL` cap. + + Submissions in `STATUSES_EXCLUDED_FROM_LIMIT` (`printing`, + `rejected`) are excluded -- they're transient or already-cleaned + states that shouldn't pin the user's quota. + """ + norm = normalize_email(email) + if not norm: + return 0 + return cls.objects.filter(canonical_email=norm).exclude( + status__in=cls.STATUSES_EXCLUDED_FROM_LIMIT + ).count() + @classmethod def _generate_unique_slug(cls, max_attempts: int = 16) -> str: """`namesgenerator.get_random_name` + collision retries in Python diff --git a/apps/submissions/views.py b/apps/submissions/views.py index 14209ca..d8d2e3e 100644 --- a/apps/submissions/views.py +++ b/apps/submissions/views.py @@ -25,7 +25,7 @@ from django.views.generic import CreateView from .emails import send_confirmation_email from .forms import SubmissionForm -from .models import Submission +from .models import Submission, VerifiedEmail class SubmitView(CreateView): @@ -52,6 +52,16 @@ class SubmitView(CreateView): submission.guest_email = None submission.email_confirmed = True submission.status = Submission.Status.PROCESSING + elif submission.guest_email and VerifiedEmail.is_trusted(submission.guest_email): + # Returning guest: their email is on the trust list AND the + # 30-day TTL hasn't lapsed (plan.md §6). Skip `identifying` and + # the welcome email entirely -- straight to `processing` like + # an OAuth submitter. Email normalisation happens inside + # `is_trusted`, so `user+a@gmail.com` and `user@gmail.com` + # collapse to the same lookup. + submission.submitted_by = None + submission.email_confirmed = True + submission.status = Submission.Status.PROCESSING else: submission.submitted_by = None # guest_email is already on the form's cleaned_data, ModelForm @@ -102,10 +112,9 @@ class SubmitView(CreateView): self.request, format_html( "Submission {slug} created. " - "We've sent a confirmation link to {email} — " + "We've sent you a confirmation link — check your inbox and " "click it within 24 hours to add your print to the queue.", slug=submission.slug, - email=submission.guest_email, ), ) else: