From 1c66eb84b1f53c222fe285cbe9c02491185c0c05 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 12 Sep 2026 17:18:30 +0200 Subject: [PATCH] fix: two ways to be told an address has an account, and a door to writing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/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 Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN --- backend/app/routers/auth.py | 26 +++++++++++++++++++++++--- frontend/src/pages/ArticlesPage.jsx | 8 ++++++-- frontend/src/pages/EditorialPage.css | 4 ++++ frontend/src/pages/EditorialPage.jsx | 9 ++++++++- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index ae451fd..6f1a362 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -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: diff --git a/frontend/src/pages/ArticlesPage.jsx b/frontend/src/pages/ArticlesPage.jsx index e520eb2..2af5df9 100644 --- a/frontend/src/pages/ArticlesPage.jsx +++ b/frontend/src/pages/ArticlesPage.jsx @@ -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('') diff --git a/frontend/src/pages/EditorialPage.css b/frontend/src/pages/EditorialPage.css index 1628b1f..11a62a1 100644 --- a/frontend/src/pages/EditorialPage.css +++ b/frontend/src/pages/EditorialPage.css @@ -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; } diff --git a/frontend/src/pages/EditorialPage.jsx b/frontend/src/pages/EditorialPage.jsx index cf2c648..e3f753b 100644 --- a/frontend/src/pages/EditorialPage.jsx +++ b/frontend/src/pages/EditorialPage.jsx @@ -68,7 +68,14 @@ export default function EditorialPage() {

Editorial

What still needs a person, rather than a list of everything that exists.

- Library + {/* 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. */} +
+ Library + Write one + Draft with AI +