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 +