mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-24 11:26:31 +00:00
Return 404 instead of looping on missing non-ASCII page paths (#4631)
This commit is contained in:
@@ -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.
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user