Compare commits

..

No commits in common. "develop" and "3.1.0" have entirely different histories.

258 changed files with 4745 additions and 18817 deletions

View file

@ -30,8 +30,7 @@ If applicable, add screenshots to help explain your problem.
- OS: [e.g., Ubuntu 20.04]
- Node.js version (`node --version`):
- npm version (`npm --version`):
- Is the server free of plugins:
- Are you using any abstraction IE docker?
- Is the server free of plugins:
**Desktop (please complete the following information):**
- OS: [e.g. iOS]

View file

@ -17,11 +17,4 @@ updates:
open-pull-requests-limit: 30
groups:
dev-dependencies:
dependency-type: "development"
cooldown:
default-days: 1 # fallback for anything not covered below
semver-major-days: 7
semver-minor-days: 3
semver-patch-days: 1
include:
- "*"
dependency-type: "development"

View file

@ -32,8 +32,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
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.3
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0
@ -66,7 +66,24 @@ jobs:
run: pnpm build
-
name: Run the backend tests
run: pnpm test
env:
# --report-on-fatalerror and friends write a Node diagnostic report
# (V8 stack, libuv handles, OS info) on fatal errors that bypass JS
# handlers — the failure mode we've been chasing on Windows + Node
# 24 since PR #7663. Reports land in node-report/ and are uploaded
# as an artifact if the step fails.
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
run: |
mkdir -p "${{ github.workspace }}/node-report"
pnpm test
- name: Upload Node diagnostic reports on failure
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
path: node-report/
if-no-files-found: ignore
retention-days: 7
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
@ -88,8 +105,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -107,7 +124,7 @@ jobs:
cache: pnpm
-
name: Install libreoffice
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0
@ -129,14 +146,26 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_guest
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
ep_table_of_contents
-
name: Run the backend tests
run: pnpm test
env:
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
run: |
mkdir -p "${{ github.workspace }}/node-report"
pnpm test
- name: Upload Node diagnostic reports on failure
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
path: node-report/
if-no-files-found: ignore
retention-days: 7
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
@ -151,25 +180,14 @@ jobs:
fail-fast: false
matrix:
# Etherpad requires Node >= 24 (see package.json engines.node).
# Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's
# bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect
# path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the
# backend suite's heavy localhost connection churn -- the long-standing Windows
# backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node
# observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0
# (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly
# because setup-node's default check-latest:false reuses the runner's
# pre-cached 24.15.0 for a bare "24".
# Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin
# back to plain "24" once the fix is across the supported 24.x baseline.
node: ${{ fromJSON('["24.16.0"]') }}
node: ${{ fromJSON('[24]') }}
name: Windows without plugins
runs-on: windows-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -200,9 +218,23 @@ jobs:
name: Run the backend tests
shell: bash
working-directory: src
# --exit makes mocha call process.exit() after the run so a leaked handle
# cannot hang the job on Windows.
run: pnpm test -- --exit
env:
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
run: |
mkdir -p "${{ github.workspace }}/node-report"
# --exit forces process.exit(failures) after the suite completes,
# closing the post-suite event-loop drain window where Windows +
# Node 24 hard-kills the process. Scoped to Windows so Linux/local
# runs still surface real handle leaks via natural drain.
pnpm test -- --exit
- name: Upload Node diagnostic reports on failure
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
path: node-report/
if-no-files-found: ignore
retention-days: 7
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
@ -216,26 +248,15 @@ jobs:
fail-fast: false
matrix:
# Etherpad requires Node >= 24 (see package.json engines.node).
# Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's
# bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect
# path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the
# backend suite's heavy localhost connection churn -- the long-standing Windows
# backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node
# observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0
# (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly
# because setup-node's default check-latest:false reuses the runner's
# pre-cached 24.15.0 for a bare "24".
# Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin
# back to plain "24" once the fix is across the supported 24.x baseline.
node: ${{ fromJSON('["24.16.0"]') }}
node: ${{ fromJSON('[24]') }}
name: Windows with Plugins
runs-on: windows-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -268,7 +289,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_guest
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
@ -294,9 +315,23 @@ jobs:
name: Run the backend tests
shell: bash
working-directory: src
# --exit makes mocha call process.exit() after the run so a leaked handle
# cannot hang the job on Windows.
run: pnpm test -- --exit
env:
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
run: |
mkdir -p "${{ github.workspace }}/node-report"
# --exit forces process.exit(failures) after the suite completes,
# closing the post-suite event-loop drain window where Windows +
# Node 24 hard-kills the process. Scoped to Windows so Linux/local
# runs still surface real handle leaks via natural drain.
pnpm test -- --exit
- name: Upload Node diagnostic reports on failure
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
path: node-report/
if-no-files-found: ignore
retention-days: 7
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest

View file

@ -36,15 +36,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
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@v6
- uses: actions/cache@v5
name: Cache vitepress build
with:
path: doc/.vitepress/cache

View file

@ -25,7 +25,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
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,24 +15,6 @@ 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:
@ -62,7 +44,7 @@ jobs:
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
@ -346,7 +328,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout etherpad source (for packaging/apt/key.asc)
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 1

View file

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

View file

@ -26,7 +26,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v7
uses: actions/checkout@v6
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@v6
- uses: actions/cache@v5
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -66,13 +66,7 @@ jobs:
name: Test
working-directory: etherpad
run: |
# ADMIN_PASSWORD provisions settings.json.docker's admin user so
# the adminSettings_7819 container spec can authenticate against
# /admin and the /settings socket. pad.js doesn't touch /admin
# so the existing API specs are unaffected.
docker run --rm -d -p 9001:9001 \
-e ADMIN_PASSWORD=changeme1 \
--name test ${{ env.TEST_TAG }}
docker run --rm -d -p 9001:9001 --name test ${{ env.TEST_TAG }}
./bin/installDeps.sh
docker logs -f test &
while true; do
@ -85,34 +79,6 @@ jobs:
esac
done
(cd src && pnpm run test-container)
# Regression for #7819. adminSettings_7819.ts saves a marker via
# the admin /settings socket and intentionally leaves it on
# disk. We assert here that the save actually hit the file
# (mocha only sees the next `load` reply — this catches a
# scenario where the load is served from cache and the file
# never actually changed).
docker exec test grep -q persist-marker-7819 /opt/etherpad-lite/settings.json || {
echo "[#7819] socket-driven save did NOT reach /opt/etherpad-lite/settings.json on disk"
docker exec test cat /opt/etherpad-lite/settings.json | head -50
exit 1
}
# Now prove the on-disk file survives an in-place container
# restart. This is the scenario a docker-compose user with
# `restart: always` hits on every host reboot.
docker restart test >/dev/null
for i in $(seq 1 60); do
status=$(docker container inspect -f '{{.State.Health.Status}}' test 2>/dev/null) || { docker logs test; exit 1; }
case ${status} in
healthy) break;;
starting) sleep 2;;
*) docker logs test; exit 1;;
esac
done
docker exec test grep -q persist-marker-7819 /opt/etherpad-lite/settings.json || {
echo "[#7819 REGRESSION] settings.json was reset on docker restart — ep_oauth block vanished"
docker logs test
exit 1
}
docker rm -f test
git clean -dxf .
-
@ -151,32 +117,6 @@ 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
@ -190,7 +130,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
path: etherpad
-
@ -261,7 +201,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
path: etherpad
-
@ -358,7 +298,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
path: etherpad
-
@ -416,7 +356,7 @@ jobs:
enable-url-completion: true
- name: Check out ether-charts
if: github.ref == 'refs/heads/develop'
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
path: ether-charts
repository: ether/ether-charts

View file

@ -1,108 +0,0 @@
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@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
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@v6
uses: actions/cache@v5
id: playwright-cache
with:
path: ~/.cache/ms-playwright

View file

@ -20,15 +20,15 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
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@v6
- uses: actions/cache@v5
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@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
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@v6
- uses: actions/cache@v5
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@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
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@v6
- uses: actions/cache@v5
name: Cache Playwright browsers
with:
path: ~/.cache/ms-playwright
@ -219,7 +219,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_guest
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
@ -269,15 +269,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
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@v6
- uses: actions/cache@v5
name: Cache Playwright browsers
with:
path: ~/.cache/ms-playwright
@ -308,7 +308,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_guest
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript

View file

@ -27,8 +27,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
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@v7
- uses: actions/checkout@v6
- 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@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
@ -70,30 +70,13 @@ 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
@ -107,14 +90,17 @@ 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@v7
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
@ -145,7 +131,6 @@ 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@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -62,8 +62,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
@ -93,7 +93,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_guest
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
@ -125,8 +125,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
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@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}

View file

@ -27,8 +27,8 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
uses: actions/checkout@v6
- uses: actions/cache@v5
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@v7
uses: actions/checkout@v6
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@v7
uses: actions/checkout@v6
with:
repository: ether/ether.github.com
path: ether.github.com
token: '${{ secrets.ETHER_RELEASE_TOKEN }}'
- uses: actions/cache@v6
- uses: actions/cache@v5
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.5
- uses: reitzig/actions-asciidoctor@v2.0.4
with:
version: 2.0.18
- name: Prepare release

View file

@ -1,33 +1,9 @@
# 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
@ -36,16 +12,8 @@ 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@v7
uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
# OIDC trusted publishing needs npm >= 11.5.1, which requires
@ -54,7 +22,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@v6
- uses: actions/cache@v5
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@v7
- uses: actions/checkout@v6
- 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@v7
- uses: actions/checkout@v6
- 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@v7
uses: actions/checkout@v6
- 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@v7
uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
name: Install pnpm

View file

@ -32,13 +32,13 @@ jobs:
steps:
-
name: Check out latest release
uses: actions/checkout@v7
uses: actions/checkout@v6
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@v6
- uses: actions/cache@v5
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.3
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0
@ -76,7 +76,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_guest
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript

5
.pr_agent.toml Normal file
View file

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

View file

@ -211,38 +211,6 @@ pnpm run plugins ls # List installed
### Settings
Configured via `settings.json`. A template is available at `settings.json.template`. Environment variables can override any setting using `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`.
## Releasing
Releases are driven almost entirely by GitHub Actions. A maintainer dispatches **one** workflow; the version bump, tagging, GitHub Release, Docker images, and snap all cascade off the pushed tag. The npm publish is a separate manual dispatch.
### Prerequisites (check these before dispatching)
- **A `# X.Y.Z` changelog section for the _target_ version must already be at the top of `CHANGELOG.md`.** `bin/release.ts` aborts with `No changelog record for X.Y.Z, please create changelog record` if it's missing. Write the section before dispatching.
- **All four `package.json` files must agree on the current version:** root `package.json`, `src/package.json`, `admin/package.json`, `bin/package.json`. `release.ts` reads the *current* version from **`src/package.json`** and computes the next with `semver.inc(current, type)`. If the files are out of sync (e.g. one was hand-edited), the computed target is wrong and the changelog guard fails. *(This exact desync — `src`/`bin` left at 3.3.0 while root/admin were 3.2.0 — blocked the 3.3.0 release in June 2026.)*
### Cutting a release
1. **Actions → "Release etherpad"** → Run workflow (`workflow_dispatch`), choose `patch` / `minor` / `major`. Cadence is monthly **minors** (3.0.0 → 3.1.0 → 3.2.0 → …); use `major` only for breaking changes.
2. The **Prepare release** step runs `bin/release.ts`, which:
- sanity-checks the tree (clean working dir, on `develop`, `develop`/`master` upstreams in sync, `../ether.github.com` cloned & clean on `master`);
- bumps the version in all four `package.json` files;
- commits `bump version`, merges `develop``master`, creates both `X.Y.Z` **and** `vX.Y.Z` tags, merges `master` back to `develop`;
- builds and stages the versioned docs into the website repo (see **Documentation** below).
3. The **Push after release** step (`bin/push-after-release.sh`) pushes `master`, `develop`, the tag, `--tags`, and the `ether.github.com` docs commit.
4. The pushed **`vX.Y.Z` tag auto-triggers** three workflows:
- `handleRelease.yml` → builds Etherpad, extracts the matching changelog section via `generateChangelog` (`bin/generateReleaseNotes.ts`), and publishes the **GitHub Release** (`make_latest: true`);
- `docker.yml` → builds & pushes the Docker images;
- `snap-publish.yml` → publishes the snap.
5. **npm publish — 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
Two distinct things, both important:
**1. Per-PR doc updates — your responsibility in every behaviour-change PR.**
The `doc/` workspace holds the user/admin/API docs (Markdown + AsciiDoc). Whenever a PR changes API responses, CLI flags, settings keys, hooks, or error formats, update the relevant files in `doc/` **in the same PR** — don't defer it to release time. The HTTP API reference is `doc/api/http_api.{md,adoc}` (keep both in sync). Preview locally with `pnpm run makeDocs`.
**2. Release-time versioned docs publishing — automated, no manual step.**
During a release, `release.ts` runs `pnpm run makeDocs` (→ `bin/make_docs.ts`) to render `doc/` into `out/doc/`, then copies it into the sibling website repo at `../ether.github.com/public/doc/vX.Y.Z`, bumps that repo's version, and commits `X.Y.Z docs`; `push-after-release.sh` pushes it, publishing the versioned docs on etherpad.org. This requires `ether.github.com` checked out as a **sibling directory** — the **Release etherpad** workflow checks it out automatically. If you ever run `release.ts` by hand, clone it first: `cd .. && git clone git@github.com:ether/ether.github.com.git`.
## Monorepo Structure
This project uses pnpm workspaces. The workspaces are:

View file

@ -1,152 +1,9 @@
# 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.
### Notable enhancements
- **Plugin pad options on by default — `settings.enablePluginPadOptions` now defaults to `true` (#7841).** The flag that gates the `ep_*` passthrough on pad options (shipped opt-in in 3.0.0, #7698) is flipped to default-on, so plugins such as `ep_plugin_helpers`' `padToggle` / `padSelect` ride the existing broadcast/persist rail out of the box. This closes `ep_comments_page#422` — stock 3.x deployments `console.warn`ed on every pad load because the helper detected `enablePluginPadOptions === false`. The `settings.json.template` env-var default is flipped to match, so Docker/supervisor configs without an explicit value get the new behaviour. Existing deployments with an explicit `"enablePluginPadOptions": false` keep that value — no migration needed — and the protocol shape is unchanged for older clients.
- **Timeslider — honour the editor's view settings (#7899).** The in-pad timeslider now respects `showAuthorshipColors`, `padFontFamily`, and line-numbers, bridged from the pad-settings checkboxes into the embedded timeslider iframe so the two views agree. `nice-select.ts` dispatches a native `change` event after the jQuery trigger so the `addEventListener`-based bridge in `pad_mode.ts` fires (jQuery 3.7.1's `trigger()` does not dispatch native DOM events), and the font-family reset is fixed for jQuery 3 (which ignores a `null` css value). The five ad-hoc listener stores in `pad_mode.ts` are consolidated into one `bindOuter()` path and the three view-setting bridges into a single data-driven `bridgeView()` (refactor only).
- **Admin settings — explain env-var substitution and surface auth errors (#7819 / #7826).** Three env-var-only UX improvements driven by #7819 (a Docker operator saved an `ep_oauth` block in the Raw view and reported it "disappeared", not realising `settings.json` on disk is a *template*, not the effective config): a banner above the editor explaining the template/substitution model (rendered only when the loaded file contains a `${VAR}` placeholder); a read-only **Effective** tab exposing the redacted runtime settings the backend already emitted as `resolved` (also gated on `${VAR}`); and an `admin_auth_error` event so a misrouted Traefik+SSO session that isn't admin gets a clear toast instead of a silent "save did nothing". A reconnect-loop guard suppresses the SPA's auto-reconnect once an auth error has been received. No behaviour change for installs without `${VAR}` placeholders.
### Security hardening
A defence-in-depth pass across the API, token, export, and deployment surfaces:
- **HTTP API request handling, random IDs, and plugin loading (#7906).** `pad_utils.randomString` now generates random IDs via `crypto.getRandomValues` (CSPRNG) instead of `Math.random`. `OAuth2Provider` compares passwords with `crypto.timingSafeEqual` on the raw UTF-8 bytes (resolving the CodeQL "insufficient computational effort" alert) behind a uniform failure delay, and looks users up via own-property access only. `API.appendChatMessage` throws `padID does not exist` rather than creating the pad, consistent with the other content API methods. The `/api/2` REST router forwards only the `authorization` header (not the full request header set) and falls back to it whenever the field is falsy, matching the `openapi.ts` handler so both routers authenticate identically. `LinkInstaller` validates plugin dependency names before building filesystem paths from them, and the admin file server returns a generic error while logging details server-side.
- **Escape exported `data-*` attributes; warn on default/placeholder credentials (#7905).** `ExportHtml` now escapes the name and value of attributes emitted by the `exportHtmlAdditionalTagsWithData` hook, consistent with the URL/text escaping already applied to exported HTML. `Settings` logs a warning (error level under `NODE_ENV=production`) when an account uses a default/placeholder password from the shipped config, and the check is extended to cover `sso.clients[].client_secret` so enabling SSO without setting `ADMIN_SECRET` / `USER_SECRET` is flagged the same way.
- **Docker deployment defaults — require explicit credentials, default `TRUST_PROXY` off (#7907).** The shipped `docker-compose` now requires `ADMIN_PASSWORD` and the database password to be provided explicitly (no implicit fallback) and defaults `TRUST_PROXY` to `false`. Operators relying on the previous implicit defaults must now set these values explicitly.
### Notable fixes
- **History mode — lay the timeslider iframe in the editor's flex slot (#7903).** In-pad history mode positioned `#history-frame-mount` as an `inset:0` absolute overlay over `#editorcontainerbox`, which took the iframe out of flow and hid any in-flow side panel (e.g. `ep_webrtc`'s `#rtcbox` video column) beneath it — so history mode and live mode disagreed. The iframe now occupies the same in-flow flex slot the live editor uses, and a latent specificity bug (the `body.history-mode #editorcontainer { display: none }` hide rule was outranked by the two-id layout rule, so the live editor was only ever painted over) is fixed by giving the hide rule matching specificity. Adds a `padmode.spec.ts` regression test.
- **Pad editor — restore URL wrapping (#7894 / #7896).** Long URLs in the pad editor overflowed instead of wrapping because the global `a { white-space: nowrap }` rule overrode the wrapping properties on `#innerdocbody`. Explicit `white-space` / `word-wrap` / `overflow-wrap` on `#innerdocbody a` restores wrapping inside the editor while preserving no-wrap for links elsewhere in the UI.
- **RTL content option no longer flips the whole page (#7900 / #7901).** The per-pad RTL content option (`rtlIsTrue`) wrote the direction to the top-level `document.documentElement`, flipping the entire page — toolbar and chrome included. The content direction is now applied to the inner editor document (`targetDoc.documentElement`); page direction stays owned by the UI language (`l10n.ts`). Adds a frontend test asserting the inner editor flips while the top-level `<html>` dir is unchanged.
- **Pad-wide view settings apply to the creator's own view (#7900 / #7902).** Because a creator is never "enforced upon themselves", a stale personal view-override cookie (e.g. `rtlIsTrue=false` from an earlier toggle) silently masked the pad-wide value they later set, so the control appeared to do nothing on their own screen. Changing a pad-wide view option now syncs the creator's personal pref to the chosen value; the precedence model is unchanged (the creator can still override afterwards via "My view").
- **URL view-option params lost to a `padeditor.init` race (#7840 / #7843).** `?showLineNumbers=false` and `?useMonospaceFont=true` were silently clobbered shortly after load — the same race #7464 fixed for `?rtl=false`, but the neighbouring `showLineNumbers` / `noColors` / `useMonospaceFontGlobal` blocks were left at the synchronous-tail site. The fix is generalised to all three (moved into `postAceInit`). Mostly observable in cross-context iframe embeds that start with no `prefs` cookie. Adds `url_view_options.spec.ts`.
- **Default welcome text attributed to the system author (#7885 / #7887).** Auto-generated default pad content (`settings.defaultPadText` / `padDefaultContent` hook) carried the creating user's `author` attribute and rendered in their authorship colour, even though they never wrote it. The welcome text's `author` *attribute* is now `Pad.SYSTEM_AUTHOR_ID`, while revision 0's `meta.author` stays the real creator so ownership (pad-wide settings gate, deletion token) is preserved. Explicitly provided text (e.g. HTTP API `createPad` with text + author) keeps the real author.
- **URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895).** Pad names are `encodeURIComponent`-d in the admin `PadPage` Open href and the colibris recent-pads href, and `decodeURIComponent`-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (`%2F``%252F`). The admin Open `window.open` gains `noopener,noreferrer`.
- **OIDC — fix broken `OIDCAdapter` flows (#7837).** Repairs the adapter flows and widens the storage type to include `string` for the `userCode` index; adds regression tests.
- **Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836).** Adds the `index.code` key referenced by `index.html` but never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin `@radix-ui/react-dialog` `Dialog.Content` a `Dialog.Title` and `Dialog.Description` (visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if any `data-l10n-id` in `src/templates/*.html` is missing from `en.json`.
- **Offline/air-gapped Docker boot — stop pnpm self-provisioning a pinned version (issue #7911).** The official image installs pnpm directly (corepack was dropped for Node 25+). Because the image's pnpm intentionally lags the `packageManager` pin in `package.json` (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), pnpm treated every call — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download the pinned build. Behind a firewall that download failed (`Failed to get pnpm version: … Command exited with code 1`), breaking startup. The Dockerfile now sets `pnpm_config_pm_on_fail=ignore`, and the startup probe plus the updater's pnpm-on-PATH checks run with the same flag, so pnpm uses the installed version instead of reaching for the network (without changing which pnpm runs the build-time install). A backend spec fails CI if that guard is dropped while a version gap exists.
- **Firefox authorship colours — tag early keystrokes with the right author (#7910).** The inner editor's `thisAuthor` starts empty and is only populated when collab_client's queued `setProperty('userAuthor', userId)` reaches the iframe (applied asynchronously via `pendingInit`). Under Firefox timing the first keystrokes could beat it, so freshly typed text — and early line-attribute changes (lists, headings, alignment) — were tagged `author=''`, which canonicalises to an unattributed insert that the server's pad-corruption guard rejects, dropping the whole change and losing authorship (the intermittent `clear_authorship_color` flake, where undo couldn't restore the author colour). A `getLocalAuthor()` helper now falls back to `clientVars.userId` (the same id, available synchronously) whenever `thisAuthor` is still empty, applied at the text-insert sites and to seed `documentAttributeManager.author`; the intentional clear-authorship path and the server-side guard are unchanged.
- **Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606).** Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads `theme-color` at parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits a `prefers-color-scheme`-scoped `theme-color` pair so the address bar is correct at first paint, plus a small blocking `<head>` script that applies the dark skin classes before the stylesheet paints. Both are gated on `enableDarkMode` (default on) and the colibris skin; `pad.ts` still runs on init to wire up the `#options-darkmode` toggle (which now updates every `theme-color` meta) and theme the editor iframes. Applies to the pad and timeslider views.
### Internal / contributor-facing
- **Root-caused and fixed the Windows backend-test "silent ELIFECYCLE" flake (#7866).** The ~22% Windows flake — rotating across random spec files, no mocha summary, no JS trace — was diagnosed from a full-memory dump as two distinct causes. (1) A timing-fragile test abandoned by mocha keeps running and later throws an *orphan* unhandled rejection; `server.ts`'s process-global `uncaughtException`/`unhandledRejection` handlers (correct for a real Etherpad process) escalated that into a clean `process.exit`. They are now gated behind `require.main === module`, and the backend-test bootstraps (`common.ts`, `diagnostics.ts`) log orphan rejections instead of rethrowing. (2) A stack-buffer overrun in Node 24.x's bundled libuv Windows TCP-connect path (`uv__tcp_connect`) corrupts memory under the suite's localhost-connection churn; CI pins the Windows backend job to Node **24.16.0** (libuv 1.52.1, the bisected fix), referencing upstream `nodejs/node#63620`. Linux stays on Node 24 LTS.
- **Removed the now-unneeded ELIFECYCLE diagnostic scaffolding (#7846 / #7838 / #7842 / #7868).** The OS-level sidecar watcher, the diagnostics heartbeat/running-test pointer, and the mid-test snapshot — added to chase the flake above — are removed now that the cause is known.
- **Docs — document the Docker `settings.json` writable-layer and env-var-vs-file semantics (#7819 / #7827).** Two operator-facing gaps surfaced by #7819: that the on-disk `settings.json` is a template (env substitution happens in memory at load time), and that the default compose puts `settings.json` in the container's writable layer with no host mount, so admin edits are lost on `down`/`pull`/watchtower but survive a plain `restart`. Adds prose + a recreate-vs-restart table to `doc/docker.md` and a commented-out opt-in bind mount to the compose files.
- **Docs refresh for 3.2.0 (#7888)**, **dropped three redundant top-level files (#7839)**, **dropped a fragile viewport assertion in the enter test (#7845)**, and a backend-test fix-up.
### Dependencies
- Two major bumps: `redis` 5.12.1 → 6.0.0 (#7869) and `ejs` 5.0.2 → 6.0.1 (#7860).
- `ueberdb2` 6.1.2 → 6.1.8, `mssql` 12.5.3 → 12.5.5, `nodemailer` 8.0.7 → 8.0.10, `mysql2` 3.22.3 → 3.22.5 (#7915), `undici` 8.3.0 → 8.4.1 (#7914), `pdfkit` 0.18.0 → 0.19.0 (#7916), `oidc-provider` 9.8.3 → 9.8.4, `@elastic/elasticsearch` 9.4.1 → 9.4.2, `lru-cache` 11.5.0 → 11.5.1, `rate-limiter-flexible` 11.1.0 → 11.1.1, `semver` 7.8.1 → 7.8.2, `js-cookie` 3.0.7 → 3.0.8, `tsx` 4.22.3 → 4.22.4, `@radix-ui/react-switch` 1.2.6 → 1.3.0 (#7913), `@tanstack/react-query` 5.100.11 → 5.101.0 (+ devtools), plus `i18next`, `react-router-dom`, and several dev-dependency group bumps (#7912).
### Localisation
- Multiple updates from translatewiki.net.
# 3.2.0
3.2 adds first-class reverse-proxy / ingress support — `X-Forwarded-Prefix` and `X-Ingress-Path` are now honoured under `trustProxy`, so Etherpad can live under a subpath (Traefik, Nginx, Kubernetes Ingress) without breaking the PWA manifest, social-meta URLs, or any of the bootstrap asset links. The admin settings page learns to show *resolved* runtime values next to `${VAR:default}` placeholders, the v3.1.0 admin pad-list filter chips now apply server-side (so "show empty pads" no longer returns 012 of hundreds), and the v3.1.0 redesigned outdated-version gritter actually fires in production now (the session-based author lookup it shipped with always returned null for pad visitors).
### Notable enhancements
- **HTTP — accept `X-Forwarded-Prefix` and `X-Ingress-Path` under `trustProxy` (#7802 / #7806).** With `trustProxy: true`, Etherpad now honours `X-Forwarded-Prefix` (de-facto Traefik / Spring) and `X-Ingress-Path` (Kubernetes Ingress) in addition to the prefix it already inferred from the request path. The shared `sanitizeProxyPath` helper added in 3.1.0 (defence-in-depth: `[A-Za-z0-9_./-]` only, `//+` collapsed, `..` traversal rejected) is extended to the new headers and applied consistently across `/manifest.json`, `socialMeta` `og:url` / `og:image`, and the `index.html` / `pad.html` / `timeslider.html` / `export_html.html` templates (manifest links, jslicense links, reconnect URLs). A pre-existing `..` segment-count miscalculation in `pad.html` / `timeslider.html` that broke the manifest link when served from a deep subpath is also fixed in passing. New end-to-end suite covers the prefix-applied / prefix-ignored matrix under `trustProxy=true|false` for both header names. `settings.json.template` documents the new headers alongside the existing `trustProxy` notes.
- **Admin settings — resolved runtime values surface on env-pill chips (#7803 / #7807).** The `/admin/settings` socket payload now carries a new `resolved` field alongside the existing raw-file `results` blob, carrying the actual in-memory settings module run through a new redactor (`AdminSettingsRedact`) that replaces known-sensitive paths (`users.*.password`, `dbSettings.password`, `sso.clients[*].client_secret`, `sessionKey`, …) with `[REDACTED]`. The admin SPA's `EnvPill` renders a `→ active value` chip when the path is resolved, or `→ ••••••` with a redacted tooltip when the server returned the sentinel — so `port: ${PORT:9001}` now shows `→ 9001` (or whatever the live value is) instead of silently falling back to the template default. Old admin SPAs that don't read `resolved` continue to work; the save round-trip is unchanged so `${VAR:default}` literals are still preserved verbatim on disk. The admin test script glob picks up `.test.tsx` alongside `.test.ts` so the new `EnvPill` and `resolveByPath` tests run under `tsx --test`.
### Notable fixes
- **Admin pads — filter chip now applies server-side, before pagination (#7798).** The 3.1.0 admin pad-list filter chips (`active` / `recent` / `empty` / `stale`) ran on the client *after* the 12-row page slice had already arrived. On a deployment with hundreds of pads, clicking "empty pads" on page 1 only matched the 012 empties that happened to land in the current page, with the pagination footer reporting nonsense totals (reported on a 3.1.0 deployment). The filter is now part of the `padLoad` socket query — pattern filter on names runs first (cheap), metadata hydration for the matching pad universe is gated on a non-`all` filter or a non-`padName` sort and runs under a 16-way concurrency cap (was unbounded `Promise.all`, which fanned out to thousands of in-flight `padManager.getPad()` reads on busy deployments), then the filter chip, then sort + slice. `total` reflects the filtered universe so the footer makes sense. Older admin clients that don't send `filter` keep working — the server defaults to `all`. The `if/else if` ladder that duplicated the hydrate-and-sort loop per `sortBy` is folded into one pipeline with a single comparator switch.
- **Pad outdated notice — author now resolved from token cookie, not session (Qodo #7804 / #7805).** The 3.1.0 redesigned outdated-version gritter never fired in production. `resolveRequestAuthor()` looked for an `authorID` on `req.session.user`, which Etherpad does not populate for pad visitors (express-session only carries the admin-login user), so `computeOutdated()` always returned EMPTY. The lookup now mirrors how the socket.io handshake resolves pad-visitor identity — read the HttpOnly `token` (or `<prefix>token`) cookie and call `authorManager.getAuthorId(token, user)` via a dynamic import (same circular-init guard pattern the file already uses for `PadManager`). The admin OpenAPI document gains a `description` note clarifying that `/api/version-status` is a public pad-side endpoint that lives in the admin doc only because it shares the same internal route registration.
- **Localisation — silence spurious "could not translate element content" warning (#7797).** `<select data-l10n-id="…">` with `<option>` element children — the pattern used by `ep_headings2`, `ep_align`, `ep_font_size`, `ep_font_family`, … — used to drop into the textContent branch of `html10n.translateNode`, hunt for a text-node child to overwrite, find none, and emit `Unexpected error: could not translate element content for key …` on every pad load. The `SELECT` / `INPUT` / `TEXTAREA` aria-label fallback already lived inside the same else-branch *after* the warning, so the accessible name landed correctly but the noisy console line still fired. Form-control elements now short-circuit into the aria-label path *before* the text-node hunt — aria-label is the only sensible localization target for these elements (a `<select>`'s text is its `<option>` labels, not its own name). Closes the console warning reported on Etherpad 3.1.0.
### Internal / contributor-facing
- **CI — swap archived `ep_readonly_guest` for `ep_guest` in the plugin matrix (#7795 / #7808).** `ep_readonly_guest` is archived (read-only on GitHub) and its `authenticate` hook unconditionally swapped `req.session.user` with a read-only guest, *even when the request carried an HTTP Authorization header*. That silently demoted admin login attempts and stalled the `anonymizeAuthorSocket` tests for 14 min/run on every with-plugins CI matrix. The pre-fix theory from 3.1.0 (#7796) blamed `ep_hash_auth.handleMessage`; that was a red herring — `handleMessage` only fires on the `/pad` namespace, never on `/settings`. `ep_guest` is the maintained successor (same authors, same purpose); 1.0.72 on npm already defers to basic auth / admin paths. Swapping the matrix unblocks the `anonymizeAuthorSocket` suite on Linux, Windows, and the upgrade-from-latest-release workflow. The runtime probe added in #7796 stays — it still catches any other authenticate-hook plugin that rejects the test's plain-text credentials (e.g. a future hashed-only plugin).
- **Tests — admin `saveSettings` round-trip + cross-restart persistence (#7819 / #7820 / #7821).** The admin `saveSettings` socket had zero direct backend coverage and the existing e2e "restart works" test only checked that the page renders after a restart, neither of which catches a deployment that resets `settings.json` on restart, nor the user-visible workflow that triggered #7819 (add a top-level plugin block via Raw, save, watch it disappear). Three new backend specs (`adminSettingsSave.ts`) verify byte-for-byte write, top-level-block augmentation round-tripping through the next `load`, and `/* */` comments surviving the write path. A new e2e spec mirrors the #7819 user workflow — open Raw, prepend an `ep_oauth`-shaped top-level block, save, `restartEtherpad()`, re-login, confirm the block is still in Raw and surfaces as its own Form-view section (`Ep oauth` from `humanize()`). A separate `docker.yml` job (`adminSettings_7819.ts`) authenticates via `POST /admin-auth/` (always-requireAdmin, regardless of `settings.requireAuthentication`), saves a hand-built minimal-but-viable settings document containing a marker block, `docker exec test grep`s for it, `docker restart`s the container, waits for the health probe, and re-greps. Both checks must pass.
- **Bug report template** now asks contributors whether the abstraction in their proposed fix matches the rest of the codebase, to head off premature-generalisation fixes earlier in review.
### Dependencies
- `ueberdb2` 6.0.3 → 6.1.2 (two patch releases of cleanup on top of the 6.1.0 `findKeysPaged` API that the 3.1.0 sessionstorage OOM fix relies on).
- `semver` 7.8.0 → 7.8.1, `lru-cache` 11.3.6 → 11.5.0, `@elastic/elasticsearch` 9.4.0 → 9.4.1, `pg` 8.20.0 → 8.21.0, `openapi-backend` 5.16.1 → 5.17.0, `tsx` 4.22.0 → 4.22.3, `@tanstack/react-query` 5.100.10 → 5.100.11 + `@tanstack/react-query-devtools`, `js-cookie` 3.0.6 → 3.0.7, plus two dev-dependency group bumps.
### Localisation
- Multiple updates from translatewiki.net.
# 3.1.0
3.1 ships the self-update programme's **Tier 4 — autonomous in a maintenance window** for real (the v3.0.0 notes documented the design; this is the release the code actually lands in), adds first-class SMTP delivery so update failures email the admin, and bundles a defence-in-depth pass across the HTTP/API entry points. Two new admin-facing escape hatches arrive: a preflight check that aborts an update *before* it mutates the working tree when the target tag's `engines.node` doesn't match the running runtime, and email notifications for every auto-rollback / preflight outcome (not just the terminal `rollback-failed` state).
### Notable enhancements
- **pad: Outdated-version notice redesigned (#7799).** The persistent "severely outdated" banner is replaced by a dismissable gritter notification (auto-fades after 8 seconds), shown only to a pad's first author and only when the server is at least one minor version behind the latest released version. Patch-only deltas no longer fire the notice. The `vulnerable-below` directive scraping, the `severe` and `vulnerable` enum values, and the `vulnerableBelow` state field have been removed.
- **API: `GET /api/version-status` updated (#7799).** Now accepts an optional `?padId=<id>` query parameter and returns `{outdated: "minor" | null, isFirstAuthor: boolean}`. The `severe` and `vulnerable` enum values are gone. Results are cached per `(padId, authorId)` for 60 seconds.
- **Self-update — Tier 4 (autonomous in a maintenance window).** Set `updates.tier: "autonomous"` together with `updates.maintenanceWindow: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}` to constrain autonomous updates to a nightly window. The scheduler snaps `scheduledFor` forward to the next window opening when grace would otherwise land outside the window, and defers the fire when the window has closed by the timer callback. Cross-midnight windows (`end < start`) are supported; DST transitions are absorbed by host wall-clock arithmetic. A missing or malformed window degrades the policy to Tier 3 with an explicit `policy.reason` of `maintenance-window-missing` / `maintenance-window-invalid`; an admin banner surfaces the misconfiguration so autonomous behaviour is not silently disabled. The admin update page shows a "Maintenance window" section with the parsed window summary, the next opening, and a "deferred until <iso>" subtitle on the scheduled panel when the timer has been snapped forward. Closes #7607 (#7753).
- **Updater — real SMTP via nodemailer (new top-level `mail.*` block).** Replaces the "(would send email)" stub. New settings: `mail.host`, `mail.port`, `mail.secure`, `mail.from`, `mail.auth.{user,pass}`. `mail.host=null` keeps the legacy log-only behaviour. The `nodemailer` dependency is lazy-imported on first send so installs that don't configure mail pay no runtime cost; the transport is cached on the full SMTP options tuple so a `reloadSettings()` change to host/port/credentials invalidates the cache. `settings.json.docker` reads `MAIL_HOST` / `MAIL_FROM` / `MAIL_PORT` / `MAIL_SECURE` from env. Send errors are logged warn and swallowed so a transient SMTP failure can never poison the updater state machine.
- **Updater — preflight against the target tag's `engines.node`.** Before mutating the working tree, `runPreflight` now runs `git show <tag>:package.json` and verifies `process.versions.node` satisfies the target's `engines.node`. A mismatch fails cleanly at `preflight-failed` with the detail `target requires Node >=X, running Y` — no drain, no restart, no rollback. The check runs *after* signature verification so we only trust signed `package.json`. New `PreflightReason: 'node-engine-mismatch'`.
@ -157,7 +14,6 @@ A defence-in-depth pass across the API, token, export, and deployment surfaces:
- **Export HTML — ordered-list counter no longer poisoned by a sibling unordered list.** When an ordered-list level was the only consumer of `olItemCounts`, closing *any* list at that depth (including a `<ul>` that happened to share the level) reset the counter to 0. A subsequent unrelated `<ol>` at the same depth then took the "counter exists but is 0" branch and emitted `<ol class="...">` without the `start=` attribute. The reset is now gated on `line.listTypeName === 'number'` so closing an unordered list never touches the ol bookkeeping. Fixes #7786 / #7787 (#7791).
- **Export — bad `:rev` returns a meaningful 500 body, not Express's HTML error page.** A non-numeric `:rev` (e.g. `/p/foo/test1/export/txt`) reached `checkValidRev` which throws `CustomError('rev is not a number', 'apierror')`; the message fell through `.catch(next)` and Express's default renderer returned an HTML 500 page. The route handler now catches the apierror and emits `err.message` as a deterministic `text/plain` 500. As a follow-up, `checkValidRev` runs *before* `res.attachment()` so an invalid rev no longer leaves a `Content-Disposition` header in place (browsers were offering to save the error message as a file), and unrelated export failures (conversion, fs, soffice) are surfaced as text/plain rather than the HTML stack page. Fixes #7788 (#7792).
- **Session cleanup no longer OOMs on huge sessionstorage tables.** Pre-2.7.3 `SessionStore._cleanup()` issued a single unbounded `findKeys('sessionstorage:*', null)` that materialised every key into one JS array; on a decade-old MariaDB install with millions of stale sessions the mysql2 driver retained the rows on the pool connection while the JS array dominated heap, OOMing the process within ~15 minutes of boot. Cleanup now pages the keyspace in 500-key batches via the new `findKeysPaged` API on ueberdb2 6.1.0 (DB-side ranged query on mysql/postgres, JS-side fallback elsewhere), yielding to the event loop between pages. A single run is capped at 10 minutes; the next scheduled run continues. The defensive cursor-stall guard now logs an error rather than silently aborting, and `DB.init()` fails fast if any required wrapper method is missing (a misconfigured ueberdb2 pin surfaces at boot instead of an hour later). Fixes #7830 (#7831).
### Security hardening

View file

@ -5,8 +5,6 @@
## Pull requests
* PRs MUST include a non-empty description explaining what the change does and why
* PRs without a description should be flagged as incomplete
* the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary
* PRs should be issued against the **develop** branch: we never pull directly into **master**
* PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing
@ -125,7 +123,7 @@ Documentation should be kept up-to-date. This means, whenever you add a new API
You can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet.
## Testing
Front-end tests are found in the `src/tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.
Front-end tests are found in the `tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.
Back-end tests can be run from the `src` directory, via `npm test`.
You can use `npm test -- --inspect-brk` and navigate to `edge://inspect` or `chrome://inspect` to debug the tests.

View file

@ -7,11 +7,6 @@
# docker build --build-arg BUILD_ENV=copy .
ARG BUILD_ENV=git
# NOTE: this intentionally lags the "packageManager" pin in package.json. pnpm
# 11.1.x enforces the minimum-release-age supply-chain policy during install,
# which the frozen-lockfile Docker build can't satisfy, so the image stays on
# 11.0.x. The version gap is made harmless by pnpm_config_pm_on_fail=ignore in
# the build stage below — see ether/etherpad#7911.
ARG PnpmVersion=11.0.6
FROM node:24-alpine AS adminbuild
@ -33,17 +28,6 @@ RUN pnpm run build:ui
FROM node:24-alpine AS build
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
# The image's pnpm intentionally lags the "packageManager" pin (see the ARG
# note above). pnpm would otherwise try to self-provision the pinned version on
# invocation — including the informational `pnpm --version` probe Etherpad runs
# at startup — which fails closed with no network and breaks air-gapped boots
# (ether/etherpad#7911). pm_on_fail=ignore makes pnpm use the installed version
# instead. Inherited by the development and production runtime stages, so it
# also covers the updater's pnpm-on-PATH check and ad-hoc `pnpm` in an exec
# shell. It does not change which pnpm runs the build-time install (still the
# installed 11.0.x), so the frozen-lockfile build is unaffected.
ENV pnpm_config_pm_on_fail=ignore
# Set these arguments when building the image from behind a proxy
ARG http_proxy=
ARG https_proxy=

View file

@ -172,7 +172,7 @@ volumes:
### Docker container
Find [here](doc/docker.md) information on running Etherpad in a container.
Find [here](doc/docker.adoc) information on running Etherpad in a container.
## Plugins
@ -247,8 +247,8 @@ git -P tag --list "v*" --merged
```
4. Select the version
```sh
git checkout v3.2.0
git switch -c v3.2.0
git checkout v2.2.5
git switch -c v2.2.5
```
5. Upgrade Etherpad
```sh

View file

@ -64,25 +64,3 @@ const SettingsPanel = () => {
The admin endpoints are not yet present in the OpenAPI spec — this client is
in place to support upcoming work (see issue #7638 follow-up). For now, it is
exercised only by the smoke test.
## Socket.io: `padLoad` query shape
The admin `/settings` namespace's `padLoad` event accepts a `PadSearchQuery`
defined in `src/node/types/PadSearchQuery.ts`:
| field | type | required | notes |
| ------------ | ----------------------------------------------------------------- | -------- | ----- |
| `pattern` | `string` | yes | Substring match on pad name. |
| `offset` | `number` | yes | Pagination start, in items. Clamped server-side. |
| `limit` | `number` | yes | Page size. Capped at 12. |
| `ascending` | `boolean` | yes | Sort direction. |
| `sortBy` | `"padName" \| "lastEdited" \| "userCount" \| "revisionNumber"` | yes | Column to sort by. |
| `filter` | `"all" \| "active" \| "recent" \| "empty" \| "stale"` *(opt.)* | no | Filter chip; defaults to `"all"`. Applied **before** pagination so `total` and the page slice both reflect the filtered universe. Older clients that omit the field get the unchanged `"all"` behaviour. |
Filter semantics — applied after pattern matching, before sort + slice:
- `active`: `userCount > 0`
- `recent`: edited within the last 7 days
- `empty`: `revisionNumber === 0`
- `stale`: not edited in the last 365 days
- `all` / missing: no further filtering

View file

@ -1,7 +1,7 @@
{
"name": "admin",
"private": true,
"version": "3.3.2",
"version": "3.1.0",
"type": "module",
"scripts": {
"dev": "pnpm gen:api && vite",
@ -11,43 +11,42 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
"preview": "vite preview",
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'"
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts'"
},
"dependencies": {
"@radix-ui/react-switch": "^1.3.3",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10",
"jsonc-parser": "^3.3.1",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4"
},
"devDependencies": {
"@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",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-toast": "^1.2.15",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.63.0",
"@typescript-eslint/parser": "^8.63.0",
"@vitejs/plugin-react": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"eslint": "^10.6.0",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"i18next": "^26.3.6",
"eslint-plugin-react-refresh": "^0.5.2",
"i18next": "^26.2.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.23.0",
"lucide-react": "^1.16.0",
"openapi-typescript": "^7.13.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"react-i18next": "^17.0.9",
"react-router-dom": "^7.18.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.75.0",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.15.1",
"socket.io-client": "^4.8.3",
"tsx": "^4.23.0",
"tsx": "^4.22.0",
"typescript": "^6.0.3",
"vite": "^8.1.4",
"vite-plugin-babel": "^1.7.3",
"zustand": "^5.0.14"
"vite": "^8.0.13",
"vite-plugin-babel": "^1.7.1",
"zustand": "^5.0.13"
}
}

View file

@ -14,8 +14,6 @@
"cap-warning": "Showing the first 1000 authors. Narrow your search to see more.",
"feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.",
"no-results": "No authors match this search.",
"confirm-dialog-title": "Confirm author erasure",
"confirm-dialog-description": "Review the impact of erasing this author and confirm or cancel.",
"confirm-preview-title": "Erase author {{name}}",
"confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.",
"confirm-irreversible": "This cannot be undone.",

View file

@ -34,37 +34,6 @@ textarea.settings:focus {
border-top: 1px solid #ddd;
}
/* --- env-var banner --- */
/* Shown only when settings.json contains ${VAR} placeholders. The
typical reader is a Docker/K8s operator who has just been surprised
by env-var substitution semantics, so the copy must explain rather
than warn visual weight matches a note, not an error. */
.settings-envvar-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
margin-bottom: 16px;
background: #f5f7ff;
border: 1px solid #c7d2fe;
border-radius: 6px;
color: #1e293b;
}
.settings-envvar-banner svg {
flex-shrink: 0;
color: #4f46e5;
margin-top: 2px;
}
.settings-envvar-banner strong {
display: block;
margin-bottom: 4px;
}
.settings-envvar-banner p {
margin: 0;
font-size: 13px;
line-height: 1.5;
}
/* --- mode toggle --- */
.settings-mode-toggle {
display: inline-flex;
@ -264,32 +233,6 @@ textarea.settings:focus {
outline: 2px solid var(--accent, #2b8a3e);
outline-offset: 1px;
}
.settings-widget-env-runtime {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 4px;
padding: 1px 8px;
border-radius: 10px;
background: #e6f4ea;
color: #1e5631;
font-family: "Fira Code", monospace;
font-size: 12px;
}
.settings-widget-env-runtime-redacted {
background: #ececec;
color: #555;
}
.settings-widget-env-runtime-arrow {
opacity: 0.6;
}
.settings-widget-env-runtime-label {
font-style: italic;
opacity: 0.7;
}
.settings-widget-env-runtime-value {
font-weight: 600;
}
/* Radix switch (boolean) */
.settings-widget-boolean {

View file

@ -32,19 +32,12 @@ export const App = () => {
const settingSocket = connect(`${WS_URL}/settings`, {transports: ['websocket']});
const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {transports: ['websocket']})
// When the server explicitly rejects us for not being admin, we must
// NOT reconnect on the subsequent `disconnect` event — otherwise the
// socket cycles forever (connect → admin_auth_error → server
// disconnect → SPA auto-reconnect → …). The flag is reset on each
// successful connect.
let authErrored = false;
pluginsSocket.on('connect', () => {
useStore.getState().setPluginsSocket(pluginsSocket);
});
settingSocket.on('connect', () => {
authErrored = false;
useStore.getState().setSettingsSocket(settingSocket);
useStore.getState().setShowLoading(false)
settingSocket.emit('load');
@ -53,7 +46,7 @@ export const App = () => {
settingSocket.on('disconnect', (reason) => {
useStore.getState().setShowLoading(true)
if (reason === 'io server disconnect' && !authErrored) settingSocket.connect();
if (reason === 'io server disconnect') settingSocket.connect();
});
settingSocket.on('settings', (settings: any) => {
@ -63,14 +56,10 @@ export const App = () => {
}
if (settings.results === 'NOT_ALLOWED') {
console.log('Not allowed to view settings.json')
useStore.getState().setResolved(null);
return;
}
if (isJSONClean(settings.results)) setSettings(settings.results);
else alert(t('admin_settings.invalid_json'));
// The resolved field is optional — old servers won't send it,
// and the SPA degrades to today's behaviour when it's null.
useStore.getState().setResolved(settings.resolved ?? null);
useStore.getState().setShowLoading(false);
});
@ -82,22 +71,6 @@ export const App = () => {
const detail = payload?.message ?? '';
setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false});
}
});
// Backend emits this when the connecting socket does not have an
// admin session. Previously the server just dropped silently, so the
// SPA would sit on a loading screen with no clue. Surface it AND
// suppress the automatic reconnect — without this flag the SPA would
// immediately reconnect to a socket that will reject it again.
settingSocket.on('admin_auth_error', (payload?: {message?: string}) => {
authErrored = true;
const {setToastState} = useStore.getState();
setToastState({
open: true,
title: payload?.message || t('admin_settings.toast.auth_error'),
success: false,
});
useStore.getState().setShowLoading(false);
})
return () => {

View file

@ -9,7 +9,6 @@ import { NumberInput } from './widgets/NumberInput';
import { BooleanToggle } from './widgets/BooleanToggle';
import { NullChip } from './widgets/NullChip';
import { EnvPill } from './widgets/EnvPill';
import { useResolvedAt } from '../../store/store';
type Props = {
/** The value node (not the property node). */
@ -37,7 +36,6 @@ const renderLeaf = (
path: JSONPath,
text: string,
onEdit: (path: JSONPath, value: unknown) => void,
resolvedValue: unknown,
) => {
if (node.type === 'string') {
const raw = text.slice(node.offset, node.offset + node.length);
@ -48,7 +46,6 @@ const renderLeaf = (
placeholder={env}
path={path}
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
resolvedValue={resolvedValue}
/>
);
}
@ -87,11 +84,6 @@ const renderLeaf = (
export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => {
const path = getNodePath(node);
const key = propertyKey(property);
// useResolvedAt must be called unconditionally for every JsoncNode
// render (React hook rules). It's cheap: a shallow zustand selector +
// an object-walk that returns undefined when the resolved payload is
// absent (old server) — in which case EnvPill simply omits the chip.
const resolvedValue = useResolvedAt(path);
const anchor = property ?? node;
const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length);
@ -171,7 +163,7 @@ export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: P
{label}
</label>
<div className="settings-row-control">
{renderLeaf(node, path, text, onEdit, resolvedValue)}
{renderLeaf(node, path, text, onEdit)}
</div>
{help && (
<p className="settings-row-help" id={helpId}>{help}</p>

View file

@ -1,17 +1,13 @@
import { Trans, useTranslation } from 'react-i18next';
export type Mode = 'form' | 'raw' | 'effective';
export type Mode = 'form' | 'raw';
type Props = {
mode: Mode;
onChange: (mode: Mode) => void;
// When false, the Effective tab is hidden. We hide it for installs that
// aren't using env-var substitution at all — there's no useful difference
// between the raw file and the effective in-memory config for them.
showEffective?: boolean;
};
export const ModeToggle = ({ mode, onChange, showEffective = false }: Props) => {
export const ModeToggle = ({ mode, onChange }: Props) => {
const { t } = useTranslation();
return (
<div className="settings-mode-toggle" role="tablist" aria-label={t('admin_settings.mode.aria_label')}>
@ -35,19 +31,6 @@ export const ModeToggle = ({ mode, onChange, showEffective = false }: Props) =>
>
<Trans i18nKey="admin_settings.mode.raw" />
</button>
{showEffective && (
<button
type="button"
role="tab"
aria-selected={mode === 'effective'}
data-testid="mode-toggle-effective"
className={mode === 'effective' ? 'active' : ''}
onClick={() => onChange('effective')}
title={t('admin_settings.mode.effective_tooltip')}
>
<Trans i18nKey="admin_settings.mode.effective" />
</button>
)}
</div>
);
};

View file

@ -3,29 +3,22 @@ import { useTranslation } from 'react-i18next';
import type { JSONPath } from 'jsonc-parser';
import type { EnvPlaceholder } from '../envPill';
const REDACTED = '[REDACTED]';
type Props = {
placeholder: EnvPlaceholder;
path: JSONPath;
onChange: (newDefault: string) => void;
resolvedValue?: unknown;
};
const sanitize = (s: string) => s.replace(/[}]/g, '');
const formatDisplay = (v: unknown): string => {
if (v === null) return 'null';
if (typeof v === 'string') return v;
return String(v);
};
export const EnvPill = ({ placeholder, path, onChange, resolvedValue }: Props) => {
export const EnvPill = ({ placeholder, path, onChange }: Props) => {
const { t } = useTranslation();
const initial = placeholder.defaultValue ?? '';
const [draft, setDraft] = useState(initial);
const focused = useRef(false);
// Sync local draft from upstream (server canonicalisation, raw-mode edit)
// only while the input isn't focused so we don't trample mid-typing.
useEffect(() => {
if (!focused.current) setDraft(initial);
}, [initial]);
@ -33,13 +26,6 @@ export const EnvPill = ({ placeholder, path, onChange, resolvedValue }: Props) =
const id = `field-${path.join('.')}`;
const testid = `env-${path.join('.')}`;
// Three runtime states:
// undefined → server didn't send resolved (old server, or path absent)
// '[REDACTED]' → secret hidden
// anything else → live runtime value
const hasResolved = resolvedValue !== undefined;
const isRedacted = resolvedValue === REDACTED;
return (
<span
className="settings-widget settings-widget-env"
@ -66,31 +52,6 @@ export const EnvPill = ({ placeholder, path, onChange, resolvedValue }: Props) =
onChange(v);
}}
/>
{hasResolved && !isRedacted && (
<span
className="settings-widget-env-runtime"
data-testid={`env-runtime-${path.join('.')}`}
title={t('admin_settings.env_pill.runtime_tooltip', { variable: placeholder.variable })}
>
<span className="settings-widget-env-runtime-arrow" aria-hidden></span>
<span className="settings-widget-env-runtime-label" aria-hidden>
{t('admin_settings.env_pill.runtime_label')}
</span>
<span className="settings-widget-env-runtime-value">
{formatDisplay(resolvedValue)}
</span>
</span>
)}
{isRedacted && (
<span
className="settings-widget-env-runtime settings-widget-env-runtime-redacted"
data-testid={`env-runtime-redacted-${path.join('.')}`}
title={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
aria-label={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
>
<span aria-hidden> </span>
</span>
)}
</span>
);
};

View file

@ -1,86 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nextProvider } from 'react-i18next';
import i18next from 'i18next';
import { EnvPill } from '../EnvPill.tsx';
i18next.init({
lng: 'en',
resources: {
en: {
translation: {
'admin_settings.env_pill.tooltip': 'env {{variable}}',
'admin_settings.env_pill.default_label': 'default',
'admin_settings.env_pill.input_aria': 'aria {{variable}}',
'admin_settings.env_pill.runtime_label': 'active value',
'admin_settings.env_pill.runtime_tooltip': 'using {{variable}}',
'admin_settings.env_pill.redacted_tooltip': 'hidden {{variable}}',
},
},
},
interpolation: { escapeValue: false },
});
const wrap = (el: React.ReactElement) =>
renderToStaticMarkup(
React.createElement(I18nextProvider, { i18n: i18next }, el),
);
test('omits runtime chip when resolvedValue is undefined', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' },
path: ['dbType'],
onChange: () => {},
}));
assert.ok(!html.includes('settings-widget-env-runtime'),
`runtime chip should be absent, got: ${html}`);
});
test('renders runtime chip with resolved value', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' },
path: ['dbType'],
onChange: () => {},
resolvedValue: 'sqlite',
} as any));
assert.ok(html.includes('settings-widget-env-runtime'),
`runtime chip class should appear, got: ${html}`);
assert.ok(html.includes('sqlite'),
`resolved value text should appear, got: ${html}`);
});
test('renders redacted chip when resolvedValue is [REDACTED]', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'DB_PASS', defaultValue: '' },
path: ['dbSettings', 'password'],
onChange: () => {},
resolvedValue: '[REDACTED]',
} as any));
assert.ok(html.includes('settings-widget-env-runtime-redacted'),
`redacted chip class should appear, got: ${html}`);
assert.ok(!html.includes('[REDACTED]'),
`literal sentinel must not be displayed to the user, got: ${html}`);
});
test('coerces non-string resolved values to display strings', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'TRUST_PROXY', defaultValue: 'false' },
path: ['trustProxy'],
onChange: () => {},
resolvedValue: true,
} as any));
assert.ok(html.includes('true'), `expected "true" in ${html}`);
});
test('renders null resolved value as the string null', () => {
const html = wrap(React.createElement(EnvPill, {
placeholder: { variable: 'IP', defaultValue: '' },
path: ['ip'],
onChange: () => {},
resolvedValue: null,
} as any));
assert.ok(html.includes('null'), `expected "null" in ${html}`);
});

View file

@ -1,7 +1,6 @@
import {Trans, useTranslation} from "react-i18next";
import {useEffect, useMemo, useState} from "react";
import * as Dialog from "@radix-ui/react-dialog";
import {VisuallyHidden} from "@radix-ui/react-visually-hidden";
import {ChevronLeft, ChevronRight, Trash2} from "lucide-react";
import {useStore} from "../store/store.ts";
import {SearchField} from "../components/SearchField.tsx";
@ -154,20 +153,16 @@ export const AuthorPage = () => {
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<VisuallyHidden asChild>
<Dialog.Title>{t('ep_admin_authors:confirm-dialog-title')}</Dialog.Title>
</VisuallyHidden>
<VisuallyHidden asChild>
<Dialog.Description>{t('ep_admin_authors:confirm-dialog-description')}</Dialog.Description>
</VisuallyHidden>
{dialog.phase === 'loading-preview' && <div>
<Trans i18nKey="ep_admin_authors:loading-preview" ns="ep_admin_authors"/>
</div>}
{(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => {
const p = dialog.preview;
return <div>
<h3>{t('ep_admin_authors:confirm-preview-title',
{name: p.name || p.authorID})}</h3>
<Dialog.Title asChild>
<h3>{t('ep_admin_authors:confirm-preview-title',
{name: p.name || p.authorID})}</h3>
</Dialog.Title>
<p>{t('ep_admin_authors:confirm-preview-counters', {
tokenMappings: p.removedTokenMappings,
externalMappings: p.removedExternalMappings,

View file

@ -1,17 +1,20 @@
import {Trans, useTranslation} from "react-i18next";
import {useEffect, useMemo, useState} from "react";
import {useStore} from "../store/store.ts";
import {PadFilter, PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
import {useDebounce} from "../utils/useDebounce.ts";
import * as Dialog from "@radix-ui/react-dialog";
import {VisuallyHidden} from "@radix-ui/react-visually-hidden";
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react";
import {useForm} from "react-hook-form";
import type {TFunction} from "i18next";
type PadCreateProps = { padName: string }
type FilterId = 'all' | 'active' | 'recent' | 'empty' | 'stale'
const PAD_FILTER_IDS: PadFilter[] = ['all', 'active', 'recent', 'empty', 'stale']
const PAD_FILTER_IDS: FilterId[] = ['all', 'active', 'recent', 'empty', 'stale']
const isRecent = (ts: number) => (Date.now() - ts) < 86_400_000 * 7
const isStale = (ts: number) => (Date.now() - ts) > 86_400_000 * 365
function relativeTime(t: TFunction, ts: number): string {
const d = (Date.now() - ts) / 1000
@ -55,23 +58,12 @@ function sanitizeLocale(lng?: string): string {
export const PadPage = () => {
const settingsSocket = useStore(state => state.settingsSocket)
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false, filter: 'all',
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false,
})
const {t, i18n} = useTranslation()
const locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language)
const [searchTerm, setSearchTerm] = useState('')
// Read filter off searchParams so chip changes round-trip through
// the server (`filter` is applied before pagination there). Clicking
// a chip used to filter only the current 12-row page slice.
//
// All searchParams mutations go through functional updaters because the
// debounced pattern handler captures a render-time snapshot and would
// otherwise revert a faster chip click / sort change made in between.
const filter: PadFilter = searchParams.filter ?? 'all'
const setFilter = (f: PadFilter) => {
setCurrentPage(0)
setSearchParams((sp) => ({...sp, filter: f, offset: 0}))
}
const [filter, setFilter] = useState<FilterId>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
const pads = useStore(state => state.pads)
const [currentPage, setCurrentPage] = useState(0)
@ -86,23 +78,28 @@ export const PadPage = () => {
[pads, searchParams.limit]
)
// The server applies `filter` before paginating; the page payload is
// already the filtered slice. The stats cards still reflect the
// current page (pre-existing behaviour) — making them global would
// require a separate aggregate query.
const visibleResults = pads?.results ?? []
const totalUsers = useMemo(() => visibleResults.reduce((s, p) => s + p.userCount, 0), [pads])
const activeCount = useMemo(() => visibleResults.filter(p => p.userCount > 0).length, [pads])
const emptyCount = useMemo(() => visibleResults.filter(p => p.revisionNumber === 0).length, [pads])
const filteredResults = useMemo(() => {
const r = pads?.results ?? []
if (filter === 'active') return r.filter(p => p.userCount > 0)
if (filter === 'recent') return r.filter(p => isRecent(p.lastEdited))
if (filter === 'empty') return r.filter(p => p.revisionNumber === 0)
if (filter === 'stale') return r.filter(p => isStale(p.lastEdited))
return r
}, [pads, filter])
const totalUsers = useMemo(() => (pads?.results ?? []).reduce((s, p) => s + p.userCount, 0), [pads])
const activeCount = useMemo(() => (pads?.results ?? []).filter(p => p.userCount > 0).length, [pads])
const emptyCount = useMemo(() => (pads?.results ?? []).filter(p => p.revisionNumber === 0).length, [pads])
const lastActivity = useMemo(() => {
return visibleResults.length ? Math.max(...visibleResults.map(p => p.lastEdited)) : null
const r = pads?.results ?? []
return r.length ? Math.max(...r.map(p => p.lastEdited)) : null
}, [pads])
const allSelected = visibleResults.length > 0 && visibleResults.every(p => selected.has(p.padName))
const allSelected = filteredResults.length > 0 && filteredResults.every(p => selected.has(p.padName))
const toggleAll = () => {
const s = new Set(selected)
if (allSelected) visibleResults.forEach(p => s.delete(p.padName))
else visibleResults.forEach(p => s.add(p.padName))
if (allSelected) filteredResults.forEach(p => s.delete(p.padName))
else filteredResults.forEach(p => s.add(p.padName))
setSelected(s)
}
const toggleOne = (name: string) => {
@ -112,10 +109,7 @@ export const PadPage = () => {
}
useDebounce(() => {
// Functional updater so this delayed callback can't clobber a faster
// user interaction (e.g. clicking a filter chip mid-typing).
setSearchParams((sp) => ({...sp, pattern: searchTerm, offset: 0}))
setCurrentPage(0)
setSearchParams({...searchParams, pattern: searchTerm})
}, 500, [searchTerm])
useEffect(() => {
@ -166,10 +160,7 @@ export const PadPage = () => {
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.delete_pad_dialog_title')}</Dialog.Title></VisuallyHidden>
<Dialog.Description asChild>
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
</Dialog.Description>
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
<div className="settings-button-bar">
<button onClick={() => setDeleteDialog(false)}><Trans i18nKey="admin_pads.cancel"/></button>
<button onClick={() => { deletePad(padToDelete); setDeleteDialog(false) }}>{t('admin_pads.confirm_button')}</button>
@ -182,10 +173,7 @@ export const PadPage = () => {
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.error_prefix')}</Dialog.Title></VisuallyHidden>
<Dialog.Description asChild>
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
</Dialog.Description>
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
<div className="settings-button-bar">
<button onClick={() => setErrorText(null)}>{t('admin_pads.confirm_button')}</button>
</div>
@ -198,7 +186,6 @@ export const PadPage = () => {
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
<VisuallyHidden asChild><Dialog.Description>{t('admin_pads.create_pad_dialog_description')}</Dialog.Description></VisuallyHidden>
<form onSubmit={handleSubmit(onPadCreate)}>
<button className="dialog-close-button" type="button" onClick={() => setCreatePadDialogOpen(false)}>×</button>
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>
@ -263,7 +250,7 @@ export const PadPage = () => {
<section className="pm-section">
<div className="pm-section-header">
<h2><Trans i18nKey="admin_pads.all_pads"/></h2>
<span className="pm-count-badge">{visibleResults.length}</span>
<span className="pm-count-badge">{filteredResults.length}</span>
<div className="pm-spacer"/>
<div className="pm-toolbar">
<div className="pm-search">
@ -281,12 +268,12 @@ export const PadPage = () => {
<select
className="pm-select"
value={searchParams.sortBy}
onChange={e => setSearchParams((sp) => ({
...sp,
onChange={e => setSearchParams({
...searchParams,
sortBy: e.target.value,
// Keep current direction when only the column changes; the
// ↑/↓ button below is the sole control for direction.
}))}
})}
>
<option value="lastEdited">{t('ep_admin_pads:ep_adminpads2_last-edited')}</option>
<option value="padName">{t('admin_pads.sort.name')}</option>
@ -295,10 +282,10 @@ export const PadPage = () => {
</select>
<button
className="pm-sort-dir"
onClick={() => setSearchParams((sp) => ({
...sp,
ascending: !sp.ascending,
}))}
onClick={() => setSearchParams({
...searchParams,
ascending: !searchParams.ascending,
})}
title={t(searchParams.ascending
? 'admin_plugins.sort_ascending'
: 'admin_plugins.sort_descending')}
@ -343,7 +330,7 @@ export const PadPage = () => {
</div>
)}
{visibleResults.length > 0 ? (
{filteredResults.length > 0 ? (
<div className="pm-table-wrap">
<table>
<thead>
@ -361,7 +348,7 @@ export const PadPage = () => {
</tr>
</thead>
<tbody>
{visibleResults.map(pad => {
{filteredResults.map(pad => {
const isEmpty = pad.revisionNumber === 0
const isSel = selected.has(pad.padName)
return (
@ -418,7 +405,7 @@ export const PadPage = () => {
</button>
<button
className="pm-btn pm-btn-primary pm-btn--sm"
onClick={() => window.open(`../../p/${encodeURIComponent(pad.padName)}`, '_blank', 'noopener,noreferrer')}
onClick={() => window.open(`../../p/${pad.padName}`, '_blank')}
>
<Eye size={13}/> <Trans i18nKey="admin_pads.open"/>
</button>
@ -445,7 +432,7 @@ export const PadPage = () => {
onClick={() => {
const p = currentPage - 1
setCurrentPage(p)
setSearchParams((sp) => ({...sp, offset: p * sp.limit}))
setSearchParams({...searchParams, offset: p * searchParams.limit})
}}
>
<ChevronLeft size={14}/> <Trans i18nKey="admin_pads.pagination.previous"/>
@ -457,7 +444,7 @@ export const PadPage = () => {
onClick={() => {
const p = currentPage + 1
setCurrentPage(p)
setSearchParams((sp) => ({...sp, offset: p * sp.limit}))
setSearchParams({...searchParams, offset: p * searchParams.limit})
}}
>
<Trans i18nKey="admin_pads.pagination.next"/> <ChevronRight size={14}/>

View file

@ -1,42 +1,22 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useState } from 'react';
import { useStore } from '../store/store';
import { isJSONClean, cleanComments } from '../utils/utils';
import { Trans, useTranslation } from 'react-i18next';
import { IconButton } from '../components/IconButton';
import { RotateCw, Save, AlignLeft, ShieldCheck, Info } from 'lucide-react';
import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react';
import { FormView } from '../components/settings/FormView';
import { ModeToggle, type Mode } from '../components/settings/ModeToggle';
const TAB_INDENT = ' ';
// Heuristic: `${VAR}` or `${VAR:default}` in the file means the operator is
// running with env-var substitution (overwhelmingly Docker / Kubernetes).
// We use this to gate the Docker-aware UX (the explanatory banner and the
// Effective-config tab) so non-container installs see the existing UI
// unchanged. Conservative on purpose — false negatives just keep the old
// behaviour.
const ENV_VAR_PATTERN = /\$\{[A-Za-z_][A-Za-z0-9_]*(?::[^}]*)?\}/;
export const SettingsPage = () => {
const { t } = useTranslation();
const settingsSocket = useStore(state => state.settingsSocket);
const settings = useStore(state => state.settings) ?? '';
const resolved = useStore(state => state.resolved);
const usesEnvVars = useMemo(() => ENV_VAR_PATTERN.test(settings), [settings]);
const [mode, setMode] = useState<Mode>('form');
const [exposeExperimental] = useState(false);
// The Effective tab is only meaningful when there is a `resolved`
// payload AND the file uses substitution. Falling back to Raw on
// either condition keeps the toggle honest if the user opens this
// page against an older server.
const canShowEffective = usesEnvVars && resolved != null;
useEffect(() => {
if (mode === 'effective' && !canShowEffective) setMode('raw');
}, [mode, canShowEffective]);
// Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders.
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Tab') return;
@ -77,80 +57,49 @@ export const SettingsPage = () => {
settingsSocket.emit('saveSettings', settings);
};
const effectiveJson = useMemo(() => {
if (resolved == null) return '';
try { return JSON.stringify(resolved, null, 2); } catch { return ''; }
}, [resolved]);
return (
<div className="settings-page">
<h1><Trans i18nKey="admin_settings.current" /></h1>
{usesEnvVars && (
<div
className="settings-envvar-banner"
role="note"
data-testid="settings-envvar-banner"
>
<Info size={18} aria-hidden="true" />
<div>
<strong><Trans i18nKey="admin_settings.envvar_banner.title" /></strong>
<p><Trans i18nKey="admin_settings.envvar_banner.body" /></p>
</div>
</div>
)}
<ModeToggle mode={mode} onChange={setMode} />
<ModeToggle mode={mode} onChange={setMode} showEffective={canShowEffective} />
{mode === 'form' && <FormView onSwitchToRaw={() => setMode('raw')} />}
{mode === 'raw' && (
<textarea
value={settings}
className="settings"
data-testid="settings-raw-textarea"
spellCheck={false}
onKeyDown={handleKeyDown}
onChange={v => useStore.getState().setSettings(v.target.value)}
/>
)}
{mode === 'effective' && (
<textarea
value={effectiveJson}
className="settings"
data-testid="settings-effective-textarea"
spellCheck={false}
readOnly
aria-readonly="true"
/>
)}
{mode === 'form'
? <FormView onSwitchToRaw={() => setMode('raw')} />
: (
<textarea
value={settings}
className="settings"
data-testid="settings-raw-textarea"
spellCheck={false}
onKeyDown={handleKeyDown}
onChange={v => useStore.getState().setSettings(v.target.value)}
/>
)
}
<div className="settings-button-bar">
{mode !== 'effective' && (
<>
<IconButton
className="settingsButton"
data-testid="save-settings-button"
icon={<Save />}
title={<Trans i18nKey="admin_settings.current_save.value" />}
onClick={handleSave}
/>
<IconButton
className="settingsButton"
data-testid="test-settings-button"
icon={<ShieldCheck />}
title={<Trans i18nKey="admin_settings.current_test.value" />}
onClick={testJSON}
/>
{exposeExperimental && (
<IconButton
className="settingsButton"
data-testid="prettify-settings-button"
icon={<AlignLeft />}
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
onClick={prettifyJSON}
/>
)}
</>
<IconButton
className="settingsButton"
data-testid="save-settings-button"
icon={<Save />}
title={<Trans i18nKey="admin_settings.current_save.value" />}
onClick={handleSave}
/>
<IconButton
className="settingsButton"
data-testid="test-settings-button"
icon={<ShieldCheck />}
title={<Trans i18nKey="admin_settings.current_test.value" />}
onClick={testJSON}
/>
{exposeExperimental && (
<IconButton
className="settingsButton"
data-testid="prettify-settings-button"
icon={<AlignLeft />}
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
onClick={prettifyJSON}
/>
)}
<IconButton
className="settingsButton"

View file

@ -1,10 +1,8 @@
import {create} from "zustand";
import {Socket} from "socket.io-client";
import type {JSONPath} from "jsonc-parser";
import {PadSearchResult} from "../utils/PadSearch.ts";
import {AuthorSearchResult} from "../utils/AuthorSearch.ts";
import {InstalledPlugin} from "../pages/Plugin.ts";
import {resolveByPath} from "../utils/resolveByPath.ts";
export type Execution =
| {status: 'idle'}
@ -47,6 +45,7 @@ export interface UpdateStatusPayload {
installMethod: string;
tier: string;
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
vulnerableBelow: Array<{announcedBy: string; threshold: string}>;
// Tier 2 additions:
execution: Execution;
lastResult: LastResult;
@ -67,11 +66,6 @@ type ToastState = {
type StoreState = {
settings: string|undefined,
setSettings: (settings: string) => void,
// Resolved runtime values for the /admin/settings page. The server
// emits this alongside the raw `settings` string so the SPA can show
// env-substituted values; secrets are redacted to "[REDACTED]".
resolved: unknown | null,
setResolved: (resolved: unknown | null) => void,
settingsSocket: Socket|undefined,
setSettingsSocket: (socket: Socket) => void,
showLoading: boolean,
@ -98,8 +92,6 @@ type StoreState = {
export const useStore = create<StoreState>()((set) => ({
settings: undefined,
setSettings: (settings: string) => set({settings}),
resolved: null,
setResolved: (resolved) => set({resolved}),
settingsSocket: undefined,
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
showLoading: false,
@ -126,6 +118,3 @@ export const useStore = create<StoreState>()((set) => ({
gdprAuthorErasureEnabled: false,
setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}),
}));
export const useResolvedAt = (path: JSONPath): unknown =>
useStore(s => resolveByPath(s.resolved, path));

View file

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

View file

@ -1,12 +1,9 @@
export type PadFilter = 'all' | 'active' | 'recent' | 'empty' | 'stale';
export type PadSearchQuery = {
pattern: string;
offset: number;
limit: number;
ascending: boolean;
sortBy: string;
filter?: PadFilter;
}

View file

@ -1,41 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveByPath } from '../resolveByPath.ts';
test('returns undefined for null/undefined root', () => {
assert.equal(resolveByPath(null, ['a']), undefined);
assert.equal(resolveByPath(undefined, ['a']), undefined);
});
test('walks nested object keys', () => {
assert.equal(resolveByPath({a: {b: {c: 42}}}, ['a', 'b', 'c']), 42);
});
test('walks arrays with numeric indices', () => {
assert.equal(resolveByPath({xs: [10, 20, 30]}, ['xs', 1]), 20);
});
test('walks mixed objects and arrays', () => {
assert.equal(
resolveByPath({sso: {clients: [{id: 'A'}, {id: 'B'}]}}, ['sso', 'clients', 1, 'id']),
'B',
);
});
test('returns undefined for missing keys', () => {
assert.equal(resolveByPath({a: 1}, ['b']), undefined);
assert.equal(resolveByPath({a: {b: 1}}, ['a', 'c']), undefined);
});
test('returns undefined when traversing into a primitive', () => {
assert.equal(resolveByPath({a: 1}, ['a', 'b']), undefined);
});
test('returns the root when path is empty', () => {
const obj = {a: 1};
assert.equal(resolveByPath(obj, []), obj);
});
test('handles string-form numeric indices for arrays', () => {
assert.equal(resolveByPath({xs: [10, 20]}, ['xs', '1']), 20);
});

View file

@ -1,17 +0,0 @@
import type { JSONPath } from 'jsonc-parser';
export const resolveByPath = (obj: unknown, path: JSONPath): unknown => {
let cur: unknown = obj;
for (const seg of path) {
if (cur === null || cur === undefined) return undefined;
if (typeof cur !== 'object') return undefined;
if (Array.isArray(cur)) {
const i = typeof seg === 'number' ? seg : Number(seg);
if (!Number.isInteger(i)) return undefined;
cur = cur[i];
} else {
cur = (cur as Record<string, unknown>)[String(seg)];
}
}
return cur;
};

132
best_practices.md Normal file
View file

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

View file

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

View file

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

View file

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

203
bin/createRelease.sh Executable file
View file

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

View file

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

View file

@ -74,20 +74,10 @@ const handleSync = async ()=>{
}
}
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()
handleSync().then(()=>{
console.log("Done syncing dbs")
process.exit(0)
}).catch(async e=>{
}).catch(e=>{
console.log(`Error syncing db ${e}`)
await ueberdb2.close().catch(()=>{})
await ueberdb1.close().catch(()=>{})
process.exit(1)
})

View file

@ -35,16 +35,26 @@ 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) {
const value = await dirty.get(key);
await db.set(key, value);
if (++numWritten % 100 === 0) console.log(`Wrote record ${numWritten} of ${keys.length}`);
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);
}
await Promise.all(p);
console.log(`Wrote all ${numWritten} records`);
await db.close();
await dirty.close();
await db.close(null);
await dirty.close(null);
console.log('Finished.');
process.exit(0)
})();

View file

@ -1,6 +1,6 @@
{
"name": "bin",
"version": "3.3.2",
"version": "3.1.0",
"description": "",
"main": "checkAllPads.js",
"directories": {
@ -9,12 +9,12 @@
"dependencies": {
"ep_etherpad-lite": "workspace:../src",
"log4js": "^6.9.1",
"semver": "^7.8.5",
"tsx": "^4.23.0",
"ueberdb2": "6.1.16"
"semver": "^7.8.0",
"tsx": "^4.22.0",
"ueberdb2": "^6.0.3"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/node": "^25.8.0",
"@types/semver": "^7.7.1",
"typescript": "^6.0.3"
},
@ -24,7 +24,6 @@
"checkAllPads": "node --import tsx checkAllPads.ts",
"compactPad": "node --import tsx compactPad.ts",
"compactAllPads": "node --import tsx compactAllPads.ts",
"compactStalePads": "node --import tsx compactStalePads.ts",
"createUserSession": "node --import tsx createUserSession.ts",
"deletePad": "node --import tsx deletePad.ts",
"repairPad": "node --import tsx repairPad.ts",

View file

@ -26,19 +26,13 @@ export default defineConfig({
text: 'About',
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' },
]
},
{

View file

@ -1,91 +0,0 @@
# 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

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

View file

@ -1,7 +1,7 @@
# Changeset Library
The [changeset
library](https://github.com/ether/etherpad/blob/develop/src/static/js/Changeset.ts)
library](https://github.com/ether/etherpad-lite/blob/develop/src/static/js/Changeset.ts)
provides tools to create, read, and apply changesets.
## Changeset
@ -21,42 +21,6 @@ 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
@ -72,59 +36,6 @@ 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:

View file

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

View file

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

View file

@ -712,31 +712,3 @@ get stats of the etherpad instance
_Example returns_:
* `{"code":0,"message":"ok","data":{"totalPads":3,"totalSessions": 2,"totalActivePads": 1}}`
===== `GET /api/version-status`
Returns an outdated-version signal intended for the pad-side gritter.
*Query parameters:*
[cols="1,1,1,3"]
|===
| name | type | required | description
| `padId`
| string
| no
| Pad whose first-author membership is being checked.
|===
*Response 200 (`application/json`):*
[source,json]
----
{
"outdated": "minor",
"isFirstAuthor": true
}
----
`outdated` is `"minor"` only when the running server is at least one minor version behind the latest published release AND the request resolves to the pad's first author. Otherwise it is `null`. Result is cached per `(padId, authorId)` for 60s. The endpoint is disabled entirely when `updates.tier = 'off'`.

View file

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

View file

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

View file

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

View file

@ -1,204 +0,0 @@
# 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).
:::

View file

@ -1,359 +0,0 @@
# 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
```

View file

@ -1,240 +0,0 @@
# 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

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

View file

@ -1,204 +0,0 @@
# 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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,633 +0,0 @@
# 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

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

View file

@ -1,280 +0,0 @@
# /admin/settings — emit resolved runtime values alongside raw file
**Issue:** [ether/etherpad#7803](https://github.com/ether/etherpad/issues/7803)
**Date:** 2026-05-18
## Problem
`/admin/settings` reads `settings.json` off disk and emits its bytes
verbatim. The admin SPA then parses those bytes against its enum
dropdowns. Anywhere `${ENV_VAR:default}` appears, the SPA can't resolve
the variable and falls back to the template default — so operators see
values that don't reflect what Etherpad is actually running with.
Concrete example: `DB_TYPE=sqlite` in the container env, settings file
contains `"dbType": "${DB_TYPE:dirty}"`. Admin UI shows the DB Type
dropdown selected on `dirty`. Etherpad is genuinely on SQLite. The
admin UI is lying.
This is the only built-in way operators have to verify runtime config,
so a fix is overdue.
## Goals
1. The admin UI accurately shows what `settings.*` values Etherpad is
actually using right now, including env-var-substituted values.
2. The raw textarea and `saveSettings` round-trip preserve the original
`${VAR:default}` literals so an admin can still edit the template
without baking env vars into the file.
3. Secrets that would otherwise leak (passwords, OIDC client secrets,
session-signing material) are redacted from the resolved payload.
## Non-goals
- Rewriting the save path so admins can edit through the form view
without touching env-var bindings. That's a larger UX rework — see
Future Work.
- Changing `settings.json.template` or `settings.json.docker` on disk.
- Touching ep_kaput.
## Design
### Architecture
Extend the existing `'load'` socket handler in
`src/node/hooks/express/adminsettings.ts` to emit one additional
field next to the existing `results` blob:
```ts
socket.emit('settings', {
results: rawFileString, // unchanged — for textarea + saveSettings
resolved: redactedRuntimeObject, // new — parsed object, env vars resolved, secrets redacted
flags, // unchanged
});
```
`resolved` is the in-memory `settings` module (which already had
`lookupEnvironmentVariables` applied to it at boot) passed through a
recursive redactor. Old clients that ignore `resolved` continue to
work unchanged. The `saveSettings` handler is not touched, so the
file's `${VAR:default}` literals survive save round-trips.
### Server: redactor
New module `src/node/utils/AdminSettingsRedact.ts` exporting:
```ts
export function redactSettings(settings: unknown): unknown
```
A pure function that takes the in-memory settings object, deep-clones
it (Node's built-in `structuredClone`, available since Node 17),
walks the clone, and replaces values at known sensitive JSON paths
with the sentinel string `"[REDACTED]"`. The original object is not
mutated. Functions on the live `settings` module — `coerceValue`,
`reloadSettings`, etc. — are dropped during the clone walk by
filtering them out before recursion (structured clone rejects
functions); the resolved payload contains only data.
Allow-list (paths use `*` for any object key and `[*]` for any array
index):
| Path | Reason |
|---|---|
| `users.*.password` | Plaintext basic-auth password |
| `users.*.passwordHash` | Bcrypt hash — credential material |
| `users.*.hash` | Older spelling used by some configs |
| `dbSettings.password` | DB password (mysql/postgres/redis) |
| `dbSettings.user` | Credential half — redact for symmetry |
| `sso.clients[*].client_secret` | OIDC client secret |
| `sso.clients[*].secret` | ep_openid_connect older spelling |
| `sso.issuer` | Only if URL contains `user:pass@` userinfo |
| `loadTest.*.password` | ep_load_test creds |
| `sessionKey` | Used to sign session cookies |
Behaviour:
- Redact to the literal string `"[REDACTED]"` regardless of original
type, so the SPA only ever has to check one sentinel.
- A redacted leaf is redacted only at the leaf — siblings stay
visible (e.g. `sso.clients[0].client_id` is shown,
`sso.clients[0].client_secret` is redacted).
- If the underlying value was `null` (env var unset, no default),
still emit `"[REDACTED]"` to avoid leaking "this secret is unset"
via a visible `null`.
- `dbSettings.filename` is NOT redacted — operators need to verify
their volume mount.
### Server: emit site
In `adminsettings.ts`, in `socket.on('load')`:
```ts
import {redactSettings} from '../../utils/AdminSettingsRedact';
// ...
const resolved = redactSettings(settings);
socket.emit('settings', {results: rawFileString, resolved, flags});
```
If `showSettingsInAdminPage === false`, omit `resolved` too (don't
emit a redacted runtime when the file blob is gated).
### Client: store
`admin/src/store/store.ts`:
- Add `resolved: unknown | null` to the store, defaulting to `null`.
- In the `'settings'` socket listener, store `payload.resolved ?? null`.
- Old servers that don't send `resolved` leave it `null` and the UI
degrades to current behaviour.
`admin/src/utils/resolveByPath.ts` (new file — single-purpose helper):
- Export `resolveByPath(obj: unknown, path: JSONPath): unknown`
walks a plain JS object by a `jsonc-parser` JSONPath
(`(string | number)[]`), returns `undefined` on miss. Pure,
unit-tested.
`admin/src/store/store.ts`:
- Add a `useResolvedAt(path: JSONPath)` selector hook that returns
`resolveByPath(state.resolved, path)`.
### Client: env pill widget
`admin/src/components/settings/widgets/EnvPill.tsx`:
- New optional prop `resolvedValue?: unknown`.
- When defined and not `'[REDACTED]'`: render a read-only chip after
the editable default input, e.g. `→ sqlite`.
- When `'[REDACTED]'`: render `→ ••••••` with an i18n tooltip
explaining the value is redacted.
- When `undefined` (old server or missing path): no chip; render
exactly as today.
New i18n key `admin_settings.env_pill.runtime_label` and
`admin_settings.env_pill.redacted_tooltip`. No hardcoded English.
### Client: jsonc tree
`admin/src/components/settings/JsoncNode.tsx`:
- Where `matchEnvPlaceholder(raw)` is currently called (line ~42),
also call `useResolvedAt(path)` and pass the result as
`resolvedValue` to `<EnvPill>`.
### Client: form view
`admin/src/components/settings/FormView.tsx`:
- FormView already has access to the parsed JSONC tree through
`rawText = useStore(s => s.settings)` plus jsonc-parser. For each
control, after detecting that the raw value at its path is an env
placeholder (`matchEnvPlaceholder` on the raw slice), the
*selected* dropdown option / displayed input value is derived from
`useResolvedAt(path)` rather than from the literal placeholder
string. Plain (non-placeholder) values keep using the raw JSONC
value as today.
- The env pill above the control still shows and is still editable
(the editable input mutates the `default` portion of the
placeholder in the raw JSONC, exactly as today).
- This is the fix for the original "dropdown shows `dirty` when DB is
sqlite" complaint.
## Data flow
```
boot
└─ Settings.ts:reloadSettings()
└─ lookupEnvironmentVariables(parsedSettings) -- ${VAR:default} → real value
└─ writes into the exported `settings` module
admin loads /admin/settings
└─ socket 'load'
├─ fsp.readFile(settings.settingsFilename) -- raw, env vars unresolved
└─ redactSettings(settings) -- live module, env vars resolved, secrets redacted
socket.emit('settings', {results: raw, resolved, flags})
admin SPA
├─ raw textarea ← results -- preserves ${VAR:default}
├─ env pill chip ← resolveByPath(resolved, path) -- shows "→ sqlite"
└─ form view dropdown selection ← resolveByPath(resolved, path)
admin clicks Save
└─ saveSettings emits the raw textarea blob (unchanged behaviour)
└─ server writes verbatim to settings.json — template intact
```
## Error handling
- `redactSettings` is a pure function over a structured clone; no I/O,
no rejection path. If it throws on a malformed live `settings`
module (shouldn't happen — that module is the source of truth at
runtime) we let the error propagate; the `socket.on('load')`
handler already has no try/catch around the emit and any throw will
surface in logs.
- On the client, `resolveByPath` returns `undefined` for missing
paths. Consumers treat `undefined` as "no resolved value
available" and fall back to current behaviour.
- Old client + new server: client ignores `resolved` — degrades to
today's misleading UI, no regression.
- New client + old server: `resolved` is `null``useResolvedAt`
returns `undefined` everywhere, env pill skips the chip, dropdowns
fall back to current behaviour.
## Testing
Per the `feedback_always_run_backend_tests` and
`feedback_test_localized_strings` memories.
**Backend vitest** (`src/tests/backend/specs/`):
- `AdminSettingsRedact.spec.ts` — fixture per allow-list path, plus
a no-op control case, plus a nested-secret-inside-an-array case.
- `adminsettings.spec.ts` — mock socket, set `process.env.DB_TYPE=sqlite`,
call `reloadSettings()`, emit `load`, assert `resolved.dbType === 'sqlite'`
and `resolved.dbSettings.password === '[REDACTED]'`.
**Frontend vitest** (`admin/src/`):
- `utils/resolveByPath.spec.ts` — nested objects, arrays, missing keys.
- `widgets/EnvPill.spec.tsx` — renders chip when value set, renders
redacted chip when sentinel, omits chip when undefined.
**Playwright e2e** (`src/tests/frontend/specs/` or `src/tests/admin/`):
- Boot Etherpad with `DB_TYPE=sqlite` env on port 9003 (per
`feedback_test_port_9003`).
- Open `/admin/settings`, switch to form view.
- Assert DB Type dropdown reflects `sqlite`, not `dirty`.
- Switch to raw view, assert env pill chip shows `→ sqlite`.
## Docs
Per `feedback_include_docs_updates`:
- Identify the existing admin-settings doc path during implementation
(likely `doc/admin/admin-settings.md` or under `doc/api/`) — add a
short section on the resolved-value chip and the `[REDACTED]`
sentinel. If no such doc exists yet, no new doc is created in this
PR (avoid the "don't create docs unless asked" rule); instead, a
pointer in the PR description.
- Follow-up PR to `ether/home-assistant-addon-etherpad/etherpad/DOCS.md`
to remove the "admin settings page is cosmetic" caveat once this
ships; reference from the etherpad PR description.
## Future work
- Form-view save path that lets an admin edit a value without
touching its env-var binding. Today FormView writes back to raw
JSONC; the env-pill default input is the only way to edit an
env-bound value, which is awkward for non-string values.
- An audit log of redacted-vs-visible keys so plugins can declare
additional secret paths via a hook.
## Implementation footprint
- New file `src/node/utils/AdminSettingsRedact.ts` (~80 lines + JSDoc).
- `src/node/hooks/express/adminsettings.ts` — ~4 lines changed.
- New file `admin/src/utils/resolveByPath.ts` (~15 lines).
- `admin/src/store/store.ts` — ~10 lines changed (store field,
selector hook).
- `admin/src/components/settings/widgets/EnvPill.tsx` — ~15 lines
added (prop, chip render).
- `admin/src/components/settings/JsoncNode.tsx` — ~3 lines changed.
- `admin/src/components/settings/FormView.tsx` — modest changes per
control type; estimate ~30 lines.
- New i18n keys in `src/locales/en.json` (English source).
- Tests: ~4 spec files, ~200 lines.
- Docs: ~30 lines.
Total: ~400 lines including tests and docs.

View file

@ -1,275 +0,0 @@
# URL base-path support (X-Forwarded-Prefix / X-Ingress-Path) — Design
GitHub issue: https://github.com/ether/etherpad/issues/7802
## Problem
Etherpad assumes it is served at `/`. When a reverse proxy adds a path prefix —
Home Assistant ingress (`/api/hassio_ingress/<random-token>/`), Nginx
`location /etherpad/`, Cloudflare Worker routes — three categories of URLs
break:
1. Hard-coded leading-slash hrefs/srcs in server-rendered HTML (`/static/...`,
`/admin/...`, `/manifest.json`, `/favicon.ico`).
2. Plugin assets injected via the inner ace iframe and via plugin DOM hooks
that emit leading-slash URLs.
3. From-request absolute URLs in social-meta tags (`og:url`, `og:image`,
`twitter:image`) — they pick up the bound listen address (e.g.
`http://0.0.0.0:9001/...`) instead of the public origin.
Failures are partial and confusing: the pad loads, the toolbar renders, the
pad text round-trips through the server, but plugin CSS 404s and admin pages
are unreachable from inside the proxied iframe.
The `publicURL` setting can't fix this: HA ingress prefixes are per-session
random tokens, not stable values. Etherpad has to learn the prefix from each
request.
## Goals
- When `settings.trustProxy === true` and a proxied request carries an
`X-Forwarded-Prefix` or `X-Ingress-Path` header, Etherpad emits every
asset URL, admin link, manifest reference, and socket.io endpoint under
that prefix.
- The HTML page also carries `<base href="${prefix}/">` so plugin-injected
leading-slash URLs route through the prefix without each plugin opting in.
- Behavior with no proxy header (or with `trustProxy === false`) is byte-for-
byte identical to today.
- No new `settings.json` field.
## Non-goals
- Static `settings.basePath` configuration. Rejected because HA ingress is
per-session, and proxies that want a stable prefix can already set the
header. (Confirmed during brainstorming.)
- Deprecating `publicURL`. It remains the canonical-origin setting for
Open Graph / Twitter Card absolute URLs; basePath is orthogonal.
- Express mount-path rewiring. Proxies strip the prefix before forwarding;
Etherpad still routes against `/p/...`.
- "No link to /admin from index" UX symptom mentioned in the issue. Out of
scope; can be filed separately as an admin discoverability ticket.
## Header handling
Honored only when `settings.trustProxy === true`. Otherwise the parser
returns `''`.
Headers checked in this order; first non-empty wins:
1. `X-Forwarded-Prefix` (HAProxy / Traefik convention)
2. `X-Ingress-Path` (Home Assistant convention)
Sanitization, in order:
1. Trim whitespace.
2. If non-empty and not starting with `/`, prepend `/`.
3. Strip trailing slashes.
4. Reject (return `''`) on any of: `..`, `://`, `\`, control characters,
or any character outside `[A-Za-z0-9_\-./~%]`.
5. Reject if length > 1024 chars.
Result is a plain string: `''` (no prefix) or `/some/prefix` (no trailing
slash, starts with `/`).
`''` is the sentinel for "no prefix" everywhere downstream.
## Architecture
Single source of truth — `req.basePath` — set once per request, propagated
to three sinks:
```
X-Forwarded-Prefix / X-Ingress-Path
parseBasePath() in middleware
req.basePath = "/foo"
┌──────────────────────────────┼──────────────────────────────┐
▼ ▼ ▼
EJS / res.locals.basePath clientVars.basePath <base href="${basePath}/">
│ │ │
templates emit pad.ts sets baseURL plugin-injected
<%= basePath %>/... socket.io path follows leading-slash URLs
for every leading socialMeta builds resolve through prefix
slash href/src from-request URLs with (belt-and-braces)
prefix
```
Backwards compatible by construction: no header → `req.basePath = ''`
templates emit `/static/...` (today's exact output) → `<base href="/">` is a
no-op.
## Pre-existing infrastructure (discovered during plan write-up)
The codebase already has substantial proxy-path support that wasn't visible
from the issue text. The plan extends rather than replaces:
- `src/node/utils/sanitizeProxyPath.ts` — already reads `x-proxy-path`,
sanitizes the value, returns `''` or `/...`. Same shape as the spec's
proposed `parseBasePath`. Plan: extend the header list to also check
`X-Ingress-Path` and `X-Forwarded-Prefix`, gated on `trustProxy`.
- `src/templates/padBootstrap.js` and `timeSliderBootstrap.js` — already
compute `basePath` from `window.location` and set `pad.baseURL`,
`window.plugins.baseURL`, `timeSlider.baseURL`. socket.io path
(`socketio.connect(exports.baseURL, ...)`) and the `fetch` call in
`pad.ts:1040` already follow the prefix without any change.
- `src/node/hooks/express/admin.ts` — already substitutes `/admin` and
`/socket.io` in the admin SPA HTML/JS/CSS using `sanitizeProxyPath`.
No admin Vite rebuild needed.
- `src/templates/pad.html`, `timeslider.html` — already use relative
paths (`../static/...`, `../favicon.ico`, `../../manifest.json`), so
they pick up the prefix naturally when served behind one.
- `src/node/hooks/express/specialpages.ts` — already calls
`sanitizeProxyPath(req)` and threads `proxyPath` into the rendered
`entrypoint` URL.
What is NOT covered today, and forms the plan's actual scope:
1. **Header source list** — HA Ingress sends `X-Ingress-Path`; nginx
subpath users typically rely on `X-Forwarded-Prefix`. Etherpad only
reads `x-proxy-path`. Mismatched header → no prefix → all symptoms.
2. **`/manifest.json` icons** (`src/node/hooks/express/pwa.ts`) emit
hard-coded `/favicon.ico` and `/static/skins/...` paths.
3. **`socialMeta` from-request URLs** (`src/node/utils/socialMeta.ts`)
don't honor `proxyPath` when building the from-request fallback,
producing wrong `og:url` / `og:image` under a prefix.
4. **Leading-slash URLs in `index.html`, `timeslider.html`, `pad.html`,
`export_html.html`** — manifest link, jslicense link, reconnect
form action. Each can be made relative or `proxyPath`-prefixed.
5. **Plugin DOM injection** — plugins that emit `<link href="/static/...">`
at runtime aren't covered by any existing rewrite. A `<base href>`
tag was considered as a belt-and-braces fix but rejected: path-absolute
URLs (`/foo`) deliberately ignore the path component of `<base href>` and
resolve against the origin, so `<base href="/sub/">` + `<link href="/static/foo">`
still resolves to `/static/foo`. And `<base href>` would change the
resolution base for existing relative URLs in `pad.html` /
`timeslider.html` (e.g. `../static/css/pad.css`), breaking them. Plugin
authors emitting leading-slash URLs need to use `clientVars`-derived or
relative paths — documented separately as a plugin guidance issue, not
resolved here.
6. **Settings documentation**`settings.json.template` and `Settings.ts`
doc comment for `trustProxy` need to mention the new header sources.
## Components
Reflects the discovery above — extends existing helpers; smaller surface than the original draft.
| File | Change |
|---|---|
| `src/node/utils/sanitizeProxyPath.ts` | Extend header source list to also read `x-forwarded-prefix` and `x-ingress-path`. Standard headers (everything other than the existing `x-proxy-path`) gated on `settings.trustProxy === true`. First non-empty wins, after sanitization. |
| `src/node/hooks/express/pwa.ts` | `/manifest.json` handler reads `sanitizeProxyPath(req)` and emits `${proxyPath}/favicon.ico`, `${proxyPath}/static/skins/...`, `${proxyPath}/` for `start_url`. Mark response `Vary: x-proxy-path, x-ingress-path, x-forwarded-prefix` + `Cache-Control: private, no-store` when proxyPath is non-empty (mirrors the admin handler's pattern). |
| `src/node/utils/socialMeta.ts` | `buildAbsoluteUrl` honors `proxyPath` when falling back to from-request origin: `${origin}${proxyPath}${pathname}`. `publicURL` still wins when set. |
| `src/templates/index.html` | Replace `<link rel="manifest" href="/manifest.json">` and the jslicense `<a href="/javascript">` with `proxyPath`-prefixed values. Route handler in `specialpages.ts` passes `proxyPath` as an explicit template variable. |
| `src/templates/timeslider.html`, `pad.html` | jslicense `<a href>` and `<form action="/ep/pad/reconnect">` use `proxyPath`. Fix pre-existing `..`-count bug in the manifest `<link>` (`../../manifest.json` in pad.html resolves under a prefix as `/manifest.json` instead of `/sub/manifest.json`; ditto `../../../manifest.json` in timeslider). Reduce by one to make the path correct in both root-mount and prefix-mount cases. |
| `src/templates/export_html.html` | `<link rel="manifest">` uses proxyPath via the export route's render context. |
| `src/node/hooks/express/specialpages.ts` + export route | Pass `proxyPath` into every `eejs.require` call that renders the affected templates. |
| `settings.json.template` + `Settings.ts` doc comment | Document the three honored header names and the trustProxy gate. No new field. |
Out: no new `basePath.ts`, no Express middleware, no EJS-context-wide helper, no admin Vite rebuild, no `clientVars.basePath`, no edits to `pad.ts` / `timeslider.ts` / `padBootstrap.js`. Pre-existing code already covers those surfaces (`pad.baseURL` and `window.plugins.baseURL` are derived client-side from `window.location` in `padBootstrap.js` / `timeSliderBootstrap.js`).
## Data flow — concrete example
Home Assistant ingress request:
```
GET /p/scratch HTTP/1.1
Host: 0.0.0.0:9001
X-Forwarded-Proto: https
X-Forwarded-Host: ha.example
X-Forwarded-Prefix: /api/hassio_ingress/abc123
X-Ingress-Path: /api/hassio_ingress/abc123
```
1. `app.enable('trust proxy')``req.protocol === 'https'`, `req.hostname === 'ha.example'`.
2. `sanitizeProxyPath(req)``'/api/hassio_ingress/abc123'` (read from `X-Ingress-Path` because trustProxy is on; would also accept `X-Forwarded-Prefix`).
3. `specialpages.ts` route handler for `/p/:pad`: renders `pad.html` with `proxyPath` in the template context. Output includes:
- jslicense `<a href>` and reconnect form `action` prefixed via the EJS variable.
- `<link rel="manifest" href="../manifest.json">` (fixed from `../../manifest.json` — see Risks): resolves to `/api/hassio_ingress/abc123/manifest.json`.
- `<link href="../static/css/pad.css...">` is already relative — resolves under the prefix to `/api/hassio_ingress/abc123/static/css/pad.css`.
4. Browser fetches `/api/hassio_ingress/abc123/manifest.json``pwa.ts` route emits manifest with all icon `src` values prefixed; `start_url` prefixed.
5. Browser establishes socket.io: client-side `padBootstrap.js` computes `basePath = new URL('..', window.location).pathname``/api/hassio_ingress/abc123/``pad.baseURL` set → `socketio.connect('/api/hassio_ingress/abc123/', ...)` → socket.io path is `/api/hassio_ingress/abc123/socket.io/`. No code change here — pre-existing logic.
6. `socialMeta`: `og:url = https://ha.example/api/hassio_ingress/abc123/p/scratch`, `og:image = https://ha.example/api/hassio_ingress/abc123/favicon.ico`.
7. Inner ace iframe loads `../static/empty.html` (relative) → resolves to `/api/hassio_ingress/abc123/static/empty.html` naturally. No code change in the inner iframe; plugin CSS injected via `aceEditorCSS` is also relative-prefixed (`../static/plugins/...`).
Direct (non-proxied) request — same code path:
1. No `x-proxy-path`, no `X-Forwarded-Prefix`, no `X-Ingress-Path``sanitizeProxyPath(req) === ''`.
2. Templates render today's output. The reduced-`..`-count manifest link still resolves to `/manifest.json` from a root-mount pad URL (`/p/test`), so no observable change for non-proxied users.
3. `pwa.ts` returns today's manifest (icon srcs `'/favicon.ico'` etc.) when `proxyPath === ''`.
## Backwards compatibility
- Existing deployments: no header → behavior unchanged. `<base href="/">` is
benign (HTML5 valid, no-op for absolute URLs).
- `publicURL` semantics unchanged. When set, `socialMeta` still uses it for
the origin in OG tags; basePath only affects request-derived fallbacks.
- Plugins using `clientVars.padId`, etc. continue to work. Plugins that build
asset URLs by hardcoding `/static/...` now route through the prefix via the
`<base href>` belt-and-braces, even if they don't read `clientVars.basePath`.
- No new dependency, no new setting, no migration step.
## Risks
- **Plugin templates and plugin-rendered HTML** outside `src/templates/` may
contain leading-slash URLs. We cannot auto-rewrite them. `<base href>`
was considered as a runtime catch-all and rejected (see "Plugin DOM
injection" above). Plugins emitting absolute URLs should prefer relative
or `clientVars`-derived paths; documenting that recommendation is a
separate follow-up.
- **Manifest `..`-count fix is a strict improvement.** Today's
`../../manifest.json` in `pad.html` resolves to `/manifest.json` from a
root-mount pad URL — a happy accident: the relative path has one `..` too
many but the browser silently caps `..` at the path root. After the fix
to `../manifest.json`, the result is `/manifest.json` from root and
`/<prefix>/manifest.json` under a prefix. No regression possible at root.
Same logic for `timeslider.html`'s `../../../manifest.json`.
- **Malicious header injection** when `trustProxy === false` is irrelevant
(we ignore the headers). When `trustProxy === true` the operator has
already declared the proxy trusted; sanitization (rejects `..`, scheme,
control chars, etc.) prevents XSS via `<base href>` and prevents path
escape. We do NOT trust headers in non-proxy mode.
## Testing
- **Unit**`src/tests/backend/specs/basePath-unit.ts`:
- `parseBasePath` truth table: trustProxy off → `''`; no headers → `''`;
`X-Forwarded-Prefix: /foo``/foo`; `X-Forwarded-Prefix: foo`
`/foo`; `X-Forwarded-Prefix: /foo/``/foo`; `X-Forwarded-Prefix:
/foo//` → `/foo`; `X-Forwarded-Prefix: /a/../b` → `''`; `X-Forwarded-Prefix:
https://evil.example/foo` → `''`; `X-Forwarded-Prefix: ` (whitespace)
`''`; `X-Forwarded-Prefix` empty + `X-Ingress-Path: /bar``/bar`;
`X-Forwarded-Prefix: /a` + `X-Ingress-Path: /b``/a` (first wins);
long-string > 1024 chars → `''`; non-ASCII → `''`.
- **Backend integration**`src/tests/backend/specs/basePath-integration.ts`:
- supertest GET `/` with `X-Forwarded-Prefix: /api/foo` (and trustProxy
on): rendered HTML contains `<base href="/api/foo/">`, contains
`href="/api/foo/static/...`, contains `href="/api/foo/manifest.json"`.
- GET `/p/test` same expectations.
- GET `/admin/`: assert prefix is present in `<link>`/`<script>` paths and `<base href>` present. The exact mechanism (Vite `base: './'` rebuild vs. server-side templating of the admin HTML) is chosen during implementation; the test only asserts the post-render output.
- GET `/` with same headers but trustProxy OFF: no prefix anywhere, output
matches the trustProxy-on-no-headers output.
- GET `/` with `X-Forwarded-Prefix: /a/../b`: no prefix (rejected),
output identical to no-headers.
- **socialMeta** — extend existing `src/tests/backend/specs/socialMeta-unit.ts`:
- With `req.basePath = '/api/foo'` and no `publicURL`: `og:url` and
`og:image` carry the prefix.
- With `publicURL` set: `publicURL` still wins (existing test); basePath
not applied (publicURL is assumed to encode the full canonical origin
including any path component).
- **No new E2E.** Existing Playwright suite covers normal URLs. Adding a
reverse-proxy harness for E2E is high-effort for low marginal coverage;
manual verification through the HA addon during the release candidate
phase is sufficient.
## Open questions
None at design time. Implementation plan will resolve:
- Exact mechanism to thread `basePath` from HTTP request to socket.io
handshake (existing socket handshake already attaches the original
request; need to confirm the access pattern in `PadMessageHandler`).
- Whether `admin/vite.config.ts` rebuild produces a stable hash filename
or whether we need to also adjust the admin route handler to scan the
built `dist/` directory at startup.

View file

@ -1,278 +0,0 @@
# Outdated-version notice redesign
**Issue:** [ether/etherpad#7799](https://github.com/ether/etherpad/issues/7799)
**Date:** 2026-05-18
**Status:** Design
## Problem
The pad-side "Etherpad on this server is severely outdated. Tell your admin." banner is shown to every visitor of every pad, persistently, whenever the running server is at least one major version behind the latest published release. The reporter (a server admin) says:
> "it's inappropriate to inform users of a site about maintenance tasks that they don't understand or have context to resolve. It wastes users' time by having them try and contact me, and it wastes my time by having to respond."
In addition to the social problem, the current implementation triggers on develop checkouts and on minor-only deltas in some upstream-version states, and has been observed intercepting chat-icon clicks (z-index 9999, bottom-right) in plugin test matrices pinned to older cores.
## Goals
1. The notice is shown **only to the pad's first author** (the author whose ID occupies position 0 in the pad's attribute pool — i.e. whoever made the first edit).
2. The notice is **non-persistent**: a dismissable `$.gritter` toast, auto-fading after 8s, rather than an always-visible badge.
3. The notice fires **only on minor-or-more behind** (e.g. 3.1.0 → 3.2.0, 2.7.3 → 3.0.0). Patch-only deltas (3.0.1 → 3.0.2) never fire.
4. The notice never fires when `current >= latest` (covers the develop-after-bump case).
5. The `vulnerable-below` UI is **dropped entirely**, along with the directive parser and state field. The vulnerable enum is gone from the API.
## Non-goals
- No new settings flag. `updates.tier = 'off'` remains the kill-switch.
- No translations of new strings in this PR. A `TODO(i18n)` placeholder is carried forward — strings are hard-coded English, mirroring the current state of the badge code. A follow-up adds `pad.outdatedNotice.*` keys once the html10n key set is set up to be shared with the pad-side bundle.
## Architecture
```
Browser (pad load, after CLIENT_VARS) Server
───────────────────────────────────── ──────
pad.ts → maybeShowOutdatedNotice()
├─ GET /api/version-status?padId=<id> (cookies: express_sid)
│ loadState(stateFilePath())
│ │
│ ├─ no latest → {outdated:null,isFirstAuthor:false}
│ ├─ current >= latest → {outdated:null,isFirstAuthor:false}
│ ├─ same major + minor differs → next step
│ ├─ major differs → next step
│ └─ patch-only behind → {outdated:null,isFirstAuthor:false}
│ next step:
│ resolve req-author via express_sid
│ load pad → firstAuthor = pool position 0
│ if req-author === firstAuthor
│ return {outdated:'minor',isFirstAuthor:true}
│ else
│ return {outdated:null,isFirstAuthor:false}
├─ outdated:'minor' && isFirstAuthor
│ → $.gritter.add({class_name:'outdated-notice', position:'bottom',
│ sticky:false, time:8000, title, text})
└─ else
→ no-op
```
The endpoint shape collapses to a single enum (`'minor' | null`) plus a per-request `isFirstAuthor` boolean. The server never returns a positive `outdated` value to a non-first-author requester — there is no client-side "the answer is minor, but show it conditionally" path. Operational signal does not leak to ordinary pad visitors.
## Server changes
### `src/node/updater/versionCompare.ts`
- **Add** `isMinorOrMoreBehind(current: string, latest: string): boolean``true` iff `parseSemver(current).major < parseSemver(latest).major`, or majors equal and `current.minor < latest.minor`. Patch-only delta returns `false`. Returns `false` on parse failure of either side.
- **Delete** `isMajorBehind`, `isVulnerable`, `parseVulnerableBelow`, the `VULN_RE` regex, and the `VulnerableBelowDirective` import.
### `src/node/updater/types.ts`
- **Delete** `VulnerableBelowDirective`.
- **Delete** `UpdaterState.vulnerableBelow` field.
### `src/node/updater/state.ts`
- Stop reading and stop writing `vulnerableBelow`. Existing state files with the field still parse — the loader ignores unknown keys. No migration needed; the field naturally drops on next write.
### `src/node/updater/VersionChecker.ts`
- Remove the release-notes scraping that called `parseVulnerableBelow`. The rest of the check (current vs latest tag) is unchanged.
### `src/node/hooks/express/updateStatus.ts` (load-bearing change)
```ts
interface OutdatedResponse {
outdated: 'minor' | null;
isFirstAuthor: boolean;
}
const EMPTY: OutdatedResponse = {outdated: null, isFirstAuthor: false};
const cache = new LRU<string, {value: OutdatedResponse; at: number}>(1000);
const inFlight = new Map<string, Promise<OutdatedResponse>>();
const TTL_MS = 60 * 1000;
const firstAuthorOf = (pad: Pad): string | null => {
const num2attrib = pad.pool.numToAttrib;
const keys = Object.keys(num2attrib).map(Number).sort((a, b) => a - b);
for (const k of keys) {
const a = num2attrib[k];
if (a && a[0] === 'author' && typeof a[1] === 'string' && a[1] !== '') return a[1];
}
return null;
};
const computeOutdated = async (padId: string | null, authorId: string | null): Promise<OutdatedResponse> => {
const state = await loadState(stateFilePath());
if (!state.latest) return EMPTY;
const current = getEpVersion();
if (!isMinorOrMoreBehind(current, state.latest.version)) return EMPTY;
if (!padId || !authorId) return EMPTY;
if (!(await padManager.doesPadExist(padId))) return EMPTY;
const pad = await padManager.getPad(padId, null);
if (firstAuthorOf(pad) !== authorId) return EMPTY;
return {outdated: 'minor', isFirstAuthor: true};
};
app.get('/api/version-status', wrapAsync(async (req, res) => {
const padId = typeof req.query.padId === 'string' ? req.query.padId : null;
const authorId = await resolveRequestAuthor(req); // express_sid → session → author, null on miss
const key = `${padId ?? ''}|${authorId ?? ''}`;
const now = Date.now();
const hit = cache.get(key);
if (hit && now - hit.at <= TTL_MS) {
res.json(hit.value);
return;
}
let flight = inFlight.get(key);
if (!flight) {
flight = computeOutdated(padId, authorId).finally(() => inFlight.delete(key));
inFlight.set(key, flight);
}
const value = await flight;
cache.set(key, {value, at: now});
res.json(value);
}));
```
- `resolveRequestAuthor(req)` is a small helper that reads `req.cookies.express_sid`, calls `sessionStore.get(sid)` (the same store used by the express-session middleware), and returns `session?.user?.author ?? null`. On any failure path it returns `null` — the request is then treated as anonymous and gets `EMPTY`.
- `padId` is validated through `padutils.validateRequest({padID: padId})` before being passed to `padManager`. Validation failures map to `EMPTY`, not 400 — keeping the endpoint quiet about whether the pad exists.
- LRU cap of 1000 entries bounds memory on busy servers; entries expire by TTL anyway.
- Single-flight per cache key collapses bursts at expiry into one disk read.
- `_resetBadgeCacheForTests()` clears both `cache` and `inFlight`.
### `src/node/hooks/express/openapi-admin.ts`
- Update the OpenAPI doc for `/api/version-status`:
- Add `padId` query parameter (string, optional, must match Etherpad's pad-id format).
- Update response schema: `{outdated: 'minor' | null, isFirstAuthor: boolean}`.
- Drop the `severe` and `vulnerable` enum values.
## Client changes
### `src/templates/pad.html`
- Delete line 648 (`<div id="version-badge" role="status" aria-live="polite" style="display:none"></div>`).
### `src/static/css/pad.css`
- Delete the `#version-badge { … }` rule block (lines ~119131). Gritter's stock styling carries the notice; no new CSS is added — matches `.privacy-notice` precedent.
### `src/static/js/pad_version_badge.ts` → renamed to `pad_outdated_notice.ts`
```ts
'use strict';
interface OutdatedResponse {
outdated: 'minor' | null;
isFirstAuthor: boolean;
}
const apiBasePath = (): string => {
if (typeof window === 'undefined') return '/';
return new URL('..', window.location.href).pathname;
};
const currentPadId = (): string | null => {
const id = (window as any).clientVars?.padId;
return typeof id === 'string' && id.length > 0 ? id : null;
};
export const maybeShowOutdatedNotice = async (): Promise<void> => {
const padId = currentPadId();
if (!padId) return;
const $ = (window as any).$;
if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return;
try {
const url = `${apiBasePath()}api/version-status?padId=${encodeURIComponent(padId)}`;
const res = await fetch(url, {credentials: 'same-origin'});
if (!res.ok) return;
const data = (await res.json()) as OutdatedResponse;
if (data.outdated !== 'minor' || !data.isFirstAuthor) return;
// TODO(i18n): switch to html10n once `pad.outdatedNotice.*` keys land.
$.gritter.add({
title: 'Etherpad update available',
text: 'A newer version of Etherpad has been released. Consider updating this server.',
sticky: false,
position: 'bottom',
class_name: 'outdated-notice',
time: 8000,
});
} catch {
/* never block pad load */
}
};
```
- Module no longer self-bootstraps on `DOMContentLoaded`; it needs `clientVars.padId`, which is only present after `CLIENT_VARS` arrives.
- Invocation site: `src/static/js/pad.ts`, in the same post-`handleClientVars` block where `showPrivacyBannerIfEnabled` is called.
- No `localStorage` write — dismissal is per-session (gritter X-click clears DOM; reload re-fetches and re-shows if still outdated).
## Tests
### Backend — `src/tests/backend/specs/api/updateStatus.spec.ts` (rewrite affected blocks)
- Drop `describe('vulnerable …')` cases entirely.
- Replace `describe('severe / isMajorBehind …')` with `describe('isMinorOrMoreBehind …')` covering:
- patch-only delta returns `false` (2.7.3 vs 2.7.4)
- minor delta returns `true` (2.7.3 vs 2.8.0)
- major delta returns `true` (2.7.3 vs 3.0.0)
- equal versions return `false`
- current newer than latest returns `false` (develop-on-bumped-package.json case)
- unparseable input on either side returns `false`
- New `describe('GET /api/version-status')` cases:
- no `state.latest``{outdated:null,isFirstAuthor:false}`
- current ≥ latest, with valid padId+author → `EMPTY`
- padId omitted → `EMPTY` (no leak)
- authorId resolves but isn't pool position 0 → `EMPTY`
- current is minor-behind AND requester is pool position 0 → `{outdated:'minor',isFirstAuthor:true}`
- current is patch-behind, requester IS pool position 0 → `EMPTY`
- cache hit within 60s for same `padId|authorId` does NOT re-call `loadState` (spy assertion)
- two different `padId|authorId` pairs are cached independently
- with the LRU cap forced low (test-only setter), the oldest entry is evicted first
Each case calls `_resetBadgeCacheForTests()` in `beforeEach`.
### Backend — `firstAuthorOf` unit test (new file next to the helper)
- empty pad → `null`
- single-author pad → that author
- A edited first then B → A
- pool with non-author attribs interleaved at low numeric keys → still returns the lowest `['author', X]`
- pool with `['author', '']` placeholder → skipped; returns the next real author
### Frontend — `src/tests/frontend-new/specs/outdated_notice.spec.ts` (new, mirrors `privacy_banner.spec.ts`)
- stub `/api/version-status` to `{outdated:null,…}` → no `.gritter-item.outdated-notice` after pad load
- stub to `{outdated:'minor', isFirstAuthor:false}` → no gritter (client belt-and-braces guard)
- stub to `{outdated:'minor', isFirstAuthor:true}``.gritter-item.outdated-notice` appears, body text matches, dismisses on X-click
- stub returning 500 → no DOM injection, no user-visible console error
- after ~9s with positive stub → gritter auto-faded (asserts `sticky:false` + `time:8000` wiring)
### Files removed entirely
- Any standalone `versionBadge.spec.ts` fixture file (merged into `updateStatus.spec.ts`).
- Any fixture referencing `vulnerableBelow`.
### Verification gates (mandatory before claiming done)
- `pnpm --filter ep_etherpad-lite test:vitest` clean (backend).
- `pnpm exec playwright test outdated_notice` clean under `xvfb-run` (frontend).
- Manual: load a pad on the dev server (`http://localhost.lan:9003/p/test`) with `var/update.state.json` pinned to a higher `latest.version` — gritter appears once for first-author in incognito-A, absent in incognito-B (second visitor).
## Docs / settings / build
- `doc/api/http_api.md` (and `.adoc` if present) — update `/api/version-status` entry: new shape, new `padId` query param, note that positive results are scoped to first-author.
- `doc/api/updater.md` (or the relevant `updates.tier` section in `doc/settings.md`) — drop the paragraph(s) on the vulnerable-below directive and the persistent banner UI.
- `CHANGELOG.md` (Unreleased) — one entry: "Outdated-version notice redesigned per #7799 — transient gritter, first-author only, minor-or-major behind only. The persistent banner, `severe` enum, and `vulnerable-below` directive scraping are removed."
- No settings-schema changes. `updates.tier = 'off'` remains the full kill-switch.
- `vite.config.ts` (and any other bundle config) — rename `pad_version_badge` entries to `pad_outdated_notice`. Grep to confirm no admin-bundle reference exists (shouldn't; pad-only).
## Risk / open questions
- **Develop-on-stale-package.json.** Today develop's `package.json` reads `2.7.3` while the latest npm release is newer. Under this design, the notice still triggers on develop because `current < latest`. The expected operational practice is for the post-release bump of develop's `package.json` to a higher pre-release identifier to short-circuit this naturally. Documented in the CHANGELOG entry. If maintainers want belt-and-braces, a follow-up can add a `.git`-presence short-circuit, but that is explicitly out-of-scope here per the design decision.
- **First-author churn on imported pads.** If a pad was created via `setText`/API by an admin script using a service-account author, the first-author signal points at that service account. Operationally fine — the notice just won't fire for anyone. Acceptable.
- **Anonymous browsers without express_sid.** First load of a pad with no prior session has no `express_sid` cookie until `socket.io` connects. The version-status request fires after `CLIENT_VARS`, which is after the socket handshake, so by then the cookie exists. If for any reason it doesn't, `resolveRequestAuthor` returns `null` and the response is `EMPTY` — fail-quiet.

View file

@ -1,154 +0,0 @@
# 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.10.0",
"packageManager": "pnpm@11.1.2",
"repository": {
"type": "git",
"url": "https://github.com/ether/etherpad.git"
},
"engineStrict": true,
"version": "3.3.2",
"version": "3.1.0",
"license": "Apache-2.0",
"pnpm": {
"onlyBuiltDependencies": [

4704
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -21,38 +21,20 @@ 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:
'@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'
basic-ftp: '>=5.3.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'
ip-address@<10.1.1: '>=10.1.1'
js-yaml@>=4.0.0 <4.2.0: '>=4.2.0'
js-yaml@>=4.0.0 <4.1.1: '>=4.1.1'
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.15.2: '>=6.15.2'
qs@>=6.7.0 <6.14.2: '>=6.14.2'
serialize-javascript@<7.0.5: '>=7.0.5'
socket.io-parser@>=4.0.0 <4.2.6: '>=4.2.6'
tar@<7.5.16: '>=7.5.16'
tar@<7.5.11: '>=7.5.11'
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,17 +210,15 @@
* 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": "${UPDATES_TIER:notify}",
"source": "${UPDATES_SOURCE:github}",
"channel": "${UPDATES_CHANNEL:stable}",
"tier": "notify",
"source": "github",
"channel": "stable",
"installMethod": "docker",
"checkIntervalHours": "${UPDATES_CHECK_INTERVAL_HOURS:6}",
"githubRepo": "${UPDATES_GITHUB_REPO:ether/etherpad}",
"requireAdminForStatus": "${UPDATES_REQUIRE_ADMIN_FOR_STATUS:false}",
"checkIntervalHours": 6,
"githubRepo": "ether/etherpad",
"requireAdminForStatus": false,
"preApplyGraceMinutes": 0,
"drainSeconds": 60,
"rollbackHealthCheckSeconds": 60,
@ -323,19 +321,7 @@
* 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}"
},
"updateServer": "https://etherpad.org/ep_infos",
/*
* 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": "${UPDATES_TIER:notify}",
"tier": "notify",
"source": "github",
"channel": "stable",
"installMethod": "auto",
@ -443,19 +443,17 @@
* https://etherpad.org/ep_infos
*/
"updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}",
"updateServer": "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": "${PRIVACY_UPDATE_CHECK:true}",
"pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}"
"updateCheck": true,
"pluginCatalog": true
},
/*
@ -537,15 +535,6 @@
*
* The other effect will be that the logs will contain the real client's IP,
* instead of the reverse proxy's IP.
*
* Setting this to `true` also makes Etherpad honor two standard URL-path-
* prefix headers from upstream proxies:
* - `X-Forwarded-Prefix` (HAProxy / Traefik convention)
* - `X-Ingress-Path` (Home Assistant supervisor ingress)
*
* Both are sanitised before use. Etherpad's own `x-proxy-path` header is
* honored regardless of this setting; the operator is presumed to have
* configured their proxy intentionally when sending the custom header.
*/
"trustProxy": false,
@ -636,12 +625,6 @@
/*
* Whether to periodically clean up expired and stale sessions from the
* database. Set to false to disable. Default: true.
*
* Cleanup runs hourly and walks the sessionstorage:* keyspace in 500-key
* pages so very large session tables don't pin the keys in memory all at
* once (see https://github.com/ether/etherpad/issues/7830). A single run
* is capped at 10 minutes; if your DB is so large the budget is reached,
* a warning is logged and the next scheduled run continues.
*/
"sessionCleanup": true
},
@ -855,11 +838,11 @@
* accepting pad-wide values under plugin-namespaced keys matching
* /^ep_[a-z0-9_]+$/ (e.g. ep_table_of_contents). Values are validated
* (JSON-safe, 64 KB per key, 256 KB total) and broadcast to every
* connected client just like native pad-wide toggles. Enabled by
* default; set to false to lock plugins out of pad-wide state. See
* doc/plugins.md.
* connected client just like native pad-wide toggles. Disabled by
* default; flip to true once your plugins (e.g. ep_plugin_helpers'
* padToggle) require it. See doc/plugins.md.
**/
"enablePluginPadOptions": "${ENABLE_PLUGIN_PAD_OPTIONS:true}",
"enablePluginPadOptions": "${ENABLE_PLUGIN_PAD_OPTIONS:false}",
/*
* Optional privacy banner shown once the pad loads. Disabled by default.

View file

@ -7,7 +7,6 @@
"Haytham morsy",
"Meno25",
"Mido",
"Mohammed Qays",
"Shbib Al-Subaie",
"Tala Ali",
"Test Create account",
@ -58,7 +57,7 @@
"index.transferSessionNow": "نقل الجلسة الآن",
"index.copyLink": "2. نسخ الرابط",
"index.copyLinkDescription": "انقر على الزر أدناه لنسخ الرابط إلى الحافظة الخاصة بك.",
"index.copyLinkButton": ُسخت الوصلة إلى الحافظة",
"index.copyLinkButton": سخ الرابط إلى الحافظة",
"index.transferToSystem": "3. نسخ الجلسة إلى النظام الجديد",
"index.transferToSystemDescription": "افتح الرابط المنسوخ في المتصفح أو الجهاز المستهدف لنقل جلستك.",
"index.transferSessionDescription": "انقل جلستك الحالية إلى المتصفح أو الجهاز بالنقر على الزر أدناه. سيؤدي هذا إلى نسخ رابط لصفحة ستنقل جلستك عند فتحها في المتصفح أو الجهاز المستهدف.",
@ -124,7 +123,7 @@
"pad.importExport.exportword": "مايكروسوفت وورد",
"pad.importExport.exportpdf": "صيغة المستندات المحمولة",
"pad.importExport.exportopen": "ODF (نسق المستند المفتوح)",
"pad.importExport.noConverter.innerHTML": "يمكنك استيراد النصوص العادية، وملفات HTML، وملفات Microsoft Word (.docx)، وملفات Etherpad مباشرةً. لاستيراد تنسيقات أخرى مثل PDF، وODT، وDOC، وRTF، يحتاج مسؤول الخادم إلى تثبيت LibreOffice - راجع <a href=\"https://docs.etherpad.org/\">وثائق Etherpad</a> .",
"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": "إعادة الاتصال ببادك..",
"pad.modals.forcereconnect": "فرض إعادة الاتصال",

View file

@ -9,7 +9,6 @@
]
},
"admin.page-title": "Адміністрацыйная панэль — Etherpad",
"admin.loading": "Ладаваньне...",
"admin_plugins": "Кіраўнік плагінаў",
"admin_plugins.available": "Даступныя плагіны",
"admin_plugins.available_not-found": "Плагіны ня знойдзеныя.",
@ -116,7 +115,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 +177,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,80 +16,13 @@
]
},
"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.",
@ -97,61 +30,23 @@
"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,7 +10,6 @@
"Metalhead64",
"Mklehr",
"Mukeber",
"Nbux",
"Nipsky",
"Predatorix",
"SamTV",
@ -23,80 +22,13 @@
]
},
"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.",
@ -104,67 +36,23 @@
"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",
@ -187,7 +75,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 Deinem Browser läuft.",
"index.createAndShareDocumentsDescription": "Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Ihrem Browser läuft.",
"pad.toolbar.bold.title": "Fett (Strg-B)",
"pad.toolbar.italic.title": "Kursiv (Strg-I)",
"pad.toolbar.underline.title": "Unterstrichen (Strg-U)",
@ -211,31 +99,18 @@
"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.title": "Einstellungen",
"pad.settings.padSettings": "Pad-weite Einstellungen",
"pad.settings.userSettings": "Nutzereinstellungen",
"pad.settings.padSettings": "Pad-Einstellungen",
"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",
@ -248,13 +123,7 @@
"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",
@ -282,11 +151,9 @@
"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": "Deine Verbindung wurde getrennt.",
"pad.modals.disconnected": "Ihre 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",
@ -299,29 +166,12 @@
"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",
@ -343,7 +193,6 @@
"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 …",
@ -354,6 +203,5 @@
"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.social.description": "Ein kollaboratives Dokument, das jeder in Echtzeit bearbeiten kann."
"pad.impexp.maxFileSize": "Die Datei ist zu groß. Kontaktiere bitte deinen Administrator, um das Limit für den Dateiimport zu erhöhen."
}

View file

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

View file

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

View file

@ -23,27 +23,6 @@
]
},
"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,17 +7,14 @@
"Chpol",
"Cquoi",
"Crochet.david",
"Crowwhailord",
"Derugon",
"Envlh",
"Framafan",
"Framasky",
"Fylip22",
"Gomoko",
"Goofy",
"Goofy-bz",
"Jean-Frédéric",
"Jérémy-Günther-Heinz Jähnick",
"Leviathan",
"Macofe",
"Mahabarata",
@ -39,73 +36,13 @@
]
},
"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.",
@ -113,25 +50,8 @@
"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",
@ -147,7 +67,6 @@
"admin_settings.current_example-prod": "Exemple de modèle de paramètres de production",
"admin_settings.current_restart.value": "Redémarrer Etherpad",
"admin_settings.current_save.value": "Enregistrer les paramètres",
"admin_settings.mode.aria_label": "Mode éditeur",
"admin_settings.page-title": "Paramètres — Etherpad",
"update.page.apply": "Appliquer la mise à jour",
"update.page.cancel": "Annuler",
@ -235,16 +154,6 @@
"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",
@ -257,10 +166,7 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"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.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.modals.connected": "Connecté.",
"pad.modals.reconnecting": "Reconnexion à votre bloc-notes en cours...",
"pad.modals.forcereconnect": "Forcer la reconnexion",

View file

@ -6,7 +6,6 @@
},
"admin.page-title": "Painéal Riaracháin - Etherpad",
"admin.loading": "Ag lódáil…",
"admin.loading_description": "Fan go fóill fad atá an leathanach á lódáil.",
"admin.toggle_sidebar": "Scoránaigh an taobhbharra",
"admin.shout": "Cumarsáid",
"admin_shout.online_one": "Tá {{count}} úsáideoir ar líne faoi láthair",
@ -26,11 +25,7 @@
"admin_pads.col.revisions": "Athbhreithnithe",
"admin_pads.col.users": "Úsáideoirí",
"admin_pads.confirm_button": "Ceart go leor",
"admin_pads.create_pad_dialog_description": "Roghnaigh ainm don phainéal nua.",
"admin_pads.delete_pad_dialog_description": "Deimhnigh nó cealaigh scriosadh an eochaircheap.",
"admin_pads.delete_pad_dialog_title": "Scrios an eochaircheap",
"admin_pads.empty_never_edited": "folamh · níor cuireadh in eagar riamh",
"admin_pads.error_dialog_description": "Tharla earráid.",
"admin_pads.error_prefix": "Earráid",
"admin_pads.filter.active": "Gníomhach",
"admin_pads.filter.all": "Gach",
@ -71,14 +66,11 @@
"admin_plugins.available_search.placeholder": "Cuardaigh breiseáin le suiteáil",
"admin_plugins.check_updates": "Seiceáil le haghaidh nuashonruithe",
"admin_plugins.core_count": "{{count}} croíleacán",
"admin_plugins.catalog_disabled": "Tá catalóg breiseán díchumasaithe ag d'oibreoir (privacy.pluginCatalog=false). Chun breiseán a shuiteáil, rith `pnpm run plugins i ep_<ainm>` ón bhfreastalaí.",
"admin_plugins.crumbs": "Breiseáin",
"admin_plugins.description": "Cur síos",
"admin_plugins.disables.label": "Díchumasaíonn:",
"admin_plugins.disables.warning_title": "Baintear na gnéithe Etherpad atá liostaithe d'aon ghnó leis an mbreiseán seo.",
"admin_plugins.error_retrieving": "Earráid ag aisghabháil breiseán",
"admin_plugins.install_error": "Theip ar {{plugin}} a shuiteáil: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad": "Ní féidir {{plugin}} a shuiteáil: teastaíonn leagan níos nuaí de Etherpad. Uasghrádaigh Etherpad agus déan iarracht arís.",
"admin_plugins.installed": "Breiseáin suiteáilte",
"admin_plugins.installed_fetching": "Ag fáil breiseáin suiteáilte…",
"admin_plugins.installed_nothing": "Níl aon bhreiseáin suiteáilte agat fós.",
@ -130,33 +122,6 @@
"admin_settings.current_restart.value": "Atosaigh Etherpad",
"admin_settings.current_save.value": "Sábháil Socruithe",
"admin_settings.invalid_json": "JSON neamhbhailí",
"admin_settings.current_test.value": "Bailíochtú JSON",
"admin_settings.current_prettify.value": "Áilleachtaigh JSON",
"admin_settings.toast.saved": "Socruithe sábháilte go rathúil.",
"admin_settings.toast.save_failed": "Theip ar shábháil: níorbh fhéidir settings.json a scríobh.",
"admin_settings.toast.json_invalid": "Earráid chomhréire: seiceáil camóga, lúibíní, agus comharthaí athfhriotail.",
"admin_settings.toast.disconnected": "Ní féidir sábháil: níl sé ceangailte leis an bhfreastalaí.",
"admin_settings.toast.validation_ok": "Tá JSON bailí.",
"admin_settings.toast.validation_failed": "Tá JSON neamhbhailí: ceartaigh earráidí comhréire le do thoil.",
"admin_settings.toast.prettify_failed": "Ní féidir áilleacht a dhéanamh: ceartaigh earráidí comhréire ar dtús le do thoil.",
"admin_settings.prettify_confirm": "Bainfear na tráchtanna go léir má dhéantar áilleachtú. Leanfaidh tú ar aghaidh?",
"admin_settings.mode.form": "Foirm",
"admin_settings.mode.raw": "Amh",
"admin_settings.mode.effective": "Éifeachtach",
"admin_settings.mode.effective_tooltip": "Radharc inléite amháin ar na luachanna atá á n-úsáid ag Etherpad faoi láthair, tar éis athsholáthar athróg timpeallachta. Tá rúin curtha in eagar.",
"admin_settings.mode.aria_label": "Mód eagarthóireachta",
"admin_settings.envvar_banner.title": "Is teimpléad an comhad seo, ní an chumraíocht bheo.",
"admin_settings.envvar_banner.body": "Cuirtear áitchoimeádaithe cosúil le ${VAR:default} isteach sa chuimhne ag an am tosaithe; ní scríobhtar ar ais chuig an gcomhad seo iad choíche. Cuir na cineálacha timpeallachta in eagar i do thimpeallacht (Docker compose, systemd, .env) chun an luach réitithe a athrú, nó cuir litriúil in ionad an áitchoimeádaitheora anseo. Téigh go dtí an cluaisín Éifeachtach chun a fheiceáil cad atá á úsáid ag Etherpad faoi láthair.",
"admin_settings.toast.auth_error": "Níl tú fíordheimhnithe mar riarthóir. Logáil isteach arís le do thoil.",
"admin_settings.section.general": "Ginearálta",
"admin_settings.parse_error.title": "Ní féidir settings.json a pharsáil",
"admin_settings.parse_error.cta": "Athraigh go amh le heagarthóireacht",
"admin_settings.env_pill.tooltip": "Léann sé ón athróg timpeallachta {{variable}}. Úsáidtear an luach thíos nuair nach bhfuil {{variable}} socraithe.",
"admin_settings.env_pill.default_label": "réamhshocraithe",
"admin_settings.env_pill.input_aria": "Luach réamhshocraithe do {{variable}}",
"admin_settings.env_pill.runtime_label": "luach gníomhach",
"admin_settings.env_pill.runtime_tooltip": "Tá an luach seo á úsáid ag Etherpad faoi láthair, réitithe ó {{variable}} nó a réamhshocrú.",
"admin_settings.env_pill.redacted_tooltip": "Tá luach á úsáid ag Etherpad do {{variable}}, ach tá sé i bhfolach mar is rún é.",
"admin_settings.page-title": "Socruithe - Etherpad",
"admin_settings.save_error": "Earráid ag sábháil socruithe",
"admin_settings.saved_success": "Socruithe sábháilte go rathúil",
@ -185,15 +150,12 @@
"update.page.policy.rollback-failed-terminal": "Theip ar nuashonrú roimhe seo agus níorbh fhéidir é a chur ar ais. Brúigh Admhaigh tar éis don tsuiteáil a bheith sláintiúil chun an glas a ghlanadh.",
"update.page.policy.up-to-date": "Tá an leagan is déanaí á rith agat.",
"update.page.policy.tier-off": "Tá nuashonruithe díchumasaithe (updates.tier = \"off\").",
"update.page.policy.maintenance-window-missing": "Éilíonn Sraith 4 (uathrialach) fuinneog chothabhála. Socraigh updates.maintenanceWindow i settings.json chun nuashonruithe uathrialacha a chumasú.",
"update.page.policy.maintenance-window-invalid": "Tá Sraith 4 (uathrialach) díchumasaithe mar gheall ar mhífhoirmiú updates.maintenanceWindow. Bhíothas ag súil le {start, end, tz} le hamanna HH:MM agus tz de \"local\" nó \"utc\".",
"update.page.last_result.verified": "Nuashonrú deireanach ar {{tag}} fíoraithe.",
"update.page.last_result.rolled-back": "Cuireadh an iarracht dheireanach ar {{tag}} a nuashonrú ar ceal: {{reason}}.",
"update.page.last_result.rollback-failed": "Theip ar an iarracht nuashonraithe dheireanach AGUS theip ar an rolladh ar ais: {{reason}}. Idirghabháil láimhe ag teastáil.",
"update.page.last_result.preflight-failed": "Theip ar an iarracht dheireanach ar {{tag}} a nuashonrú roimh ré: {{reason}}.",
"update.page.last_result.cancelled": "Cealaíodh an iarracht dheireanach ar {{tag}} a nuashonrú ag an riarthóir.",
"update.execution.idle": "Díomhaoin",
"update.execution.scheduled": "Nuashonrú sceidealaithe",
"update.execution.preflight": "Seiceálacha réamh-eitilte",
"update.execution.preflight-failed": "Theip ar an réamh-eitilt",
"update.execution.draining": "Seisiúin draenála",
@ -204,17 +166,6 @@
"update.execution.rolled-back": "Rolladh siar",
"update.execution.rollback-failed": "Theip ar an rolladh siar",
"update.banner.terminal.rollback-failed": "Theip ar iarracht nuashonraithe agus níorbh fhéidir é a chur ar ais. Idirghabháil láimhe ag teastáil.",
"update.banner.scheduled": "Nuashonrú uathoibríoch chuig {{tag}} sceidealaithe — baineann sé le {{remaining}}.",
"update.banner.maintenance-window-missing": "Bíonn nuashonruithe uathrialacha díchumasaithe go dtí go gcumraítear fuinneog chothabhála.",
"update.banner.maintenance-window-invalid": "Tá nuashonruithe uathrialacha díchumasaithe mar go bhfuil an fhuinneog chothabhála mífhoirmithe.",
"update.page.scheduled.title": "Nuashonrú sceidealaithe",
"update.page.scheduled.countdown": "Tosóidh Etherpad ag nuashonrú go {{tag}} i {{remaining}}.",
"update.page.scheduled.deferred_until": "Lasmuigh den fhuinneog chothabhála. Tosóidh an nuashonrú nuair a osclaítear an fhuinneog ag {{at}}.",
"update.page.scheduled.apply_now": "Cuir isteach anois",
"update.window.title": "Fuinneog chothabhála",
"update.window.summary": "{{start}}{{end}} ({{tz}})",
"update.window.unset": "Gan cumrú.",
"update.window.next_opens_at": "Osclaítear an chéad fhuinneog eile ag {{at}}.",
"update.drain.t60": "Atosóidh Etherpad i gceann 60 soicind chun nuashonrú a chur i bhfeidhm.",
"update.drain.t30": "Atosóidh Etherpad i gceann 30 soicind chun nuashonrú a chur i bhfeidhm.",
"update.drain.t10": "Atosóidh Etherpad i gceann 10 soicind chun nuashonrú a chur i bhfeidhm.",
@ -230,7 +181,6 @@
"index.copyLinkButton": "Cóipeáil nasc chuig an ghearrthaisce",
"index.transferToSystem": "3. Cóipeáil an seisiún chuig an gcóras nua",
"index.transferToSystemDescription": "Oscail an nasc cóipeáilte sa bhrabhsálaí nó ar an ngléas sprice chun do sheisiún a aistriú.",
"index.code": "Cód",
"index.transferSessionDescription": "Aistrigh do sheisiún reatha chuig brabhsálaí nó gléas trí chliceáil ar an gcnaipe thíos. Cóipeálfaidh sé seo nasc chuig leathanach a aistreoidh do sheisiún nuair a osclófar é sa bhrabhsálaí nó sa ghléas sprice.",
"index.createOpenPad": "Oscail ceap de réir ainm",
"index.openPad": "oscail Pad atá ann cheana féin leis an ainm:",
@ -368,11 +318,6 @@
"pad.historyMode.settings.playbackSpeed": "Luas athsheinm:",
"pad.historyMode.chat.replayHeader": "Comhrá ó {{time}}",
"pad.historyMode.users.authorsHeader": "Údair ag an athbhreithniú seo",
"pad.editor.skipToContent": "Léim go dtí an t-eagarthóir",
"pad.editor.keyboardHint": "Brúigh Escape chun an eagarthóir a fhágáil. Brúigh Alt+F9 chun an barra uirlisí a rochtain.",
"pad.editor.toolbar.formatting": "Barra uirlisí formáidithe",
"pad.editor.toolbar.actions": "Barra uirlisí gníomhartha ceap",
"pad.editor.toolbar.showMore": "Taispeáin níos mó cnaipí barra uirlisí",
"timeslider.toolbar.authors": "Údair:",
"timeslider.toolbar.authorsList": "Gan Údair",
"timeslider.toolbar.exportlink.title": "Easpórtáil",
@ -406,7 +351,6 @@
"pad.savedrevs.timeslider": "Is féidir leat athbhreithnithe sábháilte a fheiceáil trí chuairt a thabhairt ar an sleamhnán ama",
"pad.userlist.entername": "Cuir isteach d'ainm",
"pad.userlist.unnamed": "gan ainm",
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} úsáideoir ceangailte, other: {{count}} úsáideoirí ceangailte ]}",
"pad.editbar.clearcolors": "Glan dathanna údair ar an doiciméad ar fad? Ní féidir é seo a chealú",
"pad.impexp.importbutton": "Iompórtáil Anois",
"pad.impexp.importing": "Ag allmhairiú...",

View file

@ -8,7 +8,6 @@
},
"admin.page-title": "Panel de administración - Etherpad",
"admin.loading": "Cargando…",
"admin.loading_description": "Agarda mentres se carga a páxina.",
"admin.toggle_sidebar": "Activar ou desactivar a barra lateral",
"admin.shout": "Comunicación",
"admin_shout.online_one": "Nestes intres hai {{count}} usuario en liña",
@ -28,11 +27,7 @@
"admin_pads.col.revisions": "Revisións",
"admin_pads.col.users": "Usuarios",
"admin_pads.confirm_button": "Aceptar",
"admin_pads.create_pad_dialog_description": "Escolle un nome para o documento novo.",
"admin_pads.delete_pad_dialog_description": "Confirmar ou cancelar a eliminación do documento.",
"admin_pads.delete_pad_dialog_title": "Borrar o documento",
"admin_pads.empty_never_edited": "baleiro · non se editou nunca",
"admin_pads.error_dialog_description": "Houbo un erro.",
"admin_pads.error_prefix": "Erro",
"admin_pads.filter.active": "Activos",
"admin_pads.filter.all": "Todos",
@ -73,14 +68,11 @@
"admin_plugins.available_search.placeholder": "Buscar complementos para instalar",
"admin_plugins.check_updates": "Comprobar se hai actualizacións",
"admin_plugins.core_count": "{{count}} principais",
"admin_plugins.catalog_disabled": "O catálogo de complementos está desactivado polo operador (privacy.pluginCatalog=false). Para instalar un complemento, executa `pnpm run plugins i ep_<nome>` no servidor.",
"admin_plugins.crumbs": "Complementos",
"admin_plugins.description": "Descrición",
"admin_plugins.disables.label": "Desactiva:",
"admin_plugins.disables.warning_title": "Este complemento elimina intencionadamente as funcionalidades de Etherpad listadas.",
"admin_plugins.error_retrieving": "Erro ao recuperar os complementos",
"admin_plugins.install_error": "Erro ao instalar «{{plugin}}»: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad": "Non se pode instalar «{{plugin}}»: cómpre unha versión máis recente de Etherpad. Actualiza Etherpad e inténtao de novo.",
"admin_plugins.installed": "Complementos instalados",
"admin_plugins.installed_fetching": "Obtendo os complementos instalados...",
"admin_plugins.installed_nothing": "Aínda non instalaches ningún complemento.",
@ -132,33 +124,6 @@
"admin_settings.current_restart.value": "Reiniciar Etherpad",
"admin_settings.current_save.value": "Gardar axustes",
"admin_settings.invalid_json": "JSON non válido",
"admin_settings.current_test.value": "Validar o JSON",
"admin_settings.current_prettify.value": "Aquelar o JSON",
"admin_settings.toast.saved": "Os axustes gardáronse correctamente.",
"admin_settings.toast.save_failed": "Fallou o gardado: non se puido escribir no ficheiro «settings.json».",
"admin_settings.toast.json_invalid": "Erro de sintaxe: comproba as comas, as chaves e as comiñas.",
"admin_settings.toast.disconnected": "Non se pode gardar: non tes conexión co servidor.",
"admin_settings.toast.validation_ok": "O JSON é válido.",
"admin_settings.toast.validation_failed": "O JSON non é válido: corrixe os erros de sintaxe.",
"admin_settings.toast.prettify_failed": "Non se pode aquelar: primeiro corrixe os erros de sintaxe.",
"admin_settings.prettify_confirm": "Ao aquelar, eliminaranse todos os comentarios. Queres continuar?",
"admin_settings.mode.form": "Formulario",
"admin_settings.mode.raw": "En bruto",
"admin_settings.mode.effective": "Efectivo",
"admin_settings.mode.effective_tooltip": "Vista de só lectura dos valores que Etherpad está a usar agora mesmo, despois da substitución das variables de contorno. Os segredos están agochados.",
"admin_settings.mode.aria_label": "Modo de edición",
"admin_settings.envvar_banner.title": "Este ficheiro é un modelo, non a configuración real.",
"admin_settings.envvar_banner.body": "Os marcadores de posición como «${VAR:default}» substitúense na memoria durante o inicio da aplicación; nunca se escribir neste ficheiro. Edita as variables no teu contorno (Docker compose, systemd, .env) para cambiar o valor resolto ou substitúe o marcador de posición aquí por unha cadea de texto literal. Cambia á lapela «Efectivo» para ver o que está a usar Etherpad agora mesmo.",
"admin_settings.toast.auth_error": "Non te conectaches cunha conta administrativa. Inicia sesión de novo.",
"admin_settings.section.general": "Xeral",
"admin_settings.parse_error.title": "Non se pode analizar o ficheiro «settings.json»",
"admin_settings.parse_error.cta": "Cambiar ao editor en bruto para editar",
"admin_settings.env_pill.tooltip": "Le a variable de contorno «{{variable}}». O valor que aparece a continuación úsase cando a variable «{{variable}}» non está definida.",
"admin_settings.env_pill.default_label": "predeterminado",
"admin_settings.env_pill.input_aria": "Valor predeterminado para «{{variable}}»",
"admin_settings.env_pill.runtime_label": "valor activo",
"admin_settings.env_pill.runtime_tooltip": "Etherpad está a usar este valor actualmente, resolto a partir de «{{variable}}» ou o seu valor predeterminado.",
"admin_settings.env_pill.redacted_tooltip": "Etherpad está a usar un valor para «{{variable}}», pero está oculto porque é un segredo.",
"admin_settings.page-title": "Axustes - Etherpad",
"admin_settings.save_error": "Produciuse un erro ao gardar os axustes",
"admin_settings.saved_success": "Os axustes gardáronse correctamente",
@ -187,8 +152,6 @@
"update.page.policy.rollback-failed-terminal": "Fallou unha actualización anterior e non se puido reverter. Preme en «Son consciente» despois de que a instalación estea correcta para levantar o bloqueo.",
"update.page.policy.up-to-date": "Estás a executar a última versión.",
"update.page.policy.tier-off": "As actualizacións están desactivadas (updates.tier = \"off\").",
"update.page.policy.maintenance-window-missing": "O nivel 4 (autónomo) necesita un período de mantemento. Define «updates.maintenanceWindow» en «settings.json» para activar as actualizacións autónomas.",
"update.page.policy.maintenance-window-invalid": "O nivel 4 (autónomo) está desactivado porque «updates.maintenanceWindow» ten un formato incorrecto. Esperábase «{inicio, fin, zona horaria}», con horas HH:MM e zona horaria «local» ou «utc».",
"update.page.last_result.verified": "Verificouse a última actualización a {{tag}}.",
"update.page.last_result.rolled-back": "Reverteuse o último intento de actualización a {{tag}}: {{reason}}.",
"update.page.last_result.rollback-failed": "Fallou o último intento de actualización E fallou a reversión: {{reason}}. Cómpre unha intervención manual.",
@ -207,16 +170,9 @@
"update.execution.rollback-failed": "Fallou a reversión",
"update.banner.terminal.rollback-failed": "Fallou un intento de actualización e non se puido reverter. Cómpre unha intervención manual.",
"update.banner.scheduled": "Actualización automática programada a {{tag}}: aplicarase en {{remaining}}.",
"update.banner.maintenance-window-missing": "As actualizacións autónomas están desactivadas ata que se configure un período de mantemento.",
"update.banner.maintenance-window-invalid": "As actualizacións autónomas están desactivadas porque o período de mantemento ten un formato incorrecto.",
"update.page.scheduled.title": "Actualización programada",
"update.page.scheduled.countdown": "Etherpad comezará a actualizarse a {{tag}} en {{remaining}}.",
"update.page.scheduled.deferred_until": "Fóra do período de mantemento. A actualización comezará cando se inicie o período ás {{at}}.",
"update.page.scheduled.apply_now": "Aplicar agora",
"update.window.title": "Período de mantemento",
"update.window.summary": "{{start}}{{end}} ({{tz}})",
"update.window.unset": "Sen configurar.",
"update.window.next_opens_at": "O seguinte período comeza ás {{at}}.",
"update.drain.t60": "Etherpad reiniciarase en 60 segundos para aplicar unha actualización.",
"update.drain.t30": "Etherpad reiniciarase en 30 segundos para aplicar unha actualización.",
"update.drain.t10": "Etherpad reiniciarase en 10 segundos para aplicar unha actualización.",
@ -232,7 +188,6 @@
"index.copyLinkButton": "Copiar a ligazón no portapapeis",
"index.transferToSystem": "3. Copia a sesión no novo sistema",
"index.transferToSystemDescription": "Abre a ligazón copiada no navegador ou dispositivo de destino para transferir a túa sesión.",
"index.code": "Código",
"index.transferSessionDescription": "Transfire a túa sesión actual ao navegador ou dispositivo facendo clic no botón de embaixo. Isto copiará unha ligazón cara a unha páxina que transferirá a túa sesión cando se abra no navegador ou dispositivo de destino.",
"index.createOpenPad": "Abrir un documento por nome",
"index.openPad": "abrir un documento existente co nome:",
@ -323,27 +278,27 @@
"pad.modals.userdup.explanation": "Semella que este documento está aberto en varias ventás do navegador neste ordenador.",
"pad.modals.userdup.advice": "Reconectar para usar esta ventá.",
"pad.modals.unauth": "Non autorizado",
"pad.modals.unauth.explanation": "Os geus permisos cambiaron mentres estabas nesta páxina. Intenta reconectarte.",
"pad.modals.unauth.explanation": "Os seus permisos cambiaron mentres estaba nesta páxina. Intente a reconexión.",
"pad.modals.looping.explanation": "Hai un problema de comunicación co servidor de sincronización.",
"pad.modals.looping.cause": "Seica a túa conexión pasa a través dun firewall ou proxy incompatible.",
"pad.modals.looping.cause": "Seica a súa conexión pasa a través dun firewall ou proxy incompatible.",
"pad.modals.initsocketfail": "Non se pode alcanzar o servidor.",
"pad.modals.initsocketfail.explanation": "Non se pode conectar co servidor de sincronización.",
"pad.modals.initsocketfail.cause": "Isto acontece probablemente debido a un problema co navegador ou coa conexión a Internet.",
"pad.modals.initsocketfail.cause": "Isto acontece probablemente debido a un problema co navegador ou coa conexión á internet.",
"pad.modals.slowcommit.explanation": "O servidor non responde.",
"pad.modals.slowcommit.cause": "Isto pode deberse a un problema de conexión á rede.",
"pad.modals.badChangeset.explanation": "O servidor de sincronización clasificou como ilegal unha das túas edicións.",
"pad.modals.badChangeset.cause": "Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Ponte en contacto coa administración do servizo se pensas que isto é un erro. Intenta reconectar para continuar editando.",
"pad.modals.corruptPad.explanation": "O documento ao que intentas acceder está corrompido.",
"pad.modals.corruptPad.cause": "Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Ponte en contacto coa administración do servizo.",
"pad.modals.badChangeset.explanation": "O servidor de sincronización clasificou como ilegal unha das súas edicións.",
"pad.modals.badChangeset.cause": "Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Póñase en contacto co administrador do servizo, se pensa que isto é un erro. Intente reconectar para continuar editando.",
"pad.modals.corruptPad.explanation": "O documento ao que intenta acceder está corrompido.",
"pad.modals.corruptPad.cause": "Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Póñase en contacto co administrador do servizo.",
"pad.modals.deleted": "Borrado.",
"pad.modals.deleted.explanation": "Este documento foi eliminado.",
"pad.modals.rateLimited": "Taxa limitada.",
"pad.modals.rateLimited.explanation": "Enviaches demasiadas mensaxes a este documento polo que te desconectamos.",
"pad.modals.rejected.explanation": "O servidor rexeitou unha mensaxe que o teu navegador enviou.",
"pad.modals.rejected.cause": "O servidor podería ter sido actualizado mentras ollabas o documento, ou pode que sexa un fallo de Etherpad. Intenta recargar a páxina.",
"pad.modals.disconnected": "Desconectácheste.",
"pad.modals.disconnected": "Foi desconectado.",
"pad.modals.disconnected.explanation": "Perdeuse a conexión co servidor",
"pad.modals.disconnected.cause": "O servidor non está dispoñible. Ponte en contacto coa administración do servizo se o problema continúa.",
"pad.modals.disconnected.cause": "O servidor non está dispoñible. Póñase en contacto co administrador do servizo se o problema continúa.",
"pad.gritter.unacceptedCommit.title": "Edición sen gardar",
"pad.gritter.unacceptedCommit.text": "A túa edición recente aínda non está gardada. Conéctate e inténtao de novo.",
"pad.share": "Compartir este documento",
@ -371,13 +326,8 @@
"pad.historyMode.settings.playbackSpeed": "Velocidade de reprodución:",
"pad.historyMode.chat.replayHeader": "Conversa o {{time}}",
"pad.historyMode.users.authorsHeader": "Autores nesta revisión",
"pad.editor.skipToContent": "Ir ata o editor",
"pad.editor.keyboardHint": "Preme a tecla Esc para saír do editor. Preme Alt+F9 para acceder á barra de ferramentas.",
"pad.editor.toolbar.formatting": "Barras de ferramentas de formato",
"pad.editor.toolbar.actions": "Barras de ferramentas de acción",
"pad.editor.toolbar.showMore": "Mostrar máis botóns da barra de ferramentas",
"timeslider.toolbar.authors": "Editoras:",
"timeslider.toolbar.authorsList": "Sen editoras",
"timeslider.toolbar.authorsList": "Sen Editoras",
"timeslider.toolbar.exportlink.title": "Exportar",
"timeslider.exportCurrent": "Exportar a versión actual en formato:",
"timeslider.version": "Versión {{version}}",
@ -406,20 +356,19 @@
"timeslider.month.december": "decembro",
"timeslider.unnamedauthors": "{{num}} {[plural(num) one: editora anónima, other: editora anónima ]}",
"pad.savedrevs.marked": "Esta revisión está agora marcada como revisión gardada",
"pad.savedrevs.timeslider": "Podes consultar as revisións gardadas visitando a cronoloxía",
"pad.userlist.entername": "Insire o teu nome",
"pad.savedrevs.timeslider": "Pode consultar as revisións gardadas visitando a cronoloxía",
"pad.userlist.entername": "Insira o seu nome",
"pad.userlist.unnamed": "anónimo",
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} usuario conectado, other: {{count}} usuarios conectados ]}",
"pad.editbar.clearcolors": "Eliminar as cores relativas ás participantes en todo o documento? Non se poderán recuperar.",
"pad.editbar.clearcolors": "Eliminar as cores relativas ás participantes en todo o documento? Non se poderán recuperar",
"pad.impexp.importbutton": "Importar agora",
"pad.impexp.importing": "Importando...",
"pad.impexp.confirmimport": "A importación dun ficheiro ha sobrescribir o texto actual do documento. Queres continuar?",
"pad.impexp.convertFailed": "Non somos capaces de importar o ficheiro. Utiliza un formato de documento diferente ou copia e pega manualmente.",
"pad.impexp.padHasData": "Non puidemos importar este ficheiro porque este documento xa sufriu cambios; importa a un novo documento.",
"pad.impexp.uploadFailed": "Houbo un erro ao subir o ficheiro; inténtao de novo",
"pad.impexp.confirmimport": "A importación dun ficheiro ha sobrescribir o texto actual do documento. Está seguro de querer continuar?",
"pad.impexp.convertFailed": "Non somos capaces de importar o ficheiro. Utilice un formato de documento diferente ou copie e pegue manualmente",
"pad.impexp.padHasData": "Non puidemos importar este ficheiro porque este documento xa sufriu cambios; importe a un novo documento.",
"pad.impexp.uploadFailed": "Houbo un erro ao cargar o ficheiro; inténteo de novo",
"pad.impexp.importfailed": "Fallou a importación",
"pad.impexp.copypaste": "Copia e pega",
"pad.impexp.exportdisabled": "A exportación en formato {{type}} está desactivada. Ponte en contacto coa administración do sistema se queres máis detalles.",
"pad.impexp.maxFileSize": "O ficheiro é demasiado grande. Contacta coa administración para aumentar o tamaño permitido para as importacións.",
"pad.impexp.copypaste": "Copie e pegue",
"pad.impexp.exportdisabled": "A exportación en formato {{type}} está desactivada. Póñase en contacto co administrador do sistema se quere máis detalles.",
"pad.impexp.maxFileSize": "Ficheiro demasiado granda. Contacta coa administración para aumentar o tamaño permitido para importacións",
"pad.social.description": "Un documento colaborativo que calquera pode editar en tempo real."
}

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