Skip to content

Commit c1ee8eb

Browse files
authored
Merge branch 'main' into cloudflare/fully-external-middleware
2 parents 7304e34 + 957afd9 commit c1ee8eb

26 files changed

+249
-52
lines changed

.changeset/cool-seas-approve.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook": patch
3+
---
4+
5+
Fix navigation between sections/variants when previewing a site in v2

.changeset/fast-trees-battle.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gitbook/react-openapi': patch
3+
---
4+
5+
Add authorization header for OAuth2

.changeset/gorgeous-cycles-cheat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"gitbook-v2": patch
3+
---
4+
5+
add a force-revalidate api route to force bust the cache in case of errors

.changeset/rotten-seals-rush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gitbook/react-openapi': patch
3+
---
4+
5+
Indent JSON python code sample

.changeset/stupid-plums-perform.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"gitbook": patch
3+
"gitbook-v2": patch
4+
---
5+
6+
cache fonts and static image used in OGImage in memory

.changeset/thick-chefs-repeat.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gitbook/react-openapi': patch
3+
---
4+
5+
Handle nested deprecated properties in generateSchemaExample

.changeset/violet-schools-care.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gitbook/react-openapi': patch
3+
---
4+
5+
Deduplicate path parameters from OpenAPI spec

packages/gitbook-v2/next-env.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3+
/// <reference types="next/navigation-types/compat/navigation" />
34

45
// NOTE: This file should not be edited
56
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

packages/gitbook-v2/src/lib/data/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,4 @@ export * from './pages';
44
export * from './urls';
55
export * from './errors';
66
export * from './lookup';
7-
export * from './proxy';
87
export * from './visitor';

packages/gitbook-v2/src/lib/data/visitor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { withLeadingSlash, withTrailingSlash } from '@/lib/paths';
22
import type { PublishedSiteContent } from '@gitbook/api';
3-
import { getProxyRequestIdentifier, isProxyRequest } from './proxy';
3+
import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy';
44

