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);