Merge branch 'develop'

This commit is contained in:
Etherpad Release Bot 2026-06-09 08:54:33 +00:00
commit 67f7d914f4
117 changed files with 4277 additions and 1935 deletions

View file

@ -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

View file

@ -1,5 +0,0 @@
[pr_reviewer]
run_on_pr_sync = true
[pr_description]
run_on_pr_sync = true

View file

@ -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:

View file

@ -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 `<html>` 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 `<head>` 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 012 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).

View file

@ -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 `<yourdomainhere>/tests/frontend`.
Front-end tests are found in the `src/tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/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.

View file

@ -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=

View file

@ -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

View file

@ -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"
}
}

View file

@ -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.",

View file

@ -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;

View file

@ -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 () => {

View file

@ -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 (
<div className="settings-mode-toggle" role="tablist" aria-label={t('admin_settings.mode.aria_label')}>
@ -31,6 +35,19 @@ export const ModeToggle = ({ mode, onChange }: Props) => {
>
<Trans i18nKey="admin_settings.mode.raw" />
</button>
{showEffective && (
<button
type="button"
role="tab"
aria-selected={mode === 'effective'}
data-testid="mode-toggle-effective"
className={mode === 'effective' ? 'active' : ''}
onClick={() => onChange('effective')}
title={t('admin_settings.mode.effective_tooltip')}
>
<Trans i18nKey="admin_settings.mode.effective" />
</button>
)}
</div>
);
};

View file

@ -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 = () => {
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<VisuallyHidden asChild>
<Dialog.Title>{t('ep_admin_authors:confirm-dialog-title')}</Dialog.Title>
</VisuallyHidden>
<VisuallyHidden asChild>
<Dialog.Description>{t('ep_admin_authors:confirm-dialog-description')}</Dialog.Description>
</VisuallyHidden>
{dialog.phase === 'loading-preview' && <div>
<Trans i18nKey="ep_admin_authors:loading-preview" ns="ep_admin_authors"/>
</div>}
{(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => {
const p = dialog.preview;
return <div>
<Dialog.Title asChild>
<h3>{t('ep_admin_authors:confirm-preview-title',
{name: p.name || p.authorID})}</h3>
</Dialog.Title>
<h3>{t('ep_admin_authors:confirm-preview-title',
{name: p.name || p.authorID})}</h3>
<p>{t('ep_admin_authors:confirm-preview-counters', {
tokenMappings: p.removedTokenMappings,
externalMappings: p.removedExternalMappings,

View file

@ -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 = () => {
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.delete_pad_dialog_title')}</Dialog.Title></VisuallyHidden>
<Dialog.Description asChild>
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
</Dialog.Description>
<div className="settings-button-bar">
<button onClick={() => setDeleteDialog(false)}><Trans i18nKey="admin_pads.cancel"/></button>
<button onClick={() => { deletePad(padToDelete); setDeleteDialog(false) }}>{t('admin_pads.confirm_button')}</button>
@ -178,7 +182,10 @@ export const PadPage = () => {
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.error_prefix')}</Dialog.Title></VisuallyHidden>
<Dialog.Description asChild>
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
</Dialog.Description>
<div className="settings-button-bar">
<button onClick={() => setErrorText(null)}>{t('admin_pads.confirm_button')}</button>
</div>
@ -191,6 +198,7 @@ export const PadPage = () => {
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
<VisuallyHidden asChild><Dialog.Description>{t('admin_pads.create_pad_dialog_description')}</Dialog.Description></VisuallyHidden>
<form onSubmit={handleSubmit(onPadCreate)}>
<button className="dialog-close-button" type="button" onClick={() => setCreatePadDialogOpen(false)}>×</button>
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>
@ -410,7 +418,7 @@ export const PadPage = () => {
</button>
<button
className="pm-btn pm-btn-primary pm-btn--sm"
onClick={() => window.open(`../../p/${pad.padName}`, '_blank')}
onClick={() => window.open(`../../p/${encodeURIComponent(pad.padName)}`, '_blank', 'noopener,noreferrer')}
>
<Eye size={13}/> <Trans i18nKey="admin_pads.open"/>
</button>

View file

@ -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<Mode>('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<HTMLTextAreaElement>) => {
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 (
<div className="settings-page">
<h1><Trans i18nKey="admin_settings.current" /></h1>
<ModeToggle mode={mode} onChange={setMode} />
{usesEnvVars && (
<div
className="settings-envvar-banner"
role="note"
data-testid="settings-envvar-banner"
>
<Info size={18} aria-hidden="true" />
<div>
<strong><Trans i18nKey="admin_settings.envvar_banner.title" /></strong>
<p><Trans i18nKey="admin_settings.envvar_banner.body" /></p>
</div>
</div>
)}
{mode === 'form'
? <FormView onSwitchToRaw={() => setMode('raw')} />
: (
<textarea
value={settings}
className="settings"
data-testid="settings-raw-textarea"
spellCheck={false}
onKeyDown={handleKeyDown}
onChange={v => useStore.getState().setSettings(v.target.value)}
/>
)
}
<ModeToggle mode={mode} onChange={setMode} showEffective={canShowEffective} />
{mode === 'form' && <FormView onSwitchToRaw={() => setMode('raw')} />}
{mode === 'raw' && (
<textarea
value={settings}
className="settings"
data-testid="settings-raw-textarea"
spellCheck={false}
onKeyDown={handleKeyDown}
onChange={v => useStore.getState().setSettings(v.target.value)}
/>
)}
{mode === 'effective' && (
<textarea
value={effectiveJson}
className="settings"
data-testid="settings-effective-textarea"
spellCheck={false}
readOnly
aria-readonly="true"
/>
)}
<div className="settings-button-bar">
<IconButton
className="settingsButton"
data-testid="save-settings-button"
icon={<Save />}
title={<Trans i18nKey="admin_settings.current_save.value" />}
onClick={handleSave}
/>
<IconButton
className="settingsButton"
data-testid="test-settings-button"
icon={<ShieldCheck />}
title={<Trans i18nKey="admin_settings.current_test.value" />}
onClick={testJSON}
/>
{exposeExperimental && (
<IconButton
className="settingsButton"
data-testid="prettify-settings-button"
icon={<AlignLeft />}
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
onClick={prettifyJSON}
/>
{mode !== 'effective' && (
<>
<IconButton
className="settingsButton"
data-testid="save-settings-button"
icon={<Save />}
title={<Trans i18nKey="admin_settings.current_save.value" />}
onClick={handleSave}
/>
<IconButton
className="settingsButton"
data-testid="test-settings-button"
icon={<ShieldCheck />}
title={<Trans i18nKey="admin_settings.current_test.value" />}
onClick={testJSON}
/>
{exposeExperimental && (
<IconButton
className="settingsButton"
data-testid="prettify-settings-button"
icon={<AlignLeft />}
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
onClick={prettifyJSON}
/>
)}
</>
)}
<IconButton
className="settingsButton"

View file

@ -1,13 +1,18 @@
import {useStore} from "../store/store.ts";
import * as Dialog from '@radix-ui/react-dialog';
import {VisuallyHidden} from '@radix-ui/react-visually-hidden';
import {useTranslation} from 'react-i18next';
import brand from './brand.svg'
export const LoadingScreen = ()=>{
const showLoading = useStore(state => state.showLoading)
const {t} = useTranslation()
return <Dialog.Root open={showLoading}><Dialog.Portal>
<Dialog.Overlay className="loading-screen fixed inset-0 bg-black bg-opacity-50 z-50 dialog-overlay" />
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 dialog-content">
<VisuallyHidden asChild><Dialog.Title>{t('admin.loading')}</Dialog.Title></VisuallyHidden>
<VisuallyHidden asChild><Dialog.Description>{t('admin.loading_description')}</Dialog.Description></VisuallyHidden>
<div className="flex flex-col items-center">
<div className="animate-spin w-16 h-16 border-t-2 border-b-2 border-[--fg-color] rounded-full"></div>
<div className="mt-4 text-[--fg-color]">

View file

@ -1,132 +0,0 @@
# Contributor Guidelines
(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad#get-in-touch))
**We have decided that LLM/Agent/AI contributions are fine as long as they are within the instructions set out by this document.**
## 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
* when preparing your PR, please make sure that you have included the relevant **changes to the documentation** (preferably with usage examples)
* contain meaningful and detailed **commit messages** in the form:
```
submodule: description
longer description of the change you have made, eventually mentioning the
number of the issue that is being fixed, in the form: Fixes #someIssueNumber
```
* if the PR is a **bug fix**:
* The commit that fixes the bug should **include a regression test** that
would fail if the bug fix was reverted. Adding the regression test in the
same commit as the bug fix makes it easier for a reviewer to verify that the
test is appropriate for the bug fix.
* If there is a bug report, **the pull request description should include the
text "`Fixes #xxx`"** so that the bug report is auto-closed when the PR is
merged. It is less useful to say the same thing in a commit message because
GitHub will spam the bug report every time the commit is rebased, and
because a bug number alone becomes meaningless in forks. (A full URL would
be better, but ideally each commit is readable on its own without the need
to examine an external reference to understand motivation or context.)
* think about stability: code has to be backwards compatible as much as possible. Always **assume your code will be run with an older version of the DB/config file**
* if you want to remove a feature, **deprecate it instead**:
* write an issue with your deprecation plan
* output a `WARN` in the log informing that the feature is going to be removed
* remove the feature in the next version
* if you want to add a new feature, put it under a **feature flag**:
* once the new feature has reached a minimal level of stability, do a PR for it, so it can be integrated early
* expose a mechanism for enabling/disabling the feature
* the new feature should be **disabled** by default. With the feature disabled, the code path should be exactly the same as before your contribution. This is a __necessary condition__ for early integration
* think of the PR not as something that __you wrote__, but as something that __someone else is going to read__. The commit series in the PR should tell a novice developer the story of your thoughts when developing it
## How to write a bug report
* Please be polite, we all are humans and problems can occur.
* Please add as much information as possible, for example
* client os(s) and version(s)
* browser(s) and version(s), is the problem reproducible on different clients
* special environments like firewalls or antivirus
* host os and version
* npm and nodejs version
* Logfiles if available
* steps to reproduce
* what you expected to happen
* what actually happened
* Please format logfiles and code examples with markdown see github Markdown help below the issue textarea for more information.
If you send logfiles, please set the loglevel switch DEBUG in your settings.json file:
```
/* The log level we are using, can be: DEBUG, INFO, WARN, ERROR */
"loglevel": "DEBUG",
```
The logfile location is defined in startup script or the log is directly shown in the commandline after you have started etherpad.
## General goals of Etherpad
To make sure everybody is going in the same direction:
* easy to install for admins and easy to use for people
* easy to integrate into other apps, but also usable as standalone
* lightweight and scalable
* extensible, as much functionality should be extendable with plugins so changes don't have to be done in core.
Also, keep it maintainable. We don't wanna end up as the monster Etherpad was!
## How to work with git?
* Don't work in your master branch.
* Make a new branch for every feature you're working on. (This ensures that you can work you can do lots of small, independent pull requests instead of one big one with complete different features)
* Don't use the online edit function of github (this only creates ugly and not working commits!)
* Try to make clean commits that are easy readable (including descriptive commit messages!)
* Test before you push. Sounds easy, it isn't!
* Don't check in stuff that gets generated during build or runtime
* Make small pull requests that are easy to review but make sure they do add value by themselves / individually
## Coding style
* Do write comments. (You don't have to comment every line, but if you come up with something that's a bit complex/weird, just leave a comment. Bear in mind that you will probably leave the project at some point and that other people will read your code. Undocumented huge amounts of code are worthless!)
* Never ever use tabs
* Indentation: 2 spaces
* Don't overengineer. Don't try to solve any possible problem in one step, but try to solve problems as easy as possible and improve the solution over time!
* Do generalize sooner or later! (if an old solution, quickly hacked together, poses more problems than it solves today, refactor it!)
* Keep it compatible. Do not introduce changes to the public API, db schema or configurations too lightly. Don't make incompatible changes without good reasons!
* If you do make changes, document them! (see below)
* Use protocol independent urls "//"
## Branching model / git workflow
see git flow http://nvie.com/posts/a-successful-git-branching-model/
### `master` branch
* the stable
* This is the branch everyone should use for production stuff
### `develop`branch
* everything that is READY to go into master at some point in time
* This stuff is tested and ready to go out
### release branches
* stuff that should go into master very soon
* only bugfixes go into these (see http://nvie.com/posts/a-successful-git-branching-model/ for why)
* we should not be blocking new features to develop, just because we feel that we should be releasing it to master soon. This is the situation that release branches solve/handle.
### hotfix branches
* fixes for bugs in master
### feature branches (in your own repos)
* these are the branches where you develop your features in
* If it's ready to go out, it will be merged into develop
Over the time we pull features from feature branches into the develop branch. Every month we pull from develop into master. Bugs in master get fixed in hotfix branches. These branches will get merged into master AND develop. There should never be commits in master that aren't in develop
## Documentation
The docs are in the `doc/` folder in the git repository, so people can easily find the suitable docs for the current git revision.
Documentation should be kept up-to-date. This means, whenever you add a new API method, add a new hook or change the database model, pack the relevant changes to the docs in the same pull request.
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 `<yourdomainhere>/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.

View file

@ -4,9 +4,9 @@
* Compact every pad on the instance to reclaim database space.
*
* Usage:
* node bin/compactAllPads.js # collapse all history on every pad
* node bin/compactAllPads.js --keep N # keep last N revisions per pad
* node bin/compactAllPads.js --dry-run # list pads + rev counts, no writes
* pnpm run --filter bin compactAllPads # collapse all history on every pad
* pnpm run --filter bin compactAllPads --keep N # keep last N revisions per pad
* pnpm run --filter bin compactAllPads --dry-run # list pads + rev counts, no writes
*
* Composes the existing `listAllPads` and `compactPad` HTTP APIs there is
* deliberately no instance-wide HTTP endpoint, because doing this over a
@ -170,9 +170,9 @@ export const parseArgs = (argv: string[]): CompactAllOpts | null => {
// so the test harness can use `runCompactAll` directly without network.
const usage = () => {
console.error('Usage:');
console.error(' node bin/compactAllPads.js');
console.error(' node bin/compactAllPads.js --keep <N>');
console.error(' node bin/compactAllPads.js --dry-run');
console.error(' pnpm run --filter bin compactAllPads');
console.error(' pnpm run --filter bin compactAllPads --keep <N>');
console.error(' pnpm run --filter bin compactAllPads --dry-run');
process.exit(2);
};

View file

@ -4,8 +4,8 @@
* Compact a pad's revision history to reclaim database space.
*
* Usage:
* node bin/compactPad.js <padID> # collapse all history
* node bin/compactPad.js <padID> --keep N # keep only the last N revisions
* pnpm run --filter bin compactPad <padID> # collapse all history
* pnpm run --filter bin compactPad <padID> --keep N # keep only the last N revisions
*
* Wraps the existing Cleanup helper (src/node/utils/Cleanup.ts) via the
* compactPad HTTP API so admins can trigger it from the CLI without
@ -41,8 +41,8 @@ const apiPost = async (p: string): Promise<any> => {
const usage = () => {
console.error('Usage:');
console.error(' node bin/compactPad.js <padID>');
console.error(' node bin/compactPad.js <padID> --keep <N>');
console.error(' pnpm run --filter bin compactPad <padID>');
console.error(' pnpm run --filter bin compactPad <padID> --keep <N>');
process.exit(2);
};

View file

@ -4,9 +4,9 @@
* Compact every pad on the instance that has not been edited recently.
*
* Usage:
* node bin/compactStalePads.js --older-than 90 # collapse history on pads not edited in 90 days
* node bin/compactStalePads.js --older-than 90 --keep 50 # keep last 50 revisions
* node bin/compactStalePads.js --older-than 90 --dry-run # list, don't write
* pnpm run --filter bin compactStalePads --older-than 90 # collapse history on pads not edited in 90 days
* pnpm run --filter bin compactStalePads --older-than 90 --keep 50 # keep last 50 revisions
* pnpm run --filter bin compactStalePads --older-than 90 --dry-run # list, don't write
*
* Composes `listAllPads` `getLastEdited` `compactPad`. Same shape as
* `bin/compactAllPads` (per-pad error tolerance, dry-run, tally), but
@ -255,9 +255,9 @@ export const parseArgs = (argv: string[]): CompactStaleOpts | null => {
const usage = () => {
console.error('Usage:');
console.error(' node bin/compactStalePads.js --older-than <days>');
console.error(' node bin/compactStalePads.js --older-than <days> --keep <N>');
console.error(' node bin/compactStalePads.js --older-than <days> --dry-run');
console.error(' pnpm run --filter bin compactStalePads --older-than <days>');
console.error(' pnpm run --filter bin compactStalePads --older-than <days> --keep <N>');
console.error(' pnpm run --filter bin compactStalePads --older-than <days> --dry-run');
process.exit(2);
};

View file

@ -1,203 +0,0 @@
#!/bin/bash
#
# WARNING: since Etherpad 1.7.0 (2018-08-17), this script is DEPRECATED, and
# will be removed/modified in a future version.
# It's left here just for documentation.
# The branching policies for releases have been changed.
#
# This script is used to publish a new release/version of etherpad on github
#
# Work that is done by this script:
# ETHER_REPO:
# - Add text to CHANGELOG.md
# - Replace version of etherpad in src/package.json
# - Create a release branch and push it to github
# - Merges this release branch into master branch
# - Creating the windows build and the docs
# ETHER_WEB_REPO:
# - Creating a new branch with the docs and the windows build
# - Replacing the version numbers in the index.html
# - Push this branch and merge it to master
# ETHER_REPO:
# - Create a new release on github
printf "WARNING: since Etherpad 1.7.0 this script is DEPRECATED, and will be removed/modified in a future version.\n\n"
while true; do
read -p "Do you want to continue? This is discouraged. [y/N]" yn
case $yn in
[Yy]* ) break;;
[Nn]* ) exit;;
* ) printf "Please answer yes or no.\n\n";;
esac
done
ETHER_REPO="https://github.com/ether/etherpad.git"
ETHER_WEB_REPO="https://github.com/ether/ether.github.com.git"
TMP_DIR="/tmp/"
echo "WARNING: You can only run this script if your github api token is allowed to create and merge branches on $ETHER_REPO and $ETHER_WEB_REPO."
echo "This script automatically changes the version number in package.json and adds a text to CHANGELOG.md."
echo "When you use this script you should be in the branch that you want to release (develop probably) on latest version. Any changes that are currently not committed will be committed."
echo "-----"
# Get the latest version
LATEST_GIT_TAG=$(git tag | tail -n 1)
# Current environment
echo "Current environment: "
echo "- branch: $(git branch | grep '* ')"
echo "- last commit date: $(git show --quiet --pretty=format:%ad)"
echo "- current version: $LATEST_GIT_TAG"
echo "- temp dir: $TMP_DIR"
# Get new version number
# format: x.x.x
echo -n "Enter new version (x.x.x): "
read VERSION
# Get the message for the changelogs
read -p "Enter new changelog entries (press enter): "
tmp=$(mktemp)
"${EDITOR:-vi}" $tmp
changelogText=$(<$tmp)
echo "$changelogText"
rm $tmp
if [ "$changelogText" != "" ]; then
changelogText="# $VERSION\n$changelogText"
fi
# get the token for the github api
echo -n "Enter your github api token: "
read API_TOKEN
function check_api_token {
echo "Checking if github api token is valid..."
CURL_RESPONSE=$(curl --silent -i https://api.github.com/user?access_token=$API_TOKEN | iconv -f utf8)
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
[[ $HTTP_STATUS != "200" ]] && echo "Aborting: Invalid github api token" && exit 1
}
function modify_files {
# Add changelog text to first line of CHANGELOG.md
msg=""
# source: https://unix.stackexchange.com/questions/9784/how-can-i-read-line-by-line-from-a-variable-in-bash#9789
while IFS= read -r line
do
# replace newlines with literal "\n" for using with sed
msg+="$line\n"
done < <(printf '%s\n' "${changelogText}")
sed -i "1s/^/${msg}\n/" CHANGELOG.md
[[ $? != 0 ]] && echo "Aborting: Error modifying CHANGELOG.md" && exit 1
# Replace version number of etherpad in package.json
sed -i -r "s/(\"version\"[ ]*: \").*(\")/\1$VERSION\2/" src/package.json
[[ $? != 0 ]] && echo "Aborting: Error modifying package.json" && exit 1
}
function create_release_branch {
echo "Creating new release branch..."
git rev-parse --verify release/$VERSION 2>/dev/null
if [ $? == 0 ]; then
echo "Aborting: Release branch already present"
exit 1
fi
git checkout -b release/$VERSION
[[ $? != 0 ]] && echo "Aborting: Error creating release branch" && exit 1
echo "Committing CHANGELOG.md and package.json"
git add CHANGELOG.md
git add src/package.json
git commit -m "Release version $VERSION"
echo "Pushing release branch to github..."
git push -u $ETHER_REPO release/$VERSION
[[ $? != 0 ]] && echo "Aborting: Error pushing release branch to github" && exit 1
}
function merge_release_branch {
echo "Merging release to master branch on github..."
API_JSON=$(printf '{"base": "master","head": "release/%s","commit_message": "Merge new release into master branch!"}' $VERSION)
CURL_RESPONSE=$(curl --silent -i -N --data "$API_JSON" https://api.github.com/repos/ether/etherpad-lite/merges?access_token=$API_TOKEN | iconv -f utf8)
echo $CURL_RESPONSE
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
[[ $HTTP_STATUS != "200" ]] && echo "Aborting: Error merging release branch on github" && exit 1
}
function create_builds {
echo "Cloning etherpad-lite repo and ether.github.com repo..."
cd $TMP_DIR
rm -rf etherpad-lite ether.github.com
git clone $ETHER_REPO --branch master
git clone $ETHER_WEB_REPO
echo "Creating windows build..."
cd etherpad-lite
bin/buildForWindows.sh
[[ $? != 0 ]] && echo "Aborting: Error creating build for windows" && exit 1
echo "Creating docs..."
make docs
[[ $? != 0 ]] && echo "Aborting: Error generating docs" && exit 1
}
function push_builds {
cd $TMP_DIR/etherpad-lite/
echo "Copying windows build and docs to website repo..."
GIT_SHA=$(git rev-parse HEAD | cut -c1-10)
mv etherpad-win.zip $TMP_DIR/ether.github.com/downloads/etherpad-win-$VERSION-$GIT_SHA.zip
mv out/doc $TMP_DIR/ether.github.com/doc/v$VERSION
cd $TMP_DIR/ether.github.com/
sed -i "s/etherpad-win.*\.zip/etherpad-win-$VERSION-$GIT_SHA.zip/" index.html
sed -i "s/$LATEST_GIT_TAG/$VERSION/g" index.html
git checkout -b release_$VERSION
[[ $? != 0 ]] && echo "Aborting: Error creating new release branch" && exit 1
git add doc/
git add downloads/
git commit -a -m "Release version $VERSION"
git push -u $ETHER_WEB_REPO release_$VERSION
[[ $? != 0 ]] && echo "Aborting: Error pushing release branch to github" && exit 1
}
function merge_web_branch {
echo "Merging release to master branch on github..."
API_JSON=$(printf '{"base": "master","head": "release_%s","commit_message": "Release version %s"}' $VERSION $VERSION)
CURL_RESPONSE=$(curl --silent -i -N --data "$API_JSON" https://api.github.com/repos/ether/ether.github.com/merges?access_token=$API_TOKEN | iconv -f utf8)
echo $CURL_RESPONSE
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
[[ $HTTP_STATUS != "200" ]] && echo "Aborting: Error merging release branch" && exit 1
}
function publish_release {
echo -n "Do you want to publish a new release on github (y/n)? "
read PUBLISH_RELEASE
if [ $PUBLISH_RELEASE = "y" ]; then
# create a new release on github
API_JSON=$(printf '{"tag_name": "%s","target_commitish": "master","name": "Release %s","body": "%s","draft": false,"prerelease": false}' $VERSION $VERSION $changelogText)
CURL_RESPONSE=$(curl --silent -i -N --data "$API_JSON" https://api.github.com/repos/ether/etherpad-lite/releases?access_token=$API_TOKEN | iconv -f utf8)
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
[[ $HTTP_STATUS != "201" ]] && echo "Aborting: Error publishing release on github" && exit 1
else
echo "No release published on github!"
fi
}
function todo_notification {
echo "Release procedure was successful, but you have to do some steps manually:"
echo "- Update the wiki at https://github.com/ether/etherpad/wiki"
echo "- Create a pull request on github to merge the master branch back to develop"
echo "- Announce the new release on the mailing list, blog.etherpad.org and Twitter"
}
# Call functions
check_api_token
modify_files
create_release_branch
merge_release_branch
create_builds
push_builds
merge_web_branch
publish_release
todo_notification

View file

@ -1,6 +1,6 @@
{
"name": "bin",
"version": "3.2.0",
"version": "3.3.0",
"description": "",
"main": "checkAllPads.js",
"directories": {
@ -9,12 +9,12 @@
"dependencies": {
"ep_etherpad-lite": "workspace:../src",
"log4js": "^6.9.1",
"semver": "^7.8.1",
"tsx": "^4.22.3",
"ueberdb2": "^6.1.2"
"semver": "^7.8.2",
"tsx": "^4.22.4",
"ueberdb2": "^6.1.8"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@types/node": "^25.9.2",
"@types/semver": "^7.7.1",
"typescript": "^6.0.3"
},
@ -24,6 +24,7 @@
"checkAllPads": "node --import tsx checkAllPads.ts",
"compactPad": "node --import tsx compactPad.ts",
"compactAllPads": "node --import tsx compactAllPads.ts",
"compactStalePads": "node --import tsx compactStalePads.ts",
"createUserSession": "node --import tsx createUserSession.ts",
"deletePad": "node --import tsx deletePad.ts",
"repairPad": "node --import tsx repairPad.ts",

View file

@ -26,6 +26,7 @@ export default defineConfig({
text: 'About',
items: [
{ text: 'Docker', link: '/docker.md' },
{ text: 'Configuration', link: '/configuration.md' },
{ text: 'Localization', link: '/localization.md' },
{ text: 'Cookies', link: '/cookies.md' },
{ text: 'Plugins', link: '/plugins.md' },

View file

@ -29,13 +29,22 @@ In `settings.json`:
"requireSignature": false,
"trustedKeysPath": null
},
"adminEmail": null
"adminEmail": null,
// SMTP transport for the admin notification emails. host=null keeps
// log-only behaviour ("(would send email)"); set host+from to deliver.
"mail": {
"host": null,
"port": 587,
"secure": false,
"from": null,
"auth": null
}
}
```
| Setting | Default | Notes |
| --- | --- | --- |
| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. Higher tiers are silently downgraded if the install method does not allow them. PR 1 only honors `"notify"` and `"off"`. |
| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. All tiers are implemented. Higher tiers are silently downgraded if the install method does not allow them (only `"git"` installs can run the write tiers `manual` / `auto` / `autonomous`). |
| `updates.source` | `"github"` | Reserved for future alternative sources. Only `"github"` is implemented. |
| `updates.channel` | `"stable"` | Reserved. Stable releases only. |
| `updates.installMethod` | `"auto"` | One of `"auto"`, `"git"`, `"docker"`, `"npm"`, `"managed"`. Auto-detects via filesystem heuristics. Set explicitly to override. |
@ -49,6 +58,11 @@ In `settings.json`:
| `updates.requireSignature` | `false` | When `true`, refuse updates whose tag is not signed by a trusted key. Verification is done via `git verify-tag <tag>` against the user's GPG keyring. Default `false` because Etherpad's release process does not yet sign tags consistently — turning the check on by default would block every Tier 2 update. Set `true` if you run your own builds or have imported a fork's keys. |
| `updates.trustedKeysPath` | `null` | Override the keyring location passed to `git verify-tag` via the `$GNUPGHOME` env var. Useful when the trusted keys live in a dedicated keyring outside the Etherpad user's home. Only meaningful when `requireSignature: true`. |
| `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
| `mail.host` | `null` | Top-level SMTP host. **`null` keeps log-only behaviour** — notifications are logged as `(would send email)` and never delivered. Set a host (and `mail.from`) to deliver over SMTP via nodemailer. The `nodemailer` dependency is lazy-loaded, so installs that leave `mail.host` unset pay no runtime cost. |
| `mail.port` | `587` | SMTP port. |
| `mail.secure` | `false` | `true` for an implicit-TLS connection (typically port 465); `false` uses STARTTLS upgrade when offered. |
| `mail.from` | `null` | Envelope/From address. **Required for delivery** — if `mail.from` is unset (even with a host) the updater falls back to log-only `(would send email)`. |
| `mail.auth` | `null` | SMTP credentials object `{ "user": "...", "pass": "..." }`, passed through to nodemailer. Leave `null` for unauthenticated relays. |
## What "outdated" means
@ -56,14 +70,26 @@ In `settings.json`:
## Email cadence (when `adminEmail` is set)
These are the "nudge" emails sent by the periodic checker when the instance is behind:
| Trigger | First send | Repeat |
| --- | --- | --- |
| Outdated (minor or more behind) detected | Immediate | Monthly while still outdated |
| Outdated (minor or more behind) detected | Immediate | Every 30 days while still outdated (`SEVERE_INTERVAL`) |
| Up to date | No email | — |
The write tiers also email about **apply outcomes** so admins learn about failures without watching the UI:
| Outcome | When | Dedupe |
| --- | --- | --- |
| `update-preflight-failed` | An auto/autonomous apply was blocked at preflight (e.g. `node-engine-mismatch`, dirty tree, low disk). Subject: *Auto-update to `<tag>` blocked at preflight*. | Deduped on `<outcome>:<targetTag>` — one email per outcome per target tag. |
| `update-rolled-back` | An apply failed mid-flow and Etherpad auto-recovered to the previous version. Subject: *Auto-update to `<tag>` rolled back*. | Deduped on `<outcome>:<targetTag>`. |
| `update-rollback-failed` | **Terminal.** The apply failed *and* the rollback failed — manual intervention required. Subject: *Auto-update FAILED and could not be rolled back — manual intervention required*. | **Always sends**, bypassing dedupe, because the admin must learn about it even if a transient failure shared the same key. |
A different outcome or a different target tag resets the dedupe key and fires a fresh email. Manual (Tier 2) failures surface in the admin UI banner; the outcome emails are tied to the auto/autonomous flows.
If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side notice still work without it.
PR 1 ships the cadence machinery but does not yet wire a real SMTP transport — emails are logged with `(would send email)` until a future PR adds the transport. The dedupe state still advances correctly so admins are not bombarded once SMTP is wired.
SMTP delivery is wired via [nodemailer](https://nodemailer.com/) (lazy-loaded). When `mail.host` and `mail.from` are both set, emails are delivered over SMTP. When either is unset the updater falls back to logging each message as `(would send email)` — the dedupe state still advances correctly, so admins are not bombarded once SMTP is configured. An SMTP send failure is caught and logged (`email send failed: …`) and never disrupts the updater state machine.
## Pad-side notice
@ -93,7 +119,7 @@ The version check sends no telemetry. Etherpad fetches the public GitHub Release
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only `"git"` is initially supported for write tiers.
Every install method gets the Tier 1 banner. The install method gates whether the write tiers (manual click, auto, autonomous) can run: only `"git"` installs are supported for the write tiers — other methods are silently downgraded to notify.
## Tier 2 — manual click
@ -110,7 +136,7 @@ Etherpad applies an update by **exiting with code 75** so a process supervisor r
### What clicking "Apply update" does
1. **Lock acquire**`var/update.lock` (PID-based, stale locks reaped automatically).
2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, target tag exists at the configured remote, signature verifies (if `requireSignature: true`). On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back.
2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, no lock held, target tag exists at the configured remote, signature verifies (if `requireSignature: true`), and the target's Node engine matches the running Node. The Node-engine check runs *after* signature verification (so the `engines.node` range comes from a trusted tag): Etherpad reads `engines.node` from the target tag's `package.json` via `git show <tag>:package.json` and refuses the update via `semver.satisfies` if the running Node does not satisfy it. On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back.
3. **Drain**`drainSeconds` window during which T-60 / T-30 / T-10 announcements broadcast to every connected pad and new socket connections are refused. Click **Cancel** during this window to abort cleanly.
4. **Execute**`git fetch --tags origin`, `git checkout <tag>`, `pnpm install --frozen-lockfile`, `pnpm run build:ui`. Output streams to `var/log/update.log` (rotated 10 MB × 5).
5. **Exit 75** — the supervisor restarts on the new version.
@ -121,6 +147,7 @@ Etherpad applies an update by **exiting with code 75** so a process supervisor r
| What went wrong | Resulting state | Admin action |
| --- | --- | --- |
| Pre-flight check fails | `preflight-failed` | Click **Acknowledge** after fixing the underlying issue (free up disk, clean working tree, etc.). |
| Target tag requires a newer (or different) Node than the one running | `preflight-failed` (reason `node-engine-mismatch`) | Fails cleanly at preflight with a detail like *"target requires Node >=X, running Y"*. No drain, no `git checkout`, no restart, nothing to roll back — the install is untouched. Upgrade Node to a version that satisfies the target's `engines.node`, then **Acknowledge** and retry. |
| `git fetch` / `git checkout` fails mid-flow | `rolled-back` | Informational. The working tree is back where it started; click **Acknowledge** to clear. |
| `pnpm install` or `pnpm run build:ui` fails | `rolled-back` | Same as above. The lockfile and SHA are restored. |
| `/health` doesn't come up within `rollbackHealthCheckSeconds` | `rolled-back` | Same — RollbackHandler restores the previous SHA + lockfile and exits 75 again. |
@ -181,7 +208,7 @@ A single `grace-start` notification fires per scheduled tag:
> [Etherpad] Auto-update scheduled for 2.7.2
with the `scheduledFor` timestamp. Etherpad core does not yet wire SMTP; the message logs as `(would send email)` until a future PR adds a transport. Cadence and dedupe still update correctly.
with the `scheduledFor` timestamp. Delivery follows the same SMTP path as every other notification: when `mail.host` and `mail.from` are set the message is sent via nodemailer, otherwise it logs as `(would send email)`. Cadence and dedupe update correctly either way.
The right way to give docker admins an in-product Apply button is to delegate to the orchestrator rather than mutate the container. Two patterns to consider in a follow-up PR:

View file

@ -465,6 +465,20 @@ also use this to handle existing types.
`collab_client.js` has a pretty extensive list of message types, if you want to
take a look.
## handleClientTimesliderMessage_`name`
Called from: `src/static/js/broadcast.ts`
Things in context:
1. payload - the data that got sent with the message (use it for custom message
content)
This is the timeslider analog of `handleClientMessage_name`. It gets called
every time the timeslider receives a message of type `name`. Use it to handle
custom message types (or react to existing ones) while a user is viewing the
timeslider rather than editing the pad.
## aceStartLineAndCharForPoint-aceEndLineAndCharForPoint
Called from: src/static/js/ace2_inner.js
@ -496,6 +510,34 @@ Things in context:
This hook is provided to allow a plugin to handle key events.
The return value should be true if you have handled the event.
## acePaste
Called from: `src/static/js/ace2_inner.ts`
Things in context:
1. editorInfo - information about the user who is making the change
2. rep - information about where the change is being made
3. documentAttributeManager - information about attributes in the document
4. e - the fired paste event
This hook is called when content is pasted into the editor, before Etherpad
processes the pasted content. Use it to inspect or react to paste events.
## aceDrop
Called from: `src/static/js/ace2_inner.ts`
Things in context:
1. editorInfo - information about the user who is making the change
2. rep - information about where the change is being made
3. documentAttributeManager - information about attributes in the document
4. e - the fired drop event
This hook is called when content is dropped into the editor via drag-and-drop.
Use it to inspect or react to drop events.
## collectContentLineText
Called from: `src/static/js/contentcollector.js`

View file

@ -856,6 +856,39 @@ exports.exportHTMLAdditionalContent = async (hookName, {padId}) => {
};
```
## exportHTMLSend
Called from: `src/node/handler/ExportHandler.ts`
Things in context:
1. html - the full HTML body of the pad that is about to be sent to the client
as an HTML export.
This hook is invoked via `aCallFirst` for HTML exports, just before the HTML is
sent in the response. It lets a plugin rewrite or replace the exported HTML
body. Return the modified HTML string; if a non-empty value is returned, it
replaces the HTML that would otherwise be sent.
## exportConvert
Called from: `src/node/handler/ExportHandler.ts`
Things in context:
1. srcFile - the path to the source file (the intermediate file Etherpad has
produced) that needs to be converted.
2. destFile - the path to the destination file Etherpad expects the converted
output to be written to.
3. req - the express request object.
4. res - the express response object.
This hook is invoked via `aCallAll` for non-HTML export formats. It lets a
plugin handle the export format conversion itself instead of letting Etherpad
fall back to its bundled LibreOffice converter. If any plugin returns a value
(making the hook result non-empty), Etherpad assumes the conversion was handled
by the plugin and skips the built-in converter.
## stylesForExport
Called from: `src/node/utils/ExportHtml.js`
@ -1101,3 +1134,66 @@ Context properties:
value with the user's actual author ID before this hook runs).
* `padId`: The pad's real (not read-only) identifier.
* `pad`: The pad's Pad object.
## ccRegisterBlockElements
Called from: `src/node/handler/ImportHandler.ts`,
`src/node/utils/ImportEtherpad.ts`, and `src/static/js/contentcollector.ts`
Things in context: None
This is the **server-side companion** to the client-only
`aceRegisterBlockElements` hook (see the client-side hooks documentation), and
it is the half that plugin authors commonly forget to implement. Registering a
block element only on the client means imports and server-side content
collection do not treat your element as block-level.
The return value of this hook should be an array of tag names. Those tags are
added to the set of block-level elements that the content collector and the
import path (`.etherpad` and HTML/document imports) recognise, so the
collected/imported content is segmented into the correct lines. Plugins that
declare block elements via `aceRegisterBlockElements` should declare the same
elements here too.
```js
exports.ccRegisterBlockElements = () => ['pre', 'blockquote'];
```
## createServer
Called from: `src/node/server.ts`
Things in context: None
Invoked via `aCallAll` once during Etherpad startup, after the HTTP server has
been created. Use it to run any plugin initialisation that needs to happen when
the server comes up.
## restartServer
Called from: `src/node/hooks/express/adminsettings.ts` (and the plugin
installer)
Things in context: None
Invoked via `aCallAll` when the server is restarted from the admin interface
(for example after settings are saved or a plugin is installed/uninstalled). Use
it to re-initialise plugin state that needs to survive an admin-triggered
restart.
## clientReady
> **Deprecated:** use the `userJoin` hook instead. This hook is fired with an
> awkward context (the raw `CLIENT_READY` message rather than a structured set
> of context properties) and is kept only for backwards compatibility.
Called from: `src/node/handler/PadMessageHandler.ts`
Things in context:
1. message - the raw `CLIENT_READY` message sent by the client as it joins a
pad.
Invoked via `aCallAll` while a client is joining a pad, before the author and
session are fully set up. Because the context is just the raw client message,
new plugins should use `userJoin` instead.

View file

@ -306,6 +306,18 @@ Returns the Author Name of the author
-> can't be deleted cause this would involve scanning all the pads where this author was
#### anonymizeAuthor(authorID)
* API >= 1.3.1
Erases an author's identity across all pads they contributed to (GDPR Article 17, the "right to erasure"). The author's name and external/token mappings are removed and their chat messages are cleared, so the author can no longer be re-identified from pad data.
This endpoint is **disabled by default** and is gated on `gdprAuthorErasure.enabled = true` in `settings.json`. If GDPR author erasure is not enabled, the call returns an error and performs no changes. See [doc/privacy.md](../privacy.md) for the privacy/data-retention context.
*Example returns:*
* `{code: 0, message:"ok", data: {affectedPads: 3, removedTokenMappings: 1, removedExternalMappings: 1, clearedChatMessages: 7}}`
* `{code: 1, message:"anonymizeAuthor is disabled — set gdprAuthorErasure.enabled = true in settings.json to enable GDPR Art. 17 erasure", data: null}`
* `{code: 1, message:"authorID is required", data: null}`
### Session
Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. Only users with a valid session for this group, can access group pads. You can create a session after you authenticated the user at your web application, to give them access to the pads. You should save the sessionID of this session and delete it after the user logged out.
@ -609,7 +621,7 @@ token is ignored.
* `{code: 1, message:"invalid deletionToken", data: null}`
#### copyPad(sourceID, destinationID[, force=false])
* API >= 1.2.8
* API >= 1.2.9
copies a pad with full history and chat. If force is true and the destination pad exists, it will be overwritten.
@ -629,7 +641,7 @@ Note that all the revisions will be lost! In most of the cases one should use `c
* `{code: 1, message:"padID does not exist", data: null}`
#### movePad(sourceID, destinationID[, force=false])
* API >= 1.2.8
* API >= 1.2.9
moves a pad. If force is true and the destination pad exists, it will be overwritten.
@ -646,7 +658,7 @@ collapses the pad's revision history to reclaim database space (issue #6194). Wr
When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions.
Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first via `getEtherpad` if you need a backup.**
Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first (for example via the `/p/:padID/export/etherpad` export endpoint) if you need a full-history backup.**
*Example returns:*
* `{code: 0, message:"ok", data: {ok: true, mode: "all"}}`
@ -664,10 +676,10 @@ returns the read only link of a pad
* `{code: 0, message:"ok", data: {readOnlyID: "r.s8oes9dhwrvt0zif"}}`
* `{code: 1, message:"padID does not exist", data: null}`
#### getPadID(readOnlyID)
#### getPadID(roID)
* API >= 1.2.10
returns the id of a pad which is assigned to the readOnlyID
returns the id of the pad mapped to the given read-only id. The query parameter is named `roID` (the read-only id returned by `getReadOnlyID`).
*Example returns:*
* `{code: 0, message:"ok", data: {padID: "p.s8oes9dhwrvt0zif"}}`

View file

@ -1,68 +1,151 @@
# CLI
You can find different tools for migrating things, checking your Etherpad health in the bin directory.
One of these is the migrateDB command. It takes two settings.json files and copies data from one source to another one.
In this example we migrate from the old dirty db to the new rustydb engine. So we copy these files to the root of the etherpad-directory.
Etherpad ships a set of operator tools in the `bin/` directory for migrating
data, reclaiming database space, repairing damaged pads, managing sessions and
managing plugins. They are TypeScript scripts run through `tsx`, and each one
is registered as a pnpm script in `bin/package.json`. Invoke them from the
Etherpad root with:
```
pnpm run --filter bin <script> [args]
```
For example `pnpm run --filter bin checkPad my-pad`. Running the `.ts` files
directly with `node bin/foo.js` will **not** work — there are no compiled `.js`
files and the scripts need the `tsx` loader.
## Running vs. stopped
Some tools talk to a **running** Etherpad over its HTTP API (they read the API
key from `APIKEY.txt` and connect to `ip`/`port` from `settings.json`). Others
open the database **directly** and must be run while Etherpad is **stopped**,
otherwise you risk a database lock or a corrupt write. Each tool below is
labelled accordingly.
## Database migration (`migrateDB`)
`migrateDB` copies every record from one database to another. It takes two
settings files — `--file1` is the **source**, `--file2` is the **target**
each describing a database with a `dbType` and `dbSettings`. Both paths are
resolved relative to the Etherpad root.
In this example we migrate from the old `dirty` db to the new `rustydb` engine.
Create a source descriptor `source.json` in the Etherpad root:
````json
{
"dbType": "dirty",
"dbSettings": {
"filename": "./var/dirty.db"
}
}
````
and a target descriptor `target.json`:
````json
{
"dbType": "rustydb",
"dbSettings": {
"filename": "./var/rusty.db"
}
}
````
Then run:
```
pnpm run --filter bin migrateDB --file1 source.json --file2 target.json
```
````json
{
"dbType": "rustydb",
"dbSettings": {
"filename": "./var/rusty2.db"
}
}
````
After that we need to move the data from dirty to rustydb.
Therefore, we call `pnpm run --filter bin migrateDB --file1 test1.json --file2 test2.json` with these two files in our root directories. After some time the data should be copied over to the new database.
After some time the data is copied over to the new database. Run this with
Etherpad **stopped**.
## Pad compaction
Long-lived pads with heavy edit history accumulate revisions in the database. Three CLIs reclaim that space, in increasing scope:
Long-lived pads with heavy edit history accumulate revisions in the database.
Three CLIs reclaim that space, in increasing scope. All of them drive a
**running** Etherpad over the `compactPad` HTTP API.
| Tool | Targets | When to use |
| --- | --- | --- |
| `bin/compactPad.js <padID>` | one pad | you know which pad is fat |
| `bin/compactAllPads.js` | every pad | bulk reclaim across the whole instance |
| `bin/compactStalePads.js --older-than N` | pads not edited in N days | reclaim the cold tail without touching pads still in active use |
| `pnpm run --filter bin compactPad <padID>` | one pad | you know which pad is fat |
| `pnpm run --filter bin compactAllPads` | every pad | bulk reclaim across the whole instance |
| `pnpm run --filter bin compactStalePads --older-than N` | pads not edited in N days | reclaim the cold tail without touching pads still in active use |
All three are gated on `cleanup.enabled = true` in `settings.json` and are **destructive**: history is collapsed (or trimmed). Export anything you can't afford to lose with `getEtherpad` first.
All three are gated on `cleanup.enabled = true` in `settings.json` and are
**destructive**: history is collapsed (or trimmed). Export anything you can't
afford to lose with the `getEtherpad` API first.
Common flags:
- `--keep N` — retain the last N revisions instead of collapsing all history.
- `--dry-run` — list pads and revision counts without writing.
- `--dry-run` — list pads and revision counts without writing (`compactAllPads`
and `compactStalePads` only).
- `--older-than N` — (`compactStalePads` only, **required**) only consider pads
not edited in the last N days.
### Examples
````
# Compact a specific pad, collapsing all history.
node bin/compactPad.js my-pad
pnpm run --filter bin compactPad my-pad
# Keep only the last 50 revisions of one pad.
node bin/compactPad.js my-pad --keep 50
pnpm run --filter bin compactPad my-pad --keep 50
# Compact every pad on the instance (per-pad failures don't stop the run).
node bin/compactAllPads.js
node bin/compactAllPads.js --dry-run
pnpm run --filter bin compactAllPads
pnpm run --filter bin compactAllPads --dry-run
# Compact only pads not edited in the last 90 days, keeping the last 50 revisions.
node bin/compactStalePads.js --older-than 90 --keep 50
node bin/compactStalePads.js --older-than 90 --dry-run
pnpm run --filter bin compactStalePads --older-than 90 --keep 50
pnpm run --filter bin compactStalePads --older-than 90 --dry-run
````
`bin/compactStalePads.js` is the right tool for periodic operator runs on long-lived instances — hot pads that users are still navigating in timeslider stay untouched, and only the cold tail is rewritten. Per-pad failures (including a `getLastEdited` fault) are counted but do not abort the bulk run; the exit code reflects whether anything failed.
`compactStalePads` is the right tool for periodic operator runs on long-lived
instances — hot pads that users are still navigating in timeslider stay
untouched (staleness is even re-checked right before each compaction), and only
the cold tail is rewritten. Per-pad failures (including a `getLastEdited` fault)
are counted but do not abort the bulk run; the exit code reflects whether
anything failed.
See the `compactPad` HTTP API in `doc/api/http_api.md` for the same primitive over the wire (issues #6194, #7642).
See the `compactPad` HTTP API in `doc/api/http_api.md` for the same primitive
over the wire (issues #6194, #7642).
## Pad maintenance and debugging
| Tool | Purpose | Args | Etherpad |
| --- | --- | --- | --- |
| `pnpm run --filter bin checkPad <padID>` | Check one pad's revisions for data corruption. | `<padID>` | stopped |
| `pnpm run --filter bin checkAllPads` | Check every pad on the instance for data corruption. | none | stopped |
| `pnpm run --filter bin repairPad <padID>` | Repair a pad by extracting all of its data, deleting it and re-inserting it. | `<padID>` | stopped (the script refuses to be useful otherwise) |
| `pnpm run --filter bin rebuildPad <padID> <rev> [newPadID]` | Rebuild a damaged pad into a new pad at a known-good revision. The new pad defaults to `<padID>-rebuilt` and must not already exist. | `<padID> <rev> [newPadID]` | stopped |
| `node --import tsx extractPadData.ts <padID>` | Export one pad's data to a `<padID>.db` dirtyDB file so a bug can be reproduced in a dev environment. (No pnpm alias — run with the `tsx` loader directly from `bin/`.) | `<padID>` | stopped |
`checkPad`/`checkAllPads` report corruption but do not modify anything;
`repairPad` and `rebuildPad` write. As always, back up first.
## Database tools
| Tool | Purpose | Args | Etherpad |
| --- | --- | --- | --- |
| `pnpm run --filter bin migrateDB --file1 <src.json> --file2 <dst.json>` | Copy all records from a source database to a target database (see above). | `--file1 <src.json> --file2 <dst.json>` | stopped |
| `pnpm run --filter bin migrateDirtyDBtoRealDB` | One-shot migration of `var/dirty.db` into the real database configured in `settings.json`. Back up `dirty.db` first; may need more memory (e.g. `node --max-old-space-size=4096`). | none (reads target db from `settings.json`) | stopped |
| `pnpm run --filter bin importSqlFile <sqlFile>` | Import a SQL dump (rows of `REPLACE INTO store VALUES (...)`) into the configured database. | `<sqlFile>` | stopped |
## Session and pad management
| Tool | Purpose | Args | Etherpad |
| --- | --- | --- | --- |
| `pnpm run --filter bin deletePad <padID>` | Delete a single pad. | `<padID>` | running |
| `pnpm run --filter bin deleteAllGroupSessions` | Delete all group sessions (useful when a misconfiguration has wedged group access). | none | running |
| `pnpm run --filter bin createUserSession` | Create a throwaway group, pad, author and session, printing a `sessionID` you can set as a cookie — handy for debugging session-based configs. | none | running |
## Plugin tools
| Tool | Purpose | Args | Etherpad |
| --- | --- | --- | --- |
| `pnpm run --filter bin plugins <action> [names…]` | Manage installed plugins. Actions: `i`/`install`, `rm`/`remove`, `ls`/`list`, `up`/`update`. Install also accepts `--path <dir>` and `--github <repo>` sources. | `<action> [names…]` | stopped |
| `pnpm run --filter bin checkPlugin <ep_name> [autofix\|autocommit\|autopush]` | Lint a plugin checkout (a sibling `../ep_name` directory) against the plugin conventions; optional modes auto-fix, commit, or commit+push+publish (the last is dangerous). | `<ep_name> [mode]` | n/a |
| `pnpm run --filter bin stalePlugins` | List plugins in the registry not updated in over two years, with maintainer email. Requires `privacy.pluginCatalog` enabled. | none | n/a |

146
doc/configuration.md Normal file
View file

@ -0,0 +1,146 @@
# Configuration
This page explains how Etherpad is configured and documents the
reverse-proxy and subpath behaviour in detail. It is **not** an exhaustive list
of every setting — for that, see the fully-commented
[`settings.json.template`](https://github.com/ether/etherpad/blob/develop/settings.json.template),
which is the authoritative reference.
## Where settings live
Etherpad reads its configuration from `settings.json` in the installation root.
A new install copies `settings.json.template` to `settings.json` on first run.
* **Override the file location** by passing the `-s` / `--settings` flag to
the launcher, e.g. `bin/run.sh -s /etc/etherpad/settings.json`. This lets
you run multiple instances from one installation. (`bin/run.sh` forwards the
flag to `pnpm run prod`, which is the supported entrypoint — there is no
`server.js`; the runtime is `src/node/server.ts`, loaded via `tsx`.)
* **Environment-variable substitution** — any string value may reference an
environment variable using the syntax `"${ENV_VAR}"` or
`"${ENV_VAR:default}"`. The variable name **must** be quoted, even when the
resolved value is a number or boolean. A few rules worth remembering:
* `"${PORT:9001}"` → the value of `PORT`, or `9001` if unset.
* `"${MINIFY:true}"` → the boolean `true`/`false`, not the string.
* `"${UNSET_VAR:null}"``null`; `"${UNSET_VAR:}"` → the empty string.
* Substitution happens at load time, in memory only — env vars never
overwrite `settings.json` on disk.
When running in Docker, almost every setting is wired to an environment
variable in the shipped `settings.json.docker`. See the
[Docker page](/docker.md) for the full env-var list.
## Trusting a reverse proxy
If Etherpad runs behind NGINX, Traefik, HAProxy, a Kubernetes ingress, or any
other reverse proxy, set:
```json
"trustProxy": true
```
This makes Etherpad trust the standard `X-Forwarded-*` headers, so it:
* uses the real client IP (from `X-Forwarded-For`) in logs and rate limits
instead of the proxy's IP;
* respects the forwarded protocol and host, so the `secure` flag is set on
cookies when the proxy terminates TLS (required for `SameSite=None`).
Leave it at the default `false` when Etherpad is reachable directly on a public
IP — otherwise any client could forge these headers.
## Running under a subpath / ingress
Etherpad can be served under a URL-path prefix (for example
`https://example.com/etherpad/`) without recompiling anything. The prefix is
discovered per-request from upstream headers, so the same Etherpad process works
whether it is mounted at the root or under a path.
Three headers are checked, **in this order**; the first non-empty value (after
sanitization) wins:
| Order | Header | Origin | Requires `trustProxy: true`? |
| ----- | ------ | ------ | ---------------------------- |
| 1 | `x-proxy-path` | Etherpad's own convention | No — always honoured |
| 2 | `X-Forwarded-Prefix` | HAProxy / Traefik / Spring | Yes |
| 3 | `X-Ingress-Path` | Kubernetes / Home Assistant ingress | Yes |
`x-proxy-path` is always honoured because an operator must deliberately
configure their proxy to send Etherpad's custom header. The two standard
headers (`X-Forwarded-Prefix`, `X-Ingress-Path`) are honoured **only when
`trustProxy` is `true`**, because otherwise a client on a public IP could forge
them.
Once detected, the prefix is woven into the responses that would otherwise
break under a subpath:
* `manifest.json` (PWA install metadata);
* the social-media meta tags (`og:url` / `og:image`), unless an explicit
`publicURL` is configured;
* the bootstrap script entrypoint and the asset / reconnect links in the pad,
index, and timeslider pages.
### Sanitization
The header value is treated as untrusted input even when read from a trusted
header, because it ends up inside HTML, JS, CSS, and HTTP `Location` headers.
The sanitizer (`src/node/utils/sanitizeProxyPath.ts`):
* strips every character outside `[A-Za-z0-9_./-]`;
* collapses a leading `//+` to a single `/`, so the value can never be read as
a protocol-relative URL;
* prepends `/` if the result doesn't already start with one;
* **rejects** any value containing a `..` path segment (returns empty).
The output is therefore always either empty, or a string that starts with
exactly one `/` and contains only `[A-Za-z0-9_./-]`.
### Example: Traefik
```yaml
http:
middlewares:
etherpad-prefix:
stripPrefix:
prefixes:
- "/etherpad"
etherpad-headers:
headers:
customRequestHeaders:
X-Forwarded-Prefix: "/etherpad"
```
Apply both middlewares to the router and set `trustProxy: true` in
`settings.json`.
### Example: NGINX
```nginx
location /etherpad/ {
proxy_pass http://127.0.0.1:9001/;
proxy_set_header X-Proxy-Path /etherpad;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
Here `X-Proxy-Path` is used, which works regardless of `trustProxy`. Use
`X-Forwarded-Prefix` instead if you prefer the standard header (and set
`trustProxy: true`).
## Self-update, email, database, and metrics
These areas have their own pages:
* **Self-update** and **outbound email** (`adminEmail`, `mail.*` SMTP) —
see [Updates](/admin/updates.md). The corresponding Docker env vars
(`MAIL_HOST`, `MAIL_FROM`, …) are listed on the [Docker page](/docker.md).
* **Database** — choose a backend with `dbType` / `dbSettings`. The
supported drivers and example settings are documented in
[`settings.json.template`](https://github.com/ether/etherpad-lite/blob/develop/settings.json.template),
and the Docker equivalents (`DB_TYPE`, `DB_HOST`, …) are listed on the
[Docker page](/docker.md). The on-disk keyspace layout is described in
[`doc/database.adoc`](https://github.com/ether/etherpad-lite/blob/develop/doc/database.adoc).
* **Metrics** — Etherpad exposes Prometheus-compatible metrics; see
[Stats](/stats.md).

View file

@ -29,6 +29,37 @@ Edit `<BASEDIR>/settings.json.docker` at your will. When rebuilding the image, t
**Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. For details, refer to `settings.json.template`.
### How `settings.json` and environment variables interact
This trips people up often enough that it's worth calling out explicitly (see [#7819](https://github.com/ether/etherpad/issues/7819)):
* `settings.json` inside the container is a **template** containing `${VAR:default}` placeholders.
* Environment variable substitution happens at **load time, in memory only** — env vars never overwrite `settings.json` on disk.
* `docker exec <container> cat /opt/etherpad-lite/settings.json` will therefore always show the *templated* file (e.g. `"port": "${PORT:9001}"`), regardless of what `PORT` is set to in your environment. The resolved value is what Etherpad uses at runtime; the file is unchanged.
* The admin /settings page also reads this file directly, so the raw view shows placeholders too. The page now surfaces a banner and an "Effective" tab that displays the in-memory resolved values when placeholders are present.
### Persisting admin /settings edits across container recreates
`settings.json` lives in the container's writable layer by default. That means:
| Operation | Effect on `settings.json` |
|------------------------------------------|------------------------------------------|
| `docker restart` | Preserved (writable layer is reused) |
| `docker compose restart` | Preserved |
| `docker compose down && docker compose up` | **Reset** to the image template |
| `docker compose pull && docker compose up` | **Reset** to the new image template |
| Watchtower / image auto-update | **Reset** to the new image template |
| `docker rm` + `docker run` | **Reset** to the image template |
If you intend to edit `settings.json` through the admin UI (rather than relying solely on env vars), mount the file from the host so edits survive container recreate:
```yaml
volumes:
- ./settings.json:/opt/etherpad-lite/settings.json
```
(Bootstrap by copying `settings.json.docker` to `./settings.json` on the host before the first `up`.) The default compose example below ships this line commented out — uncomment it if you need persistent on-disk edits.
### Rebuilding including some plugins
If you want to install some plugins in your container, it is sufficient to list them in the ETHERPAD_PLUGINS build variable.
The variable value has to be a space separated, double quoted list of plugin names (see examples).
@ -81,6 +112,8 @@ The `settings.json.docker` available by default allows to control almost every s
| `DEFAULT_PAD_TEXT` | The default text of a pad | `Welcome to Etherpad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! Get involved with Etherpad at https://etherpad.org` |
| `IP` | IP which etherpad should bind at. Change to `::` for IPv6 | `0.0.0.0` |
| `PORT` | port which etherpad should bind at | `9001` |
| `PUBLIC_URL` | Canonical public origin of this instance, e.g. `https://pad.example.com` (no trailing slash, must include scheme). Used to build absolute URLs in link-preview meta tags. When `null`, falls back to the incoming request's protocol+Host. | `null` |
| `ENABLE_DARK_MODE` | Respect the end user's browser dark-mode preference. When enabled this overrides the admin-configured skin variants and skin name for that user. | `true` |
| `ADMIN_PASSWORD` | the password for the `admin` user (leave unspecified if you do not want to create it) | |
| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
@ -179,6 +212,31 @@ For the editor container, you can also make it full width by adding `full-width-
| `DISABLE_IP_LOGGING` | Privacy: disable IP logging | `false` |
### Email (SMTP)
SMTP transport used by the self-updater and admin notifications. When `MAIL_HOST` is `null` (the default) Etherpad keeps log-only behaviour and sends no real mail; set `MAIL_HOST` and `MAIL_FROM` to send via nodemailer.
| Variable | Description | Default |
| ------------- | ------------------------------------------------------------------------------------------- | ------- |
| `MAIL_HOST` | SMTP server hostname. Leave `null` to keep log-only behaviour (no outbound mail). | `null` |
| `MAIL_PORT` | SMTP server port. | `587` |
| `MAIL_SECURE` | Use a secure (TLS) connection to the SMTP server. | `false` |
| `MAIL_FROM` | The `From` address used on outbound mail. Required (together with `MAIL_HOST`) to send mail. | `null` |
### Privacy banner
Optional privacy banner shown to users. See `settings.json.template` for full field docs.
| Variable | Description | Default |
| ------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `PRIVACY_BANNER_ENABLED` | Show the privacy banner. | `false` |
| `PRIVACY_BANNER_TITLE` | Banner title. | `Privacy notice` |
| `PRIVACY_BANNER_BODY` | Banner body text. | `This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.` |
| `PRIVACY_BANNER_LEARN_MORE_URL` | Optional URL for a "learn more" link in the banner. | `null` |
| `PRIVACY_BANNER_DISMISSAL` | Banner dismissal behaviour, e.g. `dismissible`. | `dismissible` |
### Advanced
| Variable | Description | Default |
@ -187,6 +245,10 @@ For the editor container, you can also make it full width by adding `full-width-
| `COOKIE_SESSION_LIFETIME` | How long (ms) a user can be away before they must log in again. | `864000000` (10 days) |
| `COOKIE_SESSION_REFRESH_INTERVAL` | How often (ms) to write the latest cookie expiration time. | `86400000` (1 day) |
| `SHOW_SETTINGS_IN_ADMIN_PAGE` | hide/show the settings.json in admin page | `true` |
| `AUTHENTICATION_METHOD` | Authentication method used by the server. Use `sso` for the built-in OpenID Connect provider, or `apikey` for the legacy API-key authentication system. | `sso` |
| `ENABLE_METRICS` | Enable the Prometheus metrics endpoint used by monitoring plugins to collect metrics about Etherpad. Disable if you do not use any monitoring plugins. | `true` |
| `ENABLE_PAD_WIDE_SETTINGS` | Enable creator-owned pad-wide settings and new-pad default seeding from My View. The pad creator gets a "Pad-wide Settings" section to set/enforce defaults; other users see only their own view options. Set to `false` for the legacy single-settings behavior. | `true` |
| `GDPR_AUTHOR_ERASURE_ENABLED` | Enable the GDPR Art. 17 author anonymize/erasure REST endpoint and admin UI. Enable only when an operator process exists to authorise erasure requests. | `false` |
| `TRUST_PROXY` | set to `true` if you are using a reverse proxy in front of Etherpad (for example: Traefik for SSL termination via Let's Encrypt). This will affect security and correctness of the logs if not done | `false` |
| `IMPORT_MAX_FILE_SIZE` | maximum allowed file size when importing a pad, in bytes. | `52428800` (50 MB) |
| `IMPORT_EXPORT_MAX_REQ_PER_IP` | maximum number of import/export calls per IP. | `10` |
@ -208,7 +270,7 @@ For the editor container, you can also make it full width by adding `full-width-
| `FOCUS_LINE_PERCENTAGE_ARROW_UP` | Percentage of viewport height to be additionally scrolled when user presses arrow up in the line of the top of the viewport. Set to 0 to let the scroll to be handled as default by Etherpad | `0` |
| `FOCUS_LINE_DURATION` | Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation | `0` |
| `FOCUS_LINE_CARET_SCROLL` | Flag to control if it should scroll when user places the caret in the last line of the viewport | `false` |
| `SOCKETIO_MAX_HTTP_BUFFER_SIZE` | The maximum size (in bytes) of a single message accepted via Socket.IO. If a client sends a larger message, its connection gets closed to prevent DoS (memory exhaustion) attacks. | `50000` |
| `SOCKETIO_MAX_HTTP_BUFFER_SIZE` | The maximum size (in bytes) of a single message accepted via Socket.IO. If a client sends a larger message, its connection gets closed to prevent DoS (memory exhaustion) attacks. Larger values allow bigger pastes. | `1000000` (1 MB) |
| `LOAD_TEST` | Allow Load Testing tools to hit the Etherpad Instance. WARNING: this will disable security on the instance. | `false` |
| `DUMP_ON_UNCLEAN_EXIT` | Enable dumping objects preventing a clean exit of Node.js. WARNING: this has a significant performance impact. | `false` |
| `EXPOSE_VERSION` | Expose Etherpad version in the web interface and in the Server http header. Do not enable on production machines. | `false` |
@ -282,6 +344,13 @@ services:
volumes:
- plugins:/opt/etherpad-lite/src/plugin_packages
- etherpad-var:/opt/etherpad-lite/var
# OPTIONAL: persist admin /settings edits across container recreates.
# Without this mount, settings.json lives in the image's writable
# layer — `docker compose restart` preserves it, but `docker compose
# down && up`, `pull`, or watchtower reverts it to the image
# template. Uncomment if you intend to edit settings.json through
# the /admin UI. See https://github.com/ether/etherpad/issues/7819.
# - ./settings.json:/opt/etherpad-lite/settings.json
depends_on:
- postgres
environment:

View file

@ -13,6 +13,9 @@ hero:
- theme: brand
text: Install
link: /docker
- theme: alt
text: Configuration
link: /configuration
- theme: alt
text: API documentation
link: /api/

View file

@ -58,8 +58,22 @@ alert ('Chat');
```
to:
```js
alert(window._('pad.chat'));
alert(window.html10n.get('pad.chat'));
```
> **Note:** Etherpad core sets up a `window._` gettext-like shortcut as
> `window._ = html10n.get`. Because this assignment does **not** bind `this`,
> calling the shortcut bare as `window._('pad.chat')` loses its `this` context
> and returns `undefined` (the `get` implementation reads `this.translations`).
> Prefer one of the patterns that actually works:
>
> * Call the method on the object directly: `window.html10n.get('pad.chat')`.
> * Bind it once, then reuse: `const _ = window.html10n.get.bind(window.html10n);`
> followed by `_('pad.chat')`.
>
> Wherever possible, prefer marking up your templates with `data-l10n-id`
> attributes (see above) instead of translating strings in JavaScript at all —
> html10n applies those automatically.
### 2. Create translate files in the locales directory of your plugin
* The name of the file must be the language code of the language it contains translations for (see [supported lang codes](https://joker-x.github.com/languages4translatewiki/test/); e.g. en ? English, es ? Spanish...)

View file

@ -1,6 +1,6 @@
{
"devDependencies": {
"oxc-minify": "^0.132.0",
"oxc-minify": "^0.135.0",
"vitepress": "^2.0.0-alpha.17"
},
"scripts": {

View file

@ -267,15 +267,16 @@ below.
### Runtime flag
The passthrough is gated by `settings.enablePluginPadOptions`, default
`false`. Operators must opt in via `settings.json`:
`true`. Operators who want to lock plugins out of pad-wide state can flip
it in `settings.json`:
```json
{
"enablePluginPadOptions": true
"enablePluginPadOptions": false
}
```
When enabled, the server reflects the value to every client via
When enabled (the default), the server reflects the value to every client via
`clientVars.enablePluginPadOptions` so plugins can detect both *capable*
(static) and *active* (per-pad request) at the same point.

View file

@ -20,7 +20,7 @@ and the URL to use if you embed the timeslider in your own page.
You can choose a skin changing the parameter `skinName` in `settings.json`.
Since Etherpad **1.7.5**, two skins are included:
Two skins are included:
* `no-skin`: an empty skin, leaving the default Etherpad appearance unchanged, that you can use as guidance to develop your own.
* `colibris`: a new, experimental skin, that will become the default in Etherpad 2.0.
* `colibris`: the current default skin, used by Etherpad out of the box. This is what you see in a standard installation.
* `no-skin`: an unstyled base skin that leaves the default Etherpad appearance unchanged. Use it as a starting point and guidance to develop your own skin.

View file

@ -1,18 +1,147 @@
# Statistics
Etherpad keeps track of the goings-on inside the edit machinery. If you'd like to have a look at this, just point your browser to `/stats`.
# Statistics and metrics
We currently measure:
Etherpad tracks runtime statistics about the edit machinery, the database
layer, and the Node.js process, and can expose them over HTTP for monitoring.
- totalUsers (counter)
- connects (meter)
- disconnects (meter)
- pendingEdits (counter)
- edits (timer)
- failedChangesets (meter)
- httpRequests (timer)
- http500 (meter)
- memoryUsage (gauge)
There are two endpoints:
Under the hood, we are happy to rely on [measured](https://github.com/felixge/node-measured) for all our metrics needs.
- `GET /stats` — a JSON dump of the internal `measured-core` collection.
- `GET /stats/prometheus` — the same kind of data (plus process/runtime
metrics) in the [Prometheus](https://prometheus.io/) text exposition format.
To modify or simply access our stats in your plugin, simply `require('ep_etherpad-lite/stats')` which is a [`measured.Collection`](https://yaorg.github.io/node-measured/packages/measured-core/Collection.html).
## Enabling the endpoints
Both endpoints are gated behind the `enableMetrics` setting, which defaults to
`true`:
```json
{
"enableMetrics": true
}
```
When `enableMetrics` is `false` the routes are **not registered at all** — a
request to `/stats` or `/stats/prometheus` returns the normal 404 handling, not
an empty response. The admin-panel statistics view is unaffected by this
setting.
## `GET /stats` (JSON)
Returns the current snapshot of the `measured-core` collection as JSON. The
following metrics are collected:
| Metric | Type | Meaning |
| --- | --- | --- |
| `totalUsers` | gauge | Number of users currently connected across all pads. |
| `activePads` | gauge | Number of pads with at least one connected user. |
| `connects` | meter | Rate of new client connections. |
| `disconnects` | meter | Rate of client disconnections. |
| `rateLimited` | meter | Rate of messages dropped by the per-connection rate limiter. |
| `pendingEdits` | counter | Edits received but not yet fully processed. |
| `edits` | timer | Time taken to process an incoming `USER_CHANGES` edit (full handler span). |
| `failedChangesets` | meter | Rate of changesets that failed to apply. |
| `httpRequests` | timer | Duration of HTTP requests served by Express. |
| `http500` | meter | Rate of HTTP 500 responses. |
| `memoryUsage` | gauge | Process resident set size (`process.memoryUsage().rss`). |
| `memoryUsageHeap` | gauge | Process heap usage (`process.memoryUsage().heapUsed`). |
| `lastDisconnect` | gauge | Timestamp (ms) of the most recent socket disconnect. |
| `ueberdb_*` | gauge | One gauge per [ueberDB](https://github.com/ether/ueberDB) database statistic, e.g. read/write counts and timings (`ueberdb_reads`, `ueberdb_writes`, …). The exact set depends on the configured database driver. |
Under the hood these are provided by
[`measured-core`](https://github.com/yaorg/node-measured/tree/master/packages/measured-core).
To read or extend them from a plugin, require the shared collection:
```js
const stats = require('ep_etherpad-lite/node/stats');
// stats is a measured-core Collection
stats.counter('my_plugin_events').inc();
console.log(stats.toJSON());
```
## `GET /stats/prometheus` (Prometheus exposition format)
Served from a dedicated [`prom-client`](https://github.com/siimon/prom-client)
registry. This is the endpoint you point a Prometheus scraper at.
### Metrics exposed by default
| Metric | Type | Labels | Meaning |
| --- | --- | --- | --- |
| `etherpad_total_users` | gauge | — | Total number of connected users. |
| `etherpad_active_pads` | gauge | — | Total number of active pads. |
| `ueberdb_stats` | gauge | `type` | ueberDB statistics, one series per numeric ueberDB metric (the metric name is carried in the `type` label). |
In addition, the registry calls `prom-client`'s `collectDefaultMetrics()`, so
the standard Node.js / process metrics are also exposed, including (names as
emitted by `prom-client`):
- `process_cpu_user_seconds_total`, `process_cpu_system_seconds_total`,
`process_cpu_seconds_total`
- `process_resident_memory_bytes`, `process_heap_bytes`,
`process_virtual_memory_bytes`
- `process_open_fds`, `process_max_fds`
- `process_start_time_seconds`
- `nodejs_eventloop_lag_seconds` and the `nodejs_eventloop_lag_*` family
- `nodejs_active_handles`, `nodejs_active_requests`, `nodejs_active_resources`
(and their `_total` variants)
- `nodejs_heap_size_total_bytes`, `nodejs_heap_size_used_bytes`,
`nodejs_external_memory_bytes`, `nodejs_heap_space_size_*_bytes`
- `nodejs_gc_duration_seconds`
- `nodejs_version_info`
The exact default-metric set is determined by `prom-client` and the Node.js
version, not by Etherpad.
### Opt-in scaling-dive metrics (`scalingDiveMetrics`)
A second, more detailed instrument set was added for the scaling investigation
(PR #7756). It is gated behind the `scalingDiveMetrics` setting, which defaults
to `false`:
```json
{
"scalingDiveMetrics": false
}
```
When the flag is off, these metrics are never registered and their recording
helpers short-circuit to no-ops, so production deployments pay nothing for the
instrumentation. When enabled, the following are added to the
`/stats/prometheus` output:
| Metric | Type | Labels | Meaning |
| --- | --- | --- | --- |
| `etherpad_changeset_apply_duration_seconds` | histogram | — | Time spent applying an incoming `USER_CHANGES` message on the server (apply path only; excludes fan-out to other clients). Buckets: 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 5 seconds. |
| `etherpad_socket_emits_total` | counter | `type` | Number of socket.io broadcast emits, bucketed by message type. |
| `etherpad_pad_users` | gauge | `padId` | Active users connected to each pad, keyed by pad id. |
**Cardinality caution.** The scaling-dive metrics carry high-cardinality
labels:
- `etherpad_pad_users` adds one time series **per active pad** (`padId`
label). On instances with many pads this can produce a large number of
series; stale labels are reset on each scrape so drained pads drop out.
- `etherpad_socket_emits_total` uses the `type` label. To keep cardinality
bounded, only a fixed allowlist of known message types is reported; any
other (or missing) type is rolled into a single `other` bucket, so a
misbehaving plugin or API caller cannot explode the label space.
Enable `scalingDiveMetrics` for targeted load-testing or capacity
investigations, not as a permanent production default.
## Scraping with Prometheus
Add a scrape job pointing at the `/stats/prometheus` endpoint, for example:
```yaml
scrape_configs:
- job_name: etherpad
metrics_path: /stats/prometheus
static_configs:
- targets: ['localhost:9001']
```
Make sure `enableMetrics` is `true` (the default) so the endpoint exists. If
your instance is reachable from untrusted networks, restrict access to
`/stats` and `/stats/prometheus` at your reverse proxy, since they expose
operational details about the deployment.

View file

@ -7,15 +7,25 @@ services:
volumes:
- plugins:/opt/etherpad-lite/src/plugin_packages
- etherpad-var:/opt/etherpad-lite/var
# OPTIONAL: persist admin /settings edits across container recreates.
# Without this mount, settings.json lives in the image's writable
# layer — `docker compose restart` preserves it, but `docker compose
# down && up`, `pull`, or watchtower reverts it to the image
# template. Uncomment if you intend to edit settings.json through
# the /admin UI instead of (or in addition to) env vars. See
# https://github.com/ether/etherpad/issues/7819.
# - ./settings.json:/opt/etherpad-lite/settings.json
depends_on:
- postgres
environment:
NODE_ENV: production
ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin}
# Required — set a strong value (e.g. in .env). No fallback, so misconfig
# surfaces at `docker compose up` rather than at runtime.
ADMIN_PASSWORD: "${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:?Set DOCKER_COMPOSE_APP_ADMIN_PASSWORD to a strong value}"
DB_CHARSET: ${DOCKER_COMPOSE_APP_DB_CHARSET:-utf8mb4}
DB_HOST: postgres
DB_NAME: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}
DB_PASS: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}
DB_PASS: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}"
DB_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}
DB_TYPE: "postgres"
DB_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
@ -23,7 +33,9 @@ services:
DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEFAULT_PAD_TEXT:- }
DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DISABLE_IP_LOGGING:-false}
SOFFICE: ${DOCKER_COMPOSE_APP_SOFFICE:-null}
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-true}
# Default off: only enable when actually behind a trusted reverse proxy
# that sets the X-Forwarded-* headers.
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-false}
restart: always
ports:
- "${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}"
@ -32,7 +44,7 @@ services:
image: postgres:15-alpine
environment:
POSTGRES_DB: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}
POSTGRES_PASSWORD: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}
POSTGRES_PASSWORD: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}"
POSTGRES_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}
POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
PGDATA: /var/lib/postgresql/data/pgdata

