feat(redirect): add flexible named-link matching #28

Merged
eleith merged 1 commits from feat/flexible-matching into main 2026-11-14 05:35:36 +00:00
26 changed files with 790 additions and 117 deletions

No files matched your search

+7
View File
@@ -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.
+13 -12
View File
@@ -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();
+2
View File
@@ -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']
+7 -3
View File
@@ -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 {
+116 -3
View File
@@ -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> = {}): 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<LinkConfiguration, 'url'> & Partial<LinkConfiguration>;
function matchingDomain(links: Record<string, LinkInput>): 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=☕#完成';
+52 -28
View File
@@ -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<RedirectResponse> | 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<PreparedDomain>,
path: string,
requestedPath: string,
now: number
): Immutable<PreparedLink> | 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
};
+4
View File
@@ -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');
@@ -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,
@@ -1,7 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { resolveVisitTimeframe, visitGranularity, visitWindow } from '@goto/analytics/daily';
import type { SettingSource } from '@goto/config';
import VisitsChart from '#lib/components/admin/VisitsChart.svelte';
import BrowserOsBreakdowns from '#lib/components/admin/BrowserOsBreakdowns.svelte';
import VisitTimeframes from '#lib/components/admin/VisitTimeframes.svelte';
@@ -24,15 +23,6 @@
let browserOsVisits = $derived(data.browserOsVisitsByTimeframe[timeframe] ?? null);
</script>
{#snippet sourceHint(source: SettingSource)}
<span class="source-hint">
{#if source === 'builtin'}Built-in
{:else if source === 'global'}Global defaults
{:else if source === 'domain'}Domain defaults
{:else}Link override{/if}
</span>
{/snippet}
<AdminPage
siteTitle={data.title}
title="Link"
@@ -67,10 +57,8 @@
<section class="admin-info" aria-labelledby="link-details-heading">
<h2 id="link-details-heading">Link details</h2>
<dl>
<dt>Domain</dt>
<dd><a href={`${data.domainHref}?period=${timeframe}`}>{data.domain}</a></dd>
<dt>Path</dt>
<dd>{data.path}</dd>
<dt>Link</dt>
<dd>{data.domain}{data.path}</dd>
<dt>Destination</dt>
<dd>
<a class="admin-destination" href={data.response.url}>
@@ -78,15 +66,15 @@
</a>
</dd>
<dt>HTTP status</dt>
<dd>
{data.response.status_code}
{@render sourceHint(data.sources.status_code)}
</dd>
<dt>Forward query</dt>
<dd>
{data.response.forward_query ? 'Yes' : 'No'}
{@render sourceHint(data.sources.forward_query)}
</dd>
<dd>{data.response.status_code}</dd>
<dt>Case insensitive</dt>
<dd>{data.caseInsensitive ? 'Yes' : 'No'}</dd>
<dt>Trailing slash</dt>
<dd>{data.ignoreTrailingSlash ? 'Yes' : 'No'}</dd>
<dt>Path forwarding</dt>
<dd>{data.forwardPath ? 'Yes' : 'No'}</dd>
<dt>Query forwarding</dt>
<dd>{data.response.forward_query ? 'Yes' : 'No'}</dd>
{#if data.validFrom}
<dt>Starts</dt>
<dd>
@@ -112,11 +100,3 @@
<BrowserOsBreakdowns {browserOsVisits} totalVisits={visits} />
{/if}
</AdminPage>
<style>
.source-hint {
margin-inline-start: calc(var(--space-1) * 2);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
</style>
@@ -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<DomainConfiguration> = {
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');
@@ -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
};
@@ -38,6 +38,12 @@
<dl>
<dt>Status code</dt>
<dd>{data.redirectStatusCode}</dd>
<dt>Case insensitive</dt>
<dd>{data.caseInsensitive ? 'Yes' : 'No'}</dd>
<dt>Trailing slash</dt>
<dd>{data.ignoreTrailingSlash ? 'Yes' : 'No'}</dd>
<dt>Path forwarding</dt>
<dd>{data.forwardPath ? 'Yes' : 'No'}</dd>
<dt>Query forwarding</dt>
<dd>{data.forwardQuery ? 'Yes' : 'No'}</dd>
<dt>Fallback</dt>
@@ -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'
});
@@ -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' }
};
+4
View File
@@ -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
+13 -4
View File
@@ -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" } }
}
+19 -1
View File
@@ -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'
+1
View File
@@ -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,
+26 -3
View File
@@ -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' }
});
});
+139
View File
@@ -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<typeof configurationInput>[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
);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { hasControlCharacters } from './text.js';
import type { PreparedLink } from './types.js';
export interface MatchingDomain {
readonly links: Readonly<
Record<
string,
Pick<PreparedLink, 'case_insensitive' | 'ignore_trailing_slash' | 'forward_path'>
>
>;
readonly caseInsensitivePaths: Readonly<Record<string, string>>;
}
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;
}
+14 -2
View File
@@ -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;
}
+197 -11
View File
@@ -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)
+40 -4
View File
@@ -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<string, PreparedLink> = 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<string, PreparedLink>): Record<string, string> {
const paths: Record<string, string> = 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;
}
+13 -4
View File
@@ -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.'
}
)
},
+16 -1
View File
@@ -14,13 +14,27 @@ export interface ConfigOptions {
export type SettingSource = 'builtin' | 'global' | 'domain' | 'link';
export type PreparedLink = Omit<LinkConfiguration, 'title' | 'status_code' | 'forward_query'> & {
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<string, PreparedLink>;
caseInsensitivePaths: Record<string, string>;
}
export interface PreparedConfiguration {