From 607f1b2496255f4beee3f556bee3388f59f0fd12 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 14 Oct 2026 10:58:19 +0200
Subject: [PATCH] feat: let plugin notifications carry a link to the plugin's
own server
---
.../plugin-secrets/__tests__/route.test.ts | 12 +++-
app/api/plugin-secrets/route.ts | 12 ++--
.../__tests__/notification-center.test.tsx | 19 ++++++
.../notifications/notification-center.tsx | 16 ++++-
.../plugin-host-notifications.test.ts | 62 +++++++++++++++++++
lib/plugin-sandbox/connect-dialog.ts | 16 +++--
lib/plugin-sandbox/host-api-notify.ts | 62 +++++++++++++++++--
lib/plugin-secrets/proxy.ts | 15 ++++-
lib/plugin-types.ts | 7 +++
stores/notification-center-store.ts | 19 +++++-
10 files changed, 224 insertions(+), 16 deletions(-)
diff --git a/app/api/plugin-secrets/__tests__/route.test.ts b/app/api/plugin-secrets/__tests__/route.test.ts
index c02a4ee51..c99c310c9 100644
--- a/app/api/plugin-secrets/__tests__/route.test.ts
+++ b/app/api/plugin-secrets/__tests__/route.test.ts
@@ -33,10 +33,11 @@ vi.mock('@/lib/plugin-secrets/proxy', async (orig) => {
...real,
proxyRequest: vi.fn(async (input: unknown) => { proxied.push(input); return new Response('ok'); }),
adminCredentialStatus: vi.fn(async () => [{ credentialId: 'shared', configured: true }]),
+ adminCredentialOrigins: vi.fn(async () => ['https://paperless.example.com']),
};
});
-import { POST as saveCredential } from '../route';
+import { GET as listCredentials, POST as saveCredential } from '../route';
import { POST as proxy } from '../../plugin-proxy/route';
const VALID = {
@@ -60,6 +61,15 @@ beforeEach(() => {
proxied.length = 0;
});
+describe('GET /api/plugin-secrets', () => {
+ it("names the servers of set-up admin credentials for one plugin, for the host page's link check", async () => {
+ const res = await listCredentials(new NextRequest('http://localhost/api/plugin-secrets?plugin=paperless'));
+ expect(await res.json()).toMatchObject({ admin: [{ credentialId: 'shared', configured: true }], adminOrigins: ['https://paperless.example.com'] });
+ const all = await listCredentials(new NextRequest('http://localhost/api/plugin-secrets'));
+ expect((await all.json()).adminOrigins).toEqual([]);
+ });
+});
+
describe('POST /api/plugin-secrets', () => {
it('stores a credential for the server origin only', async () => {
const res = await save(VALID);
diff --git a/app/api/plugin-secrets/route.ts b/app/api/plugin-secrets/route.ts
index 6aee51b74..0e67b59ff 100644
--- a/app/api/plugin-secrets/route.ts
+++ b/app/api/plugin-secrets/route.ts
@@ -3,7 +3,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { CREDENTIAL_ID_RE, checkInject, credentialOrigin } from '@/lib/plugin-secrets/credentials';
-import { adminCredentialStatus } from '@/lib/plugin-secrets/proxy';
+import { adminCredentialOrigins, adminCredentialStatus } from '@/lib/plugin-secrets/proxy';
import {
VaultFullError,
deleteUserCredentials,
@@ -35,9 +35,13 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Invalid plugin id' }, { status: 400 });
}
const credentials = await listUserCredentials(user, pluginId);
- // For one plugin, also say which of its admin credentials are set up.
- const admin = pluginId ? await adminCredentialStatus(pluginId) : [];
- return NextResponse.json({ credentials, admin }, { headers: NO_STORE });
+ // For one plugin, also say which of its admin credentials are set up, and
+ // on which servers: the host page checks the links a plugin shows against
+ // them (the plugin itself only gets yes/no, see proxyAccounts).
+ const [admin, adminOrigins] = pluginId
+ ? await Promise.all([adminCredentialStatus(pluginId), adminCredentialOrigins(pluginId)])
+ : [[], []];
+ return NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE });
}
/**
diff --git a/components/notifications/__tests__/notification-center.test.tsx b/components/notifications/__tests__/notification-center.test.tsx
index ffdd36d7f..8bbe2ba38 100644
--- a/components/notifications/__tests__/notification-center.test.tsx
+++ b/components/notifications/__tests__/notification-center.test.tsx
@@ -77,6 +77,25 @@ describe('notification bell', () => {
expect(screen.queryByRole('dialog')).toBeNull();
});
+ it("shows a plugin's link under its entry, opening in a new tab without the opener", () => {
+ act(() => {
+ add({ level: 'info', title: 'Filed', body: '', pluginId: 'rss', pluginName: 'Paperless-ngx', action: { label: 'Open in Paperless', url: 'https://paperless.example.com/documents/12/details' } });
+ add({ level: 'info', title: 'Odd', body: '', pluginId: 'rss', pluginName: 'X', action: { label: 'Run', url: 'javascript:alert(1)' } });
+ });
+ render();
+ fireEvent.click(bell());
+
+ const panel = screen.getByRole('dialog', { name: 'title' });
+ const links = within(panel).getAllByRole('link');
+ expect(links).toHaveLength(1);
+ expect(links[0]).toHaveTextContent('Open in Paperless');
+ expect(links[0]).toHaveAttribute('href', 'https://paperless.example.com/documents/12/details');
+ expect(links[0]).toHaveAttribute('target', '_blank');
+ expect(links[0].getAttribute('rel')).toContain('noopener');
+ // Not inside the entry's button.
+ expect(links[0].closest('button')).toBeNull();
+ });
+
it('marks all read and clears', () => {
act(() => {
add({ level: 'warning', title: 'a', body: '' });
diff --git a/components/notifications/notification-center.tsx b/components/notifications/notification-center.tsx
index 8d42cf953..ee6a09964 100644
--- a/components/notifications/notification-center.tsx
+++ b/components/notifications/notification-center.tsx
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useFormatter, useTranslations } from "next-intl";
-import { AlertCircle, AlertTriangle, Bell, CheckCheck, Puzzle, Trash2, X } from "@/components/icons";
+import { AlertCircle, AlertTriangle, Bell, CheckCheck, ExternalLink, Puzzle, Trash2, X } from "@/components/icons";
import { useConfig } from "@/hooks/use-config";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { isDocumentRTL } from "@/i18n/direction";
@@ -130,6 +130,20 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo
{content}
)}
+ {entry.action && (
+ // Lined up with the text above (padding + icon + gap). A link of
+ // its own, outside the entry's button: one control can't hold another.
+ markRead(entry.id)}
+ className="-mt-1 mb-1 ms-[38px] inline-flex items-center gap-1 rounded text-xs font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ >
+ {entry.action.label}
+
+
+ )}
);
})}
diff --git a/lib/__tests__/plugin-host-notifications.test.ts b/lib/__tests__/plugin-host-notifications.test.ts
index c798f3737..7c61eb725 100644
--- a/lib/__tests__/plugin-host-notifications.test.ts
+++ b/lib/__tests__/plugin-host-notifications.test.ts
@@ -10,6 +10,11 @@ vi.mock('@/stores/toast-store', () => ({
toast: { success: vi.fn(), error: vi.fn(), info: (...a: unknown[]) => toastInfo(...a), warning: vi.fn() },
}));
+const overview = vi.fn();
+vi.mock('../plugin-sandbox/connect-dialog', () => ({
+ credentialOverview: (...a: unknown[]) => overview(...a),
+}));
+
import { dispatchApiCall } from '../plugin-sandbox/host-api';
function plugin(id: string, perms = ['ui:notifications']): InstalledPlugin {
@@ -36,6 +41,8 @@ class FakeNotification {
beforeEach(() => {
resetPluginRateLimits();
toastInfo.mockClear();
+ overview.mockReset();
+ overview.mockResolvedValue({ credentials: [], admin: [], adminOrigins: [] });
FakeNotification.shown = [];
FakeNotification.permission = 'granted';
vi.stubGlobal('Notification', FakeNotification);
@@ -140,3 +147,58 @@ describe('api.notifications.show in the notification panel', () => {
expect(await entries()).toHaveLength(5);
});
});
+
+describe('api.notifications.show with a link', () => {
+ const PAPERLESS = { ...plugin('paperless', ['ui:notifications', 'http:proxy']), name: 'Paperless-ngx' };
+ const link = (url: string) => ({ title: 'project-notes is in Paperless now', action: { label: 'Open in Paperless', url } });
+ const entries = async () => (await import('@/stores/notification-center-store')).useNotificationCenterStore.getState().entries;
+
+ beforeEach(async () => {
+ FakeNotification.permission = 'default';
+ const { useNotificationCenterStore } = await import('@/stores/notification-center-store');
+ useNotificationCenterStore.getState().clear();
+ });
+
+ it('puts a button on the toast that opens the link in a new tab without a handle on the app', async () => {
+ overview.mockResolvedValue({ credentials: [], admin: [{ credentialId: 'shared', configured: true }], adminOrigins: ['https://paperless.example.com'] });
+ const open = vi.fn();
+ vi.stubGlobal('open', open);
+ expect(await call(PAPERLESS, link('https://paperless.example.com/documents/12/details'))).toBe('toast');
+ const options = toastInfo.mock.calls[0][1] as { action: { label: string; onClick: () => void } };
+ expect(options.action.label).toBe('Open in Paperless');
+ options.action.onClick();
+ expect(open).toHaveBeenCalledWith('https://paperless.example.com/documents/12/details', '_blank', 'noopener,noreferrer');
+ expect((await entries())[0].action).toEqual({ label: 'Open in Paperless', url: 'https://paperless.example.com/documents/12/details' });
+ });
+
+ it('accepts the server of a credential the user connected, and the manifest httpOrigins', async () => {
+ overview.mockResolvedValue({
+ credentials: [{ pluginId: 'paperless', credentialId: 'personal', label: 'P', origin: 'https://docs.alice.example', connected: true, updatedAt: '' }],
+ admin: [], adminOrigins: [],
+ });
+ await call(PAPERLESS, link('https://docs.alice.example/documents/?title__icontains=x'));
+ await call({ ...plugin('feeds'), httpOrigins: ['https://feeds.example.org'] }, link('https://feeds.example.org/item/1'));
+ expect((await entries()).map((e) => e.action?.url)).toEqual([
+ 'https://feeds.example.org/item/1',
+ 'https://docs.alice.example/documents/?title__icontains=x',
+ ]);
+ });
+
+ it('refuses a link to a server the plugin does not talk to, and shows nothing', async () => {
+ overview.mockResolvedValue({ credentials: [], admin: [], adminOrigins: ['https://paperless.example.com'] });
+ await expect(call(PAPERLESS, link('https://paperless.example.com.evil.example/login'))).rejects.toThrow(/action.url must be on a server the plugin talks to/);
+ await expect(call({ ...plugin('feeds'), httpOrigins: ['https://feeds.example.org'] }, link('https://phish.example/'))).rejects.toThrow(/action.url must be on a server/);
+ expect(toastInfo).not.toHaveBeenCalled();
+ expect(await entries()).toHaveLength(0);
+ });
+
+ it('refuses links that are not plain http(s)', async () => {
+ overview.mockResolvedValue({ credentials: [], admin: [], adminOrigins: ['https://paperless.example.com'] });
+ await expect(call(PAPERLESS, link('javascript:alert(1)'))).rejects.toThrow(/http or https/);
+ await expect(call(PAPERLESS, link('/documents/1'))).rejects.toThrow(/absolute URL/);
+ await expect(call(PAPERLESS, link('https://u:p@paperless.example.com/'))).rejects.toThrow(/user name or password/);
+ await expect(call(PAPERLESS, { title: 'x', action: { label: '', url: 'https://paperless.example.com/' } })).rejects.toThrow(/label/);
+ await expect(call(PAPERLESS, { title: 'x', action: { label: 'ok', url: 'https://paperless.example.com/', target: '_self' } })).rejects.toThrow(/unknown property "target"/);
+ expect(toastInfo).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/plugin-sandbox/connect-dialog.ts b/lib/plugin-sandbox/connect-dialog.ts
index c9d40830e..3b259e14c 100644
--- a/lib/plugin-sandbox/connect-dialog.ts
+++ b/lib/plugin-sandbox/connect-dialog.ts
@@ -124,19 +124,27 @@ export async function listConnectedAccounts(pluginId?: string): Promise {
+export async function credentialOverview(pluginId?: string): Promise {
const query = pluginId ? `?plugin=${encodeURIComponent(pluginId)}` : '';
try {
const res = await apiFetch(`/api/plugin-secrets${query}`);
- if (!res.ok) return { credentials: [], admin: [] };
- const body = await res.json() as { credentials?: ConnectedAccount[]; admin?: { credentialId: string; configured: boolean }[] };
+ if (!res.ok) return { credentials: [], admin: [], adminOrigins: [] };
+ const body = await res.json() as Partial;
return {
credentials: Array.isArray(body.credentials) ? body.credentials : [],
admin: Array.isArray(body.admin) ? body.admin : [],
+ adminOrigins: Array.isArray(body.adminOrigins) ? body.adminOrigins.filter((o): o is string => typeof o === 'string') : [],
};
} catch {
- return { credentials: [], admin: [] };
+ return { credentials: [], admin: [], adminOrigins: [] };
}
}
diff --git a/lib/plugin-sandbox/host-api-notify.ts b/lib/plugin-sandbox/host-api-notify.ts
index df882a2a1..847e50a6b 100644
--- a/lib/plugin-sandbox/host-api-notify.ts
+++ b/lib/plugin-sandbox/host-api-notify.ts
@@ -1,16 +1,25 @@
// Host side of api.notifications.show: a system notification when the user
// has allowed them for the webmail, otherwise a toast. Every one names the
// plugin that sent it, and a plugin may send only a few a minute.
+//
+// A notification may carry one link ("Open in Paperless"). The host draws
+// it, so it looks like the app's own: it may only point at a server the
+// plugin already talks to (its httpOrigins or the server of one of its proxy
+// credentials). Otherwise any plugin with ui:notifications could put an
+// app-styled button to any site in front of the user.
import type { InstalledPlugin, NotificationInput } from '../plugin-types';
import { toast as appToast } from '@/stores/toast-store';
import { toastHooks } from '../plugin-hooks';
import { readObject, readString, withinRate } from './host-api-input';
import { recordNotification } from '@/stores/notification-center-store';
+import { credentialOverview } from './connect-dialog';
export const MAX_NOTIFICATION_TITLE = 120;
export const MAX_NOTIFICATION_BODY = 300;
export const NOTIFICATIONS_PER_MINUTE = 5;
+export const MAX_NOTIFICATION_ACTION_LABEL = 40;
+const MAX_ACTION_URL = 2048;
const TAG_RE = /^[A-Za-z0-9._:-]{1,64}$/;
// eslint-disable-next-line no-control-regex -- line breaks and other control characters become spaces
@@ -22,9 +31,9 @@ const oneLine = (text: string) => text.replace(/[\u0000-\u001f\u007f]+/g, ' ').t
* brings the webmail to the front and fires onNotificationClick for this
* plugin only, with the tag it passed.
*/
-export function doNotificationsShow(plugin: InstalledPlugin, input: unknown): 'system' | 'toast' {
+export async function doNotificationsShow(plugin: InstalledPlugin, input: unknown): Promise<'system' | 'toast'> {
const method = 'notifications.show';
- const data = readObject(input, method, 'notification', ['title', 'body', 'tag']) as NotificationInput & Record;
+ const data = readObject(input, method, 'notification', ['title', 'body', 'tag', 'action']) as NotificationInput & Record;
const title = oneLine(readString(data.title, method, 'title', MAX_NOTIFICATION_TITLE, { min: 1 }));
if (!title) throw new Error(`${method}: title must not be empty`);
const body = data.body === undefined ? '' : oneLine(readString(data.body, method, 'body', MAX_NOTIFICATION_BODY));
@@ -35,14 +44,18 @@ export function doNotificationsShow(plugin: InstalledPlugin, input: unknown): 's
}
tag = data.tag;
}
+ const action = data.action === undefined ? undefined : readAction(method, data.action);
if (!withinRate('notifications', plugin.id, NOTIFICATIONS_PER_MINUTE)) {
throw new Error(`${method}: at most ${NOTIFICATIONS_PER_MINUTE} notifications a minute`);
}
+ if (action && !(await pluginOrigins(plugin)).has(new URL(action.url).origin)) {
+ throw new Error(`${method}: action.url must be on a server the plugin talks to (its httpOrigins or a proxy credential's server)`);
+ }
const source = plugin.name || plugin.id;
// Also listed in the app's notification panel (the bell), where a click
// reaches the plugin the same way as a click on the system notification.
- recordNotification({ level: 'info', title, body, pluginId: plugin.id, pluginName: source, ...(tag ? { tag } : {}) });
+ recordNotification({ level: 'info', title, body, pluginId: plugin.id, pluginName: source, ...(tag ? { tag } : {}), ...(action ? { action } : {}) });
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
try {
const notification = new Notification(title, {
@@ -61,6 +74,47 @@ export function doNotificationsShow(plugin: InstalledPlugin, input: unknown): 's
// service worker; the toast below still tells the user.
}
}
- appToast.info(title, { message: body ? `${body} (${source})` : source });
+ appToast.info(title, {
+ message: body ? `${body} (${source})` : source,
+ ...(action ? { action: { label: action.label, onClick: () => openNotificationLink(action.url) } } : {}),
+ });
return 'toast';
}
+
+function readAction(method: string, value: unknown): { label: string; url: string } {
+ const data = readObject(value, method, 'action', ['label', 'url']);
+ const label = oneLine(readString(data.label, method, 'action.label', MAX_NOTIFICATION_ACTION_LABEL, { min: 1 }));
+ if (!label) throw new Error(`${method}: action.label must not be empty`);
+ const raw = readString(data.url, method, 'action.url', MAX_ACTION_URL, { min: 1 });
+ let url: URL;
+ try {
+ url = new URL(raw);
+ } catch {
+ throw new Error(`${method}: action.url must be an absolute URL`);
+ }
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error(`${method}: action.url must be http or https`);
+ if (url.username || url.password) throw new Error(`${method}: action.url must not carry a user name or password`);
+ return { label, url: url.href };
+}
+
+/** Origins the plugin reaches: its manifest's httpOrigins and its credentials' servers. */
+async function pluginOrigins(plugin: InstalledPlugin): Promise> {
+ const origins = new Set();
+ for (const o of plugin.httpOrigins ?? []) {
+ try { origins.add(new URL(o).origin); } catch { /* invalid entries never matched anything */ }
+ }
+ if (plugin.permissions.includes('http:proxy')) {
+ const { credentials, adminOrigins } = await credentialOverview(plugin.id);
+ for (const c of credentials) if (c.pluginId === plugin.id && c.origin) origins.add(c.origin);
+ for (const o of adminOrigins) origins.add(o);
+ }
+ return origins;
+}
+
+/** Open a notification's link in a new tab, without giving that tab a handle on the webmail. */
+export function openNotificationLink(url: string): void {
+ let parsed: URL;
+ try { parsed = new URL(url); } catch { return; }
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
+ window.open(parsed.href, '_blank', 'noopener,noreferrer');
+}
diff --git a/lib/plugin-secrets/proxy.ts b/lib/plugin-secrets/proxy.ts
index 73b02ed1b..87cd7afc5 100644
--- a/lib/plugin-secrets/proxy.ts
+++ b/lib/plugin-secrets/proxy.ts
@@ -97,6 +97,19 @@ export async function resolveCredential(user: VaultUser, pluginId: string, crede
* set). No secret or address leaves the server; plugins learn only yes/no.
*/
export async function adminCredentialStatus(pluginId: string): Promise<{ credentialId: string; configured: boolean }[]> {
+ return (await adminCredentials(pluginId)).map(({ credentialId, origin }) => ({ credentialId, configured: !!origin }));
+}
+
+/**
+ * The servers of a plugin's admin credentials that are set up. Only for the
+ * host page, which checks links a plugin shows against them: plugins
+ * themselves still learn only yes/no (adminCredentialStatus).
+ */
+export async function adminCredentialOrigins(pluginId: string): Promise {
+ return (await adminCredentials(pluginId)).flatMap(({ origin }) => (origin ? [origin] : []));
+}
+
+async function adminCredentials(pluginId: string): Promise<{ credentialId: string; origin: string | null }[]> {
const plugin = await getPlugin(pluginId);
const specs = (plugin as { credentials?: Record } | null)?.credentials;
if (!plugin || !plugin.enabled || !specs) return [];
@@ -105,7 +118,7 @@ export async function adminCredentialStatus(pluginId: string): Promise<{ credent
const user = { username: '', serverUrl: '' };
return Promise.all(adminIds.map(async (credentialId) => ({
credentialId,
- configured: !!(await resolveAdminOnly(user, pluginId, credentialId)),
+ origin: (await resolveAdminOnly(user, pluginId, credentialId))?.origin ?? null,
})));
}
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index 3919c9558..acdad7511 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -740,6 +740,13 @@ export interface NotificationInput {
* replaces the old one, and onNotificationClick hands the tag back.
*/
tag?: string;
+ /**
+ * A link the host shows as a button on the toast and as a link in the
+ * notification panel, opened in a new tab. The URL must be http(s) and on
+ * a server the plugin already talks to: one of its `httpOrigins`, or the
+ * server of one of its proxy credentials.
+ */
+ action?: { label: string; url: string };
}
export interface FilterRuleView {
diff --git a/stores/notification-center-store.ts b/stores/notification-center-store.ts
index 4491f8461..e4b77b681 100644
--- a/stores/notification-center-store.ts
+++ b/stores/notification-center-store.ts
@@ -19,6 +19,8 @@ export interface CenterEntry {
pluginId?: string;
pluginName?: string;
tag?: string;
+ /** A link the plugin added (checked by the host when it was shown). */
+ action?: { label: string; url: string };
read: boolean;
}
@@ -37,7 +39,21 @@ function isEntry(value: unknown): value is CenterEntry {
const e = value as Record;
return typeof e.id === 'string' && typeof e.time === 'number' && typeof e.title === 'string'
&& typeof e.body === 'string' && typeof e.read === 'boolean'
- && (e.level === 'error' || e.level === 'warning' || e.level === 'info');
+ && (e.level === 'error' || e.level === 'warning' || e.level === 'info')
+ && (e.action === undefined || isLink(e.action));
+}
+
+// Storage is the user's own, but a link is only ever shown when it is http(s).
+function isLink(value: unknown): value is { label: string; url: string } {
+ if (!value || typeof value !== 'object') return false;
+ const { label, url } = value as Record;
+ if (typeof label !== 'string' || typeof url !== 'string') return false;
+ try {
+ const { protocol } = new URL(url);
+ return protocol === 'https:' || protocol === 'http:';
+ } catch {
+ return false;
+ }
}
function load(): CenterEntry[] {
@@ -72,6 +88,7 @@ export const useNotificationCenterStore = create((set,
body: input.body,
...(input.pluginId ? { pluginId: input.pluginId, pluginName: input.pluginName ?? input.pluginId } : {}),
...(input.tag ? { tag: input.tag } : {}),
+ ...(input.action && isLink(input.action) ? { action: { label: input.action.label, url: input.action.url } } : {}),
read: false,
};
const entries = [entry, ...get().entries].slice(0, MAX_ENTRIES);