Compare commits

..

130 commits

Author SHA1 Message Date
translatewiki.net
ce4c251080
Localisation updates from https://translatewiki.net. 2026-08-03 14:06:09 +02:00
John McLear
031c16317d
fix: read caret geometry from the editor document (#8038) (#8080)
* fix: read caret geometry from the editor document (#8038)

`caretPosition.getPosition()` read `window.getSelection()` from the pad's
top-level window. Since 3.0 the editor modules are bundled into that
window instead of being loaded inside the ace_inner iframe, so that
selection is always empty: getPosition() returned null, and
`_isCaretAtTheBottomOfViewport()` passed it straight into
getBottomOfNextBrowserLine(), which threw `can't access property
"bottom"` and popped up the "An error occurred" box for anyone running
with `scrollWhenCaretIsInTheLastLineOfViewport` enabled.

Caret geometry now comes from the ace_inner document, which is the same
coordinate space scroll.ts's viewport arithmetic already uses (the inner
frame is as tall as its content and never scrolls). Callers handle a
null position — no caret in the pad — instead of asserting it away.

Scrolling in the same module had the mirror-image problem: `outerWin` is
the ace_outer iframe *element*, and Element.scrollTo()/scrollBy() on an
iframe silently does nothing, so the scroll this feature computed never
actually happened. Those calls now go through the frame's contentWindow.

Fixes #8038

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

* test: build the pad with insertText instead of per-key typing

Per-key events race Etherpad's input pipeline under Firefox +
WITH_PLUGINS load and drop characters — the shared writeToPad() helper
moved off keyboard.type for the same reason. Also cuts the spec's
runtime by a third.

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

* test: target the bottom-edge line by the predicate the editor uses

The click point was picked from line boxes ("last line that starts
inside the viewport"), but scroll.ts decides from text rects of the
*next* browser line, so on CI's rendering the caret landed a line short
of the trigger and the scroll never happened (biggestJump 0). Mirror the
real predicate — first visible line whose successor's first-character
rect reaches past the viewport bottom — and report the measured geometry
when the assertion fails.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 21:47:51 +02:00
dependabot[bot]
0d9c0c7933
build(deps): bump actions/stale from 10 to 11 (#8079)
Bumps [actions/stale](https://github.com/actions/stale) from 10 to 11.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10...v11)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '11'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 10:56:07 +02:00
dependabot[bot]
a267126bdb
build(deps): bump docker/login-action from 4 to 4.5.2 (#8082)
Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 10:55:57 +02:00
dependabot[bot]
5699d43178
build(deps): bump github/codeql-action from 4 to 4.37.3 (#8083)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.3.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v4...v4.37.3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 10:55:43 +02:00
translatewiki.net
de3d060153
Localisation updates from https://translatewiki.net. 2026-07-30 14:03:17 +02:00
Etherpad Release Bot
2726b11b4c Merge branch 'master' into develop 2026-07-29 11:43:32 +00:00
Etherpad Release Bot
51f177c3e0 bump version 2026-07-29 11:43:31 +00:00
Etherpad Release Bot
a90e0f191c Merge branch 'develop' 2026-07-29 11:43:31 +00:00
John McLear
9c8d16d50e
fix: pre-auth path traversal in /static/* handler (GHSA-mc8w-wjhw-45x5) (#8081)
Guards the backslash→slash conversion in Minify.ts to Windows only, closing an unauthenticated arbitrary file read via ..%5C in /static/*. Adds a backend regression test. Also adds the 3.3.3 CHANGELOG entry. Reported by @gcm-explo1t.
2026-07-29 12:30:43 +01:00
Alazar Keneni
f8686dda10
docker: make plugin_packages volume mountpoint writable (#8042)
* docker: prepare plugin volume mountpoint

Create src/plugin_packages in the development and production runtime image stages so a fresh Docker named volume is initialized with Etherpad user ownership instead of root-only ownership.

Fixes #8026

* tests: align docker plugin volume regression test with project style

Replace CommonJS imports with Node.js module imports and reformat the
test to match the project's coding style. This change is purely
stylistic and does not alter the regression test's behavior.

* tests: make docker plugin volume test less brittle

Match Dockerfile instructions with regexes instead of exact substrings so
the regression test validates the required behavior without rejecting
harmless formatting or equivalent command changes.

* docker: explain the plugin_packages mountpoint, drop the Dockerfile-text test

The reason the two `mkdir`s exist is not obvious from the Dockerfile, so state it
where the next reader will be: a named volume inherits the mountpoint's ownership
from the image, and USER is already etherpad at that point.

Removes dockerfilePluginVolume.ts. It asserted that the Dockerfile *text* contains
`mkdir -p ./src/plugin_packages` after the `COPY ./src` line — it fails when the
Dockerfile is refactored (a `.gitkeep`, a `VOLUME`, a different order all keep the
behaviour intact) and it cannot fail when the actual bug returns, since it never
inspects a built image. The build-test job already builds both runtime stages.

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

---------

Co-authored-by: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:44:30 +02:00
John McLear
da03e99676
docs: document docker-compose credential + TRUST_PROXY changes (#7907 follow-up) (#7908)
#7907 made the production docker-compose require ADMIN_PASSWORD and the DB
password (no insecure fallback) and defaulted TRUST_PROXY to false, but only
changed docker-compose.yml. This brings the docs in line:

- .env.default: document DOCKER_COMPOSE_APP_TRUST_PROXY (set true behind a
  trusted reverse proxy) and note ADMIN_PASSWORD is required (compose won't
  start while it's empty).
- .env.dev.default: document the dev DOCKER_COMPOSE_APP_DEV_ENV_TRUST_PROXY.
- README.md / doc/docker.md: update the embedded compose snippets to match the
  merged file (required ADMIN_PASSWORD/DB password, TRUST_PROXY default false).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 21:34:47 +02:00
SamTV12345
e829ca77fe
build(deps-dev): dev-dependencies group (16 updates, typescript excluded) (#8078)
* build(deps-dev): bump the dev-dependencies group across 1 directory with 18 updates

Bumps the dev-dependencies group with 18 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@types/supertest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/supertest) | `7.2.0` | `7.2.1` |
| [eslint](https://github.com/eslint/eslint) | `10.6.0` | `10.7.0` |
| [set-cookie-parser](https://github.com/nfriedly/set-cookie-parser) | `3.1.1` | `3.1.2` |
| [sinon](https://github.com/sinonjs/sinon) | `22.0.0` | `22.1.0` |
| [typescript](https://github.com/microsoft/TypeScript) | `6.0.3` | `7.0.2` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.19` | `1.1.23` |
| [@radix-ui/react-toast](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toast) | `1.2.19` | `1.2.23` |
| [@radix-ui/react-visually-hidden](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/visually-hidden) | `1.2.7` | `1.2.11` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.63.0` | `8.65.0` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.63.0` | `8.65.0` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.3` | `6.0.4` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.23.0` | `1.26.0` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.7` | `19.2.8` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.7` | `19.2.8` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.81.0` | `7.82.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.9` | `17.0.11` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.4` | `8.1.5` |
| [oxc-minify](https://github.com/oxc-project/oxc/tree/HEAD/napi/minify) | `0.139.0` | `0.141.0` |

Updates `@types/supertest` from 7.2.0 to 7.2.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/supertest)

Updates `eslint` from 10.6.0 to 10.7.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.6.0...v10.7.0)

Updates `set-cookie-parser` from 3.1.1 to 3.1.2
- [Changelog](https://github.com/nfriedly/set-cookie-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nfriedly/set-cookie-parser/compare/v3.1.1...v3.1.2)

Updates `sinon` from 22.0.0 to 22.1.0
- [Release notes](https://github.com/sinonjs/sinon/releases)
- [Changelog](https://github.com/sinonjs/sinon/blob/main/CHANGES.md)
- [Commits](https://github.com/sinonjs/sinon/compare/v22.0.0...v22.1.0)

Updates `typescript` from 6.0.3 to 7.0.2
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/commits)

Updates `@radix-ui/react-dialog` from 1.1.19 to 1.1.23
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-toast` from 1.2.19 to 1.2.23
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toast/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toast)

Updates `@radix-ui/react-visually-hidden` from 1.2.7 to 1.2.11
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/visually-hidden/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/visually-hidden)

Updates `@typescript-eslint/eslint-plugin` from 8.63.0 to 8.65.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.65.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.63.0 to 8.65.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.65.0/packages/parser)

Updates `@vitejs/plugin-react` from 6.0.3 to 6.0.4
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.4/packages/plugin-react)

Updates `lucide-react` from 1.23.0 to 1.26.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.26.0/packages/lucide-react)

Updates `react` from 19.2.7 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react)

Updates `react-dom` from 19.2.7 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom)

Updates `react-hook-form` from 7.81.0 to 7.82.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.81.0...v7.82.0)

Updates `react-i18next` from 17.0.9 to 17.0.11
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.9...v17.0.11)

Updates `vite` from 8.1.4 to 8.1.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.5/packages/vite)

Updates `oxc-minify` from 0.139.0 to 0.141.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/minify/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.141.0/napi/minify)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@radix-ui/react-toast"
  dependency-version: 1.2.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@radix-ui/react-visually-hidden"
  dependency-version: 1.2.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@types/supertest"
  dependency-version: 7.2.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.65.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.65.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: eslint
  dependency-version: 10.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: lucide-react
  dependency-version: 1.25.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: oxc-minify
  dependency-version: 0.140.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react
  dependency-version: 19.2.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react-dom
  dependency-version: 19.2.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.82.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react-i18next
  dependency-version: 17.0.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: set-cookie-parser
  dependency-version: 3.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: sinon
  dependency-version: 22.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
- dependency-name: vite
  dependency-version: 8.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix(types): make TextLinesMutator and the hook test cases check under TypeScript 7

The dev-dependency bump takes typescript to 7 (tsgo), which reports two
diagnostics the old compiler let through:

- TextLinesMutator: `_curSplice` was declared `[number, number?]` although it
  holds `[start, deleteCount, ...lines]` — that mismatch is why nearly every
  access to it carries a `@ts-ignore`. Declare the documented tuple, destructure
  it for the `splice()` call instead of spreading an optional element, and treat
  `splitTextLines()`'s String.match() result as possibly-null (it cannot be null
  here: L > 0 means the text contains a newline).
- hooks spec: the `concat()` of the async cases onto the sync ones no longer
  type-checks element-wise against the inferred literal type. Give both arrays an
  explicit `HookFnTestCase` type — the cases vary in arity and return type by
  design, so `fn` keeps loose parameters and an `unknown` result.

`admin` and `ui` stay on typescript 6: their build runs `openapi-typescript`,
which drives the JS compiler API (`ts.factory.*`) that tsgo does not expose, so
TS7 there fails the vite build with "Cannot read properties of undefined
(reading 'createKeywordTypeNode')".

Type fixes taken over from #8039 by @zi-gae, without the `@ts-ignore` / `as any[]`
suppressions.

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 21:26:11 +02:00
John McLear
e1c2fcd1f1
perf: don't log settings.loadTest warning per-message (#7756) (#7776)
* perf: don't log settings.loadTest warning per-message (#7756)

CPU profile of develop (and of the open #7775 branch) at the
100-400 author dive sweep attributed ~4% of total process CPU to
log4js inside SecurityManager.checkAccess. Tracing the actual log
call: line 79-80 emits `console.warn('bypassing socket.io
authentication...')` on every checkAccess invocation when
settings.loadTest is true — once per inbound message. With log4js's
replaceConsole + cluster-mode dispatch enabled, that warning
allocated, formatted, and dispatched a LogEvent through
sendToListeners -> sendLogEventToAppender for every CLIENT_READY,
COMMIT_CHANGESET, USERINFO_UPDATE, etc.

settings.loadTest is a configuration choice, not a per-request
condition. The warning belongs at startup. Move it to Settings.ts
init alongside the other "you set X, beware" warnings, and drop
the per-message branch (the loadTest short-circuit still applies).

Test plan:
- tests/backend/specs/api/sessionsAndGroups.ts: 32 passing
- tests/backend/specs/socketio.ts: 39 passing (handleMessage paths)

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

* fixup: address Qodo review on #7776

Three issues flagged:

1. Indentation: outdented the continuation lines inside the new
   `if (settings.loadTest)` block from 10 spaces to 8 (one level
   from `logger.warn(`), matching 2-space indent rule for added
   code.

2. Warning scope: the original wording said only socket.io
   authn/authz is bypassed, but settings.loadTest short-circuits
   SecurityManager.checkAccess() which is called from both HTTP
   (padaccess, importexport) and socket.io (PadMessageHandler)
   paths. Reword to "SecurityManager.checkAccess() will bypass
   authentication and authorization for both HTTP and socket.io
   requests".

3. Misleading "fires once at startup" comment in
   SecurityManager.ts: the warning is logged from Settings.ts
   reloadSettings(), which is also called on admin restart and
   plugin install. Rephrase to "logged from Settings.ts during
   settings load/reload, not on every request".

All three issues are accurate. No behaviour change for the fix
itself; only comment + warning text.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-27 21:03:26 +02:00
John McLear
63f95d6029
perf: avoid throw-as-control-flow in SessionManager hot path (#7756) (#7775)
CPU profile of the SUT at the 100-400 author dive sweep
(load-test workflow run 25956384097) attributed about 6% of total
process CPU to the throw + catch around getSessionInfo:

  - ~1.82% to `new CustomError('sessionID does not exist', 'apierror')`
    construction (stack trace capture)
  - ~4.12% downstream, via the catch block's `console.debug(...)`
    routed through log4js -> sendToListeners -> sendLogEventToAppender

Both call sites (`findAuthorID` on every CLIENT_READY, and
`listSessionsWithDBKey` on session listing) immediately caught
`apierror` and discarded it. The public `exports.getSessionInfo`
contract still has to throw for the HTTP API (returning code:1 for
missing sessionID), so introduce a private `getSessionInfoOrNull`
helper that returns null and have the hot-path callers use it
directly. `exports.getSessionInfo` is kept as a thin wrapper that
preserves the existing throw semantics.

No behaviour change for the HTTP API — sessionsAndGroups.ts test
file (32 cases, including "getSessionInfo of deleted session"
expecting code:1) passes unchanged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-27 21:03:21 +02:00
John McLear
0aee6a7d7d
fix(editor): PageDown/PageUp now advance on consecutive long wrapped lines (#7555)
The page up/down handler advances the caret by numberOfLinesInViewport
computed from scroll.getVisibleLineRange(). That helper returns indices
into rep.lines (logical lines, not visual/wrapped rows), so when one
wrapped logical line fills the viewport — e.g., three consecutive lines
of ~2000 chars each — the range collapses to [n, n] and the advance
count becomes 0. The caret stays on line n, scroll stays at 0, and the
user sees "PageDown does nothing".

Clamp the advance to at least one logical line so the caret and viewport
always move.

Includes a Playwright regression test covering the reporter's repro
(three very long lines, Ctrl+Home, PageDown).

Closes #4562

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-27 21:03:15 +02:00
정건우(ignite)
66dd455124
Migrate server to TypeScript 7 (tsgo) (#8039)
* Migrate server to TypeScript 7 (tsgo)

Bump typescript to ^7.0.0 in ep_etherpad-lite and bin. Fix the two
diagnostics the new compiler reports: an unguarded spread of
splitTextLines' nullable result in TextLinesMutator (silenced with
@ts-ignore, matching the sibling push calls), and per-element overload
errors on the hooks.ts test-case concat that a single @ts-ignore no
longer covers (replaced with an explicit any[] cast).

admin and ui intentionally stay on TypeScript 6: their build depends on
openapi-typescript, which uses the JS compiler API that tsgo does not
expose until 7.1.

pnpm-workspace.yaml gains minimum-release-age exclusions for the
freshly released typescript 7 packages.

* Address review: null-safe splitTextLines spread, typed hook test cases

Replace the @ts-ignore-only approach with a real null guard
(splitTextLines(text) ?? []) in TextLinesMutator.insert, and replace
the any[] cast in hooks.ts with an explicit HookFnTestCase type so the
concatenated test cases stay shape-checked.

* Type _curSplice as [number, number, ...string[]], drop 21 ts-ignores

The tuple was declared [number, number?] but it actually carries the
lines to insert after the two splice numbers (the JSDoc already said
so). Typing it correctly removes every curSplice-related ts-ignore in
the file, including the one added earlier in this branch. The two
remaining ignores are about StringArrayLike lines, unrelated to this
migration.

No behavior change: types and casts only. tsc --noEmit clean,
easysync mutation tests pass.

---------

Co-authored-by: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
2026-07-27 21:00:32 +02:00
dependabot[bot]
b7bbacc2b2
build(deps): bump actions/setup-node from 6 to 7 (#8046)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 20:36:49 +02:00
John McLear
682cb65625
security(oidc): stop shipping a hardcoded cookie key and reflecting all CORS origins (#8070)
* security(oidc): stop shipping a hardcoded OIDC cookie key and reflecting all CORS origins

The embedded OIDC provider signed its interaction/session/grant cookies
with the committed literal key `['oidc']`, so anyone with the public
source could forge valid `.sig` cookies (defeating the provider's
cookie-integrity boundary), and `clientBasedCORS` returned `true` for
every origin, reflecting arbitrary `Origin` values into
`Access-Control-Allow-Origin` on the token/userinfo endpoints.

Adds OidcProviderSecurity.ts with two unit-tested pure helpers:

- resolveOidcCookieKeys(): prefers an operator-supplied
  `settings.sso.cookieKeys` (ordered array for rotation), otherwise
  derives a secret key from the persisted session secret via a
  domain-separated SHA-256 — stable across restarts and multi-pod, never
  committed to source; falls back to an ephemeral random key.
- isOriginAllowedForOidcClient(): allows a CORS origin only when it
  matches the origin of one of the client's registered redirect URIs.

Verified end-to-end against a real oidc-provider@9.9.1 instance
(cookies.keys accepted, Client.redirectUris read correctly, attacker
origin blocked). Reported privately by meifukun.

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

* security(oidc): use DB-backed SecretRotator for cookie keys under default rotation config

Addresses Qodo review on #8070. With Etherpad's default cookie settings
(keyRotationInterval + sessionLifetime set), key rotation is enabled and
`settings.sessionKey` stays null unless SESSIONKEY.txt is provisioned, so
the previous fallback handed the OIDC provider a per-process random key —
breaking in-flight OIDC cookies on restart and across horizontally-scaled
pods on a default install.

resolveOidcCookieKeys() now takes an optional rotatedSecrets array
(priority: operator cookieKeys > rotated secrets > session-key
derivation > random) and returns it by reference so a live rotation
propagates to keygrip. OAuth2Provider.expressCreateServer() creates a
dedicated `oidcCookieSecrets` SecretRotator — the same DB-backed
mechanism the Express session cookies use — when the operator hasn't
pinned settings.sso.cookieKeys and rotation is enabled.

Adds unit tests for the rotatedSecrets priority/by-reference behavior and
an integration test proving the default config yields stable DB-backed
secrets rather than a random key.

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

* docs(settings): give sso.cookieKeys a real key in the template

The doc block sat above `sso.clients` with no `cookieKeys` property, so the admin
UI's template-comment extractor (which attaches leading comments to the next
property node) would have shown it as documentation for `clients`. An unset
OIDC_COOKIE_KEY yields [""], which resolveOidcCookieKeys() filters out, so the
derived/rotated key path is unchanged.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
2026-07-27 20:36:27 +02:00
John McLear
9706e08260
security(padid): reject ueberdb delimiter ':' in pad ids (copyPad/movePad injection) (#8073)
* security(padid): reject ueberdb delimiter ':' in pad ids (copyPad/movePad injection)

GHSA-wg58-mhwv-35pq. copyPad / movePad / copyPadWithoutHistory take their
destination via the `destinationID` API field, which — unlike padID /
padName — is never run through sanitizePadId. Because isValidPadId only
forbade `$`, a destinationID like `victim:revs:0` survived into the
engine: the embedded `:` (the ueberdb key-namespace delimiter) let it
address another pad's internal `pad:<id>:revs:<n>` records. That both
bypassed the force=false "destination already exists" guard (which checks
only the top-level `atext`) and clobbered the victim pad's revision
history.

- isValidPadId now rejects `:` (never legal in a pad id; it's the DB
  key delimiter). The name portion excludes `$` and `:`.
- Pad.copy() and Pad.copyPadWithoutHistory() validate destinationID via
  isValidPadId before any db write; movePad routes through copy(), so the
  check runs before the source is removed.

Adds isValidPadId unit cases and an integration test proving copyPad /
copyPadWithoutHistory reject a `:`-bearing destinationID with force=false
and leave the victim's rev-0 changeset untouched, while a normal copy
still works. Reported privately (finder credited on the advisory).

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

* security(padid): sanitize pad URL before validating so legacy ":" URLs still redirect

Addresses Qodo review on #8073. Rejecting ":" in isValidPadId made
padurlsanitize's validate-first ordering return 404 for a browser
visiting a legacy `/p/<id with ":">` URL, instead of redirecting it to
the sanitized `_` form.

Reorder padurlsanitize to sanitize FIRST (sanitizePadId maps whitespace
and ":" to "_"), then validate the sanitized id. `/p/foo:bar` now
redirects to `/p/foo_bar` as before; an id that stays invalid after
sanitizing (e.g. containing "$") is still forbidden. This restores the
pre-fix redirect behavior while keeping ":" out of stored pad ids.

Adds a regression test for the ":" redirect, the "$" 404, and a clean id.

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

* security(padid): keep existing pads with ":" reachable

Rejecting ":" in isValidPadId also gates padManager.getPad(), so pads whose id
contains a ":" — legal before GHSA-wg58-mhwv-35pq, which is why padIdTransforms
maps ":" at all — became unopenable, not just uncreatable: sanitizePadId returns
such an id unchanged once the pad exists, and getPad then threw.

Refuse an invalid id only when no pad with that exact id exists (in getPad and in
the pad-URL param). The injection primitive stays closed: doesPadExist() requires
a top-level `atext`, which the `pad:<id>:revs:<n>` sub-records an injected
destinationID addresses do not have. Copy destinations keep the strict check, so
no new ":" id can be created.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: SamTV12345 <40429738+samtv12345@users.noreply.github.com>
2026-07-27 20:36:24 +02:00
dependabot[bot]
a0a56ad406
build(deps): bump surrealdb from 2.0.4 to 2.0.8 (#8061)
Bumps [surrealdb](https://github.com/surrealdb/surrealdb.js) from 2.0.4 to 2.0.8.
- [Release notes](https://github.com/surrealdb/surrealdb.js/releases)
- [Commits](https://github.com/surrealdb/surrealdb.js/compare/v2.0.4...v2.0.8)

---
updated-dependencies:
- dependency-name: surrealdb
  dependency-version: 2.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 20:31:26 +02:00
dependabot[bot]
2eec87cfe4
build(deps): bump oidc-provider from 9.9.1 to 9.10.0 (#8064)
Bumps [oidc-provider](https://github.com/panva/node-oidc-provider) from 9.9.1 to 9.10.0.
- [Release notes](https://github.com/panva/node-oidc-provider/releases)
- [Changelog](https://github.com/panva/node-oidc-provider/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/node-oidc-provider/compare/v9.9.1...v9.10.0)

---
updated-dependencies:
- dependency-name: oidc-provider
  dependency-version: 9.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 20:31:18 +02:00
John McLear
492b158d93
security(export): strip remote images on the soffice path to match the native path (#8071)
* security(export): strip remote images on the soffice path to match the native path

Defense-in-depth / consistency fix — not a core vulnerability on its own.
Core Etherpad never emits <img> tags in export HTML; they only appear
when a plugin/hook injects them, so sanitising such content is primarily
the injecting plugin's responsibility.

However, the native in-process export path (issue #7538) already calls
stripRemoteImages() defensively, while the LibreOffice (soffice) path
wrote the HTML to the temp file verbatim. soffice is the only export
path that actually performs outbound fetches for remote <img> URLs during
conversion, so a plugin-injected remote image there becomes a blind SSRF
sink. This makes the soffice path consistent with the native path core
already ships.

Adds a regression test that injects a remote image via
exportHTMLAdditionalContent, intercepts the temp file via the
exportConvert hook, and asserts no remote image URL survives.
Remote-image behaviour reported privately by meifukun.

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

* export: preserve doctype and comments in stripRemoteImages

Addresses Qodo review on #8071. Applying stripRemoteImages() to the full
export document for the soffice path dropped `<!doctype html>` and HTML
comments, because the htmlparser2 handler only emitted open/text/close
tags. A missing doctype can flip LibreOffice's HTML import into quirks
mode, subtly changing rendering for all soffice exports.

Add onprocessinginstruction (doctype) and oncomment handlers so the
serializer round-trips document directives and comments while still
stripping remote images. The native path is unaffected (it strips only
extractBody() output, which has no doctype). Adds regression tests.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 20:18:47 +02:00
John McLear
f3ccc6c718
security(proxy-path): set Vary on public routes that echo x-proxy-path (cache poisoning) (#8072)
* security(proxy-path): set Vary on public routes that echo x-proxy-path (cache poisoning)

The home page, pad page and timeslider (and the legacy timeslider
redirect) echo the sanitised x-proxy-path prefix into rendered URLs,
social-preview metadata, manifest links and the redirect target, but did
not advertise Vary. A shared cache/CDN keyed on URL alone could store a
prefix injected by one client and serve it to others.

The admin routes already emit Vary: x-proxy-path (GHSA-fjgc-3mj7-8rg8);
this extends the same protection to the public routes the earlier fix
left uncovered. The value is still passed through sanitizeProxyPath
(quotes/angle-brackets/protocol-relative/.. already blocked), so this is
cache-correctness hardening, not XSS/open-redirect.

Adds a varyOnProxyPath() helper applied at every sanitizeProxyPath call
site, plus a regression test asserting Vary: x-proxy-path on /, /p/:pad,
the timeslider embed page and the legacy redirect. Reported by meifukun.

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

* security(proxy-path): only Vary on trust-gated headers when trustProxy is enabled

Addresses Qodo review on #8072. varyOnProxyPath() unconditionally varied
on x-forwarded-prefix and x-ingress-path, but sanitizeProxyPath() ignores
those two headers unless settings.trustProxy is true. Advertising them in
Vary when trustProxy is false does not reflect a real dependency and only
fragments shared caches on attacker-supplied header values.

Always vary on x-proxy-path (always honored); add the two standard
headers only when trustProxy is enabled. Test asserts the exclusion when
trustProxy=false and inclusion when true.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 20:18:44 +02:00
John McLear
fa6df9a9ff
security(auth): regenerate session id on authentication (session fixation) (#8074)
* security(auth): regenerate session id on authentication (session fixation)

GHSA-73h9-c5xp-gfg4. Etherpad never rotated the express-session id when
an anonymous session was upgraded to an authenticated one. Any auth
scheme that establishes a pre-authentication session — every OIDC/OAuth
RP, including the official ep_openid_connect plugin, which persists OAuth
state before redirecting to the IdP — was exposed to CWE-384: an attacker
who planted or captured the pre-auth cookie ends up owning the victim's
authenticated (possibly admin) session after they log in.

webaccess now calls req.session.regenerate() at the authentication
boundary (after the authenticate hook / HTTP-Basic path establishes
req.session.user), preserving the session data onto the new id. It fires
only on a *fresh* login (already-authenticated requests short-circuit in
Step 2) and no-ops when the store doesn't expose regenerate(). This lives
in core, so it protects every SSO/auth plugin.

Adds a regression test proving the session id changes across the auth
boundary and that the rotated session stays authenticated.

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

* security(auth): rotate session on identity/privilege change, not just anon->auth

Addresses Qodo review on #8074. The first cut only regenerated when the
request started with no session.user, so a privilege upgrade reaching the
authenticate step for an already-authenticated session (e.g. non-admin ->
admin re-authentication) would NOT rotate the id, leaving a fixation
window on the privilege change.

Rotate whenever authentication changes the principal — anonymous -> user,
or a username / is_admin change — while still leaving a no-op re-auth of
the same principal alone (no per-request churn). Adds a deterministic
test for the non-admin -> admin rotation. Also derives the session cookie
name from settings.cookie.prefix in the test instead of hardcoding
'express_sid'.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 20:18:40 +02:00
John McLear
4d95a12845
security(collab): apply queued USER_CHANGES to the enqueue-time pad (cross-pad write TOCTOU) (#8075)
* security(collab): apply queued USER_CHANGES to the enqueue-time pad (cross-pad write TOCTOU)

GHSA-6mcx-x5h6-rpw2. handleUserChanges re-read the mutable
sessioninfos[socket.id].padId at apply time, while the authorization /
read-only gate and the channel key were taken at enqueue time. Because
per-socket messages are processed concurrently and a thrown handler does
not disconnect the socket, a same-socket CLIENT_READY could swap the
session's padId between enqueue and apply — redirecting a queued write
onto a read-only or otherwise unauthorized pad (runtime-proven cross-pad
write; a read-only share holder could overwrite the pad).

Thread the enqueue-time pad id (already the padChannels channel key) into
handleUserChanges as `authorizedPadId` and use it for the write instead
of re-reading the session. The gate and the write now refer to the same
pad, closing the TOCTOU window. Normal (non-racing) flows are unaffected
because the session padId equals the enqueue-time padId.

Adds a deterministic regression test that invokes handleUserChanges with
the session padId already swapped to a victim pad and asserts the change
lands on the authorized (argument) pad, never the victim. Verified it
fails when the vulnerable read is reintroduced.

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

* security(collab): pin pad snapshot before awaits to fully close cross-pad write TOCTOU

Addresses Qodo review on #8075: the first cut used the padChannels channel
key as the write target, but that key was still read from thisSession.padId
at enqueue time — AFTER the awaits in handleMessage() (checkAccess and the
handleMessageSecurity/handleMessage hooks). A concurrent same-socket
CLIENT_READY can mutate sessioninfos[socket.id] in place during those awaits
(the object-identity disconnect check does not catch an in-place mutation),
so the queue key — and thus the write — could still be redirected onto a
read-only / unauthorized pad after the read-only gate passed.

Pin the pad id and read-only flag into a consistent snapshot (messagePadId /
messageReadonly) together with `auth`, BEFORE any of those awaits, and use
the snapshot for the read-only gate, the hook context, the queue key and the
write. The gate, the queue key and the write now all refer to the same pad
regardless of concurrent CLIENT_READY swaps.

handleUserChanges keeps using its authorizedPadId argument (defence for the
enqueue->apply window). The test now drives a real USER_CHANGES over a
socket and swaps the session padId mid-message via a handleMessageSecurity
hook (the concurrent-CLIENT_READY race), asserting the write lands on the
authorized pad and never the victim — verified RED when the pinned key is
reverted to thisSession.padId. This also drops the handleUserChanges export
and the direct-call test (Qodo maintainability / pendingEdits / cleanup
findings).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 20:18:36 +02:00
dependabot[bot]
87a8b2a5c2
build(deps): bump @tanstack/react-query from 5.101.2 to 5.101.4 (#8060)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.101.2 to 5.101.4.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.4/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 20:15:15 +02:00
dependabot[bot]
111c9c3864
build(deps): bump tsx from 4.23.0 to 4.23.1 (#8047)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.23.0 to 4.23.1.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.0...v4.23.1)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 19:31:40 +02:00
dependabot[bot]
c6db747089
build(deps): bump express-rate-limit from 8.5.2 to 8.6.0 (#8052)
Bumps [express-rate-limit](https://github.com/express-rate-limit/express-rate-limit) from 8.5.2 to 8.6.0.
- [Release notes](https://github.com/express-rate-limit/express-rate-limit/releases)
- [Commits](https://github.com/express-rate-limit/express-rate-limit/compare/v8.5.2...v8.6.0)

---
updated-dependencies:
- dependency-name: express-rate-limit
  dependency-version: 8.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 19:31:36 +02:00
dependabot[bot]
5dedafe515
build(deps): bump @tanstack/react-query-devtools from 5.101.2 to 5.101.4 (#8062)
Bumps [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) from 5.101.2 to 5.101.4.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.101.4/packages/react-query-devtools)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.101.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 19:31:29 +02:00
dependabot[bot]
3f965fae95
build(deps): bump mysql2 from 3.22.6 to 3.23.1 (#8063)
Bumps [mysql2](https://github.com/sidorares/node-mysql2) from 3.22.6 to 3.23.1.
- [Release notes](https://github.com/sidorares/node-mysql2/releases)
- [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md)
- [Commits](https://github.com/sidorares/node-mysql2/compare/v3.22.6...v3.23.1)

---
updated-dependencies:
- dependency-name: mysql2
  dependency-version: 3.23.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 19:31:26 +02:00
dependabot[bot]
80c0910f8b
build(deps): bump jose from 6.2.3 to 6.2.4 (#8065)
Bumps [jose](https://github.com/panva/jose) from 6.2.3 to 6.2.4.
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.3...v6.2.4)

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 19:31:22 +02:00
dependabot[bot]
64c67252a7
build(deps): bump @radix-ui/react-switch from 1.3.3 to 1.3.7 (#8076)
Bumps [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) from 1.3.3 to 1.3.7.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 19:31:19 +02:00
dependabot[bot]
9f288a730d
build(deps): bump undici from 8.7.0 to 8.9.0 (#8077)
Bumps [undici](https://github.com/nodejs/undici) from 8.7.0 to 8.9.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.7.0...v8.9.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 19:31:15 +02:00
translatewiki.net
36aba18c65
Localisation updates from https://translatewiki.net. 2026-07-27 14:04:23 +02:00
translatewiki.net
2de5d10caf
Localisation updates from https://translatewiki.net. 2026-07-20 14:05:54 +02:00
John McLear
9a6109bd1d 10,000 commits. Worth a pause.
Thanks to every contributor: PRs, issues, reviews, translations, plugins, answered questions. This project runs on volunteer effort, not one person or one company. That's by design and it's why it's lasted.

Thanks to everyone running an instance: schools, newsrooms, gov departments, or just yourself. You trusted us with your documents. We don't take that lightly.

Thanks to the wider FOSS community. We've relied on other people's open source work more than we can credit individually, and tried to give some back. If you've never contributed to a FOSS project, this is your sign. Code, docs, translations, bug reports, or just running an instance and telling people about it, all of it counts.

The migration to modern JS tooling has been one of the best things to happen to this codebase. Easier to contribute to, easier to maintain, easier to build on, which is what matters for a 15+ year old project that intends to keep going.

Going forward: keep Etherpad boring, stable, self-hostable, no enshittification, while staying open to the plugins and integrations that make it useful. We need more maintainers and more instances. If that's you, open an issue.

Here's to the next 10,000. John McLear
2026-07-14 09:53:11 +01:00
translatewiki.net
037f4a25b5
Localisation updates from https://translatewiki.net. 2026-07-13 14:04:15 +02:00
dependabot[bot]
4755bd3e17
build(deps-dev): bump the dev-dependencies group with 3 updates (#8043)
Bumps the dev-dependencies group with 3 updates: [i18next](https://github.com/i18next/i18next), [react-i18next](https://github.com/i18next/react-i18next) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `i18next` from 26.3.5 to 26.3.6
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.5...v26.3.6)

Updates `react-i18next` from 17.0.8 to 17.0.9
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.8...v17.0.9)

Updates `vite` from 8.1.3 to 8.1.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.4/packages/vite)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.3.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react-i18next
  dependency-version: 17.0.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vite
  dependency-version: 8.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 11:11:18 +01:00
dependabot[bot]
05d0f62403
build(deps): bump @radix-ui/react-switch from 1.3.2 to 1.3.3 (#8029)
Bumps [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) from 1.3.2 to 1.3.3.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 10:27:58 +01:00
dependabot[bot]
3efcbfd358
build(deps): bump ueberdb2 from 6.1.15 to 6.1.16 (#8034)
Bumps [ueberdb2](https://github.com/ether/ueberDB) from 6.1.15 to 6.1.16.
- [Changelog](https://github.com/ether/ueberDB/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ether/ueberDB/compare/v6.1.15...v6.1.16)

---
updated-dependencies:
- dependency-name: ueberdb2
  dependency-version: 6.1.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 10:07:23 +01:00
dependabot[bot]
2b5d521923
build(deps): bump oidc-provider from 9.9.0 to 9.9.1 (#8036)
Bumps [oidc-provider](https://github.com/panva/node-oidc-provider) from 9.9.0 to 9.9.1.
- [Release notes](https://github.com/panva/node-oidc-provider/releases)
- [Changelog](https://github.com/panva/node-oidc-provider/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/node-oidc-provider/compare/v9.9.0...v9.9.1)

---
updated-dependencies:
- dependency-name: oidc-provider
  dependency-version: 9.9.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 10:07:14 +01:00
dependabot[bot]
631caf911c
build(deps): bump lru-cache from 11.5.1 to 11.5.2 (#8037)
Bumps [lru-cache](https://github.com/isaacs/node-lru-cache) from 11.5.1 to 11.5.2.
- [Changelog](https://github.com/isaacs/node-lru-cache/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-lru-cache/compare/v11.5.1...v11.5.2)

---
updated-dependencies:
- dependency-name: lru-cache
  dependency-version: 11.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 10:07:06 +01:00
dependabot[bot]
0aba424357
build(deps-dev): bump the dev-dependencies group across 1 directory with 2 updates (#8041)
Bumps the dev-dependencies group with 2 updates in the / directory: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [i18next](https://github.com/i18next/i18next).


Updates `@types/node` from 26.1.0 to 26.1.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `i18next` from 26.3.4 to 26.3.5
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.4...v26.3.5)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: i18next
  dependency-version: 26.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-09 16:51:09 +01:00
John McLear
194f144940
housekeeping: Adjust dependabot cooldown settings for NPM
Removed cooldown settings for Docker package ecosystem and added them for NPM.
2026-07-09 15:24:26 +01:00
John McLear
c045fcf698
housekeeping: Configure cooldown for Docker dependency updates
Added cooldown settings for Docker dependencies.
2026-07-09 15:23:00 +01:00
translatewiki.net
451df9f01c
Localisation updates from https://translatewiki.net. 2026-07-09 14:03:51 +02:00
SamTV12345
ce27525d97 chore: reinstall lockfile 2026-07-07 21:31:25 +02:00
SamTV12345
9c48900092 chore: updated lockfile 2026-07-07 21:27:35 +02:00
dependabot[bot]
173dbb3448
build(deps): bump undici from 8.6.0 to 8.7.0 (#8028)
Bumps [undici](https://github.com/nodejs/undici) from 8.6.0 to 8.7.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.6.0...v8.7.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 21:20:58 +02:00
dependabot[bot]
24e0c44d91
build(deps): bump oidc-provider from 9.8.6 to 9.9.0 (#8030)
Bumps [oidc-provider](https://github.com/panva/node-oidc-provider) from 9.8.6 to 9.9.0.
- [Release notes](https://github.com/panva/node-oidc-provider/releases)
- [Changelog](https://github.com/panva/node-oidc-provider/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/node-oidc-provider/compare/v9.8.6...v9.9.0)

---
updated-dependencies:
- dependency-name: oidc-provider
  dependency-version: 9.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 21:20:31 +02:00
dependabot[bot]
f65701eaaf
build(deps): bump mysql2 from 3.22.5 to 3.22.6 (#8031)
Bumps [mysql2](https://github.com/sidorares/node-mysql2) from 3.22.5 to 3.22.6.
- [Release notes](https://github.com/sidorares/node-mysql2/releases)
- [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md)
- [Commits](https://github.com/sidorares/node-mysql2/compare/v3.22.5...v3.22.6)

---
updated-dependencies:
- dependency-name: mysql2
  dependency-version: 3.22.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 21:20:22 +02:00
dependabot[bot]
2f766d075d
build(deps-dev): bump the dev-dependencies group across 1 directory with 8 updates (#8032)
Bumps the dev-dependencies group with 8 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.9` | `4.1.10` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.18` | `1.1.19` |
| [@radix-ui/react-toast](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toast) | `1.2.18` | `1.2.19` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.62.1` | `8.63.0` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.62.1` | `8.63.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.80.0` | `7.81.0` |
| [oxc-minify](https://github.com/oxc-project/oxc/tree/HEAD/napi/minify) | `0.138.0` | `0.139.0` |
| [vitepress](https://github.com/vuejs/vitepress) | `2.0.0-alpha.17` | `2.0.0-alpha.18` |



Updates `vitest` from 4.1.9 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

Updates `@radix-ui/react-dialog` from 1.1.18 to 1.1.19
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-toast` from 1.2.18 to 1.2.19
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toast/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toast)

Updates `@typescript-eslint/eslint-plugin` from 8.62.1 to 8.63.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.63.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.62.1 to 8.63.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.63.0/packages/parser)

Updates `react-hook-form` from 7.80.0 to 7.81.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.80.0...v7.81.0)

Updates `oxc-minify` from 0.138.0 to 0.139.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/minify/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.139.0/napi/minify)

Updates `vitepress` from 2.0.0-alpha.17 to 2.0.0-alpha.18
- [Release notes](https://github.com/vuejs/vitepress/releases)
- [Changelog](https://github.com/vuejs/vitepress/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/vitepress/compare/v2.0.0-alpha.17...v2.0.0-alpha.18)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.19
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@radix-ui/react-toast"
  dependency-version: 1.2.19
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.63.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.81.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: oxc-minify
  dependency-version: 0.139.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: vitepress
  dependency-version: 2.0.0-alpha.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 21:20:12 +02:00
translatewiki.net
2c94ef9493
Localisation updates from https://translatewiki.net. 2026-07-06 14:03:45 +02:00
dependabot[bot]
fdc23c658f
build(deps): bump tsx from 4.22.4 to 4.23.0 (#8023)
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.22.4 to 4.23.0.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.4...v4.23.0)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 11:25:26 +02:00
dependabot[bot]
e5d6142061
build(deps): bump nano from 11.0.5 to 11.0.6 (#8025)
Bumps [nano](https://github.com/apache/couchdb-nano) from 11.0.5 to 11.0.6.
- [Release notes](https://github.com/apache/couchdb-nano/releases)
- [Commits](https://github.com/apache/couchdb-nano/compare/v11.0.5...v11.0.6)

---
updated-dependencies:
- dependency-name: nano
  dependency-version: 11.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-05 11:25:10 +02:00
dependabot[bot]
b7d2477840
build(deps): bump undici from 8.5.0 to 8.6.0 (#8021)
Bumps [undici](https://github.com/nodejs/undici) from 8.5.0 to 8.6.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.5.0...v8.6.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 21:35:34 +02:00
dependabot[bot]
4bc80d1439
build(deps-dev): bump vite in the dev-dependencies group (#8020)
Bumps the dev-dependencies group with 1 update: [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `vite` from 8.1.2 to 8.1.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.1.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 21:35:18 +02:00
dependabot[bot]
8a9c0a6e20
build(deps): bump redis from 6.0.1 to 6.1.0 (#8019)
Bumps [redis](https://github.com/redis/node-redis) from 6.0.1 to 6.1.0.
- [Release notes](https://github.com/redis/node-redis/releases)
- [Changelog](https://github.com/redis/node-redis/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/node-redis/compare/redis@6.0.1...redis@6.1.0)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 13:46:09 +01:00
dependabot[bot]
df1666cffa
build(deps-dev): bump the dev-dependencies group across 1 directory with 7 updates (#8017)
Bumps the dev-dependencies group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.0.1` | `26.1.0` |
| [@types/sinon](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/sinon) | `21.0.1` | `22.0.0` |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.17` | `1.1.18` |
| [@radix-ui/react-toast](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toast) | `1.2.17` | `1.2.18` |
| [@radix-ui/react-visually-hidden](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/visually-hidden) | `1.2.6` | `1.2.7` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.22.0` | `1.23.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.1.1` | `8.1.2` |



Updates `@types/node` from 26.0.1 to 26.1.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@types/sinon` from 21.0.1 to 22.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/sinon)

Updates `@radix-ui/react-dialog` from 1.1.17 to 1.1.18
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-toast` from 1.2.17 to 1.2.18
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toast/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toast)

Updates `@radix-ui/react-visually-hidden` from 1.2.6 to 1.2.7
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/visually-hidden/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/visually-hidden)

Updates `lucide-react` from 1.22.0 to 1.23.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.23.0/packages/lucide-react)

Updates `vite` from 8.1.1 to 8.1.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.2/packages/vite)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@radix-ui/react-toast"
  dependency-version: 1.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@radix-ui/react-visually-hidden"
  dependency-version: 1.2.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@types/node"
  dependency-version: 26.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@types/sinon"
  dependency-version: 22.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
- dependency-name: lucide-react
  dependency-version: 1.23.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: vite
  dependency-version: 8.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 13:33:17 +01:00
dependabot[bot]
afdd9b9500
build(deps): bump @radix-ui/react-switch from 1.3.1 to 1.3.2 (#8018)
Bumps [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) from 1.3.1 to 1.3.2.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 21:39:53 +02:00
dependabot[bot]
3b9acd487c
build(deps): bump mssql from 12.5.5 to 12.6.0 (#8010)
Bumps [mssql](https://github.com/tediousjs/node-mssql) from 12.5.5 to 12.6.0.
- [Release notes](https://github.com/tediousjs/node-mssql/releases)
- [Changelog](https://github.com/tediousjs/node-mssql/blob/master/CHANGELOG.txt)
- [Commits](https://github.com/tediousjs/node-mssql/compare/v12.5.5...v12.6.0)

---
updated-dependencies:
- dependency-name: mssql
  dependency-version: 12.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 21:39:45 +02:00
dependabot[bot]
82d1fd63a5
build(deps): bump @tanstack/react-query-devtools from 5.101.0 to 5.101.2 (#8005)
Bumps [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools) from 5.101.0 to 5.101.2.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.101.2/packages/react-query-devtools)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.101.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 11:22:09 +01:00
dependabot[bot]
75be62c801
build(deps): bump ueberdb2 from 6.1.14 to 6.1.15 (#8008)
Bumps [ueberdb2](https://github.com/ether/ueberDB) from 6.1.14 to 6.1.15.
- [Changelog](https://github.com/ether/ueberDB/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ether/ueberDB/compare/v6.1.14...v6.1.15)

---
updated-dependencies:
- dependency-name: ueberdb2
  dependency-version: 6.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 11:22:03 +01:00
dependabot[bot]
ab4c7beded
build(deps): bump openapi-backend from 5.17.0 to 5.18.0 (#8011)
Bumps [openapi-backend](https://github.com/openapistack/openapi-backend) from 5.17.0 to 5.18.0.
- [Release notes](https://github.com/openapistack/openapi-backend/releases)
- [Commits](https://github.com/openapistack/openapi-backend/compare/5.17.0...5.18.0)

---
updated-dependencies:
- dependency-name: openapi-backend
  dependency-version: 5.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 11:21:49 +01:00
dependabot[bot]
be8120b14e
build(deps): bump nodemailer from 9.0.1 to 9.0.3 (#8015)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.0.1 to 9.0.3.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.1...v9.0.3)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-01 11:21:39 +01:00
dependabot[bot]
33160db454
build(deps): bump mongodb from 7.3.0 to 7.4.0 (#8009)
Bumps [mongodb](https://github.com/mongodb/node-mongodb-native) from 7.3.0 to 7.4.0.
- [Release notes](https://github.com/mongodb/node-mongodb-native/releases)
- [Changelog](https://github.com/mongodb/node-mongodb-native/blob/main/HISTORY.md)
- [Commits](https://github.com/mongodb/node-mongodb-native/compare/v7.3.0...v7.4.0)

---
updated-dependencies:
- dependency-name: mongodb
  dependency-version: 7.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 22:50:26 +02:00
dependabot[bot]
9e850e17f3
build(deps): bump redis from 6.0.0 to 6.0.1 (#8013)
Bumps [redis](https://github.com/redis/node-redis) from 6.0.0 to 6.0.1.
- [Release notes](https://github.com/redis/node-redis/releases)
- [Changelog](https://github.com/redis/node-redis/blob/master/CHANGELOG.md)
- [Commits](https://github.com/redis/node-redis/compare/redis@6.0.0...redis@6.0.1)

---
updated-dependencies:
- dependency-name: redis
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 20:41:41 +02:00
dependabot[bot]
3ac0b0f0a4
build(deps): bump surrealdb from 2.0.3 to 2.0.4 (#8006)
Bumps [surrealdb](https://github.com/surrealdb/surrealdb.js) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/surrealdb/surrealdb.js/releases)
- [Commits](https://github.com/surrealdb/surrealdb.js/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: surrealdb
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 20:09:42 +02:00
dependabot[bot]
cce7cc6cd4
build(deps): bump @tanstack/react-query from 5.101.0 to 5.101.2 (#8007)
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.101.0 to 5.101.2.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.101.2/packages/react-query)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.101.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 19:40:20 +02:00
dependabot[bot]
bce18df898
build(deps): bump oidc-provider from 9.8.5 to 9.8.6 (#8012)
Bumps [oidc-provider](https://github.com/panva/node-oidc-provider) from 9.8.5 to 9.8.6.
- [Release notes](https://github.com/panva/node-oidc-provider/releases)
- [Changelog](https://github.com/panva/node-oidc-provider/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/node-oidc-provider/compare/v9.8.5...v9.8.6)

---
updated-dependencies:
- dependency-name: oidc-provider
  dependency-version: 9.8.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 19:40:13 +02:00
dependabot[bot]
1442aee756
build(deps): bump awalsh128/cache-apt-pkgs-action from 1.6.1 to 1.6.3 (#8014)
Bumps [awalsh128/cache-apt-pkgs-action](https://github.com/awalsh128/cache-apt-pkgs-action) from 1.6.1 to 1.6.3.
- [Release notes](https://github.com/awalsh128/cache-apt-pkgs-action/releases)
- [Commits](https://github.com/awalsh128/cache-apt-pkgs-action/compare/v1.6.1...v1.6.3)

---
updated-dependencies:
- dependency-name: awalsh128/cache-apt-pkgs-action
  dependency-version: 1.6.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 19:40:08 +02:00
dependabot[bot]
00e1892704
build(deps-dev): bump the dev-dependencies group across 1 directory with 12 updates (#8016)
Bumps the dev-dependencies group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.61.0` | `1.61.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `26.0.0` | `26.0.1` |
| [eslint](https://github.com/eslint/eslint) | `10.5.0` | `10.6.0` |
| [set-cookie-parser](https://github.com/nfriedly/set-cookie-parser) | `3.1.0` | `3.1.1` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.61.1` | `8.62.1` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.61.1` | `8.62.1` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.2` | `6.0.3` |
| [i18next](https://github.com/i18next/i18next) | `26.3.1` | `26.3.4` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.21.0` | `1.22.0` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.18.0` | `7.18.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.16` | `8.1.1` |
| [oxc-minify](https://github.com/oxc-project/oxc/tree/HEAD/napi/minify) | `0.137.0` | `0.138.0` |



Updates `@playwright/test` from 1.61.0 to 1.61.1
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.61.0...v1.61.1)

Updates `@types/node` from 26.0.0 to 26.0.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 10.5.0 to 10.6.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.5.0...v10.6.0)

Updates `set-cookie-parser` from 3.1.0 to 3.1.1
- [Changelog](https://github.com/nfriedly/set-cookie-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nfriedly/set-cookie-parser/compare/v3.1.0...v3.1.1)

Updates `@typescript-eslint/eslint-plugin` from 8.61.1 to 8.62.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.62.1/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.61.1 to 8.62.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.62.1/packages/parser)

Updates `@vitejs/plugin-react` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.3/packages/plugin-react)

Updates `i18next` from 26.3.1 to 26.3.4
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.3.1...v26.3.4)

Updates `lucide-react` from 1.21.0 to 1.22.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.22.0/packages/lucide-react)

Updates `react-router-dom` from 7.18.0 to 7.18.1
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router-dom@7.18.1/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.18.1/packages/react-router-dom)

Updates `vite` from 8.0.16 to 8.1.1
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.1.1/packages/vite)

Updates `oxc-minify` from 0.137.0 to 0.138.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/minify/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.138.0/napi/minify)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@types/node"
  dependency-version: 26.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: eslint
  dependency-version: 10.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: set-cookie-parser
  dependency-version: 3.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.62.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.62.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: i18next
  dependency-version: 26.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: lucide-react
  dependency-version: 1.22.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react-router-dom
  dependency-version: 7.18.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: vite
  dependency-version: 8.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: oxc-minify
  dependency-version: 0.138.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 19:40:04 +02:00
translatewiki.net
2ecd748fca
Localisation updates from https://translatewiki.net. 2026-06-29 14:04:24 +02:00
translatewiki.net
0cd01fce41
Localisation updates from https://translatewiki.net. 2026-06-25 14:03:14 +02:00
dependabot[bot]
40327d59d6
build(deps): bump actions/cache from 5 to 6 (#8001)
Bumps [actions/cache](https://github.com/actions/cache) from 5 to 6.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 11:31:56 +01:00
dependabot[bot]
b45f60e20a
build(deps): bump reitzig/actions-asciidoctor from 2.0.4 to 2.0.5 (#7998)
Bumps [reitzig/actions-asciidoctor](https://github.com/reitzig/actions-asciidoctor) from 2.0.4 to 2.0.5.
- [Release notes](https://github.com/reitzig/actions-asciidoctor/releases)
- [Changelog](https://github.com/reitzig/actions-asciidoctor/blob/master/CHANGELOG.md)
- [Commits](https://github.com/reitzig/actions-asciidoctor/compare/v2.0.4...v2.0.5)

---
updated-dependencies:
- dependency-name: reitzig/actions-asciidoctor
  dependency-version: 2.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 16:55:47 +01:00
dependabot[bot]
f5b61c3388
build(deps): bump semver from 7.8.4 to 7.8.5 (#8000)
Bumps [semver](https://github.com/npm/node-semver) from 7.8.4 to 7.8.5.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.4...v7.8.5)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 7.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 16:55:42 +01:00
dependabot[bot]
ff0bd2e819
build(deps): bump ueberdb2 from 6.1.13 to 6.1.14 (#7999)
Bumps [ueberdb2](https://github.com/ether/ueberDB) from 6.1.13 to 6.1.14.
- [Changelog](https://github.com/ether/ueberDB/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ether/ueberDB/compare/v6.1.13...v6.1.14)

---
updated-dependencies:
- dependency-name: ueberdb2
  dependency-version: 6.1.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 16:55:22 +01:00
dependabot[bot]
8e04130969
build(deps): bump undici from 7.27.2 to 8.5.0 (#7997)
Bumps [undici](https://github.com/nodejs/undici) from 7.27.2 to 8.5.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.27.2...v8.5.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 14:01:12 +01:00
translatewiki.net
c8b544802a
Localisation updates from https://translatewiki.net. 2026-06-22 14:03:35 +02:00
John McLear
01500ca70c
chore(deps): vendor the unmaintained security escaper into core (#7993)
* chore(deps): vendor the unmaintained `security` escaper into core

The `security` npm package (escapeHTML / escapeHTMLAttribute and the JS/CSS
encoders) has had no release since 2012, yet it sits directly in Etherpad's
client-side XSS-defense path (pad_utils, domline) and the server-side HTML
export. Rather than keep a 14-year-old, single-maintainer dependency guarding
output encoding, vendor its implementation into core.

- static/js/security.ts now contains the escaping logic directly (reproduced
  verbatim from security@1.0.0, MIT, Chad Weider — byte-identical output) and
  no longer does `require('security')`. The full public API is preserved, so
  plugins that `require('ep_etherpad-lite/static/js/security')` keep working
  unchanged.
- pad_utils.ts requires the local './security' module instead of the bare
  'security' specifier (domline.ts and ExportHtml.ts already did).
- Drop `security` from src/package.json dependencies and from Minify's
  LIBRARY_WHITELIST (no bare specifier is served to the browser anymore).

Added tests/backend/specs/security.ts locking the byte-for-byte escaping
output so the vendored copy can never silently drift.

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

* fix: use ESM named exports so vitest can resolve the security module

CI "Run the new vitest tests" failed with `Cannot find module './security'`
from pad_utils.ts. vitest/vite's CJS require() shim doesn't add a `.ts`
extension when resolving a relative specifier, so `require('./security')`
couldn't locate security.ts. (The old bare `require('security')` resolved to
a real .js in node_modules, which is why this only surfaced after vendoring.)

- security.ts now uses ESM `export const` for the seven helpers instead of a
  `module.exports = {...}` block.
- pad_utils.ts imports it as `import * as Security from './security'`, which
  goes through vite's resolver (knows .ts) and is also properly typed.

CJS consumers (domline.ts, ExportHtml.ts, the backend spec) keep working via
tsx/esbuild ESM->CJS interop. Verified: tsc clean, full vitest suite 721
passing, and the mocha security/export/import specs 27 passing.

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

* ci: force fresh run (prior run used a stale merge ref after reopen)

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

* fix: remove ReDoS in vendored JSON-string-literal regex

CodeQL flagged a high-severity exponential-backtracking alert on the
JSON-string-literal regex vendored from the `security` package:
`/"(?:\\.|[^"])*"/`. The `[^"]` class also matches a backslash, so it overlaps
with the `\\.` alternative and backtracks exponentially on adversarial input
like `"\!\!\!...` (no closing quote). The original lived inside node_modules so
it was never scanned; vendoring it surfaced the alert.

Fix to the canonical linear form `/"(?:[^"\\]|\\.)*"/`, where the backslash is
excluded from the character class so the two alternatives are mutually
exclusive. It matches exactly the same well-formed JSON string literals (and
encodeJavaScriptData only ever runs it over JSON.stringify output), so behaviour
is unchanged for valid input.

Added tests: encodeJavaScriptData output + a ReDoS guard that runs the regex
over 50k adversarial chars and asserts it returns in well under a second.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:56:40 +01:00
John McLear
7168f14f0a
chore(deps): drop three unmaintained dependencies (unorm, find-root, jsonminify) (#7992)
* chore(deps): drop three unmaintained dependencies from core

Remove dependencies whose upstreams are effectively abandoned, replacing
each with a maintained alternative or native API. No behaviour change for
users; reduces the production dependency surface.

- unorm (last publish 2019): replace `UNorm.nfc(s)` in contentcollector
  with native `String.prototype.normalize('NFC')`, available in every
  supported Node and browser. Also drop it from Minify's LIBRARY_WHITELIST.
- find-root (last publish 2017): inline a ~10-line equivalent in
  AbsolutePaths.findEtherpadRoot(), mirroring find-root's semantics
  (closest ancestor containing package.json, throw if none).
- jsonminify (last publish 2021): swap settings parsing to jsonc-parser
  (already used by the admin workspace, actively maintained). The old
  `jsonminify(str).replace(',]', ']').replace(',}', '}')` had two bugs that
  jsonc-parser's allowTrailingComma fixes: String#replace only swapped the
  FIRST trailing comma of each kind, and the blind replace corrupted ',]' /
  ',}' byte sequences inside string values (e.g. URLs).

Added a regression test in settings.ts covering multiple trailing commas
and ',]'/',}' inside string values.

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

* fix: drop stale $unorm bundle entry from tar.json

The unorm removal left `$unorm/lib/unorm.js` listed in the ace2_inner.js
client bundle manifest. With unorm uninstalled, getTar() would point at a
node_modules asset that no longer exists, producing 404s when loading that
bundle. Nothing imports unorm anymore (contentcollector now uses native
String.prototype.normalize), so the entry is dead and removed.

Caught by Qodo review.

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

* fix: update container loadSettings helper to jsonc-parser

tests/container/loadSettings.js is a standalone helper (separate from
node/utils/Settings.ts) that parsed settings.json.docker with jsonminify
directly. Removing jsonminify from dependencies broke the container test
suite with MODULE_NOT_FOUND. Switch it to jsonc-parser to match Settings.ts.

Verified loadSettings() parses settings.json.docker and applies the
container ip/port overrides.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:53:27 +01:00
John McLear
01d0b08a4e
docs: migrate useful wiki content into the manual (#7990) (#7994)
* docs: migrate useful wiki content into the VitePress manual (#7990)

The GitHub wiki is being retired; documentation should ship with the
software. This migrates the still-accurate, non-duplicate wiki pages into
the published VitePress site (doc/**/*.md + the sidebar in
doc/.vitepress/config.mts) so they are versioned, searchable and portable:

- deployment.md: reverse-proxy configs (Nginx/Apache/Caddy/Traefik/
  HAProxy) with the WebSocket-upgrade rules, subdirectory hosting via
  X-Proxy-Path, native HTTPS via the ssl block, a systemd unit, and the
  Istio manifest (with the Redis-adapter multi-replica caveat).
- accessibility.md: editor keyboard shortcuts (verified against
  ace2_inner.ts / broadcast_slider.ts / pad_editbar.ts), toolbar
  navigation, NVDA notes.
- faq.md: install methods, URL-path reference, listing/deleting pads
  (API-first), backup/restore, and history pruning.
- development.md: source-tree tour, the pad<->format conversion pipeline,
  the internal DB API, and the Fontello toolbar-icon workflow.
- database.md: the key/value schema plus connecting MySQL/PostgreSQL/Redis
  backends and a pgloader MySQL->PostgreSQL migration (database docs were
  previously absent from the VitePress site).

Every page was checked against the current source before inclusion:
corrected the apt instructions to the live signed repo (stable/main,
signed-by key), dropped the unpublished snap, fixed the Redis dbSettings
(flat host/port/password or url, not the obsolete client_options),
dropped charset from the PostgreSQL example, and removed a phantom
getEtherpad API reference. The VitePress site builds cleanly
(pnpm run docs:build) with the dead-link checker enabled.

Closes #7990

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

* docs: add verified hands-on changeset/atext walkthrough (#7990)

Migrate the practical Changeset-library tutorial from the wiki into
changeset_library.md, rewritten against the current API: unpack(),
deserializeOps() (replacing the deprecated opIterator) and
new AttributePool() (replacing the removed AttributePoolFactory). Every
example output was produced by running the code against the current
Changeset.ts / AttributePool.ts, not copied from the wiki. Also fixes a
stale ether/etherpad-lite source link.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 09:52:33 +01:00
Etherpad Release Bot
41dc87edf9 Merge branch 'master' into develop 2026-06-21 18:45:44 +00:00
Etherpad Release Bot
3c90fa07c3 Merge branch 'develop' 2026-06-21 18:45:43 +00:00
Etherpad Release Bot
93e5bcc1e2 bump version 2026-06-21 18:45:43 +00:00
SamTV1998
851b1fb613 feat(changelog): added readme for 3.3.2 2026-06-21 20:43:09 +02:00
dependabot[bot]
9047aacc0a
build(deps): bump undici from 7.27.2 to 8.5.0 (#7991)
Bumps [undici](https://github.com/nodejs/undici) from 7.27.2 to 8.5.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.27.2...v8.5.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-21 13:11:58 +01:00
John McLear
773a9042a7
fix(import): correct outdated import-format help message (#7988) (#7989)
* fix(import): correct outdated import-format help message (#7988)

The import dialog's "no converter" notice claimed only plain text and
HTML could be imported and linked users to the legacy AbiWord wiki page,
prompting them to install LibreOffice for formats that already work
natively.

Etherpad imports .txt, .html, .docx (via mammoth) and .etherpad files
without LibreOffice; only .pdf/.odt/.doc/.rtf still need it. Update the
message to say so and move the help link to the ether/etherpad org.

Closes #7988

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

* fix(import): point help link to new LibreOffice wiki page (Qodo #7989)

The AbiWord wiki pages were vandalized/empty. Added a clean LibreOffice
setup page on the wiki and point the import dialog there instead.

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

* fix(import): reference Etherpad docs instead of wiki for LibreOffice

The wiki is being retired, so don't link to it. Point users at the
documentation site for installing LibreOffice for extra import formats.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:08:46 +01:00
dependabot[bot]
926c94ea8a
build(deps): bump undici from 7.27.2 to 8.5.0 (#7986)
Bumps [undici](https://github.com/nodejs/undici) from 7.27.2 to 8.5.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.27.2...v8.5.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-21 12:39:27 +01:00
dependabot[bot]
bf5988580e
build(deps-dev): bump the dev-dependencies group across 1 directory with 2 updates (#7987)
Bumps the dev-dependencies group with 2 updates in the / directory: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [react-hook-form](https://github.com/react-hook-form/react-hook-form).


Updates `@types/node` from 25.9.3 to 26.0.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `react-hook-form` from 7.79.0 to 7.80.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.79.0...v7.80.0)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.80.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-21 12:38:16 +01:00
dependabot[bot]
b9ec85779f
build(deps): bump pg from 8.21.0 to 8.22.0 (#7985)
Bumps [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) from 8.21.0 to 8.22.0.
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.22.0/packages/pg)

---
updated-dependencies:
- dependency-name: pg
  dependency-version: 8.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 16:02:17 +01:00
dependabot[bot]
acba429e1a
build(deps): bump actions/checkout from 6 to 7 (#7977)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-19 12:47:45 +01:00
John McLear
8c5de8446c
fix(bin): migrate importSqlFile & migrateDirtyDBtoRealDB to ueberdb2 promise API (#7983)
Both scripts still called the pre-v6 callback-style ueberdb2 API, producing
type errors (masked in places by `// @ts-ignore`) against the current
promise-based signatures (`set(key, value)`, `init()`, `close()` — no
callback/extra args):

  importSqlFile.ts(73)            initDb(null)        Expected 0 arguments, but got 1
  migrateDirtyDBtoRealDB.ts(51)   db.set(k,v,bcb,wcb) Expected 2 arguments, but got 4
  migrateDirtyDBtoRealDB.ts(56)   db.close(null)      Expected 0 arguments, but got 1
  migrateDirtyDBtoRealDB.ts(57)   dirty.close(null)   Expected 0 arguments, but got 1

- importSqlFile: drop the unused `util` import and the `util.promisify`
  wrappers; `await db.init()`, `await db.set(...)`, `await db.close()`
  directly. Removes two `// @ts-ignore` that were hiding the broken calls.
- migrateDirtyDBtoRealDB: replace the bcb/wcb callback machinery with
  `await db.set(key, value)` in the loop and call `close()` with no args.
  Also fixes the progress log which referenced an undefined `length`
  instead of `keys.length`.

Pure type/correctness cleanup; behaviour is unchanged (writes are now
awaited, which is equivalent or safer). `tsc --noEmit` on the bin package
is now clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:51:34 +01:00
John McLear
9d2dae1b63
fix(bin): close DBs in migrateDB so it flushes and exits (#7982)
`bin/migrateDB.ts` opens a source and target ueberdb2 Database, copies all
keys, then resolves without closing either connection or calling
process.exit(). Two problems with ueberdb2 6.1.x:

- 6.1.x keeps an internal keep-alive timer running until close() is called,
  so the migration process hangs forever after "Done syncing dbs" instead
  of exiting. (Pre-6.1.x it exited on its own once the work was done.)
- Target writes are buffered and only guaranteed flushed to disk on close()
  /flush(), so an operator who Ctrl-Cs the apparently-finished process could
  end up with an incomplete migration.

Close the target then the source on both the success and error paths (which
flushes buffered writes and clears the keep-alive timer) and exit with an
explicit status code, matching the pattern already used in
migrateDirtyDBtoRealDB.ts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:48:39 +01:00
John McLear
32249d99e2
fix(ci): reap whole server tree in installer smoke test so it can't hang 6h (#7981)
The "Installer test" workflow has hung for 6 hours (until GitHub's job
ceiling cancels it) on every ubuntu/macos run since v3.2.0. The smoke
test starts `pnpm run prod` in the background, confirms /api responds,
then tears it down with:

    kill "$PID"
    wait "$PID"

`pnpm run prod` is a nested launcher (pnpm -> pnpm --filter -> node), so
$PID is only the outer pnpm. SIGTERM is forwarded down the chain and the
script then `wait`s on it, but if the node server doesn't exit (e.g. a
live flush timer keeping the event loop alive) the wait blocks forever
and the step never releases its output pipe -> 6h hang. Windows passed
because it uses `Stop-Process -Force`.

Fix the teardown to be robust regardless of server shutdown behaviour:
- `set -m` so the launcher gets its own process group
- kill the whole group (SIGTERM, then SIGKILL fallback) via a trap
- drop the blocking `wait`
- add `timeout-minutes: 8` to both smoke steps as a hard backstop so a
  future hang fails in minutes, not 6 hours

This unblocks CI on PRs that touch the installer workflow. The
underlying clean-shutdown regression (server not exiting on SIGTERM,
likely the ueberDB flush-timer setInterval) is tracked separately.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:37:04 +01:00
dependabot[bot]
2109c05ad4
build(deps): bump undici from 7.27.2 to 8.5.0 (#7980)
Bumps [undici](https://github.com/nodejs/undici) from 7.27.2 to 8.5.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.27.2...v8.5.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.5.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 22:31:52 +01:00
dependabot[bot]
f6f665ff60
build(deps-dev): bump the dev-dependencies group with 2 updates (#7978)
Bumps the dev-dependencies group with 2 updates: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) and [oxc-minify](https://github.com/oxc-project/oxc/tree/HEAD/napi/minify).


Updates `lucide-react` from 1.20.0 to 1.21.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.21.0/packages/lucide-react)

Updates `oxc-minify` from 0.136.0 to 0.137.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/minify/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.137.0/napi/minify)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.21.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: oxc-minify
  dependency-version: 0.137.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 21:48:32 +01:00
dependabot[bot]
b680076534
build(deps): bump ueberdb2 from 6.1.9 to 6.1.13 (#7979)
Bumps [ueberdb2](https://github.com/ether/ueberDB) from 6.1.9 to 6.1.13.
- [Changelog](https://github.com/ether/ueberDB/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ether/ueberDB/compare/v6.1.9...v6.1.13)

---
updated-dependencies:
- dependency-name: ueberdb2
  dependency-version: 6.1.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 21:47:16 +01:00
translatewiki.net
b1792469b1
Localisation updates from https://translatewiki.net. 2026-06-18 14:02:55 +02:00
dependabot[bot]
3aa13197e4
build(deps): bump nodemailer from 9.0.0 to 9.0.1 (#7976)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.0.0 to 9.0.1.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.0.0...v9.0.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:19:51 +01:00
dependabot[bot]
dfdbceebe2
build(deps-dev): bump the dev-dependencies group across 1 directory with 7 updates (#7970)
Bumps the dev-dependencies group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.16` | `1.1.17` |
| [@radix-ui/react-toast](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/toast) | `1.2.16` | `1.2.17` |
| [@radix-ui/react-visually-hidden](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/visually-hidden) | `1.2.5` | `1.2.6` |
| [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.61.0` | `8.61.1` |
| [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) | `8.61.0` | `8.61.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.18.0` | `1.20.0` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.17.0` | `7.18.0` |



Updates `@radix-ui/react-dialog` from 1.1.16 to 1.1.17
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog)

Updates `@radix-ui/react-toast` from 1.2.16 to 1.2.17
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/toast/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/toast)

Updates `@radix-ui/react-visually-hidden` from 1.2.5 to 1.2.6
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/visually-hidden/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/visually-hidden)

Updates `@typescript-eslint/eslint-plugin` from 8.61.0 to 8.61.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.61.0 to 8.61.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/parser)

Updates `lucide-react` from 1.18.0 to 1.20.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.20.0/packages/lucide-react)

Updates `react-router-dom` from 7.17.0 to 7.18.0
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/main/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.18.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-dialog"
  dependency-version: 1.1.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@radix-ui/react-toast"
  dependency-version: 1.2.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@radix-ui/react-visually-hidden"
  dependency-version: 1.2.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.61.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: lucide-react
  dependency-version: 1.20.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: react-router-dom
  dependency-version: 7.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 22:23:04 +01:00
John McLear
a698e34772
chore(release): park the non-functional ep_etherpad npm publish (#7922)
The releaseEtherpad workflow renames ep_etherpad-lite -> ep_etherpad and
publishes ./src to npm, but that publish is not load-bearing:

- `ep_etherpad` has 0 dependents on npm; nothing in this repo depends on it.
- Plugins import `ep_etherpad-lite` resolved from the LOCAL core install, and
  plugin CI clones `ether/etherpad` rather than `npm install`-ing core.
- Etherpad is run via git clone / Docker / zip / snap, never `npm install`.

It has been failing with E404 (the ep_etherpad package has no OIDC trusted
publisher configured on npmjs.com), which is why npm is stuck at 2.5.0 while
3.0/3.1/3.2/3.3 shipped fine without it.

Rather than chase a trusted-publisher setup for a publish nobody consumes,
park the workflow: gate the job behind an explicit `confirm: true` dispatch
input so a stray run fails fast with a clear message instead of a confusing
404, and document the status in the header + the AGENTS.MD Releasing section.

This is the package owner's (samtv12345) call: either finish the trusted-
publisher config to revive it, or remove the workflow. Parked pending that
decision; nothing about the release depends on it.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:18:41 +01:00
John McLear
ce038b0b0f
fix(pad): keep token-less Delete pad reachable without pad-wide settings (#7959) (#7960)
* fix(pad): keep token-less Delete pad reachable without pad-wide settings (#7959)

The token-less "Delete pad" button (#delete-pad) was nested inside the
enablePadWideSettings-gated pad-settings section, so disabling pad-wide
settings removed the only way to delete a pad without a recovery token.
Combined with #7926 hiding the token disclosure when deletion needs no
token (e.g. allowPadDeletionByAllUsers), a user who was allowed to delete
could be left with no deletion UI at all.

Pad deletion is unrelated to pad-wide settings, so:

- Move #delete-pad out of the enablePadWideSettings block in pad.html; it
  is now always rendered and hidden by default.
- Add a canDeletePad clientVar (isCreator || allowPadDeletionByAllUsers)
  and drive the button's visibility from it in pad_editor.ts, mirroring the
  existing canDeleteWithoutToken handling for the token disclosure.

The two controls are now mutually coherent and neither depends on
enablePadWideSettings: the plain button shows when this session can delete
without a token, the recovery-token disclosure shows otherwise.

Tests:
- backend padDeletionUiPlacement.ts: #delete-pad is rendered with
  enablePadWideSettings both on and off (fails without the template move).
- backend socketio.ts: canDeletePad reflects the creator/allow-all matrix,
  including a non-creator who only gains it under allowPadDeletionByAllUsers.
- frontend pad_settings.spec.ts: asserts #delete-pad is no longer a
  descendant of #pad-settings-section.

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

* fix(pad): never let readonly sessions delete via token-less paths (#7959)

Qodo review of #7960: `canDeletePad` was `isCreator || allowPadDeletionByAllUsers`,
so under allowPadDeletionByAllUsers a readonly viewer received
canDeletePad=true and the relocated #delete-pad button unhid for them.
Worse, the server-side handlePadDelete `flagOk`/`creatorOk` branches never
checked session.readonly either, so a readonly-link holder could actually
delete the pad without a token — a data-loss hole that the new always-rendered
button would expose.

Exclude readonly sessions from both the clientVar and the server's token-less
authorization paths. A valid recovery token (tokenOk) stays a sufficient
credential regardless of session mode.

Test: socketio.ts asserts a readonly viewer gets canDeletePad=false and that a
token-less PAD_DELETE from a readonly session leaves the pad intact (red before
this change on the clientVar assertion).

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 15:18:23 +01:00
dependabot[bot]
d67aa3ebd3
build(deps): bump undici from 8.4.1 to 8.5.0 (#7972)
Bumps [undici](https://github.com/nodejs/undici) from 8.4.1 to 8.5.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.4.1...v8.5.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 15:17:49 +01:00
dependabot[bot]
4ba72b3919
build(deps): bump oidc-provider from 9.8.4 to 9.8.5 (#7973)
Bumps [oidc-provider](https://github.com/panva/node-oidc-provider) from 9.8.4 to 9.8.5.
- [Release notes](https://github.com/panva/node-oidc-provider/releases)
- [Changelog](https://github.com/panva/node-oidc-provider/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/node-oidc-provider/compare/v9.8.4...v9.8.5)

---
updated-dependencies:
- dependency-name: oidc-provider
  dependency-version: 9.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 15:17:36 +01:00
dependabot[bot]
bfe3fad256
build(deps): bump @radix-ui/react-switch from 1.3.0 to 1.3.1 (#7974)
Bumps [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) from 1.3.0 to 1.3.1.
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 15:17:10 +01:00
John McLear
3e53962fb5
fix(deps): force @opentelemetry/core >=2.8.0 (CVE-2026-54285) (#7975)
Pin the transitive @opentelemetry/core dep (pulled in via
@elastic/elasticsearch -> @elastic/transport) to >=2.8.0 to clear
GHSA-8988-4f7v-96qf / CVE-2026-54285: W3CBaggagePropagator.extract()
did not enforce W3C size limits on inbound baggage headers, allowing
unbounded memory allocation. @elastic/transport declares the dep as
"2.x" so 2.8.0 satisfies the existing range with no parent bump, and
2.8.0's @opentelemetry/api peer range (>=1.0.0 <1.10.0) is satisfied
by the 1.9.1 already in the tree.

Override added to pnpm-workspace.yaml alongside the other CVE
force-bumps (pnpm 11 ignores root package.json pnpm.overrides).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 08:53:20 +01:00
John McLear
a59b310dbf
deps: pin ueberdb2 to 6.1.9 (fixes standalone-server startup exit) + run deb smoke on PRs (#7969)
ueberdb2 6.1.10 rewrote the cache/buffer layer (CacheAndBufferLayer.ts),
replacing the constructor's always-on, *referenced* `setInterval` flush timer
with a lazily-armed `setTimeout` that is `.unref()`'d and only created when
there are dirty keys. On a fresh, empty dirty DB there are no dirty keys, so no
flush timer is ever armed and ueberdb2 no longer anchors Node's event loop. In
the packaged (.deb/systemd) production boot this exposes a startup window where
the loop has no referenced handle and the process exits cleanly (code 0) before
`server.listen()` binds the port — so the server never serves /health.

Symptom on develop: the Debian-package amd64 smoke test failed on three
consecutive pushes (#7966 ueberdb2 6.1.9->6.1.12, then #7965, #7967), with the
service logging the version banner then "Deactivated successfully" and the
health check on :9001 never connecting. Backend/Docker/downstream-smoke stayed
green because they keep the loop alive by other means; only the bare
fresh-empty-dirty-DB packaged boot hits the gap. ueberdb2 6.1.12 is the latest
published release, so there is no fixed version to roll forward to yet — pin
back to the last green release (6.1.9) on both src/ and bin/.

Also add a `pull_request` trigger to the Debian-package workflow (scoped to the
same production-footprint paths). The smoke step is the only check that catches
this "boots then exits before binding" class of regression, but it previously
ran only on push to develop — i.e. *after* merge — which is exactly why the
Dependabot bump turned develop red instead of being blocked at PR time. The
release/apt-publish jobs are tag-guarded, so PRs run the build+smoke job only.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:39:37 +01:00
forkivan
0c0f5a8ec4
PadManager: reject unreachable '.' and '..' pad ids (#7962)
* PadManager: reject unreachable '.' and '..' pad ids

isValidPadId accepted pad ids that consist only of URL dot-segments
('.' and '..'). Per the WHATWG URL standard a browser normalises
"/p/." to "/p/" and "/p/.." to "/", so such a pad can never be opened
or exported: the request arrives at "/p/" and Etherpad answers
"Cannot GET /p/". The pad is created in the database but is forever
unreachable.

Reject these ids in isValidPadId so the broken pads can no longer be
created, and add a regression test that fails without the fix.

* adminsettings: allow deleting legacy '.'/'..' pads

The isValidPadId tightening makes getPad() reject the pad ids '.' and
'..', which also blocks their deletion: a pad with such an id created
before the change still exists (doesPadExists is true), so the admin
deletePad handler takes the "healthy" branch where getPad() now throws.
The outer catch swallowed that error without emitting a terminal
results:deletePad, leaving an undeletable orphan in the admin UI.

Fall back to the existing raw key purge when getPad() throws, so these
pads can still be removed. Adds a regression test.

* tests: run the isValidPadId regression under the mocha suite

The original unit test lived in the vitest backend-new suite, but PadManager
loads DB, Pad and customError with CommonJS require() at import time. Under
vitest those require() calls are resolved by Node natively and fail on the .ts
sources ("Cannot find module '../utils/customError'"); vi.mock could not
intercept them, so the suite errored before any test ran. It also used a
top-level `await import`, which tripped tsc TS1378 under the project tsconfig.

Move the test to the mocha backend suite, which runs with --import=tsx and
resolves the .ts requires natively, so PadManager can be required directly with
no mocking. isValidPadId is a pure function and DB only connects lazily in
DB.init(), so loading the module has no side effects and no database is needed.
2026-06-16 11:13:02 +01:00
John McLear
099de84cd1
deps: resolve open Dependabot security alerts (#7967)
* deps: resolve open Dependabot security alerts

Bump transitive dependencies flagged by Dependabot via pnpm-workspace
overrides. Refreshes stale override floors that the advisory ranges have
since grown past, and adds overrides for newly-flagged packages:

- form-data  -> >=4.0.6  (GHSA-hmw2-7cc7-3qxx, high)
- ws         -> >=8.21.0 (GHSA-96hv-2xvq-fx4p / GHSA-58qx-3vcg-4xpx, high/med)
- esbuild    -> >=0.28.1 (GHSA-gv7w-rqvm-qjhr / GHSA-g7r4-m6w7-qqqr, high/low)
- basic-ftp  -> >=5.3.1  (GHSA-rpmf-866q-6p89, high) [stale floor: 5.3.0 still vuln]
- tar        -> >=7.5.16 (GHSA-vmf3-w455-68vh, med)  [stale floor: 7.5.11]
- js-yaml    -> >=4.2.0  (GHSA-h67p-54hq-rp68, med)  [stale floor: 4.1.1 now vuln]
- qs         -> >=6.15.2 (GHSA-q8mj-m7cp-5q26, med)  [stale floor: 6.14.2 still vuln]
- ip-address -> >=10.1.1 (GHSA-v2v4-37r5-5v8g, med)
- @babel/core-> >=7.29.6 (GHSA-4x5r-pxfx-6jf8, low)

ts-check, backend test-utils, and the vite/esbuild production build all
pass locally on Node 24.

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

* deps: cap basic-ftp override to 5.x to avoid major bump

Qodo flagged the open-ended `basic-ftp@<5.3.1: '>=5.3.1'` override as
resolving to 6.0.1 (a surprise major) on the runtime plugin-install path
(live-plugin-manager -> proxy-agent -> get-uri -> basic-ftp). The CVE fix
(5.3.1) exists on the 5.x line, so bound the override to '>=5.3.1 <6.0.0'
for the minimal, lowest-risk patch. Now resolves to basic-ftp@5.3.1.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:32:06 +01:00
dependabot[bot]
8a4e1feed8
build(deps): bump awalsh128/cache-apt-pkgs-action from 1.6.0 to 1.6.1 (#7963)
Bumps [awalsh128/cache-apt-pkgs-action](https://github.com/awalsh128/cache-apt-pkgs-action) from 1.6.0 to 1.6.1.
- [Release notes](https://github.com/awalsh128/cache-apt-pkgs-action/releases)
- [Commits](https://github.com/awalsh128/cache-apt-pkgs-action/compare/v1.6.0...v1.6.1)

---
updated-dependencies:
- dependency-name: awalsh128/cache-apt-pkgs-action
  dependency-version: 1.6.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 19:08:07 +01:00
dependabot[bot]
2fdf202089
build(deps): bump nodemailer from 8.0.11 to 9.0.0 (#7965)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.11 to 9.0.0.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.11...v9.0.0)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 19:08:00 +01:00
dependabot[bot]
abdfa03f84
build(deps): bump ueberdb2 from 6.1.9 to 6.1.12 (#7966)
Bumps [ueberdb2](https://github.com/ether/ueberDB) from 6.1.9 to 6.1.12.
- [Changelog](https://github.com/ether/ueberDB/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ether/ueberDB/compare/v6.1.9...v6.1.12)

---
updated-dependencies:
- dependency-name: ueberdb2
  dependency-version: 6.1.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 19:07:47 +01:00
dependabot[bot]
c8e3248bcd
build(deps-dev): bump the dev-dependencies group with 6 updates (#7964)
Bumps the dev-dependencies group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.60.0` | `1.61.0` |
| [eslint](https://github.com/eslint/eslint) | `10.4.1` | `10.5.0` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.78.0` | `7.79.0` |
| [oxc-minify](https://github.com/oxc-project/oxc/tree/HEAD/napi/minify) | `0.135.0` | `0.136.0` |


Updates `@playwright/test` from 1.60.0 to 1.61.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.60.0...v1.61.0)

Updates `eslint` from 10.4.1 to 10.5.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.4.1...v10.5.0)

Updates `vitest` from 4.1.8 to 4.1.9
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/HEAD/packages/vitest)

Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3)

Updates `react-hook-form` from 7.78.0 to 7.79.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.78.0...v7.79.0)

Updates `oxc-minify` from 0.135.0 to 0.136.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/minify/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.136.0/napi/minify)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: eslint
  dependency-version: 10.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: vitest
  dependency-version: 4.1.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.79.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: oxc-minify
  dependency-version: 0.136.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 16:38:06 +01:00
translatewiki.net
4371af77c2
Localisation updates from https://translatewiki.net. 2026-06-15 14:03:51 +02:00
John McLear
2ba72de1de
feat(pad): suppress deletion token for durable identities + relabel recovery action (#7926) (#7930)
* feat(pad): suppress deletion token for durable identities; relabel recovery action (#7926)

Builds on the allowPadDeletionByAllUsers suppression with the rest of the
ideas discussed on the issue.

Server (handleClientReady):
- A creator's deletion token is now also withheld when they have a *durable*
  identity: authenticated (req.session.user with a username) AND the deployment
  pins that identity to a stable authorID via a getAuthorId hook. Only then does
  the creator path (author === revision-0 author) survive a cookie clear or a
  different device, making the recovery token redundant.
- This deliberately tightens the previous `requireAuthentication => always
  suppress` rule: without a getAuthorId hook the authorID still comes from the
  per-browser token cookie, so an authenticated user on a second device is NOT
  the creator. Withholding the token there would strand them, so they now keep
  getting one. SSO deployments using the documented getAuthorId pattern get the
  clean no-modal experience.
- New `canDeleteWithoutToken` clientVar (allowPadDeletionByAllUsers OR durable
  identity) drives the client label.

Client (pad_editor.ts):
- When canDeleteWithoutToken, the recovery-token disclosure summary is labelled
  plainly "Delete Pad" (reusing the already-translated pad.settings.deletePad
  key) instead of the jargon "Delete with token".

Tests (socketio.ts): anonymous -> token; allowPadDeletionByAllUsers -> none;
authenticated without a getAuthorId hook -> token; authenticated with one ->
none. Verified in a browser for both label/modal outcomes.

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

* fix(pad): render recovery disclosure for all sessions; hide it (not relabel) when no token is needed

Addresses Qodo review on #7930:

1. Token UI fully suppressed when not needed (was: only the summary relabelled).
   When canDeleteWithoutToken (allowPadDeletionByAllUsers or a durable identity),
   pad_editor.ts now hides the whole #delete-pad-with-token disclosure — label,
   token field and submit — so no deletion-token wording remains. With the plain
   "Delete Pad" button present this is also the cleaner UX.

2. Recovery disclosure no longer gated on !requireAuthentication. Because a token
   can now be issued under requireAuthentication when the deployment lacks a
   durable getAuthorId mapping, the template must render the recovery form there
   too — otherwise an authenticated creator gets a token with no UI to enter it
   on another device. It is rendered hidden by default and shown by the client
   only when canDeleteWithoutToken is false.

3. API.createPad aligned: also returns null deletionToken under
   allowPadDeletionByAllUsers, matching the socket/UI path.

Tests: deletePad.ts gains a createPad-under-allowPadDeletionByAllUsers case.
Verified live in Chromium: allowAll -> disclosure hidden, no modal; default
anonymous -> disclosure visible, modal shown.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:47:11 +01:00
dependabot[bot]
6a5ad2d90d
build(deps-dev): bump lucide-react in the dev-dependencies group (#7951)
Bumps the dev-dependencies group with 1 update: [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react).


Updates `lucide-react` from 1.17.0 to 1.18.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.18.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-13 14:34:55 +01:00
John McLear
f79cabe74c
Env-var overrides for offline/air-gapped installs (update check, plugin catalog, updater) (#7917)
* feat(settings): env-var overrides for update-check/plugin-catalog/updater (offline installs)

Air-gapped and firewalled deployments could not disable Etherpad's outbound
calls (the hourly version check, the admin plugin catalogue, and the
self-updater) without editing settings.json inside the container image — the
shipped settings.json.docker hardcoded updates.tier and omitted the privacy
block entirely, so there was no env-var to flip.

Wire the relevant keys through the existing ${ENV:default} substitution in both
settings.json.docker and settings.json.template:

  - PRIVACY_UPDATE_CHECK            (privacy.updateCheck, default true)
  - PRIVACY_PLUGIN_CATALOG          (privacy.pluginCatalog, default true)
  - UPDATES_TIER                    (updates.tier, default notify; "off" = no calls)
  - UPDATES_SOURCE / UPDATES_CHANNEL / UPDATES_CHECK_INTERVAL_HOURS /
    UPDATES_GITHUB_REPO / UPDATES_REQUIRE_ADMIN_FOR_STATUS (docker)
  - UPDATE_SERVER                   (updateServer endpoint)

Document the full set in doc/docker.md (new "Updates & privacy" section) and
cross-link from doc/admin/updates.md. Add backend regression tests that parse
the shipped settings.json.docker and settings.json.template and assert the
overrides apply with correct boolean/numeric coercion, so a future edit that
drops the ${ENV} placeholders fails loudly.

Addresses #7911 (item 2). No code changes — config + docs + tests only.

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

* docs: address Qodo review — point to PRIVACY.md, clarify tier=off scope

- Outbound-call docs/comments referenced doc/privacy.md (the storage/logging
  doc); the canonical outbound-call inventory is repo-root PRIVACY.md, which the
  runtime messages in UpdateCheck.ts / Settings.ts also reference. Re-point
  settings.json.{template,docker} and doc/docker.md there.
- doc/admin/updates.md said updates.tier="off" means "no HTTP request will leave
  the instance", but the legacy UpdateCheck.ts call to ${updateServer}/info.json
  is gated by privacy.updateCheck, not updates.tier. Clarify that air-gapped
  installs must set PRIVACY_UPDATE_CHECK=false too.

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

* docs: link PRIVACY.md via absolute URL to satisfy VitePress dead-link check

PRIVACY.md lives at the repo root, outside the doc/ tree VitePress builds, so a
relative link to it (../PRIVACY.md / ../../PRIVACY.md) is flagged as a dead link
and fails `docs:build`. Use the absolute GitHub URL instead, matching how
doc/configuration.md already links settings.json.template.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 14:32:46 +01:00
dependabot[bot]
662678275a
build(deps): bump esbuild from 0.28.0 to 0.28.1 (#7952)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.28.0 to 0.28.1.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-12 21:29:42 +02:00
dependabot[bot]
8e4eb2646a
build(deps): bump semver from 7.8.3 to 7.8.4 (#7943)
Bumps [semver](https://github.com/npm/node-semver) from 7.8.3 to 7.8.4.
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.3...v7.8.4)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 7.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-12 12:42:25 +01:00
dependabot[bot]
e313366b49
build(deps): bump nodemailer and @types/nodemailer (#7950)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) and [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer). These dependencies needed to be updated together.

Updates `nodemailer` from 8.0.10 to 8.0.11
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.10...v8.0.11)

Updates `@types/nodemailer` from 8.0.0 to 8.0.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: "@types/nodemailer"
  dependency-version: 8.0.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-12 12:42:14 +01:00
dependabot[bot]
3cddff7b27
build(deps): bump mongodb from 7.2.0 to 7.3.0 (#7941)
Bumps [mongodb](https://github.com/mongodb/node-mongodb-native) from 7.2.0 to 7.3.0.
- [Release notes](https://github.com/mongodb/node-mongodb-native/releases)
- [Changelog](https://github.com/mongodb/node-mongodb-native/blob/main/HISTORY.md)
- [Commits](https://github.com/mongodb/node-mongodb-native/compare/v7.2.0...v7.3.0)

---
updated-dependencies:
- dependency-name: mongodb
  dependency-version: 7.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-12 12:36:39 +01:00
dependabot[bot]
817ca2546e
build(deps-dev): bump @types/node in the dev-dependencies group (#7944)
Bumps the dev-dependencies group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `@types/node` from 25.9.2 to 25.9.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.9.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-12 12:15:23 +01:00
dependabot[bot]
eefdfc44b9
build(deps): bump pdfkit from 0.19.0 to 0.19.1 (#7945)
Bumps [pdfkit](https://github.com/foliojs/pdfkit) from 0.19.0 to 0.19.1.
- [Release notes](https://github.com/foliojs/pdfkit/releases)
- [Changelog](https://github.com/foliojs/pdfkit/blob/master/CHANGELOG.md)
- [Commits](https://github.com/foliojs/pdfkit/compare/v0.19.0...v0.19.1)

---
updated-dependencies:
- dependency-name: pdfkit
  dependency-version: 0.19.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-12 12:15:17 +01:00
John McLear
73a9b88bb4
test(timeslider): port legacy mocha specs to Playwright, retire orphaned suite (#7949)
* test(timeslider): port legacy mocha specs to Playwright, retire orphaned suite

The legacy src/tests/frontend/specs/ mocha suite is run by no CI workflow,
so its timeslider coverage was dead — a regression in the in-pad history UI
(#7659/#7946) sailed through CI. Port the still-meaningful cases to
frontend-new Playwright specs, re-targeted at the real in-pad UI (outer
banner / slider / export links that pad_mode.ts drives) rather than the
isolated ?embed=1 iframe the existing specs use:

- timeslider_revision_labels.spec.ts (from timeslider_labels.js): the
  #history-banner shows 'Version N' + a valid (non-NaN) date and timer, and
  both update when scrubbing to revision 0.
- timeslider_export_links.spec.ts (from timeslider_numeric_padID.js and the
  'checks the export url' case of timeslider_revisions.js): the outer export
  hrefs target /p/<pad>/<rev>/export/<type> for the viewed revision, including
  a numeric pad id, and follow the slider to revision 0.
- timeslider_deeplink.spec.ts (from the 'jumps to a revision given in the url'
  case): a #rev/N hash — and the legacy #N shortlink form — boots straight
  into history mode at that revision.

Delete the three now-ported legacy specs. Verified passing on Chromium and
Firefox. The star-marker case of timeslider_revisions.js is already covered by
timeslider_saved_revisions.spec.ts (#7948).

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

* test(timeslider): make deep-link spec robust on Firefox

Assert the canonical #rev/0 URL and the slider landing on revision 0 rather
than the #history-banner-rev label text. The banner label is populated via a
MutationObserver bridge that races the iframe load on the bootstrap path in
Firefox (it is already covered for the normal button-entry flow by
timeslider_revision_labels.spec.ts); the URL + slider value are the
deterministic signals that the deep link entered history at the right revision.

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

* test(timeslider): address Qodo review on #7949

- Pin locale to en-US in the revision-labels spec so the localized 'Version N'
  label and 'Saved <Month> <day>, <year>' date assertions are deterministic and
  the date stays Date-parseable, instead of depending on the runner's locale.
- Use a high-entropy numeric pad id (timestamp + random) in the export-links
  spec so reruns against a persistent DB can't collide on the same pad.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:14:39 +01:00
John McLear
a65e3b75a4
fix(pad): show saved-revision markers in in-pad history mode (#7946) (#7948)
* fix(pad): show saved-revision markers in in-pad history mode (#7946)

When #7659 moved the timeslider into the pad as an embedded iframe, the
user-facing control became the outer #history-slider-input range input.
The saved-revision stars are still drawn by broadcast_slider.ts into the
iframe's #ui-slider-bar, but that DOM is hidden in embed mode, and
pad_mode.ts bridged rev/max/value/timer/authors to the outer slider but
never the saved revisions. Result: clicking "Save Revision" appeared to
work but no markers showed in the timeslider (regression in 3.3.x).

Bridge the embedded timeslider's clientVars.savedRevisions onto the outer
slider as percentage-positioned star markers, rendered on first sync and
re-rendered when the slider max changes. Markers are a purely visual,
aria-hidden overlay (keyboard/SR users already reach any revision via the
slider + step buttons) with a click-to-seek convenience for mouse users.

Adds a frontend-new Playwright spec exercising the real user flow (save a
revision in the pad, enter in-pad history mode, assert a visible marker on
the outer slider). The only prior coverage lived in the legacy mocha
suite (src/tests/frontend/specs/timeslider_revisions.js) which no CI
workflow runs, and the modern timeslider specs drive the ?embed=1 iframe
directly and never exercise the outer history UI — so this regression was
invisible to CI.

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

* fix(pad): live saved-revision markers + Qodo review fixes (#7946)

Address Qodo review on #7948:

- Live updates (bug 1): the server's SAVE_REVISION handler never broadcast
  NEW_SAVEDREV, so the client handler that adds a star was dead and no open
  timeslider ever updated live. Wire it up: Pad.addSavedRevision returns the
  new revision (undefined on duplicate); handleSaveRevisionMessage broadcasts
  NEW_SAVEDREV to the pad room. pad_mode.ts now sources outer markers from the
  embedded slider's live #ui-slider-bar .star DOM (labels from clientVars) and
  observes that bar so a revision saved by a collaborator appears on an
  already-open history slider. Live editors ignore the unknown message type.

- Single-revision pad (bug 2): allow max === 0 so a revision saved at rev 0
  still renders a marker instead of being cleared by the old max <= 0 guard.

- Test rigor (bug 3): assert the marker's inline left percentage directly
  instead of falling back to a layout coordinate, which let left:0% pass.

Adds a two-client Playwright test for the live path (verified it fails with
the server broadcast removed). Backend Pad + pad API specs (88) still pass.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:12:36 +01:00
translatewiki.net
8b6a0a2f3a
Localisation updates from https://translatewiki.net. 2026-06-11 14:04:05 +02:00
Etherpad Release Bot
4fe94c9b68 Merge branch 'master' into develop 2026-06-10 10:03:40 +00:00
126 changed files with 7841 additions and 2820 deletions

View file

@ -11,8 +11,18 @@ DOCKER_COMPOSE_APP_PORT_TARGET=9001
# The env var DEFAULT_PAD_TEXT seems to be mandatory in the latest version of etherpad.
DOCKER_COMPOSE_APP_DEV_ENV_DEFAULT_PAD_TEXT="Welcome to etherpad"
# REQUIRED. The /admin account password. docker-compose refuses to start while
# this is empty (the value has no insecure fallback). Set a strong value — the
# /admin UI can install plugins, which is arbitrary code execution.
DOCKER_COMPOSE_APP_ADMIN_PASSWORD=
# Set to true ONLY when Etherpad runs behind a trusted reverse proxy that sets
# the X-Forwarded-* headers (Traefik, Nginx, Kubernetes Ingress, …). On a
# directly-exposed instance keep it false so clients can't spoof their IP. If you
# DO run behind a proxy you must set this to true, otherwise HTTPS detection
# (secure cookies) and client-IP / rate-limiting will be wrong.
DOCKER_COMPOSE_APP_TRUST_PROXY=false
DOCKER_COMPOSE_POSTGRES_DATABASE=db
DOCKER_COMPOSE_POSTGRES_PASSWORD=etherpad-lite-password
DOCKER_COMPOSE_POSTGRES_USER=etherpad-lite-user

View file

@ -13,6 +13,10 @@ DOCKER_COMPOSE_APP_DEV_ENV_DEFAULT_PAD_TEXT="Welcome to etherpad"
DOCKER_COMPOSE_APP_DEV_ADMIN_PASSWORD=
# docker-compose.dev.yml defaults this to true (dev convenience). Set to false if
# you are not running the dev container behind a reverse proxy.
DOCKER_COMPOSE_APP_DEV_ENV_TRUST_PROXY=true
DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_DATABASE=db
DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_PASSWORD=etherpad-lite-password
DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_USER=etherpad-lite-user

View file

@ -18,3 +18,10 @@ updates:
groups:
dev-dependencies:
dependency-type: "development"
cooldown:
default-days: 1 # fallback for anything not covered below
semver-major-days: 7
semver-minor-days: 3
semver-patch-days: 1
include:
- "*"

View file

@ -32,8 +32,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -45,13 +45,13 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: pnpm
-
name: Install libreoffice
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0
@ -88,8 +88,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -101,13 +101,13 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: pnpm
-
name: Install libreoffice
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0
@ -168,8 +168,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -181,7 +181,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: pnpm
@ -234,8 +234,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -247,7 +247,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: pnpm

View file

@ -36,15 +36,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v5
- uses: actions/cache@v6
name: Cache vitepress build
with:
path: doc/.vitepress/cache
@ -58,7 +58,7 @@ jobs:
# Pin Node so the build does not silently fall back to whatever the
# runner image ships with. The repo declares engines.node >=24.0.0.
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm

View file

@ -25,7 +25,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@ -37,10 +37,10 @@ jobs:
if: ${{ github.event_name == 'pull_request' }}
-
name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v4.37.3
-
name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@v4.37.3
-
name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v4.37.3

View file

@ -15,6 +15,24 @@ on:
- 'src/node/utils/run_cmd.ts'
- 'src/static/js/pluginfw/**'
- 'settings.json.template'
# Also build + smoke-test the package on PRs that touch the production
# footprint. The smoke step boots the packaged server and waits for /health,
# which is the only check that catches "server starts then exits before
# binding the port" startup regressions (e.g. a dependency bump whose change
# lets the event loop drain mid-boot). Previously this workflow ran only on
# push to develop, so such a regression was caught *after* merge — by which
# point develop was already red. Running it pre-merge blocks the PR instead.
# The release/apt-publish jobs are tag-guarded, so PRs run the build job only.
pull_request:
paths:
- 'packaging/**'
- '.github/workflows/deb-package.yml'
- 'src/package.json'
- 'pnpm-lock.yaml'
- 'src/node/server.ts'
- 'src/node/utils/run_cmd.ts'
- 'src/static/js/pluginfw/**'
- 'settings.json.template'
workflow_dispatch:
inputs:
ref:
@ -44,14 +62,14 @@ jobs:
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.ref }}
- uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: '24'
cache: pnpm
@ -328,7 +346,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout etherpad source (for packaging/apt/key.asc)
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 1

View file

@ -15,6 +15,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: 'Dependency Review'
uses: actions/dependency-review-action@v5

View file

@ -26,7 +26,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: etherpad
-
@ -42,7 +42,7 @@ jobs:
tags: ${{ env.TEST_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
- uses: actions/cache@v5
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -57,7 +57,7 @@ jobs:
# packageManager in a root package.json.
package_json_file: etherpad/package.json
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@ -190,7 +190,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: etherpad
-
@ -261,7 +261,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: etherpad
-
@ -358,7 +358,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: etherpad
-
@ -382,13 +382,13 @@ jobs:
type=semver,pattern={{major}}
-
name: Log in to Docker Hub
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Log in to GHCR
uses: docker/login-action@v4
uses: docker/login-action@v4.5.2
with:
registry: ghcr.io
username: ${{ github.actor }}
@ -416,7 +416,7 @@ jobs:
enable-url-completion: true
- name: Check out ether-charts
if: github.ref == 'refs/heads/develop'
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: ether-charts
repository: ether/ether-charts

View file

@ -26,9 +26,9 @@ jobs:
APIKEY: downstream-smoke-key
steps:
- name: Checkout core (PR)
uses: actions/checkout@v6
uses: actions/checkout@v7
- uses: actions/cache@v5
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -42,7 +42,7 @@ jobs:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm

View file

@ -27,8 +27,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -40,7 +40,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: pnpm
@ -48,7 +48,7 @@ jobs:
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm i
- name: Cache Playwright browsers
uses: actions/cache@v5
uses: actions/cache@v6
id: playwright-cache
with:
path: ~/.cache/ms-playwright

View file

@ -20,15 +20,15 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v5
- uses: actions/cache@v6
name: Cache Playwright browsers
with:
path: ~/.cache/ms-playwright
@ -40,7 +40,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@ -92,15 +92,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v5
- uses: actions/cache@v6
name: Cache Playwright browsers
with:
path: ~/.cache/ms-playwright
@ -112,7 +112,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@ -168,15 +168,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v5
- uses: actions/cache@v6
name: Cache Playwright browsers
with:
path: ~/.cache/ms-playwright
@ -188,7 +188,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@ -269,15 +269,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v5
- uses: actions/cache@v6
name: Cache Playwright browsers
with:
path: ~/.cache/ms-playwright
@ -289,7 +289,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm

View file

@ -27,8 +27,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -40,7 +40,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm

View file

@ -23,7 +23,7 @@ jobs:
name: shellcheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Run shellcheck on installer.sh
run: |
sudo apt-get update
@ -38,9 +38,9 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24
@ -70,13 +70,30 @@ jobs:
- name: Smoke test - start Etherpad and curl /api
shell: bash
# Hard backstop: if teardown ever fails to reap the server the step
# fails in minutes instead of burning to GitHub's 6h job ceiling.
timeout-minutes: 8
env:
ETHERPAD_DIR: ${{ runner.temp }}/etherpad-installer-test
run: |
set -eu
# Enable job control so the backgrounded launcher gets its own
# process group, letting us reap the whole pnpm -> node tree below.
set -m
cd "$ETHERPAD_DIR"
pnpm run prod >/tmp/etherpad.log 2>&1 &
PID=$!
# `pnpm run prod` is a nested launcher (pnpm -> pnpm --filter -> node),
# so killing $PID alone orphans the node server, which keeps the step's
# output pipe open and hangs CI. Kill the entire process group, with a
# SIGKILL fallback in case SIGTERM is swallowed (e.g. a live flush timer
# keeping the event loop alive), so the step always exits cleanly.
reap() {
kill -TERM "-$PID" 2>/dev/null || true
sleep 5
kill -KILL "-$PID" 2>/dev/null || true
}
trap reap EXIT
# Wait up to 60s for the API to come up.
ok=0
for i in $(seq 1 60); do
@ -90,19 +107,16 @@ jobs:
if [ "$ok" != "1" ]; then
echo "Etherpad did not start within 60s. Last 200 lines of log:" >&2
tail -200 /tmp/etherpad.log >&2 || true
kill "$PID" 2>/dev/null || true
exit 1
fi
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
installer-windows:
name: end-to-end install (windows-latest)
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with:
node-version: 24
@ -131,6 +145,7 @@ jobs:
- name: Smoke test - start Etherpad and curl /api
shell: pwsh
timeout-minutes: 8
env:
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
run: |

View file

@ -24,8 +24,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -37,7 +37,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@ -62,8 +62,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -75,7 +75,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@ -125,8 +125,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -138,7 +138,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm

View file

@ -24,8 +24,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -37,7 +37,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm

View file

@ -27,8 +27,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -40,7 +40,7 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm

View file

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: ether/etherpad-lite
path: etherpad
@ -42,12 +42,12 @@ jobs:
git checkout develop
git reset --hard origin/develop
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: ether/ether.github.com
path: ether.github.com
token: '${{ secrets.ETHER_RELEASE_TOKEN }}'
- uses: actions/cache@v5
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -62,7 +62,7 @@ jobs:
# packageManager in a root package.json.
package_json_file: etherpad/package.json
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
@ -82,7 +82,7 @@ jobs:
with:
ruby-version: 2.7
- uses: reitzig/actions-asciidoctor@v2.0.4
- uses: reitzig/actions-asciidoctor@v2.0.5
with:
version: 2.0.18
- name: Prepare release

View file

@ -1,9 +1,33 @@
# PARKED — npm publish of the core package is not part of the standard release.
#
# This workflow renames `ep_etherpad-lite` -> `ep_etherpad` and publishes
# `./src` to npm. As of 2026-06, that publish serves no load-bearing purpose:
# - `ep_etherpad` has 0 dependents on npm and nothing in this repo depends on it;
# - plugins import `ep_etherpad-lite` resolved from the LOCAL core install,
# and plugin CI clones `ether/etherpad` rather than `npm install`-ing core;
# - Etherpad is run via git clone / Docker / zip / snap, never `npm install`.
# The publish has been failing (E404 PUT — the `ep_etherpad` package has no OIDC
# trusted publisher configured on npmjs.com), which is why npm is stuck at 2.5.0
# while 3.x shipped fine without it.
#
# It is therefore gated behind an explicit `confirm: true` dispatch input so a
# stray run fails fast with a clear message instead of a confusing 404. To
# actually publish, the npm owner of `ep_etherpad` (samtv12345) must first
# configure a trusted publisher: npmjs.com -> ep_etherpad -> Settings ->
# Trusted Publisher -> repo `ether/etherpad`, workflow `releaseEtherpad.yml`.
# Decision pending: finish that config, or remove this workflow. See AGENTS.MD.
name: releaseEtherpad.yaml
permissions:
contents: read
id-token: write # for npm OIDC trusted publishing
on:
workflow_dispatch:
inputs:
confirm:
description: 'PARKED — publish ep_etherpad to npm? Requires a trusted publisher configured on npmjs.com first (see workflow header). Set true only if that is done.'
required: true
default: false
type: boolean
env:
PNPM_HOME: ~/.pnpm-store
@ -12,9 +36,17 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Guard — refuse unless explicitly confirmed
if: ${{ inputs.confirm != true }}
run: |
echo "::error::releaseEtherpad is PARKED. The ep_etherpad npm publish is non-functional"
echo "::error::(no trusted publisher configured on npmjs.com; 0 dependents on npm)."
echo "::error::Re-run with confirm=true only after the owner configures a trusted"
echo "::error::publisher. See the workflow header / AGENTS.MD 'Releasing' section."
exit 1
- name: Checkout repository
uses: actions/checkout@v6
- uses: actions/setup-node@v6
uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
# OIDC trusted publishing needs npm >= 11.5.1, which requires
# Node >= 22.9.0. Node 24 satisfies that and matches the rest of CI.
@ -22,7 +54,7 @@ jobs:
registry-url: https://registry.npmjs.org/
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
run: npm install -g npm@latest
- uses: actions/cache@v5
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}

View file

@ -34,7 +34,7 @@ jobs:
name: Wrapper unit tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Run snap/tests/run-all.sh
run: bash snap/tests/run-all.sh
@ -43,7 +43,7 @@ jobs:
needs: wrapper-tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install snapcraft
run: sudo snap install --classic snapcraft

View file

@ -35,7 +35,7 @@ jobs:
snap-file: ${{ steps.build.outputs.snap }}
steps:
- name: Check out
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Build snap
id: build

View file

@ -9,7 +9,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v11
with:
close-issue-label: wontfix
close-pr-label: wontfix

View file

@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out etherpad-lite
uses: actions/checkout@v6
uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
name: Install pnpm
@ -24,7 +24,7 @@ jobs:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: 24

View file

@ -32,13 +32,13 @@ jobs:
steps:
-
name: Check out latest release
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
-
name: Check out latest release tag
run: git checkout "$(git tag --list 'v*' --sort=-version:refname | head -n1)"
- uses: actions/cache@v5
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -50,12 +50,12 @@ jobs:
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Install libreoffice
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0

View file

@ -231,7 +231,7 @@ Releases are driven almost entirely by GitHub Actions. A maintainer dispatches *
- `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.
5. **npm publish — PARKED, not part of the release.** `releaseEtherpad.yaml` publishes the core as `ep_etherpad` to npm, but that package is **not load-bearing**: it has 0 dependents, nothing depends on it (plugins import `ep_etherpad-lite` from the *local* core install; plugin CI clones the repo), and Etherpad is run via clone/Docker/zip/snap — never `npm install`. The publish currently fails with `E404` because `ep_etherpad` has no OIDC trusted publisher configured on npmjs.com, which is why npm sits at 2.5.0 while 3.x shipped fine without it. The workflow is gated behind a `confirm: true` input so it can't run by accident. **Skip it for a normal release.** To revive it, the npm owner of `ep_etherpad` (`samtv12345`) configures a trusted publisher (npmjs.com → ep_etherpad → Settings → Trusted Publisher → repo `ether/etherpad`, workflow `releaseEtherpad.yml`); otherwise the workflow can be removed. Decision pending.
### Documentation

View file

@ -1,3 +1,60 @@
# 3.3.3
3.3.3 is a security release. It closes a **critical unauthenticated arbitrary-file-read** in the `/static/*` handler (GHSA-mc8w-wjhw-45x5) and bundles the fixes for a batch of privately reported issues that had already landed on `develop`: an OpenID Connect provider hardcoded cookie key and permissive CORS reflection (GHSA-pp5v-mvwg-76mp), session-fixation on authentication (GHSA-73h9-c5xp-gfg4), a same-socket cross-pad write TOCTOU (GHSA-6mcx-x5h6-rpw2), and a pad-id delimiter injection in `copyPad`/`movePad` (GHSA-wg58-mhwv-35pq). Alongside the security work it migrates the server build to TypeScript 7 (`tsgo`), fixes PageDown/PageUp navigation across consecutive long wrapped lines, and makes the docker `plugin_packages` volume mountpoint writable.
### Security
- **Prevent pre-auth path traversal / arbitrary file read in `/static/*` (GHSA-mc8w-wjhw-45x5, #8081).** On POSIX a backslash is an ordinary filename byte, so `sanitizePathname()` deliberately leaves an `..\..\..` segment untouched — but `Minify.ts` then converted backslashes to forward slashes *unconditionally, after* the sanitiser, turning those bytes back into `../` traversal components with no re-check. Because the route is mounted on `expressPreSession` (before the auth middleware), any unauthenticated client could read any file readable by the Etherpad process — e.g. `GET /static/plugins/ep_etherpad-lite/static/..%5C..%5C..%5Cetc/passwd` — escalating via disclosed `settings.json`/`credentials.json`/`/proc/self/environ` to an admin session and, through the plugin installer, RCE. The backslash conversion is now guarded to Windows only (`path.sep === '\\'`), matching the invariant already enforced in `sanitizePathname.ts`. Adds a backend regression test that fails on the pre-fix code. Reported by @gcm-explo1t.
- **Stop shipping a hardcoded OIDC cookie key and reflecting arbitrary CORS origins (GHSA-pp5v-mvwg-76mp, #8070, #8071, #8072).** The embedded OpenID Connect provider shipped a hardcoded cookie-signing key (allowing forged provider cookies) and `clientBasedCORS` reflected any request `Origin`. The provider now derives its cookie keys from the instance secret, CORS reflection is constrained, the soffice export path strips remote images to match the native path, and public routes that echo `x-proxy-path` set `Vary` to prevent cache poisoning. Reported by meifukun.
- **Regenerate the session id on authentication (GHSA-73h9-c5xp-gfg4, #8074).** Etherpad did not rotate the session identifier when a user authenticated, so a pre-auth session id fixed by an attacker (most impactfully via `ep_openid_connect` SSO) survived login, enabling session-fixation account/admin takeover. The session id is now regenerated on the authentication boundary.
- **Apply queued `USER_CHANGES` to the enqueue-time pad (GHSA-6mcx-x5h6-rpw2, #8075).** A same-socket `CLIENT_READY` pad-swap could redirect an already-queued `USER_CHANGES` onto a different (read-only or unauthorized) pad, a cross-pad write. Queued changes are now bound to the pad they were enqueued against.
- **Reject the ueberdb key delimiter `:` in `copyPad`/`movePad` destination ids (GHSA-wg58-mhwv-35pq, #8073).** A destination id containing `:` could bypass the `force=false` overwrite guard and corrupt another pad's revision records. Such ids are now rejected.
### Notable enhancements
- **Migrate the server build to TypeScript 7 / `tsgo` (#8039).** The server now type-checks and builds under the native-Go TypeScript compiler.
### Notable fixes
- **Editor — PageDown/PageUp now advance across consecutive long wrapped lines (#7555).** Paging no longer stalls when several long soft-wrapped lines follow one another.
- **Docker — make the `plugin_packages` volume mountpoint writable (#8042).** Mounting a plugin-packages volume no longer fails on a read-only mountpoint.
# 3.3.2
3.3.2 is a bug-fix and dependency-hardening follow-up to 3.3.1. It rounds out the pad-deletion UX rework (suppressing the recovery token for durable identities, keeping the token-less Delete button reachable, and closing a read-only deletion hole), restores the saved-revision markers that went missing from in-pad history mode in 3.3.x, and adds env-var overrides so air-gapped installs can switch off Etherpad's outbound calls without editing the image. It also fixes the `migrateDB` / `importSqlFile` / `migrateDirtyDBtoRealDB` CLI scripts against the promise-based ueberdb2 API, rejects unreachable `.`/`..` pad ids, and clears a batch of dependency security advisories (including CVE-2026-54285). On the CI side it unblocks the installer smoke test (which had been hanging the full 6-hour job ceiling since 3.2.0) and pins ueberdb2 past a startup-exit regression in the packaged boot.
### Security
- **Force `@opentelemetry/core` ≥ 2.8.0 (GHSA-8988-4f7v-96qf / CVE-2026-54285, #7975).** The transitive dep (pulled in via `@elastic/elasticsearch``@elastic/transport`) had a `W3CBaggagePropagator.extract()` that did not enforce W3C size limits on inbound baggage headers, allowing unbounded memory allocation. Pinned via a `pnpm-workspace.yaml` override; satisfies the existing `2.x` range with no parent bump.
- **Resolve open Dependabot security alerts (#7967).** Refreshes stale override floors and adds new ones via `pnpm-workspace` overrides: `form-data` ≥ 4.0.6, `ws` ≥ 8.21.0, `esbuild` ≥ 0.28.1, `basic-ftp` ≥ 5.3.1 (capped `<6.0.0` to avoid a surprise major on the plugin-install path), `tar` ≥ 7.5.16, `js-yaml` ≥ 4.2.0, `qs` ≥ 6.15.2, `ip-address` ≥ 10.1.1, and `@babel/core` ≥ 7.29.6.
- **Reject read-only deletion via token-less paths (part of #7959 / #7960).** Under `allowPadDeletionByAllUsers` a read-only viewer was granted `canDeletePad=true`, and the server's `flagOk`/`creatorOk` branches never checked `session.readonly` — so a read-only link holder could delete a pad without a token. Read-only sessions are now excluded from both the client var and the server's token-less authorization paths; a valid recovery token stays sufficient regardless of session mode.
### Notable enhancements
- **Pad deletion — suppress the recovery token for durable identities and relabel the action (#7926 / #7930).** Building on the `allowPadDeletionByAllUsers` suppression, a creator's deletion token is now also withheld when they have a *durable* identity — authenticated (`req.session.user` with a username) **and** the deployment pins that identity to a stable `authorID` via a `getAuthorId` hook — since only then does the creator survive a cookie clear or a different device, making the token redundant. This tightens the previous "require authentication ⇒ always suppress" rule: without `getAuthorId` the authorID still comes from the per-browser cookie, so an authenticated user on a second device is *not* the creator and keeps getting a token. A new `canDeleteWithoutToken` client var hides the whole recovery-token disclosure (label, field, submit) when no token is needed, and the recovery form now renders for all sessions (hidden by default) so an authenticated creator without a durable mapping still has UI to enter their token. `API.createPad` returns a `null` `deletionToken` under `allowPadDeletionByAllUsers`, matching the socket/UI path.
- **Offline/air-gapped installs — env-var overrides for the update check, plugin catalog, and updater (#7917, addresses #7911).** Firewalled deployments could not disable Etherpad's outbound calls without editing `settings.json` inside the image. The relevant keys are now wired through the `${ENV:default}` substitution in `settings.json.docker` and `settings.json.template`: `PRIVACY_UPDATE_CHECK`, `PRIVACY_PLUGIN_CATALOG`, `UPDATES_TIER` (`off` = no calls), `UPDATE_SERVER`, plus the docker-only `UPDATES_SOURCE` / `UPDATES_CHANNEL` / `UPDATES_CHECK_INTERVAL_HOURS` / `UPDATES_GITHUB_REPO` / `UPDATES_REQUIRE_ADMIN_FOR_STATUS`. A new "Updates & privacy" section in `doc/docker.md` documents the set; backend tests parse the shipped configs and fail if the `${ENV}` placeholders are dropped. Config, docs, and tests only — no runtime code change.
### Notable fixes
- **Pad — keep the token-less Delete button reachable without pad-wide settings (#7959 / #7960).** The token-less `#delete-pad` button was nested inside the `enablePadWideSettings`-gated section, so disabling pad-wide settings removed the only no-token deletion path — and combined with #7926 hiding the token disclosure when no token is needed, a user allowed to delete could be left with no deletion UI at all. The button is now always rendered (hidden by default) and driven by a `canDeletePad` client var (creator or `allowPadDeletionByAllUsers`, excluding read-only sessions), so the plain button and the recovery-token disclosure are mutually coherent and neither depends on pad-wide settings.
- **History mode — restore the saved-revision markers (#7946 / #7948).** When #7659 moved the timeslider into the pad as an embedded iframe, the user-facing control became the outer `#history-slider-input`, but the saved-revision stars were still drawn into the now-hidden iframe `#ui-slider-bar`, so "Save Revision" appeared to do nothing in in-pad history mode (a 3.3.x regression). `pad_mode.ts` now bridges the embedded slider's saved revisions onto the outer slider as percentage-positioned, aria-hidden star markers (with click-to-seek for mouse users), and the server's `SAVE_REVISION` handler broadcasts `NEW_SAVEDREV` to the pad room so a revision saved by a collaborator appears live on an already-open history slider. A single revision saved at rev 0 now renders too. Adds Playwright coverage for both the single-client and two-client live paths.
- **Import dialog — correct the outdated "no converter" help message (#7988 / #7989).** The notice claimed only plain text and HTML could be imported and linked to the legacy AbiWord wiki, prompting LibreOffice installs for formats that already work natively. Etherpad imports `.txt`, `.html`, `.docx` (via mammoth) and `.etherpad` without LibreOffice; only `.pdf`/`.odt`/`.doc`/`.rtf` still need it. The message now says so and points at the documentation site.
- **PadManager — reject unreachable `.` and `..` pad ids (#7962).** `isValidPadId` accepted ids consisting only of URL dot-segments, but per the WHATWG URL standard a browser normalises `/p/.` to `/p/` and `/p/..` to `/`, so such a pad could be created in the database yet never opened or exported. These ids are now rejected, and the admin `deletePad` handler falls back to a raw key purge when `getPad()` throws so any legacy `.`/`..` pad can still be removed.
### Internal / contributor-facing
- **CLI — fix the database migration/import scripts against the ueberdb2 promise API (#7982 / #7983).** `migrateDB.ts` opened source and target databases, copied all keys, then resolved without closing either — so under ueberdb2 6.1.x the keep-alive timer kept the process hanging after "Done syncing dbs", and buffered target writes were only guaranteed flushed on `close()`. It now closes both databases (flushing writes, clearing the timer) on success and error paths and exits with an explicit status. `importSqlFile.ts` and `migrateDirtyDBtoRealDB.ts` were ported off the pre-v6 callback API to `await db.init()` / `db.set(k, v)` / `db.close()`, removing two `@ts-ignore`s that hid broken calls and fixing an undefined `length` in a progress log; `tsc --noEmit` on the bin package is now clean.
- **CI — stop the installer smoke test hanging the 6-hour job ceiling (#7981).** The "Installer test" had hung on every ubuntu/macOS run since 3.2.0: `pnpm run prod` is a nested launcher, so `kill "$PID"; wait "$PID"` only signalled the outer pnpm and blocked forever if the node server didn't exit on SIGTERM. Teardown now runs the launcher in its own process group, kills the whole group (SIGTERM then SIGKILL), drops the blocking `wait`, and adds an 8-minute `timeout-minutes` backstop to both smoke steps.
- **CI — run the Debian-package smoke test on PRs (#7969).** The packaged-boot smoke test previously ran only on push to `develop` — i.e. after merge — which is why the ueberdb2 startup-exit regression turned `develop` red instead of being blocked at PR time. A `pull_request` trigger (scoped to production-footprint paths) now runs the build+smoke job on PRs; the release/apt-publish jobs stay tag-guarded.
- **Release — park the non-functional `ep_etherpad` npm publish (#7922).** The `releaseEtherpad` workflow republished `./src` as `ep_etherpad`, a package with zero dependents that nothing in the repo or any deployment path consumes, and it had been failing with E404 (no OIDC trusted publisher configured). The job is now gated behind an explicit `confirm: true` dispatch input so a stray run fails fast with a clear message, with the status documented in the workflow header and `AGENTS.MD`.
- **Tests — port the orphaned legacy timeslider specs to Playwright (#7949).** The `src/tests/frontend/specs/` mocha suite is run by no CI workflow, so its timeslider coverage was dead — which is how the #7946 history-mode regression reached a release. The still-meaningful cases (revision labels, export links, deep-link entry) were ported to `frontend-new` Playwright specs re-targeted at the real in-pad UI, and the three now-ported legacy specs were deleted.
### Dependencies
- `ueberdb2` pinned to `6.1.13`. 6.1.10 rewrote the cache/buffer layer to lazily arm an `.unref()`'d flush timer only when there are dirty keys, so on a fresh empty dirty DB nothing anchored Node's event loop and the packaged (.deb/systemd) boot could exit cleanly (code 0) before `server.listen()` bound the port — failing the Debian-package health check. The dep was pinned back to the last green release (6.1.9, #7969) and then rolled forward to the now-fixed `6.1.13` (#7979), pinned exactly rather than with a caret.
- `nodemailer` 8.x → 9.0.1 (#7965 / #7950 / #7976), `mongodb` 7.1.1 → 7.3.0 (#7941), `pg` 8.21.0 → 8.22.0 (#7985), `undici` → 8.5.0 (#7980 etc.), `oidc-provider` 9.8.4 → 9.8.5 (#7973), `pdfkit` 0.19.0 → 0.19.1 (#7945), `semver` 7.8.3 → 7.8.4 (#7943), and `@radix-ui/react-switch` 1.3.0 → 1.3.1 (#7974).
- Dev/build dependency group updates (#7964, #7970, #7978, #7987, #7944, #7951, #7952, and others), including `@types/node` 25 → 26, `esbuild` 0.28.0 → 0.28.1, `eslint` 10.4.1 → 10.5.0, `@playwright/test` 1.60 → 1.61, `vitest` 4.1.8 → 4.1.9, and `actions/checkout` 6 → 7 (#7977).
# 3.3.1
3.3.1 is a small bug-fix and hardening follow-up to 3.3.0. It closes a stored-XSS vector in the numbered-list `start` attribute, hardens the database layer so a dropped connection to PostgreSQL / Redis / RethinkDB no longer crashes the process (via ueberdb2 6.1.9), and fixes a handful of pad and admin regressions — the iOS dark-mode status bar, the settings language dropdown, the pad-deletion modal under `allowPadDeletionByAllUsers`, and a single unreadable pad blanking the admin Manage-pads list.

View file

@ -171,6 +171,12 @@ ARG ETHERPAD_GITHUB_PLUGINS=
COPY --chown=etherpad:etherpad ./src/ ./src/
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/templates/admin ./src/templates/admin
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc
# docker-compose mounts a named volume over src/plugin_packages. Docker seeds a
# fresh named volume from the mountpoint in the image, so the directory has to
# exist here (owned by etherpad, since USER is already etherpad) — otherwise
# Docker creates it root:root and plugin installs cannot write install.lock.
# See ether/etherpad#8026.
RUN mkdir -p ./src/plugin_packages
COPY --chown=etherpad:etherpad ./local_plugin[s] ./local_plugins/
@ -203,6 +209,12 @@ RUN printf 'packages:\n - src\n - bin\nonlyBuiltDependencies:\n - esbuild\nig
COPY --chown=etherpad:etherpad ./src ./src
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/templates/admin ./src/templates/admin
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc
# docker-compose mounts a named volume over src/plugin_packages. Docker seeds a
# fresh named volume from the mountpoint in the image, so the directory has to
# exist here (owned by etherpad, since USER is already etherpad) — otherwise
# Docker creates it root:root and plugin installs cannot write install.lock.
# See ether/etherpad#8026.
RUN mkdir -p ./src/plugin_packages
COPY --chown=etherpad:etherpad ./local_plugin[s] ./local_plugins/

View file

@ -117,11 +117,11 @@ services:
- postgres
environment:
NODE_ENV: production
ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin}
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}
@ -129,7 +129,7 @@ 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}
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-false}
restart: always
ports:
- "${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}"
@ -138,7 +138,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

@ -1,7 +1,7 @@
{
"name": "admin",
"private": true,
"version": "3.3.1",
"version": "3.3.3",
"type": "module",
"scripts": {
"dev": "pnpm gen:api && vite",
@ -14,39 +14,39 @@
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'"
},
"dependencies": {
"@radix-ui/react-switch": "^1.3.0",
"@tanstack/react-query": "^5.101.0",
"@tanstack/react-query-devtools": "^5.101.0",
"@radix-ui/react-switch": "^1.3.7",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4",
"jsonc-parser": "^3.3.1",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4"
},
"devDependencies": {
"@radix-ui/react-dialog": "^1.1.16",
"@radix-ui/react-toast": "^1.2.16",
"@radix-ui/react-visually-hidden": "^1.2.5",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-toast": "^1.2.23",
"@radix-ui/react-visually-hidden": "^1.2.11",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.61.0",
"@typescript-eslint/parser": "^8.61.0",
"@vitejs/plugin-react": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"@vitejs/plugin-react": "^6.0.4",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"eslint": "^10.4.1",
"eslint": "^10.7.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"i18next": "^26.3.1",
"eslint-plugin-react-refresh": "^0.5.3",
"i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.17.0",
"lucide-react": "^1.26.0",
"openapi-typescript": "^7.13.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.17.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.82.0",
"react-i18next": "^17.0.11",
"react-router-dom": "^7.18.1",
"socket.io-client": "^4.8.3",
"tsx": "^4.22.4",
"tsx": "^4.23.1",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vite": "^8.1.5",
"vite-plugin-babel": "^1.7.3",
"zustand": "^5.0.14"
}

View file

@ -2,7 +2,6 @@
// 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.
import util from "node:util";
import fs from 'node:fs';
import log4js from 'log4js';
import readline from 'readline';
@ -69,8 +68,7 @@ const unescape = (val: string) => {
if (!sqlFile) throw new Error('Use: node importSqlFile.js $SQLFILE');
log('initializing db');
const initDb = await util.promisify(db.init.bind(db));
await initDb(null);
await db.init();
log('done');
log(`Opening ${sqlFile}...`);
@ -86,8 +84,7 @@ const unescape = (val: string) => {
value = value.substring(0, value.length - 2);
console.log(`key: ${key} val: ${value}`);
console.log(`unval: ${unescape(value)}`);
// @ts-ignore
db.set(key, unescape(value), null);
await db.set(key, unescape(value));
keyNo++;
if (keyNo % 1000 === 0) log(` ${keyNo}`);
}
@ -96,9 +93,7 @@ const unescape = (val: string) => {
process.stdout.write('done. waiting for db to finish transaction. ' +
'depended on dbms this may take some time..\n');
const closeDB = util.promisify(db.close.bind(db));
// @ts-ignore
await closeDB(null);
await db.close();
log(`finished, imported ${keyNo} keys.`);
process.exit(0)
})();

View file

@ -74,10 +74,20 @@ const handleSync = async ()=>{
}
}
handleSync().then(()=>{
handleSync().then(async ()=>{
// Closing flushes any buffered writes to the target DB and clears
// ueberdb2's keep-alive timer (added in 6.1.x). Without this the migrated
// data may not be fully persisted and the process would hang forever
// instead of exiting once the sync is done.
await ueberdb2.close()
await ueberdb1.close()
console.log("Done syncing dbs")
}).catch(e=>{
process.exit(0)
}).catch(async e=>{
console.log(`Error syncing db ${e}`)
await ueberdb2.close().catch(()=>{})
await ueberdb1.close().catch(()=>{})
process.exit(1)
})

View file

@ -35,26 +35,16 @@ process.on('unhandledRejection', (err) => { throw err; });
const keys = await dirty.findKeys('*', '')
console.log(`Found ${keys.length} records, processing now.`);
const p: Promise<void>[] = [];
let numWritten = 0;
for (const key of keys) {
let value = await dirty.get(key);
let bcb, wcb;
p.push(new Promise((resolve, reject) => {
bcb = (err:any) => { if (err != null) return reject(err); };
wcb = (err:any) => {
if (err != null) return reject(err);
if (++numWritten % 100 === 0) console.log(`Wrote record ${numWritten} of ${length}`);
resolve();
};
}));
db.set(key, value, bcb, wcb);
const value = await dirty.get(key);
await db.set(key, value);
if (++numWritten % 100 === 0) console.log(`Wrote record ${numWritten} of ${keys.length}`);
}
await Promise.all(p);
console.log(`Wrote all ${numWritten} records`);
await db.close(null);
await dirty.close(null);
await db.close();
await dirty.close();
console.log('Finished.');
process.exit(0)
})();

View file

@ -1,6 +1,6 @@
{
"name": "bin",
"version": "3.3.1",
"version": "3.3.3",
"description": "",
"main": "checkAllPads.js",
"directories": {
@ -9,14 +9,14 @@
"dependencies": {
"ep_etherpad-lite": "workspace:../src",
"log4js": "^6.9.1",
"semver": "^7.8.3",
"tsx": "^4.22.4",
"ueberdb2": "^6.1.9"
"semver": "^7.8.5",
"tsx": "^4.23.1",
"ueberdb2": "6.1.16"
},
"devDependencies": {
"@types/node": "^25.9.2",
"@types/node": "^26.1.1",
"@types/semver": "^7.7.1",
"typescript": "^6.0.3"
"typescript": "^7.0.0"
},
"scripts": {
"makeDocs": "node --import tsx make_docs.ts",

View file

@ -27,13 +27,18 @@ export default defineConfig({
items: [
{ text: 'Docker', link: '/docker.md' },
{ text: 'Configuration', link: '/configuration.md' },
{ text: 'Deployment', link: '/deployment.md' },
{ text: 'Database', link: '/database.md' },
{ text: 'Localization', link: '/localization.md' },
{ text: 'Cookies', link: '/cookies.md' },
{ text: 'Plugins', link: '/plugins.md' },
{ text: 'Stats', link: '/stats.md' },
{text: 'Skins', link: '/skins.md' },
{ text: 'Accessibility', link: '/accessibility.md' },
{text: 'Demo', link: '/demo.md' },
{text: 'CLI', link: '/cli.md'},
{ text: 'Development', link: '/development.md' },
{ text: 'FAQ', link: '/faq.md' },
]
},
{

91
doc/accessibility.md Normal file
View file

@ -0,0 +1,91 @@
# Accessibility
Etherpad aims to be usable by everyone, including people who rely on a
keyboard, a screen reader, or other assistive technology. The editor follows
common conventions so that selecting, formatting, and navigating text works the
way you would expect in other applications, and the toolbar can be reached and
operated without a mouse.
If you find a feature that is not accessible, please let us know by opening an
issue so it can be improved.
## Keyboard shortcuts
The following shortcuts are built into the editor. On macOS use the Command
(`Cmd`) key wherever `Ctrl` is listed.
::: tip
Most shortcuts can be individually enabled or disabled through the
`padShortcutEnabled` settings, so a deployment may have customised which of
these are active.
:::
### Editor
| Action | Shortcut |
| --- | --- |
| Bold | `Ctrl` + `B` |
| Italic | `Ctrl` + `I` |
| Underline | `Ctrl` + `U` |
| Strikethrough | `Ctrl` + `5` |
| Ordered (numbered) list | `Ctrl` + `Shift` + `N` or `Ctrl` + `Shift` + `1` |
| Unordered (bulleted) list | `Ctrl` + `Shift` + `L` |
| Indent line or selection | `Tab` |
| Outdent line or selection | `Shift` + `Tab` |
| Undo | `Ctrl` + `Z` |
| Redo | `Ctrl` + `Y` or `Ctrl` + `Shift` + `Z` |
| Save a named revision | `Ctrl` + `S` |
| Duplicate the current line(s) | `Ctrl` + `Shift` + `D` |
| Delete the current line(s) | `Ctrl` + `Shift` + `K` |
| Clear authorship colors on the pad or selection | `Ctrl` + `Shift` + `C` |
| Show the authors of the current line | `Ctrl` + `Shift` + `2` |
| Focus the toolbar (see below) | `Alt` + `F9` |
| Focus the chat input | `Alt` + `C` |
Text selection, cut (`Ctrl` + `X`), copy (`Ctrl` + `C`), paste
(`Ctrl` + `V`), and the arrow keys behave as they do in any standard text
editor.
### Timeslider
The timeslider (revision history) provides its own shortcuts:
| Action | Shortcut |
| --- | --- |
| Play / pause history playback | `Space` |
| Step back one revision | `Left Arrow` |
| Step forward one revision | `Right Arrow` |
| Jump back to the previous starred revision | `Shift` + `Left Arrow` |
| Jump forward to the next starred revision | `Shift` + `Right Arrow` |
## Toolbar navigation
The toolbar holds the formatting controls (bold, italic, lists, and so on) and
can be reached and operated entirely from the keyboard:
* Press `Alt` + `F9` from the editor to move focus to the first button in the
toolbar.
* Use the `Left Arrow` and `Right Arrow` keys to move between buttons. `Tab`
also moves to the next focusable control.
* Press `Enter` to activate the focused button.
* Press `Alt` + `F9` again, or `Escape`, to return focus to the pad.
Pressing `Escape` while a toolbar dropdown (such as the settings or color
picker) is open closes that dropdown first.
## Screen readers
Etherpad provides as much screen reader support as possible. Support quality
varies between platforms and browsers, so the following combinations are
recommended:
* On Windows, Firefox with [NVDA](https://www.nvaccess.org/) currently gives the
best experience.
To reduce verbose feedback while typing collaboratively in NVDA, open the
keyboard settings (`NVDA` + `Ctrl` + `K`) and turn off **Speak typed characters**
and **Speak typed words**.
Support in other screen readers and browsers (for example Orca on Linux, or
Chrome) is more limited. Contributions to improve coverage on these platforms
are very welcome.

View file

@ -102,7 +102,9 @@ The notice auto-fades after 8 seconds and can be dismissed immediately. The publ
## Disabling everything
Set `updates.tier` to `"off"`. No HTTP request will leave the instance and no banner or badge will render.
Set `updates.tier` to `"off"`. The self-updater goes silent — no request to the GitHub Releases API leaves the instance and no banner or badge renders. Note this does **not** cover the separate legacy version check in `UpdateCheck.ts`, which still fetches `${updateServer}/info.json` until you also set `privacy.updateCheck` to `false` (see [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md)).
On Docker / air-gapped installs you can do both without editing `settings.json` inside the image by setting `UPDATES_TIER=off` **and** `PRIVACY_UPDATE_CHECK=false` (add `PRIVACY_PLUGIN_CATALOG=false` to also disable the admin plugin browser's catalogue fetch). See the [Updates & privacy](../docker.md#updates--privacy-offline--air-gapped) table in the Docker docs for the full set of environment variables.
## Privacy

View file

@ -1,7 +1,7 @@
# Changeset Library
The [changeset
library](https://github.com/ether/etherpad-lite/blob/develop/src/static/js/Changeset.ts)
library](https://github.com/ether/etherpad/blob/develop/src/static/js/Changeset.ts)
provides tools to create, read, and apply changesets.
## Changeset
@ -21,6 +21,42 @@ A transmitted changeset looks like this:
'Z:z>1|2=m=b*0|1+1$\n'
```
### Reading a changeset
`unpack()` splits a changeset string into its parts:
```javascript
const unpacked = Changeset.unpack('Z:z>1|2=m=b*0|1+1$\n');
// { oldLen: 35, newLen: 36, ops: '|2=m=b*0|1+1', charBank: '\n' }
```
`oldLen` is the document length before the change and `newLen` the length after.
`ops` is the list of operations, and `charBank` holds the characters inserted by
those operations.
Iterate the operations with `deserializeOps()`, which yields one `Op` at a time:
```javascript
for (const op of Changeset.deserializeOps(unpacked.ops)) {
console.log(op);
}
// Op { opcode: '=', chars: 22, lines: 2, attribs: '' }
// Op { opcode: '=', chars: 11, lines: 0, attribs: '' }
// Op { opcode: '+', chars: 1, lines: 1, attribs: '*0' }
```
There are three kinds of operation, each applied starting from the current
position in the text:
- `=` keeps text (it may still change the text's attributes, e.g. make it bold).
- `-` removes text.
- `+` inserts text (taking the characters from the changeset's `charBank`).
`opcode` is the operation type; `chars` and `lines` are how much text it covers;
and `attribs` are the attributes applied, written as `*` references into the
pad's attribute pool. In the example above the final op inserts one character
(the newline from `charBank`) carrying attribute `*0`.
## Attribute Pool
```javascript
@ -36,6 +72,59 @@ are used many times.
There is one attribute pool per pad, and it includes every current and
historical attribute used in the pad.
A pool can be serialized to and from a plain object with `toJsonable()` and
`fromJsonable()`:
```javascript
const pool = new AttributePool();
pool.fromJsonable({
numToAttrib: {
0: ['author', 'a.kVnWeomPADAT2pn9'],
1: ['bold', 'true'],
2: ['italic', 'true'],
},
nextNum: 3,
});
pool.getAttrib(1); // [ 'bold', 'true' ]
pool.getAttribKey(1); // 'bold'
pool.getAttribValue(1); // 'true'
```
Each attribute is a `[key, value]` pair — `['bold', 'true']`, or
`['author', '<authorId>']`. A character can carry several attributes (bold *and*
italic), but only one value per key (so it cannot belong to two authors).
## Attributed text (atext)
A pad's content is stored as *attributed text* (`atext`): the plain text plus an
attribute string describing which attributes apply to each span.
```javascript
const atext = {
text: 'bold text\nitalic text\nnormal text\n\n',
attribs: '*0*1+9*0|1+1*0*1*2+b|1+1*0+b|2+2',
};
```
The attribute string is a sequence of `+` operations — the same encoding used by
changesets — which you can read with `deserializeOps()`:
```javascript
for (const op of Changeset.deserializeOps(atext.attribs)) {
console.log(op);
}
// Op { opcode: '+', chars: 9, lines: 0, attribs: '*0*1' }
// Op { opcode: '+', chars: 1, lines: 1, attribs: '*0' }
// Op { opcode: '+', chars: 11, lines: 0, attribs: '*0*1*2' }
// Op { opcode: '+', chars: 1, lines: 1, attribs: '' }
// Op { opcode: '+', chars: 11, lines: 0, attribs: '*0' }
// Op { opcode: '+', chars: 2, lines: 2, attribs: '' }
```
Read against the pool above, the first nine characters (`bold text`) carry
attributes `*0*1` (author + bold), the following newline carries `*0`, and so on.
## Further Reading
Detailed information about the changesets & Easysync protocol:

204
doc/database.md Normal file
View file

@ -0,0 +1,204 @@
# Database structure
## Keys and their values
### groups
A list of all existing groups (a JSON object with groupIDs as keys and `1` as values).
### pad:$PADID
Contains all information about pads
- **atext** - the latest attributed text
- **pool** - the attribute pool
- **head** - the number of the latest revision
- **chatHead** - the number of the latest chat entry
- **public** - flag that disables security for this pad
- **passwordHash** - string that contains a salted sha512 sum of this pad's password
### pad:$PADID:revs:$REVNUM
Saves a revision $REVNUM of pad $PADID
- **meta**
- **author** - the autorID of this revision
- **timestamp** - the timestamp of when this revision was created
- **changeset** - the changeset of this revision
### pad:$PADID:chat:$CHATNUM
Saves a chat entry with num $CHATNUM of pad $PADID
- **text** - the text of this chat entry
- **userId** - the authorID of this chat entry
- **time** - the timestamp of this chat entry
### pad2readonly:$PADID
Translates a padID to a readonlyID
### readonly2pad:$READONLYID
Translates a readonlyID to a padID
### token2author:$TOKENID
Translates a token to an authorID
### globalAuthor:$AUTHORID
Information about an author
- **name** - the name of this author as shown in the pad
- **colorID** - the colorID of this author as shown in the pad
### mapper2group:$MAPPER
Maps an external application identifier to an internal group
### mapper2author:$MAPPER
Maps an external application identifier to an internal author
### group:$GROUPID
a group of pads
- **pads** - object with pad names in it, values are 1
### session:$SESSIONID
a session between an author and a group
- **groupID** - the groupID the session belongs too
- **authorID** - the authorID the session belongs too
- **validUntil** - the timestamp until this session is valid
### author2sessions:$AUTHORID
saves the sessions of an author
- **sessionsIDs** - object with sessionIDs in it, values are 1
### group2sessions:$GROUPID
- **sessionsIDs** - object with sessionIDs in it, values are 1
# Connecting to a database backend
Etherpad stores everything in a single key/value table through
[ueberDB](https://www.npmjs.com/package/ueberdb2), so the same data model works
across many backends. The backend is selected with `dbType` in `settings.json`,
and backend-specific connection options go in `dbSettings`.
The default `dirty` backend writes to a local file (`var/dirty.db`) and needs no
setup, which is convenient for development but not recommended for production.
For a production instance, point Etherpad at a real database such as MySQL/MariaDB,
PostgreSQL or Redis. Etherpad creates its own table on first run; you only need
to provision an empty database and a user with access to it.
## MySQL / MariaDB
Create the database and a user, then grant access:
```sql
CREATE DATABASE `etherpad` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
CREATE USER 'etherpad'@'localhost' IDENTIFIED BY 'a-secure-password';
GRANT CREATE,ALTER,SELECT,INSERT,UPDATE,DELETE ON `etherpad`.* TO 'etherpad'@'localhost';
```
Then configure `settings.json`:
```json
"dbType": "mysql",
"dbSettings": {
"user": "etherpad",
"host": "localhost",
"port": 3306,
"password": "a-secure-password",
"database": "etherpad",
"charset": "utf8mb4"
}
```
Setting `charset` to `utf8mb4` is strongly recommended so that the full range of
Unicode (including emoji) is stored correctly. To connect over a local socket
instead of TCP, replace `host`/`port` with `"socketPath": "/var/run/mysqld/mysqld.sock"`.
## PostgreSQL
Create the user and a database owned by it:
```sql
CREATE USER etherpad WITH PASSWORD 'a-secure-password';
CREATE DATABASE etherpad OWNER etherpad;
```
Then configure `settings.json`:
```json
"dbType": "postgres",
"dbSettings": {
"user": "etherpad",
"host": "localhost",
"port": 5432,
"password": "a-secure-password",
"database": "etherpad"
}
```
The `dbSettings` object is passed straight to the `node-postgres` connection
pool, so any option it accepts (including a single `"connectionString"`) works.
On Debian/Ubuntu you can use peer authentication over the local socket by
setting `"host": "/var/run/postgresql"` and an empty password, provided the
operating-system user that runs Etherpad matches the PostgreSQL role.
## Redis
Install Redis and make sure it persists data to disk. Configure `settings.json`
with either discrete fields or a single connection URL:
```json
"dbType": "redis",
"dbSettings": {
"host": "localhost",
"port": 6379,
"password": "a-secure-redis-password"
}
```
```json
"dbType": "redis",
"dbSettings": {
"url": "redis://:a-secure-redis-password@localhost:6379"
}
```
## Migrating from MySQL to PostgreSQL
[pgloader](https://pgloader.io/) can copy an existing Etherpad database from
MySQL to PostgreSQL. Stop Etherpad first so the source database is quiescent.
```bash
sudo apt-get install postgresql pgloader
# Create the target role and database
sudo -u postgres createuser etherpad
sudo -u postgres createdb -O etherpad etherpad
# Describe and run the migration
cat > pgloader.load <<'EOF'
LOAD DATABASE
FROM mysql://etherpad:MYSQL_PASSWORD@127.0.0.1/etherpad
INTO postgresql:///etherpad
WITH preserve index names, prefetch rows = 100
ALTER SCHEMA 'etherpad' RENAME TO 'public';
EOF
pgloader --verbose pgloader.load
```
Afterwards set the PostgreSQL user's password and make sure it can read and
write the migrated table:
```sql
ALTER USER etherpad WITH PASSWORD 'a-secure-password';
GRANT pg_read_all_data TO etherpad;
GRANT pg_write_all_data TO etherpad;
```
Then point `settings.json` at PostgreSQL as shown above and start Etherpad.
::: tip
To move data between *any* two backends supported by ueberDB, you can also
use the `migrateDB` CLI tool, which reads every record from a source database
descriptor and writes it to a target one. See the [CLI chapter](./cli.md).
:::

359
doc/deployment.md Normal file
View file

@ -0,0 +1,359 @@
# Deployment
This page collects working configurations for deploying Etherpad in production:
running it behind a reverse proxy, hosting it under a subdirectory, terminating
HTTPS natively, running it as a system service, and deploying it on Kubernetes.
Etherpad listens on port `9001` by default. Throughout this page the upstream
Etherpad server is assumed to be reachable at `http://127.0.0.1:9001`.
## Running behind a reverse proxy
The recommended production setup is to run Etherpad on `127.0.0.1:9001` and put a
reverse proxy in front of it to terminate TLS, serve a virtual host, and forward
requests.
Etherpad uses WebSockets (via socket.io). The load-bearing part of every proxy
config below is the WebSocket upgrade: the proxy **must** forward the `Upgrade`
and `Connection` headers, or real-time editing will silently fail back to slow
long-polling (or break entirely).
When Etherpad runs behind a proxy you should also set `trustProxy: true` in your
settings so that Etherpad honours the `X-Forwarded-*` headers (correct client IP,
secure-cookie flag, etc.). See the `trustProxy` section in the [Configuration documentation](./configuration.md) for the full details of which headers are trusted.
### Nginx
```nginx
# Map the Upgrade header so WebSockets work. Place this in the http context.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name pad.example.com;
ssl_certificate /etc/nginx/ssl/etherpad.crt;
ssl_certificate_key /etc/nginx/ssl/etherpad.key;
location / {
proxy_pass http://127.0.0.1:9001;
proxy_buffering off;
proxy_set_header Host $host;
proxy_pass_header Server;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
# Redirect plain HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name pad.example.com;
return 301 https://$host$request_uri;
}
```
### Apache
Enable `mod_proxy`, `mod_proxy_http`, `mod_proxy_wstunnel` and `mod_headers`.
The `mod_proxy_wstunnel` `upgrade=websocket` syntax requires Apache 2.4.47 or
newer.
```apache
<VirtualHost *:443>
ServerName pad.example.com
SSLEngine on
SSLCertificateFile /etc/ssl/etherpad/etherpad.crt
SSLCertificateKeyFile /etc/ssl/etherpad/etherpad.key
ProxyVia On
ProxyRequests Off
ProxyPreserveHost On
# WebSocket traffic (socket.io) must be matched first.
<Location "/socket.io">
ProxyPass "ws://127.0.0.1:9001/socket.io" upgrade=websocket timeout=30
ProxyPassReverse "ws://127.0.0.1:9001/socket.io"
</Location>
<Location "/">
ProxyPass "http://127.0.0.1:9001/" retry=0 timeout=30
ProxyPassReverse "http://127.0.0.1:9001/"
</Location>
</VirtualHost>
```
### Caddy
Caddy v2 proxies WebSocket connections automatically and obtains/renews a
certificate for you, so the configuration is minimal:
```caddy
pad.example.com {
reverse_proxy 127.0.0.1:9001
}
```
### Traefik
Traefik v2 also proxies WebSockets transparently. For a Docker deployment, attach
these labels to the Etherpad container:
```yaml
labels:
- "traefik.enable=true"
- "traefik.http.routers.etherpad.rule=Host(`pad.example.com`)"
- "traefik.http.routers.etherpad.entrypoints=websecure"
- "traefik.http.routers.etherpad.tls.certresolver=myresolver"
- "traefik.http.services.etherpad.loadbalancer.server.port=9001"
- "traefik.http.services.etherpad.loadbalancer.passhostheader=true"
```
### HAProxy
HAProxy detects the `Connection: Upgrade` exchange automatically and switches to
tunnel mode once the WebSocket is established. The important value is
`timeout tunnel`, which governs the lifetime of the upgraded connection.
```haproxy
frontend http
mode http
bind *:80
bind *:443 ssl crt /etc/haproxy/certs/etherpad.pem alpn h2,http/1.1
http-request redirect scheme https code 301 unless { ssl_fc }
http-request add-header X-Forwarded-Proto https if { ssl_fc }
default_backend etherpad
backend etherpad
mode http
option forwardfor
timeout client 25s
timeout server 25s
timeout tunnel 3600s
server pad 127.0.0.1:9001
```
## Hosting under a subdirectory
To serve Etherpad from a path such as `https://example.com/pad` rather than from
the root of a domain, the proxy must send the `X-Proxy-Path` header so that
Etherpad rewrites its own asset and API URLs to include the prefix. This header
is honoured regardless of the `trustProxy` setting — see the [Configuration documentation](./configuration.md).
```nginx
location /pad/ {
rewrite ^/pad/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:9001;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Proxy-Path /pad;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
```
## Native HTTPS without a proxy
Etherpad can terminate TLS itself using Node's native HTTPS server, with no
reverse proxy required. Configure the `ssl` block in `settings.json`:
```json
"ssl": {
"key": "/path-to-your/etherpad-server.key",
"cert": "/path-to-your/etherpad-server.crt",
"ca": ["/path-to-your/intermediate-cert1.crt", "/path-to-your/intermediate-cert2.crt"]
}
```
* `key` — path to the private key file.
* `cert` — path to the certificate file.
* `ca` — an (optional) array of intermediate/chain certificate paths.
Restart Etherpad after editing the settings. It will now serve HTTPS on its
configured port.
For local testing you can generate a self-signed certificate with a single
command:
```bash
openssl req -x509 -newkey rsa:4096 -nodes -days 365 \
-keyout etherpad-server.key -out etherpad-server.crt \
-subj "/CN=localhost"
```
Make sure the files are readable only by the user that runs Etherpad:
```bash
chmod 400 etherpad-server.key etherpad-server.crt
chown etherpad etherpad-server.key etherpad-server.crt
```
::: tip
Self-signed certificates trigger browser warnings and are only suitable for
testing. For production, obtain a free, trusted certificate from
[Let's Encrypt](https://letsencrypt.org/), or terminate TLS at a reverse proxy
(see above) and let it manage certificate issuance and renewal.
:::
## Running as a service (systemd)
On a modern Linux distribution, run Etherpad as a `systemd` service so it starts
on boot and restarts automatically on failure.
Create a dedicated unprivileged user and install Etherpad into its home
directory (for example `/opt/etherpad`), owned by that user. Etherpad refuses to
start as root.
Create `/etc/systemd/system/etherpad.service`:
```ini
[Unit]
Description=Etherpad collaborative editor
After=network.target
[Service]
Type=simple
User=etherpad
Group=etherpad
WorkingDirectory=/opt/etherpad
Environment=NODE_ENV=production
ExecStart=/usr/bin/pnpm run prod
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
Adjust `WorkingDirectory` to your install path and the `ExecStart` path to
wherever `pnpm` lives (`which pnpm`). Then enable and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now etherpad.service
# check status and follow logs
sudo systemctl status etherpad.service
sudo journalctl -u etherpad.service -f
```
## Kubernetes (Istio)
The following manifest deploys Etherpad behind an Istio ingress gateway. It
defines three resources: a `Gateway` (TLS + hostname), a `VirtualService`
(routing with WebSocket-friendly timeouts), and a `DestinationRule` (sticky
sessions via the socket.io `io` cookie).
It assumes:
* Istio >= 1.18
* A `Service` named `etherpad` in the `etherpad` namespace, on port `9001`
* A TLS secret `etherpad-tls` provisioned in the gateway namespace
* You replace `<your-host>` with your own hostname
::: warning
Sticky sessions are necessary but **not** sufficient for a multi-replica
Etherpad deployment. Multi-replica also needs the socket.io Redis adapter so
that pad state is shared across pods. Without it, two clients editing the same
pad but routed to different pods will see divergent state.
Recommendation: start with `replicas: 1` plus good failover, and only go
multi-replica once the Redis adapter is wired up.
:::
```yaml
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
name: etherpad
namespace: etherpad
spec:
selector:
istio: ingressgateway
servers:
- port:
number: 443
name: https
protocol: HTTPS
tls:
mode: SIMPLE
credentialName: etherpad-tls
hosts:
- <your-host>
- port:
number: 80
name: http
protocol: HTTP
hosts:
- <your-host>
tls:
httpsRedirect: true
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: etherpad
namespace: etherpad
spec:
hosts:
- <your-host>
gateways:
- etherpad
http:
- match:
- uri:
prefix: /
route:
- destination:
host: etherpad
port:
number: 9001
# No per-request timeout — websockets and long-polling sit on the
# connection indefinitely. The default of 15s kills WS upgrades.
timeout: 0s
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: etherpad
namespace: etherpad
spec:
host: etherpad
trafficPolicy:
loadBalancer:
# Sticky sessions on the socket.io session cookie. Required so that
# long-polling fallback requests land on the same pod that owns the
# session state.
consistentHash:
httpCookie:
name: io
ttl: 0s # session cookie, expires with the browser tab
connectionPool:
tcp:
maxConnections: 10000
http:
# Must comfortably exceed socket.io's pingInterval (25s) +
# pingTimeout (20s). 1h is conservative.
idleTimeout: 3600s
h2UpgradePolicy: UPGRADE
http1MaxPendingRequests: 1000
```

240
doc/development.md Normal file
View file

@ -0,0 +1,240 @@
# Development
This page is a contributor-oriented tour of the Etherpad source tree and of a
few internals that plugin authors and core contributors commonly need to
understand: how the source is laid out, how pads are converted to and from
other formats, and how to access the database from server-side code.
The Etherpad server is written in TypeScript (`.ts`). Most server code lives
under `src/node/` and most client code under `src/static/js/`.
## Source tree overview
The repository root contains, among others, the following directories:
```
etherpad/
|- bin/ # maintenance and build scripts (run.sh, pad tools, docs, release)
|- doc/ # this manual, in AsciiDoc and Markdown
|- src/ # the Etherpad source code
|- packaging/ # OS/distribution packaging helpers
|- var/ # runtime data (e.g. the dirty.db database file)
```
`bin/` contains scripts for running and maintaining Etherpad. For example
`bin/run.sh` starts the server, and there are TypeScript utilities such as
`bin/checkPad.ts`, `bin/deletePad.ts`, `bin/repairPad.ts`,
`bin/rebuildPad.ts`, `bin/migrateDB.ts` and `bin/make_docs.ts`.
The HTML manual is built from the AsciiDoc sources in `doc/` by
`bin/make_docs.ts` (exposed as the `makeDocs` script), which shells out to
`asciidoctor` and writes the result to `out/doc/`. From the repository root you
can run it with `pnpm run makeDocs`. (`asciidoctor` must be installed.)
The `src/` directory looks like this:
```
src/
|- locales/ # translations, managed via https://translatewiki.net
|- node/ # server-side code
|- static/ # client-side code, CSS and fonts
|- templates/ # server-rendered page templates
|- ep.json # core plugin/hook registration
|- package.json # package name: ep_etherpad-lite
```
### src/node/ (server side)
```
src/node/
|- db/ # database access and pad/author/group/session state
|- eejs/ # server-side embedded-JS templating
|- handler/ # import/export and collaboration message handling
|- hooks/ # express route registration and i18n
|- security/ # crypto, OAuth2/OIDC, secret rotation
|- types/ # shared TypeScript types
|- updater/ # in-place self-update machinery
|- utils/ # settings, import/export format helpers, toolbar, minification
|- server.ts # entry point
```
`db/` contains the modules that read and write pad state. `Pad.ts` manages an
individual pad; `PadManager.ts`, `AuthorManager.ts`, `GroupManager.ts`,
`SessionManager.ts` and `ReadOnlyManager.ts` manage the corresponding records;
`DB.ts` exposes the low-level key/value store (see
[Accessing the database from server code / plugins](#accessing-the-database-from-server-code-plugins)); and `API.ts` implements
the public HTTP API.
`handler/` contains the request and message handlers. `PadMessageHandler.ts`
drives real-time collaboration, while `ImportHandler.ts` and `ExportHandler.ts`
handle import and export.
`hooks/` contains mostly Express-related code. `i18n.ts` builds the translation
files and registers routes to serve them, and `hooks/express/` registers the
routes that serve pads, the timeslider, static assets and the admin pages.
`utils/` contains the import/export format converters (`ImportHtml.ts`,
`ExportHtml.ts`, `ExportTxt.ts`, `ExportEtherpad.ts`, `ImportEtherpad.ts`,
`ExportHelper.ts`, and native converters such as `ExportPdfNative.ts` and
`ImportDocxNative.ts`), the settings parser (`Settings.ts`), the toolbar builder
(`toolbar.ts`) and the asset minifier (`Minify.ts`).
### src/static/ (client side)
```
src/static/
|- css/ # stylesheets, including css/pad/icons.css
|- font/ # web fonts, including the fontawesome-etherpad icon font
|- img/
|- js/ # client-side TypeScript
|- skins/ # bundled UI skins
|- vendor/
```
`js/` contains the client-side editor code. Notable modules include
`ace2_inner.ts` and `ace2_common.ts` (the editor core), `contentcollector.ts`,
`linestylefilter.ts` and `domline.ts` (content/attribute processing, shared
with the server import/export pipeline), `Changeset.ts` and `AttributePool.ts`
(the changeset and attribute model), and `collab_client.ts` (the
client side of real-time collaboration).
### src/templates/
`templates/` contains the server-rendered page templates for the index, the
pad, the timeslider and the admin pages, plus the bootstrap scripts that load
the client bundles. The templates expose named `eejs` blocks that plugins can
hook into to inject custom HTML.
## How Etherpad converts pads to and from other formats
Internally a pad is not stored as HTML. A pad is a sequence of lines, and each
line carries **attributes** (for example `heading1`, `bullet` or a list number).
The set of attributes that a pad can use is stored in its **attribute pool**; the
pool only records which attributes exist, not where they are applied. The
pool grows over the history of the pad.
Where an attribute is applied to a line is recorded in an **attribute string**,
and a line that carries a line-level attribute is prefixed with a **line marker**
(`lmkr`). Attribute strings and changesets are defined by
`src/static/js/Changeset.ts` and `src/static/js/AttributePool.ts`.
### Collecting content
`src/static/js/contentcollector.ts` is the shared starting point for both the
client (when content is typed or pasted) and the server (when content is
imported). It walks the incoming DOM/HTML, decides which attributes apply to
each line, adds the discovered attributes to the attribute pool, and emits the
resulting attribute strings. On import, `src/node/utils/ImportHtml.ts` calls
`contentcollector.makeContentCollector(...)` to do exactly this, and the HTML
import path in `src/node/handler/ImportHandler.ts` ultimately drives it.
### From attributes to HTML/text (export)
On export the flow is, conceptually:
```
contentcollector.ts
-> linestylefilter.ts
-> ExportHtml.ts / ExportTxt.ts (helped by ExportHelper.ts)
-> ExportHandler.ts
-> the HTTP API / /export/* route
```
- `src/static/js/linestylefilter.ts` walks each line, reads its attributes,
and turns them into the classes/markup the line should render with.
- `src/node/utils/ExportHelper.ts` adds export-only logic that does not belong
in the live editor. The clearest example is lists: in the editor each list
item is rendered as its own line-level block, but a clean export needs the
items collapsed into a single properly nested list. The helper performs that
reshaping for export only.
- `src/node/utils/ExportHtml.ts` and `src/node/utils/ExportTxt.ts` (and
`ExportEtherpad.ts` for the native `.etherpad` format) turn the attributed
text (`atext`) into the final HTML or plain text.
- `src/node/handler/ExportHandler.ts` receives the export request and dispatches
on the requested format — for instance, office formats such as `.docx` and
`.pdf` are routed through the native converters / LibreOffice rather than
through the plain HTML/text path.
On the client side, edits are turned into changesets by the editor, attributes
are translated into CSS classes (so `heading2` becomes
`class="heading2"`), and `src/static/js/domline.ts` (`createDomLine`) renders
the final DOM for each line.
## Accessing the database from server code / plugins
Etherpad stores everything in a single key/value store backed by
[ueberDB](https://www.npmjs.com/package/ueberdb2), which abstracts over the
configured database (dirtyDB, MySQL/MariaDB, PostgreSQL, SQLite, MongoDB, Redis,
and others). Server-side code and plugins access it through
`src/node/db/DB.ts`.
The package name of the core module is, for historical reasons, still
`ep_etherpad-lite`, so plugins import the database module like this:
```javascript
const db = require('ep_etherpad-lite/node/db/DB');
```
The exposed methods are asynchronous and return promises (use `await`), not the
old callback style. The available methods are `get`, `set`, `remove`, `getSub`,
`setSub`, `findKeys` and `findKeysPaged`:
```javascript
// Read a record (returns undefined/null if it does not exist)
const value = await db.get('record_key');
// Create or replace a record
await db.set('record_key', data);
// Read or write a nested value inside a record
const colorId = await db.getSub('author_key', ['colorId']);
await db.setSub('author_key', ['email'], 'tutti@frutti.org');
// Delete a record
await db.remove('record_key');
```
For example, given the author record:
```json
{"colorId":"#79d9d9","name":"tutti","timestamp":1364832712430,"padIDs":{"mypad":1}}
```
calling `await db.setSub('author_key', ['email'], 'tutti@frutti.org')` yields:
```json
{"colorId":"#79d9d9","name":"tutti","timestamp":1364832712430,"padIDs":{"mypad":1},"email":"tutti@frutti.org"}
```
::: warning
Keys are namespaced (for example `pad:<padId>`,
`pad:<padId>:revs:<rev>`, `globalAuthor:<authorId>`). Prefer the high-level
managers (`Pad.ts`, `AuthorManager.ts`, etc.) over direct `DB` access where one
exists; reach for `DB` directly only for data your plugin owns, and use a key
prefix unique to your plugin to avoid collisions.
:::
## Adding a toolbar icon
Etherpad's toolbar icons come from the bundled `fontawesome-etherpad` icon
font in `src/static/font/`. Toolbar buttons reference an icon by a
`buttonicon-<name>` CSS class (see `src/node/utils/toolbar.ts`, which builds
each button's class as `buttonicon buttonicon-<name>`), and those classes are
defined in `src/static/css/pad/icons.css`. The font itself is generated with
[Fontello](http://fontello.com) from `src/static/font/config.json` (whose
`css_prefix_text` is `buttonicon-`).
To add a new icon:
1. Go to [Fontello](http://fontello.com) and import the existing
`src/static/font/config.json` (Fontello's "import" loads the current icon
set and pre-selects the icons it contains).
2. Select the additional icon(s) you want, then click **Download webfont**.
3. From the unzipped download, copy `config.json` and the
`font/fontawesome-etherpad.*` files over the ones in `src/static/font/`.
4. From the unzipped `css/fontawesome-etherpad.css`, copy the new
`.buttonicon-<name>:before { content: '\\eXXX'; }` rules into
`src/static/css/pad/icons.css`, replacing the existing block of icon rules.
The icon is then available wherever a `buttonicon-<name>` class can be used,
including toolbar button definitions.

View file

@ -118,6 +118,25 @@ The `settings.json.docker` available by default allows to control almost every s
| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
### Updates & privacy (offline / air-gapped)
Etherpad makes a small number of outbound calls (a periodic version check and the admin plugin catalogue). In an air-gapped or firewalled deployment these can be disabled entirely without editing `settings.json` inside the image — set the variables below. See [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md) and [doc/admin/updates.md](admin/updates.md) for what each call sends.
| Variable | Description | Default |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `PRIVACY_UPDATE_CHECK` | Set to `false` to disable the hourly version check (`UpdateCheck.ts`). | `true` |
| `PRIVACY_PLUGIN_CATALOG` | Set to `false` to disable the admin plugin browser (manual install-by-name via CLI still works). | `true` |
| `UPDATES_TIER` | Self-updater tier: `off` \| `notify` \| `manual` \| `auto` \| `autonomous`. Set to `off` to suppress the GitHub Releases check entirely. | `notify` |
| `UPDATES_SOURCE` | Where update metadata is fetched from. | `github` |
| `UPDATES_CHANNEL` | Release channel to track. | `stable` |
| `UPDATES_CHECK_INTERVAL_HOURS` | How often (hours) the updater polls when not `off`. | `6` |
| `UPDATES_GITHUB_REPO` | Repository the updater checks for releases. | `ether/etherpad` |
| `UPDATES_REQUIRE_ADMIN_FOR_STATUS`| Lock `/admin/update/status` to authenticated admins. | `false` |
| `UPDATE_SERVER` | Endpoint backing the version check. Point elsewhere (or disable the check above) for offline installs. | `https://etherpad.org/ep_infos` |
> **Fully offline:** set `UPDATES_TIER=off`, `PRIVACY_UPDATE_CHECK=false`, and `PRIVACY_PLUGIN_CATALOG=false`. The version check is fire-and-forget and already fails closed (a blocked endpoint is caught and logged, it does not prevent startup), but disabling it removes the outbound attempt and the log noise.
### Database
| Variable | Description | Default |
@ -355,11 +374,11 @@ services:
- postgres
environment:
NODE_ENV: production
ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin}
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}
@ -367,7 +386,7 @@ 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}
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-false}
restart: always
ports:
- "${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}"
@ -376,7 +395,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

204
doc/faq.md Normal file
View file

@ -0,0 +1,204 @@
# FAQ
This page answers common operational questions about running and maintaining
an Etherpad instance. It collects material previously kept on the project wiki.
## How do I install Etherpad?
There are several supported ways to install Etherpad. Pick whichever suits your
environment.
### Docker
The official image is published to Docker Hub (`etherpad/etherpad`) and to the
GitHub Container Registry (`ghcr.io/ether/etherpad`) with identical tags.
```bash
docker pull etherpad/etherpad
docker run -p 9001:9001 etherpad/etherpad
```
See the [Docker chapter](./docker.md) for building personalized images, enabling plugins, and
configuring office-format import/export.
### One-line installer (macOS / Linux / WSL)
```bash
curl -fsSL https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.sh | sh
```
On Windows (PowerShell):
```powershell
irm https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.ps1 | iex
```
The installer clones Etherpad, installs dependencies and builds the frontend.
Set `ETHERPAD_RUN=1` to also start it once the install finishes.
### apt repository (Debian / Ubuntu)
Etherpad publishes a signed APT repository (`stable` channel). Import the signing
key, add the repository and install:
```bash
curl -fsSL https://etherpad.org/key.asc \
| sudo gpg --dearmor -o /usr/share/keyrings/etherpad.gpg
echo "deb [signed-by=/usr/share/keyrings/etherpad.gpg] https://etherpad.org/apt stable main" \
| sudo tee /etc/apt/sources.list.d/etherpad.list
sudo apt-get update
sudo apt-get install etherpad
```
The repository provides `amd64` and `arm64` builds. Etherpad depends on
Node.js >= 24, so on older distributions you may also need NodeSource's apt
repository to satisfy that dependency.
### From source
Etherpad requires [Node.js](https://nodejs.org/) >= 24 and `pnpm`.
```bash
git clone -b master https://github.com/ether/etherpad
cd etherpad
pnpm i
pnpm run build:etherpad
pnpm run prod
```
Then open `http://localhost:9001`.
## What URL paths does Etherpad serve?
| Path | Description |
|------|-------------|
| `/admin` | Administration dashboard (requires admin login). |
| `/admin/plugins` | Install, update and remove plugins from the web UI. |
| `/admin/settings` | Edit `settings.json` from the web UI. |
| `/p/:padID` | Open (or create) the pad with the given `padID`, e.g. `/p/foo`. |
| `/p/:padID/timeslider` | Open the pad's history/timeslider view. Append `#N` to jump to a specific revision, e.g. `/p/foo/timeslider#5`. |
| `/p/:padID/export/:type` | Export the pad in the given format, e.g. `/p/foo/export/html`. Append `?revs=N` to export a specific revision. |
Supported export types:
- **Native (no extra dependencies):** `txt`, `html`, `etherpad`, `docx`, `pdf`.
- **Via LibreOffice:** `odt`, `doc`, `rtf` — these require the `soffice` setting
to point at a LibreOffice executable. See the office-format notes in the
[Docker chapter](./docker.md).
## How do I list all pads?
The recommended way is the HTTP API method `listAllPads`, combined with `jq`:
```bash
ETHERPAD_HOST='https://pad.example.com'
ETHERPAD_API_KEY='...' # the APIKEY.txt file in the Etherpad root
ETHERPAD_API_VERSION='...' # see https://pad.example.com/api
curl -s "${ETHERPAD_HOST}/api/${ETHERPAD_API_VERSION}/listAllPads?apikey=${ETHERPAD_API_KEY}" \
| jq -r '.data.padIDs[]'
```
For an interactive list with management actions, install the `ep_adminpads2`
plugin and browse to `/admin/pads`.
As a last resort you can query the database directly. The exact query depends on
your configured backend; pad records use keys of the form `pad:<padID>` and
`pad:<padID>:revs:<n>`. For example, with SQLite:
```bash
sqlite3 ./var/sqlite.db "select key from store where key like 'pad:%'" \
| grep -Eo '^pad:[^:]+' \
| sed -e 's/pad://' \
| sort -u
```
Prefer the API or admin plugin over direct SQL: the schema is an implementation
detail and may change.
## How do I delete or manage pads?
Use the HTTP API `deletePad` method:
```bash
curl -s "${ETHERPAD_HOST}/api/${ETHERPAD_API_VERSION}/deletePad?apikey=${ETHERPAD_API_KEY}&padID=foo"
```
The API also offers `copyPad`, `movePad`, `getRevisionsCount` and more — see the
[HTTP API chapter](./api/http_api.md).
For a web UI, install the `ep_adminpads2` plugin and manage pads from
`/admin/pads`, where you can search, view and delete pads.
The `deletePad` CLI tool is also available for operators:
```bash
pnpm run --filter bin deletePad <padID>
```
## How do I back up and restore pads?
### Back up the whole instance
All pad data lives in the configured database. Back it up using the tool
appropriate to your backend (for example `mysqldump` for MySQL/MariaDB,
`pg_dump` for PostgreSQL, or a file copy of `var/*.db` for the file-based
`dirty`/`rusty` engines while Etherpad is stopped). A regular, automated dump of
the database is the canonical backup for a production instance.
### Back up a single pad
Export the pad over HTTP by appending `/export/<type>` to its URL. Plain text,
HTML and the round-trippable `etherpad` format are most useful for backups:
```bash
curl -o mypad.txt https://pad.example.com/p/foo/export/txt
curl -o mypad.html https://pad.example.com/p/foo/export/html
curl -o mypad.etherpad https://pad.example.com/p/foo/export/etherpad
```
The `etherpad` export preserves the pad's full history and can be re-imported,
making it the best choice for migrating or archiving an individual pad.
### Restore or inspect an old revision
Every state the pad has been in is stored in the database, so you can retrieve
an earlier revision without a separate backup:
- Open `/p/:padID/timeslider` to browse the history and find the revision
number you want.
- Export a specific revision directly with the `?revs=N` query parameter, e.g.
`https://pad.example.com/p/foo/export/html?revs=1000`.
### Repairing a damaged pad
If a pad is corrupt, use the CLI repair tools (`checkPad`, `repairPad`,
`rebuildPad`) documented in the [CLI chapter](./cli.md). Always back up the database before
running write operations.
## How do I limit history or prune revisions?
Etherpad keeps the full revision history of every pad, so the database grows
over time. To reclaim space, use the pad-compaction CLI tools, which collapse or
trim revision history for one pad, every pad, or only stale pads:
```bash
# Collapse all history of one pad
pnpm run --filter bin compactPad <padID>
# Keep only the last 50 revisions of one pad
pnpm run --filter bin compactPad <padID> --keep 50
# Compact every pad on the instance
pnpm run --filter bin compactAllPads
# Compact only pads not edited in the last 90 days, keeping the last 50 revisions
pnpm run --filter bin compactStalePads --older-than 90 --keep 50
```
These tools require `cleanup.enabled = true` in `settings.json` and are
**destructive** — history is collapsed or trimmed. Export anything you can't
afford to lose via the pad's `/export/etherpad` route first. The same primitive
is available over the wire as the `compactPad` HTTP API method. See the [CLI chapter](./cli.md) for full details.

View file

@ -1,7 +1,7 @@
{
"devDependencies": {
"oxc-minify": "^0.135.0",
"vitepress": "^2.0.0-alpha.17"
"oxc-minify": "^0.141.0",
"vitepress": "^2.0.0-alpha.18"
},
"scripts": {
"docs:dev": "vitepress dev",

View file

@ -45,13 +45,13 @@
"node": ">=24.0.0",
"pnpm": ">=11.1.2"
},
"packageManager": "pnpm@11.1.2",
"packageManager": "pnpm@11.10.0",
"repository": {
"type": "git",
"url": "https://github.com/ether/etherpad.git"
},
"engineStrict": true,
"version": "3.3.1",
"version": "3.3.3",
"license": "Apache-2.0",
"pnpm": {
"onlyBuiltDependencies": [

4388
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -21,20 +21,59 @@ strictDepBuilds: false
# As of pnpm 11, overrides must live here (root package.json's pnpm.overrides
# is no longer read). Force-bump transitive deps with known CVEs.
overrides:
basic-ftp: '>=5.3.0'
'@babel/core@<7.29.6': '>=7.29.6'
'@opentelemetry/core@<2.8.0': '>=2.8.0'
basic-ftp@<5.3.1: '>=5.3.1 <6.0.0'
brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3'
diff@>=6.0.0 <8.0.3: '>=8.0.3'
esbuild@<0.28.1: '>=0.28.1'
flatted: '>=3.4.2'
follow-redirects: '>=1.16.0'
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
glob@>=10.2.0 <10.5.0: '>=10.5.0'
js-yaml@>=4.0.0 <4.1.1: '>=4.1.1'
ip-address@<10.1.1: '>=10.1.1'
js-yaml@>=4.0.0 <4.2.0: '>=4.2.0'
lodash: '>=4.18.0'
minimatch@>=9.0.0 <9.0.7: '>=9.0.7'
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
qs@>=6.7.0 <6.14.2: '>=6.14.2'
qs@>=6.7.0 <6.15.2: '>=6.15.2'
serialize-javascript@<7.0.5: '>=7.0.5'
socket.io-parser@>=4.0.0 <4.2.6: '>=4.2.6'
tar@<7.5.11: '>=7.5.11'
tar@<7.5.16: '>=7.5.16'
uuid@<14.0.0: '>=14.0.0'
vite@>=7.0.0 <7.3.2: '>=7.3.2'
ws@>=8.0.0 <8.21.0: '>=8.21.0'
minimumReleaseAgeExclude:
- '@radix-ui/primitive@1.1.5'
- '@radix-ui/react-collection@1.1.12'
- '@radix-ui/react-context@1.2.0'
- '@radix-ui/react-dialog@1.1.19'
- '@radix-ui/react-dismissable-layer@1.1.15'
- '@radix-ui/react-focus-scope@1.1.12'
- '@radix-ui/react-presence@1.1.7'
- '@radix-ui/react-toast@1.2.19'
- mysql2@3.22.6
- oidc-provider@9.9.0
- sql-escaper@1.4.0
- '@typescript/typescript-aix-ppc64@7.0.2'
- '@typescript/typescript-darwin-arm64@7.0.2'
- '@typescript/typescript-darwin-x64@7.0.2'
- '@typescript/typescript-freebsd-arm64@7.0.2'
- '@typescript/typescript-freebsd-x64@7.0.2'
- '@typescript/typescript-linux-arm64@7.0.2'
- '@typescript/typescript-linux-arm@7.0.2'
- '@typescript/typescript-linux-loong64@7.0.2'
- '@typescript/typescript-linux-mips64el@7.0.2'
- '@typescript/typescript-linux-ppc64@7.0.2'
- '@typescript/typescript-linux-riscv64@7.0.2'
- '@typescript/typescript-linux-s390x@7.0.2'
- '@typescript/typescript-linux-x64@7.0.2'
- '@typescript/typescript-netbsd-arm64@7.0.2'
- '@typescript/typescript-netbsd-x64@7.0.2'
- '@typescript/typescript-openbsd-arm64@7.0.2'
- '@typescript/typescript-openbsd-x64@7.0.2'
- '@typescript/typescript-sunos-x64@7.0.2'
- '@typescript/typescript-win32-arm64@7.0.2'
- '@typescript/typescript-win32-x64@7.0.2'
- typescript@7.0.2

View file

@ -210,15 +210,17 @@
* tier: "off" | "notify" | "manual" | "auto" | "autonomous"
* Default "notify" shows a banner when an update is available.
* Docker installs are read-only — tiers above "notify" are not applied even if requested.
* Air-gapped / offline deployments should set UPDATES_TIER=off to suppress the
* periodic check against the GitHub Releases API entirely.
*/
"updates": {
"tier": "notify",
"source": "github",
"channel": "stable",
"tier": "${UPDATES_TIER:notify}",
"source": "${UPDATES_SOURCE:github}",
"channel": "${UPDATES_CHANNEL:stable}",
"installMethod": "docker",
"checkIntervalHours": 6,
"githubRepo": "ether/etherpad",
"requireAdminForStatus": false,
"checkIntervalHours": "${UPDATES_CHECK_INTERVAL_HOURS:6}",
"githubRepo": "${UPDATES_GITHUB_REPO:ether/etherpad}",
"requireAdminForStatus": "${UPDATES_REQUIRE_ADMIN_FOR_STATUS:false}",
"preApplyGraceMinutes": 0,
"drainSeconds": 60,
"rollbackHealthCheckSeconds": 60,
@ -321,7 +323,19 @@
* https://etherpad.org/ep_infos
*/
"updateServer": "https://etherpad.org/ep_infos",
"updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}",
/*
* Outbound network calls. See PRIVACY.md for what each one sends.
* - PRIVACY_UPDATE_CHECK=false : disables the hourly version check (UpdateCheck.ts)
* - PRIVACY_PLUGIN_CATALOG=false : disables the admin plugin browser
* (manual install-by-name via CLI still works)
* Air-gapped / firewalled deployments should set both to false.
*/
"privacy": {
"updateCheck": "${PRIVACY_UPDATE_CHECK:true}",
"pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}"
},
/*
* The type of the database.

View file

@ -216,7 +216,7 @@
* Default "notify" shows a banner when an update is available. "off" disables the version check.
*/
"updates": {
"tier": "notify",
"tier": "${UPDATES_TIER:notify}",
"source": "github",
"channel": "stable",
"installMethod": "auto",
@ -443,17 +443,19 @@
* https://etherpad.org/ep_infos
*/
"updateServer": "https://etherpad.org/ep_infos",
"updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}",
/*
* Outbound network calls. See PRIVACY.md for what each one sends.
* - updateCheck=false : disables hourly version check (UpdateCheck.ts)
* - pluginCatalog=false: disables admin plugin browser
* (manual install-by-name via CLI still works)
* Air-gapped / firewalled deployments should set both PRIVACY_UPDATE_CHECK and
* PRIVACY_PLUGIN_CATALOG to false (or UPDATES_TIER=off, which covers the version check).
*/
"privacy": {
"updateCheck": true,
"pluginCatalog": true
"updateCheck": "${PRIVACY_UPDATE_CHECK:true}",
"pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}"
},
/*
@ -964,6 +966,14 @@
"sso": {
"issuer": "${SSO_ISSUER:http://localhost:9001}",
/*
* Signing keys for the embedded OIDC provider's cookies. Leave empty and
* Etherpad derives a secret key from the persisted session secret
* (SESSIONKEY.txt), which is stable across restarts and shared across
* horizontally-scaled pods. Set an explicit ordered array to rotate: the
* first key signs, the rest are still accepted for verify.
*/
"cookieKeys": ["${OIDC_COOKIE_KEY:}"],
"clients": [
{
"client_id": "${ADMIN_CLIENT:admin_client}",

View file

@ -124,7 +124,7 @@
"pad.importExport.exportword": "مايكروسوفت وورد",
"pad.importExport.exportpdf": "صيغة المستندات المحمولة",
"pad.importExport.exportopen": "ODF (نسق المستند المفتوح)",
"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\">تثبيت LibreOffice</a> .",
"pad.importExport.noConverter.innerHTML": "يمكنك استيراد النصوص العادية، وملفات HTML، وملفات Microsoft Word (.docx)، وملفات Etherpad مباشرةً. لاستيراد تنسيقات أخرى مثل PDF، وODT، وDOC، وRTF، يحتاج مسؤول الخادم إلى تثبيت LibreOffice - راجع <a href=\"https://docs.etherpad.org/\">وثائق Etherpad</a> .",
"pad.modals.connected": "متصل.",
"pad.modals.reconnecting": "إعادة الاتصال ببادك..",
"pad.modals.forcereconnect": "فرض إعادة الاتصال",

View file

@ -116,7 +116,7 @@
"pad.modals.looping.explanation": "Праблемы далучэньня да сэрвэра сынхранізацыі.",
"pad.modals.looping.cause": "Магчыма, вы падключыліся празь несумяшчальны брандмаўэр або проксі.",
"pad.modals.initsocketfail": "Сэрвэр недаступны.",
"pad.modals.initsocketfail.explanation": "Не атрымалася падлучыцца да сэрвэра сынхранізацыі.",
"pad.modals.initsocketfail.explanation": "Не ўдалося падлучыцца да сэрвэра сынхранізацыі.",
"pad.modals.initsocketfail.cause": "Імаверна, гэта зьвязана з праблемамі з вашым браўзэрам або інтэрнэт-злучэньнем.",
"pad.modals.slowcommit.explanation": "Сэрвэр не адказвае.",
"pad.modals.slowcommit.cause": "Гэта можа быць выклікана праблемамі зь сеткавым падлучэньнем.",
@ -178,9 +178,9 @@
"pad.impexp.importbutton": "Імпартаваць зараз",
"pad.impexp.importing": "Імпартаваньне…",
"pad.impexp.confirmimport": "Імпарт файла перазапіша цяперашні тэкст дакумэнту. Вы ўпэўненыя, што хочаце працягваць?",
"pad.impexp.convertFailed": "Не атрымалася імпартаваць гэты файл. Калі ласка, выкарыстайце іншы фармат дакумэнту або скапіюйце ўручную.",
"pad.impexp.convertFailed": "Не ўдалося імпартаваць гэты файл. Калі ласка, выкарыстайце іншы фармат дакумэнту або скапіюйце рукамі.",
"pad.impexp.padHasData": "Мы не змаглі імпартаваць гэты файл, бо дакумэнт ужо мае зьмены, калі ласка, імпартуйце ў новы дакумэнт",
"pad.impexp.uploadFailed": "Загрузка не атрымалася, калі ласка, паспрабуйце яшчэ раз",
"pad.impexp.uploadFailed": "Загрузка не ўдалася, калі ласка, паспрабуйце яшчэ раз",
"pad.impexp.importfailed": "Памылка імпарту",
"pad.impexp.copypaste": "Калі ласка, скапіюйце і ўстаўце",
"pad.impexp.exportdisabled": "Экспарт у фармаце {{type}} адключаны. Калі ласка, зьвярніцеся да вашага сыстэмнага адміністратара па падрабязнасьці.",

View file

@ -16,13 +16,80 @@
]
},
"admin.page-title": "Ovládací panel Správce - Etherpad",
"admin.loading": "Načítám…",
"admin.loading_description": "Počkejte prosím, než se stránka načte.",
"admin.toggle_sidebar": "Přepnout postranní panel",
"admin.shout": "Komunikace",
"admin_shout.online_one": "Aktuálně je online {{count}} uživatelů",
"admin_shout.online_other": "Aktuálně je online {{count}} uživatelů",
"admin_shout.sticky_toggle": "Změnit připevněnou zprávu",
"admin_login.title": "Etherpad",
"admin_login.username": "Uživatelské jméno",
"admin_login.password": "Heslo",
"admin_login.submit": "Přihlásit se",
"admin_login.failed": "Přihlášení se nezdařilo",
"admin_pads.all_pads": "Všechny Pady",
"admin_pads.bulk.cleanup_history": "Vyčistit historii",
"admin_pads.bulk.clear_selection": "Vymazat výběr",
"admin_pads.bulk.delete": "Smazat",
"admin_pads.cancel": "Zrušit",
"admin_pads.col.pad": "Pad",
"admin_pads.col.revisions": "Revize",
"admin_pads.col.users": "Uživatelé",
"admin_pads.confirm_button": "OK",
"admin_pads.create_pad_dialog_description": "Vyberte název pro nový Pad.",
"admin_pads.delete_pad_dialog_description": "Potvrdit nebo zrušit smazání Padu.",
"admin_pads.delete_pad_dialog_title": "Smazat pad",
"admin_pads.empty_never_edited": "prázdné · nikdy neupravené",
"admin_pads.error_dialog_description": "Došlo k chybě.",
"admin_pads.error_prefix": "Chyba",
"admin_pads.filter.active": "Aktivní",
"admin_pads.filter.all": "Vše",
"admin_pads.filter.empty": "Prázdné",
"admin_pads.filter.recent": "Tento týden",
"admin_pads.filter.stale": "Zastaralé (>1 rok)",
"admin_pads.open": "Otevřít",
"admin_pads.pagination.next": "Další",
"admin_pads.pagination.previous": "Předchozí",
"admin_pads.refresh": "Obnovit",
"admin_pads.relative.days": "před {{count}} dny",
"admin_pads.relative.hours": "před {{count}}h",
"admin_pads.relative.just_now": "právě teď",
"admin_pads.relative.minutes": "před {{count}} minutami",
"admin_pads.relative.months": "před {{count}} měsíci",
"admin_pads.relative.weeks": "před {{count}} týdny",
"admin_pads.relative.years": "před {{count}} lety",
"admin_pads.revisions_count": "{{count}} revizí",
"admin_pads.selected_count": "Vybráno {{count}}",
"admin_pads.show": "Zobrazit",
"admin_pads.sort.name": "Jméno (AZ)",
"admin_pads.sort.revision_number": "Revize",
"admin_pads.sort.user_count": "Uživatelé",
"admin_pads.stats.across_pads": "napříč všemi pady",
"admin_pads.stats.active_users": "Aktivní uživatelé",
"admin_pads.stats.empty_pads": "Prázdné pady",
"admin_pads.stats.last_activity": "Poslední aktivita:",
"admin_pads.stats.no_active_users": "Žádní aktivní uživatelé",
"admin_pads.stats.revisions_zero": "0 revizí",
"admin_pads.stats.total": "Celkový počet padů",
"admin_pads.stats.users_active": "{{count}} aktuálně aktivních",
"admin_pads.subtitle": "Přehled všech padů na této instanci Etherpadu. Vyhledat, vyčistit, otevřít.",
"admin_plugins": "Správce zásuvných moodulů",
"admin_plugins.available": "Dostupné zásuvné moduly",
"admin_plugins.available_not-found": "Nejsou žádné zásuvné moduly",
"admin_plugins.available_fetching": "Načítání...",
"admin_plugins.available_install.value": "Instalovat",
"admin_plugins.available_search.placeholder": "Vyhledat zásuvné moduly k instalaci",
"admin_plugins.check_updates": "Zkontrolovat aktualizace",
"admin_plugins.core_count": "{{count}} jádro",
"admin_plugins.catalog_disabled": "Katalog pluginů je zakázán vaším operátorem (privacy.pluginCatalog=false). Chcete-li plugin nainstalovat, spusťte příkaz `pnpm run plugins i ep_<name> ` ze serveru.",
"admin_plugins.crumbs": "Pluginy",
"admin_plugins.description": "Popis",
"admin_plugins.disables.label": "Vypnuto:",
"admin_plugins.disables.warning_title": "Tento plugin záměrně odstraňuje uvedené funkce Etherpadu.",
"admin_plugins.error_retrieving": "Chyba při načítání pluginů",
"admin_plugins.install_error": "Instalace pluginu {{plugin}} se nezdařila: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad": "Nelze nainstalovat {{plugin}}: vyžaduje novější verzi Etherpadu. Prosím, aktualizujte Etherpad a zkuste to znovu.",
"admin_plugins.installed": "Nainstalované zásuvné moduly",
"admin_plugins.installed_fetching": "Načítání instalovaných zásuvných modulů...",
"admin_plugins.installed_nothing": "Dosud jste nenainstalovali žádné zásuvné moduly.",
@ -30,23 +97,61 @@
"admin_plugins.last-update": "Poslední aktualizace",
"admin_plugins.name": "Název",
"admin_plugins.page-title": "Správce zásuvných modulů - Etherpad",
"admin_plugins.reload_catalog": "Obnovit katalog",
"admin_plugins.search_npm": "Hledat na npm",
"admin_plugins.sort_ascending": "Seřadit vzestupně",
"admin_plugins.sort_descending": "Seřadit sestupně",
"admin_plugins.sort.last_updated": "Naposledy aktualizováno",
"admin_plugins.sort.name": "Jméno (AZ)",
"admin_plugins.sort.version": "Verze",
"admin_plugins.source": "Zdroj pluginu",
"admin_plugins.subtitle": "Instalace, aktualizace a odebrání pluginů Etherpad. Změny vyžadují restart serveru.",
"admin_plugins.tag_core": "Jádro",
"admin_plugins.update_tooltip": "Aktualizovat",
"admin_plugins.updates_available": "Dostupné aktualizace",
"admin_plugins.update_now": "Aktualizovat",
"admin_plugins.version": "Verze",
"admin_plugins_info": "Informace o řešení problému",
"admin_plugins_info.bindings_label": "{{count}} vazeb",
"admin_plugins_info.copy_diagnostics": "Diagnostika kopírování",
"admin_plugins_info.copy_value": "Kopírovat {{label}}",
"admin_plugins_info.git_sha": "SHA v Gitu",
"admin_plugins_info.hooks": "Instalované hooks",
"admin_plugins_info.hooks_client": "hooks na straně klienta",
"admin_plugins_info.hooks_server": "hooks na straně serveru",
"admin_plugins_info.parts": "Nainstalované součásti",
"admin_plugins_info.plugins": "Nainstalované zásuvné moduly",
"admin_plugins_info.page-title": "Informace o zásuvných modulech - Etherpad",
"admin_plugins_info.tab_client": "Klient",
"admin_plugins_info.tab_server": "Server",
"admin_plugins_info.up_to_date": "Aktuální",
"admin_plugins_info.update_available": "Aktualizace k dispozici: {{version}}",
"admin_plugins_info.version": "Verze Etherpad",
"admin_plugins_info.version_latest": "Poslední dostupná verze",
"admin_plugins_info.version_number": "Číslo verze",
"admin_settings": "Nastavení",
"admin_settings.create_pad": "Vytvořit pad",
"admin_settings.current": "Aktuální konfugurace",
"admin_settings.current_example-devel": "Příklad ukázkové vývojové šablony",
"admin_settings.current_example-prod": "Příklad šablony nastavení výroby",
"admin_settings.current_restart.value": "Restartovat Etherpad",
"admin_settings.current_save.value": "Uložit nastavení",
"admin_settings.invalid_json": "Neplatný JSON",
"admin_settings.current_test.value": "Ověření JSON",
"admin_settings.current_prettify.value": "Zkrášlit JSON",
"admin_settings.toast.saved": "Nastavení bylo úspěšně uloženo.",
"admin_settings.toast.save_failed": "Uložení se nezdařilo: soubor settings.json se nepodařilo zapsat.",
"admin_settings.toast.json_invalid": "Syntaktická chyba: zkontrolujte čárky, závorky a uvozovky.",
"admin_settings.toast.disconnected": "Nelze uložit: nejsem připojen k serveru.",
"admin_settings.toast.validation_ok": "JSON je platný.",
"admin_settings.toast.validation_failed": "JSON je neplatný: opravte syntaktické chyby.",
"admin_settings.toast.prettify_failed": "Nelze upravovat: nejprve opravte syntaktické chyby.",
"admin_settings.prettify_confirm": "Zkrášlováním odstraníte všechny komentáře. Pokračovat?",
"admin_settings.mode.form": "Formulář",
"admin_settings.mode.effective": "Efektivní",
"admin_settings.mode.effective_tooltip": "Zobrazení hodnot, které Etherpad aktuálně používá, pouze pro čtení, po substituci proměnných prostředí. Tajné kódy jsou redigovány.",
"admin_settings.mode.aria_label": "Režim editoru",
"admin_settings.envvar_banner.title": "Tento soubor je šablona, nikoli živá konfigurace.",
"admin_settings.page-title": "Nastavení - Etherpad",
"index.newPad": "Založ nový Pad",
"index.settings": "Nastavení",

View file

@ -10,6 +10,7 @@
"Metalhead64",
"Mklehr",
"Mukeber",
"Nbux",
"Nipsky",
"Predatorix",
"SamTV",
@ -22,13 +23,80 @@
]
},
"admin.page-title": "Admin Dashboard - Etherpad",
"admin.loading": "Lade …",
"admin.loading_description": "Bitte warten, die Seite wird aktualisiert ...",
"admin.toggle_sidebar": "Seitenleiste ein-/ausblenden",
"admin.shout": "Kommunikation",
"admin_shout.online_one": "Es ist derzeit {{count}} Benutzer online",
"admin_shout.online_other": "Es sind aktuell {{count}} Benutzer online",
"admin_shout.sticky_toggle": "Nachricht bleibt angeheftet",
"admin_login.title": "Etherpad",
"admin_login.username": "Benutzername",
"admin_login.password": "Passwort",
"admin_login.submit": "Login",
"admin_login.failed": "Login fehlgeschlagen.",
"admin_pads.all_pads": "Alle Pads",
"admin_pads.bulk.cleanup_history": "Verlauf löschen",
"admin_pads.bulk.clear_selection": "Auswahl löschen",
"admin_pads.bulk.delete": "Löschen",
"admin_pads.cancel": "Abbrechen",
"admin_pads.col.pad": "Pad",
"admin_pads.col.revisions": "Bearbeitungen",
"admin_pads.col.users": "Benutzer",
"admin_pads.confirm_button": "OK",
"admin_pads.create_pad_dialog_description": "Wähle einen Namen für das neue Pad.",
"admin_pads.delete_pad_dialog_description": "Bestätigen oder stornieren Sie die Löschung des Pads.",
"admin_pads.delete_pad_dialog_title": "Pad löschen",
"admin_pads.empty_never_edited": "leer · nie bearbeitet",
"admin_pads.error_dialog_description": "Ein Fehler ist aufgetreten",
"admin_pads.error_prefix": "Fehler",
"admin_pads.filter.active": "Aktiv",
"admin_pads.filter.all": "Alle",
"admin_pads.filter.empty": "Leer",
"admin_pads.filter.recent": "Diese Woche",
"admin_pads.filter.stale": "Veraltet (> 1 Jahr)",
"admin_pads.open": "Offen",
"admin_pads.pagination.next": "Weiter",
"admin_pads.pagination.previous": "Zurück",
"admin_pads.refresh": "Aktualisieren",
"admin_pads.relative.days": "vor {{count}} Tag(en)",
"admin_pads.relative.hours": "vor {{count}} Stunde(n)",
"admin_pads.relative.just_now": "Soeben",
"admin_pads.relative.minutes": "vor {{count}} Minute(n)",
"admin_pads.relative.months": "vor {{count}} Monat(en)",
"admin_pads.relative.weeks": "vor {{count}} Woche(n)",
"admin_pads.relative.years": "vor {{count}} Jahr(en)",
"admin_pads.revisions_count": "{{count}} Bearbeitungen",
"admin_pads.selected_count": "{{count}} ausgewählt",
"admin_pads.show": "Anzeigen",
"admin_pads.sort.name": "Name (AZ)",
"admin_pads.sort.revision_number": "Bearbeitungen",
"admin_pads.sort.user_count": "Nutzer",
"admin_pads.stats.across_pads": "über alle Pads",
"admin_pads.stats.active_users": "Aktive Nutzer",
"admin_pads.stats.empty_pads": "Leere Pads",
"admin_pads.stats.last_activity": "Letzte Aktivität",
"admin_pads.stats.no_active_users": "Keine aktiven Benutzer",
"admin_pads.stats.revisions_zero": "0 Bearbeitungen",
"admin_pads.stats.total": "Gesamtanzahl Pads",
"admin_pads.stats.users_active": "{{count}} aktuell aktiv",
"admin_pads.subtitle": "Überblick über alle Pads auf dieser Etherpad-Instanz. Suchen, aufräumen, öffnen.",
"admin_plugins": "Pluginverwaltung",
"admin_plugins.available": "Verfügbare Plugins",
"admin_plugins.available_not-found": "Keine Plugins gefunden.",
"admin_plugins.available_fetching": "Wird abgerufen...",
"admin_plugins.available_install.value": "Installieren",
"admin_plugins.available_search.placeholder": "Suche nach Plugins zum Installieren",
"admin_plugins.check_updates": "Nach Updates suchen",
"admin_plugins.core_count": "{{count}} Kern(e)",
"admin_plugins.catalog_disabled": "Plugin-Katalog wurde vom Betreiber deaktiviert (privacy.pluginCatalog=false). Um ein Plugin zu installieren, führen Sie `pnpm run plugins i ep_<name>` auf dem Server aus.",
"admin_plugins.crumbs": "Plugins",
"admin_plugins.description": "Beschreibung",
"admin_plugins.disables.label": "Deaktiviert:",
"admin_plugins.disables.warning_title": "Dieses Plugin entfernt absichtlich die aufgeführten Etherpad-Funktionen.",
"admin_plugins.error_retrieving": "Fehler beim Abrufen von Plugins",
"admin_plugins.install_error": "Installation von {{plugin}} fehlgeschlagen: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad": "Installation von {{plugin}} nicht möglich: Hierfür ist neuere Version von Etherpad notwendig. Bitte führe ein Upgrade von Etherpad durch und versuche es erneut.",
"admin_plugins.installed": "Installierte Plugins",
"admin_plugins.installed_fetching": "Rufe installierte Plugins ab...",
"admin_plugins.installed_nothing": "Du hast bisher noch keine Plugins installiert.",
@ -36,23 +104,67 @@
"admin_plugins.last-update": "Letze Aktualisierung",
"admin_plugins.name": "Name",
"admin_plugins.page-title": "Plugin Manager - Etherpad",
"admin_plugins.reload_catalog": "Katalog neu laden",
"admin_plugins.search_npm": "Suche auf npm",
"admin_plugins.sort_ascending": "Aufsteigend sortieren",
"admin_plugins.sort_descending": "Absteigend sortieren",
"admin_plugins.sort.last_updated": "Zuletzt aktualisiert",
"admin_plugins.sort.name": "Name (AZ)",
"admin_plugins.sort.version": "Version",
"admin_plugins.source": "Plugin-Quelle",
"admin_plugins.subtitle": "Installieren, aktualisieren und entfernen Sie Etherpad-Plugins. Änderungen erfordern einen Server-Neustart.",
"admin_plugins.tag_core": "Kern",
"admin_plugins.update_tooltip": "Update",
"admin_plugins.updates_available": "Updates verfügbar",
"admin_plugins.update_now": "Update",
"admin_plugins.version": "Version",
"admin_plugins_info": "Hilfestellung",
"admin_plugins_info.copy_diagnostics": "Kopiere Diagnose-Daten",
"admin_plugins_info.copy_value": "Kopiere {{label}}",
"admin_plugins_info.git_sha": "Git SHA",
"admin_plugins_info.hook_bindings": "Hook-Bindungen",
"admin_plugins_info.hooks": "Installierte Hooks",
"admin_plugins_info.hooks_client": "Client-seitige Hooks",
"admin_plugins_info.hooks_server": "Server-seitige Hooks",
"admin_plugins_info.no_hooks": "Keine Hooks gefunden",
"admin_plugins_info.parts": "Installierte Teile",
"admin_plugins_info.plugins": "Installierte Plugins",
"admin_plugins_info.page-title": "Plugin Informationen - Etherpad",
"admin_plugins_info.search_placeholder": "Suche Hook oder Teil…",
"admin_plugins_info.subtitle": "Systemdiagnose: installierte Version, registrierte Teile und Hooks.",
"admin_plugins_info.tab_client": "Client",
"admin_plugins_info.tab_server": "Server",
"admin_plugins_info.up_to_date": "Aktuell",
"admin_plugins_info.update_available": "Update verfügbar: {{version}}",
"admin_plugins_info.version": "Etherpad Version",
"admin_plugins_info.version_latest": "Neueste verfügbare Version",
"admin_plugins_info.version_number": "Versionsnummer",
"admin_settings": "Einstellungen",
"admin_settings.create_pad": "Pad erstellen",
"admin_settings.current": "Derzeitige Konfiguration",
"admin_settings.current_example-devel": "Beispielhafte Entwicklungseinstellungs-Templates",
"admin_settings.current_example-prod": "Beispiel eines produktiven Templates",
"admin_settings.current_restart.value": "Etherpad neustarten",
"admin_settings.current_save.value": "Einstellungen speichern",
"admin_settings.invalid_json": "Ungültiges JSON",
"admin_settings.current_test.value": "Validiere JSON",
"admin_settings.current_prettify.value": "Prettify JSON",
"admin_settings.toast.saved": "Einstellungen erfolgreich gespeichert.",
"admin_settings.toast.save_failed": "Speichern fehlgeschlagen: settings.json konnte nicht geschrieben werden.",
"admin_settings.toast.json_invalid": "Syntaxfehler: Überprüfen Sie Kommas, Klammern und Anführungszeichen.",
"admin_settings.toast.disconnected": "Nicht gespeichert: Nicht mit dem Server verbunden.",
"admin_settings.toast.validation_ok": "JSON ist gültig.",
"admin_settings.toast.validation_failed": "JSON ist ungültig: Bitte beheben Sie Syntaxfehler.",
"admin_settings.toast.prettify_failed": "Prettify nicht möglich: Bitte beheben Sie zunächst Syntaxfehler.",
"admin_settings.prettify_confirm": "Prettify entfernt alle Kommentare. Weiter?",
"admin_settings.mode.form": "Formular",
"admin_settings.mode.effective_tooltip": "Read-only-Ansicht der Werte, die Etherpad tatsächlich verwendet, nach Umgebungsvariablen-Ersetzung. Secrets sind geschwärzt.",
"admin_settings.mode.aria_label": "Editor-Modus",
"admin_settings.envvar_banner.title": "Diese Datei ist eine Vorlage, nicht die Live-Konfiguration.",
"admin_settings.envvar_banner.body": "Platzhalter wie ${VAR:default} werden beim Start in den Speicher eingesetzt; sie werden nie in diese Datei zurückgeschrieben. Bearbeiten Sie env vars in Ihrer Umgebung (Docker compose, systemd, .env), um den aufgelösten Wert zu ändern, oder ersetzen Sie den Platzhalter hier durch ein Literal. Wechseln Sie auf die Registerkarte \"Effective\", um zu sehen, was Etherpad gerade verwendet.",
"admin_settings.toast.auth_error": "Sie sind nicht als Administrator authentifiziert. Bitte melden Sie sich erneut an.",
"admin_settings.section.general": "Allgemein",
"admin_settings.parse_error.title": "Parsen von settings.json nicht möglich",
"admin_settings.page-title": "Einstellungen - Etherpad",
"index.newPad": "Neues Pad",
"index.settings": "Einstellungen",
@ -75,7 +187,7 @@
"index.labelPad": "Padname (optional)",
"index.placeholderPadEnter": "Gib den Namen des Pads ein...",
"index.createAndShareDocuments": "Erstelle und teile Dokumente in Echtzeit",
"index.createAndShareDocumentsDescription": "Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Ihrem Browser läuft.",
"index.createAndShareDocumentsDescription": "Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Deinem Browser läuft.",
"pad.toolbar.bold.title": "Fett (Strg-B)",
"pad.toolbar.italic.title": "Kursiv (Strg-I)",
"pad.toolbar.underline.title": "Unterstrichen (Strg-U)",
@ -99,18 +211,31 @@
"pad.loading": "Laden …",
"pad.noCookie": "Das Cookie konnte nicht gefunden werden. Bitte erlaube Cookies in deinem Browser! Deine Sitzung und Einstellungen werden zwischen den Besuchen nicht gespeichert. Dies kann darauf zurückzuführen sein, dass Etherpad in einigen Browsern in einem iFrame enthalten ist. Bitte stelle sicher, dass sich Etherpad auf der gleichen Subdomain/Domain wie der übergeordnete iFrame befindet.",
"pad.permissionDenied": "Du hast keine Berechtigung, um auf dieses Pad zuzugreifen.",
"pad.settings.padSettings": "Pad-Einstellungen",
"pad.settings.title": "Einstellungen",
"pad.settings.padSettings": "Pad-weite Einstellungen",
"pad.settings.userSettings": "Nutzereinstellungen",
"pad.settings.myView": "Eigene Ansicht",
"pad.settings.disablechat": "Chat deaktivieren",
"pad.settings.darkMode": "Dunkler Modus",
"pad.settings.stickychat": "Chat immer anzeigen",
"pad.settings.chatandusers": "Chat und Benutzer anzeigen",
"pad.settings.colorcheck": "Autorenfarben anzeigen",
"pad.settings.fadeInactiveAuthorColors": "Farben inaktiver Autoren ausblenden",
"pad.settings.linenocheck": "Zeilennummern",
"pad.settings.rtlcheck": "Inhalt von rechts nach links lesen?",
"pad.settings.enforcedNotice": "Diese Einstellungen wurden vom Ersteller des Pads gesperrt. Wenden Sie sich an den Ersteller, wenn Sie diese ändern möchten.",
"pad.settings.fontType": "Schriftart:",
"pad.settings.fontType.normal": "Normal",
"pad.settings.language": "Sprache:",
"pad.settings.deletePad": "Pad löschen",
"pad.delete.confirm": "Möchtest du dieses Pad wirklich löschen?",
"pad.deletionToken.modalTitle": "Speichere deinen Pad-Löschtoken.",
"pad.deletionToken.modalBody": "Dieses Token ist die einzige Möglichkeit, dieses Pad zu löschen, falls Deine Browsersitzung unterbrochen wird oder Du das Gerät wechselst. Speichere es an einem sicheren Ort es wird hier nur einmal angezeigt.",
"pad.deletionToken.deleteWithToken": "Pad mit Token löschen",
"pad.deletionToken.tokenFieldLabel": "Pad-Löschtoken",
"pad.deletionToken.tokenValueLabel": "Dein Pad-Löschtoken (schreibgeschützt)",
"pad.deletionToken.invalid": "Das angegebene Token ist für dieses Pad nicht gültig.",
"pad.deletionToken.notCreator": "Sie sind nicht der Ersteller dieses Pads, daher können Sie es nicht löschen.",
"pad.settings.about": "Über",
"pad.settings.poweredBy": "Betrieben von",
"pad.importExport.import_export": "Import/Export",
@ -123,7 +248,13 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.exportetherpada.title": "Export im Etherpad-Format",
"pad.importExport.exporthtmla.title": "Exportieren als HTML",
"pad.importExport.exportplaina.title": "Export als einfacher Text",
"pad.importExport.exportworda.title": "Exportieren als Microsoft Word",
"pad.importExport.exportpdfa.title": "Als PDF exportieren",
"pad.importExport.exportopena.title": "Export als ODF (Open Document Format)",
"pad.importExport.noConverter.innerHTML": "Du kannst nur aus reinen Text- oder HTML-Formaten importieren. Für umfangreichere Importfunktionen <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">muss AbiWord oder LibreOffice auf dem Server installiert werden</a>.",
"pad.modals.connected": "Verbunden.",
"pad.modals.reconnecting": "Dein Pad wird neu verbunden...",
"pad.modals.forcereconnect": "Erneutes Verbinden erzwingen",
@ -151,9 +282,11 @@
"pad.modals.rateLimited.explanation": "Sie haben zu viele Nachrichten an dieses Pad gesendet, so dass die Verbindung unterbrochen wurde.",
"pad.modals.rejected.explanation": "Der Server hat eine Nachricht abgelehnt, die von deinem Browser gesendet wurde.",
"pad.modals.rejected.cause": "Möglicherweise wurde der Server aktualisiert, während du das Pad angesehen hast, oder es existiert ein Fehler in Etherpad. Versuche, die Seite neu zu laden.",
"pad.modals.disconnected": "Ihre Verbindung wurde getrennt.",
"pad.modals.disconnected": "Deine Verbindung wurde getrennt.",
"pad.modals.disconnected.explanation": "Die Verbindung zum Server wurde unterbrochen.",
"pad.modals.disconnected.cause": "Möglicherweise ist der Server nicht erreichbar. Bitte benachrichtige den Dienstadministrator, falls dies weiterhin passiert.",
"pad.gritter.unacceptedCommit.title": "Nicht gespeicherte Bearbeitung",
"pad.gritter.unacceptedCommit.text": "Deine letzte Änderung wurde noch nicht gespeichert. Stelle die Verbindung wieder her und versuche es erneut.",
"pad.share": "Dieses Pad teilen",
"pad.share.readonly": "Eingeschränkter Nur-Lese-Zugriff",
"pad.share.link": "Verknüpfung",
@ -166,12 +299,29 @@
"timeslider.followContents": "Aktualisierungen des Pad-Inhalts verfolgen",
"timeslider.pageTitle": "{{appTitle}} Bearbeitungsverlauf",
"timeslider.toolbar.returnbutton": "Zurück zum Pad",
"pad.historyMode.banner": "Versionsgeschichte ansehen",
"pad.historyMode.return": "Zurück zum Live-Modus",
"pad.historyMode.revisionLabel": "Revision {{rev}}",
"pad.historyMode.controlsLabel": "Steuerung Pad-Verlauf",
"pad.historyMode.sliderLabel": "Pad-Version",
"pad.historyMode.settings.title": "Wiedergabe des Bearbeitungsverlaufs",
"pad.historyMode.settings.follow": "Aktualisierungen des Pad-Inhalts verfolgen",
"pad.historyMode.settings.followShort": "Folgen",
"pad.historyMode.followOn": "Pad-Änderungen verfolgen klicken Sie hier, um die Verfolgung zu beenden.",
"pad.historyMode.followOff": "Änderungen der Pads werden nicht verfolgt zum Folgen klicken",
"pad.historyMode.settings.playbackSpeed": "Wiedergabegeschwindigkeit:",
"pad.historyMode.chat.replayHeader": "Chat mit Stand von {{time}}",
"pad.historyMode.users.authorsHeader": "Autoren bei dieser Revision",
"pad.editor.keyboardHint": "Drücken Sie die Escape-Taste, um den Editor zu verlassen. Drücken Sie Alt+F9, um die Symbolleiste aufzurufen.",
"pad.editor.toolbar.formatting": "Formatierungsleiste",
"pad.editor.toolbar.showMore": "Weitere Schaltflächen anzeigen",
"timeslider.toolbar.authors": "Autoren:",
"timeslider.toolbar.authorsList": "Keine Autoren",
"timeslider.toolbar.exportlink.title": "Diese Version exportieren",
"timeslider.exportCurrent": "Exportiere diese Version als:",
"timeslider.version": "Version {{version}}",
"timeslider.saved": "Gespeichert am {{day}}. {{month}} {{year}}",
"timeslider.settings.playbackSpeed": "Wiedergabegeschwindigkeit:",
"timeslider.playPause": "Padbearbeitung abspielen/pausieren",
"timeslider.backRevision": "Eine Version in diesem Pad zurückgehen",
"timeslider.forwardRevision": "Eine Version in diesem Pad vorwärtsgehen",
@ -193,6 +343,7 @@
"pad.savedrevs.timeslider": "Du kannst gespeicherte Versionen durch den Aufruf des Bearbeitungsverlaufs ansehen.",
"pad.userlist.entername": "Dein Name?",
"pad.userlist.unnamed": "unbenannt",
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} verbundener Benutzer, other: {{count}} verbundene Benutzer ]}",
"pad.editbar.clearcolors": "Autorenfarben im gesamten Dokument zurücksetzen? Dies kann nicht rückgängig gemacht werden",
"pad.impexp.importbutton": "Jetzt importieren",
"pad.impexp.importing": "Importiere …",
@ -203,5 +354,6 @@
"pad.impexp.importfailed": "Import fehlgeschlagen",
"pad.impexp.copypaste": "Bitte kopieren und einfügen",
"pad.impexp.exportdisabled": "Der Export im {{type}}-Format ist deaktiviert. Für Einzelheiten kontaktiere bitte deinen Systemadministrator.",
"pad.impexp.maxFileSize": "Die Datei ist zu groß. Kontaktiere bitte deinen Administrator, um das Limit für den Dateiimport zu erhöhen."
"pad.impexp.maxFileSize": "Die Datei ist zu groß. Kontaktiere bitte deinen Administrator, um das Limit für den Dateiimport zu erhöhen.",
"pad.social.description": "Ein kollaboratives Dokument, das jeder in Echtzeit bearbeiten kann."
}

View file

@ -314,7 +314,7 @@
"pad.importExport.exportworda.title": "Export as Microsoft Word",
"pad.importExport.exportpdfa.title": "Export as PDF",
"pad.importExport.exportopena.title": "Export as ODF (Open Document Format)",
"pad.importExport.noConverter.innerHTML": "You can only import from plain text or HTML formats. For more advanced import features, please <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">install LibreOffice</a>.",
"pad.importExport.noConverter.innerHTML": "You can import plain text, HTML, Microsoft Word (.docx) and Etherpad files directly. To import other formats such as PDF, ODT, DOC or RTF, the server administrator needs to install LibreOffice — see the <a href=\"https://docs.etherpad.org/\">Etherpad documentation</a>.",
"pad.modals.connected": "Connected.",
"pad.modals.reconnecting": "Reconnecting to your pad…",

View file

@ -23,6 +23,27 @@
]
},
"admin.page-title": "Ylläpitäjän kojelauta - Etherpad",
"admin.loading": "Ladataan…",
"admin.loading_description": "Odota. Sivu latautuu.",
"admin.toggle_sidebar": "Näytä/piilota sivupalkki",
"admin.shout": "Viestintä",
"admin_shout.online_one": "Tällä hetkellä {{count}} käyttäjä on paikalla",
"admin_shout.online_other": "Tällä hetkellä {{count}} käyttäjää on paikalla",
"admin_shout.sticky_toggle": "Muuta pysyvää viestiä",
"admin_login.title": "Etherpad",
"admin_login.username": "Käyttäjänimi",
"admin_login.password": "Salasana",
"admin_login.submit": "Kirjaudu sisään",
"admin_login.failed": "Kirjautuminen epäonnistui",
"admin_pads.all_pads": "Kaikki muistiot",
"admin_pads.bulk.cleanup_history": "Tyhjennä historia",
"admin_pads.bulk.delete": "Poista",
"admin_pads.cancel": "Peru",
"admin_pads.col.pad": "Muistio",
"admin_pads.col.revisions": "Muokkaushistoria",
"admin_pads.col.users": "Käyttäjät",
"admin_pads.confirm_button": "OK",
"admin_pads.create_pad_dialog_description": "Valitse nimi uudelle muistiolle.",
"admin_plugins": "Lisäosien hallinta",
"admin_plugins.available": "Saatavilla olevat liitännäiset",
"admin_plugins.available_not-found": "Lisäosia ei löytynyt.",

View file

@ -7,9 +7,11 @@
"Chpol",
"Cquoi",
"Crochet.david",
"Crowwhailord",
"Derugon",
"Envlh",
"Framafan",
"Framasky",
"Fylip22",
"Gomoko",
"Goofy",
@ -37,13 +39,73 @@
]
},
"admin.page-title": "Tableau de bord administrateur — Etherpad",
"admin.loading": "Chargement en cours…",
"admin.loading_description": "Veuillez patienter pendant le chargement de la page.",
"admin.toggle_sidebar": "Afficher/masquer la barre latérale",
"admin.shout": "Communication",
"admin_shout.online_one": "Il y a actuellement {{count}} utilisateur·ice en ligne",
"admin_shout.online_other": "Il y a actuellement {{count}} utilisateur·ices en ligne",
"admin_shout.sticky_toggle": "Modifier le message épinglé",
"admin_login.title": "Etherpad",
"admin_login.username": "Nom dutilisateur",
"admin_login.password": "Mot de passe",
"admin_login.submit": "Connexion",
"admin_login.failed": "Échec de la connexion",
"admin_pads.all_pads": "Tous les blocs-notes",
"admin_pads.bulk.cleanup_history": "Effacer lhistorique",
"admin_pads.bulk.clear_selection": "Effacer la sélection",
"admin_pads.bulk.delete": "Supprimer",
"admin_pads.cancel": "Annuler",
"admin_pads.col.pad": "Bloc-notes",
"admin_pads.col.revisions": "Révisions",
"admin_pads.col.users": "Utilisateur·ices",
"admin_pads.confirm_button": "OK",
"admin_pads.create_pad_dialog_description": "Choisissez un nom pour le nouveau bloc-notes.",
"admin_pads.delete_pad_dialog_description": "Confirmer ou annuler la suppression du bloc-notes.",
"admin_pads.delete_pad_dialog_title": "Supprimer le bloc-notes",
"admin_pads.empty_never_edited": "vide · jamais modifié",
"admin_pads.error_dialog_description": "Une erreur sest produite.",
"admin_pads.error_prefix": "Erreur",
"admin_pads.filter.active": "Actif",
"admin_pads.filter.all": "Tous",
"admin_pads.filter.empty": "Vide",
"admin_pads.filter.recent": "Cette semaine",
"admin_pads.filter.stale": "Sans modifications (> 1 an)",
"admin_pads.open": "Ouvert",
"admin_pads.pagination.next": "Suivant",
"admin_pads.pagination.previous": "Précédent",
"admin_pads.refresh": "Actualiser",
"admin_pads.relative.days": "il y a {{count}} jours",
"admin_pads.relative.hours": "il y a {{count}} heures",
"admin_pads.relative.just_now": "à linstant",
"admin_pads.show": "Afficher",
"admin_pads.sort.name": "Nom (AZ)",
"admin_pads.sort.revision_number": "Révisions",
"admin_pads.sort.user_count": "Utilisateur·ices",
"admin_pads.stats.across_pads": "sur tous les bloc-notes",
"admin_pads.stats.active_users": "Utilisateur·ices actif·ves",
"admin_pads.stats.empty_pads": "Bloc-notes vides",
"admin_pads.stats.last_activity": "Dernière activité :",
"admin_pads.stats.no_active_users": "Aucun·e utilisateur·ice actif·ve",
"admin_pads.stats.revisions_zero": "0 révisions",
"admin_pads.stats.total": "Nombre total de bloc-notes",
"admin_pads.stats.users_active": "{{count}} actuellement actifs",
"admin_pads.subtitle": "Aperçu de tous les bloc-notes de cette instance Etherpad. Rechercher, nettoyer, ouvrir.",
"admin_plugins": "Gestionnaire de greffons",
"admin_plugins.available": "Greffons disponibles",
"admin_plugins.available_not-found": "Aucun greffon trouvé.",
"admin_plugins.available_fetching": "Récupération en cours...",
"admin_plugins.available_install.value": "Installer",
"admin_plugins.available_search.placeholder": "Rechercher des greffons à installer",
"admin_plugins.check_updates": "Vérifier les mises à jour",
"admin_plugins.catalog_disabled": "Le catalogue de greffons est désactivé par votre administrateur (privacy.pluginCatalog=false). Pour installer un greffon, exécutez `pnpm run plugins i ep_<name> ` sur le serveur.",
"admin_plugins.crumbs": "Greffons",
"admin_plugins.description": "Description",
"admin_plugins.disables.label": "Désactive :",
"admin_plugins.disables.warning_title": "Ce greffon supprime intentionnellement les fonctionnalités Etherpad listées.",
"admin_plugins.error_retrieving": "Erreur lors de la récupération des greffons",
"admin_plugins.install_error": "Échec de l'installation de {{plugin}} : {{error}}",
"admin_plugins.install_error_requires_newer_etherpad": "Impossible d'installer {{plugin}} : une version plus récente d'Etherpad est requise. Veuillez mettre à jour Etherpad et réessayer.",
"admin_plugins.installed": "Greffons installés",
"admin_plugins.installed_fetching": "Récupération des greffons installés en cours...",
"admin_plugins.installed_nothing": "Vous navez encore installé aucun greffon.",
@ -51,8 +113,25 @@
"admin_plugins.last-update": "Dernière mise à jour",
"admin_plugins.name": "Nom",
"admin_plugins.page-title": "Gestionnaire de greffons — Etherpad",
"admin_plugins.reload_catalog": "Recharger le catalogue",
"admin_plugins.search_npm": "Rechercher sur npm",
"admin_plugins.sort_ascending": "Tri croissant",
"admin_plugins.sort_descending": "Tri décroissant",
"admin_plugins.sort.last_updated": "Dernière mise à jour",
"admin_plugins.sort.name": "Nom (AZ)",
"admin_plugins.sort.version": "Version",
"admin_plugins.source": "Source du greffon",
"admin_plugins.subtitle": "Installez, mettez à jour et supprimez les greffons dEtherpad. Toute modification nécessite un redémarrage du serveur.",
"admin_plugins.tag_core": "Cœur",
"admin_plugins.update_tooltip": "Mise à jour",
"admin_plugins.updates_available": "Mises à jour disponibles",
"admin_plugins.update_now": "Mettre à jour",
"admin_plugins.version": "Version",
"admin_plugins_info": "Informations de résolution de problème",
"admin_plugins_info.bindings_label": "{{count}} liaisons",
"admin_plugins_info.copy_diagnostics": "Copie des diagnostics",
"admin_plugins_info.copy_value": "Copier {{label}}",
"admin_plugins_info.git_sha": "Git SHA",
"admin_plugins_info.hooks": "Crochets installés",
"admin_plugins_info.hooks_client": "Crochets côté client",
"admin_plugins_info.hooks_server": "Crochets côté serveur",
@ -156,6 +235,16 @@
"pad.settings.language": "Langue:",
"pad.settings.deletePad": "Supprimer le bloc-notes",
"pad.delete.confirm": "Voulez-vous vraiment supprimer ce bloc-notes ?",
"pad.deletionToken.modalTitle": "Enregistrez votre jeton de suppression de bloc-notes",
"pad.deletionToken.modalBody": "Ce jeton est le seul moyen de supprimer ce bloc-notes si vous perdez votre session de navigateur ou si vous changez d'appareil. Conservez-le en lieu sûr — il n'apparaît ici qu'une seule fois.",
"pad.deletionToken.copy": "Copier",
"pad.deletionToken.copied": "Copié",
"pad.deletionToken.acknowledge": "Je l'ai enregistré",
"pad.deletionToken.deleteWithToken": "Supprimer le bloc-notes avec un jeton",
"pad.deletionToken.tokenFieldLabel": "Jeton de suppression de bloc-notes",
"pad.deletionToken.tokenValueLabel": "Votre jeton de suppression du bloc-notes (lecture seule)",
"pad.deletionToken.invalid": "Ce jeton n'est pas valable pour ce bloc-notes.",
"pad.deletionToken.notCreator": "Vous nêtes pas le créateur du bloc-notes, vous ne pouvez donc pas le supprimer.",
"pad.settings.about": "À propos",
"pad.settings.poweredBy": "Propulsé par",
"pad.importExport.import_export": "Importer/Exporter",
@ -168,7 +257,10 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.noConverter.innerHTML": "Vous pouvez uniquement importer du texte brut ou du HTML. Pour des fonctionnalités d'importation plus avancées, veuillez <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">installer LibreOffice</a>.",
"pad.importExport.exportetherpada.title": "Exporter au format Etherpad",
"pad.importExport.exporthtmla.title": "Exporter au format HTML",
"pad.importExport.exportplaina.title": "Exporter en texte brut",
"pad.importExport.noConverter.innerHTML": "Vous pouvez importer directement du texte brut, du HTML,Microsoft Word (.docx) et des fichiers Etherpad. Pour importer d'autres formats tels que PDF, ODT, DOC ou RTF, l'administrateur du serveur doit installer LibreOffice ; consultez la <a href=\"https://docs.etherpad.org/\">documentation Etherpad</a> .",
"pad.modals.connected": "Connecté.",
"pad.modals.reconnecting": "Reconnexion à votre bloc-notes en cours...",
"pad.modals.forcereconnect": "Forcer la reconnexion",

View file

@ -302,7 +302,7 @@
"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.importExport.noConverter.innerHTML": "È possibile importare direttamente file di testo semplice, HTML, Microsoft Word (.docx) e file Etherpad. Per importare altri formati come PDF, ODT, DOC o RTF, l'amministratore del server deve installare LibreOffice: consulta la <a href=\"https://docs.etherpad.org/\">documentazione di Etherpad</a>.",
"pad.modals.connected": "Connesso.",
"pad.modals.reconnecting": "Riconnessione al pad in corso…",
"pad.modals.forcereconnect": "Forza la riconnessione",

View file

@ -6,6 +6,7 @@
"Chqaz",
"Omotecho",
"Shirayuki",
"Tensama0415",
"Torinky"
]
},
@ -30,7 +31,7 @@
"admin_plugins_info.hooks_client": "クライアント側のフック",
"admin_plugins_info.hooks_server": "サーバー側のフック",
"index.newPad": "新規作成",
"index.createOpenPad": "または作成/編集するパッド名を入力:",
"index.createOpenPad": "編集するパッド名を入力",
"index.openPad": "次の名称の既存の Pad を開く:",
"pad.toolbar.bold.title": "太字 (Ctrl+B)",
"pad.toolbar.italic.title": "斜体 (Ctrl+I)",
@ -39,7 +40,7 @@
"pad.toolbar.ol.title": "番号付きリスト (Ctrl+Shift+N)",
"pad.toolbar.ul.title": "番号なしリスト (Ctrl+Shift+L)",
"pad.toolbar.indent.title": "インデント (Tab)",
"pad.toolbar.unindent.title": "インデント解除 (Shift+Tab)",
"pad.toolbar.unindent.title": "アウトデント (Shift+Tab)",
"pad.toolbar.undo.title": "元に戻す (Ctrl+Z)",
"pad.toolbar.redo.title": "やり直し (Ctrl+Y)",
"pad.toolbar.clearAuthorship.title": "作者の色分けを消去(Ctrl+Shift+C)",
@ -54,7 +55,7 @@
"pad.loading": "読み込み中...",
"pad.noCookie": "Cookie could not be found. Please allow cookies in your browser! Your session and settings will not be saved between visits. \n\nクッキーが見つかりません。ブラウザの設定でクッキーの使用を許可するまで、アクセスの記録や設定は引き継がれません。原因はブラウザによって Etherpad が iFrame に組み込まれたからと考えられます。親ドメインの iFrame と同じドメイン/サブドメインに置かれているかどうか、Etherpad の設定を確認してください。",
"pad.permissionDenied": "あなたにはこのパッドへのアクセス許可がありません",
"pad.settings.padSettings": "パッドの設定",
"pad.settings.padSettings": "パッドの設定",
"pad.settings.myView": "個人設定",
"pad.settings.stickychat": "画面にチャットを常に表示",
"pad.settings.chatandusers": "チャットとユーザーを表示",
@ -77,7 +78,7 @@
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.modals.connected": "接続されました。",
"pad.modals.reconnecting": "パッドに再接続中...",
"pad.modals.reconnecting": "パッドに再接続中",
"pad.modals.forcereconnect": "強制的に再接続",
"pad.modals.reconnecttimer": "再接続を試行中",
"pad.modals.cancel": "中止",

View file

@ -145,14 +145,14 @@
"admin_settings.current_save.value": "설정 저장",
"admin_settings.invalid_json": "잘못된 JSON",
"admin_settings.current_test.value": "JSON 검증",
"admin_settings.current_prettify.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.toast.prettify_failed": "보기 좋게 정리할 수 없습니다. 먼저 구문 오류를 수정하세요.",
"admin_settings.prettify_confirm": "정리하면 모든 의견이 제거됩니다. 계속하시겠습니까?",
"admin_settings.mode.form": "형태",
"admin_settings.mode.raw": "원본",
@ -325,7 +325,7 @@
"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.importExport.noConverter.innerHTML": "일반 텍스트, HTML, 마이크로소프트 워드 (.docx) 또는 이더패드 파일을 직접 가져올 수 있습니다. PDF, ODT, DOC 또는 RTF와 같은 다른 형식을 가져오려면 서버 관리자가 리브레오피스를 설치해야 합니다. 자세한 내용은 <a href=\"https://docs.etherpad.org/\">이더패드 설명서</a>를 참고하세요.",
"pad.modals.connected": "연결함.",
"pad.modals.reconnecting": "내 패드에 다시 연결하는 중...",
"pad.modals.forcereconnect": "강제로 다시 연결",

View file

@ -51,14 +51,19 @@
"admin_plugins.updates_available": "Aktualiséierungen disponibel",
"admin_plugins.update_now": "Aktualiséieren",
"admin_plugins.version": "Versioun",
"admin_plugins_info.tab_server": "Server",
"admin_plugins_info.update_available": "Aktualiséierung disponibel: {{version}}",
"admin_plugins_info.version": "Etherpad-Versioun",
"admin_plugins_info.version_latest": "Lescht disponibel Versioun",
"admin_plugins_info.version_number": "Versiounsnummer",
"admin_settings": "Astellungen",
"admin_settings.current": "Aktuell Konfiguratioun",
"admin_settings.current_save.value": "Astellunge späicheren",
"admin_settings.invalid_json": "Ongültegen JSON",
"admin_settings.toast.validation_ok": "Den JSON ass gülteg.",
"admin_settings.toast.validation_failed": "Den JSON ass ongülteg: Verbessert wgl. d'Syntaxfeeler.",
"admin_settings.toast.auth_error": "Dir sidd net als Admin authentifizéiert. Loggt Iech wgl. nei an.",
"admin_settings.env_pill.default_label": "Standard",
"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",
@ -68,7 +73,9 @@
"index.newPad": "Neie Pad",
"index.settings": "Astellungen",
"index.copyLink": "2. Link kopéieren",
"index.copyLinkDescription": "Klickt op de Knäppchen ënnen, fir de Link an Ären Tëschespäicher ze kopéieren.",
"index.copyLinkButton": "Link an den Tëschespäicher kopéieren",
"index.code": "Code",
"index.createOpenPad": "oder maacht ee Pad mat dësem Numm op:",
"pad.toolbar.bold.title": "Fett (Strg-B)",
"pad.toolbar.italic.title": "Schréi (Ctrl+I)",

View file

@ -187,6 +187,39 @@
"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 (автономно) бара период на одржување. Задајте го updates.maintenanceWindow во settings.json за да овозможите автономни поднови.",
"update.page.policy.maintenance-window-invalid": "Ниво 4 (автономно) е оневозможено бидејќи updates.maintenanceWindow е погрешно срочен. Се очекуваше {start, end, tz} со времиња ЧЧ:ММ и tz да биде „local“ или „utc“.",
"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": "Etherpad ќе почне со подновување на {{tag}} за {{remaining}}.",
"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": "Etherpad ќе се превклучи за 60 секунди за да направи примена и поднова.",
"update.drain.t30": "Etherpad ќе се превклучи за 30 секунди за да направи примена и поднова.",
"update.drain.t10": "Etherpad ќе се превклучи за 10 секунди за да направи примена и поднова.",
"index.newPad": "Нова тетратка",
"index.settings": "Нагодувања",
"index.transferSessionTitle": "Префрли седница",
@ -199,6 +232,7 @@
"index.copyLinkButton": "Копирај врска во меѓускладот",
"index.transferToSystem": "3. Копирај седница во нов систем",
"index.transferToSystemDescription": "Отворете ја ископираната врска во целниот прелистувач или уред за да ја префрлите вашата седница.",
"index.code": "Код",
"index.transferSessionDescription": "Префрлете ја вашата тековна седница на прелистувач или уред стискајќи на копчето подолу. Ова ќе ја прекопира врската во страница која ќе ви ја префрли седницата кога ќе се отвори во целниот прелистувач или уред.",
"index.createOpenPad": "Отвори тетратка по име",
"index.openPad": "отвори постоечка тетратка наречена:",
@ -273,6 +307,12 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.exportetherpada.title": "Извези како Etherpad",
"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\">го воспоставите LibreOffice</a>.",
"pad.modals.connected": "Поврзано.",
"pad.modals.reconnecting": "Ве преповрзувам со тетратката...",
@ -318,13 +358,31 @@
"timeslider.followContents": "Следи ги подновите во содржината на тетратката",
"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": "Стиснете Escape за да излезете од уредувачот. Стиснете Alt+F9 за алатникот.",
"pad.editor.toolbar.formatting": "Алатник за форматирање",
"pad.editor.toolbar.actions": "Алатник за дејства врз тетратка",
"pad.editor.toolbar.showMore": "Покажи повеќе копчиња на алатникот",
"timeslider.toolbar.authors": "Автори:",
"timeslider.toolbar.authorsList": "Нема автори",
"timeslider.toolbar.exportlink.title": "Извоз",
"timeslider.exportCurrent": "Извези ја тековната верзија како:",
"timeslider.version": "Верзија {{version}}",
"timeslider.saved": "Зачувано на {{day}} {{month}} {{year}} г.",
"timeslider.settings.playbackSpeed": "Брзина на репродукција:",
"timeslider.settings.playbackSpeed": "Брзина на прегледување:",
"timeslider.settings.playbackSpeed.original": "Изворна брзина",
"timeslider.settings.playbackSpeed.realtime": "Реално време",
"timeslider.settings.playbackSpeed.200ms": "200 мс",
@ -351,6 +409,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": "Увезувам...",

View file

@ -28,17 +28,17 @@
"pad.modals.cancel": "Xikxolewa",
"pad.modals.deleted": "Omopohpoloh.",
"pad.modals.deleted.explanation": "Ōmopoloh inīn Pad.",
"timeslider.version": "Inīc {{version}} Cuepaliztli",
"timeslider.month.january": "Eneroh",
"timeslider.month.february": "Febreroh",
"timeslider.month.march": "Marsoh",
"timeslider.month.april": "April",
"timeslider.month.may": "Mayoh",
"timeslider.month.june": "Honioh",
"timeslider.month.july": "Holioh",
"timeslider.month.august": "Ahostoh",
"timeslider.month.september": "Septiempreh",
"timeslider.month.october": "Oktopreh",
"timeslider.month.november": "Noviempreh",
"timeslider.month.december": "Tisiempreh"
"timeslider.version": "Inic {{version}} Tlacepaliztli",
"timeslider.month.january": "Enero",
"timeslider.month.february": "Febrero",
"timeslider.month.march": "Marzo",
"timeslider.month.april": "Abril",
"timeslider.month.may": "Mayo",
"timeslider.month.june": "Junio",
"timeslider.month.july": "Julio",
"timeslider.month.august": "Agosto",
"timeslider.month.september": "Septiembre",
"timeslider.month.october": "Octubre",
"timeslider.month.november": "Noviembre",
"timeslider.month.december": "Diciembre"
}

View file

@ -11,12 +11,243 @@
"हिमाल सुबेदी"
]
},
"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": "स्थिर (>1y)",
"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}}mo पहिले",
"admin_pads.relative.weeks": "{{count}}w अगाडि",
"admin_pads.relative.years": "{{county}} पहिले",
"admin_pads.revisions_count": "{{count}} संशोधनहरू",
"admin_pads.selected_count": "{{count}} चयन गरियो",
"admin_pads.show": "देखाउनुहोस्",
"admin_pads.sort.name": "नाम (AZ)",
"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": "0 पुनरावलोकन",
"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": "तपाईँले अहिलेसम्म कुनै पनि प्लगइन स्थापना गर्नुभएको छैन ।",
"admin_plugins.installed_uninstall.value": "स्थापना रद्द गर्नुहोस्",
"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": "नाम (AZ)",
"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": "Git 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} जस्ता प्लेसहोल्डरहरू सुरुआतमा स्मृतिमा प्रतिस्थापन गरिन्छ; तिनीहरू कहिले पनि यो फाइलमा लेखिँदैन । समाधान गरिएको मान परिवर्तन गर्न तपाईँको वातावरणमा env vars सम्पादन गर्नुहोस् (Docker compose, systemd, .env) वा यहाँको प्लेसहोल्डरलाई शाब्दिकसँग बदल्नुहोस् । इथरप्याडले अहिले के प्रयोग गरिरहेको छ हेर्न प्रभावकारी ट्याबमा स्विच गर्नुहोस् ।",
"admin_settings.toast.auth_error": "तपाईं प्रशासकको रूपमा प्रमाणित हुनुहुन्न । कृपया पुनः लगइन गर्नुहोस् ।",
"admin_settings.section.general": "सामान्य",
"admin_settings.parse_error.title": "सेटिङ्स.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": "अद्यावधिक लग (अन्तिम २०० पङ्क्तिहरू)",
"update.page.execution": "वस्तुस्थिति",
"update.page.policy.install-method-not-writable": "प्रबन्धक यूआईबाट अद्यावधिक गर्न गिट स्थापना आवश्यक पर्दछ । तपाईँको प्याकेज प्रबन्धक मार्फत अद्यावधिक गर्नुहोस् ।",
"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": "टियर ४ (स्वतन्त्र) लाई मर्मत सञ्झ्याल चाहिन्छ । स्वायत्त अद्यावधिक सक्षम पार्न settings.json मा अद्यावधिकहरू.मर्मत सञ्झ्याल सेट गर्नुहोस् ।",
"update.page.policy.maintenance-window-invalid": "टियर ४ (स्वतन्त्र) अक्षम पारिएको छ किनभने अद्यावधिकहरू.मर्मत सञ्झ्याल विकृत छ । अपेक्षित {start, end, tz} HH:MM पटक र \"local\" वा \"utc\" को 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": "इथरप्याडले {{tag}} लाई {{remaining}} मा अद्यावधिक गर्न सुरु गर्नेछ।",
"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": "अद्यावधिक लागू गर्न ईथरप्याड ६० सेकेन्डमा पुनःसुरु हुनेछ ।",
"update.drain.t30": "इथरप्याड अद्यावधिक लागू गर्न ३० सेकेन्डमा पुनः सुरु हुनेछ ।",
"update.drain.t10": "अद्यावधिक लागू गर्न 10 सेकेन्डमा इथरप्याड पुनः सुरु हुनेछ ।",
"index.newPad": "नयाँ प्याड",
"index.settings": "अभिरुचिहरू",
"index.transferSessionTitle": "सत्र स्थानान्तरण गर्नुहोस्",
"index.receiveSessionTitle": "सत्र प्राप्त गर्नुहोस्",
"index.receiveSessionDescription": "यहाँ तपाईँले अर्को ब्राउजर वा यन्त्रबाट इथरप्याड सत्र प्राप्त गर्न सक्नुहुन्छ । यद्यपि, यदि कुनै भएमा, यसले तपाईँको हालको सत्र मेट्नेछ भन्ने कृपया याद राख्नुहोस् ।",
"index.transferSession": "1. सत्र स्थानान्तरण गर्नुहोस्",
"index.transferSessionNow": "अहिले सत्र स्थानान्तरण गर्नुहोस्",
"index.copyLink": "२. लिङ्क प्रतिलिपि गर्नुहोस्",
"index.copyLinkDescription": "तपाईँको क्लिपबोर्डमा लिङ्क प्रतिलिपि गर्न तलको बटनमा क्लिक गर्नुहोस् ।",
"index.copyLinkButton": "क्लिपबोर्डमा लिङ्क प्रतिलिपि गर्नुहोस्",
"index.transferToSystem": "३. नयाँ प्रणालीमा सत्र प्रतिलिपि गर्नुहोस्",
"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)",
@ -28,25 +259,47 @@
"pad.toolbar.undo.title": "रद्द (Ctrl-Z)",
"pad.toolbar.redo.title": "पुन:लागु (Ctrl-Y)",
"pad.toolbar.clearAuthorship.title": "लेखकत्व रङहरू खाली गर्नुहोस् (Ctrl + Shift + C)",
"pad.toolbar.import_export.title": "फरक फाइल ढाँचाबाट/मा आयात/निर्यात गर्नुहोस्",
"pad.toolbar.timeslider.title": "टाइमस्लाइडर",
"pad.toolbar.savedRevision.title": "पुनरावलोकन संग्रहगर्ने",
"pad.toolbar.settings.title": "अभिरुचिहरू",
"pad.toolbar.embed.title": "यस प्याडलाई बाड्ने या इम्बेड गर्ने",
"pad.toolbar.home.title": "घर फर्कनुहोस्",
"pad.toolbar.showusers.title": "यस प्याडमा रहेका प्रयोगकर्ता देखाउने",
"pad.colorpicker.save": "सङ्ग्रह गर्नुहोस्",
"pad.colorpicker.cancel": "रद्द गर्नुहोस्",
"pad.loading": "खुल्दै छ…",
"pad.noCookie": "कुकी फेला पार्न सकिएन । कृपया तपाईँको ब्राउजरमा कुकीहरू अनुमति दिनुहोस् ! तपाईँको सत्र र सेटिङ भ्रमणको बीचमा बचत गरिने छैन । यो केही ब्राउजरहरूमा आइफ्रेममा ईथरप्याड समावेश भएको कारणले हुन सक्छ । कृपया ईथरप्याड प्रमूल आईफ्रेमको रूपमा उही उपडोमेन/डोमेनमा छ भनी सुनिश्चित गर्नुहोस्",
"pad.permissionDenied": "तपाईंलाई यो प्याड खोल्न अनुमति छैन",
"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": "लेखकका रङहरू",
"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": "आयात/निर्यात",
@ -59,18 +312,45 @@
"pad.importExport.exportword": "माइक्रोसफ्ट वर्ड",
"pad.importExport.exportpdf": "पिडिएफ",
"pad.importExport.exportopen": "ओडिएफ(खुल्ला कागजात ढाँचा)",
"pad.importExport.exportetherpada.title": "इथरप्याडको रूपमा निर्यात गर्नुहोस्",
"pad.importExport.exporthtmla.title": "HTML को रूपमा निर्यात गर्नुहोस्",
"pad.importExport.exportplaina.title": "सादा पाठको रूपमा निर्यात गर्नुहोस्",
"pad.importExport.exportworda.title": "माइक्रोसफ्ट वर्डको रूपमा निर्यात गर्नुहोस्",
"pad.importExport.exportpdfa.title": "PDF को रूपमा निर्यात गर्नुहोस्",
"pad.importExport.exportopena.title": "ODF (खुला कागजात ढाँचा) को रूपमा निर्यात गर्नुहोस्",
"pad.importExport.noConverter.innerHTML": "तपाईँले सादा पाठ, HTML, Microsoft Word (.docx) र Etherpad फाइलहरू सिधै आयात गर्न सक्नुहुन्छ । PDF, ODT, DOC वा RTF जस्ता अन्य ढाँचाहरू आयात गर्न, सर्भर प्रशासकले LibreOffice स्थापना गर्न आवश्यक छ - <a href=\"https://docs.etherpad.org/\">Etherpad मिसिलीकरण</a> हेर्नुहोस् ।",
"pad.modals.connected": "जोडीएको।",
"pad.modals.reconnecting": "तपाईंको प्याडमा पुन: जडान गर्दै",
"pad.modals.forcereconnect": "जडानको लागि जोडगर्ने",
"pad.modals.reconnecttimer": "पुन: जडान गर्न प्रयास गर्दै",
"pad.modals.cancel": "रद्द गर्नुहोस्",
"pad.modals.userdup": "अर्को सन्झ्यालमा खोल्ने",
"pad.modals.userdup.explanation": "यो प्याड यस कम्प्युटरमा एक भन्दा बढी ब्राउजर सञ्झ्यालमा खोलिएको देखिन्छ ।",
"pad.modals.userdup.advice": "सट्टामा यो विन्डो प्रयोग गर्न पुन: जडान गर्नुहोस् ।",
"pad.modals.unauth": "अनुमती नदिइएको",
"pad.modals.unauth.explanation": "यो पृष्ठ हेर्दा तपाईँको अनुमति परिवर्तन भएको छ। पुन: जडान गर्ने प्रयास गर्नुहोस्।",
"pad.modals.looping.explanation": "त्यहाँ समक्रमण सर्भरसँग सञ्चार समस्याहरू छन्।",
"pad.modals.looping.cause": "सायद तपाईँले अमिल्दो फायरवाल वा प्रोक्सी मार्फत जडान गर्नुभयो ।",
"pad.modals.initsocketfail": "सर्भरमा पहुँच पुर्‍याउन सकिएन ।",
"pad.modals.initsocketfail.explanation": "समक्रमण सर्भरमा जडान गर्न सकेन ।",
"pad.modals.initsocketfail.cause": "यो सम्भवतः तपाईँको ब्राउजर वा इन्टरनेट जडानमा समस्या भएको कारणले हो।",
"pad.modals.slowcommit.explanation": "सर्भरसँग सम्पर्क हुने सकेन ।",
"pad.modals.slowcommit.cause": "यो नेटवर्क जडानमा समस्याको कारणले हुन सक्छ।",
"pad.modals.badChangeset.explanation": "तपाईँले बनाउनु भएको सम्पादन समक्रमण सर्भरद्वारा अवैध वर्गीकरण गरिएको थियो ।",
"pad.modals.badChangeset.cause": "यो गलत सर्भर कन्फिगरेसन वा केही अन्य अनपेक्षित व्यवहारको कारणले हुन सक्छ। यदि तपाईँलाई यो त्रुटि हो जस्तो लाग्छ भने, कृपया सेवा प्रशासकलाई सम्पर्क गर्नुहोस्। सम्पादन जारी राख्न पुन: जडान प्रयास गर्नुहोस्।",
"pad.modals.corruptPad.explanation": "तपाईँले पहुँच गर्न खोज्नु भएको प्याड बिग्रिएको छ।",
"pad.modals.corruptPad.cause": "यो गलत सर्भर कन्फिगरेसन वा केही अन्य अनपेक्षित व्यवहारको कारणले हुन सक्छ। कृपया सेवा प्रशासकलाई सम्पर्क गर्नुहोस्।",
"pad.modals.deleted": "मेटिएको ।",
"pad.modals.deleted.explanation": "यो प्याड हटाइसकेको छ ।",
"pad.modals.rateLimited": "दर सिमित ।",
"pad.modals.rateLimited.explanation": "तपाईँले यो प्याडमा धेरै सन्देशहरू पठाउनुभयो त्यसैले यसले तपाईँलाई विच्छेदन गर्यो।",
"pad.modals.rejected.explanation": "सर्भरले तपाईँको ब्राउजरद्वारा पठाइएको सन्देश अस्वीकार गर्यो ।",
"pad.modals.rejected.cause": "तपाईँले प्याड हेरिरहनुभएको बेलामा सर्भर अद्यावधिक भएको हुन सक्छ, वा त्यहाँ इथरप्याडमा बग हुन सक्छ । पृष्ठ पुन: लोड गर्ने प्रयास गर्नुहोस् ।",
"pad.modals.disconnected": "तपाईंको जडान अवरुद्ध भयो ।",
"pad.modals.disconnected.explanation": "तपाईंको सर्भरसँगको जडान अवरुद्ध भयो",
"pad.modals.disconnected.cause": "सर्भर अनुपलब्ध हुन सक्छ । यदि यो हुन जारी छ भने कृपया सेवा प्रशासकलाई सूचित गर्नुहोस् ।",
"pad.gritter.unacceptedCommit.title": "बचत नगरिएको सम्पादन",
"pad.gritter.unacceptedCommit.text": "तपाईँको हालको सम्पादन अझै पनि बचत गरिएको छैन । पुन: जडान गर्नुहोस् र फेरि प्रयास गर्नुहोस् ।",
"pad.share": "यस प्यडलाई बाड्ने",
"pad.share.readonly": "पढ्ने मात्र",
"pad.share.link": "कडी",
@ -78,14 +358,41 @@
"pad.chat": "कुराकानी",
"pad.chat.title": "यस प्याडको लागि कुराकानी खोल्ने",
"pad.chat.loadmessages": "थप सन्देशहरू खोल्ने",
"pad.chat.stick.title": "स्क्रिनमा कुराकानी टाँस्नुहोस्",
"pad.chat.writeMessage.placeholder": "तपाईँको सन्देश यहाँ लेख्नुहोस्",
"timeslider.followContents": "प्याड सामग्री अद्यावधिकहरू पछ्याउनुहोस्",
"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": "सम्पादकबाट बाहिरिनका लागि Escape थिच्नुहोस् । उपकरणपट्टी पहुँच गर्न Alt+F9 थिच्नुहोस् ।",
"pad.editor.toolbar.formatting": "उपकरणपट्टी ढाँचा गर्दा",
"pad.editor.toolbar.actions": "प्याड कार्य उपकरणपट्टी",
"pad.editor.toolbar.showMore": "थप उपकरणपट्टी बटनहरू देखाउनुहोस्",
"timeslider.toolbar.authors": "लेखकहरु:",
"timeslider.toolbar.authorsList": "कुनै पनि लेखकहरू छैनन्",
"timeslider.toolbar.exportlink.title": "निर्यात",
"timeslider.exportCurrent": "हालको संस्करण निम्म रुपमा निर्यात गर्ने :",
"timeslider.version": "संस्करण {{version}}",
"timeslider.saved": "सङ्ग्रह गरिएको {{month}} {{day}}, {{year}}",
"timeslider.settings.playbackSpeed": "प्लेब्याक गति:",
"timeslider.settings.playbackSpeed.original": "मौलिक गति",
"timeslider.settings.playbackSpeed.realtime": "वास्तविक समय",
"timeslider.settings.playbackSpeed.200ms": "२०० ms",
"timeslider.settings.playbackSpeed.500ms": "५०० ms",
"timeslider.settings.playbackSpeed.1000ms": "१००० ms",
"timeslider.playPause": "प्याडको सामग्रीहरूलाई चालु / बन्द गर्नुहोस्",
"timeslider.backRevision": "यो प्याडको एक संस्करण पहिले जानुहोस्",
"timeslider.forwardRevision": "यो प्याडको एक संस्करण पछि जानुहोस्",
@ -104,11 +411,20 @@
"timeslider.month.december": "डिसेम्बर",
"timeslider.unnamedauthors": "{{num}} unnamed {[plural(num) one: author, other: authors ]}",
"pad.savedrevs.marked": "यस संस्करणलाई संग्रहितको रुपमा चिनो लगाइएको छैन",
"pad.savedrevs.timeslider": "समय स्लाइडरमा गएर तपाईँले बचत गरिएका संशोधनहरू हेर्न सक्नुहुन्छ",
"pad.userlist.entername": "तपाईंको नाम लेख्नुहोस्",
"pad.userlist.unnamed": "नाम नखुलाइएको",
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} connected user, other: {{count}} connected users ]}",
"pad.editbar.clearcolors": "सम्पूर्ण कागजातमा लेखकता रङ खाली गर्नुहोस्? यो पूर्वस्थितिमा फर्काउन सकिँदैन",
"pad.impexp.importbutton": "अहिले आयात गर्ने",
"pad.impexp.importing": "आयात गर्ने...",
"pad.impexp.confirmimport": "फाइल आयात गर्दा प्याडको हालको पाठ अधिलेखन हुनेछ । तपाईँ अगाडि बढ्न निश्चित हुनुहुन्छ ?",
"pad.impexp.convertFailed": "हामी यो फाइल आयात गर्न असक्षम भयौँ । कृपया फरक कागजात ढाँचा प्रयोग गर्नुहोस् वा म्यानुअली टाँस्नुहोस्",
"pad.impexp.padHasData": "यो प्याडमा पहिले नै परिवर्तन भएकाले हामी यो फाइल आयात गर्न असक्षम भयौँ, कृपया नयाँ प्याडमा आयात गर्नुहोस्",
"pad.impexp.uploadFailed": "अपलोड असफल भयो , कृपया पुन: प्रयास गर्नुहोस् ।",
"pad.impexp.importfailed": "आयात असफल भयो",
"pad.impexp.copypaste": "कृपया कपी पेस्ट गर्नुहोस"
"pad.impexp.copypaste": "कृपया कपी पेस्ट गर्नुहोस",
"pad.impexp.exportdisabled": "{{type}} ढाँचाको रूपमा निर्यात गर्न अक्षम पारिएको छ। कृपया विस्तृत जानकारीको लागि तपाईँको प्रणाली प्रशासकलाई सम्पर्क गर्नुहोस्।",
"pad.impexp.maxFileSize": "फाइल अति ठूलो छ। आयातका लागि अनुमति प्राप्त फाइल साइज बढाउन आफ्नो साइट प्रशासकलाई सम्पर्क गर्नुहोस्",
"pad.social.description": "सबैजनाले वास्तविक समयमा सम्पादन गर्न सक्ने सहयोगी कागजात ।"
}

View file

@ -317,13 +317,13 @@
"pad.importExport.exporthtml": "HTML",
"pad.importExport.exportplain": "Tekst zonder opmaak",
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportpdf": "pdf",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.exportetherpada.title": "Exporteren als Etherpad",
"pad.importExport.exporthtmla.title": "Exporteren als HTML",
"pad.importExport.exportplaina.title": "Exporteren als platte tekst",
"pad.importExport.exportworda.title": "Exporteren als Microsoft Word",
"pad.importExport.exportpdfa.title": "Exporteren als PDF",
"pad.importExport.exportpdfa.title": "Exporteren als pdf",
"pad.importExport.exportopena.title": "Exporteren als ODF (Open Document Format)",
"pad.importExport.noConverter.innerHTML": "U kunt alleen importeren vanuit platte tekst of HTML-bestanden. Voor meer geavanceerde importfuncties kunt u <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">LibreOffice installeren</a>.",
"pad.modals.connected": "Verbonden.",

View file

@ -7,17 +7,85 @@
"Mark",
"Rudko",
"Teslaton",
"Wizzard",
"Yardom78"
]
},
"admin.page-title": "Ovládací panel správu - Etherpad",
"admin.loading": "Načítava sa…",
"admin.loading_description": "Počkajte, prosím, kým sa stránka načíta.",
"admin.toggle_sidebar": "Prepnúť bočný panel",
"admin.shout": "Komunikácia",
"admin_shout.online_one": "Momentálne je online {{count}} používateľ",
"admin_shout.online_other": "Momentálne je online {{count}} používateľov",
"admin_shout.sticky_toggle": "Zmeniť pripnutú správu",
"admin_login.title": "Etherpad",
"admin_login.username": "Používateľské meno",
"admin_login.password": "Heslo",
"admin_login.submit": "Prihlásiť sa",
"admin_login.failed": "Prihlásenie zlyhalo",
"admin_pads.all_pads": "Všetky pady",
"admin_pads.bulk.cleanup_history": "Vyčistiť históriu",
"admin_pads.bulk.clear_selection": "Zrušiť výber",
"admin_pads.bulk.delete": "Zmazať",
"admin_pads.cancel": "Zrušiť",
"admin_pads.col.pad": "Pad",
"admin_pads.col.revisions": "Revízie",
"admin_pads.col.users": "Používatelia",
"admin_pads.confirm_button": "OK",
"admin_pads.create_pad_dialog_description": "Zvoľte názov nového padu.",
"admin_pads.delete_pad_dialog_description": "Potvrďte alebo zrušte zmazanie padu.",
"admin_pads.delete_pad_dialog_title": "Zmazať pad",
"admin_pads.empty_never_edited": "prázdny · nikdy neupravovaný",
"admin_pads.error_dialog_description": "Vyskytla sa chyba.",
"admin_pads.error_prefix": "Chyba",
"admin_pads.filter.active": "Aktívne",
"admin_pads.filter.all": "Všetky",
"admin_pads.filter.empty": "Prázdne",
"admin_pads.filter.recent": "Tento týždeň",
"admin_pads.filter.stale": "Neaktívne (>1 r.)",
"admin_pads.open": "Otvoriť",
"admin_pads.pagination.next": "Ďalej",
"admin_pads.pagination.previous": "Späť",
"admin_pads.refresh": "Obnoviť",
"admin_pads.relative.days": "pred {{count}} d",
"admin_pads.relative.hours": "pred {{count}} h",
"admin_pads.relative.just_now": "práve teraz",
"admin_pads.relative.minutes": "pred {{count}} min",
"admin_pads.relative.months": "pred {{count}} mes.",
"admin_pads.relative.weeks": "pred {{count}} týž.",
"admin_pads.relative.years": "pred {{count}} r.",
"admin_pads.revisions_count": "{{count}} revízií",
"admin_pads.selected_count": "Vybraté: {{count}}",
"admin_pads.show": "Zobraziť",
"admin_pads.sort.name": "Názov (A Z)",
"admin_pads.sort.revision_number": "Revízie",
"admin_pads.sort.user_count": "Používatelia",
"admin_pads.stats.across_pads": "vo všetkých padoch",
"admin_pads.stats.active_users": "Aktívni používatelia",
"admin_pads.stats.empty_pads": "Prázdne pady",
"admin_pads.stats.last_activity": "Posledná aktivita",
"admin_pads.stats.no_active_users": "Žiadni aktívni používatelia",
"admin_pads.stats.revisions_zero": "0 revízií",
"admin_pads.stats.total": "Pady spolu",
"admin_pads.stats.users_active": "Momentálne aktívnych: {{count}}",
"admin_pads.subtitle": "Prehľad všetkých padov na tejto inštancii Etherpadu. Vyhľadávajte, čistite, otvárajte.",
"admin_plugins": "Správca doplnkov",
"admin_plugins.available": "Dostupné doplnky",
"admin_plugins.available_not-found": "Doplnky neboli nájdené.",
"admin_plugins.available_fetching": "Načítavanie...",
"admin_plugins.available_install.value": "Inštalovať",
"admin_plugins.available_search.placeholder": "Vyhľadať doplnky na inštaláciu",
"admin_plugins.check_updates": "Skontrolovať aktualizácie",
"admin_plugins.core_count": "{{count}} základných",
"admin_plugins.catalog_disabled": "Katalóg zásuvných modulov je vypnutý vaším prevádzkovateľom (privacy.pluginCatalog=false). Zásuvný modul nainštalujete spustením `pnpm run plugins i ep_<name>` na serveri.",
"admin_plugins.crumbs": "Zásuvné moduly",
"admin_plugins.description": "Popis",
"admin_plugins.disables.label": "Vypína:",
"admin_plugins.disables.warning_title": "Tento zásuvný modul zámerne odstraňuje uvedené funkcie Etherpadu.",
"admin_plugins.error_retrieving": "Chyba pri získavaní zásuvných modulov",
"admin_plugins.install_error": "Nepodarilo sa nainštalovať {{plugin}}: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad": "Nemožno nainštalovať {{plugin}}: vyžaduje novšiu verziu Etherpadu. Aktualizujte Etherpad a skúste to znova.",
"admin_plugins.installed": "Nainštalované doplnky",
"admin_plugins.installed_fetching": "Načítavanie nainštalovaných doplnkov...",
"admin_plugins.installed_nothing": "Ešte ste nenainštalovali žiadne doplnky.",
@ -25,27 +93,161 @@
"admin_plugins.last-update": "Posledná aktualizácia",
"admin_plugins.name": "Názov",
"admin_plugins.page-title": "Správca doplnkov - Etherpad",
"admin_plugins.reload_catalog": "Znovu načítať katalóg",
"admin_plugins.search_npm": "Hľadať na npm",
"admin_plugins.sort_ascending": "Zoradiť vzostupne",
"admin_plugins.sort_descending": "Zoradiť zostupne",
"admin_plugins.sort.last_updated": "Naposledy aktualizované",
"admin_plugins.sort.name": "Názov (A Z)",
"admin_plugins.sort.version": "Verzia",
"admin_plugins.source": "Zdroj zásuvného modulu",
"admin_plugins.subtitle": "Inštalujte, aktualizujte a odstraňujte zásuvné moduly Etherpadu. Zmeny si vyžadujú reštart servera.",
"admin_plugins.tag_core": "Základné",
"admin_plugins.update_tooltip": "Aktualizovať",
"admin_plugins.updates_available": "Dostupné aktualizácie",
"admin_plugins.update_now": "Aktualizovať",
"admin_plugins.version": "Verzia",
"admin_plugins_info": "Informácie k riešeniu problémov",
"admin_plugins_info.bindings_label": "{{count}} väzieb",
"admin_plugins_info.copy_diagnostics": "Kopírovať diagnostiku",
"admin_plugins_info.copy_value": "Kopírovať {{label}}",
"admin_plugins_info.git_sha": "Git SHA",
"admin_plugins_info.hook_bindings": "Väzby hookov",
"admin_plugins_info.hooks": "Nainštalované súčasti",
"admin_plugins_info.hooks_client": "Súčasti na strane klienta",
"admin_plugins_info.hooks_server": "Súčasti na strane servera",
"admin_plugins_info.no_hooks": "Nenašli sa žiadne hooky",
"admin_plugins_info.parts": "Nainštalované súčasti",
"admin_plugins_info.plugins": "Nainštalované doplnky",
"admin_plugins_info.page-title": "Informácie o doplnkoch - Etherpad",
"admin_plugins_info.search_placeholder": "Hľadať hook alebo časť…",
"admin_plugins_info.subtitle": "Diagnostika systému: nainštalovaná verzia, registrované časti a hooky.",
"admin_plugins_info.tab_client": "Klient",
"admin_plugins_info.tab_server": "Server",
"admin_plugins_info.up_to_date": "Aktuálne",
"admin_plugins_info.update_available": "Dostupná aktualizácia: {{version}}",
"admin_plugins_info.version": "Verzia Etherpadu",
"admin_plugins_info.version_latest": "Posledná dostupná verzia",
"admin_plugins_info.version_number": "Číslo verzie",
"admin_settings": "Nastavenia",
"admin_settings.create_pad": "Vytvoriť pad",
"admin_settings.current": "Aktuálne nastavenia",
"admin_settings.current_example-devel": "Príklad šablóny vývojárskeho nastavenia",
"admin_settings.current_example-prod": "Príklad šablóny výrobného nastavenia",
"admin_settings.current_restart.value": "Reštartovať Etherpad",
"admin_settings.current_save.value": "Uložiť nastavenia",
"admin_settings.invalid_json": "Neplatný JSON",
"admin_settings.current_test.value": "Overiť JSON",
"admin_settings.current_prettify.value": "Sformátovať JSON",
"admin_settings.toast.saved": "Nastavenia boli úspešne uložené.",
"admin_settings.toast.save_failed": "Uloženie zlyhalo: súbor settings.json sa nepodarilo zapísať.",
"admin_settings.toast.json_invalid": "Syntaktická chyba: skontrolujte čiarky, zátvorky a úvodzovky.",
"admin_settings.toast.disconnected": "Nemožno uložiť: bez pripojenia k serveru.",
"admin_settings.toast.validation_ok": "JSON je platný.",
"admin_settings.toast.validation_failed": "JSON je neplatný: opravte syntaktické chyby.",
"admin_settings.toast.prettify_failed": "Nemožno sformátovať: najprv opravte syntaktické chyby.",
"admin_settings.prettify_confirm": "Formátovaním sa odstránia všetky komentáre. Pokračovať?",
"admin_settings.mode.form": "Formulár",
"admin_settings.mode.raw": "Surový",
"admin_settings.mode.effective": "Efektívny",
"admin_settings.mode.effective_tooltip": "Zobrazenie hodnôt, ktoré Etherpad práve teraz skutočne používa (po dosadení premenných prostredia), iba na čítanie. Tajné hodnoty sú skryté.",
"admin_settings.mode.aria_label": "Režim editora",
"admin_settings.envvar_banner.title": "Tento súbor je šablóna, nie živá konfigurácia.",
"admin_settings.envvar_banner.body": "Zástupné výrazy ako ${VAR:default} sa pri štarte dosadia do pamäte; nikdy sa nezapisujú späť do tohto súboru. Ak chcete zmeniť výslednú hodnotu, upravte premenné prostredia vo svojom prostredí (Docker compose, systemd, .env), alebo tu nahraďte zástupný výraz konkrétnou hodnotou. Prepnutím na kartu Efektívny zistíte, čo Etherpad práve používa.",
"admin_settings.toast.auth_error": "Nie ste overený ako správca. Prihláste sa, prosím, znova.",
"admin_settings.section.general": "Všeobecné",
"admin_settings.parse_error.title": "Nemožno spracovať settings.json",
"admin_settings.parse_error.cta": "Prepnite na surový režim na úpravu",
"admin_settings.env_pill.tooltip": "Číta sa z premennej prostredia {{variable}}. Hodnota nižšie sa použije, keď {{variable}} nie je nastavená.",
"admin_settings.env_pill.default_label": "predvolené",
"admin_settings.env_pill.input_aria": "Predvolená hodnota pre {{variable}}",
"admin_settings.env_pill.runtime_label": "aktívna hodnota",
"admin_settings.env_pill.runtime_tooltip": "Etherpad práve používa túto hodnotu, určenú z {{variable}} alebo jej predvolenej hodnoty.",
"admin_settings.env_pill.redacted_tooltip": "Etherpad používa hodnotu pre {{variable}}, je však skrytá, pretože ide o tajnú hodnotu.",
"admin_settings.page-title": "Nastavenia - Etherpad",
"admin_settings.save_error": "Chyba pri ukladaní nastavení",
"admin_settings.saved_success": "Nastavenia boli úspešne uložené",
"update.banner.title": "Dostupná aktualizácia",
"update.banner.body": "Je dostupný Etherpad {{latest}} (používate {{current}}).",
"update.banner.cta": "Zobraziť aktualizáciu",
"update.page.title": "Aktualizácie Etherpadu",
"update.page.current": "Aktuálna verzia",
"update.page.latest": "Najnovšia verzia",
"update.page.last_check": "Naposledy skontrolované",
"update.page.install_method": "Spôsob inštalácie",
"update.page.tier": "Úroveň aktualizácií",
"update.page.changelog": "Zoznam zmien",
"update.page.up_to_date": "Používate najnovšiu verziu.",
"update.page.disabled": "Kontroly aktualizácií sú vypnuté (updates.tier = „off“).",
"update.page.unauthorized": "Nemáte oprávnenie zobraziť stav aktualizácií.",
"update.page.error": "Nepodarilo sa načítať stav aktualizácií (stav {{status}}).",
"update.badge.severe": "Etherpad na tomto serveri je vážne zastaraný. Upozornite správcu.",
"update.badge.vulnerable": "Etherpad na tomto serveri používa verziu so známymi bezpečnostnými problémami. Upozornite správcu.",
"update.page.apply": "Použiť aktualizáciu",
"update.page.cancel": "Zrušiť",
"update.page.acknowledge": "Potvrdiť",
"update.page.log": "Záznam aktualizácie (posledných 200 riadkov)",
"update.page.execution": "Stav",
"update.page.policy.install-method-not-writable": "Aktualizácie zo správcovského rozhrania vyžadujú inštaláciu cez git. Aktualizujte cez svojho správcu balíkov.",
"update.page.policy.rollback-failed-terminal": "Predchádzajúca aktualizácia zlyhala a nepodarilo sa ju vrátiť späť. Po uvedení inštalácie do poriadku stlačte Potvrdiť, čím sa zámok zruší.",
"update.page.policy.up-to-date": "Používate najnovšiu verziu.",
"update.page.policy.tier-off": "Aktualizácie sú vypnuté (updates.tier = „off“).",
"update.page.policy.maintenance-window-missing": "Úroveň 4 (autonómna) vyžaduje okno údržby. Autonómne aktualizácie zapnete nastavením updates.maintenanceWindow v settings.json.",
"update.page.policy.maintenance-window-invalid": "Úroveň 4 (autonómna) je vypnutá, pretože updates.maintenanceWindow má nesprávny formát. Očakáva sa {start, end, tz} s časmi vo formáte HH:MM a tz „local“ alebo „utc“.",
"update.page.last_result.verified": "Posledná aktualizácia na {{tag}} bola overená.",
"update.page.last_result.rolled-back": "Posledná pokusná aktualizácia na {{tag}} bola vrátená späť: {{reason}}.",
"update.page.last_result.rollback-failed": "Posledný pokus o aktualizáciu zlyhal A zlyhalo aj vrátenie späť: {{reason}}. Vyžaduje sa ručný zásah.",
"update.page.last_result.preflight-failed": "Posledná pokusná aktualizácia na {{tag}} zlyhala pri kontrole pred spustením: {{reason}}.",
"update.page.last_result.cancelled": "Poslednú pokusnú aktualizáciu na {{tag}} zrušil správca.",
"update.execution.idle": "Nečinné",
"update.execution.scheduled": "Aktualizácia naplánovaná",
"update.execution.preflight": "Kontroly pred spustením",
"update.execution.preflight-failed": "Kontrola pred spustením zlyhala",
"update.execution.draining": "Ukončovanie relácií",
"update.execution.executing": "Aktualizuje sa…",
"update.execution.pending-verification": "Čaká sa na overenie",
"update.execution.verified": "Overené",
"update.execution.rolling-back": "Vracia sa späť",
"update.execution.rolled-back": "Vrátené späť",
"update.execution.rollback-failed": "Vrátenie späť zlyhalo",
"update.banner.terminal.rollback-failed": "Pokus o aktualizáciu zlyhal a nepodarilo sa ho vrátiť späť. Vyžaduje sa ručný zásah.",
"update.banner.scheduled": "Automatická aktualizácia na {{tag}} je naplánovaná — použije sa o {{remaining}}.",
"update.banner.maintenance-window-missing": "Autonómne aktualizácie sú vypnuté, kým sa nenastaví okno údržby.",
"update.banner.maintenance-window-invalid": "Autonómne aktualizácie sú vypnuté, pretože okno údržby má nesprávny formát.",
"update.page.scheduled.title": "Aktualizácia naplánovaná",
"update.page.scheduled.countdown": "Etherpad sa začne aktualizovať na {{tag}} o {{remaining}}.",
"update.page.scheduled.deferred_until": "Mimo okna údržby. Aktualizácia sa spustí, keď sa okno otvorí o {{at}}.",
"update.page.scheduled.apply_now": "Použiť teraz",
"update.window.title": "Okno údržby",
"update.window.summary": "{{start}} {{end}} ({{tz}})",
"update.window.unset": "Nie je nastavené.",
"update.window.next_opens_at": "Ďalšie okno sa otvorí o {{at}}.",
"update.drain.t60": "Etherpad sa o 60 sekúnd reštartuje, aby použil aktualizáciu.",
"update.drain.t30": "Etherpad sa o 30 sekúnd reštartuje, aby použil aktualizáciu.",
"update.drain.t10": "Etherpad sa o 10 sekúnd reštartuje, aby použil aktualizáciu.",
"index.newPad": "Nový poznámkový blok",
"index.createOpenPad": "alebo vytvoriť/otvoriť poznámkový blok s názvom:",
"index.settings": "Nastavenia",
"index.transferSessionTitle": "Preniesť reláciu",
"index.receiveSessionTitle": "Prijať reláciu",
"index.receiveSessionDescription": "Tu môžete prijať reláciu Etherpadu z iného prehliadača alebo zariadenia. Upozorňujeme však, že tým sa odstráni vaša súčasná relácia, ak nejakú máte.",
"index.transferSession": "1. Preniesť reláciu",
"index.transferSessionNow": "Preniesť reláciu teraz",
"index.copyLink": "2. Kopírovať odkaz",
"index.copyLinkDescription": "Kliknutím na tlačidlo nižšie skopírujete odkaz do schránky.",
"index.copyLinkButton": "Kopírovať odkaz do schránky",
"index.transferToSystem": "3. Skopírovať reláciu do nového systému",
"index.transferToSystemDescription": "Otvorením skopírovaného odkazu v cieľovom prehliadači alebo zariadení prenesiete svoju reláciu.",
"index.code": "Kód",
"index.transferSessionDescription": "Kliknutím na tlačidlo nižšie prenesiete svoju súčasnú reláciu do prehliadača alebo zariadenia. Skopíruje sa odkaz na stránku, ktorá po otvorení v cieľovom prehliadači alebo zariadení prenesie vašu reláciu.",
"index.createOpenPad": "Otvoriť pad podľa názvu",
"index.openPad": "otvoriť poznámkový blok s názvom:",
"index.recentPads": "Nedávne pady",
"index.recentPadsEmpty": "Nenašli sa žiadne nedávne pady.",
"index.generateNewPad": "Vygenerovať náhodný názov padu",
"index.labelPad": "Názov padu (nepovinné)",
"index.placeholderPadEnter": "Zadajte názov padu…",
"index.createAndShareDocuments": "Vytvárajte a zdieľajte dokumenty v reálnom čase",
"index.createAndShareDocumentsDescription": "Etherpad vám umožňuje spoločne upravovať dokumenty v reálnom čase, podobne ako živý viacpoužívateľský editor, ktorý beží vo vašom prehliadači.",
"pad.toolbar.bold.title": "Tučné (Ctrl+B)",
"pad.toolbar.italic.title": "Kurzíva (Ctrl+I)",
"pad.toolbar.underline.title": "Podčiarknuté (Ctrl+U)",
@ -62,22 +264,42 @@
"pad.toolbar.savedRevision.title": "Uložiť revíziu",
"pad.toolbar.settings.title": "Nastavenia",
"pad.toolbar.embed.title": "Zdieľať alebo vložiť tento poznámkový blok",
"pad.toolbar.home.title": "Späť na domovskú stránku",
"pad.toolbar.showusers.title": "Zobraziť používateľov tohoto poznámkového bloku",
"pad.colorpicker.save": "Uložiť",
"pad.colorpicker.cancel": "Zrušiť",
"pad.loading": "Načítava sa...",
"pad.noCookie": "Cookie nebolo možné nájsť. Povoľte prosím cookies vo vašom prehliadači. Vaše sedenie a nastavenia sa medzi návštevami stránky neuložia. To môže byť spôsobené tým že Etherpad je zahrnutý do iFrame v niektorých prehliadačoch. Prosím uistite sa, že Etherpad sa nachádza na tej istej doméne ako hlavný iFrame",
"pad.permissionDenied": "Ľutujeme, nemáte oprávnenie pristupovať k tomuto poznámkovému bloku",
"pad.settings.padSettings": "Nastavenia poznámkového bloku",
"pad.settings.title": "Nastavenia",
"pad.settings.padSettings": "Nastavenia pre celý pad",
"pad.settings.userSettings": "Používateľské nastavenia",
"pad.settings.myView": "Vlastný pohľad",
"pad.settings.disablechat": "Vypnúť chat",
"pad.settings.darkMode": "Tmavý režim",
"pad.settings.stickychat": "Rozhovor stále na obrazovke",
"pad.settings.chatandusers": "Zobraziť rozhovor a používateľov",
"pad.settings.colorcheck": "Farby autorov",
"pad.settings.fadeInactiveAuthorColors": "Stlmiť farby neaktívnych autorov",
"pad.settings.linenocheck": "Čísla riadkov",
"pad.settings.rtlcheck": "Čítať obsah sprava doľava?",
"pad.settings.enforceSettings": "Vynútiť nastavenia pre ostatných používateľov",
"pad.settings.enforcedNotice": "Tieto nastavenia vám uzamkol tvorca tohto padu. Ak ich potrebujete zmeniť, požiadajte tvorcu padu.",
"pad.settings.fontType": "Typ písma:",
"pad.settings.fontType.normal": "Normálne",
"pad.settings.language": "Jazyk:",
"pad.settings.deletePad": "Zmazať pad",
"pad.delete.confirm": "Naozaj chcete zmazať tento pad?",
"pad.deletionToken.modalTitle": "Uložte si token na zmazanie padu",
"pad.deletionToken.modalBody": "Tento token je jediný spôsob, ako zmazať tento pad, ak stratíte reláciu prehliadača alebo zmeníte zariadenie. Uložte si ho na bezpečné miesto — zobrazí sa tu iba raz.",
"pad.deletionToken.copy": "Kopírovať",
"pad.deletionToken.copied": "Skopírované",
"pad.deletionToken.acknowledge": "Uložil som si ho",
"pad.deletionToken.deleteWithToken": "Zmazať pad pomocou tokenu",
"pad.deletionToken.tokenFieldLabel": "Token na zmazanie padu",
"pad.deletionToken.tokenValueLabel": "Váš token na zmazanie padu (iba na čítanie)",
"pad.deletionToken.invalid": "Tento token nie je platný pre tento pad.",
"pad.deletionToken.notCreator": "Nie ste tvorcom tohto padu, takže ho nemôžete zmazať.",
"pad.settings.about": "O Etherpade",
"pad.settings.poweredBy": "Poháňané cez",
"pad.importExport.import_export": "Import/Export",
@ -90,6 +312,13 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.exportetherpada.title": "Exportovať ako Etherpad",
"pad.importExport.exporthtmla.title": "Exportovať ako HTML",
"pad.importExport.exportplaina.title": "Exportovať ako čistý text",
"pad.importExport.exportworda.title": "Exportovať ako Microsoft Word",
"pad.importExport.exportpdfa.title": "Exportovať ako PDF",
"pad.importExport.exportopena.title": "Exportovať ako ODF (Open Document Format)",
"pad.importExport.noConverter.innerHTML": "Importovať môžete iba z formátov čistého textu alebo HTML. Pre pokročilejšie funkcie importu si <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">nainštalujte LibreOffice</a>.",
"pad.modals.connected": "Pripojené.",
"pad.modals.reconnecting": "Opätovné pripájanie k vášmu poznámkovému bloku...",
"pad.modals.forcereconnect": "Vynútiť znovupripojenie",
@ -120,6 +349,8 @@
"pad.modals.disconnected": "Boli ste odpojení.",
"pad.modals.disconnected.explanation": "Spojenie so serverom sa prerušilo",
"pad.modals.disconnected.cause": "Server môže byť nedostupný. Ak by problém pretrvával, informujte správcu služby.",
"pad.gritter.unacceptedCommit.title": "Neuložená úprava",
"pad.gritter.unacceptedCommit.text": "Vaša nedávna úprava stále nie je uložená. Znova sa pripojte a skúste to ešte raz.",
"pad.share": "Zdieľať tento poznámkový blok",
"pad.share.readonly": "Len na čítanie",
"pad.share.link": "Odkaz",
@ -132,12 +363,36 @@
"timeslider.followContents": "Sledovať aktualizácie obsahu poznámkového bloku",
"timeslider.pageTitle": "Časová os {{appTitle}}",
"timeslider.toolbar.returnbutton": "Späť do poznámkového bloku",
"pad.historyMode.banner": "Prezeranie histórie",
"pad.historyMode.return": "Návrat k živej verzii",
"pad.historyMode.revisionLabel": "Revízia {{rev}}",
"pad.historyMode.controlsLabel": "Ovládanie histórie padu",
"pad.historyMode.sliderLabel": "Revízia padu",
"pad.historyMode.settings.title": "Prehrávanie histórie",
"pad.historyMode.settings.follow": "Sledovať zmeny obsahu padu",
"pad.historyMode.settings.followShort": "Sledovať",
"pad.historyMode.followOn": "Sledujú sa zmeny padu — kliknutím sledovanie ukončíte",
"pad.historyMode.followOff": "Zmeny padu sa nesledujú — kliknutím začnete sledovať",
"pad.historyMode.settings.playbackSpeed": "Rýchlosť prehrávania:",
"pad.historyMode.chat.replayHeader": "Chat k {{time}}",
"pad.historyMode.users.authorsHeader": "Autori v tejto revízii",
"pad.editor.skipToContent": "Preskočiť na editor",
"pad.editor.keyboardHint": "Stlačením Escape opustíte editor. Stlačením Alt+F9 sa dostanete na panel nástrojov.",
"pad.editor.toolbar.formatting": "Panel nástrojov formátovania",
"pad.editor.toolbar.actions": "Panel akcií padu",
"pad.editor.toolbar.showMore": "Zobraziť viac tlačidiel panela",
"timeslider.toolbar.authors": "Autori:",
"timeslider.toolbar.authorsList": "Bez autorov",
"timeslider.toolbar.exportlink.title": "Export",
"timeslider.exportCurrent": "Exportovať aktuálnu verziu ako:",
"timeslider.version": "Verzia {{version}}",
"timeslider.saved": "Uložené {{day}}. {{month}} {{year}}",
"timeslider.settings.playbackSpeed": "Rýchlosť prehrávania:",
"timeslider.settings.playbackSpeed.original": "Pôvodná rýchlosť",
"timeslider.settings.playbackSpeed.realtime": "Reálny čas",
"timeslider.settings.playbackSpeed.200ms": "200 ms",
"timeslider.settings.playbackSpeed.500ms": "500 ms",
"timeslider.settings.playbackSpeed.1000ms": "1000 ms",
"timeslider.playPause": "Pustiť / Pozastaviť obsah poznámkového bloku",
"timeslider.backRevision": "Ísť v tomto poznámkovom bloku o jednu revíziu späť",
"timeslider.forwardRevision": "Ísť v tomto poznámkovom bloku o jednu revíziu vpred",
@ -159,6 +414,7 @@
"pad.savedrevs.timeslider": "Návštevou časovej osi môžete zobraziť uložené revízie",
"pad.userlist.entername": "Zadajte svoje meno",
"pad.userlist.unnamed": "nemenovaný",
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} pripojený používateľ, few: {{count}} pripojení používatelia, many: {{count}} pripojených používateľov, other: {{count}} pripojených používateľov ]}",
"pad.editbar.clearcolors": "Odstrániť farby autorov z celého dokumentu? Táto akcia sa nedá vrátiť",
"pad.impexp.importbutton": "Importovať teraz",
"pad.impexp.importing": "Prebieha import...",
@ -169,5 +425,6 @@
"pad.impexp.importfailed": "Import zlyhal",
"pad.impexp.copypaste": "Vložte prosím kópiu cez schránku",
"pad.impexp.exportdisabled": "Export do formátu {{type}} nie je povolený. Kontaktujte prosím administrátora pre zistenie detailov.",
"pad.impexp.maxFileSize": "Súbor je príliš veľký. Kontaktujte správcu pre zväčšenie povolenej veľkosti súborov pre import"
"pad.impexp.maxFileSize": "Súbor je príliš veľký. Kontaktujte správcu pre zväčšenie povolenej veľkosti súborov pre import",
"pad.social.description": "Kolaboratívny dokument, ktorý môže každý upravovať v reálnom čase."
}

View file

@ -18,6 +18,7 @@
"Stang",
"TFX202X",
"VulpesVulpes825",
"XtexChooser",
"Yfdyh000",
"乌拉跨氪",
"列维劳德",
@ -26,6 +27,30 @@
]
},
"admin.page-title": "管理员面板 - Etherpad",
"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": "Etherpad",
"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.users": "用户",
"admin_pads.confirm_button": "确定",
"admin_pads.error_prefix": "错误",
"admin_pads.pagination.next": "下一页",
"admin_pads.pagination.previous": "上一页",
"admin_pads.refresh": "刷新",
"admin_pads.relative.just_now": "刚刚",
"admin_plugins": "插件管理器",
"admin_plugins.available": "可用插件",
"admin_plugins.available_not-found": "找不到插件。",

View file

@ -15,6 +15,16 @@
]
},
"admin.page-title": "管理員面板 - Etherpad",
"admin.loading": "載入中…",
"admin_login.title": "Etherpad",
"admin_login.username": "使用者名稱",
"admin_login.password": "密碼",
"admin_login.submit": "登入",
"admin_login.failed": "登入失敗",
"admin_pads.bulk.delete": "刪除",
"admin_pads.cancel": "取消",
"admin_pads.col.users": "使用者",
"admin_pads.error_prefix": "錯誤",
"admin_plugins": "外掛程式管理器",
"admin_plugins.available": "可用套件",
"admin_plugins.available_not-found": "沒有找到套件。",
@ -29,6 +39,9 @@
"admin_plugins.last-update": "最後更新",
"admin_plugins.name": "名稱",
"admin_plugins.page-title": "套件管理 - Etherpad",
"admin_plugins.update_tooltip": "更新",
"admin_plugins.updates_available": "有可用更新",
"admin_plugins.update_now": "更新",
"admin_plugins.version": "版本",
"admin_plugins_info": "問題排除資訊",
"admin_plugins_info.hooks": "已安裝的掛勾",
@ -37,6 +50,8 @@
"admin_plugins_info.parts": "已安裝部分",
"admin_plugins_info.plugins": "已安裝的套件",
"admin_plugins_info.page-title": "套件資訊 - Etherpad",
"admin_plugins_info.tab_client": "客戶端",
"admin_plugins_info.tab_server": "伺服器",
"admin_plugins_info.version": "Etherpad 版本",
"admin_plugins_info.version_latest": "最新可用版本",
"admin_plugins_info.version_number": "版本號",
@ -46,6 +61,9 @@
"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.page-title": "設定 - Etherpad",
"index.newPad": "新記事本",
"index.settings": "設定",
@ -116,6 +134,11 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF開放文件格式",
"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開放文件格式",
"pad.modals.connected": "已連線。",
"pad.modals.reconnecting": "重新連線到您的記事本…",
"pad.modals.forcereconnect": "強制重新連線",

View file

@ -551,10 +551,11 @@ exports.createPad = async (padID: string, text: string, authorId = '') => {
// create pad
await getPadSafe(padID, false, text, authorId);
// When requireAuthentication is on, every creator has a stable identity, so
// the cookie/identity path covers recovery and the one-time token is just
// an extra surface to leak.
const deletionToken = settings.requireAuthentication
// No recovery token when it cannot help: requireAuthentication gives every
// creator a stable identity, and allowPadDeletionByAllUsers lets anyone delete
// the pad with no token at all (issue #7926). Either way the token is just an
// extra surface to leak.
const deletionToken = settings.requireAuthentication || settings.allowPadDeletionByAllUsers
? null
: await padDeletionManager.createDeletionTokenIfAbsent(padID);
return {deletionToken};

View file

@ -632,6 +632,15 @@ class Pad {
}
async copy(destinationID: string, force: boolean) {
// Reject a destinationID that isn't a valid pad id BEFORE any db write. The
// copy path writes `pad:${destinationID}...` records directly (bypassing
// getPad), so a destinationID carrying the ueberdb delimiter `:` would
// otherwise clobber another pad's internal sub-records and slip past the
// force=false existence guard. (GHSA-wg58-mhwv-35pq.)
if (!padManager.isValidPadId(destinationID)) {
throw new CustomError('destinationID is not a valid padId', 'apierror');
}
// Kick everyone from this pad.
// This was commented due to https://github.com/ether/etherpad-lite/issues/3183.
// Do we really need to kick everyone out?
@ -729,6 +738,12 @@ class Pad {
}
async copyPadWithoutHistory(destinationID: string, force: string|boolean, authorId = '') {
// See copy(): reject an invalid destinationID (notably one containing the
// ueberdb delimiter `:`) before any db write. (GHSA-wg58-mhwv-35pq.)
if (!padManager.isValidPadId(destinationID)) {
throw new CustomError('destinationID is not a valid padId', 'apierror');
}
// flush the source pad
this.saveToDatabase();
@ -868,6 +883,8 @@ class Pad {
await this.saveToDatabase();
}
// Returns the newly created saved revision, or undefined if this revision
// was already saved (so callers can broadcast only genuine additions).
async addSavedRevision(revNum: string, savedById: string, label: string) {
// if this revision is already saved, return silently
for (const i in this.savedRevisions) {
@ -887,6 +904,7 @@ class Pad {
// save this new saved revision
this.savedRevisions.push(savedRevision);
await this.saveToDatabase();
return savedRevision;
}
getSavedRevisions() {

View file

@ -108,7 +108,15 @@ const padList = new class {
*/
exports.getPad = async (id: string, text?: string|null, authorId:string|null = ''):Promise<PadType> => {
// check if this is a valid padId
if (!exports.isValidPadId(id)) {
//
// An id that is no longer valid to *create* is still served when a pad with
// that exact id already exists: `:` was accepted until GHSA-wg58-mhwv-35pq, so
// pads carrying one exist in the wild (that is why padIdTransforms maps `:` at
// all) and rejecting them here would lock their content away. doesPadExist()
// requires a top-level `atext`, which only a real pad record has — the
// `pad:<id>:revs:<n>` / `:chat:<n>` sub-records an injected id would address do
// not have one, so they stay unreachable.
if (!exports.isValidPadId(id) && !(await exports.doesPadExist(id))) {
throw new CustomError(`${id} is not a valid padId`, 'apierror');
}
@ -192,7 +200,20 @@ exports.sanitizePadId = async (padId: string) => {
return padId;
};
exports.isValidPadId = (padId: string) => /^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId);
// Pad IDs consisting only of URL "dot-segments" ('.' or '..') are unreachable:
// per the WHATWG URL standard a browser normalises "/p/." to "/p/" and "/p/.."
// to "/", so the pad can never be opened or exported — the request arrives as
// "/p/" and Etherpad answers "Cannot GET /p/". Reject them so such (broken) pads
// can never be created.
const dotSegmentPadId = /^\.{1,2}$/;
// `:` is the ueberdb key-namespace delimiter (records are stored under
// `pad:<id>`, `pad:<id>:revs:<n>`, `pad:<id>:chat:<n>`). A pad id containing a
// `:` can therefore address another pad's internal sub-records, so it is never
// valid — the name portion excludes `$` (group-pad separator) and `:`.
// (GHSA-wg58-mhwv-35pq: copyPad/movePad destinationID injection.)
exports.isValidPadId = (padId: string) =>
/^(g.[a-zA-Z0-9]{16}\$)?[^$:]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);
/**
* Removes the pad from database and unloads it.

View file

@ -75,10 +75,13 @@ exports.checkAccess = async (padID:string, sessionCookie:string, token:string, u
}
// Authentication and authorization checks.
if (settings.loadTest) {
console.warn(
'bypassing socket.io authentication and authorization checks due to settings.loadTest');
} else if (settings.requireAuthentication) {
// settings.loadTest just short-circuits authn/authz; the user-facing
// warning about this configuration choice is logged from Settings.ts
// during settings load/reload, not on every request. Re-logging it
// here was costing ~4% of process CPU in the 100-400 author dive
// sweep (#7756): the routed-console-warn went through log4js's
// clustering dispatch on every message.
if (!settings.loadTest && settings.requireAuthentication) {
if (userSettings == null) {
authLogger.debug('access denied: authentication is required');
return DENY;

View file

@ -62,18 +62,8 @@ exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
* Also, see #3820.
*/
const sessionIDs = sessionCookie.replace(/^"|"$/g, '').split(',');
const sessionInfoPromises = sessionIDs.map(async (id) => {
try {
return await exports.getSessionInfo(id);
} catch (err:any) {
if (err.message === 'sessionID does not exist') {
console.debug(`SessionManager getAuthorID: no session exists with ID ${id}`);
} else {
throw err;
}
}
return undefined;
});
const sessionInfoPromises = sessionIDs.map(async (id) =>
(await getSessionInfoOrNull(id)) || undefined);
const now = Math.floor(Date.now() / 1000);
const isMatch = (si: {
groupID: string;
@ -163,9 +153,20 @@ exports.createSession = async (groupID: string, authorID: string, validUntil: nu
* @param {String} sessionID The id of the session
* @return {Promise<Object>} the sessioninfos
*/
// Non-throwing variant for hot-path callers. Hot path uses
// `findAuthorID` on every CLIENT_READY and `listSessionsWithDBKey` on
// session listing; both wrap getSessionInfo in try/catch and discard
// "sessionID does not exist" CustomError. Profiling against develop at
// 100-400 author sweep (ether/etherpad#7756) attributed ~6% of total
// CPU to that throw+catch pair: ~1.8% to CustomError construction and
// ~4% to the cascading `console.debug` call routed through log4js. A
// null return collapses the cost to a single nullable check.
const getSessionInfoOrNull = async (sessionID: string) =>
await db.get(`session:${sessionID}`);
exports.getSessionInfo = async (sessionID:string) => {
// check if the database entry of this session exists
const session = await db.get(`session:${sessionID}`);
const session = await getSessionInfoOrNull(sessionID);
if (session == null) {
// session does not exist
@ -250,15 +251,12 @@ const listSessionsWithDBKey = async (dbkey: string) => {
// iterate through the sessions and get the sessioninfos
for (const sessionID of Object.keys(sessions || {})) {
try {
sessions[sessionID] = await exports.getSessionInfo(sessionID);
} catch (err:any) {
if (err.name === 'apierror') {
console.warn(`Found bad session ${sessionID} in ${dbkey}`);
sessions[sessionID] = null;
} else {
throw err;
}
const info = await getSessionInfoOrNull(sessionID);
if (info == null) {
console.warn(`Found bad session ${sessionID} in ${dbkey}`);
sessions[sessionID] = null;
} else {
sessions[sessionID] = info;
}
}

View file

@ -163,7 +163,13 @@ exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string,
// for the temp path token (see matching note in ImportHandler.ts).
const randNum = crypto.randomBytes(16).toString('hex');
const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`;
await fsp_writeFile(srcFile, html);
// Strip remote <img> tags before handing the document to LibreOffice.
// soffice fetches remote image URLs during conversion, so any plugin/hook
// that injects an <img src="http://..."> into export HTML would otherwise
// turn export into a blind SSRF sink. The native path already does this
// (see stripRemoteImages above); apply it here so both paths match.
const {stripRemoteImages} = require('../utils/ExportSanitizeHtml');
await fsp_writeFile(srcFile, stripRemoteImages(html));
// ensure html can be collected by the garbage collector
html = null;

View file

@ -204,7 +204,14 @@ class Channels {
/**
* A changeset queue per pad that is processed by handleUserChanges()
*/
const padChannels = new Channels((ch, {socket, message}) => handleUserChanges(socket, message));
// The channel key `ch` is the pad id captured at enqueue time (see the enqueue
// call). Pass it through to handleUserChanges so the write targets the pad that
// was authorized when the message arrived — NOT whatever pad the mutable
// sessioninfos[socket.id].padId happens to point at by apply time. A concurrent
// same-socket CLIENT_READY can swap that padId between enqueue and apply, which
// otherwise redirects the queued write onto a read-only / unauthorized pad
// (GHSA-6mcx-x5h6-rpw2).
const padChannels = new Channels((ch, {socket, message}) => handleUserChanges(socket, message, ch));
/**
* This Method is called by server.ts to tell the message handler on which socket it should send
@ -305,8 +312,13 @@ const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage)
// back to the creator-cookie path, otherwise a creator pasting a wrong
// recovery token into the disclosure field would still succeed — masking a
// typo and contradicting the UI.
const creatorOk = !tokenSupplied && isCreator;
const flagOk = !tokenSupplied && !isCreator && settings.allowPadDeletionByAllUsers;
// Readonly sessions can never delete via the token-less paths: they cannot
// edit the pad, so they must not be able to destroy it just because
// allowPadDeletionByAllUsers is on (issue #7959). A valid recovery token
// (tokenOk) remains a sufficient credential regardless of session mode.
const writable = !session.readonly;
const creatorOk = !tokenSupplied && isCreator && writable;
const flagOk = !tokenSupplied && !isCreator && settings.allowPadDeletionByAllUsers && writable;
if (creatorOk || tokenOk || flagOk) {
await retrievedPad.remove();
@ -500,6 +512,17 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
throw new Error(`pre-CLIENT_READY message from IP ${ip}: ${msg}`);
}
// Pin the pad this message is authorized against — together with `auth` and
// the read-only flag — BEFORE any of the awaits below (checkAccess and the
// handleMessageSecurity/handleMessage hooks). A concurrent same-socket
// CLIENT_READY can mutate sessioninfos[socket.id] (padId/readonly/auth) IN
// PLACE during those awaits (the object identity check below does not catch an
// in-place mutation). Using these pinned values for the read-only gate, the
// queue key and the write keeps them all referring to the SAME pad, closing
// the cross-pad write TOCTOU (GHSA-6mcx-x5h6-rpw2).
const messagePadId = thisSession.padId;
const messageReadonly = thisSession.readonly;
const {session: {user} = {}} = socket.client.request as SocketClientRequest;
const {accessStatus, authorID} =
await securityManager.checkAccess(auth.padID, auth.sessionID, auth.token, user);
@ -521,14 +544,15 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
}
thisSession.author = authorID;
// Allow plugins to bypass the readonly message blocker
let readOnly = thisSession.readonly;
// Allow plugins to bypass the readonly message blocker. Base the decision on
// the value pinned above so a concurrent CLIENT_READY can't flip it mid-message.
let readOnly = messageReadonly;
const context = {
message,
sessionInfo: {
authorId: thisSession.author,
padId: thisSession.padId,
readOnly: thisSession.readonly,
padId: messagePadId,
readOnly: messageReadonly,
},
socket,
get client() {
@ -575,7 +599,10 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
switch (type) {
case 'USER_CHANGES':
stats.counter('pendingEdits').inc();
await padChannels.enqueue(thisSession.padId, {socket, message});
// Queue key = the pinned pad, NOT the (possibly-swapped) live
// session padId. This value is forwarded to handleUserChanges as
// the write target (see the padChannels executor).
await padChannels.enqueue(messagePadId, {socket, message});
break;
case 'PAD_DELETE': await handlePadDelete(socket, message.data as unknown as PadDeleteMessage); break;
case 'USERINFO_UPDATE': await handleUserInfoUpdate(socket, message as unknown as UserNewInfoMessage); break;
@ -622,7 +649,18 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
const handleSaveRevisionMessage = async (socket:any, message: ClientSaveRevisionMessage) => {
const {padId, author: authorId} = sessioninfos[socket.id];
const pad = await padManager.getPad(padId, null, authorId);
await pad.addSavedRevision(pad.head, authorId);
const savedRevision = await pad.addSavedRevision(pad.head, authorId);
// Notify every client in the pad room — including any open timeslider —
// so saved-revision markers appear live instead of only on the next
// timeslider load (#7946). The client's NEW_SAVEDREV handler existed but
// was never reached because this broadcast was missing; live editors that
// don't handle the type ignore it. Skip the emit for duplicate saves.
if (savedRevision) {
socketio.sockets.in(padId).emit('message', {
type: 'COLLABROOM',
data: {type: 'NEW_SAVEDREV', savedRev: savedRevision},
});
}
};
/**
@ -807,7 +845,7 @@ const handleUserInfoUpdate = async (socket:any, {data: {userInfo: {name, colorId
*/
const handleUserChanges = async (socket:any, message: {
data: ClientUserChangesMessage
}) => {
}, authorizedPadId: string) => {
// This one's no longer pending, as we're gonna process it now
stats.counter('pendingEdits').dec();
@ -834,7 +872,9 @@ const handleUserChanges = async (socket:any, message: {
if (apool == null) throw new Error('missing apool');
if (changeset == null) throw new Error('missing changeset');
const wireApool = (new AttributePool()).fromJsonable(apool);
const pad = await padManager.getPad(thisSession.padId, null, thisSession.author);
// Use the pad id captured at enqueue time, not the (mutable) session padId,
// which a concurrent CLIENT_READY may have swapped (GHSA-6mcx-x5h6-rpw2).
const pad = await padManager.getPad(authorizedPadId, null, thisSession.author);
// Verify that the changeset has valid syntax and is in canonical form
checkRep(changeset);
@ -988,7 +1028,7 @@ const handleUserChanges = async (socket:any, message: {
socket.emit('message', {disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
messageLogger.warn(`Failed to apply USER_CHANGES from author ${thisSession.author} ` +
`(socket ${socket.id}) on pad ${thisSession.padId}: ${err.stack || err}`);
`(socket ${socket.id}) on pad ${authorizedPadId}: ${err.stack || err}`);
} finally {
stopWatch.end();
}
@ -1287,15 +1327,41 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
// once. Readonly sessions never see it.
const isCreator =
!sessionInfo.readonly && sessionInfo.author === await pad.getRevisionAuthor(0);
// Skip token issuance — and so the client never shows the "Save your pad
// deletion token" modal (issue #7926) — when the token cannot help:
// - requireAuthentication: every creator already has a stable identity, so
// the cookie/identity path is sufficient.
// The deletion token is a recovery handle for the one class of creator that
// can otherwise lose the ability to delete their pad: a user whose creator
// status lives only in a per-browser author-token cookie. It is pointless —
// and the "Save your pad deletion token" modal only overwhelms users who
// will never need it (issue #7926) — when either of these holds:
//
// - allowPadDeletionByAllUsers: anyone can delete the pad with no token at
// all (see handlePadDelete's flagOk branch), so a recovery token is noise
// and the modal only overwhelms users who will never need it.
// all (see handlePadDelete's flagOk branch).
// - the creator has a *durable* identity: authenticated (req.session.user
// with a username) AND the deployment maps that identity to a stable
// authorID via a getAuthorId hook. Only then does `isCreator`
// (author === revision-0 author) survive a cookie clear or a different
// device, so the creator path replaces the token on any device.
//
// Note we deliberately do NOT treat requireAuthentication alone as durable:
// without a getAuthorId hook the authorID still comes from the per-browser
// token cookie (AuthorManager.getAuthorId -> getAuthor4Token), so an
// authenticated user on a second device is NOT the creator and would be
// stranded if we also withheld the token. The getAuthorId hook is the
// documented way (doc/api/hooks_server-side) to pin authorID to username.
const hasGetAuthorIdHook = (plugins.hooks.getAuthorId || []).length > 0;
const hasDurableIdentity = hasGetAuthorIdHook && !!(user && user.username);
const canDeleteWithoutToken = settings.allowPadDeletionByAllUsers || hasDurableIdentity;
// Whether this session may delete the pad with no token at all: the creator
// on this device (creator-cookie still present), or any user when the
// instance opted everyone in. Drives the plain "Delete pad" button, which is
// independent of enablePadWideSettings (issue #7959) — deletion is not a
// pad-wide setting and must stay reachable when that section is disabled.
// Readonly viewers are excluded: they cannot edit, let alone delete, so
// allowPadDeletionByAllUsers must not hand them a delete button (the server
// enforces the same in handlePadDelete).
const canDeletePad =
!sessionInfo.readonly && (isCreator || settings.allowPadDeletionByAllUsers);
const padDeletionToken =
isCreator && !settings.requireAuthentication && !settings.allowPadDeletionByAllUsers
isCreator && !canDeleteWithoutToken
? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId)
: null;
@ -1314,6 +1380,12 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
enablePadWideSettings: settings.enablePadWideSettings,
enablePluginPadOptions: settings.enablePluginPadOptions,
padDeletionToken,
// Drives the deletion-button label/visibility in pad settings: when the
// user can already delete without a token the recovery-token disclosure is
// redundant, so the client labels the action "Delete Pad" instead of
// "Delete with token" (issue #7926). See showDeletionTokenModalIfPresent.
canDeleteWithoutToken,
canDeletePad,
// Allow-listed copy — settings.privacyBanner could carry extra nested
// keys from a hand-edited settings.json; sending those by reference
// would leak them to every browser. See getPublicPrivacyBanner().

View file

@ -305,13 +305,22 @@ exports.socketio = (hookName: string, {io}: any) => {
socket.on('deletePad', async (padId: string) => {
try {
if (await padManager.doesPadExists(padId)) {
// Healthy pad — full relational cleanup (revs, chat, readonly,
// authors, deletion token, hooks).
logger.info(`Deleting pad: ${padId}`);
const pad = await padManager.getPad(padId);
await pad.remove();
socket.emit('results:deletePad', padId);
return;
try {
// Healthy pad — full relational cleanup (revs, chat, readonly,
// authors, deletion token, hooks).
logger.info(`Deleting pad: ${padId}`);
const pad = await padManager.getPad(padId);
await pad.remove();
socket.emit('results:deletePad', padId);
return;
} catch (err) {
// getPad() runs isValidPadId() and rejects ids that are no longer
// valid — e.g. legacy '.'/'..' pads created before that validation
// was tightened. Don't give up: fall through to the raw key purge
// below so the orphan can still be deleted from the admin UI.
logger.warn(`Relational cleanup failed for "${padId}" ` +
`(${safeErr(err)}); falling back to raw key purge`);
}
}
// doesPadExists() is false either because nothing is stored under

View file

@ -8,14 +8,32 @@ exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Functio
// redirects browser to the pad's sanitized url if needed. otherwise, renders the html
args.app.param('pad', (req:any, res:any, next:Function, padId:string) => {
(async () => {
// ensure the padname is valid and the url doesn't end with a /
if (!padManager.isValidPadId(padId) || /\/$/.test(req.url)) {
// Reject URLs ending in `/` outright.
if (/\/$/.test(req.url)) {
res.status(404).send('Such a padname is forbidden');
return;
}
// Sanitize FIRST, then validate the sanitized result. sanitizePadId maps
// legacy characters (whitespace and the ueberdb delimiter `:`) to `_`, so
// a URL like `/p/foo:bar` still redirects to `/p/foo_bar` even though `:`
// is not itself a valid pad id (GHSA-wg58-mhwv-35pq). An id that stays
// invalid after sanitizing (e.g. one containing `$`) is forbidden.
const sanitizedPadId = await padManager.sanitizePadId(padId);
// A pad that already exists keeps its URL even if its id is no longer a
// valid one to *create*: `:` was accepted by isValidPadId until
// GHSA-wg58-mhwv-35pq, so pads carrying one exist in the wild (that is why
// padIdTransforms maps `:` in the first place) and 404ing them would lock
// their content away. Only ids that are invalid AND unknown are rejected —
// opening a pad URL creates the pad, so this is what keeps new invalid ids
// out.
if (!padManager.isValidPadId(sanitizedPadId) &&
!(await padManager.doesPadExist(sanitizedPadId))) {
res.status(404).send('Such a padname is forbidden');
return;
}
if (sanitizedPadId === padId) {
// the pad id was fine, so just render it
next();

View file

@ -25,6 +25,21 @@ let ioI: { sockets: { sockets: any[]; }; } | null = null
// rules. Reused by admin.ts so both call sites share one definition.
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath';
// Public routes echo the proxy-path headers into rendered URLs, social-preview
// metadata, manifest links and the legacy timeslider redirect. Advertise the
// headers in Vary so a shared cache/CDN in front of Etherpad keys on them and
// can't serve a proxy-path injected by one client to another (cache poisoning).
// Mirrors the admin-route fix in admin.ts (GHSA-fjgc-3mj7-8rg8).
//
// Only vary on the headers sanitizeProxyPath() actually consults for the
// current config: x-proxy-path is always honored, but x-forwarded-prefix and
// x-ingress-path are ignored unless trustProxy is enabled — varying on them
// then would only fragment shared caches without affecting the response.
const varyOnProxyPath = (res: any) => {
res.vary('x-proxy-path');
if (settings.trustProxy) res.vary(['x-forwarded-prefix', 'x-ingress-path']);
};
exports.socketio = (hookName: string, {io}: any) => {
ioI = io
@ -173,6 +188,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
})
setRouteHandler('/', (req: any, res: any) => {
const proxyPath = sanitizeProxyPath(req);
varyOnProxyPath(res);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
proxyPath,
@ -202,6 +218,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
});
const proxyPath = sanitizeProxyPath(req);
varyOnProxyPath(res);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
proxyPath,
@ -246,6 +263,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
});
const proxyPath = sanitizeProxyPath(req);
varyOnProxyPath(res);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
proxyPath,
@ -369,6 +387,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
// serve index.html under /
args.app.get('/', (req: any, res: any) => {
const proxyPath = sanitizeProxyPath(req);
varyOnProxyPath(res);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
proxyPath,
@ -389,6 +408,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
});
const proxyPath = sanitizeProxyPath(req);
varyOnProxyPath(res);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
proxyPath,
@ -417,6 +437,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
// technically well-defined but Firefox dropped a trailing-slash
// case once that flaked the legacy-URL test (#7710).
const proxyPath = sanitizeProxyPath(req);
varyOnProxyPath(res);
return res.redirect(302, `${proxyPath}/p/${encodeURIComponent(req.params.pad)}`);
}
ensureAuthorTokenCookie(req, res, settings);
@ -425,6 +446,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
});
const proxyPath = sanitizeProxyPath(req);
varyOnProxyPath(res);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
proxyPath,

View file

@ -22,6 +22,22 @@ const aCallFirst0 =
// @ts-ignore
async (hookName: string, context:any, pred = null) => (await aCallFirst(hookName, context, pred))[0];
// Rotate the express-session id while preserving the session's data. Used at the
// authentication boundary to prevent session fixation (GHSA-73h9-c5xp-gfg4).
// The freshly minted cookie for the new id is kept; all other session data
// (notably req.session.user) is carried across onto the new session.
const regenerateSessionPreservingData = (req: any) => new Promise<void>((resolve, reject) => {
// Session prototype methods (regenerate/save/...) are non-enumerable, so the
// spread captures only data properties. Drop `cookie` so the new session keeps
// the fresh cookie regenerate() creates.
const {cookie, ...data} = req.session;
req.session.regenerate((err: any) => {
if (err) return reject(err);
Object.assign(req.session, data);
req.session.save((saveErr: any) => saveErr != null ? reject(saveErr) : resolve());
});
});
exports.normalizeAuthzLevel = (level: string|boolean) => {
if (!level) return false;
switch (level) {
@ -158,6 +174,11 @@ const checkAccess = async (req:any, res:any, next: Function) => {
if (settings.users == null) settings.users = {};
const ctx:WebAccessTypes = {req, res, users: settings.users, next};
// Identity carried by the session BEFORE the authenticate step runs. Used
// below to decide whether authentication changed the principal (anonymous ->
// user, or a privilege/identity change such as non-admin -> admin), which is
// the point at which the session id must be rotated (see below).
const prevUser = req.session != null ? req.session.user : null;
// If the HTTP basic auth header is present, extract the username and password so it can be given
// to authn plugins.
const httpBasicAuth = req.headers.authorization && req.headers.authorization.startsWith('Basic ');
@ -206,6 +227,26 @@ const checkAccess = async (req:any, res:any, next: Function) => {
httpLogger.error('authenticate hook failed to add user settings to session');
return res.status(500).send('Internal Server Error');
}
// Session fixation defense (GHSA-73h9-c5xp-gfg4): rotate the session id
// whenever authentication changed the principal — an anonymous session
// becoming authenticated, OR an authenticated session changing identity or
// privilege level (e.g. non-admin -> admin re-authentication). This prevents a
// pre-auth / lower-privilege id (which an attacker may have planted or
// captured — e.g. one an SSO plugin persisted before redirecting to the IdP)
// from owning the resulting session. A no-op re-authentication of the same
// principal is left alone (no churn), and the rotation is skipped when the
// session store doesn't expose regenerate().
const identityChanged = prevUser == null ||
prevUser.username !== req.session.user.username ||
!!prevUser.is_admin !== !!req.session.user.is_admin;
if (identityChanged && typeof req.session.regenerate === 'function') {
try {
await regenerateSessionPreservingData(req);
} catch (err) {
httpLogger.error(`failed to regenerate session on authentication: ${err}`);
return res.status(500).send('Internal Server Error');
}
}
const {username = '<no username>'} = req.session.user;
httpLogger.info(
`Successful authentication from IP ${anonymizeIp(req.ip, settings.ipLogging)} ` +

View file

@ -10,6 +10,12 @@ import {format} from 'url'
import {ParsedUrlQuery} from "node:querystring";
import {MapArrayType} from "../types/MapType";
import crypto from "node:crypto";
import {resolveOidcCookieKeys, isOriginAllowedForOidcClient} from "./OidcProviderSecurity";
import SecretRotator from "./SecretRotator";
// Held at module scope so the rotator (and its refresh timer) is not garbage
// collected for the lifetime of the process, mirroring express.ts.
let oidcCookieSecretRotator: SecretRotator | null = null;
// Small fixed delay applied to every failed interactive login, mirroring
// webaccess.authnFailureDelayMs, so failures take a consistent amount of time.
@ -68,9 +74,13 @@ const configuration: Configuration = {
profile: ['name'],
admin: ['admin']
},
cookies: {
keys: ['oidc'],
},
// NOTE: cookies.keys is deliberately NOT set here. A committed literal key
// (historically ['oidc']) lets anyone with the public source forge valid
// OIDC provider `.sig` cookies. The real key material is resolved at
// provider-construction time in expressCreateServer() via
// resolveOidcCookieKeys(), which prefers an operator-supplied
// settings.sso.cookieKeys and otherwise derives a secret, stable key from
// the persisted session secret. See OidcProviderSecurity.ts.
features:{
devInteractions: {enabled: false},
},
@ -92,8 +102,39 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp
publicKeyExported = publicKey
privateKeyExported = privateKey
// Resolve the OIDC provider's cookie-signing keys. Never a committed literal.
// When the operator hasn't pinned settings.sso.cookieKeys and cookie key
// rotation is enabled (the default), reuse the same DB-backed SecretRotator
// mechanism as the Express session cookies so the key is stable across
// restarts and shared across horizontally-scaled pods. `settings.sessionKey`
// is commonly null under default rotation settings, so relying on it alone
// would hand the provider an unstable per-process random key.
const operatorCookieKeys = (settings.sso as {cookieKeys?: unknown}).cookieKeys;
const hasOperatorKeys = Array.isArray(operatorCookieKeys) &&
operatorCookieKeys.some((k) => typeof k === 'string' && k.length > 0);
const {keyRotationInterval, sessionLifetime} = settings.cookie;
let rotatedSecrets: string[] | null = null;
if (!hasOperatorKeys && keyRotationInterval && sessionLifetime) {
if (oidcCookieSecretRotator == null) {
oidcCookieSecretRotator = new SecretRotator(
'oidcCookieSecrets', keyRotationInterval, sessionLifetime, settings.sessionKey);
await oidcCookieSecretRotator.start();
}
rotatedSecrets = oidcCookieSecretRotator.secrets;
}
const oidc = new Provider(settings.sso.issuer, {
...configuration, jwks: {
...configuration,
cookies: {
// Secret, deployment-stable signing keys — never a committed literal.
// See resolveOidcCookieKeys().
keys: resolveOidcCookieKeys({
cookieKeys: operatorCookieKeys,
rotatedSecrets,
sessionKey: settings.sessionKey,
}),
},
jwks: {
keys: [
privateKeyJWK
],
@ -127,9 +168,12 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp
},
jwtResponseModes: {enabled: true},
},
clientBasedCORS: (ctx, origin, client) => {
return true
},
clientBasedCORS: (ctx, origin, client) =>
// Only allow cross-origin reads from an origin registered as one of
// the client's redirect URIs. Returning `true` unconditionally
// reflected any Origin into Access-Control-Allow-Origin. See
// isOriginAllowedForOidcClient().
isOriginAllowedForOidcClient(origin, client as {redirectUris?: unknown}),
extraParams: [],
extraTokenClaims: async (ctx, token) => {
if(token.kind === 'AccessToken') {

View file

@ -0,0 +1,96 @@
import crypto from 'node:crypto';
/**
* Security helpers for the embedded OIDC provider (`OAuth2Provider.ts`).
*
* Kept in a dependency-light module (only `node:crypto`) so the pure decisions
* can be unit-tested without constructing an `oidc-provider` instance.
*/
// Domain-separation label so the derived cookie key is cryptographically
// unrelated to any other use of the session secret. The trailing NUL keeps the
// label from ambiguously running into the appended secret.
export const COOKIE_KEY_DERIVATION_LABEL = 'etherpad-oidc-provider-cookie-signing-key\0';
/**
* Resolve the cookie-signing keys for the embedded OIDC provider.
*
* oidc-provider signs its short-lived interaction/session/grant cookies
* (`_interaction`, `_interaction_resume`, `_grant`, `_session`) with an HMAC
* keyed by these values. Shipping a hardcoded key (historically `['oidc']`) let
* anyone with the public source forge valid `.sig` cookies, defeating the
* provider's cookie-integrity guarantee. Reported by `meifukun`.
*
* Resolution order:
* 1. An explicit operator-supplied `settings.sso.cookieKeys` array use this
* for controlled rotation: `[newKey, ...oldKeys]`.
* 2. The live secrets array of a DB-backed `SecretRotator` (the same mechanism
* the Express session-cookie stack uses). This is the default path: it is
* stable across restarts, shared across horizontally-scaled pods via the
* database, and rotates automatically. The array is returned BY REFERENCE
* so a later in-place rotation propagates to oidc-provider/keygrip without
* reconstructing the provider.
* 3. A value derived from the persisted Etherpad session secret
* (`SESSIONKEY.txt`) via a domain-separated SHA-256 used when rotation is
* disabled but a static session key exists. Stable across restarts/pods,
* never committed to source.
* 4. As a last resort (no key material at all), an ephemeral per-process
* random key. The integrity boundary holds, but interactions won't survive
* a restart or span multiple pods.
*/
export const resolveOidcCookieKeys = (
opts: {cookieKeys?: unknown, rotatedSecrets?: string[] | null, sessionKey?: string | null},
): string[] => {
const {cookieKeys, rotatedSecrets, sessionKey} = opts;
if (Array.isArray(cookieKeys)) {
const usable = cookieKeys.filter((k): k is string => typeof k === 'string' && k.length > 0);
if (usable.length > 0) return usable;
}
// Return the rotator's array by reference (do not copy/filter) so in-place
// rotation is observed live by keygrip.
if (Array.isArray(rotatedSecrets) &&
rotatedSecrets.some((k) => typeof k === 'string' && k.length > 0)) {
return rotatedSecrets;
}
if (typeof sessionKey === 'string' && sessionKey.length > 0) {
const derived = crypto.createHash('sha256')
.update(COOKIE_KEY_DERIVATION_LABEL)
.update(sessionKey)
.digest('hex');
return [derived];
}
return [crypto.randomBytes(32).toString('hex')];
};
/**
* Origin allow-list decision for the embedded OIDC provider's CORS-enabled
* endpoints (`/oidc/token`, `/oidc/me`, ...). oidc-provider invokes
* `clientBasedCORS(ctx, origin, client)` for every cross-origin request;
* returning `true` unconditionally (the historical behavior) reflected ANY
* `Origin` into `Access-Control-Allow-Origin`, letting unregistered origins read
* token/userinfo responses that use non-cookie credentials (Authorization
* headers, POST-body client credentials). Reported by `meifukun`.
*
* An origin is allowed only when it exactly matches the origin (scheme + host +
* port) of one of the client's registered redirect URIs.
*/
export const isOriginAllowedForOidcClient = (
origin: string | undefined | null,
client: {redirectUris?: unknown} | undefined | null,
): boolean => {
if (!origin || !client) return false;
const uris = (client as {redirectUris?: unknown}).redirectUris;
if (!Array.isArray(uris)) return false;
return uris.some((uri: unknown) => {
if (typeof uri !== 'string') return false;
try {
return new URL(uri).origin === origin;
} catch {
return false;
}
});
};

View file

@ -19,6 +19,7 @@
* limitations under the License.
*/
import log4js from 'log4js';
import fs from 'fs';
import path from 'path';
import _ from 'underscore';
@ -30,6 +31,25 @@ const absPathLogger = log4js.getLogger('AbsolutePaths');
*/
let etherpadRoot: string|null = null;
/**
* Walks up the directory tree from `start`, returning the closest ancestor
* directory (including `start` itself) that contains a package.json. Replaces
* the unmaintained `find-root` package, mirroring its semantics: it throws if
* no package.json is found before reaching the filesystem root.
*
* @param {string} start - The directory to start searching from.
* @return {string} The closest ancestor directory containing a package.json.
*/
const findRoot = (start: string): string => {
let dir = start;
for (;;) {
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
const parent = path.dirname(dir);
if (parent === dir) throw new Error('package.json not found in path');
dir = parent;
}
};
/**
* If stringArray's last elements are exactly equal to lastDesiredElements,
* returns a copy in which those last elements are popped, or false otherwise.
@ -79,7 +99,6 @@ export const findEtherpadRoot = () => {
return etherpadRoot;
}
const findRoot = require('find-root');
const foundRoot = findRoot(__dirname);
const splitFoundRoot = foundRoot.split(path.sep);

View file

@ -208,6 +208,17 @@ export const stripRemoteImages = (html: string): string => {
if (VOID_TAGS.has(name)) return;
out += `</${name}>`;
},
// Preserve document-level directives (notably `<!doctype html>`) and
// comments. stripRemoteImages() runs on the FULL export document for the
// soffice path, so dropping the doctype would flip LibreOffice into quirks
// mode. htmlparser2 surfaces the doctype as a processing instruction whose
// `data` is e.g. `!doctype html`.
onprocessinginstruction(name, data) {
out += `<${data}>`;
},
oncomment(data) {
out += `<!--${data}-->`;
},
}, {decodeEntities: false, lowerCaseTags: true});
parser.write(html);
parser.end();

View file

@ -39,11 +39,9 @@ const ROOT_DIR = path.join(settings.root, 'src/static/');
const LIBRARY_WHITELIST = [
'async',
'js-cookie',
'security',
'split-grid',
'tinycon',
'underscore',
'unorm',
];
// What follows is a terrible hack to avoid loop-back within the server.
@ -179,13 +177,18 @@ const _minify = async (req:any, res:any) => {
const plugin = plugins.plugins[library];
const pluginPath = plugin.package.realPath;
filename = path.join(pluginPath, libraryPath);
// On Windows, path.relative converts forward slashes to backslashes. Convert them back
// because some of the code below assumes forward slashes. Node.js treats both the backlash
// and the forward slash characters as pathname component separators on Windows so this does
// not change the meaning of the pathname. This conversion does not introduce a directory
// traversal vulnerability because all '..\\' substrings have already been removed by
// sanitizePathname.
filename = filename.replace(/\\/g, '/');
// On Windows, path.join converts forward slashes to backslashes. Convert them back because
// some of the code below assumes forward slashes. Node.js treats both the backslash and the
// forward slash characters as pathname component separators on Windows so this does not
// change the meaning of the pathname on Windows.
//
// THIS CONVERSION MUST ONLY BE DONE ON WINDOWS. On POSIX systems a backslash is an ordinary
// filename byte, not a separator, so sanitizePathname() deliberately leaves '..\\' segments
// untouched (they are harmless there). Replacing '\\' with '/' unconditionally would turn
// those already-sanitized bytes back into '../' path components *after* the traversal check
// has run, reintroducing a directory-traversal / arbitrary-file-read vulnerability
// (GHSA-mc8w-wjhw-45x5).
if (path.sep === '\\') filename = filename.replace(/\\/g, '/');
} else if (LIBRARY_WHITELIST.indexOf(library) !== -1) {
// Go straight into node_modules
// Avoid `require.resolve()`, since 'mustache' and 'mustache/index.js'

View file

@ -35,7 +35,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {argv} from './Cli'
import jsonminify from 'jsonminify';
import {parse as parseJsonc, printParseErrorCode, ParseError} from 'jsonc-parser';
import log4js from 'log4js';
import {createHash} from 'node:crypto';
import randomString from './randomstring';
@ -116,9 +116,18 @@ const parseSettings = (settingsFilename: string, isSettings: boolean) => {
}
try {
settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}');
// jsonc-parser tolerates comments and trailing commas, so settings files
// can stay annotated. Unlike the old jsonminify + naive ',]'/',}' string
// replace, it fixes *every* trailing comma (not just the first of each
// kind) and never mangles those sequences when they appear inside strings.
const errors: ParseError[] = [];
const settings = parseJsonc(settingsStr, errors, {allowTrailingComma: true});
const settings = JSON.parse(settingsStr);
if (errors.length > 0) {
const {error, offset} = errors[0];
throw new Error(`${printParseErrorCode(error)} at offset ${offset}`);
}
if (settings === undefined) throw new Error('file is empty or not valid JSON');
logger.info(`${settingsType} loaded from: ${settingsFilename}`);
@ -295,6 +304,10 @@ export type SettingsType = {
sso: {
issuer: string,
clients?: {client_id: string}[]
// Optional operator-supplied signing keys for the embedded OIDC provider's
// cookies. When unset, a secret key is derived from the session secret.
// Provide an ordered array `[newKey, ...oldKeys]` to rotate.
cookieKeys?: string[]
},
showSettingsInAdminPage: boolean,
cleanup: {
@ -1253,6 +1266,13 @@ export const reloadSettings = () => {
logger.warn("logLayoutType: " + settings.logLayoutType);
initLogging(settings.logconfig);
if (settings.loadTest) {
logger.warn(
'settings.loadTest is true: SecurityManager.checkAccess() will bypass ' +
'authentication and authorization for both HTTP and socket.io requests. ' +
'Do NOT enable this in production.');
}
if (!settings.skinName) {
logger.warn('No "skinName" parameter found. Please check out settings.json.template and ' +
'update your settings.json. Falling back to the default "colibris".');

View file

@ -67,7 +67,6 @@
, "skiplist.js"
, "colorutils.js"
, "undomodule.js"
, "$unorm/lib/unorm.js"
, "contentcollector.js"
, "changesettracker.js"
, "linestylefilter.js"

View file

@ -38,59 +38,56 @@
"cross-spawn": "^7.0.6",
"dirty-ts": "^1.1.8",
"ejs": "^6.0.1",
"esbuild": "^0.28.0",
"esbuild": "^0.28.1",
"express": "^5.2.1",
"express-rate-limit": "^8.5.1",
"express-rate-limit": "^8.6.0",
"express-session": "^1.19.0",
"find-root": "1.1.0",
"formidable": "^3.5.4",
"html-to-docx": "^1.8.0",
"htmlparser2": "^12.0.0",
"http-errors": "^2.0.1",
"jose": "^6.2.3",
"jose": "^6.2.4",
"js-cookie": "^3.0.8",
"jsdom": "^29.1.1",
"jsonminify": "0.4.2",
"jsonc-parser": "^3.3.1",
"jsonwebtoken": "^9.0.3",
"jwt-decode": "^4.0.0",
"languages4translatewiki": "0.1.3",
"live-plugin-manager": "^1.1.0",
"lodash.clonedeep": "4.5.0",
"log4js": "^6.9.1",
"lru-cache": "^11.5.1",
"lru-cache": "^11.5.2",
"mammoth": "^1.12.0",
"measured-core": "^2.0.0",
"mime-types": "^3.0.2",
"mongodb": "^7.1.1",
"mssql": "^12.5.5",
"mysql2": "^3.22.5",
"nano": "^11.0.5",
"nodemailer": "^8.0.10",
"oidc-provider": "9.8.4",
"openapi-backend": "^5.17.0",
"pdfkit": "^0.19.0",
"pg": "^8.21.0",
"mongodb": "^7.4.0",
"mssql": "^12.6.0",
"mysql2": "^3.23.1",
"nano": "^11.0.6",
"nodemailer": "^9.0.3",
"oidc-provider": "9.10.0",
"openapi-backend": "^5.18.0",
"pdfkit": "^0.19.1",
"pg": "^8.22.0",
"prom-client": "^15.1.3",
"proxy-addr": "^2.0.7",
"rate-limiter-flexible": "^11.2.0",
"redis": "^6.0.0",
"redis": "^6.1.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.3",
"semver": "^7.8.5",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"superagent": "10.3.0",
"surrealdb": "^2.0.3",
"surrealdb": "^2.0.8",
"tinycon": "0.6.8",
"tsx": "4.22.4",
"ueberdb2": "^6.1.9",
"tsx": "4.23.1",
"ueberdb2": "6.1.16",
"underscore": "1.13.8",
"undici": "^8.4.1",
"unorm": "1.6.0",
"undici": "^8.9.0",
"wtfnode": "^0.10.1"
},
"bin": {
@ -98,7 +95,7 @@
"etherpad-lite": "node/server.ts"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@playwright/test": "^1.61.1",
"@types/async": "^3.2.25",
"@types/cookie-parser": "^1.4.10",
"@types/cross-spawn": "^6.0.6",
@ -110,33 +107,32 @@
"@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.2",
"@types/nodemailer": "^8.0.0",
"@types/node": "^26.1.1",
"@types/nodemailer": "^8.0.1",
"@types/oidc-provider": "^9.5.0",
"@types/pdfkit": "^0.17.6",
"@types/semver": "^7.7.1",
"@types/sinon": "^21.0.1",
"@types/supertest": "^7.2.0",
"@types/sinon": "^22.0.0",
"@types/supertest": "^7.2.1",
"@types/underscore": "^1.13.0",
"@types/whatwg-mimetype": "^5.0.0",
"chokidar": "^5.0.0",
"eslint": "^10.4.1",
"eslint": "^10.7.0",
"eslint-config-etherpad": "^4.0.5",
"etherpad-cli-client": "^4.0.3",
"mocha": "^11.7.6",
"mocha-froth": "^0.2.10",
"nodeify": "^1.0.1",
"openapi-schema-validation": "^0.4.2",
"set-cookie-parser": "^3.1.0",
"sinon": "^22.0.0",
"set-cookie-parser": "^3.1.2",
"sinon": "^22.1.0",
"split-grid": "^1.0.11",
"supertest": "^7.2.2",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
"typescript": "^7.0.0",
"vitest": "^4.1.10"
},
"engines": {
"node": ">=24.0.0",
@ -164,6 +160,6 @@
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts",
"test:vitest": "vitest"
},
"version": "3.3.1",
"version": "3.3.3",
"license": "Apache-2.0"
}

View file

@ -214,12 +214,52 @@ body.history-mode #history-controls { display: flex; }
.history-controls button.buttonicon.buttonicon-play.pause::before {
content: "\e829";
}
.history-slider-input {
.history-slider-wrap {
position: relative;
flex: 1 1 auto;
min-width: 80px;
margin: 0 6px;
display: flex;
align-items: center;
}
.history-slider-input {
flex: 1 1 auto;
min-width: 0;
width: 100%;
cursor: pointer;
}
/* Saved-revision markers overlaid on the slider track (issue #7946). Inset
* left/right by ~half the native range thumb so a marker lines up with the
* thumb centre at the track extremes. pointer-events:none on the layer keeps
* slider dragging unobstructed; the stars themselves re-enable clicks to seek. */
.history-slider-stars {
position: absolute;
left: 8px;
right: 8px;
top: 0;
bottom: 0;
pointer-events: none;
}
.history-slider-stars .history-star {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 16px;
height: 16px;
padding: 0;
margin: 0;
border: 0;
background: none;
line-height: 1;
cursor: pointer;
pointer-events: auto;
}
.history-slider-stars .history-star::before {
font-family: fontawesome-etherpad;
content: "\e856";
color: #da9700;
font-size: 14px;
}
.history-timer {
flex: 0 0 auto;
font-size: 12px;
@ -282,7 +322,7 @@ body.history-mode #history-controls { display: flex; }
@media (max-width: 800px) {
.history-controls { padding: 0 6px; gap: 4px; min-height: 36px; }
.history-controls button.buttonicon { padding: 4px 6px; min-width: 32px; }
.history-slider-input { min-width: 60px; margin: 0 2px; }
.history-slider-wrap { min-width: 60px; margin: 0 2px; }
}
@media (max-width: 480px) {
.history-controls #history-leftstep,

View file

@ -13,7 +13,10 @@ import {splitTextLines} from "./Changeset";
*/
class TextLinesMutator {
private _lines: string[];
private _curSplice: [number, number?];
// Args for a future this._lines.splice(): [index, deleteCount, ...linesToInsert].
// Indexing past the two leading numbers yields `string | number` to the compiler even though
// elements from index 2 on are always strings, hence the `as string` casts below.
private _curSplice: [number, number, ...string[]];
private _inSplice: boolean;
private _curLine: number;
private _curCol: number;
@ -132,9 +135,7 @@ class TextLinesMutator {
*/
_putCurLineInSplice() {
if (!this._isCurLineInSplice()) {
// @ts-ignore
this._curSplice.push(this._linesGet(this._curSplice[0] + this._curSplice[1]));
// @ts-ignore
this._curSplice[1]++;
}
// TODO should be the same as this._curSplice.length - 1
@ -211,7 +212,6 @@ class TextLinesMutator {
* @returns {string} joined lines
*/
const nextKLinesText = (k: number) => {
// @ts-ignore
const m = this._curSplice[0] + this._curSplice[1];
return this._linesSlice(m, m + k).join('');
};
@ -219,29 +219,22 @@ class TextLinesMutator {
let removed = '';
if (this._isCurLineInSplice()) {
if (this._curCol === 0) {
// @ts-ignore
removed = this._curSplice[this._curSplice.length - 1];
removed = this._curSplice[this._curSplice.length - 1] as string;
this._curSplice.length--;
removed += nextKLinesText(L - 1);
// @ts-ignore
this._curSplice[1] += L - 1;
} else {
removed = nextKLinesText(L - 1);
// @ts-ignore
this._curSplice[1] += L - 1;
const sline = this._curSplice.length - 1;
// @ts-ignore
removed = this._curSplice[sline].substring(this._curCol) + removed;
// @ts-ignore
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) +
// @ts-ignore
removed = (this._curSplice[sline] as string).substring(this._curCol) + removed;
this._curSplice[sline] = (this._curSplice[sline] as string).substring(0, this._curCol) +
this._linesGet(this._curSplice[0] + this._curSplice[1]);
// @ts-ignore
this._curSplice[1] += 1;
}
} else {
removed = nextKLinesText(L);
this._curSplice[1]! += L;
this._curSplice[1] += L;
}
return removed;
}
@ -260,12 +253,9 @@ class TextLinesMutator {
// although the line is put into splice, curLine is not increased, because
// only some chars are removed not the whole line
const sline = this._putCurLineInSplice();
// @ts-ignore
const removed = this._curSplice[sline].substring(this._curCol, this._curCol + N);
// @ts-ignore
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) +
// @ts-ignore
this._curSplice[sline].substring(this._curCol + N);
const line = this._curSplice[sline] as string;
const removed = line.substring(this._curCol, this._curCol + N);
this._curSplice[sline] = line.substring(0, this._curCol) + line.substring(this._curCol + N);
return removed;
}
@ -275,34 +265,29 @@ class TextLinesMutator {
* @param {string} text - the text to insert
* @param {number} L - number of newlines in text
*/
insert(text: string | any[], L: any) {
insert(text: string, L: number) {
if (!text) return;
if (!this._inSplice) this._enterSplice();
if (L) {
// @ts-ignore
const newLines = splitTextLines(text);
const newLines = splitTextLines(text) ?? [];
if (this._isCurLineInSplice()) {
const sline = this._curSplice.length - 1;
/** @type {string} */
const theLine = this._curSplice[sline];
const theLine = this._curSplice[sline] as string;
const lineCol = this._curCol;
// Insert the chars up to `curCol` and the first new line.
// @ts-ignore
this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0];
this._curLine++;
newLines!.splice(0, 1);
newLines.splice(0, 1);
// insert the remaining new lines
// @ts-ignore
this._curSplice.push(...newLines);
this._curLine += newLines!.length;
this._curLine += newLines.length;
// insert the remaining chars from the "old" line (e.g. the line we were in
// when we started to insert new lines)
// @ts-ignore
this._curSplice.push(theLine.substring(lineCol));
this._curCol = 0; // TODO(doc) why is this not set to the length of last line?
} else {
this._curSplice.push(...newLines);
this._curLine += newLines!.length;
this._curLine += newLines.length;
}
} else {
// There are no additional lines. Although the line is put into splice, curLine is not
@ -315,10 +300,8 @@ class TextLinesMutator {
'https://github.com/ether/etherpad-lite/issues/2802');
console.error(err.stack || err.toString());
}
// @ts-ignore
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + text +
// @ts-ignore
this._curSplice[sline].substring(this._curCol);
const line = this._curSplice[sline] as string;
this._curSplice[sline] = line.substring(0, this._curCol) + text + line.substring(this._curCol);
this._curCol += text.length;
}
}
@ -331,7 +314,6 @@ class TextLinesMutator {
hasMore() {
let docLines = this._linesLength();
if (this._inSplice) {
// @ts-ignore
docLines += this._curSplice.length - 2 - this._curSplice[1];
}
return this._curLine < docLines;

View file

@ -3,24 +3,36 @@
// One rep.line(div) can be broken in more than one line in the browser.
// This function is useful to get the caret position of the line as
// is represented by the browser
//
// NOTE: every DOM lookup here has to go through the editor document that is
// passed in, *not* through the ambient `window` / `document`. This module is
// bundled into the pad's top-level window, while the caret lives in the
// ace_inner iframe nested inside ace_outer. Reading the top window's selection
// always yields an empty selection, which used to make getPosition() return
// null and crash the callers below (#8038).
import {Position, RepModel, RepNode} from "./types/RepModel";
export const getPosition = () => {
const range = getSelectionRange();
// @ts-ignore
if (!range || $(range.endContainer).closest('body')[0].id !== 'innerdocbody') return null;
export const getPosition = (doc: Document): Position | null => {
const range = getSelectionRange(doc);
if (!range || getBodyOf(range.endContainer)?.id !== 'innerdocbody') return null;
// When there's a <br> or any element that has no height, we can't get the dimension of the
// element where the caret is. As we can't get the element height, we create a text node to get
// the dimensions on the position.
const clonedRange = createSelectionRange(range);
const shadowCaret = $(document.createTextNode('|'));
clonedRange.insertNode(shadowCaret[0]);
clonedRange.selectNode(shadowCaret[0]);
const shadowCaret = doc.createTextNode('|');
clonedRange.insertNode(shadowCaret);
clonedRange.selectNode(shadowCaret);
const line = getPositionOfElementOrSelection(clonedRange);
shadowCaret.remove();
return line;
};
// The caret node is usually a text node, so climb to an element first.
const getBodyOf = (node: Node) => {
const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
return element ? element.closest('body') : null;
};
const createSelectionRange = (range: Range) => {
const clonedRange = range.cloneRange();
@ -33,7 +45,7 @@ const createSelectionRange = (range: Range) => {
return clonedRange;
};
const getPositionOfRepLineAtOffset = (node: any, offset: number) => {
const getPositionOfRepLineAtOffset = (node: any, offset: number, doc: Document) => {
// it is not a text node, so we cannot make a selection
if (node.tagName === 'BR' || node.tagName === 'EMPTY') {
return getPositionOfElementOrSelection(node);
@ -43,7 +55,7 @@ const getPositionOfRepLineAtOffset = (node: any, offset: number) => {
node = node.nextSibling as any;
}
const newRange = new Range();
const newRange = doc.createRange();
newRange.setStart(node, offset);
newRange.setEnd(node, offset);
const linePosition = getPositionOfElementOrSelection(newRange);
@ -66,29 +78,31 @@ const getPositionOfElementOrSelection = (element: Range):Position => {
// where is the top of the previous line
// [2] the line before is part of another rep line. It's possible this line has different margins
// height. So we have to get the exactly position of the line
export const getPositionTopOfPreviousBrowserLine = (caretLinePosition: Position, rep: RepModel) => {
let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
const isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep);
export const getPositionTopOfPreviousBrowserLine =
(caretLinePosition: Position, rep: RepModel, doc: Document) => {
let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
const isCaretLineFirstBrowserLine =
caretLineIsFirstBrowserLine(caretLinePosition.top, rep, doc);
// the caret is in the beginning of a rep line, so the previous browser line
// is the last line browser line of the a rep line
if (isCaretLineFirstBrowserLine) { // [2]
const lineBeforeCaretLine = rep.selStart[0] - 1;
const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
const linePosition =
getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep);
previousLineTop = linePosition.top;
}
return previousLineTop;
};
// the caret is in the beginning of a rep line, so the previous browser line
// is the last line browser line of the a rep line
if (isCaretLineFirstBrowserLine) { // [2]
const lineBeforeCaretLine = rep.selStart[0] - 1;
const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
const linePosition =
getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep, doc);
previousLineTop = linePosition.top;
}
return previousLineTop;
};
const caretLineIsFirstBrowserLine = (caretLineTop: number, rep: RepModel) => {
const caretLineIsFirstBrowserLine = (caretLineTop: number, rep: RepModel, doc: Document) => {
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const firstRootNode = getFirstRootChildNode(lineNode);
// to get the position of the node we get the position of the first char
const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1);
const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1, doc);
return positionOfFirstRootNode.top === caretLineTop;
};
@ -101,13 +115,13 @@ const getFirstRootChildNode = (node: RepNode) => {
}
};
const getDimensionOfLastBrowserLineOfRepLine = (line: number, rep: RepModel) => {
const getDimensionOfLastBrowserLineOfRepLine = (line: number, rep: RepModel, doc: Document) => {
const lineNode = rep.lines.atIndex(line).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);
// we get the position of the line in the last char of it
const lastRootChildNodePosition =
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length, doc);
return lastRootChildNodePosition;
};
@ -127,31 +141,32 @@ const getLastRootChildNode = (node: RepNode) => {
// So, we can use the caret line to calculate the bottom of the line.
// [2] the next line is part of another rep line.
// It's possible this line has different dimensions, so we have to get the exactly dimension of it
export const getBottomOfNextBrowserLine = (caretLinePosition: Position, rep: RepModel) => {
let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
const isCaretLineLastBrowserLine =
caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep);
export const getBottomOfNextBrowserLine =
(caretLinePosition: Position, rep: RepModel, doc: Document) => {
let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
const isCaretLineLastBrowserLine =
caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep, doc);
// the caret is at the end of a rep line, so we can get the next browser line dimension
// using the position of the first char of the next rep line
if (isCaretLineLastBrowserLine) { // [2]
const nextLineAfterCaretLine = rep.selStart[0] + 1;
const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
const linePosition =
getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep);
nextLineBottom = linePosition.bottom;
}
return nextLineBottom;
};
// the caret is at the end of a rep line, so we can get the next browser line dimension
// using the position of the first char of the next rep line
if (isCaretLineLastBrowserLine) { // [2]
const nextLineAfterCaretLine = rep.selStart[0] + 1;
const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
const linePosition =
getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep, doc);
nextLineBottom = linePosition.bottom;
}
return nextLineBottom;
};
const caretLineIsLastBrowserLineOfRepLine = (caretLineTop: number, rep: RepModel) => {
const caretLineIsLastBrowserLineOfRepLine = (caretLineTop: number, rep: RepModel, doc: Document) => {
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);
// we take a rep line and get the position of the last char of it
const lastRootChildNodePosition =
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length, doc);
return lastRootChildNodePosition.top === caretLineTop;
};
@ -181,20 +196,21 @@ export const getNextVisibleLine = (line: number, rep: RepModel): number => {
const isLineVisible = (line: number, rep: RepModel) => rep.lines.atIndex(line).lineNode.offsetHeight > 0;
const getDimensionOfFirstBrowserLineOfRepLine = (line: number, rep: RepModel) => {
const getDimensionOfFirstBrowserLineOfRepLine = (line: number, rep: RepModel, doc: Document) => {
const lineNode = rep.lines.atIndex(line).lineNode;
const firstRootChildNode = getFirstRootChildNode(lineNode);
// we can get the position of the line, getting the position of the first char of the rep line
const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1);
const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1, doc);
return firstRootChildNodePosition;
};
const getSelectionRange = () => {
if (!window.getSelection) {
return;
const getSelectionRange = (doc: Document) => {
const win = doc.defaultView;
if (!win || !win.getSelection) {
return null;
}
const selection = window.getSelection();
const selection = win.getSelection();
if (selection && selection.type !== 'None' && selection.rangeCount > 0) {
return selection.getRangeAt(0);
} else {

View file

@ -31,12 +31,13 @@ import Op from "./Op";
const _MAX_LIST_LEVEL = 16;
import AttributeMap from './AttributeMap';
import UNorm from 'unorm';
import {subattribution} from './Changeset';
import {SmartOpAssembler} from "./SmartOpAssembler";
const hooks = require('./pluginfw/hooks');
const sanitizeUnicode = (s) => UNorm.nfc(s);
// NFC-normalize via the native String API (replaces the unmaintained `unorm`
// polyfill; String.prototype.normalize is available in every supported runtime).
const sanitizeUnicode = (s: string) => s.normalize('NFC');
const tagName = (n) => n.tagName && n.tagName.toLowerCase();
// supportedElems are Supported natively within Etherpad and don't require a plugin
const supportedElems = new Set([

View file

@ -149,6 +149,23 @@ const padeditor = (() => {
}
});
// The recovery-token disclosure (#delete-pad-with-token) is rendered for
// every session because a token may now be issued under requireAuthentication
// too (when no getAuthorId hook pins a durable authorID — issue #7926).
// Show it only when the user actually needs a token: when they can already
// delete without one (everyone may delete, or they have a durable
// authenticated identity) the plain "Delete Pad" button suffices, so hide
// the disclosure and all its token wording entirely.
$('#delete-pad-with-token').prop(
'hidden', !!(window as any).clientVars?.canDeleteWithoutToken);
// The plain "Delete pad" button is shown whenever this session can delete
// without a token (creator on this device, or allowPadDeletionByAllUsers).
// It is independent of pad-wide settings so it stays reachable when that
// section is disabled (issue #7959).
$('#delete-pad').prop(
'hidden', !(window as any).clientVars?.canDeletePad);
// delete pad using a recovery token (second device / no creator cookie)
$('#delete-pad-token-submit').on('click', () => {
const token = String($('#delete-pad-token-input').val() || '').trim();

View file

@ -55,6 +55,10 @@ class PadModeController {
private padId: string;
private innerHashChangeHandler: (() => void) | null = null;
private revObserver: MutationObserver | null = null;
// Watches the embedded slider's #ui-slider-bar so saved revisions added live
// (NEW_SAVEDREV from a collaborator while we're in history mode) get mirrored
// onto the outer slider — clientVars only carries the entry-time snapshot.
private savedRevObserver: MutationObserver | null = null;
private syncingHash = false;
// History-mode bridges — populated on enter, torn down on exit.
@ -194,6 +198,11 @@ class PadModeController {
}
this.revLabel.textContent = '';
this.dateLabel.textContent = '';
const stars = document.getElementById('history-slider-stars');
if (stars) {
stars.replaceChildren();
stars.dataset.sig = '';
}
}
// Restore everything entry-time we stashed: chat message visibility, the
@ -248,6 +257,10 @@ class PadModeController {
this.revObserver.disconnect();
this.revObserver = null;
}
if (this.savedRevObserver) {
this.savedRevObserver.disconnect();
this.savedRevObserver = null;
}
if (this.iframe) {
try {
if (this.innerHashChangeHandler && this.iframe.contentWindow) {
@ -374,6 +387,10 @@ class PadModeController {
playBtn.classList.toggle('pause', playing);
playBtn.setAttribute('aria-pressed', playing ? 'true' : 'false');
}
// Saved-revision markers depend on the slider max, which is only known
// once the inner slider has reported its length — render them here so we
// pick up the correct positions on first sync and on any max change.
this.renderSavedRevisionStars(innerWin);
};
// The hook registered earlier in attachInnerBridges already calls
// onRevChange — piggyback on it for slider input/timer updates by
@ -386,10 +403,87 @@ class PadModeController {
}
BS.onSlider(sync);
sync(BS.getSliderPosition?.() ?? 0);
// Now that the inner slider exists, watch it for live NEW_SAVEDREV stars.
this.observeInnerSavedRevisions(innerWin);
};
registerSync();
}
// Mirror the embedded timeslider's saved revisions onto the outer slider as
// clickable star markers (issue #7946). The inner slider draws its own stars
// on #ui-slider-bar, but that DOM is hidden in embed mode, so users only see
// the outer #history-slider-input — which had no markers.
//
// The inner #ui-slider-bar .star elements are the live source of truth: the
// timeslider keeps them current as NEW_SAVEDREV messages arrive (each carries
// a `pos` attribute = revNum), whereas clientVars.savedRevisions is only the
// entry-time snapshot. We read positions from those stars and pull labels
// from the snapshot where available. A signature guard keeps this cheap when
// sync() fires on every scrub; positions are percentage-based so they reflow
// on resize for free.
private renderSavedRevisionStars(innerWin: Window): void {
const inner: any = innerWin as any;
const layer = document.getElementById('history-slider-stars');
const sliderInput = document.getElementById('history-slider-input') as HTMLInputElement | null;
if (!layer || !sliderInput || !innerWin.document) return;
const max = Number(sliderInput.max) || 0;
const revNums = Array.from(innerWin.document.querySelectorAll('#ui-slider-bar .star'))
.map((el) => Number(el.getAttribute('pos')))
// max === 0 is a valid single-revision pad: only rev 0 belongs there.
.filter((n) => Number.isFinite(n) && n >= 0 && (max === 0 ? n === 0 : n <= max));
if (revNums.length === 0 || max < 0) {
if (layer.childElementCount) layer.replaceChildren();
layer.dataset.sig = '';
return;
}
// Labels live in the clientVars snapshot, keyed by revNum.
const labels = new Map<number, string>();
const snapshot = inner.clientVars?.savedRevisions;
if (Array.isArray(snapshot)) {
for (const r of snapshot) {
const n = Number(r && r.revNum);
if (Number.isFinite(n) && r && typeof r.label === 'string' && r.label) labels.set(n, r.label);
}
}
const sig = `${max}:${[...revNums].sort((a, b) => a - b).join(',')}`;
if (layer.dataset.sig === sig) return;
layer.dataset.sig = sig;
layer.replaceChildren();
for (const revNum of revNums) {
const frac = max === 0 ? 0 : revNum / max;
// A purely visual marker (the layer is aria-hidden): keyboard/screen
// reader users already reach any revision via the slider and step
// buttons, so we mirror the legacy timeslider's mouse-only stars rather
// than inject extra tab stops. The hover title aids mouse users; the
// click is a convenience to jump straight to the saved point.
const star = document.createElement('span');
star.className = 'history-star';
star.style.left = `${(frac * 100).toFixed(4)}%`;
star.title = labels.get(revNum) || `Revision ${revNum}`;
star.addEventListener('click', () => {
try { inner.BroadcastSlider?.setSliderPosition?.(revNum); } catch (_e) { /* inner gone */ }
});
layer.appendChild(star);
}
}
// Re-render the outer markers whenever the embedded slider adds a star
// (NEW_SAVEDREV). Observing the inner #ui-slider-bar covers saved revisions
// created live while history mode is open, which sync()'s scrub-driven
// callback would otherwise miss until the next slider move.
private observeInnerSavedRevisions(innerWin: Window): void {
if (this.savedRevObserver) return;
const bar = innerWin.document && innerWin.document.getElementById('ui-slider-bar');
if (!bar) return;
this.savedRevObserver = new MutationObserver(() => { this.renderSavedRevisionStars(innerWin); });
this.savedRevObserver.observe(bar, {childList: true});
}
// Capture the live state we'll restore on exit: live chat message
// visibility (just the timestamps — actual messages stay), live users
// panel HTML, and current Export hrefs.

View file

@ -24,7 +24,7 @@ import {binarySearch} from "./ace2_common";
* limitations under the License.
*/
const Security = require('security');
import * as Security from './security';
import jsCookie, {CookiesStatic} from 'js-cookie'
/**

View file

@ -1,5 +1,5 @@
import {getBottomOfNextBrowserLine, getNextVisibleLine, getPosition, getPositionTopOfPreviousBrowserLine, getPreviousVisibleLine} from './caretPosition';
import {Position, RepModel, RepNode, WindowElementWithScrolling} from "./types/RepModel";
import {Position, RepModel, RepNode} from "./types/RepModel";
class Scroll {
@ -18,6 +18,15 @@ class Scroll {
this.rootDocument = document;
}
// `outerWin` is the ace_outer *iframe element*, not a window — this module is
// bundled into the pad's top-level window and ace2_inner.ts hands us the
// element. Scrolling therefore has to go through its contentWindow: calling
// scrollTo()/scrollBy() on the element itself silently does nothing, because
// an iframe element has no scrollable box of its own.
_getOuterWin(): Window | null {
return this.outerWin.contentWindow;
};
scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(rep: RepModel, isScrollableEvent: boolean, innerHeight: number) {
// are we placing the caret on the line at the bottom of viewport?
// And if so, do we need to scroll the editor, as defined on the settings.json?
@ -52,7 +61,19 @@ class Scroll {
}
}
// The editor document, i.e. the document of the ace_inner iframe nested
// inside ace_outer. This module runs in the pad's top-level window, so every
// caret measurement has to be made against this document rather than the
// ambient one. Resolved on demand because browsers (Firefox in particular)
// may replace an iframe's document after load.
_getInnerDoc(): Document | null {
const innerFrame = this.doc.getElementsByName('ace_inner')[0] as HTMLIFrameElement | undefined;
return innerFrame ? innerFrame.contentDocument : null;
};
_isCaretAtTheBottomOfViewport(rep: RepModel) {
const innerDoc = this._getInnerDoc();
if (!innerDoc) return false;
// computing a line position using getBoundingClientRect() is expensive.
// (obs: getBoundingClientRect() is called on caretPosition.getPosition())
// To avoid that, we only call this function when it is possible that the
@ -66,9 +87,11 @@ class Scroll {
this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) {
// check if the caret is in the bottom of the viewport
const caretLinePosition = getPosition()!;
const caretLinePosition = getPosition(innerDoc);
// no caret in the pad (e.g. focus is outside the editor), so nothing to scroll to
if (!caretLinePosition) return false;
const viewportBottom = this._getViewPortTopBottom().bottom;
const nextLineBottom = getBottomOfNextBrowserLine(caretLinePosition, rep);
const nextLineBottom = getBottomOfNextBrowserLine(caretLinePosition, rep, innerDoc);
return nextLineBottom > viewportBottom;
}
return false;
@ -124,9 +147,9 @@ class Scroll {
};
_getScrollXY() {
const win = this.outerWin as WindowElementWithScrolling;
const win = this._getOuterWin();
const odoc = this.doc;
if (typeof (win.pageYOffset) === 'number') {
if (win && typeof (win.pageYOffset) === 'number') {
return {
x: win.pageXOffset,
y: win.pageYOffset,
@ -139,29 +162,33 @@ class Scroll {
y: docel.scrollTop,
};
}
return {x: 0, y: 0};
};
getScrollX() {
return this._getScrollXY()!.x;
return this._getScrollXY().x;
};
getScrollY () {
return this._getScrollXY()!.y;
return this._getScrollXY().y;
};
setScrollX(x: number) {
this.outerWin.scrollTo(x, this.getScrollY());
this.setScrollXY(x, this.getScrollY());
};
setScrollY(y: number) {
this.outerWin.scrollTo(this.getScrollX(), y);
this.setScrollXY(this.getScrollX(), y);
};
setScrollXY(x: number, y: number) {
this.outerWin.scrollTo(x, y);
const win = this._getOuterWin();
if (win) win.scrollTo(x, y);
};
_isCaretAtTheTopOfViewport(rep: RepModel) {
const innerDoc = this._getInnerDoc();
if (!innerDoc) return false;
const caretLine = rep.selStart[0];
const linePrevCaretLine = caretLine - 1;
const firstLineVisibleBeforeCaretLine =
@ -171,16 +198,18 @@ class Scroll {
const lineBeforeCaretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
const caretLinePosition = getPosition(); // get the position of the browser line
const caretLinePosition = getPosition(innerDoc); // get the position of the browser line
// no caret in the pad (e.g. focus is outside the editor), so nothing to scroll to
if (!caretLinePosition) return false;
const viewportPosition = this._getViewPortTopBottom();
const viewportTop = viewportPosition.top;
const viewportBottom = viewportPosition.bottom;
const caretLineIsBelowViewportTop = caretLinePosition!.bottom >= viewportTop;
const caretLineIsAboveViewportBottom = caretLinePosition!.top < viewportBottom;
const caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop;
const caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom;
const caretLineIsInsideOfViewport =
caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
if (caretLineIsInsideOfViewport) {
const prevLineTop = getPositionTopOfPreviousBrowserLine(caretLinePosition!, rep);
const prevLineTop = getPositionTopOfPreviousBrowserLine(caretLinePosition, rep, innerDoc);
const previousLineIsAboveViewportTop = prevLineTop < viewportTop;
return previousLineIsAboveViewportTop;
}
@ -229,7 +258,8 @@ class Scroll {
};
_scrollYPageWithoutAnimation(pixelsToScroll: number) {
this.outerWin.scrollBy(0, pixelsToScroll);
const win = this._getOuterWin();
if (win) win.scrollBy(0, pixelsToScroll);
};
_scrollYPageWithAnimation(pixelsToScroll: number, durationOfAnimationToShowFocusline: number) {
@ -260,12 +290,14 @@ class Scroll {
scrollNodeVerticallyIntoView(rep: RepModel, innerHeight: number) {
const innerDoc = this._getInnerDoc();
if (!innerDoc) return;
const viewport = this._getViewPortTopBottom();
// when the selection changes outside of the viewport the browser automatically scrolls the line
// to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now
// So, when the line scrolled gets outside of the viewport we let the browser handle it.
const linePosition = getPosition();
const linePosition = getPosition(innerDoc);
if (linePosition) {
const distanceOfTopOfViewport = linePosition.top - viewport.top;
const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height;
@ -278,9 +310,11 @@ class Scroll {
} else if (caretIsBelowOfViewport) {
// setTimeout is required here as line might not be fully rendered onto the pad
setTimeout(() => {
const outer = window.parent;
// scroll to the very end of the pad outer
outer.scrollTo(0, outer[0].innerHeight);
const outer = this._getOuterWin();
// scroll to the very end of the pad outer. The inner frame is as tall
// as the whole document, so its scrollHeight is the end of the pad;
// scrollTo() clamps to the outer frame's maximum scroll offset.
if (outer) outer.scrollTo(0, innerDoc.documentElement.scrollHeight);
}, 150);
// if the above setTimeout and functionality is removed then hitting an enter
// key while on the last line wont be an optimal user experience

View file

@ -1,20 +1,73 @@
// @ts-nocheck
'use strict';
/**
* Copyright 2009 Google Inc.
* OWASP-style output-escaping helpers.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* Vendored from the `security` npm package (v1.0.0), which has been
* unmaintained since 2012. The implementation below is reproduced verbatim
* (behaviour is byte-identical) so the dependency can be dropped from core.
*
* http://www.apache.org/licenses/LICENSE-2.0
* Original work Copyright (c) 2011 Chad Weider, MIT licensed:
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*/
module.exports = require('security');
const HTML_ENTITY_MAP: {[c: string]: string} = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#x27;',
'/': '&#x2F;',
};
// OWASP Guidelines: &, <, >, ", ' plus forward slash.
const HTML_CHARACTERS_EXPRESSION = /[&"'<>/]/gm;
export const escapeHTML = (text: string) => text && text.replace(HTML_CHARACTERS_EXPRESSION,
(c: string) => HTML_ENTITY_MAP[c] || c);
// OWASP Guidelines: escape all non alphanumeric characters in ASCII space.
const HTML_ATTRIBUTE_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]/gm;
export const escapeHTMLAttribute = (text: string) => text && text.replace(HTML_ATTRIBUTE_CHARACTERS_EXPRESSION,
(c: string) => HTML_ENTITY_MAP[c] || `&#x${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)};`);
// OWASP Guidelines: escape all non alphanumeric characters in ASCII space.
// Also include line breaks (for literal).
const JAVASCRIPT_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF\u2028\u2029]/gm;
export const encodeJavaScriptIdentifier = (text: string) => text && text.replace(JAVASCRIPT_CHARACTERS_EXPRESSION,
(c: string) => `\\u${(`0000${c.charCodeAt(0).toString(16)}`).slice(-4)}`);
export const encodeJavaScriptString = (text: string) => text && `"${encodeJavaScriptIdentifier(text)}"`;
// This is not great, but it is useful.
// NB: the original `security` package used /"(?:\\.|[^"])*"/, where `[^"]` also
// matches a backslash and so overlaps with `\\.`, causing exponential
// backtracking (ReDoS) on adversarial input. We exclude the backslash from the
// character class so the two alternatives are mutually exclusive — this matches
// exactly the same well-formed JSON string literals but in linear time.
const JSON_STRING_LITERAL_EXPRESSION = /"(?:[^"\\]|\\.)*"/gm;
export const encodeJavaScriptData = (object: any) => JSON.stringify(object).replace(JSON_STRING_LITERAL_EXPRESSION,
(string: string) => encodeJavaScriptString(JSON.parse(string)));
// OWASP Guidelines: escape all non alphanumeric characters in ASCII space.
const CSS_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]/gm;
export const encodeCSSIdentifier = (text: string) => text && text.replace(CSS_CHARACTERS_EXPRESSION,
(c: string) => `\\${(`000000${c.charCodeAt(0).toString(16)}`).slice(-6)}`);
export const encodeCSSString = (text: string) => text && `"${encodeCSSIdentifier(text)}"`;

View file

@ -159,8 +159,15 @@
</button>
<button type="button" id="history-rightstep" class="buttonicon buttonicon-step-forward">
</button>
<input type="range" id="history-slider-input" class="history-slider-input"
min="0" max="0" value="0" step="1">
<!-- Slider + saved-revision markers. The stars overlay sits above the
range track; pad_mode.ts fills it from the embedded timeslider's
savedRevisions so explicit "Save Revision" points stay visible in
in-pad history mode (issue #7946). -->
<span class="history-slider-wrap">
<input type="range" id="history-slider-input" class="history-slider-input"
min="0" max="0" value="0" step="1">
<span id="history-slider-stars" class="history-slider-stars" aria-hidden="true"></span>
</span>
<span id="history-timer" class="history-timer hide-for-mobile" aria-live="polite"></span>
<!-- Follow toggle: an icon-only button. The eye is always rendered;
the diagonal slash overlay is shown only when aria-pressed is
@ -384,18 +391,24 @@
</p>
<% e.end_block(); %>
</div>
<button class="btn btn-danger" data-l10n-id="pad.settings.deletePad" id="delete-pad">Delete pad</button>
</div><% } %>
</div>
<% if (!settings.requireAuthentication) { %>
<details id="delete-pad-with-token">
<!-- Pad deletion is independent of pad-wide settings (issue #7959):
both controls are always rendered and pad_editor.ts shows exactly
the one that applies. #delete-pad (token-less) is shown when
clientVars.canDeletePad — the creator on this device, or any user
when allowPadDeletionByAllUsers is on. The recovery-token
disclosure is shown when clientVars.canDeleteWithoutToken is false
(issue #7926): a creator on a second device, or under
requireAuthentication without a durable cross-device identity. -->
<button class="btn btn-danger" data-l10n-id="pad.settings.deletePad" id="delete-pad" hidden>Delete pad</button>
<details id="delete-pad-with-token" hidden>
<summary data-l10n-id="pad.deletionToken.deleteWithToken">Delete with token</summary>
<label for="delete-pad-token-input" data-l10n-id="pad.deletionToken.tokenFieldLabel">Pad deletion token</label>
<input type="password" id="delete-pad-token-input" autocomplete="off" spellcheck="false">
<button id="delete-pad-token-submit" type="button" class="btn btn-danger"
data-l10n-id="pad.settings.deletePad">Delete pad</button>
</details>
<% } %>
<h2 data-l10n-id="pad.settings.about">About</h2>
<span data-l10n-id="pad.settings.poweredBy">Powered by</span>
<a href="https://etherpad.org" target="_blank" referrerpolicy="no-referrer" rel="noopener">Etherpad</a>

View file

@ -0,0 +1,128 @@
'use strict';
/**
* Unit coverage for the embedded OIDC provider's cookie-signing key derivation
* and CORS origin allow-list. Both were reported by `meifukun`:
* - the provider historically signed its cookies with the hardcoded key
* `['oidc']`, so anyone with the public source could forge valid `.sig`
* cookies;
* - `clientBasedCORS` returned `true` for every origin, reflecting arbitrary
* `Origin` values into `Access-Control-Allow-Origin`.
*/
const assert = require('assert').strict;
import {
resolveOidcCookieKeys,
isOriginAllowedForOidcClient,
} from '../../../node/security/OidcProviderSecurity';
describe(__filename, function () {
describe('resolveOidcCookieKeys', function () {
it('never returns the historical hardcoded key', function () {
const keys = resolveOidcCookieKeys({sessionKey: 'a-persisted-session-secret'});
assert.ok(!keys.includes('oidc'));
});
it('uses operator-supplied cookieKeys when provided', function () {
const keys = resolveOidcCookieKeys({cookieKeys: ['k1', 'k2'], sessionKey: 'x'});
assert.deepEqual(keys, ['k1', 'k2']);
});
it('ignores empty/invalid entries in cookieKeys and falls through', function () {
const keys = resolveOidcCookieKeys({cookieKeys: ['', null as any], sessionKey: 'secret'});
assert.equal(keys.length, 1);
assert.notEqual(keys[0], 'oidc');
assert.ok(keys[0].length >= 32);
});
it('prefers rotated DB-backed secrets over the session-key derivation', function () {
const rotated = ['rot-new', 'rot-old'];
const keys = resolveOidcCookieKeys({rotatedSecrets: rotated, sessionKey: 'secret'});
assert.deepEqual(keys, ['rot-new', 'rot-old']);
});
it('returns the live rotatedSecrets array by reference (so rotation propagates)', function () {
// oidc-provider/keygrip holds the array by reference and reads it live on
// each sign/verify, so returning the same object means a rotation that
// mutates the array in place is picked up without reconstructing the provider.
const rotated = ['rot-new'];
const keys = resolveOidcCookieKeys({rotatedSecrets: rotated, sessionKey: 'secret'});
assert.strictEqual(keys, rotated);
});
it('ignores an empty rotatedSecrets array and falls through', function () {
const keys = resolveOidcCookieKeys({rotatedSecrets: [], sessionKey: 'secret'});
assert.equal(keys.length, 1);
assert.notEqual(keys[0], 'oidc');
// fell through to the session-key derivation (stable, deterministic)
assert.deepEqual(keys, resolveOidcCookieKeys({sessionKey: 'secret'}));
});
it('lets operator cookieKeys win over rotated secrets', function () {
const keys = resolveOidcCookieKeys({
cookieKeys: ['operator'], rotatedSecrets: ['rot'], sessionKey: 'secret',
});
assert.deepEqual(keys, ['operator']);
});
it('derives a stable key from the session secret (survives restart/multi-pod)', function () {
const a = resolveOidcCookieKeys({sessionKey: 'secret'});
const b = resolveOidcCookieKeys({sessionKey: 'secret'});
assert.deepEqual(a, b);
});
it('derives different keys for different session secrets', function () {
const a = resolveOidcCookieKeys({sessionKey: 'secret-a'});
const b = resolveOidcCookieKeys({sessionKey: 'secret-b'});
assert.notDeepEqual(a, b);
});
it('does not reuse the raw session secret as the cookie key', function () {
const keys = resolveOidcCookieKeys({sessionKey: 'secret'});
assert.ok(!keys.includes('secret'));
});
it('falls back to a fresh random key when no session secret exists', function () {
const a = resolveOidcCookieKeys({sessionKey: null});
const b = resolveOidcCookieKeys({sessionKey: null});
assert.equal(a.length, 1);
assert.notEqual(a[0], 'oidc');
assert.ok(a[0].length >= 32);
assert.notDeepEqual(a, b); // random => different each call
});
});
describe('isOriginAllowedForOidcClient', function () {
const client = {
redirectUris: ['https://app.example.com/admin/', 'https://app.example.com/'],
};
it('allows an origin matching a registered redirect URI', function () {
assert.equal(isOriginAllowedForOidcClient('https://app.example.com', client), true);
});
it('rejects an unregistered attacker origin', function () {
assert.equal(isOriginAllowedForOidcClient('https://evil.attacker.com', client), false);
});
it('rejects a look-alike suffix origin (no substring matching)', function () {
assert.equal(isOriginAllowedForOidcClient('https://app.example.com.evil.com', client), false);
});
it('rejects a scheme mismatch (http vs https)', function () {
assert.equal(isOriginAllowedForOidcClient('http://app.example.com', client), false);
});
it('rejects when origin is missing', function () {
assert.equal(isOriginAllowedForOidcClient(undefined, client), false);
});
it('rejects when client is missing', function () {
assert.equal(isOriginAllowedForOidcClient('https://app.example.com', null), false);
});
it('rejects when client has no redirect URIs', function () {
assert.equal(isOriginAllowedForOidcClient('https://app.example.com', {}), false);
});
});
});

View file

@ -0,0 +1,58 @@
'use strict';
// Unit coverage for PadManager.isValidPadId.
//
// isValidPadId is a pure function (a regex test), so this spec just requires
// PadManager and exercises it directly — no running database is needed.
// PadManager's import-time `require`s (DB, Pad, customError) only *define*
// things; the database connection happens lazily in DB.init(), so loading the
// module here has no side effects. This runs under the mocha backend suite
// (`--import=tsx`), where `require()` resolves the `.ts` sources natively.
import {strict as assert} from 'assert';
const padManager = require('../../../node/db/PadManager');
describe(__filename, function () {
describe('isValidPadId', function () {
it('accepts ordinary pad ids', async function () {
for (const id of [
'foo',
'TF-EVC',
'TF-LEC_IP03-EMS-CSM',
'a.b',
'.foo',
'foo.',
"a'b",
'g.s8oes9dhwrvt0zif$bar', // group pad
]) {
assert.equal(padManager.isValidPadId(id), true, `expected "${id}" to be valid`);
}
});
// Regression test for "Cannot GET /p/": a pad id that is a URL dot-segment
// ('.' or '..') is normalised away by the browser per the WHATWG URL
// standard ('/p/.' -> '/p/', '/p/..' -> '/'), so the pad can never be
// opened or exported. Such ids must be rejected. Before the fix
// isValidPadId returned true for both, so this test would fail.
it('rejects URL dot-segment pad ids that would be unreachable', async function () {
assert.equal(padManager.isValidPadId('.'), false);
assert.equal(padManager.isValidPadId('..'), false);
});
it('still rejects empty ids and ids containing "$"', async function () {
assert.equal(padManager.isValidPadId(''), false);
assert.equal(padManager.isValidPadId('a$b'), false);
});
// `:` is the ueberdb key-namespace delimiter (`pad:<id>:revs:<n>`). A pad id
// carrying a `:` can address another pad's internal sub-records, so it must
// never be a valid pad id. Regression for the copyPad/movePad destinationID
// injection (GHSA-wg58-mhwv-35pq).
it('rejects ids containing the ueberdb delimiter ":"', async function () {
assert.equal(padManager.isValidPadId('victim:revs:0'), false);
assert.equal(padManager.isValidPadId('a:b'), false);
assert.equal(padManager.isValidPadId('g.s8oes9dhwrvt0zif$bar:revs:0'), false);
});
});
});

View file

@ -177,4 +177,27 @@ describe(__filename, function () {
assert.ok(!names.includes(corruptId), `corrupt pad still listed: ${JSON.stringify(names)}`);
assert.ok(names.includes(goodId), `good pad missing after delete: ${JSON.stringify(names)}`);
});
// Regression for the isValidPadId tightening that rejects '.' and '..': a
// legacy pad with such an id predates the validation, so it still exists in
// the DB (doesPadExists is true → deletePad takes the "healthy" branch) but
// getPad() now throws on it. deletePad must fall back to the raw key purge
// instead of failing silently, otherwise the orphan is undeletable from the
// admin UI. Before the fallback this `ask()` never gets `results:deletePad`
// and times out.
it("a legacy '.' pad (now an invalid id) can still be deleted", async function () {
this.timeout(30000);
const dotId = '.';
// getPad('.') would now throw, so seed the record directly with a truthy
// `atext` so doesPadExists() returns true and the handler enters the branch
// where getPad() throws.
await db.set(`pad:${dotId}`, {atext: {text: '\n', attribs: ''}, pool: {}, head: -1, savedRevisions: []});
try {
const ack = await ask(socket, 'deletePad', dotId, 'results:deletePad');
assert.equal(ack, dotId, `expected deletePad to ack "${dotId}", got ${JSON.stringify(ack)}`);
} finally {
try { await db.remove(`pad:${dotId}`); } catch { /* ignore */ }
try { padManager.unloadPad(dotId); } catch { /* ignore */ }
}
});
});

View file

@ -80,6 +80,17 @@ describe(__filename, function () {
await callApi('deletePad', {padID: padId});
});
it('createPad returns null deletionToken when allowPadDeletionByAllUsers is on', async function () {
// Anyone can delete the pad with no token at all, so the recovery token is
// pointless — matches the socket/UI path (issue #7926).
settings.allowPadDeletionByAllUsers = true;
const padId = makeId();
const res = await callApi('createPad', {padID: padId});
assert.equal(res.body.code, 0, JSON.stringify(res.body));
assert.equal(res.body.data.deletionToken, null);
await callApi('deletePad', {padID: padId});
});
it('JWT admin call (no deletionToken) still works — admins stay trusted', async function () {
const padId = makeId();
await callApi('createPad', {padID: padId});

View file

@ -0,0 +1,57 @@
'use strict';
/**
* Regression for GHSA-wg58-mhwv-35pq: copyPad/movePad/copyPadWithoutHistory
* accepted a `destinationID` containing the ueberdb key delimiter `:`
* (e.g. `victim:revs:0`), which bypassed the force=false overwrite guard and
* clobbered another pad's internal revision records.
*/
const assert = require('assert').strict;
const common = require('../common');
const api = require('../../../node/db/API');
const padManager = require('../../../node/db/PadManager');
describe(__filename, function () {
this.timeout(30000);
before(async function () {
await common.init();
});
it('rejects a copyPad destinationID that targets another pad\'s revs record', async function () {
const victim = 'wg58_victim';
const src = 'wg58_src';
await padManager.getPad(victim, 'TOP-SECRET-victim-content\n');
await padManager.getPad(src, 'attacker source\n');
const before = await api.getRevisionChangeset(victim, '0');
assert.ok(before, 'victim rev-0 changeset exists before the attack');
// force=false — the whole point is that `:` bypassed the existence guard.
await assert.rejects(
api.copyPad(src, `${victim}:revs:0`, false),
/valid padId|apierror/i,
'copyPad must reject a destinationID containing ":"');
const after = await api.getRevisionChangeset(victim, '0');
assert.equal(after, before, 'victim rev-0 changeset must be untouched');
});
it('rejects copyPadWithoutHistory with a ":" destinationID', async function () {
const src = 'wg58_src2';
await padManager.getPad(src, 'src2\n');
await assert.rejects(
api.copyPadWithoutHistory(src, 'other:revs:0', false),
/valid padId|apierror/i);
});
it('still allows a normal copyPad to a clean destination', async function () {
const src = 'wg58_ok_src';
await padManager.getPad(src, 'hello\n');
// force=true so the test is idempotent across runs (the backend DB persists
// pads between runs); the point here is that a valid destinationID copies.
await api.copyPad(src, 'wg58_ok_dst', true);
assert.ok(await padManager.doesPadExist('wg58_ok_dst'), 'destination pad exists after copy');
});
});

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