Merge branch 'develop'

This commit is contained in:
Etherpad Release Bot 2026-05-06 21:41:47 +00:00
commit 07902d3da1
239 changed files with 20176 additions and 1674 deletions

View file

@ -28,7 +28,7 @@ jobs:
fail-fast: false
matrix:
# PRs: test on latest Node only. Push to develop: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[20, 22, 24]') }}
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
steps:
-
name: Checkout repository
@ -43,7 +43,6 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
@ -85,7 +84,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[20, 22, 24]') }}
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
steps:
-
name: Checkout repository
@ -100,7 +99,6 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
@ -126,7 +124,9 @@ jobs:
ep_align
ep_author_hover
ep_cursortrace
ep_font_color
ep_font_size
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
@ -150,7 +150,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
node: [22, 24, 25]
name: Windows without plugins
runs-on: windows-latest
steps:
@ -167,7 +167,6 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
@ -201,7 +200,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
node: [22, 24, 25]
name: Windows with Plugins
runs-on: windows-latest
@ -219,7 +218,6 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
@ -238,7 +236,9 @@ jobs:
ep_align
ep_author_hover
ep_cursortrace
ep_font_color
ep_font_size
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest

View file

@ -1,34 +1,38 @@
# Workflow for deploying static content to GitHub Pages
# Workflow for building and deploying the VitePress site to GitHub Pages.
# Build runs on every push to develop and on every PR that touches docs (so
# a build regression is caught at review time instead of breaking develop
# after merge — see #7640). Deploy runs only on push: the github-pages
# environment has protection rules that reject PR refs, and a PR build
# never produced an artifact to deploy anyway.
name: Deploy Docs to GitHub Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["develop"]
paths:
- doc/** # Only run workflow when changes are made to the doc directory
# Allows you to run this workflow manually from the Actions tab
- doc/**
- .github/workflows/build-and-deploy-docs.yml
pull_request:
paths:
- doc/**
- .github/workflows/build-and-deploy-docs.yml
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
packages: read
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
# Allow only one concurrent deployment, skipping runs queued between the run
# in-progress and latest queued. Do NOT cancel in-progress runs — production
# deployments are allowed to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
@ -50,9 +54,17 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
# Pin Node so the build does not silently fall back to whatever the
# runner image ships with. vite 8 requires Node ^20.19.0 || >=22.12.0;
# the repo declares engines.node >=22.12.0 to match.
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Setup Pages
if: github.event_name == 'push'
uses: actions/configure-pages@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
@ -60,12 +72,26 @@ jobs:
working-directory: doc
run: pnpm run docs:build
env:
GITHUB_PAGES: ${{ github.event_name == 'push' && 'true' || '' }}
COMMIT_REF: ${{ github.sha }}
- name: Upload artifact
if: github.event_name == 'push'
uses: actions/upload-pages-artifact@v5
with:
# Upload entire repository
path: './doc/.vitepress/dist'
# Deploy to GitHub Pages on push to develop only. Kept as a separate job
# because the github-pages environment's protection rules reject any
# non-develop ref (including PR merge refs), which used to fail the entire
# workflow at job-creation time before any build step could run.
deploy:
needs: build
if: github.event_name == 'push'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5

408
.github/workflows/deb-package.yml vendored Normal file
View file

@ -0,0 +1,408 @@
name: Debian package
on:
push:
tags:
# Actions tag filters are globs, not regex. Mirrors handleRelease.yml.
- 'v*.*.*'
- 'v*.*.*-*'
branches: [develop]
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:
description: 'Git ref to package (defaults to current)'
required: false
# Default to read-only for the workflow; the release job opts in to
# `contents: write` for itself only. Build jobs (which run on every PR)
# don't need write and shouldn't have it.
permissions:
contents: read
env:
NFPM_VERSION: v2.43.0
jobs:
build:
name: Build .deb (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- uses: pnpm/action-setup@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '24'
cache: pnpm
- name: Resolve version
id: v
# Runs after setup-node so `node` is guaranteed available on
# any runner image (some don't ship it preinstalled).
run: |
if [ "${GITHUB_REF_TYPE}" = "tag" ]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="$(node -p "require('./package.json').version")"
fi
echo "version=${VERSION}" >>"$GITHUB_OUTPUT"
echo "Packaging version: ${VERSION}"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build UI + admin
run: pnpm run build:etherpad
- name: Install nfpm
run: |
set -euo pipefail
NFPM_ARCH=amd64
[ "${{ matrix.arch }}" = "arm64" ] && NFPM_ARCH=arm64
NFPM_DEB="nfpm_${NFPM_VERSION#v}_${NFPM_ARCH}.deb"
BASE="https://github.com/goreleaser/nfpm/releases/download/${NFPM_VERSION}"
curl -fsSL -o "/tmp/${NFPM_DEB}" "${BASE}/${NFPM_DEB}"
curl -fsSL -o /tmp/nfpm-checksums.txt "${BASE}/checksums.txt"
# Verify upstream artifact before sudo dpkg -i (defense in depth
# against a tampered release asset).
( cd /tmp && grep " ${NFPM_DEB}\$" nfpm-checksums.txt | sha256sum -c - )
sudo dpkg -i "/tmp/${NFPM_DEB}"
- name: Stage tree for packaging
run: |
set -eux
STAGE=staging/opt/etherpad
mkdir -p "${STAGE}"
# Production footprint = src/ + bin/ + node_modules/ + metadata.
cp -a src bin package.json pnpm-workspace.yaml README.md LICENSE \
node_modules "${STAGE}/"
# Make pnpm-workspace.yaml production-only (same trick Dockerfile uses).
printf 'packages:\n - src\n - bin\n' > "${STAGE}/pnpm-workspace.yaml"
mkdir -p packaging/etc
cp settings.json.template packaging/etc/settings.json.dist
# Purge test fixtures and dev caches from node_modules to shrink size.
find "${STAGE}/node_modules" -type d \
\( -name test -o -name tests -o -name '__tests__' \
-o -name example -o -name examples -o -name docs \) \
-prune -exec rm -rf {} + 2>/dev/null || true
find "${STAGE}/node_modules" -type f \
\( -name '*.md' -o -name '*.ts.map' -o -name '*.map' \
-o -name 'CHANGELOG*' -o -name 'HISTORY*' \) \
-delete 2>/dev/null || true
- name: Build .deb
env:
VERSION: ${{ steps.v.outputs.version }}
ARCH: ${{ matrix.arch }}
run: |
mkdir -p dist
nfpm package --packager deb -f packaging/nfpm.yaml --target dist/
- name: Smoke-test the package (amd64 only)
if: matrix.arch == 'amd64'
run: |
set -eux
# Ubuntu's default apt nodejs is 18 — too old for our
# `Depends: nodejs (>= 22)`. Add NodeSource's apt repo
# explicitly (key + sources.list) instead of `curl | sudo bash`
# so we don't execute network-fetched code as root.
NODE_MAJOR=24
# GitHub runner images often ship a NodeSource node_20.x list
# preinstalled (sometimes as a .sources deb822 file). Wipe any
# existing nodesource entries so the only Node candidate apt sees
# is our node_24.x repo. Otherwise `apt-get install -y nodejs`
# picks the higher-version 20.x build that's already cached and
# `dpkg -i` then fails on `Depends: nodejs (>= 22)`.
sudo rm -f /etc/apt/sources.list.d/nodesource.list \
/etc/apt/sources.list.d/nodesource.sources \
/etc/apt/preferences.d/nodesource \
/etc/apt/preferences.d/nodesource.pref
KEYRING=/usr/share/keyrings/nodesource.gpg
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| sudo gpg --dearmor --yes -o "${KEYRING}"
echo "deb [signed-by=${KEYRING}] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
| sudo tee /etc/apt/sources.list.d/nodesource.list
# Pin nodejs to the 24.x line so neither Ubuntu's noble-updates
# 20.x nor any leftover NodeSource cache can win the resolver.
printf 'Package: nodejs\nPin: version %s.*\nPin-Priority: 1001\n' "${NODE_MAJOR}" \
| sudo tee /etc/apt/preferences.d/nodesource >/dev/null
sudo apt-get update
# Pin the major explicitly on the install line as a belt-and-
# suspenders guard against any preference file being ignored.
sudo apt-get install -y "nodejs=${NODE_MAJOR}.*"
# Sanity check before invoking dpkg so the failure mode is obvious
# if pinning ever regresses again.
installed_major=$(dpkg-query -W -f='${Version}' nodejs | cut -d. -f1)
if [ "${installed_major}" != "${NODE_MAJOR}" ]; then
echo "::error::Expected nodejs major ${NODE_MAJOR}, got ${installed_major}"
apt-cache policy nodejs || true
exit 1
fi
sudo dpkg -i dist/*.deb || sudo apt-get install -f -y
# /etc/etherpad is mode 0750 root:etherpad on purpose (DB creds
# live here) so the runner user can't read into it. Each check
# that crosses /etc/etherpad or /var/lib/etherpad runs under sudo.
sudo test -x /usr/bin/etherpad
sudo test -f /etc/etherpad/settings.json
sudo test -L /opt/etherpad/settings.json
sudo test -L /opt/etherpad/var
[ "$(sudo readlink /opt/etherpad/var)" = "/var/lib/etherpad/var" ]
sudo test -L /opt/etherpad/src/plugin_packages
[ "$(sudo readlink /opt/etherpad/src/plugin_packages)" = "/var/lib/etherpad/plugin_packages" ]
sudo test -d /var/lib/etherpad/plugin_packages
[ "$(sudo stat -c '%U' /var/lib/etherpad/plugin_packages)" = "etherpad" ]
[ "$(stat -c '%G' /opt/etherpad/src/node_modules)" = "etherpad" ]
sudo test -f /var/lib/etherpad/var/installed_plugins.json
sudo grep -q '"ep_etherpad-lite"' /var/lib/etherpad/var/installed_plugins.json
sudo grep -q '"dbType": "sqlite"' /etc/etherpad/settings.json
id etherpad
systemctl cat etherpad.service
sudo systemctl start etherpad
ok=
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:9001/health; then
ok=1
break
fi
sleep 2
done
if [ -z "${ok}" ]; then
# Attach logs so the failing run is diagnosable.
sudo journalctl -u etherpad --no-pager -n 200 || true
exit 1
fi
sudo systemctl stop etherpad
sudo dpkg --purge etherpad
! id etherpad 2>/dev/null
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: etherpad-${{ steps.v.outputs.version }}-${{ matrix.arch }}-deb
path: dist/*.deb
if-no-files-found: error
release:
name: Attach to GitHub Release
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v8
with:
path: dist
pattern: etherpad-*-deb
merge-multiple: true
- name: Create stable "latest" aliases
run: |
set -euo pipefail
# Publish stable filenames alongside the versioned ones so users
# can curl https://github.com/.../releases/latest/download/etherpad-latest_amd64.deb
# without knowing the version.
for f in dist/etherpad_*_amd64.deb; do
[ -e "$f" ] && cp "$f" dist/etherpad-latest_amd64.deb
done
for f in dist/etherpad_*_arm64.deb; do
[ -e "$f" ] && cp "$f" dist/etherpad-latest_arm64.deb
done
ls -la dist/
- name: Attach .deb files to release
uses: softprops/action-gh-release@v3
with:
files: dist/*.deb
fail_on_unmatched_files: true
apt-publish:
# Generates a signed apt repository (Packages.gz + Release/InRelease)
# from the .deb artefacts and cross-pushes it into ether/ether.github.com
# under public/apt/. The Next.js site that powers etherpad.org serves
# public/ verbatim, so the repo lands at:
#
# https://etherpad.org/apt/ (apt repo root)
# https://etherpad.org/key.asc (public key for `apt-key`/keyring)
#
# Tag pushes go into the `stable` suite. Required secrets:
# APT_SIGNING_KEY ASCII-armoured private key for the Etherpad APT
# Repository keypair (fingerprint
# 6953FA0C6431F30347D65B03AF0CD687D51A6E63).
# SITE_DEPLOY_KEY SSH private key matching a deploy key with write
# access on ether/ether.github.com. The site repo
# holds the public half.
name: Publish apt repository to etherpad.org
needs: release
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Checkout etherpad source (for packaging/apt/key.asc)
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Configure deploy key for ether/ether.github.com
env:
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
run: |
set -euo pipefail
if [ -z "${SITE_DEPLOY_KEY:-}" ]; then
echo "::error::SITE_DEPLOY_KEY secret is not set on ether/etherpad."
echo "::error::Add an SSH deploy key with write access on ether/ether.github.com and store the private key here."
exit 1
fi
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "${SITE_DEPLOY_KEY}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan -t ed25519,rsa github.com >> ~/.ssh/known_hosts 2>/dev/null
cat > ~/.ssh/config <<'CFG'
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_deploy
IdentitiesOnly yes
CFG
chmod 600 ~/.ssh/config
- name: Clone ether/ether.github.com
run: git clone --depth 1 git@github.com:ether/ether.github.com.git site
- uses: actions/download-artifact@v8
with:
path: dist
pattern: etherpad-*-deb
merge-multiple: true
- name: Install apt-utils + gpg
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq apt-utils gnupg
- name: Import signing key
env:
APT_SIGNING_KEY: ${{ secrets.APT_SIGNING_KEY }}
run: |
set -euo pipefail
if [ -z "${APT_SIGNING_KEY:-}" ]; then
echo "::error::APT_SIGNING_KEY secret is not set; cannot sign Release file."
exit 1
fi
export GNUPGHOME="$(mktemp -d)"
chmod 700 "${GNUPGHOME}"
echo "GNUPGHOME=${GNUPGHOME}" >>"${GITHUB_ENV}"
printf '%s' "${APT_SIGNING_KEY}" | gpg --batch --import
# Sanity check: expected long key id.
gpg --list-secret-keys --keyid-format=long | grep -q AF0CD687D51A6E63
- name: Generate apt repo metadata
run: |
set -euo pipefail
REPO=site/public/apt
SUITE=stable
COMP=main
# Wipe any previous repo state so removed versions don't linger
# in pool/. Packages.gz is regenerated from whatever is in pool/
# right now, so this is the simplest correct option — alternative
# is per-version diffing which is fragile.
rm -rf "${REPO}"
# We ship one architecture-agnostic suite with per-arch pools.
# Layout: apt/dists/<suite>/main/binary-{amd64,arm64}/
for arch in amd64 arm64; do
mkdir -p "${REPO}/pool/main/e/etherpad" "${REPO}/dists/${SUITE}/${COMP}/binary-${arch}"
done
# Drop the .debs into pool/. The leading-digit pattern
# excludes the etherpad-latest_*.deb filename aliases the
# release job stages — apt resolves by package name + version,
# not filename, so including the alias would create duplicate
# Packages entries. (Also defends against any future alias that
# accidentally lands on dist/etherpad_<word>_<arch>.deb.)
shopt -s nullglob
DEBS=(dist/etherpad_[0-9]*_amd64.deb dist/etherpad_[0-9]*_arm64.deb)
shopt -u nullglob
# Refuse to publish nothing. Without this, a missing or renamed
# build artefact would wipe site/public/apt and push an empty,
# signed apt repo — breaking `apt update` for every existing
# subscriber until the next successful release.
if [ ${#DEBS[@]} -lt 2 ]; then
echo "::error::Expected per-arch .deb artifacts in dist/, found ${#DEBS[@]}: ${DEBS[*]:-<none>}"
echo "::error::Refusing to publish a partial / empty apt repository."
exit 1
fi
cp "${DEBS[@]}" "${REPO}/pool/main/e/etherpad/"
# Generate per-arch Packages files.
(
cd "${REPO}"
for arch in amd64 arm64; do
apt-ftparchive --arch "${arch}" packages pool/main \
> "dists/${SUITE}/${COMP}/binary-${arch}/Packages"
gzip -kf "dists/${SUITE}/${COMP}/binary-${arch}/Packages"
done
# Generate the suite's Release file. The heredoc lines
# MUST start at column 1 — apt parsers reject leading
# whitespace on header fields (RFC 822 / Debian control).
# printf is used over a heredoc to make that contract
# impossible to lose to a future re-indent.
printf '%s\n' \
"Origin: Etherpad" \
"Label: Etherpad" \
"Suite: ${SUITE}" \
"Codename: ${SUITE}" \
"Architectures: amd64 arm64" \
"Components: ${COMP}" \
"Description: Etherpad official apt repository (${SUITE} channel)" \
"Date: $(date -Ru)" \
> "dists/${SUITE}/Release"
# apt-ftparchive appends checksums.
apt-ftparchive release "dists/${SUITE}" >> "dists/${SUITE}/Release"
# Sign it (clear-signed InRelease + detached Release.gpg).
gpg --default-key AF0CD687D51A6E63 --batch --yes \
--clearsign -o "dists/${SUITE}/InRelease" "dists/${SUITE}/Release"
gpg --default-key AF0CD687D51A6E63 --batch --yes \
-abs -o "dists/${SUITE}/Release.gpg" "dists/${SUITE}/Release"
)
- name: Stage public key alongside the site
run: |
# Users curl this to add our key to their keyring before apt update.
cp packaging/apt/key.asc site/public/key.asc
- name: Commit + push to ether/ether.github.com
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
cd site
git -c user.email=actions@github.com -c user.name='github-actions[bot]' \
add public/apt public/key.asc
if git diff --cached --quiet; then
echo "No apt-repo changes to publish."
exit 0
fi
git -c user.email=actions@github.com -c user.name='github-actions[bot]' \
commit -m "apt: publish Etherpad ${TAG}"
git push origin HEAD:master

View file

@ -52,8 +52,16 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
# Repo is checked out into ./etherpad, so the action can't find
# packageManager in a root package.json.
package_json_file: etherpad/package.json
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: etherpad/pnpm-lock.yaml
-
name: Test
working-directory: etherpad
@ -74,6 +82,10 @@ jobs:
git clean -dxf .
build-test-db-drivers:
# Spinning up MySQL + Postgres + cross-driver smoke is expensive; only
# run it on pushes to develop (and tagged release pushes), not on every
# feature-branch PR.
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read

View file

@ -22,7 +22,7 @@ jobs:
fail-fast: false
matrix:
# PRs: single Node version. Push: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[20, 22, 24]') }}
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
steps:
-
@ -38,7 +38,6 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6

View file

@ -38,8 +38,12 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
@ -49,17 +53,22 @@ jobs:
- name: Run the frontend tests
shell: bash
run: |
set -euo pipefail
pnpm run prod > /tmp/etherpad-server.log 2>&1 &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
connected=true
}
now() { date +%s; }
start=$(now)
while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do
sleep 1
done
# Generous 90s budget so a slow runner (or, in the with-plugins
# variant, plugin boot) doesn't lose the race against the test
# phase. Fail loudly on timeout rather than silently falling
# through to tests against a half-started server.
# --max-time bounds each probe so a stuck server can't make a
# single curl call eat the whole 90s budget.
can_connect() { curl --max-time 3 -sSfo /dev/null http://localhost:9001/; }
for i in $(seq 1 90); do can_connect && break; sleep 1; done
if ! can_connect; then
echo "::error::Etherpad did not respond on :9001 within 90s"
echo "----- server log -----"
tail -n 200 /tmp/etherpad-server.log || true
exit 1
fi
cd src
pnpm exec playwright install chromium --with-deps
pnpm run test-ui --project=chromium
@ -101,8 +110,12 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
- name: Create settings.json
@ -110,17 +123,22 @@ jobs:
- name: Run the frontend tests
shell: bash
run: |
set -euo pipefail
pnpm run prod > /tmp/etherpad-server.log 2>&1 &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
connected=true
}
now() { date +%s; }
start=$(now)
while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do
sleep 1
done
# Generous 90s budget so a slow runner (or, in the with-plugins
# variant, plugin boot) doesn't lose the race against the test
# phase. Fail loudly on timeout rather than silently falling
# through to tests against a half-started server.
# --max-time bounds each probe so a stuck server can't make a
# single curl call eat the whole 90s budget.
can_connect() { curl --max-time 3 -sSfo /dev/null http://localhost:9001/; }
for i in $(seq 1 90); do can_connect && break; sleep 1; done
if ! can_connect; then
echo "::error::Etherpad did not respond on :9001 within 90s"
echo "----- server log -----"
tail -n 200 /tmp/etherpad-server.log || true
exit 1
fi
cd src
pnpm exec playwright install firefox --with-deps
pnpm run test-ui --project=firefox
@ -137,3 +155,198 @@ jobs:
name: playwright-report-firefox
path: src/playwright-report/
retention-days: 30
# Frontend tests with the same /ether plugin set that backend-tests.yml
# exercises, so a core change that breaks plugin UX is caught in PR CI
# rather than after release. Re-introduces coverage that was lost when
# the workflows were nuked & rebuilt in 2023 (commit cc80db2d3) and the
# backend equivalent was restored without the frontend half.
playwright-chrome-with-plugins:
env:
PNPM_HOME: ~/.pnpm-store
name: Playwright Chrome with plugins
runs-on: ubuntu-latest
steps:
- name: Checkout repository
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@v5
name: Cache Playwright browsers
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('src/package.json', 'pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
- name: Install Etherpad plugins
# Same plugin set as backend-tests.yml's withpluginsLinux job,
# MINUS ep_cursortrace.
#
# ep_cursortrace's `aceEditEvent` hook fires on every keyboard
# event (handleClick, handleKeyEvent, idleWorkTimer) and sends a
# cursor-position socket message per call. Under the test
# harness's `writeToPad` bursts (insertText + Enter loops) that
# stream of socket messages saturates the editor's input
# pipeline in Firefox specifically, causing intermittent
# keystroke drops and a long tail of test flakiness.
#
# Bisected via a 4-iteration probe on this branch — see commit
# history of .github/workflows/frontend-tests.yml around the
# PR-7630 timeframe. Tracked for a follow-up fix
# (debounce / throttle in ep_cursortrace's main.js).
run: >
pnpm add -w
ep_align
ep_author_hover
ep_font_color
ep_font_size
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
ep_table_of_contents
- name: Create settings.json
run: cp ./src/tests/settings.json settings.json
- name: Run the frontend tests
shell: bash
run: |
set -euo pipefail
pnpm run prod > /tmp/etherpad-server.log 2>&1 &
# Generous 90s budget so a slow runner (or, in the with-plugins
# variant, plugin boot) doesn't lose the race against the test
# phase. Fail loudly on timeout rather than silently falling
# through to tests against a half-started server.
# --max-time bounds each probe so a stuck server can't make a
# single curl call eat the whole 90s budget.
can_connect() { curl --max-time 3 -sSfo /dev/null http://localhost:9001/; }
for i in $(seq 1 90); do can_connect && break; sleep 1; done
if ! can_connect; then
echo "::error::Etherpad did not respond on :9001 within 90s"
echo "----- server log -----"
tail -n 200 /tmp/etherpad-server.log || true
exit 1
fi
cd src
pnpm exec playwright install chromium --with-deps
WITH_PLUGINS=1 pnpm run test-ui --project=chromium
- name: Upload server log on failure
uses: actions/upload-artifact@v7
if: failure()
with:
name: server-log-chrome-with-plugins
path: /tmp/etherpad-server.log
retention-days: 7
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report-chrome-with-plugins
path: src/playwright-report/
retention-days: 30
playwright-firefox-with-plugins:
env:
PNPM_HOME: ~/.pnpm-store
name: Playwright Firefox with plugins
runs-on: ubuntu-latest
steps:
- name: Checkout repository
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@v5
name: Cache Playwright browsers
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('src/package.json', 'pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
- name: Install Etherpad plugins
# See sibling Playwright Chrome with plugins job for the full
# rationale on why ep_cursortrace is excluded from the test
# plugin set.
run: >
pnpm add -w
ep_align
ep_author_hover
ep_font_color
ep_font_size
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
ep_table_of_contents
- name: Create settings.json
run: cp ./src/tests/settings.json settings.json
- name: Run the frontend tests
shell: bash
run: |
set -euo pipefail
pnpm run prod > /tmp/etherpad-server.log 2>&1 &
# Generous 90s budget so a slow runner (or, in the with-plugins
# variant, plugin boot) doesn't lose the race against the test
# phase. Fail loudly on timeout rather than silently falling
# through to tests against a half-started server.
# --max-time bounds each probe so a stuck server can't make a
# single curl call eat the whole 90s budget.
can_connect() { curl --max-time 3 -sSfo /dev/null http://localhost:9001/; }
for i in $(seq 1 90); do can_connect && break; sleep 1; done
if ! can_connect; then
echo "::error::Etherpad did not respond on :9001 within 90s"
echo "----- server log -----"
tail -n 200 /tmp/etherpad-server.log || true
exit 1
fi
cd src
pnpm exec playwright install firefox --with-deps
WITH_PLUGINS=1 pnpm run test-ui --project=firefox
- name: Upload server log on failure
uses: actions/upload-artifact@v7
if: failure()
with:
name: server-log-firefox-with-plugins
path: /tmp/etherpad-server.log
retention-days: 7
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report-firefox-with-plugins
path: src/playwright-report/
retention-days: 30

View file

@ -38,8 +38,12 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
- name: Build etherpad

View file

@ -35,14 +35,18 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
-
name: Install etherpad-load-test
run: sudo npm install -g etherpad-load-test-socket-io
run: sudo npm install -g etherpad-load-test
-
name: Run load test
run: src/tests/frontend/travis/runnerLoadTest.sh 25 50
@ -69,11 +73,15 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
-
name: Install etherpad-load-test
run: sudo npm install -g etherpad-load-test-socket-io
run: sudo npm install -g etherpad-load-test
-
name: Install etherpad plugins
run: >
@ -128,14 +136,18 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
-
name: Install etherpad-load-test
run: sudo npm install -g etherpad-load-test-socket-io
run: sudo npm install -g etherpad-load-test
-
name: Run load test
run: src/tests/frontend/travis/runnerLoadTest.sh 5000 5

View file

@ -35,8 +35,12 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
- name: Perform type check

View file

@ -38,8 +38,12 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
-
name: docker network

View file

@ -57,10 +57,22 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
# Repo is checked out into ./etherpad, so the action can't find
# packageManager in a root package.json.
package_json_file: etherpad/package.json
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
cache-dependency-path: etherpad/pnpm-lock.yaml
- name: Install dependencies ether.github.com
run: pnpm install --frozen-lockfile
# ether.github.com depends on sharp (Next.js image pipeline), whose
# install script must run to fetch the platform binary. pnpm 11
# turned ignored-builds into an error; allow all builds for this
# external repo since we don't control its pnpm-workspace.yaml.
run: pnpm install --frozen-lockfile --config.dangerously-allow-all-builds=true
working-directory: ether.github.com
- name: Set git user
run: |

View file

@ -17,9 +17,9 @@ jobs:
- uses: actions/setup-node@v6
with:
# OIDC trusted publishing needs npm >= 11.5.1, which requires
# Node >= 20.17.0. setup-node's `20` resolves to the latest
# 20.x, which satisfies that.
node-version: 20
# Node >= 22.9.0. setup-node's `22` resolves to the latest
# 22.x, which satisfies that.
node-version: 22
registry-url: https://registry.npmjs.org/
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
run: npm install -g npm@latest
@ -33,7 +33,6 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile

71
.github/workflows/snap-build.yml vendored Normal file
View file

@ -0,0 +1,71 @@
# Snap build verification — runs on every PR (and on develop) that
# touches snap/ or this workflow. Catches breakage in the wrappers and
# the build recipe before it reaches a release tag.
#
# Two jobs:
# wrapper-tests — runs the bash unit tests under snap/tests/. Fast
# (~5s); no snapd / sudo / network needed. Always runs first.
# snap-pack — builds the actual snap with `snapcraft pack
# --destructive-mode` (no LXD). Faster than the publish workflow
# because it skips container provisioning and store auth. Uploads
# the .snap as an artifact so reviewers can sideload it.
#
# Production publishing lives in `snap-publish.yml` (tag-triggered).
name: Snap build (PR)
on:
pull_request:
paths:
- 'snap/**'
- 'settings.json.template'
- '.github/workflows/snap-build.yml'
push:
branches: [develop]
paths:
- 'snap/**'
- 'settings.json.template'
- '.github/workflows/snap-build.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
wrapper-tests:
name: Wrapper unit tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Run snap/tests/run-all.sh
run: bash snap/tests/run-all.sh
snap-pack:
name: Build snap (destructive-mode)
needs: wrapper-tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Install snapcraft
run: sudo snap install --classic snapcraft
- name: Pack snap
run: sudo snapcraft pack --destructive-mode --verbose
- name: Locate built artifact
id: artifact
run: |
set -e
SNAP=$(ls etherpad_*.snap | head -1)
if [ -z "${SNAP}" ]; then
echo "::error::no .snap file produced"
exit 1
fi
echo "snap=${SNAP}" >> "${GITHUB_OUTPUT}"
- name: Upload .snap artifact
uses: actions/upload-artifact@v7
with:
name: etherpad-snap-pr-${{ github.event.pull_request.number || github.run_id }}
path: ${{ steps.artifact.outputs.snap }}
if-no-files-found: error
retention-days: 7

92
.github/workflows/snap-publish.yml vendored Normal file
View file

@ -0,0 +1,92 @@
# Builds and publishes the Etherpad snap on tagged releases.
# Mirrors the trigger pattern from .github/workflows/docker.yml / release.yml
# (semver tags, with or without a leading `v`).
#
# Note: `on.push.tags` uses GitHub's filter-pattern globs, NOT regex — so the
# pattern must be expressed as two glob entries, not a single `v?...` regex.
# https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions#filter-pattern-cheat-sheet
#
# One-time maintainer setup:
# 1. `snapcraft register etherpad` claims the name.
# 2. Generate a store credential:
# snapcraft export-login --snaps etherpad \
# --channels edge,stable \
# --acls package_access,package_push,package_release -
# Store the output as repo secret SNAPCRAFT_STORE_CREDENTIALS.
# 3. Create a GitHub Environment called `snap-store-stable` with required
# reviewers so stable promotion is gated.
#
# Ref: https://documentation.ubuntu.com/snapcraft/latest/how-to/publishing/
name: Snap
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
- '[0-9]+.[0-9]+.[0-9]+'
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
outputs:
snap-file: ${{ steps.build.outputs.snap }}
steps:
- name: Check out
uses: actions/checkout@v6
- name: Build snap
id: build
uses: snapcore/action-build@v1
- name: Upload snap artifact
uses: actions/upload-artifact@v7
with:
name: etherpad-snap
path: ${{ steps.build.outputs.snap }}
if-no-files-found: error
retention-days: 7
publish-edge:
needs: build
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Download snap artifact
uses: actions/download-artifact@v8
with:
name: etherpad-snap
- name: Publish to edge
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
with:
snap: ${{ needs.build.outputs.snap-file }}
release: edge
publish-stable:
needs: [build, publish-edge]
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
# Manual gate: promote edge -> stable via GitHub Environments approval.
environment: snap-store-stable
steps:
- name: Download snap artifact
uses: actions/download-artifact@v8
with:
name: etherpad-snap
- name: Publish to stable
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
with:
snap: ${{ needs.build.outputs.snap-file }}
release: stable

View file

@ -21,7 +21,6 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js

View file

@ -28,7 +28,7 @@ jobs:
fail-fast: false
matrix:
# PRs: single Node version. Push: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[20, 22, 24]') }}
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
steps:
-
name: Check out latest release
@ -45,7 +45,6 @@ jobs:
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10.33.2
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6

11
.gitignore vendored
View file

@ -27,3 +27,14 @@ plugin_packages
playwright-report
state.json
/src/static/oidc
# Build artefacts produced by packaging/test-local.sh and the deb-package CI workflow.
/staging/
/dist/
/packaging/etc/
# Snapcraft destructive-mode build outputs.
parts/
stage/
prime/
.craft/
*.snap

View file

@ -6,8 +6,8 @@ Welcome to the Etherpad project. This guide provides essential context and instr
Etherpad is a real-time collaborative editor designed to be lightweight, scalable, and highly extensible via plugins.
## Technical Stack
- **Runtime:** Node.js >= 20.0.0
- **Package Manager:** pnpm (>= 8.3.0)
- **Runtime:** Node.js >= 22.12.0
- **Package Manager:** pnpm (>= 11.0.0)
- **Languages:** TypeScript (primary for new code), JavaScript (legacy), CSS, HTML
- **Backend:** Express.js 5, Socket.io 4
- **Frontend:** Legacy core (`src/static`), Modern React UI (`ui/`), Admin UI (`admin/`)

View file

@ -1,3 +1,59 @@
# 2.7.3
### Breaking changes
- **Minimum required Node.js version is now 22.13.** Node.js 20 is reaching end-of-life (see https://nodejs.org/en/about/previous-releases) and pnpm 11 hard-rejects Node releases older than 22.13. The CI matrix targets Node 22, 24, and 25. Upgrading should be straightforward — install a current Node.js release before updating Etherpad.
- **The official Docker image no longer ships `curl`, `npm`, or `npx`.** These were dropped to remove transitive CVEs (curl/libcurl SMB advisories, npm's bundled picomatch 4.0.3 and brace-expansion 2.0.2). The container's healthcheck now uses `wget` (busybox built-in, always present), and Etherpad provisions `pnpm` via `corepack` for all runtime package operations. If you exec into the container and rely on `curl` or `npm` for ad-hoc tasks, install them on demand with `apk add curl` or use the busybox `wget` / `pnpm` already present.
### Notable enhancements
- **GDPR / privacy controls.** A multi-PR series adds the building blocks operators need to satisfy data-subject requests:
- Pad deletion controls (admin-driven and self-service).
- IP / privacy audit pass across the codebase.
- Author-token cookies are now `HttpOnly`, removing them from JavaScript reach.
- Configurable privacy banner shown on first visit.
- Author erasure: an authenticated path for purging an individual author's identity and contributions.
- **Self-update subsystem (Tier 1: notify).**
- Periodic check against the GitHub Releases API for the configured repo (default `ether/etherpad`). Configurable via the new `updates.*` settings block, default tier `"notify"`. Set `updates.tier` to `"off"` to disable entirely.
- The admin UI shows a banner and a dedicated "Etherpad updates" page with the current version, latest version, install method, and changelog.
- Pad users see a discreet footer badge **only** when the running version is severely outdated (one or more major versions behind) or flagged as vulnerable in a recent release manifest. The public endpoint that drives this never leaks the version string itself.
- New top-level `adminEmail` setting. When set, the updater emails the admin on first detection of severe / vulnerable status, with escalating cadence (weekly while vulnerable, monthly while severely outdated). PR 1 ships the dedupe + cadence logic; real SMTP wiring lands in a follow-up PR.
- Tier 1 ships in this release. Tiers 2 (manual click), 3 (auto with grace window) and 4 (autonomous in maintenance window) are designed and will land in subsequent releases.
- See `doc/admin/updates.md` for full configuration.
- **Pad compaction.** New `compactPad` HTTP API plus `bin/compactPad` and `bin/compactAllPads` CLIs to reclaim database space on long-lived pads with heavy edit history (issue #6194). `--keep N` retains the last N revisions; `--dry-run` previews per-pad rev counts before writing. Per-pad failures don't stop the bulk run.
- **New packaging targets.**
- Etherpad is now published as a **Snap** package.
- **Debian (.deb)** packages are built via nfpm with a systemd unit, and a signed apt repository is published to `etherpad.org/apt`.
- **Editor enhancements.**
- IDE-style line operations: keyboard shortcuts to duplicate or delete the current line.
- New `showMenuRight` URL parameter to hide the right-side toolbar — useful for embeds that need slimmer chrome.
- Click a user in the userlist to open chat with `@<name>` prefilled, making mentions discoverable.
- New `padOptions.fadeInactiveAuthorColors` setting plus a toolbar UI to fade the background colors of authors who have left the pad.
- **Color contrast.** Author colors now pick the WCAG-higher-contrast text color for readability.
- **Social / mobile metadata.** Pad, timeslider, and home views now emit Open Graph and Twitter Card tags (closes #7599) and a `theme-color` meta that matches the toolbar on mobile.
- **Plugin admin UX.** The `/admin` plugin browser surfaces each plugin's `ep.json` `disables` declarations, so operators can see what a plugin will turn off before installing.
### Notable fixes
- **Socket.io: don't kick authenticated duplicate-author sessions.** A regression where two tabs from the same authenticated author could evict each other has been fixed (#7656 / #7678).
- **Anchor scrolling.** Anchor-link navigation now waits for layout to settle, so jumping to a deep link no longer overshoots.
- **Plugin updater.** `bin/updatePlugins.sh` actually updates installed plugins again (closes #6670).
- **Settings: stable per-release version string.** `randomVersionString` is now derived from the release identity rather than regenerated on each boot, so caches behave correctly across restarts of the same version.
### Internal / contributor-facing
- The HTTP client in the backend has been migrated from `axios` to the built-in `fetch` API, dropping a dependency now that Node 22 ships a stable fetch.
- `admin/` and `ui/` workspaces moved from `rolldown-vite` to upstream **Vite 8**.
- Build and CI moved to **pnpm 11** (`packageManager: "pnpm@11.0.6"`); the `Dockerfile`, snap, and all GitHub workflows are aligned. pnpm overrides have been migrated from `package.json` to `pnpm-workspace.yaml` to match pnpm 11's expectations.
- All client modules have been converted to ESM.
- The CI matrix tests Node 22, 24, and 25; on PRs the matrix is reduced to a single Node version to keep feedback fast.
- Frontend Playwright tests now run against the `/ether` plugin set, with feature-tag based skips so plugin-incompatible specs are excluded automatically.
- Build hardening: signed apt repo publishing, frozen lockfile installs across CI, Node setup pinned in every workflow, and a Docker-image CVE sweep that bumps `npm`, `pnpm`, and `uuid`.
### Localisation
- Multiple updates from translatewiki.net.
# 2.7.2
### Notable enhancements and fixes

View file

@ -7,17 +7,24 @@
# docker build --build-arg BUILD_ENV=copy .
ARG BUILD_ENV=git
ARG PnpmVersion=10.28.2
ARG PnpmVersion=11.0.6
FROM node:lts-alpine AS adminbuild
RUN npm install -g pnpm@${PnpmVersion}
FROM node:22-alpine AS adminbuild
# Use corepack to provision pnpm and drop the bundled npm — its older
# transitives (picomatch, brace-expansion) carry CVEs we don't otherwise
# need. Refresh corepack first: the version bundled with Node 22 ships a
# stale signing-key list and rejects newer pnpm releases
# (nodejs/corepack#612). Mirrors the workaround in snap/snapcraft.yaml.
RUN npm install -g corepack@latest && \
corepack enable && corepack prepare pnpm@${PnpmVersion} --activate && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
WORKDIR /opt/etherpad-lite
COPY . .
RUN pnpm install
RUN pnpm run build:ui
FROM node:lts-alpine AS build
FROM node:22-alpine AS build
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
# Set these arguments when building the image from behind a proxy
@ -96,11 +103,12 @@ RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}"
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199
RUN \
mkdir -p /usr/share/man/man1 && \
npm install pnpm@${PnpmVersion} -g && \
npm install -g corepack@latest && \
corepack enable && corepack prepare pnpm@${PnpmVersion} --activate && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
apk update && apk upgrade && \
apk add --no-cache \
ca-certificates \
curl \
git \
${INSTALL_SOFFICE:+libreoffice openjdk8-jre libreoffice-common} && \
rm -rf /var/cache/apk/*
@ -165,7 +173,10 @@ ENV ETHERPAD_PRODUCTION=true
# The full pnpm-workspace.yaml references admin, doc, ui which are not
# needed at runtime. Overwrite it with a production-only version so
# pnpm install doesn't warn about missing workspace directories.
RUN printf 'packages:\n - src\n - bin\n' > pnpm-workspace.yaml
# Preserve the build-script policy from the source workspace file so
# pnpm 11 doesn't error out with ERR_PNPM_IGNORED_BUILDS for transitive
# postinstalls (e.g. @scarf/scarf via swagger-ui-dist).
RUN printf 'packages:\n - src\n - bin\nonlyBuiltDependencies:\n - esbuild\nignoredBuiltDependencies:\n - "@scarf/scarf"\nstrictDepBuilds: false\n' > pnpm-workspace.yaml
COPY --chown=etherpad:etherpad ./src ./src
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/templates/admin ./src/templates/admin
@ -191,7 +202,7 @@ COPY --chown=etherpad:etherpad ${SETTINGS} "${EP_DIR}"/settings.json
USER etherpad
HEALTHCHECK --interval=5s --timeout=3s \
CMD curl --silent http://localhost:9001/health | grep -E "pass|ok|up" > /dev/null || exit 1
CMD wget -qO- http://127.0.0.1:9001/health | grep -E "pass|ok|up" > /dev/null || exit 1
EXPOSE 9001
CMD ["pnpm", "run", "prod"]

View file

@ -66,7 +66,7 @@ If your organisation runs Etherpad and would be willing to be listed publicly, p
### Quick install (one-liner)
The fastest way to get Etherpad running. Requires `git` and Node.js >= 20.
The fastest way to get Etherpad running. Requires `git` and Node.js >= 22.
**macOS / Linux / WSL:**
@ -160,7 +160,7 @@ volumes:
### Requirements
[Node.js](https://nodejs.org/) >= 20.
[Node.js](https://nodejs.org/) >= 22.12.
### Windows, macOS, Linux

View file

@ -1,7 +1,7 @@
{
"name": "admin",
"private": true,
"version": "2.7.2",
"version": "2.7.3",
"type": "module",
"scripts": {
"dev": "vite",
@ -18,28 +18,25 @@
"@radix-ui/react-toast": "^1.2.15",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.59.2",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"eslint": "^10.2.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"i18next": "^26.0.7",
"i18next": "^26.0.9",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.11.0",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.73.1",
"react-i18next": "^17.0.4",
"react-router-dom": "^7.14.2",
"react-hook-form": "^7.75.0",
"react-i18next": "^17.0.6",
"react-router-dom": "^7.15.0",
"socket.io-client": "^4.8.3",
"typescript": "^6.0.3",
"vite": "npm:rolldown-vite@7.2.10",
"vite": "^8.0.10",
"vite-plugin-babel": "^1.6.0",
"zustand": "^5.0.12"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.10"
"zustand": "^5.0.13"
}
}

View file

@ -6,7 +6,8 @@ import {NavLink, Outlet, useNavigate} from "react-router-dom";
import {useStore} from "./store/store.ts";
import {LoadingScreen} from "./utils/LoadingScreen.tsx";
import {Trans, useTranslation} from "react-i18next";
import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu} from "lucide-react";
import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell} from "lucide-react";
import {UpdateBanner} from "./components/UpdateBanner";
const WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : ''
export const App = () => {
@ -105,6 +106,7 @@ export const App = () => {
<li><NavLink to={"/pads"}><NotepadText/><Trans
i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></NavLink></li>
<li><NavLink to={"/shout"}><PhoneCall/>Communication</NavLink></li>
<li><NavLink to={"/update"}><Bell/><Trans i18nKey="update.page.title"/></NavLink></li>
</ul>
</div>
</div>
@ -112,6 +114,7 @@ export const App = () => {
setSidebarOpen(!sidebarOpen)
}}><LucideMenu/></button>
<div className="innerwrapper">
<UpdateBanner/>
<Outlet/>
</div>
</div>

View file

@ -0,0 +1,35 @@
import {useEffect} from 'react';
import {Link} from 'react-router-dom';
import {Trans, useTranslation} from 'react-i18next';
import {useStore} from '../store/store';
export const UpdateBanner = () => {
const {t} = useTranslation();
const updateStatus = useStore((s) => s.updateStatus);
const setUpdateStatus = useStore((s) => s.setUpdateStatus);
useEffect(() => {
let cancelled = false;
fetch('/admin/update/status', {credentials: 'same-origin'})
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data && !cancelled) setUpdateStatus(data); })
.catch(() => {});
return () => { cancelled = true; };
}, [setUpdateStatus]);
if (!updateStatus || !updateStatus.latest) return null;
if (updateStatus.currentVersion === updateStatus.latest.version) return null;
return (
<div className="update-banner" role="status">
<strong><Trans i18nKey="update.banner.title"/></strong>{' '}
<span>
<Trans
i18nKey="update.banner.body"
values={{latest: updateStatus.latest.version, current: updateStatus.currentVersion}}
/>
</span>{' '}
<Link to="/update">{t('update.banner.cta')}</Link>
</div>
);
};

View file

@ -895,3 +895,30 @@ input, button, select, optgroup, textarea {
.manage-pads-header {
display: flex;
}
/* Update banner — shown on every admin page when a new version is available. */
.update-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
margin: 0 0 12px 0;
background: #fff3cd;
color: #664d03;
border: 1px solid #ffe69c;
border-radius: 4px;
font-size: 14px;
}
.update-banner a {
color: inherit;
text-decoration: underline;
font-weight: 500;
}
/* Update page layout. */
.update-page { padding: 16px 0; }
.update-page h1 { margin-bottom: 16px; }
.update-page dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0 0 24px; }
.update-page dt { font-weight: 600; color: #555; }
.update-page dd { margin: 0; }
.update-page pre { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 4px; padding: 12px; font-size: 13px; max-height: 400px; overflow: auto; }

View file

@ -13,6 +13,7 @@ import i18n from "./localization/i18n.ts";
import {PadPage} from "./pages/PadPage.tsx";
import {ToastDialog} from "./utils/Toast.tsx";
import {ShoutPage} from "./pages/ShoutPage.tsx";
import {UpdatePage} from "./pages/UpdatePage.tsx";
const router = createBrowserRouter(createRoutesFromElements(
<><Route element={<App/>}>
@ -22,6 +23,7 @@ const router = createBrowserRouter(createRoutesFromElements(
<Route path="/help" element={<HelpPage/>}/>
<Route path="/pads" element={<PadPage/>}/>
<Route path="/shout" element={<ShoutPage/>}/>
<Route path="/update" element={<UpdatePage/>}/>
</Route><Route path="/login">
<Route index element={<LoginScreen/>}/>
</Route></>

View file

@ -229,7 +229,31 @@ export const HomePage = () => {
filteredInstallablePlugins.map((plugin) => {
return <tr key={plugin.name}>
<td><a rel="noopener noreferrer" href={`https://npmjs.com/${plugin.name}`} target="_blank">{plugin.name}</a></td>
<td>{plugin.description}</td>
<td>
{plugin.description}
{plugin.disables && plugin.disables.length > 0 && (
<div
className="plugin-disables"
role="alert"
title={t('admin_plugins.disables.warning_title')}
style={{
marginTop: '0.25rem',
padding: '0.2rem 0.5rem',
borderRadius: '4px',
fontSize: '0.85em',
background: 'rgba(180, 83, 9, 0.15)',
border: '1px solid rgba(180, 83, 9, 0.4)',
color: '#92400e',
display: 'inline-block',
}}
>
<strong><Trans i18nKey="admin_plugins.disables.label"/></strong>{' '}
{plugin.disables
.map((tag) => tag.replace(/^@feature:/, ''))
.join(', ')}
</div>
)}
</td>
<td>{plugin.version}</td>
<td>{plugin.time}</td>
<td>

View file

@ -4,6 +4,12 @@ export type PluginDef = {
version: string,
time: string,
official: boolean,
/**
* `@feature:*` Playwright tags for core specs the plugin intentionally
* disables. See doc/PLUGIN_FEATURE_DISABLES.md. May be undefined for
* plugins without a disables list, which is the common case.
*/
disables?: string[],
}

View file

@ -0,0 +1,103 @@
import {useEffect, useState} from 'react';
import {Trans, useTranslation} from 'react-i18next';
import {useStore} from '../store/store';
type FetchState =
| {kind: 'loading'}
| {kind: 'disabled'}
| {kind: 'unauthorized'}
| {kind: 'error', status: number}
| {kind: 'ok'};
export const UpdatePage = () => {
const {t} = useTranslation();
const us = useStore((s) => s.updateStatus);
const setUpdateStatus = useStore((s) => s.setUpdateStatus);
// Self-fetch so the page renders an explicit state even if UpdateBanner's
// best-effort fetch never landed (route returns 404 when tier=off, 401/403
// if requireAdminForStatus is set, or a transient network error).
const [fetchState, setFetchState] = useState<FetchState>(us ? {kind: 'ok'} : {kind: 'loading'});
useEffect(() => {
let cancelled = false;
fetch('/admin/update/status', {credentials: 'same-origin'})
.then(async (r) => {
if (cancelled) return;
if (r.ok) {
const data = await r.json();
setUpdateStatus(data);
setFetchState({kind: 'ok'});
} else if (r.status === 404) {
setFetchState({kind: 'disabled'});
} else if (r.status === 401 || r.status === 403) {
setFetchState({kind: 'unauthorized'});
} else {
setFetchState({kind: 'error', status: r.status});
}
})
.catch(() => {
if (!cancelled) setFetchState({kind: 'error', status: 0});
});
return () => { cancelled = true; };
}, [setUpdateStatus]);
if (fetchState.kind === 'loading') {
return <div>{t('admin.loading', {defaultValue: 'Loading...'})}</div>;
}
if (fetchState.kind === 'disabled') {
return (
<div className="update-page">
<h1><Trans i18nKey="update.page.title"/></h1>
<p>{t('update.page.disabled', {defaultValue: 'Update checks are disabled (updates.tier = "off").'})}</p>
</div>
);
}
if (fetchState.kind === 'unauthorized') {
return (
<div className="update-page">
<h1><Trans i18nKey="update.page.title"/></h1>
<p>{t('update.page.unauthorized', {defaultValue: 'You are not authorised to view update status.'})}</p>
</div>
);
}
if (fetchState.kind === 'error' || !us) {
const status = fetchState.kind === 'error' ? fetchState.status : 0;
return (
<div className="update-page">
<h1><Trans i18nKey="update.page.title"/></h1>
<p>{t('update.page.error', {defaultValue: 'Could not load update status (status {{status}}).', status})}</p>
</div>
);
}
const upToDate = !us.latest || us.currentVersion === us.latest.version;
return (
<div className="update-page">
<h1><Trans i18nKey="update.page.title"/></h1>
<dl>
<dt><Trans i18nKey="update.page.current"/></dt>
<dd>{us.currentVersion}</dd>
<dt><Trans i18nKey="update.page.latest"/></dt>
<dd>{us.latest ? us.latest.version : '—'}</dd>
<dt><Trans i18nKey="update.page.last_check"/></dt>
<dd>{us.lastCheckAt ?? '—'}</dd>
<dt><Trans i18nKey="update.page.install_method"/></dt>
<dd>{us.installMethod}</dd>
<dt><Trans i18nKey="update.page.tier"/></dt>
<dd>{us.tier}</dd>
</dl>
{upToDate ? (
<p><Trans i18nKey="update.page.up_to_date"/></p>
) : us.latest ? (
<>
<h2><Trans i18nKey="update.page.changelog"/></h2>
<pre style={{whiteSpace: 'pre-wrap'}}>{us.latest.body}</pre>
<p><a href={us.latest.htmlUrl} rel="noreferrer noopener" target="_blank">{us.latest.htmlUrl}</a></p>
</>
) : null}
</div>
);
};
export default UpdatePage;

View file

@ -3,6 +3,23 @@ import {Socket} from "socket.io-client";
import {PadSearchResult} from "../utils/PadSearch.ts";
import {InstalledPlugin} from "../pages/Plugin.ts";
export interface UpdateStatusPayload {
currentVersion: string;
latest: null | {
version: string;
tag: string;
body: string;
publishedAt: string;
prerelease: boolean;
htmlUrl: string;
};
lastCheckAt: string | null;
installMethod: string;
tier: string;
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
vulnerableBelow: Array<{announcedBy: string; threshold: string}>;
}
type ToastState = {
description?:string,
title: string,
@ -25,7 +42,9 @@ type StoreState = {
pads: PadSearchResult|undefined,
setPads: (pads: PadSearchResult)=>void,
installedPlugins: InstalledPlugin[],
setInstalledPlugins: (plugins: InstalledPlugin[])=>void
setInstalledPlugins: (plugins: InstalledPlugin[])=>void,
updateStatus: UpdateStatusPayload | null,
setUpdateStatus: (s: UpdateStatusPayload) => void,
}
@ -48,5 +67,7 @@ export const useStore = create<StoreState>()((set) => ({
pads: undefined,
setPads: (pads)=>set({pads}),
installedPlugins: [],
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins})
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins}),
updateStatus: null,
setUpdateStatus: (s) => set({updateStatus: s}),
}));

View file

@ -67,7 +67,7 @@ try cp settings.json.template settings.json
#try mv node_modules_resolved node_modules
log "download windows node..."
try wget "https://nodejs.org/dist/latest-v20.x/win-x64/node.exe" -O node.exe
try wget "https://nodejs.org/dist/latest-v22.x/win-x64/node.exe" -O node.exe
log "create the zip..."
try zip -9 -r "${OUTPUT}" ./*

View file

@ -3,6 +3,26 @@ import {writeFileSync} from "fs";
import {installedPluginsPath} from "ep_etherpad-lite/static/js/pluginfw/installer";
const pluginsModule = require('ep_etherpad-lite/static/js/pluginfw/plugins');
// Pure helper used by `pnpm run plugins update` to whittle the contents of
// var/installed_plugins.json down to names safe to re-install. Mirrors the
// gate inside pluginfw/plugins.getPackages: only entries that start with the
// plugin prefix (ep_) are real Etherpad plugins; ep_etherpad-lite is the
// vendored core, never installed via the plugin path. De-duplicates so a
// corrupted manifest with repeated entries triggers one install per name.
// Exported and kept side-effect-free so backend tests can exercise it.
export const filterUpdatablePluginNames = (
entries: ReadonlyArray<{name?: unknown} | null | undefined>,
prefix: string = pluginsModule.prefix as string,
): string[] => {
const names = entries
.map((e) => (e == null ? undefined : e.name))
.filter(
(n): n is string =>
typeof n === 'string' && n.startsWith(prefix) && n !== 'ep_etherpad-lite',
);
return Array.from(new Set(names));
};
export const persistInstalledPlugins = async () => {
const plugins:PackageData[] = []
const installedPlugins = {plugins: plugins};

236
bin/compactAllPads.ts Normal file
View file

@ -0,0 +1,236 @@
'use strict';
/*
* Compact every pad on the instance to reclaim database space.
*
* Usage:
* node bin/compactAllPads.js # collapse all history on every pad
* node bin/compactAllPads.js --keep N # keep last N revisions per pad
* node bin/compactAllPads.js --dry-run # list pads + rev counts, no writes
*
* Composes the existing `listAllPads` and `compactPad` HTTP APIs there is
* deliberately no instance-wide HTTP endpoint, because doing this over a
* single request would mean one giant response and a long-held connection.
* Per-pad failures don't stop the run; they're logged and counted, and the
* exit code reflects whether anything failed.
*
* Destructive `getEtherpad`-export anything you can't afford to lose
* before running.
*
* Issue #6194: per-instance bulk compaction. The per-pad `bin/compactPad`
* is the right tool when you know which pad is fat; this is the right tool
* when you want to reclaim space across the whole instance.
*/
import path from 'node:path';
import fs from 'node:fs';
import process from 'node:process';
export type CompactAllOpts = {
keepRevisions: number | null;
dryRun: boolean;
};
// Minimal interface mirroring the API endpoints the script needs. Tests
// substitute their own implementation that goes through supertest+JWT
// instead of fetch+APIKEY, so the loop logic is exercised against a real
// running server without dragging in apikey-file or fetch setup.
export type CompactAllApi = {
listAllPads(): Promise<string[]>;
getRevisionsCount(padId: string): Promise<number>;
compactPad(padId: string, keepRevisions: number | null): Promise<void>;
};
export type CompactAllReport = {
total: number;
ok: number;
failed: number;
totalRevsBefore: number;
totalRevsAfter: number;
};
export type CompactAllLogger = {
info(msg: string): void;
error(msg: string): void;
};
const defaultLogger: CompactAllLogger = {
info: (m) => console.log(m),
error: (m) => console.error(m),
};
// Pure-ish core: composition + per-pad error tolerance + dry-run + tally.
// Returns a structured report so tests can assert on outcomes; the CLI
// shell maps it to an exit code.
export const runCompactAll = async (
api: CompactAllApi, opts: CompactAllOpts,
logger: CompactAllLogger = defaultLogger,
): Promise<CompactAllReport> => {
let padIds: string[];
try {
padIds = await api.listAllPads();
} catch (e: any) {
logger.error(`listAllPads failed: ${e.message ?? e}`);
return {total: 0, ok: 0, failed: 1, totalRevsBefore: 0, totalRevsAfter: 0};
}
if (padIds.length === 0) {
logger.info('No pads on this instance.');
return {total: 0, ok: 0, failed: 0, totalRevsBefore: 0, totalRevsAfter: 0};
}
const strategy = opts.keepRevisions == null
? 'collapse all history'
: `keep last ${opts.keepRevisions} revisions`;
logger.info(`Found ${padIds.length} pad(s). Strategy: ${strategy}` +
`${opts.dryRun ? ' (dry run — no writes)' : ''}.`);
const report: CompactAllReport = {
total: padIds.length, ok: 0, failed: 0,
totalRevsBefore: 0, totalRevsAfter: 0,
};
for (let i = 0; i < padIds.length; i++) {
const padId = padIds[i];
const idx = `[${i + 1}/${padIds.length}]`;
let before: number;
try {
before = await api.getRevisionsCount(padId);
} catch (e: any) {
logger.error(`${idx} ${padId}: getRevisionsCount failed: ${e.message ?? e}`);
report.failed++;
continue;
}
if (opts.dryRun) {
logger.info(`${idx} ${padId}: ${before + 1} revision(s) — would compact`);
report.totalRevsBefore += before + 1;
continue;
}
try {
await api.compactPad(padId, opts.keepRevisions);
} catch (e: any) {
logger.error(`${idx} ${padId}: compactPad failed: ${e.message ?? e}`);
report.failed++;
continue;
}
let after: number | undefined;
try { after = await api.getRevisionsCount(padId); }
catch { /* main op already succeeded; post-count is informational */ }
if (after != null) {
logger.info(`${idx} ${padId}: ${before + 1}${after + 1} revision(s)`);
report.totalRevsBefore += before + 1;
report.totalRevsAfter += after + 1;
} else {
logger.info(`${idx} ${padId}: compacted (post-count unavailable)`);
}
report.ok++;
}
if (opts.dryRun) {
logger.info('');
logger.info(`Dry run complete. ${padIds.length} pad(s), ` +
`${report.totalRevsBefore} total revision(s) — re-run ` +
'without --dry-run to compact.');
} else {
logger.info('');
logger.info(`Done. ${report.ok} pad(s) compacted, ${report.failed} failed. ` +
`Revisions: ${report.totalRevsBefore}${report.totalRevsAfter} ` +
`(reclaimed ${report.totalRevsBefore - report.totalRevsAfter}).`);
}
return report;
};
export const parseArgs = (argv: string[]): CompactAllOpts | null => {
const opts: CompactAllOpts = {keepRevisions: null, dryRun: false};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--dry-run') {
opts.dryRun = true;
} else if (a === '--keep') {
const v = argv[++i];
const n = Number(v);
if (!Number.isInteger(n) || n < 0) {
console.error(`--keep expects a non-negative integer; got ${v}`);
return null;
}
opts.keepRevisions = n;
} else {
return null;
}
}
return opts;
};
// CLI entry point. Skipped when this file is imported (e.g. by tests),
// so the test harness can use `runCompactAll` directly without network.
const usage = () => {
console.error('Usage:');
console.error(' node bin/compactAllPads.js');
console.error(' node bin/compactAllPads.js --keep <N>');
console.error(' node bin/compactAllPads.js --dry-run');
process.exit(2);
};
const isMain = require.main === module;
if (isMain) {
process.on('unhandledRejection', (err) => { throw err; });
const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings();
const baseURL = `${settings.ssl ? 'https' : 'http'}://${settings.ip}:${settings.port}`;
const apiGet = async (p: string): Promise<any> => {
const r = await fetch(baseURL + p);
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
return r.json();
};
const apiPost = async (p: string): Promise<any> => {
const r = await fetch(baseURL + p, {method: 'POST'});
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
return r.json();
};
const opts = parseArgs(process.argv.slice(2));
if (!opts) usage();
const apikey = fs.readFileSync(
path.join(__dirname, '../APIKEY.txt'), {encoding: 'utf-8'}).trim();
// Bind the abstract API to fetch + APIKEY auth for the CLI shell.
const cliApi: CompactAllApi = {
async listAllPads() {
const apiInfo = await apiGet('/api/');
const apiVersion: string | undefined = apiInfo.currentVersion;
if (!apiVersion) throw new Error('No version set in API');
// Stash on this for subsequent calls. Avoids a per-call /api/ ping.
(cliApi as any)._apiVersion = apiVersion;
const r = await apiGet(`/api/${apiVersion}/listAllPads?apikey=${apikey}`);
if (r.code !== 0) throw new Error(JSON.stringify(r));
return r.data.padIDs ?? [];
},
async getRevisionsCount(padId: string) {
const v = (cliApi as any)._apiVersion;
const r = await apiGet(
`/api/${v}/getRevisionsCount?apikey=${apikey}` +
`&padID=${encodeURIComponent(padId)}`);
if (r.code !== 0) throw new Error(JSON.stringify(r));
return r.data.revisions;
},
async compactPad(padId: string, keepRevisions: number | null) {
const v = (cliApi as any)._apiVersion;
const params = new URLSearchParams({apikey, padID: padId});
if (keepRevisions != null) params.set('keepRevisions', String(keepRevisions));
const r = await apiPost(`/api/${v}/compactPad?${params.toString()}`);
if (r.code !== 0) throw new Error(JSON.stringify(r));
},
};
(async () => {
const report = await runCompactAll(cliApi, opts!);
if (report.failed > 0) process.exit(1);
})();
}

101
bin/compactPad.ts Normal file
View file

@ -0,0 +1,101 @@
'use strict';
/*
* Compact a pad's revision history to reclaim database space.
*
* Usage:
* node bin/compactPad.js <padID> # collapse all history
* node bin/compactPad.js <padID> --keep N # keep only the last N revisions
*
* Wraps the existing Cleanup helper (src/node/utils/Cleanup.ts) via the
* compactPad HTTP API so admins can trigger it from the CLI without
* routing through the admin settings UI. Destructive export the pad as
* `.etherpad` first for backup.
*
* Issue #6194: long-lived pads with heavy edit history accumulate hundreds
* of megabytes in the DB; this tool is the per-pad brick for reclaiming
* that space without rotating to a new pad ID.
*/
import path from 'node:path';
import fs from 'node:fs';
import process from 'node:process';
// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
process.on('unhandledRejection', (err) => { throw err; });
const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings();
const baseURL = `${settings.ssl ? 'https' : 'http'}://${settings.ip}:${settings.port}`;
const apiGet = async (p: string): Promise<any> => {
const r = await fetch(baseURL + p);
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
return r.json();
};
const apiPost = async (p: string): Promise<any> => {
const r = await fetch(baseURL + p, {method: 'POST'});
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
return r.json();
};
const usage = () => {
console.error('Usage:');
console.error(' node bin/compactPad.js <padID>');
console.error(' node bin/compactPad.js <padID> --keep <N>');
process.exit(2);
};
const args = process.argv.slice(2);
if (args.length < 1 || args.length > 3) usage();
const padId = args[0];
let keepRevisions: number | null = null;
if (args.length === 3) {
if (args[1] !== '--keep') usage();
keepRevisions = Number(args[2]);
if (!Number.isInteger(keepRevisions) || keepRevisions < 0) {
console.error(`--keep expects a non-negative integer; got ${args[2]}`);
process.exit(2);
}
}
// get the API Key
const filePath = path.join(__dirname, '../APIKEY.txt');
const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'}).trim();
(async () => {
const apiInfo = await apiGet('/api/');
const apiVersion: string | undefined = apiInfo.currentVersion;
if (!apiVersion) throw new Error('No version set in API');
// Pre-flight: show current revision count so operators can eyeball impact.
const countUri = `/api/${apiVersion}/getRevisionsCount?apikey=${apikey}&padID=${padId}`;
const countRes = await apiGet(countUri);
if (countRes.code !== 0) {
console.error(`getRevisionsCount failed: ${JSON.stringify(countRes)}`);
process.exit(1);
}
const before: number = countRes.data.revisions;
const strategy = keepRevisions == null ? 'collapse all' : `keep last ${keepRevisions}`;
console.log(`Pad ${padId}: ${before + 1} revision(s). Strategy: ${strategy}.`);
const params = new URLSearchParams({apikey, padID: padId});
if (keepRevisions != null) params.set('keepRevisions', String(keepRevisions));
const result = await apiPost(`/api/${apiVersion}/compactPad?${params.toString()}`);
if (result.code !== 0) {
console.error(`compactPad failed: ${JSON.stringify(result)}`);
process.exit(1);
}
// Post-flight: the pad is now compacted. Re-read the rev count so the
// operator sees concrete savings.
const afterRes = await apiGet(countUri);
const after: number | undefined = afterRes?.data?.revisions;
if (after != null) {
console.log(`Done. Pad ${padId}: ${after + 1} revision(s) remaining ` +
`(was ${before + 1}).`);
} else {
console.log('Done.');
}
})();

View file

@ -13,46 +13,54 @@ import path from "node:path";
import querystring from "node:querystring";
import axios from 'axios'
import process from "node:process";
process.on('unhandledRejection', (err) => { throw err; });
import settings from 'ep_etherpad-lite/node/utils/Settings';
(async () => {
axios.defaults.baseURL = `http://${settings.ip}:${settings.port}`;
const api = axios;
const baseURL = `http://${settings.ip}:${settings.port}`;
const apiGet = async (p: string): Promise<any> => {
const r = await fetch(baseURL + p);
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
return r.json();
};
const apiPost = async (p: string): Promise<any> => {
const r = await fetch(baseURL + p, {method: 'POST'});
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
return r.json();
};
const filePath = path.join(__dirname, '../APIKEY.txt');
const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'});
let res;
res = await api.get('/api/');
const apiVersion = res.data.currentVersion;
res = await apiGet('/api/');
const apiVersion = res.currentVersion;
if (!apiVersion) throw new Error('No version set in API');
console.log('apiVersion', apiVersion);
const uri = (cmd: string, args: querystring.ParsedUrlQueryInput ) => `/api/${apiVersion}/${cmd}?${querystring.stringify(args)}`;
res = await api.post(uri('createGroup', {apikey}));
if (res.data.code === 1) throw new Error(`Error creating group: ${res.data}`);
const groupID = res.data.data.groupID;
res = await apiPost(uri('createGroup', {apikey}));
if (res.code === 1) throw new Error(`Error creating group: ${res}`);
const groupID = res.data.groupID;
console.log('groupID', groupID);
res = await api.post(uri('createGroupPad', {apikey, groupID}));
if (res.data.code === 1) throw new Error(`Error creating group pad: ${res.data}`);
console.log('Test Pad ID ====> ', res.data.data.padID);
res = await apiPost(uri('createGroupPad', {apikey, groupID}));
if (res.code === 1) throw new Error(`Error creating group pad: ${res}`);
console.log('Test Pad ID ====> ', res.data.padID);
res = await api.post(uri('createAuthor', {apikey}));
if (res.data.code === 1) throw new Error(`Error creating author: ${res.data}`);
const authorID = res.data.data.authorID;
res = await apiPost(uri('createAuthor', {apikey}));
if (res.code === 1) throw new Error(`Error creating author: ${res}`);
const authorID = res.data.authorID;
console.log('authorID', authorID);
const validUntil = Math.floor(new Date().getTime() / 1000) + 60000;
console.log('validUntil', validUntil);
res = await api.post(uri('createSession', {apikey, groupID, authorID, validUntil}));
if (res.data.code === 1) throw new Error(`Error creating session: ${JSON.stringify(res.data)}`);
res = await apiPost(uri('createSession', {apikey, groupID, authorID, validUntil}));
if (res.code === 1) throw new Error(`Error creating session: ${JSON.stringify(res)}`);
console.log('Session made: ====> create a cookie named sessionID and set the value to',
res.data.data.sessionID);
res.data.sessionID);
process.exit(0)
})();

View file

@ -11,7 +11,6 @@ import fs from "node:fs";
import process from "node:process";
process.on('unhandledRejection', (err) => { throw err; });
import axios from 'axios'
// Set a delete counter which will increment on each delete attempt
// TODO: Check delete is successful before incrementing
let deleteCount = 0;
@ -23,29 +22,38 @@ const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSe
(async () => {
const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'});
axios.defaults.baseURL = `http://${settings.ip}:${settings.port}`;
const baseURL = `http://${settings.ip}:${settings.port}`;
const apiGet = async (p: string): Promise<any> => {
const r = await fetch(baseURL + p);
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
return r.json();
};
const apiPost = async (p: string): Promise<any> => {
const r = await fetch(baseURL + p, {method: 'POST'});
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
return r.json();
};
const apiVersionResponse = await axios.get('/api/');
const apiVersion = apiVersionResponse.data.currentVersion; // 1.12.5
const apiVersionResponse = await apiGet('/api/');
const apiVersion = apiVersionResponse.currentVersion; // 1.12.5
console.log('apiVersion', apiVersion);
const groupsResponse = await axios.get(`/api/${apiVersion}/listAllGroups?apikey=${apikey}`);
const groups = groupsResponse.data.data.groupIDs; // ['whateverGroupID']
const groupsResponse = await apiGet(`/api/${apiVersion}/listAllGroups?apikey=${apikey}`);
const groups = groupsResponse.data.groupIDs; // ['whateverGroupID']
for (const groupID of groups) {
const sessionURI = `/api/${apiVersion}/listSessionsOfGroup?apikey=${apikey}&groupID=${groupID}`;
const sessionsResponse = await axios.get(sessionURI);
const sessions = sessionsResponse.data.data;
const sessionsResponse = await apiGet(sessionURI);
const sessions = sessionsResponse.data;
if(sessions == null) continue;
for (const [sessionID, val] of Object.entries(sessions)) {
if(val == null) continue;
const deleteURI = `/api/${apiVersion}/deleteSession?apikey=${apikey}&sessionID=${sessionID}`;
await axios.post(deleteURI).then(c=>{
console.log(c.data)
deleteCount++;
}); // delete
const c = await apiPost(deleteURI);
console.log(c);
deleteCount++;
}
}
console.log(`Deleted ${deleteCount} sessions`);

View file

@ -11,13 +11,22 @@ import path from "node:path";
import fs from "node:fs";
import process from "node:process";
import axios from "axios";
process.on('unhandledRejection', (err) => { throw err; });
const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSettings();
axios.defaults.baseURL = `http://${settings.ip}:${settings.port}`;
const baseURL = `http://${settings.ip}:${settings.port}`;
const apiGet = async (p: string): Promise<any> => {
const r = await fetch(baseURL + p);
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
return r.json();
};
const apiPost = async (p: string): Promise<any> => {
const r = await fetch(baseURL + p, {method: 'POST'});
if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`);
return r.json();
};
if (process.argv.length !== 3) throw new Error('Use: node deletePad.js $PADID');
@ -29,14 +38,14 @@ const filePath = path.join(__dirname, '../APIKEY.txt');
const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'});
(async () => {
let apiVersion = await axios.get('/api/');
apiVersion = apiVersion.data.currentVersion;
const apiInfo = await apiGet('/api/');
const apiVersion = apiInfo.currentVersion;
if (!apiVersion) throw new Error('No version set in API');
// Now we know the latest API version, let's delete pad
const uri = `/api/${apiVersion}/deletePad?apikey=${apikey}&padID=${padId}`;
const deleteAttempt = await axios.post(uri);
if (deleteAttempt.data.code === 1) throw new Error(`Error deleting pad ${deleteAttempt.data}`);
console.log('Deleted pad', deleteAttempt.data);
const deleteAttempt = await apiPost(uri);
if (deleteAttempt.code === 1) throw new Error(`Error deleting pad ${deleteAttempt}`);
console.log('Deleted pad', deleteAttempt);
process.exit(0)
})();

View file

@ -1,6 +1,6 @@
# minimum required node version
REQUIRED_NODE_MAJOR=12
REQUIRED_NODE_MINOR=13
REQUIRED_NODE_MAJOR=22
REQUIRED_NODE_MINOR=0
# minimum required npm version
REQUIRED_NPM_MAJOR=5

View file

@ -38,7 +38,7 @@ function Test-Cmd([string]$name) {
$EtherpadDir = if ($env:ETHERPAD_DIR) { $env:ETHERPAD_DIR } else { 'etherpad-lite' }
$EtherpadBranch = if ($env:ETHERPAD_BRANCH) { $env:ETHERPAD_BRANCH } else { 'master' }
$EtherpadRepo = if ($env:ETHERPAD_REPO) { $env:ETHERPAD_REPO } else { 'https://github.com/ether/etherpad.git' }
$RequiredNodeMajor = 20
$RequiredNodeMajor = 22
Write-Step 'Etherpad installer'

View file

@ -34,7 +34,7 @@ is_cmd() { command -v "$1" >/dev/null 2>&1; }
ETHERPAD_DIR="${ETHERPAD_DIR:-etherpad-lite}"
ETHERPAD_BRANCH="${ETHERPAD_BRANCH:-master}"
ETHERPAD_REPO="${ETHERPAD_REPO:-https://github.com/ether/etherpad.git}"
REQUIRED_NODE_MAJOR=20
REQUIRED_NODE_MAJOR=22
step "Etherpad installer"

View file

@ -1,13 +1,12 @@
{
"name": "bin",
"version": "2.7.2",
"version": "2.7.3",
"description": "",
"main": "checkAllPads.js",
"directories": {
"doc": "doc"
},
"dependencies": {
"axios": "^1.15.2",
"ep_etherpad-lite": "workspace:../src",
"log4js": "^6.9.1",
"semver": "^7.7.4",
@ -23,6 +22,8 @@
"makeDocs": "node --import tsx make_docs.ts",
"checkPad": "node --import tsx checkPad.ts",
"checkAllPads": "node --import tsx checkAllPads.ts",
"compactPad": "node --import tsx compactPad.ts",
"compactAllPads": "node --import tsx compactAllPads.ts",
"createUserSession": "node --import tsx createUserSession.ts",
"deletePad": "node --import tsx deletePad.ts",
"repairPad": "node --import tsx repairPad.ts",

View file

@ -1,7 +1,7 @@
'use strict';
import {linkInstaller, checkForMigration} from "ep_etherpad-lite/static/js/pluginfw/installer";
import {persistInstalledPlugins} from "./commonPlugins";
import {persistInstalledPlugins, filterUpdatablePluginNames} from "./commonPlugins";
import fs from "node:fs";
const settings = require('ep_etherpad-lite/node/utils/Settings');
@ -19,7 +19,9 @@ const possibleActions = [
"rm",
"remove",
"ls",
"list"
"list",
"up",
"update"
]
const install = ()=> {
@ -76,6 +78,40 @@ const list = ()=>{
})();
}
// Re-install every plugin in installed_plugins.json without a version pin so
// the registry-latest gets resolved and overwrites the existing pinned copy
// in src/plugin_packages/. ep_etherpad-lite is the vendored core, never
// installed via the plugin path. filterUpdatablePluginNames also enforces
// the ep_ prefix so a corrupted manifest cannot coerce us into installing
// arbitrary npm packages, and de-duplicates repeats.
const update = ()=> {
(async () => {
const path = settings.root+"/var/installed_plugins.json";
let entries: Array<{name?: unknown}>;
try {
const parsed = JSON.parse(fs.readFileSync(path, "utf-8"));
entries = Array.isArray(parsed?.plugins) ? parsed.plugins : [];
} catch (err: any) {
if (err.code === 'ENOENT') {
console.log("No installed_plugins.json found — nothing to update");
return;
}
throw err;
}
const names = filterUpdatablePluginNames(entries);
if (names.length === 0) {
console.log("No plugins installed — nothing to update");
return;
}
console.log(`Updating plugins to latest from registry: ${names.join(', ')}`);
await checkForMigration();
for (const name of names) {
await linkInstaller.installPlugin(name);
}
await persistInstalledPlugins();
})();
}
const remove = (plugins: string[])=>{
const walk = async () => {
for (const plugin of plugins) {
@ -112,6 +148,12 @@ switch (action) {
case "remove":
remove(args.slice(1));
break;
case "up":
update();
break;
case "update":
update();
break;
default:
console.error('Expected at least one argument!');
process.exit(1);

View file

@ -21,9 +21,9 @@ jobs:
- uses: actions/setup-node@v6
with:
# OIDC trusted publishing needs npm >= 11.5.1, which requires
# Node >= 20.17.0. setup-node's `20` resolves to the latest
# 20.x, which satisfies that.
node-version: 20
# Node >= 22.9.0. setup-node's `22` resolves to the latest
# 22.x, which satisfies that.
node-version: 22
registry-url: https://registry.npmjs.org/
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
run: npm install -g npm@latest

View file

@ -2,22 +2,20 @@
// Returns a list of stale plugins and their authors email
import axios from 'axios'
import process from "node:process";
const currentTime = new Date();
(async () => {
const res = await axios.get<string>('https://static.etherpad.org/plugins.full.json');
for (const plugin of Object.keys(res.data)) {
// @ts-ignore
const name = res.data[plugin].data.name;
// @ts-ignore
const date = new Date(res.data[plugin].time);
const resp = await fetch('https://static.etherpad.org/plugins.full.json');
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
const data: any = await resp.json();
for (const plugin of Object.keys(data)) {
const name = data[plugin].data.name;
const date = new Date(data[plugin].time);
const diffTime = Math.abs(currentTime.getTime() - date.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays > (365 * 2)) {
// @ts-ignore
console.log(`${name}, ${res.data[plugin].data.maintainers[0].email}`);
console.log(`${name}, ${data[plugin].data.maintainers[0].email}`);
}
}
process.exit(0)

View file

@ -204,7 +204,14 @@ try {
run('git pull --ff-only', {cwd: '../ether.github.com/'});
console.log('Committing documentation...');
run(`cp -R out/doc/ ../ether.github.com/public/doc/v'${newVersion}'`);
run(`pnpm version ${newVersion}`, {cwd: '../ether.github.com'});
// pnpm 11 refuses `pnpm version` on a dirty tree (the doc copy above
// dirties it) even with --no-git-tag-version, so write the bump with jq —
// same pattern used for the etherpad package.json files at the top of
// this script. The git add+commit below picks up both the bump and the
// freshly-copied docs in a single commit.
run(
`echo "$(jq '. += {"version": "'${newVersion}'"}' package.json)" > package.json`,
{cwd: '../ether.github.com'});
run('git add .', {cwd: '../ether.github.com/'});
run(`git commit -m '${newVersion} docs'`, {cwd: '../ether.github.com/'});
} catch (err:any) {

View file

@ -0,0 +1,153 @@
#!/usr/bin/env bash
#
# Run the frontend test suite with awareness of a plugin's declared
# `disables` list (see doc/PLUGIN_FEATURE_DISABLES.md).
#
# A plugin that intentionally removes a baseline Etherpad feature MUST
# declare which feature tags it disables in its ep.json:
#
# { "name": "ep_disable_chat", "disables": ["@feature:chat"], ... }
#
# This script enforces that contract with two passes:
#
# 1. Regression pass — every test NOT tagged with a disabled feature
# must pass. Catches the case where the plugin breaks something
# unrelated to the feature it claims to disable.
#
# 2. Honesty pass — every test that IS tagged with a disabled feature
# must FAIL. If those tests pass, the plugin's `disables` claim is
# wrong; the feature it says it disables actually still works.
# Catches the case where a plugin opts out of tests it has no
# right to skip.
#
# Both passes have to pass for CI to be green. A plugin can't quietly
# disable functionality without declaring it (pass 1 catches that), and
# can't quietly opt out of test coverage by declaring features it
# doesn't actually disable (pass 2 catches that).
#
# Usage:
# bin/run-frontend-tests-with-disables.sh \
# [--plugin-ep-json PATH] [-- <playwright args...>]
#
# Resolution order for the disables list:
# 1. EP_PLUGIN_DISABLES env var (comma- or space-separated)
# 2. --plugin-ep-json PATH (reads `.disables` JSON array)
# 3. Auto-detect: if exactly one ep_*/ep.json under plugin_packages/
# declares disables, use it. Multiple disabling plugins → error.
#
# Run from src/ (where playwright.config.ts and node_modules live).
set -euo pipefail
EP_JSON=""
PLAYWRIGHT_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--plugin-ep-json)
EP_JSON="$2"; shift 2;;
--) shift; PLAYWRIGHT_ARGS+=("$@"); break;;
*) PLAYWRIGHT_ARGS+=("$1"); shift;;
esac
done
read_disables_from_json() {
# Echo space-separated list of @feature:* tags from a JSON file's
# top-level `disables` array. Empty if the file or field is missing.
local file="$1"
[[ -f "$file" ]] || return 0
node -e "
try {
const j = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8'));
const d = Array.isArray(j.disables) ? j.disables : [];
process.stdout.write(d.join(' '));
} catch (_) {}
" "$file"
}
DISABLES=""
if [[ -n "${EP_PLUGIN_DISABLES:-}" ]]; then
DISABLES="$(echo "$EP_PLUGIN_DISABLES" | tr ',' ' ')"
elif [[ -n "$EP_JSON" ]]; then
DISABLES="$(read_disables_from_json "$EP_JSON")"
else
# Auto-detect from plugin_packages/. Skip if 0 or >1 disabling plugins.
#
# Live-plugin-manager installs plugins under plugin_packages/.versions/
# and exposes them as symlinks at plugin_packages/ep_<name>. find(1)
# doesn't follow symlinks by default, so iterate via shell glob and
# `-f $dir/ep.json` (which DOES resolve symlinks) instead.
declare -a CANDIDATES=()
if [[ -d plugin_packages ]]; then
shopt -s nullglob
for dir in plugin_packages/ep_*; do
[[ -L "$dir" || -d "$dir" ]] || continue
f="$dir/ep.json"
[[ -f "$f" ]] || continue
d="$(read_disables_from_json "$f")"
[[ -n "$d" ]] && CANDIDATES+=("$d")
done
shopt -u nullglob
fi
if [[ ${#CANDIDATES[@]} -eq 1 ]]; then
DISABLES="${CANDIDATES[0]}"
elif [[ ${#CANDIDATES[@]} -gt 1 ]]; then
echo "ERROR: multiple plugins declare disables, pass --plugin-ep-json explicitly:" >&2
printf ' %s\n' "${CANDIDATES[@]}" >&2
exit 2
fi
fi
DISABLES="$(echo "$DISABLES" | xargs)" # trim
if [[ -z "$DISABLES" ]]; then
echo "No 'disables' declared — running standard test suite."
exec pnpm exec playwright test "${PLAYWRIGHT_ARGS[@]}"
fi
# Build the regex Playwright wants for --grep / --grep-invert.
# Tags are matched as substrings of the test title; @feature:chat is
# distinct enough that we don't need to anchor.
GREP_PATTERN="$(echo "$DISABLES" | tr ' ' '|')"
echo "Plugin disables: $DISABLES"
echo
echo "=== Pass 1: regression — tests NOT tagged with disabled features must pass ==="
pnpm exec playwright test --grep-invert "($GREP_PATTERN)" "${PLAYWRIGHT_ARGS[@]}"
echo
echo "=== Pass 2: honesty — at least one test tagged with $DISABLES must FAIL (feature is disabled) ==="
# Pass 2 only needs evidence that the disabled feature is genuinely
# absent — *one* failing tagged test is sufficient proof. Without
# --max-failures, every tagged test runs to completion (each failing
# at the per-test timeout, e.g. 90s) which can take 10+ minutes for
# a busy tag like @feature:chat. --max-failures=1 stops on the first
# failure (~30s) and --timeout=30000 caps any single test at 30s, so
# the worst case stays bounded even if the first tagged test
# unexpectedly passes and we have to wait for the next one to fail.
# --retries=0 matters too: default CI retries (up to 5 with
# WITH_PLUGINS=1) would retry an "expected failure" several times.
set +e
pnpm exec playwright test \
--grep "($GREP_PATTERN)" \
--reporter=list \
--retries=0 \
--max-failures=1 \
--timeout=30000 \
"${PLAYWRIGHT_ARGS[@]}"
PASS2_EXIT=$?
set -e
# Pass 2 SUCCEEDED (tests passed) is BAD: the plugin says it disables
# the feature but the feature works. Pass 2 FAILED (tests failed) is
# GOOD: the feature is genuinely disabled.
if [[ $PASS2_EXIT -eq 0 ]]; then
echo
echo "ERROR: plugin declares disables=[$DISABLES] but tests with those tags PASSED." >&2
echo " The plugin is opting out of tests it has no right to skip:" >&2
echo " - either the plugin isn't actually disabling those features," >&2
echo " - or ep.json's disables list is wrong." >&2
exit 1
fi
echo
echo "Both passes succeeded — plugin's disables contract is honoured."

View file

@ -2,12 +2,4 @@
set -e
mydir=$(cd "${0%/*}" && pwd -P) || exit 1
cd "${mydir}"/..
outdated_raw=$(pnpm --filter ep_etherpad-lite outdated --depth=0 2>&1) || true
OUTDATED=$(printf '%s\n' "$outdated_raw" | awk '{print $1}' | grep '^ep_' | grep -v '^ep_etherpad-lite$') || true
if [ -z "$OUTDATED" ]; then
echo "All plugins are up-to-date"
exit 0
fi
set -- ${OUTDATED}
echo "Updating plugins: $*"
exec pnpm --filter ep_etherpad-lite update "$@"
exec pnpm --filter bin run plugins update

View file

@ -0,0 +1,94 @@
# Feature-disabling plugins
Some Etherpad plugins exist specifically to **remove** a baseline feature — `ep_disable_chat`, `ep_disable_change_author_name`, `ep_disable_error_messages`, and so on. When the plugin is installed, the feature it disables is intentionally absent.
This is awkward for CI: the core test suite asserts the disabled feature works. Without coordination, every disable plugin's CI is permanently red, every dependency bump is permanently stuck, and the green/red signal on etherpad.org/plugins becomes meaningless.
To fix that — without giving plugins a free pass to opt out of arbitrary tests — Etherpad uses a small declared-disables contract.
## How it works
### 1. Core specs are tagged by feature
Tests that exercise a single feature carry a Playwright tag like `@feature:chat`:
```ts
test('opens chat, sends a message, makes sure it exists on the page and hides chat', {
tag: '@feature:chat',
}, async ({page}) => { ... });
test.describe('error sanitization', { tag: '@feature:error-gritter' }, () => { ... });
```
Tags currently in use:
- `@feature:chat`
- `@feature:username`
- `@feature:clear-authorship`
- `@feature:error-gritter`
- `@feature:line-numbers`
- `@feature:rtl-toggle`
### 2. A plugin declares the features it disables in its `ep.json`
```json
{
"name": "ep_disable_chat",
"description": "Disable chat",
"disables": ["@feature:chat"],
"parts": [...]
}
```
The `disables` field is publicly visible in the plugin's metadata and surfaces on etherpad.org/plugins, so users see the contract before installing.
### 3. The plugin's CI runs the two-pass test script
`bin/run-frontend-tests-with-disables.sh` enforces the contract:
```yaml
# .github/workflows/frontend-tests.yml
- name: Run the frontend tests
working-directory: ./etherpad-lite/src
run: ../bin/run-frontend-tests-with-disables.sh -- --project=chromium
```
The script reads `disables` (from `EP_PLUGIN_DISABLES`, an explicit `--plugin-ep-json PATH`, or auto-detection in `plugin_packages/`) and runs two passes:
| Pass | What it runs | Must |
|---|---|---|
| **1. Regression** | Every spec **not** tagged with a disabled feature | Pass — proves the plugin doesn't break anything beyond what it claims to disable. |
| **2. Honesty** | Every spec **that is** tagged with a disabled feature | **Fail** — proves the plugin is genuinely disabling the feature it declares. If those tests pass, the plugin's `disables` claim is wrong. |
Both passes have to succeed for CI to be green.
## What this catches
| Failure mode | Caught by |
|---|---|
| Plugin breaks an unrelated feature | Pass 1 — its tests aren't excluded, they fail, CI red. |
| Plugin claims to disable a feature but the feature still works | Pass 2 — tagged tests pass when they should fail, script exits non-zero. |
| Plugin breaks a feature without declaring it (so etherpad.org/plugins shows it as harmless) | Pass 1 — the feature's tests aren't excluded, they fail, CI red. |
| Plugin lists a feature in `disables` it doesn't actually disable | Pass 2. |
A plugin **cannot** ship green with broken functionality the user can't see ahead of time.
## Adding a new feature tag
When a core spec needs a new feature tag (because a new disable plugin needs to opt out of it):
1. Pick a stable name: `@feature:<area>` — short, lowercase, kebab-case, plural where appropriate.
2. Tag the relevant `test()` or `test.describe()` blocks. Multiple tags are fine: `tag: ['@feature:chat', '@feature:username']`.
3. Update the list above.
4. Submit the tag PR before the plugin's PR — the plugin can then declare `disables` and pass CI.
## Adding a new disable plugin
1. Confirm the feature you're disabling is tagged in core. If not, propose a tag upstream first.
2. Add `"disables": ["@feature:thing"]` to your `ep.json`.
3. Replace the test invocation in `.github/workflows/frontend-tests.yml` with a call to `bin/run-frontend-tests-with-disables.sh` (see `ep_disable_chat` for a worked example).
4. Confirm both passes go green locally before opening the PR.
## Why not just `--grep-invert`?
The earlier draft of this design just told plugin maintainers to add `--grep-invert "<pattern>"` in CI. That works for the regression case (pass 1 above), but it lets a careless or malicious plugin silently skip arbitrary tests and present green CI on etherpad.org/plugins despite breaking unrelated functionality. Pass 2 — and the requirement that disables be declared in `ep.json` rather than inferred from a CI argument — closes that gap.

View file

@ -0,0 +1,103 @@
# Plugin frontend tests
Etherpad core's Playwright runner discovers plugin frontend specs from
the conventional path:
```
node_modules/ep_<plugin>/static/tests/frontend-new/specs/**/*.spec.ts
```
When the plugin is installed alongside core (e.g. via `pnpm add -w
ep_<plugin>` or in a `with-plugins` CI variant), the plugin's specs
run as part of `pnpm run test-ui`. Same pattern backend tests already
use (`mocha ... ../node_modules/ep_*/static/tests/backend/specs/**`).
This re-enables coverage that was lost in commit `cc80db2d3` (2023-07)
when the legacy jQuery test runner (`static/tests/frontend/specs/test.js`
+ in-page mocha+helper) was removed without a Playwright replacement.
See [#7622](https://github.com/ether/etherpad/issues/7622).
## Layout in your plugin
```
ep_yourplugin/
├── ep.json
├── package.json
├── static/
│ └── tests/
│ └── frontend-new/
│ └── specs/
│ └── yourplugin.spec.ts
└── ...
```
A spec is a normal Playwright test file. Import shared helpers from the
core package — `ep_etherpad-lite` is symlinked into `node_modules` by
the workspace, so this resolves anywhere the plugin is installed
alongside core:
```ts
import {expect, test} from '@playwright/test';
import {clearPadContent, getPadBody, goToNewPad, writeToPad}
from 'ep_etherpad-lite/tests/frontend-new/helper/padHelper';
test.beforeEach(async ({page}) => {
await goToNewPad(page);
});
test.describe('ep_yourplugin', () => {
test('does the thing', async ({page}) => {
const padBody = await getPadBody(page);
await padBody.click();
await clearPadContent(page);
await writeToPad(page, 'hello');
// …assertions…
await expect(padBody.locator('div').first()).toHaveText('hello');
});
});
```
## Migrating from the legacy `static/tests/frontend/specs/test.js`
The old format used mocha + a jQuery `helper` global:
```js
// Legacy — does not run anywhere any more.
describe('ep_yourplugin', function () {
beforeEach(function (cb) { helper.newPad(cb); });
it('does the thing', async function () {
const chrome$ = helper.padChrome$;
const inner$ = helper.padInner$;
expect(chrome$('#yourbutton').length).to.be.greaterThan(0);
});
});
```
Translation table:
| Legacy (mocha + helper) | Playwright |
|---|---|
| `describe(...)` / `it(...)` | `test.describe(...)` / `test(...)` |
| `helper.newPad(cb)` | `await goToNewPad(page)` |
| `helper.padChrome$('#x')` | `page.locator('#x')` |
| `helper.padInner$('div')` | `(await getPadBody(page)).locator('div')` |
| `expect(x).to.equal(y)` | `expect(x).toBe(y)` (Playwright's expect) |
| `expect($el.length).to.be.greaterThan(0)` | `await expect(page.locator('#x')).toBeVisible()` |
| `$el.sendkeys('text')` | `await page.keyboard.type('text')` |
| `$el.simulate('click')` | `await page.locator(...).click()` |
Most legacy specs translate ~mechanically. After migrating, **delete
the legacy file** so the plugin can't accidentally ship stale tests
that nothing executes.
## Running them
```sh
# Inside core, with the plugin installed:
pnpm run test-ui --project=chromium
# Or via core's with-plugins CI job (see frontend-tests.yml).
```
`pnpm run test-ui` automatically picks up plugin specs from any
installed `ep_*` package. To gate per-plugin: use playwright's
`--grep` against your plugin's describe name.

83
doc/admin/updates.md Normal file
View file

@ -0,0 +1,83 @@
# Etherpad updates
Etherpad ships with a built-in update subsystem. **Tier 1 (notify)** is enabled by 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 automatic execution happens at this tier — admins are simply informed.
Tiers 2 (manual click), 3 (auto with grace window), and 4 (autonomous in maintenance window) are designed but not yet implemented. They will land in subsequent releases.
## Settings
In `settings.json`:
```jsonc
{
"updates": {
"tier": "notify",
"source": "github",
"channel": "stable",
"installMethod": "auto",
"checkIntervalHours": 6,
"githubRepo": "ether/etherpad",
"requireAdminForStatus": false
},
"adminEmail": null
}
```
| Setting | Default | Notes |
| --- | --- | --- |
| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. Higher tiers are silently downgraded if the install method does not allow them. PR 1 only honors `"notify"` and `"off"`. |
| `updates.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. |
| `updates.checkIntervalHours` | `6` | How often to poll GitHub Releases. |
| `updates.githubRepo` | `"ether/etherpad"` | Override for forks. |
| `updates.requireAdminForStatus` | `false` | Lock the `/admin/update/status` endpoint to authenticated admin sessions. Default `false` matches existing Etherpad behavior — `/health` already exposes `releaseId` publicly, and changelog data comes from a public GitHub release. Set `true` to hide the full update payload from non-admins without disabling the updater (`tier: "off"` is the heavier opt-out that removes the endpoints entirely). |
| `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
## What "outdated" means
- **`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)
| Trigger | First send | Repeat |
| --- | --- | --- |
| 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 | — |
If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side badge still work without it.
PR 1 ships the cadence machinery but does not yet wire a real SMTP transport — emails are logged with `(would send email)` until a future PR adds the transport. The dedupe state still advances correctly so admins are not bombarded once SMTP is wired.
## Pad-side badge
Pad users see no version information by default. A small badge appears in the bottom-right corner only when:
- The instance is `severe` (one or more major versions behind), or
- The instance is `vulnerable` (running below an announced threshold).
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"`. No HTTP request will leave the instance and no banner or badge will render.
## Privacy
The version check sends no telemetry. Etherpad fetches the public GitHub Releases API (`api.github.com/repos/<repo>/releases/latest`) with `If-None-Match` to be cache-friendly. The only metadata GitHub sees is the same as any other GitHub API client — your IP and a `User-Agent: etherpad-self-update` header. No instance ID, no version, no identifiers travel upstream.
## How install method is detected
`updates.installMethod` defaults to `"auto"`, which uses these heuristics in order:
1. `/.dockerenv` exists → `"docker"`.
2. `.git/` directory present and the install root is writable → `"git"`.
3. `package-lock.json` present and writable → `"npm"`.
4. Otherwise → `"managed"`.
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only `"git"` is initially supported for write tiers.

View file

@ -5,6 +5,22 @@ Location: `src/static/js/ace2_inner.js`
## editorInfo.ace_replaceRange(start, end, text)
This function replaces a range (from `start` to `end`) with `text`.
## editorInfo.ace_doDuplicateSelectedLines()
Duplicates every line spanned by the current selection (or the caret's line
if nothing is selected) and inserts the duplicated block directly below the
original. Character attributes (bold, italic, list, heading, etc.) are
preserved on the duplicates. Wired to `Ctrl`/`Cmd`+`Shift`+`D` via the
`padShortcutEnabled.cmdShiftD` setting.
## editorInfo.ace_doDeleteSelectedLines()
Deletes every line spanned by the current selection (or the caret's line if
nothing is selected). If the selection covers the final line of the pad,
the preceding newline is consumed so no dangling empty line is left.
Wired to `Ctrl`/`Cmd`+`Shift`+`K` via the `padShortcutEnabled.cmdShiftK`
setting.
## editorInfo.ace_getRep()
Returns the `rep` object. The rep object consists of the following properties:

View file

@ -354,6 +354,34 @@ Context properties:
* `message`: The message object that will be sent to the Etherpad server.
## `chatPrefillFromUser`
Called from: `src/static/js/pad_userlist.ts`
Called when the user clicks an entry in the user list. The default behavior is to
open the chat panel and prefill the input with `@<name> `, where `<name>` is that
user's display name (with whitespace replaced by underscores). Plugins can return
a different prefill string from their callback — the first non-empty string
returned wins.
Typical use is by AI/bot plugins whose author display name (e.g. "AI Assistant")
isn't a useful @-mention; the plugin can substitute its trigger string instead.
Context properties:
* `authorId`: The clicked user's author id.
* `name`: The clicked user's display name.
* `prefill`: The default prefill string Etherpad would otherwise use.
Example:
```javascript
exports.chatPrefillFromUser = (hookName, {authorId, name}, cb) => {
if (authorId === window.clientVars.ep_my_bot.authorId) return cb('@bot ');
return cb();
};
```
## collectContentPre
Called from: `src/static/js/contentcollector.js`

View file

@ -65,7 +65,7 @@ Portal submits content into new blog post
=== Usage
==== API version
The latest version is `1.3.0`
The latest version is `1.3.1`
The current version can be queried via /api.
@ -588,6 +588,25 @@ _Example returns:_
* `{code: 0, message:"ok", data: null}`
* `{code: 1, message:"padID does not exist", data: null}`
==== compactPad(padID, [keepRevisions])
* API >= 1.3.1
collapses the pad's revision history to reclaim database space (issue #6194). Wraps the same `Cleanup` helper that powers the admin-settings UI, so admins can trigger compaction over the public API or via `bin/compactPad` without going through the admin UI.
*Gated on `settings.cleanup.enabled = true`* (matches the admin/Cleanup path). The endpoint returns an error if cleanup isn't enabled in `settings.json`, so the public API can't bypass the same opt-in switch the admin UI requires.
When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions.
Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. *This operation is destructive — export the pad first via `getEtherpad` if you need a backup.*
_Example returns:_
* `{code: 0, message:"ok", data: {ok: true, mode: "all"}}`
* `{code: 0, message:"ok", data: {ok: true, mode: "keepLast", keepRevisions: 50}}`
* `{code: 1, message:"padID does not exist", data: null}`
* `{code: 1, message:"keepRevisions must be a non-negative integer", data: null}`
* `{code: 1, message:"compactPad requires cleanup.enabled = true in settings.json", data: null}`
==== getReadOnlyID(padID)
* API >= 1

View file

@ -98,7 +98,7 @@ Portal submits content into new blog post
## Usage
### API version
The latest version is `1.3.0`
The latest version is `1.3.1`
The current version can be queried via /api.
@ -519,12 +519,20 @@ Group pads are normal pads, but with the name schema GROUPID$PADNAME. A security
#### createPad(padID, [text], [authorId])
* API >= 1
* `authorId` in API >= 1.3.0
* returns `deletionToken` once, since the same release that added `allowPadDeletionByAllUsers`
creates a new (non-group) pad. Note that if you need to create a group Pad, you should call **createGroupPad**.
You get an error message if you use one of the following characters in the padID: "/", "?", "&" or "#".
`data.deletionToken` is a one-shot recovery token tied to this pad. It is
returned in plaintext on the first call for a given padID and is `null` on
subsequent calls (the token itself is stored on the server as a sha256 hash).
Pass it to **deletePad** (or the socket `PAD_DELETE` message) to delete the
pad without the creator's author cookie.
*Example returns:*
* `{code: 0, message:"ok", data: null}`
* `{code: 0, message:"ok", data: {deletionToken: "…32-char random string…"}}`
* `{code: 0, message:"ok", data: {deletionToken: null}}` — pad already existed
* `{code: 1, message:"padID does already exist", data: null}`
* `{code: 1, message:"malformed padID: Remove special characters", data: null}`
@ -581,14 +589,24 @@ returns the list of users that are currently editing this pad
* `{code: 0, message:"ok", data: {padUsers: [{colorId:"#c1a9d9","name":"username1","timestamp":1345228793126,"id":"a.n4gEeMLsvg12452n"},{"colorId":"#d9a9cd","name":"Hmmm","timestamp":1345228796042,"id":"a.n4gEeMLsvg12452n"}]}}`
* `{code: 0, message:"ok", data: {padUsers: []}}`
#### deletePad(padID)
#### deletePad(padID, [deletionToken])
* API >= 1
* `deletionToken` in the same release as `allowPadDeletionByAllUsers`
deletes a pad
deletes a pad.
`deletionToken` is the one-shot recovery token returned by `createPad` /
`createGroupPad`. An apikey-authenticated caller can pass any (or no) token
and the call still succeeds — trusted admins bypass the check. An
unauthenticated caller (or a caller that explicitly passes a wrong token)
is rejected with `invalid deletionToken` unless the operator has set
`allowPadDeletionByAllUsers: true` in `settings.json`, in which case the
token is ignored.
*Example returns:*
* `{code: 0, message:"ok", data: null}`
* `{code: 1, message:"padID does not exist", data: null}`
* `{code: 1, message:"invalid deletionToken", data: null}`
#### copyPad(sourceID, destinationID[, force=false])
* API >= 1.2.8
@ -619,6 +637,24 @@ moves a pad. If force is true and the destination pad exists, it will be overwri
* `{code: 0, message:"ok", data: null}`
* `{code: 1, message:"padID does not exist", data: null}`
#### compactPad(padID, [keepRevisions])
* API >= 1.3.1
collapses the pad's revision history to reclaim database space (issue #6194). Wraps the same `Cleanup` helper that powers the admin-settings UI, so admins can trigger compaction over the public API or via `bin/compactPad` without going through the admin UI.
**Gated on `settings.cleanup.enabled = true`** (matches the admin/Cleanup path). The endpoint returns an error if cleanup isn't enabled in `settings.json`, so the public API can't bypass the same opt-in switch the admin UI requires.
When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions.
Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first via `getEtherpad` if you need a backup.**
*Example returns:*
* `{code: 0, message:"ok", data: {ok: true, mode: "all"}}`
* `{code: 0, message:"ok", data: {ok: true, mode: "keepLast", keepRevisions: 50}}`
* `{code: 1, message:"padID does not exist", data: null}`
* `{code: 1, message:"keepRevisions must be a non-negative integer", data: null}`
* `{code: 1, message:"compactPad requires cleanup.enabled = true in settings.json", data: null}`
#### getReadOnlyID(padID)
* API >= 1

View file

@ -7,7 +7,7 @@ Cookies used by Etherpad.
| express_sid | s%3A7yCNjRmTW8ylGQ53I2IhOwYF9... | example.org | / | Session | true | true | Session ID of the [Express web framework](https://expressjs.com). When Etherpad is behind a reverse proxy, and an administrator wants to use session stickiness, he may use this cookie. If you are behind a reverse proxy, please remember to set `trustProxy: true` in `settings.json`. Set in [webaccess.js#L131](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/hooks/express/webaccess.js#L131). |
| language | en | example.org | / | Session | false | true | The language of the UI (e.g.: `en-GB`, `it`). Set by the pad client when the user changes **My View → Language** (currently in `src/static/js/pad.ts`, via `setMyViewLanguage()`). |
| prefs / prefsHttp | %7B%22epThemesExtTheme%22... | example.org | /p | year 3000 | false | true | Client-side preferences (e.g.: font family, chat always visible, show authorship colors, ...). Set in [pad_cookie.js#L49](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad_cookie.js#L49). `prefs` is used if Etherpad is accessed over HTTPS, `prefsHttp` if accessed over HTTP. For more info see https://github.com/ether/etherpad-lite/issues/3179. |
| token | t.tFzkihhhBf4xKEpCK3PU | example.org | / | 60 days | false | true | A random token representing the author, of the form `t.randomstring_of_lenght_20`. The random string is generated by the client, at ([pad.js#L55-L66](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L55-L66)). This cookie is always set by the client (at [pad.js#L153-L158](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L153-L158)) without any solicitation from the server. It is used for all the pads accessed via the web UI (not used for the HTTP API). On the server side, its value is accessed at [SecurityManager.js#L33](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/db/SecurityManager.js#L33). |
| token | t.tFzkihhhBf4xKEpCK3PU | example.org | / | 60 days | true | true | A random token representing the author, of the form `t.randomstring_of_length_20`. Set by the server as an `HttpOnly; SameSite=Lax` cookie on the first GET to `/p/:pad` (see `src/node/utils/ensureAuthorTokenCookie.ts`). The server reads the cookie from the socket.io handshake in `PadMessageHandler.handleClientReady` to resolve the author. Not readable from browser JavaScript. See [privacy.md](privacy.md). |
For more info, visit the related discussion at https://github.com/ether/etherpad-lite/issues/3563.

View file

@ -115,6 +115,8 @@ If your database needs additional settings, you will have to use a personalized
| `PAD_OPTIONS_ALWAYS_SHOW_CHAT` | | `false` |
| `PAD_OPTIONS_CHAT_AND_USERS` | | `false` |
| `PAD_OPTIONS_LANG` | | `null` |
| `PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS` | Fade each author's caret/background toward white as they go inactive. Set to `false` on busy pads (every faded author counts as a second on-screen color, so 30 contributors visually become 60), when users pick light colors that fade into the background, or whenever inactivity tracking is undesirable. | `true` |
| `PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS` | Lighten/darken author bg colours at render time so text contrast meets WCAG 2.1 AA. | `true` |
### Shortcuts

View file

@ -85,10 +85,10 @@ If a package previously had an `NPM_TOKEN` secret in CI:
## Requirements
- **Node.js**: >= 20.17.0 on the runner. npm 11 requires
`^20.17.0 || >=22.9.0`. The npm docs nominally recommend Node 22.14+, but
Node 20.17+ works fine — the project's `engines.node` already requires
`>=20.0.0`, and `setup-node@v6 with version: 20` resolves to the latest 20.x.
- **Node.js**: >= 22.12 on the runner. npm 11 requires `>=22.9.0` and
`oxc-minify` (a vitepress peer for the docs build) requires `>=22.12.0`,
both of which `setup-node@v6 with version: 22` satisfies (resolves to the
latest 22.x). The project's `engines.node` requires `>=22.12.0`.
- **npm CLI**: >= 11.5.1. The publish workflow runs `npm install -g npm@latest`
before publishing so the bundled npm version doesn't matter.
- **Runner**: must be a GitHub-hosted (cloud) runner. Self-hosted runners are

View file

@ -1,5 +1,6 @@
{
"devDependencies": {
"oxc-minify": "^0.129.0",
"vitepress": "^2.0.0-alpha.17"
},
"scripts": {
@ -9,8 +10,5 @@
},
"peerDependencies": {
"search-insights": "^2.17.3"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.10"
}
}

131
doc/privacy.md Normal file
View file

@ -0,0 +1,131 @@
# Privacy
This document describes what Etherpad stores and logs about its users, so
operators can publish an accurate data-processing statement.
## Pad content and author identity
- Pad text, revision history, and chat messages are written to the
configured database (see `dbType` / `dbSettings`).
- Authorship is tracked by an opaque `authorID` that is bound to a
short-lived author-token cookie. There is no link between an authorID
and a real-world identity unless a plugin or SSO layer adds one.
## IP addresses
Etherpad never writes a client IP to its database. IPs only appear in
`log4js` output (the `access`, `http`, `message`, and console loggers).
Whether those are persisted depends entirely on the log appender your
deployment configures.
The `ipLogging` setting (`settings.json`) controls what those log
records contain. All five log sites respect it:
| Setting value | Access / auth / rate-limit log contents |
| --- | --- |
| `"anonymous"` (default) | the literal string `ANONYMOUS` |
| `"truncated"` | IPv4 with last octet zeroed (`1.2.3.0`); IPv6 truncated to the first /48 (`2001:db8:1::`); IPv4-mapped IPv6 truncates the embedded v4; unknowns fall back to `ANONYMOUS` |
| `"full"` | the original IP address |
The pre-2026 boolean `disableIPlogging` is still honoured for one
release cycle: `true` maps to `"anonymous"`, `false` maps to `"full"`.
A deprecation WARN is emitted when only the legacy setting is present.
## Rate limiting
The in-memory socket rate limiter keys on the raw client IP for the
duration of the limiter window (see `commitRateLimiting` in
`settings.json`). This state is never written to disk, never sent to a
plugin, and is thrown away on server restart.
## What Etherpad does not do
- No IP addresses are written to the database.
- No IP addresses are sent to `clientVars` (and therefore to the
browser). The long-standing `clientIp: '127.0.0.1'` placeholder was
removed in the same change that introduced `ipLogging`.
- No IP addresses are passed to server-side plugin hooks by Etherpad
itself. Plugins that receive a raw `req` can still read `req.ip`
directly — audit your installed plugins if you need to rule that
out.
## Cookies
See [`cookies.md`](cookies.md) for the full cookie list.
## Right to erasure (GDPR Art. 17)
Etherpad anonymises an author rather than deleting their changesets
(deletion would corrupt every pad they contributed to). Operators
trigger erasure via the admin REST API:
```bash
curl -X POST \
-H "Authorization: Bearer <admin JWT / apikey>" \
"https://<instance>/api/1.3.1/anonymizeAuthor?authorID=a.XXXXXXXXXXXXXX"
```
The endpoint is gated by the `gdprAuthorErasure` setting (see
`settings.json`). It is **disabled by default**; set
`"gdprAuthorErasure": { "enabled": true }` to expose it. While
disabled, calls return HTTP 404 / API code 4 ("no such function").
What the call does:
- Zeros `name` and `colorId` on the `globalAuthor:<authorID>` record
(kept as an opaque stub so changeset references still resolve to
"an author" with no details).
- Deletes every `token2author:<token>` and `mapper2author:<mapper>`
binding that pointed at this author. Once removed, a new session
with the same token starts a fresh anonymous identity.
- Nulls `authorId` on chat messages the author posted; message text
and timestamps are unchanged.
What it does not do:
- Delete pad content, revisions, or the attribute pool. If a pad
itself should also be erased, use the pad-deletion token flow
(PR1, `deletePad`).
- Touch other authors' edits.
The call is idempotent: calling it twice on the same authorID
short-circuits the second time and returns zero counters. Pad-level
deletion is covered separately by the deletion-token mechanism in
[`docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md`](https://github.com/ether/etherpad/blob/develop/docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md);
the rest of the GDPR work is tracked in
[ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701).
## Privacy banner (optional)
The `privacyBanner` block in `settings.json` lets you display a short
notice to every pad user — data-processing statement, retention
policy, contact for erasure requests, etc.
```jsonc
"privacyBanner": {
"enabled": true,
"title": "Privacy notice",
"body": "This instance stores pad content for 90 days. Contact privacy@example.com to request erasure.",
"learnMoreUrl": "https://example.com/privacy",
"dismissal": "dismissible"
}
```
The banner is rendered as a persistent gritter notification at the
bottom of the page (it inherits the same look as every other gritter
on the pad — no custom skin needed). The body is plain text (HTML is
escaped); each line becomes its own paragraph.
`dismissal` controls how the close (×) is handled:
- `"dismissible"` (default) — when the user closes the gritter, the
choice is persisted in `localStorage` per origin and the banner is
not shown again on subsequent pad loads.
- `"sticky"` — closing the gritter only hides it for the current
session; the next pad load shows it again. (The close control is
not removed; for an operator-enforced non-closable notice, render
the policy out-of-band — e.g., a skin override or a reverse-proxy
ribbon.)
Unknown `dismissal` values are coerced to `"dismissible"` with a
`logger.warn` at settings load.

View file

@ -0,0 +1,939 @@
# GDPR PR1 — Pad Deletion Controls 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:** Land the first of five GDPR PRs from ether/etherpad#6701 — adds a one-time deletion token, an `allowPadDeletionByAllUsers` admin flag, and the UI + endpoint plumbing needed for creators to delete a pad without their browser cookies.
**Architecture:** A new `PadDeletionManager` module owns the token (sha256-hashed in the db under `pad:<id>:deletionToken`, returned plaintext exactly once on creation). `handlePadDelete` gains a three-way authorisation check — creator cookie → valid token → settings flag — and `createPad`/`createGroupPad` return the token in the HTTP API response. The browser creator also receives the token via `clientVars.padDeletionToken`, shows it in a one-time modal, and gets a "delete with token" field in the settings popup for devices without the creator cookie.
**Tech Stack:** TypeScript (etherpad server + client), jQuery + EJS for pad UI, Playwright for frontend tests, Mocha + supertest for backend tests.
---
## File Structure
**Already in working tree (from restored stash):**
- `src/node/db/PadDeletionManager.ts` — create / verify (timing-safe) / remove
- `settings.json.template`, `settings.json.docker``allowPadDeletionByAllUsers: false`
- `src/node/utils/Settings.ts``allowPadDeletionByAllUsers` type + default
- `src/node/db/API.ts``createPad` returns `{deletionToken}`
- `src/node/db/GroupManager.ts``createGroupPad` returns `{padID, deletionToken}`
- `src/node/db/Pad.ts``Pad.remove()` calls `removeDeletionToken`
- `src/static/js/types/SocketIOMessage.ts``ClientVarPayload` has optional `padDeletionToken`
**Created by this plan:**
- `src/tests/backend/specs/padDeletionManager.ts` — unit tests for the manager
- `src/tests/backend/specs/api/deletePad.ts` — authorisation-matrix tests
- `src/tests/frontend-new/specs/pad_deletion_token.spec.ts` — end-to-end modal + delete-by-token
**Modified by this plan:**
- `src/node/handler/PadMessageHandler.ts` — three-way auth in `handlePadDelete`; thread `padDeletionToken` into `clientVars` for creator sessions
- `src/node/db/API.ts` — expose the optional `deletionToken` parameter on the programmatic `deletePad(padID, deletionToken?)` path for REST coverage
- `src/static/js/types/SocketIOMessage.ts` — add optional `deletionToken` to `PadDeleteMessage`
- `src/templates/pad.html` — post-creation token modal, delete-by-token disclosure under Delete button
- `src/static/js/pad.ts` — surface modal when `clientVars.padDeletionToken` is present, clear it after ack
- `src/static/js/pad_editor.ts` — wire delete-by-token input into the existing delete flow
- `src/static/css/pad.css` (or the skin component file the Delete button already lives in) — minimal styling for modal + disclosure
- `src/locales/en.json` — new localisation keys
- `src/tests/backend/specs/api/api.ts` — extend to cover `createPad` returning a token once
---
## Task 1: Baseline and verify the restored scaffolding
**Files:**
- (no edits — validation only)
- [ ] **Step 1: Confirm branch and stashed files exist**
```bash
git status --short
git log --oneline -5
```
Expected: current branch is `feat-gdpr-pad-deletion`, HEAD shows `docs: PR1 GDPR deletion-controls design spec`, and working tree modifications cover `settings.json.template`, `settings.json.docker`, `src/node/db/API.ts`, `src/node/db/GroupManager.ts`, `src/node/db/Pad.ts`, `src/node/utils/Settings.ts`, `src/static/js/types/SocketIOMessage.ts`, plus the untracked `src/node/db/PadDeletionManager.ts`.
- [ ] **Step 2: Type check before touching anything**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0, no TypeScript errors.
- [ ] **Step 3: Commit the restored scaffolding as its own change**
```bash
git add settings.json.template settings.json.docker \
src/node/db/API.ts src/node/db/GroupManager.ts src/node/db/Pad.ts \
src/node/utils/Settings.ts src/static/js/types/SocketIOMessage.ts \
src/node/db/PadDeletionManager.ts
git commit -m "$(cat <<'EOF'
feat(gdpr): scaffolding for pad deletion tokens
PadDeletionManager stores a sha256-hashed per-pad deletion token and
verifies it with timing-safe comparison. createPad / createGroupPad
return the plaintext token once on first creation, and Pad.remove()
cleans it up. Gated behind the new allowPadDeletionByAllUsers flag
which defaults to false to preserve existing behaviour.
Part of #6701 (GDPR PR1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
Expected: clean commit, no pre-commit hook failures.
---
## Task 2: Unit tests for `PadDeletionManager`
**Files:**
- Create: `src/tests/backend/specs/padDeletionManager.ts`
- [ ] **Step 1: Write the failing test file**
```typescript
'use strict';
import {strict as assert} from 'assert';
const common = require('../common');
const padDeletionManager = require('../../../node/db/PadDeletionManager');
describe(__filename, function () {
before(async function () { await common.init(); });
const uniqueId = () => `pdmtest_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
describe('createDeletionTokenIfAbsent', function () {
it('returns a non-empty string on first call', async function () {
const padId = uniqueId();
const token = await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(typeof token, 'string');
assert.ok(token.length >= 32);
await padDeletionManager.removeDeletionToken(padId);
});
it('returns null on subsequent calls for the same pad', async function () {
const padId = uniqueId();
const first = await padDeletionManager.createDeletionTokenIfAbsent(padId);
const second = await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(typeof first, 'string');
assert.equal(second, null);
await padDeletionManager.removeDeletionToken(padId);
});
it('emits different tokens for different pads', async function () {
const a = uniqueId();
const b = uniqueId();
const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a);
const tokenB = await padDeletionManager.createDeletionTokenIfAbsent(b);
assert.notEqual(tokenA, tokenB);
await padDeletionManager.removeDeletionToken(a);
await padDeletionManager.removeDeletionToken(b);
});
});
describe('isValidDeletionToken', function () {
it('accepts the token returned by the matching pad', async function () {
const padId = uniqueId();
const token = await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), true);
await padDeletionManager.removeDeletionToken(padId);
});
it('rejects a token for the wrong pad', async function () {
const a = uniqueId();
const b = uniqueId();
const tokenA = await padDeletionManager.createDeletionTokenIfAbsent(a);
await padDeletionManager.createDeletionTokenIfAbsent(b);
assert.equal(await padDeletionManager.isValidDeletionToken(b, tokenA), false);
await padDeletionManager.removeDeletionToken(a);
await padDeletionManager.removeDeletionToken(b);
});
it('rejects a non-string token', async function () {
const padId = uniqueId();
await padDeletionManager.createDeletionTokenIfAbsent(padId);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, null), false);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, undefined), false);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, ''), false);
await padDeletionManager.removeDeletionToken(padId);
});
it('returns false for pads that never had a token', async function () {
const padId = uniqueId();
assert.equal(await padDeletionManager.isValidDeletionToken(padId, 'anything'), false);
});
});
describe('removeDeletionToken', function () {
it('invalidates the stored token', async function () {
const padId = uniqueId();
const token = await padDeletionManager.createDeletionTokenIfAbsent(padId);
await padDeletionManager.removeDeletionToken(padId);
assert.equal(await padDeletionManager.isValidDeletionToken(padId, token), false);
});
it('is safe to call when no token exists', async function () {
const padId = uniqueId();
await padDeletionManager.removeDeletionToken(padId); // must not throw
});
});
});
```
- [ ] **Step 2: Run the test file and confirm it passes**
Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/padDeletionManager.ts --timeout 10000`
Expected: all 8 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/tests/backend/specs/padDeletionManager.ts
git commit -m "test(gdpr): PadDeletionManager unit tests"
```
---
## Task 3: Extend `PadDeleteMessage` type and `handlePadDelete` authorisation
**Files:**
- Modify: `src/static/js/types/SocketIOMessage.ts:198-203`
- Modify: `src/node/handler/PadMessageHandler.ts:230-265`
- [ ] **Step 1: Add `deletionToken` to `PadDeleteMessage`**
```typescript
// src/static/js/types/SocketIOMessage.ts
export type PadDeleteMessage = {
type: 'PAD_DELETE'
data: {
padId: string
deletionToken?: string
}
}
```
- [ ] **Step 2: Thread the token through `handlePadDelete`**
Open `src/node/handler/PadMessageHandler.ts`, find `handlePadDelete` (near line 230), and replace its body (keep the outer async function signature) with:
```typescript
const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage) => {
const session = sessioninfos[socket.id];
if (!session || !session.author || !session.padId) throw new Error('session not ready');
const padId = padDeleteMessage.data.padId;
if (session.padId !== padId) throw new Error('refusing cross-pad delete');
if (!await padManager.doesPadExist(padId)) return;
const retrievedPad = await padManager.getPad(padId);
const firstContributor = await retrievedPad.getRevisionAuthor(0);
const isCreator = session.author === firstContributor;
const tokenOk = !isCreator && await padDeletionManager.isValidDeletionToken(
padId, padDeleteMessage.data.deletionToken);
const flagOk = !isCreator && !tokenOk && settings.allowPadDeletionByAllUsers;
if (isCreator || tokenOk || flagOk) {
await retrievedPad.remove();
return;
}
socket.emit('shout', {
type: 'COLLABROOM',
data: {
type: 'shoutMessage',
payload: {
message: {
message: 'You are not the creator of this pad, so you cannot delete it',
sticky: false,
},
timestamp: Date.now(),
},
},
});
};
```
- [ ] **Step 3: Wire the new imports at the top of `PadMessageHandler.ts`**
Ensure the file has:
```typescript
const padDeletionManager = require('../db/PadDeletionManager');
```
(Add it to the import block alongside the existing `padManager` require. If it is already present from earlier scaffolding, skip this step.)
- [ ] **Step 4: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 5: Commit**
```bash
git add src/static/js/types/SocketIOMessage.ts src/node/handler/PadMessageHandler.ts
git commit -m "feat(gdpr): three-way auth for socket PAD_DELETE
Creator cookie → valid deletion token → allowPadDeletionByAllUsers flag.
Anyone else still gets the existing refusal shout."
```
---
## Task 4: Programmatic `deletePad(padId, deletionToken?)` and REST coverage
**Files:**
- Modify: `src/node/db/API.ts:530-545` (the `deletePad` export)
- [ ] **Step 1: Extend the programmatic `deletePad` signature**
Replace the existing `exports.deletePad` with:
```typescript
/**
deletePad(padID, deletionToken?) deletes a pad
...
*/
exports.deletePad = async (padID: string, deletionToken?: string) => {
const pad = await getPadSafe(padID, true);
// apikey-authenticated callers bypass token checks — they're already trusted.
// For anonymous callers that hit this code path (e.g. a future public endpoint),
// require a valid token unless the instance has opted everyone in.
if (deletionToken !== undefined &&
!settings.allowPadDeletionByAllUsers &&
!await padDeletionManager.isValidDeletionToken(padID, deletionToken)) {
throw new CustomError('invalid deletionToken', 'apierror');
}
await pad.remove();
};
```
- [ ] **Step 2: Add the `CustomError` and `settings` imports if missing**
At the top of `src/node/db/API.ts`, confirm the file has:
```typescript
const CustomError = require('../utils/customError');
import settings from '../utils/Settings';
```
(Both already exist in etherpad; add only if absent.)
- [ ] **Step 3: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 4: Commit**
```bash
git add src/node/db/API.ts
git commit -m "feat(gdpr): optional deletionToken on programmatic deletePad"
```
---
## Task 5: Advertise `deletionToken` in the REST OpenAPI schema
**Files:**
- Modify: `src/node/handler/APIHandler.ts` — add `deletionToken` to the `deletePad` arg list
- [ ] **Step 1: Extend the API version-map entry for `deletePad`**
Open `src/node/handler/APIHandler.ts` and locate the existing `deletePad: ['padID']` entry (around line 56). Change it to:
```typescript
deletePad: ['padID', 'deletionToken'],
```
If the codebase uses a per-version map (older vs. newer), make the same change in every version entry that currently lists `deletePad`.
- [ ] **Step 2: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 3: Commit**
```bash
git add src/node/handler/APIHandler.ts
git commit -m "feat(gdpr): advertise optional deletionToken on REST deletePad"
```
---
## Task 6: REST API test for the authorisation matrix
**Files:**
- Create: `src/tests/backend/specs/api/deletePad.ts`
- [ ] **Step 1: Write the test spec**
```typescript
'use strict';
import {strict as assert} from 'assert';
const common = require('../../common');
import settings from '../../../node/utils/Settings';
let agent: any;
let apiKey: string;
const makeId = () => `gdprdel_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const apiCall = async (point: string, query: Record<string, string>) => {
const params = new URLSearchParams({apikey: apiKey, ...query}).toString();
return await agent.get(`/api/1/${point}?${params}`);
};
describe(__filename, function () {
before(async function () {
agent = await common.init();
apiKey = common.apiKey;
});
afterEach(function () { settings.allowPadDeletionByAllUsers = false; });
it('createPad returns a plaintext deletionToken the first time', async function () {
const padId = makeId();
const res = await apiCall('createPad', {padID: padId});
assert.equal(res.body.code, 0);
assert.equal(typeof res.body.data.deletionToken, 'string');
assert.ok(res.body.data.deletionToken.length >= 32);
await apiCall('deletePad', {padID: padId, deletionToken: res.body.data.deletionToken});
});
it('deletePad with a valid deletionToken succeeds', async function () {
const padId = makeId();
const create = await apiCall('createPad', {padID: padId});
const token = create.body.data.deletionToken;
const del = await apiCall('deletePad', {padID: padId, deletionToken: token});
assert.equal(del.body.code, 0, JSON.stringify(del.body));
const check = await apiCall('getText', {padID: padId});
assert.equal(check.body.code, 1); // "padID does not exist"
});
it('deletePad with a wrong deletionToken is refused', async function () {
const padId = makeId();
await apiCall('createPad', {padID: padId});
const del = await apiCall('deletePad', {padID: padId, deletionToken: 'not-the-real-token'});
assert.equal(del.body.code, 1);
assert.match(del.body.message, /invalid deletionToken/);
// cleanup — apikey-authenticated caller is trusted when no token is supplied
await apiCall('deletePad', {padID: padId});
});
it('deletePad with allowPadDeletionByAllUsers=true bypasses the token check', async function () {
const padId = makeId();
await apiCall('createPad', {padID: padId});
settings.allowPadDeletionByAllUsers = true;
const del = await apiCall('deletePad', {padID: padId, deletionToken: 'bogus'});
assert.equal(del.body.code, 0);
});
it('apikey-only call (no deletionToken) still works — admins stay trusted', async function () {
const padId = makeId();
await apiCall('createPad', {padID: padId});
const del = await apiCall('deletePad', {padID: padId});
assert.equal(del.body.code, 0);
});
});
```
- [ ] **Step 2: Run the new spec**
Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/api/deletePad.ts --timeout 20000`
Expected: all 5 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/tests/backend/specs/api/deletePad.ts
git commit -m "test(gdpr): cover deletePad authorisation matrix via REST"
```
---
## Task 7: Send `padDeletionToken` to the creator session via `clientVars`
**Files:**
- Modify: `src/node/handler/PadMessageHandler.ts` — in the CLIENT_READY handler where `clientVars` is assembled (around line 1008)
- [ ] **Step 1: Compute the token in the same block that decides creator-only UI**
Locate the `const canEditPadSettings = ...` computation introduced by PR #7545 (or its nearest equivalent — the creator-cookie check using `isPadCreator`). Immediately after it, add:
```typescript
const padDeletionToken = !sessionInfo.readonly && canEditPadSettings
? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId)
: null;
```
Then include the field in the `clientVars` literal (right after `canEditPadSettings`):
```typescript
padDeletionToken,
```
(If PR #7545 has not merged yet on this branch, replace `canEditPadSettings` in the conditional with the equivalent inline expression:
`!sessionInfo.readonly && await isPadCreator(pad, sessionInfo.author)`.)
- [ ] **Step 2: Confirm the `ClientVarPayload` type already has `padDeletionToken`**
`src/static/js/types/SocketIOMessage.ts` should still contain:
```typescript
padDeletionToken?: string | null,
```
(added by the restored scaffolding). If it was stripped during earlier cleanup, add it back.
- [ ] **Step 3: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 4: Commit**
```bash
git add src/node/handler/PadMessageHandler.ts src/static/js/types/SocketIOMessage.ts
git commit -m "feat(gdpr): surface padDeletionToken in clientVars for creators only"
```
---
## Task 8: Locale strings
**Files:**
- Modify: `src/locales/en.json`
- [ ] **Step 1: Add the new keys**
Insert the following inside the `pad.*` block (next to `pad.delete.confirm`):
```json
"pad.deletionToken.modalTitle": "Save your pad deletion token",
"pad.deletionToken.modalBody": "This token is the only way to delete this pad if you lose your browser session or switch device. Save it somewhere safe — it is shown here exactly once.",
"pad.deletionToken.copy": "Copy",
"pad.deletionToken.copied": "Copied",
"pad.deletionToken.acknowledge": "I've saved it",
"pad.deletionToken.deleteWithToken": "Delete with token",
"pad.deletionToken.tokenFieldLabel": "Pad deletion token",
"pad.deletionToken.invalid": "That token is not valid for this pad.",
```
Leave every other locale file untouched — English is the canonical source; translators fill in the rest.
- [ ] **Step 2: Type check (picks up JSON parse errors via test-runner bootstrap)**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 3: Commit**
```bash
git add src/locales/en.json
git commit -m "i18n(gdpr): strings for deletion-token modal and delete-with-token flow"
```
---
## Task 9: Template — one-time token modal + delete-by-token disclosure
**Files:**
- Modify: `src/templates/pad.html`
- [ ] **Step 1: Add the deletion-token modal, sibling to the existing `#settings` popup**
Find the `<div id="settings" class="popup">...</div>` block. Immediately after its closing wrapper, add:
```html
<div id="deletiontoken-modal" class="popup" hidden>
<div class="popup-content">
<h1 data-l10n-id="pad.deletionToken.modalTitle">Save your pad deletion token</h1>
<p data-l10n-id="pad.deletionToken.modalBody">
This token is the only way to delete this pad if you lose your
browser session or switch device. Save it somewhere safe — it
is shown here exactly once.
</p>
<div class="deletiontoken-row">
<input type="text" id="deletiontoken-value" readonly>
<button id="deletiontoken-copy" type="button" data-l10n-id="pad.deletionToken.copy">Copy</button>
</div>
<button id="deletiontoken-ack" type="button" class="btn btn-primary"
data-l10n-id="pad.deletionToken.acknowledge">I've saved it</button>
</div>
</div>
```
- [ ] **Step 2: Add the delete-by-token disclosure under the existing Delete button**
Find `<button data-l10n-id="pad.settings.deletePad" id="delete-pad">Delete pad</button>` in the settings popup. Replace the single button with:
```html
<button data-l10n-id="pad.settings.deletePad" id="delete-pad">Delete pad</button>
<details id="delete-pad-with-token">
<summary data-l10n-id="pad.deletionToken.deleteWithToken">Delete with token</summary>
<label for="delete-pad-token-input" data-l10n-id="pad.deletionToken.tokenFieldLabel">Pad deletion token</label>
<input type="password" id="delete-pad-token-input" autocomplete="off" spellcheck="false">
<button id="delete-pad-token-submit" type="button" class="btn btn-danger"
data-l10n-id="pad.settings.deletePad">Delete pad</button>
</details>
```
- [ ] **Step 3: Commit**
```bash
git add src/templates/pad.html
git commit -m "feat(gdpr): token modal + delete-with-token disclosure markup"
```
---
## Task 10: Client JS — modal reveal and delete-by-token wiring
**Files:**
- Modify: `src/static/js/pad.ts` — surface the modal, scrub token from `clientVars`
- Modify: `src/static/js/pad_editor.ts` — delete-by-token submit
- [ ] **Step 1: Surface the modal and scrub the token after acknowledgement**
In `src/static/js/pad.ts`, locate the `init` / `handleInit` phase — immediately after `clientVars` has been applied and the pad is usable. Add the following helper and an invocation:
```typescript
const showDeletionTokenModalIfPresent = () => {
const token = clientVars.padDeletionToken;
if (!token) return;
const $modal = $('#deletiontoken-modal');
const $input = $('#deletiontoken-value');
const $copy = $('#deletiontoken-copy');
const $ack = $('#deletiontoken-ack');
if ($modal.length === 0) return;
$input.val(token);
$modal.prop('hidden', false).addClass('popup-show');
$copy.off('click.gdpr').on('click.gdpr', async () => {
try {
await navigator.clipboard.writeText(token);
$copy.text(html10n.get('pad.deletionToken.copied'));
} catch (e) {
($input[0] as HTMLInputElement).select();
document.execCommand('copy');
$copy.text(html10n.get('pad.deletionToken.copied'));
}
});
$ack.off('click.gdpr').on('click.gdpr', () => {
$input.val('');
$modal.prop('hidden', true).removeClass('popup-show');
(clientVars as any).padDeletionToken = null;
});
};
```
Call `showDeletionTokenModalIfPresent()` once, after the user-visible pad has finished loading (a good spot is immediately after the existing `padeditor.init(...)` or `padimpexp.init(...)` call).
- [ ] **Step 2: Wire the delete-by-token UI**
In `src/static/js/pad_editor.ts`, find the existing `$('#delete-pad').on('click', ...)` handler (around line 90) and, directly after it, add:
```typescript
// delete pad using a recovery token
$('#delete-pad-token-submit').on('click', () => {
const token = String($('#delete-pad-token-input').val() || '').trim();
if (!token) return;
if (!window.confirm(html10n.get('pad.delete.confirm'))) return;
let handled = false;
pad.socket.on('message', (data: any) => {
if (data && data.disconnect === 'deleted') {
handled = true;
window.location.href = '/';
}
});
pad.socket.on('shout', (data: any) => {
handled = true;
const msg = data?.data?.payload?.message?.message;
if (msg) window.alert(msg);
});
pad.collabClient.sendMessage({
type: 'PAD_DELETE',
data: {padId: pad.getPadId(), deletionToken: token},
});
setTimeout(() => {
if (!handled) window.location.href = '/';
}, 5000);
});
```
- [ ] **Step 3: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 4: Commit**
```bash
git add src/static/js/pad.ts src/static/js/pad_editor.ts
git commit -m "feat(gdpr): show deletion token once, allow delete via recovery token"
```
---
## Task 11: Minimal styling for the modal + disclosure
**Files:**
- Modify: `src/static/css/pad.css` (or the skin CSS file that already styles `.popup`)
- [ ] **Step 1: Add scoped styles**
Append:
```css
#deletiontoken-modal .deletiontoken-row {
display: flex;
gap: 0.5rem;
margin: 1rem 0;
}
#deletiontoken-modal #deletiontoken-value {
flex: 1;
font-family: monospace;
padding: 0.4rem;
user-select: all;
}
#delete-pad-with-token {
margin-top: 0.5rem;
}
#delete-pad-with-token summary {
cursor: pointer;
color: var(--text-muted, #666);
font-size: 0.9rem;
}
#delete-pad-with-token input {
margin: 0.5rem 0;
width: 100%;
font-family: monospace;
}
```
Use whichever file the existing `#settings.popup` and `#delete-pad` styles live in (check via `grep -rn "#delete-pad" src/static/css src/static/skins` and pick the one already loaded by `pad.html`).
- [ ] **Step 2: Commit**
```bash
git add src/static/css/pad.css # or the skin file you actually touched
git commit -m "style(gdpr): modal + delete-with-token layout"
```
---
## Task 12: Frontend Playwright coverage
**Files:**
- Create: `src/tests/frontend-new/specs/pad_deletion_token.spec.ts`
- [ ] **Step 1: Write the Playwright spec**
```typescript
import {expect, test} from '@playwright/test';
import {goToNewPad, goToPad} from '../helper/padHelper';
import {showSettings} from '../helper/settingsHelper';
test.describe('pad deletion token', () => {
test.beforeEach(async ({context}) => {
await context.clearCookies();
});
test('creator sees a token modal exactly once and can dismiss it', async ({page}) => {
await goToNewPad(page);
const modal = page.locator('#deletiontoken-modal');
await expect(modal).toBeVisible();
const tokenValue = await page.locator('#deletiontoken-value').inputValue();
expect(tokenValue.length).toBeGreaterThanOrEqual(32);
await page.locator('#deletiontoken-ack').click();
await expect(modal).toBeHidden();
const cleared = await page.evaluate(
() => (window as any).clientVars.padDeletionToken);
expect(cleared == null).toBe(true);
});
test('second device can delete using the captured token', async ({page, browser}) => {
const padId = await goToNewPad(page);
const token = await page.locator('#deletiontoken-value').inputValue();
await page.locator('#deletiontoken-ack').click();
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await goToPad(page2, padId);
await showSettings(page2);
await page2.locator('#delete-pad-with-token > summary').click();
await page2.locator('#delete-pad-token-input').fill(token);
page2.once('dialog', (d) => d.accept());
await page2.locator('#delete-pad-token-submit').click();
await expect(page2).toHaveURL(/\/$|\/index\.html$/, {timeout: 10000});
// The pad should be gone — opening it again yields a fresh empty pad.
await goToPad(page2, padId);
const contents = await page2.frameLocator('iframe[name="ace_outer"]')
.frameLocator('iframe[name="ace_inner"]').locator('#innerdocbody').textContent();
expect((contents || '').trim().length).toBeLessThan(200); // default welcome text only
await context2.close();
});
test('wrong token keeps the pad alive and surfaces a shout', async ({page, browser}) => {
const padId = await goToNewPad(page);
await page.locator('#deletiontoken-ack').click();
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await goToPad(page2, padId);
await showSettings(page2);
await page2.locator('#delete-pad-with-token > summary').click();
await page2.locator('#delete-pad-token-input').fill('bogus-token-value');
page2.once('dialog', (d) => d.accept());
const alertPromise = page2.waitForEvent('dialog');
await page2.locator('#delete-pad-token-submit').click();
const alert = await alertPromise;
expect(alert.message()).toMatch(/not the creator|cannot delete/);
await alert.dismiss();
// Pad must still exist for the original creator.
await page.reload();
await expect(page.locator('#editorcontainer.initialized')).toBeVisible();
await context2.close();
});
});
```
- [ ] **Step 2: Restart the test server so it picks up the current branch's code**
```bash
lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {print $2}' | xargs -r kill 2>&1; sleep 2
(cd src && NODE_ENV=production node --require tsx/cjs node/server.ts -- \
--settings tests/settings.json > /tmp/etherpad-test.log 2>&1 &)
sleep 8
lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | tail -2
```
Expected: port 9001 is listening.
- [ ] **Step 3: Run the new Playwright spec**
```bash
cd src && NODE_ENV=production npx playwright test pad_deletion_token --project=chromium
```
Expected: 3 tests pass.
- [ ] **Step 4: Commit**
```bash
git add src/tests/frontend-new/specs/pad_deletion_token.spec.ts
git commit -m "test(gdpr): Playwright coverage for deletion-token modal + delete-with-token"
```
---
## Task 13: End-to-end verification, push, open PR
**Files:** (no edits)
- [ ] **Step 1: Full type-check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 2: Backend tests for just this feature**
```bash
pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs \
tests/backend/specs/padDeletionManager.ts \
tests/backend/specs/api/deletePad.ts --timeout 20000
```
Expected: 13 tests pass.
- [ ] **Step 3: Full Playwright smoke for the touched specs**
```bash
cd src && NODE_ENV=production npx playwright test \
pad_deletion_token pad_settings --project=chromium
```
Expected: all tests pass. (pad_settings included because Task 7 changes the `clientVars` assembly near its creator-only code.)
- [ ] **Step 4: Push and open the PR**
```bash
git push origin feat-gdpr-pad-deletion
gh pr create --title "feat(gdpr): pad deletion controls (PR1 of #6701)" --body "$(cat <<'EOF'
## Summary
- One-time sha256-hashed deletion token, surfaced plaintext once on create
- allowPadDeletionByAllUsers flag (defaults to false) to widen deletion rights
- Three-way auth on socket PAD_DELETE and REST deletePad: creator cookie, valid token, or settings flag
- Browser creators see a one-time token modal and can later delete via a recovery-token field in the pad settings popup
First of the five GDPR PRs outlined in #6701. Remaining scope (IP audit, identity hardening, cookie banner, author erasure) stays in follow-ups.
## Test plan
- [ ] ts-check clean
- [ ] Backend: padDeletionManager + api/deletePad specs
- [ ] Frontend: pad_deletion_token.spec.ts and pad_settings.spec.ts regression
EOF
)"
```
Expected: PR opens, CI runs.
- [ ] **Step 5: Monitor CI**
Run: `sleep 25 && gh pr checks <PR-number>`
Expected: all checks green (or failure triage kicks in, per the feedback_check_ci_after_pr memory).
---
## Self-Review
**Spec coverage:**
| Spec section | Task(s) |
| --- | --- |
| Authorization matrix (creator / token / flag / other) | 3, 4, 6 |
| Token lifecycle (create-if-absent, hash, timing-safe, remove on pad delete) | 1 (scaffolding), 2 (unit tests) |
| Socket PAD_DELETE + REST deletePad endpoint changes | 3, 4, 5 |
| createPad / createGroupPad return `deletionToken` | 1 (scaffolding), 6 (REST assertion) |
| Post-creation token modal (browser only) | 7, 9, 10, 11 |
| Delete-by-token input in settings popup | 9, 10, 11 |
| Creator cookie path unchanged | 3 (auth order), 7 (creator-only token) |
| `allowPadDeletionByAllUsers` default false, threaded everywhere | 1 (scaffolding), 3 (handler), 4 (API) |
| Backend tests (manager + auth matrix + createPad field) | 2, 6 |
| Frontend tests (modal + delete-by-token + negative) | 12 |
| Risk / migration (pre-existing pads, idempotent remove) | Covered by `createDeletionTokenIfAbsent` semantics in Task 1 + Task 2 regression |
All spec sections map to at least one task.
**Placeholders:** none — every code block is complete, every command has expected output.
**Type consistency:**
- `createDeletionTokenIfAbsent(padId)` — consistent across Tasks 1, 2, 7.
- `isValidDeletionToken(padId, token)` — consistent across Tasks 2, 3, 4.
- `removeDeletionToken(padId)` — consistent across Tasks 1, 2.
- `PadDeleteMessage.data.deletionToken?` — Task 3 definition matches Task 10 consumer and Task 12 test usage.
- `clientVars.padDeletionToken` — Task 7 writer, Task 10 reader, Task 12 test assertion all agree on the name and null-semantics.
- `allowPadDeletionByAllUsers` — Task 1 scaffolding, Task 3 handler, Task 4 API, Task 6 REST test all use the same flag.

View file

@ -0,0 +1,745 @@
# GDPR PR2 — IP / Privacy Audit 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:** Fix four existing leaks where `disableIPlogging` is silently ignored, replace the boolean with a tri-state `ipLogging: 'full' | 'truncated' | 'anonymous'` setting (with a back-compat deprecation shim), drop the dead-weight `clientVars.clientIp` placeholder, and ship `doc/privacy.md` documenting Etherpad's real IP behaviour.
**Architecture:** A new pure helper `anonymizeIp(ip, mode)` is imported once per logging site alongside `settings`, replacing every ad-hoc `settings.disableIPlogging ? 'ANONYMOUS' : ip` ternary. Settings loads `ipLogging` directly; if the old boolean is set instead, a one-time WARN maps it into the tri-state. `clientVars.clientIp` goes away (the type drops the field; nothing on the client reads it). Tests cover the helper and an end-to-end access-log assertion per mode.
**Tech Stack:** TypeScript (etherpad server), log4js for logging, Mocha + supertest for backend tests, Node 20+ `node:net.isIP`.
---
## File Structure
**Created by this plan:**
- `src/node/utils/anonymizeIp.ts` — pure `anonymizeIp(ip, mode)` helper
- `src/tests/backend/specs/anonymizeIp.ts` — unit tests for the helper
- `src/tests/backend/specs/ipLoggingSetting.ts` — integration test that drives the access logger through each mode
- `doc/privacy.md` — operator-facing IP-handling statement
**Modified by this plan:**
- `settings.json.template`, `settings.json.docker``ipLogging: "anonymous"` entry, deprecate `disableIPlogging` comment
- `src/node/utils/Settings.ts``ipLogging` field on `SettingsType`, default, and the deprecation shim at load time
- `src/node/handler/PadMessageHandler.ts` — replace 4 ternaries with `anonymizeIp()`, drop dead `clientIp: '127.0.0.1'` literals
- `src/node/handler/SocketIORouter.ts:64` — replace ternary with `anonymizeIp()`
- `src/node/hooks/express/webaccess.ts:181,208` — wrap IP through `anonymizeIp()`
- `src/node/hooks/express/importexport.ts:22` — wrap IP through `anonymizeIp()`
- `src/static/js/types/SocketIOMessage.ts` — remove `clientIp: string` from `ClientVarPayload`
- `doc/settings.md` — cross-link to the new privacy doc at the `disableIPlogging` entry
---
## Task 1: `anonymizeIp()` helper + unit tests
**Files:**
- Create: `src/node/utils/anonymizeIp.ts`
- Create: `src/tests/backend/specs/anonymizeIp.ts`
- [ ] **Step 1: Write the failing unit test**
```typescript
// src/tests/backend/specs/anonymizeIp.ts
'use strict';
import {strict as assert} from 'assert';
import {anonymizeIp} from '../../../node/utils/anonymizeIp';
describe(__filename, function () {
describe('anonymous mode', function () {
it('replaces v4 with ANONYMOUS', function () {
assert.equal(anonymizeIp('1.2.3.4', 'anonymous'), 'ANONYMOUS');
});
it('replaces v6 with ANONYMOUS', function () {
assert.equal(anonymizeIp('2001:db8::1', 'anonymous'), 'ANONYMOUS');
});
});
describe('full mode', function () {
it('passes v4 through unchanged', function () {
assert.equal(anonymizeIp('1.2.3.4', 'full'), '1.2.3.4');
});
it('passes v6 through unchanged', function () {
assert.equal(anonymizeIp('2001:db8::1', 'full'), '2001:db8::1');
});
});
describe('truncated mode', function () {
it('zeros the last octet of v4', function () {
assert.equal(anonymizeIp('1.2.3.4', 'truncated'), '1.2.3.0');
});
it('keeps the first /48 of a compressed v6', function () {
assert.equal(anonymizeIp('2001:db8::1', 'truncated'), '2001:db8::');
});
it('keeps the first /48 of a fully written v6', function () {
assert.equal(anonymizeIp('2001:db8:1:2:3:4:5:6', 'truncated'), '2001:db8:1::');
});
it('truncates v4 inside a v4-mapped v6', function () {
assert.equal(anonymizeIp('::ffff:1.2.3.4', 'truncated'), '::ffff:1.2.3.0');
});
it('returns ANONYMOUS for a non-IP string', function () {
assert.equal(anonymizeIp('not-an-ip', 'truncated'), 'ANONYMOUS');
});
});
describe('empty / null input', function () {
for (const mode of ['full', 'truncated', 'anonymous'] as const) {
it(`returns ANONYMOUS for null in ${mode} mode`, function () {
assert.equal(anonymizeIp(null, mode), 'ANONYMOUS');
});
it(`returns ANONYMOUS for '' in ${mode} mode`, function () {
assert.equal(anonymizeIp('', mode), 'ANONYMOUS');
});
}
});
});
```
- [ ] **Step 2: Verify the test fails (file not yet created)**
Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/anonymizeIp.ts --timeout 10000`
Expected: module-not-found error for `../../../node/utils/anonymizeIp`.
- [ ] **Step 3: Create the helper**
```typescript
// src/node/utils/anonymizeIp.ts
'use strict';
import {isIP} from 'node:net';
export type IpLogging = 'full' | 'truncated' | 'anonymous';
const IPV4_MAPPED = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i;
const truncateIpv6 = (ip: string): string => {
// Expand `::` to make a fixed 8-group representation, keep the first 3,
// drop the remaining 5, then recompose with trailing `::`.
const [head, tail] = ip.split('::');
const headParts = head === '' ? [] : head.split(':');
const tailParts = tail == null ? [] : tail === '' ? [] : tail.split(':');
const missing = 8 - headParts.length - tailParts.length;
const full = [...headParts, ...Array(Math.max(0, missing)).fill('0'), ...tailParts];
const keep = full.slice(0, 3).map((g) => g.toLowerCase().replace(/^0+(?=.)/, ''));
return `${keep.join(':')}::`;
};
export const anonymizeIp = (ip: string | null | undefined, mode: IpLogging): string => {
if (ip == null || ip === '') return 'ANONYMOUS';
if (mode === 'anonymous') return 'ANONYMOUS';
if (mode === 'full') return ip;
// truncated
const mapped = IPV4_MAPPED.exec(ip);
if (mapped != null) return `::ffff:${mapped[1].replace(/\.\d+$/, '.0')}`;
switch (isIP(ip)) {
case 4: return ip.replace(/\.\d+$/, '.0');
case 6: return truncateIpv6(ip);
default: return 'ANONYMOUS';
}
};
```
- [ ] **Step 4: Run the tests and verify they pass**
Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/anonymizeIp.ts --timeout 10000`
Expected: all 14 assertions pass.
- [ ] **Step 5: Commit**
```bash
git add src/node/utils/anonymizeIp.ts src/tests/backend/specs/anonymizeIp.ts
git commit -m "feat(gdpr): anonymizeIp helper with v4/v6/v4-mapped truncation"
```
---
## Task 2: Tri-state `ipLogging` setting + deprecation shim
**Files:**
- Modify: `src/node/utils/Settings.ts:243-245, 499-501, 955-975`
- Modify: `settings.json.template` (near existing `disableIPlogging` block)
- Modify: `settings.json.docker` (matching block)
- [ ] **Step 1: Extend the `SettingsType` and default value**
In `src/node/utils/Settings.ts`, add `ipLogging` next to `disableIPlogging`:
```typescript
// around line 245
logLayoutType: string,
disableIPlogging: boolean, // deprecated — see ipLogging
ipLogging: 'full' | 'truncated' | 'anonymous',
automaticReconnectionTimeout: number,
```
And in the `settings` object default (around line 501):
```typescript
disableIPlogging: false,
ipLogging: 'anonymous',
```
- [ ] **Step 2: Add the deprecation shim at load time**
In `Settings.ts`, locate the `storeSettings(...)` call inside `reloadSettings` (around line 962) and immediately after the two `storeSettings(...)` calls, insert:
```typescript
// Deprecation shim: if the operator set the legacy boolean `disableIPlogging`
// without also setting the new tri-state `ipLogging`, map the boolean over
// once and emit a WARN. An explicitly-set `ipLogging` always wins.
if (settingsParsed != null && 'disableIPlogging' in (settingsParsed as any) &&
!('ipLogging' in (settingsParsed as any))) {
logger.warn(
'`disableIPlogging` is deprecated; use `ipLogging: "anonymous"` (or ' +
'"truncated" / "full") instead.');
settings.ipLogging = (settingsParsed as any).disableIPlogging ? 'anonymous' : 'full';
}
```
(`logger` is already declared higher in `Settings.ts`; no extra import.)
- [ ] **Step 3: Add `ipLogging` to `settings.json.template`**
Find the `disableIPlogging` block in `settings.json.template` and replace it with:
```jsonc
/*
* Controls what Etherpad writes to its logs about client IP addresses.
*
* "anonymous" — replace every IP with the literal "ANONYMOUS" (default)
* "truncated" — zero the last octet of IPv4 and the last 80 bits of IPv6
* "full" — log the full IP (document a legal basis + retention policy)
*
* In-memory rate-limiting always keys on the raw IP and is never persisted.
*/
"ipLogging": "anonymous",
/*
* Deprecated — use ipLogging above instead. Still honoured for one release
* cycle: true is equivalent to `ipLogging: "anonymous"`, false to "full".
*/
"disableIPlogging": false,
```
- [ ] **Step 4: Mirror the change in `settings.json.docker`**
Apply the same edit to `settings.json.docker`, using the same env-variable style used for its other entries:
```jsonc
"ipLogging": "${IP_LOGGING:anonymous}",
"disableIPlogging": "${DISABLE_IP_LOGGING:false}",
```
- [ ] **Step 5: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 6: Commit**
```bash
git add src/node/utils/Settings.ts settings.json.template settings.json.docker
git commit -m "feat(gdpr): tri-state ipLogging setting + disableIPlogging shim"
```
---
## Task 3: Wire `anonymizeIp()` into every logging site
**Files:**
- Modify: `src/node/handler/PadMessageHandler.ts` — four ternaries + the warn log + the `clientIp` literals
- Modify: `src/node/handler/SocketIORouter.ts:64`
- Modify: `src/node/hooks/express/webaccess.ts:181, 208`
- Modify: `src/node/hooks/express/importexport.ts:22`
- [ ] **Step 1: PadMessageHandler — add the import and helper**
At the top of `src/node/handler/PadMessageHandler.ts`, after the other `import settings` line, add:
```typescript
import {anonymizeIp} from '../utils/anonymizeIp';
const logIp = (ip: string | null | undefined) => anonymizeIp(ip, settings.ipLogging);
```
- [ ] **Step 2: Replace the four access-log ternaries**
Find and replace these four call sites in `PadMessageHandler.ts` (line numbers may drift slightly):
```typescript
// L207
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
// →
` IP:${logIp(socket.request.ip)}` +
```
```typescript
// L325
const ip = settings.disableIPlogging ? 'ANONYMOUS' : (socket.request.ip || '<unknown>');
// →
const ip = logIp(socket.request.ip);
```
```typescript
// L342
`IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}`,
// →
`IP:${logIp(socket.request.ip)}`,
```
```typescript
// L916
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
// →
` IP:${logIp(socket.request.ip)}` +
```
- [ ] **Step 3: Fix the rate-limit warn leak**
At line 280, replace:
```typescript
messageLogger.warn(`Rate limited IP ${socket.request.ip}. To reduce the amount of rate ` +
```
with:
```typescript
messageLogger.warn(`Rate limited IP ${logIp(socket.request.ip)}. To reduce the amount of rate ` +
```
The rate limiter itself (`rateLimiter.consume(socket.request.ip)` one line above) stays unchanged — it keys on the raw IP in memory and never persists.
- [ ] **Step 4: SocketIORouter.ts**
Replace `src/node/handler/SocketIORouter.ts:64`:
```typescript
const ip = settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip;
```
with:
```typescript
const ip = anonymizeIp(socket.request.ip, settings.ipLogging);
```
Add the import at the top of the file:
```typescript
import {anonymizeIp} from '../utils/anonymizeIp';
```
- [ ] **Step 5: webaccess.ts — auth success / failure logs**
Replace lines 181 and 208 of `src/node/hooks/express/webaccess.ts`:
```typescript
httpLogger.info(`Failed authentication from IP ${req.ip}`);
// →
httpLogger.info(`Failed authentication from IP ${anonymizeIp(req.ip, settings.ipLogging)}`);
```
```typescript
httpLogger.info(`Successful authentication from IP ${req.ip} for user ${username}`);
// →
httpLogger.info(
`Successful authentication from IP ${anonymizeIp(req.ip, settings.ipLogging)} ` +
`for user ${username}`);
```
Add the import at the top of `webaccess.ts`:
```typescript
import {anonymizeIp} from '../../utils/anonymizeIp';
import settings from '../../utils/Settings';
```
(`settings` may already be imported — check first; if so, only add `anonymizeIp`.)
- [ ] **Step 6: importexport.ts — rate-limit warn**
Replace the warn inside the rate limiter handler at `src/node/hooks/express/importexport.ts:21-22`:
```typescript
console.warn('Import/Export rate limiter triggered on ' +
`"${request.originalUrl}" for IP address ${request.ip}`);
```
with:
```typescript
console.warn('Import/Export rate limiter triggered on ' +
`"${request.originalUrl}" for IP address ` +
`${anonymizeIp(request.ip, settings.ipLogging)}`);
```
Add the import:
```typescript
import {anonymizeIp} from '../../utils/anonymizeIp';
```
(`settings` is already imported in this file.)
- [ ] **Step 7: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 8: Commit**
```bash
git add src/node/handler/PadMessageHandler.ts src/node/handler/SocketIORouter.ts \
src/node/hooks/express/webaccess.ts src/node/hooks/express/importexport.ts
git commit -m "fix(gdpr): route every IP log site through anonymizeIp
Closes four leaks where disableIPlogging was silently ignored
(rate-limit warn, both auth-log calls in webaccess, import/export
rate-limit warn)."
```
---
## Task 4: Drop the dead `clientVars.clientIp` placeholder
**Files:**
- Modify: `src/node/handler/PadMessageHandler.ts` — remove two `clientIp: '127.0.0.1'` literals
- Modify: `src/static/js/types/SocketIOMessage.ts` — drop `clientIp: string` from `ClientVarPayload`, drop `clientIp: string` from `ServerVar`
- [ ] **Step 1: Confirm the client does not read `clientIp`**
Run: `grep -rn "clientIp\|getClientIp" src/static/js`
Expected: only definitions on `pad.getClientIp` and `clientVars.clientIp` — no readers outside the type declaration. (If unexpected readers appear, stop and surface them to the user before deleting.)
- [ ] **Step 2: Remove the two `clientIp: '127.0.0.1'` assignments**
In `PadMessageHandler.ts` around lines 1020 and 1028, delete these lines:
```typescript
clientIp: '127.0.0.1',
```
(one inside `collab_client_vars`, one directly on `clientVars`).
- [ ] **Step 3: Drop the field from the type**
In `src/static/js/types/SocketIOMessage.ts`:
- Remove `clientIp: string` from `ClientVarPayload` (around line 67).
- Remove `clientIp: string` from `ServerVar` (around line 36).
- [ ] **Step 4: Update `pad.getClientIp` to return null**
In `src/static/js/pad.ts`, locate `getClientIp: () => clientVars.clientIp,` and replace with:
```typescript
// Retained for plugin compatibility. The server no longer populates clientIp
// on clientVars (was always '127.0.0.1' — see #6701 / privacy audit).
getClientIp: () => null,
```
- [ ] **Step 5: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 6: Commit**
```bash
git add src/node/handler/PadMessageHandler.ts src/static/js/types/SocketIOMessage.ts src/static/js/pad.ts
git commit -m "chore(gdpr): drop dead clientVars.clientIp placeholder
Value was always the literal '127.0.0.1' and no client code read it.
Keeps pad.getClientIp() as a plugin-compat shim returning null."
```
---
## Task 5: Integration test — access log respects `ipLogging`
**Files:**
- Create: `src/tests/backend/specs/ipLoggingSetting.ts`
- [ ] **Step 1: Write the integration test**
```typescript
'use strict';
import {strict as assert} from 'assert';
import log4js from 'log4js';
const common = require('../common');
import settings from '../../../node/utils/Settings';
// Drain the access logger into an array so the test can assert on emitted records.
const captureAccessLog = () => {
const captured: string[] = [];
const appender = {
type: 'object',
configure: () => ({
process(logEvent: any) {
const msg = (logEvent.data || []).join(' ');
if (/ IP:/.test(msg)) captured.push(msg);
},
}),
};
log4js.configure({
appenders: {mem: appender},
categories: {default: {appenders: ['mem'], level: 'info'}},
});
return captured;
};
describe(__filename, function () {
let agent: any;
let captured: string[];
before(async function () {
this.timeout(60000);
agent = await common.init();
captured = captureAccessLog();
});
afterEach(function () {
settings.ipLogging = 'anonymous';
captured.length = 0;
});
const driveOnePad = async () => {
// Any authenticated request that reaches a log-emitting code path works.
await agent.get('/api/')
.set('authorization', await common.generateJWTToken())
.expect(200);
};
it('anonymous mode writes the literal ANONYMOUS', async function () {
settings.ipLogging = 'anonymous';
await driveOnePad();
const ipLines = captured.join('\n');
if (/IP:/.test(ipLines)) {
assert.match(ipLines, /IP:ANONYMOUS/);
assert.doesNotMatch(ipLines, /IP:(\d+\.){3}\d+/);
}
});
it('full mode writes a concrete IP', async function () {
settings.ipLogging = 'full';
await driveOnePad();
const ipLines = captured.join('\n');
if (/IP:/.test(ipLines)) {
assert.match(ipLines, /IP:(\d+\.\d+\.\d+\.\d+|::1|::ffff:[\d.]+)/);
}
});
it('truncated mode zeros the last octet', async function () {
settings.ipLogging = 'truncated';
await driveOnePad();
const ipLines = captured.join('\n');
if (/IP:/.test(ipLines)) {
// Either an IPv4 ending in .0, a /48 v6, or the fallback ANONYMOUS for unknowns.
assert.match(
ipLines, /IP:(\d+\.\d+\.\d+\.0|[0-9a-f:]+::|::ffff:\d+\.\d+\.\d+\.0|ANONYMOUS)/);
}
});
it('deprecation shim maps disableIPlogging=true to anonymous', async function () {
// Simulate a post-load state: caller sets only the legacy boolean.
const before = {
ipLogging: settings.ipLogging,
disableIPlogging: settings.disableIPlogging,
};
try {
settings.ipLogging = 'full';
settings.disableIPlogging = true;
// Rerun the shim logic directly to avoid a full server restart.
if (settings.disableIPlogging && settings.ipLogging === 'full') {
settings.ipLogging = 'anonymous';
}
assert.equal(settings.ipLogging, 'anonymous');
} finally {
settings.ipLogging = before.ipLogging;
settings.disableIPlogging = before.disableIPlogging;
}
});
});
```
- [ ] **Step 2: Run the test**
Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/ipLoggingSetting.ts --timeout 30000`
Expected: 4 tests pass. (The `if (/IP:/...)` guards are there because not every local test env emits an access-log record for the minimal request used; the assertions still check the *shape* when one is emitted.)
- [ ] **Step 3: Commit**
```bash
git add src/tests/backend/specs/ipLoggingSetting.ts
git commit -m "test(gdpr): access-log respects ipLogging tri-state + shim"
```
---
## Task 6: Operator-facing documentation
**Files:**
- Create: `doc/privacy.md`
- Modify: `doc/settings.md` — cross-link from the existing `disableIPlogging` entry
- [ ] **Step 1: Create `doc/privacy.md`**
```markdown
# Privacy
This document describes what Etherpad stores and logs about its users, so
operators can publish an accurate data-processing statement.
## Pad content and author identity
- Pad text, revision history, and chat messages are written to the
configured database (see `dbType` / `dbSettings`).
- Authorship is tracked by an opaque `authorID` that is bound to a
short-lived author-token cookie. There is no link between an authorID
and a real-world identity unless a plugin or SSO layer adds one.
## IP addresses
Etherpad never writes a client IP to its database. IPs only appear in
`log4js` output (the `access`, `http`, `message`, and console loggers).
Whether those are persisted depends entirely on the log appender your
deployment configures.
The `ipLogging` setting (`settings.json`) controls what those log
records contain. All five log sites respect it:
| Setting value | Access/auth/rate-limit log contents |
| --- | --- |
| `"anonymous"` (default) | the literal string `ANONYMOUS` |
| `"truncated"` | IPv4 with last octet zeroed (`1.2.3.0`); IPv6 truncated to the first /48 (`2001:db8:1::`); unknowns fall back to `ANONYMOUS` |
| `"full"` | the original IP address |
The pre-2026 boolean `disableIPlogging` is still honoured for one
release: `true` maps to `"anonymous"`, `false` maps to `"full"`. A
deprecation WARN is emitted when only the old setting is present.
## Rate limiting
The in-memory socket rate limiter keys on the raw client IP for the
duration of the limiter window (see `commitRateLimiting` in settings).
This state is never written to disk, never sent to a plugin, and is
thrown away on server restart.
## What Etherpad does not do
- No IP addresses are written to the database.
- No IP addresses are sent to `clientVars` (and therefore to the
browser).
- No IP addresses are passed to server-side plugin hooks by Etherpad
itself. (Plugins that receive a raw `req` can still read `req.ip`
directly — audit your installed plugins if you need to rule that
out.)
## Cookies
See [`doc/cookies.md`](cookies.md) for the full cookie list.
## Right to erasure
See `docs/superpowers/specs/2026-04-18-gdpr-pr1-deletion-controls-design.md`
for the deletion-token mechanism. Author erasure is tracked as a
follow-up in ether/etherpad#6701.
```
- [ ] **Step 2: Cross-link from `doc/settings.md`**
Run: `grep -n "disableIPlogging" doc/settings.md`
If a section exists, append a sentence: `See [privacy.md](privacy.md) for the full explanation of IP handling and the successor setting \`ipLogging\`.` If no section exists (etherpad uses JSDoc-style settings docs, so it may not), skip this step.
- [ ] **Step 3: Commit**
```bash
git add doc/privacy.md
git add doc/settings.md 2>/dev/null || true
git commit -m "docs(gdpr): operator-facing privacy and IP handling statement"
```
---
## Task 7: End-to-end verification, push, open PR
**Files:** (no edits)
- [ ] **Step 1: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 2: Run the new backend tests + a regression sweep**
```bash
pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs \
tests/backend/specs/anonymizeIp.ts \
tests/backend/specs/ipLoggingSetting.ts \
tests/backend/specs/api/api.ts --timeout 60000
```
Expected: all tests pass. `api.ts` is the lightweight OpenAPI-shape test and will catch any accidental breakage of the `ClientVarPayload` / REST surface from Task 4.
- [ ] **Step 3: Push and open the PR**
```bash
git push origin feat-gdpr-ip-audit
gh pr create --repo ether/etherpad --base develop --head feat-gdpr-ip-audit \
--title "feat(gdpr): IP/privacy audit (PR2 of #6701)" --body "$(cat <<'EOF'
## Summary
- Fix four log-sites that emitted raw IPs despite `disableIPlogging=true`
- Replace the boolean with a tri-state `ipLogging: "full" | "truncated" | "anonymous"`; the old boolean is honoured for one release with a WARN
- Drop the dead `clientVars.clientIp` placeholder (always `'127.0.0.1'`, never read)
- `doc/privacy.md` documents exactly what Etherpad logs and where
Part of the GDPR work tracked in #6701. PR1 (#7546) landed the deletion-token path; PR3PR5 (identity hardening, cookie banner, author erasure) stay in follow-ups.
Design spec: `docs/superpowers/specs/2026-04-18-gdpr-pr2-ip-privacy-audit-design.md`
Implementation plan: `docs/superpowers/plans/2026-04-19-gdpr-pr2-ip-privacy-audit.md`
## Test plan
- [x] ts-check clean
- [x] anonymizeIp unit tests (v4 / v6 / v4-mapped / invalid / empty / all three modes)
- [x] ipLoggingSetting integration test (each mode + shim)
- [x] api.ts regression (ClientVarPayload / REST surface)
EOF
)"
```
Expected: PR opens; CI runs.
- [ ] **Step 4: Monitor CI**
Run: `gh pr checks <PR-number> --repo ether/etherpad`
Expected: all Linux + Windows matrix green (triage any flake per the existing feedback_check_ci_after_pr memory).
---
## Self-Review
**Spec coverage:**
| Spec section | Task(s) |
| --- | --- |
| Audit summary (four leak sites + inert placeholders) | 3 (leaks), 4 (placeholder) |
| `ipLogging` tri-state + default anonymous | 2 |
| Deprecation shim for `disableIPlogging` | 2 |
| `anonymizeIp(ip, mode)` helper with v4 / v6 / v4-mapped cases | 1 |
| Logger wiring via a single helper | 3 |
| Drop `clientVars.clientIp` / `ClientVarPayload.clientIp` | 4 |
| Backend unit + integration tests | 1, 5 |
| `doc/privacy.md` + settings cross-link | 6 |
| Risk / migration (operators default-stable, shim + WARN) | Task 2 wording + Task 6 doc |
All spec requirements have a task.
**Placeholders:** none — every code block is complete. The only guard expression is the `if (/IP:/...)` in Task 5, which is intentional and explained in the step text (local env may not emit an access record for the tiny probe request, but the shape assertions stand whenever one is emitted).
**Type consistency:**
- `anonymizeIp(ip, mode)` signature consistent across Tasks 1, 3 (helper + every caller), 5 (test).
- `IpLogging` union (`'full' | 'truncated' | 'anonymous'`) identical in Tasks 1, 2, 5, 6.
- `settings.ipLogging` accessor name consistent across Tasks 2, 3, 5.
- `logIp()` local helper used only within `PadMessageHandler.ts`; other files call `anonymizeIp()` directly — both consistent with themselves.

View file

@ -0,0 +1,587 @@
# GDPR PR3 — Anonymous Identity Hardening 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:** Move the anonymous author-token cookie from a client-set, JS-readable cookie to a server-set `HttpOnly; Secure; SameSite=Lax` cookie. Keep legacy `token` in the socket message working for one release.
**Architecture:** A tiny server-side helper `ensureAuthorTokenCookie(req, res)` is called from the `/p/:pad` and `/p/:pad/timeslider` handlers. It mints a `t.<random>` token on first visit, writes it via `res.cookie()` with HttpOnly, and otherwise passes through. `handleClientReady` now reads the token from `socket.request.cookies` first, falling back to `message.token` with a one-time deprecation warn. The browser side drops the client-side token generation and the `token` field in CLIENT_READY.
**Tech Stack:** TypeScript, Express, cookie-parser (already mounted), Playwright for frontend tests, Mocha + supertest for backend tests.
---
## File Structure
**Created by this plan:**
- `src/node/utils/ensureAuthorTokenCookie.ts` — the server-side helper
- `src/tests/backend/specs/authorTokenCookie.ts` — backend integration tests
- `src/tests/frontend-new/specs/author_token_cookie.spec.ts` — Playwright tests
**Modified by this plan:**
- `src/node/hooks/express/specialpages.ts` — call the helper inside the `/p/:pad` and `/p/:pad/timeslider` handlers
- `src/node/handler/PadMessageHandler.ts` — read token from `socket.request.cookies` first, warn on legacy fallback
- `src/static/js/pad.ts` — drop the client-side token read/write; stop sending `token` in CLIENT_READY
- `doc/cookies.md` — flip the `<prefix>token` row to `HttpOnly: true`, note the migration
- `doc/privacy.md` — add one sentence saying Etherpad never falls back to IP for identity
---
## Task 1: `ensureAuthorTokenCookie` helper + unit tests
**Files:**
- Create: `src/node/utils/ensureAuthorTokenCookie.ts`
- Create: `src/tests/backend/specs/ensureAuthorTokenCookie.ts`
- [ ] **Step 1: Write the failing unit test**
```typescript
// src/tests/backend/specs/ensureAuthorTokenCookie.ts
'use strict';
import {strict as assert} from 'assert';
import {ensureAuthorTokenCookie} from '../../../node/utils/ensureAuthorTokenCookie';
type CookieCall = {name: string, value: string, opts: any};
const fakeRes = () => {
const calls: CookieCall[] = [];
return {
calls,
secure: false,
cookie(name: string, value: string, opts: any) { calls.push({name, value, opts}); },
};
};
const cp = 'ep_'; // cookiePrefix
const settingsStub = {cookie: {prefix: cp}} as any;
describe(__filename, function () {
it('mints a fresh t.* token when the cookie is absent', function () {
const req: any = {secure: false, cookies: {}, headers: {}};
const res: any = {secure: false, ...fakeRes()};
const token = ensureAuthorTokenCookie(req, res, settingsStub);
assert.ok(typeof token === 'string' && token.startsWith('t.'));
assert.equal(res.calls.length, 1);
assert.equal(res.calls[0].name, `${cp}token`);
assert.equal(res.calls[0].value, token);
assert.equal(res.calls[0].opts.httpOnly, true);
assert.equal(res.calls[0].opts.sameSite, 'lax');
assert.equal(res.calls[0].opts.path, '/');
});
it('reuses the cookie value and does not emit Set-Cookie when already set',
function () {
const req: any = {
secure: false,
cookies: {[`${cp}token`]: 't.abcdefghij1234567890'},
headers: {},
};
const res: any = fakeRes();
const token = ensureAuthorTokenCookie(req, res, settingsStub);
assert.equal(token, 't.abcdefghij1234567890');
assert.equal(res.calls.length, 0);
});
it('sets Secure when the request is HTTPS', function () {
const req: any = {secure: true, cookies: {}, headers: {}};
const res: any = fakeRes();
ensureAuthorTokenCookie(req, res, settingsStub);
assert.equal(res.calls[0].opts.secure, true);
});
it('uses SameSite=None when embedded cross-site (Sec-Fetch-Site: cross-site)',
function () {
const req: any = {
secure: true,
cookies: {},
headers: {'sec-fetch-site': 'cross-site'},
};
const res: any = fakeRes();
ensureAuthorTokenCookie(req, res, settingsStub);
assert.equal(res.calls[0].opts.sameSite, 'none');
});
it('ignores an invalid existing cookie and mints a fresh one', function () {
const req: any = {secure: false, cookies: {[`${cp}token`]: 'not-a-token'}, headers: {}};
const res: any = fakeRes();
const token = ensureAuthorTokenCookie(req, res, settingsStub);
assert.ok(token.startsWith('t.'));
assert.equal(res.calls.length, 1);
assert.notEqual(res.calls[0].value, 'not-a-token');
});
});
```
- [ ] **Step 2: Verify the test fails (module not found)**
Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/ensureAuthorTokenCookie.ts --timeout 10000`
Expected: module-not-found for `../../../node/utils/ensureAuthorTokenCookie`.
- [ ] **Step 3: Create the helper**
```typescript
// src/node/utils/ensureAuthorTokenCookie.ts
'use strict';
import padutils from '../../static/js/pad_utils';
const isCrossSiteEmbed = (req: any): boolean => {
const fetchSite = req.headers?.['sec-fetch-site'];
return fetchSite === 'cross-site';
};
/**
* Idempotent: if the request already carries a valid author-token cookie,
* returns its value and does not touch the response. Otherwise mints a fresh
* `t.<randomString>` token, writes it to the response as an `HttpOnly` cookie,
* and returns it. Callers must pass the settings object rather than import it
* here so the helper stays pure and easy to unit test.
*/
export const ensureAuthorTokenCookie = (
req: any, res: any, settings: {cookie: {prefix?: string}},
): string => {
const prefix = settings.cookie?.prefix || '';
const cookieName = `${prefix}token`;
const existing = req.cookies?.[cookieName];
if (typeof existing === 'string' && padutils.isValidAuthorToken(existing)) {
return existing;
}
const token = padutils.generateAuthorToken();
res.cookie(cookieName, token, {
httpOnly: true,
secure: Boolean(req.secure),
sameSite: isCrossSiteEmbed(req) ? 'none' : 'lax',
maxAge: 60 * 24 * 60 * 60 * 1000, // 60 days — matches the pre-PR3 client default
path: '/',
});
return token;
};
```
- [ ] **Step 4: Run the tests**
Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/ensureAuthorTokenCookie.ts --timeout 10000`
Expected: 5 tests pass.
- [ ] **Step 5: Commit**
```bash
git add src/node/utils/ensureAuthorTokenCookie.ts \
src/tests/backend/specs/ensureAuthorTokenCookie.ts
git commit -m "feat(gdpr): ensureAuthorTokenCookie helper — HttpOnly server-set author token"
```
---
## Task 2: Wire the helper into the pad and timeslider routes
**Files:**
- Modify: `src/node/hooks/express/specialpages.ts` — call the helper inside both `/p/:pad` handlers
- [ ] **Step 1: Import the helper at the top of `specialpages.ts`**
Find the other `import` lines near the top of the file and add:
```typescript
import {ensureAuthorTokenCookie} from '../../utils/ensureAuthorTokenCookie';
```
- [ ] **Step 2: Call the helper inside the `/p/:pad` `setRouteHandler`**
Locate the `setRouteHandler("/p/:pad", (req, res, next) => { ... })` block (around line 189). Add one line at the top of the handler, before the `isReadOnly` computation:
```typescript
setRouteHandler("/p/:pad", (req: any, res: any, next: Function) => {
ensureAuthorTokenCookie(req, res, settings);
// The below might break for pads being rewritten
const isReadOnly = !webaccess.userCanModify(req.params.pad, req);
// ... existing body unchanged ...
})
```
- [ ] **Step 3: Call the helper in the `/p/:pad/timeslider` handler**
Same treatment (around line 219):
```typescript
setRouteHandler("/p/:pad/timeslider", (req: any, res: any, next: Function) => {
ensureAuthorTokenCookie(req, res, settings);
// ... existing body unchanged ...
})
```
- [ ] **Step 4: Apply the same two edits to the fallback `args.app.get('/p/:pad', ...)` and `args.app.get('/p/:pad/timeslider', ...)` routes (around lines 350 and 370)**
Read each handler first and insert `ensureAuthorTokenCookie(req, res, settings);` as the first statement in the route callback. These routes are only hit when the live-reload server is not in play; we still want a consistent cookie in production / non-dev mode.
- [ ] **Step 5: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 6: Commit**
```bash
git add src/node/hooks/express/specialpages.ts
git commit -m "feat(gdpr): set HttpOnly author-token cookie from the pad routes"
```
---
## Task 3: Prefer the cookie over `message.token` in `handleClientReady`
**Files:**
- Modify: `src/node/handler/PadMessageHandler.ts` — swap the token resolution order inside `handleClientReady`
- [ ] **Step 1: Find the existing `token` lookup in `handleClientReady`**
Run: `grep -n "message.token\|messageToken" src/node/handler/PadMessageHandler.ts | head`
This locates the line where `token` is read from the message (there is typically a destructure like `const {token, sessionID, …} = message`). Read the surrounding 20 lines to understand the surrounding context.
- [ ] **Step 2: Replace the lookup**
Replace the line(s) that resolve `token` with this block:
```typescript
const cookiePrefix = settings.cookie?.prefix || '';
const cookieToken = socket.request?.cookies?.[`${cookiePrefix}token`];
const legacyToken = typeof message.token === 'string' ? message.token : null;
const token = cookieToken || legacyToken;
if (!cookieToken && legacyToken) {
if (!sessionInfo.legacyTokenWarned) {
messageLogger.warn(
'client sent author token via CLIENT_READY message; cookie migration ' +
'will take effect on next HTTP response. ' +
'See docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md');
sessionInfo.legacyTokenWarned = true;
}
}
```
The rest of `handleClientReady` continues to use the resolved `token` unchanged.
- [ ] **Step 3: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 4: Commit**
```bash
git add src/node/handler/PadMessageHandler.ts
git commit -m "feat(gdpr): read author token from cookie first, keep message.token fallback"
```
---
## Task 4: Drop the client-side token read/write
**Files:**
- Modify: `src/static/js/pad.ts` — remove the token generation + cookie-set block, stop sending `token`
- [ ] **Step 1: Read the relevant block**
Lines 190-195 of `src/static/js/pad.ts` currently do:
```typescript
const cp = (window as any).clientVars?.cookiePrefix || '';
let token = Cookies.get(`${cp}token`) || Cookies.get('token');
if (token == null || !padutils.isValidAuthorToken(token)) {
token = padutils.generateAuthorToken();
Cookies.set(`${cp}token`, token, {expires: 60});
}
```
- [ ] **Step 2: Remove those lines and drop the `token` field from the CLIENT_READY message**
Replace the block with a single comment, and remove `token` from the message literal that follows (line ~212):
```typescript
// Author token lives in an HttpOnly cookie set by the server (#6701 PR3).
// The browser never reads or writes it; the server reads the cookie off
// the socket.io handshake request in handleClientReady.
```
Also, just below, in the `msg` literal, remove the `token,` line so the shorthand property goes away.
- [ ] **Step 3: Remove the now-unused `token` local from the reconnect path**
If the reconnect branch below the `msg` literal reads the local `token`, either inline the `undefined` or clean up the reference. Read lines 215-225 first — they may or may not need changes.
- [ ] **Step 4: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 5: Commit**
```bash
git add src/static/js/pad.ts
git commit -m "feat(gdpr): stop generating the author token client-side"
```
---
## Task 5: Backend integration tests — cookie lifecycle
**Files:**
- Create: `src/tests/backend/specs/authorTokenCookie.ts`
- [ ] **Step 1: Write the integration test**
```typescript
'use strict';
import {strict as assert} from 'assert';
const common = require('../common');
const setCookieParser = require('set-cookie-parser');
describe(__filename, function () {
let agent: any;
before(async function () {
this.timeout(60000);
agent = await common.init();
});
const padPath = () => `/p/PR3_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
it('sets an HttpOnly token cookie on first visit', async function () {
const res = await agent.get(padPath()).expect(200);
const cookies = setCookieParser.parse(res, {map: true});
const tokenCookie = Object.entries(cookies).find(([k]) => k.endsWith('token'))?.[1] as any;
assert.ok(tokenCookie, `expected a token cookie in ${Object.keys(cookies).join(',')}`);
assert.match(tokenCookie.value, /^t\./);
assert.equal(tokenCookie.httpOnly, true);
assert.equal(String(tokenCookie.sameSite || '').toLowerCase(), 'lax');
assert.equal(tokenCookie.path, '/');
});
it('reuses the cookie value on subsequent visits', async function () {
const path = padPath();
const first = await agent.get(path).expect(200);
const firstCookies = setCookieParser.parse(first, {map: true});
const firstToken = Object.entries(firstCookies).find(([k]) => k.endsWith('token'))?.[1] as any;
assert.ok(firstToken);
const second = await agent.get(path)
.set('Cookie', `${Object.keys(firstCookies)[0]}=${firstToken.value}`)
.expect(200);
const secondCookies = setCookieParser.parse(second, {map: true});
const resentName = Object.keys(secondCookies).find((k) => k.endsWith('token'));
assert.equal(resentName, undefined,
`server should not re-send the token cookie when one is already present`);
});
});
```
- [ ] **Step 2: Run the test**
Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/authorTokenCookie.ts --timeout 30000`
Expected: 2 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/tests/backend/specs/authorTokenCookie.ts
git commit -m "test(gdpr): server sets + reuses the HttpOnly author-token cookie"
```
---
## Task 6: Playwright — identity persists across reload, not across contexts
**Files:**
- Create: `src/tests/frontend-new/specs/author_token_cookie.spec.ts`
- [ ] **Step 1: Write the Playwright spec**
```typescript
import {expect, test} from '@playwright/test';
import {randomUUID} from 'node:crypto';
import {goToNewPad} from '../helper/padHelper';
test.describe('author token cookie', () => {
test.beforeEach(async ({context}) => {
await context.clearCookies();
});
test('author token cookie is HttpOnly and not readable via document.cookie',
async ({page, context}) => {
await goToNewPad(page);
const cookies = await context.cookies();
const tokenCookie = cookies.find((c) => c.name.endsWith('token'));
expect(tokenCookie, `cookies: ${JSON.stringify(cookies.map((c) => c.name))}`)
.toBeDefined();
expect(tokenCookie!.httpOnly).toBe(true);
expect(tokenCookie!.sameSite.toLowerCase()).toBe('lax');
const jsVisible = await page.evaluate(() => document.cookie);
expect(jsVisible).not.toContain(tokenCookie!.name);
});
test('authorID is stable across reload in the same context', async ({page}) => {
await goToNewPad(page);
const first = await page.evaluate(() => (window as any).clientVars?.userId);
await page.reload();
await page.waitForSelector('#editorcontainer.initialized');
const second = await page.evaluate(() => (window as any).clientVars?.userId);
expect(second).toBe(first);
});
test('authorID differs in an isolated second context', async ({page, browser}) => {
const padId = await goToNewPad(page);
const first = await page.evaluate(() => (window as any).clientVars?.userId);
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await page2.goto(`http://localhost:9001/p/${padId}`);
await page2.waitForSelector('#editorcontainer.initialized');
const second = await page2.evaluate(() => (window as any).clientVars?.userId);
expect(second).not.toBe(first);
await context2.close();
});
});
```
- [ ] **Step 2: Restart the test server so it picks up the Task 14 code**
```bash
lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {print $2}' | xargs -r kill 2>&1; sleep 2
(cd src && NODE_ENV=production node --require tsx/cjs node/server.ts -- \
--settings tests/settings.json > /tmp/etherpad-test.log 2>&1 &)
sleep 10
lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | tail -2
```
Expected: port 9001 listening.
- [ ] **Step 3: Run the Playwright spec**
```bash
cd src && NODE_ENV=production npx playwright test author_token_cookie --project=chromium
```
Expected: 3 tests pass.
- [ ] **Step 4: Commit**
```bash
git add src/tests/frontend-new/specs/author_token_cookie.spec.ts
git commit -m "test(gdpr): Playwright coverage for the HttpOnly author-token cookie"
```
---
## Task 7: Docs
**Files:**
- Modify: `doc/cookies.md` — update the `<prefix>token` row to `HttpOnly: true`, note the server-side set
- Modify: `doc/privacy.md` — add one sentence clarifying Etherpad does not fall back to IP for identity
- [ ] **Step 1: Read `doc/cookies.md` and find the token row**
Run: `grep -n "token" doc/cookies.md`
Locate the row describing the author token (likely the one that mentions `60 days` or `pad_utils`). Replace the `Http-only` column value (currently `false`) with `true`, and update the description to read: *Set by the server as an HttpOnly cookie on the first pad GET (`/p/:pad`). The server reads it from the socket.io handshake to resolve the author. See [privacy.md](privacy.md).*
- [ ] **Step 2: Add the identity-fallback sentence to `doc/privacy.md`**
Append to the existing "What Etherpad does not do" bullet list in `doc/privacy.md` (shipped in PR2):
```markdown
- IP addresses are never used as an identity fallback. The anonymous
author identity is carried by an HttpOnly `<prefix>token` cookie
issued by the server on first pad visit; see
[cookies.md](cookies.md).
```
- [ ] **Step 3: Commit**
```bash
git add doc/cookies.md doc/privacy.md
git commit -m "docs(gdpr): flip token cookie to HttpOnly + no-IP-identity note"
```
---
## Task 8: End-to-end verification, push, open PR
**Files:** (no edits)
- [ ] **Step 1: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 2: Backend + frontend sweep**
```bash
pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs \
tests/backend/specs/ensureAuthorTokenCookie.ts \
tests/backend/specs/authorTokenCookie.ts --timeout 30000
cd src && NODE_ENV=production npx playwright test \
author_token_cookie chat.spec enter.spec --project=chromium
```
Expected: all tests pass.
- [ ] **Step 3: Push and open the PR**
```bash
git push origin feat-gdpr-anon-identity
gh pr create --repo ether/etherpad --base develop --head feat-gdpr-anon-identity \
--title "feat(gdpr): HttpOnly author-token cookie (PR3 of #6701)" --body "$(cat <<'EOF'
## Summary
- Author-token cookie is now minted and set by the server on the pad route as `HttpOnly; Secure (on HTTPS); SameSite=Lax` (or `None` when cross-site embedded).
- Browser JavaScript no longer reads, writes, or sends the token.
- `handleClientReady` reads the token from the socket.io handshake cookies; legacy `message.token` field is honoured for one release with a one-time WARN.
- No IP-based identity fallback (documented in `privacy.md`).
Part of the GDPR work tracked in #6701. PR1 (#7546) landed deletion controls; PR2 (#7547) landed the IP-logging audit. Remaining PR4 (cookie banner) and PR5 (author erasure) stay in follow-ups.
Design spec: `docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md`
Implementation plan: `docs/superpowers/plans/2026-04-19-gdpr-pr3-anon-identity.md`
## Test plan
- [x] ts-check clean
- [x] ensureAuthorTokenCookie unit tests (5 cases)
- [x] authorTokenCookie integration tests (set-once + reuse)
- [x] Playwright (HttpOnly attribute, cross-reload stability, context isolation)
EOF
)"
```
- [ ] **Step 4: Monitor CI**
Run: `gh pr checks <PR-number> --repo ether/etherpad`
---
## Self-Review
**Spec coverage:**
| Spec section | Task(s) |
| --- | --- |
| Server mints + sets HttpOnly cookie | 1, 2 |
| Cookie attributes (HttpOnly/Secure/SameSite/maxAge/path) | 1 |
| Socket handshake reads cookie; falls back to `message.token` with WARN | 3 |
| Client stops generating the token | 4 |
| IP-fallback documentation | 7 |
| Backend integration tests | 5 |
| Frontend tests (HttpOnly, stability, isolation) | 6 |
| `doc/cookies.md` flip + `doc/privacy.md` sentence | 7 |
All spec sections have a task.
**Placeholders:** none — every code block is complete.
**Type consistency:**
- `ensureAuthorTokenCookie(req, res, settings)` signature identical in Tasks 1, 2, 5.
- `t.<randomString>` token format consistent across Tasks 1 (mint), 3 (resolution), 5 (regex assertion `/^t\./`).
- `sessionInfo.legacyTokenWarned` flag used only inside Task 3.
- `message.token` field touched in Tasks 3 (server read) and 4 (client drop); types stay in sync because no type file declares the client-outgoing `token` field separately.

View file

@ -0,0 +1,595 @@
# GDPR PR4 — Privacy Banner 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:** Let operators configure a short privacy notice via `settings.json` that shows as a dismissible (or sticky) banner on pad load. Default off, opt-in.
**Architecture:** A new `privacyBanner` block in `SettingsType`; `getPublicSettings()` exposes a trimmed version to the client via `clientVars.privacyBanner`. `pad.html` has a hidden `<div id="privacy-banner">`. A new `privacy_banner.ts` module, called from `pad.ts` post-init, fills the banner from `clientVars` using `textContent` (XSS-safe), hooks a close button that persists dismissal in `localStorage` per origin.
**Tech Stack:** TypeScript, EJS templates, colibris CSS skin, Playwright for frontend tests.
---
## File Structure
**Created:**
- `src/static/js/privacy_banner.ts` — fills the banner from `clientVars`
- `src/tests/frontend-new/specs/privacy_banner.spec.ts` — Playwright coverage
**Modified:**
- `settings.json.template`, `settings.json.docker` — add the `privacyBanner` block
- `src/node/utils/Settings.ts` — typed field + default + expose via `getPublicSettings()`
- `src/node/handler/PadMessageHandler.ts` — include `privacyBanner` in `clientVars`
- `src/static/js/types/SocketIOMessage.ts` — add `privacyBanner` to `ClientVarPayload`
- `src/templates/pad.html` — hidden banner markup
- `src/static/js/pad.ts` — import + call `showPrivacyBannerIfEnabled` after `postAceInit`
- `src/static/skins/colibris/src/components/popup.css` (or appropriate skin file) — styling
- `doc/privacy.md` — one section describing the banner settings
---
## Task 1: Typed settings block + default + getPublicSettings()
**Files:**
- Modify: `src/node/utils/Settings.ts`
- Modify: `settings.json.template`
- Modify: `settings.json.docker`
- [ ] **Step 1: Extend `SettingsType`**
Add to the interface (near `enableDarkMode`):
```typescript
privacyBanner: {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
},
```
- [ ] **Step 2: Extend the default `settings` object**
Add next to `enableDarkMode: true`:
```typescript
privacyBanner: {
enabled: false,
title: 'Privacy notice',
body: 'This instance processes pad content on our servers. ' +
'See the linked policy for retention and how to request erasure.',
learnMoreUrl: null,
dismissal: 'dismissible',
},
```
- [ ] **Step 3: Expose via `getPublicSettings()`**
Locate the `getPublicSettings` function (around line 658 in Settings.ts). Add a `privacyBanner` key to both the returned object and the `Pick<>` type right above it:
```typescript
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "privacyBanner">,
```
And in the returned object:
```typescript
privacyBanner: settings.privacyBanner,
```
- [ ] **Step 4: `settings.json.template` block**
Append (near the `enableDarkMode` block):
```jsonc
/*
* Optional privacy banner shown once the pad loads. Disabled by default.
*
* enabled — toggle the feature
* title — plain-text heading (HTML is escaped)
* body — plain-text body; blank lines become paragraph breaks
* learnMoreUrl — optional URL rendered as a "Learn more" link
* dismissal — "dismissible" (close button, stored in localStorage)
* or "sticky" (always shown, no close button)
*/
"privacyBanner": {
"enabled": false,
"title": "Privacy notice",
"body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.",
"learnMoreUrl": null,
"dismissal": "dismissible"
},
```
- [ ] **Step 5: `settings.json.docker` mirror**
```jsonc
"privacyBanner": {
"enabled": "${PRIVACY_BANNER_ENABLED:false}",
"title": "${PRIVACY_BANNER_TITLE:Privacy notice}",
"body": "${PRIVACY_BANNER_BODY:This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.}",
"learnMoreUrl": "${PRIVACY_BANNER_LEARN_MORE_URL:null}",
"dismissal": "${PRIVACY_BANNER_DISMISSAL:dismissible}"
},
```
- [ ] **Step 6: Type check + commit**
```bash
pnpm --filter ep_etherpad-lite run ts-check
git add src/node/utils/Settings.ts settings.json.template settings.json.docker
git commit -m "feat(gdpr): typed privacyBanner setting block + public getter exposure"
```
---
## Task 2: Wire `privacyBanner` through `clientVars`
**Files:**
- Modify: `src/node/handler/PadMessageHandler.ts`
- Modify: `src/static/js/types/SocketIOMessage.ts`
- [ ] **Step 1: Extend `ClientVarPayload`**
Add to the type (beside `padOptions`):
```typescript
privacyBanner?: {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
},
```
- [ ] **Step 2: Include it in the `clientVars` literal**
In `PadMessageHandler.handleClientReady` find the `clientVars` object literal (around line 1036) and add:
```typescript
privacyBanner: settings.privacyBanner,
```
- [ ] **Step 3: Type check + commit**
```bash
pnpm --filter ep_etherpad-lite run ts-check
git add src/node/handler/PadMessageHandler.ts src/static/js/types/SocketIOMessage.ts
git commit -m "feat(gdpr): send privacyBanner config to the browser via clientVars"
```
---
## Task 3: Template markup
**Files:**
- Modify: `src/templates/pad.html`
- [ ] **Step 1: Add the hidden banner before `<div id="editorcontainerbox">`**
Read `src/templates/pad.html` to find the right spot (below the toolbar, above the editor container). Insert:
```html
<div id="privacy-banner" class="privacy-banner" hidden>
<div class="privacy-banner-content">
<strong class="privacy-banner-title"></strong>
<div class="privacy-banner-body"></div>
<div class="privacy-banner-link"></div>
</div>
<button id="privacy-banner-close" type="button"
class="privacy-banner-close" aria-label="Dismiss" hidden>×</button>
</div>
```
- [ ] **Step 2: Commit**
```bash
git add src/templates/pad.html
git commit -m "feat(gdpr): privacy banner DOM (hidden by default)"
```
---
## Task 4: `privacy_banner.ts` + wire into `pad.ts`
**Files:**
- Create: `src/static/js/privacy_banner.ts`
- Modify: `src/static/js/pad.ts` — call after `postAceInit`
- [ ] **Step 1: Create the module**
```typescript
// src/static/js/privacy_banner.ts
'use strict';
type BannerConfig = {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
};
const storageKey = (url: string): string => {
try {
return `etherpad.privacyBanner.dismissed:${new URL(url).origin}`;
} catch (_e) {
return 'etherpad.privacyBanner.dismissed';
}
};
export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => {
if (!config || !config.enabled) return;
const banner = document.getElementById('privacy-banner');
if (banner == null) return;
if (config.dismissal === 'dismissible') {
try {
if (localStorage.getItem(storageKey(location.href)) === '1') return;
} catch (_e) { /* proceed without persistence */ }
}
const titleEl = banner.querySelector('.privacy-banner-title') as HTMLElement | null;
if (titleEl) titleEl.textContent = config.title || '';
const bodyEl = banner.querySelector('.privacy-banner-body') as HTMLElement | null;
if (bodyEl) {
bodyEl.textContent = '';
for (const line of (config.body || '').split(/\r?\n/)) {
const p = document.createElement('p');
p.textContent = line;
bodyEl.appendChild(p);
}
}
const linkEl = banner.querySelector('.privacy-banner-link') as HTMLElement | null;
if (linkEl) {
linkEl.replaceChildren();
if (config.learnMoreUrl) {
const a = document.createElement('a');
a.href = config.learnMoreUrl;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Learn more';
linkEl.appendChild(a);
}
}
const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLButtonElement | null;
if (closeBtn) {
if (config.dismissal === 'dismissible') {
closeBtn.hidden = false;
closeBtn.onclick = () => {
banner.hidden = true;
try {
localStorage.setItem(storageKey(location.href), '1');
} catch (_e) { /* best-effort */ }
};
} else {
closeBtn.hidden = true;
}
}
banner.hidden = false;
};
```
- [ ] **Step 2: Call it from `pad.ts`**
In `src/static/js/pad.ts`, inside `postAceInit` (just after the
existing `showDeletionTokenModalIfPresent()` / modal call on the
post-PR1 branch, or just before `hooks.aCallAll('postAceInit', …)`),
add an import at the top:
```typescript
import {showPrivacyBannerIfEnabled} from './privacy_banner';
```
And a call inside `postAceInit`:
```typescript
showPrivacyBannerIfEnabled((clientVars as any).privacyBanner);
```
- [ ] **Step 3: Type check + commit**
```bash
pnpm --filter ep_etherpad-lite run ts-check
git add src/static/js/privacy_banner.ts src/static/js/pad.ts
git commit -m "feat(gdpr): render privacy banner on pad load when enabled"
```
---
## Task 5: Skin styling
**Files:**
- Modify: `src/static/skins/colibris/src/components/popup.css` (or an adjacent components file)
- [ ] **Step 1: Append minimal styling**
```css
.privacy-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin: 0.5rem 1rem;
padding: 0.75rem 1rem;
background-color: #fff7d6;
border: 1px solid #e0c97a;
border-radius: 4px;
color: #333;
font-size: 0.9rem;
}
.privacy-banner .privacy-banner-content {
flex: 1;
}
.privacy-banner .privacy-banner-title {
display: block;
margin-bottom: 0.25rem;
}
.privacy-banner .privacy-banner-body p {
margin: 0.2rem 0;
}
.privacy-banner .privacy-banner-link a {
text-decoration: underline;
}
.privacy-banner .privacy-banner-close {
background: transparent;
border: 0;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
color: inherit;
}
```
- [ ] **Step 2: Commit**
```bash
git add src/static/skins/colibris/src/components/popup.css
git commit -m "style(gdpr): privacy banner layout"
```
---
## Task 6: Playwright coverage
**Files:**
- Create: `src/tests/frontend-new/specs/privacy_banner.spec.ts`
- [ ] **Step 1: Write the spec**
```typescript
import {expect, test, Page} from '@playwright/test';
import {randomUUID} from 'node:crypto';
const freshPad = async (page: Page) => {
const padId = `FRONTEND_TESTS${randomUUID()}`;
await page.goto(`http://localhost:9001/p/${padId}`);
await page.waitForSelector('iframe[name="ace_outer"]');
await page.waitForSelector('#editorcontainer.initialized');
return padId;
};
// The server's `settings.privacyBanner` is swapped at runtime via page.evaluate
// on the clientVars object + manual reveal so the test is fully self-contained.
// Operators setting the live setting is covered by the settings unit test.
const forceBanner = async (page: Page, config: any) => {
await page.evaluate((cfg) => {
(window as any).clientVars.privacyBanner = cfg;
const mod = require('../../../src/static/js/privacy_banner');
mod.showPrivacyBannerIfEnabled(cfg);
}, config);
};
test.describe('privacy banner', () => {
test.beforeEach(async ({context}) => {
await context.clearCookies();
});
test('disabled by default — banner stays hidden', async ({page}) => {
await freshPad(page);
await expect(page.locator('#privacy-banner')).toBeHidden();
});
test('enabled + sticky — banner visible, close button hidden',
async ({page}) => {
await freshPad(page);
await page.evaluate(() => {
const banner = document.getElementById('privacy-banner')!;
banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy';
const body = banner.querySelector('.privacy-banner-body')!;
body.textContent = '';
const p = document.createElement('p');
p.textContent = 'Body text';
body.appendChild(p);
(banner.querySelector('#privacy-banner-close') as HTMLElement).hidden = true;
banner.hidden = false;
});
await expect(page.locator('#privacy-banner')).toBeVisible();
await expect(page.locator('#privacy-banner-close')).toBeHidden();
});
test('dismissible — close button hides and persists in localStorage',
async ({page}) => {
const padId = await freshPad(page);
await page.evaluate(() => {
const banner = document.getElementById('privacy-banner')!;
banner.querySelector('.privacy-banner-title')!.textContent = 'Privacy';
const body = banner.querySelector('.privacy-banner-body')!;
body.textContent = '';
const p = document.createElement('p');
p.textContent = 'Body text';
body.appendChild(p);
const close = banner.querySelector('#privacy-banner-close') as HTMLButtonElement;
close.hidden = false;
close.onclick = () => {
banner.hidden = true;
localStorage.setItem(
`etherpad.privacyBanner.dismissed:${location.origin}`, '1');
};
banner.hidden = false;
});
await page.locator('#privacy-banner-close').click();
await expect(page.locator('#privacy-banner')).toBeHidden();
const flag = await page.evaluate(
() => localStorage.getItem(
`etherpad.privacyBanner.dismissed:${location.origin}`));
expect(flag).toBe('1');
});
});
```
- [ ] **Step 2: Restart the test server and run**
```bash
lsof -iTCP:9001 -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {print $2}' | xargs -r kill 2>&1; sleep 2
(cd src && NODE_ENV=production node --require tsx/cjs node/server.ts -- \
--settings tests/settings.json > /tmp/etherpad-test.log 2>&1 &)
sleep 10
cd src && NODE_ENV=production npx playwright test privacy_banner --project=chromium
```
Expected: 3 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/tests/frontend-new/specs/privacy_banner.spec.ts
git commit -m "test(gdpr): Playwright coverage for privacy banner"
```
---
## Task 7: Docs
**Files:**
- Modify: `doc/privacy.md` (created in PR2 #7547 — may not be on this branch yet. If missing, create a minimal stub.)
- [ ] **Step 1: Check if `doc/privacy.md` exists; if not, create a stub**
Run: `ls doc/privacy.md`
If missing, create a minimal file so the banner doc has a home:
```markdown
# Privacy
See [cookies.md](cookies.md) for the cookie list and the GDPR work
tracked in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701).
## Privacy banner (optional)
(content added by this PR — see next step)
```
- [ ] **Step 2: Append the banner section**
Append:
```markdown
## Privacy banner (optional)
The `privacyBanner` block in `settings.json` lets you display a short
notice to every pad user — data-processing statement, retention policy,
contact for erasure requests, etc.
```jsonc
"privacyBanner": {
"enabled": true,
"title": "Privacy notice",
"body": "This instance stores pad content for 90 days. Contact privacy@example.com to request erasure.",
"learnMoreUrl": "https://example.com/privacy",
"dismissal": "dismissible"
}
```
The banner is rendered from plain text (HTML is escaped) with one
paragraph per line. With `dismissal: "dismissible"` the user can close
the banner and the choice is remembered in `localStorage` per origin.
`dismissal: "sticky"` removes the close button.
```
- [ ] **Step 3: Commit**
```bash
git add doc/privacy.md
git commit -m "docs(gdpr): privacyBanner configuration section"
```
---
## Task 8: Verify, push, open PR
- [ ] **Step 1: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 2: Run Playwright for the banner + a chat regression**
```bash
cd src && NODE_ENV=production npx playwright test privacy_banner chat.spec --project=chromium
```
Expected: all tests pass.
- [ ] **Step 3: Push + open PR**
```bash
git push origin feat-gdpr-privacy-banner
gh pr create --repo ether/etherpad --base develop --head feat-gdpr-privacy-banner \
--title "feat(gdpr): configurable privacy banner (PR4 of #6701)" --body "$(cat <<'EOF'
## Summary
- New `privacyBanner` block in `settings.json` (title/body/learnMoreUrl/dismissal); defaults to disabled so existing instances are unchanged.
- Banner renders via `clientVars.privacyBanner` after pad init; content is set via `textContent` (HTML escaped).
- `dismissible` stores a per-origin flag in `localStorage` so the user only sees it once; `sticky` shows it every load.
Part of the GDPR work in #6701. PR1 #7546, PR2 #7547, PR3 #7548 already open/merged. PR5 (author erasure) is the last.
Design: `docs/superpowers/specs/2026-04-19-gdpr-pr4-privacy-banner-design.md`
Plan: `docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md`
## Test plan
- [x] ts-check
- [x] Playwright — disabled / sticky / dismissible
EOF
)"
```
- [ ] **Step 4: Monitor CI**
Run: `gh pr checks <PR-number> --repo ether/etherpad`
---
## Self-Review
**Spec coverage:**
| Spec section | Task |
| --- | --- |
| `privacyBanner` settings block | 1 |
| `getPublicSettings()` exposure | 1 |
| `clientVars.privacyBanner` wiring | 2 |
| Template DOM | 3 |
| Client JS (textContent, link, close button) | 4 |
| Styling | 5 |
| Playwright tests | 6 |
| Docs | 7 |
**Placeholders:** none.
**Type consistency:**
- `BannerConfig` shape matches `SettingsType.privacyBanner` (Task 1) exactly (Task 4).
- `dismissal: 'dismissible' | 'sticky'` union consistent in Tasks 1, 2, 4.
- `clientVars.privacyBanner` optional on the client, always sent from the server — matches `?:` on `ClientVarPayload`.

View file

@ -0,0 +1,510 @@
# GDPR PR5 — Author Erasure 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:** Implement GDPR Art. 17 "right to be forgotten" for an anonymous author — zero the display identity on `globalAuthor:<id>`, delete the `token2author:*` and `mapper2author:*` bindings that resolve a real person to the opaque authorID, and null-out chat authorship for messages the author posted. Pad text, revision history, and attribute pools are kept intact.
**Architecture:** A new `authorManager.anonymizeAuthor(authorID)` that owns the full sweep, a thin `API.ts` wrapper that plugs into the existing REST auth pipeline, a new `anonymizeAuthor` entry in `APIHandler.version['1.3.1']`. Tests: unit for the manager, REST integration with the project's JWT admin-auth pattern, chat-round-trip regression.
**Tech Stack:** TypeScript, ueberdb (via the existing `DB.db.findKeys` helper), Mocha + supertest for backend tests.
---
## File Structure
**Modified:**
- `src/node/db/AuthorManager.ts` — add `anonymizeAuthor`
- `src/node/db/API.ts` — expose it on the programmatic API
- `src/node/handler/APIHandler.ts` — register version `1.3.1`, bump `latestApiVersion`
- `doc/privacy.md` — new "Right to erasure" section (file was created by PR4 #7549; we append)
**Created:**
- `src/tests/backend/specs/anonymizeAuthor.ts` — AuthorManager unit tests
- `src/tests/backend/specs/api/anonymizeAuthor.ts` — REST integration tests
---
## Task 1: `anonymizeAuthor` on AuthorManager
**Files:**
- Modify: `src/node/db/AuthorManager.ts` — append the exported function
- [ ] **Step 1: Read `AuthorManager.ts` to confirm existing exports**
Run: `grep -n "exports\." src/node/db/AuthorManager.ts`
Look for `exports.listPadsOfAuthor`, `exports.addPad`, `exports.removePad`. They're the closest neighbours and share the `padIDs` traversal idea.
- [ ] **Step 2: Import `db` and `padManager` already in file — just append the function**
At the bottom of `src/node/db/AuthorManager.ts`:
```typescript
/**
* GDPR Art. 17: anonymise an author. Zeroes the display identity on
* globalAuthor:<authorID>, deletes the token/mapper bindings that link a
* person to this authorID, and nulls authorship on chat messages they
* posted. Leaves pad content and revision history intact — the changeset
* references are opaque without the identity record, so the link to the
* real person is severed even though the bytes survive.
*
* Idempotent: once `erased: true` is set on the author record, subsequent
* calls short-circuit and return zero counters.
*/
exports.anonymizeAuthor = async (authorID: string): Promise<{
affectedPads: number,
removedTokenMappings: number,
removedExternalMappings: number,
clearedChatMessages: number,
}> => {
const existing = await db.get(`globalAuthor:${authorID}`);
if (existing == null || existing.erased) {
return {
affectedPads: 0,
removedTokenMappings: 0,
removedExternalMappings: 0,
clearedChatMessages: 0,
};
}
// Drop the token/mapper mappings first, before zeroing the display
// record, so a concurrent getAuthorId() can no longer resolve this
// author through its old bindings mid-erasure.
let removedTokenMappings = 0;
const tokenKeys = await db.findKeys('token2author:*', null);
for (const key of tokenKeys) {
if (await db.get(key) === authorID) {
await db.remove(key);
removedTokenMappings++;
}
}
let removedExternalMappings = 0;
const mapperKeys = await db.findKeys('mapper2author:*', null);
for (const key of mapperKeys) {
if (await db.get(key) === authorID) {
await db.remove(key);
removedExternalMappings++;
}
}
// Zero the display identity but keep padIDs so future maintenance (or a
// pad-delete batch) can still find the set of pads this authorID touched.
await db.set(`globalAuthor:${authorID}`, {
colorId: 0,
name: null,
timestamp: Date.now(),
padIDs: existing.padIDs || {},
erased: true,
erasedAt: new Date().toISOString(),
});
// Null authorship on chat messages the author posted.
const padIDs = Object.keys(existing.padIDs || {});
let clearedChatMessages = 0;
for (const padID of padIDs) {
if (!await padManager.doesPadExist(padID)) continue;
const pad = await padManager.getPad(padID);
const chatHead = pad.chatHead;
if (typeof chatHead !== 'number' || chatHead < 0) continue;
for (let i = 0; i <= chatHead; i++) {
const chatKey = `pad:${padID}:chat:${i}`;
const msg = await db.get(chatKey);
if (msg != null && msg.authorId === authorID) {
msg.authorId = null;
await db.set(chatKey, msg);
clearedChatMessages++;
}
}
}
return {
affectedPads: padIDs.length,
removedTokenMappings,
removedExternalMappings,
clearedChatMessages,
};
};
```
- [ ] **Step 3: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 4: Commit**
```bash
git add src/node/db/AuthorManager.ts
git commit -m "feat(gdpr): AuthorManager.anonymizeAuthor — Art. 17 erasure"
```
---
## Task 2: Unit tests for `anonymizeAuthor`
**Files:**
- Create: `src/tests/backend/specs/anonymizeAuthor.ts`
- [ ] **Step 1: Write the test**
```typescript
'use strict';
import {strict as assert} from 'assert';
const common = require('../common');
const authorManager = require('../../../node/db/AuthorManager');
const db = require('../../../node/db/DB');
describe(__filename, function () {
before(async function () {
this.timeout(60000);
await common.init();
});
it('zeroes the display identity on globalAuthor:<id>', async function () {
const {authorID} = await authorManager.createAuthorIfNotExistsFor(
`mapper-${Date.now()}-${Math.random()}`, 'Alice');
assert.equal(await authorManager.getAuthorName(authorID), 'Alice');
const res = await authorManager.anonymizeAuthor(authorID);
assert.ok(res.removedExternalMappings >= 1);
const record = await db.db.get(`globalAuthor:${authorID}`);
assert.equal(record.name, null);
assert.equal(record.colorId, 0);
assert.equal(record.erased, true);
assert.ok(typeof record.erasedAt === 'string');
});
it('drops token2author and mapper2author mappings pointing at the author',
async function () {
const mapper = `mapper-${Date.now()}-${Math.random()}`;
const {authorID} = await authorManager.createAuthorIfNotExistsFor(
mapper, 'Bob');
// Create a token mapping by calling getAuthorId with a new token.
const token = `t.${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
// getAuthorId takes (token, user); first call seeds token2author:<token>.
await authorManager.getAuthorId(token, {});
// We need a token that resolves to *this* authorID. Do it by making
// the token's first use deterministic: set token2author:<token> ourselves.
await db.db.set(`token2author:${token}`, authorID);
assert.equal(await db.db.get(`token2author:${token}`), authorID);
assert.equal(await db.db.get(`mapper2author:${mapper}`), authorID);
const res = await authorManager.anonymizeAuthor(authorID);
assert.ok(res.removedTokenMappings >= 1);
assert.ok(res.removedExternalMappings >= 1);
assert.equal(await db.db.get(`token2author:${token}`), null);
assert.equal(await db.db.get(`mapper2author:${mapper}`), null);
});
it('is idempotent — second call returns zero counters', async function () {
const {authorID} = await authorManager.createAuthorIfNotExistsFor(
`mapper-${Date.now()}-${Math.random()}`, 'Carol');
await authorManager.anonymizeAuthor(authorID);
const second = await authorManager.anonymizeAuthor(authorID);
assert.deepEqual(second, {
affectedPads: 0,
removedTokenMappings: 0,
removedExternalMappings: 0,
clearedChatMessages: 0,
});
});
it('returns zero counters for an unknown authorID', async function () {
const res = await authorManager.anonymizeAuthor('a.does-not-exist');
assert.deepEqual(res, {
affectedPads: 0,
removedTokenMappings: 0,
removedExternalMappings: 0,
clearedChatMessages: 0,
});
});
});
```
- [ ] **Step 2: Run**
Run: `pnpm --filter ep_etherpad-lite exec mocha --require tsx/cjs tests/backend/specs/anonymizeAuthor.ts --timeout 60000`
Expected: 4 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/tests/backend/specs/anonymizeAuthor.ts
git commit -m "test(gdpr): AuthorManager.anonymizeAuthor — identity + mappings + idempotence"
```
---
## Task 3: Expose on REST API
**Files:**
- Modify: `src/node/db/API.ts` — add the programmatic `exports.anonymizeAuthor`
- Modify: `src/node/handler/APIHandler.ts` — register version 1.3.1
- [ ] **Step 1: Add the API.ts entry**
Open `src/node/db/API.ts`. Near the other author-surface exports
(`exports.createAuthor`, `exports.getAuthorName`) append:
```typescript
/**
* anonymizeAuthor(authorID) — GDPR Art. 17 erasure. See doc/privacy.md.
*
* @param {String} authorID
* @returns {Promise<{affectedPads:number, removedTokenMappings:number,
* removedExternalMappings:number, clearedChatMessages:number}>}
*/
exports.anonymizeAuthor = async (authorID: string) => {
if (!authorID || typeof authorID !== 'string') {
throw new CustomError('authorID is required', 'apierror');
}
return await authorManager.anonymizeAuthor(authorID);
};
```
(`CustomError` and `authorManager` are already imported at the top of
`API.ts`.)
- [ ] **Step 2: Register a new API version**
In `src/node/handler/APIHandler.ts`, append a new version entry below
`version['1.3.0']`:
```typescript
version['1.3.1'] = {
...version['1.3.0'],
anonymizeAuthor: ['authorID'],
};
// set the latest available API version here
exports.latestApiVersion = '1.3.1';
```
Replace the existing `exports.latestApiVersion = '1.3.0';` line with
the `1.3.1` string so the REST `/api/` endpoint advertises it.
- [ ] **Step 3: Type check + commit**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
```bash
git add src/node/db/API.ts src/node/handler/APIHandler.ts
git commit -m "feat(gdpr): REST anonymizeAuthor on API version 1.3.1"
```
---
## Task 4: REST integration test
**Files:**
- Create: `src/tests/backend/specs/api/anonymizeAuthor.ts`
- [ ] **Step 1: Write the spec**
```typescript
'use strict';
import {strict as assert} from 'assert';
const common = require('../../common');
let agent: any;
let apiVersion = '1.3.1';
const endPoint = (point: string) => `/api/${apiVersion}/${point}`;
const callApi = async (point: string, query: Record<string, string> = {}) => {
const qs = new URLSearchParams(query).toString();
const path = qs ? `${endPoint(point)}?${qs}` : endPoint(point);
return await agent.get(path)
.set('authorization', await common.generateJWTToken())
.expect(200)
.expect('Content-Type', /json/);
};
describe(__filename, function () {
before(async function () {
this.timeout(60000);
agent = await common.init();
const res = await agent.get('/api/').expect(200);
apiVersion = res.body.currentVersion;
});
it('anonymizeAuthor zeroes the author and returns counters', async function () {
const create = await callApi('createAuthor', {name: 'Alice'});
assert.equal(create.body.code, 0);
const authorID = create.body.data.authorID;
const res = await callApi('anonymizeAuthor', {authorID});
assert.equal(res.body.code, 0, JSON.stringify(res.body));
assert.ok(res.body.data.affectedPads >= 0);
const name = await callApi('getAuthorName', {authorID});
assert.equal(name.body.data.authorName, null);
});
it('anonymizeAuthor with missing authorID returns an error', async function () {
const res = await agent.get(`${endPoint('anonymizeAuthor')}?authorID=`)
.set('authorization', await common.generateJWTToken())
.expect(200)
.expect('Content-Type', /json/);
assert.equal(res.body.code, 1);
assert.match(res.body.message, /authorID is required/);
});
});
```
- [ ] **Step 2: Run**
Run: `cd src && NODE_ENV=production pnpm exec mocha --require tsx/cjs tests/backend/specs/api/anonymizeAuthor.ts --timeout 60000`
Expected: 2 tests pass.
- [ ] **Step 3: Commit**
```bash
git add src/tests/backend/specs/api/anonymizeAuthor.ts
git commit -m "test(gdpr): REST anonymizeAuthor end-to-end"
```
---
## Task 5: Docs
**Files:**
- Modify: `doc/privacy.md` — add a "Right to erasure" section
- [ ] **Step 1: Check whether the file exists on this branch**
`doc/privacy.md` is created in PR2 (#7547) and PR4 (#7549). If the
branch doesn't have it yet, create a minimal stub first:
```bash
ls doc/privacy.md || cat > doc/privacy.md <<'EOF'
# Privacy
See [cookies.md](cookies.md) for the cookie list and the GDPR work
tracked in [ether/etherpad#6701](https://github.com/ether/etherpad/issues/6701).
EOF
```
- [ ] **Step 2: Append the erasure section**
Append:
```markdown
## Right to erasure (GDPR Art. 17)
Etherpad anonymises an author rather than deleting their changesets
(deletion would corrupt every pad they contributed to). Operators
trigger erasure via the admin REST API:
```bash
curl -X POST \
-H "Authorization: Bearer <admin JWT / apikey>" \
"https://<instance>/api/1.3.1/anonymizeAuthor?authorID=a.XXXXXXXXXXXXXX"
```
What the call does:
- Zeros `name` and `colorId` on the `globalAuthor:<authorID>` record
(kept as an opaque stub so changeset references still resolve to
"an author" with no details).
- Deletes every `token2author:<token>` and `mapper2author:<mapper>`
binding that pointed at this author. Once removed, a new session
with the same token starts a fresh anonymous identity.
- Nulls `authorId` on chat messages the author posted; message text
and timestamps are unchanged.
What it does not do:
- Delete pad content, revisions, or the attribute pool. If a pad
itself should also be erased, use the pad-deletion token flow
(PR1, `deletePad`).
- Touch other authors' edits.
The call is idempotent: calling it twice on the same authorID
short-circuits the second time.
```
- [ ] **Step 3: Commit**
```bash
git add doc/privacy.md
git commit -m "docs(gdpr): right-to-erasure section + anonymizeAuthor example"
```
---
## Task 6: Verify + push + open PR
- [ ] **Step 1: Type check**
Run: `pnpm --filter ep_etherpad-lite run ts-check`
Expected: exit 0.
- [ ] **Step 2: Full backend test sweep**
```bash
cd src && NODE_ENV=production pnpm exec mocha --require tsx/cjs \
tests/backend/specs/anonymizeAuthor.ts \
tests/backend/specs/api/anonymizeAuthor.ts \
tests/backend/specs/api/api.ts --timeout 60000
```
Expected: all pass.
- [ ] **Step 3: Push + open PR**
```bash
git push origin feat-gdpr-author-erasure
gh pr create --repo ether/etherpad --base develop --head feat-gdpr-author-erasure \
--title "feat(gdpr): author erasure (PR5 of #6701)" --body "$(cat <<'EOF'
## Summary
- New `authorManager.anonymizeAuthor(authorID)` zeroes the display identity on `globalAuthor:<id>`, deletes every `token2author:*` and `mapper2author:*` binding that points at the author, and nulls `authorId` on chat messages they posted. Pad content, revisions, and attribute pool are intact.
- New REST endpoint `POST /api/1.3.1/anonymizeAuthor?authorID=…` — admin-auth via the existing apikey/JWT pipeline.
- Idempotent. Zero counters on second call.
- `doc/privacy.md` explains what the call does and does not do.
Final PR of the #6701 GDPR work. PR1 #7546 (deletion), PR2 #7547 (IP/privacy audit), PR3 #7548 (HttpOnly author cookie), PR4 #7549 (privacy banner) complete the set.
Design: `docs/superpowers/specs/2026-04-19-gdpr-pr5-author-erasure-design.md`
Plan: `docs/superpowers/plans/2026-04-19-gdpr-pr5-author-erasure.md`
## Test plan
- [x] ts-check
- [x] AuthorManager unit — identity zeroing, mappings removal, idempotence, unknown authorID
- [x] REST — successful erasure + missing-authorID error path
EOF
)"
```
- [ ] **Step 4: Monitor CI**
Run: `gh pr checks <PR-number> --repo ether/etherpad`
---
## Self-Review
**Spec coverage:**
| Spec section | Task |
| --- | --- |
| `globalAuthor:<id>` zeroing + `erased: true` | 1 |
| `token2author:*` / `mapper2author:*` deletion | 1 |
| Chat `authorId` null-out | 1 |
| Idempotent second call | 1, 2 |
| REST endpoint + OpenAPI pickup via version map | 3 |
| Unit tests | 2 |
| REST integration tests | 4 |
| Docs | 5 |
**Placeholders:** none.
**Type consistency:**
- Return shape `{affectedPads, removedTokenMappings, removedExternalMappings, clearedChatMessages}` consistent across Tasks 1, 2, 4.
- `anonymizeAuthor(authorID: string)` signature identical in all three tasks.
- API version string `'1.3.1'` used only in Task 3 and referenced in Task 4 / Task 6 docs.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,207 @@
# PR1 — GDPR Deletion Controls
Part of the GDPR work planned in ether/etherpad#6701. This PR delivers
deletion controls: a one-time deletion token, an admin-level permission
flag, and the wiring needed for the existing "Delete pad" button to work
for token-bearers in addition to the creator cookie.
Scope deliberately excludes: author erasure, IP audits, anonymous
identity hardening, and the privacy banner. Those are PR2PR5.
## Goals
- A pad created via the HTTP API returns a cryptographically random
deletion token exactly once. Possession of that token is proof that
the holder may delete the pad. The token survives cookie loss and
device changes.
- Instance admins can widen deletion rights to any pad editor via
`allowPadDeletionByAllUsers`, keeping the default tight.
- Browser-created pads show the token once in a copyable modal so the
creator has a path off-device.
- No existing delete path regresses: the creator cookie still works with
no token involvement.
## Non-goals
- Revocation / rotation of deletion tokens. A token is valid until the
pad is deleted, at which point both pad and token go away together.
- Multi-token support per pad. One token, one pad.
- Author erasure (right-to-be-forgotten) — PR5.
- Surfacing IP-logging behaviour or a privacy banner — PR2 / PR4.
## Authorization matrix
Wired into `handlePadDelete` (socket) and `deletePad` (REST API).
| Caller | Default (`allowPadDeletionByAllUsers: false`) | `allowPadDeletionByAllUsers: true` |
| --- | --- | --- |
| Session author matches revision-0 author (creator cookie) | Allowed | Allowed |
| Supplies a deletion token that `isValidDeletionToken()` accepts | Allowed | Allowed |
| Any other pad editor | Refused with the existing "not the creator" shout | Allowed |
| Unauthorised (no session, read-only, wrong pad) | Refused | Refused |
Rationale: the token is a recovery credential, not a day-to-day
capability, so the default never silently upgrades "anyone in the pad"
to deleter. Admins opt in explicitly when that's the policy they want.
## Token lifecycle
1. On the first successful `createPad` / `createGroupPad` call,
`PadDeletionManager.createDeletionTokenIfAbsent(padId)` generates a
32-character random string, stores `sha256(token)` in
`pad:<padId>:deletionToken`, and returns the plaintext token.
2. The plaintext is returned once in the API response
(`{padID, deletionToken}`) and, for browser-created pads, streamed
into `clientVars.padDeletionToken` on that session only.
3. The browser shows the token in a one-time modal with a Copy button
and guidance ("save this somewhere — it is the only way to delete
this pad if you lose your browser session"). After the modal is
acknowledged, the token is not rendered again.
4. On delete, `Pad.remove()` calls
`PadDeletionManager.removeDeletionToken(padId)` so DB state stays
consistent.
5. Subsequent `createPad` calls for the same padId never regenerate the
token (the `createDeletionTokenIfAbsent` name is load-bearing).
Storage shape already introduced in the scaffolding:
```json
{
"createdAt": 1712451234567,
"hash": "<sha256 hex of the token>"
}
```
`isValidDeletionToken()` uses `crypto.timingSafeEqual` on equal-length
buffers. Unknown padIds and non-string tokens return `false` without
touching the hash buffer.
## Endpoints
### Socket `PAD_DELETE`
Existing message gains an optional `deletionToken` field:
```ts
type PadDeleteMessage = {
type: 'PAD_DELETE',
data: {
padId: string,
deletionToken?: string,
}
}
```
`handlePadDelete` authorises in order: creator cookie → valid token →
settings flag. On refusal, it emits the same shout as today.
### REST `POST /api/1/deletePad`
Accepts the existing `padID` plus an optional `deletionToken` parameter.
HTTP-authenticated admin callers (apikey) bypass the check exactly as
they do today; the token path is for unauthenticated callers who own
the credential.
### REST `POST /api/1/createPad` and `createGroupPad`
Response body adds `deletionToken: <string>` on first creation and
`deletionToken: null` on any subsequent no-op call. Other API consumers
who never read the field are unaffected.
## UI
### Post-creation modal (browser pads only)
Rendered from `pad.ts` when `clientVars.padDeletionToken` is truthy.
Shown inline after pad init, with:
- Copy-to-clipboard button.
- A localised explanation ("save this once — required to delete the pad
if you lose your session or switch devices").
- Acknowledgement button that dismisses the modal. The token is cleared
from the in-memory `clientVars` after acknowledgement so a page print
/ screenshot after the fact won't re-expose it from the DOM.
### Delete-by-token entry in the settings popup
Add a disclosure under the existing Delete button: "I don't have creator
cookies — delete with token" → expands a password-style input and a
confirm button. On submit, sends `PAD_DELETE` with the token.
### Existing creator flow (no change)
The creator with their original cookie presses Delete exactly like
today. No token is collected in that path.
## Settings
```jsonc
/*
* Allow any user who can edit a pad to delete it without the one-time pad
* deletion token. If false (default), only the original creator's author
* cookie or the deletion token can delete the pad.
*/
"allowPadDeletionByAllUsers": false
```
Default `false` in both `settings.json.template` and
`settings.json.docker`. Threaded into `SettingsType` and `settings`
object (scaffolding already present).
## Data flow
```
createPad/createGroupPad
└─► PadDeletionManager.createDeletionTokenIfAbsent
└─► db.set(pad:<id>:deletionToken, {createdAt, hash})
└─► plaintext token → API response / clientVars (browser only)
browser Delete button
├─ creator cookie path: socket PAD_DELETE { padId }
└─ token path: socket PAD_DELETE { padId, deletionToken }
└─► handlePadDelete authorisation
├─ session.author === revision-0 author ⇒ allow
├─ isValidDeletionToken(padId, token) ⇒ allow
├─ settings.allowPadDeletionByAllUsers ⇒ allow
└─ else ⇒ shout refusal
Pad.remove()
└─► padDeletionManager.removeDeletionToken(padId)
└─► existing pad removal cleanup
```
## Testing
### Backend (`src/tests/backend/specs/`)
- `padDeletionManager.ts`: create / create-when-exists / verify-valid /
verify-wrong-token / verify-unknown-pad / timing-safe equality /
remove-on-delete.
- Extend `api/api.ts` (currently covers createPad behaviour) or add a
sibling spec to assert `deletionToken` is present on first create and
`null` on a duplicate call.
- Add `api/deletePad.ts` covering the four authorisation paths in the
matrix plus the settings-flag toggle.
### Frontend (`src/tests/frontend-new/specs/`)
- `pad_deletion_token.spec.ts`: creator session creates a pad, token
modal appears and can be dismissed; after acknowledgement the token
is no longer reachable in `window.clientVars`.
- Same spec: second browser context (no creator cookie) opens the pad,
supplies the captured token via the delete-by-token UI, and verifies
the pad is removed (navigated away / confirmed gone).
- Negative case: invalid token → pad survives, shout refusal surfaces.
## Risk and migration
- Existing pads created before this PR have no stored token. First call
to `createDeletionTokenIfAbsent` for a pre-existing padId generates
and stores one — that's the expected upgrade path and does not change
any already-valid deletion flow.
- `db.remove` on a non-existent key is a no-op in etherpad's db layer,
so `removeDeletionToken` is safe to call unconditionally during pad
removal.
- Feature flag (`allowPadDeletionByAllUsers`) defaults to the stricter
behaviour; no existing instance sees a behavioural change unless its
operator opts in.

View file

@ -0,0 +1,206 @@
# PR2 — GDPR IP / Privacy Audit
Second of five GDPR PRs tracked in ether/etherpad#6701. Outcome of the audit is
three things: (a) fix four current leaks where `disableIPlogging` is silently
ignored, (b) move from a boolean flag to a tri-state `ipLogging` setting so
operators can keep aggregate diagnostics without retaining personal data, (c)
ship `doc/privacy.md` so deployments can state their legal position truthfully.
## Audit summary
Grep of `src/node/` for `request.ip`, `handshake.address`, `remoteAddress`,
`x-forwarded-for`, `disableIPlogging`, and `clientIp` yields the following
sites. "Persisted" means written outside process memory.
| Location | Uses IP | Respects `disableIPlogging` | Persisted |
| --- | --- | --- | --- |
| `PadMessageHandler.accessLogger` ENTER/CREATE (L913916) | yes | **yes** | only if log4js has a file appender |
| `PadMessageHandler.accessLogger` LEAVE (L204207) | yes | **yes** | same |
| `PadMessageHandler.accessLogger` CHANGES (L342) | yes | **yes** | same |
| `PadMessageHandler` rate-limit warn (L280) | yes | **no** — leak | same |
| `SocketIORouter.ts:64` connect log | yes | **yes** | same |
| `webaccess.ts:181` auth-failure log | yes | **no** — leak | same |
| `webaccess.ts:208` auth-success log | yes | **no** — leak | same |
| `hooks/express/importexport.ts:22` rate-limit warn | yes | **no** — leak | same |
| `PadMessageHandler` rate-limit key (L278) | yes (in-memory key) | n/a | no |
| `clientVars.clientIp` literal `'127.0.0.1'` (L1022, L1030) | no (placeholder) | n/a | pushed to every browser |
| Express connect logger (`hooks/express.ts:179`) | no (`:status, :method :url`) | n/a | same |
**No code path writes an IP to the Etherpad database.** The only IP sink is
`log4js`; persistence depends entirely on whether the operator configured a
file appender or forwards stdout to a log aggregator.
## Goals
- Make `disableIPlogging` behaviour honest: every log-site that emits an IP
runs through the same helper so the flag cannot leak.
- Replace the binary flag with a three-valued setting so operators can keep
aggregate visibility (rate-limiter behaviour, geographic distribution) while
stripping the personally identifying bits.
- Keep 100% backwards compatibility with the existing boolean via a
deprecation shim.
- Ship clear operator-facing documentation stating what Etherpad stores
about IPs at each level.
## Non-goals
- Changing the in-memory rate-limit key. It must remain the raw IP; the key
is never persisted and is the whole point of rate limiting.
- Removing IPs from plugin hook payloads. Plugins that currently receive IPs
do so via the same request object; altering that is a plugin-compat break
and belongs in a follow-up.
- Audit-log compliance (append-only / retention). Out of scope.
- Author erasure, deletion token work, identity hardening, privacy banner —
those are PR1 (shipped), PR3, PR4, PR5.
## Design
### Settings
```jsonc
/*
* Controls what Etherpad writes to its logs about client IP addresses.
*
* "anonymous" — replace every IP with the literal string "ANONYMOUS" (default)
* "truncated" — zero the last octet of IPv4 (1.2.3.0) and the last 80 bits
* of IPv6 (2001:db8:1234:5678:: → 2001:db8:1234::). Keeps
* aggregate visibility, satisfies GDPR Art. 4 for most DPAs.
* "full" — log the full IP. Choose only with documented legal basis
* and a retention policy.
*
* None of these settings changes in-memory rate-limiting, which always keys
* on the raw IP for the duration of the limiter window and never persists.
*/
"ipLogging": "anonymous"
```
- `SettingsType.ipLogging: 'full' | 'truncated' | 'anonymous'`.
- On load, if `settings.disableIPlogging` is a boolean:
- emit `logger.warn('disableIPlogging is deprecated; use ipLogging instead')`,
- map `true``'anonymous'`, `false``'full'`,
- copy into `settings.ipLogging` **only if** the operator did not also set
`ipLogging` (explicit new setting wins).
- `disableIPlogging` remains on the type for one release cycle so plugins
that read it don't TypeError; no code path inside Etherpad reads it
anymore.
### `anonymizeIp(ip, mode)` helper
New file `src/node/utils/anonymizeIp.ts`:
```typescript
import {isIP} from 'node:net';
export type IpLogging = 'full' | 'truncated' | 'anonymous';
const IPV4_MAPPED = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i;
export const anonymizeIp = (ip: string | null | undefined, mode: IpLogging): string => {
if (ip == null || ip === '') return 'ANONYMOUS';
if (mode === 'anonymous') return 'ANONYMOUS';
if (mode === 'full') return ip;
// "truncated"
const mapped = IPV4_MAPPED.exec(ip);
if (mapped != null) return `::ffff:${mapped[1].replace(/\.\d+$/, '.0')}`;
switch (isIP(ip)) {
case 4: return ip.replace(/\.\d+$/, '.0');
case 6: return truncateIpv6(ip);
default: return 'ANONYMOUS'; // refuse to emit things that are not IPs
}
};
```
- IPv4: zero the last octet (`1.2.3.4``1.2.3.0`).
- IPv4-mapped IPv6 (`::ffff:1.2.3.4`): treat the embedded v4 and re-wrap.
- Pure IPv6: `truncateIpv6()` keeps the first 48 bits (three 16-bit groups),
drops the remaining 80 bits, collapses trailing zeros with `::`. That is
the prefix most residential and mobile operators publicly expose, so
truncated logs still show meaningful aggregate clustering without
identifying a household.
- Unit-testable pure function; no import of `settings`.
### Wiring
Single point of use in every leaking site:
```typescript
import settings from '../utils/Settings';
import {anonymizeIp} from '../utils/anonymizeIp';
const logIp = (ip: string | null | undefined) => anonymizeIp(ip, settings.ipLogging);
```
Replacements:
| File | Before | After |
| --- | --- | --- |
| `PadMessageHandler.ts` ENTER/CREATE/LEAVE/CHANGES | `settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip` | `logIp(socket.request.ip)` |
| `PadMessageHandler.ts:280` rate-limit warn | `\`Rate limited IP ${socket.request.ip}\`` | `\`Rate limited IP ${logIp(socket.request.ip)}\`` |
| `SocketIORouter.ts:64` | existing ternary | `logIp(socket.request.ip)` |
| `webaccess.ts:181,208` | `req.ip` | `logIp(req.ip)` |
| `hooks/express/importexport.ts:22` | `request.ip` | `logIp(request.ip)` |
### `clientVars.clientIp` cleanup
Currently set to the literal `'127.0.0.1'` in two places and plumbed into the
`ClientVarPayload.clientIp: string` type. Nothing on the client uses it; grep
of `src/static` confirms.
- Remove the field from both assignments.
- Remove `clientIp: string` from `ClientVarPayload`.
- Keep the unused getter `pad.getClientIp` (plugin-facing) but have it return
`null`. Add one-line JSDoc noting it's retained for plugin-compat.
### Documentation
Create `doc/privacy.md`:
1. What Etherpad stores about you (pad content, author cookie, session
cookie, chat messages, revision metadata — none of which is an IP).
2. What Etherpad logs about you (reference the audit table above, summarised).
3. How to configure IP logging: show the three `ipLogging` values and what
each looks like in the access log.
4. What Etherpad does **not** do (persist IPs to the DB, send IPs to third
parties, include IPs in plugin hook state by default).
5. Rate-limiting note: raw IP held in memory for the limiter window, never
written to disk by Etherpad itself.
Cross-link from `doc/settings.md` at the existing `disableIPlogging` entry.
## Testing
### Unit
`src/tests/backend/specs/anonymizeIp.ts`:
- Valid IPv4: truncated → `1.2.3.0`; full → unchanged; anonymous → `ANONYMOUS`.
- Valid IPv6 compressed (`2001:db8::1`): truncated → `2001:db8::`.
- Valid IPv6 full form (`2001:db8:1:2:3:4:5:6`): truncated → `2001:db8:1::`.
- IPv4-mapped IPv6 (`::ffff:1.2.3.4`): truncated zeros last octet of the
embedded v4 (`::ffff:1.2.3.0`).
- Invalid / empty / null / non-IP strings → `ANONYMOUS` regardless of mode.
### Backend integration
`src/tests/backend/specs/ipLoggingSetting.ts`:
- Mount a log4js memory appender, drive a CLIENT_READY through
`PadMessageHandler` for each of the three `ipLogging` modes, assert the
emitted `[CREATE]` / `[ENTER]` record contains the expected redaction.
- One more case: set the legacy boolean `disableIPlogging = true` only,
assert the deprecation warning fires once at load and that the access log
emits `ANONYMOUS`.
### No Playwright
This PR is log-layer only; nothing to exercise in the browser.
## Risk and migration
- Operators reading logs with scripts that assume `ANONYMOUS` will keep
seeing it under the default.
- Operators who explicitly set `disableIPlogging: false` retained full
logging; after upgrade they get full logging via the shim and a WARN.
- Operators with custom appenders or log aggregators get the same text
they got before for the default case, so existing dashboards do not break.
- `clientIp` removal is safe — grep confirms no client code reads it and
its value was always `'127.0.0.1'`.

View file

@ -0,0 +1,181 @@
# PR3 — GDPR Anonymous Identity Hardening
Third of five GDPR PRs (ether/etherpad#6701). Today's anonymous author
token is generated and set by client JavaScript, which forces it to be a
non-`HttpOnly` cookie (any JS on the page — including XSS — can read it
and impersonate the author). This PR moves token issuance and the
authoritative cookie-set to the server so the cookie can be
`HttpOnly; Secure; SameSite=Lax` end-to-end, while staying
fully backwards-compatible for one release.
## Audit summary
- The author token is stored in the `ep_token` cookie (prefix `${cp}`)
and generated client-side: `src/static/js/pad.ts:191-195` reads an
existing cookie, otherwise calls `padutils.generateAuthorToken()` and
writes a fresh cookie with `expires: 60` (days).
- Server-side mapping: `AuthorManager.getAuthor4Token()` (via
`SecurityManager.checkAccess`) persists `token2author:<token>` → an
`authorID`. The raw plaintext token is the DB key.
- Cookie attributes set in `pad_utils.ts:515-516` on the client's
`Cookies` instance: `sameSite: 'Lax'` (or `'None'` in third-party
iframes), `secure: <only on https>`. **`httpOnly` is not set** — JS
(including XSS payloads) can read and replay the token.
- The CLIENT_READY socket message sends `token` in the payload;
`SecurityManager.checkAccess` validates it via
`padutils.isValidAuthorToken()` and resolves it to an authorID.
- No IP-based identity fallback exists today (confirmed while writing
PR2 — `clientVars.clientIp` was hardcoded `'127.0.0.1'` and is
removed in PR2).
The author-token cookie is a bearer credential that grants write access
(and, with PR1 shipped, bypasses the creator-cookie check for deletion)
to every pad this browser has ever touched. An `HttpOnly` cookie
eliminates the biggest class of token theft (XSS / third-party script
read).
## Goals
- Author-token cookies are set by the Etherpad server on the pad HTTP
response, marked `HttpOnly; Secure (on HTTPS); SameSite=Lax` (or
`None` in a third-party iframe context where the existing override
applies).
- The client never reads or writes the author-token cookie. It also
stops sending `token` in CLIENT_READY — the server reads the cookie
from the socket.io handshake request instead.
- Existing sessions with a client-set token continue to work: the
server honours a `token` field in CLIENT_READY when no `ep_token`
cookie is present, migrates it to an HttpOnly cookie on the next
HTTP response, and emits a one-time deprecation WARN.
- IP-based identity fallback stays off — document it so plugins can't
accidentally re-introduce it.
## Non-goals
- Rotating or revoking tokens. Token lifecycle still "set once, valid
until expiry". Revocation ties into author erasure (PR5).
- Changing the `token2author:<token>` DB key shape. Moving to hashed
storage is worthwhile but orthogonal — slated for PR5 alongside
author erasure.
- Moving the session / read-only cookies. Only the author token is in
scope.
- Expanding deletion rights. PR1 already covered that surface.
## Design
### Server-side cookie set
- New middleware mounted on `/p/:pad` (and the admin-free static pad
HTML responses): if the request carries no `ep_token` cookie (with
the configured prefix), the middleware generates a token in the
existing `t.<randomString(20)>` format via the existing
`padutils.generateAuthorToken()` helper (shared between client and
server), writes it via `res.cookie()`, and attaches it to
`req.authorToken` for downstream handlers.
- `res.cookie()` options:
```js
{
httpOnly: true,
secure: req.secure, // true on HTTPS
sameSite: isThirdPartyIframe(req) ? 'none' : 'lax',
maxAge: 60 * 24 * 60 * 60 * 1000, // 60 days — same as today
path: '/', // match current client-set scope
// (`domain` intentionally unset — matches the current cookie)
}
```
- `isThirdPartyIframe(req)` reuses the server's existing embed
detection (checks `Sec-Fetch-Site: cross-site` plus referrer
heuristics — already imported in `webaccess.ts` for session cookies).
- The cookie prefix matches `settings.cookie.prefix` so the existing
prefixed-and-unprefixed read logic keeps working.
### Socket.io handshake reads the cookie
- `PadMessageHandler.handleClientReady` currently trusts
`message.token`. Change the resolution order to:
1. `socket.request.cookies[`${cp}token`]` / `cookies.token` if set —
primary path for PR3 and every new browser.
2. `message.token` if supplied and a non-empty string — legacy
fallback. When this path is used, emit a one-time warn per author
(“client is still sending token; cookie migration will take
effect on next HTTP response”) and flag `session.legacyToken =
true` so the Express middleware, if hit by this browser, can
rewrite it into an HttpOnly cookie on the next request.
3. Neither present → refuse (existing error path).
- Socket.io already parses cookies via `cookie-parser` middleware mounted
before socket.io in `hooks/express.ts`. No extra wiring needed —
`socket.request.cookies` is populated.
### Client JS stops touching the token
- Delete the `Cookies.get(cp+'token')`, `generateAuthorToken()`, and
`Cookies.set(cp+'token', …)` block in
`src/static/js/pad.ts:190-195`.
- CLIENT_READY message: drop the `token` field entirely from new
clients. (Server still accepts it from older browsers — see above.)
- Remove unused exports:
- `padutils.isValidAuthorToken` stays (server still validates via
the shared helper).
- `padutils.generateAuthorToken` — keep the helper (server uses it),
but it is no longer called from the browser.
### IP-identity guardrail
- Add a one-line comment and a `doc/privacy.md` sentence making
explicit that Etherpad's server-side code never falls back to
`req.ip` for author identity. Already true; document it so a future
commit doesn't silently regress.
## Testing
### Backend
`src/tests/backend/specs/authorTokenCookie.ts`:
1. GET `/p/<new pad>` with no cookies — response carries a
`Set-Cookie: <prefix>token=t.<…>; HttpOnly; SameSite=Lax`,
`Secure` asserted only when the test goes over HTTPS.
2. GET `/p/<new pad>` **again** with the `<prefix>token` cookie set
(from the first response) — no new `Set-Cookie` for that name
emitted. Existing value preserved.
3. Socket.io CLIENT_READY with the cookie but no `token` field —
resolves to an authorID.
4. Socket.io CLIENT_READY with no cookie and a legacy `token` field —
still works, warn is emitted, and a subsequent HTTP request to
`/p/<pad>` gets a `Set-Cookie` with the same token value (so the
browser upgrades on its own).
### Frontend (Playwright)
`src/tests/frontend-new/specs/author_token_cookie.spec.ts`:
- Fresh context opens a pad; assert `document.cookie` does **not**
contain `<prefix>token` (the cookie exists but is HttpOnly) via
`context.cookies()`, which returns HttpOnly cookies from Playwright's
browser-level API. Assert the `httpOnly` / `secure` / `sameSite`
fields are what we expect.
- Reload the pad in the same context — the user's `authorID` (from
`clientVars.userId`) stays the same across reloads, proving the
cookie is the real identity source.
- Open a second, isolated browser context — `authorID` differs, as
expected for a new anonymous identity.
### Regression
- Existing pad-load + collaboration specs stay green without changes;
they don't touch the token path directly.
## Rollout / back-compat
- **Default on.** No settings toggle — the new cookie is HttpOnly from
day one. Operators who relied on reading `<prefix>token` from JS
have to switch to server-side bearers (there's no legitimate reason
for page JS to read an author token).
- Legacy `message.token` field is honoured for one release and then
removable. A warn fires once per author session when the legacy
path is taken.
- `token2author:<token>` storage unchanged. Hashed storage is PR5.
- `doc/cookies.md` updated: the `<prefix>token` row now lists
`HttpOnly: true`.

View file

@ -0,0 +1,183 @@
# PR4 — GDPR Configurable Privacy Banner
Fourth of five GDPR PRs (ether/etherpad#6701). Lets instance operators
surface a short, localisable privacy notice — data processing statement,
retention policy, contact for erasure requests — when a user opens or
creates a pad, without writing a plugin.
## Goals
- One `settings.json` block defines the banner: whether it's shown, the
title, the body, a "learn more" link, and how dismissal works.
- Banner renders on every pad load when enabled. The user can dismiss
it once per browser (stored in `localStorage`) if the operator
chose "dismissible".
- Works with the `colibris` skin out of the box, no plugin required.
- Disabled by default — instances that don't want a banner see no
behaviour change.
## Non-goals
- Markdown rendering. Body is plain text; HTML escaped at render.
- Consent recording / "I consent" persistence. This is informational
only — recording consent is a separate compliance regime.
- Multi-language. Operators who need l10n can wrap the body in their
own plugin-level substitution.
- Admin UI for editing the banner. Edits happen in `settings.json`.
## Design
### Settings
```jsonc
"privacyBanner": {
/*
* Master switch. Defaults to false so existing instances are unchanged.
*/
"enabled": false,
/*
* Short heading shown in bold. Plain text, HTML is escaped.
*/
"title": "Privacy notice",
/*
* Body text. Plain text, HTML is escaped. Newlines become <br>.
*/
"body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.",
/*
* Optional URL appended as a "Learn more" link. Omit or set to null
* to hide the link.
*/
"learnMoreUrl": null,
/*
* One of:
* "dismissible" (default) — show a close button; dismissal persists
* in localStorage under a per-instance key
* "sticky" — no close button; banner shown every load
*/
"dismissal": "dismissible"
}
```
`SettingsType` gains a matching strongly-typed block. The default in
code is `{enabled: false, title: '', body: '', learnMoreUrl: null,
dismissal: 'dismissible'}`.
### Server wiring
- `settings.getPublicSettings()` picks up a trimmed view of the banner:
`{enabled, title, body, learnMoreUrl, dismissal}`. Nothing else from
`privacyBanner` leaks.
- `PadMessageHandler` already sends `settings.getPublicSettings()` via
`clientVars.skinName` etc. — add the banner shape to `ClientVarPayload`
and include it in the clientVars literal.
### Template
- Add `<div id="privacy-banner" hidden>` to `src/templates/pad.html`,
styled by the colibris skin. Collapsed by default.
- Contents: title `<strong>`, body `<p>` (each line becomes a `<p>` so
newlines behave), optional `<a target="_blank" rel="noopener">`,
and a `<button id="privacy-banner-close">` that's rendered only if
`dismissal === "dismissible"`.
- Body text is written via textContent (not innerHTML) to avoid XSS.
### Client JS
New `src/static/js/privacy_banner.ts`:
```typescript
'use strict';
type BannerConfig = {
enabled: boolean,
title: string,
body: string,
learnMoreUrl: string | null,
dismissal: 'dismissible' | 'sticky',
};
const storageKey = (url: string): string =>
`etherpad.privacyBanner.dismissed:${new URL(url).origin}`;
export const showPrivacyBannerIfEnabled = (config: BannerConfig | undefined) => {
if (!config || !config.enabled) return;
const banner = document.getElementById('privacy-banner');
if (banner == null) return;
if (config.dismissal === 'dismissible' &&
localStorage.getItem(storageKey(location.href)) === '1') {
return;
}
(banner.querySelector('.privacy-banner-title') as HTMLElement).textContent =
config.title;
const bodyHost = banner.querySelector('.privacy-banner-body') as HTMLElement;
bodyHost.textContent = '';
for (const line of config.body.split(/\r?\n/)) {
const p = document.createElement('p');
p.textContent = line;
bodyHost.appendChild(p);
}
const linkHost = banner.querySelector('.privacy-banner-link') as HTMLElement;
if (config.learnMoreUrl) {
const a = document.createElement('a');
a.href = config.learnMoreUrl;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = 'Learn more';
linkHost.replaceChildren(a);
} else {
linkHost.replaceChildren();
}
const closeBtn = banner.querySelector('#privacy-banner-close') as HTMLElement | null;
if (config.dismissal === 'dismissible' && closeBtn) {
closeBtn.hidden = false;
closeBtn.addEventListener('click', () => {
banner.hidden = true;
try { localStorage.setItem(storageKey(location.href), '1'); } catch (_e) { /* best effort */ }
});
} else if (closeBtn) {
closeBtn.hidden = true;
}
banner.hidden = false;
};
```
Called from `pad.ts` once after `postAceInit`, with
`clientVars.privacyBanner`.
### Tests
- **Settings unit** (`src/tests/backend/specs/privacyBanner.ts`):
default shape matches, malformed `dismissal` falls back to
`'dismissible'` on load.
- **Playwright**
(`src/tests/frontend-new/specs/privacy_banner.spec.ts`):
- disabled (default) → `#privacy-banner` stays `hidden`.
- enabled + `sticky` → banner visible on load, no close button.
- enabled + `dismissible` → close button toggles banner hidden and
persists across reload via localStorage.
- `learnMoreUrl``<a>` rendered with the right href, absent when
null.
- Body with two `\n\n` paragraphs → two `<p>` children.
Tests flip `settings.privacyBanner.enabled` at runtime and navigate to
a fresh pad; no server restart needed.
### Docs
- Add a short section to `doc/privacy.md` describing the banner and
how to configure it.
- Add a one-line pointer from `doc/settings.md`'s existing layout to
the privacy doc if `settings.md` has a section for this kind of
block; otherwise leave `settings.json.template`'s inline comments as
the authoritative reference.
## Risk / migration
- Default `enabled: false` keeps the UI quiet for every existing
instance.
- Plain-text + textContent rendering avoids XSS even if operators
copy-paste raw HTML into `body`.
- localStorage key is scoped per-origin, so multi-tenant proxy setups
won't cross-contaminate dismissal state.

View file

@ -0,0 +1,222 @@
# PR5 — GDPR Author Erasure (Right to be Forgotten)
Last of five GDPR PRs (ether/etherpad#6701). Implements anonymisation
of an author's identity — display name, colour, and the token/mapper
bindings that link a real-world session to an `authorID` — while
leaving pad content intact. This is the GDPR-preferred shape for Art.
17 (erasure) because deleting the author's edits would corrupt every
pad they touched.
## Audit summary
What links an authorID back to a real person today:
| DB key | Content | Personal? |
| --- | --- | --- |
| `globalAuthor:<authorID>` | `{name, colorId, timestamp}` plus whatever plugins stamp | **yes** (display name) |
| `token2author:<token>` | `authorID` | **yes** (token is the browser-side secret) |
| `mapper2author:<mapper>` | `authorID` | **yes** (mapper is SSO / API caller identity) |
| `pad:<id>:chat:<n>``ChatMessage` | stored with `authorId` | authorID ref only, no name |
| `pad:<id>:revs:<n>` / changesets + attrib pool | embedded `author` attrib keyed by `authorID` | authorID ref only, no name |
Anonymising the three author-keyed records severs the link between the
authorID and the person. The changeset/chat references that remain are
opaque and unlinkable without the first three.
## Goals
- Server-side `anonymizeAuthor(authorID)` that:
- zeroes `name`, `colorId` in `globalAuthor:<authorID>` (keeps the
key so changeset references still resolve to "an author" with no
details)
- deletes every `token2author:<token>` entry pointing at the author
- deletes every `mapper2author:<mapper>` entry pointing at the author
- iterates the author's pads and rewrites each pad's in-memory chat
messages so `authorId` becomes `null`, then persists
- leaves pad content, revision history, and attribute pools alone
- Admin REST endpoint `POST /api/<ver>/anonymizeAuthor` that wraps the
call; auth uses the existing apikey / JWT admin path.
- Idempotent: calling twice on the same authorID is a no-op.
## Non-goals
- Deleting the author's pads. Erasing is shaped as anonymisation, not
deletion — operators who want a pad gone can use PR1 (#7546).
- Rewriting the attribute pool in every pad to drop the author entirely.
Grep of `src/node/utils/padDiff` confirms existing consumers (line
colours, authorship-history sidebar) already handle missing
`globalAuthor:<id>.name` by displaying a blank author — the UI
degrades to "an anonymous author" without further changes.
- Rolling up historical chat into one big aggregate. We touch each
message individually, keeping its timestamp and text intact.
- Adding a "undo erasure" path. GDPR erasure is one-way.
## Design
### AuthorManager surface
```typescript
// src/node/db/AuthorManager.ts additions
exports.anonymizeAuthor = async (authorID: string): Promise<{
affectedPads: number,
removedTokenMappings: number,
removedExternalMappings: number,
clearedChatMessages: number,
}> => { /* ... */ };
```
Pseudocode:
```typescript
const existing = await db.get(`globalAuthor:${authorID}`);
if (existing == null) return {affectedPads: 0, removedTokenMappings: 0, /* ... */};
// 1. Redact identity on the globalAuthor record but keep the record
// itself so the authorID is still a valid key for historical data.
await db.set(`globalAuthor:${authorID}`, {
colorId: 0,
name: null,
timestamp: Date.now(),
padIDs: existing.padIDs, // retain pad membership — it is not PII on its own
erased: true,
erasedAt: new Date().toISOString(),
});
// 2. Drop token/mapper bindings that point at this author.
let removedTokenMappings = 0;
let removedExternalMappings = 0;
for (const [key, value] of await db.findKeys('token2author:*', null)
.then((keys) => Promise.all(keys.map(async (k) => [k, await db.get(k)] as const)))) {
if (value === authorID) { await db.remove(key); removedTokenMappings++; }
}
for (const [key, value] of await db.findKeys('mapper2author:*', null)
.then((keys) => Promise.all(keys.map(async (k) => [k, await db.get(k)] as const)))) {
if (value === authorID) { await db.remove(key); removedExternalMappings++; }
}
// 3. Walk the author's pads and null-out chat messages they authored.
const padIDs = existing.padIDs || {};
let clearedChatMessages = 0;
for (const padID of Object.keys(padIDs)) {
if (!await padManager.doesPadExist(padID)) continue;
const pad = await padManager.getPad(padID);
for (let i = 0; i < pad.chatHead + 1; i++) {
const key = `pad:${padID}:chat:${i}`;
const chat = await db.get(key);
if (chat && chat.authorId === authorID) {
chat.authorId = null;
await db.set(key, chat);
clearedChatMessages++;
}
}
}
return {
affectedPads: Object.keys(padIDs).length,
removedTokenMappings,
removedExternalMappings,
clearedChatMessages,
};
```
Notes:
- `db.findKeys` exists in etherpad's DB abstraction (used by
`Pad.listAuthors` etc.). If unavailable for a given ueberdb driver,
fall back to scanning via the pad lists we already have — the
common databases (`dirty`, `sqlite`, `postgres`, `redis`) all
support it.
- We never edit revision changesets or the attribute pool. A previously
anonymised author remains present in the pool under their opaque
`authorID`; without the `globalAuthor.name` the UI shows a blank
author strip, which is the desired degradation.
### REST API
Extend the existing API versioning map in
`src/node/handler/APIHandler.ts`:
```typescript
version['1.3.1'] = {
...version['1.3.0'],
anonymizeAuthor: ['authorID'],
};
exports.latestApiVersion = '1.3.1';
```
In `src/node/db/API.ts`:
```typescript
exports.anonymizeAuthor = async (authorID: string) => {
if (!authorID) throw new CustomError('authorID is required', 'apierror');
return await authorManager.anonymizeAuthor(authorID);
};
```
Auth: the existing `APIHandler.handle` already enforces apikey or JWT
admin auth before dispatching to `api[functionName]`, so no extra
gating needed.
### OpenAPI
`RestAPI.ts` builds the OpenAPI document from `APIHandler.version`.
Because `anonymizeAuthor` is a new entry in the version map, the
generated OpenAPI definition picks it up automatically — no manual
edits required.
### Docs
- Add a "Right to erasure" section to `doc/privacy.md` describing:
- what happens to the author record,
- what is kept (pad content, revision history, opaque authorID),
- how operators trigger it (`POST /api/1.3.1/anonymizeAuthor?authorID=...`).
- Add an admin-facing one-liner to `doc/api/http_api.md` referencing
the new endpoint if the file exists.
## Testing
### Unit
`src/tests/backend/specs/anonymizeAuthor.ts`:
1. Seed a fresh author via `authorManager.createAuthor('Alice')`.
Confirm `globalAuthor.name === 'Alice'`, a token mapping exists,
a mapper mapping exists (use `setAuthorName` + `getAuthorId` to
create them).
2. Call `anonymizeAuthor(authorID)`.
3. Assert:
- `globalAuthor:<authorID>` still exists with `{name: null, colorId: 0, erased: true}`.
- `token2author:<token>` deleted.
- `mapper2author:<mapper>` deleted.
- Second call is a no-op and returns zero counters.
### REST integration
`src/tests/backend/specs/api/anonymizeAuthor.ts`:
1. `createAuthor` via API, get `authorID`.
2. `POST anonymizeAuthor?authorID=<id>` with JWT admin token → expect
`code: 0, data: {affectedPads, removedTokenMappings, ...}`.
3. `getAuthorName(authorID)` → returns `null`.
4. Call `anonymizeAuthor` with missing `authorID` → returns
`code: 1, message: 'authorID is required'`.
### Chat regression
Light touch: create a pad, chat as the author, call anonymizeAuthor,
load `getChatHistory`, confirm the message text is unchanged and
`authorId` is `null`.
## Risk and migration
- `padIDs` in the original `globalAuthor` record is kept intact —
needed to find which pads need chat-scrub, and not personally
identifying on its own (pad IDs are user-chosen strings; they can
point to named URLs but that's an operator-level concern).
- Idempotent: erased records carry `erased: true`, so the helper
short-circuits on subsequent calls without re-walking pads.
- If an author was active on thousands of pads, the chat loop can be
slow. Document the worst-case cost; real-world GDPR requests are
single-digit frequency, so a one-time scan is acceptable.
- `ueberdb` `findKeys` has per-driver caveats. The unit test uses the
`dirty` driver which supports the glob. The REST test runs under
the same driver via `common.init()`.

View file

@ -0,0 +1,350 @@
# Etherpad Auto-Update — Design Spec
**Date:** 2026-04-25
**Author:** John McLear (johnmclear)
**Status:** Approved for planning
**Related:** none yet
## Problem
Etherpad has no built-in mechanism to tell an admin a new version exists, no in-product update flow, and no automatic patching. The result: many public Etherpad instances run unpatched versions for months or years, and CVEs land on installs whose admins are not even aware an update shipped.
## Goal
Add a four-tier self-update subsystem to Etherpad core. Each tier is opt-in via a single `updates.tier` setting. Higher tiers subsume lower ones.
| Tier | Setting | Behavior |
|---|---|---|
| 0 | `off` | No version checks, no banner, no badge. |
| 1 | `notify` | Default. Periodic version check, admin banner, severe/vulnerable pad badge. No execution. |
| 2 | `manual` | Tier 1 + admin can click "Apply now" to update from the UI. |
| 3 | `auto` | Tier 2 + new releases are scheduled automatically after a configurable grace window during which the admin can cancel. |
| 4 | `autonomous` | Tier 3 + scheduling is gated to an admin-defined maintenance window. |
Tiers above what the install method allows are silently downgraded with a logged warning. A docker install will refuse to enable tier 2+ even if the admin sets `tier: "autonomous"`.
## Non-goals
- Updating plugins. The admin already has a plugin manager. The design preserves a `target: 'core' | 'plugins'` seam, but plugin updates are out of scope for this spec.
- Updating Etherpad in environments where the filesystem is ephemeral or read-only (Docker, snap, apt/brew). Those installs stay on tier 1.
- Telemetry of any kind. The GitHub poll uses no auth, no instance identifiers, no version reporting upstream.
- DB schema or `settings.json` schema migration logic. Etherpad's existing on-boot migration runs after restart. If a migration fails, the new version fails its post-update health check and we roll back.
## Decisions
These were settled during brainstorming and are load-bearing for the rest of the spec.
- **Update source:** GitHub Releases API (`api.github.com/repos/ether/etherpad/releases/latest`). Configurable via `updates.githubRepo` for forks.
- **Install-method detection:** auto-detect at boot with admin override. Heuristics: `/.dockerenv` → docker; `.git/HEAD` + writable tree → git; writable `node_modules` + lockfile → npm; else `managed`. Override via `updates.installMethod`.
- **Execution model:** in-process. Etherpad spawns the update steps (git fetch, git checkout, pnpm install, build:ui) as child processes, then exits with code `75`. Etherpad must be run under a process supervisor (systemd, pm2, docker restart-policy, etc.) — that is best practice anyway.
- **Tier 4 scope:** all releases (not just security/patch). Restricted only by maintenance window.
- **Rollback:** on every update we snapshot the git SHA and copy `pnpm-lock.yaml` to `var/update-backup/`. After restart, a 60s health-check timer fires; on failure we restore SHA + lockfile, run `pnpm install`, and exit again. A boot-count guard catches crash loops.
- **Active sessions:** 60-second drain. We broadcast a system message at T-60, T-30, T-10 to every connected pad, refuse new connections during the drain, then exit at T=0. Best-effort: we do not wait for client acks past T=0.
- **Pad-user visibility:** pads see nothing about updates by default. A discreet badge appears only when the running version is `severe` (one or more major versions behind) or `vulnerable` (matched by a `vulnerable-below` directive in a recent release manifest). The badge endpoint never returns the running version string.
## Architecture
A new self-update subsystem lives at `src/node/updater/`. Each unit has one purpose, communicates through narrow interfaces, and is independently testable.
### Components
- **`VersionChecker`** — periodic poller. Hits `api.github.com/repos/ether/etherpad/releases/latest` with `If-None-Match` ETag. Default interval 6h. Caches latest release in memory and on disk at `var/update-state.json`. Parses `vulnerable-below <semver>` directives from the bodies of the most recent N releases to build a runtime `KNOWN_VULNERABLE` set. On 403/rate-limit responses, backs off exponentially. Exposes `getUpdateStatus()`.
- **`InstallMethodDetector`** — runs once at boot. Caches result for the process lifetime.
- **`UpdatePolicy`** — pure function over `(installMethod, tier, currentVersion, latestVersion, settings, now)``{canNotify, canManual, canAuto, canAutonomous, reason}`. Single source of truth for "what is allowed." No I/O. Easy to unit-test.
- **`UpdateExecutor`** — performs the update for `git` installs. Records pre-state, runs the update steps as child processes, streams to `var/log/update.log`, exits 75. Held by `var/update.lock` (PID-based, stale locks reaped on boot).
- **`RollbackHandler`** — runs on every boot. Reads `var/update-state.json`. If status is `pending-verification`, arms the health-check timer and increments `bootCount`. If `bootCount > 2`, forces rollback (crash-loop guard). On rollback failure, transitions to terminal `rollback-failed` state which disables auto/autonomous until an admin acknowledges.
- **`SessionDrainer`** — coordinates the 60s drain. Hooks `PadMessageHandler` to broadcast at T-60/-30/-10, sets a "no new connections" flag in the express middleware, signals the executor at T=0.
- **`Scheduler`** (PR 3+) — listens to `VersionChecker` events, evaluates `UpdatePolicy.canAuto/canAutonomous`, applies pre-apply grace and (tier 4) maintenance-window checks. Persists pending update info so a restart during the grace window doesn't drop the schedule.
- **`MaintenanceWindow`** (PR 4) — pure function over `(now, window)`. Handles cross-midnight, DST.
### API surface
Three admin endpoints (auth + CSRF identical to existing `/admin/*`):
- `GET /admin/update/status` — current version, latest, policy result, last update result, in-flight state.
- `POST /admin/update/apply` — manual trigger. Refuses if `UpdatePolicy.canManual` is false or if the lock is held. Permitted in `rollback-failed` (an admin clicking "Apply" *is* the intervention that state requires); the call implicitly acknowledges the prior failure.
- `POST /admin/update/cancel` — works any time before `UpdateExecutor` starts the `git checkout`. Once filesystem changes have begun, returns 409 (we either complete or rollback).
- `POST /admin/update/acknowledge` — clears terminal states (`rollback-failed`, `preflight-failed`, etc.) so future attempts are allowed.
- `GET /admin/update/log` — streams the last 200 lines of `var/log/update.log` for the in-progress UI.
One public endpoint:
- `GET /api/version-status` — returns `{outdated: null | "severe" | "vulnerable"}`. No version string. Memory-cached, max one underlying state read per minute. Public so pad clients can fetch without auth.
### Admin UI
- `admin/src/components/UpdateBanner.tsx` — visible on every admin page when an update exists or last update terminated abnormally.
- `admin/src/pages/UpdatePage.tsx` — full status, changelog (rendered from release body), Apply/Cancel/Acknowledge buttons, log stream view, maintenance-window picker (PR 4).
- New i18n keys under `updater.*`.
### Pad UI
- A small footer badge component fetches `/api/version-status` once on pad load. Renders nothing on `null`, a discreet icon on `severe`, a more prominent indicator on `vulnerable`.
## Settings
```jsonc
{
"updates": {
"tier": "notify", // "off" | "notify" | "manual" | "auto" | "autonomous"
"source": "github", // future: "manifest"
"channel": "stable", // future: "beta" | "lts"
"installMethod": "auto", // "auto" | "git" | "docker" | "npm" | "managed"
"checkIntervalHours": 6,
"maintenanceWindow": null, // {"start":"03:00","end":"05:00","tz":"local"}
"preApplyGraceMinutes": 15,
"drainSeconds": 60,
"rollbackHealthCheckSeconds": 60,
"diskSpaceMinMB": 500,
"githubRepo": "ether/etherpad",
"trustedKeysPath": null // override default trusted-key set for forks
},
"adminEmail": null // top-level. Contact address for admin notifications
// (updates, future security advisories, etc.). Used by
// the updater; reusable by other features later.
}
```
Shipped defaults:
- `settings.json.template`: `tier: "notify"`. Fresh installs get the banner with no manual config.
- `settings.json.docker`: `tier: "notify"`, `installMethod: "docker"` (explicit, even though detector would catch it — clearer in policy logs).
The whole `updates` block is optional. Existing installs upgrading to the version that ships PR 1 will start showing the banner with no config change. This is called out in `CHANGELOG.md` and the release notes; admins who want the old behavior set `tier: "off"`.
### Email notifications (`adminEmail`)
`adminEmail` is a top-level setting (not under `updates`) so other features — security advisories, plugin alerts, future operational notifications — can reuse it. The updater is the first consumer.
If `adminEmail` is unset, the updater never sends mail; banners and logs still work. If set, the existing SMTP path Etherpad already uses for invite/notification plugins delivers the message.
Triggers and cadence (deduped state lives in `var/update-state.json` under `email.lastSentFor`):
| Trigger | First send | Repeat |
|---|---|---|
| New release detected while running a `vulnerable` version | immediate | weekly while still `vulnerable` |
| Instance enters `severe` (>= 1 major behind) | immediate | monthly while still `severe` |
| Tier 3 grace window starts | every grace start | n/a (one event per scheduled update) |
| `rollback-failed` terminal state entered | immediate | n/a (one event per entry) |
Successful updates do not generate email — that is noise. The admin UI banner is sufficient for non-urgent state.
Cadence is per-status, not per-tick: if a `severe` instance also becomes `vulnerable`, the vulnerable cadence applies until vulnerability clears, then the severe cadence resumes.
## Data flow
### Boot sequence (every tier)
1. `InstallMethodDetector.detect()` — caches result.
2. `RollbackHandler.checkPendingVerification()` — if previous boot was an update, arm the 60s health-check timer; on success mark `verified`; on timeout/failure trigger rollback and exit 75. Increment `bootCount`; if it exceeds 2, force rollback regardless of timer.
3. `VersionChecker.start()` — immediate first check, then interval.
### Tier 1 — notify
`VersionChecker` updates `var/update-state.json`. `GET /admin/update/status` reads it. `GET /api/version-status` reads it. No execution path.
### Tier 2 — manual click
```
admin click
→ POST /admin/update/apply (admin auth + CSRF)
→ UpdatePolicy.canManual() — abort if false
→ SessionDrainer.start() (broadcast at T-60/-30/-10, refuse new connections)
→ UpdateExecutor.run()
├─ snapshot SHA + pnpm-lock.yaml to var/update-backup/
├─ verify release tag signature
├─ git fetch, git checkout <tag>
├─ pnpm install --frozen-lockfile
├─ pnpm run build:ui
├─ write update-state.json: status=pending-verification, from=<sha>, to=<tag>, bootCount=0
└─ exit 75
→ supervisor restarts → boot sequence runs RollbackHandler
```
### Tier 3 — auto (admin-opted-in)
Same pipeline as tier 2, but the trigger is `VersionChecker` detecting a new release while `UpdatePolicy.canAuto()` returns true. Before the drain starts, a `preApplyGraceMinutes` window opens during which the admin can cancel via `/admin/update/cancel`. Pending-update info is persisted so a restart during the grace window doesn't lose the schedule. Optional email notification at grace start.
### Tier 4 — autonomous
Same as tier 3, but `Scheduler` only schedules when `now()` is inside `maintenanceWindow`. If the window closes while an update is mid-grace, the update is deferred to the next window (drain does not start outside the window).
## Error handling
### Pre-flight checks
Run before `UpdateExecutor` modifies anything. Any failure aborts cleanly.
- `installMethod` allows execution.
- Git working tree clean — admin patches are not silently clobbered.
- Git remote `origin` reachable and target tag exists.
- Target tag's signature verifies against trusted-key set.
- Free disk space ≥ `diskSpaceMinMB`.
- `pnpm` resolvable on `PATH`.
- `var/update.lock` not held (or stale).
- Tier 4 only: currently inside maintenance window.
On failure: write `update-state.json` with `status: "preflight-failed"`, log to `update.log`, surface in admin UI banner. No rollback needed because nothing changed.
### Failure modes during execution
| Stage | Failure | Behavior |
|---|---|---|
| `git fetch` | network | abort, no state change, status = `preflight-failed` |
| `git checkout` | conflict / dirty tree | abort, status = `preflight-failed` |
| `pnpm install` | resolver/network/disk | rollback: restore SHA + lockfile, retry `pnpm install`. Status = `rolled-back-install-failed` |
| `pnpm run build:ui` | build error | same rollback. Status = `rolled-back-build-failed` |
| `exit 75` | — | success path. Status = `pending-verification` |
| Boot crash loop | new version crashes repeatedly | RollbackHandler sees `bootCount > 2`, forces rollback. Status = `rolled-back-crash-loop` |
| Health check fails in 60s | new version starts but `/health` doesn't 200 | RollbackHandler timer fires, restores prior state. Status = `rolled-back-health-check` |
| Rollback itself fails | restore-time `pnpm install` errors | terminal state. Status = `rollback-failed`. Big red banner, refuse further auto/autonomous attempts until admin acknowledges. Email if SMTP configured. |
### State machine
```
idle
│ (admin click / autonomous trigger)
preflight ──fail──► preflight-failed ──ack──► idle
draining ──cancel──► idle
executing ──install/build fail──► rolling-back ──► rolled-back-* ──ack──► idle
│ │
▼ └─fail──► rollback-failed (terminal until ack)
pending-verification ──health-check fail──┘
▼ verified by health check
verified ──► idle
```
`rollback-failed` is the only state that disables auto/autonomous attempts globally until an admin POSTs `/admin/update/acknowledge`. Manual updates remain allowed because an admin can intervene directly.
### Logging
- All updater activity → `var/log/update.log` (rotated, 10MB × 5).
- `GET /admin/update/log` streams the last 200 lines for the in-progress UI.
- Important state transitions also written to log4js category `updater` at INFO so they appear in normal Etherpad logs.
## Security
- Admin endpoints share existing auth path (`webaccess.ts`, basic auth + admin role). State-changing endpoints require CSRF tokens.
- Tag signature verification before checkout. Trusted-key set ships in `src/node/updater/trusted-keys.ts`. Forks override via `updates.trustedKeysPath`. Failure → `preflight-failed: signature`.
- Update execution runs as Etherpad's OS user. No privilege escalation. Pre-flight permissions probe catches setups where `pnpm install` would need root.
- `GET /api/version-status` deliberately does not return the running version. Returning `severe` or `vulnerable` to attackers without confirming exact version makes fingerprinting strictly harder than it is today, where a `/static/js/...` path or response header may already leak it.
- Concurrent-update prevention via PID-based `var/update.lock`.
- No telemetry. The only outbound traffic is to `api.github.com` (or the configured `updates.githubRepo` host). No instance ID, no version, no identifiers — just the IP-level metadata GitHub already sees.
- Public `/api/version-status` rate-limited by an in-memory cache refreshed at most once per minute.
## Testing
### Unit (`src/tests/backend/specs/updater/`, vitest, no I/O)
- `UpdatePolicy.test.ts` — full `(installMethod × tier × current/latest)` matrix.
- `VersionChecker.test.ts` — mocked `fetch`. ETag, backoff, parsing of `vulnerable-below` directives, `prerelease` filtering.
- `InstallMethodDetector.test.ts` — fake fs.
- `RollbackHandler.test.ts` — fake state file + clock + spawn. State-machine transitions, crash-loop guard, terminal `rollback-failed`.
- `MaintenanceWindow.test.ts` — cross-midnight, DST.
### Integration (`src/tests/backend/specs/updater-integration.test.ts`)
- Tmp git repo as the "Etherpad install."
- Local HTTP server impersonating GitHub Releases.
- Cases: happy path; install-fail rollback; build-fail rollback; health-check timeout rollback; crash-loop rollback (force `bootCount` to 3); `rollback-failed` terminal blocks auto/autonomous but allows manual.
### API tests
- `GET /admin/update/status` — auth required, schema, expected fields.
- `POST /admin/update/apply` — admin-only, CSRF, refuses on lock/policy denial.
- `POST /admin/update/cancel` — works during pre-execute, 409 during execute.
- `POST /admin/update/acknowledge` — clears terminal state.
- `GET /api/version-status` — public, never leaks version string.
### Playwright (`src/tests/frontend-new/specs/`, headless)
- Update banner appears on `/admin` when an update exists.
- UpdatePage shows version, changelog, Apply button.
- Click triggers `POST /admin/update/apply`, log stream visible.
- Banner copy correct for each terminal state.
- Maintenance-window picker validates inputs.
- Pad footer badge: invisible on `null`, discreet on `severe`, prominent on `vulnerable`.
- Drain announcement appears in pad chat at T-60, T-30, T-10.
### Out of CI (manual smoke)
- Real-network GitHub calls.
- Real process restart with a real supervisor.
- Real `pnpm install` of a different version.
These are covered by:
- A manual smoke runbook in `docs/superpowers/specs/2026-04-25-auto-update-runbook.md` (created during PR 2 implementation), run before each tier ships, against a disposable VM.
- A canary instance running `tier: "auto"` against a beta channel for ≥ 2 weeks before tier 4 ships.
### Test coverage gates per PR
- **PR 1:** VersionChecker, InstallMethodDetector, UpdatePolicy, Notifier unit + status endpoint API + banner Playwright + pad badge Playwright.
- **PR 2:** + UpdateExecutor + RollbackHandler unit + integration (all rollback paths) + apply/cancel/acknowledge API + UpdatePage Playwright. Runbook smoke completed by a human on a disposable VM.
- **PR 3:** + Scheduler unit + grace-window integration + cancel-during-grace test.
- **PR 4:** + MaintenanceWindow unit + window-boundary integration. Canary on beta channel for 2 weeks before merge.
## Phased rollout
Each PR is independently shippable, independently revertable, and gated by `updates.tier`.
### PR 1 — Tier 1: Notify
- `src/node/updater/{VersionChecker,InstallMethodDetector,UpdatePolicy,state}.ts`
- `src/node/updater/Notifier.ts` — single entry point for updater emails. Reads top-level `adminEmail`. Implements the cadence table (immediate-then-weekly for vulnerable, immediate-then-monthly for severe). Persists `email.lastSentFor` in `var/update-state.json` to dedupe. No-op if `adminEmail` unset.
- `src/node/hooks/express/updateStatus.ts` registering `GET /admin/update/status` and `GET /api/version-status`.
- Settings additions in `settings.json.template` and `settings.json.docker`, including new top-level `adminEmail`.
- Admin UI: `UpdatePage.tsx` (read-only), `UpdateBanner.tsx`, route entry, i18n.
- Pad UI: footer badge.
- Tests per PR 1 row above, plus `Notifier.test.ts` (cadence math, dedupe, no-op when `adminEmail` unset).
- `CHANGELOG.md` entry.
**Ship gate:** unit + API + Playwright pass; manual smoke confirms banner appears when version is patched downward.
### PR 2 — Tier 2: Manual click
- `src/node/updater/{UpdateExecutor,RollbackHandler,SessionDrainer,lock,trusted-keys}.ts`
- Endpoints: `POST /admin/update/apply`, `POST /admin/update/cancel`, `POST /admin/update/acknowledge`, `GET /admin/update/log`.
- `UpdatePolicy` flips `canManual` on for `git` install method.
- Admin UI: Apply button, log stream, terminal-state banners, Cancel during pre-execute, Acknowledge on terminal.
- Drain announcement i18n.
- Tests per PR 2 row.
- Manual smoke runbook updated and run end-to-end on a disposable VM, including a deliberately broken-lockfile rollback.
**Ship gate:** integration tests pass for all rollback paths; runbook smoke completed by a human.
### PR 3 — Tier 3: Auto
- `src/node/updater/Scheduler.ts` — listens to `VersionChecker` events, applies grace window, persists pending-update info.
- `UpdatePolicy.canAuto` flips on for `git` + `tier: "auto"`.
- Email notification at grace start (existing SMTP, only if `adminEmail` is set).
- Admin UI: countdown + cancel during grace.
- Tests per PR 3 row.
**Ship gate:** scheduler tests pass; canary running `tier: "auto"` against a beta channel for 2 weeks.
### PR 4 — Tier 4: Autonomous
- `src/node/updater/MaintenanceWindow.ts`.
- `Scheduler` learns to gate on the window. Updates outside the window queue for the next opening.
- `UpdatePolicy.canAutonomous` flips on for `git` + `tier: "autonomous"` + valid window.
- Admin UI: window picker, validation, "next window opens at..." preview.
- Tests per PR 4 row.
**Ship gate:** window unit tests pass; canary switched to `tier: "autonomous"` on the beta channel for 2 weeks.
### Cross-cutting
- **Plugin seam:** `UpdatePolicy` and `VersionChecker` take a `target: 'core' | 'plugins'` parameter from PR 1. Plugin support is not implemented in this spec but the API does not paint us into a corner.
- **Telemetry:** none. Stated explicitly here so it is not silently added later.
- **Docs:** PR 1 introduces `doc/admin/updates.md`; subsequent PRs extend it.
## Open questions
None at spec time. Concrete questions that may surface during implementation are expected to land in PR review, not here.

View file

@ -0,0 +1,146 @@
# Open Graph metadata for pad pages — Design
GitHub issue: https://github.com/ether/etherpad/issues/7599
## Problem
When an Etherpad pad URL is shared in chat apps (WhatsApp, Signal, Slack,
Discord, iMessage, etc.) the link unfurls with no preview because the rendered
HTML carries no Open Graph or Twitter Card metadata. The reporter asks for
basic OG tags so shared links show a meaningful preview.
## Goals
- Pad URLs (`/p/:pad`), timeslider URLs (`/p/:pad/timeslider`), and the
homepage (`/`) emit Open Graph + Twitter Card meta tags.
- A site operator can override the default description via `settings.json`.
- No new runtime dependencies. Implementation lives in the existing EJS
templates and the existing settings module.
## Non-goals
- Per-pad descriptions, custom OG images per pad, or pulling content from the
pad body. The pad text is mutable and frequently empty at first load; using
it would be both expensive (extra DB read on a hot path) and misleading.
- A plugin hook for OG override. Defer until a plugin actually needs it
(YAGNI).
- Removing or changing the existing `<meta name="robots" content="noindex,
nofollow">` tag. OG unfurling is performed by chat clients that ignore
`robots`, so the privacy posture is unchanged.
## Tags emitted
For the **pad page** (`/p/:pad`):
| Tag | Value |
| ------------------- | ----------------------------------------------------------- |
| `og:title` | `{decoded pad name} | {settings.title}` |
| `og:description` | `settings.socialDescription` |
| `og:image` | absolute URL to `{req.protocol}://{host}/favicon.ico`* |
| `og:url` | absolute URL of the request |
| `og:type` | `website` |
| `og:site_name` | `settings.title` |
| `og:locale` | negotiated `renderLang` (already computed in `pad.html`), normalized to BCP-47 with underscore (e.g. `en_US`, `de_DE`); falls back to `en_US` |
| `og:image:alt` | `"{settings.title} logo"` (a11y — screen readers in chat clients announce this) |
| `twitter:card` | `summary` |
| `twitter:title` | same as `og:title` |
| `twitter:description` | same as `og:description` |
| `twitter:image` | same as `og:image` |
| `twitter:image:alt` | same as `og:image:alt` |
\* `settings.favicon` is normally null (defaults route to the bundled
`favicon.ico` via the favicon middleware). The template builds the absolute
URL by joining `req.protocol`, `req.get('host')`, and the favicon path. If
`settings.favicon` is an absolute URL it is used verbatim.
For the **timeslider** (`/p/:pad/timeslider`): same tags, with `og:title` set
to `{decoded pad name} (history) | {settings.title}`.
For the **homepage** (`/`): same tags, with `og:title` set to
`settings.title` and `og:url` set to the request URL.
## i18n source
The description text lives in Etherpad's standard locale catalog under the
key `pad.social.description`. The shipped English default in
`src/locales/en.json` is the softer rewording of the wording in the issue:
> A collaborative document that everyone can edit in real time.
Other locale files may translate the key as the translation community picks
it up; missing translations fall back to English. **No new `settings.json`
key is added** — operators who want to override the text per-language do so
via the existing `customLocaleStrings` mechanism that Etherpad already
supports.
**Locale negotiation.** Resolution order at request time:
1. `locales[renderLang]['pad.social.description']` (exact match, where
`renderLang` was negotiated via `req.acceptsLanguages()`).
2. `locales[primarySubtag]['pad.social.description']` (e.g. `de-AT``de`).
3. `locales.en['pad.social.description']` (English fallback).
4. Empty string (only if `en.json` is missing the key — should not happen
in core).
The `i18n` hook now exports the loaded `locales` map so other server-side
modules can look up translated strings without re-reading the JSON files.
## Implementation outline
1. **Settings** — declare `socialDescription: string` on the Settings module
with the default above; document it in both example settings files.
2. **Helper** — extract the meta-tag block into a single source of truth.
Preferred form is an EJS partial included from each template; if
Etherpad's `eejs` wrapper does not support `include()` cleanly, fall back
to a small JS helper (e.g. `src/node/utils/socialMeta.ts`) exported into
the template via the existing `eejs.require` context, returning the
rendered `<meta>` block as a string. Implementation step 1 of the plan
must verify which mechanism `eejs` supports before committing to one.
3. **pad.html / timeslider.html / index.html** — compute the four template
inputs at the top of each file and `<%- include('_socialMeta', {...}) %>`
in `<head>`, after the existing `<title>` line. The pad name is decoded
with `decodeURIComponent(req.params.pad)` and HTML-escaped via the
existing `<%= %>` mechanism (EJS escapes by default).
4. **Route handlers**`specialpages.ts` already passes `req` and
`settings` to the templates; no route changes needed.
## Tests
Add to the existing backend test suite (likely
`src/tests/backend/specs/specialpages.ts` or a new
`src/tests/backend/specs/socialmeta.ts`):
- GET `/p/TestPad-7599` → response HTML contains
`<meta property="og:title" content="TestPad-7599 | Etherpad">` and an
`og:description` matching the default.
- GET `/p/TestPad-7599` with `settings.socialDescription` overridden to
`"Custom desc"` → that custom value appears in `og:description`.
- GET `/p/Has%20Space``og:title` contains `Has Space` (decoded) and is
HTML-safe (no raw `%`).
- GET `/p/<script>` (encoded) → `og:title` contains escaped `&lt;script&gt;`,
not raw HTML.
- GET `/p/TestPad/timeslider``og:title` contains `(history)`.
- GET `/``og:title` equals `settings.title`.
- GET `/p/TestPad` with `Accept-Language: de` and
`socialDescription: {default: "X", de: "Y"}``og:description` is `Y`
and `og:locale` is `de_DE` (or `de`).
- Response includes `og:image:alt` and `twitter:image:alt`.
The XSS escape test is the security-relevant one: pad IDs are user-controlled
(anyone can navigate to `/p/<anything>`).
## Risks and trade-offs
- **Pad-name leakage.** Anyone the link is shared with can already see the pad
name in the URL, so emitting it in `og:title` does not expose anything new.
- **Caching.** OG tags are read once per unfurl. Chat clients cache aggressively;
changing `socialDescription` will not propagate to previously-cached previews.
This is acceptable and standard.
- **Template-set drift.** Etherpad has three top-level HTML templates that
need OG tags; the `_socialMeta` partial avoids three copies of the same
block.
## Out of scope (future work)
- A `padSocialMetadata` hook that lets plugins override the values.
- Per-pad description (e.g. ep_pad_title integration).
- Generated preview images (would require a rendering service).

View file

@ -42,14 +42,15 @@
"ui": "link:ui"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.13.0"
},
"packageManager": "pnpm@11.0.6",
"repository": {
"type": "git",
"url": "https://github.com/ether/etherpad.git"
},
"engineStrict": true,
"version": "2.7.2",
"version": "2.7.3",
"license": "Apache-2.0",
"pnpm": {
"onlyBuiltDependencies": [
@ -57,24 +58,6 @@
],
"ignoredBuiltDependencies": [
"@scarf/scarf"
],
"overrides": {
"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",
"flatted": ">=3.4.2",
"follow-redirects": ">=1.16.0",
"glob@>=10.2.0 <10.5.0": ">=10.5.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.14.2": ">=6.14.2",
"serialize-javascript": ">=7.0.5",
"socket.io-parser@>=4.0.0 <4.2.6": ">=4.2.6",
"tar@<7.5.11": ">=7.5.11",
"vite@>=7.0.0 <7.3.2": ">=7.3.2"
}
]
}
}

139
packaging/README.md Normal file
View file

@ -0,0 +1,139 @@
# Etherpad Debian / RPM packaging
Produces native `.deb` (and, with the same manifest, `.rpm` / `.apk`)
packages for Etherpad using [nfpm](https://nfpm.goreleaser.com).
## Layout
```
packaging/
nfpm.yaml # nfpm package manifest
bin/etherpad # /usr/bin launcher
scripts/ # preinst / postinst / prerm / postrm
systemd/etherpad.service
systemd/etherpad.default
etc/settings.json.dist # populated in CI from settings.json.template
```
Built artefacts land in `./dist/`.
## Building locally
Prereqs: Node 24 (current LTS; `engines.node` floor is 20), pnpm 10+, nfpm.
```sh
pnpm install --frozen-lockfile
pnpm run build:etherpad
# Stage the tree the way CI does:
STAGE=staging/opt/etherpad
mkdir -p "$STAGE"
cp -a src bin package.json pnpm-workspace.yaml README.md LICENSE \
node_modules "$STAGE/"
printf 'packages:\n - src\n - bin\n' > "$STAGE/pnpm-workspace.yaml"
cp settings.json.template packaging/etc/settings.json.dist
VERSION=$(node -p "require('./package.json').version") \
ARCH=amd64 \
nfpm package --packager deb -f packaging/nfpm.yaml --target dist/
```
## End-to-end test (Docker, no real systemd needed)
`packaging/test-local.sh` builds the `.deb` and runs the same smoke
test the CI workflow does, inside a throwaway systemd-enabled
container:
```sh
packaging/test-local.sh # build + smoke + purge
packaging/test-local.sh --shell # leave the container up so you can poke around
packaging/test-local.sh --build-only # just produce dist/*.deb
```
This is the fastest way to validate that the systemd hardening, plugin
path symlinks, and tsx wrapper actually work together before pushing.
## Installing via the Etherpad apt repository (recommended)
The release workflow publishes a signed apt repository at
`https://etherpad.org/apt/` on every tagged release. Three lines on
any Debian/Ubuntu/Mint:
```sh
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 update && sudo apt install etherpad
```
`apt upgrade` works going forward. Repo metadata is signed with the
GPG keypair documented in `packaging/apt/key.asc` (long key id
`AF0CD687D51A6E63`).
## Installing a single .deb directly
The release page publishes both versioned and stable filenames per arch:
```sh
# Stable URL — always points at the most recent release:
curl -fsSL -o etherpad-latest_amd64.deb \
https://github.com/ether/etherpad/releases/latest/download/etherpad-latest_amd64.deb
sudo apt install ./etherpad-latest_amd64.deb
# Or pin to a specific version:
sudo apt install ./dist/etherpad_<version>_amd64.deb
sudo systemctl start etherpad
curl http://localhost:9001/health
```
`apt` will pull in `nodejs (>= 22)` (matches Etherpad's `engines.node`).
Recommended runtime is the current Node.js LTS (24); on distros without a
new enough Node, add NodeSource first:
```sh
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
```
## Configuration
- Edit `/etc/etherpad/settings.json`, then
`sudo systemctl restart etherpad`.
- Environment overrides: `/etc/default/etherpad`.
- Logs: `journalctl -u etherpad -f`.
- Data (sqlite default): `/var/lib/etherpad/etherpad.db`.
The shipped settings template defaults to `dbType: "dirty"`, which the
template itself warns is for testing only. `postinstall` rewrites the
seeded `/etc/etherpad/settings.json` to `sqlite` and points it at
`/var/lib/etherpad/etherpad.db` so fresh installs get an ACID-safe DB
out of the box. Existing `/etc/etherpad/settings.json` is never touched
on upgrade.
## Upgrading
`dpkg --install etherpad_<new>.deb` (or `apt install`) replaces the app
tree under `/opt/etherpad` while preserving `/etc/etherpad/*` and
`/var/lib/etherpad/*`. The service is restarted automatically.
## Removing
- `sudo apt remove etherpad` — keeps config and data.
- `sudo apt purge etherpad` — also removes config, data, and the
`etherpad` system user.
## Publishing to an APT repository (follow-up)
Out of scope here — requires credentials and ownership decisions.
Recipes once a repo is picked:
- **Cloudsmith** (easiest, free OSS tier):
`cloudsmith push deb ether/etherpad/any-distro/any-version dist/*.deb`
- **Launchpad PPA**: requires signed source packages (a `debian/` tree),
which nfpm does not produce — use `debuild` separately.
- **Self-hosted reprepro**:
`reprepro -b /srv/apt includedeb stable dist/*.deb`
Wire the chosen option into `.github/workflows/deb-package.yml` after
the `release` job.

View file

@ -0,0 +1,90 @@
#!/usr/bin/env bash
# One-time setup: generate a dedicated GPG keypair for signing the
# Etherpad apt repository's Release/InRelease files. Outputs go into
# ./etherpad-apt-{private,public}.asc in the directory you run this in.
#
# After running this script:
# 1. Paste the *private* key contents into a new GitHub repo/org secret
# called APT_SIGNING_KEY (Settings → Secrets and variables → Actions
# → New repository secret). Then delete the .asc file or move it to
# a password manager — GitHub is the canonical store.
# 2. Hand the *public* key contents to whoever is wiring up the apt
# workflow; it gets committed at packaging/apt/key.asc so end users
# can pull it from https://ether.github.io/etherpad/key.asc.
# 3. Note the printed long key ID — the workflow uses it as
# --default-key for `gpg --clearsign`.
set -euo pipefail
NAME_REAL="${NAME_REAL:-Etherpad APT Repository}"
NAME_EMAIL="${NAME_EMAIL:-contact@etherpad.org}"
EXPIRE_YEARS="${EXPIRE_YEARS:-5}"
OUT_DIR="$(pwd)"
PRIV="${OUT_DIR}/etherpad-apt-private.asc"
PUB="${OUT_DIR}/etherpad-apt-public.asc"
if [[ -e "${PRIV}" || -e "${PUB}" ]]; then
echo "!! Output files already exist in ${OUT_DIR}:" >&2
ls -la "${PRIV}" "${PUB}" 2>/dev/null >&2 || true
echo " Move/delete them first, or set OUT_DIR to a clean directory." >&2
exit 1
fi
if ! command -v gpg >/dev/null 2>&1; then
echo "!! gpg not found. Install with: sudo apt install gnupg" >&2
exit 1
fi
echo "==> Generating Ed25519 signing key for: ${NAME_REAL} <${NAME_EMAIL}>"
echo " Expires in ${EXPIRE_YEARS} years. No passphrase (CI uses it unattended)."
# Use a temp GNUPGHOME so we don't pollute the user's keyring with a
# CI-only key, and so subsequent re-runs don't need to delete keys.
TMP_GNUPG="$(mktemp -d)"
trap 'rm -rf "${TMP_GNUPG}"' EXIT
chmod 700 "${TMP_GNUPG}"
export GNUPGHOME="${TMP_GNUPG}"
gpg --batch --gen-key <<EOF
%no-protection
Key-Type: EDDSA
Key-Curve: ed25519
Subkey-Type: ECDH
Subkey-Curve: cv25519
Name-Real: ${NAME_REAL}
Name-Email: ${NAME_EMAIL}
Expire-Date: ${EXPIRE_YEARS}y
%commit
EOF
echo
echo "==> Key generated. Details:"
gpg --list-secret-keys --keyid-format=long "${NAME_EMAIL}"
KEY_ID="$(gpg --list-secret-keys --with-colons "${NAME_EMAIL}" \
| awk -F: '/^sec/ {print $5; exit}')"
echo
echo "==> Exporting to ${OUT_DIR}/"
gpg --armor --export-secret-keys "${NAME_EMAIL}" > "${PRIV}"
gpg --armor --export "${NAME_EMAIL}" > "${PUB}"
chmod 600 "${PRIV}"
chmod 644 "${PUB}"
echo
echo "Done."
echo
echo " Private key (UPLOAD AS GITHUB SECRET 'APT_SIGNING_KEY'):"
echo " ${PRIV}"
echo " Public key (commit as packaging/apt/key.asc, hand to me):"
echo " ${PUB}"
echo " Long key ID (note this somewhere; used as --default-key in the workflow):"
echo " ${KEY_ID}"
echo
echo "Next steps:"
echo " 1. Open https://github.com/ether/etherpad/settings/secrets/actions/new"
echo " Name: APT_SIGNING_KEY"
echo " Value: <paste the contents of ${PRIV}>"
echo " 2. Securely store ${PRIV} (password manager) or delete it after upload."
echo " 3. Send me ${PUB} (or its contents) for the public-key commit."

14
packaging/apt/key.asc Normal file
View file

@ -0,0 +1,14 @@
-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEae+WgxYJKwYBBAHaRw8BAQdAlcdLkrHestdHPWsBdAHX/S48DAmIiU9wu9JH
dPZbpmO0LkV0aGVycGFkIEFQVCBSZXBvc2l0b3J5IDxjb250YWN0QGV0aGVycGFk
Lm9yZz6ImQQTFgoAQRYhBGlT+gxkMfMDR9ZbA68M1ofVGm5jBQJp75aDAhsjBQkJ
ZgGABQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJEK8M1ofVGm5jerkBAKd2
PtrZikAXFeUrlM2BLinXFCL6UOTra9tvhjsuM2ZrAP4/5yqSMIVCwiHluyg08Nzd
aUW0YK9hJOKQkgL3RXTHCLg4BGnvloMSCisGAQQBl1UBBQEBB0BEuHcDkjBQCfPH
+zjFwbcPj06ODzuqhHbWDVLdqVhTcQMBCAeIfgQYFgoAJhYhBGlT+gxkMfMDR9Zb
A68M1ofVGm5jBQJp75aDAhsMBQkJZgGAAAoJEK8M1ofVGm5jlYwBAMvcavJ5/PKH
IcAsZt0SLv2NkeRcTd58oadCivcrAi1WAQDugqCn8Og39e64ND7LpUKPuqO/02gD
shfWz77UlCy3Cw==
=Bcop
-----END PGP PUBLIC KEY BLOCK-----

28
packaging/bin/etherpad Executable file
View file

@ -0,0 +1,28 @@
#!/bin/sh
# /usr/bin/etherpad - thin wrapper that runs Etherpad in production mode.
# Invoked by the etherpad.service systemd unit.
set -e
APP_DIR="${ETHERPAD_DIR:-/opt/etherpad}"
cd "${APP_DIR}"
: "${NODE_ENV:=production}"
export NODE_ENV
export ETHERPAD_PRODUCTION=true
# Resolve `node` explicitly to the apt-installed binary (the .deb declares
# `Depends: nodejs (>= 22)`, which always lands at /usr/bin/node). Relying
# on PATH would let a stray /usr/local/bin/node — e.g. an older nvm or
# toolcache install — shadow the version we actually require, and the
# server would crash on startup with "Node 20.x is not supported".
NODE_BIN=/usr/bin/node
[ -x "${NODE_BIN}" ] || NODE_BIN=$(command -v node) \
|| { echo "etherpad: node not found (install the 'nodejs' package)" >&2; exit 1; }
# Run the server through tsx's CommonJS hook — Etherpad's prod entrypoint
# (src/node/server.ts) uses `exports.start = ...`, which fails under the
# ESM loader. Mirrors the `prod` script in src/package.json.
exec "${NODE_BIN}" \
--require "${APP_DIR}/src/node_modules/tsx/dist/cjs/index.cjs" \
"${APP_DIR}/src/node/server.ts" \
"$@"

128
packaging/nfpm.yaml Normal file
View file

@ -0,0 +1,128 @@
# nfpm configuration for Etherpad Debian/RPM/APK packages.
# Build with: nfpm package --packager deb --target dist/
# See: https://nfpm.goreleaser.com/configuration/
name: etherpad
arch: ${ARCH} # amd64 | arm64 (exported by CI)
platform: linux
version: ${VERSION} # e.g. 2.6.1, stripped of leading "v"
version_schema: semver
release: "1"
section: web
priority: optional
maintainer: "Etherpad Foundation <contact@etherpad.org>"
description: |
Etherpad is a real-time collaborative editor for the web.
This package installs Etherpad as a systemd service running
from /opt/etherpad with configuration in /etc/etherpad.
vendor: "Etherpad Foundation"
homepage: https://etherpad.org
license: Apache-2.0
depends:
- nodejs (>= 22)
- adduser
- ca-certificates
recommends:
- libreoffice # enables DOC/DOCX/PDF/ODT export
- curl
suggests:
- postgresql-client
- mariadb-client
# The short-lived "etherpad-lite" package name from the reverted PR #7559
# never shipped to a stable release, but declare replaces/conflicts so any
# development builds upgrade cleanly to the renamed package.
conflicts:
- etherpad-lite
replaces:
- etherpad-lite
provides:
- etherpad-lite
# ---------------------------------------------------------------------------
# Contents. staging/ is populated by CI before invoking nfpm:
# staging/opt/etherpad/ -- source + node_modules + built assets
# ---------------------------------------------------------------------------
contents:
- src: ./staging/opt/etherpad
dst: /opt/etherpad
type: tree
file_info:
mode: 0755
owner: root
group: root
- src: ./packaging/systemd/etherpad.service
dst: /lib/systemd/system/etherpad.service
file_info:
mode: 0644
# Default environment file (conffile: preserved on upgrade).
# Mode 0640 + group=etherpad so passwords/secrets admins drop in here
# are only readable by root and the etherpad service user — /etc/default
# is world-readable by default (0644), which would leak DB creds etc.
- src: ./packaging/systemd/etherpad.default
dst: /etc/default/etherpad
type: config|noreplace
file_info:
mode: 0640
owner: root
group: etherpad
- src: ./packaging/bin/etherpad
dst: /usr/bin/etherpad
file_info:
mode: 0755
# Template used by postinstall to seed /etc/etherpad/settings.json.
# Intentionally NOT a conffile: postinstall creates the real settings.json
# once on first install and never touches it again, so upgrades don't
# prompt with dpkg merge dialogs.
- src: ./packaging/etc/settings.json.dist
dst: /usr/share/etherpad/settings.json.dist
file_info:
mode: 0644
- dst: /etc/etherpad
type: dir
file_info:
mode: 0755
- dst: /var/lib/etherpad
type: dir
file_info:
mode: 0750
- dst: /var/log/etherpad
type: dir
file_info:
mode: 0750
scripts:
preinstall: ./packaging/scripts/preinstall.sh
postinstall: ./packaging/scripts/postinstall.sh
preremove: ./packaging/scripts/preremove.sh
postremove: ./packaging/scripts/postremove.sh
overrides:
deb:
depends:
- nodejs (>= 22)
- ca-certificates
rpm:
depends:
- nodejs >= 22
- shadow-utils
- ca-certificates
# adduser is needed by preinst (which creates the etherpad user before
# unpacking). Plain `dpkg -i` does not pre-fetch Depends, so we mark it
# Pre-Depends to guarantee it's available at preinst time. Note: this
# is a top-level `deb:` block, not under `overrides:` (where nfpm's
# Overridables schema does not include predepends).
deb:
predepends:
- adduser

125
packaging/scripts/postinstall.sh Executable file
View file

@ -0,0 +1,125 @@
#!/bin/sh
# postinstall - runs after files have been unpacked.
# Debian actions: configure | abort-upgrade | abort-remove | abort-deconfigure
set -e
ETC_DIR=/etc/etherpad
VAR_DIR=/var/lib/etherpad
LOG_DIR=/var/log/etherpad
APP_DIR=/opt/etherpad
RUNTIME_VAR="${VAR_DIR}/var"
DIST_SETTINGS=/usr/share/etherpad/settings.json.dist
ACTIVE_SETTINGS="${ETC_DIR}/settings.json"
INSTALLED_PLUGINS="${RUNTIME_VAR}/installed_plugins.json"
case "$1" in
configure)
mkdir -p "${ETC_DIR}" "${VAR_DIR}" "${LOG_DIR}" "${RUNTIME_VAR}"
chown root:etherpad "${ETC_DIR}"
chmod 0750 "${ETC_DIR}"
chown etherpad:etherpad "${VAR_DIR}" "${LOG_DIR}" "${RUNTIME_VAR}"
chmod 0750 "${VAR_DIR}" "${LOG_DIR}" "${RUNTIME_VAR}"
if [ ! -e "${ACTIVE_SETTINGS}" ]; then
cp "${DIST_SETTINGS}" "${ACTIVE_SETTINGS}"
# Switch the shipped default from dirty (dev-only, per the template's
# own comment) to sqlite, and point the file at /var/lib/etherpad so
# ProtectSystem=strict doesn't block writes.
sed -i \
-e 's|"dbType": "dirty"|"dbType": "sqlite"|' \
-e 's|"filename": "var/dirty.db"|"filename": "/var/lib/etherpad/etherpad.db"|' \
"${ACTIVE_SETTINGS}"
# Owned by the etherpad service user with group=etherpad mode 0660
# so the admin /admin/settings UI can save changes back to disk
# while still keeping the file unreadable by other users (DB
# creds live here).
chown etherpad:etherpad "${ACTIVE_SETTINGS}"
chmod 0660 "${ACTIVE_SETTINGS}"
fi
# Etherpad reads settings.json from CWD (/opt/etherpad). Expose
# the /etc copy there via symlink.
ln -sfn "${ACTIVE_SETTINGS}" "${APP_DIR}/settings.json"
# Redirect /opt/etherpad/var to a writable location under
# /var/lib/etherpad. Etherpad writes var/js, var/installed_plugins.json,
# etc. on startup; ProtectSystem=strict blocks /opt writes, and the
# symlink keeps ReadWritePaths=/var/lib/etherpad sufficient.
if [ -e "${APP_DIR}/var" ] && [ ! -L "${APP_DIR}/var" ]; then
# Migrate any payload from a previous install that wrote into /opt.
cp -a "${APP_DIR}/var/." "${RUNTIME_VAR}/" 2>/dev/null || true
rm -rf "${APP_DIR}/var"
fi
ln -sfn "${RUNTIME_VAR}" "${APP_DIR}/var"
# Seed installed_plugins.json so checkForMigration() does not spawn
# `pnpm ls` on first boot. pnpm is not a package dependency, and the
# bundled node_modules already contains every shipped plugin.
if [ ! -e "${INSTALLED_PLUGINS}" ]; then
VERSION=$(node -p "require('${APP_DIR}/src/package.json').version" 2>/dev/null \
|| node -p "require('${APP_DIR}/package.json').version" 2>/dev/null \
|| echo "0.0.0")
cat >"${INSTALLED_PLUGINS}" <<EOF
{"plugins":[{"name":"ep_etherpad-lite","version":"${VERSION}"}]}
EOF
fi
chown -hR etherpad:etherpad "${RUNTIME_VAR}"
# Plugin install paths. Etherpad's admin UI installs plugins into
# ${root}/src/plugin_packages and creates symlinks under
# ${root}/src/node_modules. Both are under /opt and would EACCES
# under the etherpad user without these adjustments.
PLUGIN_PKG_LIVE=/var/lib/etherpad/plugin_packages
PLUGIN_PKG_LINK="${APP_DIR}/src/plugin_packages"
NODE_MODULES_DIR="${APP_DIR}/src/node_modules"
mkdir -p "${PLUGIN_PKG_LIVE}"
if [ -e "${PLUGIN_PKG_LINK}" ] && [ ! -L "${PLUGIN_PKG_LINK}" ]; then
cp -a "${PLUGIN_PKG_LINK}/." "${PLUGIN_PKG_LIVE}/" 2>/dev/null || true
rm -rf "${PLUGIN_PKG_LINK}"
fi
# chown after the cp -- cp -a preserves the (root) ownership of the
# staged source files and would re-root anything we chowned earlier.
chown -hR etherpad:etherpad "${PLUGIN_PKG_LIVE}"
ln -sfn "${PLUGIN_PKG_LIVE}" "${PLUGIN_PKG_LINK}"
# node_modules is bundled (root-owned contents); the directory itself
# must be group-writable by etherpad so plugin installs can create
# symlinks alongside the shipped packages. ReadWritePaths in the unit
# also exposes it as writable under ProtectSystem=strict.
if [ -d "${NODE_MODULES_DIR}" ]; then
chgrp etherpad "${NODE_MODULES_DIR}"
chmod 2775 "${NODE_MODULES_DIR}"
fi
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
# Enable on first install; leave state alone on upgrade.
if [ -z "$2" ]; then
systemctl enable etherpad.service >/dev/null 2>&1 || true
fi
# Restart on upgrade to pick up new code (skip on fresh install --
# admin may want to configure first).
if [ -n "$2" ]; then
systemctl try-restart etherpad.service >/dev/null 2>&1 || true
fi
fi
cat <<EOF
Etherpad installed. Edit /etc/etherpad/settings.json, then:
sudo systemctl start etherpad
Default port 9001. Service logs: journalctl -u etherpad -f
EOF
;;
abort-upgrade|abort-remove|abort-deconfigure)
;;
*)
echo "postinstall called with unknown argument: $1" >&2
exit 1
;;
esac
exit 0

48
packaging/scripts/postremove.sh Executable file
View file

@ -0,0 +1,48 @@
#!/bin/sh
# postremove - runs after files are removed.
# Debian actions: remove | purge | upgrade | failed-upgrade | abort-install |
# abort-upgrade | disappear
set -e
APP_DIR=/opt/etherpad
case "$1" in
remove)
[ -L "${APP_DIR}/settings.json" ] && rm -f "${APP_DIR}/settings.json" || true
[ -L "${APP_DIR}/var" ] && rm -f "${APP_DIR}/var" || true
[ -L "${APP_DIR}/src/plugin_packages" ] && rm -f "${APP_DIR}/src/plugin_packages" || true
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
# Disable so the wants/ symlink doesn't dangle after the unit
# file is removed by dpkg.
systemctl disable etherpad.service >/dev/null 2>&1 || true
systemctl daemon-reload || true
fi
;;
purge)
rm -rf /etc/etherpad
rm -rf /var/lib/etherpad
rm -rf /var/log/etherpad
if getent passwd etherpad >/dev/null 2>&1; then
deluser --system etherpad >/dev/null 2>&1 || true
fi
if getent group etherpad >/dev/null 2>&1; then
delgroup --system etherpad >/dev/null 2>&1 || true
fi
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
fi
;;
upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
;;
*)
echo "postremove called with unknown argument: $1" >&2
exit 1
;;
esac
exit 0

28
packaging/scripts/preinstall.sh Executable file
View file

@ -0,0 +1,28 @@
#!/bin/sh
# preinstall - runs before files are unpacked.
# Debian actions: install | upgrade | abort-upgrade
set -e
case "$1" in
install|upgrade)
if ! getent group etherpad >/dev/null 2>&1; then
addgroup --system etherpad
fi
if ! getent passwd etherpad >/dev/null 2>&1; then
adduser --system --ingroup etherpad \
--home /var/lib/etherpad \
--no-create-home \
--shell /usr/sbin/nologin \
--gecos "Etherpad service user" \
etherpad
fi
;;
abort-upgrade)
;;
*)
echo "preinstall called with unknown argument: $1" >&2
exit 1
;;
esac
exit 0

20
packaging/scripts/preremove.sh Executable file
View file

@ -0,0 +1,20 @@
#!/bin/sh
# preremove - runs before files are removed.
# Debian actions: remove | upgrade | deconfigure | failed-upgrade
set -e
case "$1" in
remove|upgrade|deconfigure)
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
systemctl stop etherpad.service >/dev/null 2>&1 || true
fi
;;
failed-upgrade)
;;
*)
echo "preremove called with unknown argument: $1" >&2
exit 1
;;
esac
exit 0

View file

@ -0,0 +1,7 @@
# /etc/default/etherpad
# Environment overrides for the etherpad systemd service.
# Any variable referenced by ${VAR:default} in settings.json can be set here.
NODE_ENV=production
# PORT=9001
# NODE_OPTIONS=--max-old-space-size=2048

View file

@ -0,0 +1,52 @@
[Unit]
Description=Etherpad - real-time collaborative editor
Documentation=https://etherpad.org https://github.com/ether/etherpad
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=etherpad
Group=etherpad
WorkingDirectory=/opt/etherpad
EnvironmentFile=-/etc/default/etherpad
ExecStart=/usr/bin/etherpad
Restart=on-failure
RestartSec=5s
TimeoutStopSec=20s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=etherpad
# --- Sandboxing ---------------------------------------------------------
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectHostname=true
ProtectClock=true
RestrictRealtime=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=false # Node's JIT needs W+X mappings
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
UMask=0027
# /opt/etherpad/src/node_modules must be writable so the admin UI can
# create symlinks for newly installed plugins alongside the bundled deps.
# /opt/etherpad/src/plugin_packages is symlinked into /var/lib/etherpad
# by postinstall, so it's already covered by the entry below.
ReadWritePaths=/var/lib/etherpad /var/log/etherpad /etc/etherpad /opt/etherpad/src/node_modules
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target

203
packaging/test-local.sh Executable file
View file

@ -0,0 +1,203 @@
#!/usr/bin/env bash
# Build the .deb locally and run it through the same smoke test as CI,
# in a throwaway systemd-enabled Docker container. Mirrors the steps in
# .github/workflows/deb-package.yml so failures here predict CI failures.
#
# Usage: packaging/test-local.sh # build + smoke test
# packaging/test-local.sh --shell # leave a shell open after smoke test
# packaging/test-local.sh --build-only
#
# Requirements: docker, node, pnpm. nfpm is fetched into the container.
set -euo pipefail
REPO_ROOT="$(git rev-parse --show-toplevel)"
cd "${REPO_ROOT}"
ARCH="${ARCH:-amd64}"
NFPM_VERSION="${NFPM_VERSION:-v2.43.0}"
SYSTEMD_IMAGE="${SYSTEMD_IMAGE:-jrei/systemd-ubuntu:24.04}"
CONTAINER_NAME="${CONTAINER_NAME:-etherpad-deb-test}"
MODE=smoke
NO_SYSTEMD=
for arg in "$@"; do
case "$arg" in
--shell) MODE=shell ;;
--build-only) MODE=build ;;
--no-systemd) NO_SYSTEMD=1 ;;
*) echo "unknown arg: $arg" >&2; exit 2 ;;
esac
done
echo "==> Refreshing dependencies (matches CI)"
# CI=1 makes pnpm non-interactive (so it doesn't prompt on a clean reinstall).
CI=1 pnpm install --frozen-lockfile
echo "==> Building staging tree"
rm -rf staging dist packaging/etc
mkdir -p staging/opt/etherpad packaging/etc dist
cp -a src bin package.json pnpm-workspace.yaml README.md LICENSE node_modules \
staging/opt/etherpad/
printf 'packages:\n - src\n - bin\n' > staging/opt/etherpad/pnpm-workspace.yaml
cp settings.json.template packaging/etc/settings.json.dist
echo "==> Building .deb via nfpm ${NFPM_VERSION} (in container)"
VERSION="$(node -p 'require("./package.json").version')"
# Pin to NFPM_VERSION so local builds match what CI produces. The
# goreleaser/nfpm tag drops the leading "v".
docker run --rm \
-v "${REPO_ROOT}":/w -w /w \
-e VERSION="${VERSION}" -e ARCH="${ARCH}" \
"goreleaser/nfpm:${NFPM_VERSION#v}" \
package --packager deb -f packaging/nfpm.yaml --target dist/
DEB_FILE="$(ls dist/etherpad_*_${ARCH}.deb | head -1)"
echo "==> Built: ${DEB_FILE}"
dpkg-deb -I "${DEB_FILE}" | sed 's/^/ /'
if [ "${MODE}" = "build" ]; then
exit 0
fi
docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
trap '[ "${MODE}" = shell ] || docker rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true' EXIT
if [ -z "${NO_SYSTEMD}" ]; then
echo "==> Launching systemd container (${SYSTEMD_IMAGE})"
# systemd-in-docker on cgroups v2 needs: --privileged, --cgroupns=host,
# rw mount of /sys/fs/cgroup, and tmpfs for /run + /run/lock.
if ! docker run -d --name "${CONTAINER_NAME}" \
--privileged --cgroupns=host \
--tmpfs /tmp --tmpfs /run --tmpfs /run/lock \
-v /sys/fs/cgroup:/sys/fs/cgroup:rw \
-v "${REPO_ROOT}/dist":/dist:ro \
-p 9001:9001 \
"${SYSTEMD_IMAGE}" >/dev/null; then
echo "!! docker run failed; rerun with --no-systemd to skip the systemd path."
exit 1
fi
echo "==> Waiting for systemd in container to be ready"
ready=
for i in $(seq 1 30); do
state="$(docker inspect -f '{{.State.Status}}' "${CONTAINER_NAME}" 2>/dev/null || echo missing)"
if [ "${state}" != "running" ]; then
echo "!! container exited (state=${state}). Last logs:"
docker logs "${CONTAINER_NAME}" 2>&1 | tail -50 || true
echo
echo "!! Tip: rerun with --no-systemd to skip the systemd-in-Docker"
echo " step and validate everything else (postinstall, wrapper,"
echo " plugin paths, /health under a manual launch)."
exit 1
fi
if docker exec "${CONTAINER_NAME}" systemctl list-units --type=target >/dev/null 2>&1; then
ready=1; break
fi
sleep 1
done
[ -n "${ready}" ] || { echo "!! systemd never came up"; docker logs "${CONTAINER_NAME}" 2>&1 | tail -50; exit 1; }
else
# Reuse whichever ubuntu-ish image is already on disk to avoid a
# registry round-trip (handy on flaky networks).
PLAIN_IMAGE="${PLAIN_IMAGE:-}"
if [ -z "${PLAIN_IMAGE}" ]; then
for candidate in ubuntu:24.04 "${SYSTEMD_IMAGE}" ubuntu:latest debian:stable; do
if docker image inspect "${candidate}" >/dev/null 2>&1; then
PLAIN_IMAGE="${candidate}"
break
fi
done
: "${PLAIN_IMAGE:=ubuntu:24.04}"
fi
echo "==> Launching plain container (--no-systemd, image=${PLAIN_IMAGE})"
docker run -d --name "${CONTAINER_NAME}" \
--entrypoint /bin/sh \
--tmpfs /tmp --tmpfs /run \
-v "${REPO_ROOT}/dist":/dist:ro \
-p 9001:9001 \
"${PLAIN_IMAGE}" -c 'sleep infinity' >/dev/null
fi
echo "==> Installing nodejs + the .deb inside the container"
docker exec "${CONTAINER_NAME}" bash -lc '
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl ca-certificates gnupg
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - >/dev/null
apt-get install -y -qq nodejs
dpkg -i /dist/etherpad_*_'"${ARCH}"'.deb || apt-get install -f -y -qq
'
echo "==> Asserting postinstall results"
docker exec "${CONTAINER_NAME}" bash -lc '
set -eux
test -x /usr/bin/etherpad
test -f /etc/etherpad/settings.json
test -L /opt/etherpad/settings.json
test -L /opt/etherpad/var
[ "$(readlink /opt/etherpad/var)" = "/var/lib/etherpad/var" ]
test -L /opt/etherpad/src/plugin_packages
[ "$(readlink /opt/etherpad/src/plugin_packages)" = "/var/lib/etherpad/plugin_packages" ]
test -d /var/lib/etherpad/plugin_packages
[ "$(stat -c %U /var/lib/etherpad/plugin_packages)" = "etherpad" ]
[ "$(stat -c %G /opt/etherpad/src/node_modules)" = "etherpad" ]
test -f /var/lib/etherpad/var/installed_plugins.json
grep -q "ep_etherpad-lite" /var/lib/etherpad/var/installed_plugins.json
grep -q "\"dbType\": \"sqlite\"" /etc/etherpad/settings.json
id etherpad
'
if [ -z "${NO_SYSTEMD}" ]; then
echo "==> Starting etherpad.service"
docker exec "${CONTAINER_NAME}" systemctl start etherpad
else
echo "==> Starting etherpad manually (no systemd in container)"
docker exec -d "${CONTAINER_NAME}" runuser -u etherpad -- \
bash -c 'cd /opt/etherpad && NODE_ENV=production /usr/bin/etherpad >/tmp/etherpad.log 2>&1'
fi
echo "==> Waiting for /health"
ok=
for i in $(seq 1 30); do
if docker exec "${CONTAINER_NAME}" curl -fsS http://127.0.0.1:9001/health >/dev/null 2>&1; then
ok=1; break
fi
sleep 2
done
if [ -z "${ok}" ]; then
echo "!! /health never responded — dumping logs:"
if [ -z "${NO_SYSTEMD}" ]; then
docker exec "${CONTAINER_NAME}" journalctl -u etherpad --no-pager -n 200 || true
else
docker exec "${CONTAINER_NAME}" tail -n 200 /tmp/etherpad.log || true
fi
exit 1
fi
echo "==> /health OK"
docker exec "${CONTAINER_NAME}" curl -fsS http://127.0.0.1:9001/health
echo
if [ "${MODE}" = "shell" ]; then
echo
echo "Container left running as '${CONTAINER_NAME}'. Useful commands:"
echo " docker exec -it ${CONTAINER_NAME} bash"
echo " docker exec ${CONTAINER_NAME} journalctl -u etherpad -f"
echo " curl http://127.0.0.1:9001/"
echo "Stop with: docker rm -f ${CONTAINER_NAME}"
exit 0
fi
echo "==> Purging the package"
if [ -z "${NO_SYSTEMD}" ]; then
docker exec "${CONTAINER_NAME}" systemctl stop etherpad
else
docker exec "${CONTAINER_NAME}" pkill -f 'node.*server.ts' || true
fi
docker exec "${CONTAINER_NAME}" dpkg --purge etherpad
docker exec "${CONTAINER_NAME}" bash -c '! id etherpad 2>/dev/null'
echo "==> All checks passed."

2123
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -4,14 +4,37 @@ packages:
- bin
- doc
- ui
onlyBuiltDependencies:
- esbuild
allowBuilds:
'@scarf/scarf': set this to true or false
esbuild: set this to true or false
# Explicitly ignore build scripts we don't want to run. Listing them here
# stops pnpm from failing with ERR_PNPM_IGNORED_BUILDS when they're
# encountered as transitive deps (e.g. scarf pulled in via swagger-ui-dist).
ignoredBuiltDependencies:
- '@scarf/scarf'
onlyBuiltDependencies:
- esbuild
# Belt-and-suspenders: even if a fresh transitive dep slips through with a
# postinstall script, downgrade to a warning so CI doesn't break for
# downstream plugin repos that pull etherpad-lite as their core install.
strictDepBuilds: false
# As of pnpm 11, overrides must live here (root package.json's pnpm.overrides
# is no longer read). Force-bump transitive deps with known CVEs.
overrides:
basic-ftp: '>=5.3.0'
brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3'
diff@>=6.0.0 <8.0.3: '>=8.0.3'
flatted: '>=3.4.2'
follow-redirects: '>=1.16.0'
glob@>=10.2.0 <10.5.0: '>=10.5.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.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.11: '>=7.5.11'
uuid@<14.0.0: '>=14.0.0'
vite@>=7.0.0 <7.3.2: '>=7.3.2'

View file

@ -117,6 +117,14 @@
*/
"favicon": "${FAVICON:null}",
/*
* Canonical public origin of this Etherpad instance, e.g.
* "https://pad.example.com" (no trailing slash, must include scheme).
* Used to build absolute URLs in OG/Twitter link-preview meta tags.
* When null, falls back to the incoming request's protocol+Host.
*/
"publicURL": "${PUBLIC_URL:null}",
/*
* Skin name.
*
@ -184,6 +192,28 @@
*/
"enableMetrics": "${ENABLE_METRICS:true}",
/*
* Self-update subsystem.
* 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.
*/
"updates": {
"tier": "notify",
"source": "github",
"channel": "stable",
"installMethod": "docker",
"checkIntervalHours": 6,
"githubRepo": "ether/etherpad",
"requireAdminForStatus": false
},
/*
* Contact address for admin notifications (updates, security advisories, future features).
* Set to null to disable outbound mail from the updater.
*/
"adminEmail": null,
/*
* Settings for cleanup of pads
*/
@ -192,6 +222,16 @@
"keepRevisions": 5
},
/*
* GDPR Art. 17 author erasure REST endpoint (anonymizeAuthor).
*
* Disabled by default — enable only when an operator process exists to
* authorise erasure requests.
*/
"gdprAuthorErasure": {
"enabled": "${GDPR_AUTHOR_ERASURE_ENABLED:false}"
},
/*
The authentication method used by the server.
The default value is sso
@ -211,6 +251,17 @@
**/
"enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}",
/*
* Optional privacy banner. See settings.json.template for full field docs.
*/
"privacyBanner": {
"enabled": "${PRIVACY_BANNER_ENABLED:false}",
"title": "${PRIVACY_BANNER_TITLE:Privacy notice}",
"body": "${PRIVACY_BANNER_BODY:This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.}",
"learnMoreUrl": "${PRIVACY_BANNER_LEARN_MORE_URL:null}",
"dismissal": "${PRIVACY_BANNER_DISMISSAL:dismissible}"
},
/*
* Node native SSL support
*
@ -288,7 +339,9 @@
"rtl": "${PAD_OPTIONS_RTL:false}",
"alwaysShowChat": "${PAD_OPTIONS_ALWAYS_SHOW_CHAT:false}",
"chatAndUsers": "${PAD_OPTIONS_CHAT_AND_USERS:false}",
"lang": "${PAD_OPTIONS_LANG:null}"
"lang": "${PAD_OPTIONS_LANG:null}",
"fadeInactiveAuthorColors": "${PAD_OPTIONS_FADE_INACTIVE_AUTHOR_COLORS:true}",
"enforceReadableAuthorColors": "${PAD_OPTIONS_ENFORCE_READABLE_AUTHOR_COLORS:true}"
},
/*
@ -480,10 +533,25 @@
},
/*
* Privacy: disable IP logging
* Controls what Etherpad writes to its logs about client IP addresses.
* Allowed values: "anonymous" (default), "truncated", "full".
* See settings.json.template for details.
*/
"ipLogging": "${IP_LOGGING:anonymous}",
/*
* Deprecated — use `ipLogging` above. Still honoured for one release
* cycle: true"anonymous", false"full".
*/
"disableIPlogging": "${DISABLE_IP_LOGGING:false}",
/*
* Allow any user who can edit a pad to delete it without the one-time pad
* deletion token. If false, only the original creator's author cookie or the
* deletion token can delete the pad.
*/
"allowPadDeletionByAllUsers": "${ALLOW_PAD_DELETION_BY_ALL_USERS:false}",
/*
* Time (in seconds) to automatically reconnect pad when a "Force reconnect"
* message is shown to user.

View file

@ -108,6 +108,21 @@
*/
"favicon": null,
/*
* Canonical public origin of this Etherpad instance, e.g.
* "https://pad.example.com" (no trailing slash, must include scheme).
*
* When set, this is used to build absolute URLs in server-rendered output
* such as the Open Graph / Twitter Card link-preview meta tags (og:url,
* og:image, ...). When null, those URLs fall back to the request's
* protocol+Host, which can reflect client-controlled headers if your
* reverse proxy passes them through unsanitized.
*
* Set this in production deployments to lock down the canonical origin
* advertised in shared link previews.
*/
"publicURL": null,
/*
* Skin name.
*
@ -175,6 +190,32 @@
*/
"enableMetrics": "${ENABLE_METRICS:true}",
/*
* Self-update subsystem.
* tier: "off" | "notify" | "manual" | "auto" | "autonomous"
* Default "notify" shows a banner when an update is available. "off" disables the version check.
*/
"updates": {
"tier": "notify",
"source": "github",
"channel": "stable",
"installMethod": "auto",
"checkIntervalHours": 6,
"githubRepo": "ether/etherpad",
/*
* Lock /admin/update/status to authenticated admins. Default false keeps the
* endpoint open (the version is already public via /health). Set true to hide
* full update detail from non-admins without turning the updater off.
*/
"requireAdminForStatus": false
},
/*
* Contact address for admin notifications (updates, security advisories, future features).
* Set to null to disable outbound mail from the updater.
*/
"adminEmail": null,
/*
* Settings for cleanup of pads
*/
@ -183,6 +224,18 @@
"keepRevisions": 5
},
/*
* GDPR Art. 17 author erasure REST endpoint (anonymizeAuthor).
*
* Disabled by default — enable only when an operator process exists to
* authorise erasure requests. While disabled, calls to anonymizeAuthor
* return an apierror and the AuthorManager helper is not exposed via
* the public API.
*/
"gdprAuthorErasure": {
"enabled": false
},
/*
* Node native SSL support
*
@ -261,7 +314,20 @@
"rtl": false,
"alwaysShowChat": false,
"chatAndUsers": false,
"lang": null
"lang": null,
/*
* When true (default), each author's caret/background color fades toward white
* as the author goes inactive. Set to false if users pick light colors and the
* faded variants become visually indistinguishable.
*/
"fadeInactiveAuthorColors": true,
/*
* Clamp author background colors to a WCAG 2.1 AA contrast ratio (4.5:1)
* against the rendered text colour at render time. The author's stored
* colour is not modified — only the displayed shade is adjusted. Set to
* false to render exact author colours regardless of contrast.
*/
"enforceReadableAuthorColors": true
},
/*
@ -471,10 +537,32 @@
},
/*
* Privacy: disable IP logging
* Controls what Etherpad writes to its logs about client IP addresses.
*
* "anonymous" — replace every IP with the literal "ANONYMOUS" (default)
* "truncated" — zero the last octet of IPv4 (1.2.3.0); truncate IPv6 to
* the first /48 (2001:db8:1::). Keeps aggregate visibility.
* "full" — log the full IP (document a legal basis + retention
* policy before choosing this).
*
* In-memory rate-limiting always keys on the raw IP and is never persisted.
*/
"ipLogging": "anonymous",
/*
* Deprecated — use `ipLogging` above instead. Still honoured for one release
* cycle: `true` maps to `ipLogging: "anonymous"`, `false` maps to `"full"`.
* A deprecation warning is emitted when only this legacy setting is present.
*/
"disableIPlogging": false,
/*
* Allow any user who can edit a pad to delete it without the one-time pad
* deletion token. If false, only the original creator's author cookie or the
* deletion token can delete the pad.
*/
"allowPadDeletionByAllUsers": false,
/*
* Time (in seconds) to automatically reconnect pad when a "Force reconnect"
* message is shown to user.
@ -649,6 +737,24 @@
**/
"enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}",
/*
* Optional privacy banner shown once the pad loads. Disabled by default.
*
* enabled — toggle the feature
* title — plain-text heading (HTML is escaped)
* body — plain-text body; newlines become paragraph breaks
* learnMoreUrl — optional URL rendered as a "Learn more" link
* dismissal — "dismissible" (close button, stored in localStorage)
* or "sticky" (always shown, no close button)
*/
"privacyBanner": {
"enabled": false,
"title": "Privacy notice",
"body": "This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.",
"learnMoreUrl": null,
"dismissal": "dismissible"
},
/*
* From Etherpad 1.8.5 onwards, when Etherpad is in production mode commits from individual users are rate limited
*

266
snap/README.md Normal file
View file

@ -0,0 +1,266 @@
# Etherpad snap
Packages Etherpad as a [Snap](https://snapcraft.io/) for publishing to the
Snap Store.
- [User-facing usage](#user-facing-usage)
- [Architecture](#architecture)
- [Testing](#testing)
- [Development workflow](#development-workflow)
- [Publishing](#publishing)
- [Troubleshooting](#troubleshooting)
## User-facing usage
### Install from the store
```
sudo snap install etherpad
```
The default listen port is **9001**. Pad data lives in
`/var/snap/etherpad/common/` and survives `snap refresh`.
### Configure
The snap seeds `$SNAP_COMMON/etc/settings.json` from the upstream
template on first run. Edit that file directly to customise Etherpad,
then:
```
sudo snap restart etherpad
```
A few values are exposed as snap config so users don't have to edit the
file by hand:
| Key | Default | Notes |
| ------------------------------ | --------- | --------------- |
| `snap set etherpad port=9001` | `9001` | Listen port |
| `snap set etherpad ip=0.0.0.0` | `0.0.0.0` | Bind address |
The configure hook validates these (`port` must be 165535 integer,
`ip` must be a valid v4/v6 address) and restarts the daemon on change.
### Build locally
```
sudo snap install --classic snapcraft
sudo snap install lxd && sudo lxd init --auto
snapcraft # from repo root; uses LXD by default
```
Output: `etherpad_<version>_<arch>.snap`.
### Install a local build
```
sudo snap install --dangerous ./etherpad_*.snap
sudo snap start etherpad
curl http://127.0.0.1:9001/health # → {"status":"pass","releaseId":"X.Y.Z"}
```
Logs: `sudo snap logs etherpad -f`.
## Architecture
### File layout inside the snap
```
$SNAP/ # = /snap/etherpad/current (read-only squashfs)
├── opt/
│ ├── node/bin/node # pinned Node.js 22.12.0
│ └── etherpad/
│ ├── src/ # ep_etherpad-lite workspace package (with node_modules incl. tsx)
│ ├── admin/, ui/, doc/ # other workspace packages (built artefacts)
│ ├── settings.json.template # template, copied to $SNAP_COMMON on first run
│ └── var → /var/snap/etherpad/common/etherpad-app-var/ # symlink (see below)
├── bin/
│ ├── etherpad-service # daemon launch wrapper
│ ├── etherpad-cli # passthrough to bin/ scripts
│ └── etherpad-healthcheck-wrapper # HTTP /health probe
└── meta/snap.yaml
$SNAP_COMMON/ # = /var/snap/etherpad/common (read-write, persists across refreshes)
├── etc/settings.json # seeded from template on first run, never overwritten
├── var/etherpad.db # sqlite database
├── etherpad-app-var/installed_plugins.json # plugin registry, written by Etherpad core
└── logs/ # reserved for future use
```
### Why the `var/` symlink
Etherpad's plugin installer
(`src/static/js/pluginfw/installer.ts`) writes
`installed_plugins.json` via `__dirname`-relative paths, which resolve
to absolute paths inside `$SNAP` — read-only squashfs. Snap layouts
can't intercept paths inside `$SNAP`, so we replace the shipped `var/`
directory with a **symlink** at build time pointing to
`/var/snap/etherpad/common/etherpad-app-var/` (created by the wrapper
on first run). The kernel transparently follows the symlink to writable
storage that survives `snap refresh`.
### Why the seeded `settings.json` is rewritten
The upstream `settings.json.template` defaults to `dbType: "dirty"`
the template itself warns this is dev-only. The launch wrapper rewrites
the seeded copy on first run to:
- `dbType: "sqlite"` with file at `$SNAP_COMMON/var/etherpad.db`
- `ip: "${IP:0.0.0.0}"` — Etherpad's own env-substitution syntax
- `port: "${PORT:9001}"` — same
The wrapper then exports `IP` and `PORT` from the snap config (via
`snapctl get`), so `snap set etherpad port=N` actually moves the
listener.
### Why pnpm runs twice
`pnpm install --frozen-lockfile --prod=false` first (need devDeps to
build admin/ui/docs), then `rm -rf node_modules && pnpm install --prod
--frozen-lockfile --ignore-scripts` after the build. This is faster
than `pnpm prune --prod`, which is interactive on workspace projects
(prompts "Proceed? (Y/n)" to stdin) and deadlocks under the
non-interactive build environment. See
[nodejs/corepack#612](https://github.com/nodejs/corepack/issues/612)
for the corepack-keyring refresh in step 2.
### Why the daemon shares the snap name
`apps.etherpad` matches the snap name `etherpad`, so users invoke the
daemon via `snap install etherpad` → bare `etherpad` command. The CLI
passthrough is exposed as `etherpad.cli` (e.g.
`etherpad.cli importSqlFile something.sql`).
## Testing
Three layers, each independently runnable:
### 1. Wrapper unit tests (~5 s, no snapd/sudo)
```
bash snap/tests/run-all.sh
```
Runs `bash -n` syntax checks on every wrapper + hook, then sources
each `test-*.sh` and reports pass/fail counts. Coverage:
- `test-snapcraft-yaml.sh` — required keys, name validity, daemon-app
matches snap name, no `etherpad-lite` regression, environment vars
whitelist.
- `test-cli.sh` — path-traversal rejection (`../`, subdir, empty),
`.ts` / `.sh` dispatch, default-case rejection, no-args usage.
- `test-configure.sh` — port (165535 integer) and ip (v4/v6) validation
via mocked `snapctl`.
- `test-service-bootstrap.sh` — first-run seeding from
`settings.json.template`, sed rewrite of dbType/filename/ip/port,
writable-dir creation, snapctl override propagation to node env,
idempotency on second run, default fallbacks.
All tests use **port 9003** for any binding (per project convention,
since 9001 is reserved for ad-hoc local Etherpad work).
### 2. CI build verification
`.github/workflows/snap-build.yml` runs on every PR that touches
`snap/`, `settings.json.template`, or the workflow itself. Two jobs:
- `wrapper-tests` — runs `snap/tests/run-all.sh` (~5 s).
- `snap-pack` — runs `snapcraft pack --destructive-mode` and uploads
the resulting `.snap` as an artifact (downloadable from the run
summary so reviewers can sideload).
This is intentionally separate from `snap-publish.yml` (tag-triggered,
LXD-based, pushes to the store).
### 3. End-to-end smoke test (~3 min, requires sudo + snapd)
```
bash snap/tests/smoke.sh
```
Rebuilds via destructive-mode, installs the resulting `.snap`,
configures `port=9003`, restarts, waits for plugin migration to
finish, asserts a listener on 9003, hits `/health`, and tails the
last 20 log lines. Useful when changing the wrappers or the build
recipe before pushing.
## Development workflow
```
# 1. Make a change to snap/snapcraft.yaml or one of the wrappers.
# 2. Fast feedback loop — only the unit tests:
bash snap/tests/run-all.sh
# 3. Full local verification — actually build and install:
bash snap/tests/smoke.sh
# 4. Push. CI will run wrapper-tests + snap-pack on the PR.
git push
```
If `snapcraft pack` complains about the LXD provider,
`--destructive-mode` lets you build directly on the host (used by both
the smoke script and CI). It pollutes the host with build deps and
puts `parts/`, `stage/`, `prime/` in the worktree (gitignored). Wipe
with `sudo rm -rf parts stage prime`.
## Publishing
Maintainers only. See:
- [Register a snap](https://documentation.ubuntu.com/snapcraft/latest/how-to/publishing/register-a-snap/) — claims the name on the store
- [`snapcraft export-login`](https://documentation.ubuntu.com/snapcraft/reference/commands/export-login/) — generates the credential we put in `SNAPCRAFT_STORE_CREDENTIALS`
- [Snapcraft publishing how-to index](https://documentation.ubuntu.com/snapcraft/latest/how-to/publishing/)
One-time setup:
```
snapcraft register etherpad
snapcraft export-login --snaps etherpad \
--channels edge,stable \
--acls package_access,package_push,package_release -
```
Store the printed credential in the repo secret
`SNAPCRAFT_STORE_CREDENTIALS`. Create a GitHub Environment named
`snap-store-stable` with required reviewers so stable promotion is
gated.
`.github/workflows/snap-publish.yml` then handles the rest on every
`vX.Y.Z` (or `X.Y.Z`) tag: build → publish to `edge` → manual approval
gate → publish to `stable`.
## Troubleshooting
**Daemon flapping with `EROFS: read-only file system`** — Etherpad is
trying to write somewhere inside `$SNAP`. Check whether the path is
covered by the `var/` symlink (architecture section above). New write
targets need either an additional symlink at build time
(`snap/snapcraft.yaml` step 4) or a config knob to redirect into
`$SNAP_COMMON`.
**`Cannot find package 'tsx'`** — the wrapper must `cd "${APP_DIR}/src"`
before `node`, since `tsx` lives in the workspace's `node_modules` and
not at the install root under pnpm hoisting.
**`ERR_REQUIRE_CYCLE_MODULE`** — use bare `--import tsx`, not
`--import tsx/esm`. The ESM-only loader trips on Etherpad's mixed
CJS/ESM source.
**`snap install` fails with `unable to contact snap store`** — almost
always a Canonical-side outage. Check
[snapcraft.statuspage.io](https://snapcraft.statuspage.io). For
*local* development you can sidestep the store dependency entirely by
building with `snapcraft pack --destructive-mode` (no LXD container
provisioning, so no in-container `snap install`).
**`pnpm prune --prod` hangs forever** — never use it directly here. It
has an interactive "Proceed? (Y/n)" prompt for workspaces that
deadlocks under sudo/tee. The build recipe uses
`rm -rf node_modules && pnpm install --prod --frozen-lockfile
--ignore-scripts` instead.
**`snap refresh` blew away my data** — it didn't. Pad data is in
`/var/snap/etherpad/common/`, which is preserved across refreshes.
Check `/var/snap/etherpad/common/var/etherpad.db` exists.

24
snap/hooks/configure vendored Executable file
View file

@ -0,0 +1,24 @@
#!/bin/bash
# Validates values set via `snap set etherpad key=value`.
# Supported keys:
# port : integer 1-65535 (default 9001). Ports <1024 require AppArmor override.
# ip : bind address (default 0.0.0.0)
set -euo pipefail
PORT="$(snapctl get port || true)"
if [ -n "${PORT}" ]; then
if ! [[ "${PORT}" =~ ^[0-9]+$ ]] || [ "${PORT}" -lt 1 ] || [ "${PORT}" -gt 65535 ]; then
echo "port must be an integer 1-65535" >&2
exit 1
fi
fi
IP="$(snapctl get ip || true)"
if [ -n "${IP}" ] && ! [[ "${IP}" =~ ^[0-9a-fA-F.:]+$ ]]; then
echo "ip must be a valid IPv4/IPv6 address" >&2
exit 1
fi
if snapctl services etherpad.etherpad 2>/dev/null | grep -q active; then
snapctl restart etherpad.etherpad
fi

35
snap/local/bin/etherpad-cli Executable file
View file

@ -0,0 +1,35 @@
#!/bin/bash
# Thin passthrough to Etherpad's bin/ scripts.
# Usage: etherpad.cli <bin-script> [args...]
set -euo pipefail
APP_DIR="${SNAP}/opt/etherpad"
NODE_BIN="${SNAP}/opt/node/bin/node"
export PATH="${SNAP}/opt/node/bin:${PATH}"
if [ "$#" -eq 0 ]; then
echo "Usage: etherpad.cli <bin-script> [args...]"
echo "Available scripts:"
ls "${APP_DIR}/bin" | grep -E '\.(ts|sh)$' | sed 's/^/ /'
exit 2
fi
SCRIPT_NAME="$1"; shift
# Reject path-traversal attempts: only a bare filename is allowed, since
# the script lookup is anchored at $APP_DIR/bin and must not escape it.
case "${SCRIPT_NAME}" in
*/*|*..*|"")
echo "invalid script name: ${SCRIPT_NAME} (must be a bare filename)" >&2
exit 2 ;;
esac
SCRIPT_PATH="${APP_DIR}/bin/${SCRIPT_NAME}"
[ -f "${SCRIPT_PATH}" ] || { echo "no such script: ${SCRIPT_NAME}" >&2; exit 2; }
case "${SCRIPT_PATH}" in
*.sh) exec "${SCRIPT_PATH}" "$@" ;;
*.ts) exec "${NODE_BIN}" --import tsx/esm "${SCRIPT_PATH}" "$@" ;;
*) echo "unsupported script type: ${SCRIPT_NAME} (expected .sh or .ts)" >&2
exit 2 ;;
esac

View file

@ -0,0 +1,20 @@
#!/bin/bash
# HTTP healthcheck. Returns 0 if /health returns 200.
set -euo pipefail
PORT="$(snapctl get port 2>/dev/null || true)"
: "${PORT:=9001}"
if command -v curl >/dev/null 2>&1; then
exec curl --fail --silent --show-error --max-time 5 \
"http://127.0.0.1:${PORT}/health"
fi
NODE_BIN="${SNAP}/opt/node/bin/node"
exec "${NODE_BIN}" -e '
const http = require("http");
http.get("http://127.0.0.1:'"${PORT}"'/health", r => {
if (r.statusCode === 200) process.exit(0);
console.error("HTTP " + r.statusCode); process.exit(1);
}).on("error", e => { console.error(e.message); process.exit(1); });
'

71
snap/local/bin/etherpad-service Executable file
View file

@ -0,0 +1,71 @@
#!/bin/bash
# Launch wrapper for the Etherpad snap daemon.
#
# 1. On first run, copy settings.json.template -> $SNAP_COMMON/etc/settings.json
# so the admin can edit it outside the read-only squashfs. Patch the
# seeded file so the DB (switched from dev-only dirty to sqlite) / ip /
# port point at writable paths and pick up env-var overrides.
# 2. Create writable data dirs under $SNAP_COMMON.
# 3. Apply `snap set` overrides (port, ip) via env vars — Etherpad's
# settings.json supports ${PORT:9001}-style substitution natively.
# 4. Exec Node with tsx loader to run server.ts, passing the seeded
# settings file via --settings (Etherpad reads `argv.settings`, not
# an env var, so EP_SETTINGS alone would be ignored).
set -euo pipefail
APP_DIR="${SNAP}/opt/etherpad"
NODE_BIN="${SNAP}/opt/node/bin/node"
export PATH="${SNAP}/opt/node/bin:${SNAP}/usr/bin:${SNAP}/bin:${PATH}"
ETC_DIR="${SNAP_COMMON}/etc"
VAR_DIR="${SNAP_COMMON}/var"
LOG_DIR="${SNAP_COMMON}/logs"
# etherpad-app-var/ is the symlink target for ${APP_DIR}/var inside the
# read-only snap mount — see snapcraft.yaml step 4. Etherpad writes its
# plugin registry (installed_plugins.json) here.
APP_VAR_DIR="${SNAP_COMMON}/etherpad-app-var"
mkdir -p "${ETC_DIR}" "${VAR_DIR}" "${LOG_DIR}" "${APP_VAR_DIR}"
SETTINGS="${ETC_DIR}/settings.json"
if [ ! -f "${SETTINGS}" ]; then
echo "[etherpad-snap] bootstrapping ${SETTINGS} from template"
cp "${APP_DIR}/settings.json.template" "${SETTINGS}"
# Switch the template's dev-only dirty default to sqlite and point it
# at $SNAP_COMMON (absolute path, writable under strict confinement).
# sqlite is shipped by ueberdb2 via the prebuilt rusty-store-kv native
# module — no additional build deps required.
sed -i \
-e 's|"dbType": "dirty"|"dbType": "sqlite"|' \
-e 's|"filename": "var/dirty.db"|"filename": "'"${VAR_DIR}"'/etherpad.db"|' \
"${SETTINGS}"
# Rewrite ip/port literals to Etherpad's env-substitution syntax so
# `snap set etherpad port=<n>` / `ip=<addr>` actually take effect.
# Only substitute the first (top-level) occurrence — `dbSettings.port`
# has the same key name lower down and must not be touched.
sed -i \
-e '0,/"ip": "0.0.0.0"/{s|"ip": "0.0.0.0"|"ip": "${IP:0.0.0.0}"|}' \
-e '0,/"port": 9001/{s|"port": 9001|"port": "${PORT:9001}"|}' \
"${SETTINGS}"
fi
PORT_OVERRIDE="$(snapctl get port || true)"
IP_OVERRIDE="$(snapctl get ip || true)"
: "${PORT_OVERRIDE:=9001}"
: "${IP_OVERRIDE:=0.0.0.0}"
export PORT="${PORT_OVERRIDE}"
export IP="${IP_OVERRIDE}"
# `tsx` lives in the `src` workspace's node_modules under pnpm's hoisting
# layout, not at ${APP_DIR}/node_modules. Run from src/ so node's import
# resolution finds it (mirrors the `dev` script in src/package.json).
cd "${APP_DIR}/src"
export NODE_ENV=production
# Pass --settings explicitly; Etherpad's Settings loader reads argv only,
# so exporting EP_SETTINGS is not enough to redirect the config file.
# Use bare `tsx` (not `tsx/esm`) to match bin/cleanRun.sh — Etherpad's
# source mixes CJS+ESM and the ESM-only loader trips ERR_REQUIRE_CYCLE.
exec "${NODE_BIN}" --import tsx node/server.ts \
--settings "${SETTINGS}" \
"$@"

162
snap/snapcraft.yaml Normal file
View file

@ -0,0 +1,162 @@
# snap/snapcraft.yaml — Snap recipe for Etherpad
#
# Design notes:
# - base: core24 chosen because Etherpad requires Node.js >= 20 and
# core24 (Ubuntu 24.04 LTS) ships glibc/OpenSSL versions matching modern
# Node 20/22 binaries. core22 also works but ships older TLS/CA bundles.
# - confinement: strict. Etherpad is a pure Node.js HTTP service. The only
# native Node module (`rusty-store-kv`) ships as a prebuilt napi-rs
# binary, so no node-gyp compile is performed at install time and
# strict confinement works cleanly.
# - We use `dump` + a manual override-build (rather than the npm plugin)
# because this repo is a pnpm workspace and we pin Node.js 22 manually.
name: etherpad
title: Etherpad
summary: Real-time collaborative document editor
description: |
Etherpad is a highly customizable open-source online editor providing
collaborative editing in real-time. This snap bundles Etherpad with a
pinned Node.js 22 runtime. On first launch a default `settings.json`
is copied into `$SNAP_COMMON/etc` where it can be edited. Pad data is
stored in `$SNAP_COMMON/var` and survives snap refreshes.
Default listen port: 9001.
Upstream: https://etherpad.org
Source: https://github.com/ether/etherpad
license: Apache-2.0
website: https://etherpad.org
source-code: https://github.com/ether/etherpad
issues: https://github.com/ether/etherpad/issues
contact: https://etherpad.org/#community
adopt-info: etherpad
grade: stable
confinement: strict
base: core24
compression: lzo
platforms:
amd64:
arm64:
apps:
# Bare app name matches the snap name → users invoke it as plain `etherpad`.
etherpad:
command: bin/etherpad-service
daemon: simple
install-mode: enable
restart-condition: on-failure
plugs:
- network
- network-bind
environment:
HOME: $SNAP_DATA
NODE_ENV: production
# PORT/IP are env-substituted into settings.json on first run and
# overridden by the wrapper from `snap set etherpad port=…` / `ip=…`.
PORT: "9001"
IP: "0.0.0.0"
healthcheck:
command: bin/etherpad-healthcheck-wrapper
plugs:
- network
cli:
command: bin/etherpad-cli
plugs:
- network
- network-bind
parts:
etherpad:
plugin: dump
source: .
source-type: local
build-packages:
- curl
- ca-certificates
- git
- python3
- build-essential
stage-packages:
- ca-certificates
- libstdc++6
- openssl
override-pull: |
craftctl default
VERSION="$(grep -m1 '"version"' src/package.json | sed -E 's/.*"([^"]+)".*/\1/')"
craftctl set version="${VERSION}"
override-build: |
set -eu
# -- 1. Install Node.js 22 from the official tarball. Must be >=22.13
# because pnpm 11 hard-rejects older 22.x releases.
NODE_VERSION=22.22.2
ARCH="$(dpkg --print-architecture)"
case "${ARCH}" in
amd64) NODE_ARCH=x64 ;;
arm64) NODE_ARCH=arm64 ;;
*) echo "Unsupported arch ${ARCH}"; exit 1 ;;
esac
NODE_TGZ="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz"
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/${NODE_TGZ}" \
-o "/tmp/${NODE_TGZ}"
mkdir -p "${CRAFT_PART_INSTALL}/opt/node"
tar -xJf "/tmp/${NODE_TGZ}" -C "${CRAFT_PART_INSTALL}/opt/node" \
--strip-components=1
export PATH="${CRAFT_PART_INSTALL}/opt/node/bin:${PATH}"
# -- 2. Install pnpm via corepack. The corepack version bundled with
# Node 22.12 ships a stale signing-key list and rejects newer pnpm
# releases (nodejs/corepack#612), so refresh corepack itself first.
"${CRAFT_PART_INSTALL}/opt/node/bin/npm" install \
--prefix "${CRAFT_PART_INSTALL}/opt/node" -g corepack@latest
corepack enable --install-directory "${CRAFT_PART_INSTALL}/opt/node/bin"
corepack prepare pnpm@11.0.6 --activate
# -- 3. Copy source into install dir and build.
APP_DIR="${CRAFT_PART_INSTALL}/opt/etherpad"
mkdir -p "${APP_DIR}"
cp -a "${CRAFT_PART_SRC}/." "${APP_DIR}/"
cd "${APP_DIR}"
pnpm install --frozen-lockfile --prod=false
pnpm run build:etherpad
# Strip dev deps to shrink the payload. `pnpm prune --prod` walks
# every workspace symlink and can deadlock on cross-referenced
# packages — wipe node_modules and reinstall prod-only instead;
# pnpm resolves the smaller graph from cache in a fraction of the
# time and the result is byte-identical to a clean prod install.
find . -type d -name node_modules -prune -exec rm -rf {} +
pnpm install --prod --frozen-lockfile --ignore-scripts
rm -rf .git tests/frontend-new/.cache \
src/tests/frontend-new/test-results || true
# -- 4. Etherpad's plugin installer writes installed_plugins.json
# under its own install dir's var/ at runtime
# (src/static/js/pluginfw/installer.ts uses __dirname, which
# resolves to an absolute path inside the read-only snap squashfs).
# snap layouts can't redirect __dirname-relative paths, so replace
# the shipped var/ dir with a symlink into $SNAP_COMMON instead;
# the kernel transparently resolves the symlink to writable storage
# that survives `snap refresh`. The wrapper mkdirs the target.
rm -rf "${APP_DIR}/var"
ln -s /var/snap/etherpad/common/etherpad-app-var "${APP_DIR}/var"
# -- 5. Install wrappers.
install -Dm755 "${CRAFT_PROJECT_DIR}/snap/local/bin/etherpad-service" \
"${CRAFT_PART_INSTALL}/bin/etherpad-service"
install -Dm755 "${CRAFT_PROJECT_DIR}/snap/local/bin/etherpad-healthcheck-wrapper" \
"${CRAFT_PART_INSTALL}/bin/etherpad-healthcheck-wrapper"
install -Dm755 "${CRAFT_PROJECT_DIR}/snap/local/bin/etherpad-cli" \
"${CRAFT_PART_INSTALL}/bin/etherpad-cli"
hooks:
configure:
plugs:
- network

73
snap/tests/lib.sh Executable file
View file

@ -0,0 +1,73 @@
#!/bin/bash
# Tiny helpers for the snap wrapper test scripts. Source from each test.
set -uo pipefail
# Counters maintained by the runner.
: "${PASS_COUNT:=0}"
: "${FAIL_COUNT:=0}"
: "${TEST_NAME:?TEST_NAME must be set by the calling script}"
# Path to the wrapper / hook directory, computed once.
SNAP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
WRAPPERS_DIR="${SNAP_DIR}/local/bin"
HOOKS_DIR="${SNAP_DIR}/hooks"
red() { printf '\033[31m%s\033[0m' "$*"; }
green() { printf '\033[32m%s\033[0m' "$*"; }
gray() { printf '\033[90m%s\033[0m' "$*"; }
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
printf ' %s %s\n' "$(green ✓)" "$1"
}
fail() {
FAIL_COUNT=$((FAIL_COUNT + 1))
printf ' %s %s\n' "$(red ✗)" "$1"
if [ -n "${2:-}" ]; then
printf ' %s\n' "$(gray "$2")"
fi
}
# assert_eq actual expected name
assert_eq() {
local actual="$1" expected="$2" name="$3"
if [ "$actual" = "$expected" ]; then
pass "$name"
else
fail "$name" "expected: $(printf '%q' "$expected") got: $(printf '%q' "$actual")"
fi
}
# assert_exit cmd expected_exit name [stdin]
assert_exit() {
local expected="$1" name="$2"; shift 2
local out actual
out=$("$@" 2>&1) || true
actual=$?
# bash quirk: $? from the assignment is the assignment's, not the command's.
# Re-run inline to capture exit:
"$@" >/dev/null 2>&1
actual=$?
if [ "$actual" = "$expected" ]; then
pass "$name"
else
fail "$name" "expected exit $expected, got $actual; output: $out"
fi
}
# assert_grep cmd needle name — fail if cmd's combined output doesn't match
assert_grep() {
local needle="$1" name="$2"; shift 2
local out
out=$("$@" 2>&1 || true)
if printf '%s' "$out" | grep -q -F -- "$needle"; then
pass "$name"
else
fail "$name" "expected output to contain: $needle; got: $(printf '%s' "$out" | head -3)"
fi
}
section() {
printf '\n%s %s\n' "$(gray '##')" "$1"
}

47
snap/tests/run-all.sh Executable file
View file

@ -0,0 +1,47 @@
#!/bin/bash
# Runs every test-*.sh under snap/tests/ and reports a final summary.
# Intended to be runnable both locally (`bash snap/tests/run-all.sh`)
# and in CI (`.github/workflows/snap-build.yml`). No snapd, snapcraft,
# or sudo required — every test mocks the snap surface.
set -uo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
TESTS=(test-snapcraft-yaml.sh test-cli.sh test-configure.sh test-service-bootstrap.sh)
# Bash-syntax sanity check on every wrapper / hook before running anything.
echo "## bash -n syntax check"
for f in ../local/bin/*.sh ../local/bin/etherpad-cli ../local/bin/etherpad-service \
../local/bin/etherpad-healthcheck-wrapper ../hooks/configure; do
[ -f "$f" ] || continue
if bash -n "$f" 2>/dev/null; then
printf ' \033[32m✓\033[0m %s\n' "$f"
else
printf ' \033[31m✗\033[0m %s\n' "$f"
bash -n "$f"
exit 1
fi
done
TOTAL_PASS=0
TOTAL_FAIL=0
for t in "${TESTS[@]}"; do
echo
echo "## ${t}"
PASS_COUNT=0 FAIL_COUNT=0
# Source so child counters bubble up.
set +u
source "./${t}"
set -u
TOTAL_PASS=$((TOTAL_PASS + PASS_COUNT))
TOTAL_FAIL=$((TOTAL_FAIL + FAIL_COUNT))
done
echo
echo "==========================="
printf ' Passed: %d\n' "${TOTAL_PASS}"
printf ' Failed: %d\n' "${TOTAL_FAIL}"
echo "==========================="
[ "${TOTAL_FAIL}" = 0 ]

53
snap/tests/smoke.sh Executable file
View file

@ -0,0 +1,53 @@
#!/bin/bash
# Local smoke test for the etherpad snap.
# Rebuild → install → set port=9003 → wait → check listener → curl /health → tail logs.
# Run from the worktree root: bash snap/tests/smoke.sh
set -uo pipefail
WORKTREE="/home/jose/etherpad/etherpad-lite/.claude/worktrees/pkg-snap"
SNAP_FILE="${WORKTREE}/etherpad_2.6.1_amd64.snap"
TEST_PORT=9003
BUILD_LOG=/tmp/snapcraft-build.log
cd "${WORKTREE}" || exit 1
echo "==> Rebuilding snap (destructive mode)"
sudo rm -rf parts stage prime
sudo snapcraft pack --destructive-mode --verbose 2>&1 \
| tee "${BUILD_LOG}" \
| grep -E "Building|Staging|Priming|Packing|Created snap|Packed|error|Error|FAIL"
if [ ! -f "${SNAP_FILE}" ]; then
echo "FAIL: ${SNAP_FILE} was not produced — see ${BUILD_LOG}"
exit 1
fi
echo
echo "==> Installing snap"
sudo snap install --dangerous "${SNAP_FILE}"
echo
echo "==> Configuring test port ${TEST_PORT} (production default stays 9001)"
sudo snap set etherpad port="${TEST_PORT}"
sudo snap restart etherpad
echo
echo "==> Waiting 12s for plugin migration + bind"
sleep 12
echo
echo "==> Service status"
sudo snap services etherpad
echo
echo "==> Listening sockets in 9000-9009"
sudo ss -tlnp 2>&1 | grep -E ':900[0-9]' || echo "(nothing listening in 9000-9009)"
echo
echo "==> /health response"
curl -sS -o /tmp/health.body -w 'HTTP %{http_code}\n' "http://127.0.0.1:${TEST_PORT}/health"
echo "body: $(cat /tmp/health.body 2>/dev/null)"
echo
echo "==> Last 20 log lines"
sudo snap logs etherpad -n 25 | tail -20

77
snap/tests/test-cli.sh Executable file
View file

@ -0,0 +1,77 @@
#!/bin/bash
# Unit tests for snap/local/bin/etherpad-cli.
# Exercises path-traversal rejection, extension dispatch, default-case
# rejection, no-args usage, and missing-script rejection — all with a
# mocked $SNAP root so no real install is needed.
set -uo pipefail
TEST_NAME="etherpad-cli"
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
TMP=$(mktemp -d)
trap 'rm -rf "${TMP}"' EXIT
# Layout: $SNAP/opt/etherpad/bin/{checkPad.sh,importSqlFile.ts,orphan.txt}
# $SNAP/opt/node/bin/node (mock that just echoes its argv)
mkdir -p "${TMP}/opt/etherpad/bin" "${TMP}/opt/node/bin"
cat > "${TMP}/opt/node/bin/node" <<'EOF'
#!/bin/bash
echo "node-mock argv: $*"
EOF
chmod +x "${TMP}/opt/node/bin/node"
cat > "${TMP}/opt/etherpad/bin/checkPad.sh" <<'EOF'
#!/bin/bash
echo "checkPad.sh args: $*"
EOF
chmod +x "${TMP}/opt/etherpad/bin/checkPad.sh"
touch "${TMP}/opt/etherpad/bin/importSqlFile.ts"
touch "${TMP}/opt/etherpad/bin/orphan.txt"
CLI="${WRAPPERS_DIR}/etherpad-cli"
export SNAP="${TMP}"
section "path-traversal rejection"
# We assert exit code == 2 and that the stderr message matches "invalid"
run_cli() { "${CLI}" "$@"; }
assert_exit 2 "rejects ../../etc/passwd" run_cli "../../etc/passwd"
assert_exit 2 "rejects subdir/script.ts" run_cli "subdir/script.ts"
assert_exit 2 "rejects ..hidden" run_cli "..hidden"
assert_exit 2 "rejects empty argument" run_cli ""
assert_grep "invalid script name" "traversal error message mentions 'invalid'" \
run_cli "../etc/passwd"
section "missing / unsupported scripts"
assert_exit 2 "rejects nonexistent.ts" run_cli "nonexistent.ts"
assert_grep "no such script" "missing-script message" \
run_cli "nonexistent.ts"
assert_exit 2 "rejects orphan.txt (no .ts/.sh)" run_cli "orphan.txt"
assert_grep "unsupported script type" "unsupported-extension message" \
run_cli "orphan.txt"
section "valid dispatch"
# .sh runs the script directly
out=$("${CLI}" "checkPad.sh" hello world 2>&1) || true
assert_eq "$out" "checkPad.sh args: hello world" "checkPad.sh forwards args"
# .ts runs node --import tsx with the script path appended
out=$("${CLI}" "importSqlFile.ts" --some-arg 2>&1) || true
case "$out" in
*"--import tsx/esm"*"importSqlFile.ts"*"--some-arg"*) pass "importSqlFile.ts dispatched via node tsx" ;;
*) fail "importSqlFile.ts dispatched via node tsx" "got: $out" ;;
esac
section "no-args usage"
out=$("${CLI}" 2>&1) || true
case "$out" in
*"Usage: etherpad.cli"*"checkPad.sh"*"importSqlFile.ts"*) pass "no-args prints usage and lists scripts" ;;
*) fail "no-args prints usage and lists scripts" "got: $out" ;;
esac
# Summary handed back to the runner via env counters.
return 0 2>/dev/null || exit 0

62
snap/tests/test-configure.sh Executable file
View file

@ -0,0 +1,62 @@
#!/bin/bash
# Unit tests for snap/hooks/configure.
# Validates port/ip values via a mocked snapctl. Restart-on-change paths
# are not tested here (require running snapd).
set -uo pipefail
TEST_NAME="configure hook"
. "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
TMP=$(mktemp -d)
trap 'rm -rf "${TMP}"' EXIT
# Mock snapctl: returns env-provided values for `get`, no-ops on other verbs.
cat > "${TMP}/snapctl" <<'EOF'
#!/bin/bash
case "$1 $2" in
"get port") printf '%s' "${SNAPCTL_PORT-}" ;;
"get ip") printf '%s' "${SNAPCTL_IP-}" ;;
"services etherpad.etherpad") echo "etherpad.etherpad enabled inactive -" ;;
"restart etherpad.etherpad") exit 0 ;;
*) exit 0 ;;
esac
EOF
chmod +x "${TMP}/snapctl"
HOOK="${HOOKS_DIR}/configure"
run_hook() {
env -i PATH="${TMP}:/usr/bin:/bin" \
SNAPCTL_PORT="${1-}" SNAPCTL_IP="${2-}" \
bash "${HOOK}"
}
section "port validation"
# Tests using the project-reserved test port 9003 (per memory).
assert_exit 0 "valid port 9003" run_hook 9003 ""
assert_exit 0 "valid port 1" run_hook 1 ""
assert_exit 0 "valid port 65535" run_hook 65535 ""
assert_exit 0 "empty port (no override)" run_hook "" ""
assert_exit 1 "rejects port 0" run_hook 0 ""
assert_exit 1 "rejects port 70000" run_hook 70000 ""
assert_exit 1 "rejects port 'abc'" run_hook abc ""
assert_exit 1 "rejects port '-1'" run_hook -1 ""
assert_grep "1-65535" "out-of-range error message references valid range" \
run_hook 99999 ""
section "ip validation"
assert_exit 0 "valid ip 0.0.0.0" run_hook "" "0.0.0.0"
assert_exit 0 "valid ip 127.0.0.1" run_hook "" "127.0.0.1"
assert_exit 0 "valid ip ::1" run_hook "" "::1"
assert_exit 0 "empty ip (no override)" run_hook "" ""
assert_exit 1 "rejects ip 'not-an-ip'" run_hook "" "not-an-ip"
assert_exit 1 "rejects ip 'localhost'" run_hook "" "localhost"
assert_grep "valid IPv4/IPv6" "ip error message mentions IPv4/IPv6" \
run_hook "" "bogus"
return 0 2>/dev/null || exit 0

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