diff --git a/.changeset/non-ascii-missing-page-404.md b/.changeset/non-ascii-missing-page-404.md new file mode 100644 index 000000000..394e896da --- /dev/null +++ b/.changeset/non-ascii-missing-page-404.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Missing pages with accented or non-Latin characters in the URL now return 404 instead of an endless redirect. diff --git a/packages/gitbook/src/components/SitePage/SitePage.tsx b/packages/gitbook/src/components/SitePage/SitePage.tsx index 37c5fd52e..6ab1b65b7 100644 --- a/packages/gitbook/src/components/SitePage/SitePage.tsx +++ b/packages/gitbook/src/components/SitePage/SitePage.tsx @@ -11,7 +11,12 @@ import { import { IconsProvider } from '@gitbook/icons'; import { PageContextProvider } from '../PageContext'; -import { type PagePathParams, fetchPageData, getPathnameParam } from './fetch'; +import { + type PagePathParams, + fetchPageData, + getLowercasePathnameRedirect, + getPathnameParam, +} from './fetch'; import { PageClientLayout } from './PageClientLayout'; import { UpdatesFilterProvider } from '@/components/DocumentView/UpdatesFilter'; import { UpdatesFilterScript } from '@/components/DocumentView/UpdatesFilterScript'; @@ -277,8 +282,8 @@ export async function getSitePageData(props: SitePageProps) { const rawPathname = getPathnameParam(props.pageParams); if (!pageTarget) { - const pathname = rawPathname.toLowerCase(); - if (pathname !== rawPathname) { + const pathname = getLowercasePathnameRedirect(rawPathname); + if (pathname !== null) { // If the pathname was not normalized, redirect to the normalized version // before trying to resolve the page again redirect(context.linker.toPathInSpace(pathname)); diff --git a/packages/gitbook/src/components/SitePage/fetch.test.ts b/packages/gitbook/src/components/SitePage/fetch.test.ts index 3ca30ebd8..d59ed3510 100644 --- a/packages/gitbook/src/components/SitePage/fetch.test.ts +++ b/packages/gitbook/src/components/SitePage/fetch.test.ts @@ -6,7 +6,8 @@ import type { GitBookSiteContext } from '@/lib/context'; mock.module('server-only', () => ({})); -const { fetchPageData } = await import('./fetch'); +const { fetchPageData, getLowercasePathnameRedirect } = await import('./fetch'); +const { normalizeURL } = await import('@/lib/data/urls'); const page = { id: 'page-1', @@ -92,3 +93,56 @@ describe('fetchPageData', () => { expect(result.pageTarget?.page.git).toBeUndefined(); }); }); + +describe('getLowercasePathnameRedirect', () => { + it('redirects ASCII paths with uppercase letters', () => { + expect(getLowercasePathnameRedirect('Foo/Bar')).toBe('foo/bar'); + }); + + it('does not redirect lowercase paths', () => { + expect(getLowercasePathnameRedirect('foo/cafe')).toBeNull(); + }); + + it('does not redirect when only percent-encoded hex digits are uppercase', () => { + expect(getLowercasePathnameRedirect('foo/caf%C3%A9')).toBeNull(); + expect(getLowercasePathnameRedirect('foo/caf%c3%a9')).toBeNull(); + }); + + it('lowercases encoded non-ASCII letters', () => { + // É -> é + expect(getLowercasePathnameRedirect('foo/%C3%89')).toBe('foo/%C3%A9'); + }); + + it('does not redirect paths that fail to decode', () => { + expect(getLowercasePathnameRedirect('Foo/%E0%A4%A')).toBeNull(); + }); + + it('redirects to a pathname the middleware leaves unchanged', () => { + const normalize = (pathname: string) => + normalizeURL(new URL(`https://example.com/${pathname}`)).pathname.slice(1); + + const paths = [ + 'Foo/Bar', + 'Foo/caf%C3%A9', + 'foo/%C3%89', + 'Video/porte%C3%91o-x.html', + '%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82', // Привет + '%CE%95%CE%BB%CE%BB%CE%AC%CE%B4%CE%B1', // Ελλάδα + '%C3%96sterreich/Stra%C3%9FE', // Österreich/StraßE + '%C4%B0stanbul', // İstanbul, lowercases to two code points + 'Foo:Bar', + 'Foo@Bar+Baz', + 'Brack[et]', + 'Q%3FX', + 'Hash%23Y', + 'Sp%20Ace', + ]; + + for (const path of paths) { + const target = getLowercasePathnameRedirect(path); + expect(target).not.toBeNull(); + expect(normalize(target!)).toBe(target!); + expect(getLowercasePathnameRedirect(target!)).toBeNull(); + } + }); +}); diff --git a/packages/gitbook/src/components/SitePage/fetch.ts b/packages/gitbook/src/components/SitePage/fetch.ts index 9338fc9a0..a00713931 100644 --- a/packages/gitbook/src/components/SitePage/fetch.ts +++ b/packages/gitbook/src/components/SitePage/fetch.ts @@ -184,3 +184,41 @@ export function getPathnameParam(params: PagePathParams): string { return pathname.map((part) => decodeURIComponent(part)).join('/'); } + +/** + * Get the lowercased pathname to redirect a missing page to, or `null` if there is none. + * The pathname is percent-encoded, so lowercase its decoded form and re-encode it canonically: + * any other encoding would make the middleware redirect again, or loop. + */ +export function getLowercasePathnameRedirect(rawPathname: string): string | null { + let changed = false; + const segments: string[] = []; + + for (const segment of rawPathname.split('/')) { + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + return null; + } + + const lowercased = decoded.toLowerCase(); + changed ||= lowercased !== decoded; + segments.push(lowercased); + } + + if (!changed) { + return null; + } + + return encodeURLPathname(segments.join('/')).slice(1); +} + +/** + * Percent-encode a decoded pathname the way the URL parser does, the canonical form `normalizeURL` produces. + */ +function encodeURLPathname(pathname: string): string { + const url = new URL('https://gitbook.invalid'); + url.pathname = pathname; + return url.pathname; +} diff --git a/packages/gitbook/src/lib/data/urls.ts b/packages/gitbook/src/lib/data/urls.ts index edf7cdd33..bad5f2b2f 100644 --- a/packages/gitbook/src/lib/data/urls.ts +++ b/packages/gitbook/src/lib/data/urls.ts @@ -233,7 +233,7 @@ export function normalizeRequestURL(url: URL): Response | null { /** * Normalize a URL to remove duplicate slashes and trailing slashes - * and transform the pathname to lowercase. + * and decode the pathname to its canonical encoding. */ export function normalizeURL(url: URL) { const result = new URL(url);