feat: resolve deep links to the local instance

This commit is contained in:
Linus Rath
2026-09-20 19:59:37 +02:00
parent c6bb4aace9
commit 77ef60caae
13 changed files with 797 additions and 5 deletions
+5 -1
View File
@@ -159,7 +159,11 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return;
}
router.replace('/admin/login');
// Carry the page that was asked for through the login, so a connector
// link to a panel (/connector/admin_extension?slug=x) does not dump
// the admin on the dashboard after signing in.
const next = encodeURIComponent(`${pathname}${window.location.search}`);
router.replace(`/admin/login?next=${next}`);
} catch (err) {
if (cancelled) return;
setAuthError(err instanceof Error ? err.message : 'Network error during admin check');
+14
View File
@@ -6,6 +6,8 @@ import { Shield } from '@/components/icons';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
import { safeAdminNext } from '@/lib/connector/admin-next';
import { isAdminTab, useAdminTabStore } from '@/stores/admin-tab-store';
export default function AdminLoginPage() {
const router = useRouter();
@@ -35,6 +37,18 @@ export default function AdminLoginPage() {
return;
}
// A connector link to an admin panel parks its destination in ?next=
// so logging in lands on the panel that was clicked, not the dashboard.
// safeAdminNext() is the gate: it is the only path here that comes out
// of a URL rather than out of a fixed template.
const next = safeAdminNext(new URLSearchParams(window.location.search).get('next'));
if (next) {
const [path, query] = next.split('?', 2);
const tab = new URLSearchParams(query ?? '').get('tab');
if (tab && isAdminTab(tab)) useAdminTabStore.getState().setActiveTab(tab);
router.push(path);
return;
}
router.push('/admin');
} catch {
setError('Network error. Please try again.');
+27
View File
@@ -0,0 +1,27 @@
import { ConnectorClient } from '@/components/connector/connector-client';
import { TARGET_NAMES } from '@/lib/connector/registry';
import { IS_LITE } from '@/lib/lite';
// Where a connector link lands: /connector/<target>?<params>, resolved to a
// real path inside the app by lib/connector/registry.ts.
//
// Outside [locale], like /protocol and /admin, so a published link never
// carries a locale it has no business choosing. Next matches the static
// `connector` segment before the `[locale]` one, and proxy.ts keeps next-intl
// from rewriting it.
//
// The target segment is all the server sees. The parameters are read from the
// address bar in the client, which is also what makes this work in the static
// Lite export, where there is no server to read them.
export const generateStaticParams = IS_LITE
? () => TARGET_NAMES.map((target) => ({ target }))
: undefined;
export default async function ConnectorRoute({
params,
}: {
params: Promise<{ target: string }>;
}) {
const { target } = await params;
return <ConnectorClient target={target} />;
}
+54
View File
@@ -0,0 +1,54 @@
import { NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
import { TARGET_NAMES } from '@/lib/connector/registry';
/**
* What a connector asks an instance about itself.
*
* Someone adding this instance at connector.bulwarkmail.org gets a request
* here, from their own browser, straight to their own server, with no
* credentials. It answers two questions: is this really a Bulwark, and which
* connector links can it resolve - so a link to a target this version does not
* have can warn before it opens instead of after.
*
* Unauthenticated on purpose: the browser making the probe has no session
* here, and the answer is narrower than the login page it sits next to. It
* says nothing about accounts, domains or the mail server - only the product,
* its version, the branded app name, and the target names, which are a
* compile-time constant.
*
* `appName` can be a customer's own branding, so the whole endpoint is gated
* on `connectorEnabled` for deployments that would rather disclose nothing.
*
* Bulwark Lite has no route handlers; scripts/lite/postbuild.mjs writes the
* same document to /connector.json, and the connector tries this first and
* falls back to that.
*/
export async function GET() {
await configManager.ensureLoaded();
if (!configManager.get<boolean>('connectorEnabled', true)) {
return new NextResponse(null, { status: 404 });
}
return NextResponse.json(
{
product: 'bulwark-webmail',
appName:
configManager.get<string>('appName', '') || process.env.NEXT_PUBLIC_APP_NAME || 'Bulwark',
version: process.env.NEXT_PUBLIC_APP_VERSION || null,
connectorPath: '/connector',
targets: TARGET_NAMES,
},
{
headers: {
// The probe is cross-origin by definition - it comes from the
// connector's origin, or from any other site someone points at their
// instance. Nothing here is per-user, so there is nothing for a
// wildcard to leak; credentials are never read.
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=300',
},
},
);
}
+105
View File
@@ -0,0 +1,105 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { resolveTarget, type ResolveResult } from '@/lib/connector/registry';
import { appPath } from '@/lib/deep-links';
import { replaceWindowLocation } from '@/lib/browser-navigation';
import { useAdminTabStore } from '@/stores/admin-tab-store';
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '';
/**
* Resolves a connector link and sends the browser to the real surface.
*
* Everything past the target name is read from `window.location` rather than
* handed down as a route prop: the Lite build is a static export with no
* server to read a query string, and on a server build it keeps the target out
* of the request the origin sees.
*
* It replaces rather than pushes, so Back returns to wherever the link was
* clicked instead of bouncing through here again.
*
* Authentication needs nothing special. The surface the user lands on does its
* own `saveRedirectAfterLogin()` + `redirectToLogin()`, and by then the
* address bar already holds the resolved path, so the login round-trip returns
* to the deep link. The admin area is the exception - see `?next=` below.
*/
export function ConnectorClient({ target }: { target: string }) {
const [result, setResult] = useState<ResolveResult | null>(null);
const setAdminTab = useAdminTabStore((s) => s.setActiveTab);
useEffect(() => {
setResult(resolveTarget(target, new URLSearchParams(window.location.search)));
}, [target]);
const destination = useMemo(() => {
if (result?.kind !== 'ok') return null;
const { path, adminTab } = result.resolution;
// The admin shell keeps its active tab in a store rather than in the URL,
// so a link to a specific panel selects it here. The store is persisted,
// so the choice survives an admin login in between.
return { url: appPath(path), adminTab: adminTab ?? null };
}, [result]);
useEffect(() => {
if (!destination) return;
if (destination.adminTab) setAdminTab(destination.adminTab);
// Nothing is appended to the URL. An admin who is not signed in gets
// bounced by the admin layout, which parks this destination in its own
// `?next=` - adding one here would nest inside that one and be refused.
replaceWindowLocation(destination.url);
}, [destination, setAdminTab]);
if (result === null || result.kind === 'ok') {
return <Shell>Opening…</Shell>;
}
// Neither case 404s. A link that this version cannot honour should say so
// and offer the app, not look like a broken server.
if (result.kind === 'unknown-target') {
return (
<Shell>
<h1 className="text-xl font-medium text-foreground">This link is newer than this Bulwark</h1>
<p className="mt-3 text-sm text-muted-foreground">
It opens something {APP_VERSION ? `version ${APP_VERSION} ` : 'this version '}
does not know about. Updating Bulwark should teach it.
</p>
<Actions />
</Shell>
);
}
return (
<Shell>
<h1 className="text-xl font-medium text-foreground">That link is not quite right</h1>
<p className="mt-3 text-sm text-muted-foreground">
It asks for {result.target.label}, but its{' '}
{/* The parameter is named, never echoed: its value is attacker-controlled. */}
<code className="rounded bg-muted px-1 py-0.5">{result.param}</code> is missing or
malformed. Nothing was opened.
</p>
<Actions />
</Shell>
);
}
function Actions() {
return (
<p className="mt-6">
<a
href={appPath('/mail')}
className="inline-flex h-9 items-center rounded-sm border border-border px-4 text-sm hover:bg-muted"
>
Open Bulwark
</a>
</p>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-dvh items-center justify-center bg-background p-6">
<div className="w-full max-w-md text-center text-muted-foreground">{children}</div>
</div>
);
}
+223
View File
@@ -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);
},
);
});
+9
View File
@@ -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.
+51
View File
@@ -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;
}
+250
View File
@@ -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) };
}
+13 -2
View File
@@ -77,7 +77,7 @@ export function isStaticAssetPath(pathname: string): boolean {
* Next-Url, Cookie, Accept-Language, ... from every page request that skips
* the intl middleware: all locale-prefixed paths - i.e. every page of a
* NEXT_PUBLIC_LOCALE_PREFIX=always (Docker) build - plus /admin, /protocol,
* /setup and the plugin sandbox. Since Next 16.3 the server recomputes the
* /setup, /connector and the plugin sandbox. Since Next 16.3 the server recomputes the
* `_rsc` cache-busting hash from those router headers
* (experimental.validateRSCRequestHeaders, on by default) and answers a
* mismatch with a 307 to the "expected" URL; the client re-requests, the
@@ -291,6 +291,10 @@ export async function proxy(request: NextRequest) {
const isAdminRoute = pathname === '/admin' || pathname.startsWith('/admin/');
const isProtocolRoute = pathname === '/protocol' || pathname.startsWith('/protocol/');
const isSetupRoute = pathname === '/setup' || pathname.startsWith('/setup/');
// Connector links (/connector/<target>) are published in docs and READMEs
// and must not carry a locale. Letting next-intl rewrite them to
// /en/connector/... 404s, which breaks every link already in the wild.
const isConnectorRoute = pathname === '/connector' || pathname.startsWith('/connector/');
// The plugin sandbox lives in its own root layout under app/(sandbox)/ and
// is not part of the localized tree. Letting next-intl rewrite the path to
// /en/plugin-sandbox 404s, which kills the iframe and disables every plugin.
@@ -305,7 +309,14 @@ export async function proxy(request: NextRequest) {
);
let intlResponse: ReturnType<typeof intlMiddleware> | null = null;
if (!isAdminRoute && !isProtocolRoute && !isSetupRoute && !isSandboxRoute && !hasLocalePrefix) {
if (
!isAdminRoute &&
!isProtocolRoute &&
!isSetupRoute &&
!isSandboxRoute &&
!isConnectorRoute &&
!hasLocalePrefix
) {
try {
intlResponse = intlMiddleware(withMatchedChineseAcceptLanguage(request));
} catch (error) {
+38
View File
@@ -155,6 +155,41 @@ export function buildLiteConfig(env = {}, { target = "static" } = {}) {
}
/** policy.json: only the gates Lite must pin; the app merges the rest over its defaults. */
/**
* What `/api/connector/capabilities` answers on a server build, as a file.
*
* Lite is a static export with no route handlers, so a connector probing this
* instance gets this instead - same document, same meaning. The connector
* tries the API path first and falls back to `/connector.json`.
*
* The target names are read out of lib/connector/registry.ts rather than
* duplicated here: a list that drifts would have this build telling connectors
* it can resolve links it cannot.
*/
export function buildConnectorCapabilities({ appName, version, basePath = "" }) {
const source = readFileSync(
join(resolveRepoRoot(import.meta.url), "lib", "connector", "registry.ts"),
"utf8",
);
const targets = [...source.matchAll(/^\s{4}name: '([a-z0-9_]+)',$/gm)].map((m) => m[1]);
if (targets.length === 0) {
throw new Error("no connector targets found in lib/connector/registry.ts");
}
return (
JSON.stringify(
{
product: "bulwark-webmail",
appName: appName || "Bulwark",
version: version || null,
connectorPath: `${basePath || ""}/connector`,
targets,
},
null,
2,
) + "\n"
);
}
export function buildLitePolicy() {
return {
_comment: "Optional admin policy for Bulwark Lite (same shape as the admin dashboard's policy). Plugins and sidebar apps stay off in Lite.",
@@ -331,6 +366,9 @@ export function buildHeaders({ basePath = "", connectSrc = "*" }) {
Content-Security-Policy: ${csp}
${basePath || ""}/_next/static/*
Cache-Control: public, max-age=31536000, immutable
${basePath || ""}/connector.json
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=300
`;
}
+4 -1
View File
@@ -11,7 +11,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
import { join } from "node:path";
import {
STALWART_EXCLUDED_DIRS, STALWART_EXCLUDED_FILES, STALWART_ZIP_NAME,
buildCaddyExample, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect,
buildCaddyExample, buildConnectorCapabilities, buildHeaders, buildLiteConfig, buildLitePolicy, buildManifest, buildNginxExample, buildNotFoundShim, buildReadme, buildRedirects, buildRootRedirect,
buildStalwartEntry, buildStalwartManifest, buildStalwartReadme, buildZip, collectZipEntries, discoverBuiltLocales, discoverShellDirs, findSegmentPrefetchDirs, isMainModule, makeBuildId, normalizeBasePath, parseLiteTarget, resolveRepoRoot,
} from "./lib.mjs";
@@ -64,6 +64,9 @@ function staticFiles({ config, basePath, locales, defaultLocale, version, commit
return {
"config.json": JSON.stringify(config, null, 2) + "\n",
"policy.json": JSON.stringify(buildLitePolicy(), null, 2) + "\n",
// The file form of /api/connector/capabilities - a static export has no
// route handlers, and a connector probing this instance needs an answer.
"connector.json": buildConnectorCapabilities({ appName: config.appName, version, basePath }),
"manifest.webmanifest": JSON.stringify(buildManifest({ appName: config.appName, basePath }), null, 2) + "\n",
"index.html": buildRootRedirect({ basePath, locales, defaultLocale }),
// Replaces Next's default not-found page: only this shim replays deep links
+4 -1
View File
@@ -45,7 +45,10 @@ export function verifyExport({ root = repoRoot, target, safeBundleBytes = STALWA
const required = resolvedTarget === "stalwart"
? ["index.html", "config.json", "policy.json", "manifest.json", "LITE-README.md", "lite-build.json"]
: ["index.html", "404.html", "config.json", "policy.json", "manifest.webmanifest", "_redirects", "_headers", "LITE-README.md", "lite-build.json"];
// connector.json is the static target only: the Stalwart bundle is served
// without custom headers, so a connector's cross-origin probe could not
// read it anyway (it falls back to adding the instance unverified).
: ["index.html", "404.html", "config.json", "policy.json", "connector.json", "manifest.webmanifest", "_redirects", "_headers", "LITE-README.md", "lite-build.json"];
for (const file of required) {
if (!existsSync(join(outDir, file))) problems.push(`missing out/${file}`);
}