mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-23 02:53:29 +00:00
Site Preview URLs (#4096)
This commit is contained in:
@@ -136,7 +136,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: v2-vercel
|
||||
visual-testing-v2-cloudflare:
|
||||
@@ -161,7 +160,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: v2-cloudflare
|
||||
visual-testing-customers-v2:
|
||||
@@ -186,7 +184,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: customers-v2-vercel
|
||||
visual-testing-customers-v2-cloudflare:
|
||||
@@ -211,7 +208,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-cloudflare.outputs.deployment-url }}/preview/
|
||||
ARGOS_TOKEN: ${{ secrets.ARGOS_TOKEN }}
|
||||
ARGOS_BUILD_NAME: customers-v2-cloudflare
|
||||
browserless-testing-v2-vercel:
|
||||
@@ -235,7 +231,6 @@ jobs:
|
||||
env:
|
||||
BASE_URL: ${{needs.deploy-v2-vercel.outputs.deployment-url}}
|
||||
SITE_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/url/
|
||||
SITE_PREVIEW_BASE_URL: ${{ needs.deploy-v2-vercel.outputs.deployment-url }}/preview/
|
||||
# browserless-testing-v2-cloudflare:
|
||||
# runs-on: ubuntu-latest
|
||||
# name: Browserless Testing v2 (Cloudflare)
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@changesets/cli": "^2.30.0",
|
||||
"turbo": "^2.8.13",
|
||||
"vercel": "^50.26.1",
|
||||
"turbo": "^2.8.16",
|
||||
"vercel": "^50.31.1",
|
||||
},
|
||||
},
|
||||
"packages/browser-types": {
|
||||
|
||||
@@ -16,7 +16,7 @@ import jwt from 'jsonwebtoken';
|
||||
|
||||
import { VISITOR_TOKEN_COOKIE } from '@/lib/visitors';
|
||||
|
||||
import { getSiteAPIToken } from '../tests/utils';
|
||||
import { getGitBookPreviewURL, getSiteAPIToken } from '../tests/utils';
|
||||
import {
|
||||
type Test,
|
||||
type TestsCase,
|
||||
@@ -754,6 +754,139 @@ const testCases: TestsCase[] = [
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With customization cookie',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/preview/${data.site}/?${searchParams.toString()}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||
// Trademark exists by default
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(1);
|
||||
|
||||
// Go to another page with the customization query to disable the trademark
|
||||
const pageBlocks = new URL(page.url());
|
||||
pageBlocks.pathname = `${pageBlocks.pathname.replace(/\/$/, '')}/blocks`;
|
||||
pageBlocks.search = getCustomizationURL({
|
||||
trademark: {
|
||||
enabled: false,
|
||||
},
|
||||
}).slice(1);
|
||||
await page.goto(pageBlocks.toString());
|
||||
// No trademark because customization is disabled
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Blocks' })
|
||||
).toBeVisible();
|
||||
|
||||
const pageBlocksCode = new URL(page.url());
|
||||
pageBlocksCode.pathname = `${pageBlocksCode.pathname.replace(/\/$/, '')}/code`;
|
||||
pageBlocksCode.search = '';
|
||||
await page.goto(pageBlocksCode.toString());
|
||||
// The trademark should not be visible because the cookie is still set,
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Code' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Site Previews',
|
||||
skip: process.env.ARGOS_BUILD_NAME !== 'v2-vercel',
|
||||
tests: [
|
||||
{
|
||||
name: 'Main content',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With sections',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken('https://gitbook.com/docs');
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
const sectionTabs = page.getByLabel('Sections');
|
||||
await expect(sectionTabs).toBeVisible();
|
||||
|
||||
const sectionTabLinks = sectionTabs.getByRole('link');
|
||||
for (const link of await sectionTabLinks.all()) {
|
||||
const href = await link.getAttribute('href');
|
||||
expect(href?.includes('/preview/site_p4Xo4')).toBeTruthy();
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'With customization cookie',
|
||||
url: async () => {
|
||||
const data = await getSiteAPIToken(
|
||||
'https://gitbook.gitbook.io/test-gitbook-open/'
|
||||
);
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set('token', data.apiToken);
|
||||
|
||||
return `url/${getGitBookPreviewURL(`${data.site}/?${searchParams.toString()}`)}`;
|
||||
},
|
||||
screenshot: false,
|
||||
run: async (page) => {
|
||||
await expect(page.locator('[data-testid="table-of-contents"]')).toBeVisible();
|
||||
// Trademark exists by default
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(1);
|
||||
|
||||
// Go to another page with the customization query to disable the trademark
|
||||
const pageBlocks = new URL(page.url());
|
||||
pageBlocks.pathname = `${pageBlocks.pathname.replace(/\/$/, '')}/blocks`;
|
||||
pageBlocks.search = getCustomizationURL({
|
||||
trademark: {
|
||||
enabled: false,
|
||||
},
|
||||
}).slice(1);
|
||||
await page.goto(pageBlocks.toString());
|
||||
// No trademark because customization is disabled
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Blocks' })
|
||||
).toBeVisible();
|
||||
|
||||
const pageBlocksCode = new URL(page.url());
|
||||
pageBlocksCode.pathname = `${pageBlocksCode.pathname.replace(/\/$/, '')}/code`;
|
||||
pageBlocksCode.search = '';
|
||||
await page.goto(pageBlocksCode.toString());
|
||||
// The trademark should not be visible because the cookie is still set,
|
||||
await expect(page.getByTestId('gb-trademark')).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole('heading', { level: 1, name: 'Code' })
|
||||
).toBeVisible();
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,6 +27,7 @@ const nextConfig = {
|
||||
GITBOOK_API_URL: process.env.GITBOOK_API_URL,
|
||||
GITBOOK_APP_URL: process.env.GITBOOK_APP_URL,
|
||||
GITBOOK_OAUTH_SERVER_URL: process.env.GITBOOK_OAUTH_SERVER_URL,
|
||||
GITBOOK_PREVIEW_BASE_URL: process.env.GITBOOK_PREVIEW_BASE_URL,
|
||||
GITBOOK_INTEGRATIONS_HOST: process.env.GITBOOK_INTEGRATIONS_HOST,
|
||||
GITBOOK_IMAGE_RESIZE_URL: process.env.GITBOOK_IMAGE_RESIZE_URL,
|
||||
GITBOOK_ICONS_URL: process.env.GITBOOK_ICONS_URL,
|
||||
|
||||
@@ -25,6 +25,7 @@ export function Trademark(props: {
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-testid="gb-trademark"
|
||||
target="_blank"
|
||||
variant="secondary"
|
||||
size="large"
|
||||
|
||||
@@ -45,6 +45,7 @@ export type SiteURLData = Pick<
|
||||
| 'siteBasePath'
|
||||
| 'basePath'
|
||||
| 'contextId'
|
||||
| 'preview'
|
||||
> & {
|
||||
/**
|
||||
* Identifier used for image resizing.
|
||||
|
||||
+6
@@ -64,6 +64,12 @@ export const GITBOOK_USER_AGENT = process.env.GITBOOK_USER_AGENT || 'GitBook-Ope
|
||||
export const GITBOOK_OAUTH_SERVER_URL =
|
||||
process.env.GITBOOK_OAUTH_SERVER_URL || 'https://sites.gitbook.com/oauth2/v1';
|
||||
|
||||
/**
|
||||
* Base URL for GitBook preview requests.
|
||||
*/
|
||||
export const GITBOOK_PREVIEW_BASE_URL =
|
||||
process.env.GITBOOK_PREVIEW_BASE_URL || 'https://sites.gitbook.com/preview/';
|
||||
|
||||
/**
|
||||
* Whether to disable tracking of events into site insights.
|
||||
* This is used to disable tracking in development mode.
|
||||
|
||||
@@ -1,13 +1,74 @@
|
||||
import type { PublishedSiteContent } from '@gitbook/api';
|
||||
import assertNever from 'assert-never';
|
||||
import { assert } from 'ts-essentials';
|
||||
import { GITBOOK_PREVIEW_BASE_URL } from './env';
|
||||
import type { ResponseCookie } from './visitors';
|
||||
|
||||
/**
|
||||
* Check if the request to the site is a preview request.
|
||||
*/
|
||||
export function isPreviewRequest(requestURL: URL): boolean {
|
||||
return requestURL.host === 'preview';
|
||||
// TODO: Remove the `requestURL.host === 'preview'` condition once we have fully migrated to the new preview URL structure.
|
||||
return (
|
||||
requestURL.host === 'preview' || requestURL.toString().startsWith(GITBOOK_PREVIEW_BASE_URL)
|
||||
);
|
||||
}
|
||||
|
||||
export function getPreviewRequestIdentifier(requestURL: URL): string {
|
||||
if (requestURL.toString().startsWith(GITBOOK_PREVIEW_BASE_URL)) {
|
||||
const siteIdentifier = requestURL.pathname.split('/').filter(Boolean)[1];
|
||||
assert(siteIdentifier, 'Expected site identifier in preview URL');
|
||||
return siteIdentifier;
|
||||
}
|
||||
|
||||
// TODO: Remove the `requestURL.host === 'preview'` condition once we have fully migrated to the new preview URL structure.
|
||||
// For preview requests, we extract the site ID from the pathname
|
||||
// e.g. https://preview/site_id/...
|
||||
const pathname = requestURL.pathname.slice(1).split('/');
|
||||
return pathname[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cookie for the preview request.
|
||||
*/
|
||||
export function getPreviewCookieResponse(args: {
|
||||
name: string;
|
||||
value: string;
|
||||
mode: 'url' | 'url-host';
|
||||
siteRequestURL: URL;
|
||||
siteURLData: PublishedSiteContent;
|
||||
}): ResponseCookie {
|
||||
const { name, value, mode, siteRequestURL, siteURLData } = args;
|
||||
// Only send the cookie to preview routes and scope it to the specific site
|
||||
// to avoid conflicts between different sites previews potentially opened at the same time.
|
||||
const path = (() => {
|
||||
switch (mode) {
|
||||
case 'url': {
|
||||
const gitbookPreviewBaseURL = new URL(GITBOOK_PREVIEW_BASE_URL);
|
||||
const gitbookPreviewHost =
|
||||
gitbookPreviewBaseURL.host + gitbookPreviewBaseURL.pathname.replace(/\/$/, '');
|
||||
// TODO: Remove support for 'preview' hostnames later.
|
||||
const host =
|
||||
siteRequestURL.hostname === 'preview'
|
||||
? siteRequestURL.hostname
|
||||
: gitbookPreviewHost;
|
||||
return `/url/${host}/${getPreviewRequestIdentifier(siteRequestURL)}`;
|
||||
}
|
||||
case 'url-host':
|
||||
return siteURLData.siteBasePath;
|
||||
default:
|
||||
assertNever(mode);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
name,
|
||||
value,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 10 * 60, // 10 minutes
|
||||
path,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import rison from 'rison';
|
||||
|
||||
import type { SiteURLData } from '@/lib/context';
|
||||
import { getContentSecurityPolicy } from '@/lib/csp';
|
||||
import { validateSerializedCustomization } from '@/lib/customization';
|
||||
import {
|
||||
@@ -17,7 +18,16 @@ import {
|
||||
import { GITBOOK_OAUTH_SERVER_URL, isGitBookAssetsHostURL, isGitBookHostURL } from '@/lib/env';
|
||||
import { getImageResizingContextId } from '@/lib/images';
|
||||
import { MiddlewareHeaders } from '@/lib/middleware';
|
||||
import {
|
||||
handleUnauthedOAuthProtectedResourceRequest,
|
||||
isOAuthProtectedResourceRequest,
|
||||
} from '@/lib/oauth-protected';
|
||||
import { removeLeadingSlash, removeTrailingSlash } from '@/lib/paths';
|
||||
import {
|
||||
getPreviewCookieResponse,
|
||||
getPreviewRequestIdentifier,
|
||||
isPreviewRequest,
|
||||
} from '@/lib/preview';
|
||||
import {
|
||||
type ResponseCookies,
|
||||
getPathScopedCookieName,
|
||||
@@ -28,12 +38,6 @@ import {
|
||||
} from '@/lib/visitors';
|
||||
import { serveResizedImage } from '@/routes/image';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { SiteURLData } from './lib/context';
|
||||
import {
|
||||
handleUnauthedOAuthProtectedResourceRequest,
|
||||
isOAuthProtectedResourceRequest,
|
||||
} from './lib/oauth-protected';
|
||||
import { getPreviewRequestIdentifier } from './lib/preview';
|
||||
import { serveProxyAnalyticsEvent } from './lib/tracking';
|
||||
export const config = {
|
||||
matcher: [
|
||||
@@ -340,6 +344,7 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
changeRequest: siteURLData.changeRequest,
|
||||
revision: siteURLData.revision,
|
||||
shareKey: siteURLData.shareKey,
|
||||
preview: siteURLData.preview,
|
||||
apiToken: siteURLData.apiToken,
|
||||
imagesContextId: imagesContextId,
|
||||
contextId: siteURLData.contextId,
|
||||
@@ -364,18 +369,17 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
routeType = 'dynamic';
|
||||
// We need to encode the customization headers, otherwise it will fail for some customization values containing non ASCII chars on vercel.
|
||||
requestHeaders.set(MiddlewareHeaders.Customization, encodeURIComponent(customization));
|
||||
cookies.push({
|
||||
name: MiddlewareHeaders.Customization,
|
||||
value: encodeURIComponent(customization),
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 10 * 60, // 10 minutes
|
||||
// Only send the cookie to preview routes and scope it to the specific site
|
||||
// to avoid conflicts between different sites previews potentially opened at the same time.
|
||||
path: `/url/preview/${getPreviewRequestIdentifier(siteRequestURL)}`,
|
||||
},
|
||||
});
|
||||
if (siteURLData.preview) {
|
||||
cookies.push(
|
||||
getPreviewCookieResponse({
|
||||
name: MiddlewareHeaders.Customization,
|
||||
value: encodeURIComponent(customization),
|
||||
mode,
|
||||
siteRequestURL,
|
||||
siteURLData,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
const theme =
|
||||
siteRequestURL.searchParams.get('theme') ??
|
||||
@@ -383,16 +387,17 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
if (theme === CustomizationThemeMode.Dark || theme === CustomizationThemeMode.Light) {
|
||||
routeType = 'dynamic';
|
||||
requestHeaders.set(MiddlewareHeaders.Theme, theme);
|
||||
cookies.push({
|
||||
name: MiddlewareHeaders.Theme,
|
||||
value: theme,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 10 * 60, // 10 minutes
|
||||
path: '/url/preview', // Only send the cookie to preview routes
|
||||
},
|
||||
});
|
||||
if (siteURLData.preview) {
|
||||
cookies.push(
|
||||
getPreviewCookieResponse({
|
||||
name: MiddlewareHeaders.Theme,
|
||||
value: theme,
|
||||
mode,
|
||||
siteRequestURL,
|
||||
siteURLData,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// We support forcing dynamic routes by setting a `gitbook-dynamic-route` cookie
|
||||
@@ -462,14 +467,15 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
|
||||
return writeResponseCookies(response, cookies);
|
||||
};
|
||||
|
||||
// For https://preview/<siteURL> requests,
|
||||
if (siteRequestURL.hostname === 'preview') {
|
||||
// For preview requests like:
|
||||
// - https://preview/<siteURL> requests, TODO: Remove support for this format later
|
||||
// - https://<GITBOOK_PREVIEW_BASE_URL>/<siteID> requests (ex: https://sites.gitbook.com/preview/site_id/path)
|
||||
if (isPreviewRequest(siteRequestURL)) {
|
||||
// Do not track page views for preview requests
|
||||
request.headers.set('x-gitbook-disable-tracking', 'true');
|
||||
|
||||
return serveWithQueryAPIToken(
|
||||
// We scope the API token to the site ID.
|
||||
`${siteRequestURL.hostname}/${requestURL.pathname.slice(1).split('/')[0]}`,
|
||||
['preview', getPreviewRequestIdentifier(siteRequestURL)].join('/'),
|
||||
request,
|
||||
withAPIToken
|
||||
);
|
||||
|
||||
@@ -45,6 +45,14 @@ export function getTestURL(urlRest: string): string {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the upstream GitBook preview URL for a preview route.
|
||||
*/
|
||||
export function getGitBookPreviewURL(urlRest: string): string {
|
||||
const url = new URL(urlRest, 'https://sites.gitbook.com/preview/');
|
||||
return url.href.replace(`${url.protocol}//`, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an API token for a site by its URL.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user