* fix(a11y): negotiate lang/dir per request and set on <html>
Server-renders the html element with `lang` and `dir` matching the
client's Accept-Language header (negotiated against availableLangs from
i18n hooks). Falls back to `en`/`ltr` if no match.
This gives screen readers a correct document language during the brief
window before client-side html10n refines it (l10n.ts already sets both
attributes after locale data loads).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): dialog semantics on popups; fix aria-role typo on userlist
Adds role=dialog, aria-modal=true, and either aria-labelledby (when an
h1 is present) or aria-label (for popups without an h1) to:
- #settings, #import_export, #embed, #skin-variants (labelledby)
- #connectivity, #users, #mycolorpicker (aria-label)
Fixes the invalid aria-role="document" attribute on #otherusers; it's
now role=region with aria-live=polite so screen readers announce
collaborator joins/leaves.
Container aria-label values are English-only for now — Etherpad's
html10n implementation only supports localizing specific attributes
(title, alt, placeholder, etc), not aria-label on container nodes.
Localization can follow once html10n grows that affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): focus management and Escape-to-close for popups
Three additions to toggleDropDown / _bodyKeyEvent:
- Remember the trigger element (document.activeElement) when opening
a popup, so we can restore focus when it closes.
- On open, focus the first focusable element inside the popup so
keyboard users land inside the dialog instead of staying on the
trigger button.
- Escape pressed while focus is inside a popup closes it, then the
restore-focus path runs and the trigger button is refocused.
Replaces the previous behavior where Escape from inside a popup did
nothing; users had to click outside to dismiss.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): make chaticon and chat header controls real buttons
- #chaticon: <div onclick> → <button type=button> with aria-label
- #titlecross / #titlesticky: <a onClick> → <button type=button>
with aria-label (Close chat / Pin chat to screen)
- Decorative chat-bubble glyph gets aria-hidden=true so it isn't
read alongside the button label
- #chatcounter labelled "Unread messages"
- Inline onclick attributes moved to chat.init() handlers
- CSS reset on the new buttons (transparent bg, no border, inherit
font/color) so they match the prior visual design
- :focus-visible outlines for keyboard users
Existing test selectors (#chaticon, #titlecross, #titlesticky) are
unchanged and continue to work — they never relied on element type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): accessible names for icon-only toolbar/export controls
- Export links (#exportetherpada, #exporthtmla, #exportplaina,
#exportworda, #exportpdfa, #exportopena): added aria-label so the
link is announced as e.g. "Export as PDF". The inner icon span
gets aria-hidden=true so screen readers don't read both the icon
text and the link label.
- Show-more toolbar toggle (.show-more-icon-btn): converted from
<span> to <button type=button> with aria-label and aria-expanded.
The click handler now toggles aria-expanded alongside the
full-icons class so assistive tech reflects the open/closed state.
- Theme switcher knob: aria-label changed from "theme-switcher-knob"
(a class-style identifier, not human text) to "Toggle theme".
Aria-label values are English-only for now. Etherpad's html10n
implementation only localizes a fixed attribute list (title, alt,
placeholder, value, innerHTML, textContent); aria-label is not
included, so a clean l10n path requires a follow-up to either
extend html10n or set aria-label client-side after locale loads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(a11y): cover dialog semantics, html lang, icon button labels
New Playwright spec verifies the a11y guarantees added by this branch:
- <html> has a non-empty lang attribute
- settings/import_export/embed/users popups expose role=dialog,
aria-modal=true, and either aria-labelledby (when an h1 exists)
or aria-label (when none does)
- Escape from inside the settings popup closes it AND restores
focus to the trigger button
- Export links each carry a descriptive aria-label
- #chaticon is a real <button> with aria-label
- #titlecross / #titlesticky are real <button>s with aria-label
- #otherusers uses role=region + aria-live=polite + aria-label
(and the previous aria-role typo is gone)
- .show-more-icon-btn is a <button> with aria-label and
aria-expanded
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(a11y): address Qodo review feedback from PR #7584
1. Users Escape close broken - toggleDropDown('none') intentionally
skips the users module so switching between other popups doesn't
hide the user list. That meant Escape couldn't dismiss the Users
popup either. The Escape branch now checks for #users as the
focused popup and closes it explicitly (respecting stickyUsers)
before falling through to the normal close-all path.
2. Embed focus overridden - the rAF auto-focus in toggleDropDown
grabbed the first focusable descendant, which stole focus from
command handlers that target a specific control (notably the Embed
command's #linkinput). rAF now bails out if focus is already
inside the newly-opened popup.
3. Button click blurs :focus before toggleDropDown captures trigger -
discovered while investigating the Firefox Playwright failure for
"settings popup Escape restores focus". Button.bind() calls
$(':focus').trigger('blur') before invoking the callback, so by
the time toggleDropDown() captured document.activeElement as the
restore target it was already <body>. The click handler now
stashes padeditbar._lastTrigger to the clicked <button> before
blur runs; toggleDropDown only falls back to activeElement when
the pre-stash didn't happen (keyboard shortcut path).
4. html10n overwrites aria-label - html10n unconditionally set
aria-label to the translated string, clobbering explicit aria-label
on elements that also carry data-l10n-id. setAttribute now only
fires when the element has no aria-label; explicit author labels
win, unlabelled translated elements still get a name.
5. Button visual reset - the show-more-icon-btn and #chaticon
conversions inherited UA default button border/background/padding,
shifting icon glyphs visibly off-centre. Added appearance /
background / border / padding resets.
6. Export links test assumes soffice is installed - #exportworda,
#exportpdfa, #exportopena are removed client-side by pad_impexp.ts
when clientVars.exportAvailable === 'no'. The test now skips links
absent at runtime.
Verified locally: all 10 a11y_dialogs specs pass on both Chromium and
Firefox; backend suite remains 799/799 passing; ts-check clean.
* fix(a11y): close popups with no focusable content; unbreak chat-icon layout
Round 2 of #7584 review follow-ups.
1. Users popup Escape still didn't close the dialog (user-confirmed).
Root cause: _bodyKeyEvent is bound to the OUTER document's body.
When #users opens, the command handler tries to focus
#myusernameedit but that input is `disabled`, so focus stays in the
ace editor iframe. Keydown from inside the iframe does not bubble
to the outer document, so Esc never reaches _bodyKeyEvent.
Fix: in the open-popup rAF, if no command handler placed focus
inside the dialog, focus the popup div itself (with tabindex=-1).
That keeps subsequent keydown events on the outer document so
Esc can dismiss the popup. Also broadened the Esc branch to fire
whenever any popup is `.popup-show`, regardless of where :focus
lives — some popups legitimately have no focusable content at
open.
Added a regression test that opens #users and asserts Esc closes
it. Passes on both Chromium and Firefox.
2. Chat icon (#chaticon) visual still wrong after the first CSS fix.
- My previous `border: 0` reset was overriding the intended
`border: 1px solid #ccc; border-bottom: none` from the earlier
rule. Removed `border: 0`; the earlier explicit border suffices
to suppress UA defaults.
- The `<span class="buttonicon">` inside `#chaticon` was picking
up the global `.buttonicon { display: flex; }` rule meant for
toolbar button instances, which broke the inline layout of the
label + glyph + counter row. Added a scoped
`#chaticon .buttonicon { display: inline; }` override.
All 11 a11y_dialogs specs pass on Chromium and Firefox. Backend
suite and ts-check remain clean.
* fix(a11y): only stash _lastTrigger for dropdown-opening buttons
Round 3 follow-up. The previous Button.bind() change stashed every
clicked toolbar button as padeditbar._lastTrigger before blurring :focus.
That was necessary for popup-opening buttons (settings, import_export,
etc.) so Escape could return focus to them — but it also fired for
non-popup toolbar buttons (list toggles, bold/italic, indent/outdent,
clearauthorship). For those, the stash held a stale reference that
interfered with subsequent editor interactions and regressed Playwright
tests: ordered_list, unordered_list, undo_clear_authorship.
Fix: only stash when the clicked command is a registered dropdown
(settings, import_export, embed, showusers, savedrevision,
connectivity). Other commands return focus to the ace editor as before
and leave _lastTrigger alone.
Verified locally on Chromium:
- ordered_list.spec.ts: 6/6 pass (was 4/6)
- unordered_list.spec.ts: 6/6 pass (was 4/6)
- undo_clear_authorship.spec.ts: 2/2 pass (was 0/2)
- a11y_dialogs.spec.ts: 11/11 pass (unchanged)
* fix(a11y): address Qodo review round 4 for PR #7584#1 Stale aria-label after relocalize
html10n.translateNode() refused to overwrite any existing aria-label,
which also skipped updates on language change (pad.applyLanguage()
re-runs localize). Use a `data-l10n-aria-label="true"` marker: set
aria-label + marker when html10n populates it, overwrite only if the
marker is present. Explicit template-supplied aria-labels stay as-is;
html10n-generated ones refresh on relocalize.
#2 Escape won't close colorpicker
_bodyKeyEvent caught Escape on any `.popup.popup-show` but only
closed dropdown popups via toggleDropDown('none'). Popups opened
outside the editbar framework (#mycolorpicker, toggled directly by
pad_userlist.ts) stayed open while preventDefault() swallowed the
key. Now the Escape branch manually closes any popup that
toggleDropDown('none') cannot reach (non-dropdown ids, plus #users
unless pinned) and leaves registered dropdowns for toggleDropDown to
close so its focus-restore sees the transition.
#3 Stale focus restoration
toggleDropDown('none') restored focus to _lastTrigger even when no
popup was open on entry, which meant background callers
(connectivity setup, periodic state handling) could yank focus out
of the editor to a stale toolbar button. Gated the restore on
`wasAnyOpen === true` so it only fires when there was a popup to
close.
#11 English aria-label overrides i18n (export links, chat icon)
Removed the hard-coded English aria-label from export anchors and
removed aria-hidden from their inner localized spans. Screen readers
now get the localized child text as the accessible name (Etherpad,
HTML, PDF, etc.), matching the visible UI language.
Removed the English aria-label from #chaticon and #titlesticky as
well — both have data-l10n-id, so html10n populates a localized
aria-label via the marker mechanism in #1. #titlecross keeps its
static aria-label because it has no data-l10n-id yet.
#4 4-space indent in a11y spec
Two tests had continuation lines at 4-space indent violating the
repo's 2-space rule. Folded the signatures onto one line.
Updated a11y_dialogs.spec.ts to assert accessible-name presence rather
than hard-coded English for elements whose names now come from the
localized text. Still asserts static English for #titlecross (not
localized yet).
Verified locally (dev server restarted for each round):
- a11y_dialogs.spec.ts: 11/11 on Chromium, 11/11 on Firefox
- ordered_list + unordered_list + undo_clear_authorship: 13/13 on Chromium
- Full backend suite: 799 passing, 0 failing
- tsc --noEmit clean in our code
#9 Popup behavior documentation: deferred to a follow-up doc PR so
this PR stays focused on the a11y code changes. The new keyboard
behavior (Escape-to-close, focus-restore-to-trigger) is small enough
to summarize in a short doc/ addition.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Pad.normalizePadSettings()` was defaulting `lang` to the literal string
'en' when `rawPadSettings.lang` was not a string. That value flowed into
`clientVars.padOptions.lang` and then into `getParams()` in pad.ts,
which calls `html10n.localize([serverValue, 'en'])` as a callback for
the `lang` setting. The result: every pad forced English on load,
overriding the browser's Accept-Language and the existing auto-detect
chain in l10n.ts (cookie -> navigator.language -> 'en').
The regression was introduced in #7545 ("Add creator-owned pad settings
defaults", commit e0ccdb4d9). 2.6.1 did not have this default, so
auto-detect worked there. 2.7.0 broke it.
Fix: default `lang` to null. The client's existing flow already handles
null correctly — getParams() at pad.ts:172 has
`if (serverValue == null) continue;`, so the forced-localize callback
simply does not fire, and l10n.ts's browser-language auto-detect runs.
Pad-settings dropdown consumer at pad.ts:489 already uses
`padOptions.lang || 'en'` so null renders fine there too.
`PadSettings.lang` is now typed `string | null` to match.
Added three backend regression tests under `normalizePadSettings lang`:
* defaults to null when lang is absent (so client auto-detects)
* preserves an explicit string lang (creator override still works)
* drops non-string lang values to null rather than coercing to 'en'
Manual verification: with Firefox set to German, loading a fresh pad
now renders the UI in German. Index and timeslider continued to work
as before. Setting `?lang=de` or a language cookie continues to
override browser detection, as intended.
Fixes#7586
PadMessageHandler built the `pluginsSanitized` payload for clientVars by
aliasing `plugins.plugins` and then mutating each entry's `package` field
in place:
let pluginsSanitized: any = plugins.plugins;
Object.keys(plugins.plugins).forEach(function(element) {
const p: any = plugins.plugins[element].package;
pluginsSanitized[element].package = {name: p.name, version: p.version};
});
Because `pluginsSanitized` is a reference to `plugins.plugins`, the
assignment clobbered the server-side plugin registry. After the first
pad connection, every plugin's `package` object held only `{name,
version}` — `realPath`, `path`, and `location` were gone.
Minify.ts resolves `/static/plugins/ep_*/...` URLs via
`plugin.package.realPath`. Once the field disappeared, every subsequent
static asset request for a bundled plugin 500'd with:
TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of
type string. Received undefined
at Object.join (node:path:1354:7)
at _minify (src/node/utils/Minify.ts:181:23)
Symptoms on Chromium: plugin CSS/JS assets fail to load (e.g.
/static/plugins/ep_font_size/static/css/size.css returns 500), so
plugins partially render or don't work at all. Firefox swallows the
resulting console errors quietly.
Fix: extract the sanitization into a pure helper `sanitizePluginsForWire`
that returns a fresh object graph and never touches the input. The
helper is covered by a new backend spec that:
* verifies the sanitized output has only {name, version} in `package`
* asserts the input registry's realPath/path/location survive the call
* runs the call repeatedly and confirms non-destructiveness
* mutates the returned copy and asserts the input is independent
Verified live with the dev server: before the fix, `/static/plugins/
ep_font_size/static/css/size.css` 500'd after visiting any pad; after
the fix it returns 200 both before and after pad connections.
* fix(editor): preserve U+00A0 non-breaking space (#3037)
Non-breaking spaces were silently normalized to regular spaces at every
ingestion point, so typed/pasted/imported nbsps never reached the
changeset and users could not glue words against line-wrap in French or
other languages that require nbsp typography.
Removed the four strip sites that replaced U+00A0 with U+0020:
- src/node/db/Pad.ts cleanText
- src/static/js/contentcollector.ts textify
- src/static/js/ace2_inner.ts textify
- src/static/js/ace2_inner.ts importText raw-text guard
Updated both processSpaces functions (domline and ExportHtml) to tokenize
U+00A0 as a separate unit, emit it verbatim as , and treat it as
content (not whitespace) for the run-collapse bookkeeping so adjacent
regular-space runs aren't miscounted.
Added backend round-trip tests for spliceText and setText, and extended
the cleanText case table. Updated the existing contentcollector and
importexport specs whose expectations encoded the previous buggy
behavior; they now assert genuine nbsp preservation.
Verified manually in Firefox: clipboard U+00A0 → paste → pad → getText
returns c2 a0; getHTML emits `100 km`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(contentcollector): collapse display-artifact nbsp runs on DOM read-back
processSpaces is a lossy one-way display transform: leading/trailing
spaces and all-but-the-last of a run get rendered as so HTML
doesn't collapse them. When incorporateUserChanges reads text back from
the DOM, those display-artifact nbsps were being stored in the changeset
model instead of being normalized back to plain spaces.
This broke handleReturnIndentation, whose /^ *(?:)/ regex only matches
ASCII spaces: auto-indent after `foo:\n` produced 4 spaces instead of
the expected prev-indent (2) + THE_TAB (4) = 6, because the previous
line's model had nbsps where it used to have spaces.
Fix: in contentcollector.textify, collapse any [ ]+ run back to
plain spaces UNLESS the run is pure U+00A0 AND strictly interior to
word chars. That preserves user-intended typographic nbsps like
"100 km" while undoing the one-way display transform.
Updated 7 contentcollector tests and 7 importexport tests whose
assertions needed to reflect the new rule (boundary/mixed runs collapse;
pure-interior nbsp runs preserve).
Fixes the Playwright regression in indentation.spec.ts:117 that the
previous commit introduced.
* fix(contentcollector): canonicalize nbsp runs at line assembly, not per text node
Addresses Qodo code review feedback on PR #7585.
## Bug fix — nbsp lost at DOM text-node boundary
The previous approach ran the "collapse display-artifact nbsp" rule inside
textify(), which is called per individual DOM TEXT_NODE. A user-intended
nbsp sitting at a text-node boundary (e.g., <span>100</span><span> km
</span>) was incorrectly seen as non-interior (before === '' for the second
text node) and normalized back to a regular space.
Fix: move the canonicalization out of textify() and run it on each
fully assembled line string inside cc.finish(). The rule remains:
[ ]+ run -> plain spaces
UNLESS pure U+00A0 AND strictly interior to non-ws chars
It is length-preserving, so attribute offsets and line lengths are
unaffected.
Added a regression test (contentcollector.spec.ts) for the cross-span
case.
## Docs concern
Reverted the type-only addition of spliceText to PadType. spliceText
is an existing Pad runtime method; the backend test now uses a cast
(`(pad as any).spliceText`) so the PR does not expand the declared
public type surface, avoiding a separate documentation requirement.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#7421 fixed the ESM/CJS interop bug where plugins using
require('ep_etherpad-lite/node/utils/Settings') got an object whose
.toolbar (and every other top-level field) was undefined, crashing
ep_font_color/ep_font_size/ep_plugin_helpers with "Cannot read
properties of undefined (reading 'indexOf')" during pad.html rendering.
That fix landed without a regression test.
Pin the contract: top-level settings fields must be reachable via a
CJS require(), the toolbar must keep its {left, right, timeslider}
shape, and setters on the shim must propagate to the underlying
settings object so reloadSettings() is visible to plugins.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: add timeslider line numbers
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* perf: coalesce timeslider line-number updates
Addresses Qodo review: updateLineNumbers() was called synchronously
from applyChangeset() on every changeset, forcing full-document layout
reads/writes during timeslider scrubbing/playback. scheduleLineNumberUpdate()
also queued a fresh double-rAF pair for every resize tick. Add a pending
flag so only one rAF pair is in flight, and route applyChangeset() through
the scheduler.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: warn when a pending edit is not accepted
Show a gritter warning only when the pad disconnects while a local commit is still awaiting acceptance, leaving normal editing UI unchanged.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* test: cover unaccepted-commit warning path
Addresses Qodo review: adds regression coverage for the two contract
changes this PR introduces — acceptCommit() must clear the pending
marker so hasUnacceptedCommit() returns false after a server ACK, and
the disconnect handler must surface the unsaved-edit gritter when a
commit is still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: allow undo of clear authorship colors without disconnect (#2802)
When a user clears authorship colors and then undoes, the undo changeset
re-applies author attributes for all authors who contributed text. The
server was rejecting this because it treated any changeset containing
another author's ID as impersonation, disconnecting the user.
The fix distinguishes between:
- '+' ops (new text): still reject if attributed to another author
- '=' ops (attribute changes on existing text): allow restoring other
authors' attributes, which is needed for undo of clear authorship
Also removes the client-side workaround in undomodule.ts that prevented
clear authorship from being undone at all, and adds backend + frontend
tests covering the multi-author undo scenario.
Fixes: https://github.com/ether/etherpad-lite/issues/2802
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use robust Playwright assertions in authorship undo tests
- Use toHaveAttribute with regex instead of raw getAttribute + toContain
- Check div/span attributes within pad body instead of broad selectors
- Use Playwright auto-retry (expect with timeout) instead of toHaveCount(0)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: handle confirm dialog and sync timing in Playwright tests
- Add page.on('dialog') handler to accept the confirm dialog triggered
by clearAuthorship when no text is selected (clears whole pad)
- Use auto-retrying toHaveAttribute assertions instead of raw getAttribute
- Increase cross-user sync timeouts to 15s for CI reliability
- Add retries: 2 to multi-user test for CI flakiness
- Scope assertions to pad body spans instead of broad selectors
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use persistent socket listeners to avoid missing messages in CI
Replace sequential waitForSocketEvent loops with single persistent
listeners that filter messages inline. This prevents race conditions
where messages arrive between off/on listener cycles, causing timeouts
on slower CI runners.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: reject - ops with foreign author to prevent pool injection
The '-' op attribs are discarded from the document but still get added
to the pad's attribute pool by moveOpsToNewPool. Without this check, an
attacker could inject a fabricated author ID into the pool via a '-' op,
then use a '=' op to attribute text to that fabricated author (bypassing
the pool existence check).
Now all non-'=' ops (+, -) with foreign author IDs are rejected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: use not.toHaveClass for cleared authorship spans
Addresses Qodo review: linestylefilter skips attribs with empty values,
so a span with author='' has no class attribute at all. The previous
negative-lookahead regex on the class attribute failed against a null
attribute and was flaky in CI. Switch to not.toHaveClass(/author-/),
which also passes when the attribute is missing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a core timeslider playback speed setting with an original-speed default, a realtime mode that uses revision timestamps, and frontend coverage for the new behavior.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* feat!: replace Abiword with LibreOffice and add DOCX export (#4805)
The Abiword converter is dropped. Abiword's DOCX export is weak and the
project is niche on modern platforms; LibreOffice (soffice) is the
common deployment path and now serves as the sole converter backend.
DOCX is added as an export format and becomes the new target for the
"Microsoft Word" UI button. The /export/doc URL still works for legacy
API consumers.
BREAKING CHANGE: The 'abiword' setting, the INSTALL_ABIWORD Dockerfile
build arg, the abiwordAvailable clientVar, and the
#importmessageabiword UI element (with locale key
pad.importExport.abiword.innerHTML) are removed. Deployments relying on
Abiword must configure 'soffice' instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: add docxExport feature flag and abiword deprecation WARN
- Add `docxExport: true` setting to opt out of DOCX (use legacy DOC)
- Pass `docxExport` to client via clientVars
- Use `docxExport` flag in pad_impexp.ts for Word button format
- Emit a specific WARN when deprecated `abiword` config is detected
- Update settings.json.template and settings.json.docker with docxExport
- Add docxExport to ClientVarPayload type in SocketIOMessage.ts
Agent-Logs-Url: https://github.com/ether/etherpad/sessions/9afc5291-73b2-4b66-b028-feed39e7056f
Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com>
* refactor: extract wordFormat variable and improve docxExport comment
Agent-Logs-Url: https://github.com/ether/etherpad/sessions/9afc5291-73b2-4b66-b028-feed39e7056f
Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com>
* fix: restore import-limitation message when no converter is configured
The abiword removal dropped both the #importmessageabiword DOM element
and its locale key, but Copilot's refactor still expected the show()
call to surface a message when exportAvailable === 'no'. Result: users
with no soffice binary got silent failure instead of an explanation.
Add #importmessagenoconverter back with updated, LibreOffice-focused
copy (new locale key pad.importExport.noConverter.innerHTML) and flip
the hidden prop when the client knows no converter is available.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* i18n: inline English fallback for noConverter import message
The original abiword message existed in ~70 locale files and was
removed from all of them by this PR. The replacement key was only
added to en.json, so non-English users had an empty div until
translators localize. Follow the project's usual pad.html pattern
(e.g. line 146's "Font type:") and include the English text inside
the div as the fallback content; html10n replaces it when a
translation is available.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Revert "i18n: inline English fallback for noConverter import message"
This reverts commit f336f24d. Follow the project convention: add the
new locale key to en.json only and let translations catch up via the
translation system, rather than putting inline fallback in the template.
* i18n: leave non-English locale files untouched
The PR had removed pad.importExport.abiword.innerHTML from ~82 locale
files alongside its removal from en.json. The replacement message uses
a new key (pad.importExport.noConverter.innerHTML) in en.json only, so
churning every localisation file for a key that is no longer referenced
produces useless translation diffs. Restore every non-en locale file to
its pre-PR state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com>
* docs(openapi): document apikey auth in openapi.json (#7532)
The API accepts the key via ?apikey=, ?api_key=, or the apikey header, but
only ?apikey= was advertised in /api-docs.json. /api/{version}/openapi.json
was worse: it hardcoded an OAuth2 scheme even when Etherpad was started in
apikey auth mode.
Switch both generators on settings.authenticationMethod and publish apiKey
schemes for the query (apikey, api_key) and header (apikey) variants. The
openapi.ts definition is now regenerated per request so runtime settings
are reflected.
The raw authorization: <key> header still works in code but is deliberately
not documented — pinning it in the spec would ossify a quirk.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(openapi): add apiKeyAlias/apiKeyHeader conditionally in RestAPI.ts
In SSO mode, apiKeyAlias and apiKeyHeader were always present in
securitySchemes even though they're only relevant when
authenticationMethod is 'apikey'. Mirror the pattern used for the sso
scheme: add these two schemes dynamically inside the apikey branch, and
mark them optional in the TypeScript type annotation.
Agent-Logs-Url: https://github.com/ether/etherpad/sessions/1d440432-7389-462e-9aac-9a3c027640e8
Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com>
The test was failing intermittently in CI with "Element is not attached
to the DOM" at selectText(). The long text typed by writeToPad triggers
DOM re-renders in Etherpad, and on slower CI runners the div locator
could detach between resolve and action.
Switch to keyboard-based selection (Ctrl+A) via the existing
selectAllText helper, which doesn't depend on a specific DOM element
staying attached. Verified stable across 10 repeated runs on chromium.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: explicitly ignore scarf build scripts to fix bin install CI
My earlier commit removed @scarf/scarf from onlyBuiltDependencies, but
pnpm 10.7+ requires every encountered build script to be either allowed
(onlyBuiltDependencies) or explicitly ignored (ignoredBuiltDependencies),
otherwise it errors with ERR_PNPM_IGNORED_BUILDS.
The Update Plugins workflow runs pnpm install inside ./bin, which pulls
in ep_etherpad-lite -> swagger-ui-dist -> @scarf/scarf as a transitive
dep. Add scarf to ignoredBuiltDependencies so pnpm silently skips its
install-time telemetry script instead of failing the install.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tests): serialize admin-spec tests to avoid shared-state races
Admin tests mutate global server state (install/uninstall plugins,
save settings, restart etherpad). Running them in parallel — both
across browsers (chromium + firefox) and with playwright's
fullyParallel mode — caused intermittent failures where one test's
install would leak into another test's assertions (e.g. seeing 3
installed plugins when expecting 2, or "ep_font_colormain" in the
hooks list when expecting only core hooks).
Two changes:
1. Drop firefox from test-admin — admin UI is browser-agnostic and
running two browsers concurrently was the main race source
2. Add test.describe.configure({ mode: 'serial' }) to each admin
spec file as a safety net in case the project list or worker
count changes in the future
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix(tests): use ep_set_title_on_pad for admin plugin install test
ep_font_color depends on ep_plugin_helpers, so installing it via the
admin UI actually installs two plugins (ep_font_color + ep_plugin_helpers),
making the installed plugins table show 3 rows (including ep_etherpad-lite)
instead of the 2 the test asserts.
Switch to ep_set_title_on_pad which has no transitive deps, so install
produces exactly one new row as the test expects.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The plugin publish workflow ran `git push --follow-tags` after `pnpm
version patch`. `--follow-tags` is non-atomic per ref: if a concurrent
publish run won the race, the branch fast-forward would be rejected
but the tag push would still land — leaving a dangling `vN+1` tag with
no matching version-bump commit on the branch. Every subsequent push
would then fail forever with `npm error fatal: tag 'vN+1' already
exists`, because `pnpm version patch` would re-derive the same tag
name from the unchanged `package.json`.
On 2026-04-08, a single churn day (badge fixes + Dependabot merges
firing back-to-back) put ~46 plugins into this state simultaneously.
Recovery required hand-bumping `package.json` past the dangling tag
on every affected repo, twice (a second wave appeared after the first
sweep finished, racing the next wave of publishes).
Fix: use `git push --atomic origin <branch> <tag>` so the branch
update and the tag update succeed or fail as a single server-side
transaction. A rejected branch push now also rejects the tag push,
the run aborts cleanly, and the next workflow tick can retry against
the up-to-date refs without leaving any orphaned tags.
Also derive the new tag name from `package.json` after the bump
(rather than parsing pnpm version's stdout, which has historically
varied) and pass it explicitly into the push.
Adds a backend regression test that asserts the workflow file uses
`--atomic`, does not contain a literal `git push --follow-tags`
command (ignoring the historical comment), and includes both the
branch ref and the freshly-bumped tag in the atomic push. The test
gates against accidental reverts.
This file is the source of truth that `bin/plugins/checkPlugin.ts`
propagates into every `ether/ep_*` plugin's `.github/workflows/`, so
the next `update-plugins` cron tick will roll the fix out across all
plugins automatically.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: setup-trusted-publishers.sh works with real npm trust CLI
Two issues found when running the script for the first time after #7490:
1. `npm trust github --file` wants ONLY the workflow filename basename
(e.g. `test-and-release.yml`), not the full
`.github/workflows/test-and-release.yml` path. npm errors out with
"GitHub Actions workflow must be just a file not a path" otherwise.
Constants updated.
2. `npm trust github` requires 2FA on accounts that have it enabled,
and there is no way to disable that requirement. Add a `--otp <code>`
pass-through flag and forward it to every call so a maintainer can
batch-process multiple packages within a single TOTP window.
Documented the limitation in the script header.
Also reword the call site so the npm command line is built without
shell-string round-tripping (passing $CMD through `$( $CMD )` was
unrelated to this bug but was bad practice).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: setup-trusted-publishers.sh recognizes 409 as already-configured
When --skip-existing is set, treat HTTP 409 Conflict from
POST /-/package/<name>/trust as 'already configured' so re-runs of
the bulk script don't fail on packages that were configured in a
previous run.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: cover setup-trusted-publishers.sh, harden against set -e, document --otp
Addresses qodo review on #7491:
- Add backend regression test that shims `npm` on PATH and asserts
`--file` is given the workflow basename (never a path), `--otp` is
forwarded to every `npm trust github` call when supplied, and the
loop survives a non-zero exit so `--skip-existing` can absorb 409
Conflict responses from the registry.
- Wrap the `npm trust github` invocation in `set +e` / `set -e`. The
`if configure_one` already shields the function from errexit in
practice, but a future refactor moving the call site out of an `if`
would silently reintroduce the bug — the explicit shim makes intent
obvious and survives such refactors.
- Document `--otp` and the 2FA / TOTP-expiry workflow in
doc/npm-trusted-publishing.md so maintainers don't follow the docs
and hit EOTP.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: capture head revision atomically with atext to prevent mismatched apply
When constructing CLIENT_VARS, pad.atext was captured at one point but
pad.getHeadRevisionNumber() was called later. If concurrent edits
advanced the revision between these two reads, the client received
initialAttributedText from rev N but rev=N+3, causing "mismatched apply"
errors when the next changeset arrived (expecting rev N+3 text).
Now captures headRev at the same time as atext and uses the captured
value consistently in CLIENT_VARS and sessionInfo.
Fixes#4040
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: flush missed revisions after socket joins pad room
During handleClientReady(), the server awaits the clientVars hook before
socket.join(). Any revisions appended during that await window are
broadcast to existing room members but the connecting socket misses them.
Call updatePadClients(pad) after joining to flush any such revisions.
Also adds a regression test that injects a slow clientVars hook and
verifies the connecting client receives catch-up changesets for edits
that occurred during the hook await window.
Fixes#4040
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: fix race condition in clientVars hook test
Listen for messages during handshake to avoid missing NEW_CHANGES that
arrive before the explicit waitForSocketEvent listener is attached.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: initialize sessionInfo.time before catch-up updatePadClients
The catch-up updatePadClients() call introduced in this PR could send
NEW_CHANGES with timeDelta=NaN because sessionInfo.time was never set
for new sessions. NaN poisons the client-side broadcast/timeslider
currentTime tracking.
Initialize sessionInfo.time to the timestamp of the snapshot revision
before the catch-up flush, with a fallback to Date.now() if the
revision date is unavailable.
Also strengthens the regression tests:
- Validate that initialAttributedText matches the pad AText at the
EXACT advertised rev (not just the latest pad text), using
pad.getInternalRevisionAText(rev).
- Add a load test that hammers the pad with concurrent edits while
multiple clients connect, asserting CLIENT_VARS consistency under
the exact race condition the fix is targeting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: replace open-ended load loop with bounded mid-handshake edit
The previous load test ran 'while (!stopLoad) await pad.setText(...)'
in the background while the test connected clients. This saturated
ueberDB's write queue and on shutdown the queued writes never drained,
hanging the mocha process for the full 6h GitHub Actions job timeout.
Replace it with a bounded approach: a clientVars hook lands 3 edits
mid-handshake (deterministic, no background loop, no shutdown hang).
Still exercises the exact race the fix targets — an edit advancing
the rev after the atext snapshot but before CLIENT_VARS is sent —
and asserts AText / rev consistency via getInternalRevisionAText.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: address remaining Qodo concerns on PR #7480
Addresses Qodo review items 1, 2, 5 from
https://github.com/ether/etherpad-lite/issues/comments/4194702740 :
- Concern 1 (no loadTesting reproduction test): the suite now toggles
settings.loadTest = true in before(), restores in after(). The
middle test also pre-populates the pad with 20 revisions before
connecting so we genuinely exercise a busy/loaded pad rather than a
fresh one.
- Concern 2 (no CLIENT_VARS / NEW_CHANGES delay test): the slow
clientVars hook in the middle test now has explicit setTimeout
delays before AND after the mid-handshake edits, so the race window
between atext snapshot and CLIENT_VARS send is observably wide
rather than relying on async scheduling alone. The test also
collects post-handshake messages and asserts a NEW_CHANGES catch-up
arrives when the pad advanced past the advertised rev.
- Concern 5 (test doesn't validate rev): both rev-consistency tests
use pad.getInternalRevisionAText(advertisedRev) and assert text and
attribs match, not just `pad.text() === clientVars.text`.
Concerns 3 (connect can miss revisions) and 4 (NaN timeDelta) were
already addressed in earlier commits on this branch via the catch-up
updatePadClients() call and the sessionInfo.time initialization.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: increase max socket.io message size to 10MB for large pastes
The default maxHttpBufferSize of 50KB caused socket.io to drop
connections when pasting >10,000 characters. Increased to 10MB which
safely accommodates large paste operations.
Fixes#4951
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* chore: reduce default maxHttpBufferSize to 1MB
10MB was too generous and creates a DoS vector. 1MB (socket.io's own
default) is sufficient for large pastes while limiting memory abuse.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: prevent race condition in session cleanup timeout
When the cleanup timeout fires, check the in-memory exp.real before
reading from the DB. If touch() extended the expiry (but the old
timeout fires late, e.g. on slow CI), reschedule instead of reading
potentially stale cached data from the DB and destroying the session.
Also increased test expiry times so the "touch after eligible for
refresh" test isn't sensitive to event loop delays on slow machines.
Fixes flaky SessionStore test from #7448.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: dev mode entrypoint paths respect x-proxy-path header
In dev mode, the /watch/* script paths were hard-coded as absolute
paths without considering the x-proxy-path header used for subdirectory
reverse proxy setups. This caused 404s for the script tags when hosting
Etherpad on a subdirectory URL (e.g., /pad).
Now reads the x-proxy-path header from the request and prefixes the
entrypoint path, matching how admin.ts handles proxy paths.
Fixes#7137
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: make proxy path tests deterministic in production mode
Tests now verify entrypoint paths and x-proxy-path header handling
in production mode (where tests run) rather than conditionally
asserting only in dev mode.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* security: sanitize x-proxy-path header to prevent XSS
The header value was injected directly into <script src="...">
without sanitization. An attacker who can set request headers could
inject arbitrary HTML/JS. Now only allows path-safe characters.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: very old .etherpad imports could break import due to lack of author metadata, allow this now
* test: add regression tests for old .etherpad import without author
Tests that importing an old .etherpad export (circa 2014) where
revision records lack meta.author succeeds without error, and that
getRevisionAuthor returns '' for such revisions.
Covers the fix for #6785.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: use correct path for connection diagnostics POST
The relative path '../ep/pad/connection-diagnostic-info' resolved
incorrectly in subdirectory setups. Use absolute path from the
application root.
Fixes#4191
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: verify connection diagnostics endpoint is reachable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: numbered list wrapped lines now indent correctly
Changed text-indent to padding-left for ordered list indentation.
text-indent only affects the first line, so wrapped text didn't
align with the numbered content above it.
Fixes#2581
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: verify numbered list uses padding-left instead of text-indent
Regression test for #2581. Verifies that ordered list items use
padding-left (which indents all lines including wrapped ones) rather
than text-indent (which only indents the first line).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: sort language dropdown alphabetically by native name
Languages in the settings dropdown were ordered by language code,
making it hard to find specific languages. Now sorted alphabetically
by their native display name.
Fixes#3263
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: verify language dropdown is sorted by native name
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: preserve ordered list numbering across unordered list interruptions in export
When ordered lists were interrupted by unordered lists, each new <ol>
segment started at 1 instead of continuing the previous numbering.
Track running counts per indent level and emit start attributes.
Fixes#6471
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: respect explicit start attributes and reset counters per level
- line.start takes priority over counter-based continuation when present
- Counter is seeded from line.start to keep subsequent continuations aligned
- Counters for closed indent levels are cleared when list depth decreases
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: RTL URL parameter rtl=false now correctly disables RTL mode
The rtl parameter callback only handled rtl=true (checkVal was 'true'),
so rtl=false was ignored and the layout stayed in RTL from the cookie.
Now accepts any value and sets rtlIsTrue = (val === 'true'). Also
always applies the RTL setting instead of only when true, so switching
from rtl=true to rtl=false takes effect.
Fixes#5559
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only override RTL when explicitly set via URL/server config
The unconditional changeViewOption('rtlIsTrue', false) overwrote
cookie-persisted RTL preferences and language-direction defaults.
Track explicit setting with rtlIsExplicit flag so we only override
when the user or server actually specified an rtl value.
Adds regression tests for rtl=true, rtl=false, and cookie persistence.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: move RTL override into postAceInit to fix race condition
The RTL changeViewOption call was racing with padeditor.init() — the
async setViewOptions(initialViewOptions) at the end of init overwrote
the URL-param-based RTL setting. Moving it into postAceInit ensures
padeditor is fully initialized. Also switched tests to use Playwright
auto-retrying assertions for robustness.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve Playwright test failures for RTL URL parameter
Three issues fixed:
- setCheckbox used .attr('checked') instead of .prop('checked'), so the
JS checked property was never set and Playwright saw unchecked state
- html10n localized event overwrote RTL setting from URL params and
cookie preferences; now skips override when either is active
- Server default padOptions.rtl:false was treated as explicit, overwriting
cookie-persisted RTL; added fromUrl flag to distinguish URL from server
All 94 Playwright tests and 740 backend tests pass locally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: correct readFileSync calls in LinkInstaller to fix plugin installation
pathToFileURL() was incorrectly wrapping paths passed to readFileSync(),
causing ENOENT errors that were silently caught. Using plain paths with
'utf-8' encoding fixes plugin dependency resolution.
Fixes#6811
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add backend tests for LinkInstaller dependency resolution
Covers the readFileSync fix from the plugin installation bug where
pathToFileURL incorrectly wrapped file paths.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: only track dependency in map after successful setup
Previously dependenciesMap.set() ran after the catch block, marking
dependencies as tracked even when linking or package.json reading
failed. This blocked later cleanup via removeSubDependency().
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the cleanup timeout fires, check the in-memory exp.real before
reading from the DB. If touch() extended the expiry (but the old
timeout fires late, e.g. on slow CI), reschedule instead of reading
potentially stale cached data from the DB and destroying the session.
Also increased test expiry times so the "touch after eligible for
refresh" test isn't sensitive to event loop delays on slow machines.
Fixes flaky SessionStore test from #7448.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: add periodic cleanup of expired/stale sessions from database
SessionStore now runs a periodic cleanup (every hour, plus once on
startup) that removes:
- Sessions with expired cookies (expires date in the past)
- Sessions with no expiry that contain no data beyond the default
cookie (the empty sessions that accumulate indefinitely per #5010)
Without this, sessions accumulated forever in the database because:
1. Sessions with no maxAge never got an expiry date
2. On server restart, in-memory expiration timeouts were lost
3. There was no mechanism to clean up sessions that were never
accessed again
Fixes#5010
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: resolve TypeScript error for sessionStore.startCleanup()
Use a local variable for the SessionStore instance to avoid type
narrowing issues with the module-level Store|null variable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address Qodo review — chained timeouts, cleanup tests, docs
- Replace setInterval with chained setTimeout to prevent overlapping
cleanup runs on large databases
- Store and clear startup timeout in shutdown() to prevent leaks
- Add .unref() on all timers so they don't delay process exit
- Fix misleading docstring — cleanup removes empty no-expiry sessions,
not sessions older than STALE_SESSION_MAX_AGE_MS (removed unused const)
- Add 5 regression tests: expired sessions removed, empty sessions
removed, sessions with data preserved, valid sessions preserved,
shutdown cancels timer
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat: add cookie.sessionCleanup setting to control session cleanup
Session cleanup is now gated behind cookie.sessionCleanup (default
true). Admins who want to keep stale sessions can set this to false
in settings.json.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: bold text retains formatting after copy-paste
When pasting bold (or italic, underline, etc.) text, the browser's
contentEditable engine normalized the pasted DOM before Etherpad's
content collector could extract the formatting. The pasted HTML
contained proper <b> tags, but the browser flattened the nested
ace-line divs and stripped the inline formatting in the process.
Now the paste handler checks clipboard HTML for formatting tags. If
found, it prevents default browser paste, parses the HTML in a
detached DOMParser document, and inserts the nodes directly into the
editor. This preserves the formatting tags for the content collector.
Fixes#5037
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* security: sanitize pasted HTML to prevent XSS via clipboard
Strip dangerous elements (script, style, iframe, object, embed, form,
link, meta) and event handler attributes (onclick, onerror, etc.) from
pasted HTML before inserting into the editor. Also removes javascript:
URLs from href attributes.
DOMParser doesn't execute scripts, but importNode copies all attributes
including event handlers that execute when inserted into the live
document.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: list bugs — indent export, renumber performance, and batching
Addresses four list-related bugs:
#4426: Indented text exports as bulleted lists. Added list-style-type:none
to indent-type <ul> elements in ExportHtml.ts so exported indented content
doesn't show bullet markers.
#3504 / #5546: List operations (indent, outdent, toggle) on large lists
are O(n²) because renumberList() runs after each individual line change.
Added _skipRenumber batching flag to setLineListType() — bulk operations
in doInsertList() and doIndentOutdent() now set all line types first,
then renumber once at the end.
#6471: Ordered list numbering in exports — the start attribute is already
read from the pad's atext during export. The client-side renumberList()
correctly sets start attributes which are persisted. Added export test
to verify numbering is preserved across bullet interruptions.
Fixes#4426, #3504, #5546
Related: #6471
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address Qodo review — exception safety, batch removal, renumber scope
- Wrap _skipRenumber in try/finally to prevent permanent disabling on error
- Move list removal (togglingOff) into the batched mods array instead of
calling setLineListType directly (fixes O(n²) for list removal)
- Use firstLine instead of mods[0][0] for renumbering since the first
mod may be an indent/removal that renumberList skips
- Rewrite indent export test to actually create indent lines via setHTML
and unconditionally assert list-style-type:none is present
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: rewrite export tests to use importHtml/exportHtml directly
The HTTP API approach (setHTML via supertest) was hanging when tests
ran standalone because the API endpoint waited for something in the
request pipeline. Using importHtml.setPadHTML and exportHtml.getPadHTML
directly is faster and more reliable.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: update importexport test to expect list-style-type on indent ul
The indent export fix adds style="list-style-type: none;" to indent
<ul> elements, which broke the golden test string comparison.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: appendText API now attributes text to the specified author
spliceText() was calling makeSplice() without passing author attributes,
so inserted text had no authorship attribution in the changeset — even
though the authorId was recorded in the revision metadata. Now passes
[['author', authorId]] and the pool to makeSplice() so the changeset
ops carry the author attribute, making the text show the author's color
in the editor and appear in listAuthorsOfPad.
Also fixed the same issue in pad init (first changeset creation) and
updated PadType interface to include the authorId parameter.
Fixes#6873
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: assert API response code on createPad and anonymous appendText
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: consecutive numbering fails after indented sub-bullets
The applyNumberList() function in renumberList() checked
listType[0] === 'indent' but after regex exec, listType[0] is the
full match (e.g., "indent1"), never just "indent". Changed to
listType[1] which is the capture group containing just the type name.
This caused indent-type lines to not be recognized during renumbering,
breaking the numbering sequence when numbered lists followed indented
content.
Fixes#5718
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: assert sub-bullet indent and remove redundant waitForTimeout
- Assert .list-bullet2 exists after Tab to verify indent precondition
- Remove waitForTimeout(500) since toHaveAttribute already waits 5s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: skip identity changesets during timeslider playback
When a pad's revision history contains an identity changeset (Z:N>0$,
representing no actual change), the timeslider playback would crash or
break because broadcast.ts tried to apply it via mutateAttributionLines
and mutateTextLines.
Now all three applyChangeset call sites in broadcast.ts check for
identity changesets using the existing isIdentity() helper and skip
them. This also prevents errors when compose() produces an identity
changeset from multiple revisions that cancel each other out.
Fixes: https://github.com/ether/etherpad-lite/issues/5214
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: move identity changeset check inside applyChangeset
Move the isIdentity() guard from the call sites into applyChangeset()
itself, so that identity changesets still advance currentRevision,
currentTime, slider position, and author UI — just skipping the
mutation (mutateAttributionLines/mutateTextLines). This prevents the
timeslider from getting stuck on a stale revision when an identity
changeset is encountered.
Also removes unused `identity` import.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: improve timeslider identity changeset test coverage
- Verify slider position advances during playback (confirms revisions
including identity changesets are processed, not skipped)
- Scrub through every revision individually instead of just rev 0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: timeslider playback test starts from rev 0
The test was starting playback from the latest revision, so the slider
had nowhere to advance — causing the position assertion to fail in CI.
Now navigates to #0 first so playback progresses through all revisions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: remove stale identity-skip comment from goToRevision
The isIdentity() check was moved inside applyChangeset() but the old
comment remained at the call sites, creating a misleading code/comment
mismatch.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: createDiffHTML API fails with "Not a changeset: undefined"
Root cause: An empty prototype override (`PadDiff.prototype._createDeletionChangeset = function() {}`)
silently replaced the real class method with a no-op returning undefined.
This caused `applyToAText(undefined, ...)` to throw "Not a changeset".
Also fixed a crash when `startRev === endRev`: the `self` property used
to access `_authors` was only initialized inside `_addAuthors()`, which
is never called when there are no changesets to process. Replaced all
`this.self!._authors` with direct `this._authors` access.
Fixes#6847
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: address Qodo review — random pad ID and assert cleanup
- Use random ID instead of Date.now() to avoid collisions in parallel runs
- Assert HTTP 200 on deletePad in after() hook
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: suppress internal error details from users in production mode
In production mode (NODE_ENV=production), the client-side error handler
now shows a generic "reload the page" message with just the ErrorId
instead of leaking internal details like error messages, file paths,
line numbers, stack traces, and user agent strings.
In development mode, the full error details are still shown for
debugging.
The basic_error_handler (pre-initialization errors) now always shows a
generic message and logs details to the console instead of displaying
them in the DOM.
The server-side jserror endpoint still receives full error details for
server-side logging — only the user-facing display is suppressed.
Fixes: https://github.com/ether/etherpad-lite/issues/5765
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: secure-by-default error sanitization and dedup fixes
Address Qodo review concerns:
- Flip mode check to `!== 'development'` (secure by default) so errors
before CLIENT_VARS handshake hide internal details
- Default server-side mode to 'development' when NODE_ENV is unset
- Replace DOM-based gritter dedup with in-memory Set so dedup works in
production mode (where .error-msg element is absent)
- Add Playwright regression tests for error sanitization
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: revert server-side mode default to preserve secure-by-default
Don't default mode to 'development' when NODE_ENV is unset — that would
defeat the client-side secure-by-default check. Let mode be undefined so
the client's `!== 'development'` check correctly hides error details.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: report early page load errors to server via sendBeacon
basic_error_handler now sends error details to ../jserror using
navigator.sendBeacon with FormData, matching the format expected by
the server's Formidable parser. Includes an errorId shown to the user
for correlation with server logs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* revert: restore original basic_error_handler with full error details
The basic_error_handler runs before the pad framework loads, so it
should show full details to help developers debug bootstrap failures.
Removes the sendBeacon reporting and error sanitization added in the
previous commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PageDown was broken — it moved the caret to the last visible line of
the current viewport instead of advancing by one page. This caused it
to get "stuck" at the bottom of the viewport.
The old code set the caret to oldVisibleLineRange[1] - 1 (the last
visible line), which was essentially a no-op for scrolling. The fix
mirrors the PageUp logic: advance/retreat by numberOfLinesInViewport.
Also simplified the clamping logic for both selStart and selEnd.
Fixes: https://github.com/ether/etherpad-lite/issues/6710
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: filter already-deleted sessions when deleting a group
deleteSession uses setSub(..., undefined) to remove session references
from group2sessions and author2sessions, but this can leave null entries
in the sessionIDs object. When deleteGroup later iterates Object.keys
of sessionIDs and calls deleteSession on each, it throws "sessionID
does not exist" for the already-deleted sessions.
Now deleteGroup filters out null/falsy session entries before attempting
to delete them.
Fixes: https://github.com/ether/etherpad-lite/issues/5798
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* test: add regression test for deleteGroup after deleteSession (#5798)
Creates a group, author, and session, then deletes the session first,
then deletes the group. Without the fix, deleteGroup would throw
"sessionID does not exist" when encountering the null session entry.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When an ordered list followed directly after an unordered list (no blank
line between), all OL items showed "1" instead of incrementing. This was
because renumberList's applyNumberList function counted bullet items in
the position counter, so the first OL item got start=3 (after 2 bullet
items) instead of start=1, preventing the CSS counter-reset class from
being applied.
The fix resets the position counter when the list type changes at the
same level (e.g., bullet -> number).
Fixes: https://github.com/ether/etherpad-lite/issues/5160
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When HTML containing <li> elements without a wrapping <ul> or <ol> is
pasted (e.g., from ChatGPT), the contentcollector crashes with
"TypeError: lineAttributes.list is undefined" because it assumes
_enterList() was already called by a parent list element.
The fix defaults bare <li> elements to bullet1 list type and properly
sets oldListTypeOrNull so the list state is cleaned up after the <li>
is processed. Also guards the .indexOf() call on lineAttributes.list.
Fixes: https://github.com/ether/etherpad-lite/issues/6665
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Drop webkit from CI workflow and Playwright config (Chrome + Firefox
are the supported browsers)
- Set retries: 2 in CI to handle intermittent failures from timing
sensitive operations (list attribute clearing, server restarts)
- Fix clearAuthorship helper to use force:true to bypass toolbar-overlay
div that intermittently intercepts clicks after text selection
- Fix admin restartEtherpad helper: increase poll intervals, add
explicit timeout, use toHaveValue with timeout instead of toBeEmpty
- Convert clear_authorship_color tests to use Playwright auto-retry
assertions (toHaveAttribute) instead of one-shot getAttribute calls
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Enforce 2-space indentation across codebase
Convert all 4-space indented source files to 2-space to match
.editorconfig and project contributor guidelines.
74 files converted: admin UI components, type definitions, security
modules, test files, helpers, and utilities.
No functional changes — 2882 insertions, 2882 deletions (pure
whitespace).
Fixes#7353
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Limit admin tests to chromium and firefox
Webkit is already tested in the dedicated frontend-tests workflow.
Running it again in admin tests causes flaky failures due to slow
socket connections and external API timeouts on webkit CI runners.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The settings textarea content is populated asynchronously via socket.
On slow CI (especially Node 20 + Firefox), the default 20s timeout
isn't enough. Increase to 30s for all toBeEmpty checks.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix flaky undo keypress test
Each Ctrl+Z may only undo one keystroke depending on how Etherpad
batches undo operations (varies between dev and prod mode). Loop
Ctrl+Z presses until content is restored instead of assuming one
press undoes everything.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix flaky admin restart test causing cascading failures
restartEtherpad used a hardcoded 500ms wait which wasn't enough for
the server to restart on slow CI. Subsequent tests got
ERR_CONNECTION_REFUSED because the server was still down.
- Poll the server until it responds instead of hardcoded timeout
- Re-login after restart since the session cookie is lost
- Remove unnecessary waitForTimeout(5000) at end of test
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Speed up restart poll: accept any response, poll at 500ms
The previous check required response.ok() (2xx) but the server
returns redirects (3xx) which caused the loop to run all 30 seconds.
Accept any non-zero status and reduce poll interval to 500ms.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix loginToAdmin: navigate to /admin/login directly
The admin SPA only shows the login form at /admin/login. Navigating to
/admin/ loads the HomePage route which doesn't have the login fields.
After a server restart the session is lost, but the SPA doesn't
automatically redirect to /admin/login, causing the test to timeout
waiting for input[name="username"].
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix plugin search test: wait for results, increase timeout
The search is debounce-triggered (500ms), not Enter-triggered. The
toHaveCount(1) check passed on the "not found" row before results
loaded. Removed the Enter press and count check, increased timeout
to 30s since the search depends on an external API call to
static.etherpad.org.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix plugin search: normalize numeric keys from registry
The plugin registry at static.etherpad.org/plugins.json changed format
from {ep_name: {data}} to {index: {name: "ep_name", data}}. The search
code iterated keys expecting plugin names starting with "ep_", but got
numeric keys like "41", skipping all plugins. Normalize the data after
fetching to use plugin names as keys.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix flaky unordered_list and undo tests
- unordered_list: re-query the DOM element after clearPadContent()
instead of holding a stale reference that detaches when the editor
rebuilds the DOM.
- undo: remove hardcoded waitForTimeout(1000) and re-query the element
so Playwright's auto-retry on toHaveText handles the timing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix flaky tests: wait for DOM settle, undo all keystrokes
- unordered_list: wait for DOM to settle after clearPadContent() with
toHaveCount assertion, then use click() instead of selectText() which
races with async DOM rebuilds.
- undo: press Ctrl+Z three times since writeToPad('foo') produces 3
keystrokes and each Ctrl+Z only undoes one.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix undo keypress test: don't clear pad before typing
Clearing the pad added an undoable operation to the history, so Ctrl+Z
could undo past the typing and restore the original welcome text.
Simplified to match the button-based undo test: type on existing
content and undo once, since Etherpad batches rapid keystrokes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix undo keypress test: restore clear step, mirror button test
writeToPad needs clearPadContent first to ensure focus is in the
editor iframe. Without it, keyboard.type() doesn't reach the editor.
Now mirrors the button-based undo test exactly, just using Ctrl+Z
instead of clicking the undo button, and without the hardcoded
waitForTimeout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix frontend test failures across all browsers
- Fix home button using fragile relative URL (window.location.href +
"/../..") that WebKit doesn't resolve correctly. Use
window.location.origin instead.
- Wait for #editorcontainer.initialized in goToNewPad/goToPad/
appendQueryParams so toolbar, chat, and cookie handlers are fully
set up before tests interact with them.
- Clear cookies in chat test beforeEach to prevent chatAndUsers cookie
from prior tests disabling the sticky chat checkbox.
- Wait for navigation to complete in editbar home button test.
Fixes#7405
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Run frontend tests on pull requests
Playwright runs locally and doesn't need Sauce Labs secrets, so
there's no reason to limit frontend tests to push events only.
Also remove stale Sauce Labs references from workflow names/comments.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix sticky chat test: use click() instead of check()/uncheck()
The stickToScreen() handler manages checkbox state internally with its
own toggle logic and a setTimeout. Playwright's check()/uncheck()
methods verify state after clicking, but race with the async toggle,
causing "Clicking the checkbox did not change its state" errors.
Using click() avoids this — the waitForSelector calls already verify
the final state.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix sticky chat handler and reduce parallel workers
- Remove force:true from sticky chat checkbox clicks — it can bypass
jQuery event handlers preventing stickToScreen() from firing.
- Wait for chatbox stickyChat class instead of checkbox state, since
stickToScreen() manages the checkbox asynchronously via setTimeout.
- Reduce workers from 5 to 2 to avoid overloading the single Etherpad
server instance, which causes goToNewPad timeouts on CI.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Clean up workflows: remove Sauce Labs, load test push-only
- Remove all Sauce Labs references (steps, comments, secrets) from
frontend test workflows — Playwright replaced Sauce Labs
- Remove unused set-output steps and GIT_HASH exports
- Remove stale commented-out code from admin tests
- Restrict load test to push events only (no need on PRs)
- Fix artifact names to not reference undefined matrix.node
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix sticky chat test: click label instead of checkbox
The label element intercepts pointer events on the checkbox (reported
by Webkit). On Chrome/Firefox the checkbox is "not stable" due to
animations. Clicking the label is how a real user interacts with it
and properly triggers the jQuery click handler.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix home button to preserve subpath installations
Use URL API to resolve '../..' relative to current URL instead of
hardcoding origin + '/'. This preserves any configured base path
(e.g. /etherpad) for reverse-proxy installations.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>