55
/**
66
* Get the appropriate base path for the visitor authentication cookie.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { getImageResizingContextId } from './getImageResizingContextId';
3+
4+
describe('getImageResizingContextId', () => {
5+
it('should return proxy identifier for proxy requests', () => {
6+
const proxyRequestURL = new URL('https://proxy.gitbook.site/sites/site_foo/hello/world');
7+
expect(getImageResizingContextId(proxyRequestURL)).toBe('sites/site_foo');
8+
});
9+
10+
it('should return preview identifier for preview requests', () => {
11+
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
12+
expect(getImageResizingContextId(previewRequestURL)).toBe('site_foo');
13+
});
14+
15+
it('should return host for regular requests', () => {
16+
const regularRequestURL = new URL('https://example.com/docs/foo/hello/world');
17+
expect(getImageResizingContextId(regularRequestURL)).toBe('example.com');
18+
});
19+
});

packages/gitbook-v2/src/lib/images/getImageResizingContextId.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { getProxyRequestIdentifier, isProxyRequest } from '../data';
1+
import { getPreviewRequestIdentifier, isPreviewRequest } from '@v2/lib/preview';
2+
import { getProxyRequestIdentifier, isProxyRequest } from '@v2/lib/proxy';
23

34
/**
45
* Get the site identifier to use for image resizing for an incoming request.
@@ -8,6 +9,9 @@ export function getImageResizingContextId(url: URL): string {
89
if (isProxyRequest(url)) {
910
return getProxyRequestIdentifier(url);
1011
}
12+
if (isPreviewRequest(url)) {
13+
return getPreviewRequestIdentifier(url);
14+
}
1115

1216
return url.host;
1317
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from 'bun:test';
2+
import { getPreviewRequestIdentifier, isPreviewRequest } from './preview';
3+
4+
describe('isPreviewRequest', () => {
5+
it('should return true for preview requests', () => {
6+
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
7+
expect(isPreviewRequest(previewRequestURL)).toBe(true);
8+
});
9+
10+
it('should return false for non-preview requests', () => {
11+
const nonPreviewRequestURL = new URL('https://example.com/docs/foo/hello/world');
12+
expect(isPreviewRequest(nonPreviewRequestURL)).toBe(false);
13+
});
14+
});
15+
16+
describe('getPreviewRequestIdentifier', () => {
17+
it('should return the correct identifier for preview requests', () => {
18+
const previewRequestURL = new URL('https://preview/site_foo/hello/world');
19+
expect(getPreviewRequestIdentifier(previewRequestURL)).toBe('site_foo');
20+
});
21+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* Check if the request to the site is a preview request.
3+
*/
4+
export function isPreviewRequest(requestURL: URL): boolean {
5+
return requestURL.host === 'preview';
6+
}
7+
8+
export function getPreviewRequestIdentifier(requestURL: URL): string {
9+
// For preview requests, we extract the site ID from the pathname
10+
// e.g. https://preview/site_id/...
11+
const pathname = requestURL.pathname.slice(1).split('/');
12+
return pathname[0];
13+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import crypto from 'node:crypto';
2+
import type { NextApiRequest, NextApiResponse } from 'next';
3+
4+
interface JsonBody {
5+
// The paths need to be the rewritten one, `res.revalidate` call don't go through the middleware
6+
paths: string[];
7+
}
8+
9+
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
10+
// Only allow POST requests
11+
if (req.method !== 'POST') {
12+
return res.status(405).json({ error: 'Method not allowed' });
13+
}
14+
const signatureHeader = req.headers['x-gitbook-signature'] as string | undefined;
15+
if (!signatureHeader) {
16+
return res.status(400).json({ error: 'Missing signature header' });
17+
}
18+
// We cannot use env from `@/v2/lib/env` here as it make it crash because of the import "server-only" in the file.
19+
if (process.env.GITBOOK_SECRET) {
20+
try {
21+
const computedSignature = crypto
22+
.createHmac('sha256', process.env.GITBOOK_SECRET)
23+
.update(JSON.stringify(req.body))
24+
.digest('hex');
25+
26+
if (computedSignature === signatureHeader) {
27+
const results = await Promise.allSettled(
28+
(req.body as JsonBody).paths.map((path) => {
29+
// biome-ignore lint/suspicious/noConsole: we want to log here
30+
console.log(`Revalidating path: ${path}`);
31+
return res.revalidate(path);
32+
})
33+
);
34+
return res.status(200).json({
35+
success: results.every((result) => result.status === 'fulfilled'),
36+
errors: results
37+
.filter((result) => result.status === 'rejected')
38+
.map((result) => (result as PromiseRejectedResult).reason),
39+
});
40+
}
41+
return res.status(401).json({ error: 'Invalid signature' });
42+
} catch (error) {
43+
console.error('Error during revalidation:', error);
44+
return res.status(400).json({ error: 'Invalid request or unable to parse JSON' });
45+
}
46+
}
47+
// If no secret is set, we do not allow revalidation
48+
return res.status(403).json({ error: 'Revalidation is disabled' });
49+
}

packages/gitbook/src/components/Header/SpacesDropdown.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { SiteSpace } from '@gitbook/api';
22

33
import { tcls } from '@/lib/tailwind';
44

