Compare commits

...

1 Commits

Author SHA1 Message Date
Claire Chabas f34a967c7c Support flagged features docs visibility 2026-06-25 17:55:08 +01:00
3 changed files with 150 additions and 24 deletions
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, mock } from 'bun:test';
import { lookupPublishedContentByUrl } from './lookup';
const originalFetch = globalThis.fetch;
describe('lookupPublishedContentByUrl', () => {
afterEach(() => {
globalThis.fetch = originalFetch;
});
it('forwards the provided GitBook visitor session cookie to published URL resolution', async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
Response.json(
{
site: 'site-id',
siteSpace: 'site-space-id',
siteBasePath: '',
basePath: '',
space: 'space-id',
organization: 'organization-id',
apiToken: 'api-token',
canonicalUrl: 'https://docs.gitbook.com/',
pathname: '',
complete: true,
},
{
headers: {
'cache-control': 'private, no-store',
},
}
)
)
);
const result = await lookupPublishedContentByUrl({
url: 'https://docs.gitbook.com/',
redirectOnError: false,
apiToken: null,
visitorPayload: {},
cookieHeader: '__session=session-value',
});
expect(result.error).toBeUndefined();
expect(result.data).toMatchObject({
site: 'site-id',
apiToken: 'api-token',
});
if (result.error) {
throw new Error(result.error.message);
}
expect(result.headers?.cacheControl).toBe('private, no-store');
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [, init] = (globalThis.fetch as ReturnType<typeof mock>).mock.calls[0] ?? [];
const headers = new Headers(init?.headers);
expect(headers.get('cookie')).toBe('__session=session-value');
});
});
+39 -5
View File
@@ -4,7 +4,7 @@ import { trace } from '@/lib/tracing';
import type { PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import { apiClient } from './api';
import { getExposableError } from './errors';
import type { DataFetcherResponse } from './types';
import type { DataFetcherErrorData } from './types';
import { getURLLookupAlternatives, stripURLSearch } from './urls';
interface LookupPublishedContentByUrlInput {
@@ -12,15 +12,29 @@ interface LookupPublishedContentByUrlInput {
redirectOnError: boolean;
apiToken: string | null;
visitorPayload: SiteVisitorPayload;
cookieHeader?: string | null;
}
type LookupPublishedContentByUrlResponse =
| {
data: PublishedSiteContentLookup;
headers?: {
cacheControl?: string;
};
error?: undefined;
}
| {
error: DataFetcherErrorData;
data?: undefined;
};
/**
* Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function lookupPublishedContentByUrl(
input: LookupPublishedContentByUrlInput
): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
): Promise<LookupPublishedContentByUrlResponse> {
const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
@@ -40,7 +54,16 @@ export async function lookupPublishedContentByUrl(
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError,
},
{ signal }
{
signal,
...(input.cookieHeader
? {
headers: {
cookie: input.cookieHeader,
},
}
: {}),
}
)
)
);
@@ -82,7 +105,12 @@ export async function lookupPublishedContentByUrl(
}
}
return { data };
return {
data,
headers: {
cacheControl: callResult.data.headers.get('cache-control') ?? undefined,
},
};
}
return null;
@@ -107,7 +135,12 @@ export async function lookupPublishedContentByUrl(
...(changeRequest ? { changeRequest } : {}),
...(revision ? { revision } : {}),
};
return { data: siteResult };
return {
data: siteResult,
headers: {
cacheControl: callResult.data.headers.get('cache-control') ?? undefined,
},
};
}
return null;
@@ -130,5 +163,6 @@ export async function lookupPublishedContentByUrl(
return {
data: result.data,
headers: result.headers,
};
}
+51 -19
View File
@@ -215,23 +215,38 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
request.headers.delete('x-gitbook-disable-tracking');
const withAPIToken = async (apiToken: string | null) => {
const siteURLData = await throwIfDataError(
lookupPublishedContentByUrl({
url: siteRequestURL.toString(),
visitorPayload: {
jwtToken: visitorToken?.token ?? undefined,
unsignedClaims,
},
// 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.
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
const siteURLLookup = await lookupPublishedContentByUrl({
url: siteRequestURL.toString(),
visitorPayload: {
jwtToken: visitorToken?.token ?? undefined,
unsignedClaims,
},
// 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.
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
// Use the API token passed in the request, if any
// as it could be used for .preview hostnames
apiToken,
})
);
// Forward only the shared GitBook visitor session cookie to gitbook-x so
// GitBook-owned adaptive claims can be evaluated during published URL resolution.
cookieHeader: getGitBookVisitorSessionCookieHeader(request),
// Use the API token passed in the request, if any
// as it could be used for .preview hostnames
apiToken,
});
const siteURLData = throwIfDataError(siteURLLookup);
let resolvedCacheControl = siteURLLookup.error
? undefined
: siteURLLookup.headers?.cacheControl;
const isPersonalizedResolution =
resolvedCacheControl
?.toLowerCase()
.split(',')
.map((directive: string) => directive.trim())
.includes('no-store') ?? false;
if (!isPersonalizedResolution) {
resolvedCacheControl = undefined;
}
const cookies: ResponseCookies = visitorParamsCookie
? [
@@ -263,8 +278,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// For these cases, we return a 303 response with an `X-Action-Redirect` header that the client can handle.
// This is what server actions do when returning a redirect response.
const isServerAction = request.headers.has('next-action') && request.method === 'POST';
const createRedirectResponse = (url: string) =>
isServerAction
const createRedirectResponse = (url: string) => {
const response = isServerAction
? new NextResponse(null, {
status: 303,
headers: {
@@ -273,6 +288,11 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
},
})
: NextResponse.redirect(url);
if (resolvedCacheControl) {
response.headers.set('cache-control', resolvedCacheControl);
}
return response;
};
// biome-ignore lint/suspicious/noConsole: we want to log the redirect
console.log('redirect', siteURLData.redirect);
if (siteURLData.target === 'content') {
@@ -435,7 +455,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
// We support forcing dynamic routes by setting a `gitbook-dynamic-route` cookie
// This is useful for testing dynamic routes.
if (request.cookies.has('gitbook-dynamic-route')) {
if (request.cookies.has('gitbook-dynamic-route') || isPersonalizedResolution) {
routeType = 'dynamic';
}
@@ -528,6 +548,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
if (siteURLData.contextId && !siteRequestURL.pathname.endsWith('~gitbook/site-index')) {
response.headers.set('cache-control', 'public, max-age=0, must-revalidate');
}
if (resolvedCacheControl) {
response.headers.set('cache-control', resolvedCacheControl);
}
return writeResponseCookies(response, cookies);
};
@@ -550,6 +573,15 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
return withAPIToken(null);
}
function getGitBookVisitorSessionCookieHeader(request: NextRequest): string | null {
const session = request.cookies.get('__session');
if (!session) {
return null;
}
return `${session.name}=${session.value}`;
}
/**
* Serve routes for PDF export for a space: /~space/:spaceId/~gitbook/pdf
*/