fix: two ways to be told an address has an account, and a door to writing

`/auth/forgot-password` and `/auth/resend-verification` both take care to say
"if that email exists" and both then answered the question anyway.

The reset limiter returned early for an unknown address, so it counted nothing
for one and counted for the other: ask four times and a registered address
gets 429 while an unknown one gets 200 for ever. It counts either way now — in
Redis for an address with no rows to count, keyed by a fingerprint, because a
list of addresses somebody tried is itself worth not keeping.

Resend answered "Email already verified." for a known verified address and "if
that email exists" for everything else, which is not a hint but an answer. One
sentence for every outcome now.

And Editorial has a way to write something. Drafting was only reachable from
the library — a page about reading, behind a button an educator arriving to
work has no reason to look for — so the two panels now open from a link.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-12 17:18:30 +02:00
parent 3418ed023b
commit 1c66eb84b1
4 changed files with 41 additions and 6 deletions

View file

@ -45,11 +45,28 @@ RESET_WINDOW_HOURS = 1
def _check_reset_rate_limit(db: Session, email: str):
"""Refuse a fourth request in an hour — for any address, not only a real one.
Returning early for an unknown address made this the oracle the careful
wording below exists to avoid: ask four times and a registered address
answers 429 while an unknown one answers 200 for ever. The limit is now
counted against the address as typed, so both answer the same.
"""
email_normalized = email.lower().strip()
user = db.query(User).filter(User.email == email_normalized).first()
if not user:
return # silently ignore unknown emails
window_start = datetime.utcnow() - timedelta(hours=RESET_WINDOW_HOURS)
if user is None:
# No rows to count for an address with no account, so the attempts are
# counted in Redis instead — keyed by a fingerprint, because a list of
# addresses somebody tried is itself worth not keeping.
import hashlib
from app.utils.auth import check_rate_limit
check_rate_limit(
key=f"pwreset:{hashlib.sha256(email_normalized.encode()).hexdigest()}",
max_calls=RESET_LIMIT, window_seconds=RESET_WINDOW_HOURS * 3600,
detail="Too many reset requests. Please wait before trying again.")
return
count = db.query(PasswordReset).filter(
PasswordReset.user_id == user.id,
PasswordReset.created_at >= window_start,
@ -205,7 +222,10 @@ async def resend_verification(data: ForgotPasswordRequest, background_tasks: Bac
record = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if record and record.verified_at is not None:
return {"message": "Email already verified."}
# The same sentence as every other outcome. Saying "already verified"
# here told anybody who asked that the address has an account and that
# it is in use — which is the whole of what the wording below is for.
return {"message": "If that email exists, a verification link has been sent."}
token = secrets.token_urlsafe(32)
if record:

View file

@ -31,8 +31,12 @@ export default function ArticlesPage() {
const [categoryId, setCategoryId] = useState('')
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(true)
const [showCreate, setShowCreate] = useState(false)
const [showAi, setShowAi] = useState(false)
// Openable from elsewhere. Editorial is where somebody arrives meaning to
// write, and it had to send them to a page about reading and hope they
// spotted a button on it.
const [params] = useSearchParams()
const [showCreate, setShowCreate] = useState(params.get('new') === '1')
const [showAi, setShowAi] = useState(params.get('draft') === '1')
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [error, setError] = useState('')

View file

@ -59,3 +59,7 @@
.ed-actions { width: 100%; }
.ed-actions .btn { flex: 1; }
}
/* Three doors, and the one that writes something is the loud one. */
.ed-header-actions { display: flex; gap: 8px; flex-wrap: wrap; }

View file

@ -68,7 +68,14 @@ export default function EditorialPage() {
<h1>Editorial</h1>
<p>What still needs a person, rather than a list of everything that exists.</p>
</div>
<Link className="btn btn-secondary" to="/articles">Library</Link>
{/* Writing one is the other half of this page's job, and it was only
reachable from the library a page about reading, behind a button
an educator arriving here to work has no reason to look for. */}
<div className="ed-header-actions">
<Link className="btn btn-secondary" to="/articles">Library</Link>
<Link className="btn btn-secondary" to="/articles?new=1">Write one</Link>
<Link className="btn btn-primary" to="/articles?draft=1">Draft with AI</Link>
</div>
</div>
<div className="ed-counts">