mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: let an install link name the extension version to offer in the marketplace
This commit is contained in:
@@ -44,9 +44,12 @@ export default function AdminLoginPage() {
|
||||
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');
|
||||
const search = new URLSearchParams(query ?? '');
|
||||
const tab = search.get('tab');
|
||||
if (tab && isAdminTab(tab)) useAdminTabStore.getState().setActiveTab(tab);
|
||||
router.push(path);
|
||||
// The tab rides in the store; a version pick (an install link to one
|
||||
// version of an extension) stays in the URL for the marketplace page.
|
||||
router.push(search.has('version') ? next : path);
|
||||
return;
|
||||
}
|
||||
router.push('/admin');
|
||||
|
||||
@@ -82,6 +82,24 @@ describe('resolving to app paths', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('carries the version an install link asked for into the marketplace page', () => {
|
||||
expect(path('admin_extension', { slug: 'quick-reply', version: '1.2.0' })).toBe(
|
||||
'/admin/marketplace/quick-reply?version=1.2.0',
|
||||
);
|
||||
expect(path('admin_extension', { slug: 'quick-reply', version: '2.0.0-beta.1' })).toBe(
|
||||
'/admin/marketplace/quick-reply?version=2.0.0-beta.1',
|
||||
);
|
||||
});
|
||||
|
||||
it('survives the admin login round-trip as a whole', () => {
|
||||
// The admin layout parks the resolved path in ?next=; the gate there must
|
||||
// let exactly what the registry produces through, or a versioned install
|
||||
// link would drop its version (or the whole path) on the way.
|
||||
const versioned = path('admin_extension', { slug: 'quick-reply', version: '1.2.0' });
|
||||
expect(safeAdminNext(versioned)).toBe(versioned);
|
||||
expect(safeAdminNext(encodeURIComponent(versioned))).toBe(versioned);
|
||||
});
|
||||
|
||||
it('defaults the admin tab when the link named none', () => {
|
||||
const admin = resolve('admin');
|
||||
if (admin.kind === 'ok') expect(admin.resolution.adminTab).toBe('dashboard');
|
||||
@@ -125,6 +143,15 @@ describe('parameter validation', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['1.2', 'v1.2.3', '1.2.3+build.5', 'latest', '1.2.3/..', '1.2.3?x', '1.2.3-'])(
|
||||
'refuses the extension version %j',
|
||||
(version) => {
|
||||
const result = resolve('admin_extension', { slug: 'quick-reply', version });
|
||||
expect(result.kind).toBe('bad-param');
|
||||
if (result.kind === 'bad-param') expect(result.param).toBe('version');
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['2026-02-31', '2026-13-01', '20260920', 'tomorrow'])(
|
||||
'refuses the date %j',
|
||||
(date) => {
|
||||
@@ -167,6 +194,8 @@ describe('safeAdminNext', () => {
|
||||
['/admin/', '/admin/'],
|
||||
['/admin/marketplace/quick-reply', '/admin/marketplace/quick-reply'],
|
||||
['/admin?tab=policy', '/admin?tab=policy'],
|
||||
['/admin/marketplace/quick-reply?version=1.2.0', '/admin/marketplace/quick-reply?version=1.2.0'],
|
||||
['/admin/marketplace/quick-reply?version=2.0.0-beta.1', '/admin/marketplace/quick-reply?version=2.0.0-beta.1'],
|
||||
['%2Fadmin%2Fplugins%2Fx', '/admin/plugins/x'],
|
||||
])('accepts %j', (raw, expected) => {
|
||||
expect(safeAdminNext(raw)).toBe(expected);
|
||||
@@ -185,6 +214,9 @@ describe('safeAdminNext', () => {
|
||||
'/admin/../mail',
|
||||
'/adminfoo',
|
||||
'/admin?tab=policy&x=1',
|
||||
'/admin/marketplace/x?version=1.2.0&tab=policy',
|
||||
'/admin/marketplace/x?version=../../etc',
|
||||
'/admin/marketplace/x?version=' + '1'.repeat(70),
|
||||
'/admin?next=https://evil.example',
|
||||
'%2f%2fevil.example',
|
||||
'/admin/' + 'a'.repeat(600),
|
||||
|
||||
@@ -20,9 +20,11 @@ const SAFE = /^\/admin(?:\/[A-Za-z0-9._~-]+)*\/?$/;
|
||||
* 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.
|
||||
* Two queries are allowed. `?tab=` is 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. `?version=` is the version a connector
|
||||
* install link asked for (`/admin/marketplace/<slug>?version=1.2.0`); the
|
||||
* marketplace page checks it again against the directory before offering it.
|
||||
*/
|
||||
export function safeAdminNext(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
@@ -44,8 +46,10 @@ export function safeAdminNext(raw: string | null | undefined): string | 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;
|
||||
// Only the tab hand-off or the version pick, each as one plain value.
|
||||
if (query && !/^tab=[a-z_]{1,32}$/.test(query) && !/^version=[0-9A-Za-z.-]{1,64}$/.test(query)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
import { ADMIN_TABS, type AdminTabId } from '@/stores/admin-tab-store';
|
||||
import type { SettingsSearchTab } from '@/lib/settings-search';
|
||||
import { buildSettingsPath, buildFilesPath } from '@/lib/deep-links';
|
||||
import { RELEASE_VERSION } from '@/lib/release-version';
|
||||
|
||||
/** Mirrors `SettingsSearchTab`. Listed so an unknown tab is refused here. */
|
||||
const SETTINGS_TABS: readonly SettingsSearchTab[] = [
|
||||
@@ -48,7 +49,7 @@ const CALENDAR_VIEWS = ['month', 'week', 'day', 'agenda', 'tasks'] as const;
|
||||
// JMAP id. The connector carries the alias list purely as suggestions for its
|
||||
// link generator.
|
||||
|
||||
export type ParamType = 'enum' | 'slug' | 'date' | 'id' | 'path';
|
||||
export type ParamType = 'enum' | 'slug' | 'date' | 'id' | 'path' | 'version';
|
||||
|
||||
export interface ParamSpec {
|
||||
readonly type: ParamType;
|
||||
@@ -137,9 +138,15 @@ export const TARGETS: readonly Target[] = [
|
||||
{
|
||||
name: 'admin_extension',
|
||||
label: 'an extension in the marketplace',
|
||||
params: { slug: { type: 'slug', required: true } },
|
||||
// `version` picks the version the page offers to install; without it the
|
||||
// page offers the newest one this Bulwark can run. The marketplace page
|
||||
// reads it from the query string, and `safeAdminNext` lets exactly this
|
||||
// query through the admin login.
|
||||
params: { slug: { type: 'slug', required: true }, version: { type: 'version' } },
|
||||
resolve: (p) => ({
|
||||
path: `/admin/marketplace/${encodeURIComponent(p.slug)}`,
|
||||
path: p.version
|
||||
? `/admin/marketplace/${encodeURIComponent(p.slug)}?version=${encodeURIComponent(p.version)}`
|
||||
: `/admin/marketplace/${encodeURIComponent(p.slug)}`,
|
||||
admin: true,
|
||||
}),
|
||||
},
|
||||
@@ -195,6 +202,8 @@ function checkParam(spec: ParamSpec, value: string): boolean {
|
||||
return SLUG.test(value);
|
||||
case 'date':
|
||||
return isRealDate(value);
|
||||
case 'version':
|
||||
return RELEASE_VERSION.test(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
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* The shape of a version the extension directory publishes: semver core with
|
||||
* an optional pre-release (`1.2.3`, `2.0.0-beta.1`). Build metadata is not
|
||||
* part of a published version and is refused with everything else.
|
||||
*
|
||||
* Shared by the connector registry (the `version` link parameter), the admin
|
||||
* login's `?next=` gate and the marketplace routes, so a version that passes
|
||||
* one passes all of them.
|
||||
*/
|
||||
export const RELEASE_VERSION = /^\d{1,9}\.\d{1,9}\.\d{1,9}(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||
Reference in New Issue
Block a user