diff --git a/README.md b/README.md index f95bb56..2b200f7 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Runtime config, overridable by environment variables at start: The module also copies `routes`, `perPage`, `sort`, `relatedCount` and `rss` into `runtimeConfig.public.journal`. -Server-side requests forward the visitor's `x-forwarded-for` and append the address of the peer that reached Nuxt. The Journal API rate-limits anonymous reads per client address; with a trusted proxy configured for the Nuxt server's address, each visitor keeps their own bucket instead of all SSR traffic sharing one. The API base always comes from runtime config, never from request input. +Server-side requests forward the visitor's `x-forwarded-for` and append the address of the peer that reached Nuxt. The Journal API rate-limits anonymous reads per client address; with a trusted proxy configured for the Nuxt server's address, each visitor keeps their own bucket instead of all SSR traffic sharing one. Under route caching (`swr` or `cache` route rules), Nitro renders a page from a copy of the request that carries no headers, so the layer's Nitro plugin `server/plugins/journal-client-address.ts` records the address in `event.context.journalForwardedFor` on the request hook, and `useJournalApi`, `/rss.xml` and the sitemap source forward that value. The API base always comes from runtime config, never from request input. ## Site cache purge diff --git a/app/composables/useJournalApi.ts b/app/composables/useJournalApi.ts index 9e1ac59..9af7d0f 100644 --- a/app/composables/useJournalApi.ts +++ b/app/composables/useJournalApi.ts @@ -2,7 +2,7 @@ // the internal base (runtimeConfig.journal.apiBase) and forwards the visitor's // x-forwarded-for, plus the address of the peer that reached Nuxt, so the Go // rate limiter keeps one bucket per visitor instead of one for the Nuxt -// server. In the browser it calls the same-origin public base. The base is +// server, also on route-cached renders. In the browser it calls the same-origin public base. The base is // always runtime config, never request input. import { createJournalClient, type JournalClient, type JournalFetcher } from '../utils/client.ts' @@ -12,9 +12,13 @@ export function useJournalApi(): JournalClient { if (import.meta.server) { const base = (config.journal as { apiBase: string }).apiBase + // Under route caching the request copy has no headers; the address the + // layer's server/plugins/journal-client-address.ts recorded comes first. + const event = useRequestEvent() + const recorded = event?.context.journalForwardedFor as string | undefined const incoming = useRequestHeaders(['x-forwarded-for'])['x-forwarded-for'] - const peer = useRequestEvent()?.node?.req?.socket?.remoteAddress - const forwarded = [incoming, peer].filter((v): v is string => typeof v === 'string' && v !== '').join(', ') + const peer = event?.node?.req?.socket?.remoteAddress + const forwarded = recorded || [incoming, peer].filter((v): v is string => typeof v === 'string' && v !== '').join(', ') const headers: Record = forwarded ? { 'x-forwarded-for': forwarded } : {} const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query, headers }) return createJournalClient(fetcher) diff --git a/server/api/__sitemap__/journal.ts b/server/api/__sitemap__/journal.ts index 8e426f8..3d99a22 100644 --- a/server/api/__sitemap__/journal.ts +++ b/server/api/__sitemap__/journal.ts @@ -15,9 +15,13 @@ export default defineSitemapEventHandler(async (event) => { const base = (config.journal as { apiBase: string }).apiBase const routes = (config.public.journal as { routes: JournalRoutes }).routes - const forwarded = [getRequestHeader(event, 'x-forwarded-for'), getRequestIP(event)] - .filter((v): v is string => typeof v === 'string' && v !== '') - .join(', ') + // Recorded by server/plugins/journal-client-address.ts before any route + // cache hides the request headers. + const forwarded = + (event.context.journalForwardedFor as string | undefined) || + [getRequestHeader(event, 'x-forwarded-for'), getRequestIP(event)] + .filter((v): v is string => typeof v === 'string' && v !== '') + .join(', ') const headers: Record = forwarded ? { 'x-forwarded-for': forwarded } : {} const fetcher: JournalFetcher = (path, query) => $fetch(path, { baseURL: base, query, headers }) const api = createJournalClient(fetcher) diff --git a/server/plugins/journal-client-address.ts b/server/plugins/journal-client-address.ts new file mode 100644 index 0000000..5ccd94c --- /dev/null +++ b/server/plugins/journal-client-address.ts @@ -0,0 +1,20 @@ +// Keeps the visitor's address available to route-cached SSR renders. +// +// An app rule such as `'/**': { swr }` wraps the page renderer (and any +// matching server route) in Nitro's cached handler, which renders from a copy +// of the request that carries no headers (only `varies` headers survive, so a +// cached page never depends on one visitor's request). Reading +// x-forwarded-for there would forward only the Nuxt peer, and every cache +// miss would share the Journal API's rate-limit bucket. The event context is +// shared with that copy, so the incoming x-forwarded-for plus the peer +// address is recorded on the request hook, before the cached handler runs; +// useJournalApi, /rss.xml and the sitemap source forward it. + +export default defineNitroPlugin((nitroApp) => { + nitroApp.hooks.hook('request', (event) => { + const forwarded = [getRequestHeader(event, 'x-forwarded-for'), event.node.req.socket?.remoteAddress] + .filter((v): v is string => typeof v === 'string' && v !== '') + .join(', ') + event.context.journalForwardedFor = forwarded + }) +}) diff --git a/server/routes/rss.xml.ts b/server/routes/rss.xml.ts index ecfdb75..b1bc818 100644 --- a/server/routes/rss.xml.ts +++ b/server/routes/rss.xml.ts @@ -10,9 +10,13 @@ export default defineEventHandler(async (event) => { } const base = (config.journal as { apiBase: string }).apiBase - const forwarded = [getRequestHeader(event, 'x-forwarded-for'), getRequestIP(event)] - .filter((v): v is string => typeof v === 'string' && v !== '') - .join(', ') + // Recorded by server/plugins/journal-client-address.ts before any route + // cache hides the request headers. + const forwarded = + (event.context.journalForwardedFor as string | undefined) || + [getRequestHeader(event, 'x-forwarded-for'), getRequestIP(event)] + .filter((v): v is string => typeof v === 'string' && v !== '') + .join(', ') let body: string try {