mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
Merge branch 'develop'
This commit is contained in:
commit
07902d3da1
239 changed files with 20176 additions and 1674 deletions
16
.github/workflows/backend-tests.yml
vendored
16
.github/workflows/backend-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
54
.github/workflows/build-and-deploy-docs.yml
vendored
54
.github/workflows/build-and-deploy-docs.yml
vendored
|
|
@ -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
408
.github/workflows/deb-package.yml
vendored
Normal 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
|
||||
14
.github/workflows/docker.yml
vendored
14
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
3
.github/workflows/frontend-admin-tests.yml
vendored
3
.github/workflows/frontend-admin-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
257
.github/workflows/frontend-tests.yml
vendored
257
.github/workflows/frontend-tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/handleRelease.yml
vendored
6
.github/workflows/handleRelease.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
24
.github/workflows/load-test.yml
vendored
24
.github/workflows/load-test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/perform-type-check.yml
vendored
6
.github/workflows/perform-type-check.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/rate-limit.yml
vendored
6
.github/workflows/rate-limit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
|
|
@ -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: |
|
||||
|
|
|
|||
7
.github/workflows/releaseEtherpad.yml
vendored
7
.github/workflows/releaseEtherpad.yml
vendored
|
|
@ -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
71
.github/workflows/snap-build.yml
vendored
Normal 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
92
.github/workflows/snap-publish.yml
vendored
Normal 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
|
||||
1
.github/workflows/update-plugins.yml
vendored
1
.github/workflows/update-plugins.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
11
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/`)
|
||||
|
|
|
|||
56
CHANGELOG.md
56
CHANGELOG.md
|
|
@ -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
|
||||
|
|
|
|||
27
Dockerfile
27
Dockerfile
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
35
admin/src/components/UpdateBanner.tsx
Normal file
35
admin/src/components/UpdateBanner.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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></>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
103
admin/src/pages/UpdatePage.tsx
Normal file
103
admin/src/pages/UpdatePage.tsx
Normal 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;
|
||||
|
|
@ -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}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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}" ./*
|
||||
|
|
|
|||
|
|
@ -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
236
bin/compactAllPads.ts
Normal 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
101
bin/compactPad.ts
Normal 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.');
|
||||
}
|
||||
})();
|
||||
|
|
@ -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)
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
153
bin/run-frontend-tests-with-disables.sh
Executable file
153
bin/run-frontend-tests-with-disables.sh
Executable 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."
|
||||
|
|
@ -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
|
||||
|
|
|
|||
94
doc/PLUGIN_FEATURE_DISABLES.md
Normal file
94
doc/PLUGIN_FEATURE_DISABLES.md
Normal 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.
|
||||
103
doc/PLUGIN_FRONTEND_TESTS.md
Normal file
103
doc/PLUGIN_FRONTEND_TESTS.md
Normal 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
83
doc/admin/updates.md
Normal 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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
131
doc/privacy.md
Normal 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.
|
||||
939
docs/superpowers/plans/2026-04-18-gdpr-pr1-deletion-controls.md
Normal file
939
docs/superpowers/plans/2026-04-18-gdpr-pr1-deletion-controls.md
Normal 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.
|
||||
745
docs/superpowers/plans/2026-04-19-gdpr-pr2-ip-privacy-audit.md
Normal file
745
docs/superpowers/plans/2026-04-19-gdpr-pr2-ip-privacy-audit.md
Normal 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; PR3–PR5 (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.
|
||||
587
docs/superpowers/plans/2026-04-19-gdpr-pr3-anon-identity.md
Normal file
587
docs/superpowers/plans/2026-04-19-gdpr-pr3-anon-identity.md
Normal 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 1–4 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.
|
||||
595
docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md
Normal file
595
docs/superpowers/plans/2026-04-19-gdpr-pr4-privacy-banner.md
Normal 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`.
|
||||
510
docs/superpowers/plans/2026-04-19-gdpr-pr5-author-erasure.md
Normal file
510
docs/superpowers/plans/2026-04-19-gdpr-pr5-author-erasure.md
Normal 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.
|
||||
2335
docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md
Normal file
2335
docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 PR2–PR5.
|
||||
|
||||
## 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.
|
||||
|
|
@ -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 (L913–916) | yes | **yes** | only if log4js has a file appender |
|
||||
| `PadMessageHandler.accessLogger` LEAVE (L204–207) | 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'`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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()`.
|
||||
350
docs/superpowers/specs/2026-04-25-auto-update-design.md
Normal file
350
docs/superpowers/specs/2026-04-25-auto-update-design.md
Normal 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.
|
||||
|
|
@ -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 `<script>`,
|
||||
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).
|
||||
25
package.json
25
package.json
|
|
@ -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
139
packaging/README.md
Normal 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.
|
||||
90
packaging/apt/generate-signing-key.sh
Executable file
90
packaging/apt/generate-signing-key.sh
Executable 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
14
packaging/apt/key.asc
Normal 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
28
packaging/bin/etherpad
Executable 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
128
packaging/nfpm.yaml
Normal 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
125
packaging/scripts/postinstall.sh
Executable 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
48
packaging/scripts/postremove.sh
Executable 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
28
packaging/scripts/preinstall.sh
Executable 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
20
packaging/scripts/preremove.sh
Executable 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
|
||||
7
packaging/systemd/etherpad.default
Normal file
7
packaging/systemd/etherpad.default
Normal 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
|
||||
52
packaging/systemd/etherpad.service
Normal file
52
packaging/systemd/etherpad.service
Normal 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
203
packaging/test-local.sh
Executable 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
2123
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
266
snap/README.md
Normal 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 1–65535 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 (1–65535 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
24
snap/hooks/configure
vendored
Executable 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
35
snap/local/bin/etherpad-cli
Executable 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
|
||||
20
snap/local/bin/etherpad-healthcheck-wrapper
Executable file
20
snap/local/bin/etherpad-healthcheck-wrapper
Executable 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
71
snap/local/bin/etherpad-service
Executable 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
162
snap/snapcraft.yaml
Normal 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
73
snap/tests/lib.sh
Executable 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
47
snap/tests/run-all.sh
Executable 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
53
snap/tests/smoke.sh
Executable 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
77
snap/tests/test-cli.sh
Executable 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
62
snap/tests/test-configure.sh
Executable 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
Loading…
Add table
Add a link
Reference in a new issue