From 6cf73e496275055938db6640a4060f372dbdd714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samy=20Pess=C3=A9?= Date: Wed, 27 Dec 2023 13:46:35 +0100 Subject: [PATCH] Support URL path to directly access a space by its ID (even private) (#76) * Handle URLs /~space/:id * Validate permissions in multi-id mode --- bun.lockb | Bin 448752 -> 448752 bytes docs/caching.md | 10 ++++-- package.json | 2 +- src/middleware.ts | 84 ++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 86 insertions(+), 10 deletions(-) diff --git a/bun.lockb b/bun.lockb index 2b4cf53acd8e80fcd95579f5db1be065cbb6b7e2..ff8211a495c78d753f87914d69bde6447cfebfd4 100755 GIT binary patch delta 160 zcmV;R0AK&`u^aHQ8;~v_osjc)?f$e=Y;$v0`r;en03=;;zGVgV>DMcu!SnXOgLsU$c#HwPe?W3{rD$`eJ{}_ND0*nT1IY!4e5yk!B|N`8*cH@YbA9~rO+dRE zz$aM%(*Bh$6r>5>&M9rj=p;LpS6H@qm5hgV3<8IB3`ltu?2=E O0W^nD*aeqR*ao2Q?n-U| delta 160 zcmV;R0AK&`u^aHQ8;~v_o7EleDzsRGds?6+o?vH)Cu_9z+Q-ag3lt4{-WVmGu}=1j zlLS^YgLsU$c#HwPe?S%mnI3r&zBAl?0=5!MRZX*wtV1Ig3G{;@AfRo>4kC4?m8~!E z508T^n*GPo2vW_y48^62<9Z%K78!9Ue)WfS3<8IB3`ltu?2=E O0W*hC*aeqR*ao12pG5Eg diff --git a/docs/caching.md b/docs/caching.md index 65c87f0e0..fee072915 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -1,18 +1,22 @@ # Caching -## Invalidate the cache +## Revalidating the cache Invalidate cache can be done at two levels using tags: - Data fetching cache - Rendering cache -To invalidate the data fetching cache, you can execute a POST request to `/.revalidate`: +To invalidate and refetch the data cache, you can execute a POST request to `/~/gitbook/revalidate`: ```bash -curl --location --request POST 'https://gitbook/mycompany.com/.revalidate' \ +curl --location --request POST 'https://gitbook/mycompany.com/~gitbook/revalidate' \ --header 'Content-Type: application/json' \ --data-raw '{"tags": ["space.id"]}' ``` To invalidate the rendering cache, the implementation mainly depends on the infrastructure serving the content, GitBook outputs a `Cache-Tag` header on every requests. The value of the header is a comma separated list of tags. + +## Purging the cache + +Purging the cache, without revalidating, is done by passing `"purge": true` in the request body. diff --git a/package.json b/package.json index 90cb96535..2b605a6c7 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@geist-ui/icons": "^1.0.2", - "@gitbook/api": "^0.23.0", + "@gitbook/api": "^0.24.0", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-popover": "^1.0.7", "@readme/openapi-parser": "^2.5.0", diff --git a/src/middleware.ts b/src/middleware.ts index b26001135..688ba7381 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,9 +1,12 @@ import { GitBookAPI } from '@gitbook/api'; +import assertNever from 'assert-never'; import { NextResponse, NextRequest } from 'next/server'; import { PublishedContentWithCache, + api, getPublishedContentByUrl, + getSpace, getSpaceContent, withAPI, } from '@/lib/api'; @@ -17,7 +20,30 @@ export const config = { const VISITOR_AUTH_PARAM = 'jwt_token'; const VISITOR_AUTH_COOKIE = 'gitbook-visitor-token'; -type URLLookupMode = 'single' | 'multi' | 'multi-path'; +const QUERY_AUTH_TOKEN = 'token'; + +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' + /** + * 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' + /** + * Spaces are located using an ID stored in the first segments of the URL (open.gitbook.com/~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'; /** * Middleware to lookup the space to render. @@ -36,7 +62,8 @@ export async function middleware(request: NextRequest) { url.searchParams.get(VISITOR_AUTH_PARAM) ?? request.cookies.get(VISITOR_AUTH_COOKIE)?.value; url.searchParams.delete(VISITOR_AUTH_PARAM); - // The API endpoint can be passed as a header + // The API endpoint can be passed as a header, making it possible to use the same GitBook Open target + // accross multiple GitBook instances. const apiEndpoint = request.headers.get('x-gitbook-api') ?? process.env.GITBOOK_API_URL; const originBasePath = request.headers.get('x-gitbook-basepath') ?? ''; @@ -141,7 +168,7 @@ function getInputURL(request: NextRequest): { url: URL; mode: URLLookupMode } { url.host = xForwardedHost; } - // When request is proxied by the GitBook infrastructure, we always force the mode as 'multi + // 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'; @@ -149,6 +176,11 @@ function getInputURL(request: NextRequest): { url: URL; mode: URLLookupMode } { url.host = xGitbookHost; } + // When request started with ~space/:id, we force the mode as 'multi-id'. + if (url.pathname.startsWith('/~space/')) { + mode = 'multi-id'; + } + return { url, mode }; } @@ -167,10 +199,11 @@ async function lookupSpaceForURL( case 'multi-path': { return await lookupSpaceInMultiPathMode(url, visitorAuthToken); } + case 'multi-id': { + return await lookupSpaceInMultiIdMode(url); + } default: - throw new Error( - `Invalid GITBOOK_MODE environment variable. It should be one of: single, multi, multipath.`, - ); + assertNever(mode); } } @@ -212,6 +245,45 @@ async function lookupSpaceInMultiMode( return lookupSpaceByAPI(url, visitorAuthToken); } +/** + * GITBOOK_MODE=multi-id + * When serving multi spaces with the ID passed in the path. + */ +async function lookupSpaceInMultiIdMode(url: URL): Promise { + // Extract the iD from the path + const pathSegments = url.pathname.slice(1).split('/'); + if (pathSegments[0] !== '~space') { + return null; + } + const spaceId = pathSegments[1]; + if (!spaceId) { + return null; + } + + // Get the auth token from the URL query + const apiToken = url.searchParams.get(QUERY_AUTH_TOKEN); + if (!apiToken) { + return null; + } + + // 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) + await withAPI( + new GitBookAPI({ + endpoint: api().endpoint, + authToken: apiToken, + }), + () => getSpace.revalidate(spaceId), + ); + + return { + space: spaceId, + basePath: `/~space/${spaceId}`, + pathname: pathSegments.slice(2).join('/'), + apiToken, + }; +} + /** * GITBOOK_MODE=multi-path * When serving multi spaces with the url passed in the path.