5+
import { joinPath } from '@/lib/paths';
56
import type { GitBookSiteContext } from '@v2/lib/context';
67
import { DropdownChevron, DropdownMenu } from './DropdownMenu';
78
import { SpacesDropdownMenuItem } from './SpacesDropdownMenuItem';
@@ -74,11 +75,22 @@ export function SpacesDropdown(props: {
7475
title: otherSiteSpace.title,
7576
url: otherSiteSpace.urls.published
7677
? linker.toLinkForContent(otherSiteSpace.urls.published)
77-
: otherSiteSpace.space.urls.app,
78+
: getFallbackSiteSpaceURL(otherSiteSpace, context),
7879
}}
7980
active={otherSiteSpace.id === siteSpace.id}
8081
/>
8182
))}
8283
</DropdownMenu>
8384
);
8485
}
86+
87+
/**
88+
* When the site is not published yet, `urls.published` is not available.
89+
* To ensure navigation works in preview, we compute a relative URL from the siteSpace path.
90+
*/
91+
function getFallbackSiteSpaceURL(siteSpace: SiteSpace, context: GitBookSiteContext) {
92+
const { linker, sections } = context;
93+
return linker.toPathInSite(
94+
sections?.current ? joinPath(sections.current.path, siteSpace.path) : siteSpace.path
95+
);
96+
}

packages/gitbook/src/components/SiteSections/encodeClientSiteSections.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ function encodeSection(context: GitBookSiteContext, section: SiteSection) {
5353
description: section.description,
5454
icon: section.icon,
5555
object: section.object,
56-
url: section.urls.published ? linker.toLinkForContent(section.urls.published) : '',
56+
url: section.urls.published
57+
? linker.toLinkForContent(section.urls.published)
58+
: linker.toPathInSite(section.path),
5759
};
5860
}

packages/gitbook/src/routes/ogimage.tsx

Lines changed: 23 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { getAssetURL } from '@/lib/assets';
99
import { filterOutNullable } from '@/lib/typescript';
1010
import { getCacheTag } from '@gitbook/cache-tags';
1111
import type { GitBookSiteContext } from '@v2/lib/context';
12-
import { getCloudflareContext } from '@v2/lib/data/cloudflare';
1312
import { getResizedImageURL } from '@v2/lib/images';
1413

1514
const googleFontsMap: { [fontName in CustomizationDefaultFont]: string } = {
@@ -73,8 +72,12 @@ export async function serveOGImage(baseContext: GitBookSiteContext, params: Page
7372

7473
const fonts = (
7574
await Promise.all([
76-
loadGoogleFont({ fontFamily, text: regularText, weight: 400 }),
77-
loadGoogleFont({ fontFamily, text: boldText, weight: 700 }),
75+
getWithCache(`google-font:${fontFamily}:400`, () =>
76+
loadGoogleFont({ fontFamily, text: regularText, weight: 400 })
77+
),
78+
getWithCache(`google-font:${fontFamily}:700`, () =>
79+
loadGoogleFont({ fontFamily, text: boldText, weight: 700 })
80+
),
7881
])
7982
).filter(filterOutNullable);
8083

@@ -322,24 +325,6 @@ function logOnCloudflareOnly(message: string) {
322325
}
323326
}
324327

325-
/**
326-
* Fetch a resource from the function itself.
327-
* To avoid error with worker to worker requests in the same zone, we use the `WORKER_SELF_REFERENCE` binding.
328-
*/
329-
async function fetchSelf(url: string) {
330-
const cloudflare = getCloudflareContext();
331-
if (cloudflare?.env.WORKER_SELF_REFERENCE) {
332-
logOnCloudflareOnly(`Fetching self: ${url}`);
333-
return await cloudflare.env.WORKER_SELF_REFERENCE.fetch(
334-
// `getAssetURL` can return a relative URL, so we need to make it absolute
335-
// the URL doesn't matter, as we're using the worker-self-reference binding
336-
new URL(url, 'https://worker-self-reference/')
337-
);
338-
}
339-
340-
return await fetch(url);
341-
}
342-
343328
/**
344329
* Read an image from a response as a base64 encoded string.
345330
*/
@@ -357,27 +342,34 @@ async function readImage(response: Response) {
357342
return `data:${contentType};base64,${base64}`;
358343
}
359344

360-
const staticImagesCache = new Map<string, string>();
345+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
346+
const staticCache = new Map<string, any>();
347+
348+
// Do we need to limit the in-memory cache size? I think given the usage, we should be fine.
349+
async function getWithCache<T>(key: string, fn: () => Promise<T>) {
350+
const cached = staticCache.get(key) as T;
351+
if (cached) {
352+
return Promise.resolve(cached);
353+
}
354+
355+
const result = await fn();
356+
staticCache.set(key, result);
357+
return result;
358+
}
361359

362360
/**
363361
* Read a static image and cache it in memory.
364362
*/
365363
async function readStaticImage(url: string) {
366-
const cached = staticImagesCache.get(url);
367-
if (cached) {
368-
return cached;
369-
}
370-
371-
const image = await readSelfImage(url);
372-
staticImagesCache.set(url, image);
373-
return image;
364+
logOnCloudflareOnly(`Reading static image: ${url}, cache size: ${staticCache.size}`);
365+
return getWithCache(`static-image:${url}`, () => readSelfImage(url));
374366
}
375367

376368
/**
377369
* Read an image from GitBook itself.
378370
*/
379371
async function readSelfImage(url: string) {
380-
const response = await fetchSelf(url);
372+
const response = await fetch(url);
381373
const image = await readImage(response);
382374
return image;
383375
}

packages/react-openapi/src/OpenAPICodeSample.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,11 @@ function getSecurityHeaders(securities: OpenAPIOperationData['securities']): {
312312
[name]: 'YOUR_API_KEY',
313313
};
314314
}
315+
case 'oauth2': {
316+
return {
317+
Authorization: 'Bearer YOUR_OAUTH2_TOKEN',
318+
};
319+
}
315320
default: {
316321
return {};
317322
}

