Files
webmail/lib/plugin-sandbox/consent.ts
T
Linus Rath e720be7e17 feat: let users install their own plugins, with admin review of the actual code
The admin page offered "User Plugin Uploads" and "Require Admin
Approval", but nothing in Settings used them: there was no upload, no
uninstall and no way to ask for approval, and the admin had no list of
requests. The approval code that existed pinned a hash of the code only
and showed the admin a manifest the browser sent, never the code.

Now:
- Settings > Plugins has an install button (when uploads are allowed),
  a remove button for the user's own plugins, and lists in plain
  language what each plugin may do, high-risk items first
- an install sends the ZIP to the server, which reads the manifest and
  code itself and pins the request to a hash of both
- the admin Plugins tab lists requests with the code and approves or
  denies them; a user may have 10 open requests
- a user plugin is checked again on every start, so a revoked approval
  or turning user plugins off takes effect
- the consent dialog and the plugin settings are translated, use the
  theme colours, and show the sites a plugin may send data to
2026-10-14 02:03:26 +02:00

61 lines
1.7 KiB
TypeScript

// Queue for plugin permission-consent requests.
//
// On first enable the plugin store posts a ConsentRequest here; the
// `PluginConsentDialog` component renders the head and resolves the promise
// when the user accepts or rejects. The store persists the granted set in
// `plugin.grantedPermissions` so future enables don't re-prompt.
import type { Permission } from '../plugin-types';
export interface ConsentRequest {
id: string;
pluginId: string;
pluginName: string;
permissions: Permission[];
/** Where the plugin may send data, shown under http:fetch / http:post. */
httpOrigins?: string[];
apiPostPaths?: string[];
resolve: (granted: boolean) => void;
}
const queue: ConsentRequest[] = [];
const listeners = new Set<() => void>();
function notify(): void {
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
function uid(): string {
return Math.random().toString(36).slice(2) + Date.now().toString(36);
}
export function requestConsent(
pluginId: string,
pluginName: string,
permissions: Permission[],
targets: { httpOrigins?: string[]; apiPostPaths?: string[] } = {},
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
queue.push({ id: uid(), pluginId, pluginName, permissions, ...targets, resolve });
notify();
});
}
export function head(): ConsentRequest | null {
return queue[0] ?? null;
}
export function resolveHead(granted: boolean): void {
const entry = queue.shift();
if (!entry) return;
try { entry.resolve(granted); } catch { /* ignore */ }
notify();
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}