mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: resolve deep links to the local instance
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { TARGETS, TARGET_NAMES, getTarget, resolveTarget } from '@/lib/connector/registry';
|
||||
import { safeAdminNext } from '@/lib/connector/admin-next';
|
||||
|
||||
const resolve = (name: string, search: Record<string, string> = {}) =>
|
||||
resolveTarget(name, search);
|
||||
|
||||
const path = (name: string, search: Record<string, string> = {}) => {
|
||||
const result = resolve(name, search);
|
||||
if (result.kind !== 'ok') throw new Error(`${name} did not resolve: ${result.kind}`);
|
||||
return result.resolution.path;
|
||||
};
|
||||
|
||||
describe('the registry', () => {
|
||||
it('has unique names that are safe as one path segment', () => {
|
||||
expect(new Set(TARGET_NAMES).size).toBe(TARGET_NAMES.length);
|
||||
for (const name of TARGET_NAMES) expect(name).toMatch(/^[a-z][a-z0-9_]*$/);
|
||||
});
|
||||
|
||||
it('labels every target for the "unknown link" card', () => {
|
||||
for (const target of TARGETS) expect(target.label.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('does not inherit names from Object.prototype', () => {
|
||||
for (const name of ['constructor', '__proto__', 'toString']) {
|
||||
expect(getTarget(name)).toBeNull();
|
||||
expect(resolve(name).kind).toBe('unknown-target');
|
||||
}
|
||||
});
|
||||
|
||||
it('reports an unknown target rather than throwing or 404ing', () => {
|
||||
expect(resolve('plugin_sandbox_settings').kind).toBe('unknown-target');
|
||||
expect(resolveTarget(null, {}).kind).toBe('unknown-target');
|
||||
expect(resolveTarget('', {}).kind).toBe('unknown-target');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolving to app paths', () => {
|
||||
it('resolves the bare surfaces', () => {
|
||||
expect(path('app')).toBe('/mail');
|
||||
expect(path('contacts')).toBe('/contacts');
|
||||
expect(path('settings')).toBe('/settings');
|
||||
expect(path('setup')).toBe('/setup');
|
||||
});
|
||||
|
||||
it('resolves a settings tab', () => {
|
||||
expect(path('settings', { tab: 'filters' })).toBe('/settings/filters');
|
||||
});
|
||||
|
||||
it('resolves a calendar view and date', () => {
|
||||
expect(path('calendar')).toBe('/calendar/month');
|
||||
expect(path('calendar', { view: 'week' })).toBe('/calendar/week');
|
||||
expect(path('calendar', { view: 'day', date: '2026-09-20' })).toBe('/calendar/day/2026-09-20');
|
||||
});
|
||||
|
||||
it('resolves a mail folder by alias and by opaque id', () => {
|
||||
expect(path('mail_folder', { ref: 'inbox' })).toBe('/mail/folder/inbox');
|
||||
expect(path('mail_folder', { ref: 'a1b2c3' })).toBe('/mail/folder/a1b2c3');
|
||||
expect(path('mail_folder')).toBe('/mail');
|
||||
});
|
||||
|
||||
it('resolves a drive path', () => {
|
||||
expect(path('files', { path: 'Documents/Invoices' })).toBe('/files/Documents/Invoices');
|
||||
expect(path('files')).toBe('/files');
|
||||
});
|
||||
|
||||
it('marks the admin targets, and carries the tab the shell keeps in a store', () => {
|
||||
const admin = resolve('admin', { tab: 'policy' });
|
||||
expect(admin.kind).toBe('ok');
|
||||
if (admin.kind === 'ok') {
|
||||
expect(admin.resolution).toEqual({ path: '/admin', adminTab: 'policy', admin: true });
|
||||
}
|
||||
|
||||
const auth = resolve('admin_auth');
|
||||
if (auth.kind === 'ok') expect(auth.resolution.adminTab).toBe('auth');
|
||||
|
||||
const ext = resolve('admin_extension', { slug: 'quick-reply' });
|
||||
if (ext.kind === 'ok') {
|
||||
expect(ext.resolution).toEqual({ path: '/admin/marketplace/quick-reply', admin: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults the admin tab when the link named none', () => {
|
||||
const admin = resolve('admin');
|
||||
if (admin.kind === 'ok') expect(admin.resolution.adminTab).toBe('dashboard');
|
||||
});
|
||||
|
||||
it('leaves the user targets unmarked, so they take the normal login path', () => {
|
||||
for (const name of ['app', 'settings', 'calendar', 'contacts', 'files', 'mail_folder']) {
|
||||
const result = resolve(name);
|
||||
if (result.kind === 'ok') expect(result.resolution.admin).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('parameter validation', () => {
|
||||
it('refuses a settings tab that is not a real tab', () => {
|
||||
const result = resolve('settings', { tab: 'root' });
|
||||
expect(result.kind).toBe('bad-param');
|
||||
if (result.kind === 'bad-param') expect(result.param).toBe('tab');
|
||||
});
|
||||
|
||||
it('requires a required parameter', () => {
|
||||
expect(resolve('admin_extension').kind).toBe('bad-param');
|
||||
expect(resolve('admin_extension', { slug: '' }).kind).toBe('bad-param');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'../../etc/passwd',
|
||||
'//evil.example',
|
||||
'Quick-Reply',
|
||||
'quick reply',
|
||||
'javascript:alert(1)',
|
||||
'a'.repeat(65),
|
||||
])('refuses the extension slug %j', (slug) => {
|
||||
expect(resolve('admin_extension', { slug }).kind).toBe('bad-param');
|
||||
});
|
||||
|
||||
it.each(['../admin', 'a/b', 'a\\b', 'a?b', 'a#b', '.', '..'])(
|
||||
'refuses the mailbox ref %j',
|
||||
(ref) => {
|
||||
expect(resolve('mail_folder', { ref }).kind).toBe('bad-param');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['2026-02-31', '2026-13-01', '20260920', 'tomorrow'])(
|
||||
'refuses the date %j',
|
||||
(date) => {
|
||||
expect(resolve('calendar', { date }).kind).toBe('bad-param');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['../secrets', 'Documents/../../etc', 'a?b'])('refuses the drive path %j', (p) => {
|
||||
expect(resolve('files', { path: p }).kind).toBe('bad-param');
|
||||
});
|
||||
|
||||
it('refuses an over-long value whatever its shape', () => {
|
||||
expect(resolve('mail_folder', { ref: 'a'.repeat(513) }).kind).toBe('bad-param');
|
||||
});
|
||||
|
||||
it('drops a parameter the target does not declare, rather than forwarding it', () => {
|
||||
// A connector newer than this instance may carry something extra; the link
|
||||
// should still open the surface it named.
|
||||
expect(path('settings', { tab: 'filters', redirect: 'https://evil.example' })).toBe(
|
||||
'/settings/filters',
|
||||
);
|
||||
});
|
||||
|
||||
it('never lets a parameter escape its path segment', () => {
|
||||
for (const hostile of ['../admin', '..%2Fadmin', 'a/../../b']) {
|
||||
const result = resolve('mail_folder', { ref: hostile });
|
||||
if (result.kind === 'ok') {
|
||||
// If it was accepted at all, it must still be a single segment.
|
||||
const tail = result.resolution.path.replace('/mail/folder/', '');
|
||||
expect(tail).not.toContain('/');
|
||||
expect(decodeURIComponent(tail)).not.toContain('..');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeAdminNext', () => {
|
||||
it.each([
|
||||
['/admin', '/admin'],
|
||||
['/admin/', '/admin/'],
|
||||
['/admin/marketplace/quick-reply', '/admin/marketplace/quick-reply'],
|
||||
['/admin?tab=policy', '/admin?tab=policy'],
|
||||
['%2Fadmin%2Fplugins%2Fx', '/admin/plugins/x'],
|
||||
])('accepts %j', (raw, expected) => {
|
||||
expect(safeAdminNext(raw)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
undefined,
|
||||
'',
|
||||
'/mail',
|
||||
'/settings/filters',
|
||||
'//evil.example',
|
||||
'/\\evil.example',
|
||||
'https://evil.example',
|
||||
'javascript:alert(1)',
|
||||
'/admin/../mail',
|
||||
'/adminfoo',
|
||||
'/admin?tab=policy&x=1',
|
||||
'/admin?next=https://evil.example',
|
||||
'%2f%2fevil.example',
|
||||
'/admin/' + 'a'.repeat(600),
|
||||
])('refuses %j', (raw) => {
|
||||
expect(safeAdminNext(raw as string | null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parity with the connector', () => {
|
||||
// The connector (github.com/bulwarkmail/connector) owns the names and the
|
||||
// parameter schemas; this side owns the paths. When both checkouts are
|
||||
// present, the names must match exactly - a target this side cannot resolve
|
||||
// would be a published link that 404s, and one the connector never emits is
|
||||
// dead code here.
|
||||
const connectorRegistry = join(process.cwd(), 'repos', 'connector', 'src', 'lib', 'registry.ts');
|
||||
|
||||
it.skipIf(!existsSync(connectorRegistry))('names the same targets', () => {
|
||||
const source = readFileSync(connectorRegistry, 'utf8');
|
||||
const theirs = [...source.matchAll(/^\s{4}name: "([a-z0-9_]+)",$/gm)].map((m) => m[1]);
|
||||
expect(theirs.length).toBeGreaterThan(0);
|
||||
expect([...theirs].sort()).toEqual([...TARGET_NAMES].sort());
|
||||
});
|
||||
|
||||
it.skipIf(!existsSync(connectorRegistry))(
|
||||
'never names a target after a path the connector site owns',
|
||||
() => {
|
||||
const source = readFileSync(connectorRegistry, 'utf8');
|
||||
const reserved = /RESERVED_PATHS = \[([^\]]+)\]/
|
||||
.exec(source)?.[1]
|
||||
.match(/"([a-z-]+)"/g)
|
||||
?.map((s) => s.replace(/"/g, '')) ?? [];
|
||||
expect(reserved.length).toBeGreaterThan(0);
|
||||
for (const name of TARGET_NAMES) expect(reserved).not.toContain(name);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -265,6 +265,15 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
logLevel: { envVar: 'LOG_LEVEL', type: 'enum', defaultValue: 'info', enumValues: ['error', 'warn', 'info', 'debug'] },
|
||||
sessionSecret: { envVar: 'SESSION_SECRET', fileEnvVar: 'SESSION_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
extensionDirectoryUrl: { envVar: 'EXTENSION_DIRECTORY_URL', type: 'url', defaultValue: 'https://extensions.bulwarkmail.org' },
|
||||
// Connector links (connector.bulwarkmail.org): docs and the extension
|
||||
// directory link to a destination, and the reader's own browser resolves it
|
||||
// to their instance. `connectorEnabled` covers both halves - the
|
||||
// /api/connector/capabilities endpoint a connector probes, and the "add this
|
||||
// instance" links the app offers - so a deployment that wants no third-party
|
||||
// hostname anywhere can switch the whole thing off. `connectorUrl` points a
|
||||
// white-label or self-hosted deployment at its own connector.
|
||||
connectorEnabled: { envVar: 'CONNECTOR_ENABLED', type: 'boolean', defaultValue: true },
|
||||
connectorUrl: { envVar: 'CONNECTOR_URL', type: 'url', defaultValue: 'https://connector.bulwarkmail.org' },
|
||||
// WOPI document editing (#425). `wopiClientUrl` is the editor's base URL
|
||||
// (Collabora Online / OnlyOffice / EuroOffice, ...); discovery is fetched
|
||||
// from `<url>/hosting/discovery` unless the URL already carries a path.
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The `?next=` round-trip for the admin area.
|
||||
*
|
||||
* A connector link to an admin panel lands on `/admin/...` with no admin
|
||||
* session, the layout bounces to `/admin/login`, and without this the admin
|
||||
* would log in and arrive at the dashboard instead of the extension they
|
||||
* clicked Install on.
|
||||
*
|
||||
* This is the one place in the whole connector flow where a *path* comes out
|
||||
* of a URL rather than out of a fixed template, so it gets the strictest
|
||||
* check in it. Everything that is not unmistakably an admin path inside this
|
||||
* app is thrown away and the login falls back to `/admin`.
|
||||
*/
|
||||
|
||||
const SAFE = /^\/admin(?:\/[A-Za-z0-9._~-]+)*\/?$/;
|
||||
|
||||
/**
|
||||
* Returns a path safe to `router.replace()` after login, or null.
|
||||
*
|
||||
* Refuses anything that could leave this origin or this area: a scheme, a
|
||||
* protocol-relative `//host`, a backslash (which some browsers normalise to
|
||||
* `/`), an encoded separator, a `..` segment, or a path outside `/admin`.
|
||||
* A `?tab=` query is allowed, and validated by the caller against the real
|
||||
* tab list - the admin shell keeps its tab in a store, so that is how a link
|
||||
* reaches a specific panel.
|
||||
*/
|
||||
export function safeAdminNext(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
|
||||
let value = raw;
|
||||
// One decode, so `%2f%2fevil` cannot smuggle a separator past the checks
|
||||
// below. A value that is not valid percent-encoding is not a path we wrote.
|
||||
try {
|
||||
value = decodeURIComponent(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.length > 512) return null;
|
||||
if (value.includes('\\') || value.includes('\0')) return null;
|
||||
if (value.startsWith('//')) return null;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return null;
|
||||
|
||||
const [path, query = ''] = value.split('?', 2);
|
||||
if (!SAFE.test(path)) return null;
|
||||
if (path.split('/').includes('..')) return null;
|
||||
// Only the tab hand-off, and only as a plain word.
|
||||
if (query && !/^tab=[a-z_]{1,32}$/.test(query)) return null;
|
||||
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* The instance half of the connector contract.
|
||||
*
|
||||
* A connector link names a destination, not a host:
|
||||
*
|
||||
* https://connector.bulwarkmail.org/settings?tab=filters
|
||||
*
|
||||
* The connector (repos/connector, github.com/bulwarkmail/connector) resolves
|
||||
* that against whichever instance the reader's browser has stored, and sends
|
||||
* them to `<instance>/connector/settings?tab=filters`. This file is what
|
||||
* happens next: it turns a target name plus its parameters into a real path
|
||||
* inside this app.
|
||||
*
|
||||
* The split is deliberate. The connector knows the *names* and the parameter
|
||||
* schemas; only this file knows the *paths*. So a route can move here without
|
||||
* breaking a link that was published a year ago, and an instance older than
|
||||
* the connector renders a "this version doesn't know that link" card instead
|
||||
* of a 404.
|
||||
*
|
||||
* `TARGET_NAMES` and `PARAMS` mirror `src/lib/registry.ts` in the connector.
|
||||
* `__tests__/connector-registry.test.ts` checks the halves agree; when they
|
||||
* diverge, this side is the one that must not silently accept something it
|
||||
* cannot resolve.
|
||||
*
|
||||
* Nothing here interpolates raw input into a path. Every parameter is checked
|
||||
* against its declared type first, and the templates are fixed strings - there
|
||||
* is deliberately no parameter that carries a path, a URL, or a redirect.
|
||||
*/
|
||||
|
||||
import { ADMIN_TABS, type AdminTabId } from '@/stores/admin-tab-store';
|
||||
import type { SettingsSearchTab } from '@/lib/settings-search';
|
||||
import { buildSettingsPath, buildFilesPath } from '@/lib/deep-links';
|
||||
|
||||
/** Mirrors `SettingsSearchTab`. Listed so an unknown tab is refused here. */
|
||||
const SETTINGS_TABS: readonly SettingsSearchTab[] = [
|
||||
'account', 'language', 'notifications', 'appearance', 'layout', 'reading',
|
||||
'composing', 'downloads', 'identities', 'vacation', 'filters', 'templates',
|
||||
'folders', 'keywords', 'security', 'content_senders', 'calendar', 'contacts',
|
||||
'files', 'protocol_handlers', 'sidebar_apps', 'about_data', 'themes',
|
||||
'plugins', 'debug',
|
||||
];
|
||||
|
||||
const CALENDAR_VIEWS = ['month', 'week', 'day', 'agenda', 'tasks'] as const;
|
||||
|
||||
// A mailbox ref is validated as an opaque id here, not against a list: the
|
||||
// readable aliases (`inbox`, `unified-sent`, ...) are resolved by
|
||||
// `resolveFolderRef` in lib/deep-links.ts, and a custom folder only has its
|
||||
// JMAP id. The connector carries the alias list purely as suggestions for its
|
||||
// link generator.
|
||||
|
||||
export type ParamType = 'enum' | 'slug' | 'date' | 'id' | 'path';
|
||||
|
||||
export interface ParamSpec {
|
||||
readonly type: ParamType;
|
||||
readonly values?: readonly string[];
|
||||
readonly required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a resolved target asks the client to do.
|
||||
*
|
||||
* Most targets are just a path. The admin ones carry a `tab` as well, because
|
||||
* the admin shell keeps its active tab in a zustand store rather than in the
|
||||
* URL (see app/(main)/admin/layout.tsx) - so reaching a specific panel means
|
||||
* setting that store before navigating to /admin. If admin ever gains real
|
||||
* `/admin/<tab>` routes, this collapses back into a plain path.
|
||||
*/
|
||||
export interface Resolution {
|
||||
/** App-relative path; feed through `appPath()` before navigating. */
|
||||
readonly path: string;
|
||||
readonly adminTab?: AdminTabId;
|
||||
/** True when the destination requires an admin session, not a user one. */
|
||||
readonly admin?: boolean;
|
||||
}
|
||||
|
||||
export interface Target {
|
||||
readonly name: string;
|
||||
/** Shown on the "this version doesn't know that link" card. */
|
||||
readonly label: string;
|
||||
readonly params?: Readonly<Record<string, ParamSpec>>;
|
||||
readonly resolve: (params: Readonly<Record<string, string>>) => Resolution;
|
||||
}
|
||||
|
||||
export const TARGETS: readonly Target[] = [
|
||||
{
|
||||
name: 'app',
|
||||
label: 'Bulwark',
|
||||
resolve: () => ({ path: '/mail' }),
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
label: 'Settings',
|
||||
params: { tab: { type: 'enum', values: SETTINGS_TABS } },
|
||||
resolve: (p) => ({ path: buildSettingsPath(p.tab ?? null) }),
|
||||
},
|
||||
{
|
||||
name: 'mail_folder',
|
||||
label: 'a mail folder',
|
||||
params: { ref: { type: 'id' } },
|
||||
resolve: (p) => ({
|
||||
path: p.ref ? `/mail/folder/${encodeURIComponent(p.ref)}` : '/mail',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'calendar',
|
||||
label: 'Calendar',
|
||||
params: {
|
||||
view: { type: 'enum', values: CALENDAR_VIEWS },
|
||||
date: { type: 'date' },
|
||||
},
|
||||
resolve: (p) => {
|
||||
const view = p.view ?? 'month';
|
||||
return { path: p.date ? `/calendar/${view}/${p.date}` : `/calendar/${view}` };
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'contacts',
|
||||
label: 'Contacts',
|
||||
resolve: () => ({ path: '/contacts' }),
|
||||
},
|
||||
{
|
||||
name: 'files',
|
||||
label: 'Files',
|
||||
params: { path: { type: 'path' } },
|
||||
resolve: (p) => ({ path: buildFilesPath(p.path ?? '') }),
|
||||
},
|
||||
{
|
||||
name: 'admin',
|
||||
label: 'the admin area',
|
||||
params: { tab: { type: 'enum', values: ADMIN_TABS } },
|
||||
resolve: (p) => ({
|
||||
path: '/admin',
|
||||
adminTab: (p.tab as AdminTabId | undefined) ?? 'dashboard',
|
||||
admin: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'admin_extension',
|
||||
label: 'an extension in the marketplace',
|
||||
params: { slug: { type: 'slug', required: true } },
|
||||
resolve: (p) => ({
|
||||
path: `/admin/marketplace/${encodeURIComponent(p.slug)}`,
|
||||
admin: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'admin_plugin',
|
||||
label: "a plugin's settings",
|
||||
params: { id: { type: 'slug', required: true } },
|
||||
resolve: (p) => ({ path: `/admin/plugins/${encodeURIComponent(p.id)}`, admin: true }),
|
||||
},
|
||||
{
|
||||
name: 'admin_auth',
|
||||
label: 'authentication settings',
|
||||
resolve: () => ({ path: '/admin', adminTab: 'auth', admin: true }),
|
||||
},
|
||||
{
|
||||
name: 'setup',
|
||||
label: 'the setup wizard',
|
||||
resolve: () => ({ path: '/setup' }),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const BY_NAME = new Map(TARGETS.map((t) => [t.name, t]));
|
||||
|
||||
/** Names this instance can resolve - what `/api/connector/capabilities` reports. */
|
||||
export const TARGET_NAMES: readonly string[] = TARGETS.map((t) => t.name);
|
||||
|
||||
export function getTarget(name: string | null | undefined): Target | null {
|
||||
if (!name) return null;
|
||||
return BY_NAME.get(name) ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_PARAM_LENGTH = 512;
|
||||
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function isRealDate(value: string): boolean {
|
||||
if (!DATE.test(value)) return false;
|
||||
const [y, m, d] = value.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d;
|
||||
}
|
||||
|
||||
function checkParam(spec: ParamSpec, value: string): boolean {
|
||||
if (value.length > MAX_PARAM_LENGTH) return false;
|
||||
switch (spec.type) {
|
||||
case 'enum':
|
||||
return (spec.values ?? []).includes(value);
|
||||
case 'slug':
|
||||
return SLUG.test(value);
|
||||
case 'date':
|
||||
return isRealDate(value);
|
||||
case 'id':
|
||||
// Becomes one path segment, so it must not be able to climb out of it.
|
||||
// `%` is refused along with the separators because the value is decoded
|
||||
// twice on the way in - once by the router, once by `resolveFolderRef`
|
||||
// in deep-links.ts - so `..%2Fadmin` would arrive as `../admin`. Real
|
||||
// JMAP ids have no percent signs in them.
|
||||
return !/[/\\?#%]/.test(value) && value !== '.' && value !== '..';
|
||||
case 'path': {
|
||||
const segments = value.split('/').filter(Boolean);
|
||||
if (segments.length === 0 || segments.length > 32) return false;
|
||||
return !segments.some((s) => s === '.' || s === '..' || /[\\?#]/.test(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolveResult =
|
||||
| { readonly kind: 'ok'; readonly target: Target; readonly resolution: Resolution }
|
||||
/** The registry has no such name - an instance older than the link. */
|
||||
| { readonly kind: 'unknown-target' }
|
||||
/** A declared parameter is malformed. Never echoed back into the page. */
|
||||
| { readonly kind: 'bad-param'; readonly target: Target; readonly param: string };
|
||||
|
||||
/**
|
||||
* Turns `/connector/<name>?<params>` into a path inside this app.
|
||||
*
|
||||
* Never throws and never 404s: an unparseable link is a card the user can read,
|
||||
* not a dead end. Unknown parameters are dropped rather than refused, so a
|
||||
* newer connector can hand an older instance a link carrying something extra
|
||||
* and have it still open the right surface.
|
||||
*/
|
||||
export function resolveTarget(
|
||||
name: string | null | undefined,
|
||||
search: URLSearchParams | Readonly<Record<string, string>>,
|
||||
): ResolveResult {
|
||||
const target = getTarget(name);
|
||||
if (!target) return { kind: 'unknown-target' };
|
||||
|
||||
const read = (key: string): string | null =>
|
||||
search instanceof URLSearchParams ? search.get(key) : (search[key] ?? null);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, spec] of Object.entries(target.params ?? {})) {
|
||||
const value = read(key);
|
||||
if (value === null || value === '') {
|
||||
if (spec.required) return { kind: 'bad-param', target, param: key };
|
||||
continue;
|
||||
}
|
||||
if (!checkParam(spec, value)) return { kind: 'bad-param', target, param: key };
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
return { kind: 'ok', target, resolution: target.resolve(params) };
|
||||
}
|
||||
Reference in New Issue
Block a user