Commit graph

2043 commits

Author SHA1 Message Date
SamTV12345
bfdbd2bb91
chore: removed axios (#7685)
* chore: removed axios

* chore: pnpm
2026-05-06 22:06:04 +02:00
John McLear
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.
2026-05-06 20:08:36 +02:00
John McLear
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>
2026-05-03 12:30:49 +01:00
John McLear
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 906e145).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(privacy-banner): allow-list keys before sending to clientVars (Qodo)

storeSettings() merges nested objects with _.defaults() and preserves
unknown nested keys, and TypeScript's Pick<> doesn't strip at runtime.
The previous wire path forwarded settings.privacyBanner by reference
into both clientVars and getPublicSettings(), so any extra keys an
operator typed (or pasted) under privacyBanner — credentials, internal
notes, anything — would have shipped to every browser on every pad
load.

Adds getPublicPrivacyBanner() in Settings.ts that returns a literal
with only {enabled, title, body, learnMoreUrl, dismissal}, and uses it
from both leak sites (PadMessageHandler.ts clientVars and
getPublicSettings()). Single source of truth for the wire shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:59:38 +08:00
John McLear
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>
2026-05-03 05:56:56 +01:00
John McLear
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 ce0c5c283 was load-bearing.

Changes:

- colorutils.ts: per-skin SKIN_TEXT_COLORS table with darkRef/lightRef
  matching what the browser actually paints (colibris #485365,
  default #222). Re-introduces ensureReadableBackground, but skin-aware
  and symmetric — blends bg toward white or black depending on which
  text colour wins, so it works for both light and dark backgrounds.
- ace2_inner.ts: setAuthorStyle runs the bg through the clamp before
  picking text colour. Gated on padOptions.enforceReadableAuthorColors
  (default true).
- Settings.ts / settings.json.template / settings.json.docker /
  doc/docker.md: padOption + PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS
  env var.
- tests: failing-then-green coverage for the issue's exact case
  (#9AB3FA + colibris), the previously-impossible #ff0000, the
  no-mutation case, non-hex pass-through, and a sweep over primaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(7377): add e2e DOM-contrast spec + extra unit cases

The previous coverage was unit-only, which is what let the original wrong-
reference-colour bug ship — the algorithm tests were green but nothing
exercised what the browser actually paints. New coverage:

Playwright (src/tests/frontend-new/specs/wcag_author_color.spec.ts):
- Sets the user's colour to the issue's exact #9AB3FA, types text, reads
  the rendered author span's computed bg + colour from the inner frame,
  and asserts the WCAG ratio between the two is >= 4.5. Repeated for
  #ff0000 (the other historically-failing case).
- Asserts #ffeedd (already AA-friendly) is rendered unchanged — guards
  against the clamp mutating colours that don't need it.

Backend additions (src/tests/backend/specs/colorutils.ts):
- Symmetric-clamp test: dark mid-saturation bg where light text wins, the
  clamp must darken (not lighten). Direction check via relativeLuminance.
- minContrast parameter: AAA (7.0) must produce more clamping than AA.
- Output shape: result must be a parseable hex string (round-trip safe).
- Short-hex (#abc) input is accepted and normalised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 12:56:30 +08:00
John McLear
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.
2026-05-03 04:01:13 +08:00
John McLear
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>
2026-05-02 10:29:25 +01:00
John McLear
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>
2026-05-01 14:23:54 +01:00
John McLear
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>
2026-05-01 13:50:04 +01:00
John McLear
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>
2026-05-01 13:47:40 +01:00
John McLear
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>
2026-05-01 20:02:12 +08:00
John McLear
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>
2026-05-01 10:43:29 +01:00
John McLear
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>
2026-05-01 10:25:24 +01:00
John McLear
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>
2026-04-29 09:21:05 +01:00
John McLear
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 5eee7895a) had no
test, which is how the bug shipped originally — plugins.ts spawned
`pnpm --version` at startup, the rejection was never caught, and
the .deb crashed mid-boot. Add a backend spec that exercises:
  - ENOENT for a missing binary -> rejects (regression test)
  - successful command -> resolves stdout
  - non-zero exit -> rejects with code

backend-tests.yml's recursive mocha glob picks up the new spec
automatically; no workflow change needed there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(packaging-ci): use NodeSource LTS for the smoke test (was Ubuntu's node 18)

ubuntu-latest's default apt nodejs is 18.19.1, but our package requires
nodejs (>= 20). The smoke test was doing `apt-get install nodejs`
followed by `dpkg -i ... || apt-get install -f`, which on a node-18
host fails the dep check, then `-f` "fixes" by REMOVING the etherpad
package — and the next assertion (test -x /usr/bin/etherpad) crashes.

Match what packaging/test-local.sh and the README recommend: install
node from NodeSource (current LTS) before installing the .deb.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(packaging-ci): sudo-prefix smoke assertions that read /etc/etherpad

postinstall sets /etc/etherpad to 0750 root:etherpad (DB creds live
here) and /var/lib/etherpad similarly. The GH Actions runner user
isn't in the etherpad group, so 'test -f /etc/etherpad/settings.json'
hits EACCES. Add sudo to each check that crosses one of those dirs.

(Wrapping the whole block in `sudo bash <<EOF` would have been
cleaner but YAML literal-block + heredoc terminator don't play well
together at this indent.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(packaging): close chown -R symlink-deref escalation; Pre-Depends adduser

postinstall:
  Use `chown -hR` instead of `chown -R` on /var/lib/etherpad/var and
  /var/lib/etherpad/plugin_packages. Both directories are writable by
  the unprivileged etherpad service user, so a symlink planted there
  could redirect root's chown onto arbitrary system files (e.g.
  /etc/shadow) on the next `apt upgrade`. -hR makes chown act on the
  symlink itself rather than its target — standard mitigation for this
  TOCTOU-style local privilege escalation.

nfpm:
  Move adduser from Depends to Pre-Depends. preinst creates the
  etherpad user before unpacking; with plain `dpkg -i` (no apt) the
  Depends list isn't installed beforehand, so a minimal system without
  adduser would fail preinst before unpack and apt-get -f couldn't
  recover. Pre-Depends guarantees adduser is configured first.

Both flagged in Qodo's persistent review of 3daf300f0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(packaging): predepends lives at top-level deb:, not under overrides

nfpm's Overridables schema doesn't include predepends; it's a deb-only
top-level field. Previous commit nested it under overrides.deb, which
caused nfpm to reject the entire manifest with "field predepends not
found in type nfpm.Overridables" and broke both arch builds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(packaging): four Qodo follow-ups (CI ordering, secure node install, disable on remove, writable settings)

deb-package.yml:
  - Move 'Resolve version' (which calls `node -p`) to AFTER setup-node
    so it doesn't depend on the runner image preinstalling node.
  - Replace `curl ... | sudo bash` NodeSource installer with the
    explicit gpg-key + sources.list approach. Same outcome (NodeSource
    LTS apt repo), but no execution of network-fetched code as root.
    Reduces blast radius if NodeSource's setup endpoint is ever
    compromised — we only trust the signed apt repo metadata.

postinstall.sh:
  - /etc/etherpad/settings.json now etherpad:etherpad mode 0660 (was
    root:etherpad 0640). The admin /admin/settings UI persists changes
    by writing back to settings.settingsFilename; with the previous
    perms the etherpad user could read but not write, so saving via
    the admin UI failed silently. Group-only access preserved (DB
    creds still unreadable by other users).

postremove.sh:
  - On `dpkg --remove`, run `systemctl disable etherpad.service` before
    `daemon-reload` so the wants/ symlink doesn't dangle after dpkg
    deletes the unit file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(packaging): narrow workflow token scope; pin local nfpm to NFPM_VERSION

deb-package.yml:
  Workflow-level permissions was `contents: write` so the build job got
  write access on every PR run, even though only the release job needs
  it (to attach release assets). Narrow the workflow default to
  `contents: read` and let the release job opt back in to write — it
  already declares its own job-level `contents: write` block, so this
  is just removing an over-broad default.

test-local.sh:
  The script defined NFPM_VERSION but then unconditionally ran
  `goreleaser/nfpm:latest`, so local builds could diverge from CI's
  pinned v2.43.0. Use the variable in the docker tag (stripping the
  leading "v" to match the image's tag scheme).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:33:30 +01:00
John McLear
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>
2026-04-27 03:36:35 +01:00
John McLear
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>
2026-04-26 10:29:02 +02:00
John McLear
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 e0ccdb4d9). 2.6.1 did not have this default, so
auto-detect worked there. 2.7.0 broke it.

Fix: default `lang` to null. The client's existing flow already handles
null correctly — getParams() at pad.ts:172 has
`if (serverValue == null) continue;`, so the forced-localize callback
simply does not fire, and l10n.ts's browser-language auto-detect runs.
Pad-settings dropdown consumer at pad.ts:489 already uses
`padOptions.lang || 'en'` so null renders fine there too.

`PadSettings.lang` is now typed `string | null` to match.

Added three backend regression tests under `normalizePadSettings lang`:
  * defaults to null when lang is absent (so client auto-detects)
  * preserves an explicit string lang (creator override still works)
  * drops non-string lang values to null rather than coercing to 'en'

Manual verification: with Firefox set to German, loading a fresh pad
now renders the UI in German. Index and timeslider continued to work
as before. Setting `?lang=de` or a language cookie continues to
override browser detection, as intended.

Fixes #7586
2026-04-23 10:38:46 +01:00
John McLear
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.
2026-04-23 10:18:57 +01:00
John McLear
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 &nbsp;, 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&nbsp;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 &nbsp; 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>&nbsp;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>
2026-04-23 08:57:30 +01:00
Stefan Müller
fe6a373bf8
feat: Remove paths from plugin packages (#7580) 2026-04-22 10:52:53 +01:00
Stefan Müller
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
2026-04-19 16:53:57 +02:00
John McLear
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>
2026-04-19 11:13:44 +01:00
John McLear
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>
2026-04-19 09:09:03 +01:00
John McLear
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 f336f24d. Follow the project convention: add the
new locale key to en.json only and let translations catch up via the
translation system, rather than putting inline fallback in the template.

* i18n: leave non-English locale files untouched

The PR had removed pad.importExport.abiword.innerHTML from ~82 locale
files alongside its removal from en.json. The replacement message uses
a new key (pad.importExport.noConverter.innerHTML) in en.json only, so
churning every localisation file for a key that is no longer referenced
produces useless translation diffs. Restore every non-en locale file to
its pre-PR state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JohnMcLear <220864+JohnMcLear@users.noreply.github.com>
2026-04-19 09:08:22 +01:00
John McLear
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>
2026-04-18 17:09:18 +01:00
John McLear
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>
2026-04-07 18:30:08 +01:00
John McLear
8c1b8b0902
fix: add setters to CJS compatibility layer in Settings (#7481)
The CJS compatibility block added in fd97532 only defined getters,
making settings properties read-only for plugins using require().
Plugins like ep_webrtc need to mutate settings (e.g. requireAuthentication)
in tests. Add setters so CJS consumers can write properties too.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:19:23 +01:00
John McLear
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>
2026-04-06 15:12:08 +01:00
John McLear
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>
2026-04-06 13:32:33 +01:00
John McLear
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>
2026-04-06 13:31:57 +01:00
John McLear
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>
2026-04-06 13:28:57 +01:00
John McLear
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>
2026-04-06 11:24:01 +01:00
John McLear
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>
2026-04-06 11:19:34 +01:00
John McLear
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>
2026-04-05 16:12:43 +01:00
John McLear
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>
2026-04-05 01:58:35 +01:00
John McLear
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>
2026-04-05 01:58:29 +01:00
John McLear
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>
2026-04-05 01:58:27 +01:00
John McLear
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>
2026-04-05 00:27:16 +01:00
John McLear
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>
2026-04-05 00:25:20 +01:00
John McLear
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>
2026-04-04 09:28:09 +01:00
John McLear
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>
2026-04-04 08:25:26 +01:00
John McLear
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>
2026-04-03 07:13:46 +01:00
John McLear
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>
2026-04-03 07:12:46 +01:00
John McLear
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>
2026-04-01 19:48:45 +01:00
John McLear
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>
2026-04-01 11:39:40 +01:00
dependabot[bot]
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>
2025-12-19 21:38:53 +01:00
SamTV12345
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
2025-11-18 12:23:55 +01:00
SamTV12345
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
2025-10-29 20:29:40 +01:00