Send visitor country in API requests for specific environments (#4667)

This commit is contained in:
conico974
2026-10-07 18:08:57 +02:00
committed by GitHub
parent 9f447aab29
commit 848a39d1c8
7 changed files with 166 additions and 1 deletions
+5
View File
@@ -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.
+1
View File
@@ -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,
@@ -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<typeof api.apiClient>);
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' });
});
});
+9
View File
@@ -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.
*/
+75
View File
@@ -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<string, string>) => ({ 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);
}
);
});
+36
View File
@@ -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<string>([]);
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.
+13 -1
View File
@@ -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.