Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions doc/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,77 @@ operations in `templates/`, in files of type ".ejs", since Etherpad uses EJS for
HTML templating. See the following link for more information about EJS:
<https://github.com/visionmedia/ejs>.

## Plugin-namespaced pad-wide options

Plugins can ride the existing `padoptions` COLLABROOM rail to store
pad-wide settings — broadcast to every connected client, persisted with the
pad, and honored by `enforceSettings` — instead of inventing their own
message type and storage. The model matches how `enablePadWideSettings`
works for native toggles like sticky chat or line numbers.

### Capability detection

```js
let padOptionsPluginPassthrough = false;
try {
// The require throws on Etherpad versions that predate this capability;
// plugins should degrade gracefully (typically falling back to a per-user
// cookie toggle) when the flag is missing.
padOptionsPluginPassthrough =
require('ep_etherpad-lite/node/utils/PluginCapabilities')
.padOptionsPluginPassthrough === true;
} catch (_e) { /* older core */ }
```

The flag means the core has the passthrough patch *available*. Whether it
is actually *enabled* at runtime is a separate per-instance setting — see
below.

### Runtime flag

The passthrough is gated by `settings.enablePluginPadOptions`, default
`false`. Operators must opt in via `settings.json`:

```json
{
"enablePluginPadOptions": true
}
```

When enabled, the server reflects the value to every client via
`clientVars.enablePluginPadOptions` so plugins can detect both *capable*
(static) and *active* (per-pad request) at the same point.

### Key namespace

Plugins must use keys matching `/^ep_[a-z0-9_]+$/`. The recommended pattern
is `ep_<plugin_name>` (e.g. `ep_table_of_contents`); compose multiple
pad-wide settings under one key as a plain object:

```js
pad.changePadOption('ep_my_plugin', {enabled: true, depth: 3});
```

The server passes through any matching key on the existing `padoptions`
message, persists it with the pad, and broadcasts it to every connected
client. `pad.padOptions.ep_my_plugin` reflects the latest value on every
client.

### Validation

Server-side `Pad.normalizePadSettings()` enforces three rules on every
plugin-namespaced key:

- Values must round-trip through `JSON.stringify` (no functions, symbols,
BigInt, or circular references).
- Each key's serialized payload must fit within **64 KB**.
- The combined size of all `ep_*` values per pad must fit within **256 KB**.

Values that fail any of these rules are dropped with a `console.warn`; the
rest of the settings round-trip cleanly. The caps prevent a misbehaving
plugin from bloating the persisted pad payload or the COLLABROOM
broadcast.

## Writing and running front-end tests for your plugin

Etherpad allows you to easily create front-end tests for plugins.
Expand Down
11 changes: 11 additions & 0 deletions settings.json.template
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,17 @@
**/
"enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:true}",

/*
* Allow plugins to ride the existing padoptions COLLABROOM rail by
* accepting pad-wide values under plugin-namespaced keys matching
* /^ep_[a-z0-9_]+$/ (e.g. ep_table_of_contents). Values are validated
* (JSON-safe, 64 KB per key, 256 KB total) and broadcast to every
* connected client just like native pad-wide toggles. Disabled by
* default; flip to true once your plugins (e.g. ep_plugin_helpers'
* padToggle) require it. See doc/plugins.md.
**/
"enablePluginPadOptions": "${ENABLE_PLUGIN_PAD_OPTIONS:false}",

/*
* Optional privacy banner shown once the pad loads. Disabled by default.
*
Expand Down
13 changes: 11 additions & 2 deletions snap/tests/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,23 @@ assert_exit() {
}

# assert_grep cmd needle name — fail if cmd's combined output doesn't match
#
# Uses a here-string instead of `printf | grep -q` because `set -o pipefail`
# (declared at the top of this file) propagates SIGPIPE failures: when grep
# -q matches early it closes its stdin, printf gets SIGPIPE on its next
# write, and pipefail makes the whole pipeline exit non-zero — even though
# the grep itself succeeded. The failure mode is timing-dependent, only
# tripping when the captured output is large enough that printf hasn't
# flushed before grep matches and exits. A here-string feeds grep its input
# in one shot with no pipe in between.
assert_grep() {
local needle="$1" name="$2"; shift 2
local out
out=$("$@" 2>&1 || true)
if printf '%s' "$out" | grep -q -F -- "$needle"; then
if grep -q -F -- "$needle" <<<"$out"; then
pass "$name"
else
fail "$name" "expected output to contain: $needle; got: $(printf '%s' "$out" | head -3)"
fail "$name" "expected output to contain: $needle; got: $(head -3 <<<"$out")"
fi
}

Expand Down
61 changes: 60 additions & 1 deletion src/node/db/Pad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,43 @@ type PadSettings = {
chatAndUsers: boolean;
lang: string | null;
view: PadViewSettings;
// Plugin-namespaced pad-wide options ride alongside the core keys.
// Anything matching /^ep_[a-z0-9_]+$/ is preserved verbatim by
// normalizePadSettings so plugins can use the existing padoptions
// broadcast/persist rail without forking their own transport.
[pluginKey: string]: any;
};

const PLUGIN_KEY_RE = /^ep_[a-z0-9_]+$/;
// Per-key serialized JSON size cap: ~64 KB. Pad-wide settings are persisted
// with the pad and broadcast to every connected client on every change, so
// plugins must keep their values small. A misbehaving plugin shouldn't bloat
// the pad payload or the broadcast.
const PLUGIN_KEY_MAX_BYTES = 64 * 1024;
// Combined ep_* size cap: ~256 KB. Same rationale, aggregated.
const PLUGIN_TOTAL_MAX_BYTES = 256 * 1024;

// Returns true iff `v` round-trips through JSON.stringify cleanly (no
// functions, symbols, BigInt, or circular references) and serializes to at
// most `maxBytes` UTF-8 bytes. Returns the serialized length on success so
// callers can enforce a cumulative cap without serializing twice.
const validatePluginValue = (
key: string, value: unknown, maxBytes: number): {ok: true, bytes: number} | {ok: false, reason: string} => {
let serialized: string;
try {
serialized = JSON.stringify(value);
} catch (e: any) {
return {ok: false, reason: `JSON.stringify failed: ${e && e.message || e}`};
}
if (serialized === undefined) {
// JSON.stringify returns undefined for top-level functions/undefined.
return {ok: false, reason: 'value is not JSON-serializable (function/undefined)'};
}
const bytes = Buffer.byteLength(serialized, 'utf8');
if (bytes > maxBytes) {
return {ok: false, reason: `serialized size ${bytes}B exceeds per-key cap ${maxBytes}B`};
}
return {ok: true, bytes};
};

/**
Expand Down Expand Up @@ -87,7 +124,7 @@ class Pad {

static normalizePadSettings(rawPadSettings: any = {}): PadSettings {
const rawView = rawPadSettings.view ?? {};
return {
const result: PadSettings = {
enforceSettings: !!rawPadSettings.enforceSettings,
showChat: rawPadSettings.showChat == null ? settings.padOptions.showChat !== false :
!!rawPadSettings.showChat,
Expand All @@ -109,6 +146,28 @@ class Pad {
!!rawView.fadeInactiveAuthorColors,
},
};
if (settings.enablePluginPadOptions) {
let totalBytes = 0;
for (const [k, v] of Object.entries(rawPadSettings)) {
if (!PLUGIN_KEY_RE.test(k)) continue;
const check = validatePluginValue(k, v, PLUGIN_KEY_MAX_BYTES);
if (!check.ok) {
// Drop and log. Persistence/broadcast still rejects the value, but
// the rest of the settings round-trip cleanly.
console.warn(`[normalizePadSettings] dropping ${k}: ${check.reason}`);
continue;
}
if (totalBytes + check.bytes > PLUGIN_TOTAL_MAX_BYTES) {
console.warn(
`[normalizePadSettings] dropping ${k}: combined ep_* size ` +
`would exceed cap ${PLUGIN_TOTAL_MAX_BYTES}B`);
continue;
}
totalBytes += check.bytes;
result[k] = v;
}
}
return result;
}

apool() {
Expand Down
1 change: 1 addition & 0 deletions src/node/handler/PadMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,7 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
},
enableDarkMode: settings.enableDarkMode,
enablePadWideSettings: settings.enablePadWideSettings,
enablePluginPadOptions: settings.enablePluginPadOptions,
padDeletionToken,
// Allow-listed copy — settings.privacyBanner could carry extra nested
// keys from a hand-edited settings.json; sending those by reference
Expand Down
18 changes: 18 additions & 0 deletions src/node/utils/PluginCapabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use strict';

// Capability flags exposed to Etherpad plugins for runtime feature detection.
// Plugins should `try { require('ep_etherpad-lite/node/utils/PluginCapabilities') }
// catch { /* old core */ }` and degrade gracefully when a flag is missing.
//
// IMPORTANT: a flag here means the core implements the capability — it does
// not mean the capability is currently enabled on this Etherpad instance.
// Capabilities can be gated by per-instance settings; plugins must inspect
// the relevant runtime flag (typically reflected through clientVars) to
// decide whether to actually use the feature on a given pad load.

// True when applyPadSettings (client + server) preserves keys matching
// /^ep_[a-z0-9_]+$/ on pad.padOptions. The runtime gate is
// settings.enablePluginPadOptions (default false), mirrored to clients via
// clientVars.enablePluginPadOptions. See doc/plugins.md for the full
// contract (key namespace, validation, size caps).
export const padOptionsPluginPassthrough = true;
9 changes: 8 additions & 1 deletion src/node/utils/Settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export type SettingsType = {
updateServer: string,
enableDarkMode: boolean,
enablePadWideSettings: boolean,
enablePluginPadOptions: boolean,
allowPadDeletionByAllUsers: boolean,
privacyBanner: {
enabled: boolean,
Expand Down Expand Up @@ -332,7 +333,7 @@ export type SettingsType = {
requireAdminForStatus: boolean,
},
adminEmail: string | null,
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings" | "privacyBanner">,
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings" | "enablePluginPadOptions" | "privacyBanner">,
}

const settings: SettingsType = {
Expand Down Expand Up @@ -397,6 +398,11 @@ const settings: SettingsType = {
updateServer: "https://static.etherpad.org",
enableDarkMode: true,
enablePadWideSettings: true,
// New plugin-padOption passthrough is opt-in per AGENTS.MD §52 ("New
// features should be placed behind feature flags and disabled by
// default"). Flip to true to let plugins (e.g. ep_plugin_helpers'
// padToggle) ride the existing padoptions broadcast/persist rail.
enablePluginPadOptions: false,
allowPadDeletionByAllUsers: false,
privacyBanner: {
enabled: false,
Expand Down Expand Up @@ -770,6 +776,7 @@ const settings: SettingsType = {
skinName: settings.skinName,
skinVariants: settings.skinVariants,
enablePadWideSettings: settings.enablePadWideSettings,
enablePluginPadOptions: settings.enablePluginPadOptions,
privacyBanner: getPublicPrivacyBanner(),
}
},
Expand Down
10 changes: 10 additions & 0 deletions src/static/js/pad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,16 @@ const pad = {
pad.padOptions.view[k] = v;
}
}
// Plugin-namespaced keys (ep_*) are passed through verbatim so plugins
// can ride the existing padoptions broadcast/persist rail. Gated on
// settings.enablePluginPadOptions (mirrored to clientVars by
// getPublicSettings). Server-side normalizePadSettings preserves the
// same keys symmetrically.
if (clientVars.enablePluginPadOptions) {
for (const [k, v] of Object.entries(opts)) {
if (/^ep_[a-z0-9_]+$/.test(k)) pad.padOptions[k] = v;
}
}
normalizeChatOptions(pad.padOptions);
pad.refreshPadSettingsControls();
pad.applyOptionsChange();
Expand Down
7 changes: 6 additions & 1 deletion src/static/js/types/SocketIOMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,12 @@ export type PadOption = {
"alwaysShowChat"?: boolean,
"chatAndUsers"?: boolean,
"lang"?: null|string,
view? : MapArrayType<boolean|string>
view? : MapArrayType<boolean|string>,
// Plugin-namespaced pad-wide options (gated by settings.enablePluginPadOptions).
// The runtime regex is /^ep_[a-z0-9_]+$/ — TypeScript template literals
// can't constrain that exactly, so this signature accepts any ep_-prefixed
// string and applyPadSettings/normalizePadSettings reject the rest.
[k: `ep_${string}`]: unknown,
}


Expand Down
Loading
Loading