diff --git a/.changeset/flexible-matching.md b/.changeset/flexible-matching.md new file mode 100644 index 0000000..60ba123 --- /dev/null +++ b/.changeset/flexible-matching.md @@ -0,0 +1,7 @@ +--- +'@goto/web': minor +'@goto/cli': minor +'@goto/worker': minor +--- + +Add inherited named-link case-insensitive matching and trailing-slash tolerance, enabled by default, plus independent opt-in path forwarding. Keep matched aliases and forwarded paths under their configured link analytics identity and expose effective matching settings in admin. diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index 7ad320e..3ff4d0b 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -221,7 +221,6 @@ test('shared credentials protect admin pages and sign-out removes access', async await expect(rootLink).toHaveAttribute('href', '/admin/domains/go.example.org/links?period=all'); const linkDetails = page.getByRole('region', { name: 'Link details', exact: true }); - const domainDetailsLink = linkDetails.getByRole('link', { name: 'go.example.org', exact: true }); const docsPath = '/admin/domains/go.example.org/links/docs'; await page.getByRole('link', { name: '/docs', exact: true }).click(); await expect(page).toHaveURL(`${appServer.origin}${docsPath}?period=all`); @@ -231,11 +230,13 @@ test('shared credentials protect admin pages and sign-out removes access', async linkDetails.getByRole('heading', { name: 'Link details', exact: true }) ).toBeVisible(); await expect(linkDetails.locator('dt')).toHaveText([ - 'Domain', - 'Path', + 'Link', 'Destination', 'HTTP status', - 'Forward query', + 'Case insensitive', + 'Trailing slash', + 'Path forwarding', + 'Query forwarding', 'Starts', 'Expires', 'Tags' @@ -250,7 +251,9 @@ test('shared credentials protect admin pages and sign-out removes access', async '2100-01-01T00:00:00Z' ); await expect(linkDetails.getByText('work, docs', { exact: true })).toBeVisible(); - await expect(linkDetails.locator('dd').nth(1)).toHaveText('/docs'); + const linkField = linkDetails.locator('dd').first(); + await expect(linkField).toHaveText('go.example.org/docs'); + await expect(linkField.getByRole('link')).toHaveCount(0); await expect(page.locator('.admin-cards .admin-stat-label')).toHaveText(['Visits', 'Status']); await expect(page.getByRole('region', { name: 'Status', exact: true })).toContainText('Active'); expect(await cardSizes()).toEqual(overviewCardSizes.slice(0, 2)); @@ -271,15 +274,13 @@ test('shared credentials protect admin pages and sign-out removes access', async await expect(chartTable.getByRole('row', { name: 'Total 0', exact: true })).toBeVisible(); await timeframe.getByRole('button', { name: '3 months', exact: true }).click(); await expect(visitsValue).toHaveText('1'); - await expect(domainDetailsLink).toHaveAttribute( - 'href', - '/admin/domains/go.example.org?period=all' - ); + await expect(overviewLink).toHaveAttribute('href', '/admin?period=all'); await expect( page.getByRole('region', { name: 'Links on go.example.org', exact: true }) ).toHaveCount(0); - await domainDetailsLink.click(); + await overviewLink.click(); + await domainLink.click(); await page.getByRole('link', { name: '/a%2Fb', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Link', exact: true })).toBeVisible(); await expect(page).toHaveTitle('Link — go.example.org/a%2Fb — Goto'); @@ -287,7 +288,7 @@ test('shared credentials protect admin pages and sign-out removes access', async `${appServer.origin}/admin/domains/go.example.org/links/a%252Fb?period=all` ); await expect(visitsValue).toHaveText('1'); - await domainDetailsLink.click(); + await page.goBack(); await page.getByRole('link', { name: '/a/b', exact: true }).click(); await expect(page).toHaveURL( `${appServer.origin}/admin/domains/go.example.org/links/a/b?period=all` @@ -296,7 +297,7 @@ test('shared credentials protect admin pages and sign-out removes access', async await expect(page).toHaveTitle('Link — go.example.org/a/b — Goto'); await expect(visitsValue).toHaveText('0'); - await domainDetailsLink.click(); + await page.goBack(); await rootLink.click(); await expect(page).toHaveURL(`${appServer.origin}/admin/domains/go.example.org/links?period=all`); await expect(page.getByRole('heading', { name: 'Root', exact: true })).toBeVisible(); diff --git a/apps/web/e2e/server.js b/apps/web/e2e/server.js index d7094fb..bd8d111 100644 --- a/apps/web/e2e/server.js +++ b/apps/web/e2e/server.js @@ -36,6 +36,8 @@ const configuration = { '/a%2Fb': { url: 'https://example.org/literal-percent' }, '/docs': { url: 'https://example.org/first-domain', + forward_path: true, + forward_query: true, valid_from: '2020-01-01T00:00:00Z', valid_until: '2100-01-01T00:00:00Z', tags: ['work', 'docs'] diff --git a/apps/web/e2e/visits.e2e.ts b/apps/web/e2e/visits.e2e.ts index bdae74d..17cb3a4 100644 --- a/apps/web/e2e/visits.e2e.ts +++ b/apps/web/e2e/visits.e2e.ts @@ -45,10 +45,14 @@ test('the compiled writer persists outcomes, isolates locks and drains visits be try { competingWriter.exec('PRAGMA busy_timeout = 1000'); competingWriter.exec('BEGIN IMMEDIATE'); - for (let index = 0; index < 3; index++) { - const response = await request.get('/docs', options); + for (const [path, destination] of [ + ['/docs', 'https://example.org/first-domain'], + ['/DOCS/', 'https://example.org/first-domain'], + ['/DoCs/Install?from=browser', 'https://example.org/first-domain/Install?from=browser'] + ] as const) { + const response = await request.get(path, options); expect(response.status()).toBe(302); - expect(response.headers().location).toBe('https://example.org/first-domain'); + expect(response.headers().location).toBe(destination); } expect(visitCount(databasePath, '/docs')).toBe(0); } finally { diff --git a/apps/web/src/lib/server/redirect.test.ts b/apps/web/src/lib/server/redirect.test.ts index e4ad0c8..ac9b0b3 100644 --- a/apps/web/src/lib/server/redirect.test.ts +++ b/apps/web/src/lib/server/redirect.test.ts @@ -1,4 +1,11 @@ -import type { PreparedDomain, PreparedLink, RedirectResponse, RedirectStatus } from '@goto/config'; +import { + validateConfig, + type LinkConfiguration, + type PreparedDomain, + type PreparedLink, + type RedirectResponse, + type RedirectStatus +} from '@goto/config'; import { describe, expect, test, vi } from 'vitest'; import { resolveRedirect } from './redirect.js'; @@ -19,7 +26,16 @@ function preparedLink(overrides: Partial = {}): PreparedLink { enabled: true, status_code: 302, forward_query: false, - provenance: { status_code: 'builtin', forward_query: 'builtin' }, + case_insensitive: false, + ignore_trailing_slash: false, + forward_path: false, + provenance: { + status_code: 'builtin', + forward_query: 'builtin', + case_insensitive: 'link', + ignore_trailing_slash: 'link', + forward_path: 'builtin' + }, ...overrides }; } @@ -29,7 +45,7 @@ function preparedDomain( fallback: PreparedDomain['fallback'] = null, root: PreparedDomain['root'] = null ): PreparedDomain { - return { root, links, fallback, provenance: { fallback: 'builtin' } }; + return { root, links, fallback, caseInsensitivePaths: {}, provenance: { fallback: 'builtin' } }; } describe('exact single-decode lookup', () => { @@ -115,6 +131,103 @@ describe('exact single-decode lookup', () => { }); }); +type LinkInput = Pick & Partial; + +function matchingDomain(links: Record): PreparedDomain { + return validateConfig( + { + auth: { credentials: { username: 'example', password: 'unit-only-password' } }, + domains: { [host]: { links } } + }, + { env: {} } + ).domains[host]!; +} + +describe('flexible matching and forwarding', () => { + test('built-in aliases count against the authored key without enabling prefix matching', () => { + const domain = matchingDomain({ '/Docs': { url: destination } }); + const matched = resolveRedirect(domain, host, '/DOCS/', 'GET'); + expect(matched.response.headers.get('location')).toBe(destination); + expect(matched.visit).toMatchObject({ path: '/Docs', outcome: 'valid' }); + + const unmatched = resolveRedirect(domain, host, '/docs/install', 'GET'); + expect(unmatched.response.status).toBe(404); + expect(unmatched.visit).toMatchObject({ path: '/docs/install', outcome: 'invalid' }); + }); + + test('forwards encoded suffix segments before independently merging query parameters', () => { + const domain = matchingDomain({ + '/docs': { + url: 'https://TARGET.example.test:443/base/?tag=old&keep=%2f#part', + forward_path: true, + forward_query: true + } + }); + const target = '/DOCS/Install%20Guide/%252F?tag=one&tag=two'; + const { response, visit } = resolveRedirect(domain, host, target, 'GET'); + expect(response.headers.get('location')).toBe( + 'https://target.example.test/base/Install%20Guide/%252F?keep=%2F&tag=one&tag=two#part' + ); + expect(visit).toMatchObject({ path: '/docs', outcome: 'valid' }); + const head = resolveRedirect(domain, host, target, 'HEAD'); + expect([...head.response.headers]).toEqual([...response.headers]); + expect(head.visit).toBeUndefined(); + }); + + test('path forwarding does not enable query forwarding or rewrite an exact destination', () => { + const authored = 'https://TARGET.example.test:443/base/?tag=old&keep=%2f#part'; + const domain = matchingDomain({ '/docs': { url: authored, forward_path: true } }); + expect( + resolveRedirect(domain, host, '/docs/Install?tag=new', 'GET').response.headers.get('location') + ).toBe('https://target.example.test/base/Install?tag=old&keep=%2f#part'); + expect( + resolveRedirect(domain, host, '/docs?tag=new', 'GET').response.headers.get('location') + ).toBe(authored); + }); + + test('the most specific match owns its suffix and captured identity', () => { + const domain = matchingDomain({ + '/docs': { url: destination, forward_path: true }, + '/docs/api': { url: 'https://api.example.test/reference', forward_path: true } + }); + const { response, visit } = resolveRedirect(domain, host, '/DOCS/api/Start', 'GET'); + expect(response.headers.get('location')).toBe('https://api.example.test/reference/Start'); + expect(visit).toMatchObject({ path: '/docs/api', outcome: 'valid' }); + }); + + test('an inactive prefix winner uses fallback without forwarding its suffix or trying a parent', () => { + const domain = matchingDomain({ + '/docs': { url: destination, forward_path: true }, + '/docs/private': { url: destination, forward_path: true, enabled: false } + }); + domain.fallback = fallback; + const { response, visit } = resolveRedirect(domain, host, '/DOCS/private/secret?x=1', 'GET'); + expect(response.headers.get('location')).toBe(fallbackUrl); + expect(visit).toMatchObject({ path: '/docs/private', outcome: 'invalid' }); + }); + + test.each(['/docs/%2E%2E/secret', '/docs/%5Csecret', '/docs/%00'])( + 'does not turn dangerous decoded suffixes into forwarded paths: %s', + (target) => { + const domain = matchingDomain({ '/docs': { url: destination, forward_path: true } }); + const { response, visit } = resolveRedirect(domain, host, target, 'GET'); + expect(response.status).toBe(404); + expect(response.headers.has('location')).toBe(false); + expect(visit?.outcome).toBe('invalid'); + } + ); + + test('runtime blocks recursion through an alias of the same prefix link', () => { + const domain = matchingDomain({ '/docs': { url: destination, forward_path: true } }); + domain.links['/docs']!.url = 'https://go.example.test/DOCS/again'; + const { response, visit } = resolveRedirect(domain, host, '/docs/start', 'GET'); + expect(response.status).toBe(404); + expect(response.headers.has('location')).toBe(false); + expect(response.headers.get('cache-control')).toBe('no-store'); + expect(visit).toMatchObject({ path: '/docs', outcome: 'invalid' }); + }); +}); + describe('HTTP responses', () => { test('serializes Unicode destinations as valid Location URLs without forwarding', () => { const url = 'https://例え.test/café?q=☕#完成'; diff --git a/apps/web/src/lib/server/redirect.ts b/apps/web/src/lib/server/redirect.ts index 015984e..bb135c2 100644 --- a/apps/web/src/lib/server/redirect.ts +++ b/apps/web/src/lib/server/redirect.ts @@ -1,5 +1,11 @@ -import { isSelfRedirect, type PreparedDomain, type PreparedLink } from '@goto/config'; +import { + isSelfRedirect, + matchLink, + type PreparedDomain, + type RedirectResponse +} from '@goto/config'; import type { VisitOutcome } from '@goto/db'; +import { currentLinkState } from './link-state.js'; import type { Immutable } from './snapshot.js'; export interface ResolvedVisit { @@ -21,6 +27,14 @@ interface ParsedTarget { interface ResolvedResponse { response: Response; + path: string; + outcome: VisitOutcome; +} + +interface SelectedVisitRedirect { + path: string; + suffix: string; + redirect: Immutable | null; outcome: VisitOutcome; } @@ -42,6 +56,14 @@ function parseTarget(target: string): ParsedTarget | undefined { } } +function forwardPath(destination: string, suffix: string): string { + const url = new URL(destination); + const basePath = url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname; + const encodedSuffix = suffix.split('/').map(encodeURIComponent).join('/'); + url.pathname = basePath + encodedSuffix; + return url.href; +} + function forwardQuery(destination: string, query: string): string { const incoming = new URLSearchParams(query); if (incoming.size === 0) return destination; @@ -73,16 +95,28 @@ function redirectResponse(destination: string, status: number): Response { return new Response(null, { status, headers }); } -function findActiveLink( +function selectVisitRedirect( domain: Immutable, - path: string, + requestedPath: string, now: number -): Immutable | undefined { - const link = Object.hasOwn(domain.links, path) ? domain.links[path] : undefined; - if (!link || !link.enabled) return; - const started = link.valid_from === undefined || now >= Date.parse(link.valid_from); - const notExpired = link.valid_until === undefined || now < Date.parse(link.valid_until); - if (started && notExpired) return link; +): SelectedVisitRedirect { + if (requestedPath === '/') { + let redirect = domain.root; + if (redirect === null) redirect = domain.fallback; + return { path: '/', suffix: '', redirect, outcome: 'valid' }; + } + + const match = matchLink(domain, requestedPath); + if (match === undefined) { + return { path: requestedPath, suffix: '', redirect: domain.fallback, outcome: 'invalid' }; + } + + const link = domain.links[match.path]!; + if (currentLinkState(link, now) !== 'active') { + return { path: match.path, suffix: '', redirect: domain.fallback, outcome: 'invalid' }; + } + + return { path: match.path, suffix: match.suffix, redirect: link, outcome: 'valid' }; } function resolveVisitResponse( @@ -91,36 +125,26 @@ function resolveVisitResponse( target: ParsedTarget, now: number ): ResolvedResponse { - const isRoot = target.path === '/'; - let redirect = domain.fallback; - let outcome: VisitOutcome = 'invalid'; - if (isRoot) { - outcome = 'valid'; - if (domain.root !== null) redirect = domain.root; - } else { - const link = findActiveLink(domain, target.path, now); - if (link) { - redirect = link; - outcome = 'valid'; - } - } + const { path, suffix, redirect, outcome } = selectVisitRedirect(domain, target.path, now); if (redirect === null) { const response = emptyResponse(404); - return { response, outcome }; + return { response, path, outcome }; } let destination = redirect.url; + if (suffix !== '') destination = forwardPath(destination, suffix); if (redirect.forward_query) destination = forwardQuery(destination, target.query); - const selfRedirect = isSelfRedirect(destination, host, target.path); + const selfRedirect = isSelfRedirect(destination, host, target.path, domain); if (selfRedirect) { const response = emptyResponse(404); - if (!isRoot) outcome = 'invalid'; - return { response, outcome }; + let loopOutcome: VisitOutcome = 'invalid'; + if (target.path === '/') loopOutcome = 'valid'; + return { response, path, outcome: loopOutcome }; } const response = redirectResponse(destination, redirect.status_code); - return { response, outcome }; + return { response, path, outcome }; } export function resolveRedirect( @@ -147,7 +171,7 @@ export function resolveRedirect( if (method === 'HEAD') return { response }; const visit: ResolvedVisit = { domain: host, - path: parsed.path, + path: resolved.path, outcome: resolved.outcome, visitedAt: now }; diff --git a/apps/web/src/lib/server/snapshot.test.ts b/apps/web/src/lib/server/snapshot.test.ts index 1833008..9efeb59 100644 --- a/apps/web/src/lib/server/snapshot.test.ts +++ b/apps/web/src/lib/server/snapshot.test.ts @@ -29,6 +29,10 @@ test('publishes a deep-frozen copy without changing the loaded configuration', ( expect(Object.isFrozen(snapshot.domains['go.example.test']!.root)).toBe(true); expect(Object.isFrozen(snapshot.domains['go.example.test']!.fallback)).toBe(true); expect(Object.isFrozen(link.tags)).toBe(true); + expect(Object.isFrozen(snapshot.domains['go.example.test']!.caseInsensitivePaths)).toBe(true); + expect( + Reflect.set(snapshot.domains['go.example.test']!.caseInsensitivePaths, '/docs', '/other') + ).toBe(false); expect(Reflect.set(link, 'enabled', false)).toBe(false); expect(link.forward_query).toBe(false); expect(link.provenance.forward_query).toBe('link'); diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.server.ts b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.server.ts index 12e0e04..5ea144f 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.server.ts +++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.server.ts @@ -39,13 +39,14 @@ export const load = (async ({ locals, params }) => { return { domain, path, - domainHref: `/admin/domains/${encodeURIComponent(domain)}`, response, + caseInsensitive: link.case_insensitive, + ignoreTrailingSlash: link.ignore_trailing_slash, + forwardPath: link.forward_path, state, validFrom, validUntil, tags: link.tags, - sources: link.provenance, retentionMonths, collectionEnabled, available: details !== null, diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.svelte b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.svelte index 7c8dc21..957681f 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.svelte +++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/+page.svelte @@ -1,7 +1,6 @@ -{#snippet sourceHint(source: SettingSource)} - - {#if source === 'builtin'}Built-in - {:else if source === 'global'}Global defaults - {:else if source === 'domain'}Domain defaults - {:else}Link override{/if} - -{/snippet} -
-
Domain
-
{data.domain}
-
Path
-
{data.path}
+
Link
+
{data.domain}{data.path}
Destination
@@ -78,15 +66,15 @@
HTTP status
-
- {data.response.status_code} - {@render sourceHint(data.sources.status_code)} -
-
Forward query
-
- {data.response.forward_query ? 'Yes' : 'No'} - {@render sourceHint(data.sources.forward_query)} -
+
{data.response.status_code}
+
Case insensitive
+
{data.caseInsensitive ? 'Yes' : 'No'}
+
Trailing slash
+
{data.ignoreTrailingSlash ? 'Yes' : 'No'}
+
Path forwarding
+
{data.forwardPath ? 'Yes' : 'No'}
+
Query forwarding
+
{data.response.forward_query ? 'Yes' : 'No'}
{#if data.validFrom}
Starts
@@ -112,11 +100,3 @@ {/if} - - diff --git a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/page.server.test.ts b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/page.server.test.ts index 292a149..710bc62 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/page.server.test.ts +++ b/apps/web/src/routes/(admin)/(auth)/admin/domains/[domain]/links/[...path]/page.server.test.ts @@ -11,20 +11,23 @@ beforeEach(() => vi.resetAllMocks()); const domain = 'go.example.test'; const linkMetadata = { response: { url: 'https://example.test/docs', status_code: 308, forward_query: true }, + caseInsensitive: false, + ignoreTrailingSlash: false, + forwardPath: true, state: 'active', validFrom: '2000-01-01T00:00:00Z', validUntil: '2100-01-01T00:00:00Z', - tags: ['guides', 'reference'], - sources: { status_code: 'link', forward_query: 'domain' } + tags: ['guides', 'reference'] }; function request(path = '/docs', options: { host?: string; retentionMonths?: number } = {}) { const domainConfiguration: Partial = { - link_defaults: { forward_query: true }, + link_defaults: { forward_query: true, ignore_trailing_slash: false }, links: { '/docs': { url: 'https://example.test/docs', status_code: 308, + forward_path: true, enabled: true, valid_from: '2000-01-01T00:00:00Z', valid_until: '2100-01-01T00:00:00Z', @@ -38,7 +41,7 @@ function request(path = '/docs', options: { host?: string; retentionMonths?: num { auth: { credentials: { username: 'test', password: 'link-test-password' } }, analytics: { retention_months: options.retentionMonths ?? 3 }, - link_defaults: { status_code: 301 }, + link_defaults: { status_code: 301, case_insensitive: false }, domains: { [domain]: domainConfiguration } }, { env: {} } @@ -86,7 +89,6 @@ test('projects only requested link settings, series and compact window counts an expect(data).toEqual({ domain, path: '/docs', - domainHref: '/admin/domains/go.example.test', ...linkMetadata, retentionMonths: 3, collectionEnabled: true, @@ -125,11 +127,13 @@ test('keeps decoded nested and literal-percent identities distinct', async () => expect(nested.path).toBe('/a/b'); expect(nested).toMatchObject({ response: { url: 'https://example.test/nested', status_code: 301, forward_query: true }, + caseInsensitive: false, + ignoreTrailingSlash: false, + forwardPath: false, state: 'active', validFrom: null, validUntil: null, - tags: [], - sources: { status_code: 'global', forward_query: 'domain' } + tags: [] }); expect(percent.path).toBe('/a%2Fb'); expect(percent.state).toBe('disabled'); diff --git a/apps/web/src/routes/(admin)/(auth)/admin/health/+page.server.ts b/apps/web/src/routes/(admin)/(auth)/admin/health/+page.server.ts index 1fea28f..b96f35f 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/health/+page.server.ts +++ b/apps/web/src/routes/(admin)/(auth)/admin/health/+page.server.ts @@ -23,6 +23,9 @@ export const load = (async ({ locals }) => { reportDelayed, redirectStatusCode: configuration.link_defaults.status_code, forwardQuery: configuration.link_defaults.forward_query, + caseInsensitive: configuration.link_defaults.case_insensitive, + ignoreTrailingSlash: configuration.link_defaults.ignore_trailing_slash, + forwardPath: configuration.link_defaults.forward_path, fallbackDestination: configuration.fallback?.url ?? 404, workerUrl: configuration.url.worker }; diff --git a/apps/web/src/routes/(admin)/(auth)/admin/health/+page.svelte b/apps/web/src/routes/(admin)/(auth)/admin/health/+page.svelte index ea2c174..d56e9a2 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/health/+page.svelte +++ b/apps/web/src/routes/(admin)/(auth)/admin/health/+page.svelte @@ -38,6 +38,12 @@
Status code
{data.redirectStatusCode}
+
Case insensitive
+
{data.caseInsensitive ? 'Yes' : 'No'}
+
Trailing slash
+
{data.ignoreTrailingSlash ? 'Yes' : 'No'}
+
Path forwarding
+
{data.forwardPath ? 'Yes' : 'No'}
Query forwarding
{data.forwardQuery ? 'Yes' : 'No'}
Fallback
diff --git a/apps/web/src/routes/(admin)/(auth)/admin/health/page.server.test.ts b/apps/web/src/routes/(admin)/(auth)/admin/health/page.server.test.ts index 35e34bc..defdb38 100644 --- a/apps/web/src/routes/(admin)/(auth)/admin/health/page.server.test.ts +++ b/apps/web/src/routes/(admin)/(auth)/admin/health/page.server.test.ts @@ -66,6 +66,9 @@ test('returns only allowlisted settings and the prepared report cutoff, without reportDelayed: false, redirectStatusCode: 302, forwardQuery: false, + caseInsensitive: true, + ignoreTrailingSlash: true, + forwardPath: false, fallbackDestination: 404, workerUrl: 'http://worker.example.test:9000/base' }); @@ -88,7 +91,13 @@ test('projects OIDC mode and configured redirect defaults without exposing the p client_secret: 'private-client-secret' } }, - link_defaults: { status_code: 307, forward_query: true }, + link_defaults: { + status_code: 307, + forward_query: true, + case_insensitive: false, + ignore_trailing_slash: false, + forward_path: true + }, fallback: { url: 'https://example.test/missing' } }) ); @@ -102,6 +111,9 @@ test('projects OIDC mode and configured redirect defaults without exposing the p reportDelayed: false, redirectStatusCode: 307, forwardQuery: true, + caseInsensitive: false, + ignoreTrailingSlash: false, + forwardPath: true, fallbackDestination: 'https://example.test/missing', workerUrl: 'http://worker.example.test:9000/base' }); diff --git a/apps/worker/src/services/config-reload.test.ts b/apps/worker/src/services/config-reload.test.ts index bbde51b..475111f 100644 --- a/apps/worker/src/services/config-reload.test.ts +++ b/apps/worker/src/services/config-reload.test.ts @@ -108,6 +108,7 @@ test('refreshes report inventory after an accepted domain/link change', async () next.domains['go.example.test'] = { root: null, links: {}, + caseInsensitivePaths: {}, fallback: null, provenance: { fallback: 'builtin' } }; diff --git a/examples/goto.yaml b/examples/goto.yaml index f6236f5..420aa3a 100644 --- a/examples/goto.yaml +++ b/examples/goto.yaml @@ -28,6 +28,9 @@ analytics: link_defaults: status_code: 302 + case_insensitive: true + ignore_trailing_slash: true + forward_path: false forward_query: false fallback: @@ -50,6 +53,7 @@ domains: url: https://example.org/documentation title: Documentation tags: [work] + forward_path: true forward_query: false /café: url: https://example.org/cafe diff --git a/packages/config/src/config.schema.json b/packages/config/src/config.schema.json index c27396c..ea0a29c 100644 --- a/packages/config/src/config.schema.json +++ b/packages/config/src/config.schema.json @@ -338,7 +338,10 @@ "type": "object", "properties": { "status_code": { "default": 302, "$ref": "#/$defs/RedirectStatus" }, - "forward_query": { "default": false, "type": "boolean" } + "forward_query": { "default": false, "type": "boolean" }, + "case_insensitive": { "default": true, "type": "boolean" }, + "ignore_trailing_slash": { "default": true, "type": "boolean" }, + "forward_path": { "default": false, "type": "boolean" } } }, "DomainLinkDefaults": { @@ -347,12 +350,15 @@ "type": "object", "properties": { "status_code": { "$ref": "#/$defs/RedirectStatus" }, - "forward_query": { "type": "boolean" } + "forward_query": { "type": "boolean" }, + "case_insensitive": { "type": "boolean" }, + "ignore_trailing_slash": { "type": "boolean" }, + "forward_path": { "type": "boolean" } } }, "LinkConfiguration": { "additionalProperties": false, - "description": "Omitted status_code and forward_query inherit domain link_defaults, then global link_defaults.", + "description": "Omitted response and matching settings inherit domain link_defaults, then global link_defaults.", "type": "object", "required": ["url"], "properties": { @@ -372,6 +378,9 @@ "enabled": { "default": true, "type": "boolean" }, "status_code": { "$ref": "#/$defs/RedirectStatus" }, "forward_query": { "type": "boolean" }, + "case_insensitive": { "type": "boolean" }, + "ignore_trailing_slash": { "type": "boolean" }, + "forward_path": { "type": "boolean" }, "valid_from": { "$ref": "#/$defs/Instant" }, "valid_until": { "$ref": "#/$defs/Instant" } } @@ -386,7 +395,7 @@ "fallback": { "$ref": "#/$defs/RedirectResponse" }, "links": { "additionalProperties": false, - "description": "Logical, case-sensitive named paths. Keys are never interpolated or percent-decoded. Use readable canonical paths without spaces, trailing slashes, controls, backslashes, dot segments or :?#[]@. Configure / with the separate root response.", + "description": "Logical named paths. Keys preserve their spelling and are never interpolated or percent-decoded. Use readable canonical paths without spaces, trailing slashes, controls, backslashes, dot segments or :?#[]@. Configure / with the separate root response.", "type": "object", "patternProperties": { "^/.+": { "default": {}, "$ref": "#/$defs/LinkConfiguration" } } } diff --git a/packages/config/src/editor-schema.test.ts b/packages/config/src/editor-schema.test.ts index e1ba993..41cae26 100644 --- a/packages/config/src/editor-schema.test.ts +++ b/packages/config/src/editor-schema.test.ts @@ -76,7 +76,13 @@ describe('Goto editor schema', () => { } for (const name of ['DomainLinkDefaults', 'LinkConfiguration']) { const schema = definition(editor, name); - for (const field of ['status_code', 'forward_query']) { + for (const field of [ + 'status_code', + 'forward_query', + 'case_insensitive', + 'ignore_trailing_slash', + 'forward_path' + ]) { expect(property(schema, field)).not.toHaveProperty('default'); expect(schema.required ?? []).not.toContain(field); } @@ -86,6 +92,18 @@ describe('Goto editor schema', () => { type: 'boolean', default: false }); + expect(property(definition(editor, 'LinkDefaults'), 'case_insensitive')).toEqual({ + type: 'boolean', + default: true + }); + expect(property(definition(editor, 'LinkDefaults'), 'ignore_trailing_slash')).toEqual({ + type: 'boolean', + default: true + }); + expect(property(definition(editor, 'LinkDefaults'), 'forward_path')).toEqual({ + type: 'boolean', + default: false + }); expect(object(definition(editor, 'LinkDefaults').properties)).not.toHaveProperty('fallback'); expect(object(definition(editor, 'DomainLinkDefaults').properties)).not.toHaveProperty( 'fallback' diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index fcbdd72..0b3cb9c 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,6 +1,7 @@ export { ConfigError, formatConfigError, type ConfigIssue } from './errors.js'; export { loadConfigFile, parseConfig, validateConfig } from './load.js'; export { startConfigReloading } from './monitor.js'; +export { matchLink } from './matching.js'; export { isSelfRedirect } from './redirect.js'; export { isCanonicalPath, diff --git a/packages/config/src/load.test.ts b/packages/config/src/load.test.ts index f49833a..56bb9f6 100644 --- a/packages/config/src/load.test.ts +++ b/packages/config/src/load.test.ts @@ -25,7 +25,13 @@ describe('configuration loading', () => { site: { title: 'Goto' }, database: { path: './data/goto.sqlite', worker_path: './data/worker.sqlite' }, analytics: { retention_months: 3 }, - link_defaults: { status_code: 302, forward_query: false } + link_defaults: { + status_code: 302, + forward_query: false, + case_insensitive: true, + ignore_trailing_slash: true, + forward_path: false + } }); expect(input).toEqual(original); }); @@ -228,7 +234,8 @@ describe('configuration loading', () => { {}, { url: 'https://example.com', status_code: 404 }, { url: 'https://example.com', forward_query: 'false' }, - { url: 'https://example.com', action: 'not_found' } + { url: 'https://example.com', action: 'not_found' }, + { url: 'https://example.com', forward_path: true } ])('rejects non-response fallback values %j', (fallback) => { expect(() => validateConfig({ ...configurationInput(), fallback }, options)).toThrow( ConfigError @@ -240,6 +247,8 @@ describe('configuration loading', () => { for (const domain of [ { root: { url: 'https://example.com', enabled: false } }, { root: { url: 'https://example.com', valid_from: '2027-01-01T00:00:00Z' } }, + { root: { url: 'https://example.com', ignore_trailing_slash: true } }, + { fallback: { url: 'https://example.com', case_insensitive: true } }, { fallback: 404 }, { defaults: { status_code: 307 } }, { link_defaults: { fallback: 'https://example.com' } } @@ -279,6 +288,19 @@ describe('configuration loading', () => { { auth: { ...configurationInput().auth, secret: 'too-short-private-secret' } }, { url: { app: 'https://ADMIN.example.com/' } }, { link_defaults: { forward_query: null } }, + { link_defaults: { case_insensitive: 'false' } }, + { + domains: { + 'go.example.com': { link_defaults: { ignore_trailing_slash: null }, links: {} } + } + }, + { + domains: { + 'go.example.com': { + links: { '/docs': { url: 'https://example.com', forward_path: 'true' } } + } + } + }, { domains: [] }, { domains: { 'go.example.com': { links: [] } } }, { @@ -406,7 +428,8 @@ describe('configuration loading', () => { }); expect(prepared.domains['go.example.org']?.links['/docs']).toMatchObject({ forward_query: false, - provenance: { forward_query: 'link' } + forward_path: true, + provenance: { forward_query: 'link', forward_path: 'link' } }); }); diff --git a/packages/config/src/matching.test.ts b/packages/config/src/matching.test.ts new file mode 100644 index 0000000..e8a3286 --- /dev/null +++ b/packages/config/src/matching.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { configurationInput } from '../test/configuration.js'; +import { isSelfRedirect, matchLink, validateConfig } from './index.js'; + +function prepareDomain(links: Parameters[0]) { + return validateConfig(configurationInput(links), { env: {} }).domains['go.example.com']!; +} + +const destination = 'https://destination.example/base'; + +describe('named link matching', () => { + it('preserves YAML identity with ASCII case aliases and one optional final slash', () => { + const domain = prepareDomain({ + '/Docs': { url: destination }, + '/café': { url: destination }, + '/literal%2Ftext': { url: destination } + }); + expect(matchLink(domain, '/Docs')).toEqual({ path: '/Docs', suffix: '' }); + expect(matchLink(domain, '/DOCS/')).toEqual({ path: '/Docs', suffix: '' }); + expect(matchLink(domain, '/CAFé')).toEqual({ path: '/café', suffix: '' }); + expect(matchLink(domain, '/CAFÉ')).toBeUndefined(); + expect(matchLink(domain, '/literal%2Ftext')).toEqual({ + path: '/literal%2Ftext', + suffix: '' + }); + expect(matchLink(domain, '/literal/text')).toBeUndefined(); + expect(matchLink(domain, '/Docs/install')).toBeUndefined(); + expect(matchLink(domain, '/')).toBeUndefined(); + }); + + it('honors explicit case and slash opt-outs even when forwarding paths', () => { + const domain = prepareDomain({ + '/Docs': { + url: destination, + case_insensitive: false, + ignore_trailing_slash: false, + forward_path: true + } + }); + expect(matchLink(domain, '/Docs')).toEqual({ path: '/Docs', suffix: '' }); + expect(matchLink(domain, '/docs')).toBeUndefined(); + expect(matchLink(domain, '/Docs/')).toBeUndefined(); + expect(matchLink(domain, '/Docs///')).toBeUndefined(); + expect(matchLink(domain, '/Docs/Install/')).toEqual({ + path: '/Docs', + suffix: '/Install/' + }); + }); + + it('prefers exact identities, then the longest forwarding segment prefix', () => { + const domain = prepareDomain({ + '/Docs': { url: destination, forward_path: true }, + '/docs/API': { url: destination, forward_path: true }, + '/docs/API/install': { url: destination } + }); + expect(matchLink(domain, '/DOCS/api/INSTALL/')).toEqual({ + path: '/docs/API/install', + suffix: '' + }); + expect(matchLink(domain, '/DOCS/api/Guide/')).toEqual({ + path: '/docs/API', + suffix: '/Guide/' + }); + expect(matchLink(domain, '/Docs/API/install/More')).toEqual({ + path: '/docs/API', + suffix: '/install/More' + }); + expect(matchLink(domain, '/Docsmith')).toBeUndefined(); + }); + + it('selects inactive links without falling through to a parent', () => { + const domain = prepareDomain({ + '/docs': { url: destination, forward_path: true }, + '/docs/private': { + url: destination, + forward_path: true, + enabled: false, + valid_from: '2099-01-01T00:00:00Z' + } + }); + expect(matchLink(domain, '/DOCS/PRIVATE')).toEqual({ + path: '/docs/private', + suffix: '' + }); + expect(matchLink(domain, '/docs/private/Guide')).toEqual({ + path: '/docs/private', + suffix: '/Guide' + }); + }); + + it('preserves suffix case and slashes without treating empty segments as extra paths', () => { + const domain = prepareDomain({ '/docs': { url: destination, forward_path: true } }); + expect(matchLink(domain, '/docs/')).toEqual({ path: '/docs', suffix: '' }); + expect(matchLink(domain, '/docs//')).toBeUndefined(); + expect(matchLink(domain, '/docs//Install/')).toEqual({ + path: '/docs', + suffix: '//Install/' + }); + expect(matchLink(domain, '/docs/%2E%2E/Next')).toEqual({ + path: '/docs', + suffix: '/%2E%2E/Next' + }); + }); + + it.each([ + '/docs/.', + '/docs/../private', + '/docs/./a', + '/docs/a\\b', + '/docs/a\u0000b', + '/docs/a\u007fb' + ])('does not prefix-serve dangerous decoded path %j', (path) => { + const domain = prepareDomain({ '/docs': { url: destination, forward_path: true } }); + expect(matchLink(domain, path)).toBeUndefined(); + }); + + it('accepts a readonly matching subset for matching and direct self-loop checks', () => { + const domain = { + links: { + '/Docs': { + case_insensitive: true, + ignore_trailing_slash: true, + forward_path: true + } + }, + caseInsensitivePaths: { '/docs': '/Docs' } + } as const; + expect(matchLink(domain, '/DOCS/Guide')).toEqual({ path: '/Docs', suffix: '/Guide' }); + expect( + isSelfRedirect('https://go.example.com/DOCS/Guide', 'go.example.com', '/Docs', domain) + ).toBe(true); + expect(isSelfRedirect('https://go.example.com/DOCS/Guide', 'go.example.com', '/Docs')).toBe( + false + ); + expect(isSelfRedirect('https://go.example.com/DOCS', 'go.example.com', '/', domain)).toBe( + false + ); + }); +}); diff --git a/packages/config/src/matching.ts b/packages/config/src/matching.ts new file mode 100644 index 0000000..e3f7333 --- /dev/null +++ b/packages/config/src/matching.ts @@ -0,0 +1,59 @@ +import { hasControlCharacters } from './text.js'; +import type { PreparedLink } from './types.js'; + +export interface MatchingDomain { + readonly links: Readonly< + Record< + string, + Pick + > + >; + readonly caseInsensitivePaths: Readonly>; +} + +export function matchLink( + domain: MatchingDomain, + path: string +): { path: string; suffix: string } | undefined { + const exact = configuredPath(domain, path); + if (exact !== undefined) return { path: exact, suffix: '' }; + + if (path.endsWith('/')) { + const withoutSlash = configuredPath(domain, path.slice(0, -1)); + if (withoutSlash !== undefined && domain.links[withoutSlash]?.ignore_trailing_slash) { + return { path: withoutSlash, suffix: '' }; + } + } + + const hasUnsafeForwardingPath = + path.includes('\\') || + hasControlCharacters(path) || + path.split('/').some((segment) => segment === '.' || segment === '..'); + if (hasUnsafeForwardingPath) return undefined; + + for ( + let boundary = path.lastIndexOf('/'); + boundary > 0; + boundary = path.lastIndexOf('/', boundary - 1) + ) { + const prefix = configuredPath(domain, path.slice(0, boundary)); + if (prefix === undefined || !domain.links[prefix]?.forward_path) continue; + const suffix = path.slice(boundary); + const hasAdditionalPathSegment = /[^/]/.test(suffix); + if (hasAdditionalPathSegment) return { path: prefix, suffix }; + } + return undefined; +} + +export function foldAsciiCase(path: string): string { + return path.replace(/[A-Z]/g, (character) => character.toLowerCase()); +} + +function configuredPath(domain: MatchingDomain, path: string): string | undefined { + if (Object.hasOwn(domain.links, path)) return path; + const folded = foldAsciiCase(path); + if (Object.hasOwn(domain.caseInsensitivePaths, folded)) { + return domain.caseInsensitivePaths[folded]; + } + return undefined; +} diff --git a/packages/config/src/redirect.ts b/packages/config/src/redirect.ts index 60fb9ee..9e30ab0 100644 --- a/packages/config/src/redirect.ts +++ b/packages/config/src/redirect.ts @@ -1,6 +1,12 @@ +import { matchLink, type MatchingDomain } from './matching.js'; import { normalizeHost } from './paths.js'; -export function isSelfRedirect(destination: string, host: string, path: string): boolean { +export function isSelfRedirect( + destination: string, + host: string, + path: string, + domain?: MatchingDomain +): boolean { const normalizedHost = normalizeHost(host); if (normalizedHost === undefined) return false; @@ -11,7 +17,13 @@ export function isSelfRedirect(destination: string, host: string, path: string): const sameHost = normalizeHost(url.host) === normalizedHost || (authoredAuthority !== undefined && normalizeHost(authoredAuthority) === normalizedHost); - return sameHost && decodeURIComponent(url.pathname) === path; + if (!sameHost) return false; + const destinationPath = decodeURIComponent(url.pathname); + if (destinationPath === path) return true; + if (domain === undefined || path === '/' || destinationPath === '/') return false; + const source = matchLink(domain, path); + const target = matchLink(domain, destinationPath); + return source !== undefined && target !== undefined && source.path === target.path; } catch { return false; } diff --git a/packages/config/src/resolve.test.ts b/packages/config/src/resolve.test.ts index 335e905..b0b2f9e 100644 --- a/packages/config/src/resolve.test.ts +++ b/packages/config/src/resolve.test.ts @@ -80,13 +80,65 @@ describe('direct self-loop validation', () => { ])('preserves distinct authored destinations: %s', (url) => { const input = configurationInput(); const { domains } = validateConfig( - { ...input, domains: { [redirectHost]: { links: { '/docs': { url } } } } }, + { + ...input, + domains: { + [redirectHost]: { + links: { + '/docs': { url, case_insensitive: false, ignore_trailing_slash: false } + } + } + } + }, options ); expect(domains[redirectHost]?.links['/docs']?.url).toBe(url); }); + it.each(['/DOCS', '/docs/', '/DOCS/Install'])( + 'rejects a destination matching the same link: %s', + (path) => { + expect(() => + validateConfig( + { + ...configurationInput(), + domains: { + [redirectHost]: { + links: { + '/docs': { + url: `https://${redirectHost}${path}?changed=one#fragment`, + forward_path: true, + enabled: false + } + } + } + } + }, + options + ) + ).toThrowError(new ConfigError()); + } + ); + + it('uses all prepared links to distinguish destination identity from its parent prefix', () => { + const { domains } = validateConfig( + { + ...configurationInput(), + domains: { + [redirectHost]: { + links: { + '/docs': { url: `https://${redirectHost}/DOCS/API/`, forward_path: true }, + '/docs/api': { url: 'https://destination.example/api' } + } + } + } + }, + options + ); + expect(domains[redirectHost]?.links['/docs']?.url).toBe(`https://${redirectHost}/DOCS/API/`); + }); + it('allows local fallback URLs without graph analysis', () => { const input = configurationInput(); const fallback = { url: 'https://go.example.test/landing' }; @@ -117,6 +169,7 @@ describe('effective domains', () => { root: null, fallback: null, provenance: { fallback: 'builtin' }, + caseInsensitivePaths: { '/docs': '/docs' }, links: { '/docs': { url: 'https://example.com/docs', @@ -125,7 +178,16 @@ describe('effective domains', () => { enabled: true, status_code: 302, forward_query: false, - provenance: { status_code: 'builtin', forward_query: 'builtin' } + case_insensitive: true, + ignore_trailing_slash: true, + forward_path: false, + provenance: { + status_code: 'builtin', + forward_query: 'builtin', + case_insensitive: 'builtin', + ignore_trailing_slash: 'builtin', + forward_path: 'builtin' + } } } }); @@ -135,20 +197,65 @@ describe('effective domains', () => { expect(configuration.domains['go.example.com']).not.toHaveProperty('fallback'); }); + it('records explicit global false matching defaults instead of treating them as omissions', () => { + const { configuration, domains } = validateConfig( + { + ...configurationInput(), + link_defaults: { + case_insensitive: false, + ignore_trailing_slash: false, + forward_path: false + } + }, + options + ); + expect(configuration.domains['go.example.com']?.link_defaults).toEqual({}); + expect(domains['go.example.com']?.links['/docs']).toMatchObject({ + case_insensitive: false, + ignore_trailing_slash: false, + forward_path: false, + provenance: { + case_insensitive: 'global', + ignore_trailing_slash: 'global', + forward_path: 'global' + } + }); + expect(domains['go.example.com']?.caseInsensitivePaths).toEqual({}); + }); + it('resolves global, domain and link overrides, including explicit false', () => { const input = configurationInput({ '/inherit': { url: 'https://example.com' }, - '/override': { url: 'https://example.com', status_code: 308, forward_query: false } + '/override': { + url: 'https://example.com', + status_code: 308, + forward_query: false, + case_insensitive: false, + ignore_trailing_slash: false, + forward_path: false + } }); const { domains } = validateConfig( { ...input, - link_defaults: { status_code: 301, forward_query: true }, + link_defaults: { + status_code: 301, + forward_query: true, + case_insensitive: true, + ignore_trailing_slash: true, + forward_path: true + }, fallback: { url: 'https://example.com' }, domains: { global: input.domains['go.example.com'], domain: { - link_defaults: { status_code: 307, forward_query: false }, + link_defaults: { + status_code: 307, + forward_query: false, + case_insensitive: false, + ignore_trailing_slash: false, + forward_path: false + }, fallback: { url: 'https://example.com/domain-missing' }, links: input.domains['go.example.com'].links } @@ -159,11 +266,28 @@ describe('effective domains', () => { expect(domains.global?.links['/inherit']).toMatchObject({ status_code: 301, forward_query: true, - provenance: { status_code: 'global', forward_query: 'global' } + case_insensitive: true, + ignore_trailing_slash: true, + forward_path: true, + provenance: { + status_code: 'global', + forward_query: 'global', + case_insensitive: 'global', + ignore_trailing_slash: 'global', + forward_path: 'global' + } }); expect(domains.global?.links['/override']).toMatchObject({ forward_query: false, - provenance: { forward_query: 'link' } + case_insensitive: false, + ignore_trailing_slash: false, + forward_path: false, + provenance: { + forward_query: 'link', + case_insensitive: 'link', + ignore_trailing_slash: 'link', + forward_path: 'link' + } }); expect(domains.global).toMatchObject({ fallback: { url: 'https://example.com', status_code: 302, forward_query: false }, @@ -172,7 +296,16 @@ describe('effective domains', () => { expect(domains.domain?.links['/inherit']).toMatchObject({ status_code: 307, forward_query: false, - provenance: { status_code: 'domain', forward_query: 'domain' } + case_insensitive: false, + ignore_trailing_slash: false, + forward_path: false, + provenance: { + status_code: 'domain', + forward_query: 'domain', + case_insensitive: 'domain', + ignore_trailing_slash: 'domain', + forward_path: 'domain' + } }); expect(domains.domain).toMatchObject({ fallback: { @@ -185,7 +318,16 @@ describe('effective domains', () => { expect(domains.domain?.links['/override']).toMatchObject({ status_code: 308, forward_query: false, - provenance: { status_code: 'link', forward_query: 'link' } + case_insensitive: false, + ignore_trailing_slash: false, + forward_path: false, + provenance: { + status_code: 'link', + forward_query: 'link', + case_insensitive: 'link', + ignore_trailing_slash: 'link', + forward_path: 'link' + } }); expect(domains.domain?.links['/override']).not.toHaveProperty('fallback'); }); @@ -211,16 +353,60 @@ describe('effective domains', () => { root: null, fallback: { url: 'https://example.com/global', status_code: 307, forward_query: true }, provenance: { fallback: 'global' }, - links: {} + links: {}, + caseInsensitivePaths: {} }); expect(domains.replaced).toEqual({ root: { url: 'https://example.com/root', status_code: 301, forward_query: true }, fallback: { url: 'https://example.com/domain', status_code: 302, forward_query: false }, provenance: { fallback: 'domain' }, - links: {} + links: {}, + caseInsensitivePaths: {} }); }); + it.each([ + [true, true], + [true, false], + [false, true] + ])( + 'rejects overlapping case identities with insensitive flags %s and %s in either order', + (first, second) => { + const links = { + '/Docs': { url: 'https://destination.example/first', case_insensitive: first }, + '/docs': { url: 'https://destination.example/second', case_insensitive: second } + }; + for (const entries of [Object.entries(links), Object.entries(links).reverse()]) { + expect(() => + validateConfig(configurationInput(Object.fromEntries(entries)), options) + ).toThrowError(new ConfigError()); + } + } + ); + + it('keeps distinct strict case identities and non-ASCII spelling independent', () => { + const { domains } = validateConfig( + configurationInput({ + '/Docs': { url: 'https://destination.example/first', case_insensitive: false }, + '/docs': { url: 'https://destination.example/second', case_insensitive: false }, + '/café': { url: 'https://destination.example/lower' }, + '/CAFÉ': { url: 'https://destination.example/upper' } + }), + options + ); + expect(Object.keys(domains['go.example.com']!.links)).toEqual([ + '/Docs', + '/docs', + '/café', + '/CAFÉ' + ]); + expect(domains['go.example.com']?.caseInsensitivePaths).toEqual({ + '/café': '/café', + '/cafÉ': '/CAFÉ' + }); + expect(Object.getPrototypeOf(domains['go.example.com']!.caseInsensitivePaths)).toBeNull(); + }); + it('rejects the root as a named link', () => { expect(() => validateConfig(configurationInput({ '/': { url: 'https://example.com' } }), options) diff --git a/packages/config/src/resolve.ts b/packages/config/src/resolve.ts index 7d3b603..4a2f01f 100644 --- a/packages/config/src/resolve.ts +++ b/packages/config/src/resolve.ts @@ -1,4 +1,5 @@ import { ConfigError } from './errors.js'; +import { foldAsciiCase } from './matching.js'; import { isCanonicalPath, normalizeHost } from './paths.js'; import { isSelfRedirect } from './redirect.js'; import type { DomainConfiguration, GotoConfiguration, LinkConfiguration } from './schema.js'; @@ -41,9 +42,24 @@ function prepareLink( link.forward_query ?? domain.link_defaults.forward_query ?? configuration.link_defaults.forward_query, + case_insensitive: + link.case_insensitive ?? + domain.link_defaults.case_insensitive ?? + configuration.link_defaults.case_insensitive, + ignore_trailing_slash: + link.ignore_trailing_slash ?? + domain.link_defaults.ignore_trailing_slash ?? + configuration.link_defaults.ignore_trailing_slash, + forward_path: + link.forward_path ?? + domain.link_defaults.forward_path ?? + configuration.link_defaults.forward_path, provenance: { status_code: linkSettingSource('status_code', global, domain, link), - forward_query: linkSettingSource('forward_query', global, domain, link) + forward_query: linkSettingSource('forward_query', global, domain, link), + case_insensitive: linkSettingSource('case_insensitive', global, domain, link), + ignore_trailing_slash: linkSettingSource('ignore_trailing_slash', global, domain, link), + forward_path: linkSettingSource('forward_path', global, domain, link) } }; } @@ -62,18 +78,38 @@ export function prepareDomains( if (domain.root && isSelfRedirect(domain.root.url, host, '/')) throw new ConfigError(); const links: Record = Object.create(null); for (const [path, link] of Object.entries(domain.links)) { - if (isSelfRedirect(link.url, host, path)) throw new ConfigError(); links[path] = prepareLink(path, link, domain, configuration, authoredLinkDefaults); } + const caseInsensitivePaths = prepareCaseInsensitivePaths(links); let fallbackSource: SettingSource = 'builtin'; if (domain.fallback) fallbackSource = 'domain'; else if (configuration.fallback) fallbackSource = 'global'; - domains[host] = { + const prepared: PreparedDomain = { root: domain.root ?? null, fallback: domain.fallback ?? configuration.fallback ?? null, provenance: { fallback: fallbackSource }, - links + links, + caseInsensitivePaths }; + for (const [path, link] of Object.entries(links)) { + if (isSelfRedirect(link.url, host, path, prepared)) throw new ConfigError(); + } + domains[host] = prepared; } return domains; } + +function prepareCaseInsensitivePaths(links: Record): Record { + const paths: Record = Object.create(null); + for (const [path, link] of Object.entries(links)) { + if (!link.case_insensitive) continue; + const folded = foldAsciiCase(path); + if (Object.hasOwn(paths, folded)) throw new ConfigError(); + paths[folded] = path; + } + for (const path of Object.keys(links)) { + const alias = paths[foldAsciiCase(path)]; + if (alias !== undefined && alias !== path) throw new ConfigError(); + } + return paths; +} diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index 1755770..fda8d56 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -232,7 +232,10 @@ const AnalyticsSchema = Type.Object( const LinkDefaultsSchema = Type.Object( { status_code: ref(RedirectStatusSchema, { default: 302 }), - forward_query: Type.Boolean({ default: false }) + forward_query: Type.Boolean({ default: false }), + case_insensitive: Type.Boolean({ default: true }), + ignore_trailing_slash: Type.Boolean({ default: true }), + forward_path: Type.Boolean({ default: false }) }, { $id: 'LinkDefaults', additionalProperties: false } ); @@ -240,7 +243,10 @@ const LinkDefaultsSchema = Type.Object( const DomainLinkDefaultsSchema = Type.Object( { status_code: Type.Optional(ref(RedirectStatusSchema)), - forward_query: Type.Optional(Type.Boolean()) + forward_query: Type.Optional(Type.Boolean()), + case_insensitive: Type.Optional(Type.Boolean()), + ignore_trailing_slash: Type.Optional(Type.Boolean()), + forward_path: Type.Optional(Type.Boolean()) }, { $id: 'DomainLinkDefaults', @@ -258,6 +264,9 @@ const LinkConfigurationSchema = Type.Object( enabled: Type.Boolean({ default: true }), status_code: Type.Optional(ref(RedirectStatusSchema)), forward_query: Type.Optional(Type.Boolean()), + case_insensitive: Type.Optional(Type.Boolean()), + ignore_trailing_slash: Type.Optional(Type.Boolean()), + forward_path: Type.Optional(Type.Boolean()), valid_from: Type.Optional(ref(InstantSchema)), valid_until: Type.Optional(ref(InstantSchema)) }, @@ -265,7 +274,7 @@ const LinkConfigurationSchema = Type.Object( $id: 'LinkConfiguration', additionalProperties: false, description: - 'Omitted status_code and forward_query inherit domain link_defaults, then global link_defaults.' + 'Omitted response and matching settings inherit domain link_defaults, then global link_defaults.' } ); @@ -280,7 +289,7 @@ const DomainConfigurationSchema = Type.Object( { additionalProperties: false, description: - 'Logical, case-sensitive named paths. Keys are never interpolated or percent-decoded. Use readable canonical paths without spaces, trailing slashes, controls, backslashes, dot segments or :?#[]@. Configure / with the separate root response.' + 'Logical named paths. Keys preserve their spelling and are never interpolated or percent-decoded. Use readable canonical paths without spaces, trailing slashes, controls, backslashes, dot segments or :?#[]@. Configure / with the separate root response.' } ) }, diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 44ae5e9..106aa2f 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -14,13 +14,27 @@ export interface ConfigOptions { export type SettingSource = 'builtin' | 'global' | 'domain' | 'link'; -export type PreparedLink = Omit & { +export type PreparedLink = Omit< + LinkConfiguration, + | 'title' + | 'status_code' + | 'forward_query' + | 'case_insensitive' + | 'ignore_trailing_slash' + | 'forward_path' +> & { title: string; status_code: RedirectStatus; forward_query: boolean; + case_insensitive: boolean; + ignore_trailing_slash: boolean; + forward_path: boolean; provenance: { status_code: SettingSource; forward_query: SettingSource; + case_insensitive: SettingSource; + ignore_trailing_slash: SettingSource; + forward_path: SettingSource; }; }; @@ -29,6 +43,7 @@ export interface PreparedDomain { fallback: RedirectResponse | null; provenance: { fallback: SettingSource }; links: Record; + caseInsensitivePaths: Record; } export interface PreparedConfiguration {