diff --git a/.changeset/send-visitor-country.md b/.changeset/send-visitor-country.md new file mode 100644 index 000000000..7b2669f47 --- /dev/null +++ b/.changeset/send-visitor-country.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Send the visitor country to resolvePublishedContentByUrl on dev, preview, staging and selected sites, using the forwarded `x-gitbook-country` header on revalidation requests. diff --git a/packages/gitbook/next.config.mjs b/packages/gitbook/next.config.mjs index 6e44e1667..ce1c0113b 100644 --- a/packages/gitbook/next.config.mjs +++ b/packages/gitbook/next.config.mjs @@ -95,6 +95,7 @@ const nextConfig = { GITBOOK_BLOCK_SEARCH_INDEXATION: process.env.GITBOOK_BLOCK_SEARCH_INDEXATION, GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: process.env.GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE, GITBOOK_DISABLE_INSIGHTS: process.env.GITBOOK_DISABLE_INSIGHTS, + VERCEL_TARGET_ENV: process.env.VERCEL_TARGET_ENV, // Next.js envs NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, diff --git a/packages/gitbook/src/lib/data/lookup.test.ts b/packages/gitbook/src/lib/data/lookup.test.ts index b450f875e..878a3a5ce 100644 --- a/packages/gitbook/src/lib/data/lookup.test.ts +++ b/packages/gitbook/src/lib/data/lookup.test.ts @@ -105,3 +105,30 @@ describe('preview auth redirects', () => { } ); }); + +describe('visitor payload', () => { + it('forwards the visitor country to the API', async () => { + const calls: { visitor?: unknown }[] = []; + const apiClientSpy = spyOn(api, 'apiClient').mockReturnValue({ + urls: { + async resolvePublishedContentByUrl(body: { visitor?: unknown }) { + calls.push(body); + return { data: { target: 'external', redirect: 'https://example.com' } }; + }, + }, + } as unknown as ReturnType); + + try { + await lookupPublishedContentByUrl({ + url: 'https://docs.example.com', + apiToken: null, + redirectOnError: false, + visitorPayload: { type: 'human', country: 'FR' }, + }); + } finally { + apiClientSpy.mockRestore(); + } + + expect(calls[0]?.visitor).toEqual({ type: 'human', country: 'FR' }); + }); +}); diff --git a/packages/gitbook/src/lib/env/globals.ts b/packages/gitbook/src/lib/env/globals.ts index 739778b83..0f5d0271e 100644 --- a/packages/gitbook/src/lib/env/globals.ts +++ b/packages/gitbook/src/lib/env/globals.ts @@ -14,6 +14,15 @@ export const GITBOOK_RUNTIME = (process.env.GITBOOK_RUNTIME ?? 'unknown') as | 'cloudflare' | 'unknown'; +/** + * Deployment stage (`STAGE` from the Cloudflare wrangler configs, the target env on Vercel), + * defaulting to `dev` locally. + */ +export const GITBOOK_STAGE = + process.env.STAGE ?? + process.env.VERCEL_TARGET_ENV ?? + (process.env.NODE_ENV === 'development' ? 'dev' : undefined); + /** * Main host on which GitBook is running. */ diff --git a/packages/gitbook/src/lib/visitors.test.ts b/packages/gitbook/src/lib/visitors.test.ts index 9a2008f82..0a583d330 100644 --- a/packages/gitbook/src/lib/visitors.test.ts +++ b/packages/gitbook/src/lib/visitors.test.ts @@ -7,11 +7,13 @@ import { getVisitorAuthCookieMaxAge, getVisitorAuthCookieName, getVisitorAuthCookieValue, + getVisitorCountry, getVisitorToken, getVisitorType, getVisitorUnsignedClaims, isRevalidationRequest, normalizeVisitorURL, + shouldSendVisitorCountry, } from './visitors'; describe('getVisitorAuthToken', () => { @@ -599,3 +601,76 @@ describe('isRevalidationRequest', () => { expect(isRevalidationRequest(new Headers())).toBe(false); }); }); + +describe('getVisitorCountry', () => { + const requestWith = (headers: Record) => ({ headers: new Headers(headers) }); + + it('should prefer the OpenNext country header', () => { + expect( + getVisitorCountry( + requestWith({ 'x-open-next-country': 'FR', 'x-vercel-ip-country': 'US' }) + ) + ).toBe('FR'); + }); + + it('should fall back to the Vercel country header', () => { + expect(getVisitorCountry(requestWith({ 'x-vercel-ip-country': 'US' }))).toBe('US'); + }); + + it('should normalize the country code to uppercase', () => { + expect(getVisitorCountry(requestWith({ 'x-open-next-country': ' fr ' }))).toBe('FR'); + }); + + it.each(['XX', 'T1', 'FRA', 'F', ''])('should ignore the invalid country code %p', (value) => { + expect(getVisitorCountry(requestWith({ 'x-open-next-country': value }))).toBeUndefined(); + }); + + it('should return undefined when no country header is present', () => { + expect(getVisitorCountry(requestWith({}))).toBeUndefined(); + }); + + it('should prefer the forwarded country header on revalidation requests', () => { + expect( + getVisitorCountry( + requestWith({ + 'user-agent': 'gitbook-open-revalidation-worker', + 'x-gitbook-country': 'DE', + 'x-open-next-country': 'FR', + }) + ) + ).toBe('DE'); + }); + + it('should ignore the forwarded country header outside revalidation requests', () => { + expect( + getVisitorCountry( + requestWith({ 'x-gitbook-country': 'DE', 'x-open-next-country': 'FR' }) + ) + ).toBe('FR'); + expect(getVisitorCountry(requestWith({ 'x-gitbook-country': 'DE' }))).toBeUndefined(); + }); + + it('should fall back to geolocation headers on revalidation requests without a forwarded country', () => { + expect( + getVisitorCountry( + requestWith({ + 'user-agent': 'gitbook-open-revalidation-worker', + 'x-open-next-country': 'FR', + }) + ) + ).toBe('FR'); + }); +}); + +describe('shouldSendVisitorCountry', () => { + it.each(['dev', 'preview', 'staging'])('should be enabled on the %p stage', (stage) => { + expect(shouldSendVisitorCountry('docs.example.com', stage)).toBe(true); + }); + + it.each(['production', undefined])( + 'should be disabled for other hostnames on the %p stage', + (stage) => { + expect(shouldSendVisitorCountry('docs.example.com', stage)).toBe(false); + } + ); +}); diff --git a/packages/gitbook/src/lib/visitors.ts b/packages/gitbook/src/lib/visitors.ts index ec8545c65..3a0186be1 100644 --- a/packages/gitbook/src/lib/visitors.ts +++ b/packages/gitbook/src/lib/visitors.ts @@ -106,6 +106,42 @@ export function getVisitorType(request: { return detection.detected && detection.method !== 'heuristic' ? 'agent' : 'human'; } +// Production hostnames for which the visitor country is sent while the feature is rolled out. +const VISITOR_COUNTRY_HOSTNAMES = new Set([]); +const VISITOR_COUNTRY_STAGES = new Set(['dev', 'preview', 'staging']); + +/** + * Whether the visitor country should be sent when resolving the site URL. + */ +export function shouldSendVisitorCountry(hostname: string, stage: string | undefined): boolean { + return ( + (!!stage && VISITOR_COUNTRY_STAGES.has(stage)) || VISITOR_COUNTRY_HOSTNAMES.has(hostname) + ); +} + +/** + * Get the ISO 3166-1 alpha-2 country code of the visitor from the geolocation headers, + * or from `x-gitbook-country` when the request comes from the revalidation worker. + */ +export function getVisitorCountry(request: { headers: Headers }): string | undefined { + // The revalidation worker forwards the original visitor country; only trust it from there. + const country = ( + (isRevalidationRequest(request.headers) && request.headers.get('x-gitbook-country')) || + request.headers.get('x-open-next-country') || + request.headers.get('x-vercel-ip-country') || + '' + ) + .trim() + .toUpperCase(); + + // Cloudflare uses XX for unknown and T1 for Tor, which are not ISO codes. + if (!/^[A-Z]{2}$/.test(country) || country === 'XX' || country === 'T1') { + return undefined; + } + + return country; +} + /** * Get the visitor data for the request potentially including: * - a JWT token that may contain signed claims or can be used for VA authentication. diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 08dcf66c2..49dce90fa 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -35,7 +35,12 @@ import { normalizeRequestURL, throwIfDataError, } from '@/lib/data'; -import { GITBOOK_DISABLE_INSIGHTS, isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env'; +import { + GITBOOK_DISABLE_INSIGHTS, + GITBOOK_STAGE, + isGitBookAssetsHostURL, + isGitBookHostURL, +} from '@/lib/env'; import { getImageResizingContextId } from '@/lib/images'; import { isAITrainingOrIndexingRequest } from '@/lib/indexing-crawlers'; import { MCP_SERVER_CARD_PATH, MCP_SERVER_CARD_WELL_KNOWN_PATH } from '@/lib/mcp/paths'; @@ -56,11 +61,13 @@ import { type ResponseCookies, getPathScopedCookieName, getResponseCookiesForVisitorAuth, + getVisitorCountry, getVisitorData, getVisitorType, isRevalidationRequest, normalizeVisitorURL, serveVisitorClaimsDataRequest, + shouldSendVisitorCountry, } from '@/lib/visitors'; import { waitUntil } from '@/lib/waitUntil'; import { serveResizedImage } from '@/routes/image'; @@ -220,6 +227,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { // request.headers.delete('x-gitbook-disable-tracking'); + const visitorCountry = shouldSendVisitorCountry(siteRequestURL.hostname, GITBOOK_STAGE) + ? getVisitorCountry(request) + : undefined; + const withAPIToken = async (apiToken: string | null) => { const siteURLData = await throwIfDataError( lookupPublishedContentByUrl({ @@ -228,6 +239,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { jwtToken: visitorToken?.token ?? undefined, unsignedClaims, type: getVisitorType(request), + ...(visitorCountry ? { country: visitorCountry } : {}), }, // When the visitor auth token is pulled from the cookie, set redirectOnError when calling resolvePublishedContentByUrl to allow // redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.