Add better email verification
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user