Skip to content

Commit 31e3810

Browse files
authored
Dynamic routing for v2: customization preview and visitor auth (#2922)
1 parent 259074b commit 31e3810

File tree

5 files changed

+118
-68
lines changed

5 files changed

+118
-68
lines changed

.github/workflows/ci.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ jobs:
224224
runs-on: ubuntu-latest
225225
name: Visual Testing
226226
needs: deploy
227-
timeout-minutes: 6
227+
timeout-minutes: 8
228228
steps:
229229
- name: Checkout
230230
uses: actions/checkout@v4
@@ -243,7 +243,7 @@ jobs:
243243
runs-on: ubuntu-latest
244244
name: Visual Testing v2
245245
needs: deploy-v2-vercel
246-
timeout-minutes: 6
246+
timeout-minutes: 8
247247
steps:
248248
- name: Checkout
249249
uses: actions/checkout@v4

packages/gitbook-v2/src/lib/middleware.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,6 @@ export enum MiddlewareHeaders {
3232
*/
3333
Customization = 'x-gitbook-customization',
3434

35-
/**
36-
* Token to use for the API.
37-
*/
38-
APIToken = 'x-gitbook-token',
39-
4035
/**
4136
* The visitor token used to access this content
4237
*/

packages/gitbook-v2/src/middleware.ts

Lines changed: 64 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
1-
import { GitBookAPIError } from '@gitbook/api';
1+
import { CustomizationThemeMode, GitBookAPIError } from '@gitbook/api';
22
import type { NextRequest } from 'next/server';
33
import { NextResponse } from 'next/server';
44

55
import { getContentSecurityPolicy } from '@/lib/csp';
6+
import { validateSerializedCustomization } from '@/lib/customization';
67
import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths';
8+
import { getResponseCookiesForVisitorAuth, getVisitorToken } from '@/lib/visitor-token';
79
import { serveResizedImage } from '@/routes/image';
810
import { getPublishedContentByURL } from '@v2/lib/data';
11+
import { GITBOOK_URL } from '@v2/lib/env';
912
import { MiddlewareHeaders } from '@v2/lib/middleware';
1013

1114
export const config = {
12-
matcher: ['/((?!_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)'],
15+
matcher: ['/((?!_next/static|_next/image).*)'],
1316
};
1417

1518
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
@@ -48,12 +51,18 @@ export async function middleware(request: NextRequest) {
4851
*/
4952
async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
5053
const { url, mode } = urlWithMode;
51-
const dynamicHeaders = getDynamicHeaders(url, request);
54+
55+
// Visitor authentication
56+
// @ts-ignore - request typing
57+
const visitorToken = getVisitorToken(request, url);
5258

5359
const result = await getPublishedContentByURL({
5460
url: url.toString(),
55-
visitorAuthToken: null,
56-
redirectOnError: false,
61+
visitorAuthToken: visitorToken?.token ?? null,
62+
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
63+
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
64+
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
65+
redirectOnError: visitorToken?.source === 'visitor-auth-cookie',
5766
});
5867

5968
if (result.error) {
@@ -66,17 +75,31 @@ async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
6675
return NextResponse.redirect(data.redirect);
6776
}
6877

69-
const routeType = dynamicHeaders ? 'dynamic' : 'static';
78+
// When visitor has authentication (adaptive content or VA), we serve dynamic routes.
79+
let routeType = visitorToken ? 'dynamic' : 'static';
7080

7181
const requestHeaders = new Headers(request.headers);
7282
requestHeaders.set(MiddlewareHeaders.RouteType, routeType);
7383
requestHeaders.set(MiddlewareHeaders.URLMode, mode);
7484
requestHeaders.set(MiddlewareHeaders.SiteURL, `${url.origin}${data.basePath}`);
7585
requestHeaders.set(MiddlewareHeaders.SiteURLData, JSON.stringify(data));
76-
if (dynamicHeaders) {
77-
for (const [key, value] of Object.entries(dynamicHeaders)) {
78-
requestHeaders.set(key, value);
79-
}
86+
87+
// Preview of customization/theme
88+
const customization = url.searchParams.get('customization');
89+
if (customization && validateSerializedCustomization(customization)) {
90+
routeType = 'dynamic';
91+
requestHeaders.set(MiddlewareHeaders.Customization, customization);
92+
}
93+
const theme = url.searchParams.get('theme');
94+
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
95+
routeType = 'dynamic';
96+
requestHeaders.set(MiddlewareHeaders.Theme, theme);
97+
}
98+
99+
// We support forcing dynamic routes by setting a `gitbook-dynamic-route` cookie
100+
// This is useful for testing dynamic routes.
101+
if (request.cookies.has('gitbook-dynamic-route')) {
102+
routeType = 'dynamic';
80103
}
81104

82105
// Pass a x-forwarded-host and origin that are equal to ensure Next doesn't block server actions when proxied
@@ -102,6 +125,17 @@ async function serveSiteByURL(request: NextRequest, urlWithMode: URLWithMode) {
102125

103126
// Add Content Security Policy header
104127
response.headers.set('content-security-policy', getContentSecurityPolicy());
128+
// Basic security headers
129+
response.headers.set('strict-transport-security', 'max-age=31536000');
130+
response.headers.set('referrer-policy', 'no-referrer-when-downgrade');
131+
response.headers.set('x-content-type-options', 'nosniff');
132+
133+
if (visitorToken) {
134+
const cookies = getResponseCookiesForVisitorAuth(data.basePath, visitorToken);
135+
for (const [key, value] of Object.entries(cookies)) {
136+
response.cookies.set(key, value.value, value.options);
137+
}
138+
}
105139

106140
return response;
107141
}
@@ -121,9 +155,13 @@ function serveErrorResponse(error: Error) {
121155
}
122156

123157
/**
124-
* The URL of the GitBook content can be passed in 3 different ways:
125-
* - The request URL is in the `X-GitBook-URL` header.
126-
* - The request URL is matching `/url/:url`
158+
* The URL of the GitBook content can be passed in 3 different ways (in order of priority):
159+
* - The request has a `X-GitBook-URL` header:
160+
* URL is taken from the header.
161+
* - The request has a `X-Forwarded-Host` header:
162+
* Host is taken from the header, pathname is taken from the request URL.
163+
* - The request URL is matching `/url/:url`:
164+
* URL is taken from the pathname.
127165
*/
128166
function extractURL(request: NextRequest): URLWithMode | null {
129167
const xGitbookUrl = request.headers.get('x-gitbook-url');
@@ -134,6 +172,19 @@ function extractURL(request: NextRequest): URLWithMode | null {
134172
};
135173
}
136174

175+
const xForwardedHost = request.headers.get('x-forwarded-host');
176+
// The x-forwarded-host is set by Vercel for all requests
177+
// so we ignore it if the hostname is the same as the instance one.
178+
if (xForwardedHost && GITBOOK_URL && new URL(GITBOOK_URL).host !== xForwardedHost) {
179+
return {
180+
url: appendQueryParams(
181+
new URL(`https://${xForwardedHost}${request.nextUrl.pathname}`),
182+
request.nextUrl.searchParams
183+
),
184+
mode: 'url-host',
185+
};
186+
}
187+
137188
const prefix = '/url/';
138189
if (request.nextUrl.pathname.startsWith(prefix)) {
139190
return {
@@ -148,17 +199,6 @@ function extractURL(request: NextRequest): URLWithMode | null {
148199
return null;
149200
}
150201

151-
/**
152-
* Evaluate if a request is dynamic or static.
153-
*/
154-
function getDynamicHeaders(_url: URL, _request: NextRequest): null | Record<string, string> {
155-
// TODO:
156-
// - check token in query string
157-
// - check token in cookies
158-
// - check special headers or query string
159-
return null;
160-
}
161-
162202
/**
163203
* Encode path in a site content.
164204
* Special paths are not encoded and passed to be handled by the route handlers.

packages/gitbook/src/lib/visitor-token.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@ const VISITOR_AUTH_PARAM = 'jwt_token';
55
const VISITOR_AUTH_COOKIE_ROOT = 'gitbook-visitor-token~';
66
export const VISITOR_TOKEN_COOKIE = 'gitbook-visitor-token';
77

8+
export type ResponseCookie = {
9+
value: string;
10+
options?: Partial<{
11+
httpOnly: boolean;
12+
sameSite: boolean | 'lax' | 'strict' | 'none' | undefined;
13+
secure: boolean;
14+
maxAge: number;
15+
}>;
16+
};
17+
18+
export type ResponseCookies = Record<string, ResponseCookie>;
19+
820
/**
921
* The contents of the visitor authentication cookie.
1022
*/
@@ -62,6 +74,39 @@ export function getVisitorToken(
6274
}
6375
}
6476

77+
/**
78+
* Return the lookup result for content served with visitor auth. It basically disables caching
79+
* and sets a cookie with the visitor auth token.
80+
*/
81+
export function getResponseCookiesForVisitorAuth(
82+
basePath: string,
83+
visitorTokenLookup: VisitorTokenLookup
84+
): ResponseCookies {
85+
return {
86+
/**
87+
* If the visitor token has been retrieve from the URL, or if its a VA cookie and the basePath is the same, set it
88+
* as a cookie on the response.
89+
*
90+
* Note that we do not re-store the gitbook-visitor-cookie in another cookie, to maintain a single source of truth.
91+
*/
92+
...(visitorTokenLookup?.source === 'url' ||
93+
(visitorTokenLookup?.source === 'visitor-auth-cookie' &&
94+
visitorTokenLookup.basePath === basePath)
95+
? {
96+
[getVisitorAuthCookieName(basePath)]: {
97+
value: getVisitorAuthCookieValue(basePath, visitorTokenLookup.token),
98+
options: {
99+
httpOnly: true,
100+
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
101+
secure: process.env.NODE_ENV === 'production',
102+
maxAge: 7 * 24 * 60 * 60,
103+
},
104+
},
105+
}
106+
: {}),
107+
};
108+
}
109+
65110
/**
66111
* Get the name of the visitor authentication cookie for a given base path.
67112
*

packages/gitbook/src/middleware.ts

Lines changed: 7 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@ import { getContentSecurityPolicy } from '@/lib/csp';
2323
import { validateSerializedCustomization } from '@/lib/customization';
2424
import { setMiddlewareHeader } from '@/lib/middleware';
2525
import {
26+
type ResponseCookies,
2627
type VisitorTokenLookup,
27-
getVisitorAuthCookieName,
28-
getVisitorAuthCookieValue,
28+
getResponseCookiesForVisitorAuth,
2929
getVisitorToken,
3030
normalizeVisitorAuthURL,
3131
} from '@/lib/visitor-token';
@@ -69,19 +69,11 @@ type URLLookupMode =
6969
*/
7070
| 'multi-id';
7171

72-
export type LookupCookies = Record<
73-
string,
74-
{
75-
value: string;
76-
options?: Partial<ResponseCookie>;
77-
}
78-
>;
79-
8072
export type LookupResult = PublishedContentWithCache & {
8173
/** API endpoint to use for the content post lookup */
8274
apiEndpoint?: string;
8375
/** Cookies to store on the response */
84-
cookies?: LookupCookies;
76+
cookies?: ResponseCookies;
8577
/** Visitor authentication token */
8678
visitorToken?: string;
8779
/** URL of the site */
@@ -229,12 +221,12 @@ export async function middleware(request: NextRequest) {
229221

230222
const customization = url.searchParams.get('customization');
231223
if (customization && validateSerializedCustomization(customization)) {
232-
headers.set('x-gitbook-customization', customization);
224+
headers.set(MiddlewareHeaders.Customization, customization);
233225
}
234226

235227
const theme = url.searchParams.get('theme');
236228
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
237-
headers.set('x-gitbook-theme', theme);
229+
headers.set(MiddlewareHeaders.Theme, theme);
238230
}
239231

240232
if (apiEndpoint) {
@@ -551,7 +543,7 @@ async function lookupSiteOrSpaceInMultiIdMode(
551543
);
552544
}
553545

554-
const cookies: LookupCookies = {
546+
const cookies: ResponseCookies = {
555547
[cookieName]: {
556548
value: encodeGitBookTokenCookie(source.id, apiToken, apiEndpoint),
557549
options: {
@@ -798,29 +790,7 @@ function getLookupResultForVisitorAuth(
798790
// No caching for content served with visitor auth
799791
cacheMaxAge: undefined,
800792
cacheTags: [],
801-
cookies: {
802-
/**
803-
* If the visitor token has been retrieve from the URL, or if its a VA cookie and the basePath is the same, set it
804-
* as a cookie on the response.
805-
*
806-
* Note that we do not re-store the gitbook-visitor-cookie in another cookie, to maintain a single source of truth.
807-
*/
808-
...(visitorTokenLookup?.source === 'url' ||
809-
(visitorTokenLookup?.source === 'visitor-auth-cookie' &&
810-
visitorTokenLookup.basePath === basePath)
811-
? {
812-
[getVisitorAuthCookieName(basePath)]: {
813-
value: getVisitorAuthCookieValue(basePath, visitorTokenLookup.token),
814-
options: {
815-
httpOnly: true,
816-
sameSite: process.env.NODE_ENV === 'production' ? 'none' : undefined,
817-
secure: process.env.NODE_ENV === 'production',
818-
maxAge: 7 * 24 * 60 * 60,
819-
},
820-
},
821-
}
822-
: {}),
823-
},
793+
cookies: getResponseCookiesForVisitorAuth(basePath, visitorTokenLookup),
824794
};
825795
}
826796

0 commit comments

Comments
 (0)