packages/react-openapi/src/OpenAPISpec.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export function OpenAPISpec(props: {
1818

1919
const { operation } = data;
2020

21-
const parameters = operation.parameters ?? [];
21+
const parameters = deduplicateParameters(operation.parameters ?? []);
2222
const parameterGroups = groupParameters(parameters, context);
2323

2424
const securities = 'securities' in data ? data.securities : [];
@@ -113,3 +113,23 @@ function getParameterGroupName(paramIn: string, context: OpenAPIClientContext):
113113
return paramIn;
114114
}
115115
}
116+
117+
/** Deduplicate parameters by name and in.
118+
* Some specs have both parameters define at path and operation level.
119+
* We only want to display one of them.
120+
*/
121+
function deduplicateParameters(parameters: OpenAPI.Parameters): OpenAPI.Parameters {
122+
const seen = new Set();
123+
124+
return parameters.filter((param) => {
125+
const key = `${param.name}:${param.in}`;
126+
127+
if (seen.has(key)) {
128+
return false;
129+
}
130+
131+
seen.add(key);
132+
133+
return true;
134+
});
135+
}

packages/react-openapi/src/code-samples.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ describe('python code sample generator', () => {
400400
const output = generator?.generate(input);
401401

402402
expect(output).toBe(
403-
'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/x-www-form-urlencoded"},\n data={"key":"value"}\n)\n\ndata = response.json()'
403+
'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/x-www-form-urlencoded"},\n data={\n "key": "value"\n }\n)\n\ndata = response.json()'
404404
);
405405
});
406406

@@ -422,7 +422,7 @@ describe('python code sample generator', () => {
422422
const output = generator?.generate(input);
423423

424424
expect(output).toBe(
425-
'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/json"},\n data=json.dumps({"key":"value","truethy":True,"falsey":False,"nullish":None})\n)\n\ndata = response.json()'
425+
'import requests\n\nresponse = requests.get(\n "https://example.com/path",\n headers={"Content-Type":"application/json"},\n data=json.dumps({\n "key": "value",\n "truethy": True,\n "falsey": False,\n "nullish": None\n })\n)\n\ndata = response.json()'
426426
);
427427
});
428428

0 commit comments

Comments
 (0)