mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 16:16:59 +00:00
A security and an accessibility review of the proofreading hook found gaps, fixed here. What leaves the browser: - A checker gets a name for each composer of its own, not the composer's id, so it can't reach the message through the other api.composer calls after proofreading stops. - Checking pauses while a plugin that can sign or encrypt is turned on but still starting, and while the policy couldn't be fetched (the built-in defaults could undo an admin's lock). - No plugin can hold up turning proofreading off (onBeforeSettingChange). - Suggestions can't carry zero-width, bidi or line-separator characters; messages lose bidi overrides; a flood of annotations is cut off early. - The notices name the plugin as installed, not a label it chose, and say that text written before signing or encryption was turned on may already have been checked. - A checker registered during start-up no longer fails its first check. Keyboard and screen readers: - Escape in the issue panel closes the panel, not the message. - Underlines carry aria-invalid, as the browser's own markers do. - The focus goes somewhere sensible when a button or an issue goes away, Tab past the popup's edges goes back to the text, and the shortcuts work in the popup and the panel. - Ctrl+' and Ctrl+; follow the character typed, so other layouts keep their own keys (Ctrl+S on Dvorak), with Ä and Ö on German keyboards. - The popup is described by its message, sits inside a modal composer, fits narrow screens, and the panel fits a phone.
102 lines
4.9 KiB
TypeScript
102 lines
4.9 KiB
TypeScript
// Host side of the text checker calls. See checkers.ts.
|
|
//
|
|
// A checker the host sends text to while the user types needs compose:check:
|
|
// it reads every message being written, without the user pointing it at
|
|
// one. A checker that checks only when the user asks (an assistant's "check
|
|
// spelling" button, with the composer id that click hands over) needs no
|
|
// more than compose:read, like the plugin's other reads of that message.
|
|
//
|
|
// composer.registerChecker(data) background frame only
|
|
// composer.updateChecker(checkerId, patch)
|
|
// composer.recheck(checkerId)
|
|
// composer.unregisterChecker(checkerId)
|
|
// composer.getCheckText(composerId, checkerId) on-demand checks
|
|
// composer.setAnnotations(composerId, checkerId, revision, annotations)
|
|
|
|
import type { InstalledPlugin } from '../plugin-types';
|
|
import {
|
|
checkCheckerData, checkCheckerPatch, checkTarget, getChecker, recheckPluginChecker,
|
|
removePluginChecker, setPluginChecker, updatePluginChecker, type CheckerSpec,
|
|
} from './checkers';
|
|
import { readId, withinRate } from './host-api-input';
|
|
import { MAX_ANNOTATIONS } from '@/lib/proofreading/annotations';
|
|
import { pluginHasPermission } from './permissions';
|
|
|
|
const CALLS_PER_MINUTE = 60;
|
|
const MAX_PUSHED = 2 * MAX_ANNOTATIONS;
|
|
|
|
function rate(method: string, plugin: InstalledPlugin): void {
|
|
if (!withinRate('composer-check', plugin.id, CALLS_PER_MINUTE)) throw new Error(`${method}: at most ${CALLS_PER_MINUTE} calls a minute`);
|
|
}
|
|
|
|
/** compose:check, or for a checker that checks only when asked, compose:read. */
|
|
function requirePermission(method: string, plugin: InstalledPlugin, mode: 'typing' | 'on-demand'): void {
|
|
if (pluginHasPermission(plugin, 'compose:check')) return;
|
|
if (mode === 'on-demand' && pluginHasPermission(plugin, 'compose:read')) return;
|
|
throw new Error(`Plugin "${plugin.id}" lacks permission "compose:check"${mode === 'on-demand' ? ' or "compose:read"' : ''} for ${method}`);
|
|
}
|
|
|
|
function checkerOf(method: string, plugin: InstalledPlugin, checkerId: unknown): CheckerSpec {
|
|
const spec = getChecker(plugin.id, checkerId);
|
|
if (!spec) throw new Error(`${method}: no checker "${String(checkerId)}" registered by this plugin`);
|
|
// Checked again on every call, in case the permission went since.
|
|
requirePermission(method, plugin, spec.mode);
|
|
return spec;
|
|
}
|
|
|
|
export function doRegisterChecker(plugin: InstalledPlugin, data: unknown, frame: 'background' | 'slot' | undefined): true {
|
|
// The host calls a checker in the plugin's background frame; a slot
|
|
// frame comes and goes with the screen.
|
|
if (frame === 'slot') throw new Error('composer.registerChecker: register checkers from activate(), not from a slot');
|
|
const checked = checkCheckerData(data);
|
|
requirePermission('composer.registerChecker', plugin, checked.mode);
|
|
rate('composer.registerChecker', plugin);
|
|
setPluginChecker(plugin.id, plugin.name, checked);
|
|
return true;
|
|
}
|
|
|
|
export function doUpdateChecker(plugin: InstalledPlugin, checkerId: unknown, patch: unknown): true {
|
|
const spec = checkerOf('composer.updateChecker', plugin, checkerId);
|
|
rate('composer.updateChecker', plugin);
|
|
updatePluginChecker(plugin.id, spec.id, checkCheckerPatch(patch));
|
|
return true;
|
|
}
|
|
|
|
export function doRecheck(plugin: InstalledPlugin, checkerId: unknown): true {
|
|
const spec = checkerOf('composer.recheck', plugin, checkerId);
|
|
rate('composer.recheck', plugin);
|
|
recheckPluginChecker(plugin.id, spec.id);
|
|
return true;
|
|
}
|
|
|
|
export function doUnregisterChecker(plugin: InstalledPlugin, checkerId: unknown): true {
|
|
removePluginChecker(plugin.id, checkerOf('composer.unregisterChecker', plugin, checkerId).id);
|
|
return true;
|
|
}
|
|
|
|
/** The whole text of a composer the plugin was handed, for an on-demand check. */
|
|
export function doGetCheckText(plugin: InstalledPlugin, composerId: unknown, checkerId: unknown): unknown {
|
|
const method = 'composer.getCheckText';
|
|
const id = readId(composerId, method, 'composerId');
|
|
const spec = checkerOf(method, plugin, checkerId);
|
|
const target = checkTarget(id);
|
|
if (!target) throw new Error(`${method}: that composer is not open any more`);
|
|
rate(method, plugin);
|
|
const request = target.getCheckText(spec);
|
|
if (!request) throw new Error('checking_off');
|
|
return request;
|
|
}
|
|
|
|
export function doSetAnnotations(plugin: InstalledPlugin, composerId: unknown, checkerId: unknown, revision: unknown, annotations: unknown): number {
|
|
const method = 'composer.setAnnotations';
|
|
const id = readId(composerId, method, 'composerId');
|
|
const spec = checkerOf(method, plugin, checkerId);
|
|
const rev = readId(revision, method, 'revision');
|
|
if (!Array.isArray(annotations)) throw new Error(`${method}: annotations must be a list`);
|
|
if (annotations.length > MAX_PUSHED) throw new Error(`${method}: at most ${MAX_PUSHED} annotations`);
|
|
const target = checkTarget(id);
|
|
if (!target) throw new Error(`${method}: that composer is not open any more`);
|
|
rate(method, plugin);
|
|
return target.setAnnotations(spec, rev, annotations);
|
|
}
|