feat: let plugin notifications carry a link to the plugin's own server

This commit is contained in:
Linus Rath
2026-10-14 10:58:19 +02:00
parent 618adcea5c
commit 607f1b2496
10 changed files with 224 additions and 16 deletions
+11 -1
View File
@@ -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);
+8 -4
View File
@@ -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 });
}
/**
@@ -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(<NavigationRail collapsed />);
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: '' });
@@ -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}
</div>
)}
{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.
<a
href={entry.action.url}
target="_blank"
rel="noopener noreferrer"
onClick={() => 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}
<ExternalLink className="w-3 h-3" aria-hidden="true" />
</a>
)}
</li>
);
})}
@@ -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();
});
});
+12 -4
View File
@@ -124,19 +124,27 @@ export async function listConnectedAccounts(pluginId?: string): Promise<Connecte
return (await credentialOverview(pluginId)).credentials;
}
export interface CredentialOverview {
credentials: ConnectedAccount[];
admin: { credentialId: string; configured: boolean }[];
/** Servers of the admin credentials that are set up. For the host page only. */
adminOrigins: string[];
}
/** The user's connected accounts, and (for one plugin) which admin credentials are set up. */
export async function credentialOverview(pluginId?: string): Promise<{ credentials: ConnectedAccount[]; admin: { credentialId: string; configured: boolean }[] }> {
export async function credentialOverview(pluginId?: string): Promise<CredentialOverview> {
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<CredentialOverview>;
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: [] };
}
}
+58 -4
View File
@@ -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<string, unknown>;
const data = readObject(input, method, 'notification', ['title', 'body', 'tag', 'action']) as NotificationInput & Record<string, unknown>;
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<Set<string>> {
const origins = new Set<string>();
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');
}
+14 -1
View File
@@ -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<string[]> {
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<string, CredentialSpec> } | 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,
})));
}
+7
View File
@@ -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 {
+18 -1
View File
@@ -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<string, unknown>;
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<string, unknown>;
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<NotificationCenterState>((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);