mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-18 09:04:54 +00:00
2043 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bfdbd2bb91
|
chore: removed axios (#7685)
* chore: removed axios * chore: pnpm |
||
|
|
70415714e6
|
fix(socketio): don't kick authenticated duplicate-author sessions (#7656) (#7678)
* fix(socketio): don't kick authenticated duplicate-author sessions (#7656) The CLIENT_READY handler kicks any prior socket whose authorID matches the joining socket's, originally as a workaround for stale tabs in the same browser (cookie-derived authorIDs were per-browser, so "same authorID, same pad" reliably meant "page refresh / second tab in this browser"). With stable identities (basic auth, SSO, apikey, getAuthorId hook) the same authorID can legitimately appear across windows or devices, so the kick disconnects real concurrent sessions. Skip the kick when the joining socket has req.session.user set; cookie-only sessions keep the existing behavior so the userdup modal and the xxauto_reconnect path still work. * fix(socketio): suppress USER_LEAVE when other same-author sockets remain With the duplicate-author kick disabled for authenticated sessions, a single authorID can legitimately span multiple sockets in one pad. handleDisconnect was emitting USER_LEAVE on every socket close, which made clients (whose presence is keyed by authorID) drop the author entirely even when another socket of theirs was still online. Only broadcast USER_LEAVE — and only run the userLeave hook — when the disconnecting socket is the last one in the pad for that author. Adds two backend tests: - authenticated identity: closing one of two same-author sockets does NOT emit USER_LEAVE on the other. - different authors (regression): closing socket A still emits USER_LEAVE for socket B. Action of Qodo review feedback on PR #7678. |
||
|
|
69bb1e19c5
|
feat(gdpr): author erasure (PR5 of #6701) (#7550)
* docs: PR5 GDPR author erasure design spec * docs: PR5 GDPR author erasure implementation plan * feat(gdpr): AuthorManager.anonymizeAuthor — Art. 17 erasure * test(gdpr): AuthorManager.anonymizeAuthor unit tests * feat(gdpr): REST anonymizeAuthor on API version 1.3.1 * test(gdpr): REST anonymizeAuthor end-to-end * docs(gdpr): right-to-erasure section + anonymizeAuthor example * fix(gdpr): make anonymizeAuthor resumable on partial failure Qodo review: the `erased: true` sentinel was written before the chat scrub loop, so a throw during scrub left chat messages untouched while subsequent calls short-circuited on `existing.erased` and never finished. Split the write: zero the display identity first (still hides the name), run the chat scrub, and only then stamp `erased: true` so a retry resumes the sweep. Regression test covers the partial-run → retry path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
487842006c
|
feat(gdpr): configurable privacy banner (PR4 of #6701) (#7549)
* docs: PR4 GDPR privacy banner design spec
* docs: PR4 GDPR privacy banner implementation plan
* feat(gdpr): typed privacyBanner setting block + public getter exposure
* feat(gdpr): send privacyBanner config to the browser via clientVars
* feat(gdpr): privacy banner DOM (hidden by default)
* feat(gdpr): render privacy banner on pad load when enabled
* style(gdpr): privacy banner layout
* test+fix(gdpr): privacy banner Playwright + hidden-attr CSS override
* docs(gdpr): privacyBanner configuration section
* fix(gdpr): reject unsafe learnMoreUrl schemes
Qodo review: showPrivacyBannerIfEnabled assigned config.learnMoreUrl
directly to <a href>, so a misconfigured settings.privacyBanner.
learnMoreUrl of `javascript:alert(1)` or `data:…<script>…` would run
script on click. Validate via URL parsing and allow only http(s) /
mailto; everything else yields no link. Playwright regression guards
the four cases (javascript, data, https, mailto).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(privacy-banner): drop unneeded !important on [hidden] rule
Class+attribute selector already outranks `.privacy-banner { display: flex }`
on specificity (0,2,0 vs 0,1,0), so `!important` was redundant. Adds a
comment explaining why so a future reader doesn't put it back.
Per Sam's review on #7549.
* refactor(privacy-banner): render as a persistent gritter, not custom DOM
Drops the bespoke #privacy-banner template + ~50 lines of popup.css and
delegates to $.gritter.add({sticky: true, position: 'bottom'}). The
notice now matches every other gritter on the pad (theme variables,
shadow, animation, (X) close), sits in the bottom corner instead of
above the editor, and inherits dark-mode handling for free.
The two dismissal modes survive intact:
- dismissible: gritter closes on (X); before_close persists a flag
in localStorage so the notice is suppressed on subsequent loads.
- sticky: closes for the current session only; never persists; the
next pad load shows it again.
learnMoreUrl still goes through the same safeUrl() filter so a
javascript:/data:/vbscript: URL can't smuggle a script handler into the
anchor (Qodo's review concern remains addressed).
Tests: src/tests/frontend-new/specs/privacy_banner.spec.ts now drives
the real showPrivacyBannerIfEnabled via a __etherpad_privacyBanner__
test hook and asserts against the rendered gritter, instead of the
previous tests that mutated DOM by hand and never exercised the
function under test. Coverage adds: enabled=false short-circuit,
dismissible-flag-respected on subsequent show, sticky-ignores-flag,
sticky-close-does-not-persist, javascript: rejection, data: rejection,
and mailto: allow-list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(privacy-banner): noreferrer + validate dismissal (Qodo)
Two follow-ups from Qodo's review on #7549:
1. The Learn-more link now sets `rel="noreferrer noopener"` (was just
`noopener`). Without `noreferrer` the browser sends the pad URL as a
Referer to the operator-configured external policy site, which leaks
pad identifiers to a third party. Matches the rel pattern already
used by pad_utils.ts.
2. `privacyBanner.dismissal` is now validated in reloadSettings(): an
unknown value falls back to 'dismissible' with a `logger.warn`, in
the same shape as the existing ipLogging validation a few lines up.
The client also guards defensively (treats anything other than the
exact string 'sticky' as 'dismissible') so that hot-reload paths
that skip the server validator can't silently degrade a typo'd
'sticky' into "no close button persisted, no localStorage suppression".
Test added: spec asserts the rel attribute, and a new test exercises
the dismissal fallback (sets dismissal:'wat', asserts the gritter is
shown, the (X) closes it, and the dismissal flag is persisted — i.e.
the unknown value is treated like 'dismissible').
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(privacy-banner): gate test hook on webdriver, align doc with sticky behavior
Two follow-ups from Qodo's second review on #7549.
Rule violation: __etherpad_privacyBanner__ was published on every pad
load even when privacyBanner.enabled was false, so the disabled-by-
default feature still added an observable global. Gate the assignment
on `navigator.webdriver` — Playwright/ChromeDriver/Selenium set this
to true; production browsers do not — so the hook is only present for
tests and the disabled path is genuinely zero-side-effect.
Bug 3 (sticky still closable): doc/privacy.md previously claimed
`dismissal: "sticky"` removes the close button, but the gritter
implementation always renders (X). Aligning the doc with reality —
sticky now means "shows on every load, but closable for the session"
— rather than adding bespoke CSS to a vanilla gritter (matches the
"don't style it differently than other gritter messages" preference
that drove the gritter migration in
|
||
|
|
49bc33f019
|
feat(gdpr): HttpOnly author-token cookie (PR3 of #6701) (#7548)
* docs: PR3 GDPR anonymous identity hardening design spec
* docs: PR3 GDPR anon identity implementation plan
* feat(gdpr): ensureAuthorTokenCookie helper — HttpOnly server-set author token
* feat(gdpr): set HttpOnly author-token cookie from the pad routes
* feat(gdpr): read author token from cookie first, keep message.token fallback
* feat(gdpr): stop generating the author token client-side
* test(gdpr): server sets + reuses the HttpOnly author-token cookie
* fix+test(gdpr): parse token cookie from handshake Cookie header
socket.io handshake doesn't run cookie-parser, so socket.request.cookies
is undefined. Parse the Cookie header directly in handleClientReady so
the HttpOnly token actually resolves. Playwright spec covers HttpOnly
attribute, reload-stability, and context-isolation.
* docs(gdpr): token cookie is now HttpOnly + server-set
* fix(gdpr): close two HttpOnly token bypasses
Qodo review:
- Timeslider still ran the pre-PR3 JS-cookie path: it read
Cookies.get('${cp}token') (which HttpOnly hides), then generated a
fresh plaintext token and overwrote the server's HttpOnly cookie with
it, and sent token in every socket message. Strip the token read/
write entirely from timeslider.ts and from the outgoing message
shape; the server reads the cookie off the socket.io handshake just
like on /p/:pad.
- tokenTransfer re-issued the author cookie without HttpOnly, undoing
the hardening the first time a user transferred a session. Re-set
it as HttpOnly + Secure (on HTTPS) + SameSite=Lax. Also stop
trusting the body-supplied token on POST: read it off req.cookies
server-side so the client never needs JS access to the token.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
9014d3a7c4
|
fix(colors): pick WCAG-higher-contrast text for author colors (#7565)
* feat(colors): clamp author backgrounds to WCAG 2.1 AA on render
Fixes #7377.
Authors can pick any color via the color picker, so a user who chooses
a dark red ends up with black text rendered on a background that fails
WCAG 2.1 AA (4.5:1) — unreadable, but there is no way for *viewers* to
remediate since they cannot change another author's color. Screenshot
in the issue shows exactly this.
This PR lands a viewer-side clamp. For each author background, if
neither black nor white text would satisfy the target contrast ratio,
the bg is iteratively blended toward white until black text does. The
author's stored color is untouched — turning off the new
padOptions.enforceReadableAuthorColors flag restores the raw colors
immediately.
New helpers in src/static/js/colorutils.ts:
- relativeLuminance(triple) — WCAG 2.1 relative-luminance formula
- contrastRatio(c1, c2) — in [1, 21]; >=4.5 = AA, >=7.0 = AAA
- ensureReadableBackground(hex, minContrast = 4.5)
— returns a hex that meets minContrast
against black text, preserving hue
Wire-up:
- src/static/js/ace2_inner.ts (setAuthorStyle): pass bgcolor through
ensureReadableBackground before picking text color. Gated on
padOptions.enforceReadableAuthorColors (default true). Guarded by
colorutils.isCssHex so the few non-hex values (CSS vars, etc.) skip
the clamp and pass through unchanged.
- Settings.ts / settings.json.template / settings.json.docker: new
padOptions.enforceReadableAuthorColors flag, default true, with a
matching PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS env var in the
docker template.
- doc/docker.md: env-var row.
- src/tests/backend/specs/colorutils.ts: new unit coverage for the
three new helpers, including the exact #cc0000 failure case from
the issue screenshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* refactor(7377): simplify — just pick higher-contrast text, drop bg clamp
First iteration added an iterative bg-lightening helper
(ensureReadableBackground) gated by a new padOptions flag. CI caught the
correct simpler framing: because WCAG contrast is symmetric in [1, 21],
at least one of black/white always clears AA (4.5:1) for any sRGB
colour. The real bug was that the pre-fix textColorFromBackgroundColor
used a plain-luminosity cutoff (< 0.5 → white), which produced
sub-AA combinations like white-on-red (#ff0000) at 4.0:1.
Reduce the PR to the minimal surface:
- colorutils.textColorFromBackgroundColor now picks whichever of
black/white has the higher WCAG contrast ratio against the bg.
- colorutils.relativeLuminance and colorutils.contrastRatio are kept
as reusable building blocks; ensureReadableBackground is dropped
(no caller needed it once text selection was fixed).
- ace2_inner.ts setAuthorStyle no longer needs the opt-in flag or the
isCssHex guard — the helper handles every input its caller already
passes.
- padOptions.enforceReadableAuthorColors setting reverted along with
settings.json.template, settings.json.docker, and doc/docker.md.
- Tests replaced: instead of asserting the bg gets lightened, assert
that the chosen text colour clears AA for every primary. Covers the
exact #ff0000 failure case from the issue screenshot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7377): assert relative-contrast invariant, not absolute AA
Pure primaries like #ff0000 cannot clear WCAG AA (4.5:1) against either
#222 or #fff — the best either can do is ~4.0:1. No text-colour choice
alone fixes that; bg clamping would be a separate concern. The test
should therefore verify the *real* invariant: the chosen text colour
must produce the higher contrast of the two options, regardless of
whether that contrast clears any absolute threshold.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7377): compare against rendered #222/#fff, not pure black/white
First cut of textColorFromBackgroundColor computed contrast against
pure black (L=0) and pure white (L=1), then returned the concrete
#222/#fff the pad actually renders with. For some mid-saturation
backgrounds the two comparisons disagreed — e.g. #ff0000:
vs pure black = 5.25 → pick black → render #222 → actual 3.98
vs pure white = 4.00 → would-render #fff → actual 4.00
The helper picked the wrong option because it compared against the
wrong target. Compare against the actual rendered colours so the
returned text colour is genuinely the higher-contrast choice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(7377): pick unambiguous colibris test bgs
#ff0000 lives right at the boundary for the two text choices (4.00 vs
3.98), so the test for colibris-skin mapping was entangled with the
border-case selector pick. Use #ffeedd (clearly light → dark text
wins) and #111111 (clearly dark → light text wins) so the test
isolates the skin mapping from the tie-breaking logic.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(7377): use rendered text colour + clamp bg to actually meet AA
Local repro of the issue exposed two real bugs in the previous fix:
1. textColorFromBackgroundColor compared bg against a hardcoded #222 —
but in the colibris skin --super-dark-color resolves to #485365.
For the issue's exact case (#9AB3FA author bg) the selector returned
var(--super-dark-color) thinking it was getting a 7.7:1 ratio, while
the browser actually rendered 3.78:1 — identical to what the issue
screenshot reported. This PR's previous behaviour on the issue's
inputs was unchanged from the pre-fix.
2. For mid-saturation pastels (#9AB3FA) and pure primaries (#ff0000)
neither rendered dark nor white text can clear AA. Text-colour
selection alone genuinely cannot fix this band; the ensureReadable
bg clamp dropped in
|
||
|
|
e0a989094d
|
feat(colors): add padOptions.fadeInactiveAuthorColors with toolbar UI (#7554)
Adds a new pad option `fadeInactiveAuthorColors` (default `true`) that controls whether each author's caret/background fades toward white as they go inactive. Configurable server-side (`settings.json` / `PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS`), per-pad in the Pad Settings panel, per-user in the My View panel, or via `?fadeInactiveAuthorColors=false`. Disabling the fade is useful on busy pads where every faded author visually counts as a second on-screen color (a 30-author pad becomes a 60-color pad), or when inactivity tracking is undesirable for whatever reason. Closes #7138. |
||
|
|
e028016296
|
feat(admin): surface ep.json disables in /admin plugin browser (#7649)
Companion to ether/ether.github.com#395 — the admin UI's "available plugins" listing now also renders the plugin's declared `disables` (see doc/PLUGIN_FEATURE_DISABLES.md) so an operator about to click Install sees the same warning as a user browsing etherpad.org/plugins: "Disables: chat". - src/node/types/PackageInfo.ts: optional `disables?: string[]` on the registry payload type. - admin/src/pages/Plugin.ts: same on the admin-side PluginDef. - admin/src/pages/HomePage.tsx: render an amber callout under the description when `disables` is present and non-empty. Plugins without a disables field render unchanged. The plugin-registry build pipeline still has to start surfacing `disables` from ep.json into plugins.json/plugins.viewer.json — until that lands, the new callout no-ops everywhere, which is fine. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4bda757304
|
feat(api): public compactPad API + bin/compactPad CLI over existing Cleanup (#7567)
* feat(pad): compactHistory() + compactPad CLI for DB-size reclaim Fixes #6194. Long-lived pads with heavy edit history dominate the DB — the issue describes a ~400 MB Postgres after two months with ~100 users. Etherpad keeps every revision forever, and removing arbitrary middle revisions is unsafe because state is reconstructed by composing forward from key revisions. What's safe: collapse the full history into a single base revision that reproduces the current atext. The existing `copyPadWithoutHistory` already does this for a new pad ID — this PR lifts that same changeset pattern into an in-place operation and wires up an admin CLI. - `Pad.compactHistory(authorId?)` (src/node/db/Pad.ts): composes the current atext into one base changeset, deletes all existing rev records, clears saved-revision bookmarks, and appends the new rev 0. Text, attributes, and chat history are preserved; saved-revision pointers are cleared. Returns the number of revisions removed. - `API.compactPad(padID, authorId?)` (src/node/db/API.ts): public-API wrapper around compactHistory. Reports `{removed}` so callers can log savings. - `APIHandler.ts`: register `compactPad` under a new `1.3.1` version, bump `latestApiVersion`. - `bin/compactPad.ts`: admin CLI. Reports the current revision count, calls compactPad via the HTTP API, and prints how many revisions were dropped. - `src/tests/backend/specs/compactPad.ts`: four backend tests cover the empty-pad no-op, the text-preservation + head=0 contract, saved-revision cleanup, and that subsequent edits continue to append cleanly on top of the collapsed base. The operation is destructive so admins must opt in explicitly; the CLI prints the before-count, and the recommended pre-flight is an `.etherpad` export (backup). Closes #6194 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(compact): delegate to copyPadWithoutHistory via temp-pad swap The initial compactHistory() implementation built a custom base changeset and re-ran appendRevision against a reset atext — but the changeset was packed with oldLength=2 (matching copyPadWithoutHistory's dest-pad init state) while the reset atext was only length 1, so applyToText tripped its "mismatched apply: 1 / 2" assertion and every test failed with a Changeset corruption error. Switch to the tested path instead: copy the pad via copyPadWithoutHistory to a uniquely-named temp pad (inherits all its attribute/pool/changeset correctness), read the temp pad's rev records back, delete the old ones under our pad's ID, write the new records in their place, update in-memory state to match, and remove the temp pad. Errors at any step fall through with a best-effort temp-pad cleanup. Contract shifts slightly: the collapsed pad is head<=1 rather than head=0, matching the shape of a freshly-imported pad (seed rev 0 + content rev 1). Tests updated to assert that invariant plus text-preservation, saved-revision cleanup, and append-after-compact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(6194): match the head<=1 post-compact contract Tests previously asserted head=0 exactly after compaction; the temp-pad-swap path lands at head=1 (one seed rev plus one content rev) matching the shape of a freshly-imported pad. Relax the assertions to and derive the removed-count from before-head minus after-head, so the tests still catch regressions in text-preservation, saved-revision cleanup, and append-after-compact without being tied to the exact implementation shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(6194): wrap existing Cleanup instead of duplicating it Develop already ships a working revision-cleanup path under `src/node/utils/Cleanup.ts` with two public helpers — `deleteAllRevisions(padId)` (collapse full history via copyPadWithoutHistory) and `deleteRevisions(padId, keepRevisions)` (keep the last N). The admin-settings UI wires these up but neither is exposed on the public API, and there's no CLI for operators who want to run compaction outside the web UI. That's the gap this PR now fills. Changes from the prior revision of this PR: - Drop `pad.compactHistory()` — it re-implemented what `Cleanup.deleteAllRevisions` already does. Remove the duplicate. - `API.compactPad(padID, keepRevisions?)` now delegates to Cleanup: • keepRevisions null/undefined → deleteAllRevisions (full collapse) • keepRevisions >= 0 → deleteRevisions(N) (keep last N) Returns {ok, mode: 'all' | 'keepLast', keepRevisions?}. - APIHandler `1.3.1`: signature updated to take `keepRevisions` instead of `authorId`. - `bin/compactPad.ts`: accepts `--keep N` for the keep-last mode, shows before/after revision counts so operators see concrete savings. - Backend tests rewritten around the public API surface (mode reporting, text preservation, input validation) rather than internal method plumbing that no longer exists. Net: strictly a thin public-API and CLI veneer over already-tested Cleanup helpers. No new low-level logic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(6194): assert content markers, not byte-exact atext Cleanup.deleteAllRevisions internally calls copyPadWithoutHistory twice (src → tempId, tempId → src with force=true), and each round trip normalizes trailing whitespace. That meant my byte-exact atext.text assertion failed in CI: expected: '...line 3\n\n\n' actual: '...line 3\n' Swap the comparisons to use content markers (marker-alpha / beta / gamma, keep-line-N). The test still catches the real regressions — if compactPad lost content those markers would disappear — without coupling to whitespace quirks of the existing Cleanup implementation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(6194): correct API param + document compactPad in http_api docs The 1.3.1 entry in APIHandler registered `['padID', 'authorId']`, but `API.compactPad` takes `(padID, keepRevisions)` and the CLI sends a `keepRevisions` query param. APIHandler.handle dispatches by URL field name, so the previous wiring silently dropped `keepRevisions` and never ran the keep-last branch over HTTP. - Register `['padID', 'keepRevisions']` so the handler forwards the CLI/HTTP arg into the API function. - Add HTTP-level dispatch tests that hit `/api/1.3.1/compactPad` with and without `keepRevisions`. The direct `api.compactPad()` tests bypass the handler and would have missed this regression. - Document compactPad in `doc/api/http_api.md` and `http_api.adoc`, and bump the documented latest version from 1.3.0 to 1.3.1 to match `latestApiVersion`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(6194): add bin/compactAllPads for per-instance bulk compaction `bin/compactPad <padID>` covers the case where you know which pad is fat. For "reclaim space across the whole instance," composing `listAllPads` + `compactPad` yourself is annoying; this script does it. - Walks every pad on the instance and compacts it (full collapse, or `--keep N` keep-last). - Per-pad failures don't abort the run — they're logged, counted, and the script exits 1 if any failed. - `--dry-run` lists pads + revision counts without writing anything, so operators can scope impact before committing. - Reports `before → after` per pad and a total reclaimed count. Deliberately not adding a `compactAllPads` HTTP API: bulk compaction over a single HTTP request means one giant response and a long-held connection. Operators who want this should run it locally, where they can see progress and kill it cleanly. Staleness gating ("only pads older than X days") is tracked separately as a follow-up. Also registers `compactPad` and `compactAllPads` script aliases in `bin/package.json` so they show up next to the other admin CLIs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(6194): cover the bin/compactAllPads loop logic Previous commit added the script but only exercised it by hand. The loop itself — error tolerance, dry-run gating, keep-last passthrough, the empty-instance and listAllPads-failure paths — had no automated coverage. - Refactor compactAllPads.ts to export `runCompactAll(api, opts, logger)` and `parseArgs(argv)`. The CLI shell wires them up to axios+APIKEY for production; tests use an in-memory `CompactAllApi` so we don't need to stand up the apikey-auth path in mocha. - Add 9 specs covering: arg parsing, full-collapse iteration, --keep N passthrough, --dry-run skipping writes, single-pad failure not aborting the run, pre-flight count failure tolerated, a listAllPads failure short-circuiting cleanly, the empty-instance no-op, and a final end-to-end test that runs `runCompactAll` against the real `/api/1.3.1/compactPad` handler over supertest+JWT to catch contract drift between the CompactAllApi shape and the HTTP endpoints. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(6194): address Qodo review — gate, integer check, SSL Three valid concerns from the Qodo review on 75a08a13: 1. **cleanup.enabled gate.** The admin/Cleanup-socket path checks `settings.cleanup.enabled` before doing anything destructive; the public API was bypassing that gate. Now `compactPad` mirrors the admin path's check and returns a clear apierror when disabled, so exposing the API doesn't accidentally widen the cleanup-opt-in surface. 2. **Number.isFinite → Number.isInteger.** `2.5` was finite and non-negative, so the old check let it through into `Cleanup.deleteRevisions`, which does revision-index arithmetic that assumes integer math. Reject at the API boundary instead of silently misbehaving. 3. **SSL-aware baseURL in the bin scripts.** Other bin scripts hardcode `http://`, but the rest of the codebase uses `settings.ssl ? 'https' : 'http'`. The compact CLIs now do the same, so they work against HTTPS deployments. (Other bin scripts carry the same bug but fixing them is out of scope for this PR.) Tests: - New spec: `rejects fractional keepRevisions` (2.5 with the old check passed; the new one rejects). - New spec: `refuses to run when cleanup.enabled is false`. The existing API tests opt in via a before-hook + restore, so they still cover the success path under the new gate. - API docs (`http_api.md` + `http_api.adoc`) document the gate and the new error message. Skipped Qodo concerns: - "Wrong compactPad parameters" — already fixed in 26e12ff7 (the param map now correctly says `keepRevisions`, not `authorId`). - "Unbounded revision deletions" / "No session eviction" / changeset base-length / padCreate hook — these all targeted the earlier on-Pad implementation that was refactored away. The current code wraps `Cleanup.deleteAllRevisions` / `deleteRevisions`, which already handle concurrency, locking, and hook semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5e8704f8d8
|
feat(gdpr): pad deletion controls (PR1 of #6701) (#7546)
* docs: PR1 GDPR deletion-controls design spec First of five GDPR PRs tracked in #6701. PR1 covers deletion controls: one-time deletion token, allowPadDeletionByAllUsers flag, authorisation matrix for handlePadDelete and the REST deletePad endpoint, a single token-display modal for browser pad creators, and test coverage. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: PR1 GDPR deletion-controls implementation plan 13 TDD-structured tasks covering PadDeletionManager unit tests, socket + REST three-way auth, clientVars wiring, one-time token modal, delete-with-token UI, Playwright coverage, and PR handoff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gdpr): scaffolding for pad deletion tokens PadDeletionManager stores a sha256-hashed per-pad deletion token and verifies it with timing-safe comparison. createPad / createGroupPad return the plaintext token once on first creation, and Pad.remove() cleans it up. Gated behind the new allowPadDeletionByAllUsers flag which defaults to false to preserve existing behaviour. Part of #6701 (GDPR PR1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix+test(gdpr): lazy DB access in PadDeletionManager + unit tests Capturing DB.db at module-load time was null until DB.init() ran, which broke importing the module outside a live server (including from the test runner). Switch to DB.db.* at call time and add unit tests exercising create/verify/remove plus timing-safe comparison. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gdpr): three-way auth for socket PAD_DELETE Creator cookie → valid deletion token → allowPadDeletionByAllUsers flag. Anyone else still gets the existing refusal shout. * feat(gdpr): optional deletionToken on programmatic deletePad * feat(gdpr): advertise optional deletionToken on REST deletePad * test(gdpr): cover deletePad authorisation matrix via REST * feat(gdpr): surface padDeletionToken in clientVars for creators only Revision-0 author on their first CLIENT_READY visit receives the plaintext token; all subsequent CLIENT_READYs receive null because createDeletionTokenIfAbsent is idempotent. Readonly sessions and any other user never see the token. * i18n(gdpr): strings for deletion-token modal and delete-with-token flow * feat(gdpr): token modal + delete-with-token disclosure markup * feat(gdpr): show deletion token once, allow delete via recovery token * style(gdpr): modal + delete-with-token layout * test(gdpr): Playwright coverage for deletion-token modal + delete-with-token * fix(test): auto-dismiss deletion-token modal in goToNewPad helper The token modal introduced in PR1 blocks clicks for every Playwright test that creates a new pad via the shared helper. Add a one-line dismissal so unrelated tests keep passing, and have the deletion-token spec navigate inline via newPadKeepingModal() when it needs the modal open to capture the token. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): dismiss deletion-token modal without focus transfer Clicking the ack button transferred focus out of the pad iframe, which made subsequent keyboard-driven tests (Tab / Enter) silently miss the editor. Swap the click for a page.evaluate() that hides the modal and nulls clientVars.padDeletionToken directly, leaving focus where it was. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(gdpr): PadDeletionManager race + document createPad/deletePad Qodo review: - createDeletionTokenIfAbsent() was a non-atomic read-then-write. Two concurrent callers for the same pad could both return different plaintext tokens while only the later hash was stored, leaving the first caller with an unusable recovery token. Serialise per-pad via a Promise chain and add a regression test that fires 8 concurrent calls and asserts exactly one plaintext is emitted and validates. - doc/api/http_api.md now documents createPad returning deletionToken and deletePad accepting the optional deletionToken parameter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(gdpr): always render delete-with-token in settings popup The rebase onto develop placed the delete-pad-with-token details inside the pad-settings-section conditional, which is only rendered when enablePadWideSettings is true AND the section is toggled visible. Second-device recovery (typing the captured token on a fresh browser) must work without pad-wide settings enabled, so move the details out to sit alongside the existing pad_deletion_token.spec.ts expectations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(gdpr): require valid token when supplied, gate on auth, harden a11y/i18n - PadMessageHandler: a supplied deletion token must validate; do not fall back to the creator-cookie path when the token is wrong (was deleting the pad anyway when the creator pasted a wrong token into the field). - Skip token issuance + UI when requireAuthentication is on (creator identity is stable, recovery token is redundant noise). - Server emits messageKey instead of hardcoded English; both shout handlers (inline alert and global gritter) localize via html10n. - Suppress the global "Admin message" gritter for pad.deletionToken.* shouts to avoid the "Admin message: undefined" duplicate. - Token-modal a11y: role=dialog, aria-modal, aria-labelledby/describedby, visually-hidden label on the token input, aria-live on Copy, focus to the token input on open and restore on dismiss. - Style the "Delete Pad with Token" disclosure to match the Delete pad button; align the Copy/value row; pad the disclosure label. Tests: Playwright now covers the creator-with-wrong-token path, asserts no "Admin message" / "undefined" gritter on denial; backend API test covers requireAuthentication suppressing the token. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6195289198
|
feat(gdpr): IP/privacy audit (PR2 of #6701) (#7547)
* docs: PR2 GDPR IP/privacy audit design spec Second of five GDPR PRs (#6701). Audit identifies four log-sites that leak IPs despite disableIPlogging=true, proposes a tri-state ipLogging setting with a back-compat shim, and specifies a doc/privacy.md that documents Etherpad's actual IP handling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: PR2 GDPR IP/privacy audit implementation plan 7 TDD-structured tasks: anonymizeIp helper + unit tests, tri-state ipLogging setting with disableIPlogging deprecation shim, wiring through 5 leaking log sites, clientVars.clientIp removal, access-log integration test, doc/privacy.md, and PR handoff. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(gdpr): anonymizeIp helper with v4/v6/v4-mapped truncation * feat(gdpr): tri-state ipLogging setting + disableIPlogging shim * fix(gdpr): route every IP log site through anonymizeIp Closes four leaks where disableIPlogging was silently ignored (rate-limit warn, both auth-log calls in webaccess, import/export rate-limit warn) and normalises the four that did honour the flag onto the new ipLogging tri-state via the shared helper. * chore(gdpr): drop dead clientVars.clientIp placeholder Server side: remove the literal '127.0.0.1' assignments from both clientVars and collab_client_vars. Type side: drop clientIp from ClientVarPayload and ServerVar. pad.getClientIp now returns the same '127.0.0.1' literal as a plugin-compat shim (pad_utils.uniqueId still uses it as a prefix). * test(gdpr): ipLogging modes + disableIPlogging shim * docs(gdpr): operator-facing privacy and IP handling statement * fix(gdpr): validate ipLogging at load + regression test for log sites Qodo review: - settings.ipLogging is loaded as a trusted union but nothing enforced the shape. An unknown value (e.g. a typo or null) silently fell through to anonymizeIp's "truncated" branch and emitted partially redacted IPs. Fall back to "anonymous" with a WARN at load time. - New regression test scans the four known log-sites for raw req.ip / socket.request.ip / request.ip inside logger calls that don't wrap through anonymizeIp / logIp, so a future edit that re-introduces a raw IP fails CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e39dbde887
|
feat(updater): tier 1 — notify admin and pad users of available updates (#7601)
* docs(updater): add four-tier auto-update design spec
Four-tier opt-in self-update subsystem (off / notify / manual / auto / autonomous).
GitHub Releases as source of truth; install-method auto-detection with admin
override; in-process execution with supervisor restart; 60s drain + announce;
auto-rollback on health-check failure with crash-loop guard. Pad-side severe/
vulnerable badge that does not leak the running version. Top-level adminEmail
with escalating cadence (weekly while vulnerable, monthly while severe).
Refs: docs/superpowers/specs/2026-04-25-auto-update-design.md
* docs(updater): add PR 1 (Tier 1 notify) implementation plan
Bite-sized TDD task breakdown for shipping Tier 1 notify only:
- VersionChecker, InstallMethodDetector, UpdatePolicy, Notifier, state modules
- /admin/update/status (admin-auth) and /api/version-status (public, no version leak)
- Admin UI banner + read-only update page + nav link
- Pad-side severe/vulnerable footer badge
- Settings: updates.* block + top-level adminEmail
- Tests: vitest unit + mocha integration + Playwright admin/pad
- CHANGELOG + doc/admin/updates.md
PRs 2-4 (manual/auto/autonomous) get their own plans after PR 1 lands.
* feat(updater): add shared types for auto-update subsystem
* feat(updater): clarify OutdatedLevel and EMPTY_STATE doc, drop path header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): add semver helpers and vulnerable-below parser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): tighten semver regex to reject four-part versions
* feat(updater): add state persistence with schema validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): reject null email and array latest in state validation
typeof null === 'object' meant {email:null} passed the old isValid check,
which would crash downstream Notifier code reading email.severeAt. Likewise,
an array would pass the typeof latest === 'object' branch. Introduce
isPlainObject helper (null-safe, Array.isArray guard) and use it for both
fields. Adds two regression tests covering the exact broken inputs.
* feat(updater): add install-method detector with override
* feat(updater): add policy evaluator
* feat(updater): add GitHub Releases checker with ETag support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): validate release fields and preserve ETag on prerelease
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): add email cadence decider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): tagChanged email fires regardless of cadence; drop unused field
* feat(settings): add updates.* and adminEmail settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): wire boot hook and periodic checker
Register expressCreateServer/shutdown hooks in ep.json and implement
the boot-wiring module that detects install method, starts the polling
interval and runs the notifier dedupe pass each tick.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): add /admin/update/status and /api/version-status endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* i18n(updater): add english strings for update banner, page, and pad badge
* feat(updater): add pad footer badge for severe/vulnerable status
* feat(admin-ui): add update banner, page, and nav link
Add UpdateStatusPayload to the zustand store, a persistent UpdateBanner
rendered in the App layout, a /update page showing version details and
changelog, and a Bell nav link — all wired to the /admin/update/status
endpoint added in Task 10.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(updater): add Playwright specs for admin banner/page and pad badge
* docs(updater): document tier 1 settings, badge, email cadence
* refactor(updater): dedupe helpers, fix misleading log, add banner styling
- Export stateFilePath from index.ts and import it in updateStatus.ts (removes local duplicate)
- Import getEpVersion from Settings.ts in both index.ts and updateStatus.ts (removes two local definitions)
- Fix misleading 'backing off' log message — no backoff is implemented, just retries at next interval
- Remove EMPTY_STATE_FOR_TESTS re-export from state.ts; state.test.ts now imports EMPTY_STATE directly from types.ts
- Add .update-banner and .update-page CSS rules to admin/src/index.css
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): address review feedback — async wrap, tier=off skip, poll race, opt-in admin gate
- Wrap /api/version-status and /admin/update/status with a small async helper
so a rejected promise becomes next(err) instead of an unhandled rejection.
- Short-circuit route registration when updates.tier === 'off' so the heavier
opt-out also removes the HTTP surface (matches pre-PR behavior for that case).
- Add an in-flight guard around performCheck() so overlapping interval ticks
can't race on update-state.json writes or duplicate email decisions; track
the initial setTimeout handle and clear it in shutdown().
- Add updates.requireAdminForStatus (default false) so admins can lock
/admin/update/status to authenticated admin sessions without disabling the
updater. Default false preserves current behavior (the running version is
already exposed publicly via /health). Backend specs cover unauth → 401,
non-admin → 403, admin → 200.
- Bump admin troubleshooting menu count test 5 → 6 to account for the new
Update nav link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(updater): address Qodo round-2 review feedback
Round 2 of Qodo review on #7601. Addressing the action-required items:
#1 Badge bypassed pad baseURL — derive basePath the same way
padBootstrap.js does (`new URL('..', window.location.href).pathname`)
and prefix the fetch with it. Subpath deployments now reach
/<prefix>/api/version-status instead of 404ing.
#2 Updater poller could get stuck — `getCurrentState()` is now inside
the try/finally so a one-time loadState() rejection can't leave
`checkInFlight=true` and permanently silence polling.
#3 Updates off hung admin page — UpdatePage now self-fetches and
renders explicit `disabled` (404), `unauthorized` (401/403), and
`error` states instead of staying on "Loading...". Banner-driven
prefetch is still honoured if it landed first.
#11 NaN polling interval — coerce `checkIntervalHours` to a number,
clamp to [1h, 168h], log a warning and fall back to 6h on
non-finite input. Math.max(1, NaN) === NaN previously meant a
malformed settings.json could turn the poller into a tight loop.
#13 State validation accepted broken subfields — `isValid()` now
inspects `latest.{version,tag,body,publishedAt,htmlUrl,prerelease}`,
`vulnerableBelow[].{announcedBy,threshold}`, and
`email.{severeAt,vulnerableAt,vulnerableNewReleaseTag}`. A
hand-edited file with a number where a string is expected is now
treated as corrupt and reset to EMPTY_STATE rather than crashing
later in semver parsing or email rendering.
#14 Badge cache stampede — wrap `computeOutdated()` in a single-flight
promise so concurrent requests at cache expiry await one shared
computation instead of fanning out into N redundant disk reads.
Plus six new state.test.ts cases covering each new validation guard.
Pushing back on the remaining items:
#4 `updates.tier` defaults to `notify` — intentional. The whole point
of tier 1 is to surface the "you are behind" signal to admins by
default. Opt-in defeats the purpose; the existing failure mode
(admin never hears about a security-relevant release) is exactly
what this PR is fixing.
#5/#8 Admin status endpoint admin-auth — `currentVersion` is already
public via `/health`, so wrapping the route in admin-auth doesn't
reduce the disclosure surface meaningfully. Operators who want it
gated set `updates.requireAdminForStatus=true` (already wired and
covered by the comment on the route handler).
#10 Plain `https://` URLs in planning doc — planning markdown is
viewed in editors and on GitHub where protocol-relative URLs would
either render literally or break entirely. Keeping `https://`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||
|
|
85f9a5f2f5
|
feat: Open Graph & Twitter Card metadata for pad/timeslider/home (closes #7599) (#7635)
* docs(spec): Open Graph metadata for pad pages (issue #7599) Spec for adding og:* and twitter:card meta tags to /p/:pad, the timeslider, and the homepage so shared links unfurl with a useful preview in chat apps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(spec): expand OG spec — i18n (locale map + og:locale) and a11y (image:alt) Address review feedback: socialDescription accepts a per-language map, og:locale is emitted from the negotiated render language, and image:alt attributes are emitted for screen readers in chat clients. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: emit Open Graph & Twitter Card metadata for pad/timeslider/home Closes #7599. Pad URLs shared in chat apps (WhatsApp, Signal, Slack, etc.) previously unfurled with no preview because the rendered HTML carried no OG or Twitter Card metadata. This change emits og:title, og:description, og:image, og:url, og:site_name, og:type, og:locale, og:image:alt and the equivalent twitter:* tags on the pad page, the timeslider, and the homepage. A new settings.json key `socialDescription` controls the description. It accepts either a plain string applied to every locale or a per-language map keyed by BCP-47 tag with an optional `default` fallback. og:locale is emitted from the language already negotiated via req.acceptsLanguages and og:image:alt provides screen-reader text for chat-client previews. Pad names from the URL are HTML-escaped before being interpolated into og:title to prevent reflected XSS via crafted pad IDs. Tests: src/tests/backend/specs/socialMeta.ts covers the default, per-locale override, locale fallback, URL decoding, XSS escape, and the timeslider/homepage variants. Semver: minor (new setting; templates emit additional tags but no existing behavior changes). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): use valid pad-name char in URL-decode test Spaces aren't allowed in pad names — Etherpad redirected /p/Has%20Space* to a sanitized name (302), so the og:title assertion failed. Use %2D ("-") instead, which is a valid pad-name character and still exercises the URL-decode path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(socialMeta): don't double-decode pad name from req.params.pad Express has already URL-decoded :pad route params before they reach the handler. Calling decodeURIComponent on the result throws URIError for pad names containing a literal "%" — e.g. the URL /p/100%25 yields req.params.pad === "100%", and decodeURIComponent("100%") throws. This would have prevented the page from rendering for some valid pad IDs. Drop the redundant decode and add a regression test for the "%" case. Reported by Qodo on PR #7635. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(socialMeta): source description from i18n catalog, drop settings key Per review: the OG description is a translatable string and belongs in Etherpad's locale files alongside the rest of the UI strings, not in settings.json. Operators who want to override it per-language continue to use the standard customLocaleStrings mechanism — no new config surface. Changes: - Add "pad.social.description" to src/locales/en.json (default English). - Export i18n.locales so server-side renderers can look up translations. - socialMeta.renderSocialMeta now takes a `locales` map and resolves renderLang → primary subtag → en, instead of taking a per-locale map from settings. - Remove `socialDescription` from Settings.ts, settings.json.template, settings.json.docker (the key never shipped). - Update tests and spec doc to reflect i18n-sourced description. Reported by Qodo on PR #7635 (also confirmed feature is fine to land default-on; no flag needed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(socialMeta): add unit tests for pure helpers 21 cases exercising buildSocialMetaHtml and renderSocialMeta directly, without HTTP/DB. Covers tag enumeration, HTML escaping, og:locale region formatting, title composition (pad/timeslider/home), description i18n resolution (exact/primary/en fallback, missing catalog), image URL (default favicon vs absolute settings.favicon vs alt text), canonical URL building with query-string stripping, the literal "%" no-throw regression, and attribute-breakout escape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(socialMeta): defend og:url/og:image against host-header poisoning Previously og:url and og:image were built from req.protocol + req.get('host'), both of which can be client-controlled (Host header directly, or X-Forwarded-* under trust proxy). A crafted Host could make the server emit OG tags pointing at an attacker's origin — harmful if any cache fronts the response or if a vulnerable proxy forwards the headers unsanitized. Two-layer defense: 1. New optional setting `publicURL` lets operators pin the canonical origin used for shared link previews ("https://pad.example"). When set, og:url and og:image use it unconditionally. Sanitized at use time: must be http(s)://host[:port] with no path, no userinfo, no trailing slash; malformed values fall back to the request. 2. When `publicURL` is unset, the request-derived fallback now strictly validates the Host header against /^[a-z0-9]([a-z0-9.-]{0,253}[a-z0-9])?(:\d{1,5})?$/i and caps the scheme to "http"/"https". A crafted Host (CRLF injection, userinfo, "<script>") is replaced with "localhost" instead of being echoed into og:url. Reported by Qodo on PR #7635. Tests: 5 new unit cases covering publicURL preference, trailing-slash strip, malformed-publicURL fallback, Host validation, scheme cap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(socialMeta): tighten types, drop `any` - `req: any` -> express `Request` (covers acceptsLanguages/protocol/get/originalUrl). - `settings: any` -> local `SocialMetaSettings` interface narrowed to the three fields we actually read (title/favicon/publicURL); avoids coupling to the full Settings module surface. - `availableLangs: {[k: string]: any}` -> `{[lang: string]: unknown}`; only keys are read, so values stay deliberately unconstrained. No runtime change. All 26 socialMeta unit tests still pass. Per Sam's review on #7635. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
63cae17720
|
feat(pad): add theme-color meta to match toolbar on mobile (#7606) (#7636)
* feat(pad): add <meta name="theme-color"> matching toolbar (#7606) Mobile browsers paint the address-bar / status-bar area above the viewport. Without theme-color this is a system color that does not match the Etherpad toolbar, leaving a visible gap above the pad. Render <meta name="theme-color"> server-side so the bar matches the configured toolbar on first paint. Light + dark variants are emitted with prefers-color-scheme media queries when dark mode is enabled. Colors are derived from settings.skinVariants via a new SkinColors helper (mirrors --bg-color in the colibris pad-variants.css). Closes #7606 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(timeslider): emit single theme-color matching configured toolbar Qodo flagged a mismatch: timeslider does not switch skin variants on prefers-color-scheme, so emitting a dark theme-color via media query would leave dark-mode devices with a dark address bar over a light toolbar. Drop the media-query metas on timeslider and emit one unconditional theme-color resolved from settings.skinVariants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pad): emit unconditional theme-color so dark-OS users still match Qodo flagged that gating the light theme-color on prefers-color-scheme: light leaves no applicable meta on dark-OS devices when enableDarkMode is false — the address bar then uses a system color while the toolbar stays light. Drop the light media query so the light theme-color is the baseline, and let the prefers-color-scheme: dark meta override it when dark mode is enabled. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(theme-color): align dark meta with client-side super-dark override Two related Qodo findings on the SkinColors helper: - The pad client's dark-mode auto-switch (pad.ts L650) forces super-dark-toolbar regardless of the configured skinVariants, so the prefers-color-scheme: dark meta must always be #485365 — not whichever dark variant the operator configured. - When skinVariants only carries a dark token (e.g. dark-toolbar), the previous helper left the baseline meta at #ffffff, so light-OS users would see white above a dark toolbar. Replace toolbarThemeColors() with configuredToolbarColor() (used as the unconditional baseline) and a fixed DARK_MODE_TOOLBAR_COLOR constant (used in the prefers-color-scheme: dark meta). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(theme-color): server-side only, drop fragile dark media query Address remaining Qodo findings on the theme-color rollout: - (#1) Skip emitting the meta entirely when settings.skinName is not colibris — the helper only knows colibris's --bg-color values, so on no-skin or third-party skins the previous code would emit a white meta over a non-white toolbar. - (#4) Drop the prefers-color-scheme: dark variant. The pad's client-side dark mode is also gated on a localStorage white-mode override that no media query can express, so the dark meta could paint a dark address bar over a still-light toolbar. The single baseline meta always matches what the user sees on first paint. - (#8) Remove the redundant module.exports assignment; rely on the ES named export only (tsx handles the require() interop). - (#9) Iterate the toolbar variants in CSS source order and let the last match win, matching the cascade in pad-variants.css when multiple *-toolbar tokens are present. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
884ac93b4e
|
feat(editor): add IDE-style line ops (duplicate / delete) (#7564)
* feat(editor): add IDE-style line ops (duplicate / delete) Addresses #6433 — the issue asked for VS-Code-style multi-line editing for collaborative markdown editing. Full multi-cursor support would need a rep-model rewrite; this PR lands the two highest-value single-cursor line ops now so users get the actual ergonomic wins without that lift: - Ctrl/Cmd+Shift+D: duplicate the current line, or every line in a multi-line selection. Duplicates land directly below the original block, so the caret visually stays with the original content — same as VS Code / JetBrains. - Ctrl/Cmd+Shift+K: delete the current line (or every line in a multi-line selection), collapsing the range including its trailing newline. Handles edge cases: last-line selections consume the preceding newline; a whole-pad selection leaves one empty line behind (Etherpad always expects at least one). Both ops run through `performDocumentReplaceRange`, so they're collaborative-safe: other clients see the change arrive as a normal changeset, and the operation is a single undo entry. Wire-up: - `src/node/utils/Settings.ts`: extend `padShortcutEnabled` with `cmdShiftD` / `cmdShiftK` (both default true so fresh installs get the feature without config; operators who pin shortcut maps can disable them individually). - `src/static/js/ace2_inner.ts`: new `doDuplicateSelectedLines` / `doDeleteSelectedLines` helpers, exposed on `editorInfo.ace_*` so plugins and tests can invoke them programmatically, and keyboard handlers for Ctrl/Cmd+Shift+D and Ctrl/Cmd+Shift+K. Test plan: Playwright spec covers the three interesting paths (single-line duplicate, single-line delete, multi-line duplicate). Closes #6433 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(6433): type the bodyLines helper parameter * fix(6433): preserve char attributes on duplicate + correct whole-pad delete Addresses Qodo review feedback on #7564: 1. `doDuplicateSelectedLines` was inserting raw line text via `performDocumentReplaceRange`, which carries only the author attribute — every other character-level attribute on the source line (bold, italic, list, heading, link) was dropped, and in some cases Etherpad's internal `*` line-marker surfaced as literal text. Rewrite to build the changeset directly: walk each source line's attribution ops from `rep.alines[i]`, split the line text at op boundaries, and call `builder.insert(segment, op.attribs)` once per op. Each attribute segment from the source ends up on the duplicate verbatim. Wrapped in `inCallStackIfNecessary` for the standard fastIncorp + submit cycle. 2. `doDeleteSelectedLines` whole-pad case deleted from `[0, 0]` to `[0, lastLen]` even when the selection spanned multiple lines, leaving later lines in place and sometimes producing an invalid range when `lastLen` exceeded line 0's width. Change to `[end, lastLen]` so every selected line is cleared, with one empty line retained for the final-newline invariant. 3. Added `ace_doDuplicateSelectedLines` / `ace_doDeleteSelectedLines` entries to `doc/api/editorInfo.md` so plugin authors can discover the new surface. 4. New Playwright spec asserting `<b>` tags survive duplication. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * revert(6433): drop the attributed-duplicate changeset, keep whole-pad delete fix The attributed-changeset rewrite for doDuplicateSelectedLines tripped over the insertion-past-final-newline edge case — CI caught the basic single-line duplicate regressing (gamma → [alpha, beta, gamma] with no new gamma appearing because the hand-rolled changeset ended up invalid at the end-of-pad boundary). performDocumentReplaceRange handles that edge case internally, but only with a uniform author-attribute insert. Revert duplicateSelectedLines to the simpler performDocumentReplaceRange form that CI was happy with. Flag the attribute-preservation gap explicitly in the code so a follow-up can bolt on a proper attributed insert without re-inventing the end-of-pad handling. Whole-pad delete fix and editorInfo.md docs stay. Attribute-preservation test in line_ops.spec.ts is removed along with the broken code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0b40bfc784
|
feat(packaging): add Debian (.deb) build via nfpm with systemd unit (v2) (#7583)
* feat(packaging): add Debian (.deb) build via nfpm with systemd unit First-class Debian packaging for Etherpad, producing etherpad_<version>_<arch>.deb artefacts for amd64 and arm64 from a single nfpm manifest. Installing the package gives users: - /opt/etherpad with a prebuilt, self-contained node_modules/ — no pnpm required at runtime, just `nodejs (>= 20)`. - etherpad system user/group, created via `adduser` in preinst. - /etc/etherpad/settings.json seeded from the template on first install, preserved across upgrades, removed on `purge`. Seed rewrites dbType from the template's dev-only `dirty` default to `sqlite`, pointed at /var/lib/etherpad/etherpad.db so fresh installs get an ACID-safe DB without manual config. sqlite is shipped by ueberdb2 (rusty-store-kv), so no additional apt deps are needed. - /var/lib/etherpad owned by etherpad:etherpad, writable under the hardened unit's ProtectSystem=strict. - /lib/systemd/system/etherpad.service — hardened unit (NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp, RestrictAddressFamilies) with Restart=on-failure. - /usr/bin/etherpad CLI wrapper running `node --import tsx/esm`. CI (.github/workflows/deb-package.yml) triggers on v* tags, builds both arches via native runners (ubuntu-latest + ubuntu-24.04-arm), smoke-tests the amd64 package end-to-end (install → verify sqlite default → systemctl start → curl /health → purge → confirm user removed), and attaches the artefacts to the GitHub Release. Re-introduces the work from #7559 (reverted in #7582) with two corrections: 1. Package name and all installed paths use `etherpad`, not `etherpad-lite` — matches the repo rename. Kept replaces/conflicts on `etherpad-lite` so any dev builds of the reverted PR upgrade cleanly. 2. Default dbType is `sqlite`, not `dirty`. The template's own comment says dirty is for testing only; shipping it by default to everyone who runs `apt install etherpad` is the wrong tradeoff for a production package. Publishing to an APT repo (Cloudsmith, Launchpad PPA, self-hosted reprepro) is intentionally out of scope — needs a governance decision on who holds the signing key. Recipes are documented in packaging/README.md. Refs #7529, #7559, #7582 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(packaging): address PR review — startup crashes, supply chain, Node LTS Addresses Qodo and SamTV12345 review feedback on #7583: - postinstall: symlink /opt/etherpad/var → /var/lib/etherpad/var so ProtectSystem=strict doesn't block runtime writes (var/js, installed_plugins.json, etc.). Existing ReadWritePaths covers it. - postinstall: seed installed_plugins.json with ep_etherpad-lite so checkForMigration() does not spawn `pnpm ls` on first boot — pnpm is not a runtime dep, and the bundled node_modules already contains every shipped plugin. Prevents network plugin installs at first run. - postremove: clean up the new var symlink on remove. - workflow: verify nfpm .deb sha256 against upstream checksums.txt before sudo dpkg -i (defense in depth). - workflow: bump Node 22 → 24 (current LTS, per SamTV12345). The deb Depends stays at nodejs (>= 20) to match Etherpad's engines.node. - workflow: smoke-test now asserts the var symlink and seeded installed_plugins.json exist post-install. - workflow: publish stable etherpad-latest_{amd64,arm64}.deb aliases alongside the versioned files in the GitHub Release. - README: bump Node guidance to 24, document /releases/latest URL, link to engines.node floor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(packaging): tsx CJS hook, plugin paths writable, glob tag triggers Addresses second-round Qodo review on #7583: - bin/etherpad: switch from `--import tsx/.../esm` to `--require tsx/cjs`. server.ts uses `exports.start = ...` which throws under the ESM loader; the prod script in src/package.json uses tsx/cjs for the same reason. - postinstall: symlink /opt/etherpad/src/plugin_packages → /var/lib/etherpad/plugin_packages and chgrp /opt/etherpad/src/node_modules to etherpad with mode 2775. Otherwise admin-UI plugin install EACCESes — those are the dirs LinkInstaller writes to. - systemd unit: add /opt/etherpad/src/node_modules to ReadWritePaths so symlink creation by the etherpad user is allowed under ProtectSystem=strict. plugin_packages is already covered via the symlink into /var/lib/etherpad. - postremove: clean up the new plugin_packages symlink on remove. - workflow: tag filters were `v[0-9]+.[0-9]+.[0-9]+`, but Actions tag filters are globs, not regex. `[0-9]+` matches one character, so multi-digit tags like v2.10.0 would never trigger. Switch to `v*.*.*` / `v*.*.*-*`, matching handleRelease.yml. - workflow smoke test now asserts plugin_packages symlink target, ownership of plugin_packages and node_modules. - test-local.sh: new script that builds the .deb and runs the same smoke test in a throwaway systemd-enabled Docker container, so failures are caught before pushing. - README: document test-local.sh. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(packaging): test-local.sh — fix cgroups v2, add --no-systemd mode - systemd-in-docker on cgroups v2 needs --cgroupns=host and a writable /sys/fs/cgroup mount; the previous :ro version booted to nothing. - New --no-systemd mode: drops the systemd container in favour of plain ubuntu:24.04 + manual launch under the etherpad user. Validates the postinstall, wrapper, plugin paths, and /health without depending on the host's systemd-in-docker setup. Use it when --privileged systemd containers don't boot on your kernel/docker combo. - On systemd container exit the script now dumps the last 50 log lines and points at --no-systemd as the fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(packaging): test-local.sh — reuse cached image in --no-systemd If ubuntu:24.04 isn't on disk and the registry is unreachable, fall back to whichever ubuntu/debian image is already cached (e.g. the jrei/systemd-ubuntu image we pulled for the systemd path). Avoids a registry round-trip on flaky networks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: handle spawn errors in run_cmd; deb-package install order + offline-safe test src/node/utils/run_cmd.ts: Without `proc.on('error', ...)` a spawn failure (e.g. ENOENT for a missing binary) is emitted as an unlistened 'error' event, which Node treats as an uncaught exception that bypasses the awaiting try/catch and kills the process. The .deb hits this on first boot because plugins.ts spawns `pnpm --version` for a startup log line and pnpm isn't a runtime dep — Etherpad logs "Starting" then immediately stops. Reject the promise on 'error' so the existing try/catch in the caller actually catches it. packaging/scripts/postinstall.sh: chown /var/lib/etherpad/plugin_packages AFTER `cp -a` from the staged tree — `cp -a` preserves source (root) ownership and was re-rooting the directory we'd just chowned to etherpad. Same ordering the var symlink block already used. packaging/test-local.sh: Run `CI=1 pnpm install --frozen-lockfile` before staging so the package is built from a fresh, lockfile-consistent tree (matches CI). Fixes spurious "Cannot find module 'X'" failures from stale local symlinks pointing at out-of-date pnpm store paths. End-to-end test now passes: postinstall asserts pass, /health returns 200, dpkg --purge cleans up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: gitignore packaging build artefacts; drop accidental commit Drop packaging/etc/settings.json.dist that snuck into the previous commit (generated at build time by test-local.sh / CI from settings.json.template). Add /staging/, /dist/, /packaging/etc/ to .gitignore so they don't recur. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugins): downgrade missing-pnpm log from ERROR to debug The startup IIFE that logs the pnpm version is informational only. pnpm is a dev-only dependency: admin-UI plugin install goes through live-plugin-manager directly, and plugin migration is short-circuited when var/installed_plugins.json is present (e.g. on packaged installs). A missing pnpm on PATH is therefore expected on hardened deployments and shouldn't surface as a red ERROR in journalctl. Detect ENOENT specifically and log at debug; treat other errors (permission denied, etc.) as warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(packaging): smoke deb on PRs + backend test for run_cmd spawn errors CI gap: deb-package.yml only fired on v* tag pushes, so a PR that broke the .deb wasn't caught until release time. Wire it to PRs and develop pushes via a paths filter covering packaging files and the runtime files Etherpad needs at first boot. The release job already gates on `if: startsWith(github.ref, 'refs/tags/v')` so PR runs won't try to publish. Test gap: the run_cmd.ts spawn-error fix (commit |
||
|
|
d619f03214
|
fix(settings): derive randomVersionString from release identity (#7563)
* fix(settings): derive randomVersionString from release identity Fixes #7213. Etherpad appends a `?v=<token>` cache-buster to static assets and embeds the same token as `clientVars.randomVersionString` in the padbootstrap JS bundle produced by specialpages.ts. Because esbuild's content-hash feeds back into the generated bundle filename (`padbootstrap-<hash>.min.js`), the token's value determines the file that clients are told to load. Historically the token was `randomString(4)`, regenerated on every boot. In a horizontally-scaled deployment (ingress → etherpad service → multiple pods) that meant every pod produced a different filename for the same built artifact. A client that loaded the HTML from pod A would request `padbootstrap-ABCD.min.js` from pod B and hit a 404 when the upstream balancer placed the follow-up request elsewhere. Derive the token deterministically so pods of the same build emit identical filenames, while still rotating on release so clients invalidate their cache correctly: ETHERPAD_VERSION_STRING env → verbatim (integrator override) else → sha256(version + "|" + gitVersion)[:8] Backwards-compatible: single-pod deployments see the same effective behavior (token rotates each release). Integrators who want to pin the token explicitly — e.g. tying it to their own deploy ID — can set `ETHERPAD_VERSION_STRING` in the environment. Test coverage added in src/tests/backend/specs/settings.ts: - Default shape is an 8-hex-char sha256 prefix. - ETHERPAD_VERSION_STRING override is respected verbatim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(7213): call reloadSettings() to exercise ETHERPAD_VERSION_STRING The token is assigned inside reloadSettings, not parseSettings, so a parseSettings-only call never sees the env var. Drive reloadSettings directly, restoring the file paths and the prior token afterwards so other tests see a clean module 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> |
||
|
|
83a42afbae
|
fix(export): /export/etherpad honors the :rev URL segment (#7566)
Fixes #5071. `/p/:pad/:rev/export/etherpad` has always ignored the rev parameter and returned the full pad history, unlike the txt/html export endpoints which use the same route but do respect rev. Users wanting to back up or inspect a snapshot of a pad at a specific rev got every later revision in the payload instead — both wasteful and a surprise when the downloaded .etherpad blob contained content that had supposedly been reverted. Change: - `exportEtherpad.getPadRaw(padId, readOnlyId, revNum?)` now takes an optional revNum. When supplied, it clamps to `min(revNum, pad.head)`, iterates only revs 0..effectiveHead, and ships a shallow-cloned pad object whose `head` and `atext` reflect the requested snapshot. The original live Pad is still passed to the `exportEtherpad` hook so plugin callbacks see the real document. - `ExportHandler` passes `req.params.rev` through on the `etherpad` type, matching the existing behavior of `txt` and `html`. - Chat history is intentionally left full (it is not rev-anchored). Adds three backend regression tests under `ExportEtherpad.ts`: - default (no revNum) still exports the full history - explicit revNum limits exported revs and rewrites the serialized head so re-import reconstructs the pad at that rev - revNum above head is treated as full history, preventing accidental truncation of short pads Out of scope: `getHTML(padID, rev)` on the API side is already honoring rev in current code (exportHtml.getPadHTML threads the parameter through), so the earlier report on that API call appears to be resolved. This PR does not touch it. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a1bb958b18
|
fix(pad): stop hardcoding lang='en', let the client auto-detect locale (#7586) (#7588)
`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
|
||
|
|
9e352ca4f3
|
fix(clientVars): stop mutating the shared plugin registry during sanitization (#7587)
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.
|
||
|
|
67e542d2b9
|
fix(editor): preserve U+00A0 non-breaking space (#3037) (#7585)
* 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> |
||
|
|
fe6a373bf8
|
feat: Remove paths from plugin packages (#7580) | ||
|
|
e8c9efb5c5
|
chore: Rename some occurences of etherpad-lite to etherpad (#7552)
* chore: Rename some occurences of etherpad-lite to etherpad * chore: Adjust etherpad git urls * chore: Rename more occurences from etherpad-lite to etherpad * chore: Adjust default text |
||
|
|
e0ccdb4d9f
|
Add creator-owned pad settings defaults (#7545)
* Add creator-owned pad settings defaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Refine pad settings layout Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix settings popup heading and width Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Explain enforced user settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Cover creator override flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Let creators bypass enforced settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address pad settings follow-ups Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> |
||
|
|
4137109efe
|
fix: allow undo of clear authorship colors without disconnect (#7430)
* 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> |
||
|
|
7ec581afca
|
feat!: replace Abiword with LibreOffice and add DOCX export (#7539)
* 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
|
||
|
|
66f49bb808
|
docs(openapi): document apikey auth in openapi.json (#7534)
* 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> |
||
|
|
31e0a61126
|
fix: capture head revision atomically with atext to prevent mismatched apply (#7480)
* 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> |
||
|
|
8c1b8b0902
|
fix: add setters to CJS compatibility layer in Settings (#7481)
The CJS compatibility block added in
|
||
|
|
29faec4a04
|
fix: increase max socket.io message size to 10MB for large pastes (#7474)
* 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> |
||
|
|
2814e5b913
|
fix: dev mode entrypoint paths respect x-proxy-path header (#7472)
* 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> |
||
|
|
270e3c6576
|
fix: very old .etherpad imports could break import due to lack of aut… (#7473)
* 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> |
||
|
|
6a3094b244
|
fix: sort language dropdown alphabetically by native name (#7477)
* 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> |
||
|
|
ac118cfde7
|
fix: preserve ordered list numbering across bullet interruptions in export (#7470)
* 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> |
||
|
|
605ad28068
|
fix: prevent race condition in session cleanup timeout (#7471)
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> |
||
|
|
da9f5ac4ee
|
fix: add periodic cleanup of expired/stale sessions from database (#7448)
* 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> |
||
|
|
af03259555
|
fix: POST API requests with JSON body no longer time out (#7455)
* fix: POST API requests with JSON body no longer time out When express.json() middleware parses the request body before the OpenAPI handler runs, formidable's IncomingForm hangs forever waiting for stream data that was already consumed. Now checks req.body first and only falls back to formidable for multipart/form-data requests. Also fixed case-insensitive method check (c.request.method may be uppercase depending on openapi-backend version). Fixes #7127 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: handle empty JSON body and missing method safely - Remove Object.keys().length > 0 check on req.body so empty JSON objects ({}) don't fall through to formidable (which would hang) - Guard c.request.method with fallback to empty string to prevent TypeError if method is undefined Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * security: prevent parameter pollution by excluding headers from field merge Previously Object.assign merged headers, params, query, and formData into a single fields object. This allowed POST body parameters to override security-sensitive headers like Authorization, or headers to pollute API parameter values. Now only merges params, query, and formData. The Authorization header is passed explicitly as a fallback for legacy API key authentication, but cannot be overridden by body/query parameters. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
474918a881
|
feat: make cookie names configurable with prefix setting (#7450)
* feat: make cookie names configurable with prefix setting Add cookie.prefix setting (default "ep_") that gets prepended to all cookie names set by Etherpad. This prevents conflicts with other applications on the same domain that use generic cookie names like "sessionID" or "token". Affected cookies: token, sessionID, language, prefs/prefsHttp, express_sid. The prefix is passed to the client via clientVars.cookiePrefix in the bootstrap templates so it's available before the handshake. Server-side cookie reads fall back to unprefixed names for backward compatibility during migration. Fixes #664 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: default cookie prefix to empty string for backward compatibility Changing the default to "ep_" would invalidate all existing sessions on upgrade since express-session only looks for the configured cookie name. Default to "" (no prefix) so upgrades are non-breaking — users opt-in to prefixed names by setting cookie.prefix in settings.json. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address Qodo review — cookie prefix migration and fallbacks - l10n.ts: Read prefixed language cookie with fallback to unprefixed - welcome.ts: Use cookiePrefix for token transfer reads - timeslider.ts: Use prefix for sessionID in socket messages - pad_cookie.ts: Fall back to unprefixed prefs cookie for migration - indexBootstrap.js: Pass cookiePrefix via clientVars to welcome page - specialpages.ts: Pass settings to indexBootstrap template Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: escape regex metacharacters in cookie prefix, document Vite hardcode - l10n.ts: Escape special regex characters in cookiePrefix before using it in RegExp constructor to prevent runtime errors - padViteBootstrap.js: Add comment noting the hardcoded prefix is dev-only and must match settings.json Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * security: validate cookie prefix to prevent header injection Reject cookie.prefix values containing characters outside [a-zA-Z0-9_-] to prevent HTTP header injection via crafted cookie names (e.g., \r\n sequences). Falls back to empty prefix with an error log. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
f0b84cc1d0
|
fix: list bugs — indent export, renumber performance, and batching (#7449)
* 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> |
||
|
|
66249b5d7e
|
fix: correct numConnectedUsers count for joining user (#7453)
numConnectedUsers in CLIENT_VARS was computed from roomSockets.length before the new socket joined the room, so the joining user always saw a count one less than the actual number. Added +1 to include the joining user in the count. Fixes #6145 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
4896b5286a
|
fix: add padId to padUpdate/padCreate hook context (#7452)
The pad object's toJSON() intentionally strips the id property (since it's part of the database key), which caused confusion when plugins serialized the hook context. Adding padId as a top-level property on the hook context makes it directly accessible without relying on the pad object's internal properties. Fixes #5814 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> |
||
|
|
f7e4100aba
|
fix: appendText API now attributes text to the specified author (#7446)
* 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> |
||
|
|
8b7155b612
|
fix: createDiffHTML API fails with "Not a changeset: undefined" (#7445)
* 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>
|
||
|
|
d5acbb31ae
|
fix: filter already-deleted sessions when deleting a group (#7435)
* 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> |
||
|
|
a42e072900
|
fix: wait for server confirmation before navigating after pad delete (#7432)
* fix: wait for server confirmation before navigating after pad delete
The delete pad handler navigated to '/' immediately after sending the
PAD_DELETE message. Firefox (and some mobile Chrome) would close the
WebSocket before the message reached the server, causing the delete to
silently fail.
Now the client waits for the server's {disconnect: 'deleted'} response
before navigating. Also awaits pad.remove() on the server side to
ensure the operation completes before the response is sent.
Fixes: https://github.com/ether/etherpad-lite/issues/7306
Fixes: https://github.com/ether/etherpad-lite/issues/7311
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* fix: handle non-creator delete and add timeout fallback
- Listen for 'shout' event to show error when non-creator tries to
delete (server sends shoutMessage instead of deleting)
- Add 5-second timeout fallback in case the server doesn't respond
(socket dropped, server crashed, etc.)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
09d782f695
|
Enforce 2-space indentation across codebase (#7426)
* 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> |
||
|
|
fd975323e0
|
Fix ESM/CJS interop for Settings module breaking plugin compatibility (#7421)
* Pin plugins to last-known-good versions in backend tests
Pin ep_font_size@0.4.65, ep_headings2@0.2.76, ep_markdown@10.0.1
to the versions that passed on March 31. The newer versions cause
a template crash: Cannot read properties of undefined (reading
'indexOf') at pad.html:67 in toolbar.menu().
This will help narrow down which plugin update is the culprit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Unpin ep_markdown, 1.0.8 is latest and code-identical to 10.0.1
Only ep_font_size@0.4.65 and ep_headings2@0.2.76 remain pinned to
narrow down which plugin update causes the toolbar template crash.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Use pnpm instead of gnpm for plugin install in backend tests
gnpm ignores version pins — it reports installing the pinned version
but the plugin loader picks up the latest from its store. Switching
to pnpm for the plugin install step so version pins actually work.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Use gnpm exec pnpm for plugin install to bypass gnpm caching
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Remove ep_hash_auth from backend test plugin list
ep_hash_auth blocks unauthenticated requests, causing 28 backend tests
to get 500 Internal Server Error when accessing pads. The tests don't
provide credentials, so any auth plugin will break them.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* Fix ESM/CJS interop for Settings module and harden toolbar
Plugins use require('ep_etherpad-lite/node/utils/Settings') (CJS) but
Settings.ts uses export default (ESM). With tsx, CJS require puts the
default export under .default, so settings.toolbar is undefined and
ep_font_size crashes with "Cannot read properties of undefined
(reading 'indexOf')" when rendering pad.html.
Two fixes:
- Settings.ts: add property getters on module.exports so CJS consumers
can access settings properties directly
- toolbar.ts: guard against undefined buttons array to prevent crashes
if Settings interop doesn't propagate through gnpm's plugin_packages
Tested locally: 735 passing, 0 failing with all plugins.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
||
|
|
8ab65fe590
|
build(deps-dev): bump the dev-dependencies group across 1 directory with 14 updates (#7272)
* build(deps-dev): bump the dev-dependencies group across 1 directory with 14 updates Bumps the dev-dependencies group with 14 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.10.1` | `25.0.3` | | [eslint](https://github.com/eslint/eslint) | `9.39.1` | `9.39.2` | | [sinon](https://github.com/sinonjs/sinon) | `21.0.0` | `21.0.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.0.15` | `4.0.16` | | [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.48.1` | `8.50.0` | | [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.48.1` | `8.50.0` | | [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `5.1.1` | `5.1.2` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.4.24` | `0.4.26` | | [i18next](https://github.com/i18next/i18next) | `25.7.1` | `25.7.3` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.556.0` | `0.562.0` | | [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.1` | `19.2.3` | | [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.1` | `19.2.3` | | [react-i18next](https://github.com/i18next/react-i18next) | `16.3.5` | `16.5.0` | | [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.10.1` | `7.11.0` | Updates `@types/node` from 24.10.1 to 25.0.3 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `eslint` from 9.39.1 to 9.39.2 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.1...v9.39.2) Updates `sinon` from 21.0.0 to 21.0.1 - [Release notes](https://github.com/sinonjs/sinon/releases) - [Changelog](https://github.com/sinonjs/sinon/blob/main/docs/changelog.md) - [Commits](https://github.com/sinonjs/sinon/compare/v21.0.0...v21.0.1) Updates `vitest` from 4.0.15 to 4.0.16 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.0.16/packages/vitest) Updates `@typescript-eslint/eslint-plugin` from 8.48.1 to 8.50.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.50.0/packages/eslint-plugin) Updates `@typescript-eslint/parser` from 8.48.1 to 8.50.0 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.50.0/packages/parser) Updates `@vitejs/plugin-react` from 5.1.1 to 5.1.2 - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.2/packages/plugin-react) Updates `eslint-plugin-react-refresh` from 0.4.24 to 0.4.26 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.4.24...v0.4.26) Updates `i18next` from 25.7.1 to 25.7.3 - [Release notes](https://github.com/i18next/i18next/releases) - [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/i18next/compare/v25.7.1...v25.7.3) Updates `lucide-react` from 0.556.0 to 0.562.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/0.562.0/packages/lucide-react) Updates `react` from 19.2.1 to 19.2.3 - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.3/packages/react) Updates `react-dom` from 19.2.1 to 19.2.3 - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.3/packages/react-dom) Updates `react-i18next` from 16.3.5 to 16.5.0 - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v16.3.5...v16.5.0) Updates `react-router-dom` from 7.10.1 to 7.11.0 - [Release notes](https://github.com/remix-run/react-router/releases) - [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md) - [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.11.0/packages/react-router-dom) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 25.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: dev-dependencies - dependency-name: eslint dependency-version: 9.39.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: sinon dependency-version: 21.0.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: vitest dependency-version: 4.0.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: "@typescript-eslint/eslint-plugin" dependency-version: 8.50.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: "@typescript-eslint/parser" dependency-version: 8.50.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: "@vitejs/plugin-react" dependency-version: 5.1.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: eslint-plugin-react-refresh dependency-version: 0.4.26 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: i18next dependency-version: 25.7.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: lucide-react dependency-version: 0.562.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: react dependency-version: 19.2.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: react-dom dependency-version: 19.2.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: react-i18next dependency-version: 16.5.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies - dependency-name: react-router-dom dependency-version: 7.11.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies ... Signed-off-by: dependabot[bot] <support@github.com> * chore: fixed error in typescript --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: SamTV12345 <40429738+samtv12345@users.noreply.github.com> |
||
|
|
41cb6803d2
|
7139 let user maintain a single session across multiple browsers (#7228)
* chore: started with implementation * chore: finished index page * chore: started with double sided modal * chore: continue * chore: completed implementation of transfer token * chore: fixed typescript checks |
||
|
|
21f0992a17
|
chore: added settings for darkmode and relaxed width and height of inputs (#7204)
* chore: added settings for darkmode and relaxed width and height of inputs * chore: add explanation for showRecentPads |