mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-21 01:53:26 +00:00
v2: use static route for VA / adaptive content (#2998)
This commit is contained in:
@@ -136,14 +136,17 @@
|
||||
"@gitbook/api": "*",
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"next": "^15.2.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"rison": "^0.1.1",
|
||||
"server-only": "^0.0.1",
|
||||
"warn-once": "^0.1.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opennextjs/cloudflare": "^0.5.10",
|
||||
"@types/rison": "^0.0.9",
|
||||
"gitbook": "*",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.0",
|
||||
@@ -253,7 +256,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@codemirror/state": "6.4.1",
|
||||
"@gitbook/api": "0.101.0",
|
||||
"@gitbook/api": "0.102.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
},
|
||||
@@ -604,7 +607,7 @@
|
||||
|
||||
"@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="],
|
||||
|
||||
"@gitbook/api": ["@gitbook/api@0.101.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-DX8CyoXCRyqXW738G9Ik9Pq97L1RjFdVCGxska9tFkrn1+bf60mwpt+6669wrRdlBxb1t2Ru4DsGuUksxCKDag=="],
|
||||
"@gitbook/api": ["@gitbook/api@0.102.0", "", { "dependencies": { "event-iterator": "^2.0.0", "eventsource-parser": "^3.0.0" } }, "sha512-9wPr5kyCHhTTwkaCYEMT4q3JjEl1q9mytgvpeusmeREfG4E0RZHYPEL8bmumO7T9wH/0vHjpfW2LSTUUs60AwQ=="],
|
||||
|
||||
"@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"],
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"@codemirror/state": "6.4.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"@gitbook/api": "0.101.0"
|
||||
"@gitbook/api": "0.102.0"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
authInterrupts: true,
|
||||
useCache: true,
|
||||
},
|
||||
|
||||
|
||||
@@ -10,11 +10,14 @@
|
||||
"@gitbook/cache-tags": "workspace:*",
|
||||
"@sindresorhus/fnv1a": "^3.1.0",
|
||||
"server-only": "^0.0.1",
|
||||
"warn-once": "^0.1.1"
|
||||
"warn-once": "^0.1.1",
|
||||
"rison": "^0.1.1",
|
||||
"jwt-decode": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"gitbook": "*",
|
||||
"@opennextjs/cloudflare": "^0.5.10",
|
||||
"@types/rison": "^0.0.9",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8"
|
||||
},
|
||||
|
||||
+3
-3
@@ -13,20 +13,20 @@ type PageProps = {
|
||||
|
||||
export default async function Page(props: PageProps) {
|
||||
const params = await props.params;
|
||||
const context = await getDynamicSiteContext(params);
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return <SitePage context={context} pageParams={{ pathname }} />;
|
||||
}
|
||||
|
||||
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||
const context = await getDynamicSiteContext(await props.params);
|
||||
const { context } = await getDynamicSiteContext(await props.params);
|
||||
return generateSitePageViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const context = await getDynamicSiteContext(params);
|
||||
const { context } = await getDynamicSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return generateSitePageMetadata({
|
||||
+5
-6
@@ -6,7 +6,7 @@ import {
|
||||
} from '@/components/SiteLayout';
|
||||
import { type RouteLayoutParams, getDynamicSiteContext } from '@v2/app/utils';
|
||||
import { GITBOOK_DISABLE_TRACKING } from '@v2/lib/env';
|
||||
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
|
||||
import { getThemeFromMiddleware } from '@v2/lib/middleware';
|
||||
|
||||
interface SiteDynamicLayoutProps {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
@@ -16,9 +16,8 @@ export default async function SiteDynamicLayout({
|
||||
params,
|
||||
children,
|
||||
}: React.PropsWithChildren<SiteDynamicLayoutProps>) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context, visitorAuthClaims } = await getDynamicSiteContext(await params);
|
||||
const forcedTheme = await getThemeFromMiddleware();
|
||||
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
|
||||
|
||||
return (
|
||||
<CustomizationRootLayout customization={context.customization}>
|
||||
@@ -26,7 +25,7 @@ export default async function SiteDynamicLayout({
|
||||
context={context}
|
||||
forcedTheme={forcedTheme}
|
||||
withTracking={!GITBOOK_DISABLE_TRACKING}
|
||||
visitorAuthToken={visitorAuthToken}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
{children}
|
||||
</SiteLayout>
|
||||
@@ -35,11 +34,11 @@ export default async function SiteDynamicLayout({
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteDynamicLayoutProps) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return generateSiteLayoutViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SiteDynamicLayoutProps) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return generateSiteLayoutMetadata(context);
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return serveLLMsTxt(context);
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return serveRobotsTxt(context);
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return servePagesSitemap(context);
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return serveRootSitemap(context);
|
||||
}
|
||||
+1
-1
@@ -7,6 +7,6 @@ export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return serveIcon(context, request);
|
||||
}
|
||||
+1
-1
@@ -8,6 +8,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
|
||||
) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return serveOGImage(context, await params);
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ export default async function RootLayout(props: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { params, children } = props;
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
|
||||
return <PDFRootLayout context={context}>{children}</PDFRootLayout>;
|
||||
}
|
||||
+2
-2
@@ -6,7 +6,7 @@ export async function generateMetadata({
|
||||
}: {
|
||||
params: Promise<RouteLayoutParams>;
|
||||
}) {
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return generatePDFMetadata(context);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,6 @@ export default async function Page(props: {
|
||||
searchParams: Promise<{ [key: string]: string }>;
|
||||
}) {
|
||||
const { params, searchParams } = props;
|
||||
const context = await getDynamicSiteContext(await params);
|
||||
const { context } = await getDynamicSiteContext(await params);
|
||||
return <PDFPage context={context} searchParams={await searchParams} />;
|
||||
}
|
||||
+3
-3
@@ -18,7 +18,7 @@ export default async function Page(props: PageProps) {
|
||||
'use cache';
|
||||
|
||||
const params = await props.params;
|
||||
const context = await getStaticSiteContext(params);
|
||||
const { context } = await getStaticSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
cacheTag(
|
||||
@@ -32,13 +32,13 @@ export default async function Page(props: PageProps) {
|
||||
}
|
||||
|
||||
export async function generateViewport(props: PageProps): Promise<Viewport> {
|
||||
const context = await getStaticSiteContext(await props.params);
|
||||
const { context } = await getStaticSiteContext(await props.params);
|
||||
return generateSitePageViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const context = await getStaticSiteContext(params);
|
||||
const { context } = await getStaticSiteContext(params);
|
||||
const pathname = getPagePathFromParams(params);
|
||||
|
||||
return generateSitePageMetadata({
|
||||
+4
-4
@@ -19,7 +19,7 @@ export default async function SiteStaticLayout({
|
||||
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
|
||||
'use cache';
|
||||
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context, visitorAuthClaims } = await getStaticSiteContext(await params);
|
||||
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
@@ -33,7 +33,7 @@ export default async function SiteStaticLayout({
|
||||
<SiteLayout
|
||||
context={context}
|
||||
withTracking={!GITBOOK_DISABLE_TRACKING}
|
||||
visitorAuthToken={null}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
{children}
|
||||
</SiteLayout>
|
||||
@@ -42,11 +42,11 @@ export default async function SiteStaticLayout({
|
||||
}
|
||||
|
||||
export async function generateViewport({ params }: SiteStaticLayoutProps) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return generateSiteLayoutViewport(context);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SiteStaticLayoutProps) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return generateSiteLayoutMetadata(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveLLMsTxt(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveRobotsTxt(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return servePagesSitemap(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveRootSitemap(context);
|
||||
}
|
||||
+1
-1
@@ -9,6 +9,6 @@ export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveIcon(context, request);
|
||||
}
|
||||
+1
-1
@@ -10,6 +10,6 @@ export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<RouteLayoutParams & PageIdParams> }
|
||||
) {
|
||||
const context = await getStaticSiteContext(await params);
|
||||
const { context } = await getStaticSiteContext(await params);
|
||||
return serveOGImage(context, await params);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
fetchSiteContextByURL,
|
||||
fetchSiteContextByURLLookup,
|
||||
getBaseContext,
|
||||
} from '@v2/lib/context';
|
||||
import { getSiteURLDataFromMiddleware } from '@v2/lib/middleware';
|
||||
import { getVisitorAuthClaims, getVisitorAuthClaimsFromToken } from '@/lib/adaptive';
|
||||
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
|
||||
import { fetchSiteContextByURLLookup, getBaseContext } from '@v2/lib/context';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { forbidden } from 'next/navigation';
|
||||
import rison from 'rison';
|
||||
|
||||
export type RouteParamMode = 'url-host' | 'url';
|
||||
|
||||
@@ -12,6 +12,9 @@ export type RouteLayoutParams = {
|
||||
|
||||
/** URL encoded site URL */
|
||||
siteURL: string;
|
||||
|
||||
/** URL and Rison encoded site data from getPublishedContentByUrl */
|
||||
siteData: string;
|
||||
};
|
||||
|
||||
export type RouteParams = RouteLayoutParams & {
|
||||
@@ -21,19 +24,29 @@ export type RouteParams = RouteLayoutParams & {
|
||||
/**
|
||||
* Get the static context when rendering statically a site.
|
||||
*/
|
||||
export function getStaticSiteContext(params: RouteLayoutParams) {
|
||||
export async function getStaticSiteContext(params: RouteLayoutParams) {
|
||||
const siteURL = getSiteURLFromParams(params);
|
||||
return fetchSiteContextByURL(
|
||||
const siteURLData = getSiteURLDataFromParams(params);
|
||||
|
||||
// For static routes, we check the expiration of the JWT token
|
||||
// as the route might be revalidated after expiration
|
||||
const decoded = jwtDecode<SiteAPIToken & { exp: number }>(siteURLData.apiToken);
|
||||
if (decoded.exp && decoded.exp < Date.now() / 1000 + 120) {
|
||||
forbidden();
|
||||
}
|
||||
|
||||
const context = await fetchSiteContextByURLLookup(
|
||||
getBaseContext({
|
||||
siteURL,
|
||||
urlMode: getModeFromParams(params.mode),
|
||||
}),
|
||||
{
|
||||
url: siteURL.toString(),
|
||||
visitorAuthToken: null,
|
||||
redirectOnError: false,
|
||||
}
|
||||
siteURLData
|
||||
);
|
||||
|
||||
return {
|
||||
context,
|
||||
visitorAuthClaims: getVisitorAuthClaimsFromToken(decoded),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,15 +55,20 @@ export function getStaticSiteContext(params: RouteLayoutParams) {
|
||||
*/
|
||||
export async function getDynamicSiteContext(params: RouteLayoutParams) {
|
||||
const siteURL = getSiteURLFromParams(params);
|
||||
const siteURLData = await getSiteURLDataFromMiddleware();
|
||||
const siteURLData = getSiteURLDataFromParams(params);
|
||||
|
||||
return fetchSiteContextByURLLookup(
|
||||
const context = await fetchSiteContextByURLLookup(
|
||||
getBaseContext({
|
||||
siteURL,
|
||||
urlMode: getModeFromParams(params.mode),
|
||||
}),
|
||||
siteURLData
|
||||
);
|
||||
|
||||
return {
|
||||
context,
|
||||
visitorAuthClaims: getVisitorAuthClaims(siteURLData),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,3 +92,11 @@ function getModeFromParams(mode: string): RouteParamMode {
|
||||
|
||||
return 'url';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the decoded site data from the params.
|
||||
*/
|
||||
function getSiteURLDataFromParams(params: RouteLayoutParams): PublishedSiteContent {
|
||||
const decoded = decodeURIComponent(params.siteData);
|
||||
return rison.decode(decoded);
|
||||
}
|
||||
|
||||
@@ -168,29 +168,6 @@ export function getLinkerForSiteURL(input: {
|
||||
return linker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the context of a site for a given URL and a base context.
|
||||
*/
|
||||
export async function fetchSiteContextByURL(
|
||||
baseContext: GitBookBaseContext,
|
||||
input: {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
}
|
||||
): Promise<GitBookSiteContext> {
|
||||
const { dataFetcher } = baseContext;
|
||||
const data = await throwIfDataError(
|
||||
dataFetcher.getPublishedContentByUrl({
|
||||
url: input.url,
|
||||
visitorAuthToken: input.visitorAuthToken,
|
||||
redirectOnError: input.redirectOnError,
|
||||
})
|
||||
);
|
||||
|
||||
return fetchSiteContextByURLLookup(baseContext, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the context of a site using the resolution of a URL
|
||||
*/
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
GitBookAPI,
|
||||
type GitBookAPIServiceBinding,
|
||||
} from '@gitbook/api';
|
||||
import {
|
||||
getCacheTag,
|
||||
getCacheTagForURL,
|
||||
getComputedContentSourceCacheTags,
|
||||
} from '@gitbook/cache-tags';
|
||||
import { getCacheTag, getComputedContentSourceCacheTags } from '@gitbook/cache-tags';
|
||||
import { GITBOOK_API_TOKEN, GITBOOK_API_URL, GITBOOK_USER_AGENT } from '@v2/lib/env';
|
||||
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';
|
||||
import { wrapDataFetcherError } from './errors';
|
||||
@@ -167,18 +163,6 @@ export function createDataFetcher(
|
||||
getUserById(userId) {
|
||||
return trace('getUserById', () => getUserById({ apiToken: null }, { userId }));
|
||||
},
|
||||
getPublishedContentByUrl(params) {
|
||||
return trace('getPublishedContentByUrl', () =>
|
||||
getPublishedContentByUrl(
|
||||
{ apiToken: null },
|
||||
{
|
||||
url: params.url,
|
||||
visitorAuthToken: params.visitorAuthToken,
|
||||
redirectOnError: params.redirectOnError,
|
||||
}
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -439,42 +423,6 @@ async function getLatestOpenAPISpecVersionContent(
|
||||
});
|
||||
}
|
||||
|
||||
async function getPublishedContentByUrl(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
}
|
||||
) {
|
||||
'use cache';
|
||||
|
||||
const { url, visitorAuthToken, redirectOnError } = params;
|
||||
|
||||
cacheTag(getCacheTagForURL(url));
|
||||
cacheLife('days');
|
||||
|
||||
return wrapDataFetcherError(async () => {
|
||||
const api = await apiClient(input);
|
||||
const res = await api.urls.getPublishedContentByUrl({
|
||||
url,
|
||||
visitorAuthToken: visitorAuthToken ?? undefined,
|
||||
redirectOnError,
|
||||
});
|
||||
|
||||
if ('site' in res.data) {
|
||||
cacheTag(
|
||||
getCacheTag({
|
||||
tag: 'site',
|
||||
site: res.data.site,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
|
||||
async function getPublishedContentSite(
|
||||
input: DataFetcherInput,
|
||||
params: {
|
||||
|
||||
@@ -94,12 +94,15 @@ export async function getPublishedContentByURL(input: {
|
||||
* In both cases, the idea is to use the deepest/longest/most inclusive path to resolve the content.
|
||||
*/
|
||||
if (alternative.primary || ('site' in data && data.complete)) {
|
||||
const changeRequest = data.changeRequest ?? lookup.changeRequest;
|
||||
const revision = data.revision ?? lookup.revision;
|
||||
|
||||
const siteResult: PublishedSiteContentLookup = {
|
||||
...data,
|
||||
changeRequest: data.changeRequest ?? lookup.changeRequest,
|
||||
revision: data.revision ?? lookup.revision,
|
||||
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
|
||||
pathname: joinPath(data.pathname, alternative.extraPath),
|
||||
...(changeRequest ? { changeRequest } : {}),
|
||||
...(revision ? { revision } : {}),
|
||||
};
|
||||
return { data: siteResult };
|
||||
}
|
||||
|
||||
@@ -37,15 +37,6 @@ export interface GitBookDataFetcher {
|
||||
*/
|
||||
getUserById(userId: string): Promise<DataFetcherResponse<api.User>>;
|
||||
|
||||
/**
|
||||
* Get a published content by its URL.
|
||||
*/
|
||||
getPublishedContentByUrl(params: {
|
||||
url: string;
|
||||
visitorAuthToken: string | null;
|
||||
redirectOnError: boolean;
|
||||
}): Promise<DataFetcherResponse<api.PublishedSiteContentLookup>>;
|
||||
|
||||
/**
|
||||
* Get a published content site by its organization ID and site ID.
|
||||
*/
|
||||
|
||||
@@ -32,11 +32,6 @@ export enum MiddlewareHeaders {
|
||||
*/
|
||||
Customization = 'x-gitbook-customization',
|
||||
|
||||
/**
|
||||
* The visitor token used to access this content
|
||||
*/
|
||||
VisitorAuthToken = 'x-gitbook-visitor-token',
|
||||
|
||||
/**
|
||||
* The API token used to fetch the content.
|
||||
* This should only be passed for non-site dynamic routes.
|
||||
@@ -105,20 +100,6 @@ export async function getThemeFromMiddleware() {
|
||||
: CustomizationThemeMode.Dark;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visitor auth token from the middleware headers.
|
||||
* This function should only be called in a dynamic route.
|
||||
*/
|
||||
export async function getVisitorAuthTokenFromMiddleware(): Promise<string | null> {
|
||||
const headersList = await headers();
|
||||
const visitorAuthToken = headersList.get(MiddlewareHeaders.VisitorAuthToken);
|
||||
if (!visitorAuthToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return visitorAuthToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the API token from the middleware headers.
|
||||
* This function should only be called in a dynamic route.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CustomizationThemeMode } from '@gitbook/api';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import rison from 'rison';
|
||||
|
||||
import { getContentSecurityPolicy } from '@/lib/csp';
|
||||
import { validateSerializedCustomization } from '@/lib/customization';
|
||||
@@ -83,7 +84,10 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
// Detect and extract the visitor authentication token from the request
|
||||
//
|
||||
// @ts-ignore - request typing
|
||||
const visitorToken = getVisitorToken(request, siteURL);
|
||||
const visitorToken = getVisitorToken({
|
||||
cookies: request.cookies.getAll(),
|
||||
url: siteURL,
|
||||
});
|
||||
|
||||
const data = await throwIfDataError(
|
||||
getPublishedContentByURL({
|
||||
@@ -95,7 +99,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
|
||||
})
|
||||
);
|
||||
let cookies: ResponseCookies = {};
|
||||
const cookies: ResponseCookies = [];
|
||||
|
||||
//
|
||||
// Handle redirects
|
||||
@@ -123,10 +127,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
return NextResponse.redirect(data.redirect);
|
||||
}
|
||||
|
||||
cookies = {
|
||||
...cookies,
|
||||
...getResponseCookiesForVisitorAuth(data.basePath, visitorToken),
|
||||
};
|
||||
cookies.push(...getResponseCookiesForVisitorAuth(data.siteBasePath, visitorToken));
|
||||
|
||||
//
|
||||
// Make sure the URL is clean of any va token after a successful lookup
|
||||
@@ -144,8 +145,9 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
// Render and serve the content
|
||||
//
|
||||
|
||||
// When visitor has authentication (adaptive content or VA), we serve dynamic routes.
|
||||
let routeType = visitorToken ? 'dynamic' : 'static';
|
||||
// The route is static, except when using dynamic parameters from query params
|
||||
// (customization override, theme, etc)
|
||||
let routeType: 'dynamic' | 'static' = 'static';
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
|
||||
@@ -184,6 +186,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
routeType,
|
||||
mode,
|
||||
encodeURIComponent(siteURLWithoutProtocol),
|
||||
encodeURIComponent(rison.encode(data)),
|
||||
pathname,
|
||||
].join('/');
|
||||
|
||||
@@ -234,8 +237,9 @@ async function servePreviewRoutes(requestURL: URL, request: NextRequest) {
|
||||
const queryAPIToken = requestURL.searchParams.get('token');
|
||||
if (queryAPIToken) {
|
||||
requestURL.searchParams.delete('token');
|
||||
return writeResponseCookies(NextResponse.redirect(requestURL.toString()), {
|
||||
[cookieName]: {
|
||||
return writeResponseCookies(NextResponse.redirect(requestURL.toString()), [
|
||||
{
|
||||
name: cookieName,
|
||||
value: queryAPIToken,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
@@ -244,7 +248,7 @@ async function servePreviewRoutes(requestURL: URL, request: NextRequest) {
|
||||
maxAge: 60 * 60, // 1 hour
|
||||
},
|
||||
},
|
||||
});
|
||||
]);
|
||||
}
|
||||
|
||||
const apiToken = request.cookies.get(cookieName)?.value;
|
||||
@@ -371,8 +375,8 @@ function appendQueryParams(url: URL, from: URLSearchParams) {
|
||||
* Write the cookies to a response.
|
||||
*/
|
||||
function writeResponseCookies<R extends NextResponse>(response: R, cookies: ResponseCookies): R {
|
||||
Object.entries(cookies).forEach(([key, { value, options }]) => {
|
||||
response.cookies.set(key, value, options);
|
||||
cookies.forEach((cookie) => {
|
||||
response.cookies.set(cookie.name, cookie.value, cookie.options);
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getThemeFromMiddleware, getVisitorAuthTokenFromMiddleware } from '@v2/lib/middleware';
|
||||
import { getSiteURLDataFromMiddleware, getThemeFromMiddleware } from '@v2/lib/middleware';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import type React from 'react';
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
generateSiteLayoutMetadata,
|
||||
generateSiteLayoutViewport,
|
||||
} from '@/components/SiteLayout';
|
||||
import { getVisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { getSiteContentPointer } from '@/lib/pointer';
|
||||
import { shouldTrackEvents } from '@/lib/tracking';
|
||||
import { fetchV1ContextForSitePointer } from '@/lib/v1';
|
||||
@@ -22,14 +23,14 @@ export default async function ContentLayout(props: { children: React.ReactNode }
|
||||
|
||||
const context = await fetchLayoutData();
|
||||
const queryStringTheme = await getThemeFromMiddleware();
|
||||
const visitorAuthToken = await getVisitorAuthTokenFromMiddleware();
|
||||
const siteData = await getSiteURLDataFromMiddleware();
|
||||
|
||||
return (
|
||||
<SiteLayout
|
||||
context={context}
|
||||
forcedTheme={queryStringTheme}
|
||||
withTracking={await shouldTrackEvents()}
|
||||
visitorAuthToken={visitorAuthToken}
|
||||
visitorAuthClaims={getVisitorAuthClaims(siteData)}
|
||||
>
|
||||
{children}
|
||||
</SiteLayout>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { OpenAPIOperationContextProvider } from '@gitbook/react-openapi';
|
||||
import * as React from 'react';
|
||||
import { useDebounceCallback, useEventCallback } from 'usehooks-ts';
|
||||
|
||||
import * as cookies from '@/lib/cookies';
|
||||
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { getAllBrowserCookiesMap } from '@/lib/browser-cookies';
|
||||
import { getSession } from './sessions';
|
||||
import { getVisitorId } from './visitorId';
|
||||
|
||||
@@ -23,6 +23,7 @@ type InsightsEventContext = {
|
||||
siteShareKey: string | null;
|
||||
spaceId: string;
|
||||
revisionId: string;
|
||||
visitorAuthClaims: VisitorAuthClaims;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -65,7 +66,6 @@ interface InsightsProviderProps extends InsightsEventContext {
|
||||
enabled: boolean;
|
||||
appURL: string;
|
||||
apiHost: string;
|
||||
visitorAuthToken: string | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ interface InsightsProviderProps extends InsightsEventContext {
|
||||
* Wrap the content of the app with the InsightsProvider to track events.
|
||||
*/
|
||||
export function InsightsProvider(props: InsightsProviderProps) {
|
||||
const { enabled, appURL, apiHost, visitorAuthToken, children, ...context } = props;
|
||||
const { enabled, appURL, apiHost, children, ...context } = props;
|
||||
|
||||
const visitorIdRef = React.useRef<string | null>(null);
|
||||
const eventsRef = React.useRef<{
|
||||
@@ -116,7 +116,6 @@ export function InsightsProvider(props: InsightsProviderProps) {
|
||||
pageContext: eventsForPathname.pageContext,
|
||||
visitorId,
|
||||
sessionId: session.id,
|
||||
visitorAuthToken,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -253,16 +252,15 @@ function transformEvents(input: {
|
||||
pageContext: InsightsEventPageContext;
|
||||
visitorId: string;
|
||||
sessionId: string;
|
||||
visitorAuthToken: string | null;
|
||||
}): api.SiteInsightsEvent[] {
|
||||
const session: api.SiteInsightsEventSession = {
|
||||
sessionId: input.sessionId,
|
||||
visitorId: input.visitorId,
|
||||
userAgent: window.navigator.userAgent,
|
||||
language: window.navigator.language,
|
||||
cookies: cookies.getAll(),
|
||||
cookies: getAllBrowserCookiesMap(),
|
||||
referrer: document.referrer || null,
|
||||
visitorAuthToken: input.visitorAuthToken ?? null,
|
||||
visitorAuthClaims: input.context.visitorAuthClaims,
|
||||
};
|
||||
|
||||
const location: api.SiteInsightsEventLocation = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import * as cookies from '@/lib/cookies';
|
||||
import { getBrowserCookie, setBrowserCookie } from '@/lib/browser-cookies';
|
||||
|
||||
const GRANTED_COOKIE = '__gitbook_cookie_granted';
|
||||
|
||||
@@ -8,7 +8,7 @@ const GRANTED_COOKIE = '__gitbook_cookie_granted';
|
||||
* Accept or reject cookies.
|
||||
*/
|
||||
export function setCookiesTracking(enabled: boolean) {
|
||||
cookies.set(GRANTED_COOKIE, enabled ? 'yes' : 'no', {
|
||||
setBrowserCookie(GRANTED_COOKIE, enabled ? 'yes' : 'no', {
|
||||
expires: 365,
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
@@ -20,7 +20,7 @@ export function setCookiesTracking(enabled: boolean) {
|
||||
* Return `undefined` if state is not known.
|
||||
*/
|
||||
export function isCookiesTrackingDisabled() {
|
||||
const state = cookies.get(GRANTED_COOKIE);
|
||||
const state = getBrowserCookie(GRANTED_COOKIE);
|
||||
|
||||
if (state === 'yes') {
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import * as cookies from '@/lib/cookies';
|
||||
import { getBrowserCookie } from '@/lib/browser-cookies';
|
||||
|
||||
import { isCookiesTrackingDisabled } from './cookies';
|
||||
import { generateRandomId } from './utils';
|
||||
@@ -37,7 +37,7 @@ async function fetchVisitorID(appURL: string): Promise<string> {
|
||||
return generateRandomId();
|
||||
}
|
||||
|
||||
const existingTrackingCookie = cookies.get(VISITORID_COOKIE);
|
||||
const existingTrackingCookie = getBrowserCookie(VISITORID_COOKIE);
|
||||
|
||||
if (existingTrackingCookie) {
|
||||
// If the cookie already exists, we'll just use that. Avoids a server request.
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SpaceLayout } from '@/components/SpaceLayout';
|
||||
import { buildVersion } from '@/lib/build';
|
||||
import { isSiteIndexable } from '@/lib/seo';
|
||||
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_ASSETS_URL, GITBOOK_ICONS_URL } from '@v2/lib/env';
|
||||
import { getResizedImageURL } from '@v2/lib/images';
|
||||
import { ClientContexts } from './ClientContexts';
|
||||
@@ -25,10 +26,10 @@ export async function SiteLayout(props: {
|
||||
context: GitBookSiteContext;
|
||||
forcedTheme?: CustomizationThemeMode | null;
|
||||
withTracking: boolean;
|
||||
visitorAuthToken: string | null;
|
||||
visitorAuthClaims: VisitorAuthClaims;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { context, nonce, forcedTheme, withTracking, visitorAuthToken, children } = props;
|
||||
const { context, nonce, forcedTheme, withTracking, visitorAuthClaims, children } = props;
|
||||
|
||||
const { scripts, customization } = context;
|
||||
|
||||
@@ -57,7 +58,7 @@ export async function SiteLayout(props: {
|
||||
<SpaceLayout
|
||||
context={context}
|
||||
withTracking={withTracking}
|
||||
visitorAuthToken={visitorAuthToken}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
{children}
|
||||
</SpaceLayout>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getSpaceLanguage } from '@/intl/server';
|
||||
import { t } from '@/intl/translate';
|
||||
import { tcls } from '@/lib/tailwind';
|
||||
|
||||
import type { VisitorAuthClaims } from '@/lib/adaptive';
|
||||
import { GITBOOK_API_PUBLIC_URL, GITBOOK_APP_URL } from '@v2/lib/env';
|
||||
import { SpacesDropdown } from '../Header/SpacesDropdown';
|
||||
import { InsightsProvider } from '../Insights';
|
||||
@@ -26,13 +27,13 @@ export function SpaceLayout(props: {
|
||||
/** Whether to enable tracking of events into site insights. */
|
||||
withTracking: boolean;
|
||||
|
||||
/** The visitor token used to access this content */
|
||||
visitorAuthToken: string | null;
|
||||
/** The visitor auth claims. */
|
||||
visitorAuthClaims: VisitorAuthClaims;
|
||||
|
||||
/** The children of the layout. */
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { context, withTracking, visitorAuthToken, children } = props;
|
||||
const { context, withTracking, visitorAuthClaims, children } = props;
|
||||
const { siteSpace, customization, sections, siteSpaces } = context;
|
||||
|
||||
const withTopHeader = customization.header.preset !== CustomizationHeaderPreset.None;
|
||||
@@ -52,7 +53,6 @@ export function SpaceLayout(props: {
|
||||
enabled={withTracking}
|
||||
appURL={GITBOOK_APP_URL}
|
||||
apiHost={GITBOOK_API_PUBLIC_URL}
|
||||
visitorAuthToken={visitorAuthToken}
|
||||
organizationId={context.organizationId}
|
||||
siteId={context.site.id}
|
||||
siteSectionId={context.sections?.current?.id ?? null}
|
||||
@@ -60,6 +60,7 @@ export function SpaceLayout(props: {
|
||||
siteShareKey={context.shareKey ?? null}
|
||||
revisionId={context.revisionId}
|
||||
spaceId={context.space.id}
|
||||
visitorAuthClaims={visitorAuthClaims}
|
||||
>
|
||||
<Header withTopHeader={withTopHeader} context={context} />
|
||||
<div className="scroll-nojump">
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { PublishedSiteContent, SiteAPIToken } from '@gitbook/api';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
|
||||
/**
|
||||
* Claims about the visitor, stored in the VA and auth token.
|
||||
*/
|
||||
export type VisitorAuthClaims = Record<string, any>;
|
||||
|
||||
/**
|
||||
* Get the visitor auth claims from the API response obtained from `getPublishedContentByUrl`.
|
||||
*/
|
||||
export function getVisitorAuthClaims(siteData: PublishedSiteContent): VisitorAuthClaims {
|
||||
const { apiToken } = siteData;
|
||||
|
||||
return getVisitorAuthClaimsFromToken(jwtDecode<SiteAPIToken>(apiToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the visitor auth claims from a decoded API token.
|
||||
*/
|
||||
export function getVisitorAuthClaimsFromToken(token: SiteAPIToken): VisitorAuthClaims {
|
||||
return token.claims ?? {};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import cookies from 'js-cookie';
|
||||
|
||||
import { checkIsSecurityError } from './security-error';
|
||||
|
||||
/**
|
||||
* Get all cookies.
|
||||
* @returns A map of cookie names to their values.
|
||||
*/
|
||||
export function getAllBrowserCookies(): Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
}> {
|
||||
try {
|
||||
const all = cookies.get();
|
||||
return Object.entries(all).map(([name, value]) => ({ name, value }));
|
||||
} catch (error) {
|
||||
if (checkIsSecurityError(error)) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a map of cookie names to their values.
|
||||
* @returns A map of cookie names to their values.
|
||||
*/
|
||||
export function getAllBrowserCookiesMap(): Record<string, string> {
|
||||
return Object.fromEntries(getAllBrowserCookies().map(({ name, value }) => [name, value]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cookie by name.
|
||||
* @param name - The name of the cookie to get.
|
||||
* @returns The value of the cookie or undefined if the cookie does not exist.
|
||||
*/
|
||||
export function getBrowserCookie(name: string): string | undefined {
|
||||
try {
|
||||
return cookies.get(name);
|
||||
} catch (error) {
|
||||
if (checkIsSecurityError(error)) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a cookie.
|
||||
* @param name - The name of the cookie to set.
|
||||
* @param value - The value of the cookie to set.
|
||||
* @param options - The options for the cookie to set.
|
||||
*/
|
||||
export const setBrowserCookie = cookies.set;
|
||||
@@ -1,29 +0,0 @@
|
||||
import cookies from 'js-cookie';
|
||||
|
||||
import { checkIsSecurityError } from './security-error';
|
||||
|
||||
export function getAll(): {
|
||||
[key: string]: string;
|
||||
} {
|
||||
try {
|
||||
return cookies.get();
|
||||
} catch (error) {
|
||||
if (checkIsSecurityError(error)) {
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function get(name: string): string | undefined {
|
||||
try {
|
||||
return cookies.get(name);
|
||||
} catch (error) {
|
||||
if (checkIsSecurityError(error)) {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const set = cookies.set;
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
getDocument,
|
||||
getEmbedByUrlInSpace,
|
||||
getLatestOpenAPISpecVersionContent,
|
||||
getPublishedContentByUrl,
|
||||
getPublishedContentSite,
|
||||
getReusableContent,
|
||||
getRevision,
|
||||
@@ -73,11 +72,7 @@ export async function getV1BaseContext(): Promise<GitBookBaseContext> {
|
||||
* This data fetcher should only be used at the top of the tree.
|
||||
*/
|
||||
async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
|
||||
const apiClient = await api();
|
||||
|
||||
const dataFetcher: GitBookDataFetcher = {
|
||||
apiEndpoint: apiClient.client.endpoint,
|
||||
|
||||
async api() {
|
||||
const result = await api();
|
||||
return result.client;
|
||||
@@ -100,17 +95,6 @@ async function getDataFetcherV1(): Promise<GitBookDataFetcher> {
|
||||
});
|
||||
},
|
||||
|
||||
// @ts-ignore - types are compatible enough, and this will not be called in v1 this way
|
||||
getPublishedContentByUrl(params) {
|
||||
return wrapDataFetcherError(async () => {
|
||||
return getPublishedContentByUrl(
|
||||
params.url,
|
||||
params.visitorAuthToken ?? undefined,
|
||||
params.redirectOnError ? true : undefined
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
getPublishedContentSite(params) {
|
||||
return wrapDataFetcherError(async () => {
|
||||
return getPublishedContentSite(params);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import type { JwtPayload } from 'jwt-decode';
|
||||
import {
|
||||
@@ -11,66 +10,94 @@ import {
|
||||
|
||||
describe('getVisitorAuthToken', () => {
|
||||
it('should return the token from the query parameters', () => {
|
||||
const request = nextRequest('https://example.com?jwt_token=123');
|
||||
expect(getVisitorToken(request, request.nextUrl)).toEqual({ source: 'url', token: '123' });
|
||||
expect(
|
||||
getVisitorToken({ cookies: [], url: new URL('https://example.com?jwt_token=123') })
|
||||
).toEqual({ source: 'url', token: '123' });
|
||||
});
|
||||
|
||||
it('should return the token from the cookie root basepath', () => {
|
||||
const request = nextRequest('https://example.com', {
|
||||
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
|
||||
const visitorAuth = getVisitorToken({
|
||||
cookies: [
|
||||
{
|
||||
name: getVisitorAuthCookieName('/'),
|
||||
value: getVisitorAuthCookieValue('/', '123'),
|
||||
},
|
||||
],
|
||||
url: new URL('https://example.com'),
|
||||
});
|
||||
const visitorAuth = getVisitorToken(request, request.nextUrl);
|
||||
assertVisitorAuthCookieValue(visitorAuth);
|
||||
expect(visitorAuth.token).toEqual('123');
|
||||
});
|
||||
|
||||
it('should return the token from the cookie root basepath for a sub-path', () => {
|
||||
const request = nextRequest('https://example.com/hello/world', {
|
||||
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', '123') },
|
||||
const visitorAuth = getVisitorToken({
|
||||
cookies: [
|
||||
{
|
||||
name: getVisitorAuthCookieName('/'),
|
||||
value: getVisitorAuthCookieValue('/', '123'),
|
||||
},
|
||||
],
|
||||
url: new URL('https://example.com/hello/world'),
|
||||
});
|
||||
const visitorAuth = getVisitorToken(request, request.nextUrl);
|
||||
assertVisitorAuthCookieValue(visitorAuth);
|
||||
expect(visitorAuth.token).toEqual('123');
|
||||
});
|
||||
|
||||
it('should return the closest token from the path', () => {
|
||||
const request = nextRequest('https://example.com/hello/world', {
|
||||
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', 'no') },
|
||||
[getVisitorAuthCookieName('/hello/')]: {
|
||||
value: getVisitorAuthCookieValue('/hello/', '123'),
|
||||
},
|
||||
const visitorAuth = getVisitorToken({
|
||||
cookies: [
|
||||
{
|
||||
name: getVisitorAuthCookieName('/'),
|
||||
value: getVisitorAuthCookieValue('/', 'no'),
|
||||
},
|
||||
{
|
||||
name: getVisitorAuthCookieName('/hello/'),
|
||||
value: getVisitorAuthCookieValue('/hello/', '123'),
|
||||
},
|
||||
],
|
||||
url: new URL('https://example.com/hello/world'),
|
||||
});
|
||||
const visitorAuth = getVisitorToken(request, request.nextUrl);
|
||||
assertVisitorAuthCookieValue(visitorAuth);
|
||||
expect(visitorAuth.token).toEqual('123');
|
||||
});
|
||||
|
||||
it('should return the token from the cookie in a collection type url', () => {
|
||||
const request = nextRequest('https://example.com/hello/v/space1/cool', {
|
||||
[getVisitorAuthCookieName('/hello/v/space1/')]: {
|
||||
value: getVisitorAuthCookieValue('/hello/v/space1/', '123'),
|
||||
},
|
||||
const visitorAuth = getVisitorToken({
|
||||
cookies: [
|
||||
{
|
||||
name: getVisitorAuthCookieName('/hello/v/space1/'),
|
||||
value: getVisitorAuthCookieValue('/hello/v/space1/', '123'),
|
||||
},
|
||||
],
|
||||
url: new URL('https://example.com/hello/v/space1/cool'),
|
||||
});
|
||||
const visitorAuth = getVisitorToken(request, request.nextUrl);
|
||||
assertVisitorAuthCookieValue(visitorAuth);
|
||||
expect(visitorAuth.token).toEqual('123');
|
||||
});
|
||||
|
||||
it('should return undefined if no cookie and no query param', () => {
|
||||
const request = nextRequest('https://example.com');
|
||||
expect(getVisitorToken(request, request.nextUrl)).toBeUndefined();
|
||||
expect(
|
||||
getVisitorToken({
|
||||
cookies: [],
|
||||
url: new URL('https://example.com'),
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
// For backwards compatibility
|
||||
it('should return the token from the cookie of a /v/ path when the url does not have /v/', () => {
|
||||
const request = nextRequest('https://example.com/hello/space1/cool', {
|
||||
[getVisitorAuthCookieName('/')]: { value: getVisitorAuthCookieValue('/', 'no') },
|
||||
[getVisitorAuthCookieName('/hello/v/space1/')]: {
|
||||
value: getVisitorAuthCookieValue('/hello/v/space1/', 'gotcha'),
|
||||
},
|
||||
const visitorAuth = getVisitorToken({
|
||||
cookies: [
|
||||
{
|
||||
name: getVisitorAuthCookieName('/'),
|
||||
value: getVisitorAuthCookieValue('/', 'no'),
|
||||
},
|
||||
{
|
||||
name: getVisitorAuthCookieName('/hello/v/space1/'),
|
||||
value: getVisitorAuthCookieValue('/hello/v/space1/', 'gotcha'),
|
||||
},
|
||||
],
|
||||
url: new URL('https://example.com/hello/space1/cool'),
|
||||
});
|
||||
|
||||
const visitorAuth = getVisitorToken(request, request.nextUrl);
|
||||
assertVisitorAuthCookieValue(visitorAuth);
|
||||
expect(visitorAuth.token).toEqual('gotcha');
|
||||
});
|
||||
@@ -131,14 +158,3 @@ function assertVisitorAuthCookieValue(
|
||||
|
||||
throw new Error('Expected a VisitorAuthCookieValue');
|
||||
}
|
||||
|
||||
function nextRequest(url: string, cookies: Record<string, { value: string }> = {}) {
|
||||
const nextUrl = new URL(url);
|
||||
// @ts-ignore
|
||||
return {
|
||||
url: nextUrl.toString(),
|
||||
nextUrl,
|
||||
headers: new Headers(),
|
||||
cookies: Object.entries(cookies),
|
||||
} as NextRequest;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,11 @@ import hash from 'object-hash';
|
||||
const VISITOR_AUTH_PARAM = 'jwt_token';
|
||||
export const VISITOR_TOKEN_COOKIE = 'gitbook-visitor-token';
|
||||
|
||||
/**
|
||||
* Typing for a cookie, matching the internal type of Next.js.
|
||||
*/
|
||||
export type ResponseCookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
options?: Partial<{
|
||||
httpOnly: boolean;
|
||||
@@ -15,7 +19,8 @@ export type ResponseCookie = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ResponseCookies = Record<string, ResponseCookie>;
|
||||
export type ResponseCookies = ResponseCookie[];
|
||||
export type RequestCookies = ResponseCookies;
|
||||
|
||||
/**
|
||||
* The contents of the visitor authentication cookie.
|
||||
@@ -52,10 +57,13 @@ export type VisitorTokenLookup =
|
||||
* Get the visitor token for the request. This token can either be in the
|
||||
* query parameters or stored as a cookie.
|
||||
*/
|
||||
export function getVisitorToken(
|
||||
request: NextRequest,
|
||||
url: URL | NextRequest['nextUrl']
|
||||
): VisitorTokenLookup {
|
||||
export function getVisitorToken({
|
||||
cookies,
|
||||
url,
|
||||
}: {
|
||||
cookies: RequestCookies;
|
||||
url: URL | NextRequest['nextUrl'];
|
||||
}): VisitorTokenLookup {
|
||||
const fromUrl = url.searchParams.get(VISITOR_AUTH_PARAM);
|
||||
|
||||
// Allow the empty string to come through
|
||||
@@ -63,27 +71,26 @@ export function getVisitorToken(
|
||||
return { source: 'url', token: fromUrl };
|
||||
}
|
||||
|
||||
const visitorAuthToken = getVisitorAuthTokenFromCookies(request, url);
|
||||
const visitorAuthToken = getVisitorAuthTokenFromCookies(cookies, url);
|
||||
if (visitorAuthToken) {
|
||||
return { source: 'visitor-auth-cookie', ...visitorAuthToken };
|
||||
}
|
||||
|
||||
const visitorCustomToken = getVisitorCustomTokenFromCookies(request);
|
||||
const visitorCustomToken = getVisitorCustomTokenFromCookies(cookies);
|
||||
if (visitorCustomToken) {
|
||||
return { source: 'gitbook-visitor-cookie', token: visitorCustomToken };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the lookup result for content served with visitor auth. It basically disables caching
|
||||
* and sets a cookie with the visitor auth token.
|
||||
* Return the lookup result for content served with visitor auth.
|
||||
*/
|
||||
export function getResponseCookiesForVisitorAuth(
|
||||
basePath: string,
|
||||
visitorTokenLookup: VisitorTokenLookup
|
||||
): ResponseCookies {
|
||||
if (!visitorTokenLookup) {
|
||||
return {};
|
||||
return [];
|
||||
}
|
||||
|
||||
let decoded: JwtPayload;
|
||||
@@ -91,32 +98,35 @@ export function getResponseCookiesForVisitorAuth(
|
||||
decoded = jwtDecode(visitorTokenLookup.token);
|
||||
} catch (error) {
|
||||
console.error('Error decoding visitor token', error);
|
||||
return {};
|
||||
return [];
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* If the visitor token has been retrieve from the URL, or if its a VA cookie and the basePath is the same, set it
|
||||
* as a cookie on the response.
|
||||
*
|
||||
* Note that we do not re-store the gitbook-visitor-cookie in another cookie, to maintain a single source of truth.
|
||||
*/
|
||||
...(visitorTokenLookup?.source === 'url' ||
|
||||
/**
|
||||
* If the visitor token has been retrieve from the URL, or if its a VA cookie and the basePath is the same, set it
|
||||
* as a cookie on the response.
|
||||
*
|
||||
* Note that we do not re-store the gitbook-visitor-cookie in another cookie, to maintain a single source of truth.
|
||||
*/
|
||||
if (
|
||||
visitorTokenLookup?.source === 'url' ||
|
||||
(visitorTokenLookup?.source === 'visitor-auth-cookie' &&
|
||||
visitorTokenLookup.basePath === basePath)
|
||||
? {
|
||||
[getVisitorAuthCookieName(basePath)]: {
|
||||
value: getVisitorAuthCookieValue(basePath, visitorTokenLookup.token),
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: getVisitorAuthCookieMaxAge(decoded),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
) {
|
||||
return [
|
||||
{
|
||||
name: getVisitorAuthCookieName(basePath),
|
||||
value: getVisitorAuthCookieValue(basePath, visitorTokenLookup.token),
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: getVisitorAuthCookieMaxAge(decoded),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +190,7 @@ function getUrlBasePathCombinations(url: URL | NextRequest['nextUrl']): string[]
|
||||
* best possible match for the current URL.
|
||||
*/
|
||||
function getVisitorAuthTokenFromCookies(
|
||||
request: NextRequest,
|
||||
cookies: RequestCookies,
|
||||
url: URL | NextRequest['nextUrl']
|
||||
): VisitorAuthCookieValue | undefined {
|
||||
const urlBasePaths = getUrlBasePathCombinations(url);
|
||||
@@ -188,7 +198,7 @@ function getVisitorAuthTokenFromCookies(
|
||||
// for the content could be hosted on a base path like `/foo/v/bar` or `/foo` or just `/`
|
||||
// We keep trying to find with each of these base paths until we find a token.
|
||||
for (const basePath of urlBasePaths) {
|
||||
const found = findVisitorAuthCookieForBasePath(request, basePath);
|
||||
const found = findVisitorAuthCookieForBasePath(cookies, basePath);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
@@ -205,32 +215,27 @@ function getVisitorAuthTokenFromCookies(
|
||||
*
|
||||
* The cookie should contain as value a JWT encoded token that contains the claims of the visitor.
|
||||
*/
|
||||
function getVisitorCustomTokenFromCookies(request: NextRequest): string | undefined {
|
||||
const visitorCustomCookie = Array.from(request.cookies).find(
|
||||
([, cookie]) => cookie.name === VISITOR_TOKEN_COOKIE
|
||||
);
|
||||
return visitorCustomCookie ? visitorCustomCookie[1].value : undefined;
|
||||
function getVisitorCustomTokenFromCookies(cookies: RequestCookies): string | undefined {
|
||||
const visitorCustomCookie = cookies.find((cookie) => cookie.name === VISITOR_TOKEN_COOKIE);
|
||||
return visitorCustomCookie ? visitorCustomCookie.value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loop through all cookies and find the visitor authentication token for a given base path.
|
||||
*/
|
||||
function findVisitorAuthCookieForBasePath(
|
||||
request: NextRequest,
|
||||
cookies: RequestCookies,
|
||||
basePath: string
|
||||
): VisitorAuthCookieValue | undefined {
|
||||
return Array.from(request.cookies).reduce<VisitorAuthCookieValue | undefined>(
|
||||
(acc, [name, cookie]) => {
|
||||
if (name === getVisitorAuthCookieName(basePath)) {
|
||||
const value = JSON.parse(cookie.value) as VisitorAuthCookieValue;
|
||||
if (value.basePath === basePath) {
|
||||
acc = value;
|
||||
}
|
||||
return cookies.reduce<VisitorAuthCookieValue | undefined>((acc, cookie) => {
|
||||
if (cookie.name === getVisitorAuthCookieName(basePath)) {
|
||||
const value = JSON.parse(cookie.value) as VisitorAuthCookieValue;
|
||||
if (value.basePath === basePath) {
|
||||
acc = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
undefined
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,6 @@ import { type ContentAPITokenPayload, CustomizationThemeMode, GitBookAPI } from
|
||||
import { getURLLookupAlternatives, normalizeURL } from '@v2/lib/data';
|
||||
import assertNever from 'assert-never';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import type { ResponseCookie } from 'next/dist/compiled/@edge-runtime/cookies';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import hash from 'object-hash';
|
||||
|
||||
@@ -183,10 +182,6 @@ export async function middleware(request: NextRequest) {
|
||||
headers.set(MiddlewareHeaders.SiteURLData, JSON.stringify(resolved));
|
||||
}
|
||||
|
||||
if (resolved.visitorToken) {
|
||||
headers.set(MiddlewareHeaders.VisitorAuthToken, resolved.visitorToken);
|
||||
}
|
||||
|
||||
// For tests, we make it possible to enable search indexation
|
||||
// using a query parameter.
|
||||
const xGitBookSearchIndexation =
|
||||
@@ -395,7 +390,10 @@ async function lookupSiteInProxy(request: NextRequest, url: URL): Promise<Lookup
|
||||
* When serving multi spaces based on the current URL.
|
||||
*/
|
||||
async function lookupSiteInMultiMode(request: NextRequest, url: URL): Promise<LookupResult> {
|
||||
const visitorAuthToken = getVisitorToken(request, url);
|
||||
const visitorAuthToken = getVisitorToken({
|
||||
cookies: request.cookies.getAll(),
|
||||
url,
|
||||
});
|
||||
const lookup = await lookupSiteByAPI(url, visitorAuthToken);
|
||||
return {
|
||||
...lookup,
|
||||
@@ -525,8 +523,9 @@ async function lookupSiteOrSpaceInMultiIdMode(
|
||||
);
|
||||
}
|
||||
|
||||
const cookies: ResponseCookies = {
|
||||
[cookieName]: {
|
||||
const cookies: ResponseCookies = [
|
||||
{
|
||||
name: cookieName,
|
||||
value: encodeGitBookTokenCookie(source.id, apiToken, apiEndpoint),
|
||||
options: {
|
||||
httpOnly: true,
|
||||
@@ -535,7 +534,7 @@ async function lookupSiteOrSpaceInMultiIdMode(
|
||||
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
];
|
||||
|
||||
// Get rid of the token from the URL
|
||||
if (url.searchParams.has(AUTH_TOKEN_QUERY) || url.searchParams.has(API_ENDPOINT_QUERY)) {
|
||||
@@ -607,7 +606,10 @@ async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promis
|
||||
const target = new URL(targetStr);
|
||||
target.search = url.search;
|
||||
|
||||
const visitorAuthToken = getVisitorToken(request, target);
|
||||
const visitorAuthToken = getVisitorToken({
|
||||
cookies: request.cookies.getAll(),
|
||||
url: target,
|
||||
});
|
||||
|
||||
const lookup = await lookupSiteByAPI(target, visitorAuthToken);
|
||||
if ('error' in lookup) {
|
||||
@@ -859,18 +861,9 @@ function encodeGitBookTokenCookie(
|
||||
return JSON.stringify({ s: spaceId, t: token, e: apiEndpoint });
|
||||
}
|
||||
|
||||
function writeCookies<R extends NextResponse>(
|
||||
response: R,
|
||||
cookies: Record<
|
||||
string,
|
||||
{
|
||||
value: string;
|
||||
options?: Partial<ResponseCookie>;
|
||||
}
|
||||
> = {}
|
||||
): R {
|
||||
Object.entries(cookies).forEach(([key, { value, options }]) => {
|
||||
response.cookies.set(key, value, options);
|
||||
function writeCookies<R extends NextResponse>(response: R, cookies: ResponseCookies = []): R {
|
||||
cookies.forEach((cookie) => {
|
||||
response.cookies.set(cookie.name, cookie.value, cookie.options);
|
||||
});
|
||||
|
||||
return response;
|
||||
|
||||
Reference in New Issue
Block a user