From ecfdb976a32e148e75490688dec81fac65cabfd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Fri, 10 Jan 2025 14:15:39 +0100 Subject: [PATCH] Cleanup headers usage (#2717) --- .changeset/friendly-trains-exercise.md | 5 + .../src/app/(global)/~gitbook/image/route.ts | 4 +- .../(content)/[[...pathname]]/not-found.tsx | 10 +- .../(site)/(content)/[[...pathname]]/page.tsx | 87 ++--- .../src/app/(site)/(content)/layout.tsx | 35 +- .../src/app/(site)/(core)/robots.txt/route.ts | 12 +- .../app/(site)/(core)/sitemap.xml/route.ts | 46 +-- .../app/(site)/(core)/~gitbook/icon/route.tsx | 10 +- .../~gitbook/ogimage/[pageId]/route.tsx | 15 +- packages/gitbook/src/app/(site)/fetch.ts | 42 +-- packages/gitbook/src/app/(site)/layout.tsx | 8 +- .../src/app/(space)/~gitbook/pdf/layout.tsx | 18 +- .../src/app/(space)/~gitbook/pdf/page.tsx | 25 +- .../src/app/(space)/~gitbook/pdf/pointer.ts | 11 +- .../components/AdminToolbar/AdminToolbar.tsx | 9 +- .../RefreshChangeRequestButton.tsx | 3 + packages/gitbook/src/components/Ads/Ad.tsx | 9 +- .../src/components/Ads/AdClassicRendering.tsx | 7 +- .../src/components/Ads/AdCoverRendering.tsx | 5 +- .../gitbook/src/components/Ads/renderAd.tsx | 33 +- .../AutoRefreshContent/server-actions.ts | 20 +- .../useCheckForContentUpdate.ts | 15 +- .../DocumentView/BlockContentRef.tsx | 6 +- .../src/components/DocumentView/Embed.tsx | 9 +- .../Integration/IntegrationBlock.tsx | 9 +- .../DocumentView/ReusableContent.tsx | 9 +- .../components/Footer/FooterLinksGroup.tsx | 5 +- .../src/components/Header/HeaderLink.tsx | 8 +- .../src/components/Header/HeaderLinkMore.tsx | 5 +- .../src/components/Header/HeaderLogo.tsx | 5 +- .../src/components/PageAside/PageAside.tsx | 16 +- .../src/components/PageBody/PageBody.tsx | 11 +- .../PageBody/PageBodyBlankslate.tsx | 7 +- .../src/components/PageBody/PageCover.tsx | 5 +- .../PageBody/PageFooterNavigation.tsx | 7 +- .../src/components/PageBody/PageHeader.tsx | 5 +- .../PageFeedback/PageFeedbackForm.tsx | 7 +- .../components/PageFeedback/server-actions.ts | 20 +- .../src/components/Search/SearchAskAnswer.tsx | 30 +- .../src/components/Search/SearchModal.tsx | 5 +- .../src/components/Search/SearchResults.tsx | 17 +- .../src/components/Search/server-actions.tsx | 166 +++++----- .../src/components/Space/SpaceIcon.tsx | 9 +- .../components/SpaceLayout/SpaceLayout.tsx | 11 +- .../TableOfContents/PageDocumentItem.tsx | 5 +- .../TableOfContents/PageLinkItem.tsx | 5 +- .../gitbook/src/components/utils/Image.tsx | 23 +- packages/gitbook/src/lib/api.ts | 309 ++++++++++-------- packages/gitbook/src/lib/cache/cache.test.ts | 12 +- packages/gitbook/src/lib/cache/cache.ts | 8 +- packages/gitbook/src/lib/csp.ts | 9 +- packages/gitbook/src/lib/gitbook-context.ts | 83 +++++ packages/gitbook/src/lib/image-signatures.ts | 36 +- packages/gitbook/src/lib/images.ts | 19 +- packages/gitbook/src/lib/links.ts | 46 ++- packages/gitbook/src/lib/pointer.ts | 26 +- packages/gitbook/src/lib/references.tsx | 33 +- packages/gitbook/src/lib/seo.ts | 21 +- packages/gitbook/src/lib/tracking.ts | 9 +- packages/gitbook/src/lib/visitor-token.ts | 9 - packages/gitbook/src/middleware.ts | 57 ++-- 61 files changed, 901 insertions(+), 610 deletions(-) create mode 100644 .changeset/friendly-trains-exercise.md create mode 100644 packages/gitbook/src/lib/gitbook-context.ts diff --git a/.changeset/friendly-trains-exercise.md b/.changeset/friendly-trains-exercise.md new file mode 100644 index 000000000..eb1616633 --- /dev/null +++ b/.changeset/friendly-trains-exercise.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Fix multiple bugs due to headers read in an anarchic way in the app. diff --git a/packages/gitbook/src/app/(global)/~gitbook/image/route.ts b/packages/gitbook/src/app/(global)/~gitbook/image/route.ts index f96d0bc90..67096ba32 100644 --- a/packages/gitbook/src/app/(global)/~gitbook/image/route.ts +++ b/packages/gitbook/src/app/(global)/~gitbook/image/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { CURRENT_SIGNATURE_VERSION, isSignatureVersion, @@ -39,7 +40,8 @@ export async function GET(request: NextRequest) { } // Verify the signature - const verified = await verifyImageSignature(url, { signature, version: signatureVersion }); + const ctx = getGitBookContextFromHeaders(request.headers); + const verified = await verifyImageSignature(ctx, url, { signature, version: signatureVersion }); if (!verified) { return new Response(`Invalid signature "${signature ?? ''}" for "${url}"`, { status: 400 }); } diff --git a/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/not-found.tsx b/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/not-found.tsx index cd36e5b26..bff278a06 100644 --- a/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/not-found.tsx +++ b/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/not-found.tsx @@ -1,14 +1,18 @@ +import { headers } from 'next/headers'; + import { TrackPageViewEvent } from '@/components/Insights'; import { getSpaceLanguage, t } from '@/intl/server'; import { getSiteData, getSpaceContentData } from '@/lib/api'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getSiteContentPointer } from '@/lib/pointer'; import { tcls } from '@/lib/tailwind'; export default async function NotFound() { - const pointer = await getSiteContentPointer(); + const ctx = getGitBookContextFromHeaders(await headers()); + const pointer = getSiteContentPointer(ctx); const [{ space }, { customization }] = await Promise.all([ - getSpaceContentData(pointer, pointer.siteShareKey), - getSiteData(pointer), + getSpaceContentData(ctx, pointer, pointer.siteShareKey), + getSiteData(ctx, pointer), ]); const language = getSpaceLanguage(customization); diff --git a/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx b/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx index 7a88e5cb9..d40e82444 100644 --- a/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx +++ b/packages/gitbook/src/app/(site)/(content)/[[...pathname]]/page.tsx @@ -1,10 +1,12 @@ import { CustomizationHeaderPreset, CustomizationThemeMode } from '@gitbook/api'; import { Metadata, Viewport } from 'next'; +import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; import React from 'react'; import { PageAside } from '@/components/PageAside'; import { PageBody, PageCover } from '@/components/PageBody'; +import { getGitBookContextFromHeaders, GitBookContext } from '@/lib/gitbook-context'; import { PageHrefContext, getAbsoluteHref, getPageHref } from '@/lib/links'; import { getPagePath, resolveFirstDocument } from '@/lib/pages'; import { ContentRefContext } from '@/lib/references'; @@ -16,17 +18,21 @@ import { PagePathParams, fetchPageData, getPathnameParam, normalizePathname } fr export const runtime = 'edge'; +type Props = { + params: Promise; + searchParams: Promise<{ fallback?: string }>; +}; + /** * Fetch and render a page. */ -export default async function Page(props: { - params: Promise; - searchParams: Promise<{ fallback?: string }>; -}) { - const { params: rawParams, searchParams: rawSearchParams } = props; - - const params = await rawParams; - const searchParams = await rawSearchParams; +export default async function Page(props: Props) { + const [headersList, params, searchParams] = await Promise.all([ + headers(), + props.params, + props.searchParams, + ]); + const ctx = getGitBookContextFromHeaders(headersList); const { content: contentPointer, @@ -39,7 +45,7 @@ export default async function Page(props: { page, ancestors, document, - } = await getPageDataWithFallback({ + } = await getPageDataWithFallback(ctx, { pagePathParams: params, searchParams, redirectOnFallback: true, @@ -52,12 +58,12 @@ export default async function Page(props: { if (pathname !== rawPathname) { // If the pathname was not normalized, redirect to the normalized version // before trying to resolve the page again - redirect(await getAbsoluteHref(pathname)); + redirect(getAbsoluteHref(ctx, pathname)); } else { notFound(); } } else if (getPagePath(pages, page) !== rawPathname) { - redirect(await getPageHref(pages, page, linksContext)); + redirect(getPageHref(ctx, pages, page, linksContext)); } const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None; @@ -121,12 +127,10 @@ export default async function Page(props: { ); } -export async function generateViewport({ - params, -}: { - params: Promise; -}): Promise { - const { customization } = await fetchPageData(await params); +export async function generateViewport(props: Props): Promise { + const [params, headersList] = await Promise.all([props.params, headers()]); + const ctx = getGitBookContextFromHeaders(headersList); + const { customization } = await fetchPageData(ctx, params); return { colorScheme: customization.themes.toggeable ? customization.themes.default === CustomizationThemeMode.Dark @@ -136,17 +140,20 @@ export async function generateViewport({ }; } -export async function generateMetadata({ - params, - searchParams, -}: { - params: Promise; - searchParams: Promise<{ fallback?: string }>; -}): Promise { - const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback({ - pagePathParams: await params, - searchParams: await searchParams, - }); +export async function generateMetadata(props: Props): Promise { + const [params, searchParams, headersList] = await Promise.all([ + props.params, + props.searchParams, + headers(), + ]); + const ctx = getGitBookContextFromHeaders(headersList); + const { space, pages, page, customization, site, ancestors } = await getPageDataWithFallback( + ctx, + { + pagePathParams: params, + searchParams: searchParams, + }, + ); if (!page) { notFound(); @@ -159,17 +166,16 @@ export async function generateMetadata({ description: page.description ?? '', alternates: { // Trim trailing slashes in canonical URL to match the redirect behavior - canonical: (await getAbsoluteHref(getPagePath(pages, page), true)).replace(/\/+$/, ''), + canonical: getAbsoluteHref(ctx, getPagePath(pages, page), true).replace(/\/+$/, ''), }, openGraph: { images: [ customization.socialPreview.url ?? - (await getAbsoluteHref(`~gitbook/ogimage/${page.id}`, true)), + getAbsoluteHref(ctx, `~gitbook/ogimage/${page.id}`, true), ], }, robots: - (await isSpaceIndexable({ space, site: site ?? null })) && - isPageIndexable(ancestors, page) + isSpaceIndexable(ctx, { space, site: site ?? null }) && isPageIndexable(ancestors, page) ? 'index, follow' : 'noindex, nofollow', }; @@ -178,14 +184,17 @@ export async function generateMetadata({ /** * Fetches the page data matching the requested pathname and fallback to root page when page is not found. */ -async function getPageDataWithFallback(args: { - pagePathParams: PagePathParams; - searchParams: { fallback?: string }; - redirectOnFallback?: boolean; -}) { +async function getPageDataWithFallback( + ctx: GitBookContext, + args: { + pagePathParams: PagePathParams; + searchParams: { fallback?: string }; + redirectOnFallback?: boolean; + }, +) { const { pagePathParams, searchParams, redirectOnFallback = false } = args; - const { pages, page: targetPage, ...otherPageData } = await fetchPageData(pagePathParams); + const { pages, page: targetPage, ...otherPageData } = await fetchPageData(ctx, pagePathParams); let page = targetPage; const canFallback = !!searchParams.fallback; @@ -193,7 +202,7 @@ async function getPageDataWithFallback(args: { const rootPage = resolveFirstDocument(pages, []); if (redirectOnFallback && rootPage?.page) { - redirect(await getPageHref(pages, rootPage?.page)); + redirect(getPageHref(ctx, pages, rootPage?.page)); } page = rootPage?.page; diff --git a/packages/gitbook/src/app/(site)/(content)/layout.tsx b/packages/gitbook/src/app/(site)/(content)/layout.tsx index ef848c882..6e628b95a 100644 --- a/packages/gitbook/src/app/(site)/(content)/layout.tsx +++ b/packages/gitbook/src/app/(site)/(content)/layout.tsx @@ -13,6 +13,7 @@ import { api } from '@/lib/api'; import { assetsDomain } from '@/lib/assets'; import { buildVersion } from '@/lib/build'; import { getContentSecurityPolicyNonce } from '@/lib/csp'; +import { GitBookContext, getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getAbsoluteHref, getBaseUrl } from '@/lib/links'; import { isSpaceIndexable } from '@/lib/seo'; import { getContentTitle } from '@/lib/utils'; @@ -27,9 +28,10 @@ export const runtime = 'edge'; * Layout when rendering the content. */ export default async function ContentLayout(props: { children: React.ReactNode }) { + const ctx = getGitBookContextFromHeaders(await headers()); const { children } = props; - const nonce = await getContentSecurityPolicyNonce(); + const nonce = getContentSecurityPolicyNonce(ctx); const { content, space, @@ -41,10 +43,9 @@ export default async function ContentLayout(props: { children: React.ReactNode } ancestors, scripts, sections, - } = await fetchContentData(); + } = await fetchContentData(ctx); - const apiCtx = await api(); - ReactDOM.preconnect(apiCtx.client.endpoint); + ReactDOM.preconnect(api(ctx).client.endpoint); if (assetsDomain) { ReactDOM.preconnect(assetsDomain); } @@ -56,7 +57,7 @@ export default async function ContentLayout(props: { children: React.ReactNode } }); }); - const queryStringTheme = await getQueryStringTheme(); + const queryStringTheme = getQueryStringTheme(ctx); return ( @@ -105,7 +106,8 @@ export default async function ContentLayout(props: { children: React.ReactNode } } export async function generateViewport(): Promise { - const { customization } = await fetchContentData(); + const ctx = getGitBookContextFromHeaders(await headers()); + const { customization } = await fetchContentData(ctx); return { colorScheme: customization.themes.toggeable ? customization.themes.default === CustomizationThemeMode.Dark @@ -116,46 +118,43 @@ export async function generateViewport(): Promise { } export async function generateMetadata(): Promise { - const { space, site, customization } = await fetchContentData(); + const ctx = getGitBookContextFromHeaders(await headers()); + const { space, site, customization } = await fetchContentData(ctx); const customIcon = 'icon' in customization.favicon ? customization.favicon.icon : null; return { title: getContentTitle(space, customization, site), generator: `GitBook (${buildVersion()})`, - metadataBase: new URL(await getBaseUrl()), + metadataBase: new URL(getBaseUrl(ctx)), icons: { icon: [ { url: customIcon?.light ?? - (await getAbsoluteHref('~gitbook/icon?size=small&theme=light', true)), + getAbsoluteHref(ctx, '~gitbook/icon?size=small&theme=light', true), type: 'image/png', media: '(prefers-color-scheme: light)', }, { url: customIcon?.dark ?? - (await getAbsoluteHref('~gitbook/icon?size=small&theme=dark', true)), + getAbsoluteHref(ctx, '~gitbook/icon?size=small&theme=dark', true), type: 'image/png', media: '(prefers-color-scheme: dark)', }, ], }, - robots: (await isSpaceIndexable({ space, site })) ? 'index, follow' : 'noindex, nofollow', + robots: isSpaceIndexable(ctx, { space, site }) ? 'index, follow' : 'noindex, nofollow', }; } /** * For preview, the theme can be set via query string (?theme=light). */ -async function getQueryStringTheme() { - const headersList = await headers(); - const queryStringTheme = headersList.get('x-gitbook-theme'); - if (!queryStringTheme) { +function getQueryStringTheme(ctx: GitBookContext) { + if (!ctx.theme) { return null; } - return queryStringTheme === 'light' - ? CustomizationThemeMode.Light - : CustomizationThemeMode.Dark; + return ctx.theme === 'light' ? CustomizationThemeMode.Light : CustomizationThemeMode.Dark; } diff --git a/packages/gitbook/src/app/(site)/(core)/robots.txt/route.ts b/packages/gitbook/src/app/(site)/(core)/robots.txt/route.ts index 8035d6fcb..9db6cf286 100644 --- a/packages/gitbook/src/app/(site)/(core)/robots.txt/route.ts +++ b/packages/gitbook/src/app/(site)/(core)/robots.txt/route.ts @@ -1,6 +1,7 @@ import { NextRequest } from 'next/server'; import { getSpace, getSite } from '@/lib/api'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getAbsoluteHref } from '@/lib/links'; import { getSiteContentPointer } from '@/lib/pointer'; import { isSpaceIndexable } from '@/lib/seo'; @@ -11,17 +12,18 @@ export const runtime = 'edge'; * Generate a robots.txt for the current space. */ export async function GET(req: NextRequest) { - const pointer = await getSiteContentPointer(); + const ctx = getGitBookContextFromHeaders(req.headers); + const pointer = getSiteContentPointer(ctx); const [site, space] = await Promise.all([ - getSite(pointer.organizationId, pointer.siteId), - getSpace(pointer.spaceId, pointer.siteShareKey), + getSite(ctx, pointer.organizationId, pointer.siteId), + getSpace(ctx, pointer.spaceId, pointer.siteShareKey), ]); const lines = [ `User-agent: *`, 'Disallow: /~gitbook/', - ...((await isSpaceIndexable({ space, site })) - ? [`Allow: /`, `Sitemap: ${await getAbsoluteHref(`/sitemap.xml`, true)}`] + ...(isSpaceIndexable(ctx, { space, site }) + ? [`Allow: /`, `Sitemap: ${getAbsoluteHref(ctx, `/sitemap.xml`, true)}`] : [`Disallow: /`]), ]; const content = lines.join('\n'); diff --git a/packages/gitbook/src/app/(site)/(core)/sitemap.xml/route.ts b/packages/gitbook/src/app/(site)/(core)/sitemap.xml/route.ts index 23fa77de6..fc211e3a4 100644 --- a/packages/gitbook/src/app/(site)/(core)/sitemap.xml/route.ts +++ b/packages/gitbook/src/app/(site)/(core)/sitemap.xml/route.ts @@ -3,6 +3,7 @@ import jsontoxml from 'jsontoxml'; import { NextRequest } from 'next/server'; import { getSpaceContentData } from '@/lib/api'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getAbsoluteHref } from '@/lib/links'; import { getPagePath } from '@/lib/pages'; import { getSiteContentPointer } from '@/lib/pointer'; @@ -14,33 +15,32 @@ export const runtime = 'edge'; * Generate a sitemap.xml for the current space. */ export async function GET(req: NextRequest) { - const pointer = await getSiteContentPointer(); - const { pages: rootPages } = await getSpaceContentData(pointer, pointer.siteShareKey); + const ctx = getGitBookContextFromHeaders(req.headers); + const pointer = getSiteContentPointer(ctx); + const { pages: rootPages } = await getSpaceContentData(ctx, pointer, pointer.siteShareKey); const pages = flattenPages(rootPages, (page) => !page.hidden && isPageIndexable([], page)); - const urls = await Promise.all( - pages.map(async ({ page, depth }) => { - // Decay priority with depth - const priority = Math.pow(2, -0.25 * depth); - // Normalize to keep 2 decimals - const normalizedPriority = Math.floor(100 * priority) / 100; + const urls = pages.map(({ page, depth }) => { + // Decay priority with depth + const priority = Math.pow(2, -0.25 * depth); + // Normalize to keep 2 decimals + const normalizedPriority = Math.floor(100 * priority) / 100; - const lastModified = page.updatedAt || page.createdAt; + const lastModified = page.updatedAt || page.createdAt; - return { - url: { - loc: await getAbsoluteHref(getPagePath(rootPages, page), true), - priority: normalizedPriority, - ...(lastModified - ? { - // lastmod format is YYYY-MM-DD - lastmod: new Date(lastModified).toISOString().split('T')[0], - } - : {}), - }, - }; - }), - ); + return { + url: { + loc: getAbsoluteHref(ctx, getPagePath(rootPages, page), true), + priority: normalizedPriority, + ...(lastModified + ? { + // lastmod format is YYYY-MM-DD + lastmod: new Date(lastModified).toISOString().split('T')[0], + } + : {}), + }, + }; + }); const xml = jsontoxml( [ diff --git a/packages/gitbook/src/app/(site)/(core)/~gitbook/icon/route.tsx b/packages/gitbook/src/app/(site)/(core)/~gitbook/icon/route.tsx index 1640266f6..ea8f720a5 100644 --- a/packages/gitbook/src/app/(site)/(core)/~gitbook/icon/route.tsx +++ b/packages/gitbook/src/app/(site)/(core)/~gitbook/icon/route.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { getSite, getSiteData, getSpace } from '@/lib/api'; import { getEmojiForCode } from '@/lib/emojis'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getSiteContentPointer } from '@/lib/pointer'; import { tcls } from '@/lib/tailwind'; import { getContentTitle } from '@/lib/utils'; @@ -32,17 +33,18 @@ const SIZES = { * Render an icon for the space. */ export async function GET(req: NextRequest) { + const ctx = getGitBookContextFromHeaders(req.headers); const options = getOptions(req.url); const size = SIZES[options.size]; - const pointer = await getSiteContentPointer(); + const pointer = getSiteContentPointer(ctx); const spaceId = pointer.spaceId; const [space, { customization }] = await Promise.all([ - getSpace(spaceId, pointer.siteShareKey), - getSiteData(pointer), + getSpace(ctx, spaceId, pointer.siteShareKey), + getSiteData(ctx, pointer), ]); - const site = await getSite(pointer.organizationId, pointer.siteId); + const site = await getSite(ctx, pointer.organizationId, pointer.siteId); const contentTitle = getContentTitle(space, customization, site); return new ImageResponse( diff --git a/packages/gitbook/src/app/(site)/(core)/~gitbook/ogimage/[pageId]/route.tsx b/packages/gitbook/src/app/(site)/(core)/~gitbook/ogimage/[pageId]/route.tsx index ad83030cf..9de958c31 100644 --- a/packages/gitbook/src/app/(site)/(core)/~gitbook/ogimage/[pageId]/route.tsx +++ b/packages/gitbook/src/app/(site)/(core)/~gitbook/ogimage/[pageId]/route.tsx @@ -5,6 +5,7 @@ import { NextRequest } from 'next/server'; import colorContrast from 'postcss-color-contrast/js'; import React from 'react'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getAbsoluteHref } from '@/lib/links'; import { tcls } from '@/lib/tailwind'; import { getContentTitle } from '@/lib/utils'; @@ -17,7 +18,8 @@ export const runtime = 'edge'; * Render the OpenGraph image for a space. */ export async function GET(req: NextRequest, { params }: { params: Promise }) { - const { space, page, customization, site } = await fetchPageData(await params); + const ctx = getGitBookContextFromHeaders(req.headers); + const { space, page, customization, site } = await fetchPageData(ctx, await params); if (customization.socialPreview.url) { // If user configured a custom social preview, we redirect to it. @@ -51,10 +53,8 @@ export async function GET(req: NextRequest, { params }: { params: Promise { + const favicon = (() => { if ('icon' in customization.favicon) return ( ); - const src = await getAbsoluteHref( + const src = getAbsoluteHref( + ctx, `~gitbook/icon?size=medium&theme=${customization.themes.default}`, true, ); diff --git a/packages/gitbook/src/app/(site)/fetch.ts b/packages/gitbook/src/app/(site)/fetch.ts index 9cebb3c6c..4448a5bd9 100644 --- a/packages/gitbook/src/app/(site)/fetch.ts +++ b/packages/gitbook/src/app/(site)/fetch.ts @@ -8,6 +8,7 @@ import { getSiteData, getSiteRedirectBySource, } from '@/lib/api'; +import { GitBookContext } from '@/lib/gitbook-context'; import { resolvePagePath, resolvePageId } from '@/lib/pages'; import { getSiteContentPointer } from '@/lib/pointer'; @@ -22,13 +23,13 @@ export interface PageIdParams { /** * Fetch all the data needed to render the content layout. */ -export async function fetchContentData() { - const content = await getSiteContentPointer(); +export async function fetchContentData(ctx: GitBookContext) { + const content = getSiteContentPointer(ctx); const [{ space, contentTarget, pages }, { customization, site, sections, spaces, scripts }] = await Promise.all([ - getSpaceContentData(content, content.siteShareKey), - getSiteData(content), + getSpaceContentData(ctx, content, content.siteShareKey), + getSiteData(ctx, content), ]); // we grab the space attached to the parent as it contains overriden customizations @@ -53,10 +54,10 @@ export async function fetchContentData() { * Fetch all the data needed to render the content. * Optimized to fetch in parallel as much as possible. */ -export async function fetchPageData(params: PagePathParams | PageIdParams) { - const contentData = await fetchContentData(); +export async function fetchPageData(ctx: GitBookContext, params: PagePathParams | PageIdParams) { + const contentData = await fetchContentData(ctx); - const page = await resolvePage({ + const page = await resolvePage(ctx, { organizationId: contentData.space.organization, siteId: contentData.site.id, spaceId: contentData.contentTarget.spaceId, @@ -66,7 +67,7 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) { params, }); const document = page?.page.documentId - ? await getDocument(contentData.space.id, page.page.documentId) + ? await getDocument(ctx, contentData.space.id, page.page.documentId) : null; return { @@ -80,15 +81,18 @@ export async function fetchPageData(params: PagePathParams | PageIdParams) { * Resolve a page from the params. * If the path can't be found, we try to resolve it from the API to handle redirects. */ -async function resolvePage(input: { - organizationId: string; - siteId: string; - spaceId: string; - revisionId: string; - shareKey: string | undefined; - pages: RevisionPage[]; - params: PagePathParams | PageIdParams; -}) { +async function resolvePage( + ctx: GitBookContext, + input: { + organizationId: string; + siteId: string; + spaceId: string; + revisionId: string; + shareKey: string | undefined; + pages: RevisionPage[]; + params: PagePathParams | PageIdParams; + }, +) { const { organizationId, siteId, spaceId, revisionId, pages, shareKey, params } = input; if ('pageId' in params) { @@ -109,13 +113,13 @@ async function resolvePage(input: { // If page can't be found, we try with the API, in case we have a redirect at space level. // We use the raw pathname to handle special/malformed redirects setup by users in the GitSync. // The page rendering will take care of redirecting to a normalized pathname. - const resolved = await getRevisionPageByPath(spaceId, revisionId, rawPathname); + const resolved = await getRevisionPageByPath(ctx, spaceId, revisionId, rawPathname); if (resolved) { return resolvePageId(pages, resolved.id); } // If a page still can't be found, we try with the API, in case we have a redirect at site level. - const resolvedSiteRedirect = await getSiteRedirectBySource({ + const resolvedSiteRedirect = await getSiteRedirectBySource(ctx, { organizationId, siteId, source: rawPathname.startsWith('/') ? rawPathname : `/${rawPathname}`, diff --git a/packages/gitbook/src/app/(site)/layout.tsx b/packages/gitbook/src/app/(site)/layout.tsx index 431c97b7d..9aa2a62a6 100644 --- a/packages/gitbook/src/app/(site)/layout.tsx +++ b/packages/gitbook/src/app/(site)/layout.tsx @@ -1,5 +1,8 @@ +import { headers } from 'next/headers'; + import { CustomizationRootLayout } from '@/components/RootLayout'; import { getSiteData } from '@/lib/api'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getSiteContentPointer } from '@/lib/pointer'; /** @@ -8,9 +11,10 @@ import { getSiteContentPointer } from '@/lib/pointer'; */ export default async function SiteRootLayout(props: { children: React.ReactNode }) { const { children } = props; + const ctx = getGitBookContextFromHeaders(await headers()); - const pointer = await getSiteContentPointer(); - const { customization } = await getSiteData(pointer); + const pointer = getSiteContentPointer(ctx); + const { customization } = await getSiteData(ctx, pointer); return ( {children} diff --git a/packages/gitbook/src/app/(space)/~gitbook/pdf/layout.tsx b/packages/gitbook/src/app/(space)/~gitbook/pdf/layout.tsx index 3ce76b104..0a2d6e73f 100644 --- a/packages/gitbook/src/app/(space)/~gitbook/pdf/layout.tsx +++ b/packages/gitbook/src/app/(space)/~gitbook/pdf/layout.tsx @@ -1,7 +1,9 @@ import { SpaceIntegrationScript } from '@gitbook/api'; +import { headers } from 'next/headers'; import { CustomizationRootLayout } from '@/components/RootLayout'; import { getSiteData, getSpaceCustomization } from '@/lib/api'; +import { getGitBookContextFromHeaders, GitBookContext } from '@/lib/gitbook-context'; import { getSiteOrSpacePointerForPDF } from './pointer'; @@ -10,12 +12,13 @@ import { getSiteOrSpacePointerForPDF } from './pointer'; * site or space and initializes the CustomizationRootLayout with it. */ export default async function PDFRootLayout(props: { children: React.ReactNode }) { + const ctx = getGitBookContextFromHeaders(await headers()); const { children } = props; - const pointer = await getSiteOrSpacePointerForPDF(); + const pointer = getSiteOrSpacePointerForPDF(ctx); const { customization } = await ('siteId' in pointer - ? getSiteData(pointer) - : getSpaceLayoutData()); + ? getSiteData(ctx, pointer) + : getSpaceLayoutData(ctx)); return ( {children} @@ -25,14 +28,11 @@ export default async function PDFRootLayout(props: { children: React.ReactNode } /** * Fetch all the layout data about a space at once. */ -async function getSpaceLayoutData() { - const [{ customization }, scripts] = await Promise.all([ - getSpaceCustomization(), - [] as SpaceIntegrationScript[], - ]); +async function getSpaceLayoutData(ctx: GitBookContext) { + const { customization } = await getSpaceCustomization(ctx); return { customization, - scripts, + scripts: [] as SpaceIntegrationScript[], }; } diff --git a/packages/gitbook/src/app/(space)/~gitbook/pdf/page.tsx b/packages/gitbook/src/app/(space)/~gitbook/pdf/page.tsx index be35fa3e1..1c72c072a 100644 --- a/packages/gitbook/src/app/(space)/~gitbook/pdf/page.tsx +++ b/packages/gitbook/src/app/(space)/~gitbook/pdf/page.tsx @@ -8,6 +8,7 @@ import { } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; import { Metadata } from 'next'; +import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; import * as React from 'react'; @@ -23,6 +24,7 @@ import { getSpaceContentData, getSiteData, } from '@/lib/api'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getPagePDFContainerId, PageHrefContext, getAbsoluteHref } from '@/lib/links'; import { resolvePageId } from '@/lib/pages'; import { ContentRefContext, resolveContentRef } from '@/lib/references'; @@ -39,10 +41,11 @@ const DEFAULT_LIMIT = 100; export const runtime = 'edge'; export async function generateMetadata(): Promise { - const pointer = await getSiteOrSpacePointerForPDF(); + const ctx = getGitBookContextFromHeaders(await headers()); + const pointer = getSiteOrSpacePointerForPDF(ctx); const [space, { customization }] = await Promise.all([ - getSpace(pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined), - 'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(), + getSpace(ctx, pointer.spaceId, 'siteId' in pointer ? pointer.siteShareKey : undefined), + 'siteId' in pointer ? getSiteData(ctx, pointer) : getSpaceCustomization(ctx), ]); return { @@ -57,19 +60,20 @@ export async function generateMetadata(): Promise { export default async function PDFHTMLOutput(props: { searchParams: Promise<{ [key: string]: string }>; }) { - const pointer = await getSiteOrSpacePointerForPDF(); + const ctx = getGitBookContextFromHeaders(await headers()); + const pointer = getSiteOrSpacePointerForPDF(ctx); const searchParams = new URLSearchParams(await props.searchParams); const pdfParams = getPDFSearchParams(new URLSearchParams(searchParams)); // Build current PDF URL and preserve all search params - let currentPDFUrl = await getAbsoluteHref('~gitbook/pdf', true); + let currentPDFUrl = getAbsoluteHref(ctx, '~gitbook/pdf', true); currentPDFUrl += '?' + searchParams.toString(); // Load the content, const [{ customization }, { space, contentTarget, pages: rootPages }] = await Promise.all([ - 'siteId' in pointer ? getSiteData(pointer) : getSpaceCustomization(), - getSpaceContentData(pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined), + 'siteId' in pointer ? getSiteData(ctx, pointer) : getSpaceCustomization(ctx), + getSpaceContentData(ctx, pointer, 'siteId' in pointer ? pointer.siteShareKey : undefined), ]); const language = getSpaceLanguage(customization); @@ -88,7 +92,7 @@ export default async function PDFHTMLOutput(props: {
@@ -249,7 +254,7 @@ async function PDFPageDocument(props: { revisionId: refContext.revisionId, }, contentRefContext: refContext, - resolveContentRef: (ref) => resolveContentRef(ref, refContext), + resolveContentRef: (ref) => resolveContentRef(ctx, ref, refContext), getId: (id) => getPagePDFContainerId(page, id), }} /> diff --git a/packages/gitbook/src/app/(space)/~gitbook/pdf/pointer.ts b/packages/gitbook/src/app/(space)/~gitbook/pdf/pointer.ts index c1d28c328..926a9ff51 100644 --- a/packages/gitbook/src/app/(space)/~gitbook/pdf/pointer.ts +++ b/packages/gitbook/src/app/(space)/~gitbook/pdf/pointer.ts @@ -1,4 +1,5 @@ import { SiteContentPointer, SpaceContentPointer } from '@/lib/api'; +import { GitBookContext } from '@/lib/gitbook-context'; import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer'; /** @@ -8,12 +9,12 @@ import { getSiteContentPointer, getSpacePointer } from '@/lib/pointer'; * * This function returns the pointer depending on the context. */ -export async function getSiteOrSpacePointerForPDF(): Promise< - SiteContentPointer | SpaceContentPointer -> { +export function getSiteOrSpacePointerForPDF( + ctx: GitBookContext, +): SiteContentPointer | SpaceContentPointer { try { - return await getSiteContentPointer(); + return getSiteContentPointer(ctx); } catch (error) { - return getSpacePointer(); + return getSpacePointer(ctx); } } diff --git a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx index dda6343a6..ebff66775 100644 --- a/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx +++ b/packages/gitbook/src/components/AdminToolbar/AdminToolbar.tsx @@ -1,8 +1,10 @@ import { Space } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; +import { headers } from 'next/headers'; import React from 'react'; import { getChangeRequest, getRevision, SiteContentPointer } from '@/lib/api'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { tcls } from '@/lib/tailwind'; import { RefreshChangeRequestButton } from './RefreshChangeRequestButton'; @@ -68,8 +70,9 @@ export function AdminToolbar(props: AdminToolbarProps) { async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: string }) { const { spaceId, changeRequestId } = props; + const ctx = getGitBookContextFromHeaders(await headers()); - const changeRequest = await getChangeRequest(spaceId, changeRequestId); + const changeRequest = await getChangeRequest(ctx, spaceId, changeRequestId); return ( @@ -89,6 +92,7 @@ async function ChangeRequestToolbar(props: { spaceId: string; changeRequestId: s { const result = showPlaceholderAd - ? await renderAd({ source: 'placeholder' }) + ? await renderAd({ source: 'placeholder', ipAndUserAgent }) : realZoneId ? await renderAd({ placement, @@ -100,6 +104,7 @@ export function Ad({ zoneId: realZoneId, mode, source: 'live', + ipAndUserAgent, }) : undefined; @@ -115,7 +120,7 @@ export function Ad({ return () => { cancelled = true; }; - }, [visible, zoneId, ignore, placement, mode, siteAdsStatus]); + }, [visible, zoneId, ignore, placement, mode, siteAdsStatus, ipAndUserAgent]); return (
diff --git a/packages/gitbook/src/components/Ads/AdClassicRendering.tsx b/packages/gitbook/src/components/Ads/AdClassicRendering.tsx index dc2859a5b..3d42ac604 100644 --- a/packages/gitbook/src/components/Ads/AdClassicRendering.tsx +++ b/packages/gitbook/src/components/Ads/AdClassicRendering.tsx @@ -1,5 +1,7 @@ +import { headers } from 'next/headers'; import * as React from 'react'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getResizedImageURL } from '@/lib/images'; import { tcls } from '@/lib/tailwind'; @@ -9,10 +11,11 @@ import { AdItem } from './types'; * Classic rendering for an ad. */ export async function AdClassicRendering({ ad }: { ad: AdItem }) { + const ctx = getGitBookContextFromHeaders(await headers()); const smallImgSrc = - 'smallImage' in ad ? await getResizedImageURL(ad.smallImage, { width: 192, dpr: 2 }) : null; + 'smallImage' in ad ? getResizedImageURL(ctx, ad.smallImage, { width: 192, dpr: 2 }) : null; const logoSrc = - 'logo' in ad ? await getResizedImageURL(ad.logo, { width: 192 - 48, dpr: 2 }) : null; + 'logo' in ad ? getResizedImageURL(ctx, ad.logo, { width: 192 - 48, dpr: 2 }) : null; return ( { - const { ip, userAgent } = await getUserAgentAndIp(); - + const { ip, userAgent } = ipAndUserAgent; const url = new URL(`https://srv.buysellads.com/ads/${zoneId}.json`); url.searchParams.set('segment', `placement:${placement}`); url.searchParams.set('v', 'true'); @@ -86,9 +91,7 @@ async function fetchAd({ return null; } -async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> { - const { ip } = await getUserAgentAndIp(); - +function getPlaceholderAd(options: FetchPlaceholderAdOptions): { ad: AdItem; ip: string } { return { ad: { active: '1', @@ -115,20 +118,6 @@ async function getPlaceholderAd(): Promise<{ ad: AdItem; ip: string }> { zoneid: '', zonekey: '', }, - ip, + ip: options.ipAndUserAgent.ip, }; } - -async function getUserAgentAndIp() { - const headersSet = await headers(); - const ip = - headersSet.get('x-gitbook-ipv4') ?? - headersSet.get('x-gitbook-ip') ?? - headersSet.get('cf-pseudo-ipv4') ?? - headersSet.get('cf-connecting-ip') ?? - headersSet.get('x-forwarded-for') ?? - ''; - const userAgent = headersSet.get('user-agent') ?? ''; - - return { ip, userAgent }; -} diff --git a/packages/gitbook/src/components/AutoRefreshContent/server-actions.ts b/packages/gitbook/src/components/AutoRefreshContent/server-actions.ts index 9888af8fc..afc574e28 100644 --- a/packages/gitbook/src/components/AutoRefreshContent/server-actions.ts +++ b/packages/gitbook/src/components/AutoRefreshContent/server-actions.ts @@ -1,15 +1,23 @@ 'use server'; import { getChangeRequest } from '@/lib/api'; +import { GitBookContext } from '@/lib/gitbook-context'; /** * Return true if a change-request has been updated. */ -export async function hasContentBeenUpdated(props: { - spaceId: string; - changeRequestId: string; - revisionId: string; -}) { - const changeRequest = await getChangeRequest.revalidate(props.spaceId, props.changeRequestId); +export async function hasContentBeenUpdated( + ctx: GitBookContext, + props: { + spaceId: string; + changeRequestId: string; + revisionId: string; + }, +) { + const changeRequest = await getChangeRequest.revalidate( + ctx, + props.spaceId, + props.changeRequestId, + ); return changeRequest.revision !== props.revisionId; } diff --git a/packages/gitbook/src/components/AutoRefreshContent/useCheckForContentUpdate.ts b/packages/gitbook/src/components/AutoRefreshContent/useCheckForContentUpdate.ts index 6048b3a15..9ad2ddc95 100644 --- a/packages/gitbook/src/components/AutoRefreshContent/useCheckForContentUpdate.ts +++ b/packages/gitbook/src/components/AutoRefreshContent/useCheckForContentUpdate.ts @@ -1,6 +1,9 @@ 'use client'; import React from 'react'; +import { useEventCallback } from 'usehooks-ts'; + +import { GitBookContext } from '@/lib/gitbook-context'; import { hasContentBeenUpdated } from './server-actions'; @@ -8,17 +11,23 @@ import { hasContentBeenUpdated } from './server-actions'; * Return a callback to check if a change request has been updated and to refresh the page if it has. */ export function useCheckForContentUpdate(props: { + ctx: GitBookContext; spaceId: string; changeRequestId: string; revisionId: string; }) { - const { spaceId, changeRequestId, revisionId } = props; + const { ctx, spaceId, changeRequestId, revisionId } = props; + const getCtx = useEventCallback(() => ctx); return React.useCallback(async () => { - const updated = await hasContentBeenUpdated({ spaceId, changeRequestId, revisionId }); + const updated = await hasContentBeenUpdated(getCtx(), { + spaceId, + changeRequestId, + revisionId, + }); if (updated) { window.location.reload(); } - }, [spaceId, changeRequestId, revisionId]); + }, [spaceId, changeRequestId, revisionId, getCtx]); } diff --git a/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx b/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx index b98997efe..c96d89e2e 100644 --- a/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx +++ b/packages/gitbook/src/components/DocumentView/BlockContentRef.tsx @@ -1,7 +1,9 @@ import { DocumentBlockContentRef } from '@gitbook/api'; +import { headers } from 'next/headers'; import { Card } from '@/components/primitives'; import { getSpaceCustomization, ignoreAPIError } from '@/lib/api'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { ResolvedContentRef } from '@/lib/references'; import { BlockProps } from './Block'; @@ -52,7 +54,9 @@ async function SpaceRefCard( return null; } - const { customization: spaceCustomization } = await getSpaceCustomization(); + const ctx = getGitBookContextFromHeaders(await headers()); + + const { customization: spaceCustomization } = await getSpaceCustomization(ctx); const customFavicon = spaceCustomization?.favicon; const customEmoji = customFavicon && 'emoji' in customFavicon ? customFavicon.emoji : undefined; const customIcon = customFavicon && 'icon' in customFavicon ? customFavicon.icon : undefined; diff --git a/packages/gitbook/src/components/DocumentView/Embed.tsx b/packages/gitbook/src/components/DocumentView/Embed.tsx index a44d9d220..dade807c9 100644 --- a/packages/gitbook/src/components/DocumentView/Embed.tsx +++ b/packages/gitbook/src/components/DocumentView/Embed.tsx @@ -5,6 +5,7 @@ import ReactDOM from 'react-dom'; import { Card } from '@/components/primitives'; import { getEmbedByUrlInSpace, getEmbedByUrl } from '@/lib/api'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { tcls } from '@/lib/tailwind'; import { BlockProps } from './Block'; @@ -12,15 +13,15 @@ import { Caption } from './Caption'; import { IntegrationBlock } from './Integration'; export async function Embed(props: BlockProps) { + const ctx = getGitBookContextFromHeaders(await headers()); const { block, context, ...otherProps } = props; - const headersList = await headers(); - const nonce = headersList.get('x-nonce') || undefined; + const nonce = ctx.nonce || undefined; ReactDOM.preload('https://cdn.iframe.ly/embed.js', { as: 'script', nonce }); const embed = await (context.content - ? getEmbedByUrlInSpace(context.content.spaceId, block.data.url) - : getEmbedByUrl(block.data.url)); + ? getEmbedByUrlInSpace(ctx, context.content.spaceId, block.data.url) + : getEmbedByUrl(ctx, block.data.url)); return ( diff --git a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx index 72ede9d59..b9843035b 100644 --- a/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx +++ b/packages/gitbook/src/components/DocumentView/Integration/IntegrationBlock.tsx @@ -1,9 +1,11 @@ import { ContentKitContext, DocumentBlockIntegration } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; import { ContentKit, ContentKitOutput, ContentKitServerContext } from '@gitbook/react-contentkit'; +import { headers } from 'next/headers'; import { ignoreAPIError, renderIntegrationUi } from '@/lib/api'; import { INTEGRATIONS_HOST } from '@/lib/csp'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { parseMarkdown } from '@/lib/markdown'; import { tcls } from '@/lib/tailwind'; @@ -45,6 +47,8 @@ export async function IntegrationBlock(props: BlockProps { 'use server'; - const output = await renderIntegrationUi(block.data.integration, request); + const output = await renderIntegrationUi(ctx, block.data.integration, request); return { children: , diff --git a/packages/gitbook/src/components/DocumentView/ReusableContent.tsx b/packages/gitbook/src/components/DocumentView/ReusableContent.tsx index e8ed31312..61ad0f210 100644 --- a/packages/gitbook/src/components/DocumentView/ReusableContent.tsx +++ b/packages/gitbook/src/components/DocumentView/ReusableContent.tsx @@ -1,11 +1,14 @@ import { DocumentBlockReusableContent } from '@gitbook/api'; +import { headers } from 'next/headers'; import { getDocument } from '@/lib/api'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { BlockProps } from './Block'; import { UnwrappedBlocks } from './Blocks'; export async function ReusableContent(props: BlockProps) { + const ctx = getGitBookContextFromHeaders(await headers()); const { block, context, ancestorBlocks } = props; if (!context.content) { @@ -17,7 +20,11 @@ export async function ReusableContent(props: BlockProps diff --git a/packages/gitbook/src/components/Header/HeaderLogo.tsx b/packages/gitbook/src/components/Header/HeaderLogo.tsx index 18c1201ee..6262b4033 100644 --- a/packages/gitbook/src/components/Header/HeaderLogo.tsx +++ b/packages/gitbook/src/components/Header/HeaderLogo.tsx @@ -5,8 +5,10 @@ import { SiteCustomizationSettings, Space, } from '@gitbook/api'; +import { headers } from 'next/headers'; import { Image } from '@/components/utils'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getAbsoluteHref } from '@/lib/links'; import { tcls } from '@/lib/tailwind'; import { getContentTitle } from '@/lib/utils'; @@ -25,8 +27,9 @@ interface HeaderLogoProps { */ export async function HeaderLogo(props: HeaderLogoProps) { + const ctx = getGitBookContextFromHeaders(await headers()); const { customization } = props; - const href = await getAbsoluteHref(''); + const href = getAbsoluteHref(ctx, ''); return ( {withPageFeedback ? ( - + ) : null} {customization.git.showEditLink && space.gitSync?.url && page.git ? ( @@ -223,6 +229,7 @@ export async function PageAside(props: {
resolveContentRef(ref, context)); + const sections = await getDocumentSections(document, (ref) => + resolveContentRef(ctx, ref, context), + ); return sections.length > 1 ? : null; } diff --git a/packages/gitbook/src/components/PageBody/PageBody.tsx b/packages/gitbook/src/components/PageBody/PageBody.tsx index 699c018a2..aab553a19 100644 --- a/packages/gitbook/src/components/PageBody/PageBody.tsx +++ b/packages/gitbook/src/components/PageBody/PageBody.tsx @@ -5,12 +5,14 @@ import { SiteCustomizationSettings, Space, } from '@gitbook/api'; +import { headers } from 'next/headers'; import React from 'react'; import { getSpaceLanguage } from '@/intl/server'; import { t } from '@/intl/translate'; -import { ContentTarget, SiteContentPointer, api } from '@/lib/api'; +import { ContentTarget, SiteContentPointer } from '@/lib/api'; import { hasFullWidthBlock, isNodeEmpty } from '@/lib/document'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { AncestorRevisionPage } from '@/lib/pages'; import { ContentRefContext, resolveContentRef } from '@/lib/references'; import { tcls } from '@/lib/tailwind'; @@ -25,7 +27,7 @@ import { TrackPageViewEvent } from '../Insights'; import { PageFeedbackForm } from '../PageFeedback'; import { DateRelative } from '../primitives'; -export function PageBody(props: { +export async function PageBody(props: { space: Space; pointer: SiteContentPointer; contentTarget: ContentTarget; @@ -36,6 +38,7 @@ export function PageBody(props: { context: ContentRefContext; withPageFeedback: boolean; }) { + const ctx = getGitBookContextFromHeaders(await headers()); const { space, contentTarget, @@ -99,7 +102,7 @@ export function PageBody(props: { content: contentTarget, contentRefContext: context, resolveContentRef: (ref, options) => - resolveContentRef(ref, context, options), + resolveContentRef(ctx, ref, context, options), }} /> @@ -140,7 +143,7 @@ export function PageBody(props: {

) : null} {withPageFeedback ? ( - + ) : null} diff --git a/packages/gitbook/src/components/PageBody/PageBodyBlankslate.tsx b/packages/gitbook/src/components/PageBody/PageBodyBlankslate.tsx index 40f8f27dc..581e624b7 100644 --- a/packages/gitbook/src/components/PageBody/PageBodyBlankslate.tsx +++ b/packages/gitbook/src/components/PageBody/PageBodyBlankslate.tsx @@ -1,6 +1,8 @@ import { RevisionPage, RevisionPageDocument, RevisionPageType } from '@gitbook/api'; +import { headers } from 'next/headers'; import { Card } from '@/components/primitives'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getPageHref } from '@/lib/links'; import { ContentRefContext, resolveContentRef } from '@/lib/references'; import { tcls } from '@/lib/tailwind'; @@ -15,6 +17,7 @@ export async function PageBodyBlankslate(props: { rootPages: RevisionPage[]; context: ContentRefContext; }) { + const ctx = getGitBookContextFromHeaders(await headers()); const { page, rootPages, context } = props; const pages = page.pages.filter((child) => @@ -35,7 +38,7 @@ export async function PageBodyBlankslate(props: { 'Unexpected computed page, it should have been computed in the API', ); } else if (child.type === RevisionPageType.Link) { - const resolved = await resolveContentRef(child.target, context); + const resolved = await resolveContentRef(ctx, child.target, context); if (!resolved) { return null; } @@ -53,7 +56,7 @@ export async function PageBodyBlankslate(props: { /> ); } else { - const href = await getPageHref(rootPages, child); + const href = getPageHref(ctx, rootPages, child); return ; } }), diff --git a/packages/gitbook/src/components/PageBody/PageCover.tsx b/packages/gitbook/src/components/PageBody/PageCover.tsx index 0047cdf4f..3b7f41632 100644 --- a/packages/gitbook/src/components/PageBody/PageCover.tsx +++ b/packages/gitbook/src/components/PageBody/PageCover.tsx @@ -1,6 +1,8 @@ import { RevisionPageDocument, RevisionPageDocumentCover } from '@gitbook/api'; +import { headers } from 'next/headers'; import { Image, ImageSize } from '@/components/utils'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { ContentRefContext, resolveContentRef } from '@/lib/references'; import { tcls } from '@/lib/tailwind'; @@ -17,8 +19,9 @@ export async function PageCover(props: { cover: RevisionPageDocumentCover; context: ContentRefContext; }) { + const ctx = getGitBookContextFromHeaders(await headers()); const { as, page, cover, context } = props; - const resolved = cover.ref ? await resolveContentRef(cover.ref, context) : null; + const resolved = cover.ref ? await resolveContentRef(ctx, cover.ref, context) : null; return (
{ - const href = await getPageHref(pages, breadcrumb); + const href = await getPageHref(ctx, pages, breadcrumb); return (
  • diff --git a/packages/gitbook/src/components/PageFeedback/PageFeedbackForm.tsx b/packages/gitbook/src/components/PageFeedback/PageFeedbackForm.tsx index 5571d4e27..27b89b851 100644 --- a/packages/gitbook/src/components/PageFeedback/PageFeedbackForm.tsx +++ b/packages/gitbook/src/components/PageFeedback/PageFeedbackForm.tsx @@ -1,10 +1,12 @@ 'use client'; import { PageFeedbackRating } from '@gitbook/api'; +import { headers } from 'next/headers'; import React from 'react'; import { useLanguage } from '@/intl/client'; import { t, tString } from '@/intl/translate'; +import { getGitBookContextFromHeaders, GitBookContext } from '@/lib/gitbook-context'; import { tcls } from '@/lib/tailwind'; import { getVisitorId, useTrackEvent } from '../Insights'; @@ -14,11 +16,12 @@ import { postPageFeedback } from './server-actions'; * Form to submit feedback on a page. */ export function PageFeedbackForm(props: { + ctx: GitBookContext; orientation?: 'horizontal' | 'vertical'; pageId: string; className?: string; }) { - const { orientation = 'vertical', pageId, className } = props; + const { ctx, orientation = 'vertical', pageId, className } = props; const languages = useLanguage(); const trackEvent = useTrackEvent(); const [submitted, setSubmitted] = React.useState(false); @@ -26,7 +29,7 @@ export function PageFeedbackForm(props: { const onSubmit = async (rating: PageFeedbackRating) => { setSubmitted(true); const visitorId = await getVisitorId(); - await postPageFeedback({ pageId, visitorId, rating }); + await postPageFeedback(ctx, { pageId, visitorId, rating }); trackEvent({ type: 'page_post_feedback', diff --git a/packages/gitbook/src/components/PageFeedback/server-actions.ts b/packages/gitbook/src/components/PageFeedback/server-actions.ts index 248e03197..a86193520 100644 --- a/packages/gitbook/src/components/PageFeedback/server-actions.ts +++ b/packages/gitbook/src/components/PageFeedback/server-actions.ts @@ -4,23 +4,25 @@ import { PageFeedbackRating } from '@gitbook/api'; import { assert } from 'ts-essentials'; import { api } from '@/lib/api'; +import { GitBookContext } from '@/lib/gitbook-context'; import { getSiteContentPointer } from '@/lib/pointer'; -export async function postPageFeedback(args: { - pageId: string; - visitorId: string; - rating: PageFeedbackRating; -}) { - const { organizationId, siteId, siteSpaceId } = await getSiteContentPointer(); +export async function postPageFeedback( + ctx: GitBookContext, + args: { + pageId: string; + visitorId: string; + rating: PageFeedbackRating; + }, +) { + const { organizationId, siteId, siteSpaceId } = getSiteContentPointer(ctx); assert( siteSpaceId, `No siteSpaceId in pointer. organizationId: ${organizationId}, siteId: ${siteId}, pageId: ${args.pageId}`, ); - const apiCtx = await api(); - - await apiCtx.client.orgs.createSitesPageFeedback( + await api(ctx).client.orgs.createSitesPageFeedback( organizationId, siteId, siteSpaceId, diff --git a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx index 018575132..6334327a5 100644 --- a/packages/gitbook/src/components/Search/SearchAskAnswer.tsx +++ b/packages/gitbook/src/components/Search/SearchAskAnswer.tsx @@ -2,6 +2,7 @@ import { Icon } from '@gitbook/icons'; import React from 'react'; +import { useEventCallback } from 'usehooks-ts'; import { Loading } from '@/components/primitives'; import { useLanguage } from '@/intl/client'; @@ -9,6 +10,7 @@ import { t } from '@/intl/translate'; import { TranslationLanguage } from '@/intl/translations'; import { iterateStreamResponse } from '@/lib/actions'; import { SiteContentPointer } from '@/lib/api'; +import { GitBookContext } from '@/lib/gitbook-context'; import { tcls } from '@/lib/tailwind'; import { AskAnswerResult, AskAnswerSource, streamAskQuestion } from './server-actions'; @@ -32,14 +34,19 @@ export type SearchAskState = /** * Fetch and render the answers to a question. */ -export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: string }) { - const { pointer, query } = props; +export function SearchAskAnswer(props: { + ctx: GitBookContext; + pointer: SiteContentPointer; + query: string; +}) { + const { ctx, pointer, query } = props; const language = useLanguage(); const trackEvent = useTrackEvent(); const [, setSearchState] = useSearch(); const [askState, setAskState] = useSearchAskContext(); const { organizationId, siteId, siteSpaceId } = pointer; + const getCtx = useEventCallback(() => ctx); React.useEffect(() => { let cancelled = false; @@ -52,7 +59,13 @@ export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: str query, }); - const response = streamAskQuestion(organizationId, siteId, siteSpaceId ?? null, query); + const response = streamAskQuestion( + getCtx(), + organizationId, + siteId, + siteSpaceId ?? null, + query, + ); const stream = iterateStreamResponse(response); // When we pass in "ask" mode, the query could still be updated by the client @@ -81,7 +94,16 @@ export function SearchAskAnswer(props: { pointer: SiteContentPointer; query: str cancelled = true; } }; - }, [organizationId, siteId, siteSpaceId, query, setAskState, setSearchState, trackEvent]); + }, [ + organizationId, + siteId, + siteSpaceId, + query, + setAskState, + setSearchState, + trackEvent, + getCtx, + ]); React.useEffect(() => { return () => { diff --git a/packages/gitbook/src/components/Search/SearchModal.tsx b/packages/gitbook/src/components/Search/SearchModal.tsx index ca165e731..a887e1d83 100644 --- a/packages/gitbook/src/components/Search/SearchModal.tsx +++ b/packages/gitbook/src/components/Search/SearchModal.tsx @@ -8,6 +8,7 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { tString, useLanguage } from '@/intl/client'; import { SiteContentPointer } from '@/lib/api'; +import { GitBookContext } from '@/lib/gitbook-context'; import { tcls } from '@/lib/tailwind'; import { SearchAskAnswer } from './SearchAskAnswer'; @@ -18,6 +19,7 @@ import { SearchState, UpdateSearchState, useSearch } from './useSearch'; import { LoadingPane } from '../primitives/LoadingPane'; interface SearchModalProps { + ctx: GitBookContext; spaceId: string; revisionId: string; spaceTitle: string; @@ -308,6 +310,7 @@ function SearchModalBody( {!state.ask || !withAsk ? ( ) : null} {state.query && state.ask && withAsk ? ( - + ) : null} ); diff --git a/packages/gitbook/src/components/Search/SearchResults.tsx b/packages/gitbook/src/components/Search/SearchResults.tsx index b3763b1b0..6c2634f6e 100644 --- a/packages/gitbook/src/components/Search/SearchResults.tsx +++ b/packages/gitbook/src/components/Search/SearchResults.tsx @@ -1,9 +1,11 @@ -import { captureException } from '@sentry/nextjs'; +'use client'; import assertNever from 'assert-never'; import React from 'react'; +import { useEventCallback } from 'usehooks-ts'; import { t, useLanguage } from '@/intl/client'; import { SiteContentPointer } from '@/lib/api'; +import { GitBookContext } from '@/lib/gitbook-context'; import { tcls } from '@/lib/tailwind'; import { SearchPageResultItem } from './SearchPageResultItem'; @@ -37,6 +39,7 @@ type ResultType = */ export const SearchResults = React.forwardRef(function SearchResults( props: { + ctx: GitBookContext; children?: React.ReactNode; query: string; spaceId: string; @@ -48,7 +51,8 @@ export const SearchResults = React.forwardRef(function SearchResults( }, ref: React.Ref, ) { - const { children, query, pointer, spaceId, revisionId, withAsk, global, onSwitchToAsk } = props; + const { ctx, children, query, pointer, spaceId, revisionId, withAsk, global, onSwitchToAsk } = + props; const language = useLanguage(); const trackEvent = useTrackEvent(); @@ -59,6 +63,7 @@ export const SearchResults = React.forwardRef(function SearchResults( const [cursor, setCursor] = React.useState(null); const refs = React.useRef<(null | HTMLAnchorElement)[]>([]); const suggestedQuestionsRef = React.useRef(null); + const getCtx = useEventCallback(() => ctx); React.useEffect(() => { if (!query) { @@ -75,7 +80,7 @@ export const SearchResults = React.forwardRef(function SearchResults( let cancelled = false; setResultsState({ results: [], fetching: true }); - getRecommendedQuestions(spaceId).then((questions) => { + getRecommendedQuestions(getCtx(), spaceId).then((questions) => { const results = questions.map((question) => ({ type: 'recommended-question', id: question, @@ -99,8 +104,8 @@ export const SearchResults = React.forwardRef(function SearchResults( let cancelled = false; const timeout = setTimeout(async () => { const results = await (global - ? searchAllSiteContent(query, pointer) - : searchSiteSpaceContent(query, pointer, revisionId)); + ? searchAllSiteContent(getCtx(), query, pointer) + : searchSiteSpaceContent(getCtx(), query, pointer, revisionId)); if (cancelled) { return; @@ -119,7 +124,7 @@ export const SearchResults = React.forwardRef(function SearchResults( clearTimeout(timeout); }; } - }, [query, global, pointer, spaceId, revisionId, withAsk, trackEvent]); + }, [query, global, pointer, spaceId, revisionId, withAsk, trackEvent, getCtx]); const results: ResultType[] = React.useMemo(() => { if (!withAsk) { diff --git a/packages/gitbook/src/components/Search/server-actions.tsx b/packages/gitbook/src/components/Search/server-actions.tsx index 924fbf8fe..1a5171e98 100644 --- a/packages/gitbook/src/components/Search/server-actions.tsx +++ b/packages/gitbook/src/components/Search/server-actions.tsx @@ -6,6 +6,7 @@ import { assert } from 'ts-essentials'; import { streamResponse } from '@/lib/actions'; import * as api from '@/lib/api'; +import { GitBookContext } from '@/lib/gitbook-context'; import { getAbsoluteHref, getPageHref } from '@/lib/links'; import { resolvePageId } from '@/lib/pages'; import { filterOutNullable } from '@/lib/typescript'; @@ -48,15 +49,18 @@ export interface AskAnswerResult { /** * Search for content in a site by scoping the search to all content, a specific spaces or current space. */ -async function searchSiteContent(args: { - pointer: api.SiteContentPointer; - query: string; - scope: - | { mode: 'all' } - | { mode: 'current'; siteSpaceId: string } - | { mode: 'specific'; siteSpaceIds: string[] }; - cacheBust?: string; -}): Promise { +async function searchSiteContent( + ctx: GitBookContext, + args: { + pointer: api.SiteContentPointer; + query: string; + scope: + | { mode: 'all' } + | { mode: 'current'; siteSpaceId: string } + | { mode: 'specific'; siteSpaceIds: string[] }; + cacheBust?: string; + }, +): Promise { const { pointer, scope, query, cacheBust } = args; if (query.length <= 1) { @@ -69,8 +73,8 @@ async function searchSiteContent(args: { (scope.mode === 'specific' && scope.siteSpaceIds.length > 1); const [searchResults, siteData] = await Promise.all([ - api.searchSiteContent(pointer.organizationId, pointer.siteId, query, scope, cacheBust), - needsStructure ? api.getSiteData(pointer) : null, + api.searchSiteContent(ctx, pointer.organizationId, pointer.siteId, query, scope, cacheBust), + needsStructure ? api.getSiteData(ctx, pointer) : null, ]); const siteStructure = siteData?.structure; @@ -93,38 +97,31 @@ async function searchSiteContent(args: { if (siteSpaces) { // We are searching all of this Site's content - return ( - await Promise.all( - searchResults.items.map(async (spaceItem) => { - const siteSpace = siteSpaces.find( - (siteSpace) => siteSpace.space.id === spaceItem.id, - ); + return searchResults.items + .map((spaceItem) => { + const siteSpace = siteSpaces.find( + (siteSpace) => siteSpace.space.id === spaceItem.id, + ); - return Promise.all( - spaceItem.pages.map((item) => transformSitePageResult(item, siteSpace)), - ); - }), - ) - ).flat(2); + return spaceItem.pages.map((item) => transformSitePageResult(ctx, item, siteSpace)); + }) + .flat(2); } - return ( - await Promise.all( - searchResults.items.map((spaceItem) => { - return Promise.all(spaceItem.pages.map((item) => transformPageResult(item))); - }), - ) - ).flat(2); + return searchResults.items + .map((spaceItem) => spaceItem.pages.map((item) => transformPageResult(ctx, item))) + .flat(2); } /** * Server action to search content in the entire site. */ export async function searchAllSiteContent( + ctx: GitBookContext, query: string, pointer: api.SiteContentPointer, ): Promise { - return await searchSiteContent({ + return await searchSiteContent(ctx, { pointer, query, scope: { mode: 'all' }, @@ -135,6 +132,7 @@ export async function searchAllSiteContent( * Server action to search content in a space. */ export async function searchSiteSpaceContent( + ctx: GitBookContext, query: string, pointer: api.SiteContentPointer, revisionId: string, @@ -142,7 +140,7 @@ export async function searchSiteSpaceContent( const siteSpaceId = pointer.siteSpaceId; assert(siteSpaceId, 'Expected siteSpaceId for searchSiteSpaceContent'); - return await searchSiteContent({ + return await searchSiteContent(ctx, { pointer, query, // If we have a siteSectionId that means its a sections site use `current` mode @@ -159,13 +157,13 @@ export async function searchSiteSpaceContent( * Server action to ask a question in a space. */ export const streamAskQuestion = streamResponse(async function* ( + ctx: GitBookContext, organizationId: string, siteId: string, siteSpaceId: string | null, question: string, ) { - const apiCtx = await api.api(); - const stream = apiCtx.client.orgs.streamAskInSite( + const stream = api.api(ctx).client.orgs.streamAskInSite( organizationId, siteId, { @@ -199,7 +197,9 @@ export const streamAskQuestion = streamResponse(async function* ( if (!spacePromises.has(source.space)) { spacePromises.set( source.space, - api.getRevisionPages(source.space, source.revision, { metadata: false }), + api.getRevisionPages(ctx, source.space, source.revision, { + metadata: false, + }), ); } @@ -221,48 +221,50 @@ export const streamAskQuestion = streamResponse(async function* ( return map; }, new Map()); }); - yield await transformAnswer(chunk.answer, pages); + yield transformAnswer(ctx, chunk.answer, pages); } }); /** * List suggested questions for a space. */ -export async function getRecommendedQuestions(spaceId: string): Promise { - const data = await api.getRecommendedQuestionsInSpace(spaceId); +export async function getRecommendedQuestions( + ctx: GitBookContext, + spaceId: string, +): Promise { + const data = await api.getRecommendedQuestionsInSpace(ctx, spaceId); return data.questions; } -async function transformAnswer( +function transformAnswer( + ctx: GitBookContext, answer: SearchAIAnswer, spacePages: Map, -): Promise { - const sources = ( - await Promise.all( - answer.sources.map(async (source) => { - if (source.type !== 'page') { - return null; - } +): AskAnswerResult { + const sources = answer.sources + .map((source) => { + if (source.type !== 'page') { + return null; + } - const pages = spacePages.get(source.space); + const pages = spacePages.get(source.space); - if (!pages) { - return null; - } + if (!pages) { + return null; + } - const page = resolvePageId(pages, source.page); - if (!page) { - return null; - } + const page = resolvePageId(pages, source.page); + if (!page) { + return null; + } - return { - id: source.page, - title: page.page.title, - href: await getPageHref(pages, page.page), - }; - }), - ) - ).filter(filterOutNullable); + return { + id: source.page, + title: page.page.title, + href: getPageHref(ctx, pages, page.page), + }; + }) + .filter(filterOutNullable); return { body: @@ -283,16 +285,19 @@ async function transformAnswer( }; } -async function transformSectionsAndPage(args: { - item: SearchPageResult; - space?: Space; - spaceURL?: string; -}): Promise<[ComputedPageResult, ComputedSectionResult[]]> { +function transformSectionsAndPage( + ctx: GitBookContext, + args: { + item: SearchPageResult; + space?: Space; + spaceURL?: string; + }, +): [ComputedPageResult, ComputedSectionResult[]] { const { item, space, spaceURL } = args; // Resolve a relative path to an absolute URL // if the search result is relative to another space, we use the space URL - const getURL = async (path: string, spaceURL?: string) => { + const getURL = (path: string, spaceURL?: string) => { if (spaceURL) { if (!spaceURL.endsWith('/')) { spaceURL += '/'; @@ -302,33 +307,36 @@ async function transformSectionsAndPage(args: { } return spaceURL + path; } else { - return getAbsoluteHref(path); + return getAbsoluteHref(ctx, path); } }; - const sections = await Promise.all( - item.sections?.map>(async (section) => ({ + const sections = + item.sections?.map((section) => ({ type: 'section', id: item.id + '/' + section.id, title: section.title, - href: await getURL(section.path, spaceURL), + href: getURL(section.path, spaceURL), body: section.body, - })) ?? [], - ); + })) ?? []; const page: ComputedPageResult = { type: 'page', id: item.id, title: item.title, - href: await getURL(item.path, spaceURL), + href: getURL(item.path, spaceURL), spaceTitle: space?.title, }; return [page, sections]; } -async function transformSitePageResult(item: SearchPageResult, siteSpace?: SiteSpace) { - const [page, sections] = await transformSectionsAndPage({ +function transformSitePageResult( + ctx: GitBookContext, + item: SearchPageResult, + siteSpace?: SiteSpace, +) { + const [page, sections] = transformSectionsAndPage(ctx, { item, space: siteSpace?.space, spaceURL: siteSpace?.urls.published, @@ -337,8 +345,8 @@ async function transformSitePageResult(item: SearchPageResult, siteSpace?: SiteS return [page, ...sections]; } -async function transformPageResult(item: SearchPageResult, space?: Space) { - const [page, sections] = await transformSectionsAndPage({ +function transformPageResult(ctx: GitBookContext, item: SearchPageResult, space?: Space) { + const [page, sections] = transformSectionsAndPage(ctx, { item, space, spaceURL: space?.urls.published ?? space?.urls.app, diff --git a/packages/gitbook/src/components/Space/SpaceIcon.tsx b/packages/gitbook/src/components/Space/SpaceIcon.tsx index 4bee2bad1..6563a148c 100644 --- a/packages/gitbook/src/components/Space/SpaceIcon.tsx +++ b/packages/gitbook/src/components/Space/SpaceIcon.tsx @@ -1,6 +1,8 @@ import { CustomizationThemedURL } from '@gitbook/api'; +import { headers } from 'next/headers'; import { Image } from '@/components/utils'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { getAbsoluteHref } from '@/lib/links'; import { Emoji } from '../primitives'; @@ -14,6 +16,7 @@ export async function SpaceIcon( 'sources' >, ) { + const ctx = getGitBookContextFromHeaders(await headers()); const { icon, emoji, alt, ...imageProps } = props; if (emoji && !icon) { @@ -37,14 +40,16 @@ export async function SpaceIcon( } : { light: { - src: await getAbsoluteHref( + src: getAbsoluteHref( + ctx, '~gitbook/icon?size=medium&theme=light', true, ), size: { width: 256, height: 256 }, }, dark: { - src: await getAbsoluteHref( + src: getAbsoluteHref( + ctx, '~gitbook/icon?size=medium&theme=dark', true, ), diff --git a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx index 22dd9d452..d435ec9aa 100644 --- a/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx +++ b/packages/gitbook/src/components/SpaceLayout/SpaceLayout.tsx @@ -9,6 +9,7 @@ import { SiteCustomizationSettings, Space, } from '@gitbook/api'; +import { headers } from 'next/headers'; import React from 'react'; import { Footer } from '@/components/Footer'; @@ -19,10 +20,10 @@ import { TableOfContents } from '@/components/TableOfContents'; import { getSpaceLanguage } from '@/intl/server'; import { t } from '@/intl/translate'; import { api, ContentTarget, type SectionsList, SiteContentPointer } from '@/lib/api'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { ContentRefContext } from '@/lib/references'; import { tcls } from '@/lib/tailwind'; import { shouldTrackEvents } from '@/lib/tracking'; -import { getCurrentVisitorToken } from '@/lib/visitor-token'; import { SpacesDropdown } from '../Header/SpacesDropdown'; import { InsightsProvider } from '../Insights'; @@ -42,6 +43,7 @@ export async function SpaceLayout(props: { ancestors: Array; children: React.ReactNode; }) { + const ctx = getGitBookContextFromHeaders(await headers()); const { space, contentTarget, @@ -73,9 +75,9 @@ export async function SpaceLayout(props: { 'sidebar' in customization.styling && customization.styling.sidebar.background === CustomizationSidebarBackgroundStyle.Filled, }; - const apiHost = (await api()).client.endpoint; - const visitorAuthToken = await getCurrentVisitorToken(); - const enabled = await shouldTrackEvents(); + const apiHost = api(ctx).client.endpoint; + const visitorAuthToken = ctx.visitorToken; + const enabled = shouldTrackEvents(ctx); return ( ; context: ContentRefContext; }) { + const ctx = getGitBookContextFromHeaders(await headers()); const { rootPages, page, ancestors, context } = props; - const href = await getPageHref(rootPages, page); + const href = await getPageHref(ctx, rootPages, page); return (
  • diff --git a/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx b/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx index 7a10e9f2b..d68dccac0 100644 --- a/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx +++ b/packages/gitbook/src/components/TableOfContents/PageLinkItem.tsx @@ -1,16 +1,19 @@ import { RevisionPageLink } from '@gitbook/api'; import { Icon } from '@gitbook/icons'; +import { headers } from 'next/headers'; import { Link } from '@/components/primitives'; +import { getGitBookContextFromHeaders } from '@/lib/gitbook-context'; import { ContentRefContext, resolveContentRef } from '@/lib/references'; import { tcls } from '@/lib/tailwind'; import { TOCPageIcon } from './TOCPageIcon'; export async function PageLinkItem(props: { page: RevisionPageLink; context: ContentRefContext }) { + const ctx = getGitBookContextFromHeaders(await headers()); const { page, context } = props; - const resolved = await resolveContentRef(page.target, context); + const resolved = await resolveContentRef(ctx, page.target, context); return (
  • diff --git a/packages/gitbook/src/components/utils/Image.tsx b/packages/gitbook/src/components/utils/Image.tsx index 3ae58ed59..62190a781 100644 --- a/packages/gitbook/src/components/utils/Image.tsx +++ b/packages/gitbook/src/components/utils/Image.tsx @@ -1,6 +1,8 @@ /* eslint-disable @next/next/no-img-element */ +import { headers } from 'next/headers'; import ReactDOM from 'react-dom'; +import { getGitBookContextFromHeaders, GitBookContext } from '@/lib/gitbook-context'; import { checkIsHttpURL, getImageSize, getResizedImageURLFactory } from '@/lib/images'; import { ClassValue, tcls } from '@/lib/tailwind'; @@ -191,6 +193,8 @@ async function ImagePictureSized( } & ImageCommonProps >, ) { + const ctx = getGitBookContextFromHeaders(await headers()); + const { source, sizes, @@ -210,7 +214,7 @@ async function ImagePictureSized( throw new Error('You must provide at least one size for the image.'); } - const attrs = await getImageAttributes({ sizes, source, quality, resize }); + const attrs = await getImageAttributes(ctx, { sizes, source, quality, resize }); const canBeFetched = checkIsHttpURL(attrs.src); const fetchPriority = canBeFetched ? getFetchPriority(priority) : undefined; const loading = priority === 'lazy' ? 'lazy' : undefined; @@ -243,12 +247,15 @@ async function ImagePictureSized( * Get the attributes for an image. * src, srcSet, sizes, width, height, etc. */ -async function getImageAttributes(params: { - sizes: ImageResponsiveSize[]; - source: ImageSourceSized; - quality: number; - resize: boolean; -}): Promise<{ +async function getImageAttributes( + ctx: GitBookContext, + params: { + sizes: ImageResponsiveSize[]; + source: ImageSourceSized; + quality: number; + resize: boolean; + }, +): Promise<{ src: string; srcSet?: string; sizes?: string; @@ -258,7 +265,7 @@ async function getImageAttributes(params: { const { sizes, source, quality, resize } = params; let src = source.src; - const getURL = resize ? await getResizedImageURLFactory(source.src) : null; + const getURL = resize ? getResizedImageURLFactory(ctx, source.src) : null; if (!getURL) { return { diff --git a/packages/gitbook/src/lib/api.ts b/packages/gitbook/src/lib/api.ts index 0f2159df4..856f3d334 100644 --- a/packages/gitbook/src/lib/api.ts +++ b/packages/gitbook/src/lib/api.ts @@ -32,6 +32,7 @@ import { noCacheFetchOptions, parseCacheResponse, } from './cache'; +import { GitBookContext } from './gitbook-context'; import { defaultCustomizationForSpace } from './utils'; /** @@ -110,16 +111,14 @@ export const DEFAULT_API_ENDPOINT = process.env.GITBOOK_API_URL ?? 'https://api. /** * Create a new API client with a token. */ -export async function apiWithToken( +export function apiWithToken( apiToken: string, contextId: string | undefined, -): Promise { - const headersList = await headers(); - const apiEndpoint = headersList.get('x-gitbook-api') ?? DEFAULT_API_ENDPOINT; - + ctx: GitBookContext, +): GitBookAPIContext { const gitbook = new GitBookAPI({ authToken: apiToken, - endpoint: apiEndpoint, + endpoint: ctx.apiEndpoint, userAgent: userAgent(), }); @@ -129,23 +128,19 @@ export async function apiWithToken( /** * Create an API client for the current request. */ -export async function api(): Promise { +export function api(ctx: GitBookContext): GitBookAPIContext { const existing = apiSyncStorage.getStore(); if (existing) { return existing; } - const headersList = await headers(); - const apiToken = headersList.get('x-gitbook-token'); - const contextId = headersList.get('x-gitbook-token-context') ?? undefined; - - if (!apiToken) { + if (!ctx.apiToken) { throw new Error( 'Missing GitBook API token, please check that the request is correctly processed by the middleware', ); } - return apiWithToken(apiToken, contextId); + return apiWithToken(ctx.apiToken, ctx.apiTokenContextId ?? undefined, ctx); } /** @@ -177,15 +172,14 @@ export type PublishedContentWithCache = */ export const getUserById = cache({ name: 'api.getUserById', - tag: (userId) => + tag: (_ctx, userId) => getAPICacheTag({ tag: 'user', user: userId, }), - get: async (userId: string, options: CacheFunctionOptions) => { + get: async (ctx: GitBookContext, userId: string, options: CacheFunctionOptions) => { try { - const apiCtx = await api(); - const response = await apiCtx.client.users.getUserById(userId, { + const response = await api(ctx).client.users.getUserById(userId, { signal: options.signal, ...noCacheFetchOptions, }); @@ -210,12 +204,13 @@ export const getUserById = cache({ */ export const getPublishedContentByUrl = cache({ name: 'api.getPublishedContentByUrl.v4', - tag: (url) => + tag: (_ctx, url) => getAPICacheTag({ tag: 'url', hostname: new URL(url).hostname, }), get: async ( + ctx: GitBookContext, url: string, visitorAuthToken: string | undefined, // Prefer undefined for a better cache key. @@ -223,8 +218,7 @@ export const getPublishedContentByUrl = cache({ options: CacheFunctionOptions, ) => { try { - const apiCtx = await api(); - const response = await apiCtx.client.urls.getPublishedContentByUrl( + const response = await api(ctx).client.urls.getPublishedContentByUrl( { url, visitorAuthToken, @@ -272,10 +266,14 @@ export const getPublishedContentByUrl = cache({ */ export const getSpace = cache({ name: 'api.getSpace', - tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), - get: async (spaceId: string, shareKey: string | undefined, options: CacheFunctionOptions) => { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.getSpaceById( + tag: (_ctx, spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async ( + ctx: GitBookContext, + spaceId: string, + shareKey: string | undefined, + options: CacheFunctionOptions, + ) => { + const response = await api(ctx).client.spaces.getSpaceById( spaceId, { shareKey, @@ -296,14 +294,22 @@ export const getSpace = cache({ */ export const getChangeRequest = cache({ name: 'api.getChangeRequest', - tag: (spaceId, changeRequestId) => + tag: (_ctx, spaceId, changeRequestId) => getAPICacheTag({ tag: 'change-request', space: spaceId, changeRequest: changeRequestId }), - get: async (spaceId: string, changeRequestId: string, options: CacheFunctionOptions) => { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.getChangeRequestById(spaceId, changeRequestId, { - ...noCacheFetchOptions, - signal: options.signal, - }); + get: async ( + ctx: GitBookContext, + spaceId: string, + changeRequestId: string, + options: CacheFunctionOptions, + ) => { + const response = await api(ctx).client.spaces.getChangeRequestById( + spaceId, + changeRequestId, + { + ...noCacheFetchOptions, + signal: options.signal, + }, + ); return cacheResponse(response, { ttl: 60 * 60, revalidateBefore: 10 * 60, @@ -321,27 +327,22 @@ interface GetRevisionOptions { metadata: boolean; } -const getAPIContextId = async () => { - const apiCtx = await api(); - return apiCtx.contextId; -}; - /** * Get a revision by its ID. */ export const getRevision = cache({ name: 'api.getRevision.v2', - tag: (spaceId, revisionId) => + tag: (_ctx, spaceId, revisionId) => getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), - getKeySuffix: getAPIContextId, + getKeySuffix: (ctx) => api(ctx).contextId, get: async ( + ctx: GitBookContext, spaceId: string, revisionId: string, fetchOptions: GetRevisionOptions, options: CacheFunctionOptions, ) => { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.getRevisionById( + const response = await api(ctx).client.spaces.getRevisionById( spaceId, revisionId, { @@ -363,17 +364,17 @@ export const getRevision = cache({ */ export const getRevisionPages = cache({ name: 'api.getRevisionPages.v4', - tag: (spaceId, revisionId) => + tag: (_ctx, spaceId, revisionId) => getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), - getKeySuffix: getAPIContextId, + getKeySuffix: (ctx) => api(ctx).contextId, get: async ( + ctx: GitBookContext, spaceId: string, revisionId: string, fetchOptions: GetRevisionOptions, options: CacheFunctionOptions, ) => { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.listPagesInRevisionById( + const response = await api(ctx).client.spaces.listPagesInRevisionById( spaceId, revisionId, { @@ -398,10 +399,11 @@ export const getRevisionPages = cache({ */ export const getRevisionPageByPath = cache({ name: 'api.getRevisionPageByPath.v3', - tag: (spaceId, revisionId) => + tag: (_ctx, spaceId, revisionId) => getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), - getKeySuffix: getAPIContextId, + getKeySuffix: (ctx) => api(ctx).contextId, get: async ( + ctx: GitBookContext, spaceId: string, revisionId: string, pagePath: string, @@ -410,8 +412,7 @@ export const getRevisionPageByPath = cache({ const encodedPath = encodeURIComponent(pagePath); try { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.getPageInRevisionByPath( + const response = await api(ctx).client.spaces.getPageInRevisionByPath( spaceId, revisionId, encodedPath, @@ -444,17 +445,17 @@ export const getRevisionPageByPath = cache({ */ const getRevisionFileById = cache({ name: 'api.getRevisionFile.v3', - tag: (spaceId, revisionId) => + tag: (_ctx, spaceId, revisionId) => getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), get: async ( + ctx: GitBookContext, spaceId: string, revisionId: string, fileId: string, options: CacheFunctionOptions, ) => { try { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.getFileInRevisionById( + const response = await api(ctx).client.spaces.getFileInRevisionById( spaceId, revisionId, fileId, @@ -480,18 +481,18 @@ const getRevisionFileById = cache({ const getRevisionReusableContentById = cache({ name: 'api.getRevisionReusableContentById.v1', - tag: (spaceId, revisionId) => + tag: (_ctx, spaceId, revisionId) => getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), - getKeySuffix: getAPIContextId, + getKeySuffix: (ctx) => api(ctx).contextId, get: async ( + ctx: GitBookContext, spaceId: string, revisionId: string, reusableContentId: string, options: CacheFunctionOptions, ) => { try { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.getReusableContentInRevisionById( + const response = await api(ctx).client.spaces.getReusableContentInRevisionById( spaceId, revisionId, reusableContentId, @@ -521,13 +522,17 @@ const getRevisionReusableContentById = cache({ */ const getRevisionAllFiles = cache({ name: 'api.getRevisionAllFiles.v2', - tag: (spaceId, revisionId) => + tag: (_ctx, spaceId, revisionId) => getAPICacheTag({ tag: 'revision', space: spaceId, revision: revisionId }), - get: async (spaceId: string, revisionId: string, options: CacheFunctionOptions) => { + get: async ( + ctx: GitBookContext, + spaceId: string, + revisionId: string, + options: CacheFunctionOptions, + ) => { const response = await getAll( async (params) => { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.listFilesInRevisionById( + const response = await api(ctx).client.spaces.listFilesInRevisionById( spaceId, revisionId, { @@ -561,35 +566,39 @@ const getRevisionAllFiles = cache({ * The approach is optimized to use the entire list of files in the revision if it has been fetched * or to use a per-file approach if not. */ -export const getRevisionFile = batch<[string, string, string], RevisionFile | null>( +export const getRevisionFile = batch<[GitBookContext, string, string, string], RevisionFile | null>( async (executions) => { - const [spaceId, revisionId] = executions[0]; + const [ctx, spaceId, revisionId] = executions[0]; - const hasRevisionInMemory = await getRevision.hasInMemory(spaceId, revisionId, { + const hasRevisionInMemory = await getRevision.hasInMemory(ctx, spaceId, revisionId, { metadata: false, }); - const hasRevisionFilesInMemory = await getRevisionAllFiles.hasInMemory(spaceId, revisionId); + const hasRevisionFilesInMemory = await getRevisionAllFiles.hasInMemory( + ctx, + spaceId, + revisionId, + ); // When fetching more than 5 files, we should bundle them all into one call to get the entire revision if (executions.length > 5 || hasRevisionFilesInMemory || hasRevisionInMemory) { let files: Record = {}; if (hasRevisionInMemory) { - const revision = await getRevision(spaceId, revisionId, { metadata: false }); + const revision = await getRevision(ctx, spaceId, revisionId, { metadata: false }); files = {}; revision.files.forEach((file) => { files[file.id] = file; }); } else { - files = await getRevisionAllFiles(spaceId, revisionId); + files = await getRevisionAllFiles(ctx, spaceId, revisionId); } - return executions.map(([spaceId, revisionId, fileId]) => files[fileId] ?? null); + return executions.map(([ctx, spaceId, revisionId, fileId]) => files[fileId] ?? null); } else { // Fetch file individually return Promise.all( - executions.map(([spaceId, revisionId, fileId]) => - getRevisionFileById(spaceId, revisionId, fileId), + executions.map(([ctx, spaceId, revisionId, fileId]) => + getRevisionFileById(ctx, spaceId, revisionId, fileId), ), ); } @@ -597,13 +606,13 @@ export const getRevisionFile = batch<[string, string, string], RevisionFile | nu { delay: 20, groupBy: (spaceId, revisionId) => spaceId + '/' + revisionId, - skip: async (spaceId, revisionId, fileId) => { + skip: async (ctx, spaceId, revisionId, fileId) => { return ( - (await getRevision.hasInMemory(spaceId, revisionId, { + (await getRevision.hasInMemory(ctx, spaceId, revisionId, { metadata: false, })) || - (await getRevisionAllFiles.hasInMemory(spaceId, revisionId)) || - (await getRevisionFileById.hasInMemory(spaceId, revisionId, fileId)) + (await getRevisionAllFiles.hasInMemory(ctx, spaceId, revisionId)) || + (await getRevisionFileById.hasInMemory(ctx, spaceId, revisionId, fileId)) ); }, }, @@ -613,23 +622,24 @@ export const getRevisionFile = batch<[string, string, string], RevisionFile | nu * Get reusable content in a revision. */ export const getReusableContent = async ( + ctx: GitBookContext, spaceId: string, revisionId: string, reusableContentId: string, ): Promise => { - const hasRevisionInMemory = await getRevision.hasInMemory(spaceId, revisionId, { + const hasRevisionInMemory = await getRevision.hasInMemory(ctx, spaceId, revisionId, { metadata: false, }); if (hasRevisionInMemory) { - const revision = await getRevision(spaceId, revisionId, { metadata: false }); + const revision = await getRevision(ctx, spaceId, revisionId, { metadata: false }); return ( revision.reusableContents.find( (reusableContent) => reusableContent.id === reusableContentId, ) ?? null ); } else { - return getRevisionReusableContentById(spaceId, revisionId, reusableContentId); + return getRevisionReusableContentById(ctx, spaceId, revisionId, reusableContentId); } }; @@ -638,12 +648,16 @@ export const getReusableContent = async ( */ export const getDocument = cache({ name: 'api.getDocument.v2', - tag: (spaceId, documentId) => + tag: (_ctx, spaceId, documentId) => getAPICacheTag({ tag: 'document', space: spaceId, document: documentId }), - getKeySuffix: getAPIContextId, - get: async (spaceId: string, documentId: string, options: CacheFunctionOptions) => { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.getDocumentById( + getKeySuffix: (ctx) => api(ctx).contextId, + get: async ( + ctx: GitBookContext, + spaceId: string, + documentId: string, + options: CacheFunctionOptions, + ) => { + const response = await api(ctx).client.spaces.getDocumentById( spaceId, documentId, { @@ -674,9 +688,10 @@ function validateSiteRedirectSource(source: string) { */ export const getSiteRedirectBySource = cache({ name: 'api.getSiteRedirectBySource', - tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }), - getKeySuffix: getAPIContextId, + tag: (_ctx, { siteId }) => getAPICacheTag({ tag: 'site', site: siteId }), + getKeySuffix: (ctx) => api(ctx).contextId, get: async ( + ctx: GitBookContext, args: { organizationId: string; siteId: string; @@ -694,8 +709,7 @@ export const getSiteRedirectBySource = cache({ }; } try { - const apiCtx = await api(); - const response = await apiCtx.client.orgs.getSiteRedirectBySource( + const response = await api(ctx).client.orgs.getSiteRedirectBySource( args.organizationId, args.siteId, { @@ -735,11 +749,15 @@ export const getSiteRedirectBySource = cache({ */ export const getSite = cache({ name: 'api.getSite', - tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), - getKeySuffix: getAPIContextId, - get: async (organizationId: string, siteId: string, options: CacheFunctionOptions) => { - const apiCtx = await api(); - const response = await apiCtx.client.orgs.getSiteById(organizationId, siteId, { + tag: (_ctx, _organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), + getKeySuffix: (ctx) => api(ctx).contextId, + get: async ( + ctx: GitBookContext, + organizationId: string, + siteId: string, + options: CacheFunctionOptions, + ) => { + const response = await api(ctx).client.orgs.getSiteById(organizationId, siteId, { ...noCacheFetchOptions, signal: options.signal, }); @@ -754,9 +772,10 @@ export const getSite = cache({ */ export const getPublishedContentSite = cache({ name: 'api.getPublishedContentSite', - tag: ({ siteId }) => getAPICacheTag({ tag: 'site', site: siteId }), - getKeySuffix: getAPIContextId, + tag: (_ctx, { siteId }) => getAPICacheTag({ tag: 'site', site: siteId }), + getKeySuffix: (ctx) => api(ctx).contextId, get: async ( + ctx: GitBookContext, args: { organizationId: string; siteId: string /** Site share key that can be used as context to resolve site space published urls */; @@ -764,8 +783,7 @@ export const getPublishedContentSite = cache({ }, options: CacheFunctionOptions, ) => { - const apiCtx = await api(); - const response = await apiCtx.client.orgs.getPublishedContentSite( + const response = await api(ctx).client.orgs.getPublishedContentSite( args.organizationId, args.siteId, { @@ -813,6 +831,7 @@ function parseSiteSectionsList(siteSectionId: string, sections: SiteSection[]) { * experience for the site (structure, customizations, scripts etc) */ export async function getSiteData( + ctx: GitBookContext, pointer: Pick< SiteContentPointer, 'organizationId' | 'siteId' | 'siteSectionId' | 'siteSpaceId' | 'siteShareKey' @@ -823,7 +842,7 @@ export async function getSiteData( structure: siteStructure, customizations, scripts, - } = await getPublishedContentSite({ + } = await getPublishedContentSite(ctx, { organizationId: pointer.organizationId, siteId: pointer.siteId, siteShareKey: pointer.siteShareKey, @@ -850,7 +869,8 @@ export async function getSiteData( const spaces = siteSpaces ?? (sections ? parseSpacesFromSiteSpaces(sections.section.siteSpaces) : []); - const customization = await getActiveCustomizationSettings( + const customization = getActiveCustomizationSettings( + ctx, pointer.siteSpaceId ? customizations.siteSpaces[pointer.siteSpaceId] : customizations.site, ); @@ -867,13 +887,12 @@ export async function getSiteData( /** * Get the customization settings for a space from the API. */ -export async function getSpaceCustomization(): Promise<{ +export function getSpaceCustomization(ctx: GitBookContext): { customization: CustomizationSettings; -}> { - const headersList = await headers(); +} { const raw = defaultCustomizationForSpace(); - const extend = headersList.get('x-gitbook-customization'); + const extend = ctx.customization; if (extend) { try { const parsed = rison.decode_object>(extend); @@ -897,10 +916,9 @@ export async function getSpaceCustomization(): Promise<{ */ export const getCollection = cache({ name: 'api.getCollection', - tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }), - get: async (collectionId: string, options: CacheFunctionOptions) => { - const apiCtx = await api(); - const response = await apiCtx.client.collections.getCollectionById(collectionId, { + tag: (_ctx, collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }), + get: async (ctx: GitBookContext, collectionId: string, options: CacheFunctionOptions) => { + const response = await api(ctx).client.collections.getCollectionById(collectionId, { ...noCacheFetchOptions, signal: options.signal, }); @@ -915,11 +933,10 @@ export const getCollection = cache({ */ export const getCollectionSpaces = cache({ name: 'api.getCollectionSpaces', - tag: (collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }), - get: async (collectionId: string, options: CacheFunctionOptions) => { + tag: (_ctx, collectionId) => getAPICacheTag({ tag: 'collection', collection: collectionId }), + get: async (ctx: GitBookContext, collectionId: string, options: CacheFunctionOptions) => { const response = await getAll(async (params) => { - const apiCtx = await api(); - const response = await apiCtx.client.collections.listSpacesInCollectionById( + const response = await api(ctx).client.collections.listSpacesInCollectionById( collectionId, params, { @@ -945,19 +962,22 @@ export const getCollectionSpaces = cache({ * instead of calling the individual functions. */ export async function getSpaceContentData( + ctx: GitBookContext, pointer: SpaceContentPointer, shareKey: string | undefined, ) { const [space, changeRequest] = await Promise.all([ - getSpace(pointer.spaceId, shareKey), - pointer.changeRequestId ? getChangeRequest(pointer.spaceId, pointer.changeRequestId) : null, + getSpace(ctx, pointer.spaceId, shareKey), + pointer.changeRequestId + ? getChangeRequest(ctx, pointer.spaceId, pointer.changeRequestId) + : null, ]); const contentTarget: ContentTarget = { spaceId: pointer.spaceId, revisionId: changeRequest?.revision ?? pointer.revisionId ?? space.revision, }; - const pages = await getRevisionPages(space.id, contentTarget.revisionId, { + const pages = await getRevisionPages(ctx, space.id, contentTarget.revisionId, { // We only care about the Git metadata when the Git sync is enabled // otherwise we can optimize performance by not fetching it metadata: !!space.gitSync, @@ -975,17 +995,17 @@ export async function getSpaceContentData( */ export const searchSpaceContent = cache({ name: 'api.searchSpaceContent', - tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), - getKeySuffix: getAPIContextId, + tag: (_ctx, spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + getKeySuffix: (ctx) => api(ctx).contextId, get: async ( + ctx: GitBookContext, spaceId: string, /** The revision ID is used as a cache bust key, to avoid revalidating lot of cache entries by tags */ revisionId: string, query: string, options: CacheFunctionOptions, ) => { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.searchSpaceContent( + const response = await api(ctx).client.spaces.searchSpaceContent( spaceId, { query }, { @@ -1002,11 +1022,15 @@ export const searchSpaceContent = cache({ */ export const searchParentContent = cache({ name: 'api.searchParentContent', - tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), - getKeySuffix: getAPIContextId, - get: async (parentId: string, query: string, options: CacheFunctionOptions) => { - const apiCtx = await api(); - const response = await apiCtx.client.search.searchContent( + tag: (_ctx, spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + getKeySuffix: (ctx) => api(ctx).contextId, + get: async ( + ctx: GitBookContext, + parentId: string, + query: string, + options: CacheFunctionOptions, + ) => { + const response = await api(ctx).client.search.searchContent( { query }, { ...noCacheFetchOptions, @@ -1024,9 +1048,10 @@ export const searchParentContent = cache({ */ export const searchSiteContent = cache({ name: 'api.searchSiteContent', - tag: (organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), - getKeySuffix: getAPIContextId, + tag: (_ctx, _organizationId, siteId) => getAPICacheTag({ tag: 'site', site: siteId }), + getKeySuffix: (ctx) => api(ctx).contextId, get: async ( + ctx: GitBookContext, organizationId: string, siteId: string, query: string, @@ -1038,8 +1063,7 @@ export const searchSiteContent = cache({ cacheBust?: string, options?: CacheFunctionOptions, ) => { - const apiCtx = await api(); - const response = await apiCtx.client.orgs.searchSiteContent( + const response = await api(ctx).client.orgs.searchSiteContent( organizationId, siteId, { @@ -1064,10 +1088,9 @@ export const searchSiteContent = cache({ */ export const getRecommendedQuestionsInSpace = cache({ name: 'api.getRecommendedQuestionsInSpace', - tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), - get: async (spaceId: string, options: CacheFunctionOptions) => { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.getRecommendedQuestionsInSpace(spaceId, { + tag: (_ctx, spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async (ctx: GitBookContext, spaceId: string, options: CacheFunctionOptions) => { + const response = await api(ctx).client.spaces.getRecommendedQuestionsInSpace(spaceId, { ...noCacheFetchOptions, signal: options.signal, }); @@ -1080,14 +1103,15 @@ export const getRecommendedQuestionsInSpace = cache({ */ export const renderIntegrationUi = cache({ name: 'api.renderIntegrationUi', - tag: (integrationName) => getAPICacheTag({ tag: 'integration', integration: integrationName }), + tag: (_ctx, integrationName) => + getAPICacheTag({ tag: 'integration', integration: integrationName }), get: async ( + ctx: GitBookContext, integrationName: string, request: RequestRenderIntegrationUI, options: CacheFunctionOptions, ) => { - const apiCtx = await api(); - const response = await apiCtx.client.integrations.renderIntegrationUiWithPost( + const response = await api(ctx).client.integrations.renderIntegrationUiWithPost( integrationName, request, { @@ -1105,9 +1129,8 @@ export const renderIntegrationUi = cache({ */ export const getEmbedByUrl = cache({ name: 'api.getEmbedByUrl', - get: async (url: string, options: CacheFunctionOptions) => { - const apiCtx = await api(); - const response = await apiCtx.client.urls.getEmbedByUrl( + get: async (ctx: GitBookContext, url: string, options: CacheFunctionOptions) => { + const response = await api(ctx).client.urls.getEmbedByUrl( { url }, { ...noCacheFetchOptions, @@ -1123,10 +1146,14 @@ export const getEmbedByUrl = cache({ */ export const getEmbedByUrlInSpace = cache({ name: 'api.getEmbedByUrlInSpace', - tag: (spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), - get: async (spaceId: string, url: string, options: CacheFunctionOptions) => { - const apiCtx = await api(); - const response = await apiCtx.client.spaces.getEmbedByUrlInSpace( + tag: (_ctx, spaceId) => getAPICacheTag({ tag: 'space', space: spaceId }), + get: async ( + ctx: GitBookContext, + spaceId: string, + url: string, + options: CacheFunctionOptions, + ) => { + const response = await api(ctx).client.spaces.getEmbedByUrlInSpace( spaceId, { url }, { @@ -1297,11 +1324,11 @@ async function getAll( * Selects the customization settings from the x-gitbook-customization header if present, * otherwise returns the original API-provided settings. */ -async function getActiveCustomizationSettings( +function getActiveCustomizationSettings( + ctx: GitBookContext, settings: SiteCustomizationSettings, -): Promise { - const headersList = await headers(); - const extend = headersList.get('x-gitbook-customization'); +): SiteCustomizationSettings { + const extend = ctx.customization; if (extend) { try { const parsedSettings = rison.decode_object(extend); diff --git a/packages/gitbook/src/lib/cache/cache.test.ts b/packages/gitbook/src/lib/cache/cache.test.ts index 0870d66da..4e3a4c896 100644 --- a/packages/gitbook/src/lib/cache/cache.test.ts +++ b/packages/gitbook/src/lib/cache/cache.test.ts @@ -74,7 +74,7 @@ describe('cache', () => { describe('cache with suffix key', () => { const impl = mock((arg: string) => 'test-' + arg); const getKeySuffixImpl: Mock['getKeySuffix']>> = - mock(async () => hash({ test: 1 })); + mock(() => hash({ test: 1 })); let fn: CacheFunction<[string], string>; let testId = 0; @@ -112,7 +112,7 @@ describe('cache with suffix key', () => { expect(impl).toHaveBeenCalledTimes(2); - getKeySuffixImpl.mockImplementation(async () => hash({ test: 2 })); + getKeySuffixImpl.mockImplementation(() => hash({ test: 2 })); expect(await fn('a')).toEqual('test-a'); expect(impl).toHaveBeenCalledTimes(3); @@ -120,7 +120,7 @@ describe('cache with suffix key', () => { it('should preserve behaviour even when the returned key suffix is undefined', async () => { // Start with the returned suffix being undefined - getKeySuffixImpl.mockImplementation(async () => undefined); + getKeySuffixImpl.mockImplementation(() => undefined); const result = await fn('a'); expect(result).toEqual('test-a'); @@ -128,19 +128,19 @@ describe('cache with suffix key', () => { expect(impl).toHaveBeenCalledTimes(1); // The returned suffix changes so we should get the value computed by the function - getKeySuffixImpl.mockImplementation(async () => hash({ test: 1 })); + getKeySuffixImpl.mockImplementation(() => hash({ test: 1 })); expect(await fn('a')).toEqual('test-a'); expect(impl).toHaveBeenCalledTimes(2); // The returned suffix is undefined again so we should get the value from a previous cache entry - getKeySuffixImpl.mockImplementation(async () => undefined); + getKeySuffixImpl.mockImplementation(() => undefined); expect(await fn('a')).toEqual('test-a'); expect(impl).toHaveBeenCalledTimes(2); // The returned suffix goes back to a previous hash so we should the value from a previous cache entry - getKeySuffixImpl.mockImplementation(async () => hash({ test: 1 })); + getKeySuffixImpl.mockImplementation(() => hash({ test: 1 })); expect(await fn('a')).toEqual('test-a'); expect(impl).toHaveBeenCalledTimes(2); diff --git a/packages/gitbook/src/lib/cache/cache.ts b/packages/gitbook/src/lib/cache/cache.ts index cef72fe99..1c6423370 100644 --- a/packages/gitbook/src/lib/cache/cache.ts +++ b/packages/gitbook/src/lib/cache/cache.ts @@ -57,7 +57,7 @@ export interface CacheDefinition { getKeyArgs?: (args: Args) => any[]; /** Returns a precomputed hash that is used alongside arguments to generate the cache key */ - getKeySuffix?: () => Promise; + getKeySuffix?: (...args: Args) => string | undefined; /** Default ttl (in seconds) */ defaultTtl?: number; @@ -245,7 +245,7 @@ export function cache( const [args, { signal }] = extractCacheFunctionOptions(rawArgs); const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args; - const cacheKeySuffix = cacheDef.getKeySuffix ? await cacheDef.getKeySuffix() : undefined; + const cacheKeySuffix = cacheDef.getKeySuffix ? cacheDef.getKeySuffix(...args) : undefined; const key = getCacheKey(cacheDef.name, cacheArgs, cacheKeySuffix); return await trace( @@ -263,7 +263,7 @@ export function cache( cacheFn.revalidate = async (...rawArgs: Args | [...Args, CacheFunctionOptions]) => { const [args, { signal }] = extractCacheFunctionOptions(rawArgs); const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args; - const cacheKeySuffix = cacheDef.getKeySuffix ? await cacheDef.getKeySuffix() : undefined; + const cacheKeySuffix = cacheDef.getKeySuffix ? cacheDef.getKeySuffix(...args) : undefined; const key = getCacheKey(cacheDef.name, cacheArgs, cacheKeySuffix); const result = await revalidate(key, signal, ...args); @@ -272,7 +272,7 @@ export function cache( cacheFn.hasInMemory = async (...args: Args) => { const cacheArgs = cacheDef.getKeyArgs ? cacheDef.getKeyArgs(args) : args; - const cacheKeySuffix = cacheDef.getKeySuffix ? await cacheDef.getKeySuffix() : undefined; + const cacheKeySuffix = cacheDef.getKeySuffix ? cacheDef.getKeySuffix(...args) : undefined; const key = getCacheKey(cacheDef.name, cacheArgs, cacheKeySuffix); const tag = cacheDef.tag?.(...args); diff --git a/packages/gitbook/src/lib/csp.ts b/packages/gitbook/src/lib/csp.ts index 663a2a14a..49a0d6428 100644 --- a/packages/gitbook/src/lib/csp.ts +++ b/packages/gitbook/src/lib/csp.ts @@ -3,18 +3,17 @@ import { merge } from 'content-security-policy-merger'; import { headers } from 'next/headers'; import { assetsDomain } from './assets'; +import { GitBookContext } from './gitbook-context'; import { filterOutNullable } from './typescript'; /** * Get the current nonce for the current request. */ -export async function getContentSecurityPolicyNonce(): Promise { - const headersList = await headers(); - const nonce = headersList.get('x-nonce'); - if (!nonce) { +export function getContentSecurityPolicyNonce(ctx: GitBookContext): string { + if (!ctx.nonce) { throw new Error('No nonce found in headers'); } - return nonce; + return ctx.nonce; } /** diff --git a/packages/gitbook/src/lib/gitbook-context.ts b/packages/gitbook/src/lib/gitbook-context.ts new file mode 100644 index 000000000..78f57ab07 --- /dev/null +++ b/packages/gitbook/src/lib/gitbook-context.ts @@ -0,0 +1,83 @@ +import { headers } from 'next/headers'; + +import { DEFAULT_API_ENDPOINT } from './api'; +import { formatBasePath } from './links'; + +export type GitBookContext = { + theme: string | null; + nonce: string | null; + visitorToken: string | null; + trackPageViews: boolean; + apiEndpoint: string; + apiToken: string | null; + apiTokenContextId: string | null; + customization: string | null; + host: string; + basePath: string; + protocol: string; + originBasePath: string; + + // Content pointers + spaceId: string | null; + siteId: string | null; + organizationId: string | null; + siteSpaceId: string | null; + siteSectionId: string | null; + siteShareKey: string | null; + contentRevisionId: string | null; + changeRequestId: string | null; + + // Indexation + searchIndexation: boolean; +}; + +/** + * Extract the gitbook context from the headers. + */ +export function getGitBookContextFromHeaders(headers: Headers): GitBookContext { + return { + theme: headers.get('x-gitbook-theme'), + nonce: headers.get('x-nonce'), + visitorToken: headers.get('x-gitbook-visitor-token'), + trackPageViews: headers.has('x-gitbook-track-page-views'), + apiEndpoint: headers.get('x-gitbook-api') ?? DEFAULT_API_ENDPOINT, + apiToken: headers.get('x-gitbook-token'), + apiTokenContextId: headers.get('x-gitbook-token-context'), + customization: headers.get('x-gitbook-customization'), + basePath: formatBasePath(headers.get('x-gitbook-basepath')), + host: headers.get('x-gitbook-host') ?? headers.get('host') ?? '', + protocol: headers.get('x-forwarded-proto') ?? 'https', + originBasePath: headers.get('x-gitbook-origin-basepath') ?? '/', + spaceId: headers.get('x-gitbook-content-space'), + siteId: headers.get('x-gitbook-content-site'), + organizationId: headers.get('x-gitbook-content-organization'), + siteSpaceId: headers.get('x-gitbook-content-site-space'), + siteSectionId: headers.get('x-gitbook-content-site-section'), + siteShareKey: headers.get('x-gitbook-content-site-share-key'), + contentRevisionId: headers.get('x-gitbook-content-revision'), + changeRequestId: headers.get('x-gitbook-content-changerequest'), + searchIndexation: headers.has('x-gitbook-search-indexation'), + }; +} + +export type IpAndUserAgent = { + ip: string; + userAgent: string; +}; + +/** + * Read the IP and User-Agent from the headers. + * This function can only be called at the top level of a component or route. + */ +export function getIpAndUserAgentFromHeaders(headers: Headers): IpAndUserAgent { + const ip = + headers.get('x-gitbook-ipv4') ?? + headers.get('x-gitbook-ip') ?? + headers.get('cf-pseudo-ipv4') ?? + headers.get('cf-connecting-ip') ?? + headers.get('x-forwarded-for') ?? + ''; + const userAgent = headers.get('user-agent') ?? ''; + + return { ip, userAgent }; +} diff --git a/packages/gitbook/src/lib/image-signatures.ts b/packages/gitbook/src/lib/image-signatures.ts index 08e042e85..1caee65a3 100644 --- a/packages/gitbook/src/lib/image-signatures.ts +++ b/packages/gitbook/src/lib/image-signatures.ts @@ -3,7 +3,7 @@ import 'server-only'; import fnv1a from '@sindresorhus/fnv1a'; import type { MaybePromise } from 'p-map'; -import { getHost } from './links'; +import { GitBookContext } from './gitbook-context'; /** * GitBook has supported different version of image signing in the past. To maintain backwards @@ -19,12 +19,14 @@ export const CURRENT_SIGNATURE_VERSION: SignatureVersion = '2'; /** * A mapping of signature versions to signature functions. */ -const IMAGE_SIGNATURE_FUNCTIONS: Record MaybePromise> = - { - '0': generateSignatureV0, - '1': generateSignatureV1, - '2': generateSignatureV2, - }; +const IMAGE_SIGNATURE_FUNCTIONS: Record< + SignatureVersion, + (ctx: GitBookContext, input: string) => MaybePromise +> = { + '0': generateSignatureV0, + '1': generateSignatureV1, + '2': generateSignatureV2, +}; export function isSignatureVersion(input: string): input is SignatureVersion { return Object.keys(IMAGE_SIGNATURE_FUNCTIONS).includes(input); @@ -34,11 +36,12 @@ export function isSignatureVersion(input: string): input is SignatureVersion { * Verify a signature of an image URL */ export async function verifyImageSignature( + ctx: GitBookContext, input: string, { signature, version }: { signature: string; version: SignatureVersion }, ): Promise { const generator = IMAGE_SIGNATURE_FUNCTIONS[version]; - const generated = await generator(input); + const generated = await generator(ctx, input); return generated === signature; } @@ -48,11 +51,14 @@ export async function verifyImageSignature( * This function is sync. If you need to implement an async version of image signing, you'll need to change * ths signature of this fn and where it's used. */ -export async function generateImageSignature(input: string): Promise<{ +export function generateImageSignature( + ctx: GitBookContext, + input: string, +): { signature: string; version: SignatureVersion; -}> { - const result = await generateSignatureV2(input); +} { + const result = generateSignatureV2(ctx, input); return { signature: result, version: CURRENT_SIGNATURE_VERSION }; } @@ -63,8 +69,8 @@ const fnv1aUtf8Buffer = new Uint8Array(512); * Generate a signature for an image. * The signature is relative to the current site being rendered to avoid serving images from other sites on the same domain. */ -async function generateSignatureV2(input: string): Promise { - const hostName = await getHost(); +function generateSignatureV2(ctx: GitBookContext, input: string): string { + const hostName = ctx.host; const all = [ input, hostName, // The hostname is used to avoid serving images from other sites on the same domain @@ -83,7 +89,7 @@ const fnv1aUtf8BufferV1 = new Uint8Array(512); * When setting it in a URL, we use version '1' for the 'sv' querystring parameneter * to know that it was the algorithm that was used. */ -function generateSignatureV1(input: string): string { +function generateSignatureV1(ctx: GitBookContext, input: string): string { const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':'); return fnv1a(all, { utf8Buffer: fnv1aUtf8BufferV1 }).toString(16); } @@ -93,7 +99,7 @@ function generateSignatureV1(input: string): string { * We still need it to validate older signatures that were generated without versioning * but still exist in previously generated and cached content. */ -async function generateSignatureV0(input: string): Promise { +async function generateSignatureV0(ctx: GitBookContext, input: string): Promise { const all = [input, process.env.GITBOOK_IMAGE_RESIZE_SIGNING_KEY].filter(Boolean).join(':'); const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(all)); diff --git a/packages/gitbook/src/lib/images.ts b/packages/gitbook/src/lib/images.ts index 83406cf33..c911c5309 100644 --- a/packages/gitbook/src/lib/images.ts +++ b/packages/gitbook/src/lib/images.ts @@ -2,6 +2,7 @@ import 'server-only'; import { noCacheFetchOptions } from '@/lib/cache/http'; +import { GitBookContext } from './gitbook-context'; import { generateImageSignature } from './image-signatures'; import { getRootUrl } from './links'; import { getImageAPIUrl } from './urls'; @@ -84,17 +85,16 @@ interface ResizeImageOptions { /** * Create a function to get resized image URLs for a given image URL. */ -export async function getResizedImageURLFactory( +export function getResizedImageURLFactory( + ctx: GitBookContext, input: string, -): Promise<((options: ResizeImageOptions) => string) | null> { +): ((options: ResizeImageOptions) => string) | null { if (!checkIsSizableImageURL(input)) { return null; } - const [{ signature, version }, rootUrl] = await Promise.all([ - generateImageSignature(input), - getRootUrl(), - ]); + const { signature, version } = generateImageSignature(ctx, input); + const rootUrl = getRootUrl(ctx); return (options) => { const url = new URL('/~gitbook/image', rootUrl); @@ -124,11 +124,12 @@ export async function getResizedImageURLFactory( * Create a new URL for an image with resized parameters. * The URL is signed and verified by the server. */ -export async function getResizedImageURL( +export function getResizedImageURL( + ctx: GitBookContext, input: string, options: ResizeImageOptions, -): Promise { - const factory = await getResizedImageURLFactory(input); +): string { + const factory = getResizedImageURLFactory(ctx, input); return factory?.(options) ?? input; } diff --git a/packages/gitbook/src/lib/links.ts b/packages/gitbook/src/lib/links.ts index 5bbf04f55..7eebdba77 100644 --- a/packages/gitbook/src/lib/links.ts +++ b/packages/gitbook/src/lib/links.ts @@ -8,6 +8,7 @@ import { } from '@gitbook/api'; import { headers } from 'next/headers'; +import { GitBookContext } from './gitbook-context'; import { getPagePath } from './pages'; export interface PageHrefContext { @@ -22,9 +23,8 @@ export interface PageHrefContext { * Return the base path for the current request. * The value will start and finish with / */ -export async function getBasePath(): Promise { - const headersList = await headers(); - let path = headersList.get('x-gitbook-basepath') ?? '/'; +export function formatBasePath(headerBasePath: string | null): string { + let path = headerBasePath ?? '/'; if (!path.startsWith('/')) { path = '/' + path; @@ -37,24 +37,15 @@ export async function getBasePath(): Promise { return path; } -/** - * Return the current host for the current request. - */ -export async function getHost(): Promise { - const headersList = await headers(); - return headersList.get('x-gitbook-host') ?? headersList.get('host') ?? ''; -} - /** * Return the root URL for the GitBook Open instance (not the content). * Use `baseUrl` to get the base URL for the current content. * * The URL will end with "/". */ -export async function getRootUrl(): Promise { - const [headersList, host] = await Promise.all([headers(), getHost()]); - const protocol = headersList.get('x-forwarded-proto') ?? 'https'; - let path = headersList.get('x-gitbook-origin-basepath') ?? '/'; +export function getRootUrl(ctx: GitBookContext): string { + const protocol = ctx.protocol; + let path = ctx.originBasePath; if (!path.startsWith('/')) { path = '/' + path; @@ -64,24 +55,26 @@ export async function getRootUrl(): Promise { path = path + '/'; } - return `${protocol}://${host}${path}`; + return `${protocol}://${ctx.host}${path}`; } /** * Return the base URL for the current content. * The URL will end with "/". */ -export async function getBaseUrl(): Promise { - const [headersList, host, basePath] = await Promise.all([headers(), getHost(), getBasePath()]); - const protocol = headersList.get('x-forwarded-proto') ?? 'https'; - return `${protocol}://${host}${basePath}`; +export function getBaseUrl(ctx: GitBookContext): string { + return `${ctx.protocol}://${ctx.host}${ctx.basePath}`; } /** * Create an absolute href in the current content. */ -export async function getAbsoluteHref(href: string, withHost: boolean = false): Promise { - const base = withHost ? await getBaseUrl() : await getBasePath(); +export function getAbsoluteHref( + ctx: GitBookContext, + href: string, + withHost: boolean = false, +): string { + const base = withHost ? getBaseUrl(ctx) : ctx.basePath; return `${base}${href.startsWith('/') ? href.slice(1) : href}`; } @@ -98,13 +91,14 @@ export function getGitbookAppHref(pathname: string): string { /** * Create a link to a page path in the current space. */ -export async function getPageHref( +export function getPageHref( + ctx: GitBookContext, rootPages: RevisionPage[], page: RevisionPageDocument | RevisionPageGroup, context: PageHrefContext = {}, /** Anchor to link to in the page. */ anchor?: string, -): Promise { +): string { const { pdf } = context; if (pdf) { @@ -120,9 +114,7 @@ export async function getPageHref( } } - const href = - (await getAbsoluteHref(getPagePath(rootPages, page))) + (anchor ? '#' + anchor : ''); - return href; + return getAbsoluteHref(ctx, getPagePath(rootPages, page)) + (anchor ? '#' + anchor : ''); } /** diff --git a/packages/gitbook/src/lib/pointer.ts b/packages/gitbook/src/lib/pointer.ts index 400fdd51d..53b8ffbac 100644 --- a/packages/gitbook/src/lib/pointer.ts +++ b/packages/gitbook/src/lib/pointer.ts @@ -1,18 +1,11 @@ -import { headers } from 'next/headers'; - import { SiteContentPointer, SpaceContentPointer } from './api'; +import { GitBookContext } from './gitbook-context'; /** * Get the current site content pointer from the headers */ -export async function getSiteContentPointer(): Promise { - const headersList = await headers(); - const spaceId = headersList.get('x-gitbook-content-space'); - const siteId = headersList.get('x-gitbook-content-site'); - const organizationId = headersList.get('x-gitbook-content-organization'); - const siteSpaceId = headersList.get('x-gitbook-content-site-space'); - const siteSectionId = headersList.get('x-gitbook-content-site-section'); - const siteShareKey = headersList.get('x-gitbook-content-site-share-key'); +export function getSiteContentPointer(ctx: GitBookContext): SiteContentPointer { + const { siteId, spaceId, organizationId, siteSectionId, siteSpaceId, siteShareKey } = ctx; if (!spaceId || !siteId || !organizationId) { throw new Error( @@ -27,8 +20,8 @@ export async function getSiteContentPointer(): Promise { siteSpaceId: siteSpaceId ?? undefined, siteShareKey: siteShareKey ?? undefined, organizationId, - revisionId: headersList.get('x-gitbook-content-revision') ?? undefined, - changeRequestId: headersList.get('x-gitbook-content-changerequest') ?? undefined, + revisionId: ctx.contentRevisionId ?? undefined, + changeRequestId: ctx.changeRequestId ?? undefined, }; return pointer; @@ -38,9 +31,8 @@ export async function getSiteContentPointer(): Promise { * Get the current space pointer from the headers. This should be used when rendering * the space in an isolated context (e.g. PDF generation). */ -export async function getSpacePointer(): Promise { - const headersList = await headers(); - const spaceId = headersList.get('x-gitbook-content-space'); +export function getSpacePointer(ctx: GitBookContext): SpaceContentPointer { + const spaceId = ctx.spaceId; if (!spaceId) { throw new Error( 'getSpacePointer is called outside the scope of a request processed by the middleware', @@ -49,8 +41,8 @@ export async function getSpacePointer(): Promise { const pointer: SpaceContentPointer = { spaceId, - revisionId: headersList.get('x-gitbook-content-revision') ?? undefined, - changeRequestId: headersList.get('x-gitbook-content-changerequest') ?? undefined, + revisionId: ctx.contentRevisionId ?? undefined, + changeRequestId: ctx.changeRequestId ?? undefined, }; return pointer; diff --git a/packages/gitbook/src/lib/references.tsx b/packages/gitbook/src/lib/references.tsx index b71ffa430..c62468f5c 100644 --- a/packages/gitbook/src/lib/references.tsx +++ b/packages/gitbook/src/lib/references.tsx @@ -28,6 +28,7 @@ import { parseSpacesFromSiteSpaces, } from './api'; import { getBlockById, getBlockTitle } from './document'; +import { GitBookContext } from './gitbook-context'; import { getGitbookAppHref, getPageHref, PageHrefContext } from './links'; import { getPagePath, resolvePageId } from './pages'; import { ClassValue } from './tailwind'; @@ -100,6 +101,7 @@ export interface ResolveContentRefOptions { * Resolve a content reference to be rendered. */ export async function resolveContentRef( + ctx: GitBookContext, contentRef: ContentRef, context: ContentRefContext, options: ResolveContentRefOptions = {}, @@ -117,7 +119,7 @@ export async function resolveContentRef( } case 'file': { - const file = await getRevisionFile(space.id, revisionId, contentRef.file); + const file = await getRevisionFile(ctx, space.id, revisionId, contentRef.file); if (file) { return { href: file.downloadURL, @@ -133,7 +135,7 @@ export async function resolveContentRef( case 'anchor': case 'page': { if (contentRef.space && contentRef.space !== space.id) { - return resolveContentRefInSpace(contentRef.space, siteContext, contentRef); + return resolveContentRefInSpace(ctx, contentRef.space, siteContext, contentRef); } const resolvePageResult = @@ -162,7 +164,7 @@ export async function resolveContentRef( if (resolveAnchorText) { const document = page.documentId - ? await getDocument(space.id, page.documentId) + ? await getDocument(ctx, space.id, page.documentId) : null; if (document) { const block = getBlockById(document, anchor); @@ -193,7 +195,7 @@ export async function resolveContentRef( } } else { // Page in the current content - href = await getPageHref(pages, page, linksContext, anchor); + href = await getPageHref(ctx, pages, page, linksContext, anchor); } return { @@ -209,7 +211,7 @@ export async function resolveContentRef( const targetSpace = contentRef.space === space.id ? space - : await getBestTargetSpace(contentRef.space, siteContext); + : await getBestTargetSpace(ctx, contentRef.space, siteContext); if (!targetSpace) { return { @@ -227,7 +229,7 @@ export async function resolveContentRef( } case 'user': { - const user = await getUserById(contentRef.user); + const user = await getUserById(ctx, contentRef.user); if (user) { return { href: `mailto:${user.email}`, @@ -250,7 +252,7 @@ export async function resolveContentRef( } case 'collection': { - const collection = await ignoreAPIError(getCollection(contentRef.collection)); + const collection = await ignoreAPIError(getCollection(ctx, contentRef.collection)); if (!collection) { return { href: getGitbookAppHref(`/s/${contentRef.collection}`), @@ -268,6 +270,7 @@ export async function resolveContentRef( case 'reusable-content': { const reusableContent = await getReusableContent( + ctx, space.id, revisionId, contentRef.reusableContent, @@ -293,16 +296,21 @@ export async function resolveContentRef( * It will try to return the space in the site context if it exists to avoid cross-site links. */ async function getBestTargetSpace( + ctx: GitBookContext, spaceId: string, siteContext: SiteContentPointer | null, ): Promise { const [fetchedSpace, publishedContentSite] = await Promise.all([ ignoreAPIError( - getSpace(spaceId, siteContext?.siteShareKey ? siteContext.siteShareKey : undefined), + getSpace( + ctx, + spaceId, + siteContext?.siteShareKey ? siteContext.siteShareKey : undefined, + ), ), siteContext ? ignoreAPIError( - getPublishedContentSite({ + getPublishedContentSite(ctx, { organizationId: siteContext.organizationId, siteId: siteContext.siteId, siteShareKey: siteContext.siteShareKey, @@ -333,6 +341,7 @@ async function getBestTargetSpace( } async function resolveContentRefInSpace( + ctx: GitBookContext, spaceId: string, siteContext: SiteContentPointer | null, contentRef: ContentRef, @@ -342,8 +351,8 @@ async function resolveContentRefInSpace( }; const [result, bestTargetSpace] = await Promise.all([ - ignoreAPIError(getSpaceContentData(pointer, siteContext?.siteShareKey)), - getBestTargetSpace(spaceId, siteContext), + ignoreAPIError(getSpaceContentData(ctx, pointer, siteContext?.siteShareKey)), + getBestTargetSpace(ctx, spaceId, siteContext), ]); if (!result) { return null; @@ -358,7 +367,7 @@ async function resolveContentRefInSpace( baseUrl += '/'; } - const resolved = await resolveContentRef(contentRef, { + const resolved = await resolveContentRef(ctx, contentRef, { siteContext, space, revisionId: space.revision, diff --git a/packages/gitbook/src/lib/seo.ts b/packages/gitbook/src/lib/seo.ts index a7db11b8f..f979207c9 100644 --- a/packages/gitbook/src/lib/seo.ts +++ b/packages/gitbook/src/lib/seo.ts @@ -1,5 +1,4 @@ import { - Collection, ContentVisibility, RevisionPageDocument, RevisionPageGroup, @@ -7,7 +6,8 @@ import { SiteVisibility, Space, } from '@gitbook/api'; -import { headers } from 'next/headers'; + +import { GitBookContext } from './gitbook-context'; /** * Return true if a page is indexable in search. @@ -31,21 +31,16 @@ export function isPageIndexable( /** * Return true if a space should be indexed by search engines. */ -export async function isSpaceIndexable({ space, site }: { space: Space; site: Site | null }) { - const headersList = await headers(); - - if ( - process.env.GITBOOK_BLOCK_SEARCH_INDEXATION && - !headersList.has('x-gitbook-search-indexation') - ) { +export function isSpaceIndexable( + ctx: GitBookContext, + { space, site }: { space: Space; site: Site | null }, +) { + if (process.env.GITBOOK_BLOCK_SEARCH_INDEXATION && !ctx.searchIndexation) { return false; } // Prevent indexation of preview of revisions / change-requests - if ( - headersList.get('x-gitbook-content-revision') || - headersList.get('x-gitbook-content-changerequest') - ) { + if (ctx.contentRevisionId || ctx.changeRequestId) { return false; } diff --git a/packages/gitbook/src/lib/tracking.ts b/packages/gitbook/src/lib/tracking.ts index 7c52a01d0..edd106d72 100644 --- a/packages/gitbook/src/lib/tracking.ts +++ b/packages/gitbook/src/lib/tracking.ts @@ -1,15 +1,12 @@ -import { headers } from 'next/headers'; +import { GitBookContext } from './gitbook-context'; /** * Return true if events should be tracked on the site. */ -export async function shouldTrackEvents(): Promise { - const headersList = await headers(); - +export function shouldTrackEvents(ctx: GitBookContext): boolean { if ( process.env.NODE_ENV === 'development' || - (process.env.GITBOOK_BLOCK_PAGE_VIEWS_TRACKING && - !headersList.has('x-gitbook-track-page-views')) + (process.env.GITBOOK_BLOCK_PAGE_VIEWS_TRACKING && !ctx.trackPageViews) ) { return false; } diff --git a/packages/gitbook/src/lib/visitor-token.ts b/packages/gitbook/src/lib/visitor-token.ts index 50987f8fd..3656aa606 100644 --- a/packages/gitbook/src/lib/visitor-token.ts +++ b/packages/gitbook/src/lib/visitor-token.ts @@ -90,15 +90,6 @@ export function normalizeVisitorAuthURL(url: URL): URL { return withoutVAParam; } -/** - * Get the visitor token from the request context. - */ -export async function getCurrentVisitorToken(): Promise { - const headersList = await headers(); - const visitorToken = headersList.get('x-gitbook-visitor-token'); - return visitorToken; -} - /** * Get all possible basePaths for a given URL. This is used to find the visitor * authentication cookie token. diff --git a/packages/gitbook/src/middleware.ts b/packages/gitbook/src/middleware.ts index 9628de899..c7a99f4f9 100644 --- a/packages/gitbook/src/middleware.ts +++ b/packages/gitbook/src/middleware.ts @@ -30,6 +30,7 @@ import { normalizeVisitorAuthURL, } from '@/lib/visitor-token'; +import { getGitBookContextFromHeaders, GitBookContext } from './lib/gitbook-context'; import { waitUntil } from './lib/waitUntil'; export const config = { @@ -96,6 +97,7 @@ export type LookupResult = PublishedContentWithCache & { * The middleware also takes care of persisting the visitor authentication state. */ export async function middleware(request: NextRequest) { + const ctx = getGitBookContextFromHeaders(request.headers); const { url, mode } = getInputURL(request); setTag('url', url.toString()); @@ -128,7 +130,7 @@ export async function middleware(request: NextRequest) { }), contextId: undefined, }, - () => lookupSiteForURL(mode, request, inputURL), + () => lookupSiteForURL(ctx, mode, request, inputURL), ); if ('error' in resolved) { return new NextResponse(resolved.error.message, { @@ -182,7 +184,7 @@ export async function middleware(request: NextRequest) { async () => { const [siteData] = await Promise.all([ 'site' in resolved - ? getSiteData({ + ? getSiteData(ctx, { organizationId: resolved.organization, siteId: resolved.site, siteSectionId: resolved.siteSection, @@ -194,6 +196,7 @@ export async function middleware(request: NextRequest) { // the cache will handle concurrent calls waitUntil( getSpaceContentData( + ctx, { spaceId: resolved.space, changeRequestId: resolved.changeRequest, @@ -367,25 +370,26 @@ function getInputURL(request: NextRequest): { } async function lookupSiteForURL( + ctx: GitBookContext, mode: URLLookupMode, request: NextRequest, url: URL, ): Promise { switch (mode) { case 'single': { - return await lookupSiteInSingleMode(url); + return lookupSiteInSingleMode(ctx, url); } case 'multi': { - return await lookupSiteInMultiMode(request, url); + return await lookupSiteInMultiMode(ctx, request, url); } case 'multi-path': { - return await lookupSiteInMultiPathMode(request, url); + return await lookupSiteInMultiPathMode(ctx, request, url); } case 'multi-id': { - return await lookupSiteOrSpaceInMultiIdMode(request, url); + return await lookupSiteOrSpaceInMultiIdMode(ctx, request, url); } case 'proxy': - return await lookupSiteInProxy(request, url); + return await lookupSiteInProxy(ctx, request, url); default: assertNever(mode); } @@ -395,7 +399,7 @@ async function lookupSiteForURL( * GITBOOK_MODE=single * When serving a single space, configured using GITBOOK_SPACE_ID and GITBOOK_TOKEN. */ -async function lookupSiteInSingleMode(url: URL): Promise { +function lookupSiteInSingleMode(ctx: GitBookContext, url: URL): LookupResult { const spaceId = process.env.GITBOOK_SPACE_ID; if (!spaceId) { throw new Error( @@ -403,8 +407,7 @@ async function lookupSiteInSingleMode(url: URL): Promise { ); } - const apiCtx = await api(); - const apiToken = getDefaultAPIToken(apiCtx.client.endpoint); + const apiToken = getDefaultAPIToken(api(ctx).client.endpoint); if (!apiToken) { throw new Error( `Missing GITBOOK_TOKEN environment variable. It should be passed when using GITBOOK_MODE=single.`, @@ -425,7 +428,11 @@ async function lookupSiteInSingleMode(url: URL): Promise { * GITBOOK_MODE=proxy * When proxying a site on a different base URL. */ -async function lookupSiteInProxy(request: NextRequest, url: URL): Promise { +async function lookupSiteInProxy( + ctx: GitBookContext, + request: NextRequest, + url: URL, +): Promise { const rawSiteUrl = request.headers.get('x-gitbook-site-url'); if (!rawSiteUrl) { throw new Error( @@ -436,16 +443,20 @@ async function lookupSiteInProxy(request: NextRequest, url: URL): Promise { +async function lookupSiteInMultiMode( + ctx: GitBookContext, + request: NextRequest, + url: URL, +): Promise { const visitorAuthToken = getVisitorToken(request, url); - const lookup = await lookupSiteByAPI(url, visitorAuthToken); + const lookup = await lookupSiteByAPI(ctx, url, visitorAuthToken); return { ...lookup, ...('basePath' in lookup && visitorAuthToken @@ -465,6 +476,7 @@ async function lookupSiteInMultiMode(request: NextRequest, url: URL): Promise { @@ -543,9 +555,8 @@ async function lookupSiteOrSpaceInMultiIdMode( // invalidated when trying to preview the site with different visitor // attributes. const contextId = decoded.claims ? hash(decoded.claims) : undefined; - const apiCtx = await api(); const gitbookAPI = new GitBookAPI({ - endpoint: apiEndpoint ?? apiCtx.client.endpoint, + endpoint: apiEndpoint ?? api(ctx).client.endpoint, authToken: apiToken, userAgent: userAgent(), }); @@ -554,7 +565,7 @@ async function lookupSiteOrSpaceInMultiIdMode( // (the cache is not dependend on the auth token, so it could leak data) if (source.kind === 'space') { await withAPI({ client: gitbookAPI, contextId }, () => - getSpace.revalidate(source.id, undefined), + getSpace.revalidate(ctx, source.id, undefined), ); } @@ -562,7 +573,7 @@ async function lookupSiteOrSpaceInMultiIdMode( // (the cache is not dependend on the auth token, so it could leak data) if (source.kind === 'site') { await withAPI({ client: gitbookAPI, contextId }, () => - getPublishedContentSite.revalidate({ + getPublishedContentSite.revalidate(ctx, { organizationId: decoded.organization, siteId: source.id, siteShareKey: undefined, @@ -612,7 +623,11 @@ async function lookupSiteOrSpaceInMultiIdMode( * GITBOOK_MODE=multi-path * When serving multi spaces with the url passed in the path. */ -async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promise { +async function lookupSiteInMultiPathMode( + ctx: GitBookContext, + request: NextRequest, + url: URL, +): Promise { // Skip useless requests if ( url.pathname === '/favicon.ico' || @@ -651,7 +666,7 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis const visitorAuthToken = getVisitorToken(request, target); - const lookup = await lookupSiteByAPI(target, visitorAuthToken); + const lookup = await lookupSiteByAPI(ctx, target, visitorAuthToken); if ('error' in lookup) { return lookup; } @@ -689,6 +704,7 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis * To optimize caching, we try multiple lookup alternatives and return the first one that matches. */ async function lookupSiteByAPI( + ctx: GitBookContext, lookupURL: URL, visitorTokenLookup: VisitorTokenLookup, ): Promise { @@ -706,6 +722,7 @@ async function lookupSiteByAPI( const result = await race(lookup.urls, async (alternative, { signal }) => { const data = await getPublishedContentByUrl( + ctx, alternative.url, visitorTokenLookup?.token, redirectOnError || undefined,