mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-10 23:34:29 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f34a967c7c |
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user