diff --git a/.github/composite/deploy-cloudflare/action.yaml b/.github/composite/deploy-cloudflare/action.yaml index a000568b1..346e295ac 100644 --- a/.github/composite/deploy-cloudflare/action.yaml +++ b/.github/composite/deploy-cloudflare/action.yaml @@ -70,6 +70,7 @@ runs: GITBOOK_BLOCK_SEARCH_INDEXATION: ${{ inputs.environment == 'preview' && 'true' || '' }} GITBOOK_ALLOW_CUSTOMIZATION_OVERRIDE: ${{ inputs.environment == 'preview' && 'true' || '' }} GITBOOK_DISABLE_INSIGHTS: ${{ inputs.environment == 'preview' && 'true' || '' }} + GITBOOK_DISABLE_LOOKUP_ALTERNATIVES: ${{ inputs.environment == 'staging' && 'true' || '' }} shell: bash - name: Upload the DO worker diff --git a/.github/composite/deploy-vercel/action.yaml b/.github/composite/deploy-vercel/action.yaml index 76968b3e2..4fd08e0c5 100644 --- a/.github/composite/deploy-vercel/action.yaml +++ b/.github/composite/deploy-vercel/action.yaml @@ -78,6 +78,11 @@ runs: echo "GITBOOK_DISABLE_INSIGHTS=true" >> .vercel/.env.${{ inputs.environment }}.local echo "--- .vercel/.env.${{ inputs.environment }}.local after inject ---" cat .vercel/.env.${{ inputs.environment }}.local + - name: Inject staging build env vars + if: ${{ inputs.environment == 'staging' }} + shell: bash + run: | + echo "GITBOOK_DISABLE_LOOKUP_ALTERNATIVES=true" >> .vercel/.env.${{ inputs.environment }}.local - name: Build Project Artifacts run: bun run vercel build --target=${{ inputs.environment }} --token=${{ inputs.vercelToken }} shell: bash diff --git a/packages/gitbook/src/app/~gitbook/env/route.ts b/packages/gitbook/src/app/~gitbook/env/route.ts index 80eec2e02..4d4382f4e 100644 --- a/packages/gitbook/src/app/~gitbook/env/route.ts +++ b/packages/gitbook/src/app/~gitbook/env/route.ts @@ -7,6 +7,7 @@ import { GITBOOK_APP_URL, GITBOOK_ASSETS_URL, GITBOOK_DISABLE_INSIGHTS, + GITBOOK_DISABLE_LOOKUP_ALTERNATIVES, GITBOOK_DISABLE_TRACKING, GITBOOK_FONTS_URL, GITBOOK_ICONS_URL, @@ -37,6 +38,7 @@ export async function GET(_req: NextRequest) { GITBOOK_INTEGRATIONS_CONTENT_HOST, GITBOOK_DISABLE_TRACKING, GITBOOK_DISABLE_INSIGHTS, + GITBOOK_DISABLE_LOOKUP_ALTERNATIVES, // Secret envs GITBOOK_SECRET: !!GITBOOK_SECRET, diff --git a/packages/gitbook/src/lib/data/urls.test.ts b/packages/gitbook/src/lib/data/urls.test.ts index f37230b19..72dd5f840 100644 --- a/packages/gitbook/src/lib/data/urls.test.ts +++ b/packages/gitbook/src/lib/data/urls.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'bun:test'; -import { getURLLookupAlternatives, getURLLookupPathname, normalizeURL } from './urls'; +import { + getURLLookupAlternatives, + getURLLookupPathname, + normalizeURL, + shouldBypassLookupAlternatives, +} from './urls'; describe('getURLLookupPathname', () => { const previewRoot = 'https://sites.gitbook.com/preview/site_example/section'; @@ -832,3 +837,54 @@ describe('normalizeURL with encoded paths', () => { expect(result.searchParams.get('filter')).toBe(risonValue); }); }); + +describe('getURLLookupAlternatives with bypass', () => { + it('only looks up the full URL', () => { + expect( + getURLLookupAlternatives(new URL('https://docs.mycompany.com/a/b/c'), { bypass: true }) + ).toEqual({ + revision: undefined, + changeRequest: undefined, + basePath: undefined, + urls: [{ url: 'https://docs.mycompany.com/a/b/c', extraPath: '', primary: true }], + }); + }); + + it('only looks up the full URL for a variant', () => { + expect( + getURLLookupAlternatives(new URL('https://test.gitbook.io/v/variant/space'), { + bypass: true, + }).urls + ).toEqual([ + { url: 'https://test.gitbook.io/v/variant/space', extraPath: '', primary: true }, + ]); + }); + + it.each(['revisions', 'changes'])('keeps the alternatives for %s', (kind) => { + const url = new URL(`https://docs.mycompany.com/a/~/${kind}/id/page`); + expect(getURLLookupAlternatives(url, { bypass: true })).toEqual( + getURLLookupAlternatives(url, { bypass: false }) + ); + }); +}); + +describe('shouldBypassLookupAlternatives', () => { + const bypassURLs = ['https://docs.mycompany.com/section', 'https://other.mycompany.com/']; + + it.each([ + 'https://docs.mycompany.com/section', + 'https://docs.mycompany.com/section/page', + 'https://other.mycompany.com/', + 'https://other.mycompany.com/page', + ])('matches %s', (url) => { + expect(shouldBypassLookupAlternatives(normalizeURL(new URL(url)), bypassURLs)).toBe(true); + }); + + it.each([ + 'https://docs.mycompany.com/sectionpage', + 'https://docs.mycompany.com/', + 'https://unknown.mycompany.com/section', + ])('does not match %s', (url) => { + expect(shouldBypassLookupAlternatives(normalizeURL(new URL(url)), bypassURLs)).toBe(false); + }); +}); diff --git a/packages/gitbook/src/lib/data/urls.ts b/packages/gitbook/src/lib/data/urls.ts index a238ae833..edf7cdd33 100644 --- a/packages/gitbook/src/lib/data/urls.ts +++ b/packages/gitbook/src/lib/data/urls.ts @@ -1,3 +1,4 @@ +import { GITBOOK_DISABLE_LOOKUP_ALTERNATIVES } from '../env'; import { joinPath, removeTrailingSlash } from '../paths'; import { isProxyRootRequest } from '../proxy'; import { DataFetcherError, getExposableError } from './errors'; @@ -42,6 +43,34 @@ function getContentPathSegments(pathSegments: string[]): string[] { return pathSegments; } +/** + * Site URL prefixes resolved with the full URL only, for sites where a shorter alternative + * would resolve to the wrong content. + */ +const LOOKUP_ALTERNATIVES_BYPASS_URLS: string[] = ['https://proxy.gitbook.site/sites/site_p4Xo4']; + +/** + * Whether the lookup of this (normalized) URL should skip the shorter alternatives. + */ +export function shouldBypassLookupAlternatives( + url: URL, + bypassURLs: string[] = LOOKUP_ALTERNATIVES_BYPASS_URLS +): boolean { + if (GITBOOK_DISABLE_LOOKUP_ALTERNATIVES) { + return true; + } + + return bypassURLs.some((bypassURL) => { + const prefix = normalizeURL(new URL(bypassURL)); + const prefixPath = removeTrailingSlash(prefix.pathname); + return ( + prefix.origin === url.origin && + (removeTrailingSlash(url.pathname) === prefixPath || + url.pathname.startsWith(`${prefixPath}/`)) + ); + }); +} + /** * For a given GitBook URL, return a list of alternative URLs that could be matched against to lookup the content. * The approach is optimized to aim at reusing cached lookup results as much as possible. @@ -62,8 +91,9 @@ function getContentPathSegments(pathSegments: string[]): string[] { * - Public content has a custom hostname in the organization with a variant: docs.company.com//v// * - Public content has a custom hostname in the organization with a variant and a share-link: docs.company.com///v// */ -export function getURLLookupAlternatives(input: URL) { +export function getURLLookupAlternatives(input: URL, options: { bypass?: boolean } = {}) { const url = normalizeURL(input); + const bypass = options.bypass ?? shouldBypassLookupAlternatives(url); let basePath: string | undefined = undefined; let changeRequest: string | undefined = undefined; @@ -123,6 +153,11 @@ export function getURLLookupAlternatives(input: URL) { pushAlternative(contentURL, pathSegments.slice(revisionOrChangeIdIndex + 1).join('/')); } + // Revisions and changes above still need their alternatives to extract the base path. + else if (bypass) { + pushAlternative(url, ''); + } + // URL looks like a collection url (with /v/ in the path) // We only start matching after the /v/ segment and we ignore everything before it // to avoid potentially matching as a page not found under the default space in the collection diff --git a/packages/gitbook/src/lib/env/globals.ts b/packages/gitbook/src/lib/env/globals.ts index 67883253b..739778b83 100644 --- a/packages/gitbook/src/lib/env/globals.ts +++ b/packages/gitbook/src/lib/env/globals.ts @@ -84,6 +84,12 @@ export const GITBOOK_DISABLE_TRACKING = Boolean( */ export const GITBOOK_DISABLE_INSIGHTS = process.env.GITBOOK_DISABLE_INSIGHTS === 'true'; +/** + * Whether to resolve site content with the full URL only, skipping the shorter lookup alternatives. + */ +export const GITBOOK_DISABLE_LOOKUP_ALTERNATIVES = + process.env.GITBOOK_DISABLE_LOOKUP_ALTERNATIVES === 'true'; + /** * Hostname serving the integrations. */