mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: resolve deep links to the local instance
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user