diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 10562bd52..c076a3542 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -66,24 +66,7 @@ jobs: run: pnpm build - name: Run the backend tests - env: - # --report-on-fatalerror and friends write a Node diagnostic report - # (V8 stack, libuv handles, OS info) on fatal errors that bypass JS - # handlers — the failure mode we've been chasing on Windows + Node - # 24 since PR #7663. Reports land in node-report/ and are uploaded - # as an artifact if the step fails. - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - pnpm test - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + run: pnpm test - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest @@ -153,19 +136,7 @@ jobs: ep_table_of_contents - name: Run the backend tests - env: - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - pnpm test - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + run: pnpm test - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest @@ -180,7 +151,18 @@ jobs: fail-fast: false matrix: # Etherpad requires Node >= 24 (see package.json engines.node). - node: ${{ fromJSON('[24]') }} + # Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's + # bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect + # path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the + # backend suite's heavy localhost connection churn -- the long-standing Windows + # backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node + # observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0 + # (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly + # because setup-node's default check-latest:false reuses the runner's + # pre-cached 24.15.0 for a bare "24". + # Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin + # back to plain "24" once the fix is across the supported 24.x baseline. + node: ${{ fromJSON('["24.16.0"]') }} name: Windows without plugins runs-on: windows-latest steps: @@ -218,23 +200,9 @@ jobs: name: Run the backend tests shell: bash working-directory: src - env: - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - # --exit forces process.exit(failures) after the suite completes, - # closing the post-suite event-loop drain window where Windows + - # Node 24 hard-kills the process. Scoped to Windows so Linux/local - # runs still surface real handle leaks via natural drain. - pnpm test -- --exit - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + # --exit makes mocha call process.exit() after the run so a leaked handle + # cannot hang the job on Windows. + run: pnpm test -- --exit - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest @@ -248,7 +216,18 @@ jobs: fail-fast: false matrix: # Etherpad requires Node >= 24 (see package.json engines.node). - node: ${{ fromJSON('[24]') }} + # Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's + # bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect + # path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the + # backend suite's heavy localhost connection churn -- the long-standing Windows + # backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node + # observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0 + # (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly + # because setup-node's default check-latest:false reuses the runner's + # pre-cached 24.15.0 for a bare "24". + # Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin + # back to plain "24" once the fix is across the supported 24.x baseline. + node: ${{ fromJSON('["24.16.0"]') }} name: Windows with Plugins runs-on: windows-latest @@ -315,23 +294,9 @@ jobs: name: Run the backend tests shell: bash working-directory: src - env: - NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report" - run: | - mkdir -p "${{ github.workspace }}/node-report" - # --exit forces process.exit(failures) after the suite completes, - # closing the post-suite event-loop drain window where Windows + - # Node 24 hard-kills the process. Scoped to Windows so Linux/local - # runs still surface real handle leaks via natural drain. - pnpm test -- --exit - - name: Upload Node diagnostic reports on failure - if: ${{ failure() }} - uses: actions/upload-artifact@v7 - with: - name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }} - path: node-report/ - if-no-files-found: ignore - retention-days: 7 + # --exit makes mocha call process.exit() after the run so a leaked handle + # cannot hang the job on Windows. + run: pnpm test -- --exit - name: Run the new vitest tests working-directory: src run: pnpm run test:vitest diff --git a/.pr_agent.toml b/.pr_agent.toml deleted file mode 100644 index 93e106ea2..000000000 --- a/.pr_agent.toml +++ /dev/null @@ -1,5 +0,0 @@ -[pr_reviewer] -run_on_pr_sync = true - -[pr_description] -run_on_pr_sync = true diff --git a/AGENTS.MD b/AGENTS.MD index 5a5fbc4e9..c3cb1115e 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -211,6 +211,38 @@ pnpm run plugins ls # List installed ### Settings Configured via `settings.json`. A template is available at `settings.json.template`. Environment variables can override any setting using `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. +## Releasing + +Releases are driven almost entirely by GitHub Actions. A maintainer dispatches **one** workflow; the version bump, tagging, GitHub Release, Docker images, and snap all cascade off the pushed tag. The npm publish is a separate manual dispatch. + +### Prerequisites (check these before dispatching) +- **A `# X.Y.Z` changelog section for the _target_ version must already be at the top of `CHANGELOG.md`.** `bin/release.ts` aborts with `No changelog record for X.Y.Z, please create changelog record` if it's missing. Write the section before dispatching. +- **All four `package.json` files must agree on the current version:** root `package.json`, `src/package.json`, `admin/package.json`, `bin/package.json`. `release.ts` reads the *current* version from **`src/package.json`** and computes the next with `semver.inc(current, type)`. If the files are out of sync (e.g. one was hand-edited), the computed target is wrong and the changelog guard fails. *(This exact desync — `src`/`bin` left at 3.3.0 while root/admin were 3.2.0 — blocked the 3.3.0 release in June 2026.)* + +### Cutting a release +1. **Actions → "Release etherpad"** → Run workflow (`workflow_dispatch`), choose `patch` / `minor` / `major`. Cadence is monthly **minors** (3.0.0 → 3.1.0 → 3.2.0 → …); use `major` only for breaking changes. +2. The **Prepare release** step runs `bin/release.ts`, which: + - sanity-checks the tree (clean working dir, on `develop`, `develop`/`master` upstreams in sync, `../ether.github.com` cloned & clean on `master`); + - bumps the version in all four `package.json` files; + - commits `bump version`, merges `develop` → `master`, creates both `X.Y.Z` **and** `vX.Y.Z` tags, merges `master` back to `develop`; + - builds and stages the versioned docs into the website repo (see **Documentation** below). +3. The **Push after release** step (`bin/push-after-release.sh`) pushes `master`, `develop`, the tag, `--tags`, and the `ether.github.com` docs commit. +4. The pushed **`vX.Y.Z` tag auto-triggers** three workflows: + - `handleRelease.yml` → builds Etherpad, extracts the matching changelog section via `generateChangelog` (`bin/generateReleaseNotes.ts`), and publishes the **GitHub Release** (`make_latest: true`); + - `docker.yml` → builds & pushes the Docker images; + - `snap-publish.yml` → publishes the snap. +5. **npm publish is a separate manual step:** dispatch **"releaseEtherpad.yaml"** (`workflow_dispatch`), which runs `npm publish --provenance --access public` via npm **OIDC trusted publishing**. It is *not* fired by the tag. + +### Documentation + +Two distinct things, both important: + +**1. Per-PR doc updates — your responsibility in every behaviour-change PR.** +The `doc/` workspace holds the user/admin/API docs (Markdown + AsciiDoc). Whenever a PR changes API responses, CLI flags, settings keys, hooks, or error formats, update the relevant files in `doc/` **in the same PR** — don't defer it to release time. The HTTP API reference is `doc/api/http_api.{md,adoc}` (keep both in sync). Preview locally with `pnpm run makeDocs`. + +**2. Release-time versioned docs publishing — automated, no manual step.** +During a release, `release.ts` runs `pnpm run makeDocs` (→ `bin/make_docs.ts`) to render `doc/` into `out/doc/`, then copies it into the sibling website repo at `../ether.github.com/public/doc/vX.Y.Z`, bumps that repo's version, and commits `X.Y.Z docs`; `push-after-release.sh` pushes it, publishing the versioned docs on etherpad.org. This requires `ether.github.com` checked out as a **sibling directory** — the **Release etherpad** workflow checks it out automatically. If you ever run `release.ts` by hand, clone it first: `cd .. && git clone git@github.com:ether/ether.github.com.git`. + ## Monorepo Structure This project uses pnpm workspaces. The workspaces are: diff --git a/CHANGELOG.md b/CHANGELOG.md index 27513e9c4..ae4622048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,52 @@ +# 3.3.0 + +3.3 is primarily a security-hardening release. A defence-in-depth pass tightens the HTTP API entry points, switches random-id generation to a CSPRNG, escapes exported `data-*` attributes, and flips the shipped Docker deployment defaults so a fresh install no longer boots with implicit credentials or a trusting proxy. Alongside that, the `ep_*` pad-options passthrough that shipped opt-in in 3.0.0 is now on by default, the in-pad timeslider learns to honour the editor's view settings (authorship colours, font family, line numbers), and a long tail of pad-editor layout, RTL, and URL-encoding fixes lands. The release also carries the root-cause fix for the long-standing Windows backend-test "silent ELIFECYCLE" flake. + +### Notable enhancements + +- **Plugin pad options on by default — `settings.enablePluginPadOptions` now defaults to `true` (#7841).** The flag that gates the `ep_*` passthrough on pad options (shipped opt-in in 3.0.0, #7698) is flipped to default-on, so plugins such as `ep_plugin_helpers`' `padToggle` / `padSelect` ride the existing broadcast/persist rail out of the box. This closes `ep_comments_page#422` — stock 3.x deployments `console.warn`ed on every pad load because the helper detected `enablePluginPadOptions === false`. The `settings.json.template` env-var default is flipped to match, so Docker/supervisor configs without an explicit value get the new behaviour. Existing deployments with an explicit `"enablePluginPadOptions": false` keep that value — no migration needed — and the protocol shape is unchanged for older clients. +- **Timeslider — honour the editor's view settings (#7899).** The in-pad timeslider now respects `showAuthorshipColors`, `padFontFamily`, and line-numbers, bridged from the pad-settings checkboxes into the embedded timeslider iframe so the two views agree. `nice-select.ts` dispatches a native `change` event after the jQuery trigger so the `addEventListener`-based bridge in `pad_mode.ts` fires (jQuery 3.7.1's `trigger()` does not dispatch native DOM events), and the font-family reset is fixed for jQuery 3 (which ignores a `null` css value). The five ad-hoc listener stores in `pad_mode.ts` are consolidated into one `bindOuter()` path and the three view-setting bridges into a single data-driven `bridgeView()` (refactor only). +- **Admin settings — explain env-var substitution and surface auth errors (#7819 / #7826).** Three env-var-only UX improvements driven by #7819 (a Docker operator saved an `ep_oauth` block in the Raw view and reported it "disappeared", not realising `settings.json` on disk is a *template*, not the effective config): a banner above the editor explaining the template/substitution model (rendered only when the loaded file contains a `${VAR}` placeholder); a read-only **Effective** tab exposing the redacted runtime settings the backend already emitted as `resolved` (also gated on `${VAR}`); and an `admin_auth_error` event so a misrouted Traefik+SSO session that isn't admin gets a clear toast instead of a silent "save did nothing". A reconnect-loop guard suppresses the SPA's auto-reconnect once an auth error has been received. No behaviour change for installs without `${VAR}` placeholders. + +### Security hardening + +A defence-in-depth pass across the API, token, export, and deployment surfaces: + +- **HTTP API request handling, random IDs, and plugin loading (#7906).** `pad_utils.randomString` now generates random IDs via `crypto.getRandomValues` (CSPRNG) instead of `Math.random`. `OAuth2Provider` compares passwords with `crypto.timingSafeEqual` on the raw UTF-8 bytes (resolving the CodeQL "insufficient computational effort" alert) behind a uniform failure delay, and looks users up via own-property access only. `API.appendChatMessage` throws `padID does not exist` rather than creating the pad, consistent with the other content API methods. The `/api/2` REST router forwards only the `authorization` header (not the full request header set) and falls back to it whenever the field is falsy, matching the `openapi.ts` handler so both routers authenticate identically. `LinkInstaller` validates plugin dependency names before building filesystem paths from them, and the admin file server returns a generic error while logging details server-side. +- **Escape exported `data-*` attributes; warn on default/placeholder credentials (#7905).** `ExportHtml` now escapes the name and value of attributes emitted by the `exportHtmlAdditionalTagsWithData` hook, consistent with the URL/text escaping already applied to exported HTML. `Settings` logs a warning (error level under `NODE_ENV=production`) when an account uses a default/placeholder password from the shipped config, and the check is extended to cover `sso.clients[].client_secret` so enabling SSO without setting `ADMIN_SECRET` / `USER_SECRET` is flagged the same way. +- **Docker deployment defaults — require explicit credentials, default `TRUST_PROXY` off (#7907).** The shipped `docker-compose` now requires `ADMIN_PASSWORD` and the database password to be provided explicitly (no implicit fallback) and defaults `TRUST_PROXY` to `false`. Operators relying on the previous implicit defaults must now set these values explicitly. + +### Notable fixes + +- **History mode — lay the timeslider iframe in the editor's flex slot (#7903).** In-pad history mode positioned `#history-frame-mount` as an `inset:0` absolute overlay over `#editorcontainerbox`, which took the iframe out of flow and hid any in-flow side panel (e.g. `ep_webrtc`'s `#rtcbox` video column) beneath it — so history mode and live mode disagreed. The iframe now occupies the same in-flow flex slot the live editor uses, and a latent specificity bug (the `body.history-mode #editorcontainer { display: none }` hide rule was outranked by the two-id layout rule, so the live editor was only ever painted over) is fixed by giving the hide rule matching specificity. Adds a `padmode.spec.ts` regression test. +- **Pad editor — restore URL wrapping (#7894 / #7896).** Long URLs in the pad editor overflowed instead of wrapping because the global `a { white-space: nowrap }` rule overrode the wrapping properties on `#innerdocbody`. Explicit `white-space` / `word-wrap` / `overflow-wrap` on `#innerdocbody a` restores wrapping inside the editor while preserving no-wrap for links elsewhere in the UI. +- **RTL content option no longer flips the whole page (#7900 / #7901).** The per-pad RTL content option (`rtlIsTrue`) wrote the direction to the top-level `document.documentElement`, flipping the entire page — toolbar and chrome included. The content direction is now applied to the inner editor document (`targetDoc.documentElement`); page direction stays owned by the UI language (`l10n.ts`). Adds a frontend test asserting the inner editor flips while the top-level `` dir is unchanged. +- **Pad-wide view settings apply to the creator's own view (#7900 / #7902).** Because a creator is never "enforced upon themselves", a stale personal view-override cookie (e.g. `rtlIsTrue=false` from an earlier toggle) silently masked the pad-wide value they later set, so the control appeared to do nothing on their own screen. Changing a pad-wide view option now syncs the creator's personal pref to the chosen value; the precedence model is unchanged (the creator can still override afterwards via "My view"). +- **URL view-option params lost to a `padeditor.init` race (#7840 / #7843).** `?showLineNumbers=false` and `?useMonospaceFont=true` were silently clobbered shortly after load — the same race #7464 fixed for `?rtl=false`, but the neighbouring `showLineNumbers` / `noColors` / `useMonospaceFontGlobal` blocks were left at the synchronous-tail site. The fix is generalised to all three (moved into `postAceInit`). Mostly observable in cross-context iframe embeds that start with no `prefs` cookie. Adds `url_view_options.spec.ts`. +- **Default welcome text attributed to the system author (#7885 / #7887).** Auto-generated default pad content (`settings.defaultPadText` / `padDefaultContent` hook) carried the creating user's `author` attribute and rendered in their authorship colour, even though they never wrote it. The welcome text's `author` *attribute* is now `Pad.SYSTEM_AUTHOR_ID`, while revision 0's `meta.author` stays the real creator so ownership (pad-wide settings gate, deletion token) is preserved. Explicitly provided text (e.g. HTTP API `createPad` with text + author) keeps the real author. +- **URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895).** Pad names are `encodeURIComponent`-d in the admin `PadPage` Open href and the colibris recent-pads href, and `decodeURIComponent`-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (`%2F` → `%252F`). The admin Open `window.open` gains `noopener,noreferrer`. +- **OIDC — fix broken `OIDCAdapter` flows (#7837).** Repairs the adapter flows and widens the storage type to include `string` for the `userCode` index; adds regression tests. +- **Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836).** Adds the `index.code` key referenced by `index.html` but never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin `@radix-ui/react-dialog` `Dialog.Content` a `Dialog.Title` and `Dialog.Description` (visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if any `data-l10n-id` in `src/templates/*.html` is missing from `en.json`. +- **Offline/air-gapped Docker boot — stop pnpm self-provisioning a pinned version (issue #7911).** The official image installs pnpm directly (corepack was dropped for Node 25+). Because the image's pnpm intentionally lags the `packageManager` pin in `package.json` (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), pnpm treated every call — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download the pinned build. Behind a firewall that download failed (`Failed to get pnpm version: … Command exited with code 1`), breaking startup. The Dockerfile now sets `pnpm_config_pm_on_fail=ignore`, and the startup probe plus the updater's pnpm-on-PATH checks run with the same flag, so pnpm uses the installed version instead of reaching for the network (without changing which pnpm runs the build-time install). A backend spec fails CI if that guard is dropped while a version gap exists. +- **Firefox authorship colours — tag early keystrokes with the right author (#7910).** The inner editor's `thisAuthor` starts empty and is only populated when collab_client's queued `setProperty('userAuthor', userId)` reaches the iframe (applied asynchronously via `pendingInit`). Under Firefox timing the first keystrokes could beat it, so freshly typed text — and early line-attribute changes (lists, headings, alignment) — were tagged `author=''`, which canonicalises to an unattributed insert that the server's pad-corruption guard rejects, dropping the whole change and losing authorship (the intermittent `clear_authorship_color` flake, where undo couldn't restore the author colour). A `getLocalAuthor()` helper now falls back to `clientVars.userId` (the same id, available synchronously) whenever `thisAuthor` is still empty, applied at the text-insert sites and to seed `documentAttributeManager.author`; the intentional clear-authorship path and the server-side guard are unchanged. +- **Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606).** Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads `theme-color` at parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits a `prefers-color-scheme`-scoped `theme-color` pair so the address bar is correct at first paint, plus a small blocking `` script that applies the dark skin classes before the stylesheet paints. Both are gated on `enableDarkMode` (default on) and the colibris skin; `pad.ts` still runs on init to wire up the `#options-darkmode` toggle (which now updates every `theme-color` meta) and theme the editor iframes. Applies to the pad and timeslider views. + +### Internal / contributor-facing + +- **Root-caused and fixed the Windows backend-test "silent ELIFECYCLE" flake (#7866).** The ~22% Windows flake — rotating across random spec files, no mocha summary, no JS trace — was diagnosed from a full-memory dump as two distinct causes. (1) A timing-fragile test abandoned by mocha keeps running and later throws an *orphan* unhandled rejection; `server.ts`'s process-global `uncaughtException`/`unhandledRejection` handlers (correct for a real Etherpad process) escalated that into a clean `process.exit`. They are now gated behind `require.main === module`, and the backend-test bootstraps (`common.ts`, `diagnostics.ts`) log orphan rejections instead of rethrowing. (2) A stack-buffer overrun in Node 24.x's bundled libuv Windows TCP-connect path (`uv__tcp_connect`) corrupts memory under the suite's localhost-connection churn; CI pins the Windows backend job to Node **24.16.0** (libuv 1.52.1, the bisected fix), referencing upstream `nodejs/node#63620`. Linux stays on Node 24 LTS. +- **Removed the now-unneeded ELIFECYCLE diagnostic scaffolding (#7846 / #7838 / #7842 / #7868).** The OS-level sidecar watcher, the diagnostics heartbeat/running-test pointer, and the mid-test snapshot — added to chase the flake above — are removed now that the cause is known. +- **Docs — document the Docker `settings.json` writable-layer and env-var-vs-file semantics (#7819 / #7827).** Two operator-facing gaps surfaced by #7819: that the on-disk `settings.json` is a template (env substitution happens in memory at load time), and that the default compose puts `settings.json` in the container's writable layer with no host mount, so admin edits are lost on `down`/`pull`/watchtower but survive a plain `restart`. Adds prose + a recreate-vs-restart table to `doc/docker.md` and a commented-out opt-in bind mount to the compose files. +- **Docs refresh for 3.2.0 (#7888)**, **dropped three redundant top-level files (#7839)**, **dropped a fragile viewport assertion in the enter test (#7845)**, and a backend-test fix-up. + +### Dependencies + +- Two major bumps: `redis` 5.12.1 → 6.0.0 (#7869) and `ejs` 5.0.2 → 6.0.1 (#7860). +- `ueberdb2` 6.1.2 → 6.1.8, `mssql` 12.5.3 → 12.5.5, `nodemailer` 8.0.7 → 8.0.10, `mysql2` 3.22.3 → 3.22.5 (#7915), `undici` 8.3.0 → 8.4.1 (#7914), `pdfkit` 0.18.0 → 0.19.0 (#7916), `oidc-provider` 9.8.3 → 9.8.4, `@elastic/elasticsearch` 9.4.1 → 9.4.2, `lru-cache` 11.5.0 → 11.5.1, `rate-limiter-flexible` 11.1.0 → 11.1.1, `semver` 7.8.1 → 7.8.2, `js-cookie` 3.0.7 → 3.0.8, `tsx` 4.22.3 → 4.22.4, `@radix-ui/react-switch` 1.2.6 → 1.3.0 (#7913), `@tanstack/react-query` 5.100.11 → 5.101.0 (+ devtools), plus `i18next`, `react-router-dom`, and several dev-dependency group bumps (#7912). + +### Localisation + +- Multiple updates from translatewiki.net. + # 3.2.0 3.2 adds first-class reverse-proxy / ingress support — `X-Forwarded-Prefix` and `X-Ingress-Path` are now honoured under `trustProxy`, so Etherpad can live under a subpath (Traefik, Nginx, Kubernetes Ingress) without breaking the PWA manifest, social-meta URLs, or any of the bootstrap asset links. The admin settings page learns to show *resolved* runtime values next to `${VAR:default}` placeholders, the v3.1.0 admin pad-list filter chips now apply server-side (so "show empty pads" no longer returns 0–12 of hundreds), and the v3.1.0 redesigned outdated-version gritter actually fires in production now (the session-based author lookup it shipped with always returned null for pad visitors). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf153068e..d22f6a97b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,8 @@ ## Pull requests +* PRs MUST include a non-empty description explaining what the change does and why +* PRs without a description should be flagged as incomplete * the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary * PRs should be issued against the **develop** branch: we never pull directly into **master** * PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing @@ -123,7 +125,7 @@ Documentation should be kept up-to-date. This means, whenever you add a new API You can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet. ## Testing -Front-end tests are found in the `tests/frontend/` folder in the repository. Run them by pointing your browser to `/tests/frontend`. +Front-end tests are found in the `src/tests/frontend/` folder in the repository. Run them by pointing your browser to `/tests/frontend`. Back-end tests can be run from the `src` directory, via `npm test`. You can use `npm test -- --inspect-brk` and navigate to `edge://inspect` or `chrome://inspect` to debug the tests. diff --git a/Dockerfile b/Dockerfile index 56d65a76f..be7a201ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,11 @@ # docker build --build-arg BUILD_ENV=copy . ARG BUILD_ENV=git +# NOTE: this intentionally lags the "packageManager" pin in package.json. pnpm +# 11.1.x enforces the minimum-release-age supply-chain policy during install, +# which the frozen-lockfile Docker build can't satisfy, so the image stays on +# 11.0.x. The version gap is made harmless by pnpm_config_pm_on_fail=ignore in +# the build stage below — see ether/etherpad#7911. ARG PnpmVersion=11.0.6 FROM node:24-alpine AS adminbuild @@ -28,6 +33,17 @@ RUN pnpm run build:ui FROM node:24-alpine AS build LABEL maintainer="Etherpad team, https://github.com/ether/etherpad" +# The image's pnpm intentionally lags the "packageManager" pin (see the ARG +# note above). pnpm would otherwise try to self-provision the pinned version on +# invocation — including the informational `pnpm --version` probe Etherpad runs +# at startup — which fails closed with no network and breaks air-gapped boots +# (ether/etherpad#7911). pm_on_fail=ignore makes pnpm use the installed version +# instead. Inherited by the development and production runtime stages, so it +# also covers the updater's pnpm-on-PATH check and ad-hoc `pnpm` in an exec +# shell. It does not change which pnpm runs the build-time install (still the +# installed 11.0.x), so the frozen-lockfile build is unaffected. +ENV pnpm_config_pm_on_fail=ignore + # Set these arguments when building the image from behind a proxy ARG http_proxy= ARG https_proxy= diff --git a/README.md b/README.md index 0560ec3dd..3be539f4c 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ volumes: ### Docker container -Find [here](doc/docker.adoc) information on running Etherpad in a container. +Find [here](doc/docker.md) information on running Etherpad in a container. ## Plugins @@ -247,8 +247,8 @@ git -P tag --list "v*" --merged ``` 4. Select the version ```sh -git checkout v2.2.5 -git switch -c v2.2.5 +git checkout v3.2.0 +git switch -c v3.2.0 ``` 5. Upgrade Etherpad ```sh diff --git a/admin/package.json b/admin/package.json index 4c2f171cb..b2bb60bea 100644 --- a/admin/package.json +++ b/admin/package.json @@ -1,7 +1,7 @@ { "name": "admin", "private": true, - "version": "3.2.0", + "version": "3.3.0", "type": "module", "scripts": { "dev": "pnpm gen:api && vite", @@ -14,39 +14,40 @@ "test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'" }, "dependencies": { - "@radix-ui/react-switch": "^1.2.6", - "@tanstack/react-query": "^5.100.11", - "@tanstack/react-query-devtools": "^5.100.11", + "@radix-ui/react-switch": "^1.3.0", + "@tanstack/react-query": "^5.101.0", + "@tanstack/react-query-devtools": "^5.101.0", "jsonc-parser": "^3.3.1", "openapi-fetch": "^0.17.0", "openapi-react-query": "^0.5.4" }, "devDependencies": { - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-toast": "^1.2.15", - "@types/react": "^19.2.15", + "@radix-ui/react-dialog": "^1.1.16", + "@radix-ui/react-toast": "^1.2.16", + "@radix-ui/react-visually-hidden": "^1.2.5", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@typescript-eslint/eslint-plugin": "^8.59.4", - "@typescript-eslint/parser": "^8.59.4", + "@typescript-eslint/eslint-plugin": "^8.60.1", + "@typescript-eslint/parser": "^8.60.1", "@vitejs/plugin-react": "^6.0.2", "babel-plugin-react-compiler": "19.1.0-rc.3", - "eslint": "^10.4.0", + "eslint": "^10.4.1", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", - "i18next": "^26.2.0", + "i18next": "^26.3.1", "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^1.16.0", + "lucide-react": "^1.17.0", "openapi-typescript": "^7.13.0", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "react-hook-form": "^7.76.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-hook-form": "^7.78.0", "react-i18next": "^17.0.8", - "react-router-dom": "^7.15.1", + "react-router-dom": "^7.17.0", "socket.io-client": "^4.8.3", - "tsx": "^4.22.3", + "tsx": "^4.22.4", "typescript": "^6.0.3", - "vite": "^8.0.14", + "vite": "^8.0.16", "vite-plugin-babel": "^1.7.3", - "zustand": "^5.0.13" + "zustand": "^5.0.14" } } diff --git a/admin/public/ep_admin_authors/en.json b/admin/public/ep_admin_authors/en.json index d4215b3b4..f482d60a1 100644 --- a/admin/public/ep_admin_authors/en.json +++ b/admin/public/ep_admin_authors/en.json @@ -14,6 +14,8 @@ "cap-warning": "Showing the first 1000 authors. Narrow your search to see more.", "feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.", "no-results": "No authors match this search.", + "confirm-dialog-title": "Confirm author erasure", + "confirm-dialog-description": "Review the impact of erasing this author and confirm or cancel.", "confirm-preview-title": "Erase author {{name}}", "confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.", "confirm-irreversible": "This cannot be undone.", diff --git a/admin/src/App.css b/admin/src/App.css index a064a56f9..c0e02cd0e 100644 --- a/admin/src/App.css +++ b/admin/src/App.css @@ -34,6 +34,37 @@ textarea.settings:focus { border-top: 1px solid #ddd; } +/* --- env-var banner --- */ +/* Shown only when settings.json contains ${VAR} placeholders. The + typical reader is a Docker/K8s operator who has just been surprised + by env-var substitution semantics, so the copy must explain rather + than warn — visual weight matches a note, not an error. */ +.settings-envvar-banner { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + margin-bottom: 16px; + background: #f5f7ff; + border: 1px solid #c7d2fe; + border-radius: 6px; + color: #1e293b; +} +.settings-envvar-banner svg { + flex-shrink: 0; + color: #4f46e5; + margin-top: 2px; +} +.settings-envvar-banner strong { + display: block; + margin-bottom: 4px; +} +.settings-envvar-banner p { + margin: 0; + font-size: 13px; + line-height: 1.5; +} + /* --- mode toggle --- */ .settings-mode-toggle { display: inline-flex; diff --git a/admin/src/App.tsx b/admin/src/App.tsx index b182be02f..d7395251b 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -32,12 +32,19 @@ export const App = () => { const settingSocket = connect(`${WS_URL}/settings`, {transports: ['websocket']}); const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {transports: ['websocket']}) + // When the server explicitly rejects us for not being admin, we must + // NOT reconnect on the subsequent `disconnect` event — otherwise the + // socket cycles forever (connect → admin_auth_error → server + // disconnect → SPA auto-reconnect → …). The flag is reset on each + // successful connect. + let authErrored = false; pluginsSocket.on('connect', () => { useStore.getState().setPluginsSocket(pluginsSocket); }); settingSocket.on('connect', () => { + authErrored = false; useStore.getState().setSettingsSocket(settingSocket); useStore.getState().setShowLoading(false) settingSocket.emit('load'); @@ -46,7 +53,7 @@ export const App = () => { settingSocket.on('disconnect', (reason) => { useStore.getState().setShowLoading(true) - if (reason === 'io server disconnect') settingSocket.connect(); + if (reason === 'io server disconnect' && !authErrored) settingSocket.connect(); }); settingSocket.on('settings', (settings: any) => { @@ -75,6 +82,22 @@ export const App = () => { const detail = payload?.message ?? ''; setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false}); } + }); + + // Backend emits this when the connecting socket does not have an + // admin session. Previously the server just dropped silently, so the + // SPA would sit on a loading screen with no clue. Surface it AND + // suppress the automatic reconnect — without this flag the SPA would + // immediately reconnect to a socket that will reject it again. + settingSocket.on('admin_auth_error', (payload?: {message?: string}) => { + authErrored = true; + const {setToastState} = useStore.getState(); + setToastState({ + open: true, + title: payload?.message || t('admin_settings.toast.auth_error'), + success: false, + }); + useStore.getState().setShowLoading(false); }) return () => { diff --git a/admin/src/components/settings/ModeToggle.tsx b/admin/src/components/settings/ModeToggle.tsx index 0f87f62ce..bce7ef81d 100644 --- a/admin/src/components/settings/ModeToggle.tsx +++ b/admin/src/components/settings/ModeToggle.tsx @@ -1,13 +1,17 @@ import { Trans, useTranslation } from 'react-i18next'; -export type Mode = 'form' | 'raw'; +export type Mode = 'form' | 'raw' | 'effective'; type Props = { mode: Mode; onChange: (mode: Mode) => void; + // When false, the Effective tab is hidden. We hide it for installs that + // aren't using env-var substitution at all — there's no useful difference + // between the raw file and the effective in-memory config for them. + showEffective?: boolean; }; -export const ModeToggle = ({ mode, onChange }: Props) => { +export const ModeToggle = ({ mode, onChange, showEffective = false }: Props) => { const { t } = useTranslation(); return (
@@ -31,6 +35,19 @@ export const ModeToggle = ({ mode, onChange }: Props) => { > + {showEffective && ( + + )}
); }; diff --git a/admin/src/pages/AuthorPage.tsx b/admin/src/pages/AuthorPage.tsx index ea119e633..0b5626b25 100644 --- a/admin/src/pages/AuthorPage.tsx +++ b/admin/src/pages/AuthorPage.tsx @@ -1,6 +1,7 @@ import {Trans, useTranslation} from "react-i18next"; import {useEffect, useMemo, useState} from "react"; import * as Dialog from "@radix-ui/react-dialog"; +import {VisuallyHidden} from "@radix-ui/react-visually-hidden"; import {ChevronLeft, ChevronRight, Trash2} from "lucide-react"; import {useStore} from "../store/store.ts"; import {SearchField} from "../components/SearchField.tsx"; @@ -153,16 +154,20 @@ export const AuthorPage = () => { + + {t('ep_admin_authors:confirm-dialog-title')} + + + {t('ep_admin_authors:confirm-dialog-description')} + {dialog.phase === 'loading-preview' &&
} {(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => { const p = dialog.preview; return
- -

{t('ep_admin_authors:confirm-preview-title', - {name: p.name || p.authorID})}

-
+

{t('ep_admin_authors:confirm-preview-title', + {name: p.name || p.authorID})}

{t('ep_admin_authors:confirm-preview-counters', { tokenMappings: p.removedTokenMappings, externalMappings: p.removedExternalMappings, diff --git a/admin/src/pages/PadPage.tsx b/admin/src/pages/PadPage.tsx index 2b8ba06d1..d1f851a58 100644 --- a/admin/src/pages/PadPage.tsx +++ b/admin/src/pages/PadPage.tsx @@ -4,6 +4,7 @@ import {useStore} from "../store/store.ts"; import {PadFilter, PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts"; import {useDebounce} from "../utils/useDebounce.ts"; import * as Dialog from "@radix-ui/react-dialog"; +import {VisuallyHidden} from "@radix-ui/react-visually-hidden"; import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react"; import {useForm} from "react-hook-form"; import type {TFunction} from "i18next"; @@ -165,7 +166,10 @@ export const PadPage = () => { -

{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}
+ {t('admin_pads.delete_pad_dialog_title')} + +
{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}
+
@@ -178,7 +182,10 @@ export const PadPage = () => { -
{t('admin_pads.error_prefix')}: {errorText}
+ {t('admin_pads.error_prefix')} + +
{t('admin_pads.error_prefix')}: {errorText}
+
@@ -191,6 +198,7 @@ export const PadPage = () => { + {t('admin_pads.create_pad_dialog_description')}
@@ -410,7 +418,7 @@ export const PadPage = () => { diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx index 7511b8e39..8c5529ea8 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -1,22 +1,42 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useStore } from '../store/store'; import { isJSONClean, cleanComments } from '../utils/utils'; import { Trans, useTranslation } from 'react-i18next'; import { IconButton } from '../components/IconButton'; -import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react'; +import { RotateCw, Save, AlignLeft, ShieldCheck, Info } from 'lucide-react'; import { FormView } from '../components/settings/FormView'; import { ModeToggle, type Mode } from '../components/settings/ModeToggle'; const TAB_INDENT = ' '; +// Heuristic: `${VAR}` or `${VAR:default}` in the file means the operator is +// running with env-var substitution (overwhelmingly Docker / Kubernetes). +// We use this to gate the Docker-aware UX (the explanatory banner and the +// Effective-config tab) so non-container installs see the existing UI +// unchanged. Conservative on purpose — false negatives just keep the old +// behaviour. +const ENV_VAR_PATTERN = /\$\{[A-Za-z_][A-Za-z0-9_]*(?::[^}]*)?\}/; + export const SettingsPage = () => { const { t } = useTranslation(); const settingsSocket = useStore(state => state.settingsSocket); const settings = useStore(state => state.settings) ?? ''; + const resolved = useStore(state => state.resolved); + + const usesEnvVars = useMemo(() => ENV_VAR_PATTERN.test(settings), [settings]); const [mode, setMode] = useState('form'); const [exposeExperimental] = useState(false); + // The Effective tab is only meaningful when there is a `resolved` + // payload AND the file uses substitution. Falling back to Raw on + // either condition keeps the toggle honest if the user opens this + // page against an older server. + const canShowEffective = usesEnvVars && resolved != null; + useEffect(() => { + if (mode === 'effective' && !canShowEffective) setMode('raw'); + }, [mode, canShowEffective]); + // Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders. const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key !== 'Tab') return; @@ -57,49 +77,80 @@ export const SettingsPage = () => { settingsSocket.emit('saveSettings', settings); }; + const effectiveJson = useMemo(() => { + if (resolved == null) return ''; + try { return JSON.stringify(resolved, null, 2); } catch { return ''; } + }, [resolved]); + return (

- + {usesEnvVars && ( +
+
+ )} - {mode === 'form' - ? setMode('raw')} /> - : ( -