Compare commits

...

110 commits

Author SHA1 Message Date
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
93e5bcc1e2 bump version 2026-06-21 18:45:43 +00:00
Etherpad Release Bot
3c90fa07c3 Merge branch 'develop' 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
92e23bf7d4 Merge branch 'develop' 2026-06-10 10:03:40 +00:00
Etherpad Release Bot
4fe94c9b68 Merge branch 'master' into develop 2026-06-10 10:03:40 +00:00
Etherpad Release Bot
59062ce11a bump version 2026-06-10 10:03:40 +00:00
John McLear
fb7e7af6c9
chore(release): prep 3.3.1 — bump ueberdb2 to ^6.1.9 + changelog (#7939)
Bumps ueberdb2 6.1.8 -> 6.1.9 (postgres pool-error handling + TCP
keep-alive for #7878; redis/rethink connection-error handlers so a
dropped DB connection no longer crashes the process) and adds the
3.3.1 CHANGELOG entry required by bin/release.ts before the release
workflow can run.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:48:32 +01:00
John McLear
ddf84fa6f2
fix(admin): don't let one unreadable pad empty the Manage-pads list (#7935) (#7938)
* fix(admin): don't let one unreadable pad empty the Manage-pads list (#7935)

The admin /settings `padLoad` handler hydrates every pad via getPad() to
build the listing (the default lastEdited sort forces a full scan).
findKeys('pad:*','*:*:*') returns every key under the `pad:` prefix,
including legacy/foreign or migration-corrupted records — e.g. a value
stored as a JSON string rather than a pad object, which a botched
dirty.db -> PostgreSQL migration produces. Loading such a record makes
Pad.init throw `Cannot use 'in' operator to search for 'pool'`.

The handler had no try/catch, so one bad record rejected the whole
request: no `results:padLoad` was emitted (the SPA showed an empty
"No results" state forever — the reported symptom) and the unhandled
rejection could exit the server.

Make loadMeta resilient — a failed getPad() logs a warning and returns
zeroed metadata so the bad pad still surfaces for deletion instead of
hiding every other pad — and wrap the handler so it always emits a
terminal reply and never bubbles to the process-level handler.

Tests:
- backend: tests/backend/specs/admin/padLoadResilience.ts asserts a
  corrupt pad:* record no longer hides the readable pads (fails without
  the fix: no results:padLoad reply + server exit).
- e2e: tests/frontend-new/admin-spec/admin_pads_page.spec.ts covers
  create pad -> welcome-page recent-pads -> /admin Manage-pads UI.

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

* fix(admin): sanitize padLoad error logs to avoid leaking pad content

Qodo review: the error logs (and the error field returned to the SPA)
used `${err}` / err.message verbatim. For the corruption case the
TypeError message embeds the raw stored value ("...'pool' in <value>"),
which for a real pad can be document text — so logging it verbatim could
leak content, bloat logs, and let embedded newlines forge log lines.

Add a safeErr() helper (error name + single-line, 120-char-capped
message, control chars stripped) and use it in both the per-pad warning
and the outer handler error log, and for the error field emitted to the
client.

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

* fix(admin): make surfaced corrupt pads deletable from the admin UI

Qodo review: padLoad now surfaces unreadable pad:* records (zeroed
metadata) so admins can delete them, but deletePad's normal path
(doesPadExists -> getPad -> Pad.remove) can't touch such a record:
doesPadExists() returns false for a non-object value and getPad() throws,
so the surfaced pad was effectively undeletable and the handler emitted
nothing.

When doesPadExists() is false but a raw `pad:${id}` value is present,
fall back to a raw key purge: sweep sub-keys (revs/chat/deletionToken/…)
via findKeys, drop the readonly mapping, then padManager.removePad()
(which removes the main key + pad-list + cache entry). Always emit a
terminal results:deletePad reply (including for an already-absent id) so
the UI clears the row instead of silently doing nothing, and wrap the
handler so failures are logged (sanitized) rather than swallowed.

Adds a backend test asserting a surfaced corrupt pad can be deleted and
disappears from the listing.

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

* test(admin): assert corrupt-pad delete via listing, not raw db.get

The delete test probed `db.get('pad:<id>')` for null right after deletePad.
That passed on postgres but failed on the dirty backend (Windows CI):
ueberdb2's per-backend read/write buffering can return the just-removed
value immediately after remove(), so it asserted storage internals rather
than behaviour. The deletion is still durable (same db.remove() every pad
uses; the in-memory pad-list entry is dropped synchronously). Assert the
behavioural outcome instead — the corrupt pad disappears from the padLoad
listing while the good pad remains.

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-10 10:46:37 +01:00
John McLear
f47626049c
fix(pad): don't issue a deletion token (or show its modal) when allowPadDeletionByAllUsers is on (#7929)
When `allowPadDeletionByAllUsers` is true, anyone can delete a pad with no
token at all (handlePadDelete's flagOk branch), so the one-time deletion
token is pointless and the "Save your pad deletion token" modal only
overwhelms users who will never need it.

Gate token issuance on `!settings.allowPadDeletionByAllUsers` so no token
reaches clientVars; the client's showDeletionTokenModalIfPresent() then
returns early and the modal never appears. No new setting — it derives
automatically from the existing one.

Closes #7926.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:55:13 +01:00
dependabot[bot]
1e7c4b8c21
build(deps): bump rate-limiter-flexible from 11.1.1 to 11.2.0 (#7934)
Bumps [rate-limiter-flexible](https://github.com/animir/node-rate-limiter-flexible) from 11.1.1 to 11.2.0.
- [Release notes](https://github.com/animir/node-rate-limiter-flexible/releases)
- [Commits](https://github.com/animir/node-rate-limiter-flexible/compare/v11.1.1...v11.2.0)

---
updated-dependencies:
- dependency-name: rate-limiter-flexible
  dependency-version: 11.2.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-10 09:54:21 +01:00
dependabot[bot]
ad2e8b18a9
build(deps): bump semver from 7.8.2 to 7.8.3 (#7933)
Bumps [semver](https://github.com/npm/node-semver) from 7.8.2 to 7.8.3.
- [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.2...v7.8.3)

---
updated-dependencies:
- dependency-name: semver
  dependency-version: 7.8.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-10 09:54:13 +01:00
dependabot[bot]
12bd199f94
build(deps-dev): bump the dev-dependencies group with 2 updates (#7932)
Bumps the dev-dependencies group with 2 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) and [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser).


Updates `@typescript-eslint/eslint-plugin` from 8.60.1 to 8.61.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.61.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.60.1 to 8.61.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.61.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.61.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-10 09:53:48 +01:00
John McLear
d828185efc
security: escape and integer-coerce numbered-list start attribute (GHSA-f7h5-v9hm-548j) (#7937)
The numbered-list branch of `domline.appendSpan` interpolated the line's
`start` attribute value into an `<ol start=...>` tag unquoted and unescaped,
then assigned the result to `node.innerHTML`. The value comes verbatim from
the attribute pool, which an attacker can populate via a crafted `.etherpad`
import (`ImportEtherpad.setPadRaw` validates attribute keys but not values).
A space-free value such as `1><svg/onload=...>` therefore broke out of the
tag and produced a live element, executing attacker JavaScript in the pad's
origin for every subsequent viewer of the pad or timeslider.

An `<ol>` start is only ever an integer, so coerce it with `Number.parseInt`,
omit the attribute entirely when the value is not a number, and HTML-escape +
quote it (the same `Security.escapeHTMLAttribute` treatment the sibling
`listType` class already gets, and that #7905 applied to the export sink).

Adds a backend regression test that drives the real domline render path
through jsdom and asserts the malicious value cannot produce a live element.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:53:26 +01:00
Stefan Müller
68d322957f
feat: Add test step to check etherpad works offline (#7936) 2026-06-10 09:53:16 +01:00
John McLear
5b146f0b03
fix(pad): show detected language in settings dropdown (#7925) (#7928)
* fix(pad): show detected language in settings dropdown (#7925)

When the UI language was auto-detected from the browser (no language
cookie and no pad-wide lang set), refreshMyViewControls() and
refreshPadSettingsControls() set the language dropdown to
`<option>.lang || 'en'`. Since the detected language lives only in
html10n (not in padOptions/effectiveOptions), the value was undefined
and the dropdown fell back to hardcoded English — even though the pad
UI itself rendered correctly in the detected language.

Fall back to html10n.getLanguage() before 'en' so the dropdown reflects
the language actually rendered. getLanguage() returns the matched
lowercase locale key, which matches the <option value> keys.

Adds a regression test that loads a pad under a de-DE browser locale and
asserts the dropdown reads "Deutsch" without any manual selection.

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

* test(language): assert German toolbar tooltip (Qodo #7928)

The detection-works precondition computed a boolean via locator.evaluate()
but never asserted it, so it could not fail. Assert the bold button's
parent title is the German "Fett (Strg-B)" with toHaveAttribute instead.

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-09 15:23:01 +01:00
John McLear
67c0e391fd
fix(skin): paint the root canvas so iOS dark mode has no white status bar (#7606) (#7931)
The OP still saw a white strip above the dark pad on iOS Safari even with the
theme-color metas in place. theme-color only tints the address-bar chrome;
the status-bar safe area at the very top is painted by iOS from the ROOT
canvas background. The colibris background variants only colour inner
containers (#editorcontainerbox, body descendants), leaving <html> and <body>
transparent (computed rgba(0,0,0,0)) with color-scheme: normal — so the canvas
fell back to the UA light default (white). Android tints its chrome from
theme-color, which is why it looked fine there but iOS did not.

Paint the <html> root per toolbar variant with the toolbar colour (matching
theme-color and the OP's request that the bar match the toolbar) and set
color-scheme so the safe area, toolbar, and address bar are one seamless
colour. Verified in a mobile viewport: dark-OS root background is now
rgb(72,83,101) (#485365, == --super-dark-color, the toolbar colour); light-OS
stays white. Colours mirror skin_toolbar_colors.ts.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:13:11 +01:00
John McLear
b420cf4850
ci(downstream): robust per-client error handling in run-clients.sh (#7927)
* ci(downstream): robust per-client error handling in run-clients.sh (Qodo)

- Move clone/fetch/checkout inside the per-client guarded block with explicit
  '|| exit 1' on every step. set -e is suspended inside a subshell used as an
  '||' operand, so relying on it silently swallowed clone/checkout failures
  (and continued from the wrong cwd); explicit guards make one client's failure
  a per-client fail=1 while the loop continues, and the run exits non-zero.
- Stop suppressing fetch errors; fetch only when the pinned commit isn't already
  reachable, and surface the real error.
- Run manifest commands via 'bash -c' instead of 'eval' (trusted in-repo
  allowlist; avoids double-parsing / leaking into this script's shell).

Verified: two bogus clients -> both reported, loop continues, exit 1; happy
path (cli, no server) -> vectors pass, smoke skips, exit 0.

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

* ci(downstream): pin clients to their merged-commit SHAs

The three client Phase-2 PRs merged; bump each manifest ref from its
pre-merge PR-branch tip (now deleted / unfetchable) to its squash-merge
commit on main:
  etherpad-pad        -> 91620c6 (ether/pad#1)
  etherpad-cli-client -> ebc516e (ether/etherpad-cli-client#136)
  etherpad-desktop    -> ab83da6 (ether/etherpad-desktop#78)

Verified each merged main carries the test entry points
(vectors.rs/smoke.rs; test:vectors/test:smoke scripts).

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

* ci(downstream): run manifest commands under bash strict mode (Qodo)

bash -c spawns a fresh shell without -e/-u/-o pipefail, so a pipeline-stage
failure inside a client's vectorTest/smokeCmd could be masked. Use
'bash -euo pipefail -c' so those failures surface as a non-zero exit.

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-09 14:31:05 +01:00
John McLear
e56a33efd0
ci(downstream): enable pad/cli/desktop smoke clients (Phase 2) (#7924)
Implements the per-kind orchestration (clone @ pinned ref, inject core's
freshly-generated wire-vectors fixture via ETHERPAD_WIRE_VECTORS, run each
client's vectorTest + smokeCmd against the booted server) in a testable
run-clients.sh, and flips the three manifest entries to enabled, pinned to the
commits that carry their Phase 2 tests:
  ether/pad#1, ether/etherpad-cli-client#136, ether/etherpad-desktop#78.

Validated end-to-end locally against a real core: all three clients' vectors +
live smoke pass. Refs should be bumped to the squash-merge commits once those
client PRs land.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:49:42 +01:00
John McLear
860ab68c04
test: downstream client compatibility gate (Phase 1) (#7923)
* docs: design for downstream client compatibility tests

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

* docs: Phase 1 implementation plan for downstream compat tests

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

* test(downstream): add golden wire-vector generator

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

* test(downstream): add committed golden wire-vectors fixture

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

* test(downstream): assert wire-vectors fixture stability + consistency

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

* test(downstream): pin socket.io handshake + USER_CHANGES sequence

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

* test(downstream): snapshot client-facing HTTP API shapes

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

* test(downstream): add client manifest (entries disabled pending Phase 2)

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

* ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold)

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

* ci(downstream): validate settings rewrite + ignore docs/** (Qodo)

Fail fast if the template's port/auth literals drift so a no-op sed can't
silently boot the smoke server on the wrong port/auth. Also ignore docs/**
(not just doc/**) so docs-only PRs don't trigger the boot job.

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-09 11:40:58 +01:00
Etherpad Release Bot
9af3a4de77 Merge branch 'master' into develop 2026-06-09 08:54:33 +00:00
108 changed files with 7153 additions and 2434 deletions

View file

@ -17,4 +17,11 @@ updates:
open-pull-requests-limit: 30
groups:
dev-dependencies:
dependency-type: "development"
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 }}
@ -51,7 +51,7 @@ jobs:
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 }}
@ -107,7 +107,7 @@ jobs:
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 }}
@ -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 }}

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

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.

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,7 +62,7 @@ jobs:
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.ref }}
@ -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 }}
@ -151,6 +151,32 @@ jobs:
docker volume rm ep7718-plugins >/dev/null 2>&1 || true
[ "$ok" = "1" ] || exit 1
-
# Regression test: Etherpad container must boot without external
# network access (e.g., air-gapped host or restrictive runtime policy).
name: Regression — boot with disabled network (--network none)
working-directory: etherpad
run: |
docker run --rm -d \
--network none \
--name test-offline ${{ env.TEST_TAG }}
docker logs -f test-offline &
ok=0
for i in $(seq 1 60); do
status=$(docker container inspect -f '{{.State.Health.Status}}' test-offline 2>/dev/null) || {
echo "container exited prematurely while offline"
docker logs test-offline || true
break
}
case ${status} in
healthy) ok=1; echo "offline boot healthy after $((i*2))s"; break;;
starting) sleep 2;;
*) echo "unexpected status: ${status}"; docker logs test-offline || true; break;;
esac
done
docker rm -f test-offline >/dev/null 2>&1 || true
[ "$ok" = "1" ] || exit 1
build-test-local-plugin:
# Regression coverage for #7687: the Docker image's
# `bin/installLocalPlugins.sh` step runs as the `etherpad` user and
@ -164,7 +190,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: etherpad
-
@ -235,7 +261,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: etherpad
-
@ -332,7 +358,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: etherpad
-
@ -390,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

108
.github/workflows/downstream-smoke.yml vendored Normal file
View file

@ -0,0 +1,108 @@
name: Downstream smoke
# Boots a real Etherpad from the PR and verifies the separate downstream clients
# (Rust terminal editor, Node CLI, desktop/mobile) still round-trip against it.
# Phase 1 lands the boot/healthcheck/self-check/teardown harness + manifest; the
# per-client matrix activates as each client flips `enabled:true` in clients.json.
on:
pull_request:
paths-ignore:
- "doc/**"
- "docs/**"
schedule:
- cron: '0 4 * * *' # nightly against the default branch
permissions:
contents: read
jobs:
smoke:
name: Boot + downstream clients
runs-on: ubuntu-latest
timeout-minutes: 25
env:
PNPM_HOME: ~/.pnpm-store
APIKEY: downstream-smoke-key
steps:
- name: Checkout core (PR)
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: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install dependencies
run: pnpm i --frozen-lockfile
- name: Boot Etherpad on :9003 (apikey auth)
run: |
# The template ships a literal "port": 9001 and sso auth; rewrite both.
# (PORT env is ignored once the settings file specifies a port.)
sed -e 's#"port": 9001,#"port": 9003,#' \
-e 's#${AUTHENTICATION_METHOD:sso}#apikey#' \
settings.json.template > settings.json
# Fail fast if the template format drifted and sed silently no-op'd.
grep -q '"port": 9003,' settings.json \
|| { echo "::error::port rewrite failed — settings.json.template format changed"; exit 1; }
grep -q '"authenticationMethod": "apikey"' settings.json \
|| { echo "::error::auth rewrite failed — settings.json.template format changed"; exit 1; }
printf '%s' "$APIKEY" > APIKEY.txt
pnpm run prod > /tmp/ep.log 2>&1 &
echo $! > /tmp/ep.pid
echo "booted pid $(cat /tmp/ep.pid)"
- name: Wait for healthcheck
run: |
for i in $(seq 1 60); do
if curl -fsS "http://localhost:9003/api/" >/dev/null 2>&1; then
echo "server up after ${i} tries"; exit 0
fi
sleep 2
done
echo "::error::server did not come up"; tail -50 /tmp/ep.log; exit 1
- name: Self-check — authenticated create + read roundtrip
run: |
K="$APIKEY"
curl -fsS "http://localhost:9003/api/1/createPad?apikey=${K}&padID=smoke&text=hi%0A" | tee /tmp/create.json
grep -q '"code":0' /tmp/create.json
curl -fsS "http://localhost:9003/api/1/getText?apikey=${K}&padID=smoke" | tee /tmp/get.json
grep -q '"text":"hi' /tmp/get.json
- name: Generate canonical wire-vectors
run: cd src && pnpm run vectors:gen
# Rust toolchain for the `rust`-kind client (etherpad-pad). Other kinds
# use the node+pnpm already set up above.
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Run enabled downstream clients
env:
SMOKE_URL: http://localhost:9003
SMOKE_APIKEY: ${{ env.APIKEY }}
run: bash src/tests/downstream/run-clients.sh
- name: Teardown (by PID, never pkill)
if: always()
run: |
if [ -f /tmp/ep.pid ]; then
kill "$(cat /tmp/ep.pid)" 2>/dev/null || true
echo "killed $(cat /tmp/ep.pid)"
fi

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

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

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,7 +38,7 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
@ -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,17 +107,14 @@ 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
with:
@ -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 }}
@ -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 }}
@ -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 }}

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

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

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 }}
@ -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,8 +36,16 @@ 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/checkout@v7
- uses: actions/setup-node@v6
with:
# OIDC trusted publishing needs npm >= 11.5.1, which requires
@ -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

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

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 }}
@ -55,7 +55,7 @@ jobs:
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,64 @@
# 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.
### Security
- **Pad editor — escape and integer-coerce the numbered-list `start` attribute (GHSA-f7h5-v9hm-548j, #7937).** A crafted `<ol start>` value flowed unescaped into `domline.ts`, a distinct client-side sink from the export-path fix in 3.3.0's #7905. The value is now integer-coerced and HTML-escaped before it reaches the DOM. A jsdom regression test covers the sink.
### Notable fixes
- **Skin — paint the root canvas so iOS dark mode has no white status bar (#7606 / #7931).** iOS Safari paints the top safe area from the `html` root background, which `theme-color` (an Android address-bar hint) does not affect, so dark-mode pads showed a white status-bar strip on iOS. Colibris now sets the root background and `color-scheme` so the safe area matches the editor.
- **Settings — show the detected language in the dropdown (#7925 / #7928).** The settings language `<select>` did not reflect the language Etherpad had actually auto-detected; it now shows the active selection.
- **Pad — don't issue a deletion token (or show its modal) when `allowPadDeletionByAllUsers` is on (#7929).** With pad deletion open to all users the client still minted a deletion token and surfaced the confirm modal; both are now suppressed in that configuration.
- **Admin — one unreadable pad no longer empties the Manage-pads list (#7935 / #7938).** A single pad that failed to read could throw out of the list-hydration path and blank the entire admin Manage-pads view; the read is now guarded per-pad so the rest of the list still renders.
### Internal / contributor-facing
- **CI — downstream client compatibility gate (#7923 / #7924 / #7927).** A new gate smoke-tests the published `etherpad-pad`, `etherpad-cli`, and `etherpad-desktop` clients against the server build (Phase 1 + Phase 2), with robust per-client error handling in `run-clients.sh` so one client's failure is reported rather than masking the others.
- **CI — verify Etherpad boots offline (#7936).** Adds a test step that confirms a built Etherpad starts with no network access.
### Dependencies
- `ueberdb2` 6.1.8 → 6.1.9 — PostgreSQL pool errors are now handled and TCP keep-alive is enabled (fixes #7878), and the Redis and RethinkDB drivers attach connection-error handlers so a dropped database connection no longer crashes the Etherpad process.
- `semver` 7.8.2 → 7.8.3 (#7933), `rate-limiter-flexible` 11.1.1 → 11.2.0 (#7934), plus a dev-dependencies group update (#7932).
# 3.3.0
3.3 is primarily a security-hardening release. A defence-in-depth pass tightens the HTTP API entry points, switches random-id generation to a CSPRNG, escapes exported `data-*` attributes, and flips the shipped Docker deployment defaults so a fresh install no longer boots with implicit credentials or a trusting proxy. Alongside that, the `ep_*` pad-options passthrough that shipped opt-in in 3.0.0 is now on by default, the in-pad timeslider learns to honour the editor's view settings (authorship colours, font family, line numbers), and a long tail of pad-editor layout, RTL, and URL-encoding fixes lands. The release also carries the root-cause fix for the long-standing Windows backend-test "silent ELIFECYCLE" flake.

View file

@ -1,7 +1,7 @@
{
"name": "admin",
"private": true,
"version": "3.3.0",
"version": "3.3.2",
"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.3",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"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.19",
"@radix-ui/react-toast": "^1.2.19",
"@radix-ui/react-visually-hidden": "^1.2.7",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.60.1",
"@typescript-eslint/parser": "^8.60.1",
"@vitejs/plugin-react": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^8.63.0",
"@typescript-eslint/parser": "^8.63.0",
"@vitejs/plugin-react": "^6.0.3",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"eslint": "^10.4.1",
"eslint": "^10.6.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.23.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-hook-form": "^7.81.0",
"react-i18next": "^17.0.9",
"react-router-dom": "^7.18.1",
"socket.io-client": "^4.8.3",
"tsx": "^4.22.4",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vite": "^8.1.4",
"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.0",
"version": "3.3.2",
"description": "",
"main": "checkAllPads.js",
"directories": {
@ -9,12 +9,12 @@
"dependencies": {
"ep_etherpad-lite": "workspace:../src",
"log4js": "^6.9.1",
"semver": "^7.8.2",
"tsx": "^4.22.4",
"ueberdb2": "^6.1.8"
"semver": "^7.8.5",
"tsx": "^4.23.0",
"ueberdb2": "6.1.16"
},
"devDependencies": {
"@types/node": "^25.9.2",
"@types/node": "^26.1.1",
"@types/semver": "^7.7.1",
"typescript": "^6.0.3"
},

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 |

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.139.0",
"vitepress": "^2.0.0-alpha.18"
},
"scripts": {
"docs:dev": "vitepress dev",

View file

@ -0,0 +1,633 @@
# Downstream Client Compatibility Tests — Phase 1 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a core-side compatibility gate (golden-vector + socket-sequence + HTTP-API-shape contract tests, plus a downstream-smoke workflow scaffold) so a PR against `develop` detects changes that would break the separate downstream clients.
**Architecture:** Layer A — hermetic contract tests run inside core's existing mocha backend suite, anchored to a committed `wire-vectors.json` fixture generated from core's own `Changeset`/`AttributePool`. Layer B — a `downstream-smoke.yml` workflow that boots a real Etherpad on :9003, proves the boot→healthcheck→teardown cycle with a self-check, and is ready to matrix over a `clients.json` manifest as clients register in Phase 2.
**Tech Stack:** TypeScript, mocha (`--import=tsx`), `assert/strict`, supertest + socket.io-client via `common.ts` helpers, GitHub Actions.
**Spec:** `docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md`
**Scope note:** This plan is **Phase 1 only** (all changes in `ether/etherpad`). Phase 2 (wiring each client repo's `test:vectors` + smoke and registering it in the manifest) is one separate plan/PR per client repo and is out of scope here.
---
## File Structure
- Create `src/tests/backend/specs/downstream/generate-vectors.ts` — pure module exporting `generateVectors(): WireVector[]`; the single source of truth for the canonical wire fixtures. Also runnable as a CLI to (re)write the fixture.
- Create `src/tests/fixtures/wire-vectors.json` — committed canonical fixture (generated, never hand-edited).
- Create `src/tests/backend/specs/downstream/wire-vectors.ts` — mocha spec asserting the committed fixture is stable and self-consistent.
- Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts` — mocha spec asserting the socket.io handshake + USER_CHANGES→ACCEPT_COMMIT sequence/shapes.
- Create `src/tests/backend/specs/downstream/wire-http-api.ts` — mocha spec snapshotting client-facing HTTP API response shapes.
- Create `src/tests/downstream/clients.json` — manifest of downstream clients (data; entries `enabled:false` until their Phase-2 smoke lands).
- Create `.github/workflows/downstream-smoke.yml` — boot/healthcheck/self-check/teardown + manifest matrix scaffold.
- Modify `src/package.json` — add `vectors:gen` script.
All backend specs live under `specs/downstream/` so the existing `mocha ... --recursive tests/backend/specs` glob picks them up with zero config change.
---
## Task 1: Golden-vector generator module
**Files:**
- Create: `src/tests/backend/specs/downstream/generate-vectors.ts`
- Test: `src/tests/backend/specs/downstream/wire-vectors.ts` (created in Task 3; this task is tested via Task 2's run)
- [ ] **Step 1: Write the generator module**
Create `src/tests/backend/specs/downstream/generate-vectors.ts`:
```typescript
'use strict';
/**
* Single source of truth for the downstream wire-compatibility fixtures.
*
* Each vector is a self-contained changeset application: given `initialAText`
* and `pool`, applying `changeset` yields `resultAText`. Downstream clients
* (which reimplement Etherpad's changeset/attribpool decoders) consume the
* exact same JSON and must reproduce `resultAText`. See the Phase 1 plan.
*
* Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json:
* pnpm run vectors:gen
*/
import Changeset from '../../../../static/js/Changeset';
import AttributePool from '../../../../static/js/AttributePool';
export type WireVector = {
name: string;
initialText: string;
changeset: string;
pool: ReturnType<AttributePool['toJsonable']>;
resultText: string;
};
const vector = (
name: string,
initialText: string,
build: (pool: AttributePool) => string,
): WireVector => {
const pool = new AttributePool();
const changeset = build(pool);
Changeset.checkRep(changeset);
return {
name,
initialText,
changeset,
pool: pool.toJsonable(),
resultText: Changeset.applyToText(changeset, initialText),
};
};
export const generateVectors = (): WireVector[] => [
vector('plain-insert', 'abc\n', () =>
Changeset.makeSplice('abc\n', 3, 0, 'XYZ')),
vector('plain-delete', 'abcdef\n', () =>
Changeset.makeSplice('abcdef\n', 1, 3, '')),
vector('formatted-insert', 'abc\n', (pool) =>
Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)),
vector('multiline-insert', 'abc\n', () =>
Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')),
vector('attrib-reuse', 'abc\n', (pool) => {
// Two formatted inserts sharing one pool entry exercises pool index reuse.
const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool);
const mid = Changeset.applyToText(cs1, 'abc\n');
const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool);
return Changeset.compose(cs1, cs2, pool);
}),
];
// CLI entry: write the canonical fixture to disk.
if (require.main === module) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path');
const out = path.join(__dirname, '../../../fixtures/wire-vectors.json');
fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`);
// eslint-disable-next-line no-console
console.log(`wrote ${out}`);
}
```
- [ ] **Step 2: Add the `vectors:gen` script**
In `src/package.json`, add to `"scripts"` (alongside the existing `test` entry):
```json
"vectors:gen": "tsx tests/backend/specs/downstream/generate-vectors.ts",
```
- [ ] **Step 3: Sanity-run the generator (no fixture committed yet)**
Run from `src/`:
```bash
cd src && pnpm run vectors:gen
```
Expected: prints `wrote .../src/tests/fixtures/wire-vectors.json` and the file exists with 5 vectors. Verify each has non-empty `changeset` and a `resultText` that differs from `initialText`.
- [ ] **Step 4: Commit**
```bash
git add src/tests/backend/specs/downstream/generate-vectors.ts src/package.json
git commit -m "test(downstream): add golden wire-vector generator"
```
---
## Task 2: Commit the generated fixture
**Files:**
- Create: `src/tests/fixtures/wire-vectors.json`
- [ ] **Step 1: Generate the fixture**
Run from `src/`:
```bash
cd src && pnpm run vectors:gen
```
Expected: `src/tests/fixtures/wire-vectors.json` written.
- [ ] **Step 2: Eyeball the fixture**
Open `src/tests/fixtures/wire-vectors.json`. Confirm it is a JSON array of 5 objects, each with keys `name, initialText, changeset, pool, resultText`. The `pool` for `plain-insert`/`plain-delete`/`multiline-insert` has empty `numToAttrib`; `formatted-insert` and `attrib-reuse` contain a `bold,true` entry.
- [ ] **Step 3: Commit**
```bash
git add src/tests/fixtures/wire-vectors.json
git commit -m "test(downstream): add committed golden wire-vectors fixture"
```
---
## Task 3: Fixture stability + self-consistency spec
**Files:**
- Create: `src/tests/backend/specs/downstream/wire-vectors.ts`
- [ ] **Step 1: Write the failing test**
Create `src/tests/backend/specs/downstream/wire-vectors.ts`:
```typescript
'use strict';
/**
* Guards the downstream wire-format contract:
* - the committed fixture exactly matches a fresh regeneration (any drift is a
* deliberate wire change and must be re-generated + reviewed in the same PR), and
* - every vector is internally consistent under core's own Changeset engine.
*/
const assert = require('assert').strict;
import fs from 'fs';
import path from 'path';
import Changeset from '../../../../static/js/Changeset';
import {generateVectors} from './generate-vectors';
const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json');
describe(__filename, function () {
it('committed fixture matches a fresh regeneration', function () {
const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
const fresh = generateVectors();
assert.deepEqual(committed, fresh,
'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result');
});
it('every vector applies to its result under core Changeset', function () {
for (const v of generateVectors()) {
Changeset.checkRep(v.changeset);
assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText,
`vector ${v.name} result mismatch`);
}
});
});
```
- [ ] **Step 2: Run test to verify it passes (fixture already committed)**
Run from `src/`:
```bash
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-vectors.ts
```
Expected: 2 passing.
- [ ] **Step 3: Prove the guard bites (temporary edit)**
Hand-edit one `resultText` in `src/tests/fixtures/wire-vectors.json`, re-run the command above.
Expected: the "committed fixture matches a fresh regeneration" test FAILS. Then `git checkout src/tests/fixtures/wire-vectors.json` to restore.
- [ ] **Step 4: Commit**
```bash
git add src/tests/backend/specs/downstream/wire-vectors.ts
git commit -m "test(downstream): assert wire-vectors fixture stability + consistency"
```
---
## Task 4: Socket message-sequence spec
**Files:**
- Create: `src/tests/backend/specs/downstream/wire-socket-sequence.ts`
Reference for helpers: `src/tests/backend/specs/messages.ts` (`common.connect`, `common.handshake`) and `src/tests/backend/common.ts`.
- [ ] **Step 1: Write the failing test**
Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts`:
```typescript
'use strict';
/**
* Pins the socket.io message sequence + shapes that every realtime client
* depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT.
* A change here is a wire-protocol change that will break downstream clients.
*/
const assert = require('assert').strict;
const common = require('../../common');
const padManager = require('../../../node/db/PadManager');
import AttributePool from '../../../../static/js/AttributePool';
import Changeset from '../../../../static/js/Changeset';
describe(__filename, function () {
let agent: any;
let socket: any;
let padId: string;
before(async function () { agent = await common.init(); });
beforeEach(async function () {
padId = common.randomString();
const pad = await padManager.getPad(padId, 'init\n');
await pad.setText('init\n');
const res = await agent.get(`/p/${padId}`).expect(200);
socket = await common.connect(res);
});
afterEach(async function () {
if (socket != null) socket.close();
socket = null;
});
it('handshake returns CLIENT_VARS with the client-facing shape', async function () {
const {type, data} = await common.handshake(socket, padId);
assert.equal(type, 'CLIENT_VARS');
assert.ok(data.userId, 'CLIENT_VARS.userId missing');
assert.ok(data.collab_client_vars, 'collab_client_vars missing');
assert.equal(typeof data.collab_client_vars.rev, 'number');
assert.ok(data.collab_client_vars.initialAttributedText, 'initialAttributedText missing');
});
it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () {
const {data: clientVars} = await common.handshake(socket, padId);
const rev = clientVars.collab_client_vars.rev;
const pool = new AttributePool();
const cs = Changeset.makeSplice('init\n', 4, 0, '-typed', [], pool);
const accepted = common.waitForSocketEvent(socket, 'message');
socket.emit('message', {
type: 'COLLABROOM',
component: 'pad',
data: {type: 'USER_CHANGES', baseRev: rev, changeset: cs, apool: pool.toJsonable()},
});
const msg: any = await accepted;
assert.equal(msg.type, 'COLLABROOM');
assert.equal(msg.data.type, 'ACCEPT_COMMIT');
assert.equal(msg.data.newRev, rev + 1);
});
});
```
- [ ] **Step 2: Run test to verify it passes**
Run from `src/`:
```bash
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-socket-sequence.ts
```
Expected: 2 passing. If `waitForSocketEvent`'s default 1s timeout is too tight on the ACCEPT_COMMIT, pass a larger `timeoutMs` (its 3rd arg) — e.g. `common.waitForSocketEvent(socket, 'message', 5000)`.
- [ ] **Step 3: Commit**
```bash
git add src/tests/backend/specs/downstream/wire-socket-sequence.ts
git commit -m "test(downstream): pin socket.io handshake + USER_CHANGES sequence"
```
---
## Task 5: HTTP API shape spec
**Files:**
- Create: `src/tests/backend/specs/downstream/wire-http-api.ts`
Reference: `src/tests/backend/specs/api/api.ts` for the `common.init()` agent + API-version pattern.
- [ ] **Step 1: Write the failing test**
Create `src/tests/backend/specs/downstream/wire-http-api.ts`:
```typescript
'use strict';
/**
* Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API
* endpoints downstream clients call to create pads and round-trip text.
*/
const assert = require('assert').strict;
const common = require('../../common');
describe(__filename, function () {
let agent: any;
let apiVersion = 1;
const apiKey = common.apiKey;
const padId = common.randomString();
const ep = (point: string, qs: string) =>
`/api/${apiVersion}/${point}?apikey=${apiKey}&${qs}`;
before(async function () {
agent = await common.init();
const res = await agent.get('/api/').expect(200);
apiVersion = res.body.currentVersion;
});
it('createPad returns the standard {code,message,data} envelope', async function () {
const res = await agent.get(ep('createPad', `padID=${padId}&text=hello%0A`)).expect(200);
assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']);
assert.equal(res.body.code, 0);
});
it('setText + getText round-trips text through the documented shape', async function () {
await agent.get(ep('setText', `padID=${padId}&text=world%0A`)).expect(200);
const res = await agent.get(ep('getText', `padID=${padId}`)).expect(200);
assert.equal(res.body.code, 0);
assert.equal(typeof res.body.data.text, 'string');
assert.equal(res.body.data.text, 'world\n');
});
it('getRevisionsCount exposes a numeric revisions field', async function () {
const res = await agent.get(ep('getRevisionsCount', `padID=${padId}`)).expect(200);
assert.equal(res.body.code, 0);
assert.equal(typeof res.body.data.revisions, 'number');
});
});
```
- [ ] **Step 2: Confirm the apiKey helper name**
Run from `src/`:
```bash
cd src && grep -n "apiKey\|apikey" tests/backend/common.ts | head
```
Expected: a `common.apiKey` export (or similar). If the export is named differently, adjust the `apiKey` reference in the spec to match before running.
- [ ] **Step 3: Run test to verify it passes**
Run from `src/`:
```bash
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-http-api.ts
```
Expected: 3 passing.
- [ ] **Step 4: Commit**
```bash
git add src/tests/backend/specs/downstream/wire-http-api.ts
git commit -m "test(downstream): snapshot client-facing HTTP API shapes"
```
---
## Task 6: Client manifest
**Files:**
- Create: `src/tests/downstream/clients.json`
- [ ] **Step 1: Write the manifest**
Create `src/tests/downstream/clients.json` (SHAs are current `main` HEADs at authoring; `enabled:false` until each client's Phase-2 smoke lands):
```json
[
{
"name": "etherpad-pad",
"repo": "https://github.com/ether/pad.git",
"ref": "31176d64ce746d45349e58ee6c0bb043052c6e66",
"kind": "rust",
"enabled": false,
"vectorTest": "cargo test --test vectors",
"smokeCmd": "cargo test --test smoke -- --ignored"
},
{
"name": "etherpad-cli-client",
"repo": "https://github.com/ether/etherpad-cli-client.git",
"ref": "edbe0bb70971e54514ebea672e4ad9b51fc55bff",
"kind": "node",
"enabled": false,
"vectorTest": "pnpm run test:vectors",
"smokeCmd": "pnpm run test:smoke"
},
{
"name": "etherpad-desktop",
"repo": "https://github.com/ether/etherpad-desktop.git",
"ref": "ad273c119f1926a8390c9908fc91f62fa2cf740f",
"kind": "desktop",
"enabled": false,
"vectorTest": "pnpm run test:vectors",
"smokeCmd": "pnpm run test:smoke"
}
]
```
- [ ] **Step 2: Validate it is well-formed JSON**
Run:
```bash
node -e "const c=require('./src/tests/downstream/clients.json'); console.log(c.length, c.map(x=>x.name).join(','))"
```
Expected: `3 etherpad-pad,etherpad-cli-client,etherpad-desktop`.
- [ ] **Step 3: Commit**
```bash
git add src/tests/downstream/clients.json
git commit -m "test(downstream): add client manifest (entries disabled pending Phase 2)"
```
---
## Task 7: Downstream-smoke workflow
**Files:**
- Create: `.github/workflows/downstream-smoke.yml`
Reference an existing workflow (`.github/workflows/backend-tests.yml`) for the checkout/pnpm/node setup block this repo uses, and copy that setup verbatim into the job below.
- [ ] **Step 1: Write the workflow**
Create `.github/workflows/downstream-smoke.yml`:
```yaml
name: Downstream smoke
on:
pull_request:
schedule:
- cron: '0 4 * * *' # nightly against develop
permissions:
contents: read
jobs:
smoke:
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- name: Checkout core (PR)
uses: actions/checkout@v4
# --- Reuse core's standard node+pnpm setup (copy from backend-tests.yml) ---
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install deps
run: pnpm install --frozen-lockfile
- name: Boot Etherpad on :9003
env:
APIKEY: downstream-smoke-key
run: |
mkdir -p var
echo -n "$APIKEY" > APIKEY.txt
PORT=9003 pnpm run prod &
echo $! > /tmp/ep.pid
- name: Wait for healthcheck
run: |
for i in $(seq 1 60); do
if curl -fsS http://localhost:9003/api/ >/dev/null; then
echo "up"; exit 0
fi
sleep 2
done
echo "server did not come up"; exit 1
- name: Self-check (boot + API roundtrip proves the harness)
run: |
K=downstream-smoke-key
curl -fsS "http://localhost:9003/api/1/createPad?apikey=$K&padID=smoke&text=hi%0A"
curl -fsS "http://localhost:9003/api/1/getText?apikey=$K&padID=smoke" | grep -q '"text":"hi'
- name: Generate canonical wire-vectors
run: cd src && pnpm run vectors:gen
- name: Run enabled downstream clients
run: |
node -e '
const fs=require("fs");
const clients=require("./src/tests/downstream/clients.json").filter(c=>c.enabled);
if(!clients.length){console.log("No clients enabled yet (Phase 1).");process.exit(0);}
fs.writeFileSync("/tmp/clients.json",JSON.stringify(clients));
'
# Phase 2 wires per-kind clone + toolchain + vector injection + smoke here,
# iterating /tmp/clients.json. Until a client is enabled this is a no-op.
- name: Teardown (by PID, never pkill)
if: always()
run: |
if [ -f /tmp/ep.pid ]; then kill "$(cat /tmp/ep.pid)" 2>/dev/null || true; fi
```
- [ ] **Step 2: Confirm the boot command + port env**
Run:
```bash
cd /home/jose/etherpad/etherpad-core-fresh && grep -nE '"prod"|"dev"|"start"' src/package.json
grep -rn "process.env.PORT\|settings.port" src/node/utils/Settings.ts | head
```
Expected: confirm the script that starts a production server and that `PORT`/`APIKEY` are honored (Settings reads `process.env.PORT`). If the runnable script is named differently (e.g. `prod` vs `dev`), update the "Boot Etherpad" step to match. If APIKEY is read from a file rather than env, the `echo ... > APIKEY.txt` line already covers it.
- [ ] **Step 3: Lint the workflow YAML**
Run:
```bash
node -e "require('js-yaml')" 2>/dev/null && npx --yes js-yaml .github/workflows/downstream-smoke.yml >/dev/null && echo "valid yaml" || python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/downstream-smoke.yml')); print('valid yaml')"
```
Expected: `valid yaml`.
- [ ] **Step 4: Commit**
```bash
git add .github/workflows/downstream-smoke.yml
git commit -m "ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold)"
```
---
## Task 8: Full backend-suite run + push
**Files:** none (verification + integration)
- [ ] **Step 1: Run the whole downstream spec group**
Per the "always run backend tests" rule, run the new specs through the real mocha invocation the suite uses, from `src/`:
```bash
cd src && cross-env NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 --extension ts --recursive tests/backend/specs/downstream
```
Expected: all specs in `tests/backend/specs/downstream/` pass (7 tests total across 3 files).
- [ ] **Step 2: Confirm no regression in the fixture guard**
Run from `src/`:
```bash
cd src && pnpm run vectors:gen && git diff --exit-code src/tests/fixtures/wire-vectors.json
```
Expected: exit 0 (regeneration is byte-identical to the committed fixture).
- [ ] **Step 3: Push the branch and open the PR**
```bash
git push -u origin feat/downstream-client-compat-tests
gh pr create --base develop \
--title "test: downstream client compatibility gate (Phase 1)" \
--body "Adds core-side contract tests (golden wire-vectors, socket-sequence, HTTP API shapes) and a downstream-smoke workflow scaffold so PRs detect changes that would break the separate CLI / terminal / desktop clients. Phase 2 wires each client repo's vector+smoke tests and flips its manifest entry to enabled. Spec + plan under docs/superpowers/. Closes nothing; tracks the downstream-compat initiative."
```
- [ ] **Step 4: Watch CI**
Per the "check CI after PRs" rule, wait ~20s then:
```bash
gh pr checks --watch
```
Expected: backend tests green (now including the downstream specs); `Downstream smoke` green (self-check passes, no clients enabled yet). Fix any red before moving on.
---
## Self-Review
**Spec coverage:**
- Layer A golden vectors → Tasks 13. ✅
- Layer A socket sequence → Task 4. ✅
- Layer A HTTP API shapes → Task 5. ✅
- Layer B manifest (pinned SHAs, `enabled` gate) → Task 6. ✅
- Layer B workflow (boot :9003, healthcheck, vector generation, PID teardown, nightly+PR triggers) → Task 7. ✅
- Flakiness mitigations: healthcheck-poll (Task 7 step), PID teardown (Task 7), :9003 (Task 7), no external clients on the gate yet so no flake surface (Task 6 `enabled:false`). ✅
- Phasing: Phase 1 self-contained and green; Phase 2 explicitly out of scope. ✅
**Verification-required tasks** (Task 5 step 2, Task 7 step 2) ask the engineer to confirm the exact `common.apiKey` export name and the production boot script/port handling against the live repo before running — these are real lookups, not placeholders, because those names are repo-version-specific.
**Type consistency:** `WireVector` fields (`name/initialText/changeset/pool/resultText`) are defined in Task 1 and used identically in Tasks 23. `generateVectors()` signature is stable across Tasks 1/3. Manifest keys (`enabled`, `vectorTest`, `smokeCmd`) in Task 6 match the workflow's `.filter(c=>c.enabled)` consumer in Task 7.

View file

@ -0,0 +1,154 @@
# Downstream Client Compatibility Tests — Design
**Date:** 2026-06-09
**Status:** Approved (brainstorming complete)
**Target repo:** `ether/etherpad` (core), branch `develop`
**Downstream repos in scope:** `etherpad-pad` (Rust terminal editor + `etherpad-client` crate), `etherpad-cli-client` (Node/TS CLI), `etherpad-desktop` (Electron desktop + Capacitor mobile)
## Problem
The three downstream clients live in **separate repos** and consume core's wire
protocols rather than importing core as a library:
- **etherpad-cli-client** ships its *own copies* of `Changeset.ts` + `AttributePool.ts`
and talks the socket.io `message` protocol. No test script today.
- **etherpad-pad** (Rust) hand-rolls engine.io v4 / socket.io v4 + changeset
decoding in its `etherpad-client` crate. Has CI + a `mock-socket` test feature.
- **etherpad-desktop** wraps / points at a core server URL. Has vitest + Playwright e2e + CI.
A core PR can change the **HTTP API**, the **socket.io handshake / `message`
sequence**, or the **changeset / attribpool wire format** and break these clients
**silently**, because their CI never runs against the new core. The goal: a PR
against core `develop` (and friends) must detect downstream breakage before merge.
## Decisions (locked during brainstorming)
| Decision | Choice |
|---|---|
| Detection strategy | **Hybrid**: fast contract tests in core (every PR) + downstream smoke (every PR) |
| Smoke cadence | **Every PR** (with strong flakiness mitigations) |
| How CI gets clients | **Git clone at pinned refs**, recorded in a manifest in core |
| Contract depth | **Shared golden vectors** — core generates canonical fixtures, each client decodes the same fixtures with its own decoder |
| Sequencing | **Phase by layer** — Phase 1 all core-side; Phase 2 one client repo at a time |
## Architecture
Two-layer compatibility gate on every core PR:
```
core PR ──┬─► Layer A: Contract tests (hermetic, fast, no network/clients)
│ • golden-vector assertions (changeset/attribpool roundtrip)
│ • socket.io message-sequence test (CLIENT_READY → CLIENT_VARS,
│ USER_CHANGES → ACCEPT_COMMIT / NEW_CHANGES)
│ • HTTP API shape snapshots
└─► Layer B: Downstream smoke (boots a real server, runs real clients)
• build + boot Etherpad from the PR on :9003 with a known API key
• healthcheck-poll until ready
• matrix over manifest: clone client @ pinned ref, set up toolchain,
inject core's freshly-generated vectors, run client `test:vectors`,
run client smoke: connect → create/open pad → write text →
read back via HTTP API → assert equality
• tear down server by PID
```
## Layer A — Contract tests (Phase 1, core)
### Golden vectors
- Generator script: `src/tests/downstream/generate-vectors.ts` (run via a package
script, e.g. `pnpm run vectors:gen`).
- Output fixture: `src/tests/fixtures/wire-vectors.json`. Each record:
`{ name, initialAText, changeset, pool, resultAText }` covering the operation
classes clients must decode: plain insert, delete, format/attrib op,
multi-line insert (char_bank ending in `\n`), attribpool reuse across ops.
- Core test `src/tests/backend/specs/wire-vectors.ts`: regenerate in-memory and
assert it matches the committed fixture exactly (drift requires a deliberate
commit, which is the signal a wire change happened).
### Socket message-sequence test
- `src/tests/backend/specs/wire-socket-sequence.ts`: drive a socket.io client
against the in-process server, assert the handshake message sequence and the
shape of `CLIENT_VARS`, `USER_CHANGES``ACCEPT_COMMIT`, and broadcast
`NEW_CHANGES`. Reuses the existing backend socket test helpers.
### HTTP API shape snapshots
- `src/tests/backend/specs/wire-http-api.ts`: snapshot the response *shapes*
(keys / types, not volatile values) of the API endpoints clients call
(`createPad`, `setText`, `getText`, `getRevisionsCount`, session/auth as needed).
These join the existing `backend-tests.yml` run — no new per-PR job, no new infra.
## Layer B — Downstream smoke (Phase 1 scaffold, Phase 2 per client)
### Manifest
`src/tests/downstream/clients.json`:
```json
[
{ "name": "etherpad-pad", "repo": "https://github.com/ether/pad.git", "ref": "<sha on main>", "kind": "rust", "smokeCmd": "..." },
{ "name": "etherpad-cli-client", "repo": "https://github.com/ether/etherpad-cli-client.git", "ref": "<sha on main>", "kind": "node", "smokeCmd": "..." },
{ "name": "etherpad-desktop", "repo": "https://github.com/ether/etherpad-desktop.git", "ref": "<sha on main>", "kind": "desktop", "smokeCmd": "..." }
]
```
Refs are pinned to a specific commit SHA (not `main`) so a client's own pushes
cannot redden core CI; bumping a ref is a deliberate PR. Current `main` HEADs at
authoring time: pad `31176d6`, cli-client `edbe0bb`, desktop `ad273c1`.
Pinned refs mean a client's *own* breakage never randomly reddens core; picking up
a client fix is a deliberate ref-bump PR. Clients are added to the manifest as
their Phase-2 smoke lands — the workflow only runs what's registered.
### Workflow
`.github/workflows/downstream-smoke.yml` (triggers: `pull_request` + nightly
`schedule` against `develop`):
1. Build core from the PR, install deps.
2. Boot Etherpad on **:9003** with a known `APIKEY` in the background; record PID.
3. Healthcheck-poll the server (bounded timeout) before proceeding.
4. Matrix over manifest entries: clone @ pinned ref → set up toolchain
(node+pnpm / rust / electron+xvfb) → copy core's freshly-generated
`wire-vectors.json` into the client → run `test:vectors` → run smoke.
5. Tear down: kill the recorded **PID** (never `pkill -f`).
### Per-client smoke (Phase 2)
Minimal roundtrip exercising the real protocol end-to-end:
- **etherpad-pad** (`rust`): integration test gated by `ETHERPAD_SMOKE_URL`, using
the real tungstenite socket — connect, open pad, send a changeset, read back
via HTTP `getText`, assert. Plus `cargo test` vector consumer reading the
injected fixture.
- **etherpad-cli-client** (`node`): add a minimal test runner (none today —
`node:test` or vitest), a `test:vectors` decoding the fixture, and a smoke
using the client lib: connect → write → verify via HTTP `getText`.
- **etherpad-desktop** (`desktop`): **headless-light** vitest smoke that points
the shell/webview at the booted URL and roundtrips. The full Electron e2e stays
in desktop's own CI — it is **not** in the core gate. If Electron is
unavoidable here, run under `xvfb-run`.
## Flakiness mitigations (because smoke runs on every PR)
- Healthcheck-poll-with-timeout before any client runs.
- Bounded timeout + 1 retry per client smoke.
- Desktop kept headless-light; heavy Electron e2e excluded from the gate.
- PID-based teardown, never `pkill -f` (would kill the developer's other servers).
- Pinned manifest refs isolate core CI from clients' own breakage.
- Tests that bind a port use **:9003** (9001 is reserved for ad-hoc local use).
## Phasing
- **Phase 1 (core only, lands first, immediately useful):** vector generator +
`wire-vectors.json` + three contract specs + `downstream-smoke.yml` +
`clients.json` manifest. Harness proven with **one** reference client wired in
(the Rust `etherpad-pad`, which already has test infra).
- **Phase 2 (one client repo at a time):** order `etherpad-pad`
`etherpad-cli-client``etherpad-desktop`. Each PR adds that client's
`test:vectors` + smoke and registers it in the core manifest.
## Out of scope
- Replacing clients' existing full e2e suites (they stay in their own repos).
- ep_kaput (excluded from all sweeps per standing instruction).
- Changing the wire protocol itself — this work only *observes* it.
## Success criteria
- A core PR that alters changeset serialization, the socket message sequence, or a
client-facing API shape fails Layer A (contract) and/or Layer B (smoke) before merge.
- Phase 1 lands green on core `develop` with the Rust client wired into the smoke matrix.
- Bumping a client's pinned ref is the only way a client's own changes affect core CI.

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.0",
"version": "3.3.2",
"license": "Apache-2.0",
"pnpm": {
"onlyBuiltDependencies": [

3790
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -21,20 +21,38 @@ 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

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}"
},
/*

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

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

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

@ -868,6 +868,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 +889,7 @@ class Pad {
// save this new saved revision
this.savedRevisions.push(savedRevision);
await this.saveToDatabase();
return savedRevision;
}
getSavedRevisions() {

View file

@ -192,7 +192,15 @@ 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}$/;
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

@ -305,8 +305,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();
@ -622,7 +627,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},
});
}
};
/**
@ -1287,9 +1303,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 when requireAuthentication is on: every creator has a
// stable identity so the cookie/identity path is sufficient.
const padDeletionToken = isCreator && !settings.requireAuthentication
// 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).
// - 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 && !canDeleteWithoutToken
? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId)
: null;
@ -1308,6 +1356,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

@ -11,6 +11,7 @@ import settings, {getEpVersion, getGitCommit, reloadSettings} from '../../utils/
import {getLatestVersion} from '../../utils/UpdateCheck';
import {redactSettings} from '../../utils/AdminSettingsRedact';
const padManager = require('../../db/PadManager');
const db = require('../../db/DB');
const api = require('../../db/API');
import {deleteRevisions} from '../../utils/Cleanup';
@ -26,6 +27,24 @@ const queryPadLimit = 12;
const PAD_HYDRATE_CONCURRENCY = 16;
const logger = log4js.getLogger('adminSettings');
// Errors thrown while reading a pad record can embed the raw stored value
// in their message — e.g. Pad.init's `'pool' in value` TypeError stringifies
// the offending value ("Cannot use 'in' operator to search for 'pool' in
// <value>"). For a corrupt record that value may be actual pad text, so
// logging it verbatim would leak content, bloat the log, and let embedded
// newlines forge log lines. Reduce any error to its name plus a single-line,
// length-capped message before logging.
const safeErr = (err: unknown): string => {
const e = err as {name?: unknown, message?: unknown} | null;
const name = (e && typeof e.name === 'string' && e.name) || 'Error';
const msg = String((e && e.message) ?? err ?? '')
.replace(/[\r\n\t]+/g, ' ')
.replace(/\s{2,}/g, ' ')
.trim()
.slice(0, 120);
return `${name}: ${msg}`;
};
// Concurrency-limited Promise.all replacement. Preserves the input
// order in the returned array (caller slices later). Used by padLoad
// to bound DB reads during hydration.
@ -147,6 +166,7 @@ exports.socketio = (hookName: string, {io}: any) => {
socket.on('padLoad', async (query: PadSearchQuery) => {
try {
const {padIDs} = await padManager.listAllPads();
// ── 1. Pattern filter (cheap, by name only) ─────────────────────
@ -172,13 +192,27 @@ exports.socketio = (hookName: string, {io}: any) => {
const needsFullScan = filter !== 'all' || query.sortBy !== 'padName';
const loadMeta = async (padName: string): Promise<PadQueryResult> => {
const pad = await padManager.getPad(padName);
return {
padName,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(padName).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber(),
};
// A single unreadable record must not take out the whole listing.
// `findKeys('pad:*', '*:*:*')` returns every key under the `pad:`
// prefix, including legacy/foreign or migration-corrupted records
// (e.g. a value stored as a JSON *string* rather than a pad object,
// which makes Pad.init throw `'pool' in value`). Before this guard
// one such key rejected the whole `padLoad` handler — the admin
// "Manage pads" page then showed *no* pads at all (issue #7935) and
// the unhandled rejection could exit the server. Surfacing the bad
// pad with zeroed metadata lets an admin see and delete it instead.
try {
const pad = await padManager.getPad(padName);
return {
padName,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(padName).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber(),
};
} catch (err) {
logger.warn(`padLoad: skipping unreadable pad "${padName}": ${safeErr(err)}`);
return {padName, lastEdited: 0 as any, userCount: 0, revisionNumber: 0};
}
};
// Lazily lifted so we don't load every pad twice on the fast path.
@ -256,16 +290,60 @@ exports.socketio = (hookName: string, {io}: any) => {
const data: {total: number, results?: PadQueryResult[]} = {total, results};
socket.emit('results:padLoad', data);
} catch (err) {
// Never leave the SPA hanging on a missing reply (it would show an
// empty "No results" state forever) and never let this bubble up to
// the process-level unhandledRejection handler, which would exit the
// whole server. Always emit a terminal reply for the request.
logger.error(`padLoad failed: ${safeErr(err)}`);
socket.emit('results:padLoad',
{total: 0, results: [], error: safeErr(err)});
}
})
socket.on('deletePad', async (padId: string) => {
const padExists = await padManager.doesPadExists(padId);
if (padExists) {
logger.info(`Deleting pad: ${padId}`);
const pad = await padManager.getPad(padId);
await pad.remove();
try {
if (await padManager.doesPadExists(padId)) {
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
// this id, or because the record is unreadable (a non-object value
// leaves `value.atext` undefined). The latter is exactly what
// padLoad now surfaces with zeroed metadata — getPad()/Pad.remove()
// would throw on it, so fall back to a raw key purge. Without this
// the surfaced corrupt pad is undeletable from the admin UI.
const raw = await db.get(`pad:${padId}`);
if (raw != null) {
logger.info(`Deleting unreadable pad record via raw key purge: ${padId}`);
// Best-effort sweep of sub-keys (revs/chat/deletionToken/…) and
// the readonly mapping, then the main key + pad-list/cache entry.
const subKeys: string[] = (await db.findKeys(`pad:${padId}:*`, null)) || [];
await Promise.all(subKeys.map((k) => db.remove(k)));
await db.remove(`pad2readonly:${padId}`);
await padManager.removePad(padId);
}
// Always emit a terminal reply (even for an already-absent id) so the
// UI clears the row instead of silently doing nothing.
socket.emit('results:deletePad', padId);
} catch (err) {
logger.error(`deletePad failed for "${padId}": ${safeErr(err)}`);
}
})

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

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

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}`);

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,11 +38,10 @@
"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-session": "^1.19.0",
"find-root": "1.1.0",
"formidable": "^3.5.4",
"html-to-docx": "^1.8.0",
"htmlparser2": "^12.0.0",
@ -50,47 +49,45 @@
"jose": "^6.2.3",
"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.22.6",
"nano": "^11.0.6",
"nodemailer": "^9.0.3",
"oidc-provider": "9.9.1",
"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.1.1",
"redis": "^6.0.0",
"rate-limiter-flexible": "^11.2.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.2",
"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.4",
"tinycon": "0.6.8",
"tsx": "4.22.4",
"ueberdb2": "^6.1.8",
"tsx": "4.23.0",
"ueberdb2": "6.1.16",
"underscore": "1.13.8",
"undici": "^8.4.1",
"unorm": "1.6.0",
"undici": "^8.7.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/sinon": "^22.0.0",
"@types/supertest": "^7.2.0",
"@types/underscore": "^1.13.0",
"@types/whatwg-mimetype": "^5.0.0",
"chokidar": "^5.0.0",
"eslint": "^10.4.1",
"eslint": "^10.6.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",
"set-cookie-parser": "^3.1.1",
"sinon": "^22.0.0",
"split-grid": "^1.0.11",
"supertest": "^7.2.2",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
"vitest": "^4.1.10"
},
"engines": {
"node": ">=24.0.0",
@ -154,6 +150,7 @@
"test-container": "mocha --import=tsx --timeout 30000 --extension ts,js tests/container/specs/api",
"dev": "cross-env NODE_ENV=development node --require tsx/cjs node/server.ts",
"prod": "cross-env NODE_ENV=production node --require tsx/cjs node/server.ts",
"vectors:gen": "node --require tsx/cjs tests/backend/specs/downstream/generate-vectors.ts",
"ts-check": "tsc --noEmit",
"ts-check:watch": "tsc --noEmit --watch",
"test-ui": "cross-env NODE_ENV=production npx playwright test",
@ -163,6 +160,6 @@
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts",
"test:vitest": "vitest"
},
"version": "3.3.0",
"version": "3.3.2",
"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

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

@ -102,12 +102,21 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
postHtml = `</li></ul>${postHtml}`;
} else {
if (start) { // is it a start of a list with more than one item in?
if (Number.parseInt(start[1]) === 1) { // if its the first one at this level?
// The `start` value comes verbatim from the attribute pool (which
// an attacker can populate via a crafted `.etherpad` import), so it
// must never be interpolated raw into the markup. An <ol> start is
// only ever an integer: coerce it and HTML-escape it defensively so
// a value such as `1><svg/onload=...>` cannot break out of the tag.
const startNum = Number.parseInt(start[1]);
if (startNum === 1) { // if its the first one at this level?
// Add start class to DIV node
lineClass = `${lineClass} ` + `list-start-${listType}`;
}
const startAttr = Number.isNaN(startNum)
? ''
: ` start="${Security.escapeHTMLAttribute(String(startNum))}"`;
preHtml +=
`<ol start=${start[1]} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
`<ol${startAttr} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
} else {
// Handles pasted contents into existing lists
preHtml += `<ol class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;

View file

@ -581,7 +581,10 @@ const pad = {
$('#padsettings-options-linenoscheck').prop('checked', view.showLineNumbers !== false);
$('#padsettings-options-rtlcheck').prop('checked', !!view.rtlIsTrue);
$('#padsettings-viewfontmenu').val(view.padFontFamily || '');
$('#padsettings-languagemenu').val(padOptions.lang || 'en');
// When no pad-wide lang is set, reflect the language html10n actually
// detected and rendered (e.g. from the browser) instead of defaulting the
// dropdown to English while the UI is in another language. See #7925.
$('#padsettings-languagemenu').val(padOptions.lang || html10n.getLanguage() || 'en');
$('#padsettings-enforcecheck').prop('checked', !!padOptions.enforceSettings);
$('#padsettings-options-stickychat, #padsettings-options-chatandusers')
.prop('disabled', padOptions.showChat === false);
@ -599,7 +602,10 @@ const pad = {
$('#options-linenoscheck').prop('checked', effectiveOptions.view?.showLineNumbers !== false);
$('#options-rtlcheck').prop('checked', !!effectiveOptions.view?.rtlIsTrue);
$('#viewfontmenu').val(effectiveOptions.view?.padFontFamily || '');
$('#languagemenu').val(effectiveOptions.lang || 'en');
// Fall back to the detected language rather than hardcoded English when the
// user has not explicitly chosen one, so the dropdown matches the rendered
// UI language. See #7925.
$('#languagemenu').val(effectiveOptions.lang || html10n.getLanguage() || 'en');
$('#settings input[id^="options-"]').prop('disabled', disabled);
$('#viewfontmenu, #languagemenu').prop('disabled', disabled);
$('#options-stickychat, #options-chatandusers')

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

@ -133,6 +133,22 @@
box-shadow: 0 0 14px 0px var(--super-dark-color);
}
/* == Root canvas / iOS status-bar safe area (issue #7606) == */
/* The variant rules above only paint inner containers, leaving the <html> root
transparent. iOS Safari fills the status-bar safe area above the page from
the ROOT canvas background (not `theme-color`), so a dark-mode pad showed a
white strip above the dark toolbar. Paint the root with the TOOLBAR colour
(matching `theme-color` and the OP's request that the bar match the toolbar)
so the safe area, toolbar, and address bar are one seamless colour. The
colours mirror toolbarColorForTokens / skin_toolbar_colors.ts. `color-scheme`
keeps UA-painted chrome (overscroll, scrollbars, form controls) in step. */
html.super-light-toolbar, html.light-toolbar { color-scheme: light; }
html.super-dark-toolbar, html.dark-toolbar { color-scheme: dark; }
html.super-light-toolbar { background-color: var(--super-light-color); }
html.light-toolbar { background-color: var(--light-color); }
html.super-dark-toolbar { background-color: var(--super-dark-color); }
html.dark-toolbar { background-color: var(--dark-color); }

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,48 @@
'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);
});
});
});

View file

@ -0,0 +1,203 @@
'use strict';
// Regression test for issue #7935 ("Display issue of notes"): pads exist
// (visible on the welcome page, returned by the API `listAllPads`) but the
// admin "Manage pads" UI shows none.
//
// Root cause: the admin /settings `padLoad` handler hydrates every pad via
// `padManager.getPad()` to build the listing (the default `lastEdited` sort
// forces a full scan). `findKeys('pad:*', '*:*:*')` returns *every* key under
// the `pad:` prefix, including legacy / foreign / migration-corrupted records
// — e.g. a value stored as a JSON *string* rather than a pad object, which is
// exactly what a botched dirty.db → PostgreSQL migration produces. Loading
// such a record makes `Pad.init` throw `Cannot use 'in' operator to search
// for 'pool' in <string>`. Before the fix that single rejection took out the
// whole handler: no `results:padLoad` was ever emitted (the SPA showed an
// empty "No results" state forever) and the unhandled rejection could exit
// the server. The handler now skips unreadable pads (surfacing them with
// zeroed metadata so an admin can still delete them) and always emits a
// terminal reply.
import {strict as assert} from 'assert';
import setCookieParser from 'set-cookie-parser';
const io = require('socket.io-client');
const common = require('../../common');
const settings = require('../../../../node/utils/Settings');
const padManager = require('../../../../node/db/PadManager');
const db = require('../../../../node/db/DB');
const adminSocket = async () => {
settings.users = settings.users || {};
settings.users['test-admin'] = {password: 'test-admin-password', is_admin: true};
const savedRequireAuthentication = settings.requireAuthentication;
settings.requireAuthentication = true;
let res: any;
try {
res = await (common.agent as any)
.get('/admin/')
.auth('test-admin', 'test-admin-password');
} finally {
settings.requireAuthentication = savedRequireAuthentication;
}
const resCookies = setCookieParser.parse(res, {map: true});
const reqCookieHdr = Object.entries(resCookies)
.map(([name, cookie]: [string, any]) =>
`${name}=${encodeURIComponent(cookie.value)}`)
.join('; ');
const socket = io(`${common.baseUrl}/settings`, {
forceNew: true,
query: {cookie: reqCookieHdr},
});
await new Promise<void>((resolve, reject) => {
const onErr = (err: any) => { socket.off('connect', onConn); reject(err); };
const onConn = () => { socket.off('connect_error', onErr); resolve(); };
socket.once('connect', onConn);
socket.once('connect_error', onErr);
});
return socket;
};
const ask = (socket: any, evt: string, payload: any, replyEvt: string, timeoutMs = 10000) =>
new Promise<any>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`no \`${replyEvt}\` reply within ${timeoutMs}ms`)), timeoutMs);
socket.once(replyEvt, (data: any) => { clearTimeout(timer); resolve(data); });
socket.emit(evt, payload);
});
describe(__filename, function () {
let socket: any;
let savedUsers: any;
let savedRequireAuthentication: boolean;
let setupCompleted = false;
const tag = `padLoadResilience-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
const goodId = `${tag}-good`;
const corruptId = `${tag}-corrupt`;
before(async function () {
this.timeout(120000);
await common.init();
savedUsers = settings.users;
savedRequireAuthentication = settings.requireAuthentication;
setupCompleted = true;
try {
socket = await adminSocket();
} catch (err: any) {
console.warn(
`[padLoadResilience] admin socket connect failed (${err && err.message}); ` +
"skipping suite — likely an authenticate-hook plugin rejecting the test's " +
'admin credentials.');
this.skip();
return;
}
// A normal, readable pad — this is what must still show up.
await padManager.getPad(goodId, 'good content\n');
// A pad that enters the pad-name index normally, then has its stored
// value clobbered into a non-object (a JSON string) to mimic a
// migration-corrupted / foreign `pad:*` record. Evicting it from the
// in-memory cache forces the next getPad() to re-read the bad value.
await padManager.getPad(corruptId, 'temp\n');
await db.set(`pad:${corruptId}`, 'corrupt-non-object-value');
padManager.unloadPad(corruptId);
// Sanity-check that the setup actually reproduces the failing read; if
// this stops throwing the test is no longer exercising the bug.
await assert.rejects(padManager.getPad(corruptId),
'expected the corrupted pad record to make getPad throw');
});
after(async function () {
if (socket) socket.disconnect();
if (!setupCompleted) return;
if (settings.users) delete settings.users['test-admin'];
settings.users = savedUsers;
settings.requireAuthentication = savedRequireAuthentication;
for (const id of [goodId, corruptId]) {
try { await db.remove(`pad:${id}`); } catch { /* ignore */ }
try { await db.remove(`pad:${id}:revs:0`); } catch { /* ignore */ }
try { padManager.unloadPad(id); } catch { /* ignore */ }
}
});
it('a single corrupt pad does not hide every other pad (issue #7935)', async function () {
this.timeout(30000);
// The default query the SPA sends on initial load: lastEdited sort forces
// the full-scan hydration path that touches every pad — including the
// corrupt one.
const res = await ask(socket, 'padLoad', {
pattern: tag, offset: 0, limit: 12,
sortBy: 'lastEdited', ascending: false, filter: 'all',
}, 'results:padLoad');
const names = res.results.map((r: any) => r.padName);
assert.ok(names.includes(goodId),
`the readable pad must still be listed; got ${JSON.stringify(names)}`);
// The bad pad is surfaced (zeroed metadata) rather than silently dropped,
// so an admin can see and delete it.
assert.ok(names.includes(corruptId),
`the corrupt pad should surface for deletion; got ${JSON.stringify(names)}`);
assert.equal(res.total, 2, `expected total=2, got ${JSON.stringify(res)}`);
});
it('still replies on the fast path (padName sort) with a corrupt pad present', async function () {
this.timeout(30000);
const res = await ask(socket, 'padLoad', {
pattern: tag, offset: 0, limit: 12,
sortBy: 'padName', ascending: true, filter: 'all',
}, 'results:padLoad');
const names = res.results.map((r: any) => r.padName);
assert.ok(names.includes(goodId), `got ${JSON.stringify(names)}`);
assert.ok(names.includes(corruptId), `got ${JSON.stringify(names)}`);
});
// Runs last: surfacing a corrupt pad is only useful if it can be removed.
// deletePad's normal path (doesPadExists + getPad + Pad.remove) can't touch
// an unreadable record, so it must fall back to a raw key purge.
it('a surfaced corrupt pad can be deleted from the admin UI', async function () {
this.timeout(30000);
const ack = await ask(socket, 'deletePad', corruptId, 'results:deletePad');
assert.equal(ack, corruptId, `expected deletePad to ack "${corruptId}", got ${JSON.stringify(ack)}`);
// Assert the user-facing outcome — the corrupt pad is gone from the
// listing (its pad-list entry was dropped) while the good pad stays.
// (We deliberately don't probe `db.get('pad:<id>')` here: ueberdb2's
// per-backend read/write buffering can still return the just-removed
// value immediately after `remove()`, so that would be a flaky
// storage-internals assertion rather than a behavioural one.)
const res = await ask(socket, 'padLoad', {
pattern: tag, offset: 0, limit: 12,
sortBy: 'padName', ascending: true, filter: 'all',
}, 'results:padLoad');
const names = res.results.map((r: any) => r.padName);
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,62 @@
'use strict';
/*
* Regression test for GHSA-f7h5-v9hm-548j.
*
* The numbered-list branch of `domline.appendSpan` used to interpolate the
* line's `start` attribute value into an `<ol start=...>` tag unquoted and
* unescaped, then assign the result to `node.innerHTML`. The value comes
* verbatim from the attribute pool, which an attacker can populate via a
* crafted `.etherpad` import, so a value such as `1><svg/onload=alert(1)>`
* broke out of the tag and produced a live element -> stored XSS for every
* viewer of the pad/timeslider.
*/
const assert = require('assert').strict;
const domline = require('../../../static/js/domline').domline;
const {lineAttributeMarker} = require('../../../static/js/linestylefilter');
import jsdom from 'jsdom';
// Build the per-span class string exactly as linestylefilter would for a
// numbered-list line marker carrying the given `start` value.
const listCls = (start: string) =>
`${lineAttributeMarker} list:number1 start:${start}`;
// Render a single line-marker span through the real domline sink and return
// the resulting DOM node.
const renderLine = (cls: string) => {
const {window} = new jsdom.JSDOM('<!DOCTYPE html><html><body></body></html>');
const node = domline.createDomLine(true, false, window, window.document);
node.clearSpans();
node.appendSpan('*', cls);
node.finishUpdate();
return node.node as HTMLElement;
};
describe(__filename, function () {
it('does not create a live element from a malicious start value', async function () {
// Space-free payload: satisfies the `\S+` capture the sink matches on.
const node = renderLine(listCls('1><svg/onload=alert(document.domain)>'));
assert.equal(node.querySelector('svg'), null,
'malicious start value must not be parsed into a live <svg> element');
assert.ok(!node.innerHTML.includes('<svg'),
`rendered markup must not contain a raw <svg> tag: ${node.innerHTML}`);
// The numbered list itself still renders; only the bogus value is dropped.
assert.ok(node.querySelector('ol'), 'a numbered list should still render');
});
it('renders a legitimate integer start value safely', async function () {
const node = renderLine(listCls('2'));
const ol = node.querySelector('ol');
assert.ok(ol, 'a numbered list should render');
assert.equal(ol!.getAttribute('start'), '2');
});
it('coerces a non-integer start value away instead of emitting it', async function () {
const node = renderLine(listCls('notanumber'));
const ol = node.querySelector('ol');
assert.ok(ol, 'a numbered list should still render');
assert.equal(ol!.getAttribute('start'), null,
'a non-integer start value must not reach the <ol> start attribute');
});
});

View file

@ -0,0 +1,77 @@
'use strict';
/**
* Single source of truth for the downstream wire-compatibility fixtures.
*
* Each vector is a self-contained changeset application: given `initialText`
* and `pool`, applying `changeset` yields `resultText`. Downstream clients
* (which reimplement Etherpad's changeset/attribpool decoders) consume the
* exact same JSON and must reproduce `resultText`. See the Phase 1 plan
* at docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md.
*
* Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json:
* pnpm run vectors:gen
*/
import * as Changeset from '../../../../static/js/Changeset';
import AttributePool from '../../../../static/js/AttributePool';
export type WireVector = {
name: string;
initialText: string;
changeset: string;
pool: ReturnType<AttributePool['toJsonable']>;
resultText: string;
};
const vector = (
name: string,
initialText: string,
build: (pool: AttributePool) => string,
): WireVector => {
const pool = new AttributePool();
const changeset = build(pool);
Changeset.checkRep(changeset);
return {
name,
initialText,
changeset,
pool: pool.toJsonable(),
resultText: Changeset.applyToText(changeset, initialText),
};
};
export const generateVectors = (): WireVector[] => [
vector('plain-insert', 'abc\n', () =>
Changeset.makeSplice('abc\n', 3, 0, 'XYZ')),
vector('plain-delete', 'abcdef\n', () =>
Changeset.makeSplice('abcdef\n', 1, 3, '')),
vector('formatted-insert', 'abc\n', (pool) =>
Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)),
vector('multiline-insert', 'abc\n', () =>
Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')),
vector('attrib-reuse', 'abc\n', (pool) => {
// Two formatted inserts sharing one pool entry exercises pool index reuse.
const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool);
const mid = Changeset.applyToText(cs1, 'abc\n');
const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool);
return Changeset.compose(cs1, cs2, pool);
}),
];
// CLI entry: write the canonical fixture to disk.
if (require.main === module) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const fs = require('fs');
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path');
const out = path.join(__dirname, '../../../fixtures/wire-vectors.json');
fs.mkdirSync(path.dirname(out), {recursive: true});
fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`);
// eslint-disable-next-line no-console
console.log(`wrote ${out}`);
}

View file

@ -0,0 +1,54 @@
'use strict';
/**
* Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API
* endpoints downstream clients call to create pads and round-trip text.
* Auth in the test harness is via JWT (common.generateJWTToken), matching the
* rest of the api specs see api/createDiffHTML.ts.
*/
const assert = require('assert').strict;
const common = require('../../common');
describe(__filename, function () {
let agent: any;
let apiVersion = 1;
const padId = `wireHttp_${common.randomString()}`;
const endPoint = (point: string) => `/api/${apiVersion}/${point}`;
before(async function () {
agent = await common.init();
const res = await agent.get('/api/').expect(200).expect('Content-Type', /json/);
apiVersion = res.body.currentVersion;
assert(apiVersion);
});
it('createPad returns the standard {code,data,message} envelope', async function () {
const res = await agent.get(`${endPoint('createPad')}?padID=${padId}&text=hello%0A`)
.set('Authorization', await common.generateJWTToken())
.expect(200);
assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']);
assert.equal(res.body.code, 0);
});
it('setText + getText round-trips text through the documented shape', async function () {
await agent.post(endPoint('setText'))
.set('Authorization', await common.generateJWTToken())
.send({padID: padId, text: 'world\n'})
.expect(200);
const res = await agent.get(`${endPoint('getText')}?padID=${padId}`)
.set('Authorization', await common.generateJWTToken())
.expect(200);
assert.equal(res.body.code, 0);
assert.equal(typeof res.body.data.text, 'string');
assert.equal(res.body.data.text, 'world\n');
});
it('getRevisionsCount exposes a numeric revisions field', async function () {
const res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${padId}`)
.set('Authorization', await common.generateJWTToken())
.expect(200);
assert.equal(res.body.code, 0);
assert.equal(typeof res.body.data.revisions, 'number');
});
});

View file

@ -0,0 +1,60 @@
'use strict';
/**
* Pins the socket.io message sequence + shapes that every realtime client
* depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT.
* A change here is a wire-protocol change that will break downstream clients
* (the Rust terminal editor and the Node CLI both speak this sequence by hand).
*/
const assert = require('assert').strict;
const common = require('../../common');
const padManager = require('../../../../node/db/PadManager');
describe(__filename, function () {
let agent: any;
let socket: any;
let pad: any;
let padId: string;
before(async function () { agent = await common.init(); });
beforeEach(async function () {
padId = common.randomString();
pad = await padManager.getPad(padId, 'dummy\n');
await pad.setText('\n'); // ensure the pad exists at a known empty state
const res = await agent.get(`/p/${padId}`).expect(200);
socket = await common.connect(res);
});
afterEach(async function () {
if (socket != null) socket.close();
socket = null;
if (pad != null) await pad.remove();
pad = null;
});
it('handshake returns CLIENT_VARS with the client-facing shape', async function () {
const {type, data} = await common.handshake(socket, padId);
assert.equal(type, 'CLIENT_VARS');
assert.ok(data.userId, 'CLIENT_VARS.userId missing');
assert.ok(data.collab_client_vars, 'collab_client_vars missing');
assert.equal(typeof data.collab_client_vars.rev, 'number');
assert.ok(data.collab_client_vars.initialAttributedText,
'collab_client_vars.initialAttributedText missing');
});
it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () {
const {data: clientVars} = await common.handshake(socket, padId);
const rev = clientVars.collab_client_vars.rev;
const authorId = clientVars.userId;
// Insert ops must carry the session author attribute (`*0+N` + matching
// apool entry) or the server rejects with {disconnect:'badChangeset'}.
const apool = {numToAttrib: {0: ['author', authorId]}, nextNum: 1};
await Promise.all([
common.waitForAcceptCommit(socket, rev + 1),
common.sendUserChanges(socket, {baseRev: rev, changeset: 'Z:1>5*0+5$hello', apool}),
]);
assert.equal(pad.text(), 'hello\n');
});
});

View file

@ -0,0 +1,33 @@
'use strict';
/**
* Guards the downstream wire-format contract:
* - the committed fixture exactly matches a fresh regeneration (any drift is a
* deliberate wire change and must be re-generated + reviewed in the same PR), and
* - every vector is internally consistent under core's own Changeset engine.
*/
const assert = require('assert').strict;
import fs from 'fs';
import path from 'path';
import * as Changeset from '../../../../static/js/Changeset';
import {generateVectors} from './generate-vectors';
const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json');
describe(__filename, function () {
it('committed fixture matches a fresh regeneration', function () {
const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
const fresh = generateVectors();
assert.deepEqual(committed, fresh,
'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result');
});
it('every vector applies to its result under core Changeset', function () {
for (const v of generateVectors()) {
Changeset.checkRep(v.changeset);
assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText,
`vector ${v.name} result mismatch`);
}
});
});

View file

@ -0,0 +1,44 @@
'use strict';
import {MapArrayType} from '../../../node/types/MapType';
import settings from '../../../node/utils/Settings';
const assert = require('assert').strict;
const common = require('../common');
// Regression coverage for issue #7959. The token-less "Delete pad" button
// (#delete-pad) used to be 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. Pad deletion is unrelated to pad-wide
// settings, so the button must be rendered regardless of that flag (its
// visibility is then driven at runtime by clientVars.canDeletePad).
describe(__filename, function () {
this.timeout(30000);
let agent: any;
const backup: MapArrayType<any> = {};
before(async function () { agent = await common.init(); });
beforeEach(async function () {
backup.enablePadWideSettings = settings.enablePadWideSettings;
});
afterEach(async function () {
settings.enablePadWideSettings = backup.enablePadWideSettings;
});
const hasDeletePadButton = (html: string): boolean =>
/id="delete-pad"/.test(html);
it('renders the Delete pad button with pad-wide settings enabled', async function () {
settings.enablePadWideSettings = true;
const res = await agent.get('/p/deleteUiPlacementOn').expect(200);
assert.equal(hasDeletePadButton(res.text), true);
});
it('renders the Delete pad button with pad-wide settings disabled (#7959)', async function () {
settings.enablePadWideSettings = false;
const res = await agent.get('/p/deleteUiPlacementOff').expect(200);
assert.equal(hasDeletePadButton(res.text), true);
});
});

View file

@ -0,0 +1,78 @@
'use strict';
const assert = require('assert').strict;
// The escaping helpers are a client module, but they are pure (no browser
// globals) so they can be exercised directly from a backend spec. This locks
// the byte-for-byte output of the helpers vendored from the (now removed)
// `security` npm package, so the vendoring can never silently drift.
const Security = require('../../../static/js/security');
describe(__filename, function () {
describe('public API', function () {
it('exposes the full set of helpers plugins may rely on', function () {
for (const fn of [
'escapeHTML', 'escapeHTMLAttribute',
'encodeJavaScriptIdentifier', 'encodeJavaScriptString', 'encodeJavaScriptData',
'encodeCSSIdentifier', 'encodeCSSString',
]) {
assert.equal(typeof Security[fn], 'function', `Security.${fn} must be a function`);
}
});
});
describe('escapeHTML', function () {
it('escapes &, <, >, ", \' and / per OWASP', function () {
assert.equal(Security.escapeHTML('<a href="x">/&\''),
'&lt;a href=&quot;x&quot;&gt;&#x2F;&amp;&#x27;');
});
it('neutralises a script tag', function () {
assert.equal(Security.escapeHTML('<script>alert(1)</script>'),
'&lt;script&gt;alert(1)&lt;&#x2F;script&gt;');
});
it('leaves plain alphanumerics untouched', function () {
assert.equal(Security.escapeHTML('Hello World 123'), 'Hello World 123');
});
it('passes falsy input straight through', function () {
assert.equal(Security.escapeHTML(''), '');
});
});
describe('escapeHTMLAttribute', function () {
it('hex-encodes non-alphanumeric ASCII not covered by named entities', function () {
assert.equal(Security.escapeHTMLAttribute('a b'), 'a&#x20;b');
// hex is lowercased, matching the original `security` package output.
assert.equal(Security.escapeHTMLAttribute('javascript:alert(1)'),
'javascript&#x3a;alert&#x28;1&#x29;');
});
it('prefers named entities for &, <, >, ", \', /', function () {
assert.equal(Security.escapeHTMLAttribute('<>&"\'/'),
'&lt;&gt;&amp;&quot;&#x27;&#x2F;');
});
it('leaves alphanumerics untouched', function () {
assert.equal(Security.escapeHTMLAttribute('abcXYZ0189'), 'abcXYZ0189');
});
});
describe('javascript / css encoders', function () {
it('encodeJavaScriptString quotes and backslash-u-escapes specials', function () {
assert.equal(Security.encodeJavaScriptString('a<b'), '"a\\u003cb"');
});
it('encodeCSSString quotes and backslash-escapes specials', function () {
assert.equal(Security.encodeCSSString('a;b'), '"a\\00003bb"');
});
it('encodeJavaScriptData escapes specials inside JSON string literals', function () {
assert.equal(
Security.encodeJavaScriptData({a: '<b>', c: 'x"y', d: 'a\\b'}),
'{"a":"\\u003cb\\u003e","c":"x\\u0022y","d":"a\\u005cb"}');
});
it('encodeJavaScriptData regex is linear (ReDoS guard)', function () {
// The JSON-string-literal regex used to be /"(?:\\.|[^"])*"/, which
// backtracks exponentially on an unterminated string of `\!` repeats.
// Run the regex directly on adversarial input and assert it returns fast.
const evil = `"${'\\!'.repeat(50000)}`; // no closing quote
const start = Date.now();
/"(?:[^"\\]|\\.)*"/gm.test(evil);
assert.ok(Date.now() - start < 1000, 'regex must not backtrack exponentially');
});
});
});

View file

@ -197,4 +197,143 @@ describe(__filename, function () {
assert.strictEqual(settings.padOptions.fadeInactiveAuthorColors, true);
});
});
// Regression test for ether/etherpad#7911.
// Air-gapped / firewalled deployments must be able to disable Etherpad's
// outbound calls (version check + plugin catalogue + self-updater) purely
// via environment variables, without editing settings.json inside the image.
// These assertions parse the *shipped* settings.json.docker so a future edit
// that drops the ${ENV} placeholders fails loudly here.
describe('offline / air-gapped env overrides (issue #7911)', function () {
const dockerSettings = path.join(__dirname, '../../../../settings.json.docker');
const templateSettings = path.join(__dirname, '../../../../settings.json.template');
const envVars = [
'PRIVACY_UPDATE_CHECK', 'PRIVACY_PLUGIN_CATALOG', 'UPDATES_TIER',
'UPDATES_SOURCE', 'UPDATES_CHANNEL', 'UPDATES_CHECK_INTERVAL_HOURS',
'UPDATES_GITHUB_REPO', 'UPDATES_REQUIRE_ADMIN_FOR_STATUS', 'UPDATE_SERVER',
];
const saved: {[k: string]: string | undefined} = {};
before(function () { for (const v of envVars) saved[v] = process.env[v]; });
afterEach(function () {
for (const v of envVars) {
if (saved[v] == null) delete process.env[v];
else process.env[v] = saved[v];
}
});
it('keeps shipped defaults when no env vars are set', function () {
for (const v of envVars) delete process.env[v];
const s = exportedForTestingOnly.parseSettings(dockerSettings, true);
assert.strictEqual(s!.privacy.updateCheck, true);
assert.strictEqual(s!.privacy.pluginCatalog, true);
assert.strictEqual(s!.updates.tier, 'notify');
assert.strictEqual(s!.updates.checkIntervalHours, 6);
assert.strictEqual(s!.updateServer, 'https://etherpad.org/ep_infos');
});
it('disables all outbound calls when the offline env vars are set', function () {
process.env.PRIVACY_UPDATE_CHECK = 'false';
process.env.PRIVACY_PLUGIN_CATALOG = 'false';
process.env.UPDATES_TIER = 'off';
const s = exportedForTestingOnly.parseSettings(dockerSettings, true);
// Coerced to real booleans, not the strings "false".
assert.strictEqual(s!.privacy.updateCheck, false);
assert.strictEqual(s!.privacy.pluginCatalog, false);
assert.strictEqual(s!.updates.tier, 'off');
});
it('honours the remaining updates.* and updateServer overrides', function () {
process.env.UPDATES_SOURCE = 'gitlab';
process.env.UPDATES_CHANNEL = 'beta';
process.env.UPDATES_CHECK_INTERVAL_HOURS = '24';
process.env.UPDATES_GITHUB_REPO = 'acme/etherpad-fork';
process.env.UPDATES_REQUIRE_ADMIN_FOR_STATUS = 'true';
process.env.UPDATE_SERVER = 'https://mirror.internal/ep_infos';
const s = exportedForTestingOnly.parseSettings(dockerSettings, true);
assert.strictEqual(s!.updates.source, 'gitlab');
assert.strictEqual(s!.updates.channel, 'beta');
assert.strictEqual(s!.updates.checkIntervalHours, 24); // numeric coercion
assert.strictEqual(s!.updates.githubRepo, 'acme/etherpad-fork');
assert.strictEqual(s!.updates.requireAdminForStatus, true); // boolean coercion
assert.strictEqual(s!.updateServer, 'https://mirror.internal/ep_infos');
});
// The source-install template carries the same placeholders so non-Docker
// deployments get the offline knobs too.
it('settings.json.template exposes the same offline overrides', function () {
for (const v of envVars) delete process.env[v];
const dflt = exportedForTestingOnly.parseSettings(templateSettings, true);
assert.strictEqual(dflt!.privacy.updateCheck, true);
assert.strictEqual(dflt!.privacy.pluginCatalog, true);
assert.strictEqual(dflt!.updates.tier, 'notify');
assert.strictEqual(dflt!.updateServer, 'https://etherpad.org/ep_infos');
process.env.UPDATES_TIER = 'off';
process.env.PRIVACY_UPDATE_CHECK = 'false';
process.env.PRIVACY_PLUGIN_CATALOG = 'false';
const over = exportedForTestingOnly.parseSettings(templateSettings, true);
assert.strictEqual(over!.updates.tier, 'off');
assert.strictEqual(over!.privacy.updateCheck, false);
assert.strictEqual(over!.privacy.pluginCatalog, false);
});
});
// Regression test for the jsonminify -> jsonc-parser migration.
// The old parser was `jsonminify(str).replace(',]', ']').replace(',}', '}')`,
// which had two correctness bugs that jsonc-parser (allowTrailingComma) fixes:
// 1. String#replace with a string needle only swaps the FIRST match, so a
// file with more than one trailing comma of the same kind stayed invalid.
// 2. The blind ',]' / ',}' replace also corrupted those byte sequences when
// they appeared *inside* a string value (e.g. a URL or literal text).
describe('JSONC settings parsing (jsonminify -> jsonc-parser)', function () {
const fs = require('fs');
const os = require('os');
let tmpFile: string;
const writeTmp = (contents: string) => {
tmpFile = path.join(os.tmpdir(), `ep-settings-jsonc-${process.pid}.json`);
fs.writeFileSync(tmpFile, contents);
return tmpFile;
};
afterEach(function () {
if (tmpFile) { try { fs.unlinkSync(tmpFile); } catch (e) { /* ignore */ } }
});
it('strips comments and tolerates multiple trailing commas', function () {
const file = writeTmp(`// leading line comment
/* block comment */
{
"list": [
"a",
"b",
],
"nested": [
[1, 2,],
[3, 4,],
],
"obj": {
"x": 1,
"y": 2,
},
}`);
const s: any = exportedForTestingOnly.parseSettings(file, true);
assert.deepEqual(s.list, ['a', 'b']);
assert.deepEqual(s.nested, [[1, 2], [3, 4]]);
assert.deepEqual(s.obj, {x: 1, y: 2});
});
it('does not corrupt ",]" / ",}" sequences inside string values', function () {
const file = writeTmp(`{
"url": "http://example.com/a,]b,}c",
"text": "trailing-comma-like ,] and ,} must survive"
}`);
const s: any = exportedForTestingOnly.parseSettings(file, true);
// The old replace() would have stripped the comma and produced
// "http://example.com/a]b}c" here.
assert.strictEqual(s.url, 'http://example.com/a,]b,}c');
assert.strictEqual(s.text, 'trailing-comma-like ,] and ,} must survive');
});
});
});

View file

@ -34,7 +34,7 @@ describe(__filename, function () {
plugins.hooks[hookName] = [];
}
backups.settings = {};
for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users', 'enablePadWideSettings']) {
for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users', 'enablePadWideSettings', 'allowPadDeletionByAllUsers']) {
// @ts-ignore
backups.settings[setting] = settings[setting];
}
@ -492,6 +492,170 @@ describe(__filename, function () {
});
});
describe('Pad deletion token issuance (#7926)', function () {
let getAuthorIdBackup: any;
const removeIfExists = async (padId: string) => {
if (await padManager.doesPadExist(padId)) {
const p = await padManager.getPad(padId);
await p.remove();
}
};
// A getAuthorId hook that pins authorID to the authenticated username — the
// documented way (doc/api/hooks_server-side) to give a user a stable
// identity across cookie clears and devices. Its mere presence is what makes
// an authenticated session "durable" for token-suppression purposes.
const installStableIdentityHook = () => {
plugins.hooks.getAuthorId = [{hook_fn: async (hookName: string, context: any) => {
const username = context.user && context.user.username;
if (!username) return;
context.dbKey = `username=${username}`;
return '';
}}];
};
beforeEach(async function () {
// @ts-ignore - public setting toggled per test
settings.allowPadDeletionByAllUsers = false;
// The outer harness only backs up preAuthorize/authenticate/authorize, so
// manage getAuthorId ourselves to avoid leaking it into later specs.
getAuthorIdBackup = plugins.hooks.getAuthorId;
plugins.hooks.getAuthorId = [];
await removeIfExists('pad');
});
afterEach(async function () {
if (socket) socket.close();
socket = null;
plugins.hooks.getAuthorId = getAuthorIdBackup;
await removeIfExists('pad');
});
it('anonymous creator receives a deletion token by default', async function () {
const res = await agent.get('/p/pad').expect(200);
socket = await common.connect(res);
const cv: any = await common.handshake(socket, 'pad');
assert.equal(cv.type, 'CLIENT_VARS');
assert.equal(typeof cv.data.padDeletionToken, 'string',
'creator should get a token so the client can show the save-token modal');
assert.ok(cv.data.padDeletionToken.length >= 32);
assert.equal(cv.data.canDeleteWithoutToken, false);
// The creator can always delete without a token on this device, so the
// plain "Delete pad" button is offered (issue #7959).
assert.equal(cv.data.canDeletePad, true);
});
it('no token (and so no modal) when allowPadDeletionByAllUsers is true', async function () {
// @ts-ignore - public setting
settings.allowPadDeletionByAllUsers = true;
const res = await agent.get('/p/pad').expect(200);
socket = await common.connect(res);
const cv: any = await common.handshake(socket, 'pad');
assert.equal(cv.type, 'CLIENT_VARS');
// A null token means showDeletionTokenModalIfPresent() returns early on the
// client, so the "Save your pad deletion token" modal never appears. Anyone
// can already delete the pad without a token in this configuration.
assert.equal(cv.data.padDeletionToken, null);
assert.equal(cv.data.canDeleteWithoutToken, true);
assert.equal(cv.data.canDeletePad, true);
});
it('non-creator gets canDeletePad=false by default, true under allowPadDeletionByAllUsers (#7959)',
async function () {
const supertest = require('supertest');
// The creator (default cookie jar) establishes the pad's rev-0 author.
const resCreator = await agent.get('/p/pad').expect(200);
socket = await common.connect(resCreator);
const cvCreator: any = await common.handshake(socket, 'pad');
assert.equal(cvCreator.data.canDeletePad, true, 'creator can always delete');
// A different browser (separate cookie jar) is NOT the creator, so with
// allowPadDeletionByAllUsers off it must not be offered the token-less
// Delete pad button.
const otherBrowser = supertest(common.baseUrl);
const resOther = await otherBrowser.get('/p/pad').expect(200);
const otherSocket = await common.connect(resOther);
try {
const cvOther: any = await common.handshake(otherSocket, 'pad');
assert.equal(cvOther.data.canDeletePad, false,
'non-creator must not see Delete pad by default');
} finally {
otherSocket.close();
}
// With everyone opted in, the same non-creator CAN delete, so the
// button must be offered — independent of enablePadWideSettings (#7959).
// @ts-ignore - public setting toggled per test
settings.allowPadDeletionByAllUsers = true;
const otherBrowser2 = supertest(common.baseUrl);
const resOther2 = await otherBrowser2.get('/p/pad').expect(200);
const otherSocket2 = await common.connect(resOther2);
try {
const cvOther2: any = await common.handshake(otherSocket2, 'pad');
assert.equal(cvOther2.data.canDeletePad, true,
'allowPadDeletionByAllUsers must offer Delete pad to everyone');
} finally {
otherSocket2.close();
}
});
it('readonly viewer is denied canDeletePad and token-less deletion under allowPadDeletionByAllUsers (#7959)',
async function () {
// @ts-ignore - public setting toggled per test
settings.allowPadDeletionByAllUsers = true;
// Creator establishes the pad (rev-0 author) and yields its read-only id.
const resCreator = await agent.get('/p/pad').expect(200);
const creatorSocket = await common.connect(resCreator);
const cvCreator: any = await common.handshake(creatorSocket, 'pad');
const readOnlyId = cvCreator.data.readOnlyId;
assert.ok(readOnlyManager.isReadOnlyId(readOnlyId));
creatorSocket.close();
// A read-only viewer must NOT be offered the token-less delete button,
// even with deletion opened to all users — readonly viewers cannot edit,
// let alone delete (issue #7959).
const resRo = await agent.get(`/p/${readOnlyId}`).expect(200);
socket = await common.connect(resRo);
const cvRo: any = await common.handshake(socket, readOnlyId);
assert.equal(cvRo.data.readonly, true);
assert.equal(cvRo.data.canDeletePad, false,
'readonly viewers must not get the token-less Delete pad button');
// ...and the server must refuse a token-less PAD_DELETE from a readonly
// session, or allowPadDeletionByAllUsers becomes a data-loss hole.
await common.sendPadDelete(socket, {padId: 'pad'}).catch(() => {});
assert.ok(await padManager.doesPadExist('pad'),
'readonly session must not be able to delete the pad without a token');
});
it('authenticated creator WITHOUT a getAuthorId hook still gets a token', async function () {
// requireAuthentication alone is NOT durable: the authorID still comes from
// the per-browser token cookie, so this user would be stranded on a second
// device if the token were withheld. They must keep getting one.
settings.requireAuthentication = true;
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const cv: any = await common.handshake(socket, 'pad');
assert.equal(cv.type, 'CLIENT_VARS');
assert.equal(typeof cv.data.padDeletionToken, 'string');
assert.equal(cv.data.canDeleteWithoutToken, false);
assert.equal(cv.data.canDeletePad, true);
});
it('authenticated creator WITH a getAuthorId hook gets no token (durable identity)',
async function () {
settings.requireAuthentication = true;
installStableIdentityHook();
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
socket = await common.connect(res);
const cv: any = await common.handshake(socket, 'pad');
assert.equal(cv.type, 'CLIENT_VARS');
assert.equal(cv.data.padDeletionToken, null);
assert.equal(cv.data.canDeleteWithoutToken, true);
assert.equal(cv.data.canDeletePad, true);
});
});
describe('SocketIORouter.js', function () {
const Module = class {
setSocketIO(io:any) {}

View file

@ -13,15 +13,16 @@
*/
const fs = require('fs');
const jsonminify = require('jsonminify');
const {parse: parseJsonc} = require('jsonc-parser');
function loadSettings() {
let settingsStr = fs.readFileSync(`${__dirname}/../../../settings.json.docker`).toString();
// try to parse the settings
try {
if (settingsStr) {
settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}');
const settings = JSON.parse(settingsStr);
// jsonc-parser tolerates the comments and trailing commas in the docker
// settings file, matching node/utils/Settings.ts.
const settings = parseJsonc(settingsStr, [], {allowTrailingComma: true});
// custom settings for running in a container
settings.ip = 'localhost';

View file

@ -0,0 +1,29 @@
[
{
"name": "etherpad-pad",
"repo": "https://github.com/ether/pad.git",
"ref": "91620c67c49536bb77e90b39f298ea70ae93c4a0",
"kind": "rust",
"enabled": true,
"vectorTest": "cargo test --test vectors",
"smokeCmd": "cargo test --test smoke -- --ignored"
},
{
"name": "etherpad-cli-client",
"repo": "https://github.com/ether/etherpad-cli-client.git",
"ref": "ebc516ef1a4e7a0c97ccd7a3f2db65e99f8e177c",
"kind": "node",
"enabled": true,
"vectorTest": "pnpm run test:vectors",
"smokeCmd": "pnpm run test:smoke"
},
{
"name": "etherpad-desktop",
"repo": "https://github.com/ether/etherpad-desktop.git",
"ref": "ab83da645b8683afbbc203e4a3fa6f3622a55709",
"kind": "desktop",
"enabled": true,
"vectorTest": "pnpm run test:vectors",
"smokeCmd": "pnpm run test:smoke"
}
]

View file

@ -0,0 +1,86 @@
#!/usr/bin/env bash
#
# Runs each enabled downstream client from clients.json against an already-booted
# Etherpad: clone @ pinned ref, set up its toolchain, point it at core's freshly
# generated wire-vectors fixture, then run the client's vectorTest + smokeCmd.
#
# The fixture is injected via $ETHERPAD_WIRE_VECTORS (absolute) so clients test
# against CURRENT core's serialization, not their vendored snapshot. The smoke
# reaches the server via $ETHERPAD_SMOKE_URL + $ETHERPAD_SMOKE_APIKEY.
#
# Env (all optional except APIKEY):
# SMOKE_URL default http://localhost:9003
# SMOKE_APIKEY required for the live smoke (clients skip cleanly without it)
# MANIFEST default src/tests/downstream/clients.json (relative to repo root)
# WIRE_VECTORS default <repo>/src/tests/fixtures/wire-vectors.json
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
MANIFEST="${MANIFEST:-$REPO_ROOT/src/tests/downstream/clients.json}"
WIRE_VECTORS="${WIRE_VECTORS:-$REPO_ROOT/src/tests/fixtures/wire-vectors.json}"
SMOKE_URL="${SMOKE_URL:-http://localhost:9003}"
SMOKE_APIKEY="${SMOKE_APIKEY:-}"
[ -f "$WIRE_VECTORS" ] || { echo "::error::fixture not found: $WIRE_VECTORS"; exit 1; }
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
MANIFEST="$MANIFEST" node -e '
const c = require(process.env.MANIFEST).filter((x) => x.enabled);
for (const x of c) {
process.stdout.write([x.name, x.repo, x.ref, x.kind, x.vectorTest, x.smokeCmd].join("\t") + "\n");
}
' > "$WORK/clients.tsv"
if [ ! -s "$WORK/clients.tsv" ]; then
echo "No downstream clients enabled. Nothing to run."
exit 0
fi
export ETHERPAD_WIRE_VECTORS="$WIRE_VECTORS"
export ETHERPAD_SMOKE_URL="$SMOKE_URL"
export ETHERPAD_SMOKE_APIKEY="$SMOKE_APIKEY"
fail=0
while IFS=$'\t' read -r name repo ref kind vectorTest smokeCmd; do
echo "::group::$name ($kind) @ ${ref:0:12}"
dir="$WORK/$name"
# Everything — clone, checkout, AND the tests — runs inside one guarded
# subshell so a single client's failure becomes a per-client failure (fail=1)
# and the loop continues to the rest. NOTE: `set -e` is suspended inside a
# subshell used as an `||` operand, so every step is guarded with an explicit
# `|| exit 1` rather than relying on `set -e`. The manifest commands are a
# trusted in-repo allowlist; running them via `bash -euo pipefail -c` (not
# `eval`) keeps them out of this script's own shell and applies strict mode
# (pipeline-stage failures surface) inside the child.
(
git clone --quiet "$repo" "$dir" || exit 1
# A default clone has all branch heads; fetch the pinned commit only if it
# is not already reachable (e.g. a non-branch-tip SHA). Fetch errors are
# NOT suppressed so the real cause surfaces instead of a vague checkout fail.
if ! git -C "$dir" cat-file -e "${ref}^{commit}" 2>/dev/null; then
git -C "$dir" fetch --quiet origin "$ref" || exit 1
fi
git -C "$dir" checkout --quiet "$ref" || exit 1
cd "$dir" || exit 1
case "$kind" in
rust)
bash -euo pipefail -c "$vectorTest" || exit 1
bash -euo pipefail -c "$smokeCmd" || exit 1
;;
node|desktop)
pnpm install || exit 1
bash -euo pipefail -c "$vectorTest" || exit 1
bash -euo pipefail -c "$smokeCmd" || exit 1
;;
*)
echo "::error::unknown client kind: $kind"; exit 1
;;
esac
) || { echo "::error::downstream client '$name' failed (clone/checkout/test)"; fail=1; }
echo "::endgroup::"
done < "$WORK/clients.tsv"
exit "$fail"

62
src/tests/fixtures/wire-vectors.json vendored Normal file
View file

@ -0,0 +1,62 @@
[
{
"name": "plain-insert",
"initialText": "abc\n",
"changeset": "Z:4>3=3+3$XYZ",
"pool": {
"numToAttrib": {},
"nextNum": 0
},
"resultText": "abcXYZ\n"
},
{
"name": "plain-delete",
"initialText": "abcdef\n",
"changeset": "Z:7<3=1-3$",
"pool": {
"numToAttrib": {},
"nextNum": 0
},
"resultText": "aef\n"
},
{
"name": "formatted-insert",
"initialText": "abc\n",
"changeset": "Z:4>4=3*0+4$bold",
"pool": {
"numToAttrib": {
"0": [
"bold",
"true"
]
},
"nextNum": 1
},
"resultText": "abcbold\n"
},
{
"name": "multiline-insert",
"initialText": "abc\n",
"changeset": "Z:4>8=3|2+8$one\ntwo\n",
"pool": {
"numToAttrib": {},
"nextNum": 0
},
"resultText": "abcone\ntwo\n\n"
},
{
"name": "attrib-reuse",
"initialText": "abc\n",
"changeset": "Z:4>2*0+1=3*0+1$AB",
"pool": {
"numToAttrib": {
"0": [
"bold",
"true"
]
},
"nextNum": 1
},
"resultText": "AabcB\n"
}
]

View file

@ -0,0 +1,54 @@
import {expect, test} from "@playwright/test";
import {loginToAdmin} from "../helper/adminhelper";
import {goToPad, writeToPad} from "../helper/padHelper";
// End-to-end coverage for issue #7935: a pad that exists must be visible
// both on the welcome page's "recent pads" list (driven by localStorage,
// i.e. gated on the browser having opened the pad) and in the admin
// "Manage pads" UI (driven by the /settings socket `padLoad` handler,
// which enumerates the DB). The admin side regressed when a single
// unreadable pad record made `padLoad` throw and silently return nothing
// — see specs/admin/padLoadResilience.ts for the server-side guard.
// /admin tests mutate global server state, so keep them serial.
test.describe.configure({mode: 'serial'});
const ADMIN_URL = 'http://localhost:9001/admin';
test.describe('a created pad shows up on the home page and in /admin', () => {
// Unique, URL-safe id so the recent-pads localStorage entry and the admin
// search both target exactly this pad and ignore leftovers from other suites.
const padId = `pw-pads-7935-${Date.now()}`;
test('opening a pad lists it in the welcome page recent-pads', async ({page}) => {
await goToPad(page, padId);
await writeToPad(page, 'hello from 7935');
// Opening the pad writes it to `recentPads` localStorage (colibris
// pad.js). The welcome page renders that list — same browser context,
// so the entry carries over.
await page.goto('http://localhost:9001/');
const recentPad = page.locator('.recent-pad', {hasText: padId});
await expect(recentPad).toBeVisible({timeout: 10000});
await expect(recentPad.locator('a')).toHaveText(padId);
});
test('the same pad is listed in the admin Manage pads UI', async ({page}) => {
await loginToAdmin(page, 'admin', 'changeme1');
await page.goto(`${ADMIN_URL}/pads`);
await expect(page.getByRole('heading', {name: 'Manage pads'}))
.toBeVisible({timeout: 30000});
// Narrow the (full-scan) listing to our pad. The search is debounced
// server-side; allow the round-trip to settle.
const search = page.getByPlaceholder('Search for pads');
await search.fill(padId);
await expect(page.locator('.pm-pad-title', {hasText: padId}))
.toBeVisible({timeout: 15000});
// The "No results" empty state must NOT be showing — the exact #7935
// symptom was an empty Manage-pads list for pads that demonstrably exist.
await expect(page.locator('.pm-empty')).toHaveCount(0);
});
});

View file

@ -90,4 +90,28 @@ test.describe('Language select and change', function () {
await page.waitForSelector('html[dir="ltr"]')
});
// Regression test for #7925: when the UI language is auto-detected from the
// browser (no language cookie, no pad-wide lang set), the language dropdown
// must reflect the detected language instead of defaulting to English.
test('dropdown reflects the browser-detected language', async function ({browser}) {
const context = await browser.newContext({locale: 'de-DE'})
await context.clearCookies()
const page = await context.newPage()
try {
await goToNewPad(page)
// The toolbar should have rendered in German (detection works) — the
// bold button's parent <li> carries the localized German tooltip.
await expect(page.locator('.buttonicon-bold').locator('..'))
.toHaveAttribute('title', 'Fett (Strg-B)')
// ... and the language dropdown must show the detected language, not
// English, even though it was never explicitly selected.
await showSettings(page)
await expect(langDropdown(page).locator('.current')).toHaveText('Deutsch')
} finally {
await context.close()
}
});
});

View file

@ -3,7 +3,7 @@ import {goToNewPad, goToPad, sendChatMessage, showChat} from "../helper/padHelpe
import {showSettings} from "../helper/settingsHelper";
test.describe('creator-owned pad settings', () => {
test('shows pad settings only to the creator and keeps delete pad there', async ({page, browser}) => {
test('shows pad settings only to the creator; delete pad is creator-gated but separate', async ({page, browser}) => {
const padId = await goToNewPad(page);
const context2 = await browser.newContext();
@ -19,6 +19,9 @@ test.describe('creator-owned pad settings', () => {
await expect(page.locator('#pad-settings-section')).toBeVisible();
await expect(page.locator('#delete-pad')).toBeVisible();
await expect(page.locator('#padsettings-enforcecheck')).toBeVisible();
// The delete-pad button is no longer nested inside the pad-wide settings
// section: deletion is independent of enablePadWideSettings (issue #7959).
await expect(page.locator('#pad-settings-section #delete-pad')).toHaveCount(0);
await expect(page2.locator('#user-settings-section > h2')).toHaveText('User Settings');
await expect(page2.locator('#theme-toggle-row')).toBeVisible();

View file

@ -59,4 +59,18 @@ test.describe('dark color scheme', () => {
// before pad.css so it takes effect at first paint.
await expect(page.locator('html')).toHaveClass(/super-dark-editor/);
});
test('root canvas matches the toolbar so the iOS status-bar area is not white',
async ({page}) => {
await goToNewPad(page);
// The <html> root must carry the toolbar colour as its background — iOS
// Safari paints the status-bar safe area from the root canvas, not from
// theme-color, so leaving it transparent produced a white strip above
// the dark pad (issue #7606). #485365 == --super-dark-color, the dark
// toolbar colour.
await expect
.poll(() => page.evaluate(() =>
getComputedStyle(document.documentElement).backgroundColor))
.toBe('rgb(72, 83, 101)');
});
});

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