diff --git a/bun.lock b/bun.lock index 47fa7d4f5c..93ecae3bd6 100644 --- a/bun.lock +++ b/bun.lock @@ -49,7 +49,7 @@ }, "packages/gitbook": { "name": "gitbook", - "version": "0.6.5", + "version": "0.7.2", "dependencies": { "@gitbook/api": "0.96.1", "@gitbook/cache-do": "workspace:*", @@ -132,7 +132,7 @@ }, "packages/gitbook-v2": { "name": "gitbook-v2", - "version": "0.1.2", + "version": "0.2.0", "dependencies": { "@gitbook/api": "0.96.1", "@gitbook/cache-tags": "workspace:*", @@ -144,7 +144,7 @@ "warn-once": "^0.1.1", }, "devDependencies": { - "@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@40fec7d", + "@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@236c84d", "gitbook": "*", "postcss": "^8", "tailwindcss": "^3.4.0", @@ -172,7 +172,7 @@ }, "packages/openapi-parser": { "name": "@gitbook/openapi-parser", - "version": "2.0.2", + "version": "2.1.0", "dependencies": { "@scalar/openapi-parser": "^0.10.9", "@scalar/openapi-types": "^0.1.9", @@ -227,7 +227,7 @@ }, "packages/react-openapi": { "name": "@gitbook/react-openapi", - "version": "1.0.5", + "version": "1.1.2", "dependencies": { "@gitbook/openapi-parser": "workspace:*", "@scalar/api-client-react": "^1.1.36", @@ -793,7 +793,7 @@ "@opennextjs/aws": ["@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@756", { "dependencies": { "@aws-sdk/client-cloudfront": "3.398.0", "@aws-sdk/client-dynamodb": "^3.398.0", "@aws-sdk/client-lambda": "^3.398.0", "@aws-sdk/client-s3": "^3.398.0", "@aws-sdk/client-sqs": "^3.398.0", "@node-minify/core": "^8.0.6", "@node-minify/terser": "^8.0.6", "@tsconfig/node18": "^1.0.1", "aws4fetch": "^1.0.18", "chalk": "^5.3.0", "esbuild": "0.19.2", "express": "5.0.1", "path-to-regexp": "^6.3.0", "urlpattern-polyfill": "^10.0.0" }, "bin": { "open-next": "./dist/index.js" } }], - "@opennextjs/cloudflare": ["@opennextjs/cloudflare@https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@40fec7d", { "dependencies": { "@ast-grep/napi": "^0.34.1", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@756", "enquirer": "^2.4.1", "glob": "^11.0.0", "yaml": "^2.7.0" }, "peerDependencies": { "wrangler": "^3.111.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }], + "@opennextjs/cloudflare": ["@opennextjs/cloudflare@https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@236c84d", { "dependencies": { "@ast-grep/napi": "^0.34.1", "@dotenvx/dotenvx": "1.31.0", "@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@756", "enquirer": "^2.4.1", "glob": "^11.0.0", "yaml": "^2.7.0" }, "peerDependencies": { "wrangler": "^3.111.0" }, "bin": { "opennextjs-cloudflare": "dist/cli/index.js" } }], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], diff --git a/packages/gitbook-v2/package.json b/packages/gitbook-v2/package.json index 3bdfe7acd9..9ddf65e2d2 100644 --- a/packages/gitbook-v2/package.json +++ b/packages/gitbook-v2/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "gitbook": "*", - "@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@40fec7d", + "@opennextjs/cloudflare": "https://pkg.pr.new/opennextjs/opennextjs-cloudflare/@opennextjs/cloudflare@236c84d", "tailwindcss": "^3.4.0", "postcss": "^8" }, diff --git a/packages/gitbook-v2/src/app/sites/dynamic/[mode]/[siteURL]/[pagePath]/page.tsx b/packages/gitbook-v2/src/app/sites/dynamic/[mode]/[siteURL]/[pagePath]/page.tsx index ec81460f3f..609057393e 100644 --- a/packages/gitbook-v2/src/app/sites/dynamic/[mode]/[siteURL]/[pagePath]/page.tsx +++ b/packages/gitbook-v2/src/app/sites/dynamic/[mode]/[siteURL]/[pagePath]/page.tsx @@ -16,7 +16,7 @@ export default async function Page(props: PageProps) { const context = await getDynamicSiteContext(params); const pathname = getPagePathFromParams(params); - return ; + return ; } export async function generateViewport(props: PageProps): Promise { @@ -25,14 +25,12 @@ export async function generateViewport(props: PageProps): Promise { } export async function generateMetadata(props: PageProps): Promise { - const [params, searchParams] = await Promise.all([props.params, props.searchParams]); + const params = await props.params; const context = await getDynamicSiteContext(params); const pathname = getPagePathFromParams(params); return generateSitePageMetadata({ context, pageParams: { pathname }, - redirectOnFallback: true, - fallback: !!searchParams.fallback, }); } diff --git a/packages/gitbook-v2/src/app/sites/static/[mode]/[siteURL]/[pagePath]/page.tsx b/packages/gitbook-v2/src/app/sites/static/[mode]/[siteURL]/[pagePath]/page.tsx index 43b9be8c43..4939dcbb7d 100644 --- a/packages/gitbook-v2/src/app/sites/static/[mode]/[siteURL]/[pagePath]/page.tsx +++ b/packages/gitbook-v2/src/app/sites/static/[mode]/[siteURL]/[pagePath]/page.tsx @@ -28,7 +28,7 @@ export default async function Page(props: PageProps) { }) ); - return ; + return ; } export async function generateViewport(props: PageProps): Promise { @@ -44,6 +44,5 @@ export async function generateMetadata(props: PageProps): Promise { return generateSitePageMetadata({ context, pageParams: { pathname }, - redirectOnFallback: true, }); } diff --git a/packages/gitbook-v2/src/lib/context.ts b/packages/gitbook-v2/src/lib/context.ts index 85a3b7c028..7e9927c950 100644 --- a/packages/gitbook-v2/src/lib/context.ts +++ b/packages/gitbook-v2/src/lib/context.ts @@ -116,40 +116,57 @@ export function getBaseContext(input: { apiToken: input.apiToken ?? GITBOOK_API_TOKEN, apiEndpoint: GITBOOK_API_URL, }); - const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined; + const linker = getLinkerForSiteURL({ + siteURL: url, + urlMode, + }); + + const imageResizer = createImageResizer({ + host: url.host, + // To ensure image resizing work for proxied sites, + // we serve images from the root of the site. + linker: linker, + }); + + return { + dataFetcher, + linker, + imageResizer, + }; +} + +/** + * Get the linker for a given site URL. + */ +export function getLinkerForSiteURL(input: { + siteURL: URL; + urlMode: 'url' | 'url-host'; +}) { + const { siteURL, urlMode } = input; + + const gitbookURL = GITBOOK_URL ? new URL(GITBOOK_URL) : undefined; const linker = urlMode === 'url-host' ? createLinker({ - host: url.host, - pathname: url.pathname, + host: siteURL.host, + pathname: siteURL.pathname, }) : createLinker({ protocol: gitbookURL?.protocol, host: gitbookURL?.host, - pathname: `/url/${url.host}${url.pathname}`, + pathname: `/url/${siteURL.host}${siteURL.pathname}`, }); if (urlMode === 'url') { // Create link in the same format for links to other sites/sections. linker.toLinkForContent = (rawURL: string) => { const urlObject = new URL(rawURL); - return `/url/${urlObject.host}${urlObject.pathname}`; + return `/url/${urlObject.host}${urlObject.pathname}${urlObject.search}`; }; } - const imageResizer = createImageResizer({ - host: url.host, - // To ensure image resizing work for proxied sites, - // we serve images from the root of the site. - linker: linker, - }); - - return { - dataFetcher, - linker, - imageResizer, - }; + return linker; } /** diff --git a/packages/gitbook-v2/src/middleware.ts b/packages/gitbook-v2/src/middleware.ts index f47ae9ed85..70e8a0058b 100644 --- a/packages/gitbook-v2/src/middleware.ts +++ b/packages/gitbook-v2/src/middleware.ts @@ -5,9 +5,15 @@ import { NextResponse } from 'next/server'; import { getContentSecurityPolicy } from '@/lib/csp'; import { validateSerializedCustomization } from '@/lib/customization'; import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths'; -import { getResponseCookiesForVisitorAuth, getVisitorToken } from '@/lib/visitor-token'; +import { + type ResponseCookies, + getResponseCookiesForVisitorAuth, + getVisitorToken, + normalizeVisitorAuthURL, +} from '@/lib/visitor-token'; import { serveResizedImage } from '@/routes/image'; -import { getPublishedContentByURL } from '@v2/lib/data'; +import { getLinkerForSiteURL } from '@v2/lib/context'; +import { getPublishedContentByURL, normalizeURL } from '@v2/lib/data'; import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env'; import { MiddlewareHeaders } from '@v2/lib/middleware'; @@ -21,6 +27,14 @@ type URLWithMode = { url: URL; mode: 'url' | 'url-host' }; export async function middleware(request: NextRequest) { try { + const requestURL = new URL(request.url); + + // Redirect to normalize the URL + const normalized = normalizeURL(requestURL); + if (normalized.toString() !== requestURL.toString()) { + return NextResponse.redirect(normalized.toString()); + } + // Route all requests to a site const extracted = getSiteURLFromRequest(request); if (extracted) { @@ -38,7 +52,7 @@ export async function middleware(request: NextRequest) { }); } - return await serveSiteByURL(request, extracted); + return await serveSiteByURL(requestURL, request, extracted); } // Handle the rest with the router default logic @@ -51,7 +65,7 @@ export async function middleware(request: NextRequest) { /** * Serve site by URL. */ -async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) { +async function serveSiteByURL(requestURL: URL, request: NextRequest, urlWithMode: URLWithMode) { const { url, mode } = urlWithMode; // Visitor authentication @@ -72,11 +86,55 @@ async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) { } const { data } = result; + let cookies: ResponseCookies = {}; + // + // 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: url, + urlMode: mode, + }); + + const contentRedirect = new URL(linker.toLinkForContent(data.redirect), request.url); + + // 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); } + cookies = { + ...cookies, + ...getResponseCookiesForVisitorAuth(data.basePath, 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 + ); + } + + // + // Render and serve the content + // + // When visitor has authentication (adaptive content or VA), we serve dynamic routes. let routeType = visitorToken ? 'dynamic' : 'static'; @@ -108,13 +166,13 @@ async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) { requestHeaders.set('x-forwarded-host', request.nextUrl.host); requestHeaders.set('origin', request.nextUrl.origin); - const siteURL = `${url.host}${data.basePath}`; + const siteURLWithoutProtocol = `${url.host}${data.basePath}`; const route = [ 'sites', routeType, mode, - encodeURIComponent(siteURL), + encodeURIComponent(siteURLWithoutProtocol), encodePathInSiteContent(data.pathname), ].join('/'); @@ -135,16 +193,9 @@ async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) { response.headers.set('x-content-type-options', 'nosniff'); // Debug header response.headers.set('x-gitbook-route-type', routeType); - response.headers.set('x-gitbook-site-url', siteURL); + response.headers.set('x-gitbook-route-site', siteURLWithoutProtocol); - if (visitorToken) { - const cookies = getResponseCookiesForVisitorAuth(data.basePath, visitorToken); - for (const [key, value] of Object.entries(cookies)) { - response.cookies.set(key, value.value, value.options); - } - } - - return response; + return writeResponseCookies(response, cookies); } /** @@ -248,3 +299,14 @@ function appendQueryParams(url: URL, from: URLSearchParams) { return url; } + +/** + * Write the cookies to a response. + */ +function writeResponseCookies(response: R, cookies: ResponseCookies): R { + Object.entries(cookies).forEach(([key, { value, options }]) => { + response.cookies.set(key, value, options); + }); + + return response; +} diff --git a/packages/gitbook/e2e/internal.spec.ts b/packages/gitbook/e2e/internal.spec.ts index 1838e086c5..955f28e26c 100644 --- a/packages/gitbook/e2e/internal.spec.ts +++ b/packages/gitbook/e2e/internal.spec.ts @@ -155,8 +155,8 @@ const testCases: TestsCase[] = [ .click(); // It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant - await page.waitForURL( - 'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions/2.0/reference/api-reference/pets?fallback=true' + await page.waitForURL((url) => + url.pathname.includes('api-multi-versions/2.0/reference/api-reference/pets') ); }, }, @@ -178,8 +178,10 @@ const testCases: TestsCase[] = [ .click(); // It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant - await page.waitForURL( - 'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/2.0/reference/api-reference/pets?fallback=true' + await page.waitForURL((url) => + url.pathname.includes( + 'api-multi-versions-share-links/8tNo6MeXg7CkFMzSSz81/2.0/reference/api-reference/pets' + ) ); }, }, @@ -213,8 +215,10 @@ const testCases: TestsCase[] = [ .click(); // It should keep the current page path, i.e "reference/api-reference/pets" when navigating to the new variant - await page.waitForURL( - 'https://gitbook-open-e2e-sites.gitbook.io/api-multi-versions-va/2.0/reference/api-reference/pets?fallback=true' + await page.waitForURL((url) => + url.pathname.includes( + 'api-multi-versions-va/2.0/reference/api-reference/pets' + ) ); }, }, @@ -244,9 +248,7 @@ const testCases: TestsCase[] = [ const sectionGroupDropdown = await page.getByText('Test Section Group 1'); await sectionGroupDropdown.hover(); await page.getByText('Section B').click(); - await page.waitForURL( - 'https://gitbook-open-e2e-sites.gitbook.io/sections/sections-4' - ); + await page.waitForURL((url) => url.pathname.includes('/sections/sections-4')); }, }, ], @@ -820,6 +822,9 @@ const testCases: TestsCase[] = [ expiresIn: '24h', } ); + + // Test that when accessing the non-canonical URL, we are redirected to the canonical URL + // with the jwt token in the query string return `spacea?jwt_token=${token}`; })(), run: waitForCookiesDialog, diff --git a/packages/gitbook/src/app/middleware/(site)/(content)/[[...pathname]]/page.tsx b/packages/gitbook/src/app/middleware/(site)/(content)/[[...pathname]]/page.tsx index 9e31f572dc..d680e8ce47 100644 --- a/packages/gitbook/src/app/middleware/(site)/(content)/[[...pathname]]/page.tsx +++ b/packages/gitbook/src/app/middleware/(site)/(content)/[[...pathname]]/page.tsx @@ -38,10 +38,9 @@ export async function generateMetadata(props: PageProps): Promise { } async function getSitePageProps(props: PageProps) { - const { params: rawParams, searchParams: rawSearchParams } = props; + const { params: rawParams } = props; const params = await rawParams; - const searchParams = await rawSearchParams; const pointer = await getSiteContentPointer(); const context = await fetchV1ContextForSitePointer(pointer); @@ -49,7 +48,5 @@ async function getSitePageProps(props: PageProps) { return { context, pageParams: params, - redirectOnFallback: true, - fallback: !!searchParams.fallback, }; } diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx index 5ecdf7717c..e180d9fff1 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx @@ -48,7 +48,7 @@ function ToolbarLayout(props: { children: React.ReactNode }) { */ export async function AdminToolbar(props: AdminToolbarProps) { const { context } = props; - const mode = await headers().get('x-gitbook-mode'); + const mode = (await headers()).get('x-gitbook-mode'); if (mode === 'multi-id') { // We don't show the admin toolbar in multi-id mode, as it's used for previewing in the dashboard. diff --git a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx index 671c646192..4cd9b34ad7 100644 --- a/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx +++ b/packages/gitbook/src/components/SiteLayout/SiteLayout.tsx @@ -101,7 +101,6 @@ export async function generateSiteLayoutMetadata(context: GitBookSiteContext): P return { title: site.title, generator: `GitBook (${buildVersion()})`, - // metadataBase: new URL(await getBaseUrl()), icons: { icon: [ { diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index 50ee21f33a..6da8b1c41e 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { PageAside } from '@/components/PageAside'; import { PageBody, PageCover } from '@/components/PageBody'; -import { getPagePath, resolveFirstDocument } from '@/lib/pages'; +import { getPagePath } from '@/lib/pages'; import { isPageIndexable, isSiteIndexable } from '@/lib/seo'; import { PageClientLayout } from './PageClientLayout'; @@ -19,8 +19,6 @@ export const dynamic = 'force-dynamic'; export type SitePageProps = { context: GitBookSiteContext; pageParams: PagePathParams; - redirectOnFallback: boolean; - fallback?: boolean; }; /** @@ -30,8 +28,6 @@ export async function SitePage(props: SitePageProps) { const { context, pageTarget } = await getPageDataWithFallback({ context: props.context, pagePathParams: props.pageParams, - fallback: props.fallback, - redirectOnFallback: props.redirectOnFallback, }); const rawPathname = getPathnameParam(props.pageParams); @@ -115,7 +111,6 @@ export async function generateSitePageMetadata(props: SitePageProps): Promise { + if (fallback) { + router.replace(basePath); + } + }, [basePath, fallback, router]); return (
-
-
+ - - -
- ) - } - innerHeader={ - // displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC. - <> - {!withTopHeader && ( -
- - - - {t( - getSpaceLanguage(customization), - customization.aiSearch.enabled - ? 'search_or_ask' - : 'search' - )} - - - +
+
+ +
- )} - {!withTopHeader && withSections && sections && ( - - )} - {isMultiVariants && ( - - )} - - } - /> -
{children}
-
+ ) + } + innerHeader={ + // displays the search button and/or the space dropdown in the ToC according to the header/variant settings. E.g if there is no header, the search button will be displayed in the ToC. + <> + {!withTopHeader && ( +
+ + + + {t( + getSpaceLanguage(customization), + customization.aiSearch.enabled + ? 'search_or_ask' + : 'search' + )} + + + +
+ )} + {!withTopHeader && withSections && sections && ( + + )} + {isMultiVariants && ( + + )} + + } + /> +
{children}
+
- {withFooter ?