Compare commits

..

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

558 changed files with 6626 additions and 78947 deletions

View file

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

View file

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

View file

@ -27,49 +27,49 @@ jobs:
strategy:
fail-fast: false
matrix:
# Etherpad requires Node >= 24 (see package.json engines.node).
node: ${{ fromJSON('[24]') }}
# PRs: test on latest Node only. Push to develop: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[">=24.0.0 <25.0.0"]') || fromJSON('[">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]') }}
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.PNPM_HOME }}
~/.local/share/gnpm
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
version: 0.0.12
-
name: Install libreoffice
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm i --frozen-lockfile
run: gnpm i --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
- name: Install admin ui
working-directory: admin
run: pnpm install
run: gnpm install --runtimeVersion="${{ matrix.node }}"
- name: Build admin ui
working-directory: admin
run: pnpm build
run: gnpm build --runtimeVersion="${{ matrix.node }}"
-
name: Run the backend tests
run: pnpm test
run: gnpm test --runtimeVersion="${{ matrix.node }}"
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
run: gnpm run test:vitest --runtimeVersion="${{ matrix.node }}"
withpluginsLinux:
env:
@ -84,62 +84,60 @@ jobs:
strategy:
fail-fast: false
matrix:
node: ${{ fromJSON('[24]') }}
node: ${{ github.event_name == 'pull_request' && fromJSON('[">=24.0.0 <25.0.0"]') || fromJSON('[">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]') }}
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup pnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.PNPM_HOME }}
~/.local/share/gnpm
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
version: 0.0.12
-
name: Install libreoffice
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
run: gnpm install --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
- name: Build admin ui
working-directory: admin
run: pnpm build
run: gnpm build --runtimeVersion="${{ matrix.node }}"
-
name: Install Etherpad plugins
run: >
pnpm add -w
gnpm install --workspace-root
ep_align
ep_author_hover
ep_cursortrace
ep_font_color
ep_font_size
ep_hash_auth
ep_headings2
ep_markdown
ep_guest
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
ep_table_of_contents
-
name: Run the backend tests
run: pnpm test
run: gnpm test --runtimeVersion="${{ matrix.node }}"
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
run: gnpm run test:vitest --runtimeVersion="${{ matrix.node }}"
# Windows tests only run on push to develop/master, not on PRs
withoutpluginsWindows:
@ -150,47 +148,34 @@ jobs:
strategy:
fail-fast: false
matrix:
# Etherpad requires Node >= 24 (see package.json engines.node).
# Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's
# bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect
# path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the
# backend suite's heavy localhost connection churn -- the long-standing Windows
# backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node
# observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0
# (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly
# because setup-node's default check-latest:false reuses the runner's
# pre-cached 24.15.0 for a bare "24".
# Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin
# back to plain "24" once the fix is across the supported 24.x baseline.
node: ${{ fromJSON('["24.16.0"]') }}
node: [">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]
name: Windows without plugins
runs-on: windows-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup pnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.PNPM_HOME }}
C:\gnpm\
C:\Users\runneradmin\AppData\Roaming\gnpm\
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
version: 0.0.12
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
run: gnpm install --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
- name: Build admin ui
working-directory: admin
run: pnpm build
run: gnpm build --runtimeVersion="${{ matrix.node }}"
-
name: Fix up the settings.json
run: |
@ -198,14 +183,11 @@ jobs:
powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json"
-
name: Run the backend tests
shell: bash
working-directory: src
# --exit makes mocha call process.exit() after the run so a leaked handle
# cannot hang the job on Windows.
run: pnpm test -- --exit
run: gnpm test --runtimeVersion="${{ matrix.node }}"
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
run: gnpm run test:vitest --runtimeVersion="${{ matrix.node }}"
withpluginsWindows:
env:
@ -215,60 +197,45 @@ jobs:
strategy:
fail-fast: false
matrix:
# Etherpad requires Node >= 24 (see package.json engines.node).
# Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's
# bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect
# path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the
# backend suite's heavy localhost connection churn -- the long-standing Windows
# backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node
# observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0
# (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly
# because setup-node's default check-latest:false reuses the runner's
# pre-cached 24.15.0 for a bare "24".
# Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin
# back to plain "24" once the fix is across the supported 24.x baseline.
node: ${{ fromJSON('["24.16.0"]') }}
node: [">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]
name: Windows with Plugins
runs-on: windows-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup pnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.PNPM_HOME }}
C:\gnpm\
C:\Users\runneradmin\AppData\Roaming\gnpm\
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
version: 0.0.12
- name: Install dependencies
run: pnpm install
run: gnpm install --runtimeVersion="${{ matrix.node }}"
- name: Build admin ui
working-directory: admin
run: pnpm build
run: gnpm build --runtimeVersion="${{ matrix.node }}"
-
name: Install Etherpad plugins
run: >
pnpm add -w
gnpm install --workspace-root
ep_align
ep_author_hover
ep_cursortrace
ep_font_color
ep_font_size
ep_hash_auth
ep_headings2
ep_markdown
ep_guest
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
@ -284,7 +251,7 @@ jobs:
# rules.
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
run: gnpm install --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
-
name: Fix up the settings.json
run: |
@ -292,11 +259,8 @@ jobs:
powershell -Command "(gc settings.json.holder) -replace '\"points\": 10', '\"points\": 1000' | Out-File -encoding ASCII settings.json"
-
name: Run the backend tests
shell: bash
working-directory: src
# --exit makes mocha call process.exit() after the run so a leaked handle
# cannot hang the job on Windows.
run: pnpm test -- --exit
run: gnpm test --runtimeVersion="${{ matrix.node }}"
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
run: gnpm run test:vitest --runtimeVersion="${{ matrix.node }}"

View file

@ -1,96 +1,68 @@
# 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.
# Workflow for deploying static content to GitHub Pages
name: Deploy Docs to GitHub Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["develop"]
paths:
- doc/**
- .github/workflows/build-and-deploy-docs.yml
pull_request:
paths:
- doc/**
- .github/workflows/build-and-deploy-docs.yml
- doc/** # Only run workflow when changes are made to the doc directory
# Allows you to run this workflow manually from the Actions tab
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. Do NOT cancel in-progress runs — production
# deployments are allowed to complete.
# 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.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ~/.pnpm-store
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v6
name: Cache vitepress build
with:
path: doc/.vitepress/cache
key: ${{ runner.os }}-vitepress-${{ hashFiles('doc/**/*.md', 'doc/.vitepress/config.*') }}
restore-keys: |
${{ runner.os }}-vitepress-
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
run_install: false
# Pin Node so the build does not silently fall back to whatever the
# runner image ships with. The repo declares engines.node >=24.0.0.
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Setup Pages
if: github.event_name == 'push'
uses: actions/configure-pages@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build app
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:
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.
# Single deploy job since we're just deploying
deploy:
needs: build
if: github.event_name == 'push'
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: |
${{ env.STORE_PATH }}
~/.local/share/gnpm
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
version: 0.0.12
- name: Setup Pages
uses: actions/configure-pages@v6
- name: Install dependencies
run: gnpm install
- name: Build app
working-directory: doc
run: gnpm run docs:build
env:
COMMIT_REF: ${{ github.sha }}
- name: Upload artifact
uses: actions/upload-pages-artifact@v5
with:
# Upload entire repository
path: './doc/.vitepress/dist'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v5

View file

@ -25,7 +25,7 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.

View file

@ -1,493 +0,0 @@
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'
# Also build + smoke-test the package on PRs that touch the production
# footprint. The smoke step boots the packaged server and waits for /health,
# which is the only check that catches "server starts then exits before
# binding the port" startup regressions (e.g. a dependency bump whose change
# lets the event loop drain mid-boot). Previously this workflow ran only on
# push to develop, so such a regression was caught *after* merge — by which
# point develop was already red. Running it pre-merge blocks the PR instead.
# The release/apt-publish jobs are tag-guarded, so PRs run the build job only.
pull_request:
paths:
- 'packaging/**'
- '.github/workflows/deb-package.yml'
- 'src/package.json'
- 'pnpm-lock.yaml'
- 'src/node/server.ts'
- 'src/node/utils/run_cmd.ts'
- 'src/static/js/pluginfw/**'
- 'settings.json.template'
workflow_dispatch:
inputs:
ref:
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@v7
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 (>= 24)`. 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 (>= 24)`.
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" ]
# plugin_packages must stay in-tree -- Node.js resolves symlinks
# to realpath before walking node_modules, so symlinking it
# outside /opt broke require("ep_etherpad-lite/...") in
# admin-installed plugins (ether/ep_comments_page#416).
sudo test -d /opt/etherpad/src/plugin_packages
sudo test ! -L /opt/etherpad/src/plugin_packages
[ "$(sudo stat -c '%G' /opt/etherpad/src/plugin_packages)" = "etherpad" ]
[ "$(sudo stat -c '%a' /opt/etherpad/src/plugin_packages)" = "2775" ]
[ "$(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
# Regression: simulate a pre-fix install (plugin_packages as a
# symlink to /var/lib/etherpad/plugin_packages, with a marker
# plugin inside) and re-run the postinst. The new postinst must
# migrate the contents back in-tree and drop the symlink so
# admin-installed plugins keep resolving ep_etherpad-lite
# (ether/ep_comments_page#416).
sudo rm -rf /opt/etherpad/src/plugin_packages
sudo mkdir -p /var/lib/etherpad/plugin_packages/.versions/ep_migration_marker
echo '{"name":"ep_migration_marker"}' | \
sudo tee /var/lib/etherpad/plugin_packages/.versions/ep_migration_marker/package.json >/dev/null
sudo chown -R etherpad:etherpad /var/lib/etherpad/plugin_packages
sudo ln -sfn /var/lib/etherpad/plugin_packages /opt/etherpad/src/plugin_packages
sudo dpkg-reconfigure etherpad
sudo test -d /opt/etherpad/src/plugin_packages
sudo test ! -L /opt/etherpad/src/plugin_packages
sudo test -f /opt/etherpad/src/plugin_packages/.versions/ep_migration_marker/package.json
[ "$(sudo stat -c '%a' /opt/etherpad/src/plugin_packages)" = "2775" ]
# Regression: stage the ep_layout_trip_wire test fixture into
# plugin_packages and confirm etherpad loads it. The fixture's
# index.js does the require('ep_etherpad-lite/...') calls that
# broke under the old symlinked layout (#416). If the layout
# ever regresses, the marker line never reaches the journal
# and this step fails.
PLUGIN_DIR=/opt/etherpad/src/plugin_packages
FIXTURE_DIR=$GITHUB_WORKSPACE/packaging/test-fixtures/ep_layout_trip_wire
sudo install -d -o etherpad -g etherpad -m 2775 "${PLUGIN_DIR}/.versions"
sudo cp -a "${FIXTURE_DIR}" "${PLUGIN_DIR}/.versions/ep_layout_trip_wire@1.0.0"
sudo ln -sfn .versions/ep_layout_trip_wire@1.0.0 "${PLUGIN_DIR}/ep_layout_trip_wire"
sudo ln -sfn ../plugin_packages/ep_layout_trip_wire \
/opt/etherpad/src/node_modules/ep_layout_trip_wire
sudo chown -R etherpad:etherpad "${PLUGIN_DIR}/.versions/ep_layout_trip_wire@1.0.0" \
"${PLUGIN_DIR}/ep_layout_trip_wire" \
/opt/etherpad/src/node_modules/ep_layout_trip_wire
echo '{"plugins":[{"name":"ep_etherpad-lite","version":"0.0.0"},{"name":"ep_layout_trip_wire","version":"1.0.0"}]}' \
| sudo tee /var/lib/etherpad/var/installed_plugins.json >/dev/null
sudo chown etherpad:etherpad /var/lib/etherpad/var/installed_plugins.json
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
sudo journalctl -u etherpad --no-pager -n 300 || true
exit 1
fi
# Marker proves the require('ep_etherpad-lite/...') calls in
# the fixture's index.js all resolved.
sudo journalctl -u etherpad --no-pager -n 500 \
| grep -F 'ep_layout_trip_wire: plugin_packages layout OK'
# And no MODULE_NOT_FOUND involving ep_etherpad-lite, anywhere.
if sudo journalctl -u etherpad --no-pager -n 500 \
| grep -E "Cannot find module '?ep_etherpad-lite"; then
echo "::error::ep_etherpad-lite require failed inside an installed plugin"
exit 1
fi
sudo systemctl stop etherpad
sudo dpkg --purge etherpad
! id etherpad 2>/dev/null
# Purge must clean up runtime-created plugin artifacts that
# dpkg didn't ship (ether/ep_comments_page#416, Qodo #3).
sudo test ! -e /opt/etherpad/src/plugin_packages
sudo test ! -e /var/lib/etherpad
- 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@v7
with:
fetch-depth: 1
- name: Configure deploy key for ether/ether.github.com
env:
SITE_DEPLOY_KEY: ${{ secrets.SITE_DEPLOY_KEY }}
run: |
set -euo pipefail
if [ -z "${SITE_DEPLOY_KEY:-}" ]; then
echo "::error::SITE_DEPLOY_KEY secret is not set on ether/etherpad."
echo "::error::Add an SSH deploy key with write access on ether/ether.github.com and store the private key here."
exit 1
fi
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s\n' "${SITE_DEPLOY_KEY}" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan -t ed25519,rsa github.com >> ~/.ssh/known_hosts 2>/dev/null
cat > ~/.ssh/config <<'CFG'
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_deploy
IdentitiesOnly yes
CFG
chmod 600 ~/.ssh/config
- name: Clone ether/ether.github.com
run: git clone --depth 1 git@github.com:ether/ether.github.com.git site
- uses: actions/download-artifact@v8
with:
path: dist
pattern: etherpad-*-deb
merge-multiple: true
- name: Install apt-utils + gpg
run: |
sudo apt-get update -qq
sudo apt-get install -y -qq apt-utils gnupg
- name: Import signing key
env:
APT_SIGNING_KEY: ${{ secrets.APT_SIGNING_KEY }}
run: |
set -euo pipefail
if [ -z "${APT_SIGNING_KEY:-}" ]; then
echo "::error::APT_SIGNING_KEY secret is not set; cannot sign Release file."
exit 1
fi
export GNUPGHOME="$(mktemp -d)"
chmod 700 "${GNUPGHOME}"
echo "GNUPGHOME=${GNUPGHOME}" >>"${GITHUB_ENV}"
printf '%s' "${APT_SIGNING_KEY}" | gpg --batch --import
# Sanity check: expected long key id.
gpg --list-secret-keys --keyid-format=long | grep -q AF0CD687D51A6E63
- name: Generate apt repo metadata
run: |
set -euo pipefail
REPO=site/public/apt
SUITE=stable
COMP=main
# Wipe any previous repo state so removed versions don't linger
# in pool/. Packages.gz is regenerated from whatever is in pool/
# right now, so this is the simplest correct option — alternative
# is per-version diffing which is fragile.
rm -rf "${REPO}"
# We ship one architecture-agnostic suite with per-arch pools.
# Layout: apt/dists/<suite>/main/binary-{amd64,arm64}/
for arch in amd64 arm64; do
mkdir -p "${REPO}/pool/main/e/etherpad" "${REPO}/dists/${SUITE}/${COMP}/binary-${arch}"
done
# Drop the .debs into pool/. The leading-digit pattern
# excludes the etherpad-latest_*.deb filename aliases the
# release job stages — apt resolves by package name + version,
# not filename, so including the alias would create duplicate
# Packages entries. (Also defends against any future alias that
# accidentally lands on dist/etherpad_<word>_<arch>.deb.)
shopt -s nullglob
DEBS=(dist/etherpad_[0-9]*_amd64.deb dist/etherpad_[0-9]*_arm64.deb)
shopt -u nullglob
# Refuse to publish nothing. Without this, a missing or renamed
# build artefact would wipe site/public/apt and push an empty,
# signed apt repo — breaking `apt update` for every existing
# subscriber until the next successful release.
if [ ${#DEBS[@]} -lt 2 ]; then
echo "::error::Expected per-arch .deb artifacts in dist/, found ${#DEBS[@]}: ${DEBS[*]:-<none>}"
echo "::error::Refusing to publish a partial / empty apt repository."
exit 1
fi
cp "${DEBS[@]}" "${REPO}/pool/main/e/etherpad/"
# Generate per-arch Packages files.
(
cd "${REPO}"
for arch in amd64 arm64; do
apt-ftparchive --arch "${arch}" packages pool/main \
> "dists/${SUITE}/${COMP}/binary-${arch}/Packages"
gzip -kf "dists/${SUITE}/${COMP}/binary-${arch}/Packages"
done
# Generate the suite's Release file. The heredoc lines
# MUST start at column 1 — apt parsers reject leading
# whitespace on header fields (RFC 822 / Debian control).
# printf is used over a heredoc to make that contract
# impossible to lose to a future re-indent.
printf '%s\n' \
"Origin: Etherpad" \
"Label: Etherpad" \
"Suite: ${SUITE}" \
"Codename: ${SUITE}" \
"Architectures: amd64 arm64" \
"Components: ${COMP}" \
"Description: Etherpad official apt repository (${SUITE} channel)" \
"Date: $(date -Ru)" \
> "dists/${SUITE}/Release"
# apt-ftparchive appends checksums.
apt-ftparchive release "dists/${SUITE}" >> "dists/${SUITE}/Release"
# Sign it (clear-signed InRelease + detached Release.gpg).
gpg --default-key AF0CD687D51A6E63 --batch --yes \
--clearsign -o "dists/${SUITE}/InRelease" "dists/${SUITE}/Release"
gpg --default-key AF0CD687D51A6E63 --batch --yes \
-abs -o "dists/${SUITE}/Release.gpg" "dists/${SUITE}/Release"
)
- name: Stage public key alongside the site
run: |
# Users curl this to add our key to their keyring before apt update.
cp packaging/apt/key.asc site/public/key.asc
- name: Commit + push to ether/ether.github.com
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
cd site
git -c user.email=actions@github.com -c user.name='github-actions[bot]' \
add public/apt public/key.asc
if git diff --cached --quiet; then
echo "No apt-repo changes to publish."
exit 0
fi
git -c user.email=actions@github.com -c user.name='github-actions[bot]' \
commit -m "apt: publish Etherpad ${TAG}"
git push origin HEAD:master

View file

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

View file

@ -26,7 +26,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
path: etherpad
-
@ -42,37 +42,27 @@ jobs:
tags: ${{ env.TEST_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
- uses: actions/cache@v6
name: Cache pnpm store
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.PNPM_HOME }}
~/.local/share/gnpm
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
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: 24
cache: pnpm
cache-dependency-path: etherpad/pnpm-lock.yaml
version: 0.0.12
-
name: Test
working-directory: etherpad
run: |
# ADMIN_PASSWORD provisions settings.json.docker's admin user so
# the adminSettings_7819 container spec can authenticate against
# /admin and the /settings socket. pad.js doesn't touch /admin
# so the existing API specs are unaffected.
docker run --rm -d -p 9001:9001 \
-e ADMIN_PASSWORD=changeme1 \
--name test ${{ env.TEST_TAG }}
docker run --rm -d -p 9001:9001 --name test ${{ env.TEST_TAG }}
./bin/installDeps.sh
docker logs -f test &
while true; do
@ -84,149 +74,10 @@ jobs:
*) printf %s\\n "unexpected status: ${status}" >&2; exit 1;;
esac
done
(cd src && pnpm run test-container)
# Regression for #7819. adminSettings_7819.ts saves a marker via
# the admin /settings socket and intentionally leaves it on
# disk. We assert here that the save actually hit the file
# (mocha only sees the next `load` reply — this catches a
# scenario where the load is served from cache and the file
# never actually changed).
docker exec test grep -q persist-marker-7819 /opt/etherpad-lite/settings.json || {
echo "[#7819] socket-driven save did NOT reach /opt/etherpad-lite/settings.json on disk"
docker exec test cat /opt/etherpad-lite/settings.json | head -50
exit 1
}
# Now prove the on-disk file survives an in-place container
# restart. This is the scenario a docker-compose user with
# `restart: always` hits on every host reboot.
docker restart test >/dev/null
for i in $(seq 1 60); do
status=$(docker container inspect -f '{{.State.Health.Status}}' test 2>/dev/null) || { docker logs test; exit 1; }
case ${status} in
healthy) break;;
starting) sleep 2;;
*) docker logs test; exit 1;;
esac
done
docker exec test grep -q persist-marker-7819 /opt/etherpad-lite/settings.json || {
echo "[#7819 REGRESSION] settings.json was reset on docker restart — ep_oauth block vanished"
docker logs test
exit 1
}
docker rm -f test
(cd src && gnpm run test-container)
git clean -dxf .
-
# Regression test for #7718. Reproduces the production
# docker-compose layout reported in that issue: a named volume
# mounted on src/plugin_packages with no TTY allocated. Under
# the previous `CMD ["pnpm", "run", "prod"]`, pnpm 11's
# runDepsStatusCheck spuriously decided node_modules was out of
# sync at boot and tried to wipe + reinstall, aborting with
# ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY before the HTTP
# server ever came up. If the CMD is ever reverted to go
# through `pnpm run`, this step will time out waiting for the
# health endpoint and fail.
name: Regression — boot with named volume on plugin_packages (#7718)
working-directory: etherpad
run: |
docker volume create ep7718-plugins
docker run --rm -d -p 9001:9001 \
-v ep7718-plugins:/opt/etherpad-lite/src/plugin_packages \
--name test-7718 ${{ env.TEST_TAG }}
docker logs -f test-7718 &
ok=0
for i in $(seq 1 60); do
status=$(docker container inspect -f '{{.State.Health.Status}}' test-7718 2>/dev/null) || {
echo "container exited prematurely — likely #7718 regression"
docker logs test-7718 || true
break
}
case ${status} in
healthy) ok=1; echo "container healthy after $((i*2))s"; break;;
starting) sleep 2;;
*) echo "unexpected status: ${status}"; docker logs test-7718; break;;
esac
done
docker rm -f test-7718 >/dev/null 2>&1 || true
docker volume rm ep7718-plugins >/dev/null 2>&1 || true
[ "$ok" = "1" ] || exit 1
-
# Regression test: Etherpad container must boot without external
# network access (e.g., air-gapped host or restrictive runtime policy).
name: Regression — boot with disabled network (--network none)
working-directory: etherpad
run: |
docker run --rm -d \
--network none \
--name test-offline ${{ env.TEST_TAG }}
docker logs -f test-offline &
ok=0
for i in $(seq 1 60); do
status=$(docker container inspect -f '{{.State.Health.Status}}' test-offline 2>/dev/null) || {
echo "container exited prematurely while offline"
docker logs test-offline || true
break
}
case ${status} in
healthy) ok=1; echo "offline boot healthy after $((i*2))s"; break;;
starting) sleep 2;;
*) echo "unexpected status: ${status}"; docker logs test-offline || true; break;;
esac
done
docker rm -f test-offline >/dev/null 2>&1 || true
[ "$ok" = "1" ] || exit 1
build-test-local-plugin:
# Regression coverage for #7687: the Docker image's
# `bin/installLocalPlugins.sh` step runs as the `etherpad` user and
# invokes pnpm via the corepack shim. A previous corepack/cache bug
# made that path fail when ETHERPAD_LOCAL_PLUGINS was set. This job
# builds the development target with a stub local plugin so the
# regression cannot silently come back.
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Check out
uses: actions/checkout@v7
with:
path: etherpad
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
-
name: Stub a local plugin
run: |
mkdir -p etherpad/local_plugins/ep_test_corepack
cat > etherpad/local_plugins/ep_test_corepack/package.json <<'EOF'
{
"name": "ep_test_corepack",
"version": "0.0.1",
"description": "regression-test stub for ether/etherpad#7687",
"main": "index.js"
}
EOF
cat > etherpad/local_plugins/ep_test_corepack/index.js <<'EOF'
exports.placeholder = true;
EOF
-
name: Build with ETHERPAD_LOCAL_PLUGINS (must succeed)
uses: docker/build-push-action@v7
with:
context: ./etherpad
target: development
load: false
build-args: |
ETHERPAD_LOCAL_PLUGINS=ep_test_corepack
cache-from: type=gha
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
@ -261,7 +112,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
path: etherpad
-
@ -358,7 +209,7 @@ jobs:
steps:
-
name: Check out
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
path: etherpad
-
@ -416,7 +267,7 @@ jobs:
enable-url-completion: true
- name: Check out ether-charts
if: github.ref == 'refs/heads/develop'
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
path: ether-charts
repository: ether/ether-charts
@ -425,13 +276,13 @@ jobs:
if: success() && github.ref == 'refs/heads/develop'
working-directory: ether-charts
run: |
sed -i 's/tag: ".*"/tag: "${{ steps.build-docker.outputs.digest }}"/' charts/etherpad/values-dev.yaml
sed -i 's/tag: ".*"/tag: "${{ steps.build-docker.outputs.digest }}"/' values-dev.yaml
- name: Commit and push changes
working-directory: ether-charts
if: success() && github.ref == 'refs/heads/develop'
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git add charts/etherpad/values-dev.yaml
git add values-dev.yaml
git commit -m 'Update develop image tag'
git push

View file

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

View file

@ -21,34 +21,34 @@ jobs:
strategy:
fail-fast: false
matrix:
# Etherpad requires Node >= 24 (see package.json engines.node).
node: ${{ fromJSON('[24]') }}
# PRs: single Node version. Push: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[20, 22, 24]') }}
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.PNPM_HOME }}
~/.local/share/gnpm
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
version: 0.0.12
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm i
run: gnpm i --runtimeVersion="${{ matrix.node }}"
- name: Cache Playwright browsers
uses: actions/cache@v6
uses: actions/cache@v5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
@ -71,11 +71,11 @@ jobs:
- name: Build admin frontend
working-directory: admin
run: |
pnpm run build
gnpm run build --runtimeVersion="${{ matrix.node }}"
- name: Run the frontend admin tests
shell: bash
run: |
pnpm run prod > /tmp/etherpad-server.log 2>&1 &
gnpm run prod --runtimeVersion="${{ matrix.node }}" > /tmp/etherpad-server.log 2>&1 &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
@ -87,7 +87,7 @@ jobs:
sleep 1
done
cd src
pnpm run test-admin
gnpm run test-admin --runtimeVersion="${{ matrix.node }}"
- name: Upload server log on failure
uses: actions/upload-artifact@v7
if: failure()

View file

@ -20,58 +20,47 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.PNPM_HOME }}
~/.cache/ms-playwright
~/.local/share/gnpm
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v6
name: Cache Playwright browsers
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
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: 24
cache: pnpm
version: 0.0.12
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
run: gnpm install --frozen-lockfile
-
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
gnpm 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
cd src
pnpm exec playwright install chromium --with-deps
pnpm run test-ui --project=chromium
gnpm exec playwright install chromium --with-deps
gnpm run test-ui --project=chromium
- name: Upload server log on failure
uses: actions/upload-artifact@v7
if: failure()
@ -92,56 +81,45 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.PNPM_HOME }}
~/.local/share/gnpm
~/.cache/ms-playwright
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v6
name: Cache Playwright browsers
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
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: 24
cache: pnpm
version: 0.0.12
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
run: gnpm install --frozen-lockfile
- 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
gnpm 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
cd src
pnpm exec playwright install firefox --with-deps
pnpm run test-ui --project=firefox
gnpm exec playwright install firefox --with-deps
gnpm run test-ui --project=firefox
- name: Upload server log on failure
uses: actions/upload-artifact@v7
if: failure()
@ -155,198 +133,3 @@ 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@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v6
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: 24
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_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@v7
- uses: actions/cache@v6
name: Cache pnpm store
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v6
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: 24
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_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
ep_table_of_contents
- name: Create settings.json
run: cp ./src/tests/settings.json settings.json
- name: Run the frontend tests
shell: bash
run: |
set -euo pipefail
pnpm run prod > /tmp/etherpad-server.log 2>&1 &
# Generous 90s budget so a slow runner (or, in the with-plugins
# variant, plugin boot) doesn't lose the race against the test
# phase. Fail loudly on timeout rather than silently falling
# through to tests against a half-started server.
# --max-time bounds each probe so a stuck server can't make a
# single curl call eat the whole 90s budget.
can_connect() { curl --max-time 3 -sSfo /dev/null http://localhost:9001/; }
for i in $(seq 1 90); do can_connect && break; sleep 1; done
if ! can_connect; then
echo "::error::Etherpad did not respond on :9001 within 90s"
echo "----- server log -----"
tail -n 200 /tmp/etherpad-server.log || true
exit 1
fi
cd src
pnpm exec playwright install firefox --with-deps
WITH_PLUGINS=1 pnpm run test-ui --project=firefox
- name: Upload server log on failure
uses: actions/upload-artifact@v7
if: failure()
with:
name: server-log-firefox-with-plugins
path: /tmp/etherpad-server.log
retention-days: 7
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report-firefox-with-plugins
path: src/playwright-report/
retention-days: 30

View file

@ -27,34 +27,32 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.STORE_PATH }}
~/.local/share/gnpm
~/.cache/ms-playwright
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
version: 0.0.12
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
run: gnpm install --frozen-lockfile
- name: Build etherpad
run: pnpm run build:etherpad
# On release, create release. `--silent` suppresses pnpm's lifecycle
# banner ("> generateChangelog\n> node ...") that would otherwise be
# captured into CHANGELOG.txt and end up at the top of the GitHub
# release notes.
run: gnpm run build:etherpad
# On release, create release
- name: Generate Changelog
working-directory: bin
run: pnpm --silent run generateChangelog ${{ github.ref }} > ${{ github.workspace }}-CHANGELOG.txt
run: gnpm run generateChangelog ${{ github.ref }} > ${{ github.workspace }}-CHANGELOG.txt
- name: Release
uses: softprops/action-gh-release@v3
if: ${{startsWith(github.ref, 'refs/tags/v') }}

View file

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

View file

@ -24,32 +24,37 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.STORE_PATH }}
~/.local/share/gnpm
~/.cache/ms-playwright
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
version: 0.0.12
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
run: gnpm install --frozen-lockfile
-
name: Install etherpad-load-test
run: sudo npm install -g etherpad-load-test
run: sudo npm install -g etherpad-load-test-socket-io
-
name: Run load test
run: src/tests/frontend/travis/runnerLoadTest.sh 25 50
run: |
gnpm --gnpmEnv
eval "$(gnpm --gnpmEnv)"
echo $PATH
src/tests/frontend/travis/runnerLoadTest.sh 25 50
withplugins:
# run on pushes to any branch
@ -62,30 +67,33 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.STORE_PATH }}
~/.local/share/gnpm
~/.cache/ms-playwright
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
version: 0.0.12
-
name: Install etherpad-load-test
run: sudo npm install -g etherpad-load-test
run: sudo npm install -g etherpad-load-test-socket-io
-
name: Install etherpad plugins
# The --legacy-peer-deps flag is required to work around a bug in npm v7:
# https://github.com/npm/cli/issues/2199
run: >
pnpm add -w
gnpm install --workspace-root
ep_align
ep_author_hover
ep_cursortrace
@ -93,7 +101,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_guest
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
@ -109,10 +117,12 @@ jobs:
# rules.
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
run: gnpm install --frozen-lockfile
-
name: Run load test
run: src/tests/frontend/travis/runnerLoadTest.sh 25 50
run: |
eval "$(gnpm --gnpmEnv)"
src/tests/frontend/travis/runnerLoadTest.sh 25 50
long:
# run on pushes to any branch
@ -125,29 +135,34 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.STORE_PATH }}
~/.local/share/gnpm
~/.cache/ms-playwright
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
version: 0.0.12
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
run: gnpm install --frozen-lockfile
-
name: Install etherpad-load-test
run: sudo npm install -g etherpad-load-test
run: sudo npm install -g etherpad-load-test-socket-io
-
name: Run load test
run: src/tests/frontend/travis/runnerLoadTest.sh 5000 5
run: |
gnpm --gnpmEnv
eval "$(gnpm --gnpmEnv)"
echo $PATH
src/tests/frontend/travis/runnerLoadTest.sh 5000 5

View file

@ -24,25 +24,27 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.STORE_PATH }}
~/.local/share/gnpm
~/.cache/ms-playwright
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
version: 0.0.12
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: gnpm install --frozen-lockfile
- name: Perform type check
working-directory: ./src
run: pnpm run ts-check
run: gnpm run ts-check

View file

@ -27,23 +27,24 @@ jobs:
steps:
-
name: Checkout repository
uses: actions/checkout@v7
- uses: actions/cache@v6
name: Cache pnpm store
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.STORE_PATH }}
~/.local/share/gnpm
~/.cache/ms-playwright
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
version: 0.0.12
-
name: docker network
@ -58,36 +59,11 @@ jobs:
name: run docker images
run: |
docker run --name etherpad-docker -p 9000:9001 --rm --network ep_net --ip 172.23.42.2 -e 'TRUST_PROXY=true' epl-debian-slim &
docker run --name nginx-docker -p 8081:80 --rm --network ep_net --ip 172.23.42.1 -d nginx-latest
docker run -p 8081:80 --rm --network ep_net --ip 172.23.42.1 -d nginx-latest
docker run --rm --network ep_net --ip 172.23.42.3 --name anotherip -dt anotherip
-
name: install dependencies and create symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
-
# The etherpad container is started in the background above; without
# this wait the test step can hit nginx before etherpad is listening
# and nginx returns 502, failing the run on a cold cache. Poll the
# nginx-proxied endpoint (which is also what the test hits) until it
# stops returning 5xx.
name: Wait for etherpad behind nginx to be ready
run: |
# ~60s budget: 30 iterations × (1s curl timeout + 1s sleep).
# Cold-start of the etherpad container is well under that.
for i in $(seq 1 30); do
if curl -fsS -o /dev/null --max-time 1 http://127.0.0.1:8081/; then
echo "etherpad is ready behind nginx"
exit 0
fi
sleep 1
done
echo "ERROR: etherpad behind nginx did not become ready in time"
echo "--- docker ps ---"
docker ps -a || true
echo "--- nginx-docker logs ---"
docker logs nginx-docker || true
echo "--- etherpad-docker logs ---"
docker logs etherpad-docker || true
exit 1
run: gnpm install --frozen-lockfile
-
name: run rate limit test
run: |

View file

@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ether/etherpad-lite
path: etherpad
@ -42,37 +42,30 @@ jobs:
git checkout develop
git reset --hard origin/develop
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
repository: ether/ether.github.com
path: ether.github.com
token: '${{ secrets.ETHER_RELEASE_TOKEN }}'
- uses: actions/cache@v6
name: Cache pnpm store
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.STORE_PATH }}
~/.local/share/gnpm
~/.cache/ms-playwright
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
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: 24
cache: pnpm
cache-dependency-path: etherpad/pnpm-lock.yaml
version: 0.0.12
- name: Install dependencies ether.github.com
# 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
run: gnpm install --frozen-lockfile
working-directory: ether.github.com
- name: Set git user
run: |
@ -82,15 +75,15 @@ jobs:
with:
ruby-version: 2.7
- uses: reitzig/actions-asciidoctor@v2.0.5
- uses: reitzig/actions-asciidoctor@v2.0.4
with:
version: 2.0.18
- name: Prepare release
working-directory: etherpad
run: |
cd bin
pnpm install
pnpm run release ${{ inputs.release_type }}
gnpm install
gnpm run release ${{ inputs.release_type }}
- name: Push after release
working-directory: etherpad
run: |

View file

@ -1,33 +1,9 @@
# PARKED — npm publish of the core package is not part of the standard release.
#
# This workflow renames `ep_etherpad-lite` -> `ep_etherpad` and publishes
# `./src` to npm. As of 2026-06, that publish serves no load-bearing purpose:
# - `ep_etherpad` has 0 dependents on npm and nothing in this repo depends on it;
# - plugins import `ep_etherpad-lite` resolved from the LOCAL core install,
# and plugin CI clones `ether/etherpad` rather than `npm install`-ing core;
# - Etherpad is run via git clone / Docker / zip / snap, never `npm install`.
# The publish has been failing (E404 PUT — the `ep_etherpad` package has no OIDC
# trusted publisher configured on npmjs.com), which is why npm is stuck at 2.5.0
# while 3.x shipped fine without it.
#
# It is therefore gated behind an explicit `confirm: true` dispatch input so a
# stray run fails fast with a clear message instead of a confusing 404. To
# actually publish, the npm owner of `ep_etherpad` (samtv12345) must first
# configure a trusted publisher: npmjs.com -> ep_etherpad -> Settings ->
# Trusted Publisher -> repo `ether/etherpad`, workflow `releaseEtherpad.yml`.
# Decision pending: finish that config, or remove this workflow. See AGENTS.MD.
name: releaseEtherpad.yaml
permissions:
contents: read
id-token: write # for npm OIDC trusted publishing
on:
workflow_dispatch:
inputs:
confirm:
description: 'PARKED — publish ep_etherpad to npm? Requires a trusted publisher configured on npmjs.com first (see workflow header). Set true only if that is done.'
required: true
default: false
type: boolean
env:
PNPM_HOME: ~/.pnpm-store
@ -36,42 +12,44 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Guard — refuse unless explicitly confirmed
if: ${{ inputs.confirm != true }}
run: |
echo "::error::releaseEtherpad is PARKED. The ep_etherpad npm publish is non-functional"
echo "::error::(no trusted publisher configured on npmjs.com; 0 dependents on npm)."
echo "::error::Re-run with confirm=true only after the owner configures a trusted"
echo "::error::publisher. See the workflow header / AGENTS.MD 'Releasing' section."
exit 1
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
# OIDC trusted publishing needs npm >= 11.5.1, which requires
# Node >= 22.9.0. Node 24 satisfies that and matches the rest of CI.
node-version: 24
# Node >= 20.17.0. setup-node's `20` resolves to the latest
# 20.x, which satisfies that.
node-version: 20
registry-url: https://registry.npmjs.org/
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
run: npm install -g npm@latest
- uses: actions/cache@v6
name: Cache pnpm store
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v5
name: Setup pnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.STORE_PATH }}
~/.local/share/gnpm
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
version: 0.0.12
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: gnpm install --frozen-lockfile
- name: Rename etherpad
working-directory: ./src
run: sed -i 's/ep_etherpad-lite/ep_etherpad/g' package.json
# Use `npm publish` directly (not the `pnpm` wrapper) because OIDC
# trusted publishing requires npm CLI >= 11.5.1 and the wrapper shells
# Use `npm publish` directly (not `gnpm`/`pnpm` wrappers) because OIDC
# trusted publishing requires npm CLI >= 11.5.1 and the wrappers shell
# out to npm; calling npm directly avoids any shim ambiguity. The
# ep_etherpad package must have a trusted publisher configured on
# npmjs.com pointing at this workflow file. See:

View file

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

View file

@ -1,92 +0,0 @@
# 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@v7
- name: Build snap
id: build
uses: snapcore/action-build@v1
- name: Upload snap artifact
uses: actions/upload-artifact@v7
with:
name: etherpad-snap
path: ${{ steps.build.outputs.snap }}
if-no-files-found: error
retention-days: 7
publish-edge:
needs: build
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Download snap artifact
uses: actions/download-artifact@v8
with:
name: etherpad-snap
- name: Publish to edge
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
with:
snap: ${{ needs.build.outputs.snap-file }}
release: edge
publish-stable:
needs: [build, publish-edge]
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
# Manual gate: promote edge -> stable via GitHub Environments approval.
environment: snap-store-stable
steps:
- name: Download snap artifact
uses: actions/download-artifact@v8
with:
name: etherpad-snap
- name: Publish to stable
uses: snapcore/action-publish@v1
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
with:
snap: ${{ needs.build.outputs.snap-file }}
release: stable

View file

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

View file

@ -27,48 +27,46 @@ jobs:
strategy:
fail-fast: false
matrix:
# Etherpad requires Node >= 24 (see package.json engines.node).
node: ${{ fromJSON('[24]') }}
# PRs: single Node version. Push: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[20, 22, 24]') }}
steps:
-
name: Check out latest release
uses: actions/checkout@v7
uses: actions/checkout@v6
with:
fetch-depth: 0
-
name: Check out latest release tag
run: git checkout "$(git tag --list 'v*' --sort=-version:refname | head -n1)"
- uses: actions/cache@v6
name: Cache pnpm store
ref: develop #FIXME change to master when doing release
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: ${{ env.PNPM_HOME }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
path: |
${{ env.STORE_PATH }}
~/.local/share/gnpm
~/.cache/ms-playwright
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: pnpm/action-setup@v6
name: Install pnpm
${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
version: 0.0.12
- name: Install libreoffice
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
run: gnpm install --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
- name: Build admin ui
working-directory: admin
run: pnpm build
run: gnpm build --runtimeVersion="${{ matrix.node }}"
-
name: Install Etherpad plugins
run: >
pnpm run install-plugins
gnpm run install-plugins
ep_align
ep_author_hover
ep_cursortrace
@ -76,21 +74,24 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_guest
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
ep_table_of_contents
ep_table_of_contents --runtimeVersion="${{ matrix.node }}"
-
name: Run the backend tests
run: pnpm run test
run: gnpm run test --runtimeVersion="${{ matrix.node }}"
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
# The job starts from the latest release tag, so fetch the pull-request
# ref explicitly before checking out ${GITHUB_SHA}. A plain "git fetch"
# only brings "normal" references (refs/heads/* and refs/tags/*), and for
# pull requests none of those include the synthetic merge commit.
run: gnpm install --frozen-lockfile --runtimeVersion="${{ matrix.node }}"
# Because actions/checkout@v6 is called with "ref: master" and without
# "fetch-depth: 0", the local clone does not have the ${GITHUB_SHA}
# commit. Fetch ${GITHUB_REF} to get the ${GITHUB_SHA} commit. Note that a
# plain "git fetch" only fetches "normal" references (refs/heads/* and
# refs/tags/*), and for pull requests none of the normal references
# include ${GITHUB_SHA}, so we have to explicitly tell Git to fetch
# ${GITHUB_REF}.
-
name: Fetch the new Git commits
run: git fetch --depth=1 origin "${GITHUB_REF}"
@ -100,4 +101,4 @@ jobs:
# commit that merges the PR's source branch to its destination branch.
run: git checkout "${GITHUB_SHA}"
- name: Run the backend tests
run: pnpm run test
run: gnpm run test --runtimeVersion="${{ matrix.node }}"

19
.gitignore vendored
View file

@ -27,22 +27,3 @@ 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
# Generated by `pnpm --filter admin gen:api` from src/node/hooks/express/openapi.ts.
# Regenerated by build/test/dev scripts; not committed.
/admin/src/api/schema.d.ts
/admin/src/api/version.ts
# Local git worktrees used by /release-review and similar workflows.
/.worktrees/

5
.pr_agent.toml Normal file
View file

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

View file

@ -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 >= 22.12.0
- **Package Manager:** pnpm (>= 11.0.0)
- **Runtime:** Node.js >= 20.0.0
- **Package Manager:** pnpm (>= 8.3.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/`)
@ -46,25 +46,6 @@ pnpm --filter ep_etherpad-lite run prod # Start production server
- **Comments:** Provide clear comments for complex logic only.
- **Backward Compatibility:** Always ensure compatibility with older versions of the database and configuration files.
### Internationalisation (i18n) — Mandatory
- **Every user-facing string MUST go through i18n.** That means JSX text, `placeholder`, `title`, `aria-label`, `alt`, toast/alert titles, `<option>` labels, error messages, breadcrumbs, empty-state copy — anything a user can see or hear via a screen reader.
- **React (admin SPA):** use `<Trans i18nKey="…"/>` for JSX text and `t('…')` for attribute values. The `t` comes from `useTranslation()`.
- **Pad UI (legacy):** use `data-l10n-id="…"` (html10n) — never bind `window._` directly to `html10n.get` and call it (it's unbound; returns `undefined`).
- **String keys live in `src/locales/en.json`.** Other locales sync from translatewiki on its own cadence — never hand-edit non-EN locale files.
- **Reuse existing keys before inventing new ones.** Check `src/locales/en.json` and the relevant plugin's `static/locale/en.json` (e.g. `admin/public/ep_admin_pads/en.json`) for an existing match. Duplicating a key like `ep_admin_pads:ep_adminpads2_last-edited` as a fresh `admin_pads.col.last_edited` fragments the translation surface for translatewiki.
- **Naming:** dot-namespaced, kebab-or-underscore-cased: `admin_plugins.subtitle`, `admin_pads.filter.all`. Group by page/feature.
- **Pluralisation:** use i18next's `_one`/`_other` suffix forms with `t('key', {count: n})` — never `n > 1 ? 'X items' : '1 item'`.
- **Locale-aware formatters:** pass a sanitised locale to `Intl.*` / `toLocaleString`. `i18n.language` is influenced by `?lng=` (user-controlled) and a malformed tag throws `RangeError`. Use a `sanitizeLocale()` helper that normalises `_``-` and validates via `Intl.DateTimeFormat.supportedLocalesOf()`, falling back to `'en'`.
- **`defaultValue:` in `t()` is for safety, not a substitute** for adding the key to `en.json`. If you're tempted to inline English with `defaultValue:` and skip the key, you're shipping a future-broken translation.
- **Hard prohibition:** literal German / French / Spanish / any non-English in JSX. The denylist test at `src/tests/backend-new/specs/admin-i18n-source-lint.test.ts` enforces this for the admin SPA — extend it when adding new admin files or new known-bad words.
### Accessibility (a11y) — Mandatory
- **Icon-only buttons MUST have `aria-label` AND `title`** (both — screen readers prefer `aria-label`; hover users get `title`). Lucide icons inside a button are not text content. Both labels must be `t('…')`-localised.
- **Sort controls are focusable.** A `<select>` plus a paired direction toggle is fine; a clickable column header is fine; "click invisible part of the row to sort" is not. When restyling, never strip a direction toggle without adding back an equivalent.
- **Semantic HTML over `<div>` soup.** Use `<nav>`, `<main>`, `<button>`, `<a>` (for navigation), `<table>` for tabular data. If a thing navigates externally, it is `<a target="_blank" rel="noopener noreferrer">`, not a click-handler on a `<span>`.
- **Don't drop existing affordances when restyling.** A UI refresh that removes `<a href="https://npmjs.com/{plugin}">` links, removes a sort-direction control, or replaces semantic elements with non-focusable divs is a regression even if it looks nicer. Audit the before/after for: focus order, keyboard reachability, external links, aria-labels, alt text.
- **Tests assert rendered strings + structural affordances.** For UI changes, the Playwright spec must assert at least one rendered translated string (catches broken i18n loading) and one structural affordance you added/preserved (links, toggles, headings).
### Development Workflow
- **Branching:** Work in feature branches. Issue PRs against the `develop` branch. Never PR directly to `master`.
- **Commits:** Maintain a linear history (no merge commits). Use meaningful messages in the format: `submodule: description`.
@ -211,38 +192,6 @@ pnpm run plugins ls # List installed
### Settings
Configured via `settings.json`. A template is available at `settings.json.template`. Environment variables can override any setting using `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`.
## Releasing
Releases are driven almost entirely by GitHub Actions. A maintainer dispatches **one** workflow; the version bump, tagging, GitHub Release, Docker images, and snap all cascade off the pushed tag. The npm publish is a separate manual dispatch.
### Prerequisites (check these before dispatching)
- **A `# X.Y.Z` changelog section for the _target_ version must already be at the top of `CHANGELOG.md`.** `bin/release.ts` aborts with `No changelog record for X.Y.Z, please create changelog record` if it's missing. Write the section before dispatching.
- **All four `package.json` files must agree on the current version:** root `package.json`, `src/package.json`, `admin/package.json`, `bin/package.json`. `release.ts` reads the *current* version from **`src/package.json`** and computes the next with `semver.inc(current, type)`. If the files are out of sync (e.g. one was hand-edited), the computed target is wrong and the changelog guard fails. *(This exact desync — `src`/`bin` left at 3.3.0 while root/admin were 3.2.0 — blocked the 3.3.0 release in June 2026.)*
### Cutting a release
1. **Actions → "Release etherpad"** → Run workflow (`workflow_dispatch`), choose `patch` / `minor` / `major`. Cadence is monthly **minors** (3.0.0 → 3.1.0 → 3.2.0 → …); use `major` only for breaking changes.
2. The **Prepare release** step runs `bin/release.ts`, which:
- sanity-checks the tree (clean working dir, on `develop`, `develop`/`master` upstreams in sync, `../ether.github.com` cloned & clean on `master`);
- bumps the version in all four `package.json` files;
- commits `bump version`, merges `develop``master`, creates both `X.Y.Z` **and** `vX.Y.Z` tags, merges `master` back to `develop`;
- builds and stages the versioned docs into the website repo (see **Documentation** below).
3. The **Push after release** step (`bin/push-after-release.sh`) pushes `master`, `develop`, the tag, `--tags`, and the `ether.github.com` docs commit.
4. The pushed **`vX.Y.Z` tag auto-triggers** three workflows:
- `handleRelease.yml` → builds Etherpad, extracts the matching changelog section via `generateChangelog` (`bin/generateReleaseNotes.ts`), and publishes the **GitHub Release** (`make_latest: true`);
- `docker.yml` → builds & pushes the Docker images;
- `snap-publish.yml` → publishes the snap.
5. **npm publish — PARKED, not part of the release.** `releaseEtherpad.yaml` publishes the core as `ep_etherpad` to npm, but that package is **not load-bearing**: it has 0 dependents, nothing depends on it (plugins import `ep_etherpad-lite` from the *local* core install; plugin CI clones the repo), and Etherpad is run via clone/Docker/zip/snap — never `npm install`. The publish currently fails with `E404` because `ep_etherpad` has no OIDC trusted publisher configured on npmjs.com, which is why npm sits at 2.5.0 while 3.x shipped fine without it. The workflow is gated behind a `confirm: true` input so it can't run by accident. **Skip it for a normal release.** To revive it, the npm owner of `ep_etherpad` (`samtv12345`) configures a trusted publisher (npmjs.com → ep_etherpad → Settings → Trusted Publisher → repo `ether/etherpad`, workflow `releaseEtherpad.yml`); otherwise the workflow can be removed. Decision pending.
### Documentation
Two distinct things, both important:
**1. Per-PR doc updates — your responsibility in every behaviour-change PR.**
The `doc/` workspace holds the user/admin/API docs (Markdown + AsciiDoc). Whenever a PR changes API responses, CLI flags, settings keys, hooks, or error formats, update the relevant files in `doc/` **in the same PR** — don't defer it to release time. The HTTP API reference is `doc/api/http_api.{md,adoc}` (keep both in sync). Preview locally with `pnpm run makeDocs`.
**2. Release-time versioned docs publishing — automated, no manual step.**
During a release, `release.ts` runs `pnpm run makeDocs` (→ `bin/make_docs.ts`) to render `doc/` into `out/doc/`, then copies it into the sibling website repo at `../ether.github.com/public/doc/vX.Y.Z`, bumps that repo's version, and commits `X.Y.Z docs`; `push-after-release.sh` pushes it, publishing the versioned docs on etherpad.org. This requires `ether.github.com` checked out as a **sibling directory** — the **Release etherpad** workflow checks it out automatically. If you ever run `release.ts` by hand, clone it first: `cd .. && git clone git@github.com:ether/ether.github.com.git`.
## Monorepo Structure
This project uses pnpm workspaces. The workspaces are:

View file

@ -1,353 +1,3 @@
# 3.3.2
3.3.2 is a bug-fix and dependency-hardening follow-up to 3.3.1. It rounds out the pad-deletion UX rework (suppressing the recovery token for durable identities, keeping the token-less Delete button reachable, and closing a read-only deletion hole), restores the saved-revision markers that went missing from in-pad history mode in 3.3.x, and adds env-var overrides so air-gapped installs can switch off Etherpad's outbound calls without editing the image. It also fixes the `migrateDB` / `importSqlFile` / `migrateDirtyDBtoRealDB` CLI scripts against the promise-based ueberdb2 API, rejects unreachable `.`/`..` pad ids, and clears a batch of dependency security advisories (including CVE-2026-54285). On the CI side it unblocks the installer smoke test (which had been hanging the full 6-hour job ceiling since 3.2.0) and pins ueberdb2 past a startup-exit regression in the packaged boot.
### Security
- **Force `@opentelemetry/core` ≥ 2.8.0 (GHSA-8988-4f7v-96qf / CVE-2026-54285, #7975).** The transitive dep (pulled in via `@elastic/elasticsearch``@elastic/transport`) had a `W3CBaggagePropagator.extract()` that did not enforce W3C size limits on inbound baggage headers, allowing unbounded memory allocation. Pinned via a `pnpm-workspace.yaml` override; satisfies the existing `2.x` range with no parent bump.
- **Resolve open Dependabot security alerts (#7967).** Refreshes stale override floors and adds new ones via `pnpm-workspace` overrides: `form-data` ≥ 4.0.6, `ws` ≥ 8.21.0, `esbuild` ≥ 0.28.1, `basic-ftp` ≥ 5.3.1 (capped `<6.0.0` to avoid a surprise major on the plugin-install path), `tar` ≥ 7.5.16, `js-yaml` ≥ 4.2.0, `qs` ≥ 6.15.2, `ip-address` ≥ 10.1.1, and `@babel/core` ≥ 7.29.6.
- **Reject read-only deletion via token-less paths (part of #7959 / #7960).** Under `allowPadDeletionByAllUsers` a read-only viewer was granted `canDeletePad=true`, and the server's `flagOk`/`creatorOk` branches never checked `session.readonly` — so a read-only link holder could delete a pad without a token. Read-only sessions are now excluded from both the client var and the server's token-less authorization paths; a valid recovery token stays sufficient regardless of session mode.
### Notable enhancements
- **Pad deletion — suppress the recovery token for durable identities and relabel the action (#7926 / #7930).** Building on the `allowPadDeletionByAllUsers` suppression, a creator's deletion token is now also withheld when they have a *durable* identity — authenticated (`req.session.user` with a username) **and** the deployment pins that identity to a stable `authorID` via a `getAuthorId` hook — since only then does the creator survive a cookie clear or a different device, making the token redundant. This tightens the previous "require authentication ⇒ always suppress" rule: without `getAuthorId` the authorID still comes from the per-browser cookie, so an authenticated user on a second device is *not* the creator and keeps getting a token. A new `canDeleteWithoutToken` client var hides the whole recovery-token disclosure (label, field, submit) when no token is needed, and the recovery form now renders for all sessions (hidden by default) so an authenticated creator without a durable mapping still has UI to enter their token. `API.createPad` returns a `null` `deletionToken` under `allowPadDeletionByAllUsers`, matching the socket/UI path.
- **Offline/air-gapped installs — env-var overrides for the update check, plugin catalog, and updater (#7917, addresses #7911).** Firewalled deployments could not disable Etherpad's outbound calls without editing `settings.json` inside the image. The relevant keys are now wired through the `${ENV:default}` substitution in `settings.json.docker` and `settings.json.template`: `PRIVACY_UPDATE_CHECK`, `PRIVACY_PLUGIN_CATALOG`, `UPDATES_TIER` (`off` = no calls), `UPDATE_SERVER`, plus the docker-only `UPDATES_SOURCE` / `UPDATES_CHANNEL` / `UPDATES_CHECK_INTERVAL_HOURS` / `UPDATES_GITHUB_REPO` / `UPDATES_REQUIRE_ADMIN_FOR_STATUS`. A new "Updates & privacy" section in `doc/docker.md` documents the set; backend tests parse the shipped configs and fail if the `${ENV}` placeholders are dropped. Config, docs, and tests only — no runtime code change.
### Notable fixes
- **Pad — keep the token-less Delete button reachable without pad-wide settings (#7959 / #7960).** The token-less `#delete-pad` button was nested inside the `enablePadWideSettings`-gated section, so disabling pad-wide settings removed the only no-token deletion path — and combined with #7926 hiding the token disclosure when no token is needed, a user allowed to delete could be left with no deletion UI at all. The button is now always rendered (hidden by default) and driven by a `canDeletePad` client var (creator or `allowPadDeletionByAllUsers`, excluding read-only sessions), so the plain button and the recovery-token disclosure are mutually coherent and neither depends on pad-wide settings.
- **History mode — restore the saved-revision markers (#7946 / #7948).** When #7659 moved the timeslider into the pad as an embedded iframe, the user-facing control became the outer `#history-slider-input`, but the saved-revision stars were still drawn into the now-hidden iframe `#ui-slider-bar`, so "Save Revision" appeared to do nothing in in-pad history mode (a 3.3.x regression). `pad_mode.ts` now bridges the embedded slider's saved revisions onto the outer slider as percentage-positioned, aria-hidden star markers (with click-to-seek for mouse users), and the server's `SAVE_REVISION` handler broadcasts `NEW_SAVEDREV` to the pad room so a revision saved by a collaborator appears live on an already-open history slider. A single revision saved at rev 0 now renders too. Adds Playwright coverage for both the single-client and two-client live paths.
- **Import dialog — correct the outdated "no converter" help message (#7988 / #7989).** The notice claimed only plain text and HTML could be imported and linked to the legacy AbiWord wiki, prompting LibreOffice installs for formats that already work natively. Etherpad imports `.txt`, `.html`, `.docx` (via mammoth) and `.etherpad` without LibreOffice; only `.pdf`/`.odt`/`.doc`/`.rtf` still need it. The message now says so and points at the documentation site.
- **PadManager — reject unreachable `.` and `..` pad ids (#7962).** `isValidPadId` accepted ids consisting only of URL dot-segments, but per the WHATWG URL standard a browser normalises `/p/.` to `/p/` and `/p/..` to `/`, so such a pad could be created in the database yet never opened or exported. These ids are now rejected, and the admin `deletePad` handler falls back to a raw key purge when `getPad()` throws so any legacy `.`/`..` pad can still be removed.
### Internal / contributor-facing
- **CLI — fix the database migration/import scripts against the ueberdb2 promise API (#7982 / #7983).** `migrateDB.ts` opened source and target databases, copied all keys, then resolved without closing either — so under ueberdb2 6.1.x the keep-alive timer kept the process hanging after "Done syncing dbs", and buffered target writes were only guaranteed flushed on `close()`. It now closes both databases (flushing writes, clearing the timer) on success and error paths and exits with an explicit status. `importSqlFile.ts` and `migrateDirtyDBtoRealDB.ts` were ported off the pre-v6 callback API to `await db.init()` / `db.set(k, v)` / `db.close()`, removing two `@ts-ignore`s that hid broken calls and fixing an undefined `length` in a progress log; `tsc --noEmit` on the bin package is now clean.
- **CI — stop the installer smoke test hanging the 6-hour job ceiling (#7981).** The "Installer test" had hung on every ubuntu/macOS run since 3.2.0: `pnpm run prod` is a nested launcher, so `kill "$PID"; wait "$PID"` only signalled the outer pnpm and blocked forever if the node server didn't exit on SIGTERM. Teardown now runs the launcher in its own process group, kills the whole group (SIGTERM then SIGKILL), drops the blocking `wait`, and adds an 8-minute `timeout-minutes` backstop to both smoke steps.
- **CI — run the Debian-package smoke test on PRs (#7969).** The packaged-boot smoke test previously ran only on push to `develop` — i.e. after merge — which is why the ueberdb2 startup-exit regression turned `develop` red instead of being blocked at PR time. A `pull_request` trigger (scoped to production-footprint paths) now runs the build+smoke job on PRs; the release/apt-publish jobs stay tag-guarded.
- **Release — park the non-functional `ep_etherpad` npm publish (#7922).** The `releaseEtherpad` workflow republished `./src` as `ep_etherpad`, a package with zero dependents that nothing in the repo or any deployment path consumes, and it had been failing with E404 (no OIDC trusted publisher configured). The job is now gated behind an explicit `confirm: true` dispatch input so a stray run fails fast with a clear message, with the status documented in the workflow header and `AGENTS.MD`.
- **Tests — port the orphaned legacy timeslider specs to Playwright (#7949).** The `src/tests/frontend/specs/` mocha suite is run by no CI workflow, so its timeslider coverage was dead — which is how the #7946 history-mode regression reached a release. The still-meaningful cases (revision labels, export links, deep-link entry) were ported to `frontend-new` Playwright specs re-targeted at the real in-pad UI, and the three now-ported legacy specs were deleted.
### Dependencies
- `ueberdb2` pinned to `6.1.13`. 6.1.10 rewrote the cache/buffer layer to lazily arm an `.unref()`'d flush timer only when there are dirty keys, so on a fresh empty dirty DB nothing anchored Node's event loop and the packaged (.deb/systemd) boot could exit cleanly (code 0) before `server.listen()` bound the port — failing the Debian-package health check. The dep was pinned back to the last green release (6.1.9, #7969) and then rolled forward to the now-fixed `6.1.13` (#7979), pinned exactly rather than with a caret.
- `nodemailer` 8.x → 9.0.1 (#7965 / #7950 / #7976), `mongodb` 7.1.1 → 7.3.0 (#7941), `pg` 8.21.0 → 8.22.0 (#7985), `undici` → 8.5.0 (#7980 etc.), `oidc-provider` 9.8.4 → 9.8.5 (#7973), `pdfkit` 0.19.0 → 0.19.1 (#7945), `semver` 7.8.3 → 7.8.4 (#7943), and `@radix-ui/react-switch` 1.3.0 → 1.3.1 (#7974).
- Dev/build dependency group updates (#7964, #7970, #7978, #7987, #7944, #7951, #7952, and others), including `@types/node` 25 → 26, `esbuild` 0.28.0 → 0.28.1, `eslint` 10.4.1 → 10.5.0, `@playwright/test` 1.60 → 1.61, `vitest` 4.1.8 → 4.1.9, and `actions/checkout` 6 → 7 (#7977).
# 3.3.1
3.3.1 is a small bug-fix and hardening follow-up to 3.3.0. It closes a stored-XSS vector in the numbered-list `start` attribute, hardens the database layer so a dropped connection to PostgreSQL / Redis / RethinkDB no longer crashes the process (via ueberdb2 6.1.9), and fixes a handful of pad and admin regressions — the iOS dark-mode status bar, the settings language dropdown, the pad-deletion modal under `allowPadDeletionByAllUsers`, and a single unreadable pad blanking the admin Manage-pads list.
### Security
- **Pad editor — escape and integer-coerce the numbered-list `start` attribute (GHSA-f7h5-v9hm-548j, #7937).** A crafted `<ol start>` value flowed unescaped into `domline.ts`, a distinct client-side sink from the export-path fix in 3.3.0's #7905. The value is now integer-coerced and HTML-escaped before it reaches the DOM. A jsdom regression test covers the sink.
### Notable fixes
- **Skin — paint the root canvas so iOS dark mode has no white status bar (#7606 / #7931).** iOS Safari paints the top safe area from the `html` root background, which `theme-color` (an Android address-bar hint) does not affect, so dark-mode pads showed a white status-bar strip on iOS. Colibris now sets the root background and `color-scheme` so the safe area matches the editor.
- **Settings — show the detected language in the dropdown (#7925 / #7928).** The settings language `<select>` did not reflect the language Etherpad had actually auto-detected; it now shows the active selection.
- **Pad — don't issue a deletion token (or show its modal) when `allowPadDeletionByAllUsers` is on (#7929).** With pad deletion open to all users the client still minted a deletion token and surfaced the confirm modal; both are now suppressed in that configuration.
- **Admin — one unreadable pad no longer empties the Manage-pads list (#7935 / #7938).** A single pad that failed to read could throw out of the list-hydration path and blank the entire admin Manage-pads view; the read is now guarded per-pad so the rest of the list still renders.
### Internal / contributor-facing
- **CI — downstream client compatibility gate (#7923 / #7924 / #7927).** A new gate smoke-tests the published `etherpad-pad`, `etherpad-cli`, and `etherpad-desktop` clients against the server build (Phase 1 + Phase 2), with robust per-client error handling in `run-clients.sh` so one client's failure is reported rather than masking the others.
- **CI — verify Etherpad boots offline (#7936).** Adds a test step that confirms a built Etherpad starts with no network access.
### Dependencies
- `ueberdb2` 6.1.8 → 6.1.9 — PostgreSQL pool errors are now handled and TCP keep-alive is enabled (fixes #7878), and the Redis and RethinkDB drivers attach connection-error handlers so a dropped database connection no longer crashes the Etherpad process.
- `semver` 7.8.2 → 7.8.3 (#7933), `rate-limiter-flexible` 11.1.1 → 11.2.0 (#7934), plus a dev-dependencies group update (#7932).
# 3.3.0
3.3 is primarily a security-hardening release. A defence-in-depth pass tightens the HTTP API entry points, switches random-id generation to a CSPRNG, escapes exported `data-*` attributes, and flips the shipped Docker deployment defaults so a fresh install no longer boots with implicit credentials or a trusting proxy. Alongside that, the `ep_*` pad-options passthrough that shipped opt-in in 3.0.0 is now on by default, the in-pad timeslider learns to honour the editor's view settings (authorship colours, font family, line numbers), and a long tail of pad-editor layout, RTL, and URL-encoding fixes lands. The release also carries the root-cause fix for the long-standing Windows backend-test "silent ELIFECYCLE" flake.
### Notable enhancements
- **Plugin pad options on by default — `settings.enablePluginPadOptions` now defaults to `true` (#7841).** The flag that gates the `ep_*` passthrough on pad options (shipped opt-in in 3.0.0, #7698) is flipped to default-on, so plugins such as `ep_plugin_helpers`' `padToggle` / `padSelect` ride the existing broadcast/persist rail out of the box. This closes `ep_comments_page#422` — stock 3.x deployments `console.warn`ed on every pad load because the helper detected `enablePluginPadOptions === false`. The `settings.json.template` env-var default is flipped to match, so Docker/supervisor configs without an explicit value get the new behaviour. Existing deployments with an explicit `"enablePluginPadOptions": false` keep that value — no migration needed — and the protocol shape is unchanged for older clients.
- **Timeslider — honour the editor's view settings (#7899).** The in-pad timeslider now respects `showAuthorshipColors`, `padFontFamily`, and line-numbers, bridged from the pad-settings checkboxes into the embedded timeslider iframe so the two views agree. `nice-select.ts` dispatches a native `change` event after the jQuery trigger so the `addEventListener`-based bridge in `pad_mode.ts` fires (jQuery 3.7.1's `trigger()` does not dispatch native DOM events), and the font-family reset is fixed for jQuery 3 (which ignores a `null` css value). The five ad-hoc listener stores in `pad_mode.ts` are consolidated into one `bindOuter()` path and the three view-setting bridges into a single data-driven `bridgeView()` (refactor only).
- **Admin settings — explain env-var substitution and surface auth errors (#7819 / #7826).** Three env-var-only UX improvements driven by #7819 (a Docker operator saved an `ep_oauth` block in the Raw view and reported it "disappeared", not realising `settings.json` on disk is a *template*, not the effective config): a banner above the editor explaining the template/substitution model (rendered only when the loaded file contains a `${VAR}` placeholder); a read-only **Effective** tab exposing the redacted runtime settings the backend already emitted as `resolved` (also gated on `${VAR}`); and an `admin_auth_error` event so a misrouted Traefik+SSO session that isn't admin gets a clear toast instead of a silent "save did nothing". A reconnect-loop guard suppresses the SPA's auto-reconnect once an auth error has been received. No behaviour change for installs without `${VAR}` placeholders.
### Security hardening
A defence-in-depth pass across the API, token, export, and deployment surfaces:
- **HTTP API request handling, random IDs, and plugin loading (#7906).** `pad_utils.randomString` now generates random IDs via `crypto.getRandomValues` (CSPRNG) instead of `Math.random`. `OAuth2Provider` compares passwords with `crypto.timingSafeEqual` on the raw UTF-8 bytes (resolving the CodeQL "insufficient computational effort" alert) behind a uniform failure delay, and looks users up via own-property access only. `API.appendChatMessage` throws `padID does not exist` rather than creating the pad, consistent with the other content API methods. The `/api/2` REST router forwards only the `authorization` header (not the full request header set) and falls back to it whenever the field is falsy, matching the `openapi.ts` handler so both routers authenticate identically. `LinkInstaller` validates plugin dependency names before building filesystem paths from them, and the admin file server returns a generic error while logging details server-side.
- **Escape exported `data-*` attributes; warn on default/placeholder credentials (#7905).** `ExportHtml` now escapes the name and value of attributes emitted by the `exportHtmlAdditionalTagsWithData` hook, consistent with the URL/text escaping already applied to exported HTML. `Settings` logs a warning (error level under `NODE_ENV=production`) when an account uses a default/placeholder password from the shipped config, and the check is extended to cover `sso.clients[].client_secret` so enabling SSO without setting `ADMIN_SECRET` / `USER_SECRET` is flagged the same way.
- **Docker deployment defaults — require explicit credentials, default `TRUST_PROXY` off (#7907).** The shipped `docker-compose` now requires `ADMIN_PASSWORD` and the database password to be provided explicitly (no implicit fallback) and defaults `TRUST_PROXY` to `false`. Operators relying on the previous implicit defaults must now set these values explicitly.
### Notable fixes
- **History mode — lay the timeslider iframe in the editor's flex slot (#7903).** In-pad history mode positioned `#history-frame-mount` as an `inset:0` absolute overlay over `#editorcontainerbox`, which took the iframe out of flow and hid any in-flow side panel (e.g. `ep_webrtc`'s `#rtcbox` video column) beneath it — so history mode and live mode disagreed. The iframe now occupies the same in-flow flex slot the live editor uses, and a latent specificity bug (the `body.history-mode #editorcontainer { display: none }` hide rule was outranked by the two-id layout rule, so the live editor was only ever painted over) is fixed by giving the hide rule matching specificity. Adds a `padmode.spec.ts` regression test.
- **Pad editor — restore URL wrapping (#7894 / #7896).** Long URLs in the pad editor overflowed instead of wrapping because the global `a { white-space: nowrap }` rule overrode the wrapping properties on `#innerdocbody`. Explicit `white-space` / `word-wrap` / `overflow-wrap` on `#innerdocbody a` restores wrapping inside the editor while preserving no-wrap for links elsewhere in the UI.
- **RTL content option no longer flips the whole page (#7900 / #7901).** The per-pad RTL content option (`rtlIsTrue`) wrote the direction to the top-level `document.documentElement`, flipping the entire page — toolbar and chrome included. The content direction is now applied to the inner editor document (`targetDoc.documentElement`); page direction stays owned by the UI language (`l10n.ts`). Adds a frontend test asserting the inner editor flips while the top-level `<html>` dir is unchanged.
- **Pad-wide view settings apply to the creator's own view (#7900 / #7902).** Because a creator is never "enforced upon themselves", a stale personal view-override cookie (e.g. `rtlIsTrue=false` from an earlier toggle) silently masked the pad-wide value they later set, so the control appeared to do nothing on their own screen. Changing a pad-wide view option now syncs the creator's personal pref to the chosen value; the precedence model is unchanged (the creator can still override afterwards via "My view").
- **URL view-option params lost to a `padeditor.init` race (#7840 / #7843).** `?showLineNumbers=false` and `?useMonospaceFont=true` were silently clobbered shortly after load — the same race #7464 fixed for `?rtl=false`, but the neighbouring `showLineNumbers` / `noColors` / `useMonospaceFontGlobal` blocks were left at the synchronous-tail site. The fix is generalised to all three (moved into `postAceInit`). Mostly observable in cross-context iframe embeds that start with no `prefs` cookie. Adds `url_view_options.spec.ts`.
- **Default welcome text attributed to the system author (#7885 / #7887).** Auto-generated default pad content (`settings.defaultPadText` / `padDefaultContent` hook) carried the creating user's `author` attribute and rendered in their authorship colour, even though they never wrote it. The welcome text's `author` *attribute* is now `Pad.SYSTEM_AUTHOR_ID`, while revision 0's `meta.author` stays the real creator so ownership (pad-wide settings gate, deletion token) is preserved. Explicitly provided text (e.g. HTTP API `createPad` with text + author) keeps the real author.
- **URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895).** Pad names are `encodeURIComponent`-d in the admin `PadPage` Open href and the colibris recent-pads href, and `decodeURIComponent`-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (`%2F``%252F`). The admin Open `window.open` gains `noopener,noreferrer`.
- **OIDC — fix broken `OIDCAdapter` flows (#7837).** Repairs the adapter flows and widens the storage type to include `string` for the `userCode` index; adds regression tests.
- **Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836).** Adds the `index.code` key referenced by `index.html` but never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin `@radix-ui/react-dialog` `Dialog.Content` a `Dialog.Title` and `Dialog.Description` (visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if any `data-l10n-id` in `src/templates/*.html` is missing from `en.json`.
- **Offline/air-gapped Docker boot — stop pnpm self-provisioning a pinned version (issue #7911).** The official image installs pnpm directly (corepack was dropped for Node 25+). Because the image's pnpm intentionally lags the `packageManager` pin in `package.json` (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), pnpm treated every call — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download the pinned build. Behind a firewall that download failed (`Failed to get pnpm version: … Command exited with code 1`), breaking startup. The Dockerfile now sets `pnpm_config_pm_on_fail=ignore`, and the startup probe plus the updater's pnpm-on-PATH checks run with the same flag, so pnpm uses the installed version instead of reaching for the network (without changing which pnpm runs the build-time install). A backend spec fails CI if that guard is dropped while a version gap exists.
- **Firefox authorship colours — tag early keystrokes with the right author (#7910).** The inner editor's `thisAuthor` starts empty and is only populated when collab_client's queued `setProperty('userAuthor', userId)` reaches the iframe (applied asynchronously via `pendingInit`). Under Firefox timing the first keystrokes could beat it, so freshly typed text — and early line-attribute changes (lists, headings, alignment) — were tagged `author=''`, which canonicalises to an unattributed insert that the server's pad-corruption guard rejects, dropping the whole change and losing authorship (the intermittent `clear_authorship_color` flake, where undo couldn't restore the author colour). A `getLocalAuthor()` helper now falls back to `clientVars.userId` (the same id, available synchronously) whenever `thisAuthor` is still empty, applied at the text-insert sites and to seed `documentAttributeManager.author`; the intentional clear-authorship path and the server-side guard are unchanged.
- **Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606).** Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads `theme-color` at parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits a `prefers-color-scheme`-scoped `theme-color` pair so the address bar is correct at first paint, plus a small blocking `<head>` script that applies the dark skin classes before the stylesheet paints. Both are gated on `enableDarkMode` (default on) and the colibris skin; `pad.ts` still runs on init to wire up the `#options-darkmode` toggle (which now updates every `theme-color` meta) and theme the editor iframes. Applies to the pad and timeslider views.
### Internal / contributor-facing
- **Root-caused and fixed the Windows backend-test "silent ELIFECYCLE" flake (#7866).** The ~22% Windows flake — rotating across random spec files, no mocha summary, no JS trace — was diagnosed from a full-memory dump as two distinct causes. (1) A timing-fragile test abandoned by mocha keeps running and later throws an *orphan* unhandled rejection; `server.ts`'s process-global `uncaughtException`/`unhandledRejection` handlers (correct for a real Etherpad process) escalated that into a clean `process.exit`. They are now gated behind `require.main === module`, and the backend-test bootstraps (`common.ts`, `diagnostics.ts`) log orphan rejections instead of rethrowing. (2) A stack-buffer overrun in Node 24.x's bundled libuv Windows TCP-connect path (`uv__tcp_connect`) corrupts memory under the suite's localhost-connection churn; CI pins the Windows backend job to Node **24.16.0** (libuv 1.52.1, the bisected fix), referencing upstream `nodejs/node#63620`. Linux stays on Node 24 LTS.
- **Removed the now-unneeded ELIFECYCLE diagnostic scaffolding (#7846 / #7838 / #7842 / #7868).** The OS-level sidecar watcher, the diagnostics heartbeat/running-test pointer, and the mid-test snapshot — added to chase the flake above — are removed now that the cause is known.
- **Docs — document the Docker `settings.json` writable-layer and env-var-vs-file semantics (#7819 / #7827).** Two operator-facing gaps surfaced by #7819: that the on-disk `settings.json` is a template (env substitution happens in memory at load time), and that the default compose puts `settings.json` in the container's writable layer with no host mount, so admin edits are lost on `down`/`pull`/watchtower but survive a plain `restart`. Adds prose + a recreate-vs-restart table to `doc/docker.md` and a commented-out opt-in bind mount to the compose files.
- **Docs refresh for 3.2.0 (#7888)**, **dropped three redundant top-level files (#7839)**, **dropped a fragile viewport assertion in the enter test (#7845)**, and a backend-test fix-up.
### Dependencies
- Two major bumps: `redis` 5.12.1 → 6.0.0 (#7869) and `ejs` 5.0.2 → 6.0.1 (#7860).
- `ueberdb2` 6.1.2 → 6.1.8, `mssql` 12.5.3 → 12.5.5, `nodemailer` 8.0.7 → 8.0.10, `mysql2` 3.22.3 → 3.22.5 (#7915), `undici` 8.3.0 → 8.4.1 (#7914), `pdfkit` 0.18.0 → 0.19.0 (#7916), `oidc-provider` 9.8.3 → 9.8.4, `@elastic/elasticsearch` 9.4.1 → 9.4.2, `lru-cache` 11.5.0 → 11.5.1, `rate-limiter-flexible` 11.1.0 → 11.1.1, `semver` 7.8.1 → 7.8.2, `js-cookie` 3.0.7 → 3.0.8, `tsx` 4.22.3 → 4.22.4, `@radix-ui/react-switch` 1.2.6 → 1.3.0 (#7913), `@tanstack/react-query` 5.100.11 → 5.101.0 (+ devtools), plus `i18next`, `react-router-dom`, and several dev-dependency group bumps (#7912).
### Localisation
- Multiple updates from translatewiki.net.
# 3.2.0
3.2 adds first-class reverse-proxy / ingress support — `X-Forwarded-Prefix` and `X-Ingress-Path` are now honoured under `trustProxy`, so Etherpad can live under a subpath (Traefik, Nginx, Kubernetes Ingress) without breaking the PWA manifest, social-meta URLs, or any of the bootstrap asset links. The admin settings page learns to show *resolved* runtime values next to `${VAR:default}` placeholders, the v3.1.0 admin pad-list filter chips now apply server-side (so "show empty pads" no longer returns 012 of hundreds), and the v3.1.0 redesigned outdated-version gritter actually fires in production now (the session-based author lookup it shipped with always returned null for pad visitors).
### Notable enhancements
- **HTTP — accept `X-Forwarded-Prefix` and `X-Ingress-Path` under `trustProxy` (#7802 / #7806).** With `trustProxy: true`, Etherpad now honours `X-Forwarded-Prefix` (de-facto Traefik / Spring) and `X-Ingress-Path` (Kubernetes Ingress) in addition to the prefix it already inferred from the request path. The shared `sanitizeProxyPath` helper added in 3.1.0 (defence-in-depth: `[A-Za-z0-9_./-]` only, `//+` collapsed, `..` traversal rejected) is extended to the new headers and applied consistently across `/manifest.json`, `socialMeta` `og:url` / `og:image`, and the `index.html` / `pad.html` / `timeslider.html` / `export_html.html` templates (manifest links, jslicense links, reconnect URLs). A pre-existing `..` segment-count miscalculation in `pad.html` / `timeslider.html` that broke the manifest link when served from a deep subpath is also fixed in passing. New end-to-end suite covers the prefix-applied / prefix-ignored matrix under `trustProxy=true|false` for both header names. `settings.json.template` documents the new headers alongside the existing `trustProxy` notes.
- **Admin settings — resolved runtime values surface on env-pill chips (#7803 / #7807).** The `/admin/settings` socket payload now carries a new `resolved` field alongside the existing raw-file `results` blob, carrying the actual in-memory settings module run through a new redactor (`AdminSettingsRedact`) that replaces known-sensitive paths (`users.*.password`, `dbSettings.password`, `sso.clients[*].client_secret`, `sessionKey`, …) with `[REDACTED]`. The admin SPA's `EnvPill` renders a `→ active value` chip when the path is resolved, or `→ ••••••` with a redacted tooltip when the server returned the sentinel — so `port: ${PORT:9001}` now shows `→ 9001` (or whatever the live value is) instead of silently falling back to the template default. Old admin SPAs that don't read `resolved` continue to work; the save round-trip is unchanged so `${VAR:default}` literals are still preserved verbatim on disk. The admin test script glob picks up `.test.tsx` alongside `.test.ts` so the new `EnvPill` and `resolveByPath` tests run under `tsx --test`.
### Notable fixes
- **Admin pads — filter chip now applies server-side, before pagination (#7798).** The 3.1.0 admin pad-list filter chips (`active` / `recent` / `empty` / `stale`) ran on the client *after* the 12-row page slice had already arrived. On a deployment with hundreds of pads, clicking "empty pads" on page 1 only matched the 012 empties that happened to land in the current page, with the pagination footer reporting nonsense totals (reported on a 3.1.0 deployment). The filter is now part of the `padLoad` socket query — pattern filter on names runs first (cheap), metadata hydration for the matching pad universe is gated on a non-`all` filter or a non-`padName` sort and runs under a 16-way concurrency cap (was unbounded `Promise.all`, which fanned out to thousands of in-flight `padManager.getPad()` reads on busy deployments), then the filter chip, then sort + slice. `total` reflects the filtered universe so the footer makes sense. Older admin clients that don't send `filter` keep working — the server defaults to `all`. The `if/else if` ladder that duplicated the hydrate-and-sort loop per `sortBy` is folded into one pipeline with a single comparator switch.
- **Pad outdated notice — author now resolved from token cookie, not session (Qodo #7804 / #7805).** The 3.1.0 redesigned outdated-version gritter never fired in production. `resolveRequestAuthor()` looked for an `authorID` on `req.session.user`, which Etherpad does not populate for pad visitors (express-session only carries the admin-login user), so `computeOutdated()` always returned EMPTY. The lookup now mirrors how the socket.io handshake resolves pad-visitor identity — read the HttpOnly `token` (or `<prefix>token`) cookie and call `authorManager.getAuthorId(token, user)` via a dynamic import (same circular-init guard pattern the file already uses for `PadManager`). The admin OpenAPI document gains a `description` note clarifying that `/api/version-status` is a public pad-side endpoint that lives in the admin doc only because it shares the same internal route registration.
- **Localisation — silence spurious "could not translate element content" warning (#7797).** `<select data-l10n-id="…">` with `<option>` element children — the pattern used by `ep_headings2`, `ep_align`, `ep_font_size`, `ep_font_family`, … — used to drop into the textContent branch of `html10n.translateNode`, hunt for a text-node child to overwrite, find none, and emit `Unexpected error: could not translate element content for key …` on every pad load. The `SELECT` / `INPUT` / `TEXTAREA` aria-label fallback already lived inside the same else-branch *after* the warning, so the accessible name landed correctly but the noisy console line still fired. Form-control elements now short-circuit into the aria-label path *before* the text-node hunt — aria-label is the only sensible localization target for these elements (a `<select>`'s text is its `<option>` labels, not its own name). Closes the console warning reported on Etherpad 3.1.0.
### Internal / contributor-facing
- **CI — swap archived `ep_readonly_guest` for `ep_guest` in the plugin matrix (#7795 / #7808).** `ep_readonly_guest` is archived (read-only on GitHub) and its `authenticate` hook unconditionally swapped `req.session.user` with a read-only guest, *even when the request carried an HTTP Authorization header*. That silently demoted admin login attempts and stalled the `anonymizeAuthorSocket` tests for 14 min/run on every with-plugins CI matrix. The pre-fix theory from 3.1.0 (#7796) blamed `ep_hash_auth.handleMessage`; that was a red herring — `handleMessage` only fires on the `/pad` namespace, never on `/settings`. `ep_guest` is the maintained successor (same authors, same purpose); 1.0.72 on npm already defers to basic auth / admin paths. Swapping the matrix unblocks the `anonymizeAuthorSocket` suite on Linux, Windows, and the upgrade-from-latest-release workflow. The runtime probe added in #7796 stays — it still catches any other authenticate-hook plugin that rejects the test's plain-text credentials (e.g. a future hashed-only plugin).
- **Tests — admin `saveSettings` round-trip + cross-restart persistence (#7819 / #7820 / #7821).** The admin `saveSettings` socket had zero direct backend coverage and the existing e2e "restart works" test only checked that the page renders after a restart, neither of which catches a deployment that resets `settings.json` on restart, nor the user-visible workflow that triggered #7819 (add a top-level plugin block via Raw, save, watch it disappear). Three new backend specs (`adminSettingsSave.ts`) verify byte-for-byte write, top-level-block augmentation round-tripping through the next `load`, and `/* */` comments surviving the write path. A new e2e spec mirrors the #7819 user workflow — open Raw, prepend an `ep_oauth`-shaped top-level block, save, `restartEtherpad()`, re-login, confirm the block is still in Raw and surfaces as its own Form-view section (`Ep oauth` from `humanize()`). A separate `docker.yml` job (`adminSettings_7819.ts`) authenticates via `POST /admin-auth/` (always-requireAdmin, regardless of `settings.requireAuthentication`), saves a hand-built minimal-but-viable settings document containing a marker block, `docker exec test grep`s for it, `docker restart`s the container, waits for the health probe, and re-greps. Both checks must pass.
- **Bug report template** now asks contributors whether the abstraction in their proposed fix matches the rest of the codebase, to head off premature-generalisation fixes earlier in review.
### Dependencies
- `ueberdb2` 6.0.3 → 6.1.2 (two patch releases of cleanup on top of the 6.1.0 `findKeysPaged` API that the 3.1.0 sessionstorage OOM fix relies on).
- `semver` 7.8.0 → 7.8.1, `lru-cache` 11.3.6 → 11.5.0, `@elastic/elasticsearch` 9.4.0 → 9.4.1, `pg` 8.20.0 → 8.21.0, `openapi-backend` 5.16.1 → 5.17.0, `tsx` 4.22.0 → 4.22.3, `@tanstack/react-query` 5.100.10 → 5.100.11 + `@tanstack/react-query-devtools`, `js-cookie` 3.0.6 → 3.0.7, plus two dev-dependency group bumps.
### Localisation
- Multiple updates from translatewiki.net.
# 3.1.0
3.1 ships the self-update programme's **Tier 4 — autonomous in a maintenance window** for real (the v3.0.0 notes documented the design; this is the release the code actually lands in), adds first-class SMTP delivery so update failures email the admin, and bundles a defence-in-depth pass across the HTTP/API entry points. Two new admin-facing escape hatches arrive: a preflight check that aborts an update *before* it mutates the working tree when the target tag's `engines.node` doesn't match the running runtime, and email notifications for every auto-rollback / preflight outcome (not just the terminal `rollback-failed` state).
### Notable enhancements
- **pad: Outdated-version notice redesigned (#7799).** The persistent "severely outdated" banner is replaced by a dismissable gritter notification (auto-fades after 8 seconds), shown only to a pad's first author and only when the server is at least one minor version behind the latest released version. Patch-only deltas no longer fire the notice. The `vulnerable-below` directive scraping, the `severe` and `vulnerable` enum values, and the `vulnerableBelow` state field have been removed.
- **API: `GET /api/version-status` updated (#7799).** Now accepts an optional `?padId=<id>` query parameter and returns `{outdated: "minor" | null, isFirstAuthor: boolean}`. The `severe` and `vulnerable` enum values are gone. Results are cached per `(padId, authorId)` for 60 seconds.
- **Self-update — Tier 4 (autonomous in a maintenance window).** Set `updates.tier: "autonomous"` together with `updates.maintenanceWindow: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}` to constrain autonomous updates to a nightly window. The scheduler snaps `scheduledFor` forward to the next window opening when grace would otherwise land outside the window, and defers the fire when the window has closed by the timer callback. Cross-midnight windows (`end < start`) are supported; DST transitions are absorbed by host wall-clock arithmetic. A missing or malformed window degrades the policy to Tier 3 with an explicit `policy.reason` of `maintenance-window-missing` / `maintenance-window-invalid`; an admin banner surfaces the misconfiguration so autonomous behaviour is not silently disabled. The admin update page shows a "Maintenance window" section with the parsed window summary, the next opening, and a "deferred until <iso>" subtitle on the scheduled panel when the timer has been snapped forward. Closes #7607 (#7753).
- **Updater — real SMTP via nodemailer (new top-level `mail.*` block).** Replaces the "(would send email)" stub. New settings: `mail.host`, `mail.port`, `mail.secure`, `mail.from`, `mail.auth.{user,pass}`. `mail.host=null` keeps the legacy log-only behaviour. The `nodemailer` dependency is lazy-imported on first send so installs that don't configure mail pay no runtime cost; the transport is cached on the full SMTP options tuple so a `reloadSettings()` change to host/port/credentials invalidates the cache. `settings.json.docker` reads `MAIL_HOST` / `MAIL_FROM` / `MAIL_PORT` / `MAIL_SECURE` from env. Send errors are logged warn and swallowed so a transient SMTP failure can never poison the updater state machine.
- **Updater — preflight against the target tag's `engines.node`.** Before mutating the working tree, `runPreflight` now runs `git show <tag>:package.json` and verifies `process.versions.node` satisfies the target's `engines.node`. A mismatch fails cleanly at `preflight-failed` with the detail `target requires Node >=X, running Y` — no drain, no restart, no rollback. The check runs *after* signature verification so we only trust signed `package.json`. New `PreflightReason: 'node-engine-mismatch'`.
- **Updater — email admin on rollback / preflight-failed (not just `rollback-failed`).** Before this release only the terminal `rollback-failed` state emailed. Auto-recovered failures (`rolled-back-install-failed`, `rolled-back-build-failed`, `rolled-back-health-check`, `rolled-back-crash-loop`) and `preflight-failed` now also fire one email per `<outcome>:<targetTag>` (dedupe key in `EmailSendLog.lastFailureKey`). A 3am autonomous update that rolls back because of, say, a Node engine bump now lands in the admin inbox at 3am instead of staying invisible until the next admin login. Boot-path catch-up covers cases where the failure preceded a clean process exit (timer-fired health-check rollback, crash-loop forced rollback, preflight-failed that didn't get to email before exit).
- **API — `listAuthorsOfPad` filters the synthetic system author.** `Pad.SYSTEM_AUTHOR_ID` (`a.etherpad-system`) is the placeholder Etherpad attributes to when the HTTP API receives a call without an `authorId` (setText, setHTML, appendText, server-side import). It was leaking through `listAuthorsOfPad`, making pads with only API-driven content appear to have one "real" author. The synthetic id is now filtered at that API surface only — `getAllAuthors()` and downstream callers (copy, anonymize, atext verification) still see it. Fixes #7785 / #7790 (#7793).
### Notable fixes
- **Export HTML — ordered-list counter no longer poisoned by a sibling unordered list.** When an ordered-list level was the only consumer of `olItemCounts`, closing *any* list at that depth (including a `<ul>` that happened to share the level) reset the counter to 0. A subsequent unrelated `<ol>` at the same depth then took the "counter exists but is 0" branch and emitted `<ol class="...">` without the `start=` attribute. The reset is now gated on `line.listTypeName === 'number'` so closing an unordered list never touches the ol bookkeeping. Fixes #7786 / #7787 (#7791).
- **Export — bad `:rev` returns a meaningful 500 body, not Express's HTML error page.** A non-numeric `:rev` (e.g. `/p/foo/test1/export/txt`) reached `checkValidRev` which throws `CustomError('rev is not a number', 'apierror')`; the message fell through `.catch(next)` and Express's default renderer returned an HTML 500 page. The route handler now catches the apierror and emits `err.message` as a deterministic `text/plain` 500. As a follow-up, `checkValidRev` runs *before* `res.attachment()` so an invalid rev no longer leaves a `Content-Disposition` header in place (browsers were offering to save the error message as a file), and unrelated export failures (conversion, fs, soffice) are surfaced as text/plain rather than the HTML stack page. Fixes #7788 (#7792).
- **Session cleanup no longer OOMs on huge sessionstorage tables.** Pre-2.7.3 `SessionStore._cleanup()` issued a single unbounded `findKeys('sessionstorage:*', null)` that materialised every key into one JS array; on a decade-old MariaDB install with millions of stale sessions the mysql2 driver retained the rows on the pool connection while the JS array dominated heap, OOMing the process within ~15 minutes of boot. Cleanup now pages the keyspace in 500-key batches via the new `findKeysPaged` API on ueberdb2 6.1.0 (DB-side ranged query on mysql/postgres, JS-side fallback elsewhere), yielding to the event loop between pages. A single run is capped at 10 minutes; the next scheduled run continues. The defensive cursor-stall guard now logs an error rather than silently aborting, and `DB.init()` fails fast if any required wrapper method is missing (a misconfigured ueberdb2 pin surfaces at boot instead of an hour later). Fixes #7830 (#7831).
### Security hardening
A bundle of defence-in-depth tightening picked up during an internal audit pass (#7784):
- **HTTP API — OAuth JWT path.** Verify the signature *before* reading any claim off the payload; require `admin: true` strictly (presence is no longer sufficient). The apikey comparison switches to `crypto.timingSafeEqual`.
- **Import/Export temp-file path tokens.** Derived from `crypto.randomBytes(16)` instead of `Math.random()`.
- **Token transfer.** Records now have a 5-minute TTL and are single-use (removed from the store before responding). The author token is no longer in the redemption response body — the `HttpOnly` cookie is the only delivery channel.
- **`x-proxy-path` header sanitiser (new `src/node/utils/sanitizeProxyPath.ts`).** Shared by `admin.ts` and `specialpages.ts`. Strips characters outside `[A-Za-z0-9_./-]`, collapses leading `//+` to a single `/`, rejects `..` traversal. `admin.ts` also emits `Vary: x-proxy-path` and `Cache-Control: private, no-store` so a poisoned response can never be reused for another origin.
- **`Pad.appendRevision` insert-op author invariant.** Centralises the "every insert op carries an `author` attribute" rule the socket handler already enforced, so non-wire callers (`setText`, `setHTML`, `restoreRevision`, plugin paths) get the same check. `Pad.init` and `setPadHTML` substitute `SYSTEM_AUTHOR_ID` when no author is supplied — same pattern `setText` / `spliceText` already used.
- **`setPadRaw` legacy-import rewrite.** Bulk-import bypasses `appendRevision`, so a hand-crafted `.etherpad` file could persist non-conforming records that any subsequent `setText` / `setHTML` would refuse to extend. A pre-pass now walks revs in order, sanitises each changeset's `+` ops against the cumulative pad pool (substituting `SYSTEM_AUTHOR_ID` where needed), and re-applies each changeset to a running atext so the head atext and key-rev `meta.atext` / `meta.pool` snapshots stay in lock-step. Conforming payloads round-trip unchanged.
### Internal / contributor-facing
- **Backend tests — `tests/backend/specs/{api,admin}/*` un-skipped.** The pnpm test script's glob (`tests/backend/specs/**.ts`) only matched depth-1 files. Every spec under `api/` (14 files) and `admin/` (2 files) has been silently skipped by CI. Switched to `--extension ts --recursive` so mocha walks the tree as documented. A new vitest regression check reads the pnpm script, hands mocha the same arguments under `--dry-run --list-files`, and asserts representative specs from both subdirectories appear in the discovered list (#7789).
- **CI — Windows `npx ENOENT` in the glob-discovery regression check.** `execFileSync('npx', ...)` doesn't pick up `npx.cmd` on Windows runners. Resolved by running `mocha`'s JS entry directly via `require.resolve` under the current node process. Path normalisation now goes through `path.relative` + `replace([\\/])` so mixed-separator / drive-letter casing on Windows mocha output still matches the POSIX-relative assertions (#7794).
- **CI — `anonymizeAuthorSocket` suite gated on admin-socket health when `ep_hash_auth` is installed.** Un-hiding the suite in #7789 surfaced a 14-minute stall on every with-plugins matrix run because `ep_hash_auth`'s `handleMessage` hook fires for every socket message regardless of namespace and reads from the deprecated `client` context (undefined for non-pad namespaces). Until the root cause lands (tracked in #7795), the suite skips itself when an application-level probe shows the admin `/settings` namespace isn't responding — keeps the no-plugin matrix covered and stops burning ~14 minutes per with-plugins run (#7796).
### Localisation
- Multiple updates from translatewiki.net.
# 3.0.0
3.0 is a feature-heavy release that closes out the self-update programme (Tiers 2 and 3 land alongside Tier 1 from 2.7.3), removes the last identified upstream telemetry vector, and ships a parsed JSONC settings editor, native DOCX export, in-place pad history scrubbing, and an admin UI for GDPR author erasure. It also marks the start of the broader Etherpad app ecosystem (see *Companion apps* below).
### Breaking changes
- **Minimum required Node.js version is now 24.** Node.js 22 is no longer supported. Node 25 was briefly the floor mid-cycle but was rolled back to **24 LTS (Krypton, supported through ~May 2028)** because Node 25 reached end-of-life on 2026-04-10 (see #7779 / #7781). The CI matrix targets Node 24 and 26. Node 24 still ships Corepack, so existing `bin/installer.sh` / `bin/installer.ps1` flows continue to work unchanged; the global `pnpm` install fallback added for the Node 25 detour is kept for forward-compatibility.
- **`pnpm` floor raised to `pnpm@11.1.2`.** `packageManager` is now pinned to `pnpm@11.1.2` and `engines.pnpm` requires `>=11.1.2`. The Dockerfile, snap, .deb and all GitHub workflows are aligned.
- **`swagger-ui-express` removed.** `/api-docs` now serves a vendored, telemetry-free copy of [Scalar](https://github.com/scalar/scalar) (see the privacy item below). The route, the OpenAPI document, and the rendered output are unchanged for downstream consumers, but anything that introspected `swagger-ui-express` internals will need updating.
- **Debian package depends on `nodejs (>= 24)`.** The signed apt repository at `etherpad.org/apt` is rebuilt against this floor; older Node packages are no longer acceptable as a dependency (#7754).
### Companion apps
This release coincides with the launch of two ecosystem projects, both maintained under the [`ether` org](https://github.com/ether) and able to talk to any 3.x Etherpad server over its existing HTTP / WebSocket API:
- **[`ether/etherpad-desktop`](https://github.com/ether/etherpad-desktop)** — a native desktop wrapper around Etherpad for macOS, Windows and Linux. Single-window editor experience, system-tray indicator, and an optional embedded server for fully offline pads.
- **[`ether/pad`](https://github.com/ether/pad)** — a portable cross-target client: an Android and iOS app for editing pads on the go, and a `nano`-style terminal client for headless / SSH workflows. Shares the same realtime client transport as the browser editor so changes propagate live across desktop, mobile, terminal and the web UI.
Both clients hit the **stable 3.x API surface**, so server operators don't need to enable anything extra to support them — the OpenAPI clean-up landed in this release (see *Notable enhancements*) is what makes the shared client code generators viable.
### Notable enhancements
- **Self-update subsystem — Tier 2 (manual click).**
- Admins on a git install can click "Apply update" at `/admin/update`. Etherpad runs a 60s session drain (with T-60 / T-30 / T-10 broadcasts to every pad), `git fetch / checkout / pnpm install --frozen-lockfile / pnpm run build:ui`, and exits with code 75 so a process supervisor restarts it on the new version. The next boot runs a 60s health check; if `/health` doesn't come up the previous SHA + lockfile are restored automatically.
- Crash-loop guard: if the new version reboots more than twice without the health check completing, RollbackHandler forces a rollback regardless of the timer.
- Terminal `rollback-failed` state surfaces a strong banner; the admin clicks Acknowledge once they've manually recovered to clear the lock and re-allow Tier 2 attempts.
- New settings under `updates.*`: `preApplyGraceMinutes`, `drainSeconds`, `rollbackHealthCheckSeconds`, `diskSpaceMinMB`, `requireSignature`, `trustedKeysPath`. Tag signature verification is opt-in (default `false`) — see `doc/admin/updates.md` for the keyring setup.
- **A process supervisor (systemd / pm2 / docker `--restart=unless-stopped`) is required to apply updates.** Without one, exit 75 leaves the instance down.
- **Self-update subsystem — Tier 3 (auto with grace window).**
- On a git install, set `updates.tier: "auto"` to have new releases applied automatically after `preApplyGraceMinutes`. During the grace window, `/admin/update` shows a live countdown plus Cancel and Apply now buttons. Schedules are persisted to `var/update-state.json`, so an Etherpad restart during the grace window rehydrates the timer instead of losing the schedule. A new release tag detected mid-grace re-arms the timer; if `adminEmail` is set, a one-shot `grace-start` notification fires per scheduled tag (issue #7607).
- The terminal `rollback-failed` state continues to disable auto/autonomous attempts globally until acknowledged; manual click stays available because an admin click *is* the intervention the terminal state requires.
- **Self-update subsystem — Tier 4 (autonomous in a maintenance window).**
- Set `updates.tier: "autonomous"` together with `updates.maintenanceWindow: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}` to constrain autonomous updates to a nightly window. The scheduler snaps `scheduledFor` forward to the next window opening when grace would otherwise land outside the window, and defers the fire when the window has closed by the timer callback. Cross-midnight windows (`end < start`) are supported; DST transitions are absorbed by the host's wall-clock arithmetic.
- A missing or malformed window degrades the policy to Tier 3 with an explicit `policy.reason` of `maintenance-window-missing` / `maintenance-window-invalid`; an admin banner surfaces the misconfiguration so autonomous behavior is not silently disabled. Closes #7607.
- **Privacy — drop swagger-ui telemetry, document phone-homes, add opt-outs.**
- Dropped `swagger-ui-express` because upstream injects a Scarf analytics pixel that cannot be disabled at install or runtime (see [swagger-api/swagger-ui#10573](https://github.com/swagger-api/swagger-ui/issues/10573)). `/api-docs` now serves a vendored copy of [Scalar](https://github.com/scalar/scalar) (MIT) configured with `withDefaultFonts: false` and `telemetry: false` so no outbound calls are made.
- New `privacy.updateCheck` (default `true`) — set to `false` to disable the hourly `UpdateCheck.ts` request to `${updateServer}/info.json`.
- New `privacy.pluginCatalog` (default `true`) — set to `false` to disable the admin plugins page fetch of `${updateServer}/plugins.json`. CLI install-by-name still works.
- New [`PRIVACY.md`](PRIVACY.md) at repo root documenting both outbound calls, what they send, and how to turn each off.
- `bin/plugins/stalePlugins.ts` now reads `settings.updateServer` (was hardcoded to `static.etherpad.org`) and honours the new flag.
- Closes #7524.
- **Parsed JSONC settings editor in `/admin`.** The settings page now parses `settings.json` as JSONC (with comments and trailing commas preserved), validates edits in-browser, and writes the file back without clobbering comment blocks (#7709, closes #7603, takes over #7666).
- **GDPR — admin UI for author erasure.** Builds on the 2.7.3 author-erasure API: admins can now find an author by id or name in `/admin` and run a confirmed erasure flow from the UI (#7667, follow-up to #7550).
- **Pad-wide settings on by default.** `padOptions`-style settings can now be edited from the in-pad cog without flipping a flag, and the modal title no longer misleads about scope (#7679). Plugin-namespaced `ep_*` keys also flow through `applyPadSettings` so plugins can register their own pad-wide options (#7698).
- **Scrub history in-place on the pad URL.** A long-edited pad can now have its history rewritten in place (e.g. for compliance or to drop accidentally-pasted secrets), without changing the pad URL or breaking deep-links (#7710, closes #7659).
- **`bin/compactStalePads` — staleness-gated bulk compaction.** Companion to the 2.7.3 `compactAllPads` CLI: targets only pads not edited in the last `--older-than N` days, so hot pads in active timeslider use are left alone. Same `--keep` / `--dry-run` shape as `compactAllPads` (#7708, issue #7642).
- **Native DOCX export (opt-in).** A `html-to-docx`-based exporter lands as an alternative to the LibreOffice path, so installs that don't want `soffice` on the host can still produce `.docx`. `soffice` is now documented as optional for `.docx` and `.pdf` (#7568 / #7707, issue #7538).
- **Editor / UI.**
- Settings popup is now scrollable on short viewports so the lower controls stay reachable on small laptops (#7703, issue #7696).
- Admin design pass cleans up the rework introduced in 2.7.3 (#7716).
- `theme-color` meta now follows the client-side dark-mode switch instead of locking to the boot-time value (#7690, issue #7606).
- `menu_right` stays visible on readonly pads by default; operators that prefer the slimmer chrome can still opt in via `showMenuRight` (#7783).
- Social meta: new `settings.socialMeta.description` override (#7691) plus a fix for numeric / boolean override values that were silently being dropped during coercion (#7692).
- **Admin / API surface.**
- The published OpenAPI spec is cleaned up for downstream codegens — duplicate operationIds removed, response schemas filled in, `nullable``oneOf null` migrated for OpenAPI 3.1 (#7714). The companion apps above consume this directly.
- Admin endpoints (`/admin/*` JSON APIs) are now documented in the OpenAPI spec (#7693 / #7705) and called from a typesafe TanStack Query client in the admin SPA (#7638 / #7695).
- "Requires newer Etherpad" message in the plugin browser when an `ep.json` declares an `engines.etherpad` higher than the running version, instead of failing with a generic install error (#7763 / #7771).
- **Security hardening.**
- Reject `USER_CHANGES` inserts that arrive without an author attribute, closing a server-side trust gap where unattributed changes could be applied to a pad (#7773).
- Integrator-issued `sessionID` cookies can now be marked `HttpOnly` via the new option, matching the 2.7.3 author-token hardening (#7045 / #7755).
- **Observability — Prometheus counters.** Three new counters surface scaling-relevant events (`pad_load_total`, `socket_connect_total`, `changeset_apply_total`) so operators can drive horizontal-scaling decisions off the existing `/metrics` endpoint without a custom exporter (#7756 / #7762).
- **Accessibility (continuation of the 2.7.2 / 2.7.3 pass).**
- Skip-to-content link plus hiding line-number gutters from screen readers (#7255 / #7758).
- Named `role="toolbar"` regions and `linemetricsdiv` hidden from assistive tech (#7255 / #7777).
- Localized `aria-label` on form controls (`<select>`, `<input>`, `<textarea>`) and on export-as links (#7697 / #7713).
- Removed `role="textbox"` / `aria-multiline` from `innerdocbody` where they no longer matched the editor's real semantics (#7778 / #7782).
### Notable fixes
- **Docker — pnpm at runtime.** Bypass `pnpm` at container start so the entrypoint no longer triggers a spurious `deps-status` reinstall on every restart (#7718 / #7727). The Corepack cache is now shared so the unprivileged `etherpad` user can resolve `pnpm` (#7689).
- **Debian — `plugin_packages` stays in-tree.** The `.deb` now keeps `plugin_packages/` under the install root so plugins installed at runtime can still resolve `ep_etherpad-lite` (#7750).
- **Admin — restore search and sort.** `SearchField` and the column-sort helpers used by the authors page were lost during the admin rework; they're restored (#7746).
- **Admin — German strings hardcoded in error paths.** A handful of leftover German strings from the rework are replaced with i18n keys (#7735 / #7736).
- **Settings — `username: false` / `malformed color: false` regression.** Legacy `settings.json` files that used `false` to disable a feature no longer surface as `'false'` username or `'malformed color: false'` errors (#7688, issue #7686).
### Internal / contributor-facing
- **Database driver — `ueberdb2` 5 → 6.** Major-version bump to `ueberdb2@^6.0.3` (#7734). Drivers are pinned through the lockfile; the schema-level changes are documented in the `ueberdb2` 6.0 release notes.
- **CI / tests.**
- Windows + Node 24 backend-test flake fixed; native crashes are now captured for diagnosis (#7748).
- `updater-integration` rmdir-retry to clear the long-standing Windows `EBUSY` flake (#7728).
- `lowerCasePadIds` spec closes its socket.io clients on teardown (#7722).
- Admin tests realigned to the typesafe API client + plugin row count fixes (#7712).
- Rate-limit test waits for Etherpad readiness before running, instead of racing the boot sequence (#7726).
- README link fixes and tidy-up (#7723 / #7724 / #7725).
- Several dependency-group bumps across the dev and runtime trees: `undici` 7.25 → 8.3, `semver` 7.7.4 → 7.8, `tsx` 4.21 → 4.22, `mssql` 12.5.2 → 12.5.3, `js-cookie` 3.0.5 → 3.0.6, `@tanstack/react-query` 5.100.9 → 5.100.10, `actions/dependency-review-action` 4 → 5, plus the usual Dependabot dev-group rollups.
### Localisation
- Multiple updates from translatewiki.net.
# 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.
- `bin/compactStalePads` (issue #7642) targets only pads not edited in the last `--older-than N` days, so hot pads in active timeslider use are left alone. Same `--keep` / `--dry-run` shape as `bin/compactAllPads`. Targeting is deliberately a CLI concern — the `compactPad` API surface stays unchanged.
- **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.1.2"`); 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
- Accessibility pass: corrected dialog semantics, improved focus management, added missing icon labels, and set the `html lang` attribute correctly.
- Chat: clicking the chat icon works again, disabled toggles render properly, and the username layout no longer overflows.
- `/export/etherpad` now honors the `:rev` URL segment, so revision-specific exports return the requested revision instead of the latest.
- Undo / redo now scrolls the viewport to follow the caret, so reverted edits stay in view.
- Page Down / Page Up now scrolls by viewport height instead of a fixed line count, matching standard editor behavior on tall and short windows alike.
- Editbar: caret is restored to the pad after changing a toolbar select, so typing continues in the document instead of falling through to the toolbar.
- Admin: i18n is restored on `/admin` so the admin UI is translated again.
# 2.7.1
### Notable enhancements and fixes
- fixed stop harcoding lang=en, letting the client auto detect locale
- Stop mutating the shared plugin registry during sanitization
- Preserve non-breaking space
# 2.7.0
### Breaking changes

View file

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

View file

@ -7,43 +7,19 @@
# docker build --build-arg BUILD_ENV=copy .
ARG BUILD_ENV=git
# NOTE: this intentionally lags the "packageManager" pin in package.json. pnpm
# 11.1.x enforces the minimum-release-age supply-chain policy during install,
# which the frozen-lockfile Docker build can't satisfy, so the image stays on
# 11.0.x. The version gap is made harmless by pnpm_config_pm_on_fail=ignore in
# the build stage below — see ether/etherpad#7911.
ARG PnpmVersion=11.0.6
ARG PnpmVersion=10.28.2
FROM node:24-alpine AS adminbuild
# Install pnpm directly via npm (rather than via corepack) so the same
# image recipe keeps working on Node 25+, where corepack has been
# dropped from the distribution. The node:24-alpine image also bundles
# yarn; remove it first to avoid leaving an unused binary on PATH.
# Drop bundled npm afterwards — its older transitives (picomatch,
# brace-expansion) carry CVEs we don't otherwise need.
RUN rm -f /usr/local/bin/yarn /usr/local/bin/yarnpkg && \
npm install -g pnpm@${PnpmVersion} && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
FROM node:lts-alpine AS adminbuild
RUN npm install -g pnpm@${PnpmVersion}
WORKDIR /opt/etherpad-lite
COPY . .
RUN pnpm install
RUN pnpm run build:ui
FROM node:24-alpine AS build
FROM node:lts-alpine AS build
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
# The image's pnpm intentionally lags the "packageManager" pin (see the ARG
# note above). pnpm would otherwise try to self-provision the pinned version on
# invocation — including the informational `pnpm --version` probe Etherpad runs
# at startup — which fails closed with no network and breaks air-gapped boots
# (ether/etherpad#7911). pm_on_fail=ignore makes pnpm use the installed version
# instead. Inherited by the development and production runtime stages, so it
# also covers the updater's pnpm-on-PATH check and ad-hoc `pnpm` in an exec
# shell. It does not change which pnpm runs the build-time install (still the
# installed 11.0.x), so the frozen-lockfile build is unaffected.
ENV pnpm_config_pm_on_fail=ignore
# Set these arguments when building the image from behind a proxy
ARG http_proxy=
ARG https_proxy=
@ -116,21 +92,15 @@ RUN groupadd --system ${EP_GID:+--gid "${EP_GID}" --non-unique} etherpad && \
ARG EP_DIR=/opt/etherpad-lite
RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}"
# Install pnpm directly via npm (rather than via corepack) so the same
# recipe stays valid on Node 25+, which dropped corepack. Then drop
# both npm and the pre-bundled yarn binary to keep the runtime image
# free of unused tooling and known-CVE transitives.
#
# the mkdir is needed for configuration of openjdk-11-jre-headless, see
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199
RUN \
mkdir -p /usr/share/man/man1 && \
rm -f /usr/local/bin/yarn /usr/local/bin/yarnpkg && \
npm install -g pnpm@${PnpmVersion} && \
rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
npm install pnpm@${PnpmVersion} -g && \
apk update && apk upgrade && \
apk add --no-cache \
ca-certificates \
curl \
git \
${INSTALL_SOFFICE:+libreoffice openjdk8-jre libreoffice-common} && \
rm -rf /var/cache/apk/*
@ -195,10 +165,7 @@ 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.
# 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
RUN printf 'packages:\n - src\n - bin\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
@ -224,18 +191,7 @@ COPY --chown=etherpad:etherpad ${SETTINGS} "${EP_DIR}"/settings.json
USER etherpad
HEALTHCHECK --interval=5s --timeout=3s \
CMD wget -qO- http://127.0.0.1:9001/health | grep -E "pass|ok|up" > /dev/null || exit 1
CMD curl --silent http://localhost:9001/health | grep -E "pass|ok|up" > /dev/null || exit 1
EXPOSE 9001
# Run node directly instead of via `pnpm run prod`. pnpm 11's
# `runDepsStatusCheck` fires before every `pnpm run …` and spuriously
# decides node_modules is out of sync on first start under the named-
# volume layout used by docker-compose (mounting src/plugin_packages).
# It then tries to `pnpm install --production`, which either prompts to
# wipe node_modules (tty: true) or aborts with
# ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY (no tty). Bypassing pnpm
# at runtime sidesteps the check; the image's node_modules was already
# verified during build. See ether/etherpad#7718.
# `exec` makes node PID 1 so it receives SIGTERM directly and shuts down
# cleanly.
CMD ["sh", "-c", "cd src && exec node --require tsx/cjs node/server.ts"]
CMD ["pnpm", "run", "prod"]

View file

@ -1,68 +0,0 @@
# Privacy in Etherpad
## What this document is
A complete, current list of every network call Etherpad's own code makes
to a third party, plus how to turn each one off. Plugins are out of
scope — audit any plugin you install.
## TL;DR
Etherpad ships with two outbound calls to `etherpad.org`. Both are
documented below. Both can be disabled with a single config value each.
No analytics, no usage pings, no third-party SDKs at runtime.
## Outbound calls
### 1. Version check
| | |
|---|---|
| URL | `https://static.etherpad.org/info.json` (override via `updateServer`) |
| Frequency | hourly while the server runs |
| Payload | GET only; `User-Agent: Etherpad/<version>` |
| Purpose | surface an "update available" notice in the admin panel |
| Disable | set `privacy.updateCheck: false` in `settings.json` |
| Source | `src/node/utils/UpdateCheck.ts` |
### 2. Plugin catalog
| | |
|---|---|
| URL | `https://static.etherpad.org/plugins.json` (override via `updateServer`) |
| Frequency | on admin-plugins page load (cached 10 min) |
| Payload | GET only; same `User-Agent` |
| Purpose | list installable `ep_*` plugins in the admin UI |
| Disable | set `privacy.pluginCatalog: false` in `settings.json` (manual install via CLI still works) |
| Source | `src/static/js/pluginfw/installer.ts` |
## What we removed
`swagger-ui-express` was dropped because the upstream npm package
injects a Scarf analytics pixel that cannot be disabled at install or
runtime (see [swagger-api/swagger-ui#10573](https://github.com/swagger-api/swagger-ui/issues/10573)).
`/api-docs` is now served by a vendored copy of [Scalar](https://github.com/scalar/scalar)
(MIT) with no outbound calls. The shell explicitly opts out of Scalar's
default font fetch (`withDefaultFonts: false`) and analytics
(`telemetry: false`), and pins a system-font stack via CSS.
`@scarf/scarf` is listed under `ignoredBuiltDependencies` in
`pnpm-workspace.yaml`, so its postinstall pixel is suppressed even if a
future transitive dep pulls Scarf in.
## What we will not add
- usage analytics or telemetry SDKs
- crash reporters that send data without explicit opt-in
- third-party CDN dependencies at runtime
- dependencies whose install or runtime phones home
## Plugins
Third-party plugins are out of this guarantee. Plugins run in your
Etherpad process with full access; audit any plugin you install.
## Reporting
Found an outbound call this doc doesn't list? Open an issue with the
label `privacy`.

View file

@ -10,7 +10,7 @@
Every keystroke is attributed to its author. Every revision is preserved. The timeslider lets you scrub through a document's entire history, character by character. Author colours make collaboration visible at a glance — not buried in a menu.
Etherpad runs on your server, under your governance. No telemetry. No upsells. AI is a plugin you install, pointed at the model you choose, running on infrastructure you control — not a feature decided for you in a boardroom you weren't in. See [PRIVACY.md](PRIVACY.md) for the two opt-out network calls Etherpad's own code makes and how to disable each.
Etherpad runs on your server, under your governance. No telemetry. No upsells. AI is a plugin you install, pointed at the model you choose, running on infrastructure you control — not a feature decided for you in a boardroom you weren't in.
The code is Apache 2.0. The data format is open. It [scales to thousands of simultaneous editors per pad](http://scale.etherpad.org/). Translated into 105 languages. Extended through hundreds of plugins. Used by Wikimedia, governments, public-sector institutions, and self-hosters worldwide since 2009.
@ -18,7 +18,7 @@ The code is Apache 2.0. The data format is open. It [scales to thousands of simu
## Try it out
[Try out a public Etherpad instance](https://scanner.etherpad.org)
[Try out a public Etherpad instance](https://github.com/ether/etherpad/wiki/Sites-That-Run-Etherpad#sites-that-run-etherpad)
## Project Status
@ -36,14 +36,16 @@ Etherpad has been doing the same thing — well — since 2009. No pivots, no ac
[![Simulated Load](https://github.com/ether/etherpad/actions/workflows/load-test.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad/actions/workflows/load-test.yml)
[![Rate Limit](https://github.com/ether/etherpad/actions/workflows/rate-limit.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad/actions/workflows/rate-limit.yml)
[![Docker file](https://github.com/ether/etherpad/actions/workflows/docker.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad/actions/workflows/docker.yml)
[![Frontend admin tests](https://github.com/ether/etherpad/actions/workflows/frontend-admin-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad/actions/workflows/frontend-admin-tests.yml)
[![Frontend tests](https://github.com/ether/etherpad/actions/workflows/frontend-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad/actions/workflows/frontend-tests.yml)
[![Frontend admin tests powered by Sauce Labs](https://github.com/ether/etherpad/actions/workflows/frontend-admin-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad/actions/workflows/frontend-admin-tests.yml)
[![Frontend tests powered by Sauce Labs](https://github.com/ether/etherpad/actions/workflows/frontend-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad/actions/workflows/frontend-tests.yml)
[![Sauce Test Status](https://saucelabs.com/buildstatus/etherpad.svg)](https://saucelabs.com/u/etherpad)
[![Windows Build](https://github.com/ether/etherpad/actions/workflows/windows.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad/actions/workflows/windows.yml)
### Engagement
[![Docker Pulls](https://img.shields.io/docker/pulls/etherpad/etherpad?color=%2344b492)](https://hub.docker.com/r/etherpad/etherpad)
[![Discord](https://img.shields.io/discord/741309013593030667?color=%2344b492)](https://discord.com/invite/daEjfhw)
[![Etherpad plugins](https://img.shields.io/endpoint?url=https%3A%2F%2Fstatic.etherpad.org%2Fshields.json&color=%2344b492 "Etherpad plugins")](https://etherpad.org/plugins)
[![Etherpad plugins](https://img.shields.io/endpoint?url=https%3A%2F%2Fstatic.etherpad.org%2Fshields.json&color=%2344b492 "Etherpad plugins")](https://static.etherpad.org/index.html)
![Languages](https://img.shields.io/static/v1?label=Languages&message=105&color=%2344b492)
![Translation Coverage](https://img.shields.io/static/v1?label=Languages&message=98%&color=%2344b492)
@ -58,13 +60,13 @@ For more than a decade, Etherpad has quietly underpinned the documents that matt
- **Newsrooms and investigative journalism teams** — where authorship and editing history matter for legal and editorial integrity.
- **Tens of thousands of self-hosted instances** worldwide, run by IT teams who chose Etherpad because it is theirs.
[Public Etherpad Instances for you to try out. Third party instances not provided by the Etherpad foundation](https://scanner.etherpad.org/).
If your organisation runs Etherpad and would be willing to be listed publicly, please [add it to the wiki](https://github.com/ether/etherpad/wiki/Sites-That-Run-Etherpad).
## Installation
### Quick install (one-liner)
The fastest way to get Etherpad running. Requires `git` and Node.js >= 24.
The fastest way to get Etherpad running. Requires `git` and Node.js >= 20.
**macOS / Linux / WSL:**
@ -158,7 +160,7 @@ volumes:
### Requirements
[Node.js](https://nodejs.org/) >= 24.
[Node.js](https://nodejs.org/) >= 20.
### Windows, macOS, Linux
@ -172,7 +174,7 @@ volumes:
### Docker container
Find [here](doc/docker.md) information on running Etherpad in a container.
Find [here](doc/docker.adoc) information on running Etherpad in a container.
## Plugins
@ -247,8 +249,8 @@ git -P tag --list "v*" --merged
```
4. Select the version
```sh
git checkout v3.2.0
git switch -c v3.2.0
git checkout v2.2.5
git switch -c v2.2.5
```
5. Upgrade Etherpad
```sh

View file

@ -1,88 +1,30 @@
# Admin UI
# React + TypeScript + Vite
Vite + React 19 single-page app served at `/admin`. Talks to the backend over
socket.io for the existing settings / plugins / pads pages, and (when
endpoints are added to the OpenAPI spec) over a typed REST client.
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Scripts
Currently, two official plugins are available:
| Script | What it does |
| -------------------- | -------------------------------------------------------- |
| `pnpm dev` | `gen:api` + Vite dev server (expects backend on :9001). |
| `pnpm gen:api` | Regenerates `src/api/{schema.d.ts,version.ts}` from the OpenAPI spec. |
| `pnpm build` | `gen:api` + `tsc` + `vite build`. |
| `pnpm build-copy` | Same, but writes into `../src/templates/admin`. |
| `pnpm test` | `gen:api` + smoke tests for the API client wiring. |
| `pnpm lint` | ESLint. |
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Typed API client
## Expanding the ESLint configuration
The admin uses [`openapi-typescript`] to generate types from
`src/node/hooks/express/openapi.ts`, [`openapi-fetch`] for typed requests, and
[`openapi-react-query`] for TanStack Query bindings.
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
[`openapi-typescript`]: https://github.com/openapi-ts/openapi-typescript
[`openapi-fetch`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch
[`openapi-react-query`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-react-query
- Configure the top-level `parserOptions` property like this:
### Generated files
`admin/src/api/schema.d.ts` and `admin/src/api/version.ts` are generated by
`gen:api` and gitignored — never commit them. They are produced by:
```sh
pnpm --filter admin gen:api
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
`admin/scripts/gen-api.mjs` loads `src/node/hooks/express/openapi.ts`, calls
`generateDefinitionForVersion` for the latest API version, pipes the JSON
through `openapi-typescript` to produce `schema.d.ts`, and emits a runtime
constant `LATEST_API_VERSION` (read from `info.version` in the spec) to
`version.ts` so `client.ts` can build the right `/api/<version>/` baseUrl.
`gen:api` runs as the first step of `dev`, `build`, `build-copy`, and
`test`, so a fresh checkout produces the generated files automatically when
any of those scripts is invoked. After modifying any of the following, the
next `pnpm <dev|build|test>` will refresh the generated files; you can also
run `gen:api` directly:
- `src/node/hooks/express/openapi.ts`
- `src/node/handler/APIHandler.ts` (changes to `latestApiVersion`)
- the resource definitions referenced by `openapi.ts`
### Using the client
```tsx
import { $api } from './api/client';
const SettingsPanel = () => {
const { data } = $api.useQuery('get', '/admin/settings'); // example
return <pre>{JSON.stringify(data, null, 2)}</pre>;
};
```
The admin endpoints are not yet present in the OpenAPI spec — this client is
in place to support upcoming work (see issue #7638 follow-up). For now, it is
exercised only by the smoke test.
## Socket.io: `padLoad` query shape
The admin `/settings` namespace's `padLoad` event accepts a `PadSearchQuery`
defined in `src/node/types/PadSearchQuery.ts`:
| field | type | required | notes |
| ------------ | ----------------------------------------------------------------- | -------- | ----- |
| `pattern` | `string` | yes | Substring match on pad name. |
| `offset` | `number` | yes | Pagination start, in items. Clamped server-side. |
| `limit` | `number` | yes | Page size. Capped at 12. |
| `ascending` | `boolean` | yes | Sort direction. |
| `sortBy` | `"padName" \| "lastEdited" \| "userCount" \| "revisionNumber"` | yes | Column to sort by. |
| `filter` | `"all" \| "active" \| "recent" \| "empty" \| "stale"` *(opt.)* | no | Filter chip; defaults to `"all"`. Applied **before** pagination so `total` and the page slice both reflect the filtered universe. Older clients that omit the field get the unchanged `"all"` behaviour. |
Filter semantics — applied after pattern matching, before sort + slice:
- `active`: `userCount > 0`
- `recent`: edited within the last 7 days
- `empty`: `revisionNumber === 0`
- `stale`: not edited in the last 365 days
- `all` / missing: no further filtering
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

View file

@ -1,53 +1,46 @@
{
"name": "admin",
"private": true,
"version": "3.3.2",
"version": "2.7.0",
"type": "module",
"scripts": {
"dev": "pnpm gen:api && vite",
"dev:only": "vite",
"gen:api": "node scripts/gen-api.mjs",
"build": "pnpm gen:api && tsc && vite build",
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
"preview": "vite preview",
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'"
"build-copy": "tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-switch": "^1.3.3",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"jsonc-parser": "^3.3.1",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4"
"@radix-ui/react-switch": "^1.2.6"
},
"devDependencies": {
"@radix-ui/react-dialog": "^1.1.19",
"@radix-ui/react-toast": "^1.2.19",
"@radix-ui/react-visually-hidden": "^1.2.7",
"@types/react": "^19.2.17",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-toast": "^1.2.15",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.63.0",
"@typescript-eslint/parser": "^8.63.0",
"@vitejs/plugin-react": "^6.0.3",
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@typescript-eslint/parser": "^8.58.2",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"i18next": "^26.3.6",
"eslint": "^10.2.0",
"eslint-plugin-react-hooks": "^7.1.0",
"eslint-plugin-react-refresh": "^0.5.2",
"i18next": "^26.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.23.0",
"openapi-typescript": "^7.13.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-hook-form": "^7.81.0",
"react-i18next": "^17.0.9",
"react-router-dom": "^7.18.1",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.72.1",
"react-i18next": "^17.0.4",
"react-router-dom": "^7.14.1",
"socket.io-client": "^4.8.3",
"tsx": "^4.23.0",
"typescript": "^6.0.3",
"vite": "^8.1.4",
"vite-plugin-babel": "^1.7.3",
"zustand": "^5.0.14"
"vite": "npm:rolldown-vite@7.2.10",
"vite-plugin-babel": "^1.6.0",
"vite-plugin-static-copy": "^4.0.1",
"zustand": "^5.0.12"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.10"
}
}

View file

@ -1,33 +0,0 @@
{
"title": "Authors",
"search-placeholder": "Search by name or mapper",
"column.color": "Color",
"column.name": "Name",
"column.mapper": "Mapper",
"column.last-seen": "Last seen",
"column.author-id": "Author ID",
"column.actions": "Actions",
"show-erased": "Show erased authors",
"erase": "Erase",
"erase-disabled-tooltip": "Author erasure is disabled. Set gdprAuthorErasure.enabled = true in settings.json.",
"erased-stub": "(erased)",
"cap-warning": "Showing the first 1000 authors. Narrow your search to see more.",
"feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.",
"no-results": "No authors match this search.",
"confirm-dialog-title": "Confirm author erasure",
"confirm-dialog-description": "Review the impact of erasing this author and confirm or cancel.",
"confirm-preview-title": "Erase author {{name}}",
"confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.",
"confirm-irreversible": "This cannot be undone.",
"cancel": "Cancel",
"continue": "Continue",
"loading-preview": "Loading preview…",
"erasing": "Erasing…",
"erase-success-toast": "Author {{authorID}} erased.",
"erase-error-toast": "Erase failed: {{error}}",
"no-mappers": "—",
"never-seen": "—",
"prev-page": "Previous Page",
"next-page": "Next Page",
"page-counter": "{{current}} out of {{total}}"
}

View file

@ -1,74 +0,0 @@
import {test} from 'node:test';
import {strict as assert} from 'node:assert';
import {mergeOpenAPI} from '../merge-openapi.mjs';
const minimal = (overrides = {}) => ({
openapi: '3.0.2',
info: {title: 'X', version: '0.0.0'},
paths: {},
components: {schemas: {}, securitySchemes: {}},
...overrides,
});
test('unions paths from both docs', () => {
const pub = minimal({paths: {'/createGroup': {post: {operationId: 'createGroup'}}}});
const adm = minimal({paths: {'/admin-auth/': {post: {operationId: 'verifyAdminAccess'}}}});
const out = mergeOpenAPI(pub, adm);
assert.deepEqual(Object.keys(out.paths).sort(), ['/admin-auth/', '/createGroup']);
});
test('throws on path collision', () => {
const pub = minimal({paths: {'/x': {get: {}}}});
const adm = minimal({paths: {'/x': {post: {}}}});
assert.throws(() => mergeOpenAPI(pub, adm), /path collision/i);
});
test('unions components.schemas', () => {
const pub = minimal({components: {schemas: {A: {}}, securitySchemes: {}}});
const adm = minimal({components: {schemas: {B: {}}, securitySchemes: {}}});
const out = mergeOpenAPI(pub, adm);
assert.deepEqual(Object.keys(out.components.schemas).sort(), ['A', 'B']);
});
test('throws on schema name collision', () => {
const pub = minimal({components: {schemas: {Dup: {}}, securitySchemes: {}}});
const adm = minimal({components: {schemas: {Dup: {}}, securitySchemes: {}}});
assert.throws(() => mergeOpenAPI(pub, adm), /schema collision/i);
});
test('unions securitySchemes', () => {
const pub = minimal({components: {schemas: {}, securitySchemes: {apiKey: {}}}});
const adm = minimal({components: {schemas: {}, securitySchemes: {basicAuth: {}}}});
const out = mergeOpenAPI(pub, adm);
assert.deepEqual(
Object.keys(out.components.securitySchemes).sort(),
['apiKey', 'basicAuth'],
);
});
test('preserves public root security; admin per-operation security survives', () => {
const pub = minimal({security: [{apiKey: []}]});
const adm = minimal({
paths: {
'/admin-auth/': {
post: {
security: [{basicAuth: []}, {}],
},
},
},
});
const out = mergeOpenAPI(pub, adm);
assert.deepEqual(out.security, [{apiKey: []}]);
assert.deepEqual(
out.paths['/admin-auth/'].post.security,
[{basicAuth: []}, {}],
);
});
test('public info wins on conflict', () => {
const pub = minimal({info: {title: 'Public', version: '1.0'}});
const adm = minimal({info: {title: 'Admin', version: '2.0'}});
const out = mergeOpenAPI(pub, adm);
assert.equal(out.info.title, 'Public');
assert.equal(out.info.version, '1.0');
});

View file

@ -1,57 +0,0 @@
// admin/scripts/dump-spec.ts
//
// Imports the public + admin OpenAPI spec builders from the etherpad
// source, merges them into one document, and writes JSON to argv[2].
// Invoked by admin/scripts/gen-api.mjs via `tsx`.
//
// Why a file argument instead of stdout: importing openapi*.ts triggers
// Settings init, which configures log4js to write INFO/WARN lines to
// stdout. Capturing stdout would mix logs with JSON.
import {writeFileSync} from 'node:fs';
import path from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
// @ts-expect-error — sibling .mjs has no .d.ts; tsx resolves it at runtime.
import {mergeOpenAPI} from './merge-openapi.mjs';
const outFile = process.argv[2];
if (!outFile) {
process.stderr.write('Usage: tsx scripts/dump-spec.ts <output-path>\n');
process.exit(2);
}
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, '..', '..');
const apiHandlerPath = path.join(repoRoot, 'src', 'node', 'handler', 'APIHandler.ts');
const openapiPath = path.join(repoRoot, 'src', 'node', 'hooks', 'express', 'openapi.ts');
const openapiAdminPath = path.join(
repoRoot, 'src', 'node', 'hooks', 'express', 'openapi-admin.ts',
);
type ApiHandlerModule = {latestApiVersion: string};
type OpenApiModule = {
generateDefinitionForVersion: (version: string, style?: string) => unknown;
APIPathStyle: {FLAT: string; REST: string};
};
type OpenApiAdminModule = {
generateAdminDefinition: () => unknown;
};
const apiHandlerMod = await import(pathToFileURL(apiHandlerPath).href);
const openapiMod = await import(pathToFileURL(openapiPath).href);
const openapiAdminMod = await import(pathToFileURL(openapiAdminPath).href);
const apiHandler = (apiHandlerMod.default ?? apiHandlerMod) as ApiHandlerModule;
const openapi = (openapiMod.default ?? openapiMod) as OpenApiModule;
const openapiAdmin = (openapiAdminMod.default ?? openapiAdminMod) as OpenApiAdminModule;
const publicSpec = openapi.generateDefinitionForVersion(
apiHandler.latestApiVersion,
openapi.APIPathStyle.FLAT,
);
const adminSpec = openapiAdmin.generateAdminDefinition();
const merged = mergeOpenAPI(publicSpec, adminSpec);
writeFileSync(path.resolve(outFile), JSON.stringify(merged, null, 2), 'utf8');

View file

@ -1,78 +0,0 @@
// admin/scripts/gen-api.mjs
//
// Regenerates admin/src/api/schema.d.ts from the live OpenAPI spec exported
// by src/node/hooks/express/openapi.ts. Run via `pnpm --filter admin gen:api`.
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const adminRoot = path.resolve(here, '..');
const outFile = path.join(adminRoot, 'src', 'api', 'schema.d.ts');
const tmpDir = mkdtempSync(path.join(tmpdir(), 'etherpad-openapi-'));
const specPath = path.join(tmpDir, 'spec.json');
// On Windows pnpm resolves to pnpm.cmd, which spawnSync can only find via a
// shell. Use shell on Windows only to avoid Node's DEP0190 warning elsewhere.
// Every argument here is fixed (no user input) so the shell:true variant is
// not an injection risk.
const spawnOpts = {
cwd: adminRoot,
stdio: 'inherit',
shell: process.platform === 'win32',
};
try {
const dump = spawnSync(
'pnpm',
['exec', 'tsx', 'scripts/dump-spec.ts', specPath],
spawnOpts,
);
if (dump.status !== 0) {
console.error(`dump-spec.ts failed with exit code ${dump.status}`);
process.exit(dump.status ?? 1);
}
const gen = spawnSync(
'pnpm',
['exec', 'openapi-typescript', specPath, '-o', outFile],
spawnOpts,
);
if (gen.status !== 0) {
console.error(`openapi-typescript failed with exit code ${gen.status}`);
process.exit(gen.status ?? 1);
}
const header =
`// GENERATED — do not edit. Run \`pnpm --filter admin gen:api\` to regenerate.\n` +
`// Source: src/node/hooks/express/openapi.ts (#7638)\n\n`;
const body = readFileSync(outFile, 'utf8');
writeFileSync(outFile, header + body, 'utf8');
// Emit a runtime-side version constant so client.ts can build the right
// baseUrl. Generated paths are unprefixed (e.g. "/createGroup"), but the
// backend mounts the FLAT-style spec under /api/<version>/.
const spec = JSON.parse(readFileSync(specPath, 'utf8'));
const apiVersion = spec?.info?.version;
if (typeof apiVersion !== 'string' || apiVersion.length === 0) {
console.error('OpenAPI spec is missing info.version; cannot emit version.ts');
process.exit(1);
}
const versionFile = path.join(adminRoot, 'src', 'api', 'version.ts');
writeFileSync(
versionFile,
header +
`export const LATEST_API_VERSION = ${JSON.stringify(apiVersion)};\n` +
`export const API_BASE_URL = \`/api/\${LATEST_API_VERSION}\`;\n`,
'utf8',
);
console.log(`Wrote ${path.relative(process.cwd(), outFile)}`);
console.log(`Wrote ${path.relative(process.cwd(), versionFile)}`);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}

View file

@ -1,56 +0,0 @@
// admin/scripts/merge-openapi.mjs
//
// Deep-merges the public-API OpenAPI document with the admin OpenAPI
// document into a single document for openapi-typescript to consume.
//
// Rules:
// - paths: union by key; collision throws
// - components.{schemas,parameters,responses,securitySchemes}: union by name; collision throws
// - root info, servers, security: public wins (admin's are ignored at the root)
// - per-operation security on admin paths is preserved untouched
const unionMap = (label, a = {}, b = {}) => {
const out = {...a};
for (const [k, v] of Object.entries(b)) {
if (k in out) {
throw new Error(`${label} on key "${k}"`);
}
out[k] = v;
}
return out;
};
export const mergeOpenAPI = (publicDoc, adminDoc) => {
if (!publicDoc || !adminDoc) {
throw new Error('mergeOpenAPI requires both publicDoc and adminDoc');
}
return {
openapi: publicDoc.openapi || adminDoc.openapi,
info: publicDoc.info,
...(publicDoc.servers ? {servers: publicDoc.servers} : {}),
...(publicDoc.security ? {security: publicDoc.security} : {}),
paths: unionMap('path collision', publicDoc.paths, adminDoc.paths),
components: {
schemas: unionMap(
'schema collision',
publicDoc.components?.schemas,
adminDoc.components?.schemas,
),
parameters: unionMap(
'parameter collision',
publicDoc.components?.parameters,
adminDoc.components?.parameters,
),
responses: unionMap(
'response collision',
publicDoc.components?.responses,
adminDoc.components?.responses,
),
securitySchemes: unionMap(
'securityScheme collision',
publicDoc.components?.securitySchemes,
adminDoc.components?.securitySchemes,
),
},
};
};

View file

@ -1,347 +0,0 @@
/* Raw textarea (kept dark to signal "this is code") */
textarea.settings {
font-family: "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
font-size: 14px;
white-space: pre;
overflow-wrap: normal;
overflow-x: auto;
width: 100%;
height: 500px;
padding: 15px;
background-color: #1e1e1e;
color: #d4d4d4;
line-height: 1.5;
border: 1px solid #333;
resize: vertical;
}
textarea.settings:focus {
outline: 2px solid #007acc;
outline-offset: -1px;
}
.settings-button-bar {
display: flex;
flex-shrink: 0;
gap: 10px;
margin-top: 15px;
}
.settings-links {
display: flex;
gap: 20px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
/* --- env-var banner --- */
/* Shown only when settings.json contains ${VAR} placeholders. The
typical reader is a Docker/K8s operator who has just been surprised
by env-var substitution semantics, so the copy must explain rather
than warn visual weight matches a note, not an error. */
.settings-envvar-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
margin-bottom: 16px;
background: #f5f7ff;
border: 1px solid #c7d2fe;
border-radius: 6px;
color: #1e293b;
}
.settings-envvar-banner svg {
flex-shrink: 0;
color: #4f46e5;
margin-top: 2px;
}
.settings-envvar-banner strong {
display: block;
margin-bottom: 4px;
}
.settings-envvar-banner p {
margin: 0;
font-size: 13px;
line-height: 1.5;
}
/* --- mode toggle --- */
.settings-mode-toggle {
display: inline-flex;
flex-shrink: 0;
border: 1px solid #ccc;
border-radius: 6px;
overflow: hidden;
margin-bottom: 16px;
background: #fff;
}
.settings-mode-toggle button {
padding: 6px 14px;
border: 0;
background: transparent;
color: #555;
font: inherit;
cursor: pointer;
}
.settings-mode-toggle button.active {
background: var(--etherpad-color, #0f775b);
color: #fff;
}
/* --- form (light, two-column) --- */
.settings-form {
font-family: inherit;
font-size: 14px;
color: #333;
}
.settings-section {
background: #fff;
border: 1px solid #ddd;
border-radius: 6px;
margin-bottom: 18px;
overflow: hidden;
}
.settings-section-header {
padding: 14px 18px;
border-bottom: 1px solid #eee;
background: #fafafa;
}
.settings-section-header h2 {
margin: 0;
font-size: 15px;
font-weight: 600;
color: #222;
letter-spacing: 0.01em;
text-transform: uppercase;
}
.settings-section-header p {
margin: 4px 0 0;
font-size: 13px;
color: #666;
white-space: pre-wrap;
}
.settings-section-body {
padding: 4px 0;
}
/* Two-column row: label | control, with help below spanning column 2.
* Single-column on narrow widths. */
.settings-row {
display: grid;
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
gap: 6px 18px;
padding: 10px 18px;
align-items: center;
border-top: 1px solid #f4f4f4;
}
.settings-row:first-child {
border-top: 0;
}
.settings-row-label {
font-weight: 600;
color: #333;
word-break: break-word;
}
.settings-row-control {
min-width: 0;
}
.settings-row-help {
grid-column: 2;
margin: 2px 0 0;
font-size: 12.5px;
color: #666;
white-space: pre-wrap;
line-height: 1.4;
}
@media (max-width: 600px) {
.settings-row {
grid-template-columns: 1fr;
}
.settings-row-help {
grid-column: 1;
}
}
/* --- nested subsections (objects/arrays inside a section) --- */
.settings-subsection {
grid-column: 1 / -1;
margin: 8px 18px;
border-left: 3px solid #e2e2e2;
padding-left: 14px;
}
.settings-subsection-header {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0;
}
.settings-subsection-title {
font-weight: 600;
color: #444;
font-size: 13.5px;
text-transform: uppercase;
letter-spacing: 0.01em;
}
.settings-subsection-help {
color: #777;
font-size: 12.5px;
white-space: pre-wrap;
}
.settings-subsection-body .settings-row {
padding-left: 0;
padding-right: 0;
}
/* --- leaf widgets (light) --- */
.settings-widget-string,
.settings-widget-number {
width: 100%;
background: #fff;
color: #222;
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 10px;
font-family: inherit;
font-size: inherit;
}
.settings-widget-string:focus,
.settings-widget-number:focus {
outline: none;
border-color: var(--etherpad-color, #0f775b);
box-shadow: 0 0 0 3px rgba(15, 119, 91, 0.15);
}
.settings-widget-number.invalid {
border-color: #ce5050;
}
.settings-widget-null {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
background: #f0f0f0;
color: #888;
font-style: italic;
font-size: 12.5px;
}
.settings-widget-env {
display: inline-flex;
align-items: center;
gap: 6px;
background: #f4f8ff;
color: #335;
border: 1px dashed #88a;
border-radius: 12px;
padding: 2px 10px;
font-size: 13px;
}
.settings-widget-env-icon {
font-style: normal;
color: #557;
}
.settings-widget-env-name {
font-family: "Fira Code", monospace;
font-weight: 600;
}
.settings-widget-env-default-label {
color: #557;
font-size: 12px;
text-transform: lowercase;
}
.settings-widget-env-default-input {
background: white;
border: 1px solid #ccd;
border-radius: 6px;
padding: 1px 6px;
font-family: "Fira Code", monospace;
font-size: 13px;
color: #804;
min-width: 80px;
max-width: 240px;
width: auto;
}
.settings-widget-env-default-input:focus {
outline: 2px solid var(--accent, #2b8a3e);
outline-offset: 1px;
}
.settings-widget-env-runtime {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: 4px;
padding: 1px 8px;
border-radius: 10px;
background: #e6f4ea;
color: #1e5631;
font-family: "Fira Code", monospace;
font-size: 12px;
}
.settings-widget-env-runtime-redacted {
background: #ececec;
color: #555;
}
.settings-widget-env-runtime-arrow {
opacity: 0.6;
}
.settings-widget-env-runtime-label {
font-style: italic;
opacity: 0.7;
}
.settings-widget-env-runtime-value {
font-weight: 600;
}
/* Radix switch (boolean) */
.settings-widget-boolean {
appearance: none;
width: 36px;
height: 20px;
border-radius: 999px;
background: #ccc;
border: 0;
position: relative;
cursor: pointer;
transition: background 120ms ease;
padding: 0;
}
.settings-widget-boolean[data-state="checked"] {
background: var(--etherpad-color, #0f775b);
}
.settings-widget-boolean-thumb {
display: block;
width: 16px;
height: 16px;
background: #fff;
border-radius: 50%;
transform: translateX(2px);
transition: transform 120ms ease;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.settings-widget-boolean[data-state="checked"] .settings-widget-boolean-thumb {
transform: translateX(18px);
}
/* --- parse error --- */
.settings-parse-error {
border: 1px solid #d99;
background: #fff5f5;
color: #842;
padding: 14px 18px;
border-radius: 6px;
}
.settings-parse-error-detail {
margin: 8px 0;
white-space: pre-wrap;
font-family: "Fira Code", monospace;
font-size: 12.5px;
}
.settings-parse-error button {
margin-top: 4px;
background: var(--etherpad-color, #0f775b);
color: #fff;
border: 0;
padding: 6px 14px;
border-radius: 4px;
cursor: pointer;
font: inherit;
}

View file

@ -6,45 +6,45 @@ 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, Bell, Users} from "lucide-react";
import {UpdateBanner} from "./components/UpdateBanner";
import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu} from "lucide-react";
const WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : ''
export const App = () => {
const setSettings = useStore(state => state.setSettings);
const erasureEnabled = useStore(state => state.gdprAuthorErasureEnabled)
const {t} = useTranslation()
const navigate = useNavigate()
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
const updateStatus = useStore(state => state.updateStatus)
const version = updateStatus?.currentVersion ?? null
useEffect(() => {
fetch('/admin-auth/', {method: 'POST'}).then((value) => {
if (!value.ok) navigate('/login')
}).catch(() => navigate('/login'))
fetch('/admin-auth/', {
method: 'POST'
}).then((value) => {
if (!value.ok) {
navigate('/login')
}
}).catch(() => {
navigate('/login')
})
}, []);
useEffect(() => {
document.title = t('admin.page-title')
useStore.getState().setShowLoading(true);
const settingSocket = connect(`${WS_URL}/settings`, {transports: ['websocket']});
const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {transports: ['websocket']})
// When the server explicitly rejects us for not being admin, we must
// NOT reconnect on the subsequent `disconnect` event — otherwise the
// socket cycles forever (connect → admin_auth_error → server
// disconnect → SPA auto-reconnect → …). The flag is reset on each
// successful connect.
let authErrored = false;
useStore.getState().setShowLoading(true);
const settingSocket = connect(`${WS_URL}/settings`, {
transports: ['websocket'],
});
const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {
transports: ['websocket'],
})
pluginsSocket.on('connect', () => {
useStore.getState().setPluginsSocket(pluginsSocket);
});
settingSocket.on('connect', () => {
authErrored = false;
useStore.getState().setSettingsSocket(settingSocket);
useStore.getState().setShowLoading(false)
settingSocket.emit('load');
@ -52,52 +52,32 @@ export const App = () => {
});
settingSocket.on('disconnect', (reason) => {
// The settingSocket.io client will automatically try to reconnect for all reasons other than "io
// server disconnect".
useStore.getState().setShowLoading(true)
if (reason === 'io server disconnect' && !authErrored) settingSocket.connect();
if (reason === 'io server disconnect') {
settingSocket.connect();
}
});
settingSocket.on('settings', (settings: any) => {
if (settings && typeof settings.flags === 'object' && settings.flags) {
useStore.getState().setGdprAuthorErasureEnabled(
!!settings.flags.gdprAuthorErasure);
}
settingSocket.on('settings', (settings) => {
/* Check whether the settings.json is authorized to be viewed */
if (settings.results === 'NOT_ALLOWED') {
console.log('Not allowed to view settings.json')
useStore.getState().setResolved(null);
return;
}
if (isJSONClean(settings.results)) setSettings(settings.results);
else alert(t('admin_settings.invalid_json'));
// The resolved field is optional — old servers won't send it,
// and the SPA degrades to today's behaviour when it's null.
useStore.getState().setResolved(settings.resolved ?? null);
useStore.getState().setShowLoading(false);
});
settingSocket.on('saveprogress', (status: string, payload?: {message?: string}) => {
const {setToastState} = useStore.getState();
if (status === 'saved') {
setToastState({open: true, title: t('admin_settings.toast.saved'), success: true});
/* Check to make sure the JSON is clean before proceeding */
if (isJSONClean(settings.results)) {
setSettings(settings.results);
} else {
const detail = payload?.message ?? '';
setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false});
alert('Invalid JSON');
}
useStore.getState().setShowLoading(false);
});
// Backend emits this when the connecting socket does not have an
// admin session. Previously the server just dropped silently, so the
// SPA would sit on a loading screen with no clue. Surface it AND
// suppress the automatic reconnect — without this flag the SPA would
// immediately reconnect to a socket that will reject it again.
settingSocket.on('admin_auth_error', (payload?: {message?: string}) => {
authErrored = true;
const {setToastState} = useStore.getState();
setToastState({
open: true,
title: payload?.message || t('admin_settings.toast.auth_error'),
success: false,
});
useStore.getState().setShowLoading(false);
settingSocket.on('saveprogress', (status) => {
console.log(status)
})
return () => {
@ -106,109 +86,35 @@ export const App = () => {
}
}, []);
const closeOnMobile = () => {
if (window.innerWidth < 768) setSidebarOpen(false)
}
return (
<div id="wrapper" className={sidebarOpen ? '' : 'closed'}>
<LoadingScreen/>
<div className="menu">
<div className="inner-menu">
<div className="sidebar-top">
<button
className="sidebar-burger"
onClick={() => setSidebarOpen(!sidebarOpen)}
aria-label={t('admin.toggle_sidebar')}
>
<LucideMenu size={20}/>
</button>
{sidebarOpen && (
<div className="sidebar-brand">
<div className="sidebar-brand-mark"><Crown size={20} strokeWidth={1.8}/></div>
<span className="sidebar-brand-name">Etherpad</span>
</div>
)}
</div>
<nav className="sidebar-nav" onClick={closeOnMobile}>
<NavLink
to="/plugins"
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
title={sidebarOpen ? undefined : t('admin_plugins')}
>
<span className="sidebar-nav-icon"><Cable size={18}/></span>
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="admin_plugins"/></span>}
</NavLink>
<NavLink
to="/settings"
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
title={sidebarOpen ? undefined : t('admin_settings')}
>
<span className="sidebar-nav-icon"><Wrench size={18}/></span>
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="admin_settings"/></span>}
</NavLink>
<NavLink
to="/help"
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
title={sidebarOpen ? undefined : t('admin_plugins_info')}
>
<span className="sidebar-nav-icon"><Construction size={18}/></span>
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="admin_plugins_info"/></span>}
</NavLink>
<NavLink
to="/pads"
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
title={sidebarOpen ? undefined : undefined}
>
<span className="sidebar-nav-icon"><NotepadText size={18}/></span>
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></span>}
</NavLink>
{erasureEnabled && (
<NavLink
to="/authors"
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
title={sidebarOpen ? undefined : t('ep_admin_authors:title', {ns: 'ep_admin_authors'})}
>
<span className="sidebar-nav-icon"><Users size={18}/></span>
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="ep_admin_authors:title" ns="ep_admin_authors"/></span>}
</NavLink>
)}
<NavLink
to="/shout"
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
title={sidebarOpen ? undefined : t('admin.shout')}
>
<span className="sidebar-nav-icon"><PhoneCall size={18}/></span>
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="admin.shout"/></span>}
</NavLink>
<NavLink
to="/update"
className={({isActive}) => `sidebar-nav-item${isActive ? ' is-active' : ''}`}
title={sidebarOpen ? undefined : t('update.page.title')}
>
<span className="sidebar-nav-icon"><Bell size={18}/></span>
{sidebarOpen && <span className="sidebar-nav-label"><Trans i18nKey="update.page.title"/></span>}
</NavLink>
</nav>
{sidebarOpen && (
<div className="sidebar-footer">
<div className="sidebar-footer-row">
<span className="sidebar-status-dot"/>
<span>{version ? `v${version}` : 'Etherpad'}</span>
</div>
</div>
)}
</div>
</div>
<div className="innerwrapper">
<UpdateBanner/>
<Outlet/>
return <div id="wrapper" className={`${sidebarOpen ? '': 'closed' }`}>
<LoadingScreen/>
<div className="menu">
<div className="inner-menu">
<span>
<Crown width={40} height={40}/>
<h1>Etherpad</h1>
</span>
<ul onClick={()=>{
if (window.innerWidth < 768) {
setSidebarOpen(false)
}
}}>
<li><NavLink to="/plugins"><Cable/><Trans i18nKey="admin_plugins"/></NavLink></li>
<li><NavLink to={"/settings"}><Wrench/><Trans i18nKey="admin_settings"/></NavLink></li>
<li><NavLink to={"/help"}> <Construction/> <Trans i18nKey="admin_plugins_info"/></NavLink></li>
<li><NavLink to={"/pads"}><NotepadText/><Trans
i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></NavLink></li>
<li><NavLink to={"/shout"}><PhoneCall/>Communication</NavLink></li>
</ul>
</div>
</div>
)
<button id="icon-button" onClick={() => {
setSidebarOpen(!sidebarOpen)
}}><LucideMenu/></button>
<div className="innerwrapper">
<Outlet/>
</div>
</div>
}
export default App

View file

@ -1,40 +0,0 @@
// admin/src/api/QueryProvider.tsx
//
// TanStack Query provider for the admin UI. Devtools are loaded lazily and
// only in dev builds so they don't ship to production.
import { lazy, Suspense, useState, type ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const Devtools = import.meta.env.DEV
? lazy(() =>
import('@tanstack/react-query-devtools').then((m) => ({
default: m.ReactQueryDevtools,
})),
)
: null;
export const QueryProvider = ({ children }: { children: ReactNode }) => {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: true,
},
},
}),
);
return (
<QueryClientProvider client={client}>
{children}
{Devtools && (
<Suspense fallback={null}>
<Devtools initialIsOpen={false} />
</Suspense>
)}
</QueryClientProvider>
);
};

View file

@ -1,20 +0,0 @@
// admin/src/api/__tests__/client.test.ts
//
// Smoke test that the OpenAPI client module loads and exposes the expected
// surface. Catches toolchain wiring regressions (missing peer deps,
// generator output that doesn't export `paths`, etc.).
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('client module exports public + admin clients and query hooks', async () => {
const mod = await import('../client.ts');
assert.ok(mod.fetchClient, 'fetchClient export is present');
assert.ok(mod.adminFetchClient, 'adminFetchClient export is present');
assert.ok(mod.$api, '$api export is present');
assert.ok(mod.$adminApi, '$adminApi export is present');
assert.equal(typeof mod.fetchClient.GET, 'function', 'fetchClient.GET is a function');
assert.equal(typeof mod.adminFetchClient.GET, 'function', 'adminFetchClient.GET is a function');
assert.equal(typeof mod.$api.useQuery, 'function', '$api.useQuery is a function');
assert.equal(typeof mod.$adminApi.useQuery, 'function', '$adminApi.useQuery is a function');
});

View file

@ -1,30 +0,0 @@
// admin/src/api/client.ts
//
// Typed HTTP clients and TanStack Query hooks derived from the generated
// OpenAPI schema. Regenerate the schema with `pnpm --filter admin gen:api`.
//
// The merged spec covers two surfaces with different baseUrls:
//
// - Public versioned API at /api/<version>/ (paths like /createGroup)
// - Admin endpoints at root (paths like /admin-auth/)
//
// We narrow the generated `paths` interface by URL prefix and create one
// typed client per surface. TypeScript then rejects calling an admin path on
// the public client (or vice versa) at compile time — there is no shared
// client whose runtime baseUrl would silently target the wrong surface.
import createClient from 'openapi-fetch';
import createQueryHooks from 'openapi-react-query';
import type { paths } from './schema';
import { API_BASE_URL } from './version';
type AdminPath = Extract<keyof paths, `/admin${string}`>;
type PublicPath = Exclude<keyof paths, AdminPath>;
type PublicPaths = Pick<paths, PublicPath>;
type AdminPaths = Pick<paths, AdminPath>;
export const fetchClient = createClient<PublicPaths>({ baseUrl: API_BASE_URL });
export const adminFetchClient = createClient<AdminPaths>({ baseUrl: '/' });
export const $api = createQueryHooks(fetchClient);
export const $adminApi = createQueryHooks(adminFetchClient);

View file

@ -1,37 +0,0 @@
type Props = {
color: string | number | null;
size?: number;
};
// Resolves the colorId stored on globalAuthor records into a CSS color.
// AuthorManager stores either a string hex (legacy) or an integer index
// into the palette returned by getColorPalette() — we re-derive the
// palette here rather than fetch it because the order is stable and the
// admin already has many other small constants inline.
const PALETTE = [
'#ffc7c7', '#fff1c7', '#e3ffc7', '#c7ffd5', '#c7ffff', '#c7d5ff',
'#e3c7ff', '#ffc7f1', '#ffa8a8', '#ffe699', '#cfff9e', '#99ffb3',
'#a3ffff', '#99b3ff', '#cc99ff', '#ff99e5', '#e7b1b1', '#e9dcAf',
'#cde9af', '#bfedcc', '#b1e7e7', '#c3cdee', '#d2b8ea', '#eec3e6',
'#e9cece', '#e7e0ca', '#d3e5c7', '#bce1c5', '#c1e2e2', '#c1c9e2',
'#cfc1e2', '#e0bdd9', '#baded3', '#a0f8eb', '#b1e7e0', '#c3c8e4',
'#cec5e2', '#b1d5e7', '#cda8f0', '#f0f0a8', '#f2f2a6', '#f5a8eb',
'#c5f9a9', '#ececbb', '#e7c4bc', '#daf0b2', '#b0a0fd', '#bce2e7',
'#cce2bb', '#ec9afe', '#edabbd', '#aeaeea', '#c4e7b1', '#d722bb',
'#f3a5e7', '#ffa8a8', '#d8c0c5', '#eaaedd', '#adc6eb', '#bedad1',
'#dee9af', '#e9afc2', '#f8d2a0', '#b3b3e6',
];
export const ColorSwatch = ({color, size = 14}: Props) => {
let resolved = '#ccc';
if (typeof color === 'string') {
resolved = color;
} else if (typeof color === 'number' && color >= 0 && color < PALETTE.length) {
resolved = PALETTE[color];
}
return <span style={{
display: 'inline-block', width: size, height: size,
background: resolved, border: '1px solid rgba(0,0,0,0.2)',
borderRadius: 3, verticalAlign: 'middle',
}}/>;
};

View file

@ -1,14 +1,17 @@
import {ButtonHTMLAttributes, FC, JSX, ReactElement} from "react";
import {FC, JSX, ReactElement} from "react";
export type IconButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'title' | 'onClick'> & {
export type IconButtonProps = {
style?: React.CSSProperties,
icon: JSX.Element,
title: string|ReactElement,
onClick: ()=>void,
className?: string,
disabled?: boolean
}
export const IconButton: FC<IconButtonProps> = ({icon, className, onClick, title, type = 'button', ...rest}) => (
<button {...rest} type={type} onClick={onClick} className={"icon-button " + (className ?? "")}>
export const IconButton:FC<IconButtonProps> = ({icon,className,onClick,title, disabled, style})=>{
return <button style={style} onClick={onClick} className={"icon-button "+ className} disabled={disabled}>
{icon}
<span>{title}</span>
</button>
);
</button>
}

View file

@ -1,104 +0,0 @@
import {useEffect, useState} from 'react';
import {Link} from 'react-router-dom';
import {Trans, useTranslation} from 'react-i18next';
import {useStore} from '../store/store';
const fmtRemaining = (ms: number): string => {
if (ms <= 0) return '0s';
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const sec = s % 60;
return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
};
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]);
const scheduledFor = updateStatus?.execution?.status === 'scheduled'
? (updateStatus.execution as {scheduledFor: string}).scheduledFor
: null;
const [remainingMs, setRemainingMs] = useState<number>(() =>
scheduledFor ? Math.max(0, new Date(scheduledFor).getTime() - Date.now()) : 0);
useEffect(() => {
if (!scheduledFor) return;
const target = new Date(scheduledFor).getTime();
setRemainingMs(Math.max(0, target - Date.now()));
const id = setInterval(() => setRemainingMs(Math.max(0, target - Date.now())), 1000);
return () => clearInterval(id);
}, [scheduledFor]);
if (!updateStatus) return null;
// Terminal rollback-failed wins over the regular "update available" banner —
// an admin who left the system in this state needs to fix it before any
// other admin work matters.
if (updateStatus.execution?.status === 'rollback-failed') {
return (
<div className="update-banner update-banner-terminal" role="alert">
<strong><Trans i18nKey="update.banner.terminal.rollback-failed"/></strong>{' '}
<Link to="/update">{t('update.banner.cta')}</Link>
</div>
);
}
// Tier 4: tier is autonomous but the maintenance window isn't usable.
// Surface that before the generic "update available" banner so the admin
// knows the autonomous behavior is sitting idle.
const policyReason = updateStatus.policy?.reason;
if (updateStatus.tier === 'autonomous'
&& (policyReason === 'maintenance-window-missing'
|| policyReason === 'maintenance-window-invalid')) {
return (
<div className="update-banner update-banner-window" role="status">
<strong>
<Trans i18nKey={`update.banner.${policyReason}`}/>
</strong>{' '}
<Link to="/update">{t('update.banner.cta')}</Link>
</div>
);
}
// Tier 3: scheduled update — show countdown banner instead of the plain
// "update available" one.
if (updateStatus.execution?.status === 'scheduled') {
const exec = updateStatus.execution as {targetTag: string; scheduledFor: string};
return (
<div className="update-banner update-banner-scheduled" role="status">
<strong>
<Trans
i18nKey="update.banner.scheduled"
values={{tag: exec.targetTag, remaining: fmtRemaining(remainingMs)}}
/>
</strong>{' '}
<Link to="/update">{t('update.banner.cta')}</Link>
</div>
);
}
if (!updateStatus.latest) return null;
if (updateStatus.currentVersion === updateStatus.latest.version) return null;
return (
<div className="update-banner" role="status">
<strong><Trans i18nKey="update.banner.title"/></strong>{' '}
<span>
<Trans
i18nKey="update.banner.body"
values={{latest: updateStatus.latest.version, current: updateStatus.currentVersion}}
/>
</span>{' '}
<Link to="/update">{t('update.banner.cta')}</Link>
</div>
);
};

View file

@ -1,144 +0,0 @@
import { parseTree, getNodePath, type JSONPath, type Node, type ParseError } from 'jsonc-parser';
import { useStore } from '../../store/store';
import { useTranslation } from 'react-i18next';
import { editJsonc } from './jsoncEdit';
import { JsoncNode } from './JsoncNode';
import { ParseErrorBanner } from './ParseErrorBanner';
import { extractAdjacentComments } from './comments';
import { lookupTemplateComment } from './templateComments';
import { labelAndHelp } from './labels';
type Props = {
onSwitchToRaw: () => void;
};
// Parser-error token labels are kept in English — they are technical tokens
// matching the jsonc-parser error enum, not user-facing prose.
const ParseErrorMessage: Record<number, string> = {
1: 'Invalid symbol',
2: 'Invalid number format',
3: 'Property name expected',
4: 'Value expected',
5: 'Colon expected',
6: 'Comma expected',
7: 'Closing brace expected',
8: 'Closing bracket expected',
9: 'End of file expected',
16: 'Unexpected end of comment',
17: 'Unexpected end of string',
18: 'Unexpected end of number',
19: 'Invalid unicode',
20: 'Invalid escape character',
21: 'Invalid character',
};
const formatErrors = (errors: ParseError[]): string =>
errors.length === 0
? ''
: errors.map(e => `offset ${e.offset}: ${ParseErrorMessage[e.error] ?? 'parse error'}`).join('\n');
const Section = ({ title, description, children }: {
title: string;
description?: string;
children: React.ReactNode;
}) => (
<section className="settings-section">
<header className="settings-section-header">
<h2>{title}</h2>
{description && <p>{description}</p>}
</header>
<div className="settings-section-body">{children}</div>
</section>
);
const propertyKey = (prop: Node): string =>
prop.type === 'property' && prop.children?.[0]?.type === 'string'
? String(prop.children[0].value)
: '';
const propertyComment = (prop: Node, text: string, key: string): string | null => {
const valueNode = prop.children?.[1];
if (!valueNode) return null;
const live = extractAdjacentComments(text, prop.offset, valueNode.offset, valueNode.length);
if (live.leading) return live.leading;
// Section headings prefer the leading block comment; only fall back to the
// trailing comment if no leading documentation exists at all.
const tmpl = lookupTemplateComment([key]);
if (!tmpl) return null;
return tmpl.leading || tmpl.trailing || null;
};
export const FormView = ({ onSwitchToRaw }: Props) => {
const { t } = useTranslation();
const rawText = useStore(s => s.settings);
// While settings haven't loaded yet, show an empty busy placeholder so we
// don't flash a parse-error banner for the undefined→'' empty-string case.
if (rawText === undefined) {
return <div className="settings-form" data-testid="settings-form-view" aria-busy="true" />;
}
const text = rawText;
const errors: ParseError[] = [];
const tree = parseTree(text, errors, { allowTrailingComma: true });
// Always read the latest text from the store instead of closing over the
// render-time snapshot, so rapid sequential edits don't clobber each other.
const onEdit = (path: JSONPath, value: unknown) => {
const current = useStore.getState().settings ?? '';
useStore.getState().setSettings(editJsonc(current, path, value));
};
if (!tree || errors.length > 0 || tree.type !== 'object') {
return <ParseErrorBanner message={formatErrors(errors)} onSwitchToRaw={onSwitchToRaw} />;
}
const generalProps: Node[] = [];
const sectionProps: Node[] = [];
for (const prop of tree.children ?? []) {
if (prop.type !== 'property' || !prop.children?.[1]) continue;
const valueType = prop.children[1].type;
if (valueType === 'object' || valueType === 'array') sectionProps.push(prop);
else generalProps.push(prop);
}
return (
<div className="settings-form" data-testid="settings-form-view">
{generalProps.length > 0 && (
<Section title={t('admin_settings.section.general')}>
{generalProps.map((prop) => {
const propPath = getNodePath(prop);
const propKey = propPath.join('.');
return (
<JsoncNode
key={propKey}
node={prop.children![1]}
property={prop}
text={text}
onEdit={onEdit}
/>
);
})}
</Section>
)}
{sectionProps.map((prop) => {
const key = propertyKey(prop);
const { label, help } = labelAndHelp(propertyComment(prop, text, key), key);
const propPath = getNodePath(prop);
const sectionKey = propPath.join('.');
return (
<Section key={sectionKey} title={label} description={help}>
<JsoncNode
node={prop.children![1]}
property={prop}
text={text}
onEdit={onEdit}
suppressOwnHeader
/>
</Section>
);
})}
</div>
);
};

View file

@ -1,181 +0,0 @@
import type { JSONPath, Node } from 'jsonc-parser';
import { getNodePath } from 'jsonc-parser';
import { extractAdjacentComments } from './comments';
import { matchEnvPlaceholder } from './envPill';
import { lookupTemplateComment } from './templateComments';
import { humanize, labelAndHelp } from './labels';
import { StringInput } from './widgets/StringInput';
import { NumberInput } from './widgets/NumberInput';
import { BooleanToggle } from './widgets/BooleanToggle';
import { NullChip } from './widgets/NullChip';
import { EnvPill } from './widgets/EnvPill';
import { useResolvedAt } from '../../store/store';
type Props = {
/** The value node (not the property node). */
node: Node;
/** The property node, when this value is the value-side of `"key": value`. */
property?: Node;
text: string;
onEdit: (path: JSONPath, value: unknown) => void;
/**
* When true, this group's own label/header is suppressed because a
* containing Section already rendered it. The group's children still
* render. Used for top-level object/array sections in FormView.
*/
suppressOwnHeader?: boolean;
};
const propertyKey = (property: Node | undefined): string => {
if (!property || property.type !== 'property') return '';
const k = property.children?.[0];
return k?.type === 'string' ? String(k.value) : '';
};
const renderLeaf = (
node: Node,
path: JSONPath,
text: string,
onEdit: (path: JSONPath, value: unknown) => void,
resolvedValue: unknown,
) => {
if (node.type === 'string') {
const raw = text.slice(node.offset, node.offset + node.length);
const env = matchEnvPlaceholder(raw);
if (env) {
return (
<EnvPill
placeholder={env}
path={path}
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
resolvedValue={resolvedValue}
/>
);
}
return (
<StringInput
value={String(node.value)}
path={path}
onChange={v => onEdit(path, v)}
/>
);
}
if (node.type === 'number') {
return (
<NumberInput
value={Number(node.value)}
path={path}
onChange={v => onEdit(path, v)}
/>
);
}
if (node.type === 'boolean') {
return (
<BooleanToggle
value={Boolean(node.value)}
path={path}
onChange={v => onEdit(path, v)}
/>
);
}
if (node.type === 'null') {
return <NullChip path={path} />;
}
return null;
};
export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => {
const path = getNodePath(node);
const key = propertyKey(property);
// useResolvedAt must be called unconditionally for every JsoncNode
// render (React hook rules). It's cheap: a shallow zustand selector +
// an object-walk that returns undefined when the resolved payload is
// absent (old server) — in which case EnvPill simply omits the chip.
const resolvedValue = useResolvedAt(path);
const anchor = property ?? node;
const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length);
const tmpl = property ? lookupTemplateComment(path) : null;
const leading = fileComments.leading || tmpl?.leading || '';
const trailing = fileComments.trailing || tmpl?.trailing || '';
// Leading block comments (e.g. /* Description … */ above a key) carry the
// descriptive label — use labelAndHelp's first-sentence split.
// Trailing same-line comments (e.g. "altF9": true, /* focus on … */) are
// brief per-key annotations: the key itself reads as the label, the comment
// belongs in the help slot below the control. See #7740.
let label: string;
let help: string;
if (leading) {
const r = labelAndHelp(leading, key);
label = r.label;
help = [r.help, trailing].filter(Boolean).join(' ');
} else if (trailing) {
label = humanize(key);
help = trailing;
} else {
label = humanize(key);
help = '';
}
const rowId = `settings-row-${path.join('.') || 'root'}`;
const helpId = help ? `${rowId}-help` : undefined;
// ---- Object / array groups ----
if (node.type === 'object' || node.type === 'array') {
const children = (node.children ?? []).map((child) => {
// For object: child is a property node, drill into its value node.
// For array: child is a value node directly.
if (node.type === 'object') {
const valueNode = child.children?.[1];
if (!valueNode) return null;
const propPath = getNodePath(child);
const propKey = propPath.join('.');
return (
<JsoncNode
key={propKey}
node={valueNode}
property={child}
text={text}
onEdit={onEdit}
/>
);
}
// Array element: use stable JSON path as key.
const childPath = getNodePath(child);
return <JsoncNode key={childPath.join('.')} node={child} text={text} onEdit={onEdit} />;
});
if (suppressOwnHeader || !property) {
// Render children flat — the containing Section provides the label.
return <>{children}</>;
}
// Nested group within a section: render as a sub-section with its own
// heading, indented under its parent.
return (
<div className="settings-subsection" data-testid={`group-${path.join('.')}`}>
<div className="settings-subsection-header">
<span className="settings-subsection-title">{label}</span>
{help && <span className="settings-subsection-help">{help}</span>}
</div>
<div className="settings-subsection-body">{children}</div>
</div>
);
}
// ---- Leaf row ----
return (
<div className="settings-row" id={rowId}>
<label className="settings-row-label" htmlFor={`field-${path.join('.')}`}>
{label}
</label>
<div className="settings-row-control">
{renderLeaf(node, path, text, onEdit, resolvedValue)}
</div>
{help && (
<p className="settings-row-help" id={helpId}>{help}</p>
)}
</div>
);
};

View file

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

View file

@ -1,16 +0,0 @@
import { Trans } from 'react-i18next';
type Props = {
message: string;
onSwitchToRaw: () => void;
};
export const ParseErrorBanner = ({ message, onSwitchToRaw }: Props) => (
<div className="settings-parse-error" role="alert" data-testid="parse-error-banner">
<strong><Trans i18nKey="admin_settings.parse_error.title" /></strong>
<pre className="settings-parse-error-detail">{message}</pre>
<button type="button" onClick={onSwitchToRaw} data-testid="parse-error-switch-raw">
<Trans i18nKey="admin_settings.parse_error.cta" />
</button>
</div>
);

View file

@ -1,93 +0,0 @@
// admin/src/components/settings/__tests__/comments.test.ts
//
// Regression coverage for https://github.com/ether/etherpad/issues/7740.
// A previous version of findLeading treated any line ending in `*/` as a
// comment continuation; a JSON line like
// "altF9": true, /* focus on the File Menu and/or editbar */
// then leaked into the next sibling's "leading comment", which the form
// view rendered as the row label.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { extractAdjacentComments } from '../comments.ts';
import { humanize, labelAndHelp } from '../labels.ts';
const padShortcutText = `{
"padShortcutEnabled" : {
"altF9": true, /* focus on the File Menu and/or editbar */
"altC": true, /* focus on the Chat window */
"cmdShift2": true, /* shows a gritter popup showing a line author */
"delete": true,
"return": true,
"esc": true, /* in mozilla versions 14-19 avoid reconnecting pad */
"cmdS": true /* save a revision */
}
}`;
const offsetsFor = (text: string, key: string) => {
const keyOffset = text.indexOf(`"${key}"`);
const valOffset = text.indexOf('true', keyOffset);
return { keyOffset, valOffset, valLength: 4 };
};
test('does not absorb prior JSON line with trailing comment as leading', () => {
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'altC');
const { leading, trailing } =
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
assert.equal(leading, '');
assert.equal(trailing, 'focus on the Chat window');
});
test('does not accumulate multiple prior trailing-comment lines', () => {
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'cmdShift2');
const { leading } =
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
assert.equal(leading, '');
});
test('leading is empty when prior line is plain code (no trailing comment)', () => {
const { keyOffset, valOffset, valLength } = offsetsFor(padShortcutText, 'return');
const { leading } =
extractAdjacentComments(padShortcutText, keyOffset, valOffset, valLength);
assert.equal(leading, '');
});
test('still recognises JSDoc-style leading block comments', () => {
const text = `{
/*
* Pad Shortcut Keys
*/
"padShortcutEnabled" : {}
}`;
const keyOffset = text.indexOf('"padShortcutEnabled"');
const valOffset = text.indexOf('{}');
const { leading, trailing } = extractAdjacentComments(text, keyOffset, valOffset, 2);
assert.equal(leading, 'Pad Shortcut Keys');
assert.equal(trailing, '');
});
test('still recognises single-line // leading comments', () => {
const text = `{
// Whether to enable the thing.
"thing": true
}`;
const keyOffset = text.indexOf('"thing"');
const valOffset = text.indexOf('true');
const { leading } = extractAdjacentComments(text, keyOffset, valOffset, 4);
assert.equal(leading, 'Whether to enable the thing.');
});
test('humanize spaces camelCase and capitalises only the first word', () => {
assert.equal(humanize('requireAuthentication'), 'Require authentication');
assert.equal(humanize('altF9'), 'Alt f9');
});
test('labelAndHelp splits a leading block at the first sentence boundary', () => {
const { label, help } = labelAndHelp(
'Name your instance! Optional context follows.',
'title',
);
assert.equal(label, 'Name your instance!');
assert.equal(help, 'Optional context follows.');
});

View file

@ -1,95 +0,0 @@
// admin/src/components/settings/comments.ts
//
// Given the source text and a property's `keyOffset` (jsonc-parser's
// Node.offset for the property node), extract:
// - `leading`: the contiguous run of `/* */` or `//` comments
// immediately above the key. At most one blank line is allowed
// between the comment block and the key.
// - `trailing`: a single `// ...` or `/* ... */` on the same line
// as the value, after any trailing comma.
export type AdjacentComments = {
leading: string;
trailing: string;
};
const LINE_BREAK = /\r?\n/;
const stripCommentMarkers = (raw: string): string => {
// raw is a concatenation of comment tokens separated by newlines.
// Drop /* */ and // markers and trim each line.
return raw
.split(LINE_BREAK)
.map(line => line
.replace(/^\s*\/\*+/, '')
.replace(/\*+\/\s*$/, '')
.replace(/^\s*\*\s?/, '')
.replace(/^\s*\/\/\s?/, '')
.trim())
.filter(line => line.length > 0)
.join(' ');
};
const findLeading = (text: string, keyOffset: number): string => {
// Walk backwards from keyOffset to the start of the line containing it.
const lineStart = text.lastIndexOf('\n', keyOffset - 1) + 1;
let cursor = lineStart;
let blankLineSeen = false;
const collected: string[] = [];
while (cursor > 0) {
// Look at the previous line.
const prevLineEnd = cursor - 1; // the '\n' before our cursor's line
const prevLineStart = text.lastIndexOf('\n', prevLineEnd - 1) + 1;
const line = text.slice(prevLineStart, prevLineEnd);
const trimmed = line.trim();
if (trimmed === '') {
if (blankLineSeen) break;
blankLineSeen = true;
cursor = prevLineStart;
continue;
}
// A JSON line with a trailing `/* … */` comment (e.g.
// "altF9": true, /* focus on the File Menu and/or editbar */
// ) ends with `*/` but is NOT a comment continuation. Only treat a
// previous line as part of the leading comment block if it structurally
// opens (`/*`), continues (`*` — JSDoc style, covers ` */` close), or
// is a single-line `//` comment. This matches the comment styles used
// in settings.json.template; #7740.
const isComment =
trimmed.startsWith('//') ||
trimmed.startsWith('/*') ||
trimmed.startsWith('*');
if (!isComment) break;
collected.unshift(line);
cursor = prevLineStart;
}
return stripCommentMarkers(collected.join('\n'));
};
const findTrailing = (text: string, valueOffset: number, valueLength: number): string => {
// Trailing comments only exist on the same line as the value. If there's
// no newline after the value the file has no line structure (e.g. minified
// settings.json) and `//` inside any later string literal would otherwise
// be matched as a comment.
const lineEnd = text.indexOf('\n', valueOffset + valueLength);
if (lineEnd === -1) return '';
const slice = text.slice(valueOffset + valueLength, lineEnd);
const m = /,?\s*(\/\/.*|\/\*.*?\*\/)\s*$/.exec(slice);
return m ? stripCommentMarkers(m[1]) : '';
};
export const extractAdjacentComments = (
text: string,
keyOffset: number,
valueOffset: number,
valueLength: number,
): AdjacentComments => ({
leading: findLeading(text, keyOffset),
trailing: findTrailing(text, valueOffset, valueLength),
});

View file

@ -1,21 +0,0 @@
// admin/src/components/settings/envPill.ts
//
// Detect `"${VAR}"` and `"${VAR:default}"` placeholders inside the raw
// slice of a string node. The slice INCLUDES the surrounding quotes,
// because jsonc-parser exposes node.offset/length over the whole literal.
export type EnvPlaceholder = {
variable: string;
defaultValue: string | null;
};
const RE = /^"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::([^}]*))?\}"$/;
export const matchEnvPlaceholder = (rawSlice: string): EnvPlaceholder | null => {
const m = RE.exec(rawSlice);
if (!m) return null;
return {
variable: m[1],
defaultValue: m[2] ?? null,
};
};

View file

@ -1,11 +0,0 @@
// admin/src/components/settings/jsoncEdit.ts
import { applyEdits, modify, type JSONPath } from 'jsonc-parser';
const FORMATTING = {
formattingOptions: { tabSize: 2, insertSpaces: true, eol: '\n' as const },
};
export const editJsonc = (text: string, path: JSONPath, value: unknown): string => {
const edits = modify(text, path, value, FORMATTING);
return edits.length === 0 ? text : applyEdits(text, edits);
};

View file

@ -1,46 +0,0 @@
// Pretty-label derivation. The first sentence of a key's documentation
// comment is its label; the rest stays in the help-text slot. When no
// comment exists, fall back to a humanized key name (camelCase → "Camel
// case").
const SENTENCE_END = /[.!?](\s|$)/;
export const humanize = (key: string): string => {
if (!key) return key;
// Split camelCase / PascalCase / snake_case / kebab-case
const words = key
.replace(/[_-]+/g, ' ')
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.toLowerCase()
.trim()
.split(/\s+/);
if (words.length === 0) return key;
return words[0].charAt(0).toUpperCase() + words[0].slice(1) +
(words.length > 1 ? ' ' + words.slice(1).join(' ') : '');
};
const splitFirstSentence = (text: string): { head: string; rest: string } => {
const trimmed = text.trim();
const m = SENTENCE_END.exec(trimmed);
if (!m) return { head: trimmed, rest: '' };
const cut = m.index + 1; // include the punctuation
return {
head: trimmed.slice(0, cut).trim(),
rest: trimmed.slice(cut).trim(),
};
};
export const labelAndHelp = (
comment: string | null | undefined,
key: string,
): { label: string; help: string } => {
if (!comment || !comment.trim()) {
return { label: humanize(key), help: '' };
}
const { head, rest } = splitFirstSentence(comment);
return {
label: head || humanize(key),
help: rest,
};
};

View file

@ -1,50 +0,0 @@
// Build a fallback path → comment map from `settings.json.template`. The live
// settings.json is per-developer and often lacks comments; the template is the
// authoritative source of per-key documentation.
import { parseTree, type JSONPath, type Node } from 'jsonc-parser';
import { extractAdjacentComments, type AdjacentComments } from './comments';
// Injected by Vite at build time from settings.json.template (see vite.config.ts).
// Inlining at config time avoids widening the dev server's filesystem allowlist
// to the repo root, which would expose settings.json/credentials.json over the
// dev server.
declare const __SETTINGS_TEMPLATE__: string;
const templateText: string = __SETTINGS_TEMPLATE__;
const pathKey = (path: JSONPath): string => path.map(String).join('.');
const buildMap = (text: string): Map<string, AdjacentComments> => {
const map = new Map<string, AdjacentComments>();
const tree = parseTree(text, [], { allowTrailingComma: true });
if (!tree) return map;
const walk = (node: Node, path: JSONPath) => {
if (node.type === 'object') {
for (const prop of node.children ?? []) {
if (prop.type !== 'property' || !prop.children || prop.children.length < 2) continue;
const keyNode = prop.children[0];
const valueNode = prop.children[1];
if (keyNode.type !== 'string') continue;
const childPath = [...path, String(keyNode.value)];
const adjacent = extractAdjacentComments(
text, prop.offset, valueNode.offset, valueNode.length,
);
if (adjacent.leading || adjacent.trailing) {
map.set(pathKey(childPath), adjacent);
}
walk(valueNode, childPath);
}
} else if (node.type === 'array') {
(node.children ?? []).forEach((child, i) => walk(child, [...path, i]));
}
};
walk(tree, []);
return map;
};
const templateMap = buildMap(templateText);
export const lookupTemplateComment = (path: JSONPath): AdjacentComments | null =>
templateMap.get(pathKey(path)) ?? null;

View file

@ -1,20 +0,0 @@
import * as Switch from '@radix-ui/react-switch';
import type { JSONPath } from 'jsonc-parser';
type Props = {
value: boolean;
path: JSONPath;
onChange: (next: boolean) => void;
};
export const BooleanToggle = ({ value, path, onChange }: Props) => (
<Switch.Root
checked={value}
onCheckedChange={onChange}
id={`field-${path.join('.')}`}
className="settings-widget settings-widget-boolean"
data-testid={`field-${path.join('.')}`}
>
<Switch.Thumb className="settings-widget-boolean-thumb" />
</Switch.Root>
);

View file

@ -1,96 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { JSONPath } from 'jsonc-parser';
import type { EnvPlaceholder } from '../envPill';
const REDACTED = '[REDACTED]';
type Props = {
placeholder: EnvPlaceholder;
path: JSONPath;
onChange: (newDefault: string) => void;
resolvedValue?: unknown;
};
const sanitize = (s: string) => s.replace(/[}]/g, '');
const formatDisplay = (v: unknown): string => {
if (v === null) return 'null';
if (typeof v === 'string') return v;
return String(v);
};
export const EnvPill = ({ placeholder, path, onChange, resolvedValue }: Props) => {
const { t } = useTranslation();
const initial = placeholder.defaultValue ?? '';
const [draft, setDraft] = useState(initial);
const focused = useRef(false);
useEffect(() => {
if (!focused.current) setDraft(initial);
}, [initial]);
const id = `field-${path.join('.')}`;
const testid = `env-${path.join('.')}`;
// Three runtime states:
// undefined → server didn't send resolved (old server, or path absent)
// '[REDACTED]' → secret hidden
// anything else → live runtime value
const hasResolved = resolvedValue !== undefined;
const isRedacted = resolvedValue === REDACTED;
return (
<span
className="settings-widget settings-widget-env"
title={t('admin_settings.env_pill.tooltip', { variable: placeholder.variable })}
>
<span className="settings-widget-env-icon" aria-hidden></span>
<span className="settings-widget-env-name">{placeholder.variable}</span>
<span className="settings-widget-env-default-label" aria-hidden>
{t('admin_settings.env_pill.default_label')}
</span>
<input
id={id}
data-testid={testid}
className="settings-widget-env-default-input"
type="text"
value={draft}
spellCheck={false}
aria-label={t('admin_settings.env_pill.input_aria', { variable: placeholder.variable })}
onFocus={() => { focused.current = true; }}
onBlur={() => { focused.current = false; }}
onChange={e => {
const v = sanitize(e.target.value);
setDraft(v);
onChange(v);
}}
/>
{hasResolved && !isRedacted && (
<span
className="settings-widget-env-runtime"
data-testid={`env-runtime-${path.join('.')}`}
title={t('admin_settings.env_pill.runtime_tooltip', { variable: placeholder.variable })}
>
<span className="settings-widget-env-runtime-arrow" aria-hidden></span>
<span className="settings-widget-env-runtime-label" aria-hidden>
{t('admin_settings.env_pill.runtime_label')}
</span>
<span className="settings-widget-env-runtime-value">
{formatDisplay(resolvedValue)}
</span>
</span>
)}
{isRedacted && (
<span
className="settings-widget-env-runtime settings-widget-env-runtime-redacted"
data-testid={`env-runtime-redacted-${path.join('.')}`}
title={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
aria-label={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
>
<span aria-hidden> </span>
</span>
)}
</span>
);
};

View file

@ -1,10 +0,0 @@
import type { JSONPath } from 'jsonc-parser';
type Props = { path: JSONPath };
export const NullChip = ({ path }: Props) => (
<span
className="settings-widget settings-widget-null"
data-testid={`field-${path.join('.')}`}
>null</span>
);

View file

@ -1,48 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import type { JSONPath } from 'jsonc-parser';
type Props = {
value: number;
path: JSONPath;
onChange: (next: number) => void;
};
export const NumberInput = ({ value, path, onChange }: Props) => {
const [draft, setDraft] = useState(String(value));
const [invalid, setInvalid] = useState(false);
const focusedRef = useRef(false);
// Sync draft when the prop value changes (e.g. after a server round-trip
// canonicalises the number) — but only when the input is not focused so we
// don't stomp on the user while they are typing.
useEffect(() => {
if (!focusedRef.current) {
setDraft(String(value));
setInvalid(false);
}
}, [value]);
return (
<input
type="text"
inputMode="decimal"
id={`field-${path.join('.')}`}
className={'settings-widget settings-widget-number' + (invalid ? ' invalid' : '')}
data-testid={`field-${path.join('.')}`}
value={draft}
onFocus={() => { focusedRef.current = true; }}
onBlur={() => { focusedRef.current = false; }}
onChange={e => {
const next = e.target.value;
setDraft(next);
const parsed = Number(next);
if (next.trim() !== '' && Number.isFinite(parsed)) {
setInvalid(false);
onChange(parsed);
} else {
setInvalid(true);
}
}}
/>
);
};

View file

@ -1,19 +0,0 @@
import type { JSONPath } from 'jsonc-parser';
type Props = {
value: string;
path: JSONPath;
onChange: (next: string) => void;
};
export const StringInput = ({ value, path, onChange }: Props) => (
<input
type="text"
id={`field-${path.join('.')}`}
className="settings-widget settings-widget-string"
data-testid={`field-${path.join('.')}`}
value={value}
spellCheck={false}
onChange={e => onChange(e.target.value)}
/>
);

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,47 +1,36 @@
import i18n from 'i18next'
import {initReactI18next} from "react-i18next";
import LanguageDetector from 'i18next-browser-languagedetector'
import type {BackendModule} from 'i18next';
// Core translations live in /src/locales (shared with the pad UI). Letting
// Vite resolve them via import.meta.glob means each language ships as its own
// hashed JSON chunk, lazy-loaded on demand — no build-time copy step or
// /admin/locales/* express route. Earlier setups copying files into the build
// output were fragile (see https://github.com/ether/etherpad/issues/7586).
const coreLocales = import.meta.glob<{default: Record<string, unknown>}>(
'../../../src/locales/*.json');
const coreLocaleByLang = (language: string) =>
coreLocales[`../../../src/locales/${language}.json`];
import { BackendModule } from 'i18next';
const LazyImportPlugin: BackendModule = {
type: 'backend',
init: function () {
},
read: async function (language, namespace, callback) {
try {
if (namespace === 'translation') {
const loader = coreLocaleByLang(language);
if (!loader) {
callback(new Error(`No core locale for "${language}"`), null);
return;
}
const mod = await loader();
callback(null, mod.default);
return;
}
// Plugin namespaces (e.g. ep_admin_pads) are still served as static
// assets from admin/public/<namespace>/<lang>.json.
const baseURL = `${import.meta.env.BASE_URL}/${namespace}/${language}.json`;
const res = await fetch(baseURL);
if (!res.ok) {
callback(new Error(`HTTP ${res.status} loading ${baseURL}`), null);
return;
}
callback(null, await res.json());
} catch (e) {
callback(e instanceof Error ? e : new Error(String(e)), null);
let baseURL = import.meta.env.BASE_URL
if(namespace === "translation") {
// If default we load the translation file
baseURL+=`/locales/${language}.json`
} else {
// Else we load the former plugin translation file
baseURL+=`/${namespace}/${language}.json`
}
const localeJSON = await fetch(baseURL)
let json;
try {
json = JSON.parse(await localeJSON.text())
} catch(e) {
callback(new Error("Error loading"), null);
}
callback(null, json);
},
save: function () {
@ -58,7 +47,7 @@ i18n
.use(initReactI18next)
.init(
{
ns: ['translation','ep_admin_pads','ep_admin_authors'],
ns: ['translation','ep_admin_pads'],
fallbackLng: 'en'
}
)

View file

@ -11,11 +11,8 @@ import * as Toast from '@radix-ui/react-toast'
import {I18nextProvider} from "react-i18next";
import i18n from "./localization/i18n.ts";
import {PadPage} from "./pages/PadPage.tsx";
import {AuthorPage} from "./pages/AuthorPage.tsx";
import {ToastDialog} from "./utils/Toast.tsx";
import {ShoutPage} from "./pages/ShoutPage.tsx";
import {UpdatePage} from "./pages/UpdatePage.tsx";
import {QueryProvider} from './api/QueryProvider.tsx';
const router = createBrowserRouter(createRoutesFromElements(
<><Route element={<App/>}>
@ -24,9 +21,7 @@ const router = createBrowserRouter(createRoutesFromElements(
<Route path="/settings" element={<SettingsPage/>}/>
<Route path="/help" element={<HelpPage/>}/>
<Route path="/pads" element={<PadPage/>}/>
<Route path="/authors" element={<AuthorPage/>}/>
<Route path="/shout" element={<ShoutPage/>}/>
<Route path="/update" element={<UpdatePage/>}/>
</Route><Route path="/login">
<Route index element={<LoginScreen/>}/>
</Route></>
@ -37,13 +32,11 @@ const router = createBrowserRouter(createRoutesFromElements(
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryProvider>
<I18nextProvider i18n={i18n}>
<Toast.Provider>
<ToastDialog/>
<RouterProvider router={router}/>
</Toast.Provider>
</I18nextProvider>
</QueryProvider>
<I18nextProvider i18n={i18n}>
<Toast.Provider>
<ToastDialog/>
<RouterProvider router={router}/>
</Toast.Provider>
</I18nextProvider>
</React.StrictMode>,
)

View file

@ -1,300 +0,0 @@
import {Trans, useTranslation} from "react-i18next";
import {useEffect, useMemo, useState} from "react";
import * as Dialog from "@radix-ui/react-dialog";
import {VisuallyHidden} from "@radix-ui/react-visually-hidden";
import {ChevronLeft, ChevronRight, Trash2} from "lucide-react";
import {useStore} from "../store/store.ts";
import {SearchField} from "../components/SearchField.tsx";
import {ColorSwatch} from "../components/ColorSwatch.tsx";
import {IconButton} from "../components/IconButton.tsx";
import {determineSorting} from "../utils/sorting.ts";
import {useDebounce} from "../utils/useDebounce.ts";
import {
AnonymizePreview, AnonymizeResult, AuthorRow, AuthorSearchQuery,
AuthorSearchResult, AuthorSortBy,
} from "../utils/AuthorSearch.ts";
type DialogState =
| {phase: 'closed'}
| {phase: 'loading-preview', authorID: string, name: string | null}
| {phase: 'preview', preview: AnonymizePreview}
| {phase: 'committing', preview: AnonymizePreview};
export const AuthorPage = () => {
const {t} = useTranslation();
const settingsSocket = useStore((s) => s.settingsSocket);
const authors = useStore((s) => s.authors);
const setAuthors = useStore((s) => s.setAuthors);
const erasureEnabled = useStore((s) => s.gdprAuthorErasureEnabled);
const [searchTerm, setSearchTerm] = useState('');
const [includeErased, setIncludeErased] = useState(false);
const [searchParams, setSearchParams] = useState<AuthorSearchQuery>({
pattern: '', offset: 0, limit: 12,
sortBy: 'name', ascending: true, includeErased: false,
});
const [currentPage, setCurrentPage] = useState(0);
const [dialog, setDialog] = useState<DialogState>({phase: 'closed'});
const pages = useMemo(() => {
if (!authors) return 0;
return Math.ceil(authors.total / searchParams.limit);
}, [authors, searchParams.limit]);
useDebounce(() => {
setCurrentPage(0);
setSearchParams((p) => ({...p, pattern: searchTerm, offset: 0}));
}, 500, [searchTerm]);
useEffect(() => {
setSearchParams((p) => ({...p, includeErased, offset: 0}));
setCurrentPage(0);
}, [includeErased]);
useEffect(() => {
if (!settingsSocket) return;
settingsSocket.emit('authorLoad', searchParams);
}, [settingsSocket, searchParams]);
useEffect(() => {
if (!settingsSocket) return;
const onLoad = (data: AuthorSearchResult) => setAuthors(data);
const onPreview = (data: AnonymizePreview) => {
if (data.error) {
useStore.getState().setToastState({
open: true, success: false,
title: t('ep_admin_authors:erase-error-toast', {error: data.error}),
});
setDialog({phase: 'closed'});
return;
}
setDialog((cur) =>
cur.phase === 'loading-preview' && cur.authorID === data.authorID
? {phase: 'preview', preview: data}
: cur);
};
const onErase = (data: AnonymizeResult) => {
if (data.error) {
useStore.getState().setToastState({
open: true, success: false,
title: t('ep_admin_authors:erase-error-toast', {error: data.error}),
});
setDialog({phase: 'closed'});
return;
}
useStore.getState().setToastState({
open: true, success: true,
title: t('ep_admin_authors:erase-success-toast', {authorID: data.authorID}),
});
const cur = useStore.getState().authors;
if (cur) {
setAuthors({
...cur,
results: cur.results.map((r): AuthorRow =>
r.authorID === data.authorID
? {...r, name: null, erased: true, mapper: []}
: r),
});
}
setDialog({phase: 'closed'});
};
settingsSocket.on('results:authorLoad', onLoad);
settingsSocket.on('results:anonymizeAuthorPreview', onPreview);
settingsSocket.on('results:anonymizeAuthor', onErase);
return () => {
settingsSocket.off('results:authorLoad', onLoad);
settingsSocket.off('results:anonymizeAuthorPreview', onPreview);
settingsSocket.off('results:anonymizeAuthor', onErase);
};
}, [settingsSocket, setAuthors, t]);
const sortBy = (col: AuthorSortBy) => () => {
setCurrentPage(0);
setSearchParams((p) => ({
...p, sortBy: col,
ascending: p.sortBy === col ? !p.ascending : true,
offset: 0,
}));
};
const openErase = (row: AuthorRow) => {
setDialog({phase: 'loading-preview', authorID: row.authorID, name: row.name});
settingsSocket?.emit('anonymizeAuthorPreview', {authorID: row.authorID});
};
const commitErase = () => {
if (dialog.phase !== 'preview') return;
setDialog({phase: 'committing', preview: dialog.preview});
settingsSocket?.emit('anonymizeAuthor', {authorID: dialog.preview.authorID});
};
const lastSeenLabel = (row: AuthorRow) =>
row.lastSeen
? new Date(row.lastSeen).toLocaleString()
: t('ep_admin_authors:never-seen');
const mapperLabel = (row: AuthorRow) => {
if (row.mapper.length === 0) return t('ep_admin_authors:no-mappers');
if (row.mapper.length === 1) return row.mapper[0];
return `${row.mapper[0]} +${row.mapper.length - 1}`;
};
return <div>
{!erasureEnabled && (
<div role="alert"
style={{margin: '0 0 12px', padding: '12px',
background: '#fff8e1', border: '1px solid #f0c36d',
borderRadius: 4}}>
<Trans i18nKey="ep_admin_authors:feature-disabled-banner"
ns="ep_admin_authors"/>
</div>
)}
<Dialog.Root open={dialog.phase !== 'closed'}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<VisuallyHidden asChild>
<Dialog.Title>{t('ep_admin_authors:confirm-dialog-title')}</Dialog.Title>
</VisuallyHidden>
<VisuallyHidden asChild>
<Dialog.Description>{t('ep_admin_authors:confirm-dialog-description')}</Dialog.Description>
</VisuallyHidden>
{dialog.phase === 'loading-preview' && <div>
<Trans i18nKey="ep_admin_authors:loading-preview" ns="ep_admin_authors"/>
</div>}
{(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => {
const p = dialog.preview;
return <div>
<h3>{t('ep_admin_authors:confirm-preview-title',
{name: p.name || p.authorID})}</h3>
<p>{t('ep_admin_authors:confirm-preview-counters', {
tokenMappings: p.removedTokenMappings,
externalMappings: p.removedExternalMappings,
chatMessages: p.clearedChatMessages,
affectedPads: p.affectedPads,
})}</p>
<p><strong>
<Trans i18nKey="ep_admin_authors:confirm-irreversible"
ns="ep_admin_authors"/>
</strong></p>
<div className="settings-button-bar">
<button onClick={() => setDialog({phase: 'closed'})}
disabled={dialog.phase === 'committing'}>
<Trans i18nKey="ep_admin_authors:cancel"
ns="ep_admin_authors"/>
</button>
<button onClick={commitErase}
disabled={dialog.phase === 'committing' || !erasureEnabled}
title={erasureEnabled ? undefined :
t('ep_admin_authors:erase-disabled-tooltip')}>
<Trans i18nKey="ep_admin_authors:continue"
ns="ep_admin_authors"/>
</button>
</div>
{dialog.phase === 'committing' && <p style={{marginTop: 8}}>
<Trans i18nKey="ep_admin_authors:erasing"
ns="ep_admin_authors"/>
</p>}
</div>;
})()}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<span className="manage-pads-header">
<h1>
<Trans i18nKey="ep_admin_authors:title" ns="ep_admin_authors"/>
</h1>
</span>
<SearchField value={searchTerm}
onChange={(v) => setSearchTerm(v.target.value)}
placeholder={t('ep_admin_authors:search-placeholder')}/>
<label style={{display: 'inline-flex', alignItems: 'center', gap: 6,
margin: '8px 0'}}>
<input type="checkbox" checked={includeErased}
onChange={(e) => setIncludeErased(e.target.checked)}/>
<Trans i18nKey="ep_admin_authors:show-erased" ns="ep_admin_authors"/>
</label>
{authors?.cappedAt != null && (
<p style={{color: '#a35'}}>
<Trans i18nKey="ep_admin_authors:cap-warning" ns="ep_admin_authors"/>
</p>
)}
<table>
<thead>
<tr className="search-pads">
<th><Trans i18nKey="ep_admin_authors:column.color" ns="ep_admin_authors"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'name')}
onClick={sortBy('name')}>
<Trans i18nKey="ep_admin_authors:column.name" ns="ep_admin_authors"/>
</th>
<th><Trans i18nKey="ep_admin_authors:column.mapper" ns="ep_admin_authors"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'lastSeen')}
onClick={sortBy('lastSeen')}>
<Trans i18nKey="ep_admin_authors:column.last-seen" ns="ep_admin_authors"/>
</th>
<th><Trans i18nKey="ep_admin_authors:column.author-id" ns="ep_admin_authors"/></th>
<th><Trans i18nKey="ep_admin_authors:column.actions" ns="ep_admin_authors"/></th>
</tr>
</thead>
<tbody className="search-pads-body">
{authors?.results.length === 0 && <tr><td colSpan={6}
style={{textAlign: 'center', padding: '12px'}}>
<Trans i18nKey="ep_admin_authors:no-results" ns="ep_admin_authors"/>
</td></tr>}
{authors?.results.map((row) => (
<tr key={row.authorID}>
<td style={{textAlign: 'center'}}><ColorSwatch color={row.colorId}/></td>
<td style={{textAlign: 'center'}}>
{row.erased
? <em><Trans i18nKey="ep_admin_authors:erased-stub"
ns="ep_admin_authors"/></em>
: (row.name ?? '—')}
</td>
<td style={{textAlign: 'center'}} title={row.mapper.join(', ')}>
{mapperLabel(row)}
</td>
<td style={{textAlign: 'center'}}>{lastSeenLabel(row)}</td>
<td style={{textAlign: 'center', fontFamily: 'monospace'}}>
{row.authorID}
</td>
<td>
<div className="settings-button-bar">
<IconButton icon={<Trash2/>}
title={<Trans i18nKey="ep_admin_authors:erase"
ns="ep_admin_authors"/>}
onClick={() => openErase(row)}
disabled={!erasureEnabled || row.erased}/>
</div>
</td>
</tr>
))}
</tbody>
</table>
<div className="settings-button-bar pad-pagination">
<button disabled={currentPage === 0} onClick={() => {
setCurrentPage(currentPage - 1);
setSearchParams((p) => ({...p,
offset: (currentPage - 1) * searchParams.limit}));
}}><ChevronLeft/><span>
<Trans i18nKey="ep_admin_authors:prev-page" ns="ep_admin_authors"/>
</span></button>
<span>{t('ep_admin_authors:page-counter',
{current: currentPage + 1, total: pages})}</span>
<button disabled={pages === 0 || pages === currentPage + 1} onClick={() => {
const next = currentPage + 1;
setCurrentPage(next);
setSearchParams((p) => ({...p,
offset: next * searchParams.limit}));
}}><span>
<Trans i18nKey="ep_admin_authors:next-page" ns="ep_admin_authors"/>
</span><ChevronRight/></button>
</div>
</div>;
};

View file

@ -1,225 +1,70 @@
import {Trans, useTranslation} from "react-i18next";
import {Trans} from "react-i18next";
import {useStore} from "../store/store.ts";
import {useEffect, useMemo, useState} from "react";
import {useEffect, useState} from "react";
import {HelpObj} from "./Plugin.ts";
import {Copy, Search, X, Plug} from "lucide-react";
export const HelpPage = () => {
const settingsSocket = useStore(state => state.settingsSocket)
const {t} = useTranslation()
const [helpData, setHelpData] = useState<HelpObj>()
const [tab, setTab] = useState<'server' | 'client'>('server')
const [q, setQ] = useState('')
const settingsSocket = useStore(state=>state.settingsSocket)
const [helpData, setHelpData] = useState<HelpObj>();
useEffect(() => {
if (!settingsSocket) return
settingsSocket.on('reply:help', (data) => setHelpData(data))
settingsSocket.emit('help')
}, [settingsSocket])
if(!settingsSocket) return;
settingsSocket?.on('reply:help', (data) => {
setHelpData(data)
});
const serverHooks = useMemo(() => {
if (!helpData) return []
return Object.keys(helpData.installedServerHooks).map(hookName => ({
name: hookName,
parts: Object.keys((helpData.installedServerHooks as Record<string, Record<string, unknown>>)[hookName] ?? {}),
}))
}, [helpData])
settingsSocket?.emit('help');
}, [settingsSocket]);
const clientHooks = useMemo(() => {
if (!helpData) return []
return Object.keys(helpData.installedClientHooks).map(hookName => ({
name: hookName,
parts: Object.keys(helpData.installedClientHooks[hookName] ?? {}),
}))
}, [helpData])
const hooks = tab === 'server' ? serverHooks : clientHooks
const filteredHooks = useMemo(() => {
if (!q.trim()) return hooks
const s = q.toLowerCase()
return hooks.filter(h =>
h.name.toLowerCase().includes(s) || h.parts.some(p => p.toLowerCase().includes(s))
)
}, [hooks, q])
const totalBindings = hooks.reduce((n, h) => n + h.parts.length, 0)
const updateAvailable = helpData
? helpData.epVersion.localeCompare(helpData.latestVersion, undefined, {numeric: true}) < 0
: false
const copyDiag = () => {
if (!helpData) return
navigator.clipboard?.writeText(JSON.stringify({
version: helpData.epVersion,
latestVersion: helpData.latestVersion,
gitCommit: helpData.gitCommit,
plugins: helpData.installedPlugins.length,
parts: helpData.installedParts.length,
hookBindings: totalBindings,
}, null, 2))
const renderHooks = (hooks:Record<string, Record<string, string>>) => {
return Object.keys(hooks).map((hookName, i) => {
return <div key={hookName+i}>
<h3>{hookName}</h3>
<ul>
{Object.keys(hooks[hookName]).map((hook, i) => <li key={hook+i}>{hook}
<ul key={hookName+hook+i}>
{Object.keys(hooks[hookName][hook]).map((subHook, i) => <li key={i}>{subHook}</li>)}
</ul>
</li>)}
</ul>
</div>
})
}
if (!helpData) return (
<div className="pm-page">
<div className="pm-empty"><div className="pm-empty-icon"></div></div>
if (!helpData) return <div></div>
return <div>
<h1><Trans i18nKey="admin_plugins_info.version"/></h1>
<div className="help-block">
<div><Trans i18nKey="admin_plugins_info.version_number"/></div>
<div>{helpData?.epVersion}</div>
<div><Trans i18nKey="admin_plugins_info.version_latest"/></div>
<div>{helpData.latestVersion}</div>
<div>Git sha</div>
<div>{helpData.gitCommit}</div>
</div>
)
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
<ul>
{helpData.installedPlugins.map((plugin, i) => <li key={plugin+i}>{plugin}</li>)}
</ul>
return (
<div className="pm-page">
<h2><Trans i18nKey="admin_plugins_info.parts"/></h2>
<ul>
{helpData.installedParts.map((part, i) => <li key={part+i}>{part}</li>)}
</ul>
{/* ── Page header ── */}
<div className="pm-header">
<div>
<div className="pm-crumbs">Admin <span className="pm-crumbs-sep"></span> <Trans i18nKey="admin_plugins_info"/></div>
<h1 className="pm-title"><Trans i18nKey="admin_plugins_info"/></h1>
<p className="pm-subtitle"><Trans i18nKey="admin_plugins_info.subtitle"/></p>
</div>
<div className="pm-header-actions">
<button className="pm-btn pm-btn-ghost" onClick={copyDiag}>
<Copy size={14}/> <Trans i18nKey="admin_plugins_info.copy_diagnostics"/>
</button>
</div>
</div>
<h2><Trans i18nKey="admin_plugins_info.hooks"/></h2>
{
renderHooks(helpData.installedServerHooks)
}
{/* ── Version block ── */}
<section className="pm-help-version">
<div className="pm-hv-main">
<div className="pm-hv-lbl"><Trans i18nKey="admin_plugins_info.version"/></div>
<div className="pm-hv-num">{helpData.epVersion}</div>
<div className={`pm-hv-status${updateAvailable ? ' is-warn' : ' is-ok'}`}>
<span className="pm-hv-dot"/>
{updateAvailable
? t('admin_plugins_info.update_available', {version: helpData.latestVersion})
: t('admin_plugins_info.up_to_date')}
</div>
</div>
<div className="pm-hv-meta">
<div className="pm-hv-cell">
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins_info.version_latest"/></div>
<div className="pm-hv-cell-val">{helpData.latestVersion}</div>
</div>
<div className="pm-hv-cell">
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins_info.git_sha"/></div>
<div className="pm-hv-cell-val pm-mono">
{helpData.gitCommit}
<button
className="pm-mini-btn"
onClick={() => navigator.clipboard?.writeText(helpData.gitCommit)}
title={t('admin_plugins_info.copy_value', {label: t('admin_plugins_info.git_sha')})}
>
<Copy size={11}/>
</button>
</div>
</div>
<div className="pm-hv-cell">
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins.installed"/></div>
<div className="pm-hv-cell-val">{helpData.installedPlugins.length}</div>
</div>
<div className="pm-hv-cell">
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins_info.parts"/></div>
<div className="pm-hv-cell-val">{helpData.installedParts.length}</div>
</div>
<div className="pm-hv-cell">
<div className="pm-hv-cell-lbl"><Trans i18nKey="admin_plugins_info.hook_bindings"/></div>
<div className="pm-hv-cell-val">{totalBindings}</div>
</div>
</div>
</section>
<h2>
<Trans i18nKey="admin_plugins_info.hooks_client"/>
{
renderHooks(helpData.installedClientHooks)
}
</h2>
{/* ── Plugins + Parts ── */}
<section className="pm-section">
<div className="pm-help-grid">
<div className="pm-help-card">
<div className="pm-section-header pm-sec-tight">
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
<span className="pm-count-badge">{helpData.installedPlugins.length}</span>
</div>
<div className="pm-tag-cloud">
{helpData.installedPlugins.map(p => (
<span key={p} className="pm-pill pm-pill-mono">
<span className="pm-pill-ico"><Plug size={11}/></span>
{p}
</span>
))}
</div>
</div>
<div className="pm-help-card">
<div className="pm-section-header pm-sec-tight">
<h2><Trans i18nKey="admin_plugins_info.parts"/></h2>
<span className="pm-count-badge">{helpData.installedParts.length}</span>
</div>
<div className="pm-tag-cloud">
{helpData.installedParts.map(p => {
const slash = p.indexOf('/')
const ns = slash >= 0 ? p.slice(0, slash) : p
const name = slash >= 0 ? p.slice(slash + 1) : ''
return (
<span key={p} className="pm-pill pm-pill-mono" title={p}>
<span className="pm-pill-ns">{ns}</span>
{name && <><span className="pm-pill-sep">/</span><span>{name}</span></>}
</span>
)
})}
</div>
</div>
</div>
</section>
{/* ── Hooks ── */}
<section className="pm-section">
<div className="pm-section-header">
<h2><Trans i18nKey="admin_plugins_info.hooks"/></h2>
<span className="pm-count-badge">{filteredHooks.length}</span>
<div className="pm-spacer"/>
<div className="pm-toolbar">
<div className="pm-tabs">
<button className={`pm-tab${tab === 'server' ? ' is-on' : ''}`} onClick={() => setTab('server')}>
<Trans i18nKey="admin_plugins_info.tab_server"/> <span className="pm-tab-n">{serverHooks.length}</span>
</button>
<button className={`pm-tab${tab === 'client' ? ' is-on' : ''}`} onClick={() => setTab('client')}>
<Trans i18nKey="admin_plugins_info.tab_client"/> <span className="pm-tab-n">{clientHooks.length}</span>
</button>
</div>
<div className="pm-search">
<Search size={14} className="pm-search-icon"/>
<input
className="pm-search-input"
value={q}
onChange={e => setQ(e.target.value)}
placeholder={t('admin_plugins_info.search_placeholder')}
/>
{q && <button className="pm-search-clear" onClick={() => setQ('')}><X size={12}/></button>}
</div>
</div>
</div>
{filteredHooks.length > 0 ? (
<div className="pm-hooks">
{filteredHooks.map(h => (
<div key={h.name} className="pm-hook">
<div className="pm-hook-h">
<span className="pm-hook-name">{h.name}</span>
<span className="pm-hook-count">{t('admin_plugins_info.bindings_label', {count: h.parts.length})}</span>
</div>
<div className="pm-hook-parts">
{h.parts.map(p => (
<span key={p} className="pm-pill pm-pill-mono pm-pill-sm">{p}</span>
))}
</div>
</div>
))}
</div>
) : (
<div className="pm-empty">
<div className="pm-empty-icon"></div>
<div className="pm-empty-title"><Trans i18nKey="admin_plugins_info.no_hooks"/></div>
</div>
)}
</section>
</div>
)
</div>
}

View file

@ -3,400 +3,245 @@ import {useEffect, useMemo, useState} from "react";
import {InstalledPlugin, PluginDef, SearchParams} from "./Plugin.ts";
import {useDebounce} from "../utils/useDebounce.ts";
import {Trans, useTranslation} from "react-i18next";
import {ArrowUpFromDot, Download, ExternalLink, Plug, RefreshCw, Search, Trash, X} from "lucide-react";
import {SearchField} from "../components/SearchField.tsx";
import {ArrowUpFromDot, Download, Trash} from "lucide-react";
import {IconButton} from "../components/IconButton.tsx";
import {determineSorting} from "../utils/sorting.ts";
export const HomePage = () => {
const pluginsSocket = useStore(state => state.pluginsSocket)
const [plugins, setPlugins] = useState<PluginDef[]>([])
const [catalogDisabled, setCatalogDisabled] = useState(false)
const installedPlugins = useStore(state => state.installedPlugins)
const setInstalledPlugins = useStore(state => state.setInstalledPlugins)
// Default sort: name ascending. PR #7716 set this to "downloads desc" but
// the backend (src/static/js/pluginfw/installer.ts) never populates
// `downloads`, so the "Most popular" sort/"Popular" tag/Downloads column
// were dead UI — removed alongside this default.
const pluginsSocket = useStore(state=>state.pluginsSocket)
const [plugins,setPlugins] = useState<PluginDef[]>([])
const installedPlugins = useStore(state=>state.installedPlugins)
const setInstalledPlugins = useStore(state=>state.setInstalledPlugins)
const [searchParams, setSearchParams] = useState<SearchParams>({
offset: 0,
limit: 99999,
sortBy: 'name',
sortDir: 'asc',
searchTerm: '',
searchTerm: ''
})
const [searchTerm, setSearchTerm] = useState<string>('')
const {t} = useTranslation()
const updatableCount = useMemo(
() => installedPlugins.filter(p => p.updatable).length,
[installedPlugins]
)
// "Core" plugins are the ones Etherpad ships as part of the runtime
// (currently just ep_etherpad-lite). Derive from data rather than
// hardcoding 1 — future packaging changes may bundle more.
const coreCount = useMemo(
() => installedPlugins.filter(p => p.name === 'ep_etherpad-lite').length,
[installedPlugins]
)
const sortedInstalledPlugins = useMemo(
() => [...installedPlugins].sort((a, b) => a.name.localeCompare(b.name)),
[installedPlugins]
)
const filteredInstallablePlugins = useMemo(() => {
return [...plugins].sort((a, b) => {
const dir = searchParams.sortDir === 'asc' ? 1 : -1
if (searchParams.sortBy === 'version') {
return a.version.localeCompare(b.version) * dir
const filteredInstallablePlugins = useMemo(()=>{
return plugins.sort((a, b)=>{
if(searchParams.sortBy === "version"){
if(searchParams.sortDir === "asc"){
return a.version.localeCompare(b.version)
}
return b.version.localeCompare(a.version)
}
if (searchParams.sortBy === 'last-updated') {
return a.time.localeCompare(b.time) * dir
if(searchParams.sortBy === "last-updated"){
if(searchParams.sortDir === "asc"){
return a.time.localeCompare(b.time)
}
return b.time.localeCompare(a.time)
}
return a.name.localeCompare(b.name) * dir
if (searchParams.sortBy === "name") {
if(searchParams.sortDir === "asc"){
return a.name.localeCompare(b.name)
}
return b.name.localeCompare(a.name)
}
return 0
})
}, [plugins, searchParams])
useEffect(() => {
if (!pluginsSocket) return
const sortedInstalledPlugins = useMemo(()=>{
return useStore.getState().installedPlugins.sort((a, b)=>{
const onInstalled = (data: {installed: InstalledPlugin[]}) => {
setInstalledPlugins(data.installed)
}
const onUpdatable = (data: {updatable: string[]}) => {
const updated = useStore.getState().installedPlugins.map(plugin =>
data.updatable.includes(plugin.name) ? {...plugin, updatable: true} : plugin
)
setInstalledPlugins(updated)
}
const onFinishedInstall = (data: {plugin: string; code?: string | null; error?: string | null}) => {
if (data?.error) {
const key = data.code === 'PLUGIN_REQUIRES_NEWER_ETHERPAD'
? 'admin_plugins.install_error_requires_newer_etherpad'
: 'admin_plugins.install_error'
useStore.getState().setToastState({
open: true,
title: t(key, {plugin: data.plugin, error: data.error}),
success: false,
if(a.name < b.name){
return -1
}
if(a.name > b.name){
return 1
}
return 0
})
}
pluginsSocket.emit('getInstalled')
}
const onFinishedUninstall = () => {
console.log('Finished uninstall')
}
const onConnect = () => {
pluginsSocket.emit('getInstalled')
pluginsSocket.emit('search', searchParams)
} ,[installedPlugins, searchParams])
const [searchTerm, setSearchTerm] = useState<string>('')
const {t} = useTranslation()
useEffect(() => {
if(!pluginsSocket){
return
}
pluginsSocket.on('results:installed', (data:{
installed: InstalledPlugin[]
})=>{
setInstalledPlugins(data.installed)
})
pluginsSocket.on('results:updatable', (data) => {
const newInstalledPlugins = useStore.getState().installedPlugins.map(plugin => {
if (data.updatable.includes(plugin.name)) {
return {
...plugin,
updatable: true
}
}
return plugin
})
setInstalledPlugins(newInstalledPlugins)
})
pluginsSocket.on('finished:install', () => {
pluginsSocket!.emit('getInstalled');
})
pluginsSocket.on('finished:uninstall', () => {
console.log("Finished uninstall")
})
// Reload on reconnect
pluginsSocket.on('connect', ()=>{
// Initial retrieval of installed plugins
pluginsSocket.emit('getInstalled');
pluginsSocket.emit('search', searchParams)
})
pluginsSocket.emit('getInstalled');
// check for updates every 5mins
const interval = setInterval(() => {
pluginsSocket.emit('checkUpdates');
}, 1000 * 60 * 5);
return ()=>{
clearInterval(interval)
}
}, [pluginsSocket]);
useEffect(() => {
if (!pluginsSocket) {
return
}
pluginsSocket?.emit('search', searchParams)
pluginsSocket!.on('results:search', (data: {
results: PluginDef[]
}) => {
setPlugins(data.results)
})
pluginsSocket!.on('results:searcherror', (data: {error: string}) => {
console.log(data.error)
useStore.getState().setToastState({
open: true,
title: "Error retrieving plugins",
success: false
})
})
}, [searchParams, pluginsSocket]);
const uninstallPlugin = (pluginName: string)=>{
pluginsSocket!.emit('uninstall', pluginName);
// Remove plugin
setInstalledPlugins(installedPlugins.filter(i=>i.name !== pluginName))
}
const onCatalogDisabled = () => setCatalogDisabled(true)
pluginsSocket.on('results:installed', onInstalled)
pluginsSocket.on('results:updatable', onUpdatable)
pluginsSocket.on('finished:install', onFinishedInstall)
pluginsSocket.on('finished:uninstall', onFinishedUninstall)
pluginsSocket.on('connect', onConnect)
pluginsSocket.on('results:catalogDisabled', onCatalogDisabled)
pluginsSocket.emit('getInstalled')
const interval = setInterval(() => pluginsSocket.emit('checkUpdates'), 1000 * 60 * 5)
return () => {
clearInterval(interval)
pluginsSocket.off('results:installed', onInstalled)
pluginsSocket.off('results:updatable', onUpdatable)
pluginsSocket.off('finished:install', onFinishedInstall)
pluginsSocket.off('finished:uninstall', onFinishedUninstall)
pluginsSocket.off('connect', onConnect)
pluginsSocket.off('results:catalogDisabled', onCatalogDisabled)
}
}, [pluginsSocket])
useEffect(() => {
if (!pluginsSocket) return
const onSearchResults = (data: {results: PluginDef[]}) => {
setPlugins(data.results)
}
const onSearchError = () => {
useStore.getState().setToastState({open: true, title: t('admin_plugins.error_retrieving'), success: false})
const installPlugin = (pluginName: string)=>{
pluginsSocket!.emit('install', pluginName);
setPlugins(plugins.filter(plugin=>plugin.name !== pluginName))
}
pluginsSocket.emit('search', searchParams)
pluginsSocket.on('results:search', onSearchResults)
pluginsSocket.on('results:searcherror', onSearchError)
useDebounce(()=>{
setSearchParams({
...searchParams,
offset: 0,
searchTerm: searchTerm
})
}, 500, [searchTerm])
return () => {
pluginsSocket.off('results:search', onSearchResults)
pluginsSocket.off('results:searcherror', onSearchError)
}
}, [searchParams, pluginsSocket])
const uninstallPlugin = (pluginName: string) => {
pluginsSocket!.emit('uninstall', pluginName)
setInstalledPlugins(installedPlugins.filter(i => i.name !== pluginName))
}
return <div>
<h1><Trans i18nKey="admin_plugins"/></h1>
const installPlugin = (pluginName: string) => {
pluginsSocket!.emit('install', pluginName)
setPlugins(plugins.filter(p => p.name !== pluginName))
}
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
useDebounce(() => {
setSearchParams({...searchParams, offset: 0, searchTerm})
}, 500, [searchTerm])
return (
<div className="pm-page">
{catalogDisabled && (
<div className="pm-banner pm-banner-info" role="status">
<Trans i18nKey="admin_plugins.catalog_disabled"/>
</div>
)}
{/* ── Page header ────────────────────────────────────────────────── */}
<div className="pm-header">
<div>
<div className="pm-crumbs">
Admin <span className="pm-crumbs-sep"></span> <Trans i18nKey="admin_plugins.crumbs"/>
</div>
<h1 className="pm-title">{t('admin_plugins')}</h1>
<p className="pm-subtitle">
<Trans i18nKey="admin_plugins.subtitle"/>
</p>
</div>
<div className="pm-header-actions">
<button
className="pm-btn pm-btn-ghost"
onClick={() => pluginsSocket?.emit('search', searchParams)}
>
<RefreshCw size={14}/> <Trans i18nKey="admin_plugins.reload_catalog"/>
</button>
<a
className="pm-btn pm-btn-primary pm-btn-link"
href={`//www.npmjs.com/search?q=${encodeURIComponent(searchTerm ? `keywords:etherpad ${searchTerm}` : 'keywords:etherpad')}`}
target="_blank"
rel="noreferrer"
>
<ExternalLink size={14}/> <Trans i18nKey="admin_plugins.search_npm"/>
</a>
</div>
</div>
{/* ── Stats row ──────────────────────────────────────────────────── */}
<div className="pm-stats">
<div className="pm-stat pm-stat--primary">
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.installed"/></div>
<div className="pm-stat-value">{installedPlugins.length}</div>
<div className="pm-stat-hint">{t('admin_plugins.core_count', {count: coreCount})}</div>
</div>
<div className="pm-stat">
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.available"/></div>
<div className="pm-stat-value">{plugins.length}</div>
</div>
<div className={`pm-stat${updatableCount > 0 ? ' pm-stat--warn' : ''}`}>
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.updates_available"/></div>
<div className="pm-stat-value">{updatableCount}</div>
{updatableCount > 0 && (
<button
className="pm-stat-action"
onClick={() => pluginsSocket?.emit('checkUpdates')}
>
<Trans i18nKey="admin_plugins.update_now"/>
</button>
)}
</div>
<div className="pm-stat">
<div className="pm-stat-label"><Trans i18nKey="admin_plugins.source"/></div>
<div className="pm-stat-value pm-stat-value--sm">npm</div>
<div className="pm-stat-hint">registry.npmjs.org</div>
</div>
</div>
{/* ── Installed plugins ──────────────────────────────────────────── */}
<section className="pm-section">
<div className="pm-section-header">
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
<span className="pm-count-badge">{installedPlugins.length}</span>
<div className="pm-spacer"/>
<button
className="pm-btn pm-btn-ghost"
onClick={() => pluginsSocket?.emit('checkUpdates')}
>
<RefreshCw size={14}/> <Trans i18nKey="admin_plugins.check_updates"/>
</button>
</div>
<div className="pm-installed">
{sortedInstalledPlugins.map(plugin => (
<div key={plugin.name} className="pm-installed-row">
<div className="pm-installed-icon">
<Plug size={16}/>
</div>
<div className="pm-installed-main">
<div className="pm-installed-title">
<a
className="pm-mono pm-plugin-link"
href={`https://npmjs.com/${plugin.name}`}
target="_blank"
rel="noopener noreferrer"
>{plugin.name}</a>
{plugin.name === 'ep_etherpad-lite' && (
<span className="pm-tag pm-tag--core"><Trans i18nKey="admin_plugins.tag_core"/></span>
)}
<span className="pm-tag pm-tag--ver">v{plugin.version}</span>
</div>
{plugin.description && (
<div className="pm-installed-desc">{plugin.description}</div>
)}
</div>
<div className="pm-installed-actions">
{plugin.updatable ? (
<IconButton
onClick={() => installPlugin(plugin.name)}
icon={<ArrowUpFromDot size={14}/>}
title={t('admin_plugins.update_tooltip')}
/>
) : (
<IconButton
disabled={plugin.name === 'ep_etherpad-lite'}
icon={<Trash size={14}/>}
title={<Trans i18nKey="admin_plugins.installed_uninstall.value"/>}
onClick={() => uninstallPlugin(plugin.name)}
/>
)}
</div>
</div>
))}
</div>
</section>
{/* ── Available plugins ──────────────────────────────────────────── */}
<section className="pm-section">
<div className="pm-section-header">
<h2><Trans i18nKey="admin_plugins.available"/></h2>
<span className="pm-count-badge">{filteredInstallablePlugins.length}</span>
<div className="pm-spacer"/>
<div className="pm-toolbar">
<div className="pm-search">
<Search size={14} className="pm-search-icon"/>
<input
className="pm-search-input"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder={t('admin_plugins.available_search.placeholder')}
/>
{searchTerm && (
<button className="pm-search-clear" onClick={() => setSearchTerm('')}>
<X size={12}/>
</button>
)}
</div>
<select
className="pm-select"
value={searchParams.sortBy}
onChange={e => {
const sortBy = e.target.value as SearchParams['sortBy']
setSearchParams({
...searchParams,
sortBy,
sortDir: 'asc',
})
}}
>
<option value="name">{t('admin_plugins.sort.name')}</option>
<option value="version">{t('admin_plugins.sort.version')}</option>
<option value="last-updated">{t('admin_plugins.sort.last_updated')}</option>
</select>
<button
className="pm-sort-dir"
onClick={() => setSearchParams({
...searchParams,
sortDir: searchParams.sortDir === 'asc' ? 'desc' : 'asc',
})}
title={t(searchParams.sortDir === 'asc'
? 'admin_plugins.sort_ascending'
: 'admin_plugins.sort_descending')}
aria-label={t(searchParams.sortDir === 'asc'
? 'admin_plugins.sort_ascending'
: 'admin_plugins.sort_descending')}
>
{searchParams.sortDir === 'asc' ? '↑' : '↓'}
</button>
</div>
</div>
{filteredInstallablePlugins.length > 0 ? (
<div className="pm-table-wrap">
<table className="pm-table">
<thead>
<tr>
<th><Trans i18nKey="admin_plugins.name"/></th>
<th><Trans i18nKey="admin_plugins.description"/></th>
<th style={{width: 62, textAlign: 'right'}}><Trans i18nKey="admin_plugins.version"/></th>
<th style={{width: 96}}><Trans i18nKey="admin_plugins.last-update"/></th>
<th style={{width: 108, textAlign: 'right'}}></th>
</tr>
</thead>
<tbody>
{filteredInstallablePlugins.map(plugin => (
<tr key={plugin.name}>
<table id="installed-plugins">
<thead>
<tr>
<th><Trans i18nKey="admin_plugins.name"/></th>
<th><Trans i18nKey="admin_plugins.version"/></th>
<th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
</tr>
</thead>
<tbody style={{overflow: 'auto'}}>
{sortedInstalledPlugins.map((plugin, index) => {
return <tr key={index}>
<td><a rel="noopener noreferrer" href={`https://npmjs.com/${plugin.name}`} target="_blank">{plugin.name}</a></td>
<td>{plugin.version}</td>
<td>
<div className="pm-cell-name">
<span className="pm-cell-icon"><Plug size={13}/></span>
<div className="pm-cell-title">
<a
className="pm-mono pm-plugin-link"
href={`https://npmjs.com/${plugin.name}`}
target="_blank"
rel="noopener noreferrer"
>{plugin.name}</a>
</div>
</div>
{
plugin.updatable ?
<IconButton onClick={() => installPlugin(plugin.name)} icon={<ArrowUpFromDot/>} title="Update"></IconButton>
: <IconButton disabled={plugin.name == "ep_etherpad-lite"} icon={<Trash/>} title={<Trans i18nKey="admin_plugins.installed_uninstall.value"/>} onClick={() => uninstallPlugin(plugin.name)}/>
}
</td>
<td className="pm-cell-desc">
{plugin.description}
{plugin.disables && plugin.disables.length > 0 && (
<div
className="plugin-disables"
role="alert"
title={t('admin_plugins.disables.warning_title')}
>
<strong><Trans i18nKey="admin_plugins.disables.label"/></strong>{' '}
{plugin.disables
.map(tag => tag.replace(/^@feature:/, ''))
.join(', ')}
</div>
)}
</td>
<td className="pm-num">{plugin.version}</td>
<td className="pm-cell-date">{plugin.time}</td>
<td className="pm-cell-action">
<button
className="pm-btn pm-btn-primary pm-btn--sm"
onClick={() => installPlugin(plugin.name)}
>
<Download size={13}/> <Trans i18nKey="admin_plugins.available_install.value"/>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="pm-empty">
<div className="pm-empty-icon"></div>
<div className="pm-empty-title">
{searchTerm === ''
? <Trans i18nKey="pad.loading"/>
: <Trans i18nKey="admin_plugins.available_not-found"/>}
</div>
</div>
)}
</section>
</tr>
})}
</tbody>
</table>
<h2><Trans i18nKey="admin_plugins.available"/></h2>
<SearchField onChange={v=>{setSearchTerm(v.target.value)}} placeholder={t('admin_plugins.available_search.placeholder')} value={searchTerm}/>
<div className="table-container">
<table id="available-plugins">
<thead>
<tr>
<th className={determineSorting(searchParams.sortBy, searchParams.sortDir == "asc", 'name')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'name',
sortDir: searchParams.sortDir === "asc"? "desc": "asc"
})
}}>
<Trans i18nKey="admin_plugins.name" /></th>
<th style={{width: '30%'}}><Trans i18nKey="admin_plugins.description"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.sortDir == "asc", 'version')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'version',
sortDir: searchParams.sortDir === "asc"? "desc": "asc"
})
}}><Trans i18nKey="admin_plugins.version"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.sortDir == "asc", 'last-updated')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'last-updated',
sortDir: searchParams.sortDir === "asc"? "desc": "asc"
})
}}><Trans i18nKey="admin_plugins.last-update"/></th>
<th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
</tr>
</thead>
<tbody style={{overflow: 'auto'}}>
{(filteredInstallablePlugins.length > 0) ?
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.version}</td>
<td>{plugin.time}</td>
<td>
<IconButton icon={<Download/>} onClick={() => installPlugin(plugin.name)} title={<Trans i18nKey="admin_plugins.available_install.value"/>}/>
</td>
</tr>
})
:
<tr><td colSpan={5}>{searchTerm == '' ? <Trans i18nKey="pad.loading"/>: <Trans i18nKey="admin_plugins.available_not-found"/>}</td></tr>
}
</tbody>
</table>
</div>
</div>
)
}

View file

@ -3,7 +3,6 @@ import {useNavigate} from "react-router-dom";
import {SubmitHandler, useForm} from "react-hook-form";
import {Eye, EyeOff} from "lucide-react";
import {useState} from "react";
import {useTranslation} from "react-i18next";
type Inputs = {
username: string
@ -13,7 +12,6 @@ type Inputs = {
export const LoginScreen = ()=>{
const navigate = useNavigate()
const [passwordVisible, setPasswordVisible] = useState<boolean>(false)
const {t} = useTranslation()
const {
register,
@ -29,7 +27,7 @@ export const LoginScreen = ()=>{
if(!r.ok) {
useStore.getState().setToastState({
open: true,
title: t('admin_login.failed'),
title: "Login failed",
success: false
})
} else {
@ -42,21 +40,21 @@ export const LoginScreen = ()=>{
return <div className="login-background login-page">
<div className="login-box login-form">
<h1 className="login-title">{t('admin_login.title')}</h1>
<h1 className="login-title">Etherpad</h1>
<form className="login-inner-box input-control" onSubmit={handleSubmit(login)}>
<div>{t('admin_login.username')}</div>
<div>Username</div>
<input {...register('username', {
required: true
})} className="login-textinput input-control" type="text" placeholder={t('admin_login.username')}/>
<div>{t('admin_login.password')}</div>
})} className="login-textinput input-control" type="text" placeholder="Username"/>
<div>Password</div>
<span className="icon-input">
<input {...register('password', {
required: true
})} className="login-textinput" type={passwordVisible?"text":"password"} placeholder={t('admin_login.password')}/>
})} className="login-textinput" type={passwordVisible?"text":"password"} placeholder="Password"/>
{passwordVisible? <Eye onClick={()=>setPasswordVisible(!passwordVisible)}/> :
<EyeOff onClick={()=>setPasswordVisible(!passwordVisible)}/>}
</span>
<input type="submit" value={t('admin_login.submit')} className="login-button"/>
<input type="submit" value="Login" className="login-button"/>
</form>
</div>
</div>

View file

@ -1,469 +1,284 @@
import {Trans, useTranslation} from "react-i18next";
import {useEffect, useMemo, useState} from "react";
import {useStore} from "../store/store.ts";
import {PadFilter, PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
import {useDebounce} from "../utils/useDebounce.ts";
import {determineSorting} from "../utils/sorting.ts";
import * as Dialog from "@radix-ui/react-dialog";
import {VisuallyHidden} from "@radix-ui/react-visually-hidden";
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react";
import {IconButton} from "../components/IconButton.tsx";
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon} from "lucide-react";
import {SearchField} from "../components/SearchField.tsx";
import {useForm} from "react-hook-form";
import type {TFunction} from "i18next";
type PadCreateProps = { padName: string }
const PAD_FILTER_IDS: PadFilter[] = ['all', 'active', 'recent', 'empty', 'stale']
function relativeTime(t: TFunction, ts: number): string {
const d = (Date.now() - ts) / 1000
if (d < 60) return t('admin_pads.relative.just_now')
if (d < 3600) return t('admin_pads.relative.minutes', {count: Math.floor(d / 60)})
if (d < 86400) return t('admin_pads.relative.hours', {count: Math.floor(d / 3600)})
if (d < 86400 * 7) return t('admin_pads.relative.days', {count: Math.floor(d / 86400)})
if (d < 86400 * 30) return t('admin_pads.relative.weeks', {count: Math.floor(d / 86400 / 7)})
if (d < 86400 * 365) return t('admin_pads.relative.months', {count: Math.floor(d / 86400 / 30)})
return t('admin_pads.relative.years', {count: Math.floor(d / 86400 / 365)})
type PadCreateProps = {
padName: string
}
function fmtDate(locale: string, ts: number): string {
const d = new Date(ts)
return (
d.toLocaleDateString(locale, {day: '2-digit', month: 'short', year: 'numeric'}) +
' · ' +
d.toLocaleTimeString(locale, {hour: '2-digit', minute: '2-digit'})
)
}
// i18next's language detector reads ?lng= from the URL, so the value can be
// attacker-controlled and structurally invalid (e.g. "en_US", "💥", " ").
// Intl.* throws RangeError on bad tags, which would crash the pads page
// during render. Normalise underscores → dashes and let the Intl runtime
// tell us which subset of the tag it can support; on failure, fall back to
// 'en' to mirror i18next's fallbackLng so dates render in a sane locale
// rather than the user's browser default fighting the page copy.
function sanitizeLocale(lng?: string): string {
if (!lng) return 'en'
const normalized = lng.trim().replace(/_/g, '-')
if (!normalized) return 'en'
try {
const [supported] = Intl.DateTimeFormat.supportedLocalesOf([normalized])
return supported ?? 'en'
} catch {
return 'en'
}
}
export const PadPage = () => {
const settingsSocket = useStore(state => state.settingsSocket)
export const PadPage = ()=>{
const settingsSocket = useStore(state=>state.settingsSocket)
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false, filter: 'all',
offset: 0,
limit: 12,
pattern: '',
sortBy: 'padName',
ascending: true
})
const {t, i18n} = useTranslation()
const locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language)
const [searchTerm, setSearchTerm] = useState('')
// Read filter off searchParams so chip changes round-trip through
// the server (`filter` is applied before pagination there). Clicking
// a chip used to filter only the current 12-row page slice.
//
// All searchParams mutations go through functional updaters because the
// debounced pattern handler captures a render-time snapshot and would
// otherwise revert a faster chip click / sort change made in between.
const filter: PadFilter = searchParams.filter ?? 'all'
const setFilter = (f: PadFilter) => {
setCurrentPage(0)
setSearchParams((sp) => ({...sp, filter: f, offset: 0}))
}
const [selected, setSelected] = useState<Set<string>>(new Set())
const pads = useStore(state => state.pads)
const [currentPage, setCurrentPage] = useState(0)
const [deleteDialog, setDeleteDialog] = useState(false)
const [errorText, setErrorText] = useState<string | null>(null)
const [padToDelete, setPadToDelete] = useState('')
const [createPadDialogOpen, setCreatePadDialogOpen] = useState(false)
const {t} = useTranslation()
const [searchTerm, setSearchTerm] = useState<string>('')
const pads = useStore(state=>state.pads)
const [currentPage, setCurrentPage] = useState<number>(0)
const [deleteDialog, setDeleteDialog] = useState<boolean>(false)
const [errorText, setErrorText] = useState<string|null>(null)
const [padToDelete, setPadToDelete] = useState<string>('')
const [createPadDialogOpen, setCreatePadDialogOpen] = useState<boolean>(false)
const {register, handleSubmit} = useForm<PadCreateProps>()
const pages = useMemo(()=>{
if(!pads){
return 0;
}
const pages = useMemo(
() => pads ? Math.ceil(pads.total / searchParams.limit) : 0,
[pads, searchParams.limit]
)
return Math.ceil(pads!.total / searchParams.limit)
},[pads, searchParams.limit])
// The server applies `filter` before paginating; the page payload is
// already the filtered slice. The stats cards still reflect the
// current page (pre-existing behaviour) — making them global would
// require a separate aggregate query.
const visibleResults = pads?.results ?? []
const totalUsers = useMemo(() => visibleResults.reduce((s, p) => s + p.userCount, 0), [pads])
const activeCount = useMemo(() => visibleResults.filter(p => p.userCount > 0).length, [pads])
const emptyCount = useMemo(() => visibleResults.filter(p => p.revisionNumber === 0).length, [pads])
const lastActivity = useMemo(() => {
return visibleResults.length ? Math.max(...visibleResults.map(p => p.lastEdited)) : null
}, [pads])
useDebounce(()=>{
setSearchParams({
...searchParams,
pattern: searchTerm
})
const allSelected = visibleResults.length > 0 && visibleResults.every(p => selected.has(p.padName))
const toggleAll = () => {
const s = new Set(selected)
if (allSelected) visibleResults.forEach(p => s.delete(p.padName))
else visibleResults.forEach(p => s.add(p.padName))
setSelected(s)
}
const toggleOne = (name: string) => {
const s = new Set(selected)
s.has(name) ? s.delete(name) : s.add(name)
setSelected(s)
}
useDebounce(() => {
// Functional updater so this delayed callback can't clobber a faster
// user interaction (e.g. clicking a filter chip mid-typing).
setSearchParams((sp) => ({...sp, pattern: searchTerm, offset: 0}))
setCurrentPage(0)
}, 500, [searchTerm])
useEffect(() => {
if (!settingsSocket) return
if(!settingsSocket){
return
}
settingsSocket.emit('padLoad', searchParams)
}, [settingsSocket, searchParams])
}, [settingsSocket, searchParams]);
useEffect(() => {
if (!settingsSocket) return
if(!settingsSocket){
return
}
settingsSocket.on('results:padLoad', (data: PadSearchResult) => {
useStore.getState().setPads(data)
settingsSocket.on('results:padLoad', (data: PadSearchResult)=>{
useStore.getState().setPads(data);
})
settingsSocket.on('results:deletePad', (padID: string) => {
const newPads = useStore.getState().pads?.results?.filter(p => p.padName !== padID)
useStore.getState().setPads({total: useStore.getState().pads!.total - 1, results: newPads})
settingsSocket.on('results:deletePad', (padID: string)=>{
const newPads = useStore.getState().pads?.results?.filter((pad)=>{
return pad.padName !== padID
})
useStore.getState().setPads({
total: useStore.getState().pads!.total-1,
results: newPads
})
})
type CreateResponse = {error: string} | {success: string}
settingsSocket.on('results:createPad', (rep: CreateResponse) => {
if ('error' in rep) {
useStore.getState().setToastState({open: true, title: rep.error, success: false})
} else {
useStore.getState().setToastState({open: true, title: rep.success, success: true})
setCreatePadDialogOpen(false)
settingsSocket.emit('padLoad', searchParams)
type SettingsSocketCreateReponse = {
error: string
} | {
success: string
}
settingsSocket.on('results:createPad', (rep: SettingsSocketCreateReponse)=>{
if ('error' in rep) {
useStore.getState().setToastState({
open: true,
title: rep.error,
success: false
})
} else {
useStore.getState().setToastState({
open: true,
title: rep.success,
success: true
})
setCreatePadDialogOpen(false)
// reload pads
settingsSocket.emit('padLoad', searchParams)
}
})
settingsSocket.on('results:cleanupPadRevisions', (data)=>{
const newPads = useStore.getState().pads?.results ?? []
if (data.error) {
setErrorText(data.error)
return
}
newPads.forEach((pad)=>{
if (pad.padName === data.padId) {
pad.revisionNumber = data.keepRevisions
}
})
useStore.getState().setPads({
results: newPads,
total: useStore.getState().pads!.total
})
})
}, [settingsSocket, pads]);
settingsSocket.on('results:cleanupPadRevisions', (data) => {
const newPads = useStore.getState().pads?.results ?? []
if (data.error) { setErrorText(data.error); return }
newPads.forEach(p => { if (p.padName === data.padId) p.revisionNumber = data.keepRevisions })
useStore.getState().setPads({results: newPads, total: useStore.getState().pads!.total})
})
}, [settingsSocket, pads])
const deletePad = (padID: string)=>{
settingsSocket?.emit('deletePad', padID)
}
const deletePad = (id: string) => settingsSocket?.emit('deletePad', id)
const cleanupPad = (id: string) => settingsSocket?.emit('cleanupPadRevisions', id)
const onPadCreate = (data: PadCreateProps) => settingsSocket?.emit('createPad', {padName: data.padName})
const cleanupPad = (padID: string)=>{
settingsSocket?.emit('cleanupPadRevisions', padID)
}
return (
<div className="pm-page">
const onPadCreate = (data: PadCreateProps)=>{
settingsSocket?.emit('createPad', {
padName: data.padName
})
}
{/* ── Dialogs ── */}
<Dialog.Root open={deleteDialog}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.delete_pad_dialog_title')}</Dialog.Title></VisuallyHidden>
<Dialog.Description asChild>
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
</Dialog.Description>
<div className="settings-button-bar">
<button onClick={() => setDeleteDialog(false)}><Trans i18nKey="admin_pads.cancel"/></button>
<button onClick={() => { deletePad(padToDelete); setDeleteDialog(false) }}>{t('admin_pads.confirm_button')}</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root open={errorText !== null}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.error_prefix')}</Dialog.Title></VisuallyHidden>
<Dialog.Description asChild>
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
</Dialog.Description>
<div className="settings-button-bar">
<button onClick={() => setErrorText(null)}>{t('admin_pads.confirm_button')}</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root open={createPadDialogOpen}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
<VisuallyHidden asChild><Dialog.Description>{t('admin_pads.create_pad_dialog_description')}</Dialog.Description></VisuallyHidden>
<form onSubmit={handleSubmit(onPadCreate)}>
<button className="dialog-close-button" type="button" onClick={() => setCreatePadDialogOpen(false)}>×</button>
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>
<label><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></label>
<input {...register('padName', {required: true})}/>
return <div>
<Dialog.Root open={deleteDialog}><Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay" />
<Dialog.Content className="dialog-confirm-content">
<div className="">
<div className=""></div>
<div className="">
{t("ep_admin_pads:ep_adminpads2_confirm", {
padID: padToDelete,
})}
</div>
<div className="settings-button-bar">
<button onClick={()=>{
setDeleteDialog(false)
}}>Cancel</button>
<button onClick={()=>{
deletePad(padToDelete)
setDeleteDialog(false)
}}>Ok</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root open={errorText !== null}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<div>
<div>Error occured: {errorText}</div>
<div className="settings-button-bar">
<button onClick={() => {
setErrorText(null)
}}>OK</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root open={createPadDialogOpen}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay" />
<Dialog.Content className="dialog-confirm-content">
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
<form onSubmit={handleSubmit(onPadCreate)}>
<button className="dialog-close-button" onClick={()=>{
setCreatePadDialogOpen(false);
}}>x</button>
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>
<label><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></label>
<input {...register('padName', {
required: true
})}/>
</div>
<input type="submit" value={t('admin_settings.createPad')} className="login-button" />
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<span className="manage-pads-header">
<h1><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></h1>
<span style={{width: '29px', marginBottom: 'auto', marginTop: 'auto', flexGrow: 1}}><IconButton style={{float: 'right'}} icon={<PlusIcon/>} title={<Trans i18nKey="index.newPad"/>} onClick={()=>{
setCreatePadDialogOpen(true)
}}/></span>
</span>
<SearchField value={searchTerm} onChange={v=>setSearchTerm(v.target.value)} placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/>
<table>
<thead>
<tr className="search-pads">
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'padName')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'padName',
ascending: !searchParams.ascending
})
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'userCount')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'userCount',
ascending: !searchParams.ascending
})
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_pad-user-count"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'lastEdited')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'lastEdited',
ascending: !searchParams.ascending
})
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_last-edited"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'revisionNumber')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'revisionNumber',
ascending: !searchParams.ascending
})
}}>Revision number</th>
<th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
</tr>
</thead>
<tbody className="search-pads-body">
{
pads?.results?.map((pad)=>{
return <tr key={pad.padName}>
<td style={{textAlign: 'center'}}>{pad.padName}</td>
<td style={{textAlign: 'center'}}>{pad.userCount}</td>
<td style={{textAlign: 'center'}}>{new Date(pad.lastEdited).toLocaleString()}</td>
<td style={{textAlign: 'center'}}>{pad.revisionNumber}</td>
<td>
<div className="settings-button-bar">
<IconButton icon={<Trash2/>} title={<Trans i18nKey="ep_admin_pads:ep_adminpads2_delete.value"/>} onClick={()=>{
setPadToDelete(pad.padName)
setDeleteDialog(true)
}}/>
<IconButton icon={<FileStack/>} title={<Trans i18nKey="ep_admin_pads:ep_adminpads2_cleanup"/>} onClick={()=>{
cleanupPad(pad.padName)
}}/>
<IconButton icon={<Eye/>} title={<Trans i18nKey="index.createOpenPad"/>} onClick={()=>window.open(`../../p/${pad.padName}`, '_blank')}/>
</div>
<input type="submit" value={t('admin_settings.create_pad')} className="login-button"/>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{/* ── Page header ── */}
<div className="pm-header">
<div>
<div className="pm-crumbs">Admin <span className="pm-crumbs-sep"></span> <Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></div>
<h1 className="pm-title"><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></h1>
<p className="pm-subtitle"><Trans i18nKey="admin_pads.subtitle"/></p>
</div>
<div className="pm-header-actions">
<button className="pm-btn pm-btn-ghost" onClick={() => settingsSocket?.emit('padLoad', searchParams)}>
<RefreshCw size={14}/> <Trans i18nKey="admin_pads.refresh"/>
</button>
<button className="pm-btn pm-btn-primary" onClick={() => setCreatePadDialogOpen(true)}>
<PlusIcon size={14}/> <Trans i18nKey="index.newPad"/>
</button>
</div>
</div>
{/* ── Stats ── */}
<div className="pm-stats">
<div className="pm-stat pm-stat--primary">
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.total"/></div>
<div className="pm-stat-value">{pads?.total ?? '—'}</div>
<div className="pm-stat-hint">{activeCount > 0
? t('admin_pads.stats.users_active', {count: activeCount})
: t('admin_pads.stats.no_active_users')}</div>
</div>
<div className="pm-stat">
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.active_users"/></div>
<div className="pm-stat-value">{totalUsers}</div>
<div className="pm-stat-hint"><Trans i18nKey="admin_pads.stats.across_pads"/></div>
</div>
<div className={`pm-stat${emptyCount > 0 ? ' pm-stat--warn' : ''}`}>
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.empty_pads"/></div>
<div className="pm-stat-value">{emptyCount}</div>
<div className="pm-stat-hint"><Trans i18nKey="admin_pads.stats.revisions_zero"/></div>
{emptyCount > 0 && (
<button className="pm-stat-action" onClick={() => setFilter('empty')}>{t('admin_pads.show')} </button>
)}
</div>
<div className="pm-stat">
<div className="pm-stat-label"><Trans i18nKey="admin_pads.stats.last_activity"/></div>
<div className="pm-stat-value pm-stat-value--sm">
{lastActivity ? relativeTime(t, lastActivity) : '—'}
</div>
<div className="pm-stat-hint">{pads?.results?.[0]?.padName ?? ''}</div>
</div>
</div>
{/* ── Pads section ── */}
<section className="pm-section">
<div className="pm-section-header">
<h2><Trans i18nKey="admin_pads.all_pads"/></h2>
<span className="pm-count-badge">{visibleResults.length}</span>
<div className="pm-spacer"/>
<div className="pm-toolbar">
<div className="pm-search">
<Search size={14} className="pm-search-icon"/>
<input
className="pm-search-input"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}
/>
{searchTerm && (
<button className="pm-search-clear" onClick={() => setSearchTerm('')}><X size={12}/></button>
)}
</div>
<select
className="pm-select"
value={searchParams.sortBy}
onChange={e => setSearchParams((sp) => ({
...sp,
sortBy: e.target.value,
// Keep current direction when only the column changes; the
// ↑/↓ button below is the sole control for direction.
}))}
>
<option value="lastEdited">{t('ep_admin_pads:ep_adminpads2_last-edited')}</option>
<option value="padName">{t('admin_pads.sort.name')}</option>
<option value="userCount">{t('admin_pads.sort.user_count')}</option>
<option value="revisionNumber">{t('admin_pads.sort.revision_number')}</option>
</select>
<button
className="pm-sort-dir"
onClick={() => setSearchParams((sp) => ({
...sp,
ascending: !sp.ascending,
}))}
title={t(searchParams.ascending
? 'admin_plugins.sort_ascending'
: 'admin_plugins.sort_descending')}
aria-label={t(searchParams.ascending
? 'admin_plugins.sort_ascending'
: 'admin_plugins.sort_descending')}
>
{searchParams.ascending ? '↑' : '↓'}
</button>
</div>
</div>
{/* Filter chips */}
<div className="pm-chips">
{PAD_FILTER_IDS.map(id => (
<button key={id} className={`pm-chip${filter === id ? ' is-on' : ''}`} onClick={() => setFilter(id)}>
{t(`admin_pads.filter.${id}`)}
</button>
))}
</div>
{/* Bulk bar */}
{selected.size > 0 && (
<div className="pm-bulk">
<span className="pm-bulk-count">{t('admin_pads.selected_count', {count: selected.size})}</span>
<div className="pm-spacer"/>
<button className="pm-btn pm-btn-ghost" onClick={() => {
selected.forEach(name => cleanupPad(name))
setSelected(new Set())
}}>
<History size={14}/> <Trans i18nKey="admin_pads.bulk.cleanup_history"/>
</button>
<button className="pm-btn pm-btn-danger" onClick={() => {
selected.forEach(name => deletePad(name))
setSelected(new Set())
}}>
<Trash2 size={14}/> <Trans i18nKey="admin_pads.bulk.delete"/>
</button>
<button className="pm-btn pm-btn-icon" onClick={() => setSelected(new Set())} title={t('admin_pads.bulk.clear_selection')}>
<X size={14}/>
</button>
</div>
)}
{visibleResults.length > 0 ? (
<div className="pm-table-wrap">
<table>
<thead>
<tr>
<th style={{width: 40}}>
<label className="pm-check">
<input type="checkbox" checked={allSelected} onChange={toggleAll}/>
</label>
</th>
<th><Trans i18nKey="admin_pads.col.pad"/></th>
<th style={{width: 100, textAlign: 'center'}}><Trans i18nKey="admin_pads.col.users"/></th>
<th style={{width: 110, textAlign: 'right'}}><Trans i18nKey="admin_pads.col.revisions"/></th>
<th style={{width: 210}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_last-edited"/></th>
<th style={{width: 170, textAlign: 'right'}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
</tr>
</thead>
<tbody>
{visibleResults.map(pad => {
const isEmpty = pad.revisionNumber === 0
const isSel = selected.has(pad.padName)
return (
<tr key={pad.padName} className={`${isSel ? 'is-sel' : ''} ${isEmpty ? 'is-empty' : ''}`}>
<td>
<label className="pm-check">
<input type="checkbox" checked={isSel} onChange={() => toggleOne(pad.padName)}/>
</label>
</td>
<td>
<div className="pm-pad-name">
<span className="pm-pad-mark" data-empty={isEmpty || undefined}>
<FileStack size={13}/>
</span>
<div>
<div className="pm-pad-title">{pad.padName}</div>
<div className="pm-pad-sub">
{isEmpty
? t('admin_pads.empty_never_edited')
: t('admin_pads.revisions_count', {count: pad.revisionNumber})}
</div>
</div>
</div>
</td>
<td style={{textAlign: 'center'}}>
{pad.userCount > 0 ? (
<span className="pm-users-pill"><span className="pm-users-dot"/> {pad.userCount}</span>
) : (
<span className="pm-users-pill is-muted">0</span>
)}
</td>
<td className="pm-num">{pad.revisionNumber.toLocaleString(locale)}</td>
<td>
<div className="pm-time">
<span className="pm-time-rel">{relativeTime(t, pad.lastEdited)}</span>
<span className="pm-time-abs">{fmtDate(locale, pad.lastEdited)}</span>
</div>
</td>
<td className="pm-cell-action">
<div className="pm-row-actions">
<button
className="pm-btn-icon"
title={t('ep_admin_pads:ep_adminpads2_cleanup')}
onClick={() => cleanupPad(pad.padName)}
>
<History size={14}/>
</button>
<button
className="pm-btn-icon pm-btn-icon--danger"
title={t('ep_admin_pads:ep_adminpads2_delete.value')}
onClick={() => { setPadToDelete(pad.padName); setDeleteDialog(true) }}
>
<Trash2 size={14}/>
</button>
<button
className="pm-btn pm-btn-primary pm-btn--sm"
onClick={() => window.open(`../../p/${encodeURIComponent(pad.padName)}`, '_blank', 'noopener,noreferrer')}
>
<Eye size={13}/> <Trans i18nKey="admin_pads.open"/>
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
<div className="pm-empty">
<div className="pm-empty-icon"></div>
<div className="pm-empty-title"><Trans i18nKey="ep_admin_pads:ep_adminpads2_no-results"/></div>
</div>
)}
{/* Pagination */}
<div className="pm-pagination">
<button
className="pm-btn pm-btn-ghost"
disabled={currentPage === 0}
onClick={() => {
const p = currentPage - 1
setCurrentPage(p)
setSearchParams((sp) => ({...sp, offset: p * sp.limit}))
}}
>
<ChevronLeft size={14}/> <Trans i18nKey="admin_pads.pagination.previous"/>
</button>
<span className="pm-pagination-info">{currentPage + 1} / {pages || 1}</span>
<button
className="pm-btn pm-btn-ghost"
disabled={pages === 0 || pages === currentPage + 1}
onClick={() => {
const p = currentPage + 1
setCurrentPage(p)
setSearchParams((sp) => ({...sp, offset: p * sp.limit}))
}}
>
<Trans i18nKey="admin_pads.pagination.next"/> <ChevronRight size={14}/>
</button>
</div>
</section>
</td>
</tr>
})
}
</tbody>
</table>
<div className="settings-button-bar pad-pagination">
<button disabled={currentPage == 0} onClick={()=>{
setCurrentPage(currentPage-1)
setSearchParams({
...searchParams,
offset: (Number(currentPage)-1)*searchParams.limit})
}}><ChevronLeft/><span>Previous Page</span></button>
<span>{currentPage+1} out of {pages}</span>
<button disabled={pages == 0 || pages == currentPage+1} onClick={()=>{
const newCurrentPage = currentPage+1
setCurrentPage(newCurrentPage)
setSearchParams({
...searchParams,
offset: (Number(newCurrentPage))*searchParams.limit
})
}}><span>Next Page</span><ChevronRight/></button>
</div>
)
</div>
}

View file

@ -4,12 +4,6 @@ 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[],
}
@ -17,8 +11,7 @@ export type InstalledPlugin = {
name: string,
path: string,
realPath: string,
version: string,
description?: string,
version:string,
updatable?: boolean
}

View file

@ -1,174 +1,50 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useStore } from '../store/store';
import { isJSONClean, cleanComments } from '../utils/utils';
import { Trans, useTranslation } from 'react-i18next';
import { IconButton } from '../components/IconButton';
import { RotateCw, Save, AlignLeft, ShieldCheck, Info } from 'lucide-react';
import { FormView } from '../components/settings/FormView';
import { ModeToggle, type Mode } from '../components/settings/ModeToggle';
import {useStore} from "../store/store.ts";
import {isJSONClean, cleanComments} from "../utils/utils.ts";
import {Trans} from "react-i18next";
import {IconButton} from "../components/IconButton.tsx";
import {RotateCw, Save} from "lucide-react";
const TAB_INDENT = ' ';
export const SettingsPage = ()=>{
const settingsSocket = useStore(state=>state.settingsSocket)
const settings = cleanComments(useStore(state=>state.settings))
// Heuristic: `${VAR}` or `${VAR:default}` in the file means the operator is
// running with env-var substitution (overwhelmingly Docker / Kubernetes).
// We use this to gate the Docker-aware UX (the explanatory banner and the
// Effective-config tab) so non-container installs see the existing UI
// unchanged. Conservative on purpose — false negatives just keep the old
// behaviour.
const ENV_VAR_PATTERN = /\$\{[A-Za-z_][A-Za-z0-9_]*(?::[^}]*)?\}/;
export const SettingsPage = () => {
const { t } = useTranslation();
const settingsSocket = useStore(state => state.settingsSocket);
const settings = useStore(state => state.settings) ?? '';
const resolved = useStore(state => state.resolved);
const usesEnvVars = useMemo(() => ENV_VAR_PATTERN.test(settings), [settings]);
const [mode, setMode] = useState<Mode>('form');
const [exposeExperimental] = useState(false);
// The Effective tab is only meaningful when there is a `resolved`
// payload AND the file uses substitution. Falling back to Raw on
// either condition keeps the toggle honest if the user opens this
// page against an older server.
const canShowEffective = usesEnvVars && resolved != null;
useEffect(() => {
if (mode === 'effective' && !canShowEffective) setMode('raw');
}, [mode, canShowEffective]);
// Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders.
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== 'Tab') return;
e.preventDefault();
const target = e.currentTarget;
const { selectionStart, selectionEnd, value } = target;
const next = value.substring(0, selectionStart) + TAB_INDENT + value.substring(selectionEnd);
useStore.getState().setSettings(next);
requestAnimationFrame(() => {
target.selectionStart = target.selectionEnd = selectionStart + TAB_INDENT.length;
});
};
const showToast = (titleKey: string, success: boolean) => {
useStore.getState().setToastState({ open: true, title: t(titleKey), success });
};
const testJSON = () => {
if (isJSONClean(settings)) showToast('admin_settings.toast.validation_ok', true);
else showToast('admin_settings.toast.validation_failed', false);
};
const prettifyJSON = () => {
try {
const obj = JSON.parse(cleanComments(settings) ?? '');
if (window.confirm(t('admin_settings.prettify_confirm'))) {
useStore.getState().setSettings(JSON.stringify(obj, null, 2));
}
} catch {
showToast('admin_settings.toast.prettify_failed', false);
}
};
const handleSave = () => {
if (!isJSONClean(settings)) return showToast('admin_settings.toast.json_invalid', false);
if (!settingsSocket?.connected) return showToast('admin_settings.toast.disconnected', false);
// Toast is shown by the saveprogress socket listener in App.tsx on server ack.
settingsSocket.emit('saveSettings', settings);
};
const effectiveJson = useMemo(() => {
if (resolved == null) return '';
try { return JSON.stringify(resolved, null, 2); } catch { return ''; }
}, [resolved]);
return (
<div className="settings-page">
<h1><Trans i18nKey="admin_settings.current" /></h1>
{usesEnvVars && (
<div
className="settings-envvar-banner"
role="note"
data-testid="settings-envvar-banner"
>
<Info size={18} aria-hidden="true" />
<div>
<strong><Trans i18nKey="admin_settings.envvar_banner.title" /></strong>
<p><Trans i18nKey="admin_settings.envvar_banner.body" /></p>
</div>
</div>
)}
<ModeToggle mode={mode} onChange={setMode} showEffective={canShowEffective} />
{mode === 'form' && <FormView onSwitchToRaw={() => setMode('raw')} />}
{mode === 'raw' && (
<textarea
value={settings}
className="settings"
data-testid="settings-raw-textarea"
spellCheck={false}
onKeyDown={handleKeyDown}
onChange={v => useStore.getState().setSettings(v.target.value)}
/>
)}
{mode === 'effective' && (
<textarea
value={effectiveJson}
className="settings"
data-testid="settings-effective-textarea"
spellCheck={false}
readOnly
aria-readonly="true"
/>
)}
<div className="settings-button-bar">
{mode !== 'effective' && (
<>
<IconButton
className="settingsButton"
data-testid="save-settings-button"
icon={<Save />}
title={<Trans i18nKey="admin_settings.current_save.value" />}
onClick={handleSave}
/>
<IconButton
className="settingsButton"
data-testid="test-settings-button"
icon={<ShieldCheck />}
title={<Trans i18nKey="admin_settings.current_test.value" />}
onClick={testJSON}
/>
{exposeExperimental && (
<IconButton
className="settingsButton"
data-testid="prettify-settings-button"
icon={<AlignLeft />}
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
onClick={prettifyJSON}
/>
)}
</>
)}
<IconButton
className="settingsButton"
data-testid="restart-etherpad-button"
icon={<RotateCw />}
title={<Trans i18nKey="admin_settings.current_restart.value" />}
onClick={() => settingsSocket?.emit('restartServer')}
/>
</div>
<div className="settings-links">
<a rel="noopener noreferrer" target="_blank" href="https://github.com/ether/etherpad/wiki/Example-Production-Settings.JSON">
<Trans i18nKey="admin_settings.current_example-prod" />
</a>
<a rel="noopener noreferrer" target="_blank" href="https://github.com/ether/etherpad/wiki/Example-Development-Settings.JSON">
<Trans i18nKey="admin_settings.current_example-devel" />
</a>
</div>
return <div className="settings-page">
<h1><Trans i18nKey="admin_settings.current"/></h1>
<textarea value={settings} className="settings" onChange={v => {
useStore.getState().setSettings(v.target.value)
}}/>
<div className="settings-button-bar">
<IconButton className="settingsButton" icon={<Save/>}
title={<Trans i18nKey="admin_settings.current_save.value"/>} onClick={() => {
if (isJSONClean(settings!)) {
// JSON is clean so emit it to the server
settingsSocket!.emit('saveSettings', settings!);
useStore.getState().setToastState({
open: true,
title: "Successfully saved settings",
success: true
})
} else {
useStore.getState().setToastState({
open: true,
title: "Error saving settings",
success: false
})
}
}}/>
<IconButton className="settingsButton" icon={<RotateCw/>}
title={<Trans i18nKey="admin_settings.current_restart.value"/>} onClick={() => {
settingsSocket!.emit('restartServer');
}}/>
</div>
);
};
<div className="separator"/>
<div className="settings-button-bar">
<a rel="noopener noreferrer" target="_blank"
href="https://github.com/ether/etherpad-lite/wiki/Example-Production-Settings.JSON"><Trans
i18nKey="admin_settings.current_example-prod"/></a>
<a rel="noopener noreferrer" target="_blank"
href="https://github.com/ether/etherpad-lite/wiki/Example-Development-Settings.JSON"><Trans
i18nKey="admin_settings.current_example-devel"/></a>
</div>
</div>
}

View file

@ -3,7 +3,6 @@ import {SendHorizonal} from 'lucide-react'
import {useStore} from "../store/store.ts";
import * as Switch from '@radix-ui/react-switch';
import {ShoutType} from "../components/ShoutType.ts";
import {Trans, useTranslation} from "react-i18next";
export const ShoutPage = ()=>{
const [totalUsers, setTotalUsers] = useState(0);
@ -12,7 +11,6 @@ export const ShoutPage = ()=>{
const socket = useStore(state => state.settingsSocket);
const pluginSocket = useStore(state => state.pluginsSocket);
const [shouts, setShouts] = useState<ShoutType[]>([]);
const {t} = useTranslation()
useEffect(() => {
@ -44,8 +42,8 @@ export const ShoutPage = ()=>{
return (
<div>
<h1><Trans i18nKey="admin.shout"/></h1>
{totalUsers > 0 && <p>{t('admin_shout.online', {count: totalUsers})}</p>}
<h1>Communication</h1>
{totalUsers > 0 && <p>There {totalUsers>1?"are":"is"} currently {totalUsers} user{totalUsers>1?"s":""} online</p>}
<div style={{height: '80vh', display: 'flex', flexDirection: 'column'}}>
<div style={{flexGrow: 1, backgroundColor: 'white', overflowY: "auto"}}>
{
@ -68,7 +66,7 @@ export const ShoutPage = ()=>{
e.preventDefault()
sendMessage()
}} className="send-message search-field" style={{display: 'flex', gap: '10px'}}>
<Switch.Root title={t('admin_shout.sticky_toggle')} className="SwitchRoot" checked={sticky}
<Switch.Root title="Change sticky message" className="SwitchRoot" checked={sticky}
onCheckedChange={() => {
setSticky(!sticky);
}}>

View file

@ -1,286 +0,0 @@
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'};
const IN_FLIGHT_STATUSES = ['preflight', 'draining', 'executing', 'rolling-back'];
const fmtRemaining = (ms: number): string => {
if (ms <= 0) return '0s';
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
const sec = s % 60;
return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
};
export const UpdatePage = () => {
const {t} = useTranslation();
const us = useStore((s) => s.updateStatus);
const setUpdateStatus = useStore((s) => s.setUpdateStatus);
const log = useStore((s) => s.updateLog);
const setLog = useStore((s) => s.setUpdateLog);
// 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'});
const [actionInFlight, setActionInFlight] = useState(false);
const refreshStatus = async () => {
try {
const r = await fetch('/admin/update/status', {credentials: 'same-origin'});
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 {
setFetchState({kind: 'error', status: 0});
}
};
useEffect(() => {
let cancelled = false;
void refreshStatus().then(() => { if (cancelled) return; });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Poll log + status while the executor is in flight, then stop.
const status = us?.execution?.status ?? 'idle';
const inFlight = IN_FLIGHT_STATUSES.includes(status);
useEffect(() => {
if (!inFlight) return;
let cancelled = false;
const tick = async () => {
if (cancelled) return;
try {
const lr = await fetch('/admin/update/log', {credentials: 'same-origin'});
if (lr.ok) setLog(await lr.text());
} catch {/* noop */}
await refreshStatus();
if (!cancelled) setTimeout(tick, 1000);
};
void tick();
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inFlight]);
const post = async (path: string) => {
setActionInFlight(true);
try {
await fetch(path, {method: 'POST', credentials: 'same-origin'});
await refreshStatus();
} finally {
setActionInFlight(false);
}
};
// Tier 3 countdown — derive scheduledFor outside the conditional returns so
// the hook order is stable on every render.
const scheduledFor = us?.execution?.status === 'scheduled'
? (us.execution as {scheduledFor: string}).scheduledFor
: null;
const [remainingMs, setRemainingMs] = useState<number>(() =>
scheduledFor ? Math.max(0, new Date(scheduledFor).getTime() - Date.now()) : 0);
useEffect(() => {
if (!scheduledFor) return;
const target = new Date(scheduledFor).getTime();
setRemainingMs(Math.max(0, target - Date.now()));
const id = setInterval(() => setRemainingMs(Math.max(0, target - Date.now())), 1000);
return () => clearInterval(id);
}, [scheduledFor]);
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 stat = 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: stat})}</p>
</div>
);
}
const upToDate = !us.latest || us.currentVersion === us.latest.version;
const showApply = !!us.policy?.canManual
&& (status === 'idle' || status === 'verified' || status === 'scheduled')
&& !us.lockHeld
&& !upToDate;
const showCancel = status === 'preflight' || status === 'draining' || status === 'scheduled';
const showAcknowledge = status === 'preflight-failed' || status === 'rolled-back' || status === 'rollback-failed';
// Optional-chain the execution lookup: some integration-test stubs of
// /admin/update/status omit Tier 2/3 fields entirely (see
// update-banner.spec.ts), and accessing `.status` on an undefined
// execution would crash the whole page before the h1 renders.
const scheduled = us.execution?.status === 'scheduled'
? us.execution as {targetTag: string; scheduledFor: string}
: null;
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>
<dt><Trans i18nKey="update.page.execution"/></dt>
<dd>{t(`update.execution.${status}`, {defaultValue: status})}</dd>
</dl>
{us.lastResult && (
<p className={`last-result last-result-${us.lastResult.outcome}`}>
<Trans
i18nKey={`update.page.last_result.${us.lastResult.outcome}`}
values={{tag: us.lastResult.targetTag, reason: us.lastResult.reason ?? ''}}
/>
</p>
)}
{us.policy && !us.policy.canManual && !upToDate && (
<p className="policy-deny">
<Trans
i18nKey={`update.page.policy.${us.policy.reason}`}
defaults={us.policy.reason}
/>
</p>
)}
{scheduled && (
<section className="update-scheduled" aria-live="polite">
<h2><Trans i18nKey="update.page.scheduled.title"/></h2>
<p>
<Trans
i18nKey="update.page.scheduled.countdown"
values={{tag: scheduled.targetTag, remaining: fmtRemaining(remainingMs)}}
/>
</p>
{/* Tier 4: only surface the deferral subtitle when `scheduledFor`
was actually snapped forward to the next window opening. The
backend keeps `scheduledFor = now + grace` whenever that lands
inside the window, so we can't use a fixed time-distance
heuristic (a normal 15-min grace would falsely match). Instead,
compare against `nextWindowOpensAt` with a small tolerance the
two are computed seconds apart at request time, so an exact-ish
match is the only safe signal that the schedule was deferred. */}
{us.tier === 'autonomous' && us.nextWindowOpensAt
&& Math.abs(new Date(scheduled.scheduledFor).getTime()
- new Date(us.nextWindowOpensAt).getTime()) < 60 * 1000 && (
<p className="update-scheduled-deferred">
<Trans
i18nKey="update.page.scheduled.deferred_until"
values={{at: us.nextWindowOpensAt}}
/>
</p>
)}
</section>
)}
{us.tier === 'autonomous' && (
<section className="update-maintenance-window">
<h2><Trans i18nKey="update.window.title"/></h2>
{us.maintenanceWindow ? (
<>
<p>
<Trans
i18nKey="update.window.summary"
values={{
start: us.maintenanceWindow.start,
end: us.maintenanceWindow.end,
tz: us.maintenanceWindow.tz,
}}
/>
</p>
{us.nextWindowOpensAt && (
<p>
<Trans
i18nKey="update.window.next_opens_at"
values={{at: us.nextWindowOpensAt}}
/>
</p>
)}
</>
) : (
<p><Trans i18nKey="update.window.unset"/></p>
)}
</section>
)}
<div className="update-actions">
{showApply && (
<button onClick={() => post('/admin/update/apply')} disabled={actionInFlight}>
{status === 'scheduled'
? t('update.page.scheduled.apply_now')
: t('update.page.apply')}
</button>
)}
{showCancel && (
<button onClick={() => post('/admin/update/cancel')} disabled={actionInFlight}>
{t('update.page.cancel')}
</button>
)}
{showAcknowledge && (
<button onClick={() => post('/admin/update/acknowledge')} disabled={actionInFlight}>
{t('update.page.acknowledge')}
</button>
)}
</div>
{inFlight && (
<section className="update-log">
<h2><Trans i18nKey="update.page.log"/></h2>
<pre style={{whiteSpace: 'pre-wrap', maxHeight: '320px', overflow: 'auto'}}>{log}</pre>
</section>
)}
{upToDate ? (
<p><Trans i18nKey="update.page.up_to_date"/></p>
) : us.latest ? (
<>
<h2><Trans i18nKey="update.page.changelog"/></h2>
<pre style={{whiteSpace: 'pre-wrap'}}>{us.latest.body}</pre>
<p><a href={us.latest.htmlUrl} rel="noreferrer noopener" target="_blank">{us.latest.htmlUrl}</a></p>
</>
) : null}
</div>
);
};
export default UpdatePage;

View file

@ -1,60 +1,7 @@
import {create} from "zustand";
import {Socket} from "socket.io-client";
import type {JSONPath} from "jsonc-parser";
import {PadSearchResult} from "../utils/PadSearch.ts";
import {AuthorSearchResult} from "../utils/AuthorSearch.ts";
import {InstalledPlugin} from "../pages/Plugin.ts";
import {resolveByPath} from "../utils/resolveByPath.ts";
export type Execution =
| {status: 'idle'}
| {status: 'scheduled'; targetTag: string; scheduledFor: string; startedAt: string}
| {status: 'preflight'; targetTag: string; startedAt: string}
| {status: 'preflight-failed'; targetTag: string; reason: string; at: string}
| {status: 'draining'; targetTag: string; drainEndsAt: string; startedAt: string}
| {status: 'executing'; targetTag: string; fromSha: string; startedAt: string}
| {status: 'pending-verification'; targetTag: string; fromSha: string; deadlineAt: string}
| {status: 'verified'; targetTag: string; verifiedAt: string}
| {status: 'rolling-back'; reason: string; targetTag: string; fromSha: string; at: string}
| {status: 'rolled-back'; reason: string; targetTag: string; restoredSha: string; at: string}
| {status: 'rollback-failed'; reason: string; targetTag: string; fromSha: string; at: string};
export type LastResult = null | {
targetTag: string;
fromSha: string;
outcome: 'verified' | 'rolled-back' | 'rollback-failed' | 'preflight-failed' | 'cancelled';
reason: string | null;
at: string;
};
export interface MaintenanceWindow {
start: string;
end: string;
tz: 'local' | 'utc';
}
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};
// Tier 2 additions:
execution: Execution;
lastResult: LastResult;
lockHeld: boolean;
// Tier 4 additions:
maintenanceWindow: MaintenanceWindow | null;
nextWindowOpensAt: string | null;
}
type ToastState = {
description?:string,
@ -67,11 +14,6 @@ type ToastState = {
type StoreState = {
settings: string|undefined,
setSettings: (settings: string) => void,
// Resolved runtime values for the /admin/settings page. The server
// emits this alongside the raw `settings` string so the SPA can show
// env-substituted values; secrets are redacted to "[REDACTED]".
resolved: unknown | null,
setResolved: (resolved: unknown | null) => void,
settingsSocket: Socket|undefined,
setSettingsSocket: (socket: Socket) => void,
showLoading: boolean,
@ -83,23 +25,13 @@ type StoreState = {
pads: PadSearchResult|undefined,
setPads: (pads: PadSearchResult)=>void,
installedPlugins: InstalledPlugin[],
setInstalledPlugins: (plugins: InstalledPlugin[])=>void,
updateStatus: UpdateStatusPayload | null,
setUpdateStatus: (s: UpdateStatusPayload) => void,
updateLog: string,
setUpdateLog: (log: string) => void,
authors: AuthorSearchResult|undefined,
setAuthors: (authors: AuthorSearchResult)=>void,
gdprAuthorErasureEnabled: boolean,
setGdprAuthorErasureEnabled: (enabled: boolean)=>void,
setInstalledPlugins: (plugins: InstalledPlugin[])=>void
}
export const useStore = create<StoreState>()((set) => ({
settings: undefined,
setSettings: (settings: string) => set({settings}),
resolved: null,
setResolved: (resolved) => set({resolved}),
settingsSocket: undefined,
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
showLoading: false,
@ -116,16 +48,5 @@ export const useStore = create<StoreState>()((set) => ({
pads: undefined,
setPads: (pads)=>set({pads}),
installedPlugins: [],
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins}),
updateStatus: null,
setUpdateStatus: (s) => set({updateStatus: s}),
updateLog: '',
setUpdateLog: (log) => set({updateLog: log}),
authors: undefined,
setAuthors: (authors)=>set({authors}),
gdprAuthorErasureEnabled: false,
setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}),
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins})
}));
export const useResolvedAt = (path: JSONPath): unknown =>
useStore(s => resolveByPath(s.resolved, path));

View file

@ -1,45 +0,0 @@
export type AuthorSortBy = 'name' | 'lastSeen';
export type AuthorSearchQuery = {
pattern: string;
offset: number;
limit: number;
sortBy: AuthorSortBy;
ascending: boolean;
includeErased: boolean;
};
export type AuthorRow = {
authorID: string;
name: string | null;
colorId: string | number | null;
mapper: string[];
lastSeen: number | null;
erased: boolean;
};
export type AuthorSearchResult = {
total: number;
cappedAt?: number;
results: AuthorRow[];
error?: string;
};
export type AnonymizePreview = {
authorID: string;
name: string | null;
affectedPads: number;
removedTokenMappings: number;
removedExternalMappings: number;
clearedChatMessages: number;
error?: string;
};
export type AnonymizeResult = {
authorID: string;
affectedPads?: number;
removedTokenMappings?: number;
removedExternalMappings?: number;
clearedChatMessages?: number;
error?: string;
};

View file

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

View file

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

View file

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

View file

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

View file

@ -21,6 +21,5 @@
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"exclude": ["src/**/__tests__/**"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View file

@ -1,46 +1,36 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
// Inline `settings.json.template` at config time so the bundle has the
// per-key documentation without expanding the dev server's filesystem
// allowlist (which would otherwise serve every file in the repo root,
// including settings.json and credentials.json, to anything that can
// reach the dev server).
const settingsTemplate = readFileSync(
resolve(__dirname, '..', 'settings.json.template'),
'utf8',
)
import {viteStaticCopy} from "vite-plugin-static-copy";
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
],
base: '/admin',
define: {
__SETTINGS_TEMPLATE__: JSON.stringify(settingsTemplate),
},
build: {
outDir: '../src/templates/admin',
emptyOutDir: true,
},
server: {
plugins: [viteStaticCopy({
targets: [
{
src: '../src/locales',
dest: ''
}
]
}), react({
babel: {
plugins: ['babel-plugin-react-compiler'],
}})],
base: '/admin',
build:{
outDir: '../src/templates/admin',
emptyOutDir: true,
},
server:{
proxy: {
'/socket.io/*': {
target: 'http://localhost:9001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
rewrite: (path) => path.replace(/^\/api/, '')
},
'/admin-auth/': {
target: 'http://localhost:9001',
changeOrigin: true,
},
},
},
'/admin-auth/': {
target: 'http://localhost:9001',
changeOrigin: true,
}
}
}
})

132
best_practices.md Normal file
View file

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

View file

@ -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-v22.x/win-x64/node.exe" -O node.exe
try wget "https://nodejs.org/dist/latest-v20.x/win-x64/node.exe" -O node.exe
log "create the zip..."
try zip -9 -r "${OUTPUT}" ./*

View file

@ -3,26 +3,6 @@ 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};

View file

@ -1,236 +0,0 @@
'use strict';
/*
* Compact every pad on the instance to reclaim database space.
*
* Usage:
* pnpm run --filter bin compactAllPads # collapse all history on every pad
* pnpm run --filter bin compactAllPads --keep N # keep last N revisions per pad
* pnpm run --filter bin compactAllPads --dry-run # list pads + rev counts, no writes
*
* 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(' pnpm run --filter bin compactAllPads');
console.error(' pnpm run --filter bin compactAllPads --keep <N>');
console.error(' pnpm run --filter bin compactAllPads --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);
})();
}

View file

@ -1,101 +0,0 @@
'use strict';
/*
* Compact a pad's revision history to reclaim database space.
*
* Usage:
* pnpm run --filter bin compactPad <padID> # collapse all history
* pnpm run --filter bin compactPad <padID> --keep N # keep only the last N revisions
*
* 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(' pnpm run --filter bin compactPad <padID>');
console.error(' pnpm run --filter bin compactPad <padID> --keep <N>');
process.exit(2);
};
const args = process.argv.slice(2);
if (args.length < 1 || args.length > 3) usage();
const padId = args[0];
let keepRevisions: number | null = null;
if (args.length === 3) {
if (args[1] !== '--keep') usage();
keepRevisions = Number(args[2]);
if (!Number.isInteger(keepRevisions) || keepRevisions < 0) {
console.error(`--keep expects a non-negative integer; got ${args[2]}`);
process.exit(2);
}
}
// get the API Key
const filePath = path.join(__dirname, '../APIKEY.txt');
const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'}).trim();
(async () => {
const apiInfo = await apiGet('/api/');
const apiVersion: string | undefined = apiInfo.currentVersion;
if (!apiVersion) throw new Error('No version set in API');
// Pre-flight: show current revision count so operators can eyeball impact.
const countUri = `/api/${apiVersion}/getRevisionsCount?apikey=${apikey}&padID=${padId}`;
const countRes = await apiGet(countUri);
if (countRes.code !== 0) {
console.error(`getRevisionsCount failed: ${JSON.stringify(countRes)}`);
process.exit(1);
}
const before: number = countRes.data.revisions;
const strategy = keepRevisions == null ? 'collapse all' : `keep last ${keepRevisions}`;
console.log(`Pad ${padId}: ${before + 1} revision(s). Strategy: ${strategy}.`);
const params = new URLSearchParams({apikey, padID: padId});
if (keepRevisions != null) params.set('keepRevisions', String(keepRevisions));
const result = await apiPost(`/api/${apiVersion}/compactPad?${params.toString()}`);
if (result.code !== 0) {
console.error(`compactPad failed: ${JSON.stringify(result)}`);
process.exit(1);
}
// Post-flight: the pad is now compacted. Re-read the rev count so the
// operator sees concrete savings.
const afterRes = await apiGet(countUri);
const after: number | undefined = afterRes?.data?.revisions;
if (after != null) {
console.log(`Done. Pad ${padId}: ${after + 1} revision(s) remaining ` +
`(was ${before + 1}).`);
} else {
console.log('Done.');
}
})();

View file

@ -1,328 +0,0 @@
'use strict';
/*
* Compact every pad on the instance that has not been edited recently.
*
* Usage:
* pnpm run --filter bin compactStalePads --older-than 90 # collapse history on pads not edited in 90 days
* pnpm run --filter bin compactStalePads --older-than 90 --keep 50 # keep last 50 revisions
* pnpm run --filter bin compactStalePads --older-than 90 --dry-run # list, don't write
*
* Composes `listAllPads` `getLastEdited` `compactPad`. Same shape as
* `bin/compactAllPads` (per-pad error tolerance, dry-run, tally), but
* filters by edit-recency before touching anything. Targeting which pads
* to compact is deliberately a CLI concern and not a `compactPad` API
* param staleness changes from one run to the next, the compaction
* primitive does not.
*
* Destructive `getEtherpad`-export anything you can't afford to lose
* before running.
*
* Issue #7642: long-lived instances accumulate cold pads whose history
* nobody is navigating any more. Hot pads should be left alone; this
* tool is the brick for reclaiming space on the cold tail.
*/
import path from 'node:path';
import fs from 'node:fs';
import process from 'node:process';
export type CompactStaleOpts = {
olderThanDays: number;
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 CompactStaleApi = {
listAllPads(): Promise<string[]>;
getLastEdited(padId: string): Promise<number>;
getRevisionsCount(padId: string): Promise<number>;
compactPad(padId: string, keepRevisions: number | null): Promise<void>;
};
export type CompactStaleReport = {
total: number;
stale: number;
ok: number;
failed: number;
skippedFresh: number;
totalRevsBefore: number;
totalRevsAfter: number;
};
export type CompactStaleLogger = {
info(msg: string): void;
error(msg: string): void;
};
const defaultLogger: CompactStaleLogger = {
info: (m) => console.log(m),
error: (m) => console.error(m),
};
const DAY_MS = 24 * 60 * 60 * 1000;
// Pure-ish core: compose listAllPads → getLastEdited → compactPad with
// the same per-pad error tolerance + dry-run + tally as compactAllPads.
// `now` is injected so tests can pin the wall clock.
export const runCompactStale = async (
api: CompactStaleApi, opts: CompactStaleOpts,
logger: CompactStaleLogger = defaultLogger,
now: () => number = Date.now,
): Promise<CompactStaleReport> => {
const cutoff = now() - opts.olderThanDays * DAY_MS;
let padIds: string[];
try {
padIds = await api.listAllPads();
} catch (e: any) {
logger.error(`listAllPads failed: ${e.message ?? e}`);
return {
total: 0, stale: 0, ok: 0, failed: 1, skippedFresh: 0,
totalRevsBefore: 0, totalRevsAfter: 0,
};
}
if (padIds.length === 0) {
logger.info('No pads on this instance.');
return {
total: 0, stale: 0, ok: 0, failed: 0, skippedFresh: 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). Filter: not edited in ` +
`${opts.olderThanDays} day(s). Strategy: ${strategy}` +
`${opts.dryRun ? ' (dry run — no writes)' : ''}.`);
const report: CompactStaleReport = {
total: padIds.length, stale: 0, ok: 0, failed: 0, skippedFresh: 0,
totalRevsBefore: 0, totalRevsAfter: 0,
};
// First pass: figure out which pads are actually stale. A getLastEdited
// failure on a pad is counted as a failure (we can't decide), but does
// not stop the run.
const stalePads: string[] = [];
for (const padId of padIds) {
let lastEdited: number;
try {
lastEdited = await api.getLastEdited(padId);
} catch (e: any) {
logger.error(`${padId}: getLastEdited failed: ${e.message ?? e}`);
report.failed++;
continue;
}
if (lastEdited > cutoff) {
report.skippedFresh++;
continue;
}
stalePads.push(padId);
}
report.stale = stalePads.length;
if (stalePads.length === 0) {
logger.info(
`No stale pads (${report.skippedFresh} fresh, ${report.failed} unreadable).`);
return report;
}
logger.info(
`${stalePads.length} stale pad(s) to process ` +
`(${report.skippedFresh} fresh skipped).`);
for (let i = 0; i < stalePads.length; i++) {
const padId = stalePads[i];
const idx = `[${i + 1}/${stalePads.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;
}
// Re-check staleness right before compacting. Without this the
// first-pass selection is a TOCTOU window: on a long bulk run a
// pad can become active between selection and compaction, and
// compactPad would then kick those sessions. Re-checking here
// shrinks the window to one round-trip and treats the pad as
// freshened (skipped, not failed).
let lastEditedNow: number;
try {
lastEditedNow = await api.getLastEdited(padId);
} catch (e: any) {
logger.error(`${idx} ${padId}: getLastEdited recheck failed: ${e.message ?? e}`);
report.failed++;
continue;
}
if (lastEditedNow > cutoff) {
logger.info(`${idx} ${padId}: edited during run — skipping (now fresh)`);
report.skippedFresh++;
report.stale--;
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. ${stalePads.length} stale 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, ` +
`${report.skippedFresh} fresh skipped. ` +
`Revisions: ${report.totalRevsBefore}${report.totalRevsAfter} ` +
`(reclaimed ${report.totalRevsBefore - report.totalRevsAfter}).`);
}
return report;
};
export const parseArgs = (argv: string[]): CompactStaleOpts | null => {
const opts: CompactStaleOpts = {
olderThanDays: NaN, 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 === '--older-than') {
const v = argv[++i];
const n = Number(v);
if (!Number.isInteger(n) || n < 0) {
console.error(`--older-than expects a non-negative integer; got ${v}`);
return null;
}
opts.olderThanDays = n;
} 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;
}
}
if (!Number.isFinite(opts.olderThanDays)) {
console.error('--older-than is required');
return null;
}
return opts;
};
const usage = () => {
console.error('Usage:');
console.error(' pnpm run --filter bin compactStalePads --older-than <days>');
console.error(' pnpm run --filter bin compactStalePads --older-than <days> --keep <N>');
console.error(' pnpm run --filter bin compactStalePads --older-than <days> --dry-run');
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: CompactStaleApi = {
async listAllPads() {
const apiInfo = await apiGet('/api/');
const apiVersion: string | undefined = apiInfo.currentVersion;
if (!apiVersion) throw new Error('No version set in API');
(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 getLastEdited(padId: string) {
const v = (cliApi as any)._apiVersion;
const r = await apiGet(
`/api/${v}/getLastEdited?apikey=${apikey}` +
`&padID=${encodeURIComponent(padId)}`);
if (r.code !== 0) throw new Error(JSON.stringify(r));
return r.data.lastEdited;
},
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 runCompactStale(cliApi, opts!);
if (report.failed > 0) process.exit(1);
})();
}

203
bin/createRelease.sh Executable file
View file

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

View file

@ -13,54 +13,46 @@ 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 () => {
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();
};
axios.defaults.baseURL = `http://${settings.ip}:${settings.port}`;
const api = axios;
const filePath = path.join(__dirname, '../APIKEY.txt');
const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'});
let res;
res = await apiGet('/api/');
const apiVersion = res.currentVersion;
res = await api.get('/api/');
const apiVersion = res.data.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 apiPost(uri('createGroup', {apikey}));
if (res.code === 1) throw new Error(`Error creating group: ${res}`);
const groupID = res.data.groupID;
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;
console.log('groupID', groupID);
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('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('createAuthor', {apikey}));
if (res.code === 1) throw new Error(`Error creating author: ${res}`);
const authorID = res.data.authorID;
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;
console.log('authorID', authorID);
const validUntil = Math.floor(new Date().getTime() / 1000) + 60000;
console.log('validUntil', validUntil);
res = await apiPost(uri('createSession', {apikey, groupID, authorID, validUntil}));
if (res.code === 1) throw new Error(`Error creating session: ${JSON.stringify(res)}`);
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)}`);
console.log('Session made: ====> create a cookie named sessionID and set the value to',
res.data.sessionID);
res.data.data.sessionID);
process.exit(0)
})();

View file

@ -11,6 +11,7 @@ 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;
@ -22,38 +23,29 @@ const settings = require('ep_etherpad-lite/tests/container/loadSettings').loadSe
(async () => {
const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'});
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();
};
axios.defaults.baseURL = `http://${settings.ip}:${settings.port}`;
const apiVersionResponse = await apiGet('/api/');
const apiVersion = apiVersionResponse.currentVersion; // 1.12.5
const apiVersionResponse = await axios.get('/api/');
const apiVersion = apiVersionResponse.data.currentVersion; // 1.12.5
console.log('apiVersion', apiVersion);
const groupsResponse = await apiGet(`/api/${apiVersion}/listAllGroups?apikey=${apikey}`);
const groups = groupsResponse.data.groupIDs; // ['whateverGroupID']
const groupsResponse = await axios.get(`/api/${apiVersion}/listAllGroups?apikey=${apikey}`);
const groups = groupsResponse.data.data.groupIDs; // ['whateverGroupID']
for (const groupID of groups) {
const sessionURI = `/api/${apiVersion}/listSessionsOfGroup?apikey=${apikey}&groupID=${groupID}`;
const sessionsResponse = await apiGet(sessionURI);
const sessions = sessionsResponse.data;
const sessionsResponse = await axios.get(sessionURI);
const sessions = sessionsResponse.data.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}`;
const c = await apiPost(deleteURI);
console.log(c);
deleteCount++;
await axios.post(deleteURI).then(c=>{
console.log(c.data)
deleteCount++;
}); // delete
}
}
console.log(`Deleted ${deleteCount} sessions`);

View file

@ -11,22 +11,13 @@ 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();
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();
};
axios.defaults.baseURL = `http://${settings.ip}:${settings.port}`;
if (process.argv.length !== 3) throw new Error('Use: node deletePad.js $PADID');
@ -38,14 +29,14 @@ const filePath = path.join(__dirname, '../APIKEY.txt');
const apikey = fs.readFileSync(filePath, {encoding: 'utf-8'});
(async () => {
const apiInfo = await apiGet('/api/');
const apiVersion = apiInfo.currentVersion;
let apiVersion = await axios.get('/api/');
apiVersion = apiVersion.data.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 apiPost(uri);
if (deleteAttempt.code === 1) throw new Error(`Error deleting pad ${deleteAttempt}`);
console.log('Deleted pad', deleteAttempt);
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);
process.exit(0)
})();

View file

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

View file

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

View file

@ -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 = 24
$RequiredNodeMajor = 20
Write-Step 'Etherpad installer'

View file

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

View file

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

View file

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

View file

@ -1,20 +1,21 @@
{
"name": "bin",
"version": "3.3.2",
"version": "2.7.0",
"description": "",
"main": "checkAllPads.js",
"directories": {
"doc": "doc"
},
"dependencies": {
"axios": "^1.15.1",
"ep_etherpad-lite": "workspace:../src",
"log4js": "^6.9.1",
"semver": "^7.8.5",
"tsx": "^4.23.0",
"ueberdb2": "6.1.16"
"semver": "^7.7.4",
"tsx": "^4.21.0",
"ueberdb2": "^5.0.48"
},
"devDependencies": {
"@types/node": "^26.1.1",
"@types/node": "^25.6.0",
"@types/semver": "^7.7.1",
"typescript": "^6.0.3"
},
@ -22,9 +23,6 @@
"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",
"compactStalePads": "node --import tsx compactStalePads.ts",
"createUserSession": "node --import tsx createUserSession.ts",
"deletePad": "node --import tsx deletePad.ts",
"repairPad": "node --import tsx repairPad.ts",

View file

@ -1,7 +1,7 @@
'use strict';
import {linkInstaller, checkForMigration} from "ep_etherpad-lite/static/js/pluginfw/installer";
import {persistInstalledPlugins, filterUpdatablePluginNames} from "./commonPlugins";
import {persistInstalledPlugins} from "./commonPlugins";
import fs from "node:fs";
const settings = require('ep_etherpad-lite/node/utils/Settings');
@ -19,9 +19,7 @@ const possibleActions = [
"rm",
"remove",
"ls",
"list",
"up",
"update"
"list"
]
const install = ()=> {
@ -78,40 +76,6 @@ 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) {
@ -148,12 +112,6 @@ switch (action) {
case "remove":
remove(args.slice(1));
break;
case "up":
update();
break;
case "update":
update();
break;
default:
console.error('Expected at least one argument!');
process.exit(1);

View file

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

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