View file

@ -197,7 +197,7 @@ GET /admin/openapi.json (CORS: *)
The route is gated by `settings.adminOpenAPI.enabled`, **default `false`**,
per the project's "new features behind a flag, off by default" policy
(CONTRIBUTING.md, AGENTS.MD, best_practices.md). When the flag is off,
(CONTRIBUTING.md, AGENTS.MD). When the flag is off,
`expressPreSession` returns early and the route is dormant.
When enabled, the route registers in `expressPreSession`, which runs before

View file

@ -51,7 +51,7 @@
"url": "https://github.com/ether/etherpad.git"
},
"engineStrict": true,
"version": "3.2.0",
"version": "3.3.0",
"license": "Apache-2.0",
"pnpm": {
"onlyBuiltDependencies": [

1824
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -853,11 +853,11 @@
* accepting pad-wide values under plugin-namespaced keys matching
* /^ep_[a-z0-9_]+$/ (e.g. ep_table_of_contents). Values are validated
* (JSON-safe, 64 KB per key, 256 KB total) and broadcast to every
* connected client just like native pad-wide toggles. Disabled by
* default; flip to true once your plugins (e.g. ep_plugin_helpers'
* padToggle) require it. See doc/plugins.md.
* connected client just like native pad-wide toggles. Enabled by
* default; set to false to lock plugins out of pad-wide state. See
* doc/plugins.md.
**/
"enablePluginPadOptions": "${ENABLE_PLUGIN_PAD_OPTIONS:false}",
"enablePluginPadOptions": "${ENABLE_PLUGIN_PAD_OPTIONS:true}",
/*
* Optional privacy banner shown once the pad loads. Disabled by default.

View file

@ -7,6 +7,7 @@
"Haytham morsy",
"Meno25",
"Mido",
"Mohammed Qays",
"Shbib Al-Subaie",
"Tala Ali",
"Test Create account",
@ -57,7 +58,7 @@
"index.transferSessionNow": "نقل الجلسة الآن",
"index.copyLink": "2. نسخ الرابط",
"index.copyLinkDescription": "انقر على الزر أدناه لنسخ الرابط إلى الحافظة الخاصة بك.",
"index.copyLinkButton": سخ الرابط إلى الحافظة",
"index.copyLinkButton": ُسخت الوصلة إلى الحافظة",
"index.transferToSystem": "3. نسخ الجلسة إلى النظام الجديد",
"index.transferToSystemDescription": "افتح الرابط المنسوخ في المتصفح أو الجهاز المستهدف لنقل جلستك.",
"index.transferSessionDescription": "انقل جلستك الحالية إلى المتصفح أو الجهاز بالنقر على الزر أدناه. سيؤدي هذا إلى نسخ رابط لصفحة ستنقل جلستك عند فتحها في المتصفح أو الجهاز المستهدف.",

View file

@ -12,6 +12,7 @@
]
},
"admin.page-title": "Panoyê İdarekari - Etherpad",
"admin_pads.error_dialog_description": "Yew xeta biye.",
"admin_plugins": "Gıredayışê raverberi",
"admin_plugins.available": "Mewcud Dekerdeki",
"admin_plugins.available_not-found": "Dekerdek nevineya",
@ -45,6 +46,7 @@
"admin_settings.current_save.value": "Eyaran qeyd ke",
"admin_settings.page-title": "Eyari - Etherpad",
"index.newPad": "Bloknoto newe",
"index.code": "Kod",
"index.createOpenPad": "ya zi be nê nameyi ra yew bloknot vıraze/ake:",
"index.openPad": "yew Padê biyayeyi be nê nameyi ra ake:",
"pad.toolbar.bold.title": "Qalınd (Ctrl-B)",

View file

@ -1,6 +1,7 @@
{
"admin.page-title": "Admin Dashboard - Etherpad",
"admin.loading": "Loading…",
"admin.loading_description": "Please wait while the page is loading.",
"admin.toggle_sidebar": "Toggle sidebar",
"admin.shout": "Communication",
"admin_shout.online_one": "There is currently {{count}} user online",
@ -20,7 +21,11 @@
"admin_pads.col.revisions": "Revisions",
"admin_pads.col.users": "Users",
"admin_pads.confirm_button": "OK",
"admin_pads.create_pad_dialog_description": "Choose a name for the new pad.",
"admin_pads.delete_pad_dialog_description": "Confirm or cancel pad deletion.",
"admin_pads.delete_pad_dialog_title": "Delete pad",
"admin_pads.empty_never_edited": "empty · never edited",
"admin_pads.error_dialog_description": "An error has occurred.",
"admin_pads.error_prefix": "Error",
"admin_pads.filter.active": "Active",
"admin_pads.filter.all": "All",
@ -132,7 +137,12 @@
"admin_settings.prettify_confirm": "Prettifying will remove all comments. Continue?",
"admin_settings.mode.form": "Form",
"admin_settings.mode.raw": "Raw",
"admin_settings.mode.effective": "Effective",
"admin_settings.mode.effective_tooltip": "Read-only view of the values Etherpad is actually using right now, after environment-variable substitution. Secrets are redacted.",
"admin_settings.mode.aria_label": "Editor mode",
"admin_settings.envvar_banner.title": "This file is a template, not the live config.",
"admin_settings.envvar_banner.body": "Placeholders like ${VAR:default} are substituted into memory at startup; they are never written back to this file. Edit env vars in your environment (Docker compose, systemd, .env) to change the resolved value, or replace the placeholder here with a literal. Switch to the Effective tab to see what Etherpad is using right now.",
"admin_settings.toast.auth_error": "You are not authenticated as admin. Please log in again.",
"admin_settings.section.general": "General",
"admin_settings.parse_error.title": "Cannot parse settings.json",
"admin_settings.parse_error.cta": "Switch to raw to edit",
@ -217,6 +227,7 @@
"index.copyLinkButton": "Copy link to clipboard",
"index.transferToSystem": "3. Copy session to new system",
"index.transferToSystemDescription": "Open the copied link in the target browser or device to transfer your session.",
"index.code": "Code",
"index.transferSessionDescription": "Transfer your current session to browser or device by clicking the button below. This will copy a link to a page that will transfer your session when opened in the target browser or device.",
"index.createOpenPad": "Open pad by name",
"index.openPad": "open an existing Pad with the name:",

View file

@ -6,6 +6,7 @@
},
"admin.page-title": "Painéal Riaracháin - Etherpad",
"admin.loading": "Ag lódáil…",
"admin.loading_description": "Fan go fóill fad atá an leathanach á lódáil.",
"admin.toggle_sidebar": "Scoránaigh an taobhbharra",
"admin.shout": "Cumarsáid",
"admin_shout.online_one": "Tá {{count}} úsáideoir ar líne faoi láthair",
@ -25,7 +26,11 @@
"admin_pads.col.revisions": "Athbhreithnithe",
"admin_pads.col.users": "Úsáideoirí",
"admin_pads.confirm_button": "Ceart go leor",
"admin_pads.create_pad_dialog_description": "Roghnaigh ainm don phainéal nua.",
"admin_pads.delete_pad_dialog_description": "Deimhnigh nó cealaigh scriosadh an eochaircheap.",
"admin_pads.delete_pad_dialog_title": "Scrios an eochaircheap",
"admin_pads.empty_never_edited": "folamh · níor cuireadh in eagar riamh",
"admin_pads.error_dialog_description": "Tharla earráid.",
"admin_pads.error_prefix": "Earráid",
"admin_pads.filter.active": "Gníomhach",
"admin_pads.filter.all": "Gach",
@ -137,7 +142,12 @@
"admin_settings.prettify_confirm": "Bainfear na tráchtanna go léir má dhéantar áilleachtú. Leanfaidh tú ar aghaidh?",
"admin_settings.mode.form": "Foirm",
"admin_settings.mode.raw": "Amh",
"admin_settings.mode.effective": "Éifeachtach",
"admin_settings.mode.effective_tooltip": "Radharc inléite amháin ar na luachanna atá á n-úsáid ag Etherpad faoi láthair, tar éis athsholáthar athróg timpeallachta. Tá rúin curtha in eagar.",
"admin_settings.mode.aria_label": "Mód eagarthóireachta",
"admin_settings.envvar_banner.title": "Is teimpléad an comhad seo, ní an chumraíocht bheo.",
"admin_settings.envvar_banner.body": "Cuirtear áitchoimeádaithe cosúil le ${VAR:default} isteach sa chuimhne ag an am tosaithe; ní scríobhtar ar ais chuig an gcomhad seo iad choíche. Cuir na cineálacha timpeallachta in eagar i do thimpeallacht (Docker compose, systemd, .env) chun an luach réitithe a athrú, nó cuir litriúil in ionad an áitchoimeádaitheora anseo. Téigh go dtí an cluaisín Éifeachtach chun a fheiceáil cad atá á úsáid ag Etherpad faoi láthair.",
"admin_settings.toast.auth_error": "Níl tú fíordheimhnithe mar riarthóir. Logáil isteach arís le do thoil.",
"admin_settings.section.general": "Ginearálta",
"admin_settings.parse_error.title": "Ní féidir settings.json a pharsáil",
"admin_settings.parse_error.cta": "Athraigh go amh le heagarthóireacht",
@ -220,6 +230,7 @@
"index.copyLinkButton": "Cóipeáil nasc chuig an ghearrthaisce",
"index.transferToSystem": "3. Cóipeáil an seisiún chuig an gcóras nua",
"index.transferToSystemDescription": "Oscail an nasc cóipeáilte sa bhrabhsálaí nó ar an ngléas sprice chun do sheisiún a aistriú.",
"index.code": "Cód",
"index.transferSessionDescription": "Aistrigh do sheisiún reatha chuig brabhsálaí nó gléas trí chliceáil ar an gcnaipe thíos. Cóipeálfaidh sé seo nasc chuig leathanach a aistreoidh do sheisiún nuair a osclófar é sa bhrabhsálaí nó sa ghléas sprice.",
"index.createOpenPad": "Oscail ceap de réir ainm",
"index.openPad": "oscail Pad atá ann cheana féin leis an ainm:",

View file

@ -8,6 +8,7 @@
},
"admin.page-title": "Panel de administración - Etherpad",
"admin.loading": "Cargando…",
"admin.loading_description": "Agarda mentres se carga a páxina.",
"admin.toggle_sidebar": "Activar ou desactivar a barra lateral",
"admin.shout": "Comunicación",
"admin_shout.online_one": "Nestes intres hai {{count}} usuario en liña",
@ -27,7 +28,11 @@
"admin_pads.col.revisions": "Revisións",
"admin_pads.col.users": "Usuarios",
"admin_pads.confirm_button": "Aceptar",
"admin_pads.create_pad_dialog_description": "Escolle un nome para o documento novo.",
"admin_pads.delete_pad_dialog_description": "Confirmar ou cancelar a eliminación do documento.",
"admin_pads.delete_pad_dialog_title": "Borrar o documento",
"admin_pads.empty_never_edited": "baleiro · non se editou nunca",
"admin_pads.error_dialog_description": "Houbo un erro.",
"admin_pads.error_prefix": "Erro",
"admin_pads.filter.active": "Activos",
"admin_pads.filter.all": "Todos",
@ -139,7 +144,12 @@
"admin_settings.prettify_confirm": "Ao aquelar, eliminaranse todos os comentarios. Queres continuar?",
"admin_settings.mode.form": "Formulario",
"admin_settings.mode.raw": "En bruto",
"admin_settings.mode.effective": "Efectivo",
"admin_settings.mode.effective_tooltip": "Vista de só lectura dos valores que Etherpad está a usar agora mesmo, despois da substitución das variables de contorno. Os segredos están agochados.",
"admin_settings.mode.aria_label": "Modo de edición",
"admin_settings.envvar_banner.title": "Este ficheiro é un modelo, non a configuración real.",
"admin_settings.envvar_banner.body": "Os marcadores de posición como «${VAR:default}» substitúense na memoria durante o inicio da aplicación; nunca se escribir neste ficheiro. Edita as variables no teu contorno (Docker compose, systemd, .env) para cambiar o valor resolto ou substitúe o marcador de posición aquí por unha cadea de texto literal. Cambia á lapela «Efectivo» para ver o que está a usar Etherpad agora mesmo.",
"admin_settings.toast.auth_error": "Non te conectaches cunha conta administrativa. Inicia sesión de novo.",
"admin_settings.section.general": "Xeral",
"admin_settings.parse_error.title": "Non se pode analizar o ficheiro «settings.json»",
"admin_settings.parse_error.cta": "Cambiar ao editor en bruto para editar",
@ -222,6 +232,7 @@
"index.copyLinkButton": "Copiar a ligazón no portapapeis",
"index.transferToSystem": "3. Copia a sesión no novo sistema",
"index.transferToSystemDescription": "Abre a ligazón copiada no navegador ou dispositivo de destino para transferir a túa sesión.",
"index.code": "Código",
"index.transferSessionDescription": "Transfire a túa sesión actual ao navegador ou dispositivo facendo clic no botón de embaixo. Isto copiará unha ligazón cara a unha páxina que transferirá a túa sesión cando se abra no navegador ou dispositivo de destino.",
"index.createOpenPad": "Abrir un documento por nome",
"index.openPad": "abrir un documento existente co nome:",

View file

@ -15,13 +15,80 @@
]
},
"admin.page-title": "Pannello amministrativo - Etherpad",
"admin.loading": "Caricamento…",
"admin.loading_description": "Attendi il caricamento della pagina.",
"admin.toggle_sidebar": "Attiva/disattiva barra laterale",
"admin.shout": "Comunicazione",
"admin_shout.online_one": "Attualmente ci sono {{count}} utenti online",
"admin_shout.online_other": "Attualmente ci sono {{count}} utenti online",
"admin_shout.sticky_toggle": "Cambiare il messaggio adesivo",
"admin_login.title": "Etherpad",
"admin_login.username": "Nome utente",
"admin_login.password": "Password",
"admin_login.submit": "Accedi",
"admin_login.failed": "Accesso non riuscito!",
"admin_pads.all_pads": "Tutti i Pads",
"admin_pads.bulk.cleanup_history": "Pulizia della cronologia",
"admin_pads.bulk.clear_selection": "Cancella selezione",
"admin_pads.bulk.delete": "Elimina",
"admin_pads.cancel": "Annulla",
"admin_pads.col.pad": "Pad",
"admin_pads.col.revisions": "Revisioni",
"admin_pads.col.users": "Utenti",
"admin_pads.confirm_button": "OK",
"admin_pads.create_pad_dialog_description": "Scegli un nome per il nuovo Pad.",
"admin_pads.delete_pad_dialog_description": "Conferma o annulla la cancellazione del Pad.",
"admin_pads.delete_pad_dialog_title": "Cancella Pad",
"admin_pads.empty_never_edited": "vuoto · mai modificato",
"admin_pads.error_dialog_description": "Si è verificato un errore.",
"admin_pads.error_prefix": "Errore",
"admin_pads.filter.active": "Attivo",
"admin_pads.filter.all": "Tutto",
"admin_pads.filter.empty": "Vuoto",
"admin_pads.filter.recent": "Questa settimana",
"admin_pads.filter.stale": "Stale (>1y)",
"admin_pads.open": "Aperto",
"admin_pads.pagination.next": "Successivo",
"admin_pads.pagination.previous": "Precedente",
"admin_pads.refresh": "Aggiorna",
"admin_pads.relative.days": "{{count}}d fa",
"admin_pads.relative.hours": "{{count}} ore fa",
"admin_pads.relative.just_now": "proprio ora",
"admin_pads.relative.minutes": "{{count}}m fa",
"admin_pads.relative.months": "{{count}}mese fa",
"admin_pads.relative.weeks": "{{count}} settimane fa",
"admin_pads.relative.years": "{{count}} anni fa",
"admin_pads.revisions_count": "{{count}} revisioni",
"admin_pads.selected_count": "{{count}} selezionati",
"admin_pads.show": "Mostra",
"admin_pads.sort.name": "Nome (AZ)",
"admin_pads.sort.revision_number": "Revisioni",
"admin_pads.sort.user_count": "Utenti",
"admin_pads.stats.across_pads": "su tutti i pad",
"admin_pads.stats.active_users": "Utenti attivi",
"admin_pads.stats.empty_pads": "Pads vuoti",
"admin_pads.stats.last_activity": "Ultima attività",
"admin_pads.stats.no_active_users": "Nessun utente attivo",
"admin_pads.stats.revisions_zero": "0 revisioni",
"admin_pads.stats.total": "Pads totali",
"admin_pads.stats.users_active": "{{count}} attualmente attivi",
"admin_pads.subtitle": "Panoramica di tutti i pad su questa istanza di Etherpad. Cerca, pulisci, apri.",
"admin_plugins": "Gestione plugin",
"admin_plugins.available": "Plugin disponibili",
"admin_plugins.available_not-found": "Nessun plugin trovato.",
"admin_plugins.available_fetching": "Recupero in corso…",
"admin_plugins.available_install.value": "Installa",
"admin_plugins.available_search.placeholder": "Cerca i plugin da installare",
"admin_plugins.check_updates": "Verifica la presenza di aggiornamenti",
"admin_plugins.core_count": "{{count}} core",
"admin_plugins.catalog_disabled": "Il catalogo dei plugin è disabilitato dal tuo operatore (privacy.pluginCatalog=false). Per installare un plugin, esegui `pnpm run plugins i ep_<name> dal server.",
"admin_plugins.crumbs": "Plugin",
"admin_plugins.description": "Descrizione",
"admin_plugins.disables.label": "Disabilitati:",
"admin_plugins.disables.warning_title": "Questo plugin rimuove intenzionalmente le funzionalità di Etherpad elencate.",
"admin_plugins.error_retrieving": "Errore durante il recupero dei plugin",
"admin_plugins.install_error": "Impossibile installare {{plugin}}: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad": "Impossibile installare {{plugin}}: richiede una versione più recente di Etherpad. Aggiorna Etherpad e riprova.",
"admin_plugins.installed": "Plugin installati",
"admin_plugins.installed_fetching": "Recupero dei plugin installati…",
"admin_plugins.installed_nothing": "Non hai ancora installato alcun plugin.",
@ -29,27 +96,120 @@
"admin_plugins.last-update": "Ultimo aggiornamento",
"admin_plugins.name": "Nome",
"admin_plugins.page-title": "Gestore dei plugin - Etherpad",
"admin_plugins.reload_catalog": "Ricarica il catalogo",
"admin_plugins.search_npm": "Cerca su npm",
"admin_plugins.sort_ascending": "Ordinamento crescente",
"admin_plugins.sort_descending": "Ordinamento decrescente",
"admin_plugins.sort.last_updated": "Ultimo aggiornamento",
"admin_plugins.sort.name": "Nome (AZ)",
"admin_plugins.sort.version": "Versione",
"admin_plugins.source": "Fonte del plugin",
"admin_plugins.subtitle": "Installa, aggiorna e rimuovi i plugin di Etherpad. Le modifiche richiedono il riavvio del server.",
"admin_plugins.tag_core": "Core",
"admin_plugins.update_tooltip": "Aggiornamento",
"admin_plugins.updates_available": "Aggiornamenti disponibili",
"admin_plugins.update_now": "Aggiorna",
"admin_plugins.version": "Versione",
"admin_plugins_info": "Informazioni sulla risoluzione dei problemi",
"admin_plugins_info.bindings_label": "{{count}} bindings",
"admin_plugins_info.copy_diagnostics": "Copia diagnostica",
"admin_plugins_info.copy_value": "Copia {{label}}",
"admin_plugins_info.git_sha": "Git SHA",
"admin_plugins_info.hook_bindings": "Hook bindings",
"admin_plugins_info.hooks": "Hook installati",
"admin_plugins_info.hooks_client": "Hook lato client",
"admin_plugins_info.hooks_server": "Hook lato server",
"admin_plugins_info.no_hooks": "Nessun hooks trovato",
"admin_plugins_info.parts": "Parti installate",
"admin_plugins_info.plugins": "Plugin installati",
"admin_plugins_info.page-title": "Informazioni sul plugin - Etherpad",
"admin_plugins_info.search_placeholder": "Cerca hook o parte…",
"admin_plugins_info.subtitle": "Diagnostica di sistema: versione installata, componenti registrati e hook.",
"admin_plugins_info.tab_client": "Client",
"admin_plugins_info.tab_server": "Server",
"admin_plugins_info.up_to_date": "Aggiornato",
"admin_plugins_info.update_available": "Aggiornamento disponibile: {{version}}",
"admin_plugins_info.version": "Versione di Etherpad",
"admin_plugins_info.version_latest": "Ultima versione disponibile",
"admin_plugins_info.version_number": "Numero di versione",
"admin_settings": "Impostazioni",
"admin_settings.create_pad": "Crea pad",
"admin_settings.current": "Configurazione attuale",
"admin_settings.current_example-devel": "Esempio di modello di impostazioni di sviluppo",
"admin_settings.current_example-prod": "Esempio di modello di impostazioni di produzione",
"admin_settings.current_restart.value": "Riavvia Etherpad",
"admin_settings.current_save.value": "Salva impostazioni",
"admin_settings.invalid_json": "JSON non valido",
"admin_settings.current_test.value": "Validare JSON",
"admin_settings.toast.saved": "Impostazioni salvate correttamente.",
"admin_settings.toast.save_failed": "Salvataggio non riuscito: impossibile scrivere il file settings.json.",
"admin_settings.toast.json_invalid": "Errore di sintassi: verificare virgole, parentesi graffe e virgolette.",
"admin_settings.toast.disconnected": "Impossibile salvare: connessione al server non riuscita.",
"admin_settings.toast.validation_ok": "Il formato JSON è valido.",
"admin_settings.toast.validation_failed": "Il formato JSON non è valido: correggere gli errori di sintassi.",
"admin_settings.mode.form": "Modulo",
"admin_settings.mode.aria_label": "Modalità editor",
"admin_settings.section.general": "Generale",
"admin_settings.parse_error.title": "Impossibile analizzare il file settings.json",
"admin_settings.parse_error.cta": "Passa al formato raw per modificare",
"admin_settings.env_pill.tooltip": "Legge dalla variabile d'ambiente {{variable}}. Il valore seguente viene utilizzato quando {{variable}} non è impostata.",
"admin_settings.env_pill.default_label": "predefinito",
"admin_settings.env_pill.input_aria": "Valore predefinito per {{variable}}",
"admin_settings.env_pill.runtime_label": "valore attivo",
"admin_settings.env_pill.runtime_tooltip": "Etherpad sta attualmente utilizzando questo valore, risolto da {{variable}} o dal suo valore predefinito.",
"admin_settings.env_pill.redacted_tooltip": "Etherpad sta utilizzando un valore per {{variable}}, ma è nascosto perché è un segreto.",
"admin_settings.page-title": "Impostazioni - Etherpad",
"admin_settings.save_error": "Impostazioni di salvataggio degli errori",
"admin_settings.saved_success": "Impostazioni salvate con successo",
"update.banner.title": "Aggiornamento disponibile",
"update.banner.body": "Etherpad {{latest}} è disponibile (stai eseguendo {{current}}).",
"update.banner.cta": "Visualizza aggiornamento",
"update.page.title": "Aggiornamenti di Etherpad",
"update.page.current": "Versione attuale",
"update.page.latest": "Ultima versione",
"update.page.last_check": "Ultimo controllo effettuato",
"update.page.install_method": "Metodo di installazione",
"update.page.changelog": "Changelog",
"update.page.up_to_date": "Stiamo utilizzando l'ultima versione.",
"update.page.disabled": "I controlli degli aggiornamenti sono disabilitati (updates.tier = \"off\").",
"update.page.unauthorized": "Non sei autorizzato a visualizzare lo stato degli aggiornamenti.",
"update.page.error": "Impossibile caricare lo stato dell'aggiornamento (stato {{status}}).",
"update.badge.severe": "Etherpad su questo server è gravemente obsoleto. Segnalalo all'amministratore.",
"update.badge.vulnerable": "Su questo server è in esecuzione una versione di Etherpad con vulnerabilità di sicurezza note. Informa l'amministratore.",
"update.page.apply": "Applica aggiornamento",
"update.page.cancel": "Annulla",
"update.page.log": "Registro dei log (ultime 200 righe)",
"update.page.execution": "Stato",
"update.page.policy.install-method-not-writable": "Gli aggiornamenti dall'interfaccia di amministrazione richiedono un'installazione da Git. Aggiorna tramite il tuo gestore di pacchetti.",
"update.page.policy.rollback-failed-terminal": "Un precedente aggiornamento non è riuscito e non è stato possibile annullarlo. Premi Conferma al termine dell'installazione per rimuovere il blocco.",
"update.page.policy.up-to-date": "Stiamo utilizzando l'ultima versione.",
"update.page.policy.tier-off": "Gli aggiornamenti sono disabilitati (updates.tier = \"off\").",
"update.page.policy.maintenance-window-missing": "Il livello 4 (autonomo) richiede una finestra di manutenzione. Imposta updates.maintenanceWindow in settings.json per abilitare gli aggiornamenti autonomi.",
"update.page.last_result.verified": "Ultimo aggiornamento a {{tag}} verificato.",
"update.page.last_result.rolled-back": "Ultimo tentativo di aggiornamento di {{tag}} annullato: {{reason}}.",
"update.page.last_result.rollback-failed": "Ultimo tentativo di aggiornamento non riuscito E rollback non riuscito: {{reason}}. Intervento manuale necessario.",
"update.page.last_result.cancelled": "Ultimo tentativo di aggiornamento di {{tag}} annullato dall'amministratore.",
"update.execution.idle": "Idle",
"update.execution.scheduled": "Aggiornamento programmato",
"update.execution.executing": "Aggiornamento...",
"update.execution.pending-verification": "In attesa di verifica",
"update.execution.verified": "Verificato",
"update.execution.rolling-back": "Rollback",
"update.execution.rolled-back": "Rolled back",
"update.execution.rollback-failed": "Rollback fallito",
"update.banner.terminal.rollback-failed": "Il tentativo di aggiornamento non è riuscito e non è stato possibile annullarlo. È necessario un intervento manuale.",
"update.banner.scheduled": "Aggiornamento automatico programmato per {{tag}} — si applica tra {{remaining}}.",
"update.banner.maintenance-window-missing": "Gli aggiornamenti automatici sono disabilitati fino a quando non viene configurata una finestra di manutenzione.",
"update.page.scheduled.title": "Aggiornamento programmato",
"update.page.scheduled.countdown": "Etherpad inizierà l'aggiornamento a {{tag}} in {{remaining}}.",
"update.page.scheduled.deferred_until": "Fuori dalla finestra di manutenzione. L'aggiornamento inizierà all'apertura della finestra alle {{at}}.",
"update.page.scheduled.apply_now": "Applica ora",
"update.window.title": "Finestra di manutenzione",
"update.window.unset": "Non configurato.",
"update.window.next_opens_at": "La prossima finestra si aprirà alle {{at}}.",
"update.drain.t60": "Etherpad si riavvierà tra 60 secondi per applicare un aggiornamento.",
"update.drain.t30": "Etherpad si riavvierà tra 30 secondi per applicare un aggiornamento.",
"update.drain.t10": "Etherpad si riavvierà tra 10 secondi per applicare un aggiornamento.",
"index.newPad": "Nuovo pad",
"index.settings": "Impostazioni",
"index.transferSessionTitle": "Sessione di trasferimento",
@ -62,6 +222,7 @@
"index.copyLinkButton": "Copia link negli appunti",
"index.transferToSystem": "3. Copia la sessione sul nuovo sistema",
"index.transferToSystemDescription": "Apri il collegamento copiato nel browser o nel dispositivo di destinazione per trasferire la sessione",
"index.code": "Codice",
"index.transferSessionDescription": "Trasferisci la tua sessione corrente al browser o al dispositivo cliccando sul pulsante qui sotto. Verrà copiato un link a una pagina che trasferirà la tua sessione quando verrà aperta nel browser o dispositivo di destinazione.",
"index.createOpenPad": "Apri pad per nome",
"index.openPad": "apri un Pad esistente col nome:",
@ -111,8 +272,18 @@
"pad.settings.fontType": "Tipo di carattere:",
"pad.settings.fontType.normal": "Normale",
"pad.settings.language": "Lingua:",
"pad.settings.deletePad": "Elimina Pad",
"pad.settings.deletePad": "Cancella Pad",
"pad.delete.confirm": "Vuoi veramente cancellare questo pad?",
"pad.deletionToken.modalTitle": "Salva il token di eliminazione del tuo pad",
"pad.deletionToken.modalBody": "Questo token è l'unico modo per eliminare questo pad se perdi la sessione del browser o il dispositivo di commutazione. Conservalo in un luogo sicuro - qui viene mostrato esattamente solo una volta.",
"pad.deletionToken.copy": "Copia",
"pad.deletionToken.copied": "Copiato",
"pad.deletionToken.acknowledge": "L'ho salvato",
"pad.deletionToken.deleteWithToken": "Cancellare il pad con il token",
"pad.deletionToken.tokenFieldLabel": "Token di eliminazione del pad",
"pad.deletionToken.tokenValueLabel": "Il tuo token di cancellazione del pad (solo-lettura)",
"pad.deletionToken.invalid": "Quel token non è valido per questo pad.",
"pad.deletionToken.notCreator": "Non sei tu il creatore di questo pad, quindi non puoi eliminarlo.",
"pad.settings.about": "Informazioni",
"pad.settings.poweredBy": "Realizzato con",
"pad.importExport.import_export": "Importazione/esportazione",
@ -125,6 +296,13 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.exportetherpada.title": "Esporta come Etherpad",
"pad.importExport.exporthtmla.title": "Esporta come HTML",
"pad.importExport.exportplaina.title": "Esporta come testo semplice",
"pad.importExport.exportworda.title": "Esporta come Microsoft Word",
"pad.importExport.exportpdfa.title": "Esporta in formato PDF",
"pad.importExport.exportopena.title": "Esporta in formato ODF (Open Document Format)",
"pad.importExport.noConverter.innerHTML": "È possibile importare solo file di testo semplice o in formato HTML. Per funzionalità di importazione più avanzate, si prega <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">di installare LibreOffice</a> .",
"pad.modals.connected": "Connesso.",
"pad.modals.reconnecting": "Riconnessione al pad in corso…",
"pad.modals.forcereconnect": "Forza la riconnessione",
@ -171,16 +349,25 @@
"timeslider.toolbar.returnbutton": "Ritorna al Pad",
"pad.historyMode.banner": "Visualizza cronologia",
"pad.historyMode.revisionLabel": "Versione {{rev}}",
"pad.historyMode.controlsLabel": "Controlli della cronologia del pad",
"pad.historyMode.sliderLabel": "Versione Pad",
"pad.historyMode.settings.follow": "Segui gli aggiornamenti dei contenuti del pad",
"pad.historyMode.settings.followShort": "Segui",
"pad.historyMode.settings.playbackSpeed": "Velocità di riproduzione:",
"pad.historyMode.chat.replayHeader": "Chat del {{time}}",
"pad.historyMode.users.authorsHeader": "Autori in questa versione",
"pad.editor.skipToContent": "Vai all'editor",
"pad.editor.keyboardHint": "Premi Esc per uscire dall'editor. Premi Alt+F9 per accedere alla barra degli strumenti.",
"pad.editor.toolbar.formatting": "Strumento di formattazione",
"pad.editor.toolbar.actions": "Barra degli strumenti delle azioni del pad",
"pad.editor.toolbar.showMore": "Mostra altri pulsanti della barra degli strumenti",
"timeslider.toolbar.authors": "Autori:",
"timeslider.toolbar.authorsList": "Nessun autore",
"timeslider.toolbar.exportlink.title": "Esporta",
"timeslider.exportCurrent": "Esporta la versione corrente come:",
"timeslider.version": "Versione {{version}}",
"timeslider.saved": "Salvato {{day}} {{month}} {{year}}",
"timeslider.settings.playbackSpeed": "Velocità di riproduzione:",
"timeslider.settings.playbackSpeed.original": "Velocità originale",
"timeslider.settings.playbackSpeed.realtime": "In tempo reale",
"timeslider.settings.playbackSpeed.200ms": "200 ms",
@ -207,6 +394,7 @@
"pad.savedrevs.timeslider": "Puoi vedere le versioni salvate visitando la cronologia",
"pad.userlist.entername": "Inserisci il tuo nome",
"pad.userlist.unnamed": "senza nome",
"pad.userlist.onlineCount": "{[ plurale(count) uno: {{count}} utente connesso, altro: {{count}} utenti connessi ]}",
"pad.editbar.clearcolors": "Eliminare i colori degli autori sull'intero documento? Questa azione non può essere annullata",
"pad.impexp.importbutton": "Importa ora",
"pad.impexp.importing": "Importazione in corso...",
@ -217,5 +405,6 @@
"pad.impexp.importfailed": "Importazione fallita",
"pad.impexp.copypaste": "Si prega di copiare e incollare",
"pad.impexp.exportdisabled": "L'esportazione come {{type}} è disabilitata. Contattare l'amministratore per i dettagli.",
"pad.impexp.maxFileSize": "File troppo grande. Contatta l'amministratore del sito per incrementare la dimensione consentita per l'importazione"
"pad.impexp.maxFileSize": "File troppo grande. Contatta l'amministratore del sito per incrementare la dimensione consentita per l'importazione",
"pad.social.description": "Un documento collaborativo che tutti possono modificare in tempo reale."
}

View file

@ -19,13 +19,80 @@
]
},
"admin.page-title": "관리 대시보드 - 이더패드",
"admin.loading": "불러오는 중…",
"admin.loading_description": "페이지가 로딩되는 동안 잠시 기다려 주세요.",
"admin.toggle_sidebar": "사이드바 토글",
"admin.shout": "커뮤니케이션",
"admin_shout.online_one": "현재 {{count}}명의 사용자가 온라인 상태입니다",
"admin_shout.online_other": "현재 {{count}}명의 사용자가 온라인 상태입니다",
"admin_shout.sticky_toggle": "고정 메시지 변경",
"admin_login.title": "이더패드",
"admin_login.username": "사용자 이름",
"admin_login.password": "비밀번호",
"admin_login.submit": "로그인",
"admin_login.failed": "로그인 실패",
"admin_pads.all_pads": "모든 패드",
"admin_pads.bulk.cleanup_history": "역사 지우기",
"admin_pads.bulk.clear_selection": "선택 항목 지우기",
"admin_pads.bulk.delete": "삭제",
"admin_pads.cancel": "취소",
"admin_pads.col.pad": "패드",
"admin_pads.col.revisions": "판",
"admin_pads.col.users": "사용자",
"admin_pads.confirm_button": "확인",
"admin_pads.create_pad_dialog_description": "새 패드의 이름을 선택하세요.",
"admin_pads.delete_pad_dialog_description": "패드 삭제를 확인하거나 취소하세요.",
"admin_pads.delete_pad_dialog_title": "패드 삭제",
"admin_pads.empty_never_edited": "비었음 · 편집된 적 없음",
"admin_pads.error_dialog_description": "오류가 발생했습니다.",
"admin_pads.error_prefix": "오류",
"admin_pads.filter.active": "활성",
"admin_pads.filter.all": "모두",
"admin_pads.filter.empty": "비었음",
"admin_pads.filter.recent": "이번 주",
"admin_pads.filter.stale": "오래됨 (>1년)",
"admin_pads.open": "열기",
"admin_pads.pagination.next": "다음",
"admin_pads.pagination.previous": "이전",
"admin_pads.refresh": "새로 고침",
"admin_pads.relative.days": "{{count}}일 전",
"admin_pads.relative.hours": "{{count}}시간 전",
"admin_pads.relative.just_now": "방금",
"admin_pads.relative.minutes": "{{count}}분 전",
"admin_pads.relative.months": "{{count}}달 전",
"admin_pads.relative.weeks": "{{count}}주 전",
"admin_pads.relative.years": "{{count}}년 전",
"admin_pads.revisions_count": "수정 내역 {{count}}개",
"admin_pads.selected_count": "{{count}}개 선택됨",
"admin_pads.show": "보기",
"admin_pads.sort.name": "이름 (A-Z)",
"admin_pads.sort.revision_number": "수정내역",
"admin_pads.sort.user_count": "사용자",
"admin_pads.stats.across_pads": "모든 패드에 걸쳐서",
"admin_pads.stats.active_users": "활동적인 사용자",
"admin_pads.stats.empty_pads": "빈 패드",
"admin_pads.stats.last_activity": "마지막 활동",
"admin_pads.stats.no_active_users": "활동적인 사용자 없음",
"admin_pads.stats.revisions_zero": "수정 내역 없음",
"admin_pads.stats.total": "총 패드",
"admin_pads.stats.users_active": "{{count}}명 현재 활동중",
"admin_pads.subtitle": "이 이더패드 인스턴스의 모든 패드 개요입니다. 검색, 정리, 열기를 할 수 있습니다.",
"admin_plugins": "플러그인 관리자",
"admin_plugins.available": "사용 가능한 플러그인",
"admin_plugins.available_not-found": "플러그인이 없습니다.",
"admin_plugins.available_fetching": "검색 중...",
"admin_plugins.available_install.value": "설치",
"admin_plugins.available_search.placeholder": "설치할 플러그인을 검색",
"admin_plugins.check_updates": "업데이트 확인",
"admin_plugins.core_count": "코어 {{count}}개",
"admin_plugins.catalog_disabled": "운영자가 플러그인 카탈로그를 비활성화했습니다(privacy.pluginCatalog=false). 플러그인을 설치하려면 서버에서 `pnpm run plugins i ep_<name>`을 실행하세요.",
"admin_plugins.crumbs": "플러그인",
"admin_plugins.description": "설명",
"admin_plugins.disables.label": "비활성화:",
"admin_plugins.disables.warning_title": "이 플러그인은 나열된 이더패드 기능을 의도적으로 제거합니다.",
"admin_plugins.error_retrieving": "플러그인을 가져오는 중 오류 발생",
"admin_plugins.install_error": "{{plugin}} 설치 실패: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad": "{{plugin}}을 설치할 수 없습니다. 더 새로운 버전의 이더패드가 필요합니다. 이더패드를 업그레이드한 뒤 다시 시도하세요.",
"admin_plugins.installed": "설치된 플러그인",
"admin_plugins.installed_fetching": "설치된 플러그인을 검색하는 중...",
"admin_plugins.installed_nothing": "아직 플러인을 설치하지 않으셨습니다.",
@ -33,50 +100,161 @@
"admin_plugins.last-update": "마지막 업데이트",
"admin_plugins.name": "이름",
"admin_plugins.page-title": "플러그인 관리자 - 이더패드",
"admin_plugins.reload_catalog": "카탈로그 새로고침",
"admin_plugins.search_npm": "npm에서 검색",
"admin_plugins.sort_ascending": "오름차순 정렬",
"admin_plugins.sort_descending": "내림차순 정렬",
"admin_plugins.sort.last_updated": "최근 업데이트",
"admin_plugins.sort.name": "이름 (A-Z)",
"admin_plugins.sort.version": "버전",
"admin_plugins.source": "플러그인 소스",
"admin_plugins.subtitle": "이더패드 플러그인을 설치, 업데이트 및 제거합니다. 변경 사항을 적용하려면 서버를 다시 시작해야 합니다.",
"admin_plugins.tag_core": "코어",
"admin_plugins.update_tooltip": "업데이트",
"admin_plugins.updates_available": "업데이트가 있습니다",
"admin_plugins.update_now": "업데이트",
"admin_plugins.version": "버전",
"admin_plugins_info": "문제 해결 정보",
"admin_plugins_info.bindings_label": "바인딩 {{count}}개",
"admin_plugins_info.copy_diagnostics": "진단 복사",
"admin_plugins_info.copy_value": "{{label}} 복사",
"admin_plugins_info.git_sha": "깃 SHA",
"admin_plugins_info.hook_bindings": "후크 바인딩",
"admin_plugins_info.hooks": "설치된 훅",
"admin_plugins_info.hooks_client": "클라이언트 사이드 훅",
"admin_plugins_info.hooks_server": "서버사이드 훅",
"admin_plugins_info.no_hooks": "훅이 없습니다",
"admin_plugins_info.parts": "설치된 항목",
"admin_plugins_info.plugins": "설치된 플러그인",
"admin_plugins_info.page-title": "플러그인 정보 - 이더패드",
"admin_plugins_info.search_placeholder": "후크 또는 파츠 검색…",
"admin_plugins_info.subtitle": "시스템 진단: 설치된 버전, 등록된 파츠 및 후크입니다.",
"admin_plugins_info.tab_client": "클라이언트",
"admin_plugins_info.tab_server": "서버",
"admin_plugins_info.up_to_date": "현재 최신 버전",
"admin_plugins_info.update_available": "업데이트 가능: {{version}}",
"admin_plugins_info.version": "이더패드 버전",
"admin_plugins_info.version_latest": "사용 가능한 최신 버전",
"admin_plugins_info.version_number": "버전 번호",
"admin_settings": "설정",
"admin_settings.create_pad": "패드 만들기",
"admin_settings.current": "현재 구성",
"admin_settings.current_example-devel": "예시 개발용 설정 틀",
"admin_settings.current_example-prod": "예시 운영용 설정 틀",
"admin_settings.current_restart.value": "이더패드 다시 시작",
"admin_settings.current_save.value": "설정 저장",
"admin_settings.invalid_json": "잘못된 JSON",
"admin_settings.current_test.value": "JSON 검증",
"admin_settings.current_prettify.value": "JSON 정리",
"admin_settings.toast.saved": "설정을 성공적으로 저장했습니다.",
"admin_settings.toast.save_failed": "저장 실패: settings.json에 쓰기 할 수 없습니다.",
"admin_settings.toast.json_invalid": "구문 오류: 쉼표, 중괄호, 따옴표를 확인하세요.",
"admin_settings.toast.disconnected": "저장할 수 없음: 서버에 연결되어 있지 않습니다.",
"admin_settings.toast.validation_ok": "JSON이 유효합니다.",
"admin_settings.toast.validation_failed": "JSON이 유효하지 않습니다. 구문 오류를 수정하세요.",
"admin_settings.toast.prettify_failed": "정리할 수 없습니다. 먼저 구문 오류를 수정하세요.",
"admin_settings.prettify_confirm": "정리하면 모든 의견이 제거됩니다. 계속하시겠습니까?",
"admin_settings.mode.form": "형태",
"admin_settings.mode.raw": "원본",
"admin_settings.mode.effective": "효과 적용 중",
"admin_settings.mode.effective_tooltip": "환경 변수 대체 후 이더패드가 현재 실제로 사용 중인 값을 읽기 전용으로 보여줍니다. 비밀 정보는 가려졌습니다.",
"admin_settings.mode.aria_label": "편집기 모드",
"admin_settings.envvar_banner.title": "이 파일은 틀이며, 라이브 설정이 아닙니다.",
"admin_settings.envvar_banner.body": "${VAR:default}와 같은 자리 표시자는 시작 시 메모리에 대체되며, 이 파일에는 다시 기록되지 않습니다. 환경 변수(Docker compose, systemd, .env)를 편집하여 확인된 값을 변경하거나, 여기의 자리 표시자를 리터럴 값으로 바꾸세요. 현재 이더패드에서 사용 중인 값을 확인하려면 '효과 적용 중' 탭으로 이동하세요.",
"admin_settings.toast.auth_error": "관리자 계정으로 인증되지 않았습니다. 다시 로그인해 주세요.",
"admin_settings.section.general": "일반",
"admin_settings.parse_error.title": "settings.json을 구문 분석할 수 없습니다",
"admin_settings.parse_error.cta": "원본 편집으로 전환",
"admin_settings.env_pill.tooltip": "{{variable}} 환경 변수에서 읽습니다. {{variable}}이 설정되지 않은 경우 아래 값이 사용됩니다.",
"admin_settings.env_pill.default_label": "기본값",
"admin_settings.env_pill.input_aria": "{{variable}}의 기본값",
"admin_settings.env_pill.runtime_label": "활성 값",
"admin_settings.env_pill.runtime_tooltip": "이더패드는 현재 {{variable}} 또는 해당 기본값에서 확인된 이 값을 사용하고 있습니다.",
"admin_settings.env_pill.redacted_tooltip": "이더패드가 {{variable}}의 값을 사용하고 있지만, 비밀 값이므로 숨겨져 있습니다.",
"admin_settings.page-title": "설정 - 이더패드",
"admin_settings.save_error": "설정 저장 오류",
"admin_settings.saved_success": "설정이 성공적으로 저장되었습니다",
"update.banner.title": "업데이트 사용 가능",
"update.banner.body": "이더패드 {{latest}}을 사용할 수 있습니다(현재 {{current}} 실행 중).",
"update.banner.cta": "업데이트 보기",
"update.page.title": "이더패드 업데이트",
"update.page.current": "현재 판",
"update.page.latest": "최신 버전",
"update.page.last_check": "마지막으로 확인함",
"update.page.install_method": "설치 방법",
"update.page.tier": "업데이트 티어",
"update.page.changelog": "바뀐기록",
"update.page.up_to_date": "최신 버전을 사용 중입니다.",
"update.page.disabled": "업데이트 확인이 비활성화되어 있습니다(updates.tier = \"off\").",
"update.page.unauthorized": "업데이트 상태를 볼 권한이 없습니다.",
"update.page.error": "업데이트 상태를 불러올 수 없습니다(상태 {{status}}).",
"update.badge.severe": "이 서버의 이더패드가 심각하게 오래되었습니다. 관리자에게 알리세요.",
"update.badge.vulnerable": "이 서버의 이더패드는 알려진 보안 문제가 있는 버전을 실행하고 있습니다. 관리자에게 알리세요.",
"update.page.apply": "업데이트 적용",
"update.page.cancel": "취소",
"update.page.acknowledge": "확인",
"update.page.log": "업데이트 로그 (최근 200줄)",
"update.page.execution": "상태",
"update.page.policy.install-method-not-writable": "관리자 UI에서 업데이트하려면 Git 설치가 필요합니다. 패키지 관리자를 통해 업데이트하세요.",
"update.page.policy.rollback-failed-terminal": "이전 업데이트가 실패했으며 롤백할 수 없었습니다. 설치가 정상화된 뒤 확인을 눌러 잠금을 해제하세요.",
"update.page.policy.up-to-date": "최신 버전을 사용 중입니다.",
"update.page.policy.tier-off": "업데이트가 비활성화되었습니다 (updates.tier = \"off\").",
"update.page.policy.maintenance-window-missing": "계층 4(자율)는 유지보수 기간이 필요합니다. 자율 업데이트를 활성화하려면 settings.json에서 updates.maintenanceWindow를 설정하세요.",
"update.page.policy.maintenance-window-invalid": "updates.maintenanceWindow 형식이 잘못되어 계층 4(자율)가 비활성화되었습니다. HH:MM 시간과 \"local\" 또는 \"utc\"인 tz를 포함한 {start, end, tz} 형식이 필요합니다.",
"update.page.last_result.verified": "{{tag}}의 마지막 업데이트가 확인되었습니다.",
"update.page.last_result.rolled-back": "{{tag}}에 대한 마지막 업데이트 시도가 롤백되었습니다: {{reason}}.",
"update.page.last_result.rollback-failed": "마지막 업데이트 시도가 실패했고 롤백도 실패했습니다: {{reason}}. 수동 개입이 필요합니다.",
"update.page.last_result.preflight-failed": "{{tag}}에 대한 마지막 업데이트 시도가 사전 검사에서 실패했습니다: {{reason}}.",
"update.page.last_result.cancelled": "관리자에 의해 {{tag}}에 대한 마지막 업데이트 시도가 취소되었습니다.",
"update.execution.idle": "유휴 상태",
"update.execution.scheduled": "예정된 업데이트",
"update.execution.preflight": "비행 전 점검",
"update.execution.preflight-failed": "비행 전 점검 실패",
"update.execution.draining": "세션 정리 중",
"update.execution.executing": "업데이트 중...",
"update.execution.pending-verification": "확인 대기 중",
"update.execution.verified": "확인됨",
"update.execution.rolling-back": "되돌리는 중",
"update.execution.rolled-back": "되돌려짐",
"update.execution.rollback-failed": "되돌리기 실패",
"update.banner.terminal.rollback-failed": "업데이트 시도가 실패했으며 롤백할 수 없습니다. 수동 조치가 필요합니다.",
"update.banner.scheduled": "{{tag}}에 대한 자동 업데이트가 예약되었습니다. {{remaining}}에 적용됩니다.",
"update.banner.maintenance-window-missing": "유지 관리 기간이 설정될 때까지 자동 업데이트가 비활성화됩니다.",
"update.banner.maintenance-window-invalid": "유지 관리 기간이 잘못 설정되어 있어 자동 업데이트가 비활성화되었습니다.",
"update.page.scheduled.title": "예정된 업데이트",
"update.page.scheduled.countdown": "이더패드는 {{remaining}} 후에 {{tag}}(으)로의 업데이트를 시작합니다.",
"update.page.scheduled.deferred_until": "유지 보수 시간 외입니다. 업데이트는 유지 보수가 시작되는 {{at}}에 시작됩니다.",
"update.page.scheduled.apply_now": "지금 적용",
"update.window.title": "유지보수 창",
"update.window.summary": "{{start}}{{end}} ({{tz}})",
"update.window.unset": "구성되지 않았습니다.",
"update.window.next_opens_at": "다음 창은 {{at}}에 열립니다.",
"update.drain.t60": "업데이트 적용을 위해 이더패드가 60초 뒤에 재시작됩니다.",
"update.drain.t30": "업데이트 적용을 위해 이더패드가 30초 뒤에 재시작됩니다.",
"update.drain.t10": "업데이트 적용을 위해 이더패드가 10초 뒤에 재시작됩니다.",
"index.newPad": "새 패드",
"index.settings": "설정",
"index.transferSessionTitle": "전송 세션",
"index.receiveSessionTitle": "수신 세션",
"index.receiveSessionDescription": "여기에서 다른 브라우저나 기기에서 이더패드 세션을 수신할 수 있습니다. 단, 이렇게 하면 현재 세션이 삭제되므로 주의하세요.",
"index.transferSession": "1. 전송 세션",
"index.transferSessionNow": "전송 세션을 지금 시작",
"index.copyLink": "2. 링크 복사",
"index.copyLinkDescription": "아래 버튼을 클릭하여 링크를 클립보드에 복사하세요.",
"index.copyLinkButton": "클립보드에 링크 복사하기",
"index.transferToSystem": "3. 세션을 새 시스템으로 복사",
"index.transferToSystemDescription": "복사한 링크를 대상 브라우저 또는 기기에서 열어 세션을 전송하세요.",
"index.code": "코드",
"index.transferSessionDescription": "아래 버튼을 클릭하여 현재 세션을 브라우저 또는 기기로 전송하세요. 이 버튼을 클릭하면 대상 브라우저 또는 기기에서 열었을 때 세션이 전송될 문서 링크가 복사됩니다.",
"index.createOpenPad": "이름으로 패드 열기",
"index.openPad": "이름으로 기존 패드 열기:",
"index.recentPads": "최근 패드",
"index.recentPadsEmpty": "최근 패드가 없습니다.",
"index.generateNewPad": "무작위 패드 이름 생성",
"index.labelPad": "패드 이름 (선택 사항)",
"index.placeholderPadEnter": "패드 이름을 입력...",
"index.createAndShareDocuments": "실시간으로 문서를 생성하고 공유하세요",
"index.createAndShareDocumentsDescription": "이더패드를 사용하면 마치 브라우저에서 실행되는 실시간 멀티플레이어 편집기처럼 문서를 실시간으로 공동 편집할 수 있습니다.",
"pad.toolbar.bold.title": "굵게 (Ctrl+B)",
"pad.toolbar.italic.title": "기울임꼴 (Ctrl+I)",
"pad.toolbar.underline.title": "밑줄 (Ctrl+U)",
@ -109,33 +287,45 @@
"pad.settings.stickychat": "화면에 항상 대화 보기",
"pad.settings.chatandusers": "대화와 사용자 보기",
"pad.settings.colorcheck": "작성자 표시 색상",
"pad.settings.fadeInactiveAuthorColors": "비활성 저자의 색상 흐리기",
"pad.settings.linenocheck": "줄 번호",
"pad.settings.rtlcheck": "우횡서(오른쪽에서 왼쪽으로)입니까?",
"pad.settings.enforceSettings": "다른 사용자에게 설정 적용",
"pad.settings.enforcedNotice": "이 설정은 패드 제작자가 잠가 놓았습니다. 설정을 변경해야 하는 경우 패드 제작자에게 문의하세요.",
"pad.settings.fontType": "글꼴 종류:",
"pad.settings.fontType.normal": "보통",
"pad.settings.language": "언어:",
"pad.settings.deletePad": "패드 삭제",
"pad.delete.confirm": "정말로 이 패드를 삭제하겠습니까?",
"pad.deletionToken.modalTitle": "패드 삭제 토큰을 저장",
"pad.deletionToken.modalBody": "이 토큰은 브라우저 세션이 종료되거나 기기를 변경했을 때 이 패드를 삭제할 수 있는 유일한 방법입니다. 안전한 곳에 저장해 두세요. 이 토큰은 여기에 딱 한 번만 표시됩니다.",
"pad.deletionToken.copy": "복사",
"pad.deletionToken.copied": "복사됨",
"pad.deletionToken.acknowledge": "저장했습니다",
"pad.deletionToken.deleteWithToken": "토큰으로 패드 삭제",
"pad.deletionToken.tokenFieldLabel": "패드 삭제 토큰",
"pad.deletionToken.tokenValueLabel": "패드 삭제 토큰(읽기 전용)",
"pad.deletionToken.invalid": "이 패드에 유효한 토큰이 아닙니다.",
"pad.deletionToken.notCreator": "당신은 이 패드의 제작자가 아니므로 삭제할 수 없습니다.",
"pad.settings.about": "정보",
"pad.settings.poweredBy": "제공:",
"pad.importExport.import_export": "가져오기/내보내기",
"pad.importExport.import": "텍스트 파일이나 문서 올리기",
"pad.importExport.importSuccessful": "성공!",
"pad.importExport.export": "다음으로 현재 패드 내보내기:",
"pad.importExport.exportetherpad": "Etherpad",
"pad.importExport.exportetherpad": "이더패드",
"pad.importExport.exporthtml": "HTML",
"pad.importExport.exportplain": "일반 텍스트",
"pad.importExport.exportword": "Microsoft 워드",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format, 개방형 문서 형식)",
"pad.importExport.exportetherpada.title": "이더패드 형식으로 내보내기",
"pad.importExport.exporthtmla.title": "HTML로 내보내기",
"pad.importExport.exportplaina.title": "일반 텍스트로 내보내기",
"pad.importExport.exportworda.title": "Microsoft Word 파일로 내보내기",
"pad.importExport.exportpdfa.title": "PDF로 내보내기",
"pad.importExport.exportopena.title": "ODF(Open Document Format) 형식으로 내보내기",
"pad.importExport.noConverter.innerHTML": "일반 텍스트나 HTML 형식으로만 가져올 수 있습니다. 고급 가져오기 기능에 대해서는 <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">리브레오피스를 설치</a>하세요.",
"pad.modals.connected": "연결함.",
"pad.modals.reconnecting": "내 패드에 다시 연결하는 중...",
"pad.modals.forcereconnect": "강제로 다시 연결",
@ -181,9 +371,23 @@
"timeslider.pageTitle": "{{appTitle}} 시간슬라이더",
"timeslider.toolbar.returnbutton": "패드로 돌아가기",
"pad.historyMode.banner": "역사 보는 중",
"pad.historyMode.return": "라이브로 돌아가기",
"pad.historyMode.revisionLabel": "판 {{rev}}",
"pad.historyMode.controlsLabel": "패드 기록 제어",
"pad.historyMode.sliderLabel": "패드 판",
"pad.historyMode.settings.title": "역사 재생",
"pad.historyMode.settings.follow": "패드 콘텐츠의 갱신 주시하기",
"pad.historyMode.settings.followShort": "팔로우",
"pad.historyMode.followOn": "패드 변경 사항 추적중 — 추적 중지하려면 클릭",
"pad.historyMode.followOff": "패드 변경 사항 추적 안함 — 추적하려면 클릭",
"pad.historyMode.settings.playbackSpeed": "재생 속도:",
"pad.historyMode.chat.replayHeader": "{{time}} 시점의 채팅",
"pad.historyMode.users.authorsHeader": "이 판의 작성자",
"pad.editor.skipToContent": "편집기로 바로 가기",
"pad.editor.keyboardHint": "Esc 키를 눌러 편집기를 종료하세요. Alt+F9 키를 눌러 도구 모음에 액세스하세요.",
"pad.editor.toolbar.formatting": "도구 모음 설정",
"pad.editor.toolbar.actions": "패드 작업 도구 모음",
"pad.editor.toolbar.showMore": "도구 모음 버튼 더 보기",
"timeslider.toolbar.authors": "작성자:",
"timeslider.toolbar.authorsList": "저자 없음",
"timeslider.toolbar.exportlink.title": "내보내기",
@ -217,6 +421,7 @@
"pad.savedrevs.timeslider": "당신은 타임슬라이더를 통해 저장된 버전을 볼 수 있습니다",
"pad.userlist.entername": "이름을 입력하세요",
"pad.userlist.unnamed": "이름없음",
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}}명의 연결된 사용자, other: {{count}}명의 연결된 사용자 ]}",
"pad.editbar.clearcolors": "전체 문서의 작성자 표시 색상을 지우시겠습니까? 이 작업은 취소할 수 없습니다",
"pad.impexp.importbutton": "지금 가져오기",
"pad.impexp.importing": "가져오는 중...",
@ -227,5 +432,6 @@
"pad.impexp.importfailed": "가져오기를 실패했습니다",
"pad.impexp.copypaste": "복사하여 붙여넣으세요",
"pad.impexp.exportdisabled": "{{type}} 형식으로 내보내기가 비활성화되어 있습니다. 자세한 내용은 시스템 관리자에게 문의하시기 바랍니다.",
"pad.impexp.maxFileSize": "파일의 용량이 너무 큽니다. 가져올 파일의 허용 크기를 늘리려면 사이트 관리자에게 문의하십시오"
"pad.impexp.maxFileSize": "파일의 용량이 너무 큽니다. 가져올 파일의 허용 크기를 늘리려면 사이트 관리자에게 문의하십시오",
"pad.social.description": "모두가 실시간으로 편집할 수 있는 공동 작업 문서입니다."
}

View file

@ -18,6 +18,7 @@
"admin_pads.col.users": "Benotzer",
"admin_pads.confirm_button": "OK",
"admin_pads.empty_never_edited": "eidel · nach ni geännert",
"admin_pads.error_dialog_description": "Et ass e Feeler geschitt.",
"admin_pads.error_prefix": "Feeler",
"admin_pads.filter.active": "Aktiv",
"admin_pads.filter.empty": "Eidel",
@ -57,6 +58,7 @@
"admin_settings": "Astellungen",
"admin_settings.current_save.value": "Astellunge späicheren",
"admin_settings.invalid_json": "Ongültegen JSON",
"admin_settings.toast.auth_error": "Dir sidd net als Admin authentifizéiert. Loggt Iech wgl. nei an.",
"admin_settings.env_pill.runtime_label": "aktive Wäert",
"admin_settings.page-title": "Astellungen - Etherpad",
"admin_settings.save_error": "Feeler beim Späichere vun den Astellungen",

View file

@ -8,6 +8,7 @@
},
"admin.page-title": "Администраторска управувачница — Etherpad",
"admin.loading": "Вчитувам…",
"admin.loading_description": "Почекајте додека се вчита страницата.",
"admin.toggle_sidebar": "Префрли страничник",
"admin.shout": "Општење",
"admin_shout.online_one": "Моментално има {{count}} корисник на линија",
@ -27,7 +28,11 @@
"admin_pads.col.revisions": "Преработки",
"admin_pads.col.users": "Корисници",
"admin_pads.confirm_button": "ОК",
"admin_pads.create_pad_dialog_description": "Изберете име на новата тетратка.",
"admin_pads.delete_pad_dialog_description": "Потврдете или откажете го бришењето на тетратката.",
"admin_pads.delete_pad_dialog_title": "Избриши тетратка",
"admin_pads.empty_never_edited": "празно · никогаш неуредено",
"admin_pads.error_dialog_description": "Се појави грешка.",
"admin_pads.error_prefix": "Грешка",
"admin_pads.filter.active": "Активно",
"admin_pads.filter.all": "Сите",
@ -111,16 +116,52 @@
"admin_plugins_info.plugins": "Воспоставени приклучоци",
"admin_plugins_info.page-title": "Информации за приклучоци — Etherpad",
"admin_plugins_info.search_placeholder": "Пребарај пресретник или дел…",
"admin_plugins_info.subtitle": "Системска дијагностика: воспоставена верзија, регистрирани делови и пресретници.",
"admin_plugins_info.tab_client": "Клиент",
"admin_plugins_info.tab_server": "Опслужувач",
"admin_plugins_info.up_to_date": "Подновено",
"admin_plugins_info.update_available": "Достапна поднова: {{version}}",
"admin_plugins_info.version": "Верзија на Etherpad",
"admin_plugins_info.version_latest": "Најнова достапна верзија",
"admin_plugins_info.version_number": "Број на верзијата",
"admin_settings": "Нагодувања",
"admin_settings.create_pad": "Создај тетратка",
"admin_settings.current": "Тековна поставеност",
"admin_settings.current_example-devel": "Предлошка за примерни разработни нагодувања",
"admin_settings.current_example-prod": "Предлошка за примерни производни нагодувања",
"admin_settings.current_restart.value": "Пушти го Etherpad одново",
"admin_settings.current_save.value": "Зачувај нагодувања",
"admin_settings.invalid_json": "Неважечки JSON",
"admin_settings.current_test.value": "Провери го JSON",
"admin_settings.current_prettify.value": "Разубави го JSON",
"admin_settings.toast.saved": "Нагодувањата се успешно зачувани.",
"admin_settings.toast.save_failed": "Зачувувањето на успеа: не можев да запишам во settings.json.",
"admin_settings.toast.json_invalid": "Синтаксна грешка: проверете запирки, загради и наводници.",
"admin_settings.toast.disconnected": "Не можам да зачуван: не сте поврзани на опслужувачот.",
"admin_settings.toast.validation_ok": "JSON е важечки.",
"admin_settings.toast.validation_failed": "JSON е неважечки: исправете ги синтаксните грешки.",
"admin_settings.toast.prettify_failed": "Не можам да разубавам: прво поправете ги синтаксните грешки.",
"admin_settings.prettify_confirm": "Разубавувањето ќе ги отстрани сите коментари. Да продолжам?",
"admin_settings.mode.form": "Образец",
"admin_settings.mode.raw": "Сирово",
"admin_settings.mode.effective": "На дело",
"admin_settings.mode.effective_tooltip": "Преглед само за читање на вредностите кои Etherpad всушност во моментов ги користи по околински променливо заменување. Тајните се скриени.",
"admin_settings.mode.aria_label": "Уредувачки режим",
"admin_settings.envvar_banner.title": "Оваа податотека е предлошка, не поставките во живо.",
"admin_settings.envvar_banner.body": "Пополнувачите како ${VAR:default} се заменуваат во складот при покревање; никогаш не се запишуваат назад во оваа податотека. Уредете околински променливи во вашата околина (Docker compose, systemd, .env) за да ја измените произлезената вредност, или пак да го замените пополнувачот тука со буквална низа. Прејдете на јазичето „На дело“ за да видите што во тековно користи Etherpad.",
"admin_settings.toast.auth_error": "Не сте заверени како администратор. Најавете се повторно.",
"admin_settings.section.general": "Општо",
"admin_settings.parse_error.title": "Не можам да го расчленам settings.json",
"admin_settings.parse_error.cta": "Префрлете се на сирово за да уредувате",
"admin_settings.env_pill.tooltip": "Чита од околинската променлива {{variable}}. Вредноста подолу се користи кога нема зададено ништо за {{variable}}.",
"admin_settings.env_pill.default_label": "по основно",
"admin_settings.env_pill.input_aria": "Стандардна вредност за {{variable}}",
"admin_settings.env_pill.runtime_label": "активна вредност",
"admin_settings.env_pill.runtime_tooltip": "Etherpad тековно ја користи оваа вредност, изведена од {{variable}} или основно зададената.",
"admin_settings.env_pill.redacted_tooltip": "Etherpad користи вредност за {{variable}}, но таа е скриена бидејќи е тајна.",
"admin_settings.page-title": "Нагодувања — Etherpad",
"admin_settings.save_error": "Грешка при зачувувањето на нагодувањата",
"admin_settings.saved_success": "Успешно зачувани нагодувањата",
"update.banner.title": "Достапна е надградба",
"update.banner.body": "Достапен е Etherpad {{latest}} (ја користите {{current}}).",
"update.banner.cta": "Погл. надградба",
@ -132,8 +173,20 @@
"update.page.tier": "Степен на надградба",
"update.page.changelog": "Дневник на измени",
"update.page.up_to_date": "Ја користите најновата верзија.",
"update.page.disabled": "Проверките за поднова се оневозможени (updates.tier = „off“).",
"update.page.unauthorized": "Не сте овластени да го гледате статусот на подновеност.",
"update.page.error": "Не можев да го вчитам статусот на подновеност (статус {{status}}).",
"update.badge.severe": "Etherpad на овој опслужувач е многу застарен. Известете го администраторот.",
"update.badge.vulnerable": "Etherpad на овој опслужувач работи на верзија со познати безбедносни проблеми. Известете го администраторот.",
"update.page.apply": "Примени поднова",
"update.page.cancel": "Откажи",
"update.page.acknowledge": "Прифати",
"update.page.log": "Дневник на поднови (барем 200 редови)",
"update.page.execution": "Статус",
"update.page.policy.install-method-not-writable": "Подновите од администраторскиот посредник бараат Git-воспоставка. Подновете преку вашиот управувач со пакети.",
"update.page.policy.rollback-failed-terminal": "Претходната поднова не успеа и не можеше да се отповика. Стиснете на „Прифати“ откако воспоставката ќе стане задрава за да отклучите.",
"update.page.policy.up-to-date": "Ја користите најновата верзија.",
"update.page.policy.tier-off": "Подновие се оневозможени (updates.tier = „off“).",
"index.newPad": "Нова тетратка",
"index.settings": "Нагодувања",
"index.transferSessionTitle": "Префрли седница",

View file

@ -20,6 +20,7 @@
},
"admin.page-title": "Beheerdashboard Etherpad",
"admin.loading": "Bezig met laden...",
"admin.loading_description": "Even geduld, de pagina wordt geladen.",
"admin.toggle_sidebar": "Zijbalk omschakelen",
"admin.shout": "Communicatie",
"admin_shout.online_one": "Er zijn momenteel {{count}} gebruikers online",
@ -39,7 +40,11 @@
"admin_pads.col.revisions": "Versies",
"admin_pads.col.users": "Gebruikers",
"admin_pads.confirm_button": "OK",
"admin_pads.create_pad_dialog_description": "Kies een naam voor de nieuwe notitie.",
"admin_pads.delete_pad_dialog_description": "Verwijderen van de notitie bevestigen of annuleren.",
"admin_pads.delete_pad_dialog_title": "Notitie verwijderen",
"admin_pads.empty_never_edited": "leeg · nooit bewerkt",
"admin_pads.error_dialog_description": "Er is een fout opgetreden.",
"admin_pads.error_prefix": "Fout",
"admin_pads.filter.active": "Actief",
"admin_pads.filter.all": "Alle",
@ -151,7 +156,12 @@
"admin_settings.prettify_confirm": "Met opmaken worden alle opmerkingen verwijderd. Doorgaan?",
"admin_settings.mode.form": "Formulier",
"admin_settings.mode.raw": "Ruw",
"admin_settings.mode.effective": "Actief",
"admin_settings.mode.effective_tooltip": "Alleen-lezen weergave van de waarden die Etherpad momenteel daadwerkelijk gebruikt, na vervanging van omgevingsvariabelen. Geheimen zijn verwijderd.",
"admin_settings.mode.aria_label": "Bewerkingsmodus",
"admin_settings.envvar_banner.title": "Dit bestand is een sjabloon, niet de daadwerkelijke configuratie.",
"admin_settings.envvar_banner.body": "Plaatsaanduidingen zoals ${VAR:default} worden bij het opstarten in het geheugen geladen; ze worden nooit naar dit bestand teruggeschreven. Bewerk omgevingsvariabelen in uw omgeving (Docker Compose, systemd, .env) om de opgeloste waarde te wijzigen, of vervang de plaatsaanduiding hier door een letterlijke waarde. Ga naar het tabblad Actief om te zien wat Etherpad momenteel gebruikt.",
"admin_settings.toast.auth_error": "U bent niet aangemeld als beheerder. Meld alstublieft opnieuw aan.",
"admin_settings.section.general": "Algemeen",
"admin_settings.parse_error.title": "Kan settings.json niet verwerken",
"admin_settings.parse_error.cta": "Overschakelen naar ruw bewerken",
@ -186,7 +196,7 @@
"update.page.log": "Updatelogboek (laatste 200 regels)",
"update.page.execution": "Status",
"update.page.policy.install-method-not-writable": "Updates via de beheerdersinterface vereisen een Git-installatie. Update via uw pakketbeheerder.",
"update.page.policy.rollback-failed-terminal": "Een eerdere update is mislukt en kon niet ongedaan worden gemaakt. Druk op 'Bevestigen' nadat de installatie succesvol is voltooid om de vergrendeling op te heffen.",
"update.page.policy.rollback-failed-terminal": "Een eerdere update is mislukt en kon niet ongedaan worden gemaakt. Druk op 'Bevestigen' nadat de installatie is voltooid om de vergrendeling op te heffen.",
"update.page.policy.up-to-date": "U gebruikt de nieuwste versie.",
"update.page.policy.tier-off": "Updates zijn uitgeschakeld (updates.tier = \"off\").",
"update.page.policy.maintenance-window-missing": "Niveau 4 (autonoom) vereist een onderhoudsvenster. Stel updates.maintenanceWindow in settings.json in om autonoom bijwerken in te schakelen.",
@ -234,6 +244,7 @@
"index.copyLinkButton": "Koppeling naar klembord kopiëren",
"index.transferToSystem": "3. Kopieer de sessie naar het nieuwe systeem",
"index.transferToSystemDescription": "Open de gekopieerde koppeling in de doelbrowser of het doelapparaat om uw sessie over te zetten.",
"index.code": "Code",
"index.transferSessionDescription": "Zet uw huidige sessie over naar uw browser of apparaat door op de onderstaande knop te klikken. Hiermee wordt een koppeling naar een pagina gekopieerd die uw sessie overzet wanneer deze in de gewenste browser of op het gewenste apparaat wordt geopend.",
"index.createOpenPad": "Notitie openen met de naam",
"index.openPad": "open een bestaande notitie met de naam:",
@ -295,7 +306,7 @@
"pad.deletionToken.tokenFieldLabel": "Padverwijderingstoken",
"pad.deletionToken.tokenValueLabel": "Uw padverwijderingstoken (alleen-lezen)",
"pad.deletionToken.invalid": "Dat token is niet geldig voor deze notitie.",
"pad.deletionToken.notCreator": "U bent niet de maker van dit notitieblok, dus je kunt het niet verwijderen.",
"pad.deletionToken.notCreator": "U bent niet de maker van dit notitieblok, dus u kunt het niet verwijderen.",
"pad.settings.about": "Over",
"pad.settings.poweredBy": "Mogelijk gemaakt door",
"pad.importExport.import_export": "Importeren/exporteren",

View file

@ -7,13 +7,71 @@
},
"admin.page-title": "Cruscòt d'aministrator - Etherpad",
"admin.loading": "Antramentre ch'as caria…",
"admin.loading_description": "Për piasì, ch'a speta antramentre che la pàgina as caria.",
"admin.toggle_sidebar": "Ativé o disativé la bara lateral",
"admin.shout": "Comunicassion",
"admin_shout.online_one": "Al moment a-i é {{count}} utent an linia",
"admin_shout.online_other": "Al moment a-i son {{count}} utent an linia",
"admin_shout.sticky_toggle": "Cangé ël mëssagi an evidensa",
"admin_login.title": "Etherpad",
"admin_login.username": "Stranòm",
"admin_login.password": "Ciav",
"admin_login.submit": "Intré ant ël sistema",
"admin_login.failed": "Falì a intré ant ël sistema",
"admin_pads.all_pads": "Tuti ij blochèt",
"admin_pads.bulk.cleanup_history": "Dëscancelé la stòria",
"admin_pads.bulk.clear_selection": "Dëscancelé la selession",
"admin_pads.bulk.delete": "Dëscancelé",
"admin_pads.cancel": "Anulé",
"admin_pads.col.pad": "Blochèt",
"admin_pads.col.revisions": "Revision",
"admin_pads.col.users": "Utent",
"admin_pads.confirm_button": "Va bin",
"admin_pads.create_pad_dialog_description": "Serne un nòm për ël neuv blochèt.",
"admin_pads.delete_pad_dialog_description": "Confirmé o anulé la dëscancelassion dël blochèt.",
"admin_pads.delete_pad_dialog_title": "Dëscancelé ël blochèt",
"admin_pads.empty_never_edited": "veuid · mai modificà",
"admin_pads.error_dialog_description": "A l'é rivaje n'eror.",
"admin_pads.error_prefix": "Eror",
"admin_pads.filter.active": "Ativ",
"admin_pads.filter.all": "Tuti",
"admin_pads.filter.empty": "Veuid",
"admin_pads.filter.recent": "Costa sman-a",
"admin_pads.filter.stale": "Blocà (>1a)",
"admin_pads.open": "Duverté",
"admin_pads.pagination.next": "Sucessiv",
"admin_pads.pagination.previous": "Precedent",
"admin_pads.refresh": "Rinfrësché",
"admin_pads.relative.days": "{{count}}d fa",
"admin_pads.relative.hours": "{{count}}o fa",
"admin_pads.relative.just_now": "pròpi adess",
"admin_pads.relative.minutes": "{{count}}m fa",
"admin_pads.relative.months": "{{count}}mè fa",
"admin_pads.relative.weeks": "{{count}}s fa",
"admin_pads.relative.years": "{{count}}a fa",
"admin_pads.revisions_count": "{{count}} revision",
"admin_pads.selected_count": "{{count}} selessionà",
"admin_pads.show": "Smon-e",
"admin_pads.sort.name": "Nòm (AZ)",
"admin_pads.sort.revision_number": "Revision",
"admin_pads.sort.user_count": "Utent",
"admin_pads.stats.across_pads": "a travers tuti ij blochèt",
"admin_pads.stats.active_users": "Utent ativ",
"admin_pads.stats.empty_pads": "Blochèt veuid",
"admin_pads.stats.last_activity": "Ùltima atività",
"admin_pads.stats.no_active_users": "Gnun utent ativ",
"admin_pads.stats.revisions_zero": "0 revision",
"admin_pads.stats.total": "Blochèt totaj",
"admin_pads.stats.users_active": "{{count}} ativ al moment",
"admin_pads.subtitle": "Vista global ëd tuti ij blochèt ansima a costa istansa d'Etherpad. Arserché, polidé, duverté.",
"admin_plugins": "Mansé dj'anstalassion",
"admin_plugins.available": "Anstalassion disponìbij",
"admin_plugins.available_not-found": "Gnun-e anstalassion trovà.",
"admin_plugins.available_fetching": "Arcuperassion…",
"admin_plugins.available_install.value": "Anstalé",
"admin_plugins.available_search.placeholder": "Arserca d'aplicassion da anstalé",
"admin_plugins.check_updates": "Controlé për dj'agiornament",
"admin_plugins.core_count": "{{count}} nos",
"admin_plugins.description": "Descrission",
"admin_plugins.installed": "Aplicassion anstalà",
"admin_plugins.installed_fetching": "Arcuperassion dj'aplicassion anstalà…",

View file

@ -5,19 +5,113 @@
"شاه زمان پټان"
]
},
"admin.page-title": "پازوال ليددړه - اېترپيډ",
"admin.loading": "بارېږي...",
"admin.loading_description": "مهرباني وکړئ د مخ بارېدلو پرمهال په تمه شئ.",
"admin.toggle_sidebar": "څنگپټه بدلول",
"admin.shout": "اړيکتيا",
"admin_shout.online_one": "اوسمهال {{count}} کارنان پرليکه دي",
"admin_shout.online_other": "اوسمهال {{count}} کارنان پرليکه دي",
"admin_login.title": "اېترپيډ",
"admin_login.username": "کارن‌نوم",
"admin_login.password": "پټنوم",
"admin_login.submit": "ننوتل",
"admin_login.failed": "ننوتل نابرياله شول",
"admin_pads.all_pads": "ټولې ليکچې",
"admin_pads.bulk.cleanup_history": "پېښليک سپينول",
"admin_pads.bulk.clear_selection": "ټاکنه سپاکول",
"admin_pads.bulk.delete": "ړنگول",
"admin_pads.cancel": "ناگارل",
"admin_pads.col.pad": "ليکچه",
"admin_pads.col.revisions": "بياليدنې",
"admin_pads.col.users": "کارنان",
"admin_pads.confirm_button": "ښه",
"admin_pads.create_pad_dialog_description": "نوې ليکچې ته نوم خوښ کړئ.",
"admin_pads.delete_pad_dialog_description": "د ليکچې د ړنگول تاييد يا ناگارل",
"admin_pads.delete_pad_dialog_title": "ليکچه ړنگول",
"admin_pads.empty_never_edited": "تش . هېڅکله سم شوی نه دی",
"admin_pads.error_dialog_description": "یوه تېروتنه رامنځته شوې.",
"admin_pads.error_prefix": "تېروتنه",
"admin_pads.filter.active": "کارنده",
"admin_pads.filter.all": "ټول",
"admin_pads.filter.empty": "تش",
"admin_pads.filter.recent": "دا اوونۍ",
"admin_pads.open": "پرانېستل",
"admin_pads.pagination.next": "راتلونکی",
"admin_pads.pagination.previous": "مخکنی",
"admin_pads.refresh": "تاندول",
"admin_pads.relative.days": "{{count}} ورځې دمخه",
"admin_pads.relative.hours": "{{count}} گړۍ دمخه",
"admin_pads.relative.just_now": "همدا اوس",
"admin_pads.relative.minutes": "{{count}} دقيقې دمخه",
"admin_pads.relative.months": "{{count}} مياشتې دمخه",
"admin_pads.relative.weeks": "{{count}} اوونۍ دمخه",
"admin_pads.relative.years": "{{count}} کاله دمخه",
"admin_pads.revisions_count": "{{count}} بياليدنې",
"admin_pads.selected_count": "{{count}} غوره‌شوي",
"admin_pads.show": "ښودل",
"admin_pads.sort.name": "نوم (ا ـ ي)",
"admin_pads.sort.revision_number": "بياليدنې",
"admin_pads.sort.user_count": "کارنان",
"admin_pads.stats.across_pads": "په ټولو ليکچو کې",
"admin_pads.stats.active_users": "کارنده کارنان",
"admin_pads.stats.empty_pads": "تشې ليکچې",
"admin_pads.stats.last_activity": "وروستۍ کړنه",
"admin_pads.stats.no_active_users": "هېڅ کارنده کارنان نشته",
"admin_pads.stats.revisions_zero": "۰ بياليدنې",
"admin_pads.stats.total": "ټولټال ليکچې",
"admin_pads.stats.users_active": "وسمهال {{count}} کارنده",
"admin_plugins.description": "څرگنداوی",
"admin_plugins.disables.label": "ناچارن‌شوي:",
"admin_plugins.last-update": "وروستۍ هم‌مهالېدنه",
"admin_plugins.name": "نوم",
"admin_plugins.sort.last_updated": "وروستۍ هم‌مهالېدنه",
"admin_plugins.sort.name": "نوم (ا ـ ي)",
"admin_plugins.sort.version": "بلبڼه",
"admin_plugins.update_tooltip": "هم‌مهالول",
"admin_plugins.updates_available": "هم‌مهالېدنې د لاسرسي وړ دي",
"admin_plugins.update_now": "هم‌مهالول",
"admin_plugins.version": "بل‌بڼه",
"admin_plugins_info": "ستونزو اواري مالومات",
"admin_plugins_info.copy_value": "{{label}} لمېسل",
"admin_plugins_info.hooks": "ځای‌پرځای‌شوي چنگکونه",
"admin_plugins_info.tab_client": "غوښتندوی",
"admin_plugins_info.tab_server": "پالنگر",
"admin_plugins_info.up_to_date": "هم مهاله شوی",
"admin_plugins_info.update_available": "هم‌مهالېدنه شتون لري: {{version}}",
"admin_plugins_info.version_latest": "وروستۍ د لاسرسي وړ بل‌بڼه",
"admin_plugins_info.version_number": "بل‌بڼې شمېره",
"admin_settings": "اوڼنې",
"admin_settings.create_pad": "ليکنه جوړول",
"admin_settings.current": "اوسنی ترتيب",
"admin_settings.current_example-devel": "د پراختيااوڼنې کينډۍ بېلگه",
"admin_settings.current_example-prod": "د توليد اوڼنې کينډۍ بېلگه",
"admin_settings.current_save.value": "اوڼنې خوندي‌کول",
"admin_settings.mode.form": "فورمه",
"admin_settings.mode.raw": "اومه",
"admin_settings.mode.effective": "اغېزمن",
"admin_settings.mode.aria_label": "سمونگر ونگ‌ډول",
"admin_settings.section.general": "ټولواله",
"admin_settings.env_pill.default_label": "تلواله",
"admin_settings.env_pill.runtime_label": "کارنده ارزښت",
"update.banner.title": "هم‌مهالېدنه شتون لري",
"update.banner.cta": "هم‌مهالېدنه کتل",
"update.page.current": "اوسنۍ بلبڼه",
"update.page.latest": "اوسنۍ بل‌بڼه",
"update.page.apply": "هم‌مهالېدنه پلې‌کول",
"update.page.cancel": "ناگارل",
"update.page.execution": "دريځ",
"update.execution.executing": "هم‌مهالول...",
"update.execution.pending-verification": "د تاييد په تمه",
"update.execution.verified": "تایید شوی",
"index.newPad": "نوې ليکچه",
"index.settings": "اوڼنې",
"index.transferSessionTitle": "لېږد غونډه",
"index.receiveSessionTitle": "غونډې ته لاسرسی",
"index.transferSession": "۱. لېږد غونډه",
"index.copyLink": "۲. تړونی لمېسل",
"index.copyLinkButton": "تړونی ټينگدړې ته لمېسل",
"index.code": "کوډ",
"index.createOpenPad": "ليکچه د نوم له مخې پرانېستل",
"index.recentPads": "وروستۍ ليکچې",
"index.recentPadsEmpty": "هېڅ وروستۍ ليکچې ونه موندل شوې.",
@ -34,14 +128,18 @@
"pad.toolbar.undo.title": "ناکړل (Ctrl-Z)",
"pad.toolbar.redo.title": "بياکړل (Ctrl-Y)",
"pad.toolbar.clearAuthorship.title": "د ليکوالۍ رنگونه سپينول (Ctrl+Shift+C)",
"pad.toolbar.savedRevision.title": "مخکتنه خوندي کول",
"pad.toolbar.savedRevision.title": "بياليدنه خوندي‌کول",
"pad.toolbar.settings.title": "اوڼنې",
"pad.toolbar.home.title": "بېرته کور ته",
"pad.colorpicker.save": "خوندي کول",
"pad.colorpicker.cancel": "ناگارل",
"pad.loading": "رابرسېرېږي...",
"pad.settings.padSettings": "د ليکچې اوڼنې",
"pad.settings.title": "اوڼنې",
"pad.settings.padSettings": "د ليکچې-پراخ اوڼنې",
"pad.settings.userSettings": "کارن اوڼنې",
"pad.settings.myView": "زما کتنه",
"pad.settings.disablechat": "بنډار ناچارنول",
"pad.settings.darkMode": "تياره ونگ‌ډول",
"pad.settings.stickychat": "تل په پردې بانډار کول",
"pad.settings.chatandusers": "کارنان او بانډار ښکاره کول",
"pad.settings.colorcheck": "د ليکوالۍ رنگونه",
@ -49,52 +147,71 @@
"pad.settings.fontType": "ليکبڼې ډول:",
"pad.settings.fontType.normal": "نورمال",
"pad.settings.language": "ژبه:",
"pad.settings.deletePad": "ليکچه ړنگول",
"pad.delete.confirm": "ايا تاسو په رښتيا دا ليکچه ړنگول غواړئ؟",
"pad.deletionToken.copy": "لمېسل",
"pad.deletionToken.copied": "ولمېسل شو",
"pad.deletionToken.acknowledge": "ما دا خوندي کړی دی",
"pad.settings.about": "په‌اړه",
"pad.settings.poweredBy": "چلوونکی",
"pad.importExport.import_export": "رالېږدول/بهرلېږل",
"pad.importExport.importSuccessful": "بريالی شو!",
"pad.importExport.exportetherpad": "اېترپډ",
"pad.importExport.export": "اوسنۍ ليکچه په لاندې ډول بهرلېږل:",
"pad.importExport.exportetherpad": "اېترپيډ",
"pad.importExport.exporthtml": "اچ ټي ام اېل",
"pad.importExport.exportplain": "ساده متن",
"pad.importExport.exportplain": "ساده ليک",
"pad.importExport.exportword": "مايکروسافټ ورډ",
"pad.importExport.exportpdf": "پي ډي اېف",
"pad.importExport.exportopen": "ODF (اوپن ډاکومنټ فارمټ)",
"pad.importExport.exporthtmla.title": "اېچ‌ټي‌اېم‌اېل په توگه بهرلېږل",
"pad.importExport.exportplaina.title": "ساده ليک په توگه بهرلېږل",
"pad.importExport.exportworda.title": "مايکروسافټ ورډ په توگه بهرلېږل",
"pad.importExport.exportpdfa.title": "پي‌ډي‌اېف په توگه بهرلېږل",
"pad.modals.connected": "اړيکمن شو.",
"pad.modals.reconnecting": "بېرته ستاسو ليکې سره پېلېږي...",
"pad.modals.forcereconnect": "په زوره بيانښلونه",
"pad.modals.reconnecttimer": "د بيانښلولو هڅه‌کول",
"pad.modals.cancel": "ناگارل",
"pad.modals.userdup": "په بله کړکۍ کې پرانېستل شو",
"pad.modals.slowcommit.explanation": "پالنگر ځواب نه وايي.",
"pad.modals.slowcommit.cause": "دا کېدای شي د جال د اړيکتيايي ستونزو په سبب وي.",
"pad.modals.deleted": "ړنگ شو.",
"pad.gritter.unacceptedCommit.title": "ناخوندي شوی سمون",
"pad.share": "دا ليکچه شريکول",
"pad.share.readonly": "يوازې لوستنه",
"pad.share.link": "تړونی",
"pad.share.emebdcode": "يو آر اېل ټومبل",
"pad.share.emebdcode": "وېبتړ ټومبل",
"pad.chat": "بانډار",
"pad.chat.loadmessages": "نور پيغامونه برسېرول",
"pad.historyMode.banner": "پېښليک کتل",
"pad.historyMode.revisionLabel": "بياليدنه {{rev}}",
"pad.historyMode.sliderLabel": "د ليکچې بياليدنه",
"pad.historyMode.settings.followShort": "څارل",
"timeslider.toolbar.authors": "ليکوال:",
"timeslider.toolbar.authorsList": "بې ليکواله",
"timeslider.toolbar.exportlink.title": "صادرول",
"timeslider.version": "بڼه {{version}}",
"timeslider.toolbar.exportlink.title": "بهرلېږل",
"timeslider.version": لبڼه {{version}}",
"timeslider.saved": "خوندي شو {{month}} {{day}}, {{year}}",
"timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}",
"timeslider.month.january": "جنوري",
"timeslider.month.february": بروري",
"timeslider.month.february": ېبروري",
"timeslider.month.march": "مارچ",
"timeslider.month.april": "اپرېل",
"timeslider.month.may": ۍ",
"timeslider.month.april": "اپریل",
"timeslider.month.may": ی",
"timeslider.month.june": "جون",
"timeslider.month.july": "جولای",
"timeslider.month.august": "اگسټ",
"timeslider.month.september": "سېپتمبر",
"timeslider.month.october": "اکتوبر",
"timeslider.month.november": "نومبر",
"timeslider.month.december": يسمبر",
"timeslider.month.december": ېسمبر",
"timeslider.unnamedauthors": "{{num}} بې نومه {[ډېرگړي(num) يو: ليکوال، نور: ليکوالان ]}",
"pad.savedrevs.marked": "اوس دا مخکتنه د يوې خوندي شوې مخکتنې په توگه په نښه شوه",
"pad.userlist.entername": "نوم مو ورکړۍ",
"pad.savedrevs.marked": "اوس دا بياليدنه د يوې خوندي شوې بياليدنې په توگه په نښه شوه",
"pad.userlist.entername": "نوم مو ورکړئ",
"pad.userlist.unnamed": "بې نومه",
"pad.impexp.importbutton": "اوس واردول",
"pad.impexp.importing": "په واردولو کې دی...",
"pad.impexp.importbutton": "اوس رالېږدول",
"pad.impexp.importing": "رالېدول...",
"pad.impexp.uploadFailed": "راپورته‌کول نابرياله شول، مهرباني وکړئ بيا هڅه وکړئ.",
"pad.impexp.importfailed": "رالېږدول نابرياله شو",
"pad.impexp.copypaste": "لطفاً لمېسل لېښل ترسره کړئ"
}

View file

@ -4,7 +4,8 @@
"AmaryllisGardener",
"CiphriusKane",
"John Reid",
"Nintendofan885"
"Nintendofan885",
"Tensama0415"
]
},
"index.newPad": "New Pad",
@ -72,7 +73,7 @@
"pad.modals.corruptPad.cause": "This micht be cause o ae wrang server confeeguration or some ither onexpected behavior. Please contact the service admeenistrater.",
"pad.modals.deleted": "Deletit.",
"pad.modals.deleted.explanation": "This pad has been hif't.",
"pad.modals.disconnected": "Ye'v been disconnected.",
"pad.modals.disconnected": "切断されました。",
"pad.modals.disconnected.explanation": "The connection til the server wis loast",
"pad.modals.disconnected.cause": "The server micht be onavailable. Please notify the service admeenistrater gif this continues tae happen.",
"pad.share": "Share this pad",

View file

@ -414,7 +414,13 @@ exports.appendChatMessage = async (padID: string, text: string|object, authorID:
time = Date.now();
}
// @TODO - missing getPadSafe() call ?
// Reject messages addressed to a pad that doesn't exist. Without this check
// the downstream padManager.getPad() would create the pad on demand with
// default content, so the documented {code:1,"padID does not exist"} result
// would never be returned.
if (!await padManager.doesPadExists(padID)) {
throw new CustomError('padID does not exist', 'apierror');
}
// save chat message to database and send message to all connected clients
await padMessageHandler.sendChatMessageToPadClients(new ChatMessage(text, authorID, time), padID);

View file

@ -590,25 +590,42 @@ class Pad {
Object.assign(this, value);
if ('pool' in value) this.pool = new AttributePool().fromJsonable(value.pool);
} else {
if (text == null) {
// Auto-generated default content (settings.defaultPadText or whatever a
// padDefaultContent hook substitutes) is not written by the user who
// happens to open the pad first, so the text must not carry their author
// attribute — otherwise the welcome text shows up in the creator's
// authorship colour (issue #7885). Track whether the text came from the
// default-content path so its insert op can be attributed to the system
// author.
const usedDefaultContent = (text == null);
if (usedDefaultContent) {
const context = {pad: this, authorId, type: 'text', content: settings.defaultPadText};
await hooks.aCallAll('padDefaultContent', context);
if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`);
text = exports.cleanText(context.content);
}
// When the initial pad text is non-empty but no authorId was
// supplied (internal getPad calls during HTTP API setup,
// padDefaultContent flows, plugin-driven pad creation), fall back
// to the stable system author so the initial changeset's insert
// op carries an `author` attribute. Mirrors the same substitution
// The author *attribute* applied to the initial text — i.e. what colours
// it in the editor — is the stable system author when the content is
// auto-generated default text (#7885), or when non-empty text was
// supplied without an authorId (internal getPad calls during HTTP API
// setup, plugin-driven pad creation). The latter keeps the insert op
// carrying an `author` attribute, mirroring the substitution
// setText/appendText already do via spliceText.
const effectiveAuthorId =
(text.length > 0 && !authorId) ? Pad.SYSTEM_AUTHOR_ID : authorId;
const firstAttribs = effectiveAuthorId
? [['author', effectiveAuthorId] as [string, string]]
const attribAuthorId =
((usedDefaultContent || !authorId) && text.length > 0)
? Pad.SYSTEM_AUTHOR_ID : authorId;
const firstAttribs = attribAuthorId
? [['author', attribAuthorId] as [string, string]]
: undefined;
// The *revision* author (revs:0 meta.author) stays the real creator so
// pad ownership is preserved: isPadCreator() / the pad-wide settings gate
// and the deletion token all key off getRevisionAuthor(0). Only when no
// author was supplied at all do we fall back to the system author, so the
// initial revision still records a stable, non-empty author.
const revisionAuthorId =
authorId || (text.length > 0 ? Pad.SYSTEM_AUTHOR_ID : '');
const firstChangeset = makeSplice('\n', 0, 0, text, firstAttribs, this.pool);
await this.appendRevision(firstChangeset, effectiveAuthorId);
await this.appendRevision(firstChangeset, revisionAuthorId);
}
this.padSettings = Pad.normalizePadSettings(this.padSettings);
await hooks.aCallAll('padLoad', {pad: this});

View file

@ -1471,7 +1471,15 @@ export const expressCreateServer = async (hookName: string, {app}: ArgsExpressTy
}
}
const fields = Object.assign({}, headers, params, query, formData);
// Merge with clear precedence: body > query > path params, matching the
// openapi.ts handler. Forward only the authorization header explicitly
// instead of merging all request headers into the field set.
const fields = Object.assign({}, params, query, formData);
if (headers && headers.authorization) {
// Match the openapi.ts handler: fall back to the header whenever the
// field value is falsy (absent or empty), not only when it is null.
fields.authorization = fields.authorization || headers.authorization;
}
if (mapping.has(method) && pathToFunction in mapping.get(method)!) {
const {apiVersion, functionName} = mapping.get(method)![pathToFunction]!

View file

@ -66,8 +66,11 @@ exports.expressCreateServer = (hookName: string, args: ArgsExpressType, cb: Func
// read file from file system
fs.readFile(pathname, function (err, data) {
if (err) {
// Log the detailed error server-side; return a generic message to the
// client rather than echoing the filesystem error.
console.error(`admin: error reading ${pathname}: ${err}`);
res.statusCode = 500;
res.end(`Error getting the file: ${err}.`);
res.end('Error getting the file.');
} else {
let dataToSend:Buffer|string = data
// if the file is found, set Content-type and send data

View file

@ -50,7 +50,17 @@ exports.socketio = (hookName: string, {io}: any) => {
io.of('/settings').on('connection', (socket: any) => {
// @ts-ignore
const {session: {user: {is_admin: isAdmin} = {}} = {}} = socket.conn.request;
if (!isAdmin) return;
if (!isAdmin) {
// Previously this branch silently returned, so a non-admin client
// (e.g. a misrouted Traefik / OIDC session that didn't carry the
// admin cookie) would connect, emit load/save, and get nothing
// back — no error toast, no way to tell the save was ignored. Emit
// a dedicated event the SPA can surface, then drop the socket.
socket.emit('admin_auth_error',
{message: 'Not authenticated as admin. Re-authenticate and retry.'});
socket.disconnect(true);
return;
}
socket.on('load', async (query: string): Promise<any> => {
let data;

View file

@ -84,7 +84,11 @@ const buildPreflightDeps = (installMethod: ReturnType<typeof getDetectedInstallM
}
},
pnpmOnPath: () => new Promise<boolean>((resolve) => {
const c = spawn('pnpm', ['--version'], {stdio: 'ignore'});
// pm_on_fail=ignore so a "packageManager" pin mismatch doesn't make pnpm
// exit non-zero (it would otherwise try to fetch the pinned build, which
// fails offline) and falsely report pnpm as absent. See #7911.
const c = spawn('pnpm', ['--version'],
{stdio: 'ignore', env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}});
c.on('close', (code) => resolve(code === 0));
c.on('error', () => resolve(false));
}),

View file

@ -9,6 +9,21 @@ import express from 'express';
import {format} from 'url'
import {ParsedUrlQuery} from "node:querystring";
import {MapArrayType} from "../types/MapType";
import crypto from "node:crypto";
// Small fixed delay applied to every failed interactive login, mirroring
// webaccess.authnFailureDelayMs, so failures take a consistent amount of time.
const OAUTH_LOGIN_FAILURE_DELAY_MS = 1000;
// Constant-time string comparison using crypto.timingSafeEqual. Unequal-length
// inputs short-circuit to false (that length difference is covered by the
// uniform failure delay below). The raw bytes are compared directly — the
// values are not hashed/stored, this only avoids a content-dependent compare.
const constantTimeEquals = (a: string, b: string): boolean => {
const ba = Buffer.from(String(a), 'utf8');
const bb = Buffer.from(String(b), 'utf8');
return ba.length === bb.length && crypto.timingSafeEqual(ba, bb);
};
const configuration: Configuration = {
scopes: ['openid', 'profile', 'email'],
@ -171,21 +186,28 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp
admin: boolean;
}
}
const usersArray1 = Object.keys(users).map((username) => ({
username,
...users[username]
}));
const account = usersArray1.find((user) => user.username === login as unknown as string && user.password === password as unknown as string);
const loginStr = String(login ?? '');
const passwordStr = String(password ?? '');
// Look up by own property only, then compare the password
// with constantTimeEquals.
const user = Object.prototype.hasOwnProperty.call(users, loginStr)
? users[loginStr] : undefined;
const passwordOk = user != null &&
constantTimeEquals(passwordStr, String(user.password));
const account = passwordOk ? {username: loginStr, ...user} : undefined;
if (!account) {
// Apply the failure delay and stop here (explicit break)
// so a failed login never reaches the grant branch.
await new Promise((resolve) =>
setTimeout(resolve, OAUTH_LOGIN_FAILURE_DELAY_MS));
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({error: "Invalid login"}));
break;
}
if (account) {
await oidc.interactionFinished(req, res, {
login: {accountId: account.username}
}, {mergeWithLastSubmission: false});
}
await oidc.interactionFinished(req, res, {
login: {accountId: account.username}
}, {mergeWithLastSubmission: false});
break;
}
case 'consent': {

View file

@ -1,58 +1,52 @@
import {LRUCache} from 'lru-cache';
import type {Adapter, AdapterPayload} from "oidc-provider";
const options = {
max: 500,
sizeCalculation: (item:any, key:any) => {
return 1
},
// for use with tracking overall storage size
sizeCalculation: (item: any, key: any) => 1,
maxSize: 5000,
// how long to live in ms
ttl: 1000 * 60 * 5,
// return stale items before removing from cache?
allowStale: false,
updateAgeOnGet: false,
updateAgeOnHas: false,
}
const epochTime = (date = Date.now()) => Math.floor(date / 1000);
const storage = new LRUCache<string,AdapterPayload|string[]|string>(options);
const storage = new LRUCache<string, AdapterPayload | string[] | string>(options);
function grantKeyFor(id: string) {
return `grant:${id}`;
}
function userCodeKeyFor(userCode:string) {
function userCodeKeyFor(userCode: string) {
return `userCode:${userCode}`;
}
class MemoryAdapter implements Adapter{
class MemoryAdapter implements Adapter {
private readonly name: string;
constructor(name:string) {
constructor(name: string) {
this.name = name;
}
key(id:string) {
key(id: string) {
return `${this.name}:${id}`;
}
destroy(id:string) {
destroy(id: string) {
const key = this.key(id);
const found = storage.get(key) as AdapterPayload | undefined;
const grantId = found?.grantId;
const found = storage.get(key) as AdapterPayload;
const grantId = found && found.grantId;
if (found?.userCode) {
storage.delete(userCodeKeyFor(found.userCode));
}
storage.delete(key);
if (grantId) {
const grantKey = grantKeyFor(grantId);
(storage.get(grantKey) as string[])!.forEach(token => storage.delete(token));
const tokens = storage.get(grantKey) as string[] | undefined;
tokens?.forEach(token => storage.delete(token));
storage.delete(grantKey);
}
@ -60,15 +54,20 @@ class MemoryAdapter implements Adapter{
}
consume(id: string) {
(storage.get(this.key(id)) as AdapterPayload)!.consumed = epochTime();
const key = this.key(id);
const payload = storage.get(key) as AdapterPayload | undefined;
if (payload) {
payload.consumed = epochTime();
storage.set(key, payload);
}
return Promise.resolve();
}
find(id: string): Promise<AdapterPayload | void | undefined> {
if (storage.has(this.key(id))){
return Promise.resolve<AdapterPayload>(storage.get(this.key(id)) as AdapterPayload);
if (storage.has(this.key(id))) {
return Promise.resolve(storage.get(this.key(id)) as AdapterPayload);
}
return Promise.resolve<undefined>(undefined)
return Promise.resolve(undefined);
}
findByUserCode(userCode: string) {
@ -76,26 +75,28 @@ class MemoryAdapter implements Adapter{
return this.find(id);
}
upsert(id: string, payload: {
iat: number;
exp: number;
uid: string;
kind: string;
jti: string;
accountId: string;
loginTs: number;
}, expiresIn: number) {
upsert(id: string, payload: AdapterPayload, expiresIn: number) {
const key = this.key(id);
storage.set(key, payload, {ttl: expiresIn * 1000});
if (payload.grantId) {
const grantKey = grantKeyFor(payload.grantId);
const grant = (storage.get(grantKey) as string[]) || [];
if (!grant.includes(key)) grant.push(key);
storage.set(grantKey, grant);
}
if (payload.userCode) {
storage.set(userCodeKeyFor(payload.userCode), id);
}
storage.set(key, payload, {ttl: expiresIn * 1000});
return Promise.resolve();
}
findByUid(uid: string): Promise<AdapterPayload | void | undefined> {
for(const [_, value] of storage.entries()){
if(typeof value ==="object" && "uid" in value && value.uid === uid){
return Promise.resolve(value);
for (const [_, value] of storage.entries()) {
if (typeof value === "object" && "uid" in value && value.uid === uid) {
return Promise.resolve(value as AdapterPayload);
}
}
return Promise.resolve(undefined);
@ -103,7 +104,7 @@ class MemoryAdapter implements Adapter{
revokeByGrantId(grantId: string): Promise<void | undefined> {
const grantKey = grantKeyFor(grantId);
const grant = storage.get(grantKey) as string[];
const grant = storage.get(grantKey) as string[] | undefined;
if (grant) {
grant.forEach((token) => storage.delete(token));
storage.delete(grantKey);
@ -112,4 +113,4 @@ class MemoryAdapter implements Adapter{
}
}
export default MemoryAdapter
export default MemoryAdapter;

View file

@ -118,38 +118,57 @@ exports.start = async () => {
// @ts-ignore
stats.gauge('memoryUsageHeap', () => process.memoryUsage().heapUsed);
process.on('uncaughtException', (err: ErrorCaused) => {
logger.debug(`uncaught exception: ${err.stack || err}`);
// These process-global handlers turn ANY uncaught exception / unhandled
// rejection / termination signal into a full Etherpad shutdown + exit.
// That is correct for a real Etherpad process, but catastrophic when
// server.start() is called in-process by a test runner (mocha): a single
// leaked promise rejection from any one test would call exports.exit()
// and tear down the whole suite mid-run — the long-standing Windows
// "silent ELIFECYCLE" flake (root-caused via a procdump capture showing
// node::ReallyExit firing from a microtask). Only install them when
// server.ts is the program entry point (production). Under a test runner
// require.main is the runner, not this module, and mocha's own per-test
// uncaughtException/unhandledRejection handling fails the offending test
// instead of killing the process. (This mirrors the existing
// `if (require.main === module) exports.start()` idiom at the bottom of
// this file — embedders that call start() programmatically are likewise
// responsible for their own process lifecycle.)
if (require.main === module) {
process.on('uncaughtException', (err: ErrorCaused) => {
logger.debug(`uncaught exception: ${err.stack || err}`);
// eslint-disable-next-line promise/no-promise-in-callback
exports.exit(err)
.catch((err: ErrorCaused) => {
logger.error('Error in process exit', err);
// eslint-disable-next-line n/no-process-exit
process.exit(1);
});
});
// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
process.on('unhandledRejection', (err: ErrorCaused) => {
logger.debug(`unhandled rejection: ${err.stack || err}`);
throw err;
});
for (const signal of ['SIGINT', 'SIGTERM'] as NodeJS.Signals[]) {
// Forcibly remove other signal listeners to prevent them from terminating node before we are
// done cleaning up. See https://github.com/andywer/threads.js/pull/329 for an example of a
// problematic listener. This means that exports.exit is solely responsible for performing all
// necessary cleanup tasks.
for (const listener of process.listeners(signal)) {
removeSignalListener(signal, listener);
}
process.on(signal, exports.exit);
// Prevent signal listeners from being added in the future.
process.on('newListener', (event, listener) => {
if (event !== signal) return;
removeSignalListener(signal, listener);
// eslint-disable-next-line promise/no-promise-in-callback
exports.exit(err)
.catch((err: ErrorCaused) => {
logger.error('Error in process exit', err);
// eslint-disable-next-line n/no-process-exit
process.exit(1);
});
});
// As of v14, Node.js does not exit when there is an unhandled Promise
// rejection. Convert an unhandled rejection into an uncaught exception,
// which does cause Node.js to exit.
process.on('unhandledRejection', (err: ErrorCaused) => {
logger.debug(`unhandled rejection: ${err.stack || err}`);
throw err;
});
for (const signal of ['SIGINT', 'SIGTERM'] as NodeJS.Signals[]) {
// Forcibly remove other signal listeners to prevent them from
// terminating node before we are done cleaning up. See
// https://github.com/andywer/threads.js/pull/329 for an example of a
// problematic listener. This means that exports.exit is solely
// responsible for performing all necessary cleanup tasks.
for (const listener of process.listeners(signal)) {
removeSignalListener(signal, listener);
}
process.on(signal, exports.exit);
// Prevent signal listeners from being added in the future.
process.on('newListener', (event, listener) => {
if (event !== signal) return;
removeSignalListener(signal, listener);
});
}
}
await db.init();

View file

@ -297,7 +297,11 @@ const buildSchedulerApplyDeps = (): ApplyPipelineDeps => ({
}
},
pnpmOnPath: () => new Promise<boolean>((resolve) => {
const c = spawn('pnpm', ['--version'], {stdio: 'ignore'});
// pm_on_fail=ignore so a "packageManager" pin mismatch doesn't make pnpm
// exit non-zero (it would otherwise try to fetch the pinned build, which
// fails offline) and falsely report pnpm as absent. See #7911.
const c = spawn('pnpm', ['--version'],
{stdio: 'ignore', env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}});
c.on('close', (code) => resolve(code === 0));
c.on('error', () => resolve(false));
}),

View file

@ -63,7 +63,12 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
// like <span data-tag="value">
hooks.aCallAll('exportHtmlAdditionalTagsWithData', pad).then((newProps: string[]) => {
newProps.forEach((prop) => {
tags.push(`span data-${prop[0]}="${prop[1]}"`);
// Attribute names/values here originate from the pad's attribute pool
// (user content), so escape the value and constrain the data-* name
// before interpolating, consistent with the escaping already applied to
// exported URLs and text below.
const dataName = String(prop[0]).replace(/[^a-zA-Z0-9_-]/g, '');
tags.push(`span data-${dataName}="${Security.escapeHTMLAttribute(String(prop[1]))}"`);
props.push(prop);
});
}),

View file

@ -12,7 +12,7 @@
// True when applyPadSettings (client + server) preserves keys matching
// /^ep_[a-z0-9_]+$/ on pad.padOptions. The runtime gate is
// settings.enablePluginPadOptions (default false), mirrored to clients via
// settings.enablePluginPadOptions (default true), mirrored to clients via
// clientVars.enablePluginPadOptions. See doc/plugins.md for the full
// contract (key namespace, validation, size caps).
export const padOptionsPluginPassthrough = true;

View file

@ -369,7 +369,7 @@ export type SettingsType = {
from: string | null;
auth: {user: string; pass: string} | null;
},
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings" | "enablePluginPadOptions" | "privacyBanner">,
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enableDarkMode" | "enablePadWideSettings" | "enablePluginPadOptions" | "privacyBanner">,
}
const settings: SettingsType = {
@ -434,11 +434,11 @@ const settings: SettingsType = {
updateServer: "https://static.etherpad.org",
enableDarkMode: true,
enablePadWideSettings: true,
// New plugin-padOption passthrough is opt-in per AGENTS.MD §52 ("New
// features should be placed behind feature flags and disabled by
// default"). Flip to true to let plugins (e.g. ep_plugin_helpers'
// padToggle) ride the existing padoptions broadcast/persist rail.
enablePluginPadOptions: false,
// Lets plugins (e.g. ep_plugin_helpers' padToggle / padSelect) ride the
// existing padoptions broadcast/persist rail to store pad-wide options
// under ep_* keys. Operators who want to lock plugin-driven pad-wide
// state out can set this to false in settings.json.
enablePluginPadOptions: true,
allowPadDeletionByAllUsers: false,
privacyBanner: {
enabled: false,
@ -871,6 +871,9 @@ const settings: SettingsType = {
title: settings.title,
skinName: settings.skinName,
skinVariants: settings.skinVariants,
// Needed so pad.html / timeslider.html only emit the dark theme-color
// variant when dark mode can actually be reached client-side (#7606).
enableDarkMode: settings.enableDarkMode,
enablePadWideSettings: settings.enablePadWideSettings,
enablePluginPadOptions: settings.enablePluginPadOptions,
privacyBanner: getPublicPrivacyBanner(),
@ -1341,6 +1344,41 @@ export const reloadSettings = () => {
settings.cookie.prefix = '';
}
// Warn when an account still uses a placeholder/example password from the
// shipped config; these should be changed before the instance is exposed.
// Logged loudly (error level in production) rather than throwing, so test
// fixtures and existing setups that use placeholder credentials still run.
{
const weakPasswords = new Set(['changeme1', 'changeme', 'admin', 'password', '']);
const users = (settings.users || {}) as Record<string, {password?: string, is_admin?: boolean}>;
const offenders = Object.keys(users).filter((name) =>
users[name] && typeof users[name].password === 'string' &&
weakPasswords.has(users[name].password as string));
if (offenders.length) {
const msg = `Account(s) using a default/placeholder password: ${offenders.join(', ')}. ` +
'Set a strong password (or use the ep_hash_auth plugin) before exposing this instance.';
if (process.env.NODE_ENV === 'production') logger.error(msg);
else logger.warn(msg);
}
// Same check for OIDC client secrets when SSO is configured: the shipped
// templates fall back to placeholder values if ADMIN_SECRET / USER_SECRET
// are not provided.
const sso = (settings as any).sso;
const ssoClients: Array<{client_id?: string, client_secret?: string}> =
(sso && Array.isArray(sso.clients)) ? sso.clients : [];
const weakSecrets = new Set(['admin', 'user', 'secret', 'changeme', '']);
const secretOffenders = ssoClients
.filter((c) => c && typeof c.client_secret === 'string' && weakSecrets.has(c.client_secret))
.map((c) => c.client_id || '(unnamed client)');
if (secretOffenders.length) {
const msg = `SSO client(s) using a default/placeholder client_secret: ${secretOffenders.join(', ')}. ` +
'Set a strong secret (e.g. via the ADMIN_SECRET / USER_SECRET env vars) before enabling SSO in production.';
if (process.env.NODE_ENV === 'production') logger.error(msg);
else logger.warn(msg);
}
}
if (settings.dbType === 'dirty') {
const dirtyWarning = 'DirtyDB is used. This is not recommended for production.';
if (!settings.suppressErrorsInPadText) {

View file

@ -14,3 +14,18 @@ export const configuredToolbarColor = (
if (skinName !== 'colibris') return null;
return toolbarColorForTokens((skinVariants || '').split(/\s+/).filter(Boolean));
};
// The toolbar color colibris auto-switches to on a dark-OS client (pad.ts
// forces 'super-dark-toolbar' when enableDarkMode is on and the system is in
// dark mode). Templates emit this in a `media="(prefers-color-scheme: dark)"`
// theme-color meta so iOS Safari — which colors the address bar at parse time
// and does not reliably repaint when JS mutates the tag later — picks the
// right color at first paint instead of staying on the light baseline
// (issue #7606). Returns null for non-colibris skins, matching
// configuredToolbarColor.
export const darkToolbarColor = (
skinName: string | undefined | null,
): string | null => {
if (skinName !== 'colibris') return null;
return toolbarColorForTokens(['super-dark-toolbar']);
};

View file

@ -30,14 +30,14 @@
}
],
"dependencies": {
"@elastic/elasticsearch": "^9.4.1",
"@elastic/elasticsearch": "^9.4.2",
"async": "^3.2.6",
"cassandra-driver": "^4.8.0",
"cookie-parser": "^1.4.7",
"cross-env": "^10.1.0",
"cross-spawn": "^7.0.6",
"dirty-ts": "^1.1.8",
"ejs": "^5.0.2",
"ejs": "^6.0.1",
"esbuild": "^0.28.0",
"express": "^5.2.1",
"express-rate-limit": "^8.5.1",
@ -48,7 +48,7 @@
"htmlparser2": "^12.0.0",
"http-errors": "^2.0.1",
"jose": "^6.2.3",
"js-cookie": "^3.0.7",
"js-cookie": "^3.0.8",
"jsdom": "^29.1.1",
"jsonminify": "0.4.2",
"jsonwebtoken": "^9.0.3",
@ -57,39 +57,39 @@
"live-plugin-manager": "^1.1.0",
"lodash.clonedeep": "4.5.0",
"log4js": "^6.9.1",
"lru-cache": "^11.5.0",
"lru-cache": "^11.5.1",
"mammoth": "^1.12.0",
"measured-core": "^2.0.0",
"mime-types": "^3.0.2",
"mongodb": "^7.1.1",
"mssql": "^12.5.3",
"mysql2": "^3.22.3",
"mssql": "^12.5.5",
"mysql2": "^3.22.5",
"nano": "^11.0.5",
"nodemailer": "^8.0.7",
"oidc-provider": "9.8.3",
"nodemailer": "^8.0.10",
"oidc-provider": "9.8.4",
"openapi-backend": "^5.17.0",
"pdfkit": "^0.18.0",
"pdfkit": "^0.19.0",
"pg": "^8.21.0",
"prom-client": "^15.1.3",
"proxy-addr": "^2.0.7",
"rate-limiter-flexible": "^11.1.0",
"redis": "^5.12.1",
"rate-limiter-flexible": "^11.1.1",
"redis": "^6.0.0",
"rehype": "^13.0.2",
"rehype-minify-whitespace": "^6.0.2",
"resolve": "1.22.12",
"rethinkdb": "^2.4.2",
"rusty-store-kv": "^1.3.1",
"security": "1.0.0",
"semver": "^7.8.1",
"semver": "^7.8.2",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"superagent": "10.3.0",
"surrealdb": "^2.0.3",
"tinycon": "0.6.8",
"tsx": "4.22.3",
"ueberdb2": "^6.1.2",
"tsx": "4.22.4",
"ueberdb2": "^6.1.8",
"underscore": "1.13.8",
"undici": "^8.3.0",
"undici": "^8.4.1",
"unorm": "1.6.0",
"wtfnode": "^0.10.1"
},
@ -107,14 +107,14 @@
"@types/express-session": "^1.19.0",
"@types/formidable": "^3.5.1",
"@types/http-errors": "^2.0.5",
"@types/jquery": "^4.0.0",
"@types/jquery": "^4.0.1",
"@types/js-cookie": "^3.0.6",
"@types/jsdom": "^28.0.3",
"@types/jsonminify": "^0.4.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^3.0.1",
"@types/mocha": "^10.0.9",
"@types/node": "^25.9.1",
"@types/node": "^25.9.2",
"@types/nodemailer": "^8.0.0",
"@types/oidc-provider": "^9.5.0",
"@types/pdfkit": "^0.17.6",
@ -124,7 +124,7 @@
"@types/underscore": "^1.13.0",
"@types/whatwg-mimetype": "^5.0.0",
"chokidar": "^5.0.0",
"eslint": "^10.4.0",
"eslint": "^10.4.1",
"eslint-config-etherpad": "^4.0.5",
"etherpad-cli-client": "^4.0.3",
"mocha": "^11.7.6",
@ -136,7 +136,7 @@
"split-grid": "^1.0.11",
"supertest": "^7.2.2",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
"vitest": "^4.1.8"
},
"engines": {
"node": ">=24.0.0",
@ -149,7 +149,7 @@
},
"scripts": {
"lint": "eslint .",
"test": "cross-env NODE_ENV=production mocha --import=tsx --require ./tests/backend/diagnostics.ts --timeout 120000 --extension ts --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs",
"test": "cross-env NODE_ENV=production mocha --import=tsx --timeout 120000 --extension ts --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs",
"test-utils": "cross-env NODE_ENV=production mocha --import=tsx --timeout 5000 --recursive tests/backend/specs/*utils.ts",
"test-container": "mocha --import=tsx --timeout 30000 --extension ts,js tests/container/specs/api",
"dev": "cross-env NODE_ENV=development node --require tsx/cjs node/server.ts",
@ -163,6 +163,6 @@
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts",
"test:vitest": "vitest"
},
"version": "3.2.0",
"version": "3.3.0",
"license": "Apache-2.0"
}

View file

@ -53,6 +53,9 @@ html.outer-editor, html.inner-editor {
#innerdocbody a {
color: #2e96f3;
white-space: normal;
word-wrap: break-word;
overflow-wrap: break-word;
}
#innerdocbody.authorColors [class^='author-'] a {
color: inherit;

View file

@ -139,16 +139,22 @@ input {
.history-banner-date { opacity: 0.85; }
.history-banner #history-banner-return { margin-left: auto; }
/* The history iframe takes the place of the live ACE editor. We use the */
/* same positioning model (absolute, fill the editor area) so the page */
/* layout is identical between modes. */
/* The history iframe takes the place of the live ACE editor by occupying the
* same in-flow flex slot that #editorcontainer fills in live mode (flex: 1 1
* auto inside the row-flex #editorcontainerbox). This keeps the layout
* identical between modes crucially including any in-flow side panels in
* #editorcontainerbox, such as ep_webrtc's video column, which now sit beside
* the history iframe exactly as they sit beside the live editor.
*
* This used to be an absolute, inset:0 overlay: it filled the editor area but
* took the iframe out of flow, so an in-flow side panel ended up hidden
* beneath it (see ether/ep_webrtc rendering nothing in the timeslider). */
.history-frame-mount {
display: none;
position: absolute;
inset: 0;
flex: 1 1 auto;
min-width: 0; /* allow the iframe to shrink to make room for a side panel */
border: 0;
background: var(--bg-color, #f2f3f4);
z-index: 4;
}
.history-frame-mount[hidden] { display: none; }
.history-frame-mount > iframe {
@ -164,8 +170,14 @@ input {
/* editor, so we swap it for the history controls (slider + play/step */
/* buttons) that drive the iframe. The right-side menu (Settings / Share / */
/* Users / Chat / Home) stays fully interactive across modes. */
body.history-mode #editorcontainer { display: none; }
body.history-mode #editorcontainerbox { position: relative; }
/* The two-id `#editorcontainerbox #editorcontainer { display: flex }` layout
* rule (specificity 0-2-0) outranks a plain `body.history-mode #editorcontainer`
* (0-1-1), so match the same container path here to actually win the cascade
* and remove the live editor from flow. This was latent before: the old
* absolute, inset:0 iframe overlay painted over the still-in-flow editor, so
* nobody noticed it wasn't hidden. Now that the iframe sits in the normal
* flex flow, the editor must genuinely be gone or the two would split the row. */
body.history-mode #editorcontainerbox #editorcontainer { display: none; }
body.history-mode .history-frame-mount { display: block; }
body.history-mode #editbar .menu_left { display: none; }
body.history-mode #editbar .show-more-icon-btn { display: none; }

View file

@ -683,7 +683,11 @@ function Ace2Inner(editorInfo, cssManagers) {
rtlistrue: (value) => {
targetBody.classList.toggle('rtl', value);
targetBody.classList.toggle('ltr', !value);
document.documentElement.dir = value ? 'rtl' : 'ltr';
// Apply the base direction to the inner editor document only. Using the
// top-level `document` here would flip the whole page (toolbar, chrome)
// even though this is a per-pad content option — see issue #7900. The
// page direction is governed solely by the UI language (see l10n.ts).
targetDoc.documentElement.dir = value ? 'rtl' : 'ltr';
},
fadeinactiveauthorcolors: (value) => {
fadeInactiveAuthorColors = `${value}` !== 'false';

View file

@ -565,7 +565,7 @@ const loadBroadcastJS = (socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro
const bgcolor = typeof data.colorId === 'number'
? clientVars.colorPalette[data.colorId] : data.colorId;
if (bgcolor) {
const selector = dynamicCSS.selectorStyle(`.${linestylefilter.getAuthorClassName(author)}`);
const selector = dynamicCSS.selectorStyle(`.authorColors .${linestylefilter.getAuthorClassName(author)}`);
selector.backgroundColor = bgcolor;
selector.color = (colorutils.luminosity(colorutils.css2triple(bgcolor)) < 0.5)
? '#ffffff' : '#000000'; // see ace2_inner.js for the other part

View file

@ -26,6 +26,7 @@
const chat = require('./chat').chat;
const hooks = require('./pluginfw/hooks');
const browser = require('./vendors/browser');
import {stampAuthorOnInserts} from './stampAuthorOnInserts';
// Dependency fill on init. This exists for `pad.socket` only.
// TODO: bind directly to the socket.
@ -48,6 +49,23 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
let commitDelay = 500;
const userId = initialUserInfo.userId;
// Build the outgoing changeset and guarantee every insert carries an author.
// collab_client only exists after CLIENT_VARS has arrived, so `userId` is always
// populated here — whereas the editor can build a changeset with an empty author
// if the user typed before CLIENT_VARS landed (the early-typing race that the
// server's pad-corruption guard rejects). Stamping here is the last line of
// defense before the changeset goes on the wire. See stampAuthorOnInserts.
const prepareUserChangeset = () => {
const data = editor.prepareUserChangeset();
if (data.changeset) {
const stamped = stampAuthorOnInserts(data.changeset, data.apool, userId);
data.changeset = stamped.changeset;
data.apool = stamped.apool;
}
return data;
};
// var socket;
const userSet = {}; // userId -> userInfo
userSet[userId] = initialUserInfo;
@ -114,7 +132,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
// Check if there are any pending revisions to be received from server.
// Allow only if there are no pending revisions to be received from server
if (!isPendingRevision) {
const userChangesData = editor.prepareUserChangeset();
const userChangesData = prepareUserChangeset();
if (userChangesData.changeset) {
lastCommitTime = now;
committing = true;
@ -416,7 +434,7 @@ const getCollabClient = (ace2editor, serverVars, initialUserInfo, options, _pad)
obj.committedChangesetAPool = stateMessage.apool;
editor.applyPreparedChangesetToBase();
}
const userChangesData = editor.prepareUserChangeset();
const userChangesData = prepareUserChangeset();
if (userChangesData.changeset) {
obj.furtherChangeset = userChangesData.changeset;
obj.furtherChangesetAPool = userChangesData.apool;

View file

@ -720,10 +720,27 @@ const pad = {
pad.refreshPadSettingsControls();
pad.applyOptionsChange();
pad.refreshMyViewControls();
// The following view overrides MUST run inside postAceInit (after
// padeditor.init resolves), not in the synchronous tail of
// _afterHandshake. Running them sync queues a setProperty in the
// Ace2Editor pending-init queue; the queue is flushed when ace
// finishes loading, but padeditor.init's own
// setViewOptions(initialViewOptions) call runs immediately *after*
// that flush and clobbers the URL-driven value. See #7464 for the
// RTL incarnation of this race (now generalised here for #7840).
if (settings.rtlIsExplicit) {
// URL or server config explicitly set RTL — takes priority over cookie
pad.changeViewOption('rtlIsTrue', settings.rtlIsTrue === true);
}
if (settings.LineNumbersDisabled === true) {
pad.changeViewOption('showLineNumbers', false);
}
if (settings.noColors === true) {
pad.changeViewOption('noColors', true);
}
if (settings.useMonospaceFontGlobal === true) {
pad.changeViewOption('padFontFamily', 'RobotoMono');
}
// Prevent sticky chat or chat and users to be checked for mobiles
const checkChatAndUsersVisibility = (x) => {
@ -809,24 +826,6 @@ const pad = {
ace.ace_setEditable(!window.clientVars.readonly);
});
// If the LineNumbersDisabled value is set to true then we need to hide the Line Numbers
if (settings.LineNumbersDisabled === true) {
this.changeViewOption('showLineNumbers', false);
}
// If the noColors value is set to true then we need to
// hide the background colors on the ace spans
if (settings.noColors === true) {
this.changeViewOption('noColors', true);
}
// RTL override is applied in postAceInit (after padeditor.init resolves)
// to avoid a race where setViewOptions(initialViewOptions) overwrites it.
// If the Monospacefont value is set to true then change it to monospace.
if (settings.useMonospaceFontGlobal === true) {
this.changeViewOption('padFontFamily', 'RobotoMono');
}
// if the globalUserName value is set we need to tell the server and
// the client about the new authorname
if (settings.globalUserName !== false) {
@ -877,6 +876,14 @@ const pad = {
options,
changedBy: pad.myUserInfo.name || 'unnamed',
});
// The pad creator is never "enforced upon themselves", so their personal
// view overrides (cookies) are always merged on top of the pad-wide value
// in getEffectivePadOptions. A stale personal pref would therefore mask the
// pad-wide value they just set, making the control appear to do nothing
// (#7900). Sync the creator's personal pref to the value they chose so
// their own view adopts it immediately. They can still override it
// afterwards via the "My view" controls.
pad.setMyViewOption(key, value);
},
changeViewOption: (key, value) => {
const effectiveOptions = pad.getEffectivePadOptions();

View file

@ -62,9 +62,8 @@ class PadModeController {
private usersSnapshot: string | null = null;
private chatHeaderSnapshot: {parent: HTMLElement; sibling: Node | null} | null = null;
private chatHeaderEl: HTMLElement | null = null;
private playbackChangeListener: ((e: Event) => void) | null = null;
private followChangeListener: ((e: Event) => void) | null = null;
// Outer history controls (#history-controls) — bridge listeners.
// Every listener we attach to an outer Settings / history control is
// tracked here so teardownBridges() can remove them all in one pass.
private outerControlListeners: Array<{el: HTMLElement; type: string; fn: EventListener}> = [];
constructor() {
@ -215,22 +214,22 @@ class PadModeController {
this.exportSnapshot.forEach((href, anchor) => { anchor.setAttribute('href', href); });
this.exportSnapshot = null;
}
if (this.playbackChangeListener) {
const sel = document.getElementById('history-playbackspeed');
if (sel) sel.removeEventListener('change', this.playbackChangeListener);
this.playbackChangeListener = null;
}
if (this.followChangeListener) {
const cb = document.getElementById('history-options-followContents');
if (cb) cb.removeEventListener('change', this.followChangeListener);
this.followChangeListener = null;
}
// Inner BroadcastSlider has no removeCallback API, but the whole iframe
// is destroyed on exit so any callbacks die with it.
// Every outer Settings/history control we bound is tracked in one list,
// so a single pass tears them all down. (The inner BroadcastSlider has no
// removeCallback API, but the whole iframe is destroyed on exit so any
// callbacks die with it.)
this.outerControlListeners.forEach(({el, type, fn}) => el.removeEventListener(type, fn));
this.outerControlListeners = [];
}
// Attach a listener to an outer control and register it for teardown on
// exit. No-ops if the element is missing so callers can stay terse.
private bindOuter(el: HTMLElement | null, type: string, fn: EventListener): void {
if (!el) return;
el.addEventListener(type, fn);
this.outerControlListeners.push({el, type, fn});
}
private mountIframe(rev: number | null): void {
const innerHash = rev == null || rev < 0 ? '' : `#${rev}`;
const src =
@ -335,27 +334,21 @@ class PadModeController {
const rightStep = document.getElementById('history-rightstep') as HTMLButtonElement | null;
const timer = document.getElementById('history-timer') as HTMLElement | null;
const bind = (el: HTMLElement | null, type: string, fn: EventListener) => {
if (!el) return;
el.addEventListener(type, fn);
this.outerControlListeners.push({el, type, fn});
};
bind(sliderInput, 'input', () => {
this.bindOuter(sliderInput, 'input', () => {
if (!sliderInput) return;
const target = Math.max(0, Math.floor(Number(sliderInput.value) || 0));
try { inner.BroadcastSlider?.setSliderPosition?.(target); } catch (_e) {}
});
bind(playBtn, 'click', () => {
this.bindOuter(playBtn, 'click', () => {
try { inner.BroadcastSlider?.playpause?.(); } catch (_e) {}
});
// Inner #leftstep / #rightstep already wire all the step logic; just
// forward the click so we share the same code path.
bind(leftStep, 'click', () => {
this.bindOuter(leftStep, 'click', () => {
try { (innerWin.document.getElementById('leftstep') as HTMLElement | null)?.click(); }
catch (_e) {}
});
bind(rightStep, 'click', () => {
this.bindOuter(rightStep, 'click', () => {
try { (innerWin.document.getElementById('rightstep') as HTMLElement | null)?.click(); }
catch (_e) {}
});
@ -501,15 +494,15 @@ class PadModeController {
// BroadcastSlider state from those controls so the user sees one set of
// controls regardless of mode.
private wireSettingsBridges(innerWin: Window): void {
const inner: any = innerWin as any;
const speedSel = document.getElementById('history-playbackspeed') as HTMLSelectElement | null;
const followCb = document.getElementById('history-options-followContents') as HTMLInputElement | null;
const inner: any = innerWin as any;
if (speedSel) {
// Initial sync: read existing inner cookie/setting if available.
const innerSpeed = inner.document.getElementById('playbackspeed') as HTMLSelectElement | null;
if (innerSpeed && innerSpeed.value) speedSel.value = innerSpeed.value;
this.playbackChangeListener = () => {
this.bindOuter(speedSel, 'change', () => {
const v = speedSel.value || '100';
try {
inner.BroadcastSlider?.setPlaybackSpeed?.(v);
@ -518,20 +511,34 @@ class PadModeController {
innerSpeed.dispatchEvent(new Event('change'));
}
} catch (_e) {}
};
speedSel.addEventListener('change', this.playbackChangeListener);
});
}
if (followCb) {
const innerFollow = inner.document.getElementById('options-followContents') as HTMLInputElement | null;
if (innerFollow) followCb.checked = !!innerFollow.checked;
this.followChangeListener = () => {
this.bindOuter(followCb, 'change', () => {
if (!innerFollow) return;
innerFollow.checked = followCb.checked;
innerFollow.dispatchEvent(new Event('change'));
};
followCb.addEventListener('change', this.followChangeListener);
});
}
// Authorship colours, font family and line numbers each appear in two
// places in the outer Settings UI (the legacy popup ids and the
// `#padsettings-…` pane), so bridge every id to the embedded slider's
// matching view-setting method.
const bridgeView = <T extends HTMLElement>(ids: string[], apply: (el: T) => void) =>
ids.forEach((id) => {
const el = document.getElementById(id) as T | null;
this.bindOuter(el, 'change', () => { try { apply(el!); } catch (_e) {} });
});
bridgeView<HTMLInputElement>(['options-colorscheck', 'padsettings-options-colorscheck'],
(cb) => inner.BroadcastSlider?.setShowAuthorColors?.(cb.checked));
bridgeView<HTMLSelectElement>(['viewfontmenu', 'padsettings-viewfontmenu'],
(sel) => inner.BroadcastSlider?.setPadFontFamily?.(sel.value));
bridgeView<HTMLInputElement>(['options-linenoscheck', 'padsettings-options-linenoscheck'],
(cb) => inner.BroadcastSlider?.setShowLineNumbers?.(cb.checked));
}
private setInnerRevision(rev: number): void {

View file

@ -557,7 +557,7 @@ const paduserlist = (() => {
if (localStorage.getItem('recentPads') != null) {
const recentPadsList = JSON.parse(localStorage.getItem('recentPads'));
const pathSegments = window.location.pathname.split('/');
const padName = pathSegments[pathSegments.length - 1];
const padName = decodeURIComponent(pathSegments[pathSegments.length - 1]);
const existingPad = recentPadsList.find((pad) => pad.name === padName);
if (existingPad) {
existingPad.members = online;

View file

@ -33,11 +33,22 @@ import jsCookie, {CookiesStatic} from 'js-cookie'
*/
export const randomString = (len?: number) => {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
let randomstring = '';
len = len || 20;
for (let i = 0; i < len; i++) {
const rnum = Math.floor(Math.random() * chars.length);
randomstring += chars.substring(rnum, rnum + 1);
// Generate these IDs from crypto.getRandomValues (a CSPRNG) rather than
// Math.random. getRandomValues is available in browsers and in Node >= 20
// (Node 24 is required) and, unlike crypto.subtle, needs no secure context.
// Rejection-sample to the largest multiple of chars.length (62*4=248) to
// avoid modulo bias.
const maxUnbiased = 256 - (256 % chars.length); // 248
let randomstring = '';
while (randomstring.length < len) {
const bytes = new Uint8Array(len - randomstring.length);
globalThis.crypto.getRandomValues(bytes);
for (const b of bytes) {
if (b >= maxUnbiased) continue; // drop biased samples, keep going
randomstring += chars[b % chars.length];
if (randomstring.length === len) break;
}
}
return randomstring;
};

View file

@ -177,7 +177,20 @@ export class LinkInstaller {
}
}
// A dependency name comes from a plugin's package.json and is used to build
// filesystem paths (readFileSync / symlinkSync), so validate it is a plain npm
// package name (optional scope, no path separators or "..") before use.
private isValidDependencyName(dependency: string): boolean {
return typeof dependency === 'string' &&
!dependency.includes('..') &&
/^(@[a-z0-9-._~]+\/)?[a-z0-9-._~]+$/i.test(dependency);
}
private async addSubDependency(plugin: string, dependency: string) {
if (!this.isValidDependencyName(dependency)) {
console.error(`Skipping plugin dependency with invalid name: ${dependency}`)
return
}
if (this.dependenciesMap.has(dependency)) {
// We already added the sub dependency
this.dependenciesMap.get(dependency)?.add(plugin)
@ -201,6 +214,10 @@ export class LinkInstaller {
}
private linkDependency(dependency: string) {
if (!this.isValidDependencyName(dependency)) {
console.error(`Skipping plugin dependency with invalid name: ${dependency}`)
return
}
try {
// Check if the dependency is already installed
accessSync(path.join(node_modules, dependency), constants.F_OK)

View file

@ -20,9 +20,16 @@ const logger = log4js.getLogger('plugins');
// installs go through live-plugin-manager directly), so a missing pnpm
// is expected on hardened/packaged installs and shouldn't produce an
// ERROR in the logs.
//
// pnpm_config_pm_on_fail=ignore keeps this informational probe from failing
// when the installed pnpm differs from package.json's "packageManager" pin:
// by default pnpm would try to download the pinned version, which throws in
// air-gapped/firewalled installs (ether/etherpad#7911). We only want to read
// the local version here, never to self-provision a different one.
(async () => {
try {
const version = await runCmd(['pnpm', '--version'], {stdio: [null, 'string']});
const version = await runCmd(['pnpm', '--version'],
{stdio: [null, 'string'], env: {...process.env, pnpm_config_pm_on_fail: 'ignore'}});
logger.info(`pnpm --version: ${version}`);
} catch (err) {
if ((err as any).code === 'ENOENT') {

View file

@ -7,19 +7,21 @@ const containers = ['editor', 'background', 'toolbar'];
const colors = ['super-light', 'light', 'dark', 'super-dark'];
// Keep <meta name="theme-color"> in sync with the toolbar the user actually
// sees. The server emits a baseline derived from settings.skinVariants, but
// pad.ts may flip the toolbar to super-dark on first paint (enableDarkMode
// + prefers-color-scheme:dark + no localStorage white-mode override) and
// the user can toggle via #options-darkmode. Without this, dark-mode users
// keep the light meta and see a white address bar above a dark toolbar
// (issue #7606 follow-up). Color resolution lives in skin_toolbar_colors so
// the server-rendered baseline and the client updates share one source of
// truth — Qodo flagged the prior duplicated table as a drift hazard.
// sees. The server emits a media-scoped light + dark pair so iOS Safari picks
// the right color at first paint (issue #7606); on desktop/Android the user
// can still override the system scheme via #options-darkmode. When that
// happens we point EVERY theme-color meta at the chosen color so the explicit
// choice wins regardless of which media query the browser is currently
// matching — otherwise toggling light while the OS is dark (or vice versa)
// would update a tag the browser is ignoring. Color resolution lives in
// skin_toolbar_colors so the server-rendered baseline and the client updates
// share one source of truth — Qodo flagged the prior duplicated table as a
// drift hazard.
const updateThemeColorMeta = (newClasses: string[]) => {
const meta = document.querySelector('meta[name="theme-color"]');
if (!meta) return;
meta.setAttribute('content',
toolbarColorForTokens(newClasses.join(' ').split(/\s+/).filter(Boolean)));
const metas = document.querySelectorAll('meta[name="theme-color"]');
if (!metas.length) return;
const color = toolbarColorForTokens(newClasses.join(' ').split(/\s+/).filter(Boolean));
metas.forEach((meta) => { meta.setAttribute('content', color); });
};
// add corresponding classes when config change

View file

@ -0,0 +1,58 @@
'use strict';
import {deserializeOps, pack, unpack} from './Changeset';
import AttributeMap from './AttributeMap';
import AttributePool from './AttributePool';
import {SmartOpAssembler} from './SmartOpAssembler';
/**
* Ensure every insert (`+`) op in a wire changeset carries an `author` attribute.
*
* Why this exists: the local author id (`clientVars.userId`) only becomes available
* once the CLIENT_VARS socket message arrives. Under load (notably Firefox with
* plugins) the editor can become editable and the user can type *before* that
* message lands, so the editor tags the insert with an empty author. An empty
* author canonicalizes to "no author", producing an unattributed insert that the
* server's pad-corruption guard rejects dropping the whole USER_CHANGES and
* silently losing the typed text's authorship (the clear_authorship_color flake).
*
* This runs in collab_client, which only exists after CLIENT_VARS has arrived, so
* the author id passed here is always populated. Stamping any author-less insert
* just before the changeset is sent guarantees the server never sees an
* unattributed insert, independent of editor-init timing.
*
* @param changeset - The wire changeset string from prepareUserChangeset().
* @param apoolJsonable - The jsonable wire attribute pool that accompanies it.
* @param authorId - The local author id (collab_client's userId). If falsy, the
* inputs are returned unchanged (nothing better to stamp with).
* @returns The (possibly) rewritten changeset + jsonable pool. Returns the inputs
* unchanged when no insert needed an author, so the common path is a no-op.
*/
export const stampAuthorOnInserts = (
changeset: string,
apoolJsonable: any,
authorId: string,
): {changeset: string, apool: any} => {
if (!authorId) return {changeset, apool: apoolJsonable};
const pool = (new AttributePool()).fromJsonable(apoolJsonable);
const unpacked = unpack(changeset);
const assem = new SmartOpAssembler();
let modified = false;
for (const op of deserializeOps(unpacked.ops)) {
if (op.opcode === '+') {
const attribs = AttributeMap.fromString(op.attribs, pool);
if (!attribs.get('author')) {
attribs.set('author', authorId);
op.attribs = attribs.toString();
modified = true;
}
}
assem.append(op);
}
if (!modified) return {changeset, apool: apoolJsonable};
assem.endDocument();
return {
changeset: pack(unpacked.oldLen, unpacked.newLen, assem.toString(), unpacked.charBank),
apool: pool.toJsonable(),
};
};

View file

@ -67,6 +67,17 @@ const applyShowLineNumbers = (showLineNumbers) => {
window.requestAnimationFrame(() => $(window).trigger('resize'));
};
const applyShowAuthorColors = (showAuthorColors) => {
$('#innerdocbody').toggleClass('authorColors', showAuthorColors);
$('#sidedivinner').toggleClass('authorColors', showAuthorColors);
};
// Pass '' (not null) to clear the rule — jQuery 3 ignores a null css value,
// so the inline font-family would otherwise stick on reset.
const applyPadFontFamily = (fontFamily) => {
$('#innerdocbody').css('font-family', fontFamily || '');
};
const init = () => {
padutils.setupGlobalExceptionHandler();
$(document).ready(() => {
@ -240,11 +251,33 @@ const handleClientVars = (message) => {
});
applyShowLineNumbers(readPadPrefs().showLineNumbers !== false);
// font family change
// Honour the view preferences the pad editor saved to the cookie so the
// first paint matches the user's pad settings.
applyShowAuthorColors(readPadPrefs().showAuthorshipColors !== false);
const padFontFamily = readPadPrefs().padFontFamily;
if (padFontFamily) $('#viewfontmenu').val(padFontFamily);
applyPadFontFamily(padFontFamily);
$('#viewfontmenu').on('change', function () {
$('#innerdocbody').css('font-family', $(this).val() || '');
const fontFamily = $(this).val() || '';
setPadPref('padFontFamily', fontFamily);
applyPadFontFamily(fontFamily);
});
// Entry points for the outer pad shell (#7659 in-place history mode) to push
// view settings into this iframe live when the user changes them on the pad.
BroadcastSlider.setShowAuthorColors = (showAuthorColors) => {
applyShowAuthorColors(showAuthorColors);
setPadPref('showAuthorshipColors', showAuthorColors);
};
BroadcastSlider.setShowLineNumbers = (showLineNumbers) => {
applyShowLineNumbers(showLineNumbers);
setPadPref('showLineNumbers', showLineNumbers);
};
BroadcastSlider.setPadFontFamily = (fontFamily) => {
applyPadFontFamily(fontFamily);
setPadPref('padFontFamily', fontFamily);
};
const savedPlaybackSpeed = Cookies.get(`${cp}${playbackSpeedCookie}`) || '100';
$('#playbackspeed').val(savedPlaybackSpeed);
BroadcastSlider.setPlaybackSpeed(savedPlaybackSpeed);

View file

@ -165,7 +165,11 @@
var text = $option.data('display') || $option.text();
$dropdown.find('.current').text(text);
$dropdown.prev('select').val($option.data('value')).trigger('change');
const $nativeSelect = $dropdown.prev('select');
$nativeSelect.val($option.data('value')).trigger('change');
// Fire native event for handlers attached via addEventListener (e.g.
// the pad_mode.ts settings bridge to the embedded timeslider iframe).
$nativeSelect[0]?.dispatchEvent(new Event('change', {bubbles: true}));
});
// Keyboard events

View file

@ -70,12 +70,21 @@ window.customStart = () => {
li.style.cursor = 'pointer';
li.className = 'recent-pad';
const padPath = `${window.location.href}p/${pad.name}`;
// Normalize the stored name to its decoded form. Entries saved by older
// versions may already be URL-encoded; decoding first avoids
// double-encoding (e.g. %2F -> %252F) when we build the href below.
let padName = pad.name;
try {
padName = decodeURIComponent(pad.name);
} catch {
// pad.name is not valid percent-encoding; use it as-is.
}
const padPath = `${window.location.href}p/${encodeURIComponent(padName)}`;
const link = document.createElement('a');
link.style.textDecoration = 'none';
link.href = padPath;
link.innerText = pad.name;
link.innerText = padName;
li.appendChild(link);

View file

@ -8,7 +8,7 @@ window.customStart = () => {
$('.buttonicon').on('mouseup', function () { $(this).parent().removeClass('pressed'); });
const pathSegments = window.location.pathname.split('/');
const padName = pathSegments[pathSegments.length - 1];
const padName = decodeURIComponent(pathSegments[pathSegments.length - 1]);
const recentPads = localStorage.getItem('recentPads');
if (recentPads == null) {
localStorage.setItem('recentPads', JSON.stringify([]));

View file

@ -7,11 +7,16 @@
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
// theme-color matches the configured toolbar so mobile address bars don't
// paint a mismatched system color above the toolbar on first paint. We do
// not emit a prefers-color-scheme: dark variant: the client-side dark-mode
// auto-switch is gated on enableDarkMode, matchMedia, and a localStorage
// white-mode override, none of which a media query can express.
// paint a mismatched system color above the toolbar on first paint. We emit
// a prefers-color-scheme: dark variant scoped with the `media` attribute so
// iOS Safari — which colors the address bar at parse time and does not
// reliably repaint when JS mutates the tag later — picks the dark toolbar
// color at first paint on a dark-OS client (issue #7606). The dark variant
// is only emitted when enableDarkMode is on, since that is what gates the
// client-side auto-switch; the manual #options-darkmode toggle and the
// localStorage white-mode override are still handled by skin_variants.ts.
var configuredColor = skinColors.configuredToolbarColor(settings.skinName, settings.skinVariants);
var darkColor = settings.enableDarkMode ? skinColors.darkToolbarColor(settings.skinName) : null;
%>
<!doctype html>
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=pluginUtils.clientPluginNames().join(' '); %> <%=settings.skinVariants%>">
@ -49,9 +54,38 @@
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<% if (configuredColor) { %><meta name="theme-color" content="<%=configuredColor%>"><% } %>
<% if (configuredColor) { %><meta name="theme-color" content="<%=configuredColor%>"<% if (darkColor) { %> media="(prefers-color-scheme: light)"<% } %>><% } %>
<% if (darkColor) { %><meta name="theme-color" content="<%=darkColor%>" media="(prefers-color-scheme: dark)"><% } %>
<link rel="shortcut icon" href="../favicon.ico">
<% if (darkColor) { %>
<script>
// Apply the dark skin classes before the stylesheet paints so dark-OS
// users don't see a white flash while the JS bundle loads (issue #7606).
// enableDarkMode being on is implied by this script being emitted; the
// matchMedia + localStorage white-mode checks mirror the auto-switch in
// pad.ts, which still runs on init to wire up the #options-darkmode toggle
// and theme the editor iframes (those don't exist yet at parse time).
(function () {
try {
// The skin-variants builder lets the user pick variants by hand; pad.ts
// skips the auto-dark switch there, so we must too.
if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') return;
if (localStorage.getItem('ep_darkMode') === 'false') return;
if (!(window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) return;
var el = document.documentElement;
['super-light', 'light', 'dark', 'super-dark'].forEach(function (c) {
['editor', 'background', 'toolbar'].forEach(function (k) {
el.classList.remove(c + '-' + k);
});
});
el.classList.add('super-dark-editor', 'dark-background', 'super-dark-toolbar');
} catch (e) { /* localStorage/matchMedia unavailable — fall back to light */ }
})();
</script>
<% } %>
<% e.begin_block("styles"); %>
<link href="../static/css/pad.css?v=<%=settings.randomVersionString%>" rel="stylesheet">

View file

@ -5,6 +5,9 @@
&& req.acceptsLanguages(Object.keys(langs))) || 'en';
var renderDir = (langs[renderLang] && langs[renderLang].direction === 'rtl') ? 'rtl' : 'ltr';
var themeColor = skinColors.configuredToolbarColor(settings.skinName, settings.skinVariants);
// See pad.html: emit a media-scoped dark variant so iOS Safari picks the
// dark toolbar color at first paint on a dark-OS client (issue #7606).
var darkThemeColor = settings.enableDarkMode ? skinColors.darkToolbarColor(settings.skinName) : null;
%>
<!doctype html>
<html lang="<%=renderLang%>" dir="<%=renderDir%>" translate="no" class="pad <%=settings.skinVariants%>">
@ -39,8 +42,30 @@
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<% if (themeColor) { %><meta name="theme-color" content="<%=themeColor%>"><% } %>
<% if (themeColor) { %><meta name="theme-color" content="<%=themeColor%>"<% if (darkThemeColor) { %> media="(prefers-color-scheme: light)"<% } %>><% } %>
<% if (darkThemeColor) { %><meta name="theme-color" content="<%=darkThemeColor%>" media="(prefers-color-scheme: dark)"><% } %>
<link rel="shortcut icon" href="../../favicon.ico">
<% if (darkThemeColor) { %>
<script>
// See pad.html: apply the dark skin classes before the stylesheet paints
// so dark-OS users don't see a white flash on load (issue #7606).
(function () {
try {
if (window.location.hash.toLowerCase() === '#skinvariantsbuilder') return;
if (localStorage.getItem('ep_darkMode') === 'false') return;
if (!(window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches)) return;
var el = document.documentElement;
['super-light', 'light', 'dark', 'super-dark'].forEach(function (c) {
['editor', 'background', 'toolbar'].forEach(function (k) {
el.classList.remove(c + '-' + k);
});
});
el.classList.add('super-dark-editor', 'dark-background', 'super-dark-toolbar');
} catch (e) { /* localStorage/matchMedia unavailable — fall back to light */ }
})();
</script>
<% } %>
<% e.begin_block("timesliderStyles"); %>
<link rel="stylesheet" href="../../static/css/pad.css?v=<%=settings.randomVersionString%>">
<link rel="stylesheet" href="../../static/css/iframe_editor.css?v=<%=settings.randomVersionString%>">

View file

@ -1,19 +1,19 @@
'use strict';
// Source-level lint pinning the Windows + Node 24 backend-test flake
// mitigations from PR #7748. Two independent attacks at the failure:
// Source-level lint pinning the one remaining Windows backend-test CI
// invariant after the silent-ELIFECYCLE flake was root-caused and fixed
// (the in-process process.exit path is gated in src/node/server.ts, and the
// Windows jobs run on Node 24.16.0 to avoid the libuv TCP-connect stack
// overrun in 24.15.0 — tracked upstream as nodejs/node#63620):
//
// 1. Mocha --exit on the Windows CI jobs so the post-suite event-loop
// drain — where Windows + Node 24 hard-kills the process — never
// executes. Scoped to Windows so Linux/local runs still surface
// real handle leaks via natural drain.
// 2. NODE_OPTIONS=--report-on-fatalerror (and friends) on every
// Backend tests step, with the resulting node-report/ directory
// uploaded as an artifact on failure. If the flake recurs we
// finally get a V8 stack + libuv handle table.
// mocha --exit on the Windows CI jobs, and ONLY there, so a leaked handle
// can't hang the job at post-suite drain. Linux/local keep natural drain so
// real handle leaks stay visible. Easy to silently revert in a workflow
// refactor or leak into the shared test script; this test fails fast if it
// disappears or spreads.
//
// Both pieces are easy to silently revert in a workflow refactor; this
// test fails fast if either disappears.
// (The earlier --report-on-fatalerror NODE_OPTIONS + node-report uploads were
// diagnostics for hunting the flake; removed once the cause was found.)
import {readFileSync} from 'fs';
import {join} from 'path';
@ -24,27 +24,7 @@ const read = (rel: string) => readFileSync(join(repoRoot, rel), 'utf8');
const workflow = read('.github/workflows/backend-tests.yml');
describe('backend-tests flake mitigation (PR #7748)', () => {
it('every Backend tests step exposes Node diagnostic reports via NODE_OPTIONS', () => {
// Count the "Run the backend tests" steps so the expected-count is
// explicit — if a job is added later, this test reminds the author
// to wire the diag flags into it too.
const runStepCount = (workflow.match(/name: Run the backend tests/g) || []).length;
expect(runStepCount, 'expected 4 Backend tests step blocks (Linux × 2, Windows × 2)')
.toBe(4);
const nodeOptionsCount = (workflow.match(
/--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact/g,
) || []).length;
expect(nodeOptionsCount,
'every Backend tests step must set NODE_OPTIONS with the report-on-fatalerror diag flags')
.toBe(runStepCount);
const uploadCount = (workflow.match(/name: Upload Node diagnostic reports on failure/g) || [])
.length;
expect(uploadCount,
'every Backend tests step must be followed by an Upload Node diagnostic reports step')
.toBe(runStepCount);
});
describe('backend-tests Windows --exit invariant', () => {
it('Windows backend-test steps invoke pnpm test with --exit', () => {
// --exit is the Windows-only mitigation. Linux still runs natural-drain
// so leaked-handle regressions stay visible there.

View file

@ -19,6 +19,8 @@ import supertest from 'supertest';
import type {Express} from 'express';
import type {UpdateState} from '../../../../../node/updater/types';
import {EMPTY_STATE} from '../../../../../node/updater/types';
import {getEpVersion} from '../../../../../node/utils/Settings';
import {parseSemver} from '../../../../../node/updater/versionCompare';
// ---------------------------------------------------------------------------
// Module mocks — must appear before any import that transitively imports them.
@ -83,34 +85,41 @@ const makeState = (latest: UpdateState['latest']): UpdateState => ({
latest,
});
/** A fake ReleaseInfo for a version that is a minor-version ahead of 3.1.0. */
// Build fixtures relative to the actual current version so the test stays
// stable across release bumps. Hard-coded versions break every time the
// codebase rolls forward past the fixture (e.g. once 3.2.0 shipped, a
// MINOR_AHEAD pinned at "3.2.0" no longer counted as minor-behind).
const CUR = parseSemver(getEpVersion())!;
const v = (major: number, minor: number, patch: number) => `${major}.${minor}.${patch}`;
/** A fake ReleaseInfo for a version that is one minor ahead of current. */
const MINOR_AHEAD: UpdateState['latest'] = {
version: '3.2.0',
tag: 'v3.2.0',
version: v(CUR.major, CUR.minor + 1, 0),
tag: `v${v(CUR.major, CUR.minor + 1, 0)}`,
body: '',
publishedAt: '2026-05-01T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.2.0',
htmlUrl: `https://github.com/ether/etherpad/releases/tag/v${v(CUR.major, CUR.minor + 1, 0)}`,
};
/** A fake ReleaseInfo that is only a patch ahead of 3.1.0 (no minor/major delta). */
/** A fake ReleaseInfo that is only a patch ahead of current (no minor/major delta). */
const PATCH_AHEAD: UpdateState['latest'] = {
version: '3.1.1',
tag: 'v3.1.1',
version: v(CUR.major, CUR.minor, CUR.patch + 1),
tag: `v${v(CUR.major, CUR.minor, CUR.patch + 1)}`,
body: '',
publishedAt: '2026-05-01T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.1.1',
htmlUrl: `https://github.com/ether/etherpad/releases/tag/v${v(CUR.major, CUR.minor, CUR.patch + 1)}`,
};
/** A fake ReleaseInfo that is behind or equal to 3.1.0 (current >= latest). */
/** A fake ReleaseInfo that is behind current (current >= latest). */
const SAME_OR_BEHIND: UpdateState['latest'] = {
version: '3.0.0',
tag: 'v3.0.0',
version: v(Math.max(0, CUR.major - 1), 0, 0),
tag: `v${v(Math.max(0, CUR.major - 1), 0, 0)}`,
body: '',
publishedAt: '2026-01-01T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.0.0',
htmlUrl: `https://github.com/ether/etherpad/releases/tag/v${v(Math.max(0, CUR.major - 1), 0, 0)}`,
};
// ---------------------------------------------------------------------------

View file

@ -0,0 +1,76 @@
'use strict';
import {describe, it, expect} from 'vitest';
import {stampAuthorOnInserts} from '../../../static/js/stampAuthorOnInserts';
import {checkRep, deserializeOps, unpack} from '../../../static/js/Changeset';
import AttributeMap from '../../../static/js/AttributeMap';
import AttributePool from '../../../static/js/AttributePool';
const AUTHOR = 'a.test1234567890';
const EMPTY_POOL = () => (new AttributePool()).toJsonable();
// Read the author attribute off the first '+' op of a (changeset, jsonable pool).
const firstInsertAuthor = (changeset: string, apoolJsonable: any): string | undefined => {
const pool = (new AttributePool()).fromJsonable(apoolJsonable);
for (const op of deserializeOps(unpack(changeset).ops)) {
if (op.opcode === '+') return AttributeMap.fromString(op.attribs, pool).get('author');
}
return undefined;
};
describe('stampAuthorOnInserts', () => {
it('stamps the author onto an unattributed insert (the flake changeset)', () => {
// `Z:1>5+5$Hello` — insert "Hello" with NO author, exactly what the editor
// emits during the early-typing race and what the server rejects.
const input = 'Z:1>5+5$Hello';
const {changeset, apool} = stampAuthorOnInserts(input, EMPTY_POOL(), AUTHOR);
// The insert now carries the author...
expect(firstInsertAuthor(changeset, apool)).toBe(AUTHOR);
// ...the result is a valid canonical changeset...
expect(() => checkRep(changeset)).not.toThrow();
// ...and the text is preserved.
expect(unpack(changeset).charBank).toBe('Hello');
// It actually changed (was unattributed before).
expect(changeset).not.toBe(input);
});
it('leaves an already-attributed insert unchanged', () => {
// Build `Z:1>5*0+5$Hello` with author already in the pool.
const pool = new AttributePool();
const n = pool.putAttrib(['author', AUTHOR]); // index 0
const attributed = `Z:1>5*${n}+5$Hello`;
const jsonable = pool.toJsonable();
const {changeset, apool} = stampAuthorOnInserts(attributed, jsonable, AUTHOR);
expect(changeset).toBe(attributed); // unchanged (no-op path)
expect(firstInsertAuthor(changeset, apool)).toBe(AUTHOR);
});
it('does not invent an author when authorId is empty', () => {
const input = 'Z:1>5+5$Hello';
const {changeset} = stampAuthorOnInserts(input, EMPTY_POOL(), '');
expect(changeset).toBe(input); // nothing to stamp with → unchanged
});
it('does not touch keep/remove-only changesets', () => {
// `Z:6<1=5-1$` — keep 5, remove 1; no insert ops.
const input = 'Z:6<1=5-1$x';
const {changeset} = stampAuthorOnInserts(input, EMPTY_POOL(), AUTHOR);
expect(changeset).toBe(input);
});
it('preserves a non-author attribute already on the insert while adding author', () => {
// Insert with a bold attribute but no author.
const pool = new AttributePool();
const b = pool.putAttrib(['bold', 'true']); // index 0
const input = `Z:1>5*${b}+5$Hello`;
const {changeset, apool} = stampAuthorOnInserts(input, pool.toJsonable(), AUTHOR);
const outPool = (new AttributePool()).fromJsonable(apool);
let amap: AttributeMap | null = null;
for (const op of deserializeOps(unpack(changeset).ops)) {
if (op.opcode === '+') { amap = AttributeMap.fromString(op.attribs, outPool); break; }
}
expect(amap!.get('author')).toBe(AUTHOR);
expect(amap!.get('bold')).toBe('true');
expect(() => checkRep(changeset)).not.toThrow();
});
});

View file

@ -0,0 +1,40 @@
'use strict';
// Regression: src/templates/index.html referenced `data-l10n-id="index.code"`
// but src/locales/en.json had no `index.code` key, producing a "Couldn't find
// translation key index.code" console error on the landing page (issue #7835).
// This test asserts every data-l10n-id attribute in our shipped templates has
// a matching source string in en.json so the class of bug fails in CI.
import {readFileSync, readdirSync} from 'fs';
import {join} from 'path';
import {describe, it, expect} from 'vitest';
const repoRoot = join(__dirname, '..', '..', '..', '..');
const templatesDir = join(repoRoot, 'src', 'templates');
const enJsonPath = join(repoRoot, 'src', 'locales', 'en.json');
const en = JSON.parse(readFileSync(enJsonPath, 'utf8')) as Record<string, string>;
const collectKeys = (html: string): string[] => {
const out: string[] = [];
const re = /data-l10n-id="([^"]+)"/g;
let m;
while ((m = re.exec(html)) !== null) out.push(m[1]);
return out;
};
const templateFiles = readdirSync(templatesDir)
.filter((f) => f.endsWith('.html'))
.map((f) => join(templatesDir, f));
describe('template l10n keys', () => {
for (const file of templateFiles) {
it(`every data-l10n-id in ${file.replace(repoRoot + '/', '')} exists in en.json`, () => {
const html = readFileSync(file, 'utf8');
const keys = collectKeys(html);
const missing = keys.filter((k) => !(k in en));
expect(missing, `missing keys in en.json: ${missing.join(', ')}`).toEqual([]);
});
}
});

View file

@ -28,28 +28,29 @@ export const logger = log4js.getLogger('test');
const logLevel = logger.level;
// Mocha doesn't monitor unhandled Promise rejections, so convert them to uncaught exceptions.
// https://github.com/mochajs/mocha/issues/2640
// Log unhandled Promise rejections; do NOT rethrow and do NOT process.exit().
//
// Log via process.stderr.write before throwing: when the rethrown rejection
// kills mocha between specs, the runner exits with code 1 and no summary.
// Without this, the rejection reason is lost (worst on Windows runners,
// where stderr is not always flushed before abrupt exit) and CI shows a
// silent ELIFECYCLE with no clue what rejected.
// Root cause of the long-standing Windows backend "silent ELIFECYCLE" flake
// (confirmed via a procdump full-memory capture showing node::ReallyExit
// firing from a microtask): a timing-fragile test (e.g. SessionStore touch/
// expiry specs) gets timed out and abandoned by mocha, but its async body
// keeps running; when its trailing assertion later throws, it surfaces as an
// *orphan* unhandled rejection — one that belongs to no currently-awaited
// test. PR #7663 rethrew these (→ uncaughtException) and an earlier revision
// even called process.exit(), and server.ts's production handler turned them
// into a full Etherpad shutdown. Any one orphan rejection therefore killed
// the whole suite mid-run with no mocha summary.
//
// Orphan rejections cannot be cleanly attributed to a test, so rethrowing
// just produces an ERR_MOCHA_MULTIPLE_DONE mess and a non-deterministic
// abort. Log them loudly instead. Real failures are unaffected: an assertion
// inside a test's own awaited path rejects THAT test's promise and mocha
// fails it normally — it never reaches this global handler. The companion
// fix is in server.ts, where the production process-exit handlers are now
// gated on `require.main === module` so they don't fire under the test runner.
process.on('unhandledRejection', (reason: any) => {
process.stderr.write(`[backend tests] unhandledRejection: ${
process.stderr.write(`[backend tests] unhandledRejection (logged, non-fatal): ${
reason && reason.stack ? reason.stack : String(reason)}\n`);
throw reason;
});
// Surface uncaught exceptions for the same reason. Node's default behavior
// (exit non-zero) is preserved by the explicit process.exit below — without
// the handler, Node would write to stderr and exit; with the handler we have
// to do it ourselves.
process.on('uncaughtException', (err: any) => {
process.stderr.write(`[backend tests] uncaughtException: ${
err && err.stack ? err.stack : String(err)}\n`);
process.exit(1);
});
before(async function () {

View file

@ -1,100 +0,0 @@
'use strict';
// Diagnostic-only mocha bootstrap, loaded via `mocha --require ./tests/backend/diagnostics.ts`.
//
// PR #7663 added unhandledRejection / uncaughtException handlers in
// tests/backend/common.ts to surface the silent ~22% backend-test flake.
// The next failure (run 25279692065, Windows without plugins, Node 24)
// showed mocha exit with code 1 mid-suite, 261ms after the last passing
// test, with NEITHER handler firing. This means the process was killed
// in a way that bypassed JS handlers — SIGKILL, OOM, or a fatal native
// error — OR mocha itself called process.exit before the handlers ran.
//
// This file:
// 1. Registers handlers UNCONDITIONALLY at mocha startup (common.ts is
// only imported by ~27 of 47 specs, so its handlers may register
// late or after a death-causing event).
// 2. Writes via fs.writeSync(2, ...) — synchronous stderr writes that
// complete before the kernel returns from the syscall, so the line
// lands in the runner log even if the process is killed
// milliseconds later.
// 3. Tracks the last-seen test via a mocha root afterEach hook so the
// death point is identified.
// 4. Logs exit-related events so we can discriminate:
// beforeExit + exit -> clean event-loop drain (Linux CI, local)
// only exit -> process.exit() called — expected when mocha
// is launched with --exit (the Windows CI
// jobs do this to mitigate a hard-kill flake;
// elsewhere "only exit" still means something
// else called process.exit unexpectedly)
// neither -> hard kill (SIGKILL/OOM/runner)
// signal lines -> SIGTERM / SIGINT / SIGBREAK received
//
// Drop this file once the flake's root cause is identified and fixed.
import {writeSync} from 'node:fs';
const t0 = Date.now();
let lastSeenTest = '<no test seen yet>';
const diag = (msg: string): void => {
const line = `[diag +${Date.now() - t0}ms] ${msg}\n`;
try {
writeSync(2, line);
} catch (_) {
// Best-effort: if stderr is closed there is nothing we can do.
}
};
diag('diagnostics loaded');
process.on('unhandledRejection', (reason: any) => {
diag(`unhandledRejection: ${
reason && reason.stack ? reason.stack : String(reason)
} (lastTest="${lastSeenTest}")`);
// Re-throw so existing common.ts handlers / mocha behavior is preserved.
throw reason;
});
process.on('uncaughtException', (err: any) => {
diag(`uncaughtException: ${
err && err.stack ? err.stack : String(err)
} (lastTest="${lastSeenTest}")`);
// Force fail-fast. Specs that don't import common.ts only have THIS handler,
// and Node won't exit on its own once an uncaughtException listener is
// registered. Without the explicit exit a fatal error would be swallowed.
// common.ts has the same process.exit(1); whichever handler runs first wins.
process.exit(1);
});
process.on('beforeExit', (code: number) => {
diag(`beforeExit code=${code} exitCode=${process.exitCode} ` +
`lastTest="${lastSeenTest}"`);
});
process.on('exit', (code: number) => {
diag(`exit code=${code} lastTest="${lastSeenTest}"`);
});
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'] as const) {
// SIGHUP / SIGBREAK don't exist on every platform; ignore registration errors.
try {
process.on(sig as any, () => {
diag(`received ${sig} (lastTest="${lastSeenTest}")`);
// Let the default behavior (exit) happen.
process.exit(128);
});
} catch (_) {
// ignore
}
}
// Mocha root hook — only registered if mocha picks up this file via --require.
// We track the most recently-finished test so the death point is visible.
export const mochaHooks = {
afterEach(this: any) {
if (this.currentTest) {
lastSeenTest = this.currentTest.fullTitle();
}
},
};

View file

@ -0,0 +1,57 @@
/**
* Regression tests for OIDCAdapter (MemoryAdapter).
*
* Covers the four flows that were silently broken before the fix:
* 1. upsert revokeByGrantId actually clears tokens
* 2. upsert findByUserCode returns the payload
* 3. destroy clears the userCode mapping
* 4. consume on a missing id does not throw
*/
import {describe, it} from 'mocha';
import {strict as assert} from 'assert';
import MemoryAdapter from '../../../node/security/OIDCAdapter';
describe('OIDCAdapter', () => {
let adapter: InstanceType<typeof MemoryAdapter>;
beforeEach(() => {
adapter = new MemoryAdapter('Session');
});
it('revokeByGrantId clears all tokens sharing the grant', async () => {
const grantId = 'grant-1';
await adapter.upsert('tok-a', {grantId, jti: 'tok-a'} as any, 60);
await adapter.upsert('tok-b', {grantId, jti: 'tok-b'} as any, 60);
await adapter.revokeByGrantId(grantId);
assert.equal(await adapter.find('tok-a'), undefined);
assert.equal(await adapter.find('tok-b'), undefined);
});
it('findByUserCode returns the payload after upsert', async () => {
const userCode = 'USER-CODE-123';
await adapter.upsert('dc-tok', {userCode, jti: 'dc-tok'} as any, 60);
const result = await adapter.findByUserCode(userCode);
assert.ok(result, 'expected payload to be defined');
assert.equal((result as any).jti, 'dc-tok');
});
it('destroy removes the userCode mapping', async () => {
const userCode = 'USER-CODE-456';
await adapter.upsert('dc-tok-2', {userCode, jti: 'dc-tok-2'} as any, 60);
await adapter.destroy('dc-tok-2');
const result = await adapter.findByUserCode(userCode);
assert.equal(result, undefined);
});
it('consume on a missing id does not throw', async () => {
await assert.doesNotReject(() => adapter.consume('non-existent-id'));
});
});

View file

@ -179,6 +179,58 @@ describe(__filename, function () {
pad = await padManager.getPad(padId);
assert.equal(pad!.text(), `${want}\n`);
});
// Returns the set of author IDs actually applied to the pad's text, by
// resolving every attribute marker in the current AText against the pool.
// This is what colours the text in the editor — distinct from
// getRevisionAuthor()/getAllAuthors() which also reflect pool bookkeeping.
const authorsAppliedToText = (p: any): Set<string> => {
const applied = new Set<string>();
const attribs: string = p.atext.attribs;
for (const m of attribs.matchAll(/\*([0-9a-z]+)/g)) {
const attr = p.pool.getAttrib(parseInt(m[1], 36));
if (attr && attr[0] === 'author' && attr[1] !== '') applied.add(attr[1]);
}
return applied;
};
it('does not colour default content with the creating user (issue #7885)',
async function () {
// When a user opens a brand-new pad, CLIENT_READY calls
// getPad(padId, null, session.author). The default welcome text is not
// written by that user, so its insert op must not carry their author
// attribute (which would colour it in the creator's colour). The system
// author owns the text instead.
const creator = await authorManager.getAuthorId(`t.${padId}`);
pad = await padManager.getPad(padId, null, creator);
const applied = authorsAppliedToText(pad);
assert(!applied.has(creator),
`default text must not be coloured with the creating author ${creator}`);
assert(applied.has('a.etherpad-system'),
'default text should be owned by the system author');
});
it('keeps the creating user as the revision-0 author so pad ownership is preserved',
async function () {
// isPadCreator()/the pad-wide settings gate and the deletion token all
// key off getRevisionAuthor(0). Reassigning the welcome-text colour to
// the system author (above) must not strip the creator's ownership.
const creator = await authorManager.getAuthorId(`t.${padId}`);
pad = await padManager.getPad(padId, null, creator);
assert.equal(await (pad as any).getRevisionAuthor(0), creator,
'the creating user must remain the revision-0 author');
});
it('still colours explicitly provided content with the creating author',
async function () {
// A real author providing real text (e.g. API createPad with text)
// keeps ownership of that text — only auto-generated default content is
// reassigned to the system author.
const creator = await authorManager.getAuthorId(`t.${padId}`);
pad = await padManager.getPad(padId, 'real user content', creator);
assert(authorsAppliedToText(pad).has(creator),
'explicitly provided text should be coloured with the creating author');
});
});
describe('normalizePadSettings lang (issue #7586)', function () {
@ -214,7 +266,7 @@ describe(__filename, function () {
});
afterEach(function () { console.warn = warnSpy; });
describe('with enablePluginPadOptions = true', function () {
describe('with enablePluginPadOptions = true (default)', function () {
before(function () { settings.enablePluginPadOptions = true; });
it('preserves ep_* keys verbatim so plugins can ride padoptions', function () {
@ -285,10 +337,10 @@ describe(__filename, function () {
});
});
describe('with enablePluginPadOptions = false (default)', function () {
describe('with enablePluginPadOptions = false (operator opt-out)', function () {
before(function () { settings.enablePluginPadOptions = false; });
it('drops every ep_* key — feature flag is opt-in', function () {
it('drops every ep_* key — operator has opted out of plugin pad-wide state', function () {
const ps: any = Pad.Pad.normalizePadSettings({
ep_table_of_contents: {enabled: true},
ep_font_color: 'red',

View file

@ -0,0 +1,56 @@
'use strict';
// Regression coverage for #7819 follow-up: non-admin sockets used to be
// silently dropped, which made misconfigured admin auth (e.g. Traefik +
// SSO + cross-origin iframe losing the cookie) look like "save didn't
// work" with no error path. The connection handler now emits a dedicated
// `admin_auth_error` event before disconnecting so the SPA can surface it.
import {strict as assert} from 'assert';
const io = require('socket.io-client');
const common = require('../../common');
const connectAnonymous = (): any =>
io(`${common.baseUrl}/settings`, {
forceNew: true,
// Deliberately omit cookie / auth — this mirrors a socket from a
// session that resolves through express-session but lacks is_admin.
transports: ['websocket'],
});
describe(__filename, function () {
this.timeout(15000);
before(async function () {
await common.init();
});
it('emits admin_auth_error and disconnects when not authenticated as admin',
async function () {
const socket = connectAnonymous();
const result = await new Promise<{event: 'auth_error' | 'disconnect' | 'timeout'; payload?: any}>((res) => {
const timeout = setTimeout(
() => { try { socket.disconnect(); } catch {} res({event: 'timeout'}); },
8000);
socket.once('admin_auth_error', (payload: any) => {
clearTimeout(timeout);
res({event: 'auth_error', payload});
});
socket.once('disconnect', () => {
clearTimeout(timeout);
res({event: 'disconnect'});
});
});
try { socket.disconnect(); } catch {}
// Either order is acceptable (auth_error then disconnect, or just
// disconnect if the event arrives after the close handshake) — but
// a timeout is a regression: the silent-no-op path is back.
assert.notEqual(result.event, 'timeout',
'admin_auth_error or disconnect must arrive within 8s for non-admin socket');
if (result.event === 'auth_error') {
assert.ok(result.payload && typeof result.payload.message === 'string',
`admin_auth_error must carry a message; got ${JSON.stringify(result.payload)}`);
}
});
});

View file

@ -217,4 +217,5 @@ describe(__filename, function () {
assert.ok(onDisk.includes('persisted-marker-7819'),
'comment must survive the write path');
});
});

View file

@ -418,8 +418,6 @@ describe(__filename, function () {
// that a buggy makeGoodExport() doesn't cause checks to accidentally pass.
const records = makeGoodExport();
await deleteTestPad();
const importedPads = await importEtherpad(records)
console.log(importedPads)
await importEtherpad(records)
.expect(200)
.expect('Content-Type', /json/)

View file

@ -0,0 +1,80 @@
'use strict';
// Regression tests for ether/etherpad#7911.
//
// The official Docker image installs pnpm directly (corepack was dropped for
// Node 25+). Standalone pnpm still honours the "packageManager" pin in
// package.json: when the pnpm baked into the image differs from that pin, pnpm
// treats every invocation — including the informational `pnpm --version` probe
// Etherpad runs at startup — as a request to self-provision the pinned build.
// With no outbound network (air-gapped / behind a corporate firewall) that
// download fails and pnpm exits non-zero, surfacing as `Failed to get pnpm
// version` and breaking offline boots.
//
// The image deliberately lags the pin (pnpm 11.1.x enforces a minimum-release-
// age policy the frozen-lockfile build can't satisfy), so the guard is not to
// force the versions equal but to neutralise the gap: the Dockerfile must set
// pnpm_config_pm_on_fail=ignore so pnpm uses the installed version instead of
// reaching for the network. This test fails if that guard is dropped while a
// version gap exists.
const assert = require('assert').strict;
import fs from 'fs';
import path from 'path';
const repoRoot = path.join(__dirname, '../../../../');
const readRepoFile = (rel: string) => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
describe(__filename, function () {
describe('Docker pnpm offline guard (issue #7911)', function () {
let dockerfile: string;
let imagePnpm: string;
let pinPnpm: string;
let guardPresent: boolean;
before(function () {
dockerfile = readRepoFile('Dockerfile');
const argMatch = dockerfile.match(/^ARG PnpmVersion=(\S+)/m);
assert.ok(argMatch, 'Dockerfile must declare `ARG PnpmVersion=<version>`');
imagePnpm = argMatch![1];
const pkg = JSON.parse(readRepoFile('package.json'));
const pinMatch = String(pkg.packageManager || '').match(/^pnpm@(.+)$/);
assert.ok(pinMatch, `expected packageManager "pnpm@<version>", got "${pkg.packageManager}"`);
pinPnpm = pinMatch![1];
// The guard only protects runtime if it lives in the `build` stage, which
// the development/production runtime stages inherit from. The same ENV in
// the throwaway `adminbuild` stage would NOT reach runtime, so scope the
// check to the build stage block (from `FROM ... AS build` to the next
// `FROM`) rather than matching anywhere in the file.
const buildStage = dockerfile.match(
/^FROM\s+\S+\s+AS\s+build\b[\s\S]*?(?=^FROM\s)/m);
assert.ok(buildStage, 'Dockerfile must define a `FROM ... AS build` stage');
guardPresent = /ENV\s+pnpm_config_pm_on_fail=ignore/.test(buildStage![0]);
});
it('neutralises any pnpm version gap so offline boots do not self-provision', function () {
// The actual safety property: a mismatch between the image pnpm and the
// package.json pin is only safe when pm_on_fail=ignore is set. (If they
// are ever realigned, the guard becomes belt-and-suspenders, not required.)
if (imagePnpm !== pinPnpm) {
assert.ok(guardPresent,
`Dockerfile pnpm ${imagePnpm} differs from package.json pnpm@${pinPnpm}, ` +
'but pnpm_config_pm_on_fail=ignore is not set — pnpm will try to ' +
'self-provision the pinned version and break offline/air-gapped ' +
'startup (issue #7911).');
}
});
it('sets pnpm_config_pm_on_fail=ignore in the runtime-inherited build stage', function () {
assert.ok(guardPresent,
'The `build` stage must set pnpm_config_pm_on_fail=ignore (it is ' +
'inherited by the development/production runtime stages) so runtime ' +
'pnpm calls — the startup probe, the updater pnpm check — do not fail ' +
'closed when offline (issue #7911). Setting it only in `adminbuild` ' +
'would not reach runtime.');
});
});
});

Some files were not shown because too many files have changed in this diff Show more