feat(redirect): add flexible named-link matching #28
No files matched your search
@@ -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
@@ -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();
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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=☕#完成';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+11
-31
@@ -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
-7
@@ -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' }
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" } }
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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' }
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.'
|
||||
}
|
||||
)
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user