diff --git a/src/lib/site-offline.ts b/src/lib/site-offline.ts index fbbd338..45607f8 100644 --- a/src/lib/site-offline.ts +++ b/src/lib/site-offline.ts @@ -19,7 +19,9 @@ export const SITE_OFFLINE = true; -const STILL_SERVED = ['/api/health', '/api/webhooks']; +// The web manifest too: every page links it, and a 307 to /invite-only for it was two +// wasted requests per page view against a signed-out visitor's allowance. +const STILL_SERVED = ['/api/health', '/api/webhooks', '/manifest.json']; export function stillServed(pathname: string): boolean { return STILL_SERVED.some((p) => pathname === p || pathname.startsWith(`${p}/`)); diff --git a/src/lib/throttle.test.ts b/src/lib/throttle.test.ts new file mode 100644 index 0000000..bff5fe8 --- /dev/null +++ b/src/lib/throttle.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { meter } from './throttle'; + +/** A signed-out browser: no session cookie, one address. */ +const from = (ip: string, path: string, init: RequestInit = {}) => + new Request(`https://bittorrented.com${path}`, { + ...init, + headers: { 'x-forwarded-for': ip, 'x-real-ip': ip, 'user-agent': 'Mozilla/5.0 Firefox/140.0' }, + }); + +describe('the site-wide allowance, signed out', () => { + it('never meters the way back in', async () => { + // A logout plus a few reloads: Next prefetches each of these on every page view. + const paths = ['/invite-only', '/login', '/signup', '/forgot-password', '/reset-password', '/manifest.json']; + for (let i = 0; i < 40; i++) { + for (const path of paths) expect(await meter(from('198.51.100.7', path))).toBeNull(); + } + }); + + it('keeps "who am I" out of the sign-in bucket, so page views cannot spend the login', async () => { + const ip = '198.51.100.8'; + for (let i = 0; i < 30; i++) expect(await meter(from(ip, '/api/auth/me'))).toBeNull(); + expect(await meter(from(ip, '/api/auth/login', { method: 'POST' }))).toBeNull(); + }); + + it('still holds sign-in attempts to ten a minute', async () => { + const ip = '198.51.100.9'; + for (let i = 0; i < 10; i++) expect(await meter(from(ip, '/api/auth/login', { method: 'POST' }))).toBeNull(); + const refused = await meter(from(ip, '/api/auth/login', { method: 'POST' })); + expect(refused?.status).toBeGreaterThanOrEqual(402); + }); + + it('still meters everything else at a hundred a minute', async () => { + const ip = '198.51.100.10'; + for (let i = 0; i < 100; i++) expect(await meter(from(ip, '/pricing'))).toBeNull(); + expect((await meter(from(ip, '/pricing')))?.status).toBeGreaterThanOrEqual(402); + }); +}); diff --git a/src/lib/throttle.ts b/src/lib/throttle.ts index 123eed7..4fe548f 100644 --- a/src/lib/throttle.ts +++ b/src/lib/throttle.ts @@ -48,6 +48,15 @@ export const throttle = createThrottle({ ? (/^Bearer\s+(\S+)/i.exec(request.headers.get('authorization') ?? '')?.[1] ?? null) : null), credential: { limit: 600, ceiling: 1200 }, + /* + * The way back in is never metered. While the site is invite only, a signed-out + * browser can reach almost nothing but these, and Next prefetches every one of + * them (plus a refetch of each 307 to /invite-only) on every page view: one + * logout and two reloads spent the whole hundred, and the person who tripped it + * was then refused the login page itself (2026-10-06). A paywall you cannot + * reach to sign in past is a wall. + */ + openPaths: ['/invite-only', '/login', '/signup', '/forgot-password', '/reset-password', '/manifest.json'], rules: [ /* The expensive ones, at the numbers they were already tuned to. */ { path: '/api/search/', limit: 30 }, @@ -57,6 +66,12 @@ export const throttle = createThrottle({ { path: '/dht', limit: 60 }, /* Sign-in stays address-bucketed, or a guess buys the session budget. */ { path: '/api/auth/', limit: 10, credential: false }, + /* + * Except "who am I", which every page asks on load. In the sign-in bucket it + * spent the ten guesses before anyone typed a password, so the login POST + * that followed a few page views was the request refused. + */ + { path: '/api/auth/me', limit: 100 }, ], });