From 8f65a22e13e00dd28e4d72083252cc37ff1a0ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Thu, 20 Mar 2025 19:54:55 +0100 Subject: [PATCH] Accept API token in query parameters for normal v2 routes (#3011) --- packages/gitbook-v2/src/lib/data/lookup.ts | 3 +- packages/gitbook-v2/src/middleware.ts | 323 ++++++++++++--------- packages/gitbook/e2e/internal.spec.ts | 23 ++ packages/gitbook/e2e/util.ts | 5 + 4 files changed, 208 insertions(+), 146 deletions(-) diff --git a/packages/gitbook-v2/src/lib/data/lookup.ts b/packages/gitbook-v2/src/lib/data/lookup.ts index b6857cba2..9ebd70609 100644 --- a/packages/gitbook-v2/src/lib/data/lookup.ts +++ b/packages/gitbook-v2/src/lib/data/lookup.ts @@ -15,13 +15,14 @@ export async function getPublishedContentByURL(input: { url: string; visitorAuthToken: string | null; redirectOnError: boolean; + apiToken: string | null; }): Promise> { const lookupURL = new URL(input.url); const url = stripURLSearch(lookupURL); const lookup = getURLLookupAlternatives(url); const result = await race(lookup.urls, async (alternative, { signal }) => { - const api = await apiClient(); + const api = await apiClient({ apiToken: input.apiToken }); const callResult = await trace( { diff --git a/packages/gitbook-v2/src/middleware.ts b/packages/gitbook-v2/src/middleware.ts index a4c368754..491f1a1e8 100644 --- a/packages/gitbook-v2/src/middleware.ts +++ b/packages/gitbook-v2/src/middleware.ts @@ -42,7 +42,7 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(normalized.toString()); } - for (const handler of [serveSiteRoutes, servePreviewRoutes]) { + for (const handler of [serveSiteRoutes, serveSpacePDFRoutes]) { const result = await handler(requestURL, request); if (result) { return result; @@ -89,155 +89,207 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) { url: siteURL, }); - const data = await throwIfDataError( - getPublishedContentByURL({ - url: siteURL.toString(), - visitorAuthToken: visitorToken?.token ?? null, - // When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl 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 cookies: ResponseCookies = []; + const withAPIToken = async (apiToken: string | null) => { + const data = await throwIfDataError( + getPublishedContentByURL({ + url: siteURL.toString(), + visitorAuthToken: visitorToken?.token ?? null, + // When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl 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', - // - // Handle redirects - // - if ('redirect' in data) { - // biome-ignore lint/suspicious/noConsole: we want to log the redirect - console.log('redirect', data.redirect); - if (data.target === 'content') { - // For content redirects, we use the linker to redirect the optimal URL - // during development and testing in 'url' mode. - const linker = getLinkerForSiteURL({ - siteURL, - urlMode: mode, - }); + // Use the API token passed in the request, if any + // as it could be used for .preview hostnames + apiToken, + }) + ); + const cookies: ResponseCookies = []; - const contentRedirect = new URL(linker.toLinkForContent(data.redirect), request.url); + // + // Handle redirects + // + if ('redirect' in data) { + // biome-ignore lint/suspicious/noConsole: we want to log the redirect + console.log('redirect', data.redirect); + if (data.target === 'content') { + // For content redirects, we use the linker to redirect the optimal URL + // during development and testing in 'url' mode. + const linker = getLinkerForSiteURL({ + siteURL, + urlMode: mode, + }); - // Keep the same search params as the original request - // as it might contain a VA token - contentRedirect.search = request.nextUrl.search; + const contentRedirect = new URL( + linker.toLinkForContent(data.redirect), + request.url + ); - return NextResponse.redirect(contentRedirect); + // Keep the same search params as the original request + // as it might contain a VA token + contentRedirect.search = request.nextUrl.search; + + return NextResponse.redirect(contentRedirect); + } + + return NextResponse.redirect(data.redirect); } - return NextResponse.redirect(data.redirect); - } + cookies.push(...getResponseCookiesForVisitorAuth(data.siteBasePath, visitorToken)); - cookies.push(...getResponseCookiesForVisitorAuth(data.siteBasePath, visitorToken)); + // + // Make sure the URL is clean of any va token after a successful lookup + // The token is stored in a cookie that is set on the redirect response + // + const requestURLWithoutToken = normalizeVisitorAuthURL(requestURL); + if (requestURLWithoutToken.toString() !== requestURL.toString()) { + return writeResponseCookies( + NextResponse.redirect(requestURLWithoutToken.toString()), + cookies + ); + } - // - // Make sure the URL is clean of any va token after a successful lookup - // The token is stored in a cookie that is set on the redirect response - // - const requestURLWithoutToken = normalizeVisitorAuthURL(requestURL); - if (requestURLWithoutToken.toString() !== requestURL.toString()) { - return writeResponseCookies( - NextResponse.redirect(requestURLWithoutToken.toString()), - cookies + // + // Render and serve the content + // + + // The route is static, except when using dynamic parameters from query params + // (customization override, theme, etc) + let routeType: 'dynamic' | 'static' = 'static'; + + const requestHeaders = new Headers(request.headers); + requestHeaders.set(MiddlewareHeaders.RouteType, routeType); + requestHeaders.set(MiddlewareHeaders.URLMode, mode); + requestHeaders.set(MiddlewareHeaders.SiteURL, `${siteURL.origin}${data.basePath}`); + requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(data)); + + // Preview of customization/theme + const customization = siteURL.searchParams.get('customization'); + if (customization && validateSerializedCustomization(customization)) { + routeType = 'dynamic'; + requestHeaders.set(MiddlewareHeaders.Customization, customization); + } + const theme = siteURL.searchParams.get('theme'); + if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) { + routeType = 'dynamic'; + requestHeaders.set(MiddlewareHeaders.Theme, theme); + } + + // 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')) { + routeType = 'dynamic'; + } + + // Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied + requestHeaders.set('x-forwarded-host', request.nextUrl.host); + requestHeaders.set('origin', request.nextUrl.origin); + + const siteURLWithoutProtocol = `${siteURL.host}${data.basePath}`; + const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent( + data.pathname + ); + routeType = routeTypeFromPathname ?? routeType; + + const route = [ + 'sites', + routeType, + mode, + encodeURIComponent(siteURLWithoutProtocol), + encodeURIComponent(rison.encode(data)), + pathname, + ].join('/'); + + console.log(`rewriting ${request.nextUrl.toString()} to ${route}`); + + const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString()); + const response = NextResponse.rewrite(rewrittenURL, { + request: { + headers: requestHeaders, + }, + }); + + // Add Content Security Policy header + response.headers.set('content-security-policy', getContentSecurityPolicy()); + // Basic security headers + response.headers.set('strict-transport-security', 'max-age=31536000'); + response.headers.set('referrer-policy', 'no-referrer-when-downgrade'); + response.headers.set('x-content-type-options', 'nosniff'); + // Debug header + response.headers.set('x-gitbook-route-type', routeType); + response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol); + + return writeResponseCookies(response, cookies); + }; + + // For https://preview/ requests, + if (siteURL.hostname === 'preview') { + return serveWithQueryAPIToken( + // We scope the API token to the site ID. + `${siteURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`, + request, + withAPIToken ); } - // - // Render and serve the content - // - - // The route is static, except when using dynamic parameters from query params - // (customization override, theme, etc) - let routeType: 'dynamic' | 'static' = 'static'; - - const requestHeaders = new Headers(request.headers); - requestHeaders.set(MiddlewareHeaders.RouteType, routeType); - requestHeaders.set(MiddlewareHeaders.URLMode, mode); - requestHeaders.set(MiddlewareHeaders.SiteURL, `${siteURL.origin}${data.basePath}`); - requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(data)); - - // Preview of customization/theme - const customization = siteURL.searchParams.get('customization'); - if (customization && validateSerializedCustomization(customization)) { - routeType = 'dynamic'; - requestHeaders.set(MiddlewareHeaders.Customization, customization); - } - const theme = siteURL.searchParams.get('theme'); - if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) { - routeType = 'dynamic'; - requestHeaders.set(MiddlewareHeaders.Theme, theme); - } - - // 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')) { - routeType = 'dynamic'; - } - - // Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied - requestHeaders.set('x-forwarded-host', request.nextUrl.host); - requestHeaders.set('origin', request.nextUrl.origin); - - const siteURLWithoutProtocol = `${siteURL.host}${data.basePath}`; - const { pathname, routeType: routeTypeFromPathname } = encodePathInSiteContent(data.pathname); - routeType = routeTypeFromPathname ?? routeType; - - const route = [ - 'sites', - routeType, - mode, - encodeURIComponent(siteURLWithoutProtocol), - encodeURIComponent(rison.encode(data)), - pathname, - ].join('/'); - - console.log(`rewriting ${request.nextUrl.toString()} to ${route}`); - - const rewrittenURL = new URL(`/${route}`, request.nextUrl.toString()); - const response = NextResponse.rewrite(rewrittenURL, { - request: { - headers: requestHeaders, - }, - }); - - // Add Content Security Policy header - response.headers.set('content-security-policy', getContentSecurityPolicy()); - // Basic security headers - response.headers.set('strict-transport-security', 'max-age=31536000'); - response.headers.set('referrer-policy', 'no-referrer-when-downgrade'); - response.headers.set('x-content-type-options', 'nosniff'); - // Debug header - response.headers.set('x-gitbook-route-type', routeType); - response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol); - - return writeResponseCookies(response, cookies); + return withAPIToken(null); } /** - * Serve routes for previewing unpublished content. - * Routes are: - * - PDF export for a space: /~space/:spaceId/~gitbook/pdf - * - Preview of an unpublished site: /~site/:siteId/ + * Serve routes for PDF export for a space: /~space/:spaceId/~gitbook/pdf */ -async function servePreviewRoutes(requestURL: URL, request: NextRequest) { +async function serveSpacePDFRoutes(requestURL: URL, request: NextRequest) { const pathnameParts = requestURL.pathname.slice(1).split('/'); - if (pathnameParts[0] !== '~space' && pathnameParts[0] !== '~site') { return null; } - // We store the API token in a cookie that is scoped to the specific preview route - // to avoid errors when multiple previews are opened in different tabs. - const cookieName = getPathScopedCookieName( - 'gitbook-api-token', - pathnameParts.slice(0, 2).join('/') + return serveWithQueryAPIToken( + pathnameParts.slice(0, 2).join('/'), + request, + async (apiToken) => { + // Handle the rest with the router default logic + return NextResponse.next({ + headers: { + [MiddlewareHeaders.APIToken]: apiToken, + }, + }); + } ); +} + +/** + * Serve an error response. + */ +function serveErrorResponse(error: Error) { + if (error instanceof DataFetcherError) { + return new Response(error.message, { + status: error.code, + headers: { 'content-type': 'text/plain' }, + }); + } + + throw error; +} + +/** + * Server a response with an API token obtained from the query params. + */ +async function serveWithQueryAPIToken( + scopePath: string, + request: NextRequest, + serve: (apiToken: string) => Promise +) { + // We store the API token in a cookie that is scoped to the specific route + // to avoid errors when multiple previews are opened in different tabs. + const cookieName = getPathScopedCookieName('gitbook-api-token', scopePath); // Extract a potential GitBook API token passed in the request // If found, we redirect to the same URL but with the token in the cookie - const queryAPIToken = requestURL.searchParams.get('token'); + const queryAPIToken = request.nextUrl.searchParams.get('token'); if (queryAPIToken) { - requestURL.searchParams.delete('token'); - return writeResponseCookies(NextResponse.redirect(requestURL.toString()), [ + request.nextUrl.searchParams.delete('token'); + return writeResponseCookies(NextResponse.redirect(request.nextUrl.toString()), [ { name: cookieName, value: queryAPIToken, @@ -256,26 +308,7 @@ async function servePreviewRoutes(requestURL: URL, request: NextRequest) { throw new DataFetcherError('Missing API token', 400); } - // Handle the rest with the router default logic - return NextResponse.next({ - headers: { - [MiddlewareHeaders.APIToken]: apiToken, - }, - }); -} - -/** - * Serve an error response. - */ -function serveErrorResponse(error: Error) { - if (error instanceof DataFetcherError) { - return new Response(error.message, { - status: error.code, - headers: { 'content-type': 'text/plain' }, - }); - } - - throw error; + return serve(apiToken); } /** diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 21773709c..60e9f2f7f 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -367,6 +367,29 @@ const testCases: TestsCase[] = [ }, ], }, + { + name: 'Site Preview', + skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel', + tests: [ + { + name: 'Main content', + url: async () => { + const data = await getSiteAPIToken( + 'https://gitbook.gitbook.io/test-gitbook-open/' + ); + + const searchParams = new URLSearchParams(); + searchParams.set('token', data.apiToken); + + return `url/preview/${data.site}/?${searchParams.toString()}`; + }, + screenshot: false, + run: async (page) => { + await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible(); + }, + }, + ], + }, { name: 'Content tests', contentBaseURL: 'https://gitbook.gitbook.io/test-gitbook-open/', diff --git a/packages/gitbook/e2e/util.ts b/packages/gitbook/e2e/util.ts index ee44f7768..ff9555c5e 100644 --- a/packages/gitbook/e2e/util.ts +++ b/packages/gitbook/e2e/util.ts @@ -63,6 +63,7 @@ export interface Test { export type TestsCase = { name: string; + skip?: boolean; tests: Array; contentBaseURL?: string; }; @@ -142,6 +143,10 @@ export async function waitForCookiesDialog(page: Page) { */ export function runTestCases(testCases: TestsCase[]) { for (const testCase of testCases) { + if (testCase.skip) { + continue; + } + test.describe(testCase.name, () => { for (const testEntry of testCase.tests) { const testFn = testEntry.only ? test.only : test;