Files
gitbook/packages/gitbook/src/middleware.ts
T
2025-03-19 20:11:44 +01:00

878 lines
30 KiB
TypeScript

import { type ContentAPITokenPayload, CustomizationThemeMode, GitBookAPI } from '@gitbook/api';
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';
import {
DEFAULT_API_ENDPOINT,
type PublishedContentWithCache,
api,
getPublishedContentByUrl,
getPublishedContentSite,
getSpace,
userAgent,
withAPI,
} from '@/lib/api';
import { race } from '@/lib/async';
import { buildVersion } from '@/lib/build';
import { getContentSecurityPolicy } from '@/lib/csp';
import { validateSerializedCustomization } from '@/lib/customization';
import { setMiddlewareHeader } from '@/lib/middleware';
import {
type ResponseCookies,
type VisitorTokenLookup,
getResponseCookiesForVisitorAuth,
getVisitorToken,
normalizeVisitorAuthURL,
} from '@/lib/visitor-token';
import { joinPath, normalizePathname } from '@/lib/paths';
import { getProxyModeBasePath } from '@/lib/proxy';
import { MiddlewareHeaders } from '@v2/lib/middleware';
import { addResponseCacheTag } from './lib/cache/response';
export const config = {
matcher:
'/((?!_next/static|_next/image|~gitbook/revalidate|~gitbook/image|~gitbook/monitoring|~gitbook/static|~scalar/proxy).*)',
skipTrailingSlashRedirect: true,
};
type URLLookupMode =
/**
* Only a single space is served on this instance, defined by the env GITBOOK_SPACE_ID.
* This mode is useful when self-hosting a single space.
*/
| 'single'
/**
* Mode when a site is being proxied on a different base URL.
* - x-gitbook-site-url is used to determine the site to serve.
* - host / x-forwarded-host / x-gitbook-host + x-gitbook-basepath is used to determine the base URL.
*/
| 'proxy'
/**
* Spaces are located using the incoming URL (using forwarded host headers).
* This mode is the default one when serving on the GitBook infrastructure.
*/
| 'multi'
/**
* Spaces are located using the first segments of the url (open.gitbook.com/docs.mycompany.com).
* This mode is the default one when developing.
*/
| 'multi-path'
/**
* Sites or Spaces are located using an ID stored in the first segments of the URL (open.gitbook.com/~site|~space/:id/)
* This mode is automatically detected and doesn't need to be configured.
* When this mode is used, an authentication token should be passed as a query parameter (`token`).
*/
| 'multi-id';
export type LookupResult = PublishedContentWithCache & {
/** API endpoint to use for the content post lookup */
apiEndpoint?: string;
/** Cookies to store on the response */
cookies?: ResponseCookies;
/** Visitor authentication token */
visitorToken?: string;
/** URL of the site */
siteURL?: string;
};
/**
* Middleware to lookup the site to render.
* It takes as input a request with an URL, and a set of headers:
* - x-gitbook-api: the API endpoint to use, if undefined, the default one is used
* - x-gitbook-basepath: base in the path that should be ignored for routing
*
* Once the site has been looked-up, the middleware passes the info to the rendering
* using a rewrite with a set of headers. This is the only way in next.js to do this (basically similar to AsyncLocalStorage).
*
* The middleware also takes care of persisting the visitor authentication state.
*/
export async function middleware(request: NextRequest) {
const { url, mode } = getInputURL(request);
const isServerAction = request.headers.has('Next-Action');
// Redirect to normalize the URL
const normalized = normalizeURL(url);
if (normalized.toString() !== url.toString()) {
return NextResponse.redirect(normalized.toString());
}
// The API endpoint can be passed as a header, making it possible to use the same GitBook Open target
// accross multiple GitBook instances.
let apiEndpoint = request.headers.get('x-gitbook-api') ?? DEFAULT_API_ENDPOINT;
const originBasePath = request.headers.get('x-gitbook-basepath') ?? '';
const inputURL = mode === 'proxy' ? url : stripURLBasePath(url, originBasePath);
const resolved = await withAPI(
{
client: new GitBookAPI({
endpoint: apiEndpoint,
authToken: getDefaultAPIToken(apiEndpoint),
userAgent: userAgent(),
}),
contextId: undefined,
},
() => lookupSiteForURL(mode, request, inputURL)
);
if ('error' in resolved) {
return new NextResponse(resolved.error.message, {
status: resolved.error.code,
headers: {
'x-gitbook-version': buildVersion(),
},
});
}
if ('redirect' in resolved) {
return writeCookies(NextResponse.redirect(resolved.redirect), resolved.cookies);
}
// Make sure the URL is clean of any va token after a successful lookup
// The token is stored in a cookie that is set on the redirect response
const normalizedVA = normalizeVisitorAuthURL(normalized);
if (normalizedVA.toString() !== normalized.toString()) {
return writeCookies(NextResponse.redirect(normalizedVA.toString()), resolved.cookies);
}
// Because of how Next will encode, we need to encode ourselves the pathname before rewriting to it.
const rewritePathname = normalizePathname(encodePathname(resolved.pathname));
// Resolution might have changed the API endpoint
apiEndpoint = resolved.apiEndpoint ?? apiEndpoint;
const contextId = 'site' in resolved ? resolved.contextId : undefined;
const csp = getContentSecurityPolicy();
const headers = new Headers(request.headers);
headers.set('content-security-policy', csp);
// Pass a x-forwarded-host and origin to ensure Next doesn't block server actions when proxied
headers.set('x-forwarded-host', inputURL.host);
headers.set('origin', inputURL.origin);
headers.set('x-gitbook-token', resolved.apiToken);
if (contextId) {
headers.set('x-gitbook-token-context', contextId);
}
headers.set('x-gitbook-mode', mode);
headers.set('x-gitbook-origin-basepath', originBasePath);
headers.set(
'x-gitbook-basepath',
mode === 'proxy'
? getProxyModeBasePath(inputURL, resolved)
: joinPath(originBasePath, resolved.basePath)
);
headers.set('x-gitbook-content-space', resolved.space);
if ('site' in resolved) {
headers.set('x-gitbook-content-organization', resolved.organization);
headers.set('x-gitbook-content-site', resolved.site);
if (resolved.siteSection) {
headers.set('x-gitbook-content-site-section', resolved.siteSection);
}
if (resolved.siteSpace) {
headers.set('x-gitbook-content-site-space', resolved.siteSpace);
}
if (resolved.shareKey) {
headers.set('x-gitbook-content-site-share-key', resolved.shareKey);
}
// For server actions that use v2 code
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 =
headers.get('x-gitbook-search-indexation') ??
url.searchParams.has('x-gitbook-search-indexation');
if (xGitBookSearchIndexation) {
headers.set('x-gitbook-search-indexation', 'true');
}
if (resolved.revision) {
headers.set('x-gitbook-content-revision', resolved.revision);
}
if (resolved.changeRequest) {
headers.set('x-gitbook-content-changerequest', resolved.changeRequest);
}
const customization = url.searchParams.get('customization');
if (customization && validateSerializedCustomization(customization)) {
headers.set(MiddlewareHeaders.Customization, customization);
}
const theme = url.searchParams.get('theme');
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
headers.set(MiddlewareHeaders.Theme, theme);
}
if (apiEndpoint) {
headers.set('x-gitbook-api', apiEndpoint);
}
const target = new URL(joinPath('/middleware', rewritePathname), request.nextUrl.toString());
target.search = url.search;
const response = writeCookies(
NextResponse.rewrite(target, {
request: {
headers,
},
}),
// A long-standing bug in Nextjs causes modifying cookies in Server Actions to refresh the page and cause root rerenders.
// https://github.com/vercel/next.js/issues/50163
// We don't set the cookies if we're in a server action.
isServerAction ? undefined : resolved.cookies
);
// Add method so Cloudflare can use it for caching
setMiddlewareHeader(response, 'x-http-method', request.method);
setMiddlewareHeader(response, 'x-gitbook-version', buildVersion());
// Add Content Security Policy header
setMiddlewareHeader(response, 'content-security-policy', csp);
// Basic security headers
setMiddlewareHeader(response, 'strict-transport-security', 'max-age=31536000');
setMiddlewareHeader(response, 'referrer-policy', 'no-referrer-when-downgrade');
setMiddlewareHeader(response, 'x-content-type-options', 'nosniff');
const cacheControl = (() => {
// For Server Actions, we don't want to cache the response on the server.
// We don't want to store responses either.
if (isServerAction) {
return 'no-cache, no-store';
}
// When the request is authenticated, we don't want to cache the response on the server.
// Allow storing so that revalidation still happens with server.
if (resolved.visitorToken) {
return 'no-cache, no-store';
}
if (typeof resolved.cacheMaxAge === 'number') {
return `public, max-age=0, s-maxage=${resolved.cacheMaxAge}, stale-if-error=0`;
}
})();
if (cacheControl) {
if (process.env.GITBOOK_OUTPUT_CACHE === 'true' && process.env.NODE_ENV !== 'development') {
setMiddlewareHeader(response, 'cache-control', cacheControl);
setMiddlewareHeader(response, 'Cloudflare-CDN-Cache-Control', cacheControl);
} else {
setMiddlewareHeader(response, 'x-gitbook-cache-control', cacheControl);
}
}
if (resolved.cacheTags) {
addResponseCacheTag(...resolved.cacheTags);
}
return response;
}
/**
* Compute the input URL the user is trying to access.
*/
function getInputURL(request: NextRequest): {
url: URL;
mode: URLLookupMode;
} {
const url = new URL(request.url);
let mode: URLLookupMode =
(process.env.GITBOOK_MODE as URLLookupMode | undefined) ?? 'multi-path';
// When developing locally using something.localhost:3000, the url only contains http://localhost:3000
if (url.hostname === 'localhost') {
url.host = request.headers.get('host') ?? url.hostname;
}
// When request is proxied, the host is passed in the x-forwarded-host header
const xForwardedHost = request.headers.get('x-forwarded-host');
if (xForwardedHost) {
url.port = '';
url.host = xForwardedHost;
}
// When request is proxied by the GitBook infrastructure, we always force the mode as 'multi'.
const xGitbookHost = request.headers.get('x-gitbook-host');
if (xGitbookHost) {
mode = 'multi';
url.port = '';
url.host = xGitbookHost;
}
// When request started with ~space/:id or ~site/:id, we force the mode as 'multi-id'.
if (url.pathname.startsWith('/~space/') || url.pathname.startsWith('/~site/')) {
mode = 'multi-id';
}
// When passing a x-gitbook-site-url header, this URL is used instead of the request URL
// to determine the site to serve.
const xGitbookSite = request.headers.get('x-gitbook-site-url');
if (xGitbookSite) {
return {
mode: 'proxy',
url: new URL(xGitbookSite),
};
}
return { url, mode };
}
async function lookupSiteForURL(
mode: URLLookupMode,
request: NextRequest,
url: URL
): Promise<LookupResult> {
switch (mode) {
case 'single': {
return await lookupSiteInSingleMode(url);
}
case 'multi': {
return await lookupSiteInMultiMode(request, url);
}
case 'multi-path': {
return await lookupSiteInMultiPathMode(request, url);
}
case 'multi-id': {
return await lookupSiteOrSpaceInMultiIdMode(request, url);
}
case 'proxy':
return await lookupSiteInProxy(request, url);
default:
assertNever(mode);
}
}
/**
* GITBOOK_MODE=single
* When serving a single space, configured using GITBOOK_SPACE_ID and GITBOOK_TOKEN.
*/
async function lookupSiteInSingleMode(url: URL): Promise<LookupResult> {
const spaceId = process.env.GITBOOK_SPACE_ID;
if (!spaceId) {
throw new Error(
'Missing GITBOOK_SPACE_ID environment variable. It should be passed when using GITBOOK_MODE=single.'
);
}
const apiCtx = await api();
const apiToken = getDefaultAPIToken(apiCtx.client.endpoint);
if (!apiToken) {
throw new Error(
'Missing GITBOOK_TOKEN environment variable. It should be passed when using GITBOOK_MODE=single.'
);
}
return {
kind: 'space',
space: spaceId,
basePath: '',
pathname: url.pathname,
apiToken,
visitorToken: undefined,
};
}
/**
* GITBOOK_MODE=proxy
* When proxying a site on a different base URL.
*/
async function lookupSiteInProxy(request: NextRequest, url: URL): Promise<LookupResult> {
return await lookupSiteInMultiMode(request, url);
}
/**
* GITBOOK_MODE=multi
* When serving multi spaces based on the current URL.
*/
async function lookupSiteInMultiMode(request: NextRequest, url: URL): Promise<LookupResult> {
const visitorAuthToken = getVisitorToken(request, url);
const lookup = await lookupSiteByAPI(url, visitorAuthToken);
return {
...lookup,
...('basePath' in lookup && visitorAuthToken
? getLookupResultForVisitorAuth(lookup.basePath, visitorAuthToken)
: {}),
visitorToken: visitorAuthToken?.token,
};
}
/**
* GITBOOK_MODE=multi-id
* When serving multi spaces with the ID passed in the path.
*
* The format of the path is:
* - /~space|~site/:id/:path
* - /~space|~site/:id/~/changes/:changeId/:path
* - /~space|~site/:id/~/revisions/:revisionId/:path
*/
async function lookupSiteOrSpaceInMultiIdMode(
request: NextRequest,
url: URL
): Promise<LookupResult> {
const basePathParts: string[] = [];
const pathSegments = url.pathname.slice(1).split('/');
const eatPathId = (...prefixes: string[]): string | undefined => {
const match = prefixes.every((prefix, index) => pathSegments[index] === prefix);
if (!match || pathSegments.length < prefixes.length + 1) {
return;
}
// Remove the prefix from the path segments
pathSegments.splice(0, prefixes.length);
// Add the prefix to the base path
basePathParts.push(...prefixes);
const id = pathSegments.shift();
basePathParts.push(id!);
return id;
};
const spaceId = eatPathId('~space');
const siteId = eatPathId('~site');
const source: { kind: 'space' | 'site'; id: string } | undefined = spaceId
? { kind: 'space', id: spaceId }
: siteId
? { kind: 'site', id: siteId }
: undefined;
if (!source) {
return {
error: {
code: 400,
message: 'Missing site or space ID in the path',
},
};
}
// Extract the change request or revision ID from the path
const changeRequestId = eatPathId('~', 'changes');
const revisionId = eatPathId('~', 'revisions');
// Get the auth token from the URL query
const AUTH_TOKEN_QUERY = 'token';
const API_ENDPOINT_QUERY = 'api';
const cookieName = `gitbook-token-${source.id}`;
const { apiToken, apiEndpoint } = url.searchParams.has(AUTH_TOKEN_QUERY)
? {
apiToken: url.searchParams.get(AUTH_TOKEN_QUERY) ?? '',
apiEndpoint: url.searchParams.get(API_ENDPOINT_QUERY) ?? undefined,
}
: (decodeGitBookTokenCookie(source.id, request.cookies.get(cookieName)?.value) ?? {
apiToken: undefined,
apiEndpoint: undefined,
});
if (!apiToken) {
return {
error: {
code: 400,
message: 'Missing token query parameter',
},
};
}
const decoded = jwt.decode(apiToken) as ContentAPITokenPayload;
if (decoded.kind === 'collection') {
throw new Error('Collection is not supported in multi-id mode');
}
// The claims property in the content API token is included when
// visitor attributes/assertions are passed to the site preview URL.
//
// When it's present, we generate a hash using the same method as
// getPublishedContentByURL to get the context ID so the cache can be
// 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,
authToken: apiToken,
userAgent: userAgent(),
});
// Verify access to the space to avoid leaking cached data in this mode
// (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)
);
}
// Verify access to the site to avoid leaking cached data in this mode
// (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({
organizationId: decoded.organization,
siteId: source.id,
siteShareKey: undefined,
})
);
}
const cookies: ResponseCookies = {
[cookieName]: {
value: encodeGitBookTokenCookie(source.id, apiToken, apiEndpoint),
options: {
httpOnly: true,
maxAge: 60 * 30,
secure: process.env.NODE_ENV === 'production',
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)) {
const withoutToken = new URL(url);
withoutToken.searchParams.delete(AUTH_TOKEN_QUERY);
withoutToken.searchParams.delete(API_ENDPOINT_QUERY);
return {
target: 'external',
redirect: withoutToken.toString(),
cookies,
};
}
const basePath = normalizePathname(basePathParts.join('/'));
return {
// In multi-id mode, complete is always considered true because there is no URL to resolve
...(decoded.kind === 'site' ? { ...decoded, complete: true } : decoded),
changeRequest: changeRequestId,
revision: revisionId,
siteBasePath: basePath,
basePath,
pathname: normalizePathname(pathSegments.join('/')),
apiToken,
apiEndpoint,
contextId,
cookies,
};
}
/**
* GITBOOK_MODE=multi-path
* When serving multi spaces with the url passed in the path.
*/
async function lookupSiteInMultiPathMode(request: NextRequest, url: URL): Promise<LookupResult> {
// Skip useless requests
if (
url.pathname === '/favicon.ico' ||
url.pathname === '/robots.txt' ||
url.pathname === '/sitemap.xml'
) {
return {
error: {
code: 404,
message: 'favicon.ico, robots.txt, sitemap.xml should be accessed under a content',
},
};
}
// Only match something that starts with a domain like
if (!url.pathname.match(/^.+\..+/)) {
return {
error: {
code: 404,
message: 'Invalid URL in the path, should start with a domain',
},
};
}
const targetStr = `https://${url.pathname}`;
if (!URL.canParse(targetStr)) {
return {
error: {
code: 404,
message: 'Invalid URL in the path',
},
};
}
const target = new URL(targetStr);
target.search = url.search;
const visitorAuthToken = getVisitorToken(request, target);
const lookup = await lookupSiteByAPI(target, visitorAuthToken);
if ('error' in lookup) {
return lookup;
}
if ('redirect' in lookup) {
if (lookup.target === 'content') {
// Redirect to the content URL in the same application
const redirect = new URL(lookup.redirect);
redirect.search = url.search;
return {
target: 'content',
redirect: new URL(
`/${redirect.hostname}${redirect.pathname}${redirect.search}`,
url
).toString(),
};
}
return lookup;
}
return {
...lookup,
basePath: joinPath(target.host, lookup.basePath),
...('basePath' in lookup && visitorAuthToken
? getLookupResultForVisitorAuth(lookup.basePath, visitorAuthToken)
: {}),
visitorToken: visitorAuthToken?.token,
};
}
/**
* Lookup a space by its URL using the GitBook API.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
async function lookupSiteByAPI(
lookupURL: URL,
visitorTokenLookup: VisitorTokenLookup
): Promise<LookupResult> {
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
// When the visitor auth token is pulled from the cookie, set redirectOnError when calling getPublishedContentByUrl to allow
// redirecting when the token is invalid as we could be dealing with stale token stored in the cookie.
// For example when the VA backend signature has changed but the token stored in the cookie is not yet expired.
const redirectOnError = visitorTokenLookup?.source === 'visitor-auth-cookie';
const result = await race(lookup.urls, async (alternative, { signal }) => {
const data = await getPublishedContentByUrl(
alternative.url,
visitorTokenLookup?.token,
redirectOnError || undefined,
{
signal,
}
);
if ('error' in data) {
if (alternative.primary) {
// We only return an error for the primary alternative (full URL),
// as other parts could result in errors due to the URL being incomplete (share links, etc).
return data;
}
return null;
}
if ('redirect' in data) {
if (alternative.primary) {
// Append the path to the redirect URL
// because we might have matched a shorter path and the redirect is relative to it
if (alternative.extraPath) {
if (data.target === 'content') {
const redirect = new URL(data.redirect);
redirect.pathname = joinPath(redirect.pathname, alternative.extraPath);
data.redirect = redirect.toString();
} else {
const redirect = new URL(data.redirect);
if (redirect.searchParams.has('location')) {
redirect.searchParams.set(
'location',
joinPath(
redirect.searchParams.get('location') ?? '',
alternative.extraPath
)
);
data.redirect = redirect.toString();
}
}
}
return data;
}
return null;
}
/**
* We use the following criteria to determine if the lookup result is the right one:
* - the primary alternative was resolved (because that's the longest or most inclusive path)
* - the resolution of the site URL is complete (because we want to resolve the deepest path possible)
*
* 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;
return {
space: data.space,
changeRequest,
revision: data.revision ?? lookup.revision,
basePath: joinPath(data.basePath, lookup.basePath ?? ''),
pathname: joinPath(data.pathname, alternative.extraPath),
apiToken: data.apiToken,
// We don't cache change requests as they often change and we want to have consistent previews
// Purging the CDN cache will not be efficient enough.
cacheMaxAge: changeRequest ? 0 : data.cacheMaxAge,
cacheTags: data.cacheTags,
...('site' in data
? {
site: data.site,
siteSection: data.siteSection,
siteSpace: data.siteSpace,
organization: data.organization,
shareKey: data.shareKey,
...(data.contextId ? { contextId: data.contextId } : {}),
}
: {}),
} as PublishedContentWithCache;
}
return null;
});
if (result) {
if ('site' in result) {
return {
...result,
siteURL: `${lookupURL.origin}${result.basePath}`,
};
}
return result;
}
return {
error: {
code: 404,
message: 'No content found',
},
};
}
/**
* Return the lookup result for content served with visitor auth. It basically disables caching
* and sets a cookie with the visitor auth token.
*/
function getLookupResultForVisitorAuth(
basePath: string,
visitorTokenLookup: VisitorTokenLookup
): Partial<LookupResult> {
return {
// No caching for content served with visitor auth
cacheMaxAge: undefined,
cacheTags: [],
cookies: getResponseCookiesForVisitorAuth(basePath, visitorTokenLookup),
};
}
/**
* Get the default API token for an API endpoint.
* The default token is configured globally in the instance using `GITBOOK_TOKEN`,
* but it's scoped to the default API endpoint.
*/
function getDefaultAPIToken(apiEndpoint: string): string | undefined {
const defaultToken = process.env.GITBOOK_TOKEN;
if (!defaultToken) {
return;
}
if (apiEndpoint !== DEFAULT_API_ENDPOINT) {
// The default token is only used for the default API endpoint
return;
}
return defaultToken;
}
function stripBasePath(pathname: string, basePath: string): string {
if (basePath === '') {
return pathname;
}
if (!pathname.startsWith(basePath)) {
throw new Error(`Invalid pathname ${pathname} for basePath ${basePath}`);
}
pathname = pathname.slice(basePath.length);
if (!pathname.startsWith('/')) {
pathname = `/${pathname}`;
}
return pathname;
}
function stripURLBasePath(url: URL, basePath: string): URL {
const stripped = new URL(url.toString());
stripped.pathname = stripBasePath(stripped.pathname, basePath);
return stripped;
}
function stripURLSearch(url: URL): URL {
const stripped = new URL(url.toString());
stripped.search = '';
return stripped;
}
function encodePathname(pathname: string): string {
return pathname.split('/').map(encodeURIComponent).join('/');
}
function decodeGitBookTokenCookie(
sourceId: string,
cookie: string | undefined
): { apiToken: string; apiEndpoint: string | undefined } | undefined {
if (!cookie) {
return;
}
try {
const parsed = JSON.parse(cookie);
if (typeof parsed.t === 'string' && parsed.s === sourceId) {
return {
apiToken: parsed.t,
apiEndpoint: typeof parsed.e === 'string' ? parsed.e : undefined,
};
}
} catch (_error) {
// ignore
}
}
function encodeGitBookTokenCookie(
spaceId: string,
token: string,
apiEndpoint: string | undefined
): string {
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);
});
return response;
}