Merge branch 'develop'

This commit is contained in:
Etherpad Release Bot 2026-05-16 19:00:09 +00:00
commit 149c6a24e7
283 changed files with 38340 additions and 3017 deletions

View file

@ -27,8 +27,8 @@ jobs:
strategy:
fail-fast: false
matrix:
# PRs: test on latest Node only. Push to develop: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
# Etherpad requires Node >= 24 (see package.json engines.node).
node: ${{ fromJSON('[24]') }}
steps:
-
name: Checkout repository
@ -66,7 +66,24 @@ jobs:
run: pnpm build
-
name: Run the backend tests
run: pnpm test
env:
# --report-on-fatalerror and friends write a Node diagnostic report
# (V8 stack, libuv handles, OS info) on fatal errors that bypass JS
# handlers — the failure mode we've been chasing on Windows + Node
# 24 since PR #7663. Reports land in node-report/ and are uploaded
# as an artifact if the step fails.
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
run: |
mkdir -p "${{ github.workspace }}/node-report"
pnpm test
- name: Upload Node diagnostic reports on failure
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
path: node-report/
if-no-files-found: ignore
retention-days: 7
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
@ -84,7 +101,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
node: ${{ fromJSON('[24]') }}
steps:
-
name: Checkout repository
@ -136,7 +153,19 @@ jobs:
ep_table_of_contents
-
name: Run the backend tests
run: pnpm test
env:
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
run: |
mkdir -p "${{ github.workspace }}/node-report"
pnpm test
- name: Upload Node diagnostic reports on failure
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
path: node-report/
if-no-files-found: ignore
retention-days: 7
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
@ -150,7 +179,8 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [22, 24, 25]
# Etherpad requires Node >= 24 (see package.json engines.node).
node: ${{ fromJSON('[24]') }}
name: Windows without plugins
runs-on: windows-latest
steps:
@ -186,8 +216,25 @@ 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
run: pnpm test
env:
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
run: |
mkdir -p "${{ github.workspace }}/node-report"
# --exit forces process.exit(failures) after the suite completes,
# closing the post-suite event-loop drain window where Windows +
# Node 24 hard-kills the process. Scoped to Windows so Linux/local
# runs still surface real handle leaks via natural drain.
pnpm test -- --exit
- name: Upload Node diagnostic reports on failure
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
path: node-report/
if-no-files-found: ignore
retention-days: 7
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest
@ -200,7 +247,8 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [22, 24, 25]
# Etherpad requires Node >= 24 (see package.json engines.node).
node: ${{ fromJSON('[24]') }}
name: Windows with Plugins
runs-on: windows-latest
@ -265,8 +313,25 @@ 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
run: pnpm test
env:
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
run: |
mkdir -p "${{ github.workspace }}/node-report"
# --exit forces process.exit(failures) after the suite completes,
# closing the post-suite event-loop drain window where Windows +
# Node 24 hard-kills the process. Scoped to Windows so Linux/local
# runs still surface real handle leaks via natural drain.
pnpm test -- --exit
- name: Upload Node diagnostic reports on failure
if: ${{ failure() }}
uses: actions/upload-artifact@v7
with:
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
path: node-report/
if-no-files-found: ignore
retention-days: 7
- name: Run the new vitest tests
working-directory: src
run: pnpm run test:vitest

View file

@ -56,12 +56,11 @@ jobs:
with:
run_install: false
# Pin Node so the build does not silently fall back to whatever the
# runner image ships with. vite 8 requires Node ^20.19.0 || >=22.12.0;
# the repo declares engines.node >=22.12.0 to match.
# runner image ships with. The repo declares engines.node >=24.0.0.
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
- name: Setup Pages
if: github.event_name == 'push'

View file

@ -128,7 +128,7 @@ jobs:
run: |
set -eux
# Ubuntu's default apt nodejs is 18 — too old for our
# `Depends: nodejs (>= 22)`. Add NodeSource's apt repo
# `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
@ -137,7 +137,7 @@ jobs:
# existing nodesource entries so the only Node candidate apt sees
# is our node_24.x repo. Otherwise `apt-get install -y nodejs`
# picks the higher-version 20.x build that's already cached and
# `dpkg -i` then fails on `Depends: nodejs (>= 22)`.
# `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 \
@ -172,10 +172,14 @@ jobs:
sudo test -L /opt/etherpad/settings.json
sudo test -L /opt/etherpad/var
[ "$(sudo readlink /opt/etherpad/var)" = "/var/lib/etherpad/var" ]
sudo test -L /opt/etherpad/src/plugin_packages
[ "$(sudo readlink /opt/etherpad/src/plugin_packages)" = "/var/lib/etherpad/plugin_packages" ]
sudo test -d /var/lib/etherpad/plugin_packages
[ "$(sudo stat -c '%U' /var/lib/etherpad/plugin_packages)" = "etherpad" ]
# 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
@ -197,8 +201,71 @@ jobs:
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

View file

@ -17,4 +17,4 @@ jobs:
- name: 'Checkout Repository'
uses: actions/checkout@v6
- name: 'Dependency Review'
uses: actions/dependency-review-action@v4
uses: actions/dependency-review-action@v5

View file

@ -59,7 +59,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: etherpad/pnpm-lock.yaml
-
@ -79,7 +79,88 @@ jobs:
esac
done
(cd src && pnpm run test-container)
docker rm -f test
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
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@v6
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
@ -284,13 +365,13 @@ jobs:
if: success() && github.ref == 'refs/heads/develop'
working-directory: ether-charts
run: |
sed -i 's/tag: ".*"/tag: "${{ steps.build-docker.outputs.digest }}"/' values-dev.yaml
sed -i 's/tag: ".*"/tag: "${{ steps.build-docker.outputs.digest }}"/' charts/etherpad/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 values-dev.yaml
git add charts/etherpad/values-dev.yaml
git commit -m 'Update develop image tag'
git push

View file

@ -21,8 +21,8 @@ jobs:
strategy:
fail-fast: false
matrix:
# PRs: single Node version. Push: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
# Etherpad requires Node >= 24 (see package.json engines.node).
node: ${{ fromJSON('[24]') }}
steps:
-

View file

@ -42,7 +42,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
-
name: Install all dependencies and symlink for ep_etherpad-lite
@ -114,7 +114,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
@ -190,7 +190,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
@ -291,7 +291,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile

View file

@ -42,7 +42,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile

View file

@ -42,7 +42,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- 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.server_url }}/${{ github.repository }}.git
ETHERPAD_BRANCH: ${{ github.head_ref || github.ref_name }}
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 }}
NO_COLOR: "1"
run: sh ./bin/installer.sh
@ -104,7 +104,7 @@ jobs:
- uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- name: Pre-install pnpm
run: npm install -g pnpm
@ -113,8 +113,8 @@ jobs:
shell: pwsh
env:
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
ETHERPAD_REPO: ${{ github.server_url }}/${{ github.repository }}.git
ETHERPAD_BRANCH: ${{ github.head_ref || github.ref_name }}
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 }}
NO_COLOR: "1"
run: ./bin/installer.ps1

View file

@ -39,7 +39,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
-
name: Install all dependencies and symlink for ep_etherpad-lite
@ -77,7 +77,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
-
name: Install etherpad-load-test
@ -140,7 +140,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
-
name: Install all dependencies and symlink for ep_etherpad-lite

View file

@ -39,7 +39,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
- name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile

View file

@ -42,7 +42,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
-
@ -58,11 +58,36 @@ 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 -p 8081:80 --rm --network ep_net --ip 172.23.42.1 -d nginx-latest
docker run --name nginx-docker -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
-
name: run rate limit test
run: |

View file

@ -64,7 +64,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
cache-dependency-path: etherpad/pnpm-lock.yaml
- name: Install dependencies ether.github.com

View file

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

View file

@ -26,7 +26,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
- name: Install bin dependencies
working-directory: ./bin

View file

@ -27,14 +27,17 @@ jobs:
strategy:
fail-fast: false
matrix:
# PRs: single Node version. Push: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[22, 24, 25]') }}
# Etherpad requires Node >= 24 (see package.json engines.node).
node: ${{ fromJSON('[24]') }}
steps:
-
name: Check out latest release
uses: actions/checkout@v6
with:
ref: develop #FIXME change to master when doing release
fetch-depth: 0
-
name: Check out latest release tag
run: git checkout "$(git tag --list 'v*' --sort=-version:refname | head -n1)"
- uses: actions/cache@v5
name: Cache pnpm store
with:
@ -84,13 +87,10 @@ jobs:
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: pnpm install --frozen-lockfile
# 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}.
# 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.
-
name: Fetch the new Git commits
run: git fetch --depth=1 origin "${GITHUB_REF}"

8
.gitignore vendored
View file

@ -38,3 +38,11 @@ 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/

View file

@ -46,6 +46,25 @@ 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`.

View file

@ -1,3 +1,92 @@
# 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.
- Tier 4 (autonomous in a maintenance window) remains designed but unimplemented and will land in a subsequent release.
- **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
@ -21,6 +110,7 @@
- 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`.
@ -44,7 +134,7 @@
- The HTTP client in the backend has been migrated from `axios` to the built-in `fetch` API, dropping a dependency now that Node 22 ships a stable fetch.
- `admin/` and `ui/` workspaces moved from `rolldown-vite` to upstream **Vite 8**.
- Build and CI moved to **pnpm 11** (`packageManager: "pnpm@11.0.6"`); the `Dockerfile`, snap, and all GitHub workflows are aligned. pnpm overrides have been migrated from `package.json` to `pnpm-workspace.yaml` to match pnpm 11's expectations.
- 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.

View file

@ -9,14 +9,15 @@ ARG BUILD_ENV=git
ARG PnpmVersion=11.0.6
FROM node:22-alpine AS adminbuild
# Use corepack to provision pnpm and drop the bundled npm — its older
# transitives (picomatch, brace-expansion) carry CVEs we don't otherwise
# need. Refresh corepack first: the version bundled with Node 22 ships a
# stale signing-key list and rejects newer pnpm releases
# (nodejs/corepack#612). Mirrors the workaround in snap/snapcraft.yaml.
RUN npm install -g corepack@latest && \
corepack enable && corepack prepare pnpm@${PnpmVersion} --activate && \
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
WORKDIR /opt/etherpad-lite
COPY . .
@ -24,7 +25,7 @@ RUN pnpm install
RUN pnpm run build:ui
FROM node:22-alpine AS build
FROM node:24-alpine AS build
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
# Set these arguments when building the image from behind a proxy
@ -99,12 +100,17 @@ 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 && \
npm install -g corepack@latest && \
corepack enable && corepack prepare pnpm@${PnpmVersion} --activate && \
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 && \
apk update && apk upgrade && \
apk add --no-cache \
@ -205,4 +211,15 @@ HEALTHCHECK --interval=5s --timeout=3s \
CMD wget -qO- http://127.0.0.1:9001/health | grep -E "pass|ok|up" > /dev/null || exit 1
EXPOSE 9001
CMD ["pnpm", "run", "prod"]
# 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"]

68
PRIVACY.md Normal file
View file

@ -0,0 +1,68 @@
# 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.
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.
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://github.com/ether/etherpad/wiki/Sites-That-Run-Etherpad#sites-that-run-etherpad)
[Try out a public Etherpad instance](https://scanner.etherpad.org)
## Project Status
@ -36,16 +36,14 @@ 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 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)
[![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)
### 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://static.etherpad.org/index.html)
[![Etherpad plugins](https://img.shields.io/endpoint?url=https%3A%2F%2Fstatic.etherpad.org%2Fshields.json&color=%2344b492 "Etherpad plugins")](https://etherpad.org/plugins)
![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)
@ -60,13 +58,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.
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).
[Public Etherpad Instances for you to try out. Third party instances not provided by the Etherpad foundation](https://scanner.etherpad.org/).
## Installation
### Quick install (one-liner)
The fastest way to get Etherpad running. Requires `git` and Node.js >= 22.
The fastest way to get Etherpad running. Requires `git` and Node.js >= 24.
**macOS / Linux / WSL:**
@ -160,7 +158,7 @@ volumes:
### Requirements
[Node.js](https://nodejs.org/) >= 22.12.
[Node.js](https://nodejs.org/) >= 24.
### Windows, macOS, Linux

View file

@ -1,30 +1,66 @@
# React + TypeScript + Vite
# Admin UI
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
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.
Currently, two official plugins are available:
## Scripts
- [@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
| 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. |
## Expanding the ESLint configuration
## Typed API client
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
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.
- Configure the top-level `parserOptions` property like this:
[`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
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
### 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
```
- 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
`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.

View file

@ -1,42 +1,52 @@
{
"name": "admin",
"private": true,
"version": "2.7.3",
"version": "3.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"dev": "pnpm gen:api && vite",
"dev:only": "vite",
"gen:api": "node scripts/gen-api.mjs",
"build": "pnpm gen:api && tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"build-copy": "tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
"preview": "vite preview"
"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'"
},
"dependencies": {
"@radix-ui/react-switch": "^1.2.6"
"@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10",
"jsonc-parser": "^3.3.1",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4"
},
"devDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-toast": "^1.2.15",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.59.2",
"@vitejs/plugin-react": "^6.0.1",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"eslint": "^10.3.0",
"eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"i18next": "^26.0.9",
"i18next": "^26.2.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.14.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"lucide-react": "^1.16.0",
"openapi-typescript": "^7.13.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.75.0",
"react-i18next": "^17.0.6",
"react-router-dom": "^7.15.0",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.15.1",
"socket.io-client": "^4.8.3",
"tsx": "^4.22.0",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vite-plugin-babel": "^1.6.0",
"vite": "^8.0.13",
"vite-plugin-babel": "^1.7.1",
"zustand": "^5.0.13"
}
}

View file

@ -0,0 +1,31 @@
{
"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-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

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

@ -0,0 +1,57 @@
// 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');

78
admin/scripts/gen-api.mjs Normal file
View file

@ -0,0 +1,78 @@
// 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

@ -0,0 +1,56 @@
// 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

@ -0,0 +1,290 @@
/* 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;
}
/* --- 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;
}
/* 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,37 @@ 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} from "lucide-react";
import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell, Users} from "lucide-react";
import {UpdateBanner} from "./components/UpdateBanner";
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'],
})
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', () => {
useStore.getState().setSettingsSocket(settingSocket);
useStore.getState().setShowLoading(false)
@ -53,32 +45,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') {
settingSocket.connect();
}
if (reason === 'io server disconnect') settingSocket.connect();
});
settingSocket.on('settings', (settings) => {
/* Check whether the settings.json is authorized to be viewed */
settingSocket.on('settings', (settings: any) => {
if (settings && typeof settings.flags === 'object' && settings.flags) {
useStore.getState().setGdprAuthorErasureEnabled(
!!settings.flags.gdprAuthorErasure);
}
if (settings.results === 'NOT_ALLOWED') {
console.log('Not allowed to view settings.json')
return;
}
/* Check to make sure the JSON is clean before proceeding */
if (isJSONClean(settings.results)) {
setSettings(settings.results);
} else {
alert('Invalid JSON');
}
if (isJSONClean(settings.results)) setSettings(settings.results);
else alert(t('admin_settings.invalid_json'));
useStore.getState().setShowLoading(false);
});
settingSocket.on('saveprogress', (status) => {
console.log(status)
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});
} else {
const detail = payload?.message ?? '';
setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false});
}
})
return () => {
@ -87,37 +79,109 @@ export const App = () => {
}
}, []);
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>
<li><NavLink to={"/update"}><Bell/><Trans i18nKey="update.page.title"/></NavLink></li>
</ul>
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/>
</div>
</div>
<button id="icon-button" onClick={() => {
setSidebarOpen(!sidebarOpen)
}}><LucideMenu/></button>
<div className="innerwrapper">
<UpdateBanner/>
<Outlet/>
</div>
</div>
)
}
export default App

View file

@ -0,0 +1,40 @@
// 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

@ -0,0 +1,20 @@
// 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');
});

30
admin/src/api/client.ts Normal file
View file

@ -0,0 +1,30 @@
// 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

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

View file

@ -1,8 +1,16 @@
import {useEffect} from 'react';
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);
@ -17,7 +25,51 @@ export const UpdateBanner = () => {
return () => { cancelled = true; };
}, [setUpdateStatus]);
if (!updateStatus || !updateStatus.latest) return null;
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 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 (

View file

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

@ -0,0 +1,173 @@
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';
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,
) => {
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}}`)}
/>
);
}
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);
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)}
</div>
{help && (
<p className="settings-row-help" id={helpId}>{help}</p>
)}
</div>
);
};

View file

@ -0,0 +1,36 @@
import { Trans, useTranslation } from 'react-i18next';
export type Mode = 'form' | 'raw';
type Props = {
mode: Mode;
onChange: (mode: Mode) => void;
};
export const ModeToggle = ({ mode, onChange }: Props) => {
const { t } = useTranslation();
return (
<div className="settings-mode-toggle" role="tablist" aria-label={t('admin_settings.mode.aria_label')}>
<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>
</div>
);
};

View file

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

@ -0,0 +1,93 @@
// 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

@ -0,0 +1,95 @@
// 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

@ -0,0 +1,21 @@
// 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

@ -0,0 +1,11 @@
// 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

@ -0,0 +1,46 @@
// 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

@ -0,0 +1,50 @@
// 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

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

@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { JSONPath } from 'jsonc-parser';
import type { EnvPlaceholder } from '../envPill';
type Props = {
placeholder: EnvPlaceholder;
path: JSONPath;
onChange: (newDefault: string) => void;
};
const sanitize = (s: string) => s.replace(/[}]/g, '');
export const EnvPill = ({ placeholder, path, onChange }: Props) => {
const { t } = useTranslation();
const initial = placeholder.defaultValue ?? '';
const [draft, setDraft] = useState(initial);
const focused = useRef(false);
// Sync local draft from upstream (server canonicalisation, raw-mode edit)
// only while the input isn't focused so we don't trample mid-typing.
useEffect(() => {
if (!focused.current) setDraft(initial);
}, [initial]);
const id = `field-${path.join('.')}`;
const testid = `env-${path.join('.')}`;
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);
}}
/>
</span>
);
};

View file

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

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

@ -0,0 +1,19 @@
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)}
/>
);

File diff suppressed because it is too large Load diff

View file

@ -58,7 +58,7 @@ i18n
.use(initReactI18next)
.init(
{
ns: ['translation','ep_admin_pads'],
ns: ['translation','ep_admin_pads','ep_admin_authors'],
fallbackLng: 'en'
}
)

View file

@ -11,9 +11,11 @@ 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/>}>
@ -22,6 +24,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">
@ -34,11 +37,13 @@ const router = createBrowserRouter(createRoutesFromElements(
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<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>
</QueryProvider>
</React.StrictMode>,
)

View file

@ -0,0 +1,295 @@
import {Trans, useTranslation} from "react-i18next";
import {useEffect, useMemo, useState} from "react";
import * as Dialog from "@radix-ui/react-dialog";
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">
{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>
<Dialog.Title asChild>
<h3>{t('ep_admin_authors:confirm-preview-title',
{name: p.name || p.authorID})}</h3>
</Dialog.Title>
<p>{t('ep_admin_authors:confirm-preview-counters', {
tokenMappings: p.removedTokenMappings,
externalMappings: p.removedExternalMappings,
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,70 +1,225 @@
import {Trans} from "react-i18next";
import {Trans, useTranslation} from "react-i18next";
import {useStore} from "../store/store.ts";
import {useEffect, useState} from "react";
import {useEffect, useMemo, 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 [helpData, setHelpData] = useState<HelpObj>();
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('')
useEffect(() => {
if(!settingsSocket) return;
settingsSocket?.on('reply:help', (data) => {
setHelpData(data)
});
if (!settingsSocket) return
settingsSocket.on('reply:help', (data) => setHelpData(data))
settingsSocket.emit('help')
}, [settingsSocket])
settingsSocket?.emit('help');
}, [settingsSocket]);
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])
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>
})
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))
}
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>
if (!helpData) return (
<div className="pm-page">
<div className="pm-empty"><div className="pm-empty-icon"></div></div>
</div>
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
<ul>
{helpData.installedPlugins.map((plugin, i) => <li key={plugin+i}>{plugin}</li>)}
</ul>
)
<h2><Trans i18nKey="admin_plugins_info.parts"/></h2>
<ul>
{helpData.installedParts.map((part, i) => <li key={part+i}>{part}</li>)}
</ul>
return (
<div className="pm-page">
<h2><Trans i18nKey="admin_plugins_info.hooks"/></h2>
{
renderHooks(helpData.installedServerHooks)
}
{/* ── 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_client"/>
{
renderHooks(helpData.installedClientHooks)
}
</h2>
{/* ── 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>
</div>
{/* ── 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>
)
}

View file

@ -3,269 +3,400 @@ 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 {SearchField} from "../components/SearchField.tsx";
import {ArrowUpFromDot, Download, Trash} from "lucide-react";
import {ArrowUpFromDot, Download, ExternalLink, Plug, RefreshCw, Search, Trash, X} 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 installedPlugins = useStore(state=>state.installedPlugins)
const setInstalledPlugins = useStore(state=>state.setInstalledPlugins)
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 [searchParams, setSearchParams] = useState<SearchParams>({
offset: 0,
limit: 99999,
sortBy: 'name',
sortDir: 'asc',
searchTerm: ''
searchTerm: '',
})
const [searchTerm, setSearchTerm] = useState<string>('')
const {t} = useTranslation()
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)
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
}
if(searchParams.sortBy === "last-updated"){
if(searchParams.sortDir === "asc"){
return a.time.localeCompare(b.time)
}
return b.time.localeCompare(a.time)
if (searchParams.sortBy === 'last-updated') {
return a.time.localeCompare(b.time) * dir
}
if (searchParams.sortBy === "name") {
if(searchParams.sortDir === "asc"){
return a.name.localeCompare(b.name)
}
return b.name.localeCompare(a.name)
}
return 0
return a.name.localeCompare(b.name) * dir
})
}, [plugins, searchParams])
const sortedInstalledPlugins = useMemo(()=>{
return useStore.getState().installedPlugins.sort((a, b)=>{
useEffect(() => {
if (!pluginsSocket) return
if(a.name < b.name){
return -1
}
if(a.name > b.name){
return 1
}
return 0
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,
})
} ,[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))
}
pluginsSocket.emit('getInstalled')
}
const onFinishedUninstall = () => {
console.log('Finished uninstall')
}
const onConnect = () => {
pluginsSocket.emit('getInstalled')
pluginsSocket.emit('search', searchParams)
}
const installPlugin = (pluginName: string)=>{
pluginsSocket!.emit('install', pluginName);
setPlugins(plugins.filter(plugin=>plugin.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})
}
useDebounce(()=>{
setSearchParams({
...searchParams,
offset: 0,
searchTerm: searchTerm
})
}, 500, [searchTerm])
pluginsSocket.emit('search', searchParams)
pluginsSocket.on('results:search', onSearchResults)
pluginsSocket.on('results:searcherror', onSearchError)
return () => {
pluginsSocket.off('results:search', onSearchResults)
pluginsSocket.off('results:searcherror', onSearchError)
}
}, [searchParams, pluginsSocket])
return <div>
<h1><Trans i18nKey="admin_plugins"/></h1>
const uninstallPlugin = (pluginName: string) => {
pluginsSocket!.emit('uninstall', pluginName)
setInstalledPlugins(installedPlugins.filter(i => i.name !== pluginName))
}
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
const installPlugin = (pluginName: string) => {
pluginsSocket!.emit('install', pluginName)
setPlugins(plugins.filter(p => p.name !== pluginName))
}
<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>
{
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>
</tr>
})}
</tbody>
</table>
useDebounce(() => {
setSearchParams({...searchParams, offset: 0, searchTerm})
}, 500, [searchTerm])
return (
<div className="pm-page">
<h2><Trans i18nKey="admin_plugins.available"/></h2>
<SearchField onChange={v=>{setSearchTerm(v.target.value)}} placeholder={t('admin_plugins.available_search.placeholder')} value={searchTerm}/>
{catalogDisabled && (
<div className="pm-banner pm-banner-info" role="status">
<Trans i18nKey="admin_plugins.catalog_disabled"/>
</div>
)}
<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}
{plugin.disables && plugin.disables.length > 0 && (
<div
className="plugin-disables"
role="alert"
title={t('admin_plugins.disables.warning_title')}
style={{
marginTop: '0.25rem',
padding: '0.2rem 0.5rem',
borderRadius: '4px',
fontSize: '0.85em',
background: 'rgba(180, 83, 9, 0.15)',
border: '1px solid rgba(180, 83, 9, 0.4)',
color: '#92400e',
display: 'inline-block',
}}
>
<strong><Trans i18nKey="admin_plugins.disables.label"/></strong>{' '}
{plugin.disables
.map((tag) => tag.replace(/^@feature:/, ''))
.join(', ')}
</div>
)}
</td>
<td>{plugin.version}</td>
<td>{plugin.time}</td>
<td>
<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>
{/* ── 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}>
<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>
</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>
</div>
)
}

View file

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

View file

@ -3,282 +3,454 @@ import {useEffect, useMemo, useState} from "react";
import {useStore} from "../store/store.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 {IconButton} from "../components/IconButton.tsx";
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon} from "lucide-react";
import {SearchField} from "../components/SearchField.tsx";
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react";
import {useForm} from "react-hook-form";
import type {TFunction} from "i18next";
type PadCreateProps = {
padName: string
type PadCreateProps = { padName: string }
type FilterId = 'all' | 'active' | 'recent' | 'empty' | 'stale'
const PAD_FILTER_IDS: FilterId[] = ['all', 'active', 'recent', 'empty', 'stale']
const isRecent = (ts: number) => (Date.now() - ts) < 86_400_000 * 7
const isStale = (ts: number) => (Date.now() - ts) > 86_400_000 * 365
function relativeTime(t: TFunction, ts: number): string {
const d = (Date.now() - ts) / 1000
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)})
}
export const PadPage = ()=>{
const settingsSocket = useStore(state=>state.settingsSocket)
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)
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
offset: 0,
limit: 12,
pattern: '',
sortBy: 'padName',
ascending: true
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: 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 {t, i18n} = useTranslation()
const locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language)
const [searchTerm, setSearchTerm] = useState('')
const [filter, setFilter] = useState<FilterId>('all')
const [selected, setSelected] = useState<Set<string>>(new Set())
const pads = useStore(state => state.pads)
const [currentPage, setCurrentPage] = useState(0)
const [deleteDialog, setDeleteDialog] = useState(false)
const [errorText, setErrorText] = useState<string | null>(null)
const [padToDelete, setPadToDelete] = useState('')
const [createPadDialogOpen, setCreatePadDialogOpen] = useState(false)
const {register, handleSubmit} = useForm<PadCreateProps>()
const pages = useMemo(()=>{
if(!pads){
return 0;
}
return Math.ceil(pads!.total / searchParams.limit)
},[pads, searchParams.limit])
const pages = useMemo(
() => pads ? Math.ceil(pads.total / searchParams.limit) : 0,
[pads, searchParams.limit]
)
useDebounce(()=>{
setSearchParams({
...searchParams,
pattern: searchTerm
})
const filteredResults = useMemo(() => {
const r = pads?.results ?? []
if (filter === 'active') return r.filter(p => p.userCount > 0)
if (filter === 'recent') return r.filter(p => isRecent(p.lastEdited))
if (filter === 'empty') return r.filter(p => p.revisionNumber === 0)
if (filter === 'stale') return r.filter(p => isStale(p.lastEdited))
return r
}, [pads, filter])
const totalUsers = useMemo(() => (pads?.results ?? []).reduce((s, p) => s + p.userCount, 0), [pads])
const activeCount = useMemo(() => (pads?.results ?? []).filter(p => p.userCount > 0).length, [pads])
const emptyCount = useMemo(() => (pads?.results ?? []).filter(p => p.revisionNumber === 0).length, [pads])
const lastActivity = useMemo(() => {
const r = pads?.results ?? []
return r.length ? Math.max(...r.map(p => p.lastEdited)) : null
}, [pads])
const allSelected = filteredResults.length > 0 && filteredResults.every(p => selected.has(p.padName))
const toggleAll = () => {
const s = new Set(selected)
if (allSelected) filteredResults.forEach(p => s.delete(p.padName))
else filteredResults.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(() => {
setSearchParams({...searchParams, pattern: searchTerm})
}, 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((pad)=>{
return pad.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(p => p.padName !== padID)
useStore.getState().setPads({total: useStore.getState().pads!.total - 1, results: newPads})
})
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
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)
}
})
useStore.getState().setPads({
results: newPads,
total: useStore.getState().pads!.total
})
})
}, [settingsSocket, pads]);
const deletePad = (padID: string)=>{
settingsSocket?.emit('deletePad', padID)
}
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 cleanupPad = (padID: string)=>{
settingsSocket?.emit('cleanupPadRevisions', 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 onPadCreate = (data: PadCreateProps)=>{
settingsSocket?.emit('createPad', {
padName: data.padName
})
}
return (
<div className="pm-page">
{/* ── Dialogs ── */}
<Dialog.Root open={deleteDialog}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
<div className="settings-button-bar">
<button onClick={() => setDeleteDialog(false)}><Trans i18nKey="admin_pads.cancel"/></button>
<button onClick={() => { deletePad(padToDelete); setDeleteDialog(false) }}>{t('admin_pads.confirm_button')}</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
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')}/>
<Dialog.Root open={errorText !== null}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
<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>
<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})}/>
</div>
</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>
<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">{filteredResults.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({
...searchParams,
sortBy: e.target.value,
// Keep current direction when only the column changes; the
// ↑/↓ button below is the sole control for direction.
})}
>
<option value="lastEdited">{t('ep_admin_pads:ep_adminpads2_last-edited')}</option>
<option value="padName">{t('admin_pads.sort.name')}</option>
<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({
...searchParams,
ascending: !searchParams.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>
)}
{filteredResults.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>
{filteredResults.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/${pad.padName}`, '_blank')}
>
<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({...searchParams, offset: p * searchParams.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({...searchParams, offset: p * searchParams.limit})
}}
>
<Trans i18nKey="admin_pads.pagination.next"/> <ChevronRight size={14}/>
</button>
</div>
</section>
</div>
</div>
)
}

View file

@ -17,7 +17,8 @@ export type InstalledPlugin = {
name: string,
path: string,
realPath: string,
version:string,
version: string,
description?: string,
updatable?: boolean
}

View file

@ -1,50 +1,123 @@
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";
import React, { useState } from 'react';
import { useStore } from '../store/store';
import { isJSONClean, cleanComments } from '../utils/utils';
import { Trans, useTranslation } from 'react-i18next';
import { IconButton } from '../components/IconButton';
import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react';
import { FormView } from '../components/settings/FormView';
import { ModeToggle, type Mode } from '../components/settings/ModeToggle';
export const SettingsPage = ()=>{
const settingsSocket = useStore(state=>state.settingsSocket)
const settings = cleanComments(useStore(state=>state.settings))
const TAB_INDENT = ' ';
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');
}}/>
export const SettingsPage = () => {
const { t } = useTranslation();
const settingsSocket = useStore(state => state.settingsSocket);
const settings = useStore(state => state.settings) ?? '';
const [mode, setMode] = useState<Mode>('form');
const [exposeExperimental] = useState(false);
// 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);
};
return (
<div className="settings-page">
<h1><Trans i18nKey="admin_settings.current" /></h1>
<ModeToggle mode={mode} onChange={setMode} />
{mode === 'form'
? <FormView onSwitchToRaw={() => setMode('raw')} />
: (
<textarea
value={settings}
className="settings"
data-testid="settings-raw-textarea"
spellCheck={false}
onKeyDown={handleKeyDown}
onChange={v => useStore.getState().setSettings(v.target.value)}
/>
)
}
<div className="settings-button-bar">
<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>
</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,6 +3,7 @@ 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);
@ -11,6 +12,7 @@ export const ShoutPage = ()=>{
const socket = useStore(state => state.settingsSocket);
const pluginSocket = useStore(state => state.pluginsSocket);
const [shouts, setShouts] = useState<ShoutType[]>([]);
const {t} = useTranslation()
useEffect(() => {
@ -42,8 +44,8 @@ export const ShoutPage = ()=>{
return (
<div>
<h1>Communication</h1>
{totalUsers > 0 && <p>There {totalUsers>1?"are":"is"} currently {totalUsers} user{totalUsers>1?"s":""} online</p>}
<h1><Trans i18nKey="admin.shout"/></h1>
{totalUsers > 0 && <p>{t('admin_shout.online', {count: totalUsers})}</p>}
<div style={{height: '80vh', display: 'flex', flexDirection: 'column'}}>
<div style={{flexGrow: 1, backgroundColor: 'white', overflowY: "auto"}}>
{
@ -66,7 +68,7 @@ export const ShoutPage = ()=>{
e.preventDefault()
sendMessage()
}} className="send-message search-field" style={{display: 'flex', gap: '10px'}}>
<Switch.Root title="Change sticky message" className="SwitchRoot" checked={sticky}
<Switch.Root title={t('admin_shout.sticky_toggle')} className="SwitchRoot" checked={sticky}
onCheckedChange={() => {
setSticky(!sticky);
}}>

View file

@ -9,37 +9,98 @@ type FetchState =
| {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;
fetch('/admin/update/status', {credentials: 'same-origin'})
.then(async (r) => {
if (cancelled) return;
if (r.ok) {
const data = await r.json();
setUpdateStatus(data);
setFetchState({kind: 'ok'});
} else if (r.status === 404) {
setFetchState({kind: 'disabled'});
} else if (r.status === 401 || r.status === 403) {
setFetchState({kind: 'unauthorized'});
} else {
setFetchState({kind: 'error', status: r.status});
}
})
.catch(() => {
if (!cancelled) setFetchState({kind: 'error', status: 0});
});
void refreshStatus().then(() => { if (cancelled) return; });
return () => { cancelled = true; };
}, [setUpdateStatus]);
// 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>;
@ -61,16 +122,30 @@ export const UpdatePage = () => {
);
}
if (fetchState.kind === 'error' || !us) {
const status = fetchState.kind === 'error' ? fetchState.status : 0;
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})}</p>
<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">
@ -86,7 +161,67 @@ export const UpdatePage = () => {
<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>
</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 ? (

View file

@ -1,8 +1,30 @@
import {create} from "zustand";
import {Socket} from "socket.io-client";
import {PadSearchResult} from "../utils/PadSearch.ts";
import {AuthorSearchResult} from "../utils/AuthorSearch.ts";
import {InstalledPlugin} from "../pages/Plugin.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 UpdateStatusPayload {
currentVersion: string;
latest: null | {
@ -18,6 +40,10 @@ export interface UpdateStatusPayload {
tier: string;
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
vulnerableBelow: Array<{announcedBy: string; threshold: string}>;
// Tier 2 additions:
execution: Execution;
lastResult: LastResult;
lockHeld: boolean;
}
type ToastState = {
@ -45,6 +71,12 @@ type StoreState = {
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,
}
@ -70,4 +102,10 @@ export const useStore = create<StoreState>()((set) => ({
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}),
}));

View file

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

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

View file

@ -1,5 +1,17 @@
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',
)
// https://vitejs.dev/config/
export default defineConfig({
@ -11,6 +23,9 @@ export default defineConfig({
}),
],
base: '/admin',
define: {
__SETTINGS_TEMPLATE__: JSON.stringify(settingsTemplate),
},
build: {
outDir: '../src/templates/admin',
emptyOutDir: true,

328
bin/compactStalePads.ts Normal file
View file

@ -0,0 +1,328 @@
'use strict';
/*
* Compact every pad on the instance that has not been edited recently.
*
* Usage:
* node bin/compactStalePads.js --older-than 90 # collapse history on pads not edited in 90 days
* node bin/compactStalePads.js --older-than 90 --keep 50 # keep last 50 revisions
* node bin/compactStalePads.js --older-than 90 --dry-run # list, don't write
*
* Composes `listAllPads` `getLastEdited` `compactPad`. Same shape as
* `bin/compactAllPads` (per-pad error tolerance, dry-run, tally), but
* 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(' node bin/compactStalePads.js --older-than <days>');
console.error(' node bin/compactStalePads.js --older-than <days> --keep <N>');
console.error(' node bin/compactStalePads.js --older-than <days> --dry-run');
process.exit(2);
};
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);
})();
}

View file

@ -1,5 +1,5 @@
# minimum required node version
REQUIRED_NODE_MAJOR=22
REQUIRED_NODE_MAJOR=24
REQUIRED_NODE_MINOR=0
# minimum required npm version

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 = 22
$RequiredNodeMajor = 24
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=22
REQUIRED_NODE_MAJOR=24
step "Etherpad installer"

View file

@ -1,6 +1,6 @@
{
"name": "bin",
"version": "2.7.3",
"version": "3.0.0",
"description": "",
"main": "checkAllPads.js",
"directories": {
@ -9,12 +9,12 @@
"dependencies": {
"ep_etherpad-lite": "workspace:../src",
"log4js": "^6.9.1",
"semver": "^7.7.4",
"tsx": "^4.21.0",
"ueberdb2": "^5.0.48"
"semver": "^7.8.0",
"tsx": "^4.22.0",
"ueberdb2": "^6.0.3"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/node": "^25.8.0",
"@types/semver": "^7.7.1",
"typescript": "^6.0.3"
},

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. setup-node's `22` resolves to the latest
# 22.x, which satisfies that.
node-version: 22
# Node >= 22.9.0. Use Node 24 to match the rest of CI and the
# Etherpad core minimum.
node-version: 24
registry-url: https://registry.npmjs.org/
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
run: npm install -g npm@latest

View file

@ -3,10 +3,17 @@
// Returns a list of stale plugins and their authors email
import process from "node:process";
import settings from "../../src/node/utils/Settings";
const currentTime = new Date();
(async () => {
const resp = await fetch('https://static.etherpad.org/plugins.full.json');
if (!settings.privacy.pluginCatalog) {
console.info(
'stalePlugins: plugin catalog disabled by privacy.pluginCatalog=false; exiting'
);
process.exit(0);
}
const resp = await fetch(`${settings.updateServer}/plugins.full.json`);
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${resp.statusText}`);
const data: any = await resp.json();
for (const plugin of Object.keys(data)) {

View file

@ -1,8 +1,11 @@
# Etherpad updates
Etherpad ships with a built-in update subsystem. **Tier 1 (notify)** is enabled by default: a banner appears in the admin UI when a new release is available, and pad users see a discreet badge if the running version is severely outdated or flagged as vulnerable. No automatic execution happens at this tier — admins are simply informed.
Etherpad ships with a built-in update subsystem.
Tiers 2 (manual click), 3 (auto with grace window), and 4 (autonomous in maintenance window) are designed but not yet implemented. They will land in subsequent releases.
- **Tier 1 (notify)** — default. A banner appears in the admin UI when a new release is available, and pad users see a discreet badge if the running version is severely outdated or flagged as vulnerable. No execution.
- **Tier 2 (manual click)** — admins on a git install can click "Apply update" at `/admin/update`. Etherpad drains active sessions, runs `git fetch / checkout / pnpm install / pnpm run build:ui`, and exits with code 75 so a process supervisor restarts it on the new version. Auto-rolls back on failure.
- **Tier 3 (auto with grace window)** — opt-in. On a git install, a newly detected release transitions execution state to `scheduled` and is applied after `preApplyGraceMinutes`. During the grace window, `/admin/update` shows a live countdown plus Cancel and Apply now buttons; an admin email (if `adminEmail` is set) fires once per scheduled tag.
- **Tier 4 (autonomous in maintenance window)** — designed, not yet implemented.
## Settings
@ -17,7 +20,14 @@ In `settings.json`:
"installMethod": "auto",
"checkIntervalHours": 6,
"githubRepo": "ether/etherpad",
"requireAdminForStatus": false
"requireAdminForStatus": false,
// Tier 2+ knobs (only meaningful at tier "manual" or higher):
"preApplyGraceMinutes": 0,
"drainSeconds": 60,
"rollbackHealthCheckSeconds": 60,
"diskSpaceMinMB": 500,
"requireSignature": false,
"trustedKeysPath": null
},
"adminEmail": null
}
@ -32,6 +42,12 @@ In `settings.json`:
| `updates.checkIntervalHours` | `6` | How often to poll GitHub Releases. |
| `updates.githubRepo` | `"ether/etherpad"` | Override for forks. |
| `updates.requireAdminForStatus` | `false` | Lock the `/admin/update/status` endpoint to authenticated admin sessions. Default `false` matches existing Etherpad behavior — `/health` already exposes `releaseId` publicly, and changelog data comes from a public GitHub release. Set `true` to hide the full update payload from non-admins without disabling the updater (`tier: "off"` is the heavier opt-out that removes the endpoints entirely). |
| `updates.preApplyGraceMinutes` | `0` | **Tier 3 only.** Wait this many minutes between detecting a new release and starting the drain so the admin can cancel via `/admin/update`. `0` applies immediately when allowed. Clamped to `[0, 7*24*60]` (one week). Has no effect at tier `"manual"`. |
| `updates.drainSeconds` | `60` | How long to broadcast "restart imminent" announcements to active pads before exiting. T-60 / T-30 / T-10 broadcasts fire automatically at the matching offsets within this window. |
| `updates.rollbackHealthCheckSeconds` | `60` | After a fresh boot post-update, give `/health` this long to come up. If it doesn't, RollbackHandler restores the previous SHA. |
| `updates.diskSpaceMinMB` | `500` | Pre-flight refuses to start an update unless the install volume has at least this many MB free. |
| `updates.requireSignature` | `false` | When `true`, refuse updates whose tag is not signed by a trusted key. Verification is done via `git verify-tag <tag>` against the user's GPG keyring. Default `false` because Etherpad's release process does not yet sign tags consistently — turning the check on by default would block every Tier 2 update. Set `true` if you run your own builds or have imported a fork's keys. |
| `updates.trustedKeysPath` | `null` | Override the keyring location passed to `git verify-tag` via the `$GNUPGHOME` env var. Useful when the trusted keys live in a dedicated keyring outside the Etherpad user's home. Only meaningful when `requireSignature: true`. |
| `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
## What "outdated" means
@ -81,3 +97,98 @@ The version check sends no telemetry. Etherpad fetches the public GitHub Release
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only `"git"` is initially supported for write tiers.
## Tier 2 — manual click
Tier 2 is opt-in. To enable: set `updates.tier: "manual"` and ensure your install was deployed via git (not docker / npm / managed package).
### Process supervisor is required
Etherpad applies an update by **exiting with code 75** so a process supervisor restarts it. Without a supervisor the instance simply exits and stays down. Common supervisor setups:
- **systemd:** add `Restart=on-failure` + `RestartSec=5` to your unit file.
- **pm2:** the default behaviour restarts on exit.
- **docker:** add `--restart=unless-stopped` (Tier 2 itself is not supported on docker installs anyway, but if you wrap your own image around a git checkout this applies).
### What clicking "Apply update" does
1. **Lock acquire**`var/update.lock` (PID-based, stale locks reaped automatically).
2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, target tag exists at the configured remote, signature verifies (if `requireSignature: true`). On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back.
3. **Drain**`drainSeconds` window during which T-60 / T-30 / T-10 announcements broadcast to every connected pad and new socket connections are refused. Click **Cancel** during this window to abort cleanly.
4. **Execute**`git fetch --tags origin`, `git checkout <tag>`, `pnpm install --frozen-lockfile`, `pnpm run build:ui`. Output streams to `var/log/update.log` (rotated 10 MB × 5).
5. **Exit 75** — the supervisor restarts on the new version.
6. **Health check** — RollbackHandler arms a `rollbackHealthCheckSeconds` timer at boot. When `/health` responds 200 (i.e., Etherpad reaches the `RUNNING` state) the timer cancels and the state lands on `verified`.
### Failure modes
| What went wrong | Resulting state | Admin action |
| --- | --- | --- |
| Pre-flight check fails | `preflight-failed` | Click **Acknowledge** after fixing the underlying issue (free up disk, clean working tree, etc.). |
| `git fetch` / `git checkout` fails mid-flow | `rolled-back` | Informational. The working tree is back where it started; click **Acknowledge** to clear. |
| `pnpm install` or `pnpm run build:ui` fails | `rolled-back` | Same as above. The lockfile and SHA are restored. |
| `/health` doesn't come up within `rollbackHealthCheckSeconds` | `rolled-back` | Same — RollbackHandler restores the previous SHA + lockfile and exits 75 again. |
| The new version crashes at boot more than twice (`bootCount > 2`) | `rolled-back` | Crash-loop guard kicks in regardless of the health-check timer. |
| Rollback itself fails (e.g., `pnpm install` errors restoring old lockfile) | `rollback-failed` | **Manual intervention required.** The admin banner switches to a strong red alert. Restore the install by hand, then click **Acknowledge** to clear the lock and re-allow Tier 2 attempts. |
### Endpoints
All Tier 2 endpoints require an authenticated admin session (`is_admin: true`) regardless of `requireAdminForStatus`.
- `POST /admin/update/apply` — start an apply. Returns `202 {accepted, drainEndsAt}` once the drain begins. Body unused.
- `POST /admin/update/cancel` — cancel during pre-flight or drain. Returns `409` once the executor has begun mutating the filesystem (state machine guarantees we either complete or roll back from there).
- `POST /admin/update/acknowledge` — clear a terminal `preflight-failed` / `rolled-back` / `rollback-failed` state back to `idle`.
- `GET /admin/update/log` — tail the last 200 lines of `var/log/update.log`. Plain text. Used by the in-progress UI.
### Signature verification
Default off. Etherpad releases are not yet consistently signed; turning verification on by default would block every Tier 2 update. To enable:
```jsonc
"updates": {
"requireSignature": true,
"trustedKeysPath": "/srv/etherpad/keys" // optional — defaults to the OS user keyring
}
```
The check shells out to `git verify-tag <tag>`. The keyring at `trustedKeysPath` is passed to git via `GNUPGHOME`. If `trustedKeysPath` is `null` (default), the OS user's default keyring is used.
### Docker-friendly update flows (future work)
Tier 2 deliberately refuses to apply on `installMethod: "docker"` because in-container `git fetch / pnpm install / build:ui` doesn't survive a container restart — the orchestrator brings the container back up on the same image tag and the work is lost. Docker installs stay on Tier 1 (banner + version status) for now.
## Tier 3 — auto with grace window
Tier 3 builds on Tier 2 by scheduling the apply automatically when a new release is detected. The same `git fetch / checkout / pnpm install / build:ui / exit 75` pipeline runs — only the trigger changes.
To enable, on a git install: set `updates.tier: "auto"` and (optionally) `updates.preApplyGraceMinutes` to the grace duration you want.
### What happens when a new release lands
1. The periodic version checker (`updates.checkIntervalHours`) hits GitHub Releases.
2. If `policy.canAuto` is true (install is git, no terminal `rollback-failed` state, tier is `"auto"` or `"autonomous"`), the scheduler transitions `execution.status` to `scheduled` with `scheduledFor = now + preApplyGraceMinutes`.
3. The schedule is persisted to `var/update-state.json`, so an Etherpad restart inside the grace window rehydrates the timer rather than losing the schedule.
4. `/admin/update` shows a live countdown panel plus two buttons:
- **Cancel**`POST /admin/update/cancel` returns the state to `idle` and drops the in-process timer.
- **Apply now**`POST /admin/update/apply` skips the remaining grace; the regular Tier 2 pipeline runs immediately.
5. When the timer fires, the scheduler runs the exact same pipeline as a manual Tier 2 click: pre-flight → drain → execute → exit 75.
### Re-scheduling and stale state
- If a newer release tag appears while a schedule is pending, the scheduler re-arms the timer for the new tag. The `email.graceStartTag` dedupe field guards against duplicate `grace-start` notifications.
- If `updates.tier` is flipped back to `"manual"` or `"notify"` while a schedule is pending, the next periodic check cancels the schedule (state back to `idle`).
- `rollback-failed` disables Tier 3 globally. The admin must `POST /admin/update/acknowledge` (or visit `/admin/update` and click Acknowledge) before any further auto-schedules are armed. Tier 2 manual click stays available because the admin click *is* the intervention the terminal state requires.
### Email (`adminEmail` set)
A single `grace-start` notification fires per scheduled tag:
> [Etherpad] Auto-update scheduled for 2.7.2
with the `scheduledFor` timestamp. Etherpad core does not yet wire SMTP; the message logs as `(would send email)` until a future PR adds a transport. Cadence and dedupe still update correctly.
The right way to give docker admins an in-product Apply button is to delegate to the orchestrator rather than mutate the container. Two patterns to consider in a follow-up PR:
- **Instructions-only.** When the page detects `installMethod: docker` *and* a newer release exists, swap the policy-denial copy for actionable instructions (`docker pull etherpad/etherpad:<tag>` for plain docker; `docker compose pull && docker compose up -d` for compose). Cheap, no new attack surface.
- **Deploy webhook.** New setting `updates.dockerWebhook`. When set, the Apply button on a docker install POSTs to the configured URL and trusts the orchestrator (Render / Railway / Fly / Portainer / Coolify / GitHub Actions — they all expose redeploy webhooks) to do the actual pull-and-recreate.
Direct Docker-socket access (mount `/var/run/docker.sock` into the container) is **out of scope** — anyone who escapes the Etherpad process via that socket gets root on the host. Admins who want fully autonomous docker updates should run [Watchtower](https://containrrr.dev/watchtower/) alongside Etherpad rather than bake equivalent privilege into Etherpad itself.

View file

@ -27,3 +27,42 @@ In this example we migrate from the old dirty db to the new rustydb engine. So w
After that we need to move the data from dirty to rustydb.
Therefore, we call `pnpm run --filter bin migrateDB --file1 test1.json --file2 test2.json` with these two files in our root directories. After some time the data should be copied over to the new database.
## Pad compaction
Long-lived pads with heavy edit history accumulate revisions in the database. Three CLIs reclaim that space, in increasing scope:
| Tool | Targets | When to use |
| --- | --- | --- |
| `bin/compactPad.js <padID>` | one pad | you know which pad is fat |
| `bin/compactAllPads.js` | every pad | bulk reclaim across the whole instance |
| `bin/compactStalePads.js --older-than N` | pads not edited in N days | reclaim the cold tail without touching pads still in active use |
All three are gated on `cleanup.enabled = true` in `settings.json` and are **destructive**: history is collapsed (or trimmed). Export anything you can't afford to lose with `getEtherpad` first.
Common flags:
- `--keep N` — retain the last N revisions instead of collapsing all history.
- `--dry-run` — list pads and revision counts without writing.
### Examples
````
# Compact a specific pad, collapsing all history.
node bin/compactPad.js my-pad
# Keep only the last 50 revisions of one pad.
node bin/compactPad.js my-pad --keep 50
# Compact every pad on the instance (per-pad failures don't stop the run).
node bin/compactAllPads.js
node bin/compactAllPads.js --dry-run
# Compact only pads not edited in the last 90 days, keeping the last 50 revisions.
node bin/compactStalePads.js --older-than 90 --keep 50
node bin/compactStalePads.js --older-than 90 --dry-run
````
`bin/compactStalePads.js` is the right tool for periodic operator runs on long-lived instances — hot pads that users are still navigating in timeslider stay untouched, and only the cold tail is rewritten. Per-pad failures (including a `getLastEdited` fault) are counted but do not abort the bulk run; the exit code reflects whether anything failed.
See the `compactPad` HTTP API in `doc/api/http_api.md` for the same primitive over the wire (issues #6194, #7642).

View file

@ -15,4 +15,4 @@ Etherpad HTTP API clients may make use (if they choose so) to send another cooki
| Name | Sample value | Domain | Usage description |
|-----------|------------------------------------|-------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| sessionID | s.1c70968b333b25476a2c7bdd0e0bed17 | example.org | Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. More info - https://github.com/ether/etherpad-lite/blob/develop/doc/api/http_api.md#session |
| sessionID | s.1c70968b333b25476a2c7bdd0e0bed17 | example.org | Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID is set as a cookie by the integrator and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. Since [#7045](https://github.com/ether/etherpad/issues/7045) Etherpad reads this cookie server-side from the socket.io handshake, so integrators **should** set it as `HttpOnly; Secure; SameSite=Lax` to mitigate XSS. More info - https://github.com/ether/etherpad-lite/blob/develop/doc/api/http_api.md#session |

View file

@ -62,16 +62,17 @@ The variable value has to be a space separated, double quoted list of plugin nam
Some plugins will need personalized settings. Just refer to the previous section, and include them in your custom `settings.json.docker`.
==== Rebuilding including export functionality for DOC/DOCX/PDF/ODT
==== Office-format import/export
If you want to be able to export your pads to DOC/DOCX/PDF/ODT files, you can
install Libreoffice via setting the `INSTALL_SOFFICE` build variable to any
value.
DOCX export, PDF export, and DOCX import work out of the box — Etherpad
ships pure-JS in-process converters and needs no extra dependencies for
those three formats.
Also, you will need to configure the path to the libreoffice executable
via setting the `soffice` property in `<BASEDIR>/settings.json.docker` to
`/usr/bin/soffice` or via setting the environment variable `SOFFICE` to
`/usr/bin/soffice`.
DOC/ODT/RTF export and PDF import still require LibreOffice. To enable
them, install LibreOffice via the `INSTALL_SOFFICE` build variable (any
value), and either set the `soffice` property in
`<BASEDIR>/settings.json.docker` to `/usr/bin/soffice` or set the
`SOFFICE` environment variable to `/usr/bin/soffice`.
==== Examples
@ -452,7 +453,7 @@ For the editor container, you can also make it full width by adding `full-width-
| `21600` (6 hours)
| `SOFFICE`
| Absolute path to the soffice (LibreOffice) executable. Needed for advanced import/export of pads (docx, pdf, odt). Setting it to null disables LibreOffice and will only allow plain text and HTML import/exports.
| Absolute path to the soffice (LibreOffice) executable. When configured, all advanced import/export formats use it (docx, pdf, odt, doc, rtf). Setting it to null falls back to in-process pure-JS converters: docx and pdf export, plus docx import, still work; odt/doc/rtf and pdf import remain unavailable.
| `null`
| `ALLOW_UNKNOWN_FILE_ENDS`

View file

@ -35,16 +35,17 @@ The variable value has to be a space separated, double quoted list of plugin nam
Some plugins will need personalized settings. Just refer to the previous section, and include them in your custom `settings.json.docker`.
### Rebuilding including export functionality for DOC/DOCX/PDF/ODT
### Office-format import/export
If you want to be able to export your pads to DOC/DOCX/PDF/ODT files, you can
install Libreoffice via setting the `INSTALL_SOFFICE` build variable to any
value.
DOCX export, PDF export, and DOCX import work out of the box — Etherpad
ships pure-JS in-process converters and needs no extra dependencies for
those three formats.
Also, you will need to configure the path to the libreoffice executable
via setting the `soffice` property in `<BASEDIR>/settings.json.docker` to
`/usr/bin/soffice` or via setting the environment variable `SOFFICE` to
`/usr/bin/soffice`.
DOC/ODT/RTF export and PDF import still require LibreOffice. To enable
them, install LibreOffice via the `INSTALL_SOFFICE` build variable (any
value), and either set the `soffice` property in
`<BASEDIR>/settings.json.docker` to `/usr/bin/soffice` or set the
`SOFFICE` environment variable to `/usr/bin/soffice`.
### Examples
@ -197,7 +198,7 @@ For the editor container, you can also make it full width by adding `full-width-
| `EDIT_ONLY` | Users may edit pads but not create new ones. Pad creation is only via the API. This applies both to group pads and regular pads. | `false` |
| `MINIFY` | If true, all css & js will be minified before sending to the client. This will improve the loading performance massively, but makes it difficult to debug the javascript/css | `true` |
| `MAX_AGE` | How long may clients use served javascript code (in seconds)? Not setting this may cause problems during deployment. Set to 0 to disable caching. | `21600` (6 hours) |
| `SOFFICE` | Absolute path to the soffice (LibreOffice) executable. Needed for advanced import/export of pads (docx, pdf, odt). Setting it to null disables LibreOffice and will only allow plain text and HTML import/exports. | `null` |
| `SOFFICE` | Absolute path to the soffice (LibreOffice) executable. When configured, all advanced import/export formats use it (docx, pdf, odt, doc, rtf). Setting it to null falls back to in-process pure-JS converters: docx and pdf export, plus docx import, still work; odt/doc/rtf and pdf import remain unavailable. | `null` |
| `ALLOW_UNKNOWN_FILE_ENDS` | Allow import of file types other than the supported ones: txt, doc, docx, rtf, odt, html & htm | `true` |
| `REQUIRE_AUTHENTICATION` | This setting is used if you require authentication of all users. Note: "/admin" always requires authentication. | `false` |
| `REQUIRE_AUTHORIZATION` | Require authorization by a module, or a user with is_admin set, see below. | `false` |

View file

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

View file

@ -1,6 +1,6 @@
{
"devDependencies": {
"oxc-minify": "^0.129.0",
"oxc-minify": "^0.131.0",
"vitepress": "^2.0.0-alpha.17"
},
"scripts": {

View file

@ -231,7 +231,7 @@ publish your plugin.
"author": "USERNAME (REAL NAME) <MAIL@EXAMPLE.COM>",
"contributors": [],
"dependencies": {"MODULE": "0.3.20"},
"engines": {"node": ">=12.17.0"}
"engines": {"node": ">=22.0.0"}
}
----

View file

@ -226,7 +226,7 @@ publish your plugin.
"author": "USERNAME (REAL NAME) <MAIL@EXAMPLE.COM>",
"contributors": [],
"dependencies": {"MODULE": "0.3.20"},
"engines": {"node": ">=12.17.0"}
"engines": {"node": ">=22.0.0"}
}
```
@ -238,6 +238,77 @@ operations in `templates/`, in files of type ".ejs", since Etherpad uses EJS for
HTML templating. See the following link for more information about EJS:
<https://github.com/visionmedia/ejs>.
## Plugin-namespaced pad-wide options
Plugins can ride the existing `padoptions` COLLABROOM rail to store
pad-wide settings — broadcast to every connected client, persisted with the
pad, and honored by `enforceSettings` — instead of inventing their own
message type and storage. The model matches how `enablePadWideSettings`
works for native toggles like sticky chat or line numbers.
### Capability detection
```js
let padOptionsPluginPassthrough = false;
try {
// The require throws on Etherpad versions that predate this capability;
// plugins should degrade gracefully (typically falling back to a per-user
// cookie toggle) when the flag is missing.
padOptionsPluginPassthrough =
require('ep_etherpad-lite/node/utils/PluginCapabilities')
.padOptionsPluginPassthrough === true;
} catch (_e) { /* older core */ }
```
The flag means the core has the passthrough patch *available*. Whether it
is actually *enabled* at runtime is a separate per-instance setting — see
below.
### Runtime flag
The passthrough is gated by `settings.enablePluginPadOptions`, default
`false`. Operators must opt in via `settings.json`:
```json
{
"enablePluginPadOptions": true
}
```
When enabled, the server reflects the value to every client via
`clientVars.enablePluginPadOptions` so plugins can detect both *capable*
(static) and *active* (per-pad request) at the same point.
### Key namespace
Plugins must use keys matching `/^ep_[a-z0-9_]+$/`. The recommended pattern
is `ep_<plugin_name>` (e.g. `ep_table_of_contents`); compose multiple
pad-wide settings under one key as a plain object:
```js
pad.changePadOption('ep_my_plugin', {enabled: true, depth: 3});
```
The server passes through any matching key on the existing `padoptions`
message, persists it with the pad, and broadcasts it to every connected
client. `pad.padOptions.ep_my_plugin` reflects the latest value on every
client.
### Validation
Server-side `Pad.normalizePadSettings()` enforces three rules on every
plugin-namespaced key:
- Values must round-trip through `JSON.stringify` (no functions, symbols,
BigInt, or circular references).
- Each key's serialized payload must fit within **64 KB**.
- The combined size of all `ep_*` values per pad must fit within **256 KB**.
Values that fail any of these rules are dropped with a `console.warn`; the
rest of the settings round-trip cleanly. The caps prevent a misbehaving
plugin from bloating the persisted pad payload or the COLLABROOM
broadcast.
## Writing and running front-end tests for your plugin
Etherpad allows you to easily create front-end tests for plugins.

View file

@ -6,11 +6,18 @@ A skin is a directory located under `static/skins/<skin_name>`, with the followi
* `index.css`: stylesheet affecting `/`
* `pad.js`: javascript that will be run in `/p/:padid`
* `pad.css`: stylesheet affecting `/p/:padid`
* `timeslider.js`: javascript that will be run in `/p/:padid/timeslider`
* `timeslider.css`: stylesheet affecting `/p/:padid/timeslider`
* `timeslider.js`: javascript that will be run in the embedded timeslider iframe
* `timeslider.css`: stylesheet affecting the embedded timeslider iframe
* `favicon.ico`: overrides the default favicon
* `robots.txt`: overrides the default `robots.txt`
Since Etherpad *2.7*, the timeslider is rendered in-place inside the pad
page (issue #7659). Direct visits to `/p/:padid/timeslider` 302-redirect to
`/p/:padid` so the in-pad `PadModeController` can take over via a `#rev/N`
URL hash. The full timeslider HTML is still served at
`/p/:padid/timeslider?embed=1` -- that is the URL the in-pad iframe loads,
and the URL to use if you embed the timeslider in your own page.
You can choose a skin changing the parameter `skinName` in `settings.json`.
Since Etherpad **1.7.5**, two skins are included:

View file

@ -6,8 +6,15 @@ A skin is a directory located under `static/skins/<skin_name>`, with the followi
* `index.css`: stylesheet affecting `/`
* `pad.js`: javascript that will be run in `/p/:padid`
* `pad.css`: stylesheet affecting `/p/:padid`
* `timeslider.js`: javascript that will be run in `/p/:padid/timeslider`
* `timeslider.css`: stylesheet affecting `/p/:padid/timeslider`
* `timeslider.js`: javascript that will be run in the embedded timeslider iframe
* `timeslider.css`: stylesheet affecting the embedded timeslider iframe
Since Etherpad **2.7**, the timeslider is rendered in-place inside the pad
page (issue #7659). Direct visits to `/p/:padid/timeslider` 302-redirect to
`/p/:padid` so the in-pad PadModeController can take over via a `#rev/N`
URL hash. The full timeslider HTML is still served at
`/p/:padid/timeslider?embed=1` — that is the URL the in-pad iframe loads,
and the URL to use if you embed the timeslider in your own page.
* `favicon.ico`: overrides the default favicon
* `robots.txt`: overrides the default `robots.txt`

View file

@ -0,0 +1,804 @@
# Issue 7638 — Typesafe Admin API Client + TanStack Query Rails Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Lay down the codegen toolchain, runtime client, and TanStack Query provider for the admin UI. No call-site migrations.
**Architecture:** A small Node script imports the OpenAPI spec builder from `src/node/hooks/express/openapi.ts`, writes the JSON to a temp file, and runs `openapi-typescript` to produce a checked-in `admin/src/api/schema.d.ts`. The runtime exposes a typed `openapi-fetch` client and `openapi-react-query` hooks via `admin/src/api/client.ts`, mounted under a `<QueryProvider>` at the admin root. CI re-runs codegen and fails if the working tree is dirty.
**Tech Stack:** TypeScript, React 19, Vite (rolldown-vite), `openapi-typescript`, `openapi-fetch`, `openapi-react-query`, `@tanstack/react-query`, `@tanstack/react-query-devtools`, `tsx` (devDep, runs the codegen script against TS source).
**Spec:** `docs/superpowers/specs/2026-05-01-issue-7638-admin-typesafe-api-design.md`
**Branch:** `chore/admin-typesafe-api-7638` (already cut off `origin/develop`, design doc committed as `41d2babf4`).
**Working directory for all commands:** `/home/jose/etherpad/etherpad-lite` unless otherwise stated.
---
## File Structure
**Create:**
- `admin/scripts/gen-api.mjs` — orchestrator script. Invokes `tsx` to run a small TS entry that prints the spec JSON, captures stdout to a temp file, then shells out to `openapi-typescript`.
- `admin/scripts/dump-spec.ts` — TS entry that imports `generateDefinitionForVersion` from the etherpad source and writes the JSON to stdout.
- `admin/src/api/schema.d.ts` — generated. Checked in.
- `admin/src/api/client.ts``openapi-fetch` + `openapi-react-query` instances.
- `admin/src/api/QueryProvider.tsx` — TanStack Query provider, dev-only devtools.
- `admin/src/api/__tests__/client.test.ts` — module-load smoke test.
- `admin/README.md` — codegen docs (file does not currently exist).
**Modify:**
- `src/node/hooks/express/openapi.ts` — add `export { generateDefinitionForVersion }` at the end so external scripts can call the spec builder. Surgical change, no behavior delta.
- `admin/package.json` — add deps and `gen:api` script; amend `build` to run `gen:api` first.
- `admin/src/main.tsx` — wrap router subtree in `<QueryProvider>`.
- `.github/workflows/frontend-admin-tests.yml` — add a freshness-check step before the existing admin build step.
**Conventions to honor:**
- Per project memory, the PR will go to `johnmclear/etherpad-lite`, not `ether/etherpad-lite`.
- Commit at the end of each task.
- Run `pnpm ts-check` and admin's lint at the end before declaring done.
---
## Task 1: Export the spec builder from `openapi.ts`
**Files:**
- Modify: `src/node/hooks/express/openapi.ts:422` (and end of file)
The script needs to call `generateDefinitionForVersion` from outside the module. It is currently only used within the file. Adding a CommonJS-style export keeps the existing `exports.expressPreSession` style consistent.
- [ ] **Step 1: Read the current export style at the bottom of the file**
Run: `grep -n "^exports\." src/node/hooks/express/openapi.ts`
Expected output: a line like `578:exports.expressPreSession = async (hookName:string, {app}:any) => {`
- [ ] **Step 2: Add the export**
Append at the end of `src/node/hooks/express/openapi.ts` (after the existing hook export, after line 771):
```ts
exports.generateDefinitionForVersion = generateDefinitionForVersion;
exports.APIPathStyle = APIPathStyle;
```
(Both are needed: the script will call `generateDefinitionForVersion(apiHandler.latestApiVersion, APIPathStyle.FLAT)` and we want a single import surface.)
- [ ] **Step 3: Verify ts-check still passes**
Run: `pnpm ts-check`
Expected: no new errors. (If pre-existing errors are present, confirm none are in `openapi.ts`.)
- [ ] **Step 4: Commit**
```bash
git add src/node/hooks/express/openapi.ts
git commit -m "$(cat <<'EOF'
feat(api): export generateDefinitionForVersion from openapi hook
Required by the admin codegen script (#7638) to dump the OpenAPI spec
without booting Express. No behavior change for the request hook.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: Add admin dependencies
**Files:**
- Modify: `admin/package.json`
- [ ] **Step 1: Read the current `admin/package.json`**
Run: `cat admin/package.json`
Expected: confirm there is a `dependencies` block and a `devDependencies` block.
- [ ] **Step 2: Install runtime deps**
Run:
```bash
pnpm --filter admin add @tanstack/react-query @tanstack/react-query-devtools openapi-fetch openapi-react-query
```
Expected: deps added under `dependencies`. `pnpm-lock.yaml` updated at repo root.
- [ ] **Step 3: Install dev deps**
Run:
```bash
pnpm --filter admin add -D openapi-typescript tsx
```
Expected: deps added under `devDependencies`.
- [ ] **Step 4: Sanity check the diff**
Run: `git diff admin/package.json`
Expected: six new entries (4 deps, 2 devDeps), no other changes.
- [ ] **Step 5: Commit**
```bash
git add admin/package.json pnpm-lock.yaml
git commit -m "$(cat <<'EOF'
chore(admin): add OpenAPI codegen + TanStack Query deps (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: Write the spec-dump entry
**Files:**
- Create: `admin/scripts/dump-spec.ts`
This file is intentionally tiny. It runs under `tsx` so it can resolve the etherpad-lite TypeScript source directly.
- [ ] **Step 1: Create the file**
```ts
// admin/scripts/dump-spec.ts
//
// Imports the OpenAPI spec builder from the etherpad source and writes the
// flat-style spec for the latest API version as JSON to stdout. Invoked by
// admin/scripts/gen-api.mjs via `tsx`.
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const repoRoot = path.resolve(__dirname, '..', '..');
// `openapi.ts` uses CommonJS-style `exports.*` despite living in an ESM repo,
// so we go through createRequire to load it cleanly.
import { createRequire } from 'node:module';
const require = createRequire(pathToFileURL(path.join(repoRoot, 'src', 'node', 'hooks', 'express', 'openapi.ts')).toString());
const apiHandler = require('../../src/node/handler/APIHandler');
const { generateDefinitionForVersion, APIPathStyle } =
require('../../src/node/hooks/express/openapi') as {
generateDefinitionForVersion: (version: string, style?: string) => unknown;
APIPathStyle: { FLAT: string; REST: string };
};
const spec = generateDefinitionForVersion(apiHandler.latestApiVersion, APIPathStyle.FLAT);
process.stdout.write(JSON.stringify(spec, null, 2));
```
- [ ] **Step 2: Smoke-test the entry**
Run:
```bash
cd admin && pnpm exec tsx scripts/dump-spec.ts > /tmp/etherpad-spec.json
echo "exit: $?"
head -c 200 /tmp/etherpad-spec.json
```
Expected: exit 0; the head output starts with `{` and contains `"openapi"` and `"paths"`.
If the script fails because importing `openapi.ts` triggers errors from `Settings`, debug by running `pnpm exec tsx -e "require('../src/node/hooks/express/openapi.ts')"` from `admin/` to isolate. The most likely fix is to set `EP_LOG_DESTINATION=stderr` or similar; do not refactor `Settings` from this PR — note the issue and ask before expanding scope.
- [ ] **Step 3: Commit**
```bash
git add admin/scripts/dump-spec.ts
git commit -m "$(cat <<'EOF'
chore(admin): add OpenAPI spec dump entry (#7638)
Loaded via tsx by gen-api.mjs in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: Write the codegen orchestrator
**Files:**
- Create: `admin/scripts/gen-api.mjs`
- [ ] **Step 1: Create the file**
```js
// 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');
try {
const dump = spawnSync('pnpm', ['exec', 'tsx', 'scripts/dump-spec.ts'], {
cwd: adminRoot,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
});
if (dump.status !== 0) {
console.error(`dump-spec.ts failed with exit code ${dump.status}`);
process.exit(dump.status ?? 1);
}
writeFileSync(specPath, dump.stdout, 'utf8');
const gen = spawnSync(
'pnpm',
['exec', 'openapi-typescript', specPath, '-o', outFile],
{ cwd: adminRoot, stdio: 'inherit' },
);
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');
console.log(`Wrote ${path.relative(process.cwd(), outFile)}`);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
```
- [ ] **Step 2: Add the `gen:api` script and amend `build`**
In `admin/package.json`, edit the `scripts` block. Before:
```json
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"build-copy": "tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
"preview": "vite preview"
}
```
After:
```json
"scripts": {
"dev": "vite",
"gen:api": "node scripts/gen-api.mjs",
"build": "pnpm gen:api && 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"
}
```
- [ ] **Step 3: Run codegen and confirm output**
Run:
```bash
mkdir -p admin/src/api
pnpm --filter admin gen:api
ls -la admin/src/api/schema.d.ts
head -10 admin/src/api/schema.d.ts
```
Expected:
- exit 0
- `schema.d.ts` exists, > 1 KB
- first two lines are the generated header
- subsequent lines contain `export interface paths` and entries like `"/api/{version}/createGroup"`
- [ ] **Step 4: Commit script + package.json + generated schema**
```bash
git add admin/scripts/gen-api.mjs admin/package.json admin/src/api/schema.d.ts
git commit -m "$(cat <<'EOF'
chore(admin): wire OpenAPI codegen into build (#7638)
Adds `gen:api` script and amends `build`/`build-copy` to regenerate
admin/src/api/schema.d.ts before compiling. The generated file is
checked in so it shows up in PR review and so a fresh checkout doesn't
need codegen to typecheck.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: Runtime client module
**Files:**
- Create: `admin/src/api/client.ts`
- [ ] **Step 1: Create the file**
```ts
// admin/src/api/client.ts
//
// Typed HTTP client and TanStack Query hooks derived from the generated
// OpenAPI schema. Regenerate the schema with `pnpm --filter admin gen:api`.
import createClient from 'openapi-fetch';
import createQueryHooks from 'openapi-react-query';
import type { paths } from './schema';
export const fetchClient = createClient<paths>({ baseUrl: '/' });
export const $api = createQueryHooks(fetchClient);
```
- [ ] **Step 2: Confirm typecheck passes**
Run: `pnpm --filter admin exec tsc --noEmit`
Expected: no errors. If `paths` is missing from `schema.d.ts`, rerun `pnpm --filter admin gen:api` (it should have produced an `export interface paths` already in Task 4).
- [ ] **Step 3: Commit**
```bash
git add admin/src/api/client.ts
git commit -m "$(cat <<'EOF'
feat(admin): typed openapi-fetch + react-query client (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: Query provider with dev-only devtools
**Files:**
- Create: `admin/src/api/QueryProvider.tsx`
- [ ] **Step 1: Create the file**
```tsx
// 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>
);
};
```
- [ ] **Step 2: Typecheck**
Run: `pnpm --filter admin exec tsc --noEmit`
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add admin/src/api/QueryProvider.tsx
git commit -m "$(cat <<'EOF'
feat(admin): TanStack Query provider, dev-only devtools (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 7: Mount the provider at the admin root
**Files:**
- Modify: `admin/src/main.tsx`
- [ ] **Step 1: Read the file to confirm current shape**
Run: `cat admin/src/main.tsx`
Expected: matches the structure where `<I18nextProvider>` wraps `<Toast.Provider>` wraps `<RouterProvider>` inside `<React.StrictMode>`.
- [ ] **Step 2: Edit `admin/src/main.tsx`**
Add the import after the existing imports:
```tsx
import { QueryProvider } from './api/QueryProvider.tsx';
```
Wrap the existing `<I18nextProvider>...</I18nextProvider>` subtree in `<QueryProvider>`. The render block becomes:
```tsx
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryProvider>
<I18nextProvider i18n={i18n}>
<Toast.Provider>
<ToastDialog/>
<RouterProvider router={router}/>
</Toast.Provider>
</I18nextProvider>
</QueryProvider>
</React.StrictMode>,
)
```
(Provider order matters only for context lookups; placing `QueryProvider` outside `I18nextProvider` is fine because it does not consume i18n.)
- [ ] **Step 3: Typecheck**
Run: `pnpm --filter admin exec tsc --noEmit`
Expected: no errors.
- [ ] **Step 4: Build the admin bundle**
Run: `pnpm --filter admin run build`
Expected: build succeeds. Output indicates one bundle (no extra chunk for devtools in production — confirm by grepping the `dist/` for `query-devtools` strings; should be absent).
```bash
grep -rn "ReactQueryDevtools" admin/dist/ 2>/dev/null | head
```
Expected: no matches (production bundle excludes devtools).
- [ ] **Step 5: Commit**
```bash
git add admin/src/main.tsx
git commit -m "$(cat <<'EOF'
feat(admin): mount TanStack Query provider at root (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 8: Smoke test for the client module
**Files:**
- Create: `admin/src/api/__tests__/client.test.ts`
The admin package does not yet ship a unit test runner. Reuse whatever the rest of admin uses for tests if anything; otherwise, this test runs under `tsx --test` (Node's built-in test runner, no extra deps). Confirm at Step 1.
- [ ] **Step 1: Detect the test runner**
Run:
```bash
grep -E '"(test|vitest|jest)"' admin/package.json
ls admin/vitest.config.* admin/jest.config.* 2>/dev/null
```
If admin has no runner configured, use Node's built-in `node:test` (which `tsx` supports).
- [ ] **Step 2: Create the test file**
```ts
// 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 fetchClient and $api', async () => {
const mod = await import('../client.ts');
assert.ok(mod.fetchClient, 'fetchClient export is present');
assert.ok(mod.$api, '$api export is present');
assert.equal(typeof mod.fetchClient.GET, 'function', 'fetchClient.GET is a function');
assert.equal(typeof mod.$api.useQuery, 'function', '$api.useQuery is a function');
});
```
- [ ] **Step 3: Add a `test` script to `admin/package.json`** (only if one does not already exist)
If `admin/package.json` has no `"test"` script, add:
```json
"test": "tsx --test src/api/__tests__/client.test.ts"
```
If admin already has a test runner (e.g. `vitest`), skip the script addition and instead place the test at the location the existing runner picks up (`*.test.ts` is conventional for both vitest and node:test).
- [ ] **Step 4: Run the test**
Run: `pnpm --filter admin test`
Expected: 1 test passing.
- [ ] **Step 5: Commit**
```bash
git add admin/src/api/__tests__/client.test.ts admin/package.json
git commit -m "$(cat <<'EOF'
test(admin): smoke test for typed openapi-fetch client (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 9: CI freshness check
**Files:**
- Modify: `.github/workflows/frontend-admin-tests.yml`
Add a step before the existing `Build admin frontend` step that runs codegen and fails if the working tree changed.
- [ ] **Step 1: Read the current workflow**
Run: `grep -n "Build admin frontend" .github/workflows/frontend-admin-tests.yml`
Expected: a single match around the build step that runs `pnpm run build` from `working-directory: admin`.
- [ ] **Step 2: Insert the freshness check**
Insert immediately before the `Build admin frontend` step:
```yaml
- name: Verify admin OpenAPI schema is up to date
working-directory: admin
run: |
pnpm gen:api
if ! git diff --exit-code src/api/schema.d.ts; then
echo ""
echo "::error::admin/src/api/schema.d.ts is out of date."
echo "Run \`pnpm --filter admin gen:api\` and commit the result."
exit 1
fi
```
- [ ] **Step 3: Lint the YAML**
Run: `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/frontend-admin-tests.yml'))" && echo OK`
Expected: `OK`.
- [ ] **Step 4: Commit**
```bash
git add .github/workflows/frontend-admin-tests.yml
git commit -m "$(cat <<'EOF'
ci(admin): verify generated OpenAPI schema is up to date (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 10: Documentation
**Files:**
- Create: `admin/README.md`
- [ ] **Step 1: Create the file**
```markdown
# Admin UI
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.
## Scripts
| Script | What it does |
| -------------------- | -------------------------------------------------------- |
| `pnpm dev` | Vite dev server. Expects an etherpad backend on :9001. |
| `pnpm gen:api` | Regenerates `src/api/schema.d.ts` from the OpenAPI spec. |
| `pnpm build` | `gen:api` + `tsc` + `vite build`. |
| `pnpm build-copy` | Same, but writes into `../src/templates/admin`. |
| `pnpm test` | Smoke tests for the API client wiring. |
| `pnpm lint` | ESLint. |
## Typed API client
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.
[`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
### Regenerating the schema
```sh
pnpm --filter admin gen:api
```
This runs `admin/scripts/gen-api.mjs`, which loads
`src/node/hooks/express/openapi.ts`, calls `generateDefinitionForVersion` for
the latest API version, pipes the JSON through `openapi-typescript`, and
writes the result to `admin/src/api/schema.d.ts`. The generated file is
checked in.
Run `gen:api` after any change to:
- `src/node/hooks/express/openapi.ts`
- `src/node/handler/APIHandler.ts` (changes to `latestApiVersion`)
- the resource definitions referenced by `openapi.ts`
### CI freshness check
`.github/workflows/frontend-admin-tests.yml` runs `pnpm gen:api` and fails the
build if `admin/src/api/schema.d.ts` is out of date. If you see the failure
locally, run `pnpm --filter admin gen:api` and commit the regenerated file.
### 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.
```
- [ ] **Step 2: Commit**
```bash
git add admin/README.md
git commit -m "$(cat <<'EOF'
docs(admin): document OpenAPI codegen workflow (#7638)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Task 11: Full verification pass
No new files — this task confirms the work is green end-to-end before pushing.
- [ ] **Step 1: Clean rebuild**
Run:
```bash
pnpm --filter admin gen:api
pnpm --filter admin run build
```
Expected: both succeed.
- [ ] **Step 2: Repo-wide typecheck**
Run: `pnpm ts-check`
Expected: no new errors versus baseline. If there are pre-existing errors, confirm none are in files this PR touched.
- [ ] **Step 3: Admin tests**
Run: `pnpm --filter admin test`
Expected: 1 test passing.
- [ ] **Step 4: Backend unit tests** (sanity — `openapi.ts` change)
Run: `pnpm test` (or the narrowest available suite covering the API hook; if the full suite is slow, run specs that exercise `openapi.ts` only).
Expected: green.
- [ ] **Step 5: Confirm devtools absent from production bundle**
Run: `grep -rn "ReactQueryDevtools" admin/dist/ 2>/dev/null`
Expected: zero matches.
- [ ] **Step 6: Manual smoke**
Per project convention (memory: install plugin/branch for manual test), install this branch on a local etherpad and:
- Open `/admin/` in a dev build (`pnpm --filter admin dev`). Confirm the React Query devtools panel button appears in the bottom corner.
- Open `/admin/` in the production-built bundle. Confirm devtools panel is absent.
- Click through plugins / settings / pads / shout pages and confirm no regression versus pre-PR behavior (existing socket.io flows unchanged).
Document the smoke results in the PR description.
- [ ] **Step 7: Push**
```bash
git push -u fork chore/admin-typesafe-api-7638
```
- [ ] **Step 8: Open PR**
```bash
gh pr create \
--repo johnmclear/etherpad-lite \
--title "chore(admin): typesafe API client + TanStack Query rails (#7638)" \
--body "$(cat <<'EOF'
## Summary
Lays down the rails for a typesafe, OpenAPI-derived admin API client backed by TanStack Query. Closes #7638.
- Codegen toolchain (`pnpm --filter admin gen:api`) producing `admin/src/api/schema.d.ts` from `src/node/hooks/express/openapi.ts`.
- Runtime client (`openapi-fetch` + `openapi-react-query`).
- `<QueryProvider>` mounted at the admin root with dev-only devtools.
- CI freshness check on the generated schema.
- `admin/README.md` documenting the workflow.
**No call sites migrated.** Admin endpoints aren't in the OpenAPI spec yet — that gap is filed as a follow-up issue and must land before any migration is useful. #7601 should rebase onto this branch.
**Semver:** patch — build tooling + currently-unused runtime libs, no observable behavior change.
## Test plan
- [x] `pnpm --filter admin gen:api` runs clean
- [x] `pnpm --filter admin run build` succeeds
- [x] `pnpm --filter admin test` passes (smoke test)
- [x] `pnpm ts-check` clean
- [x] Production bundle does not contain devtools
- [x] Manual smoke: dev build shows devtools, prod build hides them, existing socket.io pages unaffected
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
- [ ] **Step 9: Trigger Qodo review** (per project convention)
```bash
gh pr comment <PR-number> --repo johnmclear/etherpad-lite --body "/review"
```
- [ ] **Step 10: File the spec-coverage follow-up issue**
Create a new issue on `ether/etherpad` titled "Document admin endpoints in the OpenAPI spec" and link from the PR body. The issue should note that 7638 rails are unused until admin endpoints are added.
---
## Risk register (carried from spec)
- **`openapi.ts` not cleanly importable.** If `dump-spec.ts` fails to import the module due to side effects (Settings, log4js init), pause and ask before refactoring `Settings`. A common workaround is to set `EP_LOG_DESTINATION=stderr` or set `NODE_ENV=production`. Do not silently expand scope.
- **Generated schema differs by Node version.** `openapi-typescript` output is deterministic, but if a contributor sees a phantom diff, confirm Node major matches the CI matrix (22/24/25 today; CI uses 24 on PRs).
- **Bundle size.** ~12 KB gzipped added to the admin bundle even with no call sites. Acceptable; flagged in the PR body for transparency.
## Out of scope (do not pull in)
- Adding admin endpoints to the OpenAPI spec.
- Migrating any `fetch()` site in `admin/src/`.
- Backend handler changes.
- Pad-side frontend changes.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,246 @@
# Etherpad Auto-Update — Manual Smoke Runbook
**Status:** required gate before each tier ships, per `2026-04-25-auto-update-design.md` § "Phased rollout".
**Audience:** the engineer cutting a release that includes new updater code.
**Time budget:** ~3040 minutes for the full sweep against a disposable VM.
This runbook exercises the failure paths that unit and integration tests cannot reach: a real process supervisor, a real `pnpm install` run, real session drain broadcasts to a real pad client. Run it on a throw-away VM you don't mind nuking.
## 0. Provision a disposable VM
Anything Linux works; the example below uses Debian/Ubuntu under systemd.
```bash
# On the VM
sudo adduser --system --group --home /srv/etherpad --shell /bin/bash etherpad
sudo apt update && sudo apt install -y git nodejs ca-certificates
# Etherpad's pnpm comes from corepack — Node 22+ ships it.
sudo -u etherpad bash -c '
cd /srv/etherpad
git clone https://github.com/ether/etherpad.git current
cd current
corepack enable && corepack prepare pnpm@latest-9 --activate
pnpm install
pnpm run build:ui
'
```
## 1. Install Etherpad as a systemd service
`/etc/systemd/system/etherpad.service`:
```ini
[Unit]
Description=Etherpad
After=network.target
[Service]
Type=simple
User=etherpad
WorkingDirectory=/srv/etherpad/current
ExecStart=/usr/bin/pnpm run dev
Restart=on-failure
RestartSec=5
SuccessExitStatus=75
# Treat exit 75 as "intentional" so systemd doesn't escalate-restart counters.
[Install]
WantedBy=multi-user.target
```
Then:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now etherpad
journalctl -u etherpad -f & # tail the log in another terminal
```
## 2. Configure for Tier 2
Edit `/srv/etherpad/current/settings.json` and set:
```jsonc
{
"updates": {
"tier": "manual",
"checkIntervalHours": 1,
"drainSeconds": 30, // shorten the wait during smoke testing
"rollbackHealthCheckSeconds": 30
}
}
```
`sudo systemctl restart etherpad`. Visit `http://<vm-ip>:9001/admin/update` and log in as the admin user from `settings.json`.
## 3. Force "an update is available"
The simplest way: `git checkout` to a commit *before* a tagged release.
```bash
sudo -u etherpad bash -c 'cd /srv/etherpad/current && git checkout v2.7.2'
sudo systemctl restart etherpad
```
Trigger an immediate version check (or wait an hour):
```bash
curl -fsSL http://localhost:9001/admin/update/status | jq .
# Expect: latest.version newer than currentVersion, policy.canManual=true
```
The admin UI banner should now read **"Update available"**, and `/admin/update` should show an **"Apply update"** button.
## 4. Happy path: apply, drain, restart, verify
1. Open a pad in another browser tab (`http://<vm-ip>:9001/p/test`).
2. Click **Apply update** on `/admin/update`.
3. **Within 30 seconds** confirm:
- The pad shows a gritter notification "Etherpad will restart in 30 seconds…" (i18n string from `update.drain.t30`), then `update.drain.t10`.
- The page polls `/admin/update/log`; the `<pre>` block fills with `git fetch / checkout / pnpm install / pnpm run build:ui` output.
4. systemd journal shows `update executed: <fromSha> -> <tag>; exiting 75 for supervisor restart`.
5. systemd restarts the unit (~5s under `RestartSec`).
6. Reload `/admin/update`. State should be **`verified`** with `lastResult.outcome: "verified"`.
**Sign-off:** every observable transition matches the state machine in the design spec § "State machine". If any step lingers or the page shows a different status, capture `var/log/update.log` and stop.
## 5. Rollback path: install failure
Force a rollback by giving pnpm something it can't resolve.
```bash
# As etherpad user, in /srv/etherpad/current:
git checkout v2.7.2
echo 'lockfileVersion: this-is-not-real-content' >> pnpm-lock.yaml
sudo systemctl restart etherpad
```
Visit `/admin/update` and click Apply.
Expected:
- Drain announcement on the pad as before.
- Log shows `pnpm install --frozen-lockfile` exiting non-zero.
- State goes through `rolling-back``rolled-back`.
- After supervisor restart, `/admin/update` shows the **rolled-back** banner with `lastResult.reason` describing the install failure.
- `git rev-parse HEAD` matches the pre-update SHA.
- Click **Acknowledge** to clear the lastResult banner.
## 6. Rollback path: build failure
```bash
git checkout v2.7.2
# Break the build by introducing a syntax error:
echo 'this is not valid TypeScript' >> src/static/js/pad.ts
sudo systemctl restart etherpad # confirm the broken tree still serves; we want apply to fail at build:ui, not at boot
```
Apply, observe `pnpm run build:ui` exit non-zero in the log, observe `rolling-back``rolled-back`. Working tree restored.
Revert the syntax error before continuing.
## 7. Crash-loop guard
Force the new version to crash at boot more than twice. Easiest:
```bash
# As etherpad user:
git checkout v2.7.2
# Apply to v2.7.3, but during the apply window introduce a startup error:
# (Edit src/node/server.ts in the v2.7.3 tag's worktree to throw immediately.)
```
Click Apply. The new boot crashes; systemd restarts; RollbackHandler increments `bootCount`. After three crashes, `bootCount > 2` triggers a forced rollback regardless of the health-check timer.
Observe state lands on `rolled-back` with `reason: "health-check-failed-or-crash-loop"`. Working tree on the original SHA.
## 8. Rollback-failed terminal state
Hardest to set up; force `pnpm install` to fail on the rollback path too.
```bash
# Trigger a normal install-failed rollback (step 5), but BEFORE it runs the
# rollback step, corrupt the backup lockfile:
echo garbage > /srv/etherpad/current/var/update-backup/pnpm-lock.yaml
# … or remove the etherpad user's permission to the install dir mid-flow.
```
Expected:
- State lands on **`rollback-failed`**.
- `/admin/update` shows the strong red banner (role=alert) with the
`update.banner.terminal.rollback-failed` copy.
- `policy.canManual` stays true; `policy.canAuto` is false (terminal-blocked).
- Manually fix the install (restore the lockfile, fix permissions), then
click **Acknowledge**. State returns to `idle` and Apply re-enables.
## 9. Cancel during drain
Click Apply. Within 30s, click Cancel.
Expected:
- Drain timers stop firing immediately.
- State returns to `idle`.
- `lastResult.outcome: "cancelled"`.
- `var/update.lock` is gone.
- No exit; systemd doesn't restart.
## 10. Sign-off checklist
Tick every line before approving the release that introduces this code:
- [ ] Happy path lands on `verified` with the working tree on the new tag.
- [ ] Install-fail and build-fail rollbacks restore the previous SHA.
- [ ] Crash-loop guard forces rollback at `bootCount > 2`.
- [ ] `rollback-failed` shows the strong banner and Acknowledge clears it.
- [ ] Cancel during drain leaves no lock, returns to `idle`.
- [ ] Pad client renders the localised drain announcement (NOT the literal i18n key).
- [ ] systemd journal shows no unhandled rejections, no orphaned processes.
- [ ] `var/log/update.log` is rotated when it crosses 10 MB (force this by writing >10 MB into the file and triggering an Apply).
If any line is unticked, do not ship the release.
## 11. Tier 3 — grace window, scheduled apply, cancel, restart-in-grace
Configure the VM for tier 3:
```jsonc
{
"updates": {
"tier": "auto",
"preApplyGraceMinutes": 2, // short for smoke
"drainSeconds": 15,
"checkIntervalHours": 1
}
}
```
1. As in §3, `git checkout v2.7.2`. Restart Etherpad. Wait for the immediate first version check (~5s after boot).
2. Confirm a schedule was created:
```bash
curl -fsSL http://localhost:9001/admin/update/status | jq '.execution'
# Expect: {"status":"scheduled","targetTag":"v...","scheduledFor":"...","startedAt":"..."}
```
3. Visit `/admin/update`. Confirm:
- A countdown panel renders with the localised "Etherpad will start updating to vX.Y.Z in Nm Ms." copy.
- Two buttons: **Cancel** and **Apply now**.
4. **Happy path:** wait for the timer to fire. The same flow as §4 (drain, executor, exit 75) runs. State lands on `verified` after the supervisor restarts on the new version.
5. **Cancel path:** repeat steps 13 in a fresh setup. Click **Cancel** during the countdown. Expected:
- State transitions to `idle`; `lastResult.outcome: "cancelled"`.
- `journalctl -u etherpad` shows `cancelled pending schedule (admin-cancellation)`.
- The next version check (within `checkIntervalHours`) re-schedules the same release — this is correct: the schedule was cancelled but the policy still wants the update. To opt out completely, set `updates.tier: "notify"` instead.
6. **Apply-now path:** repeat steps 13. Click **Apply now**. The regular Tier 2 pipeline starts immediately; the previously-armed timer is harmlessly stale (the executor takes over before it fires).
7. **Restart-in-grace path:** repeat steps 13, then `sudo systemctl restart etherpad` mid-countdown. On boot, the journal logs `updater: rehydrating Tier 3 schedule for vX.Y.Z at ...`. `/admin/update` resumes the countdown from the persisted `scheduledFor` (no re-arming, no re-emailing).
8. **Email path** (if `adminEmail` is set): the journal logs `(would send email) ... [Etherpad] Auto-update scheduled for ...` exactly once per scheduled tag. Re-arming for the same tag does not re-email. A different tag arming over the top does.
If any step diverges, capture `var/log/update.log` and stop. Add to the §10 sign-off checklist:
- [ ] Tier 3 schedule transitions execution → `scheduled` after the version check.
- [ ] Countdown panel renders the localised string (not the i18n key).
- [ ] Cancel during scheduled returns to `idle`.
- [ ] Apply now during scheduled runs the Tier 2 pipeline immediately.
- [ ] Restart-in-grace rehydrates the timer.
- [ ] `grace-start` email fires once per tag when `adminEmail` is set.

View file

@ -144,3 +144,38 @@ The XSS escape test is the security-relevant one: pad IDs are user-controlled
- A `padSocialMetadata` hook that lets plugins override the values.
- Per-pad description (e.g. ep_pad_title integration).
- Generated preview images (would require a rendering service).
## Follow-up (2026-05-07): operator description override
Issue #7599 follow-up comment from @stffen flagged two gaps in the shipped
behaviour:
1. The default description is in English and there is no obvious place in
`settings.json` to change it.
2. The visitor's language is negotiated from `Accept-Language`, which most
link-preview crawlers (WhatsApp, Signal, Slack, Telegram, Facebook) do not
send — so non-English instances always serve the English fallback to
crawlers regardless of which locale files exist.
Resolution: keep the i18n catalog as the default source (the original Qodo
review still stands — translatable strings belong in locale files), but add
an explicit `settings.socialMeta.description` override that wins when set:
- `socialMeta.description: null` (default) → existing behaviour: i18n
catalog with `Accept-Language` negotiation, English fallback.
- `socialMeta.description: "<text>"` → that string is used verbatim for
`og:description` / `twitter:description` regardless of the negotiated
language. This is the lever that fixes the crawler-no-Accept-Language
case.
- Empty / whitespace-only override is treated as unset (would otherwise
blank out previews silently — a footgun).
- The override is HTML-escaped via the same path as every other
interpolated value.
- `og:locale` is unaffected; it continues to reflect the negotiated render
language. Operators who want fully localised descriptions still use
`customLocaleStrings` to override `pad.social.description` per-language.
Documentation lives next to `publicURL` in both `settings.json.template`
and `settings.json.docker` (mirrors how the original feature is
configured), and the `customLocaleStrings` example now shows the
`pad.social.description` key explicitly so operators can find both routes.

View file

@ -0,0 +1,198 @@
# Issue 7638 — Typesafe Admin API Client + TanStack Query Rails
**Status:** design approved 2026-05-01
**Issue:** https://github.com/ether/etherpad/issues/7638
**Related:** #7601 (introduces new admin REST sites that will adopt these rails)
## Goal
Lay down the toolchain and runtime rails for a typesafe, OpenAPI-derived admin
API client backed by TanStack Query. Do not migrate any existing call sites.
## Why rails-only
The issue's framing ("migrate every `useEffect`+`fetch` site") overstates what is
actually present in `admin/src/` today.
- The only REST `fetch()` sites are `App.tsx` and `LoginScreen.tsx` (both POST to
`/admin-auth/`) and `i18n.ts` (locale loading).
- All admin pages with real data flow (Settings, Plugins, Pads, Shout) run over
socket.io + zustand, not REST.
- The OpenAPI spec produced by `src/node/hooks/express/openapi.ts` only covers
the public Etherpad HTTP API under `/api/{version}/*`. It documents zero admin
endpoints — no `/admin-auth/`, no future `/admin/*` REST endpoints from #7601.
So the generated client has nothing in `admin/src/` to type today. The value of
landing this PR now is to get the rails in place so #7601 (and any subsequent
admin REST work) can adopt them on day one.
A separate issue will be filed to add admin endpoint coverage to the OpenAPI
spec; until that lands, no migrations are useful.
## Out of scope
- Admin endpoint coverage in the OpenAPI spec (separate issue).
- Migrating any existing `fetch()` call site.
- Backend changes.
- Pad-side frontend.
## Toolchain
| Package | Type | Purpose |
| -------------------------------- | -------------- | ---------------------------------------- |
| `openapi-typescript` | devDependency | Generates `.d.ts` from the OpenAPI spec |
| `openapi-fetch` | dependency | Typed `fetch` wrapper |
| `openapi-react-query` | dependency | TanStack Query bindings over the client |
| `@tanstack/react-query` | dependency | Query runtime |
| `@tanstack/react-query-devtools` | dependency | Dev-only devtools panel |
All added to `admin/package.json`. No version pinning beyond standard caret
ranges; pick the latest stable at implementation time.
## Codegen (option 3, hybrid)
One checked-in artifact, CI-enforced freshness.
### Script: `admin/scripts/gen-api.mjs`
1. Imports the spec-building entry point from
`src/node/hooks/express/openapi.ts` (or a thin wrapper module that calls
the spec builder without booting Express). Writes the resulting spec JSON
to a temp file in `os.tmpdir()`.
2. Shells out:
`openapi-typescript <tmp> -o admin/src/api/schema.d.ts`.
3. Prepends a generated header comment to the output:
`// GENERATED — do not edit. Run \`pnpm gen:api\` to regenerate.`
4. Removes the temp file.
If `openapi.ts` cannot be loaded as an ES module without side effects (e.g.
because it imports settings or boots an Express app at import time), the
implementation must extract the pure spec-builder into a dedicated module so
the script can call it cleanly. That refactor is in scope; the touch should be
minimal.
### Wiring
- `admin/package.json`:
- `"scripts": { "gen:api": "node scripts/gen-api.mjs", ... }`.
- `"build"` is amended to run `gen:api` before `tsc && vite build` so a
fresh checkout builds without manual steps.
- Root `package.json`: existing admin build entry point invokes the same
script (or relies on `admin/package.json`'s amended `build`).
### Generated output
- Path: `admin/src/api/schema.d.ts`.
- Checked in.
- First line: generated-header comment.
### CI freshness check
A CI job (folded into the existing admin lint workflow if practical, otherwise
a new step) runs:
```bash
pnpm --filter admin gen:api
git diff --exit-code admin/src/api/schema.d.ts
```
If the diff is non-empty, CI fails with a message instructing the contributor
to run `pnpm --filter admin gen:api` and commit the result.
## Runtime client
### `admin/src/api/client.ts`
```ts
import createClient from "openapi-fetch";
import createQueryHooks from "openapi-react-query";
import type { paths } from "./schema";
export const fetchClient = createClient<paths>({ baseUrl: "/" });
export const $api = createQueryHooks(fetchClient);
```
### `admin/src/api/QueryProvider.tsx`
- Wraps children in `QueryClientProvider`.
- Single shared `QueryClient` constructed once (module-level or `useState`
initializer), with defaults:
- `staleTime: 30_000`
- `refetchOnWindowFocus: true`
- Other defaults left at library defaults.
- Mounts `ReactQueryDevtools` only when `import.meta.env.DEV` is true. Use a
dynamic `import()` so devtools do not ship in the production bundle.
### `admin/src/main.tsx`
Wrap `<App />` in `<QueryProvider>`. No other changes.
## Documentation
`admin/README.md` (create or extend) documents:
- How to regenerate: `pnpm --filter admin gen:api`.
- When to regenerate: after any change to `src/node/hooks/express/openapi.ts`
or anything that affects the spec it builds.
- What gets regenerated: `admin/src/api/schema.d.ts` only.
- The CI freshness check and how to recover from a failing check.
- A short "how to use the client" snippet showing
`$api.useQuery("get", "/some/path")` once admin endpoints are in the spec.
## Tests
- **Module-load smoke test** (`admin/src/api/__tests__/client.test.ts` or
similar, matching whatever test infra `admin/` already uses): imports
`$api` and `fetchClient`, asserts both are defined. This catches toolchain
wiring breakage (missing peer deps, bad export shape, etc.).
- **CI freshness check** (above) is the test for spec/schema sync.
- **Manual smoke after PR install:** install the branch on the local
Etherpad, open `/admin`, confirm:
- Existing socket.io flows (settings, plugins, pads) still work — no
regressions from the `<QueryProvider>` wrap.
- React Query devtools panel appears in a dev build (`pnpm --filter admin
dev`) and is absent from a production build.
Note: per project convention, the user expects automated tests before manual
verification, but the manual smoke is unavoidable here because devtools
visibility and provider wrap are runtime concerns. The smoke check is a
secondary safety net, not the primary test strategy.
## Branch / PR plan
- Fork: `johnmclear/etherpad-lite` (per project convention; never commit
directly to `ether/etherpad-lite`).
- Branch: `chore/admin-typesafe-api-7638`.
- Base: latest `main` of the fork, after syncing from `ether/etherpad-lite`.
- PR title: `chore(admin): typesafe API client + TanStack Query rails`.
- PR body declares semver: **patch** (build tooling + unused runtime libs;
no observable behavior change).
- PR body links #7638 and notes:
- Rails-only — no call site migrations.
- Separate spec-coverage issue to follow.
- #7601 should rebase onto this branch once merged.
## Risks
- **`openapi.ts` not cleanly importable.** If pulling the spec builder out
requires touching production paths, that risk needs a small refactor PR
first. Mitigation: keep the extraction surgical; if it grows, split into
its own PR and rebase 7638 on top.
- **Bundle size.** TanStack Query + react-query bindings add ~12 KB gzipped
to the admin bundle even with no call sites using it. Acceptable for an
internal admin UI; flag in PR body for transparency.
- **Provider wrap regressions.** `<QueryProvider>` wrapping `<App />` should
be inert for socket.io paths but the manual smoke confirms.
## Definition of done
- `pnpm --filter admin gen:api` runs cleanly on a fresh checkout.
- `pnpm --filter admin build` succeeds.
- `admin/src/api/schema.d.ts` is checked in with the generated header.
- `<QueryProvider>` wraps `<App />`; devtools visible in dev, absent in
production build.
- CI freshness check is wired and passing.
- `admin/README.md` documents the codegen workflow.
- Manual smoke confirms no regression in existing socket.io-driven pages.
- PR opened against `johnmclear/etherpad-lite`, semver labelled patch,
Qodo `/review` triggered after push.

View file

@ -0,0 +1,286 @@
# Admin UI for GDPR Art. 17 author erasure
Follow-up to PR5 of #6701 (`feat-gdpr-author-erasure`, merged via #7550).
PR5 shipped the `anonymizeAuthor` capability as a REST endpoint only.
This spec adds an in-product surface so an operator can find and erase
an author from `/admin` without crafting a `curl`.
## Problem
After PR5, erasing an author requires:
1. Knowing the opaque `authorID` (e.g. `a.XXXXXXXXXXXXXXXX`).
2. Holding admin credentials (apikey / JWT).
3. Running a `curl` against `/api/1.3.1/anonymizeAuthor` with the
correct settings flag enabled.
For instances handling real GDPR Art. 17 requests this is too much
friction and too easy to mis-target (the only check before destruction
is "did you paste the right ID?"). Operators have asked for the same
"search → click → confirm" flow they already have for pads.
## Goals
1. Admins can locate an author by display name **or** by external
mapper (SSO subject, token-binding key) — the two identifiers a
GDPR request typically arrives carrying.
2. Before the irreversible erasure runs, the admin sees a server-side
preview of what will be touched (mappings, chat messages, affected
pads).
3. The page itself is discoverable even when the feature flag is off,
so admins know the capability exists and where to enable it.
4. No new public API surface; the public REST endpoint is unchanged
and its single feature flag (`gdprAuthorErasure.enabled`) keeps its
existing meaning.
## Non-goals
- **Pad-context discovery** (drilling from a pad to its contributors).
Possible follow-up; not in this spec.
- **Bulk erase / multi-select.** GDPR requests are per-subject.
- **Audit-log export of erasures.** Operators already have log4js +
the existing `anonymizeAuthor` log line.
- **Undo / recovery.** Erasure is irreversible by design.
- **Refactoring `PadPage.tsx`** into a shared list-page component.
After this lands there will be two real consumers; the abstraction
comes then, not before.
- **Backfill migration for the new `lastSeen` field.** New-on-touch
only; pre-existing records show `—` until they are touched again.
## UX
A new admin page at `/admin/authors`, sidebar entry between Pads and
Shout (icon: `Users` from lucide).
Layout mirrors `PadPage.tsx`:
- Search field — substring match on `name` OR `mapper`.
- Toggle "Show erased authors" (off by default).
- Sortable table:
| Color | Name | Mapper | Last seen | Author ID | Actions |
- Color renders as an inline `<span>` with `background-color`.
- Author ID column shows the full ID (copyable).
- Mapper column renders the first mapper string; if an author has
more than one (multi-SSO accounts, rare), append `+N` and show
the full list in a `title` tooltip.
- Actions column has a single `Trash2` "Erase" button per row.
- Pagination — 12 rows per page (matches Pads).
- Cap warning — when the server reports `cappedAt`, render a banner
"Showing first 1000 authors. Narrow your search to see more."
### Erasure flow (two-step)
Clicking "Erase" opens a Radix `Dialog.Root` with two phases held in
local state (`'preview' | 'committing' | 'closed'`):
1. **Preview** — open emits `anonymizeAuthorPreview`. While waiting
the modal shows a spinner. On `results:anonymizeAuthorPreview`,
counters render:
> About to erase author **`<name>`** (`a.XXXX`).
> Will clear: **N** token mappings, **M** mapper bindings, **K**
> chat messages, across **P** pads.
> **This cannot be undone.**
Buttons: Cancel · Continue.
2. **Commit** — Continue emits `anonymizeAuthor`. On
`results:anonymizeAuthor` the modal closes, a success toast
renders, and the row is replaced in-place with a greyed
"(erased)" stub.
If `results:anonymizeAuthor` carries `error`, the modal stays open
and surfaces the error inline (no destructive close-on-error).
### Disabled-flag UX
When `gdprAuthorErasure.enabled = false`:
- The page renders normally — table, search, sort and pagination
all work (read-only browse is harmless).
- A persistent banner at the top reads:
> Author erasure is disabled. Set `"gdprAuthorErasure": {"enabled":
> true}` in `settings.json` to enable.
- Every Erase button is disabled with the same message as a
`title` tooltip.
- The dry-run preview event remains usable from the admin socket
(it is read-only and admin-authed) — but the UI does not invoke it
while the live action is disabled, to avoid implying an action is
about to happen.
## Backend
Three new admin-socket events on the existing `settings_admin` socket
(parallel to `deletePad` / `cleanupPadRevisions`). **Not REST.**
Rationale: matches the existing admin pattern, reuses the admin-auth
middleware, and keeps the public REST surface unchanged so
`gdprAuthorErasure.enabled` keeps its single meaning ("expose the
public REST endpoint").
| Event in | Payload | Event out | Result shape |
|---|---|---|---|
| `authorLoad` | `{offset, limit, pattern, sortBy, ascending, includeErased}` | `results:authorLoad` | `{total, cappedAt?, results: [{authorID, name, colorId, mapper, lastSeen, erased}]}` |
| `anonymizeAuthorPreview` | `{authorID}` | `results:anonymizeAuthorPreview` | `{authorID, name, removedTokenMappings, removedExternalMappings, clearedChatMessages, affectedPads}` |
| `anonymizeAuthor` | `{authorID}` | `results:anonymizeAuthor` | `{authorID, ...counters} \| {authorID, error: 'disabled' \| 'unknown' \| <message>}` |
### `authorManager.searchAuthors(query)`
New helper. Algorithm:
1. `keys = await db.findKeys('globalAuthor:*', null)`.
2. `mapperIndex = Map<authorID, mapper[]>` built once via
`db.findKeys('mapper2author:*', null)` + a single batch read of
the values. (`mapper2author:<mapper>``{authorID}`.)
3. For each `globalAuthor:<id>` record:
- read the record;
- skip if `erased` and `!includeErased`;
- filter on `pattern` (substring match on `name` OR any mapper in
`mapperIndex.get(authorID) ?? []`);
- emit `{authorID, name, colorId, mapper: mapperIndex.get(...) ??
[], lastSeen, erased}`.
4. Sort the in-memory list by `sortBy` (`name` | `lastSeen`),
ascending or descending.
5. If pre-pagination length > 1000, slice to 1000 and set `cappedAt:
1000`.
6. Apply `offset`/`limit` for pagination; return `{total, cappedAt?,
results}` where `total` is the post-filter, post-cap count.
Performance is acceptable for the typical instance size and is bounded
by the cap. A proper indexed scan can replace this if anyone hits the
cap regularly — explicit follow-up, not now.
### `lastSeen` field
Added to `globalAuthor:<id>`. Set to `Date.now()` on the existing
write paths in `AuthorManager` that already touch the record
(`setAuthorName`, `setAuthorColorId`, `createAuthor*`) — i.e. when
an author actively does something the system records. Read paths are
not modified to avoid an extra write per page load. New-on-touch
only; no migration sweep. Surfaced as ISO-8601 in the search result
and rendered as `toLocaleString()` in the UI. Records without
`lastSeen` render as `—`.
### Dry-run plumbing
`authorManager.anonymizeAuthor(authorID, {dryRun: true})` returns the
same counter shape without writing. Implementation: walk the same
loops, count, return — no `db.set` / `db.remove`. Same admin-auth gate
on the socket layer. The `gdprAuthorErasure.enabled` flag does NOT
gate the dry-run path (read-only, admin-authed); it only gates the
live `anonymizeAuthor` socket event (matching the public REST
endpoint's behaviour).
### Settings flag delivery to client
The `settingsSocket` already streams an `init` payload to the admin
on connect. Add `gdprAuthorErasure: settings.gdprAuthorErasure` to it
and have `App.tsx` populate `gdprAuthorErasureEnabled` in the store
once on connect. The page renders the disabled banner when false.
## Frontend
### New files
- `admin/src/pages/AuthorPage.tsx` — page component, mirrors
`PadPage.tsx` shape.
- `admin/src/utils/AuthorSearch.ts``AuthorSearchQuery`,
`AuthorSearchResult`, `AuthorRow` types.
- `admin/src/components/ColorSwatch.tsx` — small `<span>` with inline
`background-color`. Reusable.
### Edited files
- `admin/src/store/store.ts``authors`, `setAuthors`,
`gdprAuthorErasureEnabled`.
- `admin/src/main.tsx` — register `<Route path="/authors"
element={<AuthorPage/>}/>`.
- `admin/src/App.tsx` (or whichever file owns the sidebar) — new
"Authors" link between Pads and Shout.
- `admin/src/localization/locales/en.json` — see i18n keys below.
- `src/node/hooks/express/admin.ts` — extend the `init` payload with
`gdprAuthorErasure`.
- `src/node/hooks/express/settings_admin.ts` (or equivalent) — wire
the three new socket events.
### i18n keys
All user-visible strings go through `Trans` / `t()` per the project's
i18n rule. New keys:
- `ep_admin_authors:title`
- `ep_admin_authors:search-placeholder`
- `ep_admin_authors:column.color`
- `ep_admin_authors:column.name`
- `ep_admin_authors:column.mapper`
- `ep_admin_authors:column.last-seen`
- `ep_admin_authors:column.author-id`
- `ep_admin_authors:column.actions`
- `ep_admin_authors:show-erased`
- `ep_admin_authors:erase`
- `ep_admin_authors:erased-stub`
- `ep_admin_authors:cap-warning`
- `ep_admin_authors:feature-disabled-banner`
- `ep_admin_authors:confirm-preview-title`
- `ep_admin_authors:confirm-preview-counters`
- `ep_admin_authors:confirm-irreversible`
- `ep_admin_authors:cancel`
- `ep_admin_authors:continue`
- `ep_admin_authors:erase-success-toast`
- `ep_admin_authors:erase-error-toast`
Other locales fall back to English until translated.
## Testing
Per the project rule, both backend and frontend suites ship with the
PR.
### Backend (`mocha --import=tsx`)
- **`src/tests/backend/specs/admin/authorSearch.ts`** — covers
`authorManager.searchAuthors`:
- empty store → `{total: 0, results: []}`
- 3 authors, no filter → all 3, sorted by name asc
- search by name substring matches
- search by mapper substring matches (joins `mapper2author`)
- `includeErased: false` (default) hides erased; `true` includes
- sort by `lastSeen` asc / desc
- cap-at-1000: insert 1100, assert `results.length === 1000` and
`cappedAt === 1000`.
- **`src/tests/backend/specs/anonymizeAuthor.ts`** (extend existing):
- dry-run returns the same counter shape as the live path without
mutating `globalAuthor:<id>`
- dry-run on an unknown authorID returns zeros without throwing.
- **`src/tests/backend/specs/admin/anonymizeAuthorSocket.ts`** —
admin-socket integration:
- opens `settings_admin` with admin creds;
- `authorLoad` round-trip;
- `anonymizeAuthorPreview` round-trip; asserts `erased` is NOT
flipped on the record;
- live `anonymizeAuthor` round-trip when flag enabled;
- live `anonymizeAuthor` returns `{error: 'disabled'}` when flag
off;
- dry-run preview still works when flag off.
### Frontend (Playwright, `src/tests/frontend-new/specs/`)
- **`admin_authors_page.spec.ts`**:
- navigates to `/admin/authors` via the existing admin auth
fixture;
- seeds two authors via the existing API helpers;
- asserts the localized header string (`t('ep_admin_authors:title')`)
renders — not just element presence (per project rule);
- search by name filters the table to one row;
- clicking Erase opens the modal; preview counters render;
Continue commits; row shows the localized "(erased)" stub;
success toast text matches the localized string;
- with the feature flag toggled off via the test settings hook,
the localized banner renders and the Erase button is disabled.
## Backwards compatibility
- The admin socket gains three new events; absent admin builds
ignore them.
- The public REST endpoint and its flag are unchanged.
- Adding `lastSeen` to `globalAuthor` is additive — older record
readers ignore unknown fields.
- No DB migration required.

View file

@ -0,0 +1,304 @@
# Issue 7693 — Document admin endpoints in the OpenAPI spec
**Status:** design approved 2026-05-08
**Issue:** https://github.com/ether/etherpad/issues/7693
**Stacks on:** PR #7695 (`chore/admin-typesafe-api-7638-upstream`) — codegen rails
**Related:** #7601 (introduced `/admin/update/status`); #7607 (Tier 2 update endpoints, in-flight)
## Goal
Add OpenAPI definitions for the admin endpoints currently consumed by the admin
UI so the typed client generated by PR #7695 (`admin/src/api/schema.d.ts`)
gains admin call-sites the day it lands.
This PR adds the schema only. **No call-sites migrate** — that is the explicit
follow-up named in #7693.
## Scope
In:
- `POST /admin-auth/` — login + session check (consumed by `LoginScreen.tsx`
and `App.tsx`).
- `GET /admin/update/status` — Tier 1 update banner data (consumed by
`UpdateBanner.tsx` and `UpdatePage.tsx`; introduced by #7601, merged on
develop).
Out:
- `/admin/update/{apply,cancel,acknowledge,log}` — Tier 2 endpoints from the
in-flight `feat/7607-auto-update-tier2-manual-click` branch. That PR amends
`openapi-admin.ts` when it lands.
- The admin SPA static-file route (`/admin/{*filename}`) — not an API.
- `/admin/socket.io/*` — websocket; out of OpenAPI scope.
- `/api/version-status` — already public, belongs in the public spec, not the
admin spec.
- Migrating any of the four admin `fetch()` call-sites to `$api`.
## Architecture
### File layout (new files marked NEW)
```
src/node/hooks/express/
├── openapi.ts unchanged — APIHandler-driven public spec
└── openapi-admin.ts NEW — hand-authored OpenAPI 3.0 doc for admin routes
src/tests/backend/specs/
└── openapi-admin.ts NEW — Mocha specs asserting document shape
admin/scripts/
├── dump-spec.ts MODIFIED — also import generateAdminDefinition,
│ deep-merge into one document, write merged JSON
├── merge-openapi.mjs NEW — focused deep-merge with collision detection
├── __tests__/
│ └── merge-openapi.test.mjs NEW — node --test unit specs for the merge
└── gen-api.mjs unchanged — still calls dump-spec.ts then
openapi-typescript on the resulting JSON
```
`openapi-admin.ts` is a **static OpenAPI document** (no APIHandler reflection).
Hand-authored because admin routes aren't registered through APIHandler — they
are plain Express handlers. This keeps `openapi.ts`'s 771-line generator
untouched and avoids tangling two different generation strategies in one
module.
### Why merge in `dump-spec.ts` rather than at `openapi-typescript` time
`openapi-typescript` only accepts one input. We could run it twice and emit
two `.d.ts` files, but the chosen design (see "Codegen merge" below) is a
single merged `schema.d.ts`. The merge therefore happens at JSON-dump time,
before `openapi-typescript` runs.
### Two clients, one schema
The merged schema covers two surfaces with different baseUrls (public API
under `/api/<version>/`, admin endpoints at root). A single runtime client
with one `baseUrl` cannot target both correctly. `admin/src/api/client.ts`
therefore narrows the generated `paths` interface by URL prefix and exports
two clients:
```ts
type AdminPath = Extract<keyof paths, `/admin${string}`>;
type PublicPath = Exclude<keyof paths, AdminPath>;
export const fetchClient = createClient<Pick<paths, PublicPath>>({ baseUrl: API_BASE_URL });
export const adminFetchClient = createClient<Pick<paths, AdminPath>>({ baseUrl: '/' });
export const $api = createQueryHooks(fetchClient);
export const $adminApi = createQueryHooks(adminFetchClient);
```
Narrowing at the type level means TypeScript rejects calling an admin path
on `fetchClient` (or vice versa) at compile time — the runtime baseUrl
mismatch is unrepresentable.
## OpenAPI document contents
### Info & security schemes
```yaml
openapi: 3.0.2
info:
title: Etherpad Admin API
version: <getEpVersion()>
description: |
Authenticated administrative endpoints consumed by the Etherpad admin UI.
Distinct from the public /api/{version}/* surface served by openapi.json.
components:
securitySchemes:
basicAuth:
type: http
scheme: basic
sessionCookie:
type: apiKey
in: cookie
name: express_sid
```
`basicAuth` covers the login POST to `/admin-auth/`. `sessionCookie` covers
post-login admin sessions established by `express-session` (cookie name
`express_sid` is the Etherpad default; if a deployment overrides it the spec
remains structurally correct — only the documented cookie name shifts).
The two schemes coexist on `/admin-auth/`; only `sessionCookie` applies on
`/admin/update/status`.
### Paths
#### `POST /admin-auth/``verifyAdminAccess`
- **Security:** `[{ basicAuth: [] }, { sessionCookie: [] }, {}]` — Basic *or*
session cookie *or* none. The empty object documents that the server
accepts the request without auth and replies `401`.
- **Responses:**
- `200` — admin verified (Basic logged in, or session cookie was valid for
an admin user). Empty body.
- `401` — no auth presented and no session. Empty body.
- `403` — auth presented or session present, but the user is not an admin.
Empty body.
- **Description:** notes that POST with `Authorization: Basic …` establishes
an admin session; POST with no auth header verifies an existing one.
This single-operation modeling matches reality: the route is one
middleware-terminated path that branches on what the client sends. Two
operations on the same path would imply different server behavior the
admin UI does not actually depend on.
#### `GET /admin/update/status``getUpdateStatus`
- **Security:** `[{ sessionCookie: [] }, {}]` — cookie when
`updates.requireAdminForStatus=true`, otherwise anonymous OK. The
conditional is documented in the description; clients that depend on
receiving the full diagnostic payload should send the session cookie.
- **Responses:**
- `200` — JSON body matching the `UpdateStatus` schema below.
- `401` / `403` — only emitted when `updates.requireAdminForStatus=true`.
Response schema `UpdateStatus` mirrors the runtime shape returned by
`src/node/hooks/express/updateStatus.ts:res.json({...})` on the base branch
(`chore/admin-typesafe-api-7638-upstream`, which mirrors develop's Tier 1):
```yaml
UpdateStatus:
type: object
required: [currentVersion, installMethod, tier, vulnerableBelow]
properties:
currentVersion: { type: string }
latest: { $ref: '#/components/schemas/ReleaseInfo', nullable: true }
lastCheckAt: { type: string, format: date-time, nullable: true }
installMethod: { type: string, enum: [auto, git, docker, npm, managed] }
tier: { type: string, enum: [off, notify, manual, auto, autonomous] }
policy: { $ref: '#/components/schemas/PolicyResult', nullable: true }
vulnerableBelow:
type: array
items: { $ref: '#/components/schemas/VulnerableBelowDirective' }
```
Sub-schemas (`ReleaseInfo`, `PolicyResult`, `VulnerableBelowDirective`)
mirror the exported interfaces in `src/node/updater/types.ts` exactly:
- `ReleaseInfo`: `version`, `tag`, `body`, `publishedAt`, `prerelease`, `htmlUrl`.
- `PolicyResult`: `canNotify`, `canManual`, `canAuto`, `canAutonomous`, `reason`.
- `VulnerableBelowDirective`: `announcedBy`, `threshold`.
The Tier 2 PR (#7607) will amend `UpdateStatus` to add `execution`,
`lastResult`, and `lockHeld` (with their corresponding sub-schemas) when it
ships its own changes to `updateStatus.ts`. Those fields are out of scope
here.
### Public exposure (runtime)
`openapi-admin.ts` exports an `expressPreSession` hook that **conditionally**
mounts:
```
GET /admin/openapi.json (CORS: *)
```
The route is gated by `settings.adminOpenAPI.enabled`, **default `false`**,
per the project's "new features behind a flag, off by default" policy
(CONTRIBUTING.md, AGENTS.MD, best_practices.md). When the flag is off,
`expressPreSession` returns early and the route is dormant.
When enabled, the route registers in `expressPreSession`, which runs before
`expressCreateServer` (where `admin.ts` registers the SPA wildcard
`/admin/{*filename}`). The earlier registration ensures
`/admin/openapi.json` resolves before the wildcard catches it.
Codegen does not depend on this route — `dump-spec.ts` calls
`generateAdminDefinition()` in-process. The route exists for downstream
tooling (Postman, swagger-ui, third-party clients) that operators choose to
expose.
## Codegen merge
`merge-openapi.mjs` exports one function:
```js
mergeOpenAPI(publicDoc, adminDoc) -> mergedDoc
```
Rules:
| Section | Rule |
| ------------------------------ | ----------------------------------------------------------------- |
| `paths` | Union by path key. Collision throws. |
| `components.schemas` | Union by name. Collision throws. |
| `components.parameters` | Union by name. Collision throws. |
| `components.responses` | Union by name. Collision throws. |
| `components.securitySchemes` | Union by name. Collision throws. |
| `security` (root) | Public spec's root `security` is preserved; admin paths declare their own per-operation security so admin requirements never apply to public paths. |
| `info`, `servers` | Public spec wins. |
Throwing on collision is intentional: silent overwrite is a footgun, and the
backend test below catches collisions before merge runs in CI.
## Tests
### Backend — `src/tests/backend/specs/openapi-admin.ts`
Mocha specs against `generateAdminDefinition()`. No live HTTP.
- Document is valid OpenAPI 3.0 (smoke check via `openapi-schema-validation`,
already in `node_modules`).
- `paths['/admin-auth/'].post.operationId === 'verifyAdminAccess'` and
declares responses `200`, `401`, `403`.
- `paths['/admin/update/status'].get.operationId === 'getUpdateStatus'` and
references `#/components/schemas/UpdateStatus`.
- `components.securitySchemes` contains `basicAuth` and `sessionCookie`.
- `components.schemas.UpdateStatus.properties` contains every property name
emitted by `updateStatus.ts:res.json({...})`. Cross-checked by importing
the same handler and asserting key parity. This is the regression net for
spec/handler drift.
- Admin operationIds and admin path keys do not collide with the public
spec (cross-loaded via `generateDefinitionForVersion`). Cross-collision
is impossible today (admin paths start with `/admin`, public paths are
flat or `/createGroup`-style), but the test fails loudly if a future
rename breaks the assumption.
### Codegen merge — `admin/scripts/__tests__/merge-openapi.test.mjs`
Node `--test` runner (already used by #7695 for `client.test.ts`).
- Two minimal docs merge into the expected union.
- Path collision throws.
- Schema-name collision throws.
- Public root `security` is preserved when admin doc declares no root
security.
- Per-operation security on admin paths survives the merge unchanged.
### No frontend tests this PR
No call-sites migrate, so there is nothing UI-observable to assert.
Migration PRs add Playwright coverage when they touch each fetch.
## Risks & mitigations
| Risk | Mitigation |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `UpdateStatus` schema drifts from `updateStatus.ts` over time | Backend spec cross-checks property names against the handler. Tier 2 PR amends both spec and handler in one change. |
| Tier 2 (#7607) rebase conflicts with the new `openapi-admin.ts` | This PR adds only `/admin/update/status`. Tier 2 appends new entries — no conflict on the existing one. |
| `merge-openapi.mjs` silently overwrites a duplicate | Throws on collision. Backend spec cross-checks against the public spec. |
| `/admin/openapi.json` collides with `/admin/{*filename}` SPA wildcard | `openapi-admin.ts` registers in `expressPreSession`; `admin.ts` registers in `expressCreateServer`. Earlier hook wins. Backend smoke test confirms 200 + JSON content-type. |
| #7695 changes shape before it merges, breaking our base | This PR is stacked on #7695's branch. Rebase when #7695 rebases. PR description documents the dependency. |
| `express_sid` is not the actual cookie name in some deployments | Documented; spec is structurally correct; deployments that override it can still consume a typed client. |
## Rollout
1. Branch `feat/7693-admin-openapi` from `chore/admin-typesafe-api-7638-upstream`.
2. Add `openapi-admin.ts`, `merge-openapi.mjs`; modify `dump-spec.ts`.
3. Add backend spec and merge unit tests.
4. Open PR #7693 as **draft**, base set to `chore/admin-typesafe-api-7638-upstream`.
5. When PR #7695 merges to develop, change base to `develop`, rebase, mark
ready for review.
6. Follow-up PR (separately tracked) migrates the four admin `fetch()`
sites: `LoginScreen.tsx`, `App.tsx`, `UpdateBanner.tsx`, `UpdatePage.tsx`.
## Open question deferred to implementation
The `express_sid` cookie name is the documented default but Etherpad
deployments can override it via settings. Implementation will read the
configured name at spec-generation time (or document the override path) so
the spec reflects the running configuration. If reading the configured name
is awkward at codegen time (it requires booting Settings), the spec keeps
the default and notes the override in the description.

View file

@ -0,0 +1,230 @@
# Native DOCX + PDF export and DOCX import without LibreOffice
**Status:** spec — pending implementation
**Issue:** #7538
**Extending PR:** #7568 (`feat/native-docx-export-7538`)
**Date:** 2026-05-08
## Problem
Etherpad's import/export pipeline shells out to LibreOffice (`soffice`) for every "office" format — `pdf`, `docx`, `odt`, `doc`, `rtf`. Operators who want any of those formats must install ~500 MB of LibreOffice as a runtime dependency, plus pay subprocess latency on every export. Operators who don't want LibreOffice lose those formats entirely.
PR #7568 took a first cut at native DOCX export via `html-to-docx`, but:
- it's flag-gated (`settings.nativeDocxExport`) and falls back to soffice on error, so soffice remains a soft requirement;
- the `/export` route guard and pad UI both gate `docx` on `soffice` being configured, so the new path is unreachable in a real no-soffice deployment (Qodo finding #2);
- existing tests use `settings.soffice = 'false'` (a non-null string), which sidesteps the route guard and doesn't simulate a real no-soffice deployment (Qodo finding #3);
- the `html-to-docx` dependency tree includes `node-fetch` via `image-to-base64`, so plugin-modified HTML can trigger outbound requests from the converter (Qodo finding #4);
- nothing addresses PDF, which the issue explicitly scopes alongside DOCX.
This spec replaces the flag-gated half-measure with a soffice-first selection model, adds a native PDF export path, adds native DOCX import, and hardens both export converters against SSRF.
## Goal
A deployment with `settings.soffice = null` can:
- export pads as `html`, `txt`, `etherpad`, `docx`, `pdf` — all in-process, no subprocess, no native binaries.
- import `.html`, `.txt`, `.etherpad`, `.docx` files — all in-process.
A deployment with `settings.soffice` configured retains today's behavior bit-for-bit. There is no flag to flip; the path is chosen automatically based on `sofficeAvailable()`.
`odt`, `doc`, `rtf` (and `pdf` import) continue to require soffice. The deployment matrix is documented; users get a clear error message instead of a silent failure.
## Non-goals
- Native ODT export. No mature pure-JS writer; deferred to a follow-up issue.
- Native PDF/ODT/DOC/RTF import. No mature pure-JS readers for these in Node. Deferred.
- Pixel-perfect PDF fidelity. We target structural fidelity (paragraphs, headings, lists, tables, images, basic styling) — the same bar `html-to-docx` hits for DOCX.
- Memory/timeout caps on conversion. Pad size is already gated upstream; we'll add caps if production signal warrants it.
## Selection model
A single cascade in `ExportHandler.ts` (and a mirror in `ImportHandler`):
```text
if (sofficeAvailable() === 'yes') {
→ existing soffice path (handles all formats)
} else if (sofficeAvailable() === 'withoutPDF') {
// Windows: soffice present but can't render PDF
if (type === 'pdf') → native PDF
else → soffice
} else { // 'no' — soffice null
if (type === 'docx') → native DOCX
else if (type === 'pdf') → native PDF
else → 4xx "this format requires soffice"
}
```
No fallback chain on native error. If the native converter throws, the request returns a 500 with a clear log line. This is deliberate — fallback-to-soffice is the pattern that PR #7568 originally used and that Qodo flagged as defeating the no-soffice goal.
The `nativeDocxExport` setting introduced by PR #7568 is removed entirely. With it go `NATIVE_DOCX_EXPORT`, the `doc/docker.md` row, and the new entries in `settings.json.template` / `settings.json.docker`. Native is built-in; the only thing that varies behavior is whether `soffice` is configured.
## Route guard and UI capability
`src/node/hooks/express/importexport.ts` currently rejects all of `['odt','pdf','doc','docx']` when `exportAvailable() === 'no'`. Tighten that list:
```text
if (exportAvailable() === 'no' && ['odt','doc'].includes(req.params.type)) {
→ existing "this export is not enabled" message
}
// pdf and docx fall through to ExportHandler, which dispatches per the cascade above
```
Same shape on the import endpoint: `pdf`, `odt`, `doc`, `rtf` blocked when soffice is null; `docx` (plus the pre-existing `etherpad`/`html`/`txt`) goes through.
UI side — `src/static/js/pad_impexp.ts:147-166` currently hides DOCX/PDF/ODT export links when `clientVars.exportAvailable === 'no'`. Update so:
- ODT link: visible iff `exportAvailable === 'yes'` (effectively unchanged)
- DOCX, PDF links: always visible
No new clientVars flags. The "always visible" rule reflects reality — those paths are built into core.
## Native PDF export
Module: `src/node/utils/ExportPdfNative.ts`. Single export `htmlToPdfBuffer(html: string): Promise<Buffer>`.
Approach: **`pdfkit` + `htmlparser2` + a small walker we own.** Pure JS, no jsdom, ~3 MB install footprint. We control the renderer end-to-end, so there is no SSRF surface from the converter.
### Pipeline
1. `htmlparser2` parses the input HTML into a SAX-style event stream.
2. A walker maintains a `pdfkit` document and a stack of inline-style state. Tag handling:
- `<p>`, `<h1..h6>` — block break + font sizing
- `<strong>`/`<b>`, `<em>`/`<i>`, `<u>`, `<s>` — toggle inline style
- `<ul>`/`<ol>`/`<li>` — indent + bullet/number prefix
- `<a href="…">` — underlined text + `doc.link()` annotation
- `<br>``doc.moveDown(0.5)`
- `<table>`/`<tr>`/`<td>` — best-effort grid via computed x/y on `doc.text()`. Pad HTML emits real tables only via plugins; we render what we can.
- `<img src="data:…">` — embed via `doc.image(buffer)` after decoding the data URI
- `<img src="…">` (any non-data URL) — replaced with the `alt=` text or skipped. **No fetch.** This is an explicit SSRF guard at the converter; the upstream sanitizer (next section) handles it too, this is defense-in-depth.
- Unknown tags — recurse into children, ignore the wrapper
3. Buffer the PDF in memory via a `PassThrough` stream → resolve with the concatenated `Buffer`.
### Bail-out criterion
The walker is a pragmatic bet — pad HTML is constrained enough that a small walker should cover it. **If, during implementation, the walker exceeds ~500 lines of code or hits a class of pad/plugin HTML it cannot reasonably render**, switch to **Approach A**: `pdfmake` + `html-to-pdfmake` + `jsdom`. That swap keeps the same `ExportHandler.ts` integration shape — only `ExportPdfNative.ts` changes — and adds ~1520 MB of pure-JS deps.
The plan that follows this spec must call out this decision point so the implementer doesn't grind on a dying walker.
## HTML sanitization (defense-in-depth)
New module: `src/node/utils/ExportSanitizeHtml.ts`. Single export `stripRemoteImages(html: string): string`.
Walks the HTML once with `htmlparser2`, drops any `<img>` whose `src` is not `data:` or a same-origin/relative URL. Replaces with the original `alt=` text (empty string if absent). Pure string-in/string-out, ~50 lines + a unit test.
Both export branches call this *before* handing HTML to their respective converters:
```text
const safeHtml = stripRemoteImages(html);
buffer = (type === 'docx') ? await htmlToDocx(safeHtml) : await htmlToPdfBuffer(safeHtml);
```
This addresses Qodo finding #4 against the existing DOCX path (which was always present, not introduced here) and prevents the equivalent SSRF on the PDF path.
## Native DOCX import
Module: `src/node/utils/ImportDocxNative.ts`. Single export `docxBufferToHtml(buf: Buffer): Promise<string>`.
Wraps `mammoth.convertToHtml({buffer: buf})` and returns `result.value`. Mammoth is pure JS, ~3 MB, embeds images as data URLs by default — no fetches, no SSRF surface. We pass `convertImage: mammoth.images.imgElement(...)` configured to emit data URLs only, as belt-and-braces.
Dispatch in `src/node/handler/ImportHandler.ts` mirrors the export cascade:
```text
if (sofficeAvailable() === 'yes') {
→ existing soffice path
} else if (extension === '.docx') {
const html = await docxBufferToHtml(buffer);
→ hand to existing HTML import pipeline
} else if (['.pdf','.odt','.doc','.rtf'].includes(extension)) {
→ 4xx "this format requires soffice"
}
// .etherpad / .html / .txt unchanged on both branches
```
The HTML import pipeline already handles whatever `mammoth` emits (semantic HTML with paragraphs, lists, headings, links, inline styles, embedded images).
## Error handling
Native conversion errors surface to the client as 5xx with a logged error line that includes the pad id and format:
```text
} catch (err) {
console.error(`native ${type} export failed for pad "${padId}":`, err);
res.status(500).send(`Failed to export pad as ${type}.`);
}
```
No fallback chain. No silent retries.
## Tests
`src/tests/backend/specs/export.ts` — revise existing native-DOCX tests:
- Set `settings.soffice = null` (was `'false'` — fixes Qodo #3)
- Assert response is a ZIP-signature DOCX with the correct content-type
- Keep the `require.resolve('html-to-docx')` describe-skip guard for the `upgrade-from-latest-release` CI job
`src/tests/backend/specs/export.ts` — add native-PDF tests:
- With `settings.soffice = null`, GET `/p/<pad>/export/pdf` → 200, `Content-Type: application/pdf`, body starts with `%PDF-`
- Same describe-skip guard for `pdfkit` and `htmlparser2`
Negative test: with `settings.soffice = null`, GET `/p/<pad>/export/odt` still returns the "not enabled" message (proves we tightened the right gate).
`src/tests/backend/specs/export.ts` (or a sibling file) — unit test for `stripRemoteImages`:
- `<img src="https://evil/x.png">` → dropped
- `<img src="data:image/png;base64,…">` → kept
- `<img src="/local/x.png">` → kept (same-origin/relative)
A new import test file (e.g. `src/tests/backend/specs/import.ts` — there is no existing import-flow file in `src/tests/backend/specs/`, only `ImportEtherpad.ts`) for native DOCX import:
- Fixture: a small known `.docx` with a heading, a paragraph, and a bullet list, committed under `src/tests/backend/specs/fixtures/`
- With `settings.soffice = null`, POST `/p/<pad>/import` with the fixture → assert pad atext/HTML contains the expected structure
- Negative: rename fixture to `.odt` extension, POST → still rejected with the "requires soffice" message
`exportHTMLSend` plugin hook: verify by reading the code whether the hook fires on the native paths (currently it's only invoked on the `type === 'html'` branch). If a small move is needed to keep the hook contract intact across native DOCX/PDF, include it. If the existing behavior is "hook only fires for html export", document that and don't change it — out of scope for this spec.
## Files touched
| File | Change |
|---|---|
| `src/node/handler/ExportHandler.ts` | Replace flag-gated branch with soffice-first cascade; call sanitizer; native PDF + DOCX dispatch |
| `src/node/handler/ImportHandler.ts` | Soffice-first cascade; native DOCX import dispatch |
| `src/node/utils/ExportPdfNative.ts` | **new** — pdfkit walker, ≤500 lines (bail-out criterion) |
| `src/node/utils/ExportSanitizeHtml.ts` | **new**`stripRemoteImages`, ~50 lines |
| `src/node/utils/ImportDocxNative.ts` | **new** — mammoth wrapper, ~30 lines |
| `src/node/hooks/express/importexport.ts` | Tighten export and import route guards to `['odt','doc','pdf','rtf']`-as-appropriate |
| `src/node/utils/Settings.ts` | **revert** `nativeDocxExport` field (introduced by PR #7568) |
| `src/static/js/pad_impexp.ts` | Always show DOCX + PDF export links; ODT link still gated on `exportAvailable` |
| `src/package.json` | Add `pdfkit`, `htmlparser2`, `mammoth`. Keep `html-to-docx`. Drop nothing. |
| `pnpm-lock.yaml` | Lockfile regen |
| `settings.json.template`, `settings.json.docker` | **revert** `nativeDocxExport` entries |
| `doc/docker.md` | **revert** `NATIVE_DOCX_EXPORT` row |
| `src/tests/backend/specs/export.ts` | Revise DOCX tests (`soffice=null`); add PDF tests; add negative ODT; add unit test for sanitizer |
| `src/tests/backend/specs/import.ts` | Add native DOCX import tests; add negative ODT |
| `src/tests/backend/specs/fixtures/<file>.docx` | **new** — small DOCX fixture |
## Open questions handled in implementation, not spec
- Exact error response shape for the route-guard-rejected formats — match whatever the existing soffice-disabled path uses, no fresh design.
- Whether `exportHTMLSend` needs to fire on the native paths — covered in the test plan; verify against current behavior, don't expand scope.
- Image MIME sniffing for `data:` URLs in the PDF walker — `pdfkit` accepts PNG/JPEG buffers; we'll decode the base64 and let pdfkit reject unsupported types, surfacing as a converter error.
## Dependencies summary
| Package | Purpose | Approx install size |
|---|---|---|
| `html-to-docx` | DOCX export (pre-existing in PR #7568) | ~5 MB |
| `pdfkit` | PDF export rendering | ~2 MB |
| `htmlparser2` | HTML SAX parser used by walker + sanitizer | <1 MB |
| `mammoth` | DOCX → HTML import | ~3 MB |
Total added install: roughly 11 MB across all four. Compared against ~500 MB for LibreOffice and ~200 MB for puppeteer (the alternative considered and rejected in #7538), this is the right tradeoff for the structural-fidelity bar.
## Out of scope (deferred to follow-ups)
- Native ODT export — file follow-up issue.
- Native PDF/ODT/DOC/RTF import — file follow-up issue, document why they were rejected (no mature pure-JS readers).
- Memory/timeout caps on conversion — add when production signal warrants.
- Plugin hook coverage on native paths — beyond the `exportHTMLSend` check above.

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