Merge branch 'develop'

This commit is contained in:
Etherpad Release Bot 2026-04-22 07:07:10 +00:00
commit 6d92d90a80
318 changed files with 14508 additions and 7225 deletions

View File

@ -1,7 +1,7 @@
<!--
1. If you haven't already, please read https://github.com/ether/etherpad-lite/blob/master/CONTRIBUTING.md#pull-requests .
2. Run all the tests, both front-end and back-end. (see https://github.com/ether/etherpad-lite/blob/master/CONTRIBUTING.md#testing)
1. If you haven't already, please read https://github.com/ether/etherpad/blob/master/CONTRIBUTING.md#pull-requests .
2. Run all the tests, both front-end and back-end. (see https://github.com/ether/etherpad/blob/master/CONTRIBUTING.md#testing)
3. Keep business logic and validation on the server-side.
4. Update documentation.
5. Write `fixes #XXXX` in your comment to auto-close an issue.

View File

@ -27,7 +27,8 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]
# PRs: test on latest Node only. Push to develop: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[">=24.0.0 <25.0.0"]') || fromJSON('[">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]') }}
steps:
-
name: Checkout repository
@ -83,7 +84,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]
node: ${{ github.event_name == 'pull_request' && fromJSON('[">=24.0.0 <25.0.0"]') || fromJSON('[">=20.0.0 <21.0.0", ">=22.0.0 <23.0.0", ">=24.0.0 <25.0.0"]') }}
steps:
-
name: Checkout repository
@ -124,14 +125,13 @@ jobs:
ep_author_hover
ep_cursortrace
ep_font_size
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
ep_table_of_contents --runtimeVersion="${{ matrix.node }}"
ep_table_of_contents
-
name: Run the backend tests
run: gnpm test --runtimeVersion="${{ matrix.node }}"
@ -139,14 +139,12 @@ jobs:
working-directory: src
run: gnpm run test:vitest --runtimeVersion="${{ matrix.node }}"
# Windows tests only run on push to develop/master, not on PRs
withoutpluginsWindows:
env:
PNPM_HOME: ~\\.pnpm-store
# run on pushes to any branch
# run on PRs from external forks
if: |
(github.event_name != 'pull_request')
|| (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)
github.event_name != 'pull_request'
strategy:
fail-fast: false
matrix:
@ -194,11 +192,8 @@ jobs:
withpluginsWindows:
env:
PNPM_HOME: ~\\.pnpm-store
# run on pushes to any branch
# run on PRs from external forks
if: |
(github.event_name != 'pull_request')
|| (github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id)
github.event_name != 'pull_request'
strategy:
fail-fast: false
matrix:
@ -232,22 +227,19 @@ jobs:
run: gnpm build --runtimeVersion="${{ matrix.node }}"
-
name: Install Etherpad plugins
# The --legacy-peer-deps flag is required to work around a bug in npm
# v7: https://github.com/npm/cli/issues/2199
run: >
gnpm install --workspace-root
ep_align
ep_author_hover
ep_cursortrace
ep_font_size
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
ep_table_of_contents --runtimeVersion="${{ matrix.node }}"
ep_table_of_contents
# Etherpad core dependencies must be installed after installing the
# plugin's dependencies, otherwise npm will try to hoist common
# dependencies by removing them from src/node_modules and installing them

View File

@ -50,7 +50,7 @@ jobs:
with:
version: 0.0.12
- name: Setup Pages
uses: actions/configure-pages@v5
uses: actions/configure-pages@v6
- name: Install dependencies
run: gnpm install
- name: Build app
@ -59,10 +59,10 @@ jobs:
env:
COMMIT_REF: ${{ github.sha }}
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
uses: actions/upload-pages-artifact@v5
with:
# Upload entire repository
path: './doc/.vitepress/dist'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5

View File

@ -12,12 +12,15 @@ on:
- 'v?[0-9]+.[0-9]+.[0-9]+'
env:
TEST_TAG: etherpad/etherpad:test
permissions:
contents: read
jobs:
docker:
build-test:
runs-on: ubuntu-latest
permissions:
contents: read
env:
PNPM_HOME: ~/.pnpm-store
steps:
@ -26,17 +29,12 @@ jobs:
uses: actions/checkout@v6
with:
path: etherpad
-
name: Set up QEMU
if: github.event_name == 'push'
uses: docker/setup-qemu-action@v3
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
-
name: Build and export to Docker
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ./etherpad
target: production
@ -78,13 +76,156 @@ jobs:
done
(cd src && gnpm run test-container)
git clean -dxf .
build-test-db-drivers:
runs-on: ubuntu-latest
permissions:
contents: read
services:
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: etherpad
MYSQL_USER: etherpad
MYSQL_PASSWORD: password
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -ppassword"
--health-interval=5s
--health-timeout=5s
--health-retries=20
postgres:
image: postgres:16
env:
POSTGRES_DB: etherpad
POSTGRES_USER: etherpad
POSTGRES_PASSWORD: password
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U etherpad -d etherpad"
--health-interval=5s
--health-timeout=5s
--health-retries=20
steps:
-
name: Check out
uses: actions/checkout@v6
with:
path: etherpad
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
-
name: Build production image
uses: docker/build-push-action@v7
with:
context: ./etherpad
target: production
load: true
tags: ${{ env.TEST_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
-
name: Driver presence test (all 10 ueberdb2 drivers must resolve)
run: |
docker run --rm -w /opt/etherpad-lite/src "$TEST_TAG" node -e "
const mods = [
'@elastic/elasticsearch','cassandra-driver','mongodb','mssql',
'mysql2','nano','pg','redis','rethinkdb','surrealdb'
];
let fail = false;
for (const m of mods) {
try { require(m); console.log('ok', m); }
catch (e) { console.error('MISSING', m, e.message); fail = true; }
}
if (fail) process.exit(1);
"
-
name: MySQL smoke — start Etherpad against mysql service
run: |
docker run --rm -d \
--network host \
-e NODE_ENV=production \
-e ADMIN_PASSWORD=admin \
-e DB_TYPE=mysql \
-e DB_HOST=127.0.0.1 \
-e DB_PORT=3306 \
-e DB_NAME=etherpad \
-e DB_USER=etherpad \
-e DB_PASS=password \
-e DB_CHARSET=utf8mb4 \
-e DEFAULT_PAD_TEXT="Test " \
--name et-mysql "$TEST_TAG"
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:9001/ >/dev/null; then
echo "mysql smoke: Etherpad is serving /"
docker rm -f et-mysql
exit 0
fi
sleep 2
done
echo "mysql smoke: timed out waiting for Etherpad"
docker logs et-mysql || true
docker rm -f et-mysql || true
exit 1
-
name: Postgres smoke — start Etherpad against postgres service
run: |
docker run --rm -d \
--network host \
-e NODE_ENV=production \
-e ADMIN_PASSWORD=admin \
-e DB_TYPE=postgres \
-e DB_HOST=127.0.0.1 \
-e DB_PORT=5432 \
-e DB_NAME=etherpad \
-e DB_USER=etherpad \
-e DB_PASS=password \
-e DEFAULT_PAD_TEXT="Test " \
--name et-postgres "$TEST_TAG"
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:9001/ >/dev/null; then
echo "postgres smoke: Etherpad is serving /"
docker rm -f et-postgres
exit 0
fi
sleep 2
done
echo "postgres smoke: timed out waiting for Etherpad"
docker logs et-postgres || true
docker rm -f et-postgres || true
exit 1
publish:
needs: [build-test, build-test-db-drivers]
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
-
name: Check out
uses: actions/checkout@v6
with:
path: etherpad
-
name: Set up QEMU
uses: docker/setup-qemu-action@v4
-
name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
-
name: Docker meta
if: github.event_name == 'push'
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: etherpad/etherpad
images: |
etherpad/etherpad
ghcr.io/ether/etherpad
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
@ -92,16 +233,21 @@ jobs:
type=semver,pattern={{major}}
-
name: Log in to Docker Hub
if: github.event_name == 'push'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Build and push
id: build-docker
if: github.event_name == 'push'
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ./etherpad
target: production
@ -109,6 +255,7 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
- name: Update repo description
uses: peter-evans/dockerhub-description@v5
if: github.ref == 'refs/heads/master'
@ -118,8 +265,8 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: etherpad/etherpad
enable-url-completion: true
- name: Check out
if: github.event_name == 'push' && github.ref == 'refs/heads/develop'
- name: Check out ether-charts
if: github.ref == 'refs/heads/develop'
uses: actions/checkout@v6
with:
path: ether-charts

View File

@ -1,13 +1,15 @@
# Leave the powered by Sauce Labs bit in as this means we get additional concurrency
name: "Frontend admin tests"
on:
push:
paths-ignore:
- 'doc/**'
pull_request:
paths-ignore:
- 'doc/**'
permissions:
contents: read # to fetch code (actions/checkout)
contents: read
jobs:
withplugins:
@ -19,15 +21,10 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
# PRs: single Node version. Push: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[20, 22, 24]') }}
steps:
-
name: Generate Sauce Labs strings
id: sauce_strings
run: |
printf %s\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }} - Node ${{ matrix.node }}'
printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}-node${{ matrix.node }}'
-
name: Checkout repository
uses: actions/checkout@v6
@ -47,47 +44,26 @@ jobs:
uses: SamTV12345/gnpm-setup@main
with:
version: 0.0.12
- name: Cache playwright binaries
uses: actions/cache@v5
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
#-
# name: Install etherpad plugins
# # We intentionally install an old ep_align version to test upgrades to
# # the minor version number. The --legacy-peer-deps flag is required to
# # work around a bug in npm v7: https://github.com/npm/cli/issues/2199
# run: pnpm install --workspace-root ep_align@0.2.27
# Etherpad core dependencies must be installed after installing the
# plugin's dependencies, otherwise npm will try to hoist common
# dependencies by removing them from src/node_modules and installing them
# in the top-level node_modules. As of v6.14.10, npm's hoist logic appears
# to be buggy, because it sometimes removes dependencies from
# src/node_modules but fails to add them to the top-level node_modules.
# Even if npm correctly hoists the dependencies, the hoisting seems to
# confuse tools such as `npm outdated`, `npm update`, and some ESLint
# rules.
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: gnpm i --runtimeVersion="${{ matrix.node }}"
#-
# name: Install etherpad plugins
# run: rm -Rf node_modules/ep_align/static/tests/*
-
name: export GIT_HASH to env
id: environment
run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})"
- name: Cache Playwright browsers
uses: actions/cache@v5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('src/package.json') }}
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: cd src && npx playwright install
- name: Install Playwright system dependencies
run: cd src && npx playwright install-deps
-
name: Create settings.json
run: cp settings.json.template settings.json
-
name: Write custom settings.json that enables the Admin UI tests
run: "sed -i 's/\"enableAdminUITests\": false/\"enableAdminUITests\": true,\\n\"users\":{\"admin\":{\"password\":\"changeme1\",\"is_admin\":true}}/' settings.json"
-
name: increase maxHttpBufferSize
run: "sed -i 's/\"maxHttpBufferSize\": 50000/\"maxHttpBufferSize\": 10000000/' settings.json"
-
name: Disable import/export rate limiting
run: |
@ -96,37 +72,10 @@ jobs:
working-directory: admin
run: |
gnpm run build --runtimeVersion="${{ matrix.node }}"
# name: Run the frontend admin tests
# shell: bash
# env:
# SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
# SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
# SAUCE_NAME: ${{ steps.sauce_strings.outputs.name }}
# TRAVIS_JOB_NUMBER: ${{ steps.sauce_strings.outputs.tunnel_id }}
# GIT_HASH: ${{ steps.environment.outputs.sha_short }}
# run: |
# src/tests/frontend/travis/adminrunner.sh
#-
# uses: saucelabs/sauce-connect-action@v2.3.6
# with:
# username: ${{ secrets.SAUCE_USERNAME }}
# accessKey: ${{ secrets.SAUCE_ACCESS_KEY }}
# tunnelIdentifier: ${{ steps.sauce_strings.outputs.tunnel_id }}
#-
# name: Run the frontend admin tests
# shell: bash
# env:
# SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
# SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
# SAUCE_NAME: ${{ steps.sauce_strings.outputs.name }}
# TRAVIS_JOB_NUMBER: ${{ steps.sauce_strings.outputs.tunnel_id }}
# GIT_HASH: ${{ steps.environment.outputs.sha_short }}
# run: |
# src/tests/frontend/travis/adminrunner.sh
- name: Run the frontend admin tests
shell: bash
run: |
gnpm run prod --runtimeVersion="${{ matrix.node }}" &
gnpm run prod --runtimeVersion="${{ matrix.node }}" > /tmp/etherpad-server.log 2>&1 &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
@ -138,10 +87,15 @@ jobs:
sleep 1
done
cd src
gnpm exec playwright install --runtimeVersion="${{ matrix.node }}"
gnpm exec playwright install-deps --runtimeVersion="${{ matrix.node }}"
gnpm run test-admin --runtimeVersion="${{ matrix.node }}"
- uses: actions/upload-artifact@v6
- name: Upload server log on failure
uses: actions/upload-artifact@v7
if: failure()
with:
name: server-log-admin-${{ matrix.node }}
path: /tmp/etherpad-server.log
retention-days: 7
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report-${{ matrix.node }}

View File

@ -1,13 +1,15 @@
# Leave the powered by Sauce Labs bit in as this means we get additional concurrency
name: "Frontend tests powered by Sauce Labs"
name: "Frontend tests"
on:
push:
paths-ignore:
- 'doc/**'
pull_request:
paths-ignore:
- 'doc/**'
permissions:
contents: read # to fetch code (actions/checkout)
contents: read
jobs:
playwright-chrome:
@ -16,12 +18,6 @@ jobs:
name: Playwright Chrome
runs-on: ubuntu-latest
steps:
-
name: Generate Sauce Labs strings
id: sauce_strings
run: |
printf %s\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }}'
printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}'
-
name: Checkout repository
uses: actions/checkout@v6
@ -45,17 +41,13 @@ jobs:
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: gnpm install --frozen-lockfile
-
name: export GIT_HASH to env
id: environment
run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})"
-
name: Create settings.json
run: cp ./src/tests/settings.json settings.json
- name: Run the frontend tests
shell: bash
run: |
gnpm run prod &
gnpm run prod > /tmp/etherpad-server.log 2>&1 &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
@ -69,10 +61,17 @@ jobs:
cd src
gnpm exec playwright install chromium --with-deps
gnpm run test-ui --project=chromium
- uses: actions/upload-artifact@v6
- name: Upload server log on failure
uses: actions/upload-artifact@v7
if: failure()
with:
name: server-log-chrome
path: /tmp/etherpad-server.log
retention-days: 7
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report-${{ matrix.node }}-chrome
name: playwright-report-chrome
path: src/playwright-report/
retention-days: 30
playwright-firefox:
@ -81,11 +80,6 @@ jobs:
name: Playwright Firefox
runs-on: ubuntu-latest
steps:
- name: Generate Sauce Labs strings
id: sauce_strings
run: |
printf %s\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }}'
printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}'
- name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
@ -107,15 +101,12 @@ jobs:
version: 0.0.12
- name: Install all dependencies and symlink for ep_etherpad-lite
run: gnpm install --frozen-lockfile
- name: export GIT_HASH to env
id: environment
run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})"
- name: Create settings.json
run: cp ./src/tests/settings.json settings.json
- name: Run the frontend tests
shell: bash
run: |
gnpm run prod &
gnpm run prod > /tmp/etherpad-server.log 2>&1 &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
@ -129,76 +120,16 @@ jobs:
cd src
gnpm exec playwright install firefox --with-deps
gnpm run test-ui --project=firefox
- uses: actions/upload-artifact@v6
- name: Upload server log on failure
uses: actions/upload-artifact@v7
if: failure()
with:
name: server-log-firefox
path: /tmp/etherpad-server.log
retention-days: 7
- uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report-${{ matrix.node }}-firefox
name: playwright-report-firefox
path: src/playwright-report/
retention-days: 30
playwright-webkit:
name: Playwright Webkit
runs-on: ubuntu-latest
env:
PNPM_HOME: ~/.pnpm-store
steps:
-
name: Generate Sauce Labs strings
id: sauce_strings
run: |
printf %s\\n '::set-output name=name::${{ github.workflow }} - ${{ github.job }}'
printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}'
-
name: Checkout repository
uses: actions/checkout@v6
- uses: actions/cache@v5
name: Setup gnpm cache
if: always()
with:
path: |
${{ env.PNPM_HOME }}
~/.local/share/gnpm
~/.cache/ms-playwright
/usr/local/bin/gnpm
/usr/local/bin/gnpm-0.0.12
key: ${{ runner.os }}-gnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-gnpm-store-
- name: Setup gnpm
uses: SamTV12345/gnpm-setup@main
with:
version: 0.0.12
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: gnpm install --frozen-lockfile
-
name: export GIT_HASH to env
id: environment
run: echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})"
-
name: Create settings.json
run: cp ./src/tests/settings.json settings.json
- name: Run the frontend tests
shell: bash
run: |
gnpm run prod &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1
connected=true
}
now() { date +%s; }
start=$(now)
while [ $(($(now) - $start)) -le 15 ] && ! can_connect; do
sleep 1
done
cd src
gnpm exec playwright install webkit --with-deps
gnpm run test-ui --project=webkit || true
- uses: actions/upload-artifact@v6
if: always()
with:
name: playwright-report-${{ matrix.node }}-webkit
path: src/playwright-report/
retention-days: 30

View File

@ -54,7 +54,7 @@ jobs:
working-directory: bin
run: gnpm run generateChangelog ${{ github.ref }} > ${{ github.workspace }}-CHANGELOG.txt
- name: Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
if: ${{startsWith(github.ref, 'refs/tags/v') }}
with:
body_path: ${{ github.workspace }}-CHANGELOG.txt

165
.github/workflows/installer-test.yml vendored Normal file
View File

@ -0,0 +1,165 @@
name: "Installer test"
# Exercises bin/installer.sh and bin/installer.ps1 end-to-end so the
# one-line install paths in the README stay working.
on:
push:
paths:
- "bin/installer.sh"
- "bin/installer.ps1"
- ".github/workflows/installer-test.yml"
pull_request:
paths:
- "bin/installer.sh"
- "bin/installer.ps1"
- ".github/workflows/installer-test.yml"
permissions:
contents: read
jobs:
shellcheck:
name: shellcheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Run shellcheck on installer.sh
run: |
sudo apt-get update
sudo apt-get install -y shellcheck
shellcheck bin/installer.sh
installer-posix:
name: end-to-end install (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Pre-install pnpm (avoid sudo prompt in the installer)
run: npm install -g pnpm
- 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 }}
NO_COLOR: "1"
run: sh ./bin/installer.sh
- name: Verify clone + dependencies + build artifacts
shell: bash
env:
ETHERPAD_DIR: ${{ runner.temp }}/etherpad-installer-test
run: |
set -eux
test -d "$ETHERPAD_DIR/.git"
test -f "$ETHERPAD_DIR/package.json"
test -d "$ETHERPAD_DIR/node_modules"
test -d "$ETHERPAD_DIR/src/node_modules"
# build:etherpad copies the admin SPA into src/templates/admin
test -f "$ETHERPAD_DIR/src/templates/admin/index.html"
- name: Smoke test - start Etherpad and curl /api
shell: bash
env:
ETHERPAD_DIR: ${{ runner.temp }}/etherpad-installer-test
run: |
set -eu
cd "$ETHERPAD_DIR"
pnpm run prod >/tmp/etherpad.log 2>&1 &
PID=$!
# Wait up to 60s for the API to come up.
ok=0
for i in $(seq 1 60); do
if curl -fsS http://localhost:9001/api >/dev/null 2>&1; then
echo "Etherpad answered on /api after ${i}s"
ok=1
break
fi
sleep 1
done
if [ "$ok" != "1" ]; then
echo "Etherpad did not start within 60s. Last 200 lines of log:" >&2
tail -200 /tmp/etherpad.log >&2 || true
kill "$PID" 2>/dev/null || true
exit 1
fi
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
installer-windows:
name: end-to-end install (windows-latest)
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Pre-install pnpm
run: npm install -g pnpm
- name: Run bin/installer.ps1 against this commit
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 }}
NO_COLOR: "1"
run: ./bin/installer.ps1
- name: Verify clone + dependencies + build artifacts
shell: pwsh
env:
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
run: |
if (-not (Test-Path "$env:ETHERPAD_DIR\.git")) { throw '.git missing' }
if (-not (Test-Path "$env:ETHERPAD_DIR\package.json")) { throw 'package.json missing' }
if (-not (Test-Path "$env:ETHERPAD_DIR\node_modules")) { throw 'node_modules missing' }
if (-not (Test-Path "$env:ETHERPAD_DIR\src\node_modules")) { throw 'src/node_modules missing' }
if (-not (Test-Path "$env:ETHERPAD_DIR\src\templates\admin\index.html")) { throw 'admin SPA missing' }
- name: Smoke test - start Etherpad and curl /api
shell: pwsh
env:
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
run: |
Push-Location $env:ETHERPAD_DIR
$logPath = Join-Path $env:RUNNER_TEMP 'etherpad.log'
# pnpm on Windows is a .cmd shim, which Start-Process can't run
# directly — wrap it in cmd.exe.
$proc = Start-Process -FilePath cmd.exe `
-ArgumentList '/c','pnpm','run','prod' `
-RedirectStandardOutput $logPath `
-RedirectStandardError "$logPath.err" `
-PassThru -NoNewWindow
$ok = $false
for ($i = 1; $i -le 90; $i++) {
try {
Invoke-WebRequest -UseBasicParsing -Uri http://localhost:9001/api -TimeoutSec 2 | Out-Null
Write-Host "Etherpad answered on /api after ${i}s"
$ok = $true
break
} catch { Start-Sleep -Seconds 1 }
}
if (-not $ok) {
Write-Host 'Etherpad did not start within 90s. Last 200 lines of log (stdout):'
if (Test-Path $logPath) { Get-Content $logPath -Tail 200 }
Write-Host 'Last 200 lines of stderr:'
if (Test-Path "$logPath.err") { Get-Content "$logPath.err" -Tail 200 }
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
Pop-Location
exit 1
}
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
Pop-Location

View File

@ -1,13 +1,9 @@
name: "Loadtest"
# any branch is useful for testing before a PR is submitted
on:
push:
paths-ignore:
- "doc/**"
pull_request:
paths-ignore:
- "doc/**"
schedule:
- cron: '0 8 * * *' # Daily at 08:00 UTC
workflow_dispatch: # Allow manual trigger
permissions:
contents: read

View File

@ -75,7 +75,7 @@ jobs:
with:
ruby-version: 2.7
- uses: reitzig/actions-asciidoctor@v2.0.2
- uses: reitzig/actions-asciidoctor@v2.0.4
with:
version: 2.0.18
- name: Prepare release

View File

@ -1,6 +1,7 @@
name: releaseEtherpad.yaml
permissions:
contents: read
id-token: write # for npm OIDC trusted publishing
on:
workflow_dispatch:
@ -13,6 +14,15 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
# OIDC trusted publishing needs npm >= 11.5.1, which requires
# Node >= 20.17.0. setup-node's `20` resolves to the latest
# 20.x, which satisfies that.
node-version: 20
registry-url: https://registry.npmjs.org/
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
run: npm install -g npm@latest
- name: Get pnpm store directory
shell: bash
run: |
@ -38,8 +48,12 @@ jobs:
- name: Rename etherpad
working-directory: ./src
run: sed -i 's/ep_etherpad-lite/ep_etherpad/g' package.json
- name: Release to npm
run: gnpm publish --no-git-checks
# Use `npm publish` directly (not `gnpm`/`pnpm` wrappers) because OIDC
# trusted publishing requires npm CLI >= 11.5.1 and the wrappers shell
# out to npm; calling npm directly avoids any shim ambiguity. The
# ep_etherpad package must have a trusted publisher configured on
# npmjs.com pointing at this workflow file. See:
# https://docs.npmjs.com/trusted-publishers
- name: Release to npm via OIDC
run: npm publish --provenance --access public
working-directory: ./src
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PRIVATE_TOKEN }}

116
.github/workflows/update-plugins.yml vendored Normal file
View File

@ -0,0 +1,116 @@
name: Update Plugins
on:
schedule:
- cron: '0 6 * * *' # Daily at 06:00 UTC
workflow_dispatch: # Allow manual trigger
# The cross-repo work (cloning ether/ep_* repos, pushing updates, merging
# Dependabot PRs) is authenticated via secrets.PLUGINS_PAT. The default
# GITHUB_TOKEN only needs read access to this repo for actions/checkout.
permissions:
contents: read
jobs:
update-plugins:
runs-on: ubuntu-latest
steps:
- name: Check out etherpad-lite
uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
name: Install pnpm
with:
version: 10
run_install: false
- name: Use Node.js
uses: actions/setup-node@v6
with:
node-version: 22
- name: Install bin dependencies
working-directory: ./bin
run: pnpm install
- name: Configure git
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
- name: Clone and update all plugins
env:
GH_TOKEN: ${{ secrets.PLUGINS_PAT }}
run: |
# Configure git to use the PAT for all ether/ repos
git config --global url."https://x-access-token:${GH_TOKEN}@github.com/ether/".insteadOf "https://github.com/ether/"
cd ..
# List all ep_* repos from ether org
plugins=$(gh repo list ether --limit 200 --json name --jq '.[] | select(.name | startswith("ep_")) | .name')
failed=""
succeeded=""
skipped=""
for plugin in $plugins; do
echo "============================================================"
echo "Processing $plugin"
echo "============================================================"
# Clone if not present
if [ ! -d "$plugin" ]; then
git clone "https://github.com/ether/${plugin}.git" "$plugin" || { echo "SKIP: clone failed"; skipped="$skipped $plugin"; continue; }
fi
# Pull latest
(cd "$plugin" && git pull --ff-only) || { echo "SKIP: pull failed"; skipped="$skipped $plugin"; continue; }
# Run checkPlugin with autopush — continue on failure
if cd etherpad-lite/bin && pnpm run checkPlugin "$plugin" autopush 2>&1; then
succeeded="$succeeded $plugin"
else
echo "WARN: checkPlugin failed for $plugin"
failed="$failed $plugin"
fi
cd ../..
done
echo ""
echo "============================================================"
echo "SUMMARY"
echo "============================================================"
echo "Succeeded:$(echo $succeeded | wc -w) -$succeeded"
echo "Failed:$(echo $failed | wc -w) -$failed"
echo "Skipped:$(echo $skipped | wc -w) -$skipped"
- name: Merge clean Dependabot PRs on plugin repos
env:
GH_TOKEN: ${{ secrets.PLUGINS_PAT }}
run: |
# For every ep_* repo under ether, merge any Dependabot PR whose
# mergeStateStatus is CLEAN (no conflicts, branch up to date, all
# required checks green). Anything else is left alone for a human.
plugins=$(gh repo list ether --limit 200 --json name --jq '.[] | select(.name | startswith("ep_")) | .name')
merged=""
for plugin in $plugins; do
repo="ether/${plugin}"
prs=$(gh pr list --repo "$repo" \
--author "app/dependabot" \
--state open \
--json number,mergeStateStatus,title \
--jq '.[] | select(.mergeStateStatus=="CLEAN") | .number') || continue
for pr in $prs; do
echo "Merging ${repo}#${pr}"
if gh pr merge --repo "$repo" --squash --delete-branch "$pr"; then
merged="$merged ${repo}#${pr}"
else
echo "WARN: failed to merge ${repo}#${pr}"
fi
done
done
echo ""
echo "Merged Dependabot PRs:$merged"

View File

@ -27,7 +27,8 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
# PRs: single Node version. Push: full matrix.
node: ${{ github.event_name == 'pull_request' && fromJSON('[24]') || fromJSON('[20, 22, 24]') }}
steps:
-
name: Check out latest release
@ -56,12 +57,6 @@ jobs:
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0
-
name: Install libreoffice
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0
-
name: Install all dependencies and symlink for ep_etherpad-lite
run: gnpm install --frozen-lockfile --runtimeVersion="${{ matrix.node }}"

1
.gitignore vendored
View File

@ -18,7 +18,6 @@ out/
.nyc_output
.idea
/package-lock.json
/src/bin/abiword.exe
/src/bin/convertSettings.json
/src/bin/etherpad-1.deb
/src/bin/node.exe

View File

@ -1,9 +0,0 @@
extraction:
javascript:
index:
exclude:
- src/static/js/vendors
python:
index:
exclude:
- /

8
.npmrc
View File

@ -1 +1,7 @@
auto-install-peers=false
strict-dep-builds=false
# Use hardlinks when populating node_modules instead of clone/copyfile.
# pnpm's default "auto" mode ends up using copy_file_range which fails on
# ZFS (https://github.com/pnpm/pnpm/issues/7024) and breaks `docker
# compose build` on hosts with a ZFS root (#7342). Hardlinks are fast,
# save disk, and work on every filesystem Etherpad supports.
package-import-method=hardlink

5
.pr_agent.toml Normal file
View File

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

View File

@ -1,145 +0,0 @@
language: node_js
node_js:
- "lts/*"
services:
- docker
cache: false
env:
global:
- secure: "WMGxFkOeTTlhWB+ChMucRtIqVmMbwzYdNHuHQjKCcj8HBEPdZLfCuK/kf4rG\nVLcLQiIsyllqzNhBGVHG1nyqWr0/LTm8JRqSCDDVIhpyzp9KpCJQQJG2Uwjk\n6/HIJJh/wbxsEdLNV2crYU/EiVO3A4Bq0YTHUlbhUqG3mSCr5Ec="
- secure: "gejXUAHYscbR6Bodw35XexpToqWkv2ifeECsbeEmjaLkYzXmUUNWJGknKSu7\nEUsSfQV8w+hxApr1Z+jNqk9aX3K1I4btL3cwk2trnNI8XRAvu1c1Iv60eerI\nkE82Rsd5lwUaMEh+/HoL8ztFCZamVndoNgX7HWp5J/NRZZMmh4g="
_set_loglevel_warn: &set_loglevel_warn |
sed -e 's/"loglevel":[^,]*/"loglevel": "WARN"/' \
settings.json.template >settings.json.template.new &&
mv settings.json.template.new settings.json.template
_enable_admin_tests: &enable_admin_tests |
sed -e 's/"enableAdminUITests": false/"enableAdminUITests": true,\n"users":{"admin":{"password":"changeme","is_admin":true}}/' \
settings.json.template >settings.json.template.new &&
mv settings.json.template.new settings.json.template
_install_libreoffice: &install_libreoffice >-
sudo add-apt-repository -y ppa:libreoffice/ppa &&
sudo apt-get update &&
sudo apt-get -y install libreoffice libreoffice-pdfimport
# The --legacy-peer-deps flag is required to work around a bug in npm v7:
# https://github.com/npm/cli/issues/2199
_install_plugins: &install_plugins >-
npm install --no-save --legacy-peer-deps
ep_align
ep_author_hover
ep_cursortrace
ep_font_size
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
ep_spellcheck
ep_subscript_and_superscript
ep_table_of_contents
ep_set_title_on_pad
jobs:
include:
# we can only frontend tests from the ether/ organization and not from forks.
# To request tests to be run ask a maintainer to fork your repo to ether/
- if: fork = false
name: "Test the Frontend without Plugins"
install:
- *set_loglevel_warn
- *enable_admin_tests
- "src/tests/frontend/travis/sauce_tunnel.sh"
- "bin/installDeps.sh"
- "export GIT_HASH=$(git rev-parse --verify --short HEAD)"
script:
- "./src/tests/frontend/travis/runner.sh"
- name: "Run the Backend tests without Plugins"
install:
- *install_libreoffice
- *set_loglevel_warn
- "bin/installDeps.sh"
- "cd src && pnpm install && cd -"
script:
- "cd src && pnpm test"
- name: "Test the Dockerfile"
install:
- "cd src && pnpm install && cd -"
script:
- "docker build -t etherpad:test ."
- "docker run -d -p 9001:9001 etherpad:test && sleep 3"
- "cd src && pnpm run test-container"
- name: "Load test Etherpad without Plugins"
install:
- *set_loglevel_warn
- "bin/installDeps.sh"
- "cd src && pnpm install && cd -"
- "npm install -g etherpad-load-test"
script:
- "src/tests/frontend/travis/runnerLoadTest.sh"
# we can only frontend tests from the ether/ organization and not from forks.
# To request tests to be run ask a maintainer to fork your repo to ether/
- if: fork = false
name: "Test the Frontend Plugins only"
install:
- *set_loglevel_warn
- *enable_admin_tests
- "src/tests/frontend/travis/sauce_tunnel.sh"
- "bin/installDeps.sh"
- "rm src/tests/frontend/specs/*"
- *install_plugins
- "export GIT_HASH=$(git rev-parse --verify --short HEAD)"
script:
- "./src/tests/frontend/travis/runner.sh"
- name: "Lint test package-lock.json"
install:
- "npm install lockfile-lint"
script:
- npx lockfile-lint --path src/package-lock.json --validate-https --allowed-hosts npm
- name: "Run the Backend tests with Plugins"
install:
- *install_libreoffice
- *set_loglevel_warn
- "bin/installDeps.sh"
- *install_plugins
- "cd src && pnpm install && cd -"
script:
- "cd src && pnpm test"
- name: "Test the Dockerfile"
install:
- "cd src && pnpm install && cd -"
script:
- "docker build -t etherpad:test ."
- "docker run -d -p 9001:9001 etherpad:test && sleep 3"
- "cd src && pnpm run test-container"
- name: "Load test Etherpad with Plugins"
install:
- *set_loglevel_warn
- "bin/installDeps.sh"
- *install_plugins
- "cd src && npm install && cd -"
- "npm install -g etherpad-load-test"
script:
- "src/tests/frontend/travis/runnerLoadTest.sh"
- name: "Test rate limit"
install:
- "docker network create --subnet=172.23.42.0/16 ep_net"
- "docker build -f Dockerfile -t epl-debian-slim ."
- "docker build -f src/tests/ratelimit/Dockerfile.nginx -t nginx-latest ."
- "docker build -f src/tests/ratelimit/Dockerfile.anotherip -t anotherip ."
- "docker run -p 8081:80 --rm --network ep_net --ip 172.23.42.1 -d nginx-latest"
- "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 --rm --network ep_net --ip 172.23.42.3 --name anotherip -dt anotherip"
- "./bin/installDeps.sh"
script:
- "cd src/tests/ratelimit && bash testlimits.sh"
notifications:
irc:
channels:
- "irc.freenode.org#etherpad-lite-dev"

207
AGENTS.MD Normal file
View File

@ -0,0 +1,207 @@
# Agent Guide - Etherpad
Welcome to the Etherpad project. This guide provides essential context and instructions for AI agents and developers to effectively contribute to the codebase.
## Project Overview
Etherpad is a real-time collaborative editor designed to be lightweight, scalable, and highly extensible via plugins.
## Technical Stack
- **Runtime:** Node.js >= 20.0.0
- **Package Manager:** pnpm (>= 8.3.0)
- **Languages:** TypeScript (primary for new code), JavaScript (legacy), CSS, HTML
- **Backend:** Express.js 5, Socket.io 4
- **Frontend:** Legacy core (`src/static`), Modern React UI (`ui/`), Admin UI (`admin/`)
- **Database:** ueberdb2 abstraction (supports dirtyDB, MySQL, PostgreSQL, Redis)
- **Build Tools:** Vite (for `ui` and `admin`), esbuild, tsx
- **Testing:** Mocha (backend), Playwright (frontend E2E), Vitest (unit)
- **Auth:** JWT (jose library), OIDC provider
## Directory Structure
- `src/node/` - Backend logic, API handlers, database models, hooks
- `src/static/` - Core frontend logic (legacy jQuery-based editor)
- `src/static/js/pluginfw/` - Plugin framework (installer, hook system)
- `src/tests/` - Test suites (backend, frontend, container)
- `ui/` - Modern React OIDC login UI (Vite + TypeScript)
- `admin/` - Modern React admin panel (Vite + TypeScript + Radix UI)
- `bin/` - CLI utilities, build scripts, plugin management tools
- `bin/plugins/` - Plugin maintenance scripts (checkPlugin.ts, updateCorePlugins.sh)
- `doc/` - Documentation (VitePress + Markdown/AsciiDoc)
- `local_plugins/` - Directory for developing and testing plugins locally
- `var/` - Runtime data (logs, dirtyDB, etc. - ignored by git)
## Quick Start
```bash
pnpm install # Install all dependencies
pnpm run build:etherpad # Build admin UI and static assets
pnpm --filter ep_etherpad-lite run dev # Start dev server (port 9001)
pnpm --filter ep_etherpad-lite run prod # Start production server
```
## Core Mandates & Conventions
### Coding Style
- **Indentation:** 2 spaces for all files (JS/TS/CSS/HTML). No tabs.
- **TypeScript:** All new code should be TypeScript. Strict mode is enabled.
- **Comments:** Provide clear comments for complex logic only.
- **Backward Compatibility:** Always ensure compatibility with older versions of the database and configuration files.
### 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`.
- **Feature Flags:** New features should be placed behind feature flags and disabled by default.
- **Deprecation:** Never remove features abruptly; deprecate them first with a `WARN` log.
- **Forks:** For etherpad-lite changes, commit to `johnmclear/etherpad-lite` fork on a new branch, then PR to `ether/etherpad`. For plugins (`ep_*` repos), committing directly is acceptable.
### Testing & Validation
- **Requirement:** Every bug fix MUST include a regression test in the same commit.
- **Always run tests locally before pushing to CI.**
- **Linting:** `pnpm run lint`
- **Type Check:** `pnpm --filter ep_etherpad-lite run ts-check`
- **Build:** `pnpm run build:etherpad` before production deployment
#### Running Backend Tests Locally
Backend tests use Mocha with tsx and run against a real server instance (started automatically by the test harness). No separate server process is needed.
```bash
# Run ALL backend tests (includes plugin tests)
pnpm --filter ep_etherpad-lite run test
# Run only utility tests (faster, ~5s timeout)
pnpm --filter ep_etherpad-lite run test-utils
# Run a single test file directly
cd src && cross-env NODE_ENV=production npx mocha --import=tsx --timeout 120000 tests/backend/specs/YOUR_TEST.ts
# Run unit tests (Vitest)
cd src && npx vitest
```
- Tests run with `NODE_ENV=production`.
- Default timeout is 120 seconds per test.
- Test files live in `src/tests/backend/specs/`.
- Plugin backend tests live in `node_modules/ep_*/static/tests/backend/specs/` (at repo root) and are included automatically by the test script.
#### Running Frontend E2E Tests Locally
Frontend tests use Playwright. **You must have a running Etherpad server** before launching them — the Playwright config does not auto-start the server.
**Before running frontend or admin tests, ensure Playwright browsers are installed.** Check and install if needed:
```bash
# Check which browsers are installed
cd src && npx playwright install --dry-run
# Install all browsers and their system dependencies (must run from src/)
cd src && npx playwright install
cd src && sudo npx playwright install-deps
```
If `sudo` is unavailable, install system dependencies for webkit manually:
```bash
# Check which system libraries are missing for webkit
ldd ~/.cache/ms-playwright/webkit-*/minibrowser-wpe/MiniBrowser 2>&1 | grep "not found"
```
If browsers or system dependencies are missing, tests will fail silently or timeout — **always verify browser installation before debugging test failures.**
```bash
# 1. Start the dev server in a separate terminal
pnpm --filter ep_etherpad-lite run dev
# 2. Run frontend E2E tests
pnpm --filter ep_etherpad-lite run test-ui
# 3. Run with interactive Playwright UI (useful for debugging)
pnpm --filter ep_etherpad-lite run test-ui:ui
# Run a single test file
cd src && cross-env NODE_ENV=production npx playwright test tests/frontend-new/specs/YOUR_TEST.spec.ts
```
- Tests expect the server at `localhost:9001`.
- Test files live in `src/tests/frontend-new/specs/`.
- Runs against chromium and firefox by default (webkit is disabled).
- Playwright config is at `src/playwright.config.ts`.
#### Running Admin Panel Tests Locally
```bash
# Requires a running server and Playwright browsers installed (same as frontend tests)
pnpm --filter ep_etherpad-lite run test-admin
# Interactive UI mode
pnpm --filter ep_etherpad-lite run test-admin:ui
```
- Admin tests run with `--workers 1` (sequential) on chromium and firefox only.
- Test files live in `src/tests/frontend-new/admin-spec/`.
### Backend Test Auth
Tests use JWT authentication, not API keys. Pattern:
```typescript
import * as common from 'ep_etherpad-lite/tests/backend/common';
const agent = await common.init(); // Starts server, returns supertest agent
const token = await common.generateJWTToken();
agent.get('/api/1/endpoint').set('authorization', token);
```
Do not use `APIKEY.txt` — it may not exist in the test environment.
## Key Concepts
### Easysync
The real-time synchronization engine. It is complex; refer to `doc/public/easysync/` before modifying core synchronization logic.
### Plugin Framework
Most functionality should be implemented as plugins (`ep_*`). Avoid modifying the core unless absolutely necessary.
**Plugin structure:**
```
ep_myplugin/
├── ep.json # Hook declarations (server_hooks, client_hooks)
├── index.js # Server-side hook implementations
├── package.json
├── static/
│ ├── js/ # Client-side code
│ ├── css/
│ └── tests/
│ ├── backend/specs/ # Backend tests (Mocha)
│ └── frontend-new/ # Frontend tests (Playwright)
├── templates/ # EJS templates
└── locales/ # i18n files
```
**Plugin management:**
```bash
pnpm run plugins i ep_plugin_name # Install from npm
pnpm run plugins i --path ../plugin # Install from local path
pnpm run plugins rm ep_plugin_name # Remove
pnpm run plugins ls # List installed
```
**Plugin installation internals:** Plugins are installed to `src/plugin_packages/` via `live-plugin-manager`, which stores them at `src/plugin_packages/.versions/ep_name@version/`. Symlinks are created: `src/node_modules/ep_name` → `src/plugin_packages/ep_name` → `.versions/ep_name@ver/`.
### Plugin Repositories
- **Monorepo:** `ether/ether-plugins` contains 80+ plugins with shared CI/publishing
- **Standalone repos:** Individual `ether/ep_*` repos still exist for many plugins
- **Plugin CI templates:** `bin/plugins/lib/` contains workflow templates pushed to standalone plugin repos via `checkPlugin.ts`
- **Shared pipelines:** `ether/ether-pipelines` contains reusable GitHub Actions workflows for plugin CI
### Settings
Configured via `settings.json`. A template is available at `settings.json.template`. Environment variables can override any setting using `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`.
## Monorepo Structure
This project uses pnpm workspaces. The workspaces are:
- `src/` - Core Etherpad (package: `ep_etherpad-lite`)
- `bin/` - CLI tools and plugin scripts
- `ui/` - Login UI
- `admin/` - Admin panel
- `doc/` - Documentation
Root-level commands operate across all workspaces. Use `pnpm --filter <package>` to target specific workspaces.
## AI-Specific Guidance
AI/Agent contributions are explicitly welcomed by the maintainers, provided they strictly adhere to the guidelines in `CONTRIBUTING.md` and this guide. Always prioritize stability, readability, and compatibility.

View File

@ -1,3 +1,51 @@
# 2.7.0
### Breaking changes
- **Abiword has been replaced with LibreOffice for document import/export.** If you were using Abiword for DOCX/ODT/PDF conversion, update your `settings.json` to point `soffice` at your LibreOffice binary. DOCX export is now supported out of the box.
### Notable enhancements
- Added line numbers to the timeslider so you can follow along with specific lines while replaying a pad's history.
- Added a playback speed setting to the timeslider — you can now scrub through history faster (or slower) than real time.
- Creator-owned pad settings defaults: the user who creates a pad now seeds its default settings, giving pad creators more control over initial configuration.
- Cookie names are now configurable via a prefix setting. Useful when running multiple Etherpads on the same domain and you need to keep their session cookies from colliding.
- Added a new `aceRegisterLineAttributes` hook so plugins can preserve custom line attributes across Enter / line-split operations. Documentation for the hook is included.
- Added a one-line installer script for getting Etherpad running quickly on a fresh machine.
- Docker images are now published to GitHub Container Registry (GHCR) in addition to Docker Hub.
- npm publishing of core and plugins has been migrated to OIDC trusted publishing for stronger supply-chain security.
### Notable fixes
- Database drivers are now bundled with Etherpad again, so fresh installs no longer fail to connect to Postgres, MySQL, and friends out of the box. A regression test has been added to prevent this from breaking again.
- Pending changesets are now flushed immediately after a reconnect instead of being silently dropped, and users are warned when a pending edit is not accepted by the server.
- Head revision and atext are now captured atomically, preventing the occasional "mismatched apply" errors on busy pads.
- Clearing authorship colors can now be undone without forcing a client disconnect.
- Added periodic cleanup of expired/stale sessions from the database, and fixed a race condition in the session cleanup timeout.
- Error messages returned to clients are now sanitized by default with deduplication, so internal details no longer leak through error responses.
- Raised the maximum socket.io message size to 10 MB so large pastes no longer get rejected.
- Dev mode entrypoint paths now respect the `x-proxy-path` header, fixing reverse-proxy setups in development.
- Numerous list-related fixes: numbered list wrapped lines now indent correctly, ordered list numbering is preserved across bullet interruptions during export, consecutive numbering survives indented sub-bullets, switching from unordered to ordered resets numbering, and line attributes are preserved across drag-and-drop.
- Bold (and other) formatting is now retained after copy-paste.
- Dead-key / compose-key input no longer eats the preceding space.
- `POST` API requests with a JSON body no longer time out.
- `appendText` now correctly attributes the new text to the specified author.
- `createDiffHTML` no longer fails with `Not a changeset: undefined`.
- Added `padId` to the `padUpdate` / `padCreate` hook context.
- Fixed `numConnectedUsers` to include the joining user in its count.
- Accessibility improvements: keyboard trap fix, better screen reader support, and `aria-live` announcements.
- RTL URL parameter `rtl=false` now correctly disables RTL mode.
- Language dropdown is now sorted alphabetically by native name.
- PageDown now advances the caret by a full page of lines.
- ESM/CJS interop issues in the Settings module that had been breaking plugin compatibility have been resolved, with setters added to the CJS compatibility layer and regression tests in place.
- Several Docker build fixes: git submodule handling, `hardlink` package-import-method for ZFS, and production-only workspace config.
### Other
- Many occurrences of "etherpad-lite" have been renamed to "etherpad" across the codebase and documentation.
- Pinned 33 transitive dependencies to patched versions to clear out Dependabot security alerts.
- Restricted `GITHUB_TOKEN` permissions in the update-plugins workflow.
# 2.6.1
For those wondering where the new updates are and why it was very quite throughout the last 1 1/2 years I've been working on a new implementation of Etherpad from scratch in Go. It's called Etherpad-Go and you can find it here: `https://github.com/ether/etherpad-go`

View File

@ -1,5 +1,7 @@
# Contributor Guidelines
(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad-lite#get-in-touch))
(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad#get-in-touch))
**We have decided that LLM/Agent/AI contributions are fine as long as they are within the instructions set out by this document.**
## Pull requests
@ -81,7 +83,7 @@ Also, keep it maintainable. We don't wanna end up as the monster Etherpad was!
## Coding style
* Do write comments. (You don't have to comment every line, but if you come up with something that's a bit complex/weird, just leave a comment. Bear in mind that you will probably leave the project at some point and that other people will read your code. Undocumented huge amounts of code are worthless!)
* Never ever use tabs
* Indentation: JS/CSS: 2 spaces; HTML: 4 spaces
* Indentation: 2 spaces
* Don't overengineer. Don't try to solve any possible problem in one step, but try to solve problems as easy as possible and improve the solution over time!
* Do generalize sooner or later! (if an old solution, quickly hacked together, poses more problems than it solves today, refactor it!)
* Keep it compatible. Do not introduce changes to the public API, db schema or configurations too lightly. Don't make incompatible changes without good reasons!
@ -138,5 +140,5 @@ Etherpad is much more than software. So if you aren't a developer then worry no
* Write proposals for grants
* Co-Author and Publish CVEs
* Work with SFC to maintain legal side of project
* Maintain TODO page - https://github.com/ether/etherpad-lite/wiki/TODO#IMPORTANT_TODOS
* Maintain TODO page - https://github.com/ether/etherpad/wiki/TODO#IMPORTANT_TODOS

View File

@ -1,12 +1,16 @@
# Etherpad Lite Dockerfile
# Etherpad Dockerfile
#
# https://github.com/ether/etherpad-lite
# https://github.com/ether/etherpad
#
# Author: muxator
# Set to "copy" for builds without git metadata (source tarballs, some CI):
# docker build --build-arg BUILD_ENV=copy .
ARG BUILD_ENV=git
ARG PnpmVersion=10.28.2
FROM node:lts-alpine AS adminbuild
RUN npm install -g pnpm@latest
RUN npm install -g pnpm@${PnpmVersion}
WORKDIR /opt/etherpad-lite
COPY . .
RUN pnpm install
@ -14,7 +18,7 @@ RUN pnpm run build:ui
FROM node:lts-alpine AS build
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad-lite"
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
# Set these arguments when building the image from behind a proxy
ARG http_proxy=
@ -58,15 +62,7 @@ ARG ETHERPAD_LOCAL_PLUGINS=
# ETHERPAD_GITHUB_PLUGINS="ether/ep_plugin"
ARG ETHERPAD_GITHUB_PLUGINS=
# Control whether abiword will be installed, enabling exports to DOC/PDF/ODT formats.
# By default, it is not installed.
# If given any value, abiword will be installed.
#
# EXAMPLE:
# INSTALL_ABIWORD=true
ARG INSTALL_ABIWORD=
# Control whether libreoffice will be installed, enabling exports to DOC/PDF/ODT formats.
# Control whether libreoffice will be installed, enabling exports to DOC/DOCX/PDF/ODT formats.
# By default, it is not installed.
# If given any value, libreoffice will be installed.
#
@ -100,13 +96,12 @@ RUN mkdir -p "${EP_DIR}" && chown etherpad:etherpad "${EP_DIR}"
# https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=863199
RUN \
mkdir -p /usr/share/man/man1 && \
npm install pnpm@latest -g && \
npm install pnpm@${PnpmVersion} -g && \
apk update && apk upgrade && \
apk add --no-cache \
ca-certificates \
curl \
git \
${INSTALL_ABIWORD:+abiword abiword-plugin-command} \
${INSTALL_SOFFICE:+libreoffice openjdk8-jre libreoffice-common} && \
rm -rf /var/cache/apk/*
@ -123,8 +118,13 @@ COPY --chown=etherpad:etherpad ./pnpm-workspace.yaml ./package.json ./
FROM build AS build_git
ONBUILD COPY --chown=etherpad:etherpad ./.git/HEA[D] ./.git/HEAD
ONBUILD COPY --chown=etherpad:etherpad ./.git/ref[s] ./.git/refs
# When checked out as a git submodule, .git is a file (gitlink) instead of a
# directory, so .git/HEAD and .git/refs do not exist. Copy the whole .git
# entry (the .dockerignore already strips the heavy objects) and normalise it
# with a shell step so the build succeeds in both cases and across builders
# (Docker, buildah, podman). See #6663 and containers/buildah#5742.
ONBUILD COPY --chown=etherpad:etherpad ./.git ./.git
ONBUILD RUN if [ -f .git ]; then rm .git; fi
FROM build AS build_copy
@ -139,7 +139,7 @@ ARG ETHERPAD_LOCAL_PLUGINS_ENV=
ARG ETHERPAD_GITHUB_PLUGINS=
COPY --chown=etherpad:etherpad ./src/ ./src/
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/ templates/admin./src/templates/admin
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/templates/admin ./src/templates/admin
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc
COPY --chown=etherpad:etherpad ./local_plugin[s] ./local_plugins/
@ -162,6 +162,11 @@ ARG ETHERPAD_GITHUB_PLUGINS=
ENV NODE_ENV=production
ENV ETHERPAD_PRODUCTION=true
# The full pnpm-workspace.yaml references admin, doc, ui which are not
# needed at runtime. Overwrite it with a production-only version so
# pnpm install doesn't warn about missing workspace directories.
RUN printf 'packages:\n - src\n - bin\n' > pnpm-workspace.yaml
COPY --chown=etherpad:etherpad ./src ./src
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/templates/admin ./src/templates/admin
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc

112
README.md
View File

@ -1,37 +1,45 @@
# Etherpad: A real-time collaborative editor for the web
# Etherpad — the editor for documents that matter
> Real-time collaborative editing where authorship is the default, your server is the only server, and you decide what AI (if any) ever touches your text.
![Demo Etherpad Animated Jif](doc/public/etherpad_demo.gif "Etherpad in action")
## About
Etherpad is a real-time collaborative editor [scalable to thousands of
simultaneous real time users](http://scale.etherpad.org/). It provides [full
data
export](https://github.com/ether/etherpad-lite/wiki/Understanding-Etherpad's-Full-Data-Export-capabilities)
capabilities, and runs on _your_ server, under _your_ control.
**Etherpad is a real-time collaborative editor for documents that matter.**
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.
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.
[Full data export](https://github.com/ether/etherpad/wiki/Understanding-Etherpad's-Full-Data-Export-capabilities) is built in. The history is yours.
## Try it out
Wikimedia provide a [public Etherpad instance for you to Try Etherpad out.](https://etherpad.wikimedia.org) or [use another public Etherpad instance to see other features](https://github.com/ether/etherpad-lite/wiki/Sites-That-Run-Etherpad#sites-that-run-etherpad)
[Try out a public Etherpad instance](https://github.com/ether/etherpad/wiki/Sites-That-Run-Etherpad#sites-that-run-etherpad)
## Project Status
We're looking for maintainers and have some funding available. Please contact John McLear if you can help.
Etherpad has been doing the same thing — well — since 2009. No pivots, no acquisitions, no enshittification. Maintained by a small volunteer team.
**We are actively looking for maintainers.** If you have experience with Node.js, real-time systems, or institutional collaboration tooling and you want to work on infrastructure that thousands of organisations quietly depend on, please [open an issue](https://github.com/ether/etherpad/issues) or contact [John McLear](https://github.com/JohnMcLear).
### Code Quality
[![Code Quality](https://github.com/ether/etherpad-lite/actions/workflows/codeql-analysis.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/codeql-analysis.yml)
[![Code Quality](https://github.com/ether/etherpad/actions/workflows/codeql-analysis.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad/actions/workflows/codeql-analysis.yml)
### Testing
[![Backend tests](https://github.com/ether/etherpad-lite/actions/workflows/backend-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/backend-tests.yml)
[![Simulated Load](https://github.com/ether/etherpad-lite/actions/workflows/load-test.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/load-test.yml)
[![Rate Limit](https://github.com/ether/etherpad-lite/actions/workflows/rate-limit.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/rate-limit.yml)
[![Docker file](https://github.com/ether/etherpad-lite/actions/workflows/dockerfile.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/dockerfile.yml)
[![Frontend admin tests powered by Sauce Labs](https://github.com/ether/etherpad-lite/actions/workflows/frontend-admin-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/frontend-admin-tests.yml)
[![Frontend tests powered by Sauce Labs](https://github.com/ether/etherpad-lite/actions/workflows/frontend-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/frontend-tests.yml)
[![Backend tests](https://github.com/ether/etherpad/actions/workflows/backend-tests.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad/actions/workflows/backend-tests.yml)
[![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-lite/actions/workflows/windows.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad-lite/actions/workflows/windows.yml)
[![Windows Build](https://github.com/ether/etherpad/actions/workflows/windows.yml/badge.svg?color=%2344b492)](https://github.com/ether/etherpad/actions/workflows/windows.yml)
### Engagement
@ -41,15 +49,67 @@ We're looking for maintainers and have some funding available. Please contact J
![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)
## Who uses Etherpad
For more than a decade, Etherpad has quietly underpinned the documents that matter to:
- **Wikimedia Foundation** — collaborative drafting across editor communities.
- **Public-sector institutions across the EU** — including organisations that legally cannot use US-cloud SaaS for sovereignty and GDPR reasons.
- **Universities and schools worldwide** — including jurisdictions where Google Workspace is no longer permitted in education.
- **Civic-tech and democratic-deliberation projects** — citizen assemblies, participatory budgeting, public consultations.
- **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).
## Installation
### Quick install (one-liner)
The fastest way to get Etherpad running. Requires `git` and Node.js >= 20.
**macOS / Linux / WSL:**
```sh
curl -fsSL https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.sh | sh
```
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.ps1 | iex
```
Both installers clone Etherpad into `./etherpad-lite`, install dependencies, and
build the frontend. When the installer finishes, run:
```sh
cd etherpad-lite && pnpm run prod
```
Then open <http://localhost:9001>.
To install and start in one go:
```sh
# macOS / Linux / WSL
ETHERPAD_RUN=1 sh -c "$(curl -fsSL https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.sh)"
```
```powershell
# Windows
$env:ETHERPAD_RUN=1; irm https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.ps1 | iex
```
### Docker-Compose
The official image is published to both Docker Hub (`etherpad/etherpad`) and GitHub Container Registry (`ghcr.io/ether/etherpad`) with identical tags. Use whichever suits your environment; GHCR avoids Docker Hub's anonymous pull rate limits.
```yaml
services:
app:
user: "0:0"
image: etherpad/etherpad:latest
image: etherpad/etherpad:latest # or: ghcr.io/ether/etherpad:latest
tty: true
stdin_open: true
volumes:
@ -100,7 +160,7 @@ volumes:
### Requirements
[Node.js](https://nodejs.org/) >= **18.18.2**.
[Node.js](https://nodejs.org/) >= 20.
### Windows, macOS, Linux
@ -142,7 +202,7 @@ pnpm run plugins i ep_${plugin_name}
```
Also see [the plugin wiki
article](https://github.com/ether/etherpad-lite/wiki/Available-Plugins).
article](https://github.com/ether/etherpad/wiki/Available-Plugins).
### Suggested Plugins
@ -239,7 +299,7 @@ playing!
## Helpful resources
The [wiki](https://github.com/ether/etherpad-lite/wiki) is your one-stop
The [wiki](https://github.com/ether/etherpad/wiki) is your one-stop
resource for Tutorials and How-to's.
Documentation can be found in `doc/`.
@ -257,21 +317,21 @@ dependency or upgrading version.
If you want to find out how Etherpad's `Easysync` works (the library that makes
it really realtime), start with this
[PDF](https://github.com/ether/etherpad-lite/raw/master/doc/easysync/easysync-full-description.pdf)
[PDF](https://github.com/ether/etherpad/raw/master/doc/easysync/easysync-full-description.pdf)
(complex, but worth reading).
### Contributing
Read our [**Developer
Guidelines**](https://github.com/ether/etherpad-lite/blob/master/CONTRIBUTING.md)
Guidelines**](https://github.com/ether/etherpad/blob/master/CONTRIBUTING.md)
### HTTP API
Etherpad is designed to be easily embeddable and provides a [HTTP
API](https://github.com/ether/etherpad-lite/wiki/HTTP-API) that allows your web
API](https://github.com/ether/etherpad/wiki/HTTP-API) that allows your web
application to manage pads, users and groups. It is recommended to use the
[available client
implementations](https://github.com/ether/etherpad-lite/wiki/HTTP-API-client-libraries)
implementations](https://github.com/ether/etherpad/wiki/HTTP-API-client-libraries)
in order to interact with this API.
OpenAPI (previously swagger) definitions for the API are exposed under
@ -299,12 +359,12 @@ send pull request to each plugin individually.
## FAQ
Visit the **[FAQ](https://github.com/ether/etherpad-lite/wiki/FAQ)**.
Visit the **[FAQ](https://github.com/ether/etherpad/wiki/FAQ)**.
## Get in touch
The official channel for contacting the development team is via the [GitHub
issues](https://github.com/ether/etherpad-lite/issues).
issues](https://github.com/ether/etherpad/issues).
For **responsible disclosure of vulnerabilities**, please write a mail to the
maintainers (a.mux@inwind.it and contact@etherpad.org).

View File

@ -1,7 +1,7 @@
{
"name": "admin",
"private": true,
"version": "2.6.1",
"version": "2.7.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -16,29 +16,29 @@
"devDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-toast": "^1.2.15",
"@types/react": "^19.2.9",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"@vitejs/plugin-react": "^5.1.2",
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@typescript-eslint/parser": "^8.58.2",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"i18next": "^25.8.0",
"i18next-browser-languagedetector": "^8.2.0",
"lucide-react": "^0.563.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-hook-form": "^7.71.1",
"react-i18next": "^16.5.3",
"react-router-dom": "^7.13.0",
"eslint": "^10.2.0",
"eslint-plugin-react-hooks": "^7.1.0",
"eslint-plugin-react-refresh": "^0.5.2",
"i18next": "^26.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.72.1",
"react-i18next": "^17.0.4",
"react-router-dom": "^7.14.1",
"socket.io-client": "^4.8.3",
"typescript": "^5.9.3",
"typescript": "^6.0.3",
"vite": "npm:rolldown-vite@7.2.10",
"vite-plugin-babel": "^1.4.1",
"vite-plugin-static-copy": "^3.1.6",
"zustand": "^5.0.10"
"vite-plugin-babel": "^1.6.0",
"vite-plugin-static-copy": "^4.0.1",
"zustand": "^5.0.12"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.10"

View File

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

View File

@ -1,14 +1,14 @@
import {ChangeEventHandler, FC} from "react";
import {Search} from 'lucide-react'
export type SearchFieldProps = {
value: string,
onChange: ChangeEventHandler<HTMLInputElement>,
placeholder?: string
value: string,
onChange: ChangeEventHandler<HTMLInputElement>,
placeholder?: string
}
export const SearchField:FC<SearchFieldProps> = ({onChange,value, placeholder})=>{
return <span className="search-field">
<input value={value} onChange={onChange} placeholder={placeholder}/>
<Search/>
</span>
return <span className="search-field">
<input value={value} onChange={onChange} placeholder={placeholder}/>
<Search/>
</span>
}

View File

@ -1,13 +1,13 @@
export type ShoutType = {
type: string,
data:{
type: string,
data:{
type: string,
payload: {
message: {
message: string,
sticky: boolean
},
timestamp: number
}
payload: {
message: {
message: string,
sticky: boolean
},
timestamp: number
}
}
}

View File

@ -6,52 +6,50 @@ import LanguageDetector from 'i18next-browser-languagedetector'
import { BackendModule } from 'i18next';
const LazyImportPlugin: BackendModule = {
type: 'backend',
init: function () {
},
read: async function (language, namespace, callback) {
type: 'backend',
init: function () {
},
read: async function (language, namespace, callback) {
let baseURL = import.meta.env.BASE_URL
if(namespace === "translation") {
// If default we load the translation file
baseURL+=`/locales/${language}.json`
} else {
// Else we load the former plugin translation file
baseURL+=`/${namespace}/${language}.json`
}
let baseURL = import.meta.env.BASE_URL
if(namespace === "translation") {
// If default we load the translation file
baseURL+=`/locales/${language}.json`
} else {
// Else we load the former plugin translation file
baseURL+=`/${namespace}/${language}.json`
}
const localeJSON = await fetch(baseURL, {
cache: "force-cache"
})
let json;
const localeJSON = await fetch(baseURL)
let json;
try {
json = JSON.parse(await localeJSON.text())
} catch(e) {
callback(new Error("Error loading"), null);
}
try {
json = JSON.parse(await localeJSON.text())
} catch(e) {
callback(new Error("Error loading"), null);
}
callback(null, json);
},
callback(null, json);
},
save: function () {
},
save: function () {
},
create: function () {
/* save the missing translation */
},
create: function () {
/* save the missing translation */
},
};
i18n
.use(LanguageDetector)
.use(LazyImportPlugin)
.use(initReactI18next)
.init(
{
ns: ['translation','ep_admin_pads'],
fallbackLng: 'en'
}
)
.use(LanguageDetector)
.use(LazyImportPlugin)
.use(initReactI18next)
.init(
{
ns: ['translation','ep_admin_pads'],
fallbackLng: 'en'
}
)
export default i18n

View File

@ -4,67 +4,67 @@ import {useEffect, useState} from "react";
import {HelpObj} from "./Plugin.ts";
export const HelpPage = () => {
const settingsSocket = useStore(state=>state.settingsSocket)
const [helpData, setHelpData] = useState<HelpObj>();
const settingsSocket = useStore(state=>state.settingsSocket)
const [helpData, setHelpData] = useState<HelpObj>();
useEffect(() => {
if(!settingsSocket) return;
settingsSocket?.on('reply:help', (data) => {
setHelpData(data)
});
useEffect(() => {
if(!settingsSocket) return;
settingsSocket?.on('reply:help', (data) => {
setHelpData(data)
});
settingsSocket?.emit('help');
}, [settingsSocket]);
settingsSocket?.emit('help');
}, [settingsSocket]);
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 renderHooks = (hooks:Record<string, Record<string, string>>) => {
return Object.keys(hooks).map((hookName, i) => {
return <div key={hookName+i}>
<h3>{hookName}</h3>
<ul>
{Object.keys(hooks[hookName]).map((hook, i) => <li key={hook+i}>{hook}
<ul key={hookName+hook+i}>
{Object.keys(hooks[hookName][hook]).map((subHook, i) => <li key={i}>{subHook}</li>)}
</ul>
</li>)}
</ul>
</div>
})
}
if (!helpData) return <div></div>
return <div>
<h1><Trans i18nKey="admin_plugins_info.version"/></h1>
<div className="help-block">
<div><Trans i18nKey="admin_plugins_info.version_number"/></div>
<div>{helpData?.epVersion}</div>
<div><Trans i18nKey="admin_plugins_info.version_latest"/></div>
<div>{helpData.latestVersion}</div>
<div>Git sha</div>
<div>{helpData.gitCommit}</div>
</div>
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
<ul>
{helpData.installedPlugins.map((plugin, i) => <li key={plugin+i}>{plugin}</li>)}
</ul>
<h2><Trans i18nKey="admin_plugins_info.parts"/></h2>
<ul>
{helpData.installedParts.map((part, i) => <li key={part+i}>{part}</li>)}
</ul>
<h2><Trans i18nKey="admin_plugins_info.hooks"/></h2>
{
renderHooks(helpData.installedServerHooks)
}
<h2>
<Trans i18nKey="admin_plugins_info.hooks_client"/>
{
renderHooks(helpData.installedClientHooks)
}
</h2>
if (!helpData) return <div></div>
return <div>
<h1><Trans i18nKey="admin_plugins_info.version"/></h1>
<div className="help-block">
<div><Trans i18nKey="admin_plugins_info.version_number"/></div>
<div>{helpData?.epVersion}</div>
<div><Trans i18nKey="admin_plugins_info.version_latest"/></div>
<div>{helpData.latestVersion}</div>
<div>Git sha</div>
<div>{helpData.gitCommit}</div>
</div>
<h2><Trans i18nKey="admin_plugins.installed"/></h2>
<ul>
{helpData.installedPlugins.map((plugin, i) => <li key={plugin+i}>{plugin}</li>)}
</ul>
<h2><Trans i18nKey="admin_plugins_info.parts"/></h2>
<ul>
{helpData.installedParts.map((part, i) => <li key={part+i}>{part}</li>)}
</ul>
<h2><Trans i18nKey="admin_plugins_info.hooks"/></h2>
{
renderHooks(helpData.installedServerHooks)
}
<h2>
<Trans i18nKey="admin_plugins_info.hooks_client"/>
{
renderHooks(helpData.installedClientHooks)
}
</h2>
</div>
</div>
}

View File

@ -5,57 +5,57 @@ import {Eye, EyeOff} from "lucide-react";
import {useState} from "react";
type Inputs = {
username: string
password: string
username: string
password: string
}
export const LoginScreen = ()=>{
const navigate = useNavigate()
const [passwordVisible, setPasswordVisible] = useState<boolean>(false)
const navigate = useNavigate()
const [passwordVisible, setPasswordVisible] = useState<boolean>(false)
const {
register,
handleSubmit} = useForm<Inputs>()
const {
register,
handleSubmit} = useForm<Inputs>()
const login: SubmitHandler<Inputs> = ({username,password})=>{
fetch('/admin-auth/', {
method: 'POST',
headers:{
Authorization: `Basic ${btoa(`${username}:${password}`)}`
}
}).then(r=>{
if(!r.ok) {
useStore.getState().setToastState({
open: true,
title: "Login failed",
success: false
})
} else {
navigate('/')
}
}).catch(e=>{
console.error(e)
const login: SubmitHandler<Inputs> = ({username,password})=>{
fetch('/admin-auth/', {
method: 'POST',
headers:{
Authorization: `Basic ${btoa(`${username}:${password}`)}`
}
}).then(r=>{
if(!r.ok) {
useStore.getState().setToastState({
open: true,
title: "Login failed",
success: false
})
}
} else {
navigate('/')
}
}).catch(e=>{
console.error(e)
})
}
return <div className="login-background login-page">
<div className="login-box login-form">
<h1 className="login-title">Etherpad</h1>
<form className="login-inner-box input-control" onSubmit={handleSubmit(login)}>
<div>Username</div>
<input {...register('username', {
required: true
})} className="login-textinput input-control" type="text" placeholder="Username"/>
<div>Password</div>
<span className="icon-input">
<input {...register('password', {
required: true
})} className="login-textinput" type={passwordVisible?"text":"password"} placeholder="Password"/>
{passwordVisible? <Eye onClick={()=>setPasswordVisible(!passwordVisible)}/> :
<EyeOff onClick={()=>setPasswordVisible(!passwordVisible)}/>}
</span>
<input type="submit" value="Login" className="login-button"/>
</form>
</div>
return <div className="login-background login-page">
<div className="login-box login-form">
<h1 className="login-title">Etherpad</h1>
<form className="login-inner-box input-control" onSubmit={handleSubmit(login)}>
<div>Username</div>
<input {...register('username', {
required: true
})} className="login-textinput input-control" type="text" placeholder="Username"/>
<div>Password</div>
<span className="icon-input">
<input {...register('password', {
required: true
})} className="login-textinput" type={passwordVisible?"text":"password"} placeholder="Password"/>
{passwordVisible? <Eye onClick={()=>setPasswordVisible(!passwordVisible)}/> :
<EyeOff onClick={()=>setPasswordVisible(!passwordVisible)}/>}
</span>
<input type="submit" value="Login" className="login-button"/>
</form>
</div>
</div>
}

View File

@ -11,274 +11,274 @@ import {SearchField} from "../components/SearchField.tsx";
import {useForm} from "react-hook-form";
type PadCreateProps = {
padName: string
padName: string
}
export const PadPage = ()=>{
const settingsSocket = useStore(state=>state.settingsSocket)
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
offset: 0,
limit: 12,
pattern: '',
sortBy: 'padName',
ascending: true
const settingsSocket = useStore(state=>state.settingsSocket)
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
offset: 0,
limit: 12,
pattern: '',
sortBy: 'padName',
ascending: true
})
const {t} = useTranslation()
const [searchTerm, setSearchTerm] = useState<string>('')
const pads = useStore(state=>state.pads)
const [currentPage, setCurrentPage] = useState<number>(0)
const [deleteDialog, setDeleteDialog] = useState<boolean>(false)
const [errorText, setErrorText] = useState<string|null>(null)
const [padToDelete, setPadToDelete] = useState<string>('')
const [createPadDialogOpen, setCreatePadDialogOpen] = useState<boolean>(false)
const {register, handleSubmit} = useForm<PadCreateProps>()
const pages = useMemo(()=>{
if(!pads){
return 0;
}
return Math.ceil(pads!.total / searchParams.limit)
},[pads, searchParams.limit])
useDebounce(()=>{
setSearchParams({
...searchParams,
pattern: searchTerm
})
const {t} = useTranslation()
const [searchTerm, setSearchTerm] = useState<string>('')
const pads = useStore(state=>state.pads)
const [currentPage, setCurrentPage] = useState<number>(0)
const [deleteDialog, setDeleteDialog] = useState<boolean>(false)
const [errorText, setErrorText] = useState<string|null>(null)
const [padToDelete, setPadToDelete] = useState<string>('')
const [createPadDialogOpen, setCreatePadDialogOpen] = useState<boolean>(false)
const {register, handleSubmit} = useForm<PadCreateProps>()
const pages = useMemo(()=>{
if(!pads){
return 0;
}
return Math.ceil(pads!.total / searchParams.limit)
},[pads, searchParams.limit])
}, 500, [searchTerm])
useDebounce(()=>{
setSearchParams({
...searchParams,
pattern: searchTerm
})
useEffect(() => {
if(!settingsSocket){
return
}
}, 500, [searchTerm])
settingsSocket.emit('padLoad', searchParams)
useEffect(() => {
if(!settingsSocket){
return
}
}, [settingsSocket, searchParams]);
settingsSocket.emit('padLoad', searchParams)
useEffect(() => {
if(!settingsSocket){
return
}
}, [settingsSocket, searchParams]);
useEffect(() => {
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((pad)=>{
return pad.padName !== padID
})
useStore.getState().setPads({
total: useStore.getState().pads!.total-1,
results: newPads
})
})
type SettingsSocketCreateReponse = {
error: string
} | {
success: string
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
}
})
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)
}
})
useStore.getState().setPads({
results: newPads,
total: useStore.getState().pads!.total
})
})
}, [settingsSocket, pads]);
settingsSocket.on('results:cleanupPadRevisions', (data)=>{
const newPads = useStore.getState().pads?.results ?? []
const deletePad = (padID: string)=>{
settingsSocket?.emit('deletePad', padID)
}
if (data.error) {
setErrorText(data.error)
return
}
const cleanupPad = (padID: string)=>{
settingsSocket?.emit('cleanupPadRevisions', padID)
}
newPads.forEach((pad)=>{
if (pad.padName === data.padId) {
pad.revisionNumber = data.keepRevisions
}
})
useStore.getState().setPads({
results: newPads,
total: useStore.getState().pads!.total
})
})
}, [settingsSocket, pads]);
const deletePad = (padID: string)=>{
settingsSocket?.emit('deletePad', padID)
}
const cleanupPad = (padID: string)=>{
settingsSocket?.emit('cleanupPadRevisions', padID)
}
const onPadCreate = (data: PadCreateProps)=>{
settingsSocket?.emit('createPad', {
padName: data.padName
})
}
const onPadCreate = (data: PadCreateProps)=>{
settingsSocket?.emit('createPad', {
padName: data.padName
})
}
return <div>
<Dialog.Root open={deleteDialog}><Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay" />
<Dialog.Content className="dialog-confirm-content">
<div className="">
<div className=""></div>
<div className="">
{t("ep_admin_pads:ep_adminpads2_confirm", {
padID: padToDelete,
})}
</div>
<div className="settings-button-bar">
<button onClick={()=>{
setDeleteDialog(false)
}}>Cancel</button>
<button onClick={()=>{
deletePad(padToDelete)
setDeleteDialog(false)
}}>Ok</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root open={errorText !== null}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<div>
<div>Error occured: {errorText}</div>
<div className="settings-button-bar">
<button onClick={() => {
setErrorText(null)
}}>OK</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root open={createPadDialogOpen}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay" />
<Dialog.Content className="dialog-confirm-content">
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
<form onSubmit={handleSubmit(onPadCreate)}>
<button className="dialog-close-button" onClick={()=>{
setCreatePadDialogOpen(false);
}}>x</button>
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>
<label><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></label>
<input {...register('padName', {
required: true
})}/>
</div>
<input type="submit" value={t('admin_settings.createPad')} className="login-button" />
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<span className="manage-pads-header">
<h1><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></h1>
<span style={{width: '29px', marginBottom: 'auto', marginTop: 'auto', flexGrow: 1}}><IconButton style={{float: 'right'}} icon={<PlusIcon/>} title={<Trans i18nKey="index.newPad"/>} onClick={()=>{
setCreatePadDialogOpen(true)
}}/></span>
</span>
<SearchField value={searchTerm} onChange={v=>setSearchTerm(v.target.value)} placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/>
<table>
<thead>
<tr className="search-pads">
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'padName')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'padName',
ascending: !searchParams.ascending
})
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'userCount')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'userCount',
ascending: !searchParams.ascending
})
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_pad-user-count"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'lastEdited')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'lastEdited',
ascending: !searchParams.ascending
})
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_last-edited"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'revisionNumber')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'revisionNumber',
ascending: !searchParams.ascending
})
}}>Revision number</th>
<th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
</tr>
</thead>
<tbody className="search-pads-body">
{
pads?.results?.map((pad)=>{
return <tr key={pad.padName}>
<td style={{textAlign: 'center'}}>{pad.padName}</td>
<td style={{textAlign: 'center'}}>{pad.userCount}</td>
<td style={{textAlign: 'center'}}>{new Date(pad.lastEdited).toLocaleString()}</td>
<td style={{textAlign: 'center'}}>{pad.revisionNumber}</td>
<td>
<div className="settings-button-bar">
<IconButton icon={<Trash2/>} title={<Trans i18nKey="ep_admin_pads:ep_adminpads2_delete.value"/>} onClick={()=>{
setPadToDelete(pad.padName)
setDeleteDialog(true)
}}/>
<IconButton icon={<FileStack/>} title={<Trans i18nKey="ep_admin_pads:ep_adminpads2_cleanup"/>} onClick={()=>{
cleanupPad(pad.padName)
}}/>
<IconButton icon={<Eye/>} title={<Trans i18nKey="index.createOpenPad"/>} onClick={()=>window.open(`../../p/${pad.padName}`, '_blank')}/>
</div>
</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>
return <div>
<Dialog.Root open={deleteDialog}><Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay" />
<Dialog.Content className="dialog-confirm-content">
<div className="">
<div className=""></div>
<div className="">
{t("ep_admin_pads:ep_adminpads2_confirm", {
padID: padToDelete,
})}
</div>
<div className="settings-button-bar">
<button onClick={()=>{
setDeleteDialog(false)
}}>Cancel</button>
<button onClick={()=>{
deletePad(padToDelete)
setDeleteDialog(false)
}}>Ok</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root open={errorText !== null}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay"/>
<Dialog.Content className="dialog-confirm-content">
<div>
<div>Error occured: {errorText}</div>
<div className="settings-button-bar">
<button onClick={() => {
setErrorText(null)
}}>OK</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root open={createPadDialogOpen}>
<Dialog.Portal>
<Dialog.Overlay className="dialog-confirm-overlay" />
<Dialog.Content className="dialog-confirm-content">
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
<form onSubmit={handleSubmit(onPadCreate)}>
<button className="dialog-close-button" onClick={()=>{
setCreatePadDialogOpen(false);
}}>x</button>
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>
<label><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></label>
<input {...register('padName', {
required: true
})}/>
</div>
<input type="submit" value={t('admin_settings.createPad')} className="login-button" />
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<span className="manage-pads-header">
<h1><Trans i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></h1>
<span style={{width: '29px', marginBottom: 'auto', marginTop: 'auto', flexGrow: 1}}><IconButton style={{float: 'right'}} icon={<PlusIcon/>} title={<Trans i18nKey="index.newPad"/>} onClick={()=>{
setCreatePadDialogOpen(true)
}}/></span>
</span>
<SearchField value={searchTerm} onChange={v=>setSearchTerm(v.target.value)} placeholder={t('ep_admin_pads:ep_adminpads2_search-heading')}/>
<table>
<thead>
<tr className="search-pads">
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'padName')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'padName',
ascending: !searchParams.ascending
})
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_padname"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'userCount')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'userCount',
ascending: !searchParams.ascending
})
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_pad-user-count"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'lastEdited')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'lastEdited',
ascending: !searchParams.ascending
})
}}><Trans i18nKey="ep_admin_pads:ep_adminpads2_last-edited"/></th>
<th className={determineSorting(searchParams.sortBy, searchParams.ascending, 'revisionNumber')} onClick={()=>{
setSearchParams({
...searchParams,
sortBy: 'revisionNumber',
ascending: !searchParams.ascending
})
}}>Revision number</th>
<th><Trans i18nKey="ep_admin_pads:ep_adminpads2_action"/></th>
</tr>
</thead>
<tbody className="search-pads-body">
{
pads?.results?.map((pad)=>{
return <tr key={pad.padName}>
<td style={{textAlign: 'center'}}>{pad.padName}</td>
<td style={{textAlign: 'center'}}>{pad.userCount}</td>
<td style={{textAlign: 'center'}}>{new Date(pad.lastEdited).toLocaleString()}</td>
<td style={{textAlign: 'center'}}>{pad.revisionNumber}</td>
<td>
<div className="settings-button-bar">
<IconButton icon={<Trash2/>} title={<Trans i18nKey="ep_admin_pads:ep_adminpads2_delete.value"/>} onClick={()=>{
setPadToDelete(pad.padName)
setDeleteDialog(true)
}}/>
<IconButton icon={<FileStack/>} title={<Trans i18nKey="ep_admin_pads:ep_adminpads2_cleanup"/>} onClick={()=>{
cleanupPad(pad.padName)
}}/>
<IconButton icon={<Eye/>} title={<Trans i18nKey="index.createOpenPad"/>} onClick={()=>window.open(`../../p/${pad.padName}`, '_blank')}/>
</div>
</td>
</tr>
})
}
</tbody>
</table>
<div className="settings-button-bar pad-pagination">
<button disabled={currentPage == 0} onClick={()=>{
setCurrentPage(currentPage-1)
setSearchParams({
...searchParams,
offset: (Number(currentPage)-1)*searchParams.limit})
}}><ChevronLeft/><span>Previous Page</span></button>
<span>{currentPage+1} out of {pages}</span>
<button disabled={pages == 0 || pages == currentPage+1} onClick={()=>{
const newCurrentPage = currentPage+1
setCurrentPage(newCurrentPage)
setSearchParams({
...searchParams,
offset: (Number(newCurrentPage))*searchParams.limit
})
}}><span>Next Page</span><ChevronRight/></button>
</div>
</div>
}

View File

@ -1,36 +1,36 @@
export type PluginDef = {
name: string,
description: string,
version: string,
time: string,
official: boolean,
name: string,
description: string,
version: string,
time: string,
official: boolean,
}
export type InstalledPlugin = {
name: string,
path: string,
realPath: string,
version:string,
updatable?: boolean
name: string,
path: string,
realPath: string,
version:string,
updatable?: boolean
}
export type SearchParams = {
searchTerm: string,
offset: number,
limit: number,
sortBy: 'name'|'version'|'last-updated',
sortDir: 'asc'|'desc'
searchTerm: string,
offset: number,
limit: number,
sortBy: 'name'|'version'|'last-updated',
sortDir: 'asc'|'desc'
}
export type HelpObj = {
epVersion: string
gitCommit: string
installedClientHooks: Record<string, Record<string, string>>,
installedParts: string[],
installedPlugins: string[],
installedServerHooks: Record<string, never>,
latestVersion: string
epVersion: string
gitCommit: string
installedClientHooks: Record<string, Record<string, string>>,
installedParts: string[],
installedPlugins: string[],
installedServerHooks: Record<string, never>,
latestVersion: string
}

View File

@ -5,46 +5,46 @@ import {IconButton} from "../components/IconButton.tsx";
import {RotateCw, Save} from "lucide-react";
export const SettingsPage = ()=>{
const settingsSocket = useStore(state=>state.settingsSocket)
const settings = cleanComments(useStore(state=>state.settings))
const settingsSocket = useStore(state=>state.settingsSocket)
const settings = cleanComments(useStore(state=>state.settings))
return <div className="settings-page">
<h1><Trans i18nKey="admin_settings.current"/></h1>
<textarea value={settings} className="settings" onChange={v => {
useStore.getState().setSettings(v.target.value)
}}/>
<div className="settings-button-bar">
<IconButton className="settingsButton" icon={<Save/>}
title={<Trans i18nKey="admin_settings.current_save.value"/>} onClick={() => {
if (isJSONClean(settings!)) {
// JSON is clean so emit it to the server
settingsSocket!.emit('saveSettings', settings!);
useStore.getState().setToastState({
open: true,
title: "Successfully saved settings",
success: true
})
} else {
useStore.getState().setToastState({
open: true,
title: "Error saving settings",
success: false
})
}
}}/>
<IconButton className="settingsButton" icon={<RotateCw/>}
title={<Trans i18nKey="admin_settings.current_restart.value"/>} onClick={() => {
settingsSocket!.emit('restartServer');
}}/>
</div>
<div className="separator"/>
<div className="settings-button-bar">
<a rel="noopener noreferrer" target="_blank"
href="https://github.com/ether/etherpad-lite/wiki/Example-Production-Settings.JSON"><Trans
i18nKey="admin_settings.current_example-prod"/></a>
<a rel="noopener noreferrer" target="_blank"
href="https://github.com/ether/etherpad-lite/wiki/Example-Development-Settings.JSON"><Trans
i18nKey="admin_settings.current_example-devel"/></a>
</div>
return <div className="settings-page">
<h1><Trans i18nKey="admin_settings.current"/></h1>
<textarea value={settings} className="settings" onChange={v => {
useStore.getState().setSettings(v.target.value)
}}/>
<div className="settings-button-bar">
<IconButton className="settingsButton" icon={<Save/>}
title={<Trans i18nKey="admin_settings.current_save.value"/>} onClick={() => {
if (isJSONClean(settings!)) {
// JSON is clean so emit it to the server
settingsSocket!.emit('saveSettings', settings!);
useStore.getState().setToastState({
open: true,
title: "Successfully saved settings",
success: true
})
} else {
useStore.getState().setToastState({
open: true,
title: "Error saving settings",
success: false
})
}
}}/>
<IconButton className="settingsButton" icon={<RotateCw/>}
title={<Trans i18nKey="admin_settings.current_restart.value"/>} onClick={() => {
settingsSocket!.emit('restartServer');
}}/>
</div>
<div className="separator"/>
<div className="settings-button-bar">
<a rel="noopener noreferrer" target="_blank"
href="https://github.com/ether/etherpad-lite/wiki/Example-Production-Settings.JSON"><Trans
i18nKey="admin_settings.current_example-prod"/></a>
<a rel="noopener noreferrer" target="_blank"
href="https://github.com/ether/etherpad-lite/wiki/Example-Development-Settings.JSON"><Trans
i18nKey="admin_settings.current_example-devel"/></a>
</div>
</div>
}

View File

@ -4,49 +4,49 @@ import {PadSearchResult} from "../utils/PadSearch.ts";
import {InstalledPlugin} from "../pages/Plugin.ts";
type ToastState = {
description?:string,
title: string,
open: boolean,
success: boolean
description?:string,
title: string,
open: boolean,
success: boolean
}
type StoreState = {
settings: string|undefined,
setSettings: (settings: string) => void,
settingsSocket: Socket|undefined,
setSettingsSocket: (socket: Socket) => void,
showLoading: boolean,
setShowLoading: (show: boolean) => void,
setPluginsSocket: (socket: Socket) => void
pluginsSocket: Socket|undefined,
toastState: ToastState,
setToastState: (val: ToastState)=>void,
pads: PadSearchResult|undefined,
setPads: (pads: PadSearchResult)=>void,
installedPlugins: InstalledPlugin[],
setInstalledPlugins: (plugins: InstalledPlugin[])=>void
settings: string|undefined,
setSettings: (settings: string) => void,
settingsSocket: Socket|undefined,
setSettingsSocket: (socket: Socket) => void,
showLoading: boolean,
setShowLoading: (show: boolean) => void,
setPluginsSocket: (socket: Socket) => void
pluginsSocket: Socket|undefined,
toastState: ToastState,
setToastState: (val: ToastState)=>void,
pads: PadSearchResult|undefined,
setPads: (pads: PadSearchResult)=>void,
installedPlugins: InstalledPlugin[],
setInstalledPlugins: (plugins: InstalledPlugin[])=>void
}
export const useStore = create<StoreState>()((set) => ({
settings: undefined,
setSettings: (settings: string) => set({settings}),
settingsSocket: undefined,
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
showLoading: false,
setShowLoading: (show: boolean) => set({showLoading: show}),
pluginsSocket: undefined,
setPluginsSocket: (socket: Socket) => set({pluginsSocket: socket}),
setToastState: (val )=>set({toastState: val}),
toastState: {
open: false,
title: '',
description:'',
success: false
},
pads: undefined,
setPads: (pads)=>set({pads}),
installedPlugins: [],
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins})
settings: undefined,
setSettings: (settings: string) => set({settings}),
settingsSocket: undefined,
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
showLoading: false,
setShowLoading: (show: boolean) => set({showLoading: show}),
pluginsSocket: undefined,
setPluginsSocket: (socket: Socket) => set({pluginsSocket: socket}),
setToastState: (val )=>set({toastState: val}),
toastState: {
open: false,
title: '',
description:'',
success: false
},
pads: undefined,
setPads: (pads)=>set({pads}),
installedPlugins: [],
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins})
}));

View File

@ -3,27 +3,27 @@ import {useCallback, useEffect, useRef} from "react";
type Args = any[]
export const useAnimationFrame = <Fn extends (...args: Args)=>void>(
callback: Fn,
wait = 0
callback: Fn,
wait = 0
): ((...args: Parameters<Fn>)=>void)=>{
const rafId = useRef(0)
const render = useCallback(
(...args: Parameters<Fn>)=>{
cancelAnimationFrame(rafId.current)
const timeStart = performance.now()
const rafId = useRef(0)
const render = useCallback(
(...args: Parameters<Fn>)=>{
cancelAnimationFrame(rafId.current)
const timeStart = performance.now()
const renderFrame = (timeNow: number)=>{
if(timeNow-timeStart<wait){
rafId.current = requestAnimationFrame(renderFrame)
return
}
callback(...args)
}
rafId.current = requestAnimationFrame(renderFrame)
}, [callback, wait]
)
const renderFrame = (timeNow: number)=>{
if(timeNow-timeStart<wait){
rafId.current = requestAnimationFrame(renderFrame)
return
}
callback(...args)
}
rafId.current = requestAnimationFrame(renderFrame)
}, [callback, wait]
)
useEffect(()=>cancelAnimationFrame(rafId.current),[])
return render
useEffect(()=>cancelAnimationFrame(rafId.current),[])
return render
}

View File

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

View File

@ -1,20 +1,20 @@
export type PadSearchQuery = {
pattern: string;
offset: number;
limit: number;
ascending: boolean;
sortBy: string;
pattern: string;
offset: number;
limit: number;
ascending: boolean;
sortBy: string;
}
export type PadSearchResult = {
total: number;
results?: PadType[]
total: number;
results?: PadType[]
}
export type PadType = {
padName: string;
lastEdited: number;
userCount: number;
revisionNumber: number;
padName: string;
lastEdited: number;
userCount: number;
revisionNumber: number;
}

View File

@ -3,24 +3,24 @@ import {useStore} from "../store/store.ts";
import {useMemo} from "react";
export const ToastDialog = ()=>{
const toastState = useStore(state => state.toastState)
const resultingClass = useMemo(()=> {
return toastState.success?'ToastRootSuccess':'ToastRootFailure'
}, [toastState.success])
const toastState = useStore(state => state.toastState)
const resultingClass = useMemo(()=> {
return toastState.success?'ToastRootSuccess':'ToastRootFailure'
}, [toastState.success])
console.log()
return <>
<Toast.Root className={"ToastRoot "+resultingClass} open={toastState && toastState.open} onOpenChange={()=>{
useStore.getState().setToastState({
...toastState!,
open: !toastState?.open
})
}}>
<Toast.Title className="ToastTitle">{toastState.title}</Toast.Title>
<Toast.Description asChild>
{toastState.description}
</Toast.Description>
</Toast.Root>
<Toast.Viewport className="ToastViewport"/>
</>
console.log()
return <>
<Toast.Root className={"ToastRoot "+resultingClass} open={toastState && toastState.open} onOpenChange={()=>{
useStore.getState().setToastState({
...toastState!,
open: !toastState?.open
})
}}>
<Toast.Title className="ToastTitle">{toastState.title}</Toast.Title>
<Toast.Description asChild>
{toastState.description}
</Toast.Description>
</Toast.Root>
<Toast.Viewport className="ToastViewport"/>
</>
}

View File

@ -1,6 +1,6 @@
export const determineSorting = (sortBy: string, ascending: boolean, currentSymbol: string) => {
if (sortBy === currentSymbol) {
return ascending ? 'sort up' : 'sort down';
}
return 'sort none';
if (sortBy === currentSymbol) {
return ascending ? 'sort up' : 'sort down';
}
return 'sort none';
}

View File

@ -4,19 +4,19 @@ import {useAnimationFrame} from "./AnimationFrameHook";
const defaultDeps: DependencyList = []
export const useDebounce = (
fn:EffectCallback,
wait = 0,
deps = defaultDeps
fn:EffectCallback,
wait = 0,
deps = defaultDeps
):void => {
const isFirstRender = useRef(true)
const render = useAnimationFrame(fn, wait)
const isFirstRender = useRef(true)
const render = useAnimationFrame(fn, wait)
useMemo(()=>{
if(isFirstRender.current){
isFirstRender.current = false
return
}
useMemo(()=>{
if(isFirstRender.current){
isFirstRender.current = false
return
}
render()
}, deps)
render()
}, deps)
}

View File

@ -1,70 +1,70 @@
export const cleanComments = (json: string|undefined)=>{
if (json !== undefined){
json = json.replace(/\/\*.*?\*\//g, ""); // remove single line comments
json = json.replace(/ *\/\*.*(.|\n)*?\*\//g, ""); // remove multi line comments
json = json.replace(/[ \t]+$/gm, ""); // trim trailing spaces
json = json.replace(/^(\n)/gm, ""); // remove empty lines
}
return json;
if (json !== undefined){
json = json.replace(/\/\*.*?\*\//g, ""); // remove single line comments
json = json.replace(/ *\/\*.*(.|\n)*?\*\//g, ""); // remove multi line comments
json = json.replace(/[ \t]+$/gm, ""); // trim trailing spaces
json = json.replace(/^(\n)/gm, ""); // remove empty lines
}
return json;
}
export const minify = (json: string)=>{
let tokenizer = /"|(\/\*)|(\*\/)|(\/\/)|\n|\r/g,
in_string = false,
in_multiline_comment = false,
in_singleline_comment = false,
tmp, tmp2, new_str = [], ns = 0, from = 0, lc, rc
;
let tokenizer = /"|(\/\*)|(\*\/)|(\/\/)|\n|\r/g,
in_string = false,
in_multiline_comment = false,
in_singleline_comment = false,
tmp, tmp2, new_str = [], ns = 0, from = 0, lc, rc
;
tokenizer.lastIndex = 0;
tokenizer.lastIndex = 0;
while (tmp = tokenizer.exec(json)) {
lc = RegExp.leftContext;
rc = RegExp.rightContext;
if (!in_multiline_comment && !in_singleline_comment) {
tmp2 = lc.substring(from);
if (!in_string) {
tmp2 = tmp2.replace(/(\n|\r|\s)*/g,"");
}
new_str[ns++] = tmp2;
}
from = tokenizer.lastIndex;
if (tmp[0] == "\"" && !in_multiline_comment && !in_singleline_comment) {
tmp2 = lc.match(/(\\)*$/);
if (!in_string || !tmp2 || (tmp2[0].length % 2) == 0) { // start of string with ", or unescaped " character found to end string
in_string = !in_string;
}
from--; // include " character in next catch
rc = json.substring(from);
}
else if (tmp[0] == "/*" && !in_string && !in_multiline_comment && !in_singleline_comment) {
in_multiline_comment = true;
}
else if (tmp[0] == "*/" && !in_string && in_multiline_comment && !in_singleline_comment) {
in_multiline_comment = false;
}
else if (tmp[0] == "//" && !in_string && !in_multiline_comment && !in_singleline_comment) {
in_singleline_comment = true;
}
else if ((tmp[0] == "\n" || tmp[0] == "\r") && !in_string && !in_multiline_comment && in_singleline_comment) {
in_singleline_comment = false;
}
else if (!in_multiline_comment && !in_singleline_comment && !(/\n|\r|\s/.test(tmp[0]))) {
new_str[ns++] = tmp[0];
}
while (tmp = tokenizer.exec(json)) {
lc = RegExp.leftContext;
rc = RegExp.rightContext;
if (!in_multiline_comment && !in_singleline_comment) {
tmp2 = lc.substring(from);
if (!in_string) {
tmp2 = tmp2.replace(/(\n|\r|\s)*/g,"");
}
new_str[ns++] = tmp2;
}
new_str[ns++] = rc;
return new_str.join("");
from = tokenizer.lastIndex;
if (tmp[0] == "\"" && !in_multiline_comment && !in_singleline_comment) {
tmp2 = lc.match(/(\\)*$/);
if (!in_string || !tmp2 || (tmp2[0].length % 2) == 0) { // start of string with ", or unescaped " character found to end string
in_string = !in_string;
}
from--; // include " character in next catch
rc = json.substring(from);
}
else if (tmp[0] == "/*" && !in_string && !in_multiline_comment && !in_singleline_comment) {
in_multiline_comment = true;
}
else if (tmp[0] == "*/" && !in_string && in_multiline_comment && !in_singleline_comment) {
in_multiline_comment = false;
}
else if (tmp[0] == "//" && !in_string && !in_multiline_comment && !in_singleline_comment) {
in_singleline_comment = true;
}
else if ((tmp[0] == "\n" || tmp[0] == "\r") && !in_string && !in_multiline_comment && in_singleline_comment) {
in_singleline_comment = false;
}
else if (!in_multiline_comment && !in_singleline_comment && !(/\n|\r|\s/.test(tmp[0]))) {
new_str[ns++] = tmp[0];
}
}
new_str[ns++] = rc;
return new_str.join("");
}
export const isJSONClean = (data: string) => {
let cleanSettings = minify(data);
// this is a bit naive. In theory some key/value might contain the sequences ',]' or ',}'
cleanSettings = cleanSettings.replace(',]', ']').replace(',}', '}');
try {
return typeof JSON.parse(cleanSettings) === 'object';
} catch (e) {
return false; // the JSON failed to be parsed
}
let cleanSettings = minify(data);
// this is a bit naive. In theory some key/value might contain the sequences ',]' or ',}'
cleanSettings = cleanSettings.replace(',]', ']').replace(',}', '}');
try {
return typeof JSON.parse(cleanSettings) === 'object';
} catch (e) {
return false; // the JSON failed to be parsed
}
};

132
best_practices.md Normal file
View File

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

View File

@ -4,14 +4,14 @@ import {installedPluginsPath} from "ep_etherpad-lite/static/js/pluginfw/installe
const pluginsModule = require('ep_etherpad-lite/static/js/pluginfw/plugins');
export const persistInstalledPlugins = async () => {
const plugins:PackageData[] = []
const installedPlugins = {plugins: plugins};
for (const pkg of Object.values(await pluginsModule.getPackages()) as PackageData[]) {
installedPlugins.plugins.push({
name: pkg.name,
version: pkg.version,
});
}
installedPlugins.plugins = [...new Set(installedPlugins.plugins)];
writeFileSync(installedPluginsPath, JSON.stringify(installedPlugins));
const plugins:PackageData[] = []
const installedPlugins = {plugins: plugins};
for (const pkg of Object.values(await pluginsModule.getPackages()) as PackageData[]) {
installedPlugins.plugins.push({
name: pkg.name,
version: pkg.version,
});
}
installedPlugins.plugins = [...new Set(installedPlugins.plugins)];
writeFileSync(installedPluginsPath, JSON.stringify(installedPlugins));
};

View File

@ -31,7 +31,7 @@ while true; do
esac
done
ETHER_REPO="https://github.com/ether/etherpad-lite.git"
ETHER_REPO="https://github.com/ether/etherpad.git"
ETHER_WEB_REPO="https://github.com/ether/ether.github.com.git"
TMP_DIR="/tmp/"
@ -186,7 +186,7 @@ function publish_release {
function todo_notification {
echo "Release procedure was successful, but you have to do some steps manually:"
echo "- Update the wiki at https://github.com/ether/etherpad-lite/wiki"
echo "- Update the wiki at https://github.com/ether/etherpad/wiki"
echo "- Create a pull request on github to merge the master branch back to develop"
echo "- Announce the new release on the mailing list, blog.etherpad.org and Twitter"
}

View File

@ -1,49 +0,0 @@
@echo off
:: Change directory to etherpad-lite root
cd /D "%~dp0\.."
:: Is node installed?
cmd /C node -e "" || ( echo "Please install node.js ( https://nodejs.org )" && exit /B 1 )
echo _
echo Ensure that all dependencies are up to date... If this is the first time you have run Etherpad please be patient.
:: Install admin ui only if available
IF EXIST admin (
cd /D .\admin
dir
cmd /C pnpm i || exit /B 1
cmd /C pnpm run build || exit /B 1
cd /D ..
)
:: Install ui only if available
IF EXIST ui (
cd /D .\ui
dir
cmd /C pnpm i || exit /B 1
cmd /C pnpm run build || exit /B 1
cd /D ..
)
cmd /C pnpm i || exit /B 1
cd /D "%~dp0\.."
echo _
echo Clearing cache...
del /S var\minified*
echo _
echo Setting up settings.json...
IF NOT EXIST settings.json (
echo Can't find settings.json.
echo Copying settings.json.template...
cmd /C copy settings.json.template settings.json || exit /B 1
)
echo _
echo Installed Etherpad! To run Etherpad type start.bat

155
bin/installer.ps1 Normal file
View File

@ -0,0 +1,155 @@
# Etherpad one-line installer for Windows (PowerShell).
#
# Usage:
# irm https://raw.githubusercontent.com/ether/etherpad-lite/master/bin/installer.ps1 | iex
#
# Optional environment variables:
# $env:ETHERPAD_DIR Directory to install into (default: .\etherpad-lite)
# $env:ETHERPAD_BRANCH Branch / tag to clone (default: master)
# $env:ETHERPAD_REPO Repo URL (default: https://github.com/ether/etherpad.git)
# $env:ETHERPAD_RUN If "1", start Etherpad after install
# $env:NO_COLOR If set, disables coloured output
#Requires -Version 5.1
$ErrorActionPreference = 'Stop'
# ---------- pretty output ----------
$useColor = -not $env:NO_COLOR
function Write-Step([string]$msg) {
if ($useColor) { Write-Host "==> $msg" -ForegroundColor Green }
else { Write-Host "==> $msg" }
}
function Write-Warn([string]$msg) {
if ($useColor) { Write-Host "==> $msg" -ForegroundColor Yellow }
else { Write-Host "==> $msg" }
}
function Write-Fatal([string]$msg) {
if ($useColor) { Write-Host "==> $msg" -ForegroundColor Red }
else { Write-Host "==> $msg" }
exit 1
}
function Test-Cmd([string]$name) {
return [bool](Get-Command $name -ErrorAction SilentlyContinue)
}
# ---------- defaults ----------
$EtherpadDir = if ($env:ETHERPAD_DIR) { $env:ETHERPAD_DIR } else { 'etherpad-lite' }
$EtherpadBranch = if ($env:ETHERPAD_BRANCH) { $env:ETHERPAD_BRANCH } else { 'master' }
$EtherpadRepo = if ($env:ETHERPAD_REPO) { $env:ETHERPAD_REPO } else { 'https://github.com/ether/etherpad.git' }
$RequiredNodeMajor = 20
Write-Step 'Etherpad installer'
# ---------- prerequisite checks ----------
if (-not (Test-Cmd git)) {
Write-Fatal 'git is required but not installed. See https://git-scm.com/download/win'
}
if (-not (Test-Cmd node)) {
Write-Fatal "Node.js is required (>= $RequiredNodeMajor). Install it from https://nodejs.org"
}
$nodeMajor = [int](node -p 'process.versions.node.split(".")[0]')
if ($nodeMajor -lt $RequiredNodeMajor) {
$nodeVer = (node --version)
Write-Fatal "Node.js >= $RequiredNodeMajor required. You have $nodeVer."
}
if (-not (Test-Cmd pnpm)) {
Write-Step 'Installing pnpm globally'
if (-not (Test-Cmd npm)) {
Write-Fatal "npm not found. Install Node.js >= $RequiredNodeMajor."
}
npm install -g pnpm
if ($LASTEXITCODE -ne 0) {
Write-Fatal 'Failed to install pnpm. Install it manually: https://pnpm.io/installation'
}
if (-not (Test-Cmd pnpm)) {
Write-Fatal 'pnpm install reported success but pnpm is still not on PATH. Open a new shell and re-run.'
}
}
# ---------- clone ----------
if (Test-Path $EtherpadDir) {
if (Test-Path (Join-Path $EtherpadDir '.git')) {
Write-Warn "$EtherpadDir already exists; updating to $EtherpadBranch."
Push-Location $EtherpadDir
try {
# Verify the existing checkout points at the expected remote.
$existingRemote = (git remote get-url origin 2>$null)
if ($existingRemote -and $existingRemote -ne $EtherpadRepo) {
Write-Fatal "$EtherpadDir is checked out from '$existingRemote', expected '$EtherpadRepo'. Refusing to overwrite."
}
# Refuse to clobber meaningful local changes. pnpm-lock.yaml is
# excluded because `pnpm i` rewrites it during installation,
# which would otherwise make every re-run of the installer fail.
$statusLines = (git status --porcelain) -split "`n" |
Where-Object { $_ -and ($_ -notmatch '\bpnpm-lock\.yaml$') }
if ($statusLines) {
$statusLines | ForEach-Object { Write-Host $_ }
Write-Fatal "$EtherpadDir has uncommitted changes. Commit/stash them or remove the directory."
}
git fetch --tags --prune origin
if ($LASTEXITCODE -ne 0) { Write-Fatal "git fetch failed in $EtherpadDir" }
# Discard any pnpm-lock.yaml changes from a prior pnpm install
# so the subsequent checkout doesn't refuse to overwrite.
git checkout -- pnpm-lock.yaml 2>$null
# Switch to the requested branch / tag and fast-forward to it.
git show-ref --verify --quiet "refs/remotes/origin/$EtherpadBranch"
$isBranch = ($LASTEXITCODE -eq 0)
git show-ref --verify --quiet "refs/tags/$EtherpadBranch"
$isTag = ($LASTEXITCODE -eq 0)
if ($isBranch) {
git checkout -B $EtherpadBranch "origin/$EtherpadBranch"
if ($LASTEXITCODE -ne 0) { Write-Fatal "git checkout $EtherpadBranch failed" }
} elseif ($isTag) {
git checkout --detach "refs/tags/$EtherpadBranch"
if ($LASTEXITCODE -ne 0) { Write-Fatal "git checkout tag $EtherpadBranch failed" }
} else {
Write-Fatal "Branch or tag '$EtherpadBranch' not found on origin."
}
$installedRev = (git rev-parse --short HEAD)
Write-Step "Updated $EtherpadDir to $EtherpadBranch @ $installedRev"
} finally {
Pop-Location
}
} else {
Write-Fatal "$EtherpadDir exists and is not a git checkout. Aborting."
}
} else {
Write-Step "Cloning Etherpad ($EtherpadBranch) into $EtherpadDir"
git clone --depth 1 --branch $EtherpadBranch $EtherpadRepo $EtherpadDir
if ($LASTEXITCODE -ne 0) { Write-Fatal 'git clone failed.' }
}
Push-Location $EtherpadDir
# ---------- install + build ----------
Write-Step 'Installing dependencies (pnpm i)'
pnpm i
if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Fatal 'pnpm i failed.' }
Write-Step 'Building Etherpad (pnpm run build:etherpad)'
pnpm run build:etherpad
if ($LASTEXITCODE -ne 0) { Pop-Location; Write-Fatal 'pnpm run build:etherpad failed.' }
# ---------- done ----------
Write-Host ''
if ($useColor) { Write-Host "🎉 Etherpad is installed in $EtherpadDir" -ForegroundColor Green }
else { Write-Host "Etherpad is installed in $EtherpadDir" }
Write-Host 'To start Etherpad:'
Write-Host " cd $EtherpadDir; pnpm run prod"
Write-Host 'Then open http://localhost:9001 in your browser.'
Write-Host ''
if ($env:ETHERPAD_RUN -eq '1') {
Write-Step 'Starting Etherpad on http://localhost:9001'
pnpm run prod
}

133
bin/installer.sh Executable file
View File

@ -0,0 +1,133 @@
#!/bin/sh
#
# Etherpad one-line installer.
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/ether/etherpad-lite/master/bin/installer.sh | sh
#
# Optional environment variables:
# ETHERPAD_DIR Directory to install into (default: ./etherpad-lite)
# ETHERPAD_BRANCH Branch / tag to clone (default: master)
# ETHERPAD_RUN If set to 1, start Etherpad after install
# NO_COLOR If set, disables coloured output
set -eu
# ---------- pretty output ----------
if [ -z "${NO_COLOR:-}" ] && [ -t 1 ]; then
bold=$(printf '\033[1m')
green=$(printf '\033[32m')
red=$(printf '\033[31m')
yellow=$(printf '\033[33m')
reset=$(printf '\033[0m')
else
bold=''; green=''; red=''; yellow=''; reset=''
fi
step() { printf '%s==>%s %s%s%s\n' "$green" "$reset" "$bold" "$*" "$reset"; }
warn() { printf '%s==>%s %s\n' "$yellow" "$reset" "$*" >&2; }
fatal() { printf '%s==>%s %s\n' "$red" "$reset" "$*" >&2; exit 1; }
is_cmd() { command -v "$1" >/dev/null 2>&1; }
# ---------- defaults ----------
ETHERPAD_DIR="${ETHERPAD_DIR:-etherpad-lite}"
ETHERPAD_BRANCH="${ETHERPAD_BRANCH:-master}"
ETHERPAD_REPO="${ETHERPAD_REPO:-https://github.com/ether/etherpad.git}"
REQUIRED_NODE_MAJOR=20
step "Etherpad installer"
# ---------- prerequisite checks ----------
is_cmd git || fatal "git is required but not installed. See https://git-scm.com/downloads"
if ! is_cmd node; then
fatal "Node.js is required (>= ${REQUIRED_NODE_MAJOR}). Install it from https://nodejs.org"
fi
NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]")
if [ "$NODE_MAJOR" -lt "$REQUIRED_NODE_MAJOR" ]; then
fatal "Node.js >= ${REQUIRED_NODE_MAJOR} required. You have $(node --version)."
fi
if ! is_cmd pnpm; then
step "Installing pnpm globally"
is_cmd npm || fatal "npm not found. Install Node.js >= ${REQUIRED_NODE_MAJOR}."
if ! npm install -g pnpm 2>/dev/null; then
warn "Global npm install requires elevated permissions; retrying with sudo."
is_cmd sudo || fatal "sudo not available. Install pnpm manually: https://pnpm.io/installation"
sudo npm install -g pnpm || \
fatal "Failed to install pnpm. Install it manually: https://pnpm.io/installation"
fi
is_cmd pnpm || \
fatal "pnpm install reported success but pnpm is still not on PATH. Open a new shell and re-run."
fi
# ---------- clone ----------
if [ -d "$ETHERPAD_DIR" ]; then
if [ -d "$ETHERPAD_DIR/.git" ]; then
warn "$ETHERPAD_DIR already exists; updating to $ETHERPAD_BRANCH."
cd "$ETHERPAD_DIR" || fatal "Cannot cd into $ETHERPAD_DIR"
# Verify the existing checkout points at the expected remote.
EXISTING_REMOTE=$(git remote get-url origin 2>/dev/null || echo "")
if [ -n "$EXISTING_REMOTE" ] && [ "$EXISTING_REMOTE" != "$ETHERPAD_REPO" ]; then
fatal "$ETHERPAD_DIR is checked out from '$EXISTING_REMOTE', expected '$ETHERPAD_REPO'. Refusing to overwrite."
fi
# Refuse to clobber meaningful local changes. pnpm-lock.yaml is excluded
# because `pnpm i` rewrites it during installation, which would otherwise
# make every re-run of the installer fail.
DIRTY=$(git status --porcelain 2>/dev/null | awk '$2 != "pnpm-lock.yaml" {print}')
if [ -n "$DIRTY" ]; then
printf '%s\n' "$DIRTY" >&2
fatal "$ETHERPAD_DIR has uncommitted changes. Commit/stash them or remove the directory."
fi
git fetch --tags --prune origin || fatal "git fetch failed in $ETHERPAD_DIR"
# Discard any pnpm-lock.yaml changes from a prior pnpm install so the
# subsequent checkout doesn't refuse to overwrite local changes.
git checkout -- pnpm-lock.yaml 2>/dev/null || true
# Switch to the requested branch / tag and fast-forward to it.
if git show-ref --verify --quiet "refs/remotes/origin/$ETHERPAD_BRANCH"; then
git checkout -B "$ETHERPAD_BRANCH" "origin/$ETHERPAD_BRANCH" || \
fatal "git checkout $ETHERPAD_BRANCH failed"
elif git show-ref --verify --quiet "refs/tags/$ETHERPAD_BRANCH"; then
git checkout --detach "refs/tags/$ETHERPAD_BRANCH" || \
fatal "git checkout tag $ETHERPAD_BRANCH failed"
else
fatal "Branch or tag '$ETHERPAD_BRANCH' not found on origin."
fi
INSTALLED_REV=$(git rev-parse --short HEAD)
step "Updated $ETHERPAD_DIR to $ETHERPAD_BRANCH @ $INSTALLED_REV"
cd - >/dev/null || exit 1
else
fatal "$ETHERPAD_DIR exists and is not a git checkout. Aborting."
fi
else
step "Cloning Etherpad ($ETHERPAD_BRANCH) into $ETHERPAD_DIR"
git clone --depth 1 --branch "$ETHERPAD_BRANCH" "$ETHERPAD_REPO" "$ETHERPAD_DIR"
fi
cd "$ETHERPAD_DIR"
# ---------- install + build ----------
step "Installing dependencies (pnpm i)"
pnpm i
step "Building Etherpad (pnpm run build:etherpad)"
pnpm run build:etherpad
# ---------- done ----------
printf '\n%s🎉 Etherpad is installed in %s%s\n' "$green" "$ETHERPAD_DIR" "$reset"
printf 'To start Etherpad:\n'
printf ' cd %s && pnpm run prod\n' "$ETHERPAD_DIR"
printf 'Then open http://localhost:9001 in your browser.\n\n'
if [ "${ETHERPAD_RUN:-0}" = "1" ]; then
step "Starting Etherpad on http://localhost:9001"
exec pnpm run prod
fi

View File

@ -8,46 +8,46 @@ const VERSION=pjson.version
console.log(`Building docs for version ${VERSION}`)
const createDirIfNotExists = (dir: fs.PathLike) => {
if (!fs.existsSync(dir)){
fs.mkdirSync(dir)
}
if (!fs.existsSync(dir)){
fs.mkdirSync(dir)
}
}
function copyFolderSync(from: fs.PathLike, to: fs.PathLike) {
if(fs.existsSync(to)){
const stat = fs.lstatSync(to)
if (stat.isDirectory()){
fs.rmSync(to, { recursive: true })
}
else{
fs.rmSync(to)
}
if(fs.existsSync(to)){
const stat = fs.lstatSync(to)
if (stat.isDirectory()){
fs.rmSync(to, { recursive: true })
}
fs.mkdirSync(to);
fs.readdirSync(from).forEach(element => {
if (fs.lstatSync(path.join(<string>from, element)).isFile()) {
if (typeof from === "string") {
if (typeof to === "string") {
fs.copyFileSync(path.join(from, element), path.join(to, element))
}
}
} else {
if (typeof from === "string") {
if (typeof to === "string") {
copyFolderSync(path.join(from, element), path.join(to, element))
}
}
}
});
else{
fs.rmSync(to)
}
}
fs.mkdirSync(to);
fs.readdirSync(from).forEach(element => {
if (fs.lstatSync(path.join(<string>from, element)).isFile()) {
if (typeof from === "string") {
if (typeof to === "string") {
fs.copyFileSync(path.join(from, element), path.join(to, element))
}
}
} else {
if (typeof from === "string") {
if (typeof to === "string") {
copyFolderSync(path.join(from, element), path.join(to, element))
}
}
}
});
}
exec('asciidoctor -v', (err,stdout)=>{
if (err){
console.log('Please install asciidoctor')
console.log('https://asciidoctor.org/docs/install-toolchain/')
process.exit(1)
}
if (err){
console.log('Please install asciidoctor')
console.log('https://asciidoctor.org/docs/install-toolchain/')
process.exit(1)
}
});

View File

@ -1,23 +1,23 @@
{
"name": "bin",
"version": "2.6.1",
"version": "2.7.0",
"description": "",
"main": "checkAllPads.js",
"directories": {
"doc": "doc"
},
"dependencies": {
"axios": "^1.13.3",
"axios": "^1.15.1",
"ep_etherpad-lite": "workspace:../src",
"log4js": "^6.9.1",
"semver": "^7.7.3",
"semver": "^7.7.4",
"tsx": "^4.21.0",
"ueberdb2": "^5.0.23"
"ueberdb2": "^5.0.48"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@types/node": "^25.6.0",
"@types/semver": "^7.7.1",
"typescript": "^5.9.3"
"typescript": "^6.0.3"
},
"scripts": {
"makeDocs": "node --import tsx make_docs.ts",

View File

@ -374,6 +374,56 @@ log4js.configure({
'Translation files help with Etherpad accessibility.');
}
// Check template files for absolute `/static/plugins/...` asset paths.
// Those break any Etherpad instance hosted behind a reverse proxy at a
// sub-path (e.g. https://example.com/etherpad/pad) because the browser
// resolves them against the domain root instead of the proxy prefix.
// See #5203, and ep_embedmedia#4 for the original fix this check is modelled on.
//
// Autofix only rewrites paths in `templates/` (rendered under `/p/<pad>/...`
// where `../static/plugins/...` is correct). Files under `static/` are
// served from `/static/plugins/<plugin>/static/...` and need a different
// relative prefix that depends on the file's depth, so we only warn.
const STATIC_ABS = /(?<![./:\w])\/static\/plugins\//g;
const scanDir = async (dir: 'templates' | 'static') => {
const abs = `${pluginPath}/${dir}`;
if (!files.includes(dir)) return;
const scanFiles: string[] = [];
const walk = async (d: string) => {
for (const entry of await fsp.readdir(d, {withFileTypes: true})) {
const full = `${d}/${entry.name}`;
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '.git') continue;
await walk(full);
} else if (/\.(ejs|html)$/.test(entry.name)) {
scanFiles.push(full);
}
}
};
await walk(abs);
for (const fp of scanFiles) {
const src = await fsp.readFile(fp, 'utf8');
if (!STATIC_ABS.test(src)) continue;
STATIC_ABS.lastIndex = 0;
const rel = path.relative(pluginPath, fp);
if (dir === 'templates') {
logger.warn(`${rel} contains absolute '/static/plugins/...' asset paths; ` +
'these break reverse-proxied Etherpad deployments. Use ' +
"'../static/plugins/...' instead.");
if (autoFix) {
logger.info(`Autofixing absolute /static/plugins/ paths in ${rel}`);
await fsp.writeFile(fp, src.replace(STATIC_ABS, '../static/plugins/'));
}
} else {
logger.warn(`${rel} contains absolute '/static/plugins/...' asset paths; ` +
'these break reverse-proxied Etherpad deployments. Use a path ' +
"relative to this file's location under 'static/' (no leading '/').");
}
}
};
await scanDir('templates');
await scanDir('static');
if (files.includes('.ep_initialized')) {
logger.warn(
@ -397,10 +447,16 @@ log4js.configure({
if (files.includes('static')) {
const staticFiles = await fsp.readdir(`${pluginPath}/static`);
if (!staticFiles.includes('tests')) {
logger.warn('Test files not found, please create tests. https://github.com/ether/etherpad-lite/wiki/Creating-a-plugin#writing-and-running-front-end-tests-for-your-plugin');
logger.warn('Test files not found, please create tests. https://github.com/ether/etherpad/wiki/Creating-a-plugin#writing-and-running-front-end-tests-for-your-plugin');
}
} else {
logger.warn('Test files not found, please create tests. https://github.com/ether/etherpad-lite/wiki/Creating-a-plugin#writing-and-running-front-end-tests-for-your-plugin');
logger.warn('Test files not found, please create tests. https://github.com/ether/etherpad/wiki/Creating-a-plugin#writing-and-running-front-end-tests-for-your-plugin');
}
// Update all dependencies to their latest compatible versions.
if (autoFix) {
logger.info('Updating dependencies...');
execSync('pnpm update', {cwd: `${pluginPath}/`, stdio: 'inherit'});
}
// Install dependencies so we can run ESLint. This should also create or update package-lock.json

View File

@ -1,5 +1,5 @@
# Contributor Guidelines
(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad-lite#get-in-touch))
(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad#get-in-touch))
## Pull requests
@ -131,5 +131,5 @@ Etherpad is much more than software. So if you aren't a developer then worry no
* Write proposals for grants
* Co-Author and Publish CVEs
* Work with SFC to maintain legal side of project
* Maintain TODO page - https://github.com/ether/etherpad-lite/wiki/TODO#IMPORTANT_TODOS
* Maintain TODO page - https://github.com/ether/etherpad/wiki/TODO#IMPORTANT_TODOS

View File

@ -16,13 +16,13 @@ jobs:
steps:
-
name: Install libreoffice
uses: awalsh128/cache-apt-pkgs-action@v1.4.2
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
with:
packages: libreoffice libreoffice-pdfimport
version: 1.0
-
name: Install etherpad core
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
repository: ether/etherpad-lite
path: etherpad-lite
@ -44,20 +44,9 @@ jobs:
${{ runner.os }}-pnpm-store-
-
name: Checkout plugin repository
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
path: plugin
-
name: Determine plugin name
id: plugin_name
working-directory: ./plugin
run: |
npx -c 'printf %s\\n "::set-output name=plugin_name::${npm_package_name}"'
-
name: Link plugin directory
working-directory: ./plugin
run: |
pnpm link --global
- name: Remove tests
working-directory: ./etherpad-lite
run: rm -rf ./src/tests/backend/specs
@ -65,28 +54,18 @@ jobs:
name: Install Etherpad core dependencies
working-directory: ./etherpad-lite
run: bin/installDeps.sh
- name: Link plugin to etherpad-lite
- name: Install plugin
working-directory: ./etherpad-lite
run: |
pnpm link --global $PLUGIN_NAME
pnpm run plugins i --path ../../plugin
env:
PLUGIN_NAME: ${{ steps.plugin_name.outputs.plugin_name }}
- name: Link ep_etherpad-lite
working-directory: ./etherpad-lite/src
run: |
pnpm link --global
- name: Link etherpad to plugin
working-directory: ./plugin
run: |
pnpm link --global ep_etherpad-lite
-
name: Run the backend tests
working-directory: ./etherpad-lite
working-directory: ./etherpad-lite/src
run: |
res=$(find .. -path "./node_modules/ep_*/static/tests/backend/specs/**" | wc -l)
shopt -s globstar
res=$(find ./plugin_packages -path "*/static/tests/backend/specs/*" 2>/dev/null | wc -l)
if [ $res -eq 0 ]; then
echo "No backend tests found"
else
pnpm run test
npx cross-env NODE_ENV=production mocha --import=tsx --timeout 120000 --recursive node_modules/ep_*/static/tests/backend/specs/**
fi

View File

@ -12,7 +12,7 @@ jobs:
steps:
-
name: Check out Etherpad core
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
repository: ether/etherpad-lite
- uses: pnpm/action-setup@v3
@ -33,7 +33,7 @@ jobs:
${{ runner.os }}-pnpm-store-
-
name: Check out the plugin
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
path: ./node_modules/__tmp
-
@ -78,7 +78,7 @@ jobs:
- name: Run the frontend tests
shell: bash
run: |
pnpm run prod &
pnpm run dev &
connected=false
can_connect() {
curl -sSfo /dev/null http://localhost:9001/ || return 1

View File

@ -1,5 +1,10 @@
# This workflow will run tests using node and then publish a package to the npm registry when a release is created
# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages
#
# Publishing uses npm Trusted Publishing (OIDC) — no NPM_TOKEN secret is
# required. Each package must have a trusted publisher configured on npmjs.com
# pointing at this workflow file. See:
# https://docs.npmjs.com/trusted-publishers
name: Node.js Package
@ -9,16 +14,24 @@ on:
jobs:
publish-npm:
runs-on: ubuntu-latest
permissions:
contents: write # for the atomic version-bump push (branch + tag)
id-token: write # for npm OIDC trusted publishing
steps:
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
# OIDC trusted publishing needs npm >= 11.5.1, which requires
# Node >= 20.17.0. setup-node's `20` resolves to the latest
# 20.x, which satisfies that.
node-version: 20
registry-url: https://registry.npmjs.org/
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
run: npm install -g npm@latest
- name: Check out Etherpad core
uses: actions/checkout@v3
uses: actions/checkout@v6
with:
repository: ether/etherpad-lite
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v5
name: Install pnpm
with:
version: 10
@ -27,7 +40,7 @@ jobs:
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
- uses: actions/cache@v5
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
@ -35,7 +48,7 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
-
uses: actions/checkout@v3
uses: actions/checkout@v6
with:
fetch-depth: 0
-
@ -47,8 +60,22 @@ jobs:
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
pnpm i
# `pnpm version patch` bumps package.json, makes a commit, and creates
# a `v<new-version>` tag. Capture the new tag name from package.json
# rather than parsing pnpm's output, which has historically varied.
pnpm version patch
git push --follow-tags
NEW_TAG="v$(node -p "require('./package.json').version")"
# CRITICAL: use --atomic so the branch update and the tag update
# succeed (or fail) as a single transaction on the server. The old
# `git push --follow-tags` was non-atomic per ref: if a concurrent
# publish run won the race, the branch fast-forward would be rejected
# but the tag push would still land — leaving a dangling tag with no
# matching commit on the branch. Subsequent runs would then forever
# try to bump to the same already-existing tag and fail with
# `tag 'vN+1' already exists`. With --atomic, a rejected branch push
# rejects the tag push too, and the next workflow tick can retry
# cleanly against the up-to-date refs.
git push --atomic origin "${GITHUB_REF_NAME}" "${NEW_TAG}"
# This is required if the package has a prepare script that uses something
# in dependencies or devDependencies.
-
@ -63,12 +90,10 @@ jobs:
# already-used version number. By running `npm publish` after `git push`,
# back-to-back merges will cause the first merge's workflow to fail but
# the second's will succeed.
-
run: pnpm publish
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
#-
# name: Add package to etherpad organization
# run: pnpm access grant read-write etherpad:developers
# env:
# NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
#
# Use `npm publish` directly (not `pnpm publish`) because OIDC trusted
# publishing requires npm CLI >= 11.5.1 and `pnpm publish` shells out to
# whichever `npm` is on PATH; calling `npm` directly avoids any shim
# ambiguity.
- name: Publish to npm via OIDC
run: npm publish --provenance --access public

View File

@ -1,6 +1,11 @@
name: Node.js Package
on: [push]
# id-token: write must be granted here so the reusable npmpublish workflow
# can request an OIDC token for npm trusted publishing.
permissions:
contents: write
id-token: write
jobs:
backend:
@ -14,5 +19,8 @@ jobs:
needs:
- backend
- frontend
permissions:
contents: write # for the version bump push
id-token: write # for npm OIDC trusted publishing
uses: ./.github/workflows/npmpublish.yml
secrets: inherit

169
bin/setup-trusted-publishers.sh Executable file
View File

@ -0,0 +1,169 @@
#!/bin/sh
#
# Configure npm Trusted Publishers (OIDC) for ep_etherpad and every
# ether/ep_* plugin in bulk.
#
# Prerequisites:
# - npm CLI >= 11.5.1 (the version that ships `npm trust github`)
# - Logged into npmjs.com as a maintainer of the packages: `npm login`
# - `gh` CLI logged in (only needed for plugin discovery; pass --packages
# to skip discovery and use a static list)
#
# Usage:
# bin/setup-trusted-publishers.sh # all ether/ep_* plugins + ep_etherpad
# bin/setup-trusted-publishers.sh --dry-run # print what would happen
# bin/setup-trusted-publishers.sh --packages ep_align,ep_webrtc
# bin/setup-trusted-publishers.sh --skip-existing # don't fail if already configured
# bin/setup-trusted-publishers.sh --otp 123456 # supply 2FA OTP up front
#
# Note: `npm trust github` requires 2FA. If your account has 2FA enabled
# (it should), pass --otp once and the same code will be reused for every
# package call inside the same minute. The TOTP code typically expires
# every 30s, so you may need to run the script in chunks via --packages.
#
# Each package gets a GitHub Actions trusted publisher pointing at the
# canonical workflow file used by that package family:
# - plugins: .github/workflows/test-and-release.yml
# - ep_etherpad: .github/workflows/releaseEtherpad.yml
#
# Existing configurations cannot be overwritten — only one trust relationship
# per package is allowed today. Use `--skip-existing` to ignore those failures.
set -eu
# `npm trust github --file` wants ONLY the workflow filename (basename),
# not the full .github/workflows/<name> path.
PLUGIN_WORKFLOW="test-and-release.yml"
CORE_WORKFLOW="releaseEtherpad.yml"
CORE_PACKAGE="ep_etherpad"
CORE_REPO="etherpad-lite"
ORG="ether"
DRY_RUN=0
SKIP_EXISTING=0
PACKAGES=""
OTP=""
usage() {
sed -n '2,/^$/p' "$0" | sed 's/^# \?//'
exit "${1:-0}"
}
# ---------- arg parsing ----------
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--skip-existing) SKIP_EXISTING=1; shift ;;
--packages) PACKAGES="$2"; shift 2 ;;
--otp) OTP="$2"; shift 2 ;;
-h|--help) usage 0 ;;
*) printf 'Unknown flag: %s\n' "$1" >&2; usage 1 ;;
esac
done
# ---------- prereq checks ----------
is_cmd() { command -v "$1" >/dev/null 2>&1; }
is_cmd npm || { echo "npm CLI not found." >&2; exit 1; }
NPM_MAJOR=$(npm --version | cut -d. -f1)
NPM_MINOR=$(npm --version | cut -d. -f2)
NPM_PATCH=$(npm --version | cut -d. -f3)
if [ "$NPM_MAJOR" -lt 11 ] || \
{ [ "$NPM_MAJOR" -eq 11 ] && [ "$NPM_MINOR" -lt 5 ]; } || \
{ [ "$NPM_MAJOR" -eq 11 ] && [ "$NPM_MINOR" -eq 5 ] && [ "$NPM_PATCH" -lt 1 ]; }; then
echo "npm >= 11.5.1 required (you have $(npm --version)). Run: npm install -g npm@latest" >&2
exit 1
fi
# Verify auth (whoami fails if not logged in). Skipped in --dry-run.
if [ "$DRY_RUN" != "1" ]; then
if ! npm whoami >/dev/null 2>&1; then
echo "Not logged into npm. Run 'npm login' first." >&2
exit 1
fi
fi
# ---------- discover packages ----------
if [ -z "$PACKAGES" ]; then
is_cmd gh || {
echo "gh CLI not found. Either install it or pass --packages ep_a,ep_b,..." >&2
exit 1
}
echo "Discovering ether/ep_* repos..."
PACKAGES=$(gh repo list "$ORG" --limit 300 --json name,isArchived \
--jq '.[] | select(.name | startswith("ep_")) | select(.isArchived | not) | .name' \
| tr '\n' ',' | sed 's/,$//')
PACKAGES="${CORE_PACKAGE},${PACKAGES}"
fi
# ---------- per-package setup ----------
configure_one() {
PKG="$1"
if [ "$PKG" = "$CORE_PACKAGE" ]; then
REPO="$CORE_REPO"
WORKFLOW="$CORE_WORKFLOW"
else
REPO="$PKG"
WORKFLOW="$PLUGIN_WORKFLOW"
fi
printf '%-40s -> %s/%s @ %s\n' "$PKG" "$ORG" "$REPO" "$WORKFLOW"
if [ "$DRY_RUN" = "1" ]; then
printf ' (dry-run) would run: npm trust github %s --repository %s/%s --file %s --yes\n' \
"$PKG" "$ORG" "$REPO" "$WORKFLOW"
return 0
fi
# Disable -e around the npm call so a non-zero exit can never short-circuit
# the STATUS / --skip-existing handling below. In practice the wrapping
# `if configure_one` already suppresses errexit inside this function (POSIX
# errexit-in-conditional behaviour), but relying on that is fragile — anyone
# later refactoring the call site out of an `if` would silently reintroduce
# the bug. The explicit shim makes the intent obvious and survives such
# refactors.
set +e
if [ -n "$OTP" ]; then
OUTPUT=$(npm trust github "$PKG" --repository "$ORG/$REPO" --file "$WORKFLOW" --otp "$OTP" --yes 2>&1)
else
OUTPUT=$(npm trust github "$PKG" --repository "$ORG/$REPO" --file "$WORKFLOW" --yes 2>&1)
fi
STATUS=$?
set -e
if [ "$STATUS" -eq 0 ]; then
printf ' ok\n'
else
# The npm registry returns 409 Conflict when a trust relationship
# already exists (you can only have one per package today). Treat
# that as success when --skip-existing is set, alongside the older
# "already exists/configured" string match.
if [ "$SKIP_EXISTING" = "1" ] && \
echo "$OUTPUT" | grep -qiE "409 Conflict|already (exists|configured)"; then
printf ' already configured (skipped)\n'
return 0
fi
printf ' FAILED:\n%s\n' "$OUTPUT" | sed 's/^/ /'
return 1
fi
}
FAILED=""
TOTAL=0
OK=0
IFS=','
for PKG in $PACKAGES; do
TOTAL=$((TOTAL + 1))
if configure_one "$PKG"; then
OK=$((OK + 1))
else
FAILED="$FAILED $PKG"
fi
done
unset IFS
printf '\n%d/%d packages configured\n' "$OK" "$TOTAL"
if [ -n "$FAILED" ]; then
printf 'Failed:%s\n' "$FAILED"
exit 1
fi

View File

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

View File

@ -73,7 +73,7 @@ export default defineConfig({
},
socialLinks: [
{ icon: 'github', link: 'https://github.com/ether/etherpad-lite' }
{ icon: 'github', link: 'https://github.com/ether/etherpad' }
]
}
})

View File

@ -1,6 +1,6 @@
== Changeset Library
The https://github.com/ether/etherpad-lite/blob/develop/src/static/js/Changeset.ts[changeset
The https://github.com/ether/etherpad/blob/develop/src/static/js/Changeset.ts[changeset
library]
provides tools to create, read, and apply changesets.
@ -31,7 +31,7 @@ const AttributePool = require('ep_etherpad-lite/static/js/AttributePool');
----
Changesets do not include any attribute keyvalue pairs. Instead, they use
numeric identifiers that reference attributes kept in an https://github.com/ether/etherpad-lite/blob/develop/src/static/js/AttributePool.ts[attribute pool].
numeric identifiers that reference attributes kept in an https://github.com/ether/etherpad/blob/develop/src/static/js/AttributePool.ts[attribute pool].
This attribute interning reduces the transmission overhead of attributes that
are used many times.
@ -42,5 +42,5 @@ historical attribute used in the pad.
Detailed information about the changesets & Easysync protocol:
* https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-notes.pdf[Easysync Protocol]
* https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-full-description.pdf[Etherpad and EasySync Technical Manual]
* https://github.com/ether/etherpad/blob/develop/doc/easysync/easysync-notes.pdf[Easysync Protocol]
* https://github.com/ether/etherpad/blob/develop/doc/easysync/easysync-full-description.pdf[Etherpad and EasySync Technical Manual]

View File

@ -29,7 +29,7 @@ const AttributePool = require('ep_etherpad-lite/static/js/AttributePool');
Changesets do not include any attribute keyvalue pairs. Instead, they use
numeric identifiers that reference attributes kept in an [attribute
pool](https://github.com/ether/etherpad-lite/blob/develop/src/static/js/AttributePool.ts).
pool](https://github.com/ether/etherpad/blob/develop/src/static/js/AttributePool.ts).
This attribute interning reduces the transmission overhead of attributes that
are used many times.
@ -40,5 +40,5 @@ historical attribute used in the pad.
Detailed information about the changesets & Easysync protocol:
* [Easysync Protocol](https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-notes.pdf)
* [Etherpad and EasySync Technical Manual](https://github.com/ether/etherpad-lite/blob/develop/doc/easysync/easysync-full-description.pdf)
* [Easysync Protocol](https://github.com/ether/etherpad/blob/develop/doc/easysync/easysync-notes.pdf)
* [Etherpad and EasySync Technical Manual](https://github.com/ether/etherpad/blob/develop/doc/easysync/easysync-full-description.pdf)

View File

@ -211,6 +211,34 @@ The return value of this hook will add elements into the "lineMarkerAttribute"
category, making the aceDomLineProcessLineAttributes hook (documented below)
call for those elements.
=== aceRegisterLineAttributes
Called from: `src/static/js/ace2_inner.ts`
Things in context: None
Tells Etherpad which line attributes should be preserved when a user presses
Enter to split a line. Without this hook, custom line attributes (such as
headings or alignment) are lost when a line is split.
The return value should be an array of attribute names:
[source,javascript]
----
exports.aceRegisterLineAttributes = function(){
return [ 'heading' ];
}
----
When Enter is pressed on a line that has a registered attribute:
* **Middle or end of line:** the attribute is copied to the new line below.
* **Start of line (column 0):** the attribute moves down with the text content,
and the now-empty line above is cleared.
This is backwards compatible — on Etherpad versions that do not have this hook,
the registration is silently ignored.
=== aceInitialized
Called from: `src/static/js/ace2_inner.js`

View File

@ -207,6 +207,33 @@ The return value of this hook will add elements into the "lineMarkerAttribute"
category, making the aceDomLineProcessLineAttributes hook (documented below)
call for those elements.
## aceRegisterLineAttributes
Called from: `src/static/js/ace2_inner.ts`
Things in context: None
Tells Etherpad which line attributes should be preserved when a user presses
Enter to split a line. Without this hook, custom line attributes (such as
headings or alignment) are lost when a line is split.
The return value should be an array of attribute names:
```
exports.aceRegisterLineAttributes = function(){
return [ 'heading' ];
}
```
When Enter is pressed on a line that has a registered attribute:
* **Middle or end of line:** the attribute is copied to the new line below.
* **Start of line (column 0):** the attribute moves down with the text content,
and the now-empty line above is cleared.
This is backwards compatible — on Etherpad versions that do not have this hook,
the registration is silently ignored.
## aceInitialized
Called from: `src/static/js/ace2_inner.js`

View File

@ -65,7 +65,7 @@ Portal submits content into new blog post
=== Usage
==== API version
The latest version is `1.2.15`
The latest version is `1.3.0`
The current version can be queried via /api.

View File

@ -98,7 +98,7 @@ Portal submits content into new blog post
## Usage
### API version
The latest version is `1.2.15`
The latest version is `1.3.0`
The current version can be queried via /api.

View File

@ -5,7 +5,7 @@ Cookies used by Etherpad.
| Name | Sample value | Domain | Path | Expires/max-age | Http-only | Secure | Usage description |
|-------------------|----------------------------------|-------------|------|-----------------|-----------|--------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| express_sid | s%3A7yCNjRmTW8ylGQ53I2IhOwYF9... | example.org | / | Session | true | true | Session ID of the [Express web framework](https://expressjs.com). When Etherpad is behind a reverse proxy, and an administrator wants to use session stickiness, he may use this cookie. If you are behind a reverse proxy, please remember to set `trustProxy: true` in `settings.json`. Set in [webaccess.js#L131](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/hooks/express/webaccess.js#L131). |
| language | en | example.org | / | Session | false | true | The language of the UI (e.g.: `en-GB`, `it`). Set in [pad_editor.js#L111](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad_editor.js#L111). |
| language | en | example.org | / | Session | false | true | The language of the UI (e.g.: `en-GB`, `it`). Set by the pad client when the user changes **My View → Language** (currently in `src/static/js/pad.ts`, via `setMyViewLanguage()`). |
| prefs / prefsHttp | %7B%22epThemesExtTheme%22... | example.org | /p | year 3000 | false | true | Client-side preferences (e.g.: font family, chat always visible, show authorship colors, ...). Set in [pad_cookie.js#L49](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad_cookie.js#L49). `prefs` is used if Etherpad is accessed over HTTPS, `prefsHttp` if accessed over HTTP. For more info see https://github.com/ether/etherpad-lite/issues/3179. |
| token | t.tFzkihhhBf4xKEpCK3PU | example.org | / | 60 days | false | true | A random token representing the author, of the form `t.randomstring_of_lenght_20`. The random string is generated by the client, at ([pad.js#L55-L66](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L55-L66)). This cookie is always set by the client (at [pad.js#L153-L158](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/static/js/pad.js#L153-L158)) without any solicitation from the server. It is used for all the pads accessed via the web UI (not used for the HTTP API). On the server side, its value is accessed at [SecurityManager.js#L33](https://github.com/ether/etherpad-lite/blob/01497aa399690e44393e91c19917d11d025df71b/src/node/db/SecurityManager.js#L33). |

View File

@ -11,7 +11,7 @@ If you are ok downloading a https://hub.docker.com/r/etherpad/etherpad[prebuilt
docker pull etherpad/etherpad
# gets a specific version
docker pull etherpad/etherpad:1.8.0
docker pull etherpad/etherpad:2.6.1
----
=== Build a personalized container
@ -62,24 +62,11 @@ 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/PDF/ODT
==== Rebuilding including export functionality for DOC/DOCX/PDF/ODT
If you want to be able to export your pads to DOC/PDF/ODT files, you can install
either Abiword or Libreoffice via setting a build variable.
===== Via Abiword
For installing Abiword, set the `INSTALL_ABIWORD` build variable to any value.
Also, you will need to configure the path to the abiword executable
via setting the `abiword` property in `<BASEDIR>/settings.json.docker` to
`/usr/bin/abiword` or via setting the environment variable `ABIWORD` to
`/usr/bin/abiword`.
===== Via Libreoffice
For installing Libreoffice instead, set the `INSTALL_SOFFICE` build variable
to any value.
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.
Also, you will need to configure the path to the libreoffice executable
via setting the `soffice` property in `<BASEDIR>/settings.json.docker` to
@ -464,12 +451,8 @@ For the editor container, you can also make it full width by adding `full-width-
| 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)
| `ABIWORD`
| Absolute path to the Abiword executable. Abiword is needed to get advanced import/export features of pads. Setting it to null disables Abiword and will only allow plain text and HTML import/exports.
| `null`
| `SOFFICE`
| This is the absolute path to the soffice executable. LibreOffice can be used in lieu of Abiword to export pads. Setting it to null disables LibreOffice exporting.
| 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`
| `ALLOW_UNKNOWN_FILE_ENDS`

View File

@ -1,15 +1,21 @@
# Docker
The official Docker image is available on https://hub.docker.com/r/etherpad/etherpad.
The official Docker image is published to two registries with identical tags:
## Downloading from Docker Hub
If you are ok downloading a [prebuilt image from Docker Hub](https://hub.docker.com/r/etherpad/etherpad), these are the commands:
- Docker Hub (canonical): https://hub.docker.com/r/etherpad/etherpad
- GitHub Container Registry (mirror): https://github.com/ether/etherpad/pkgs/container/etherpad
The GHCR mirror is useful if you are hitting Docker Hub anonymous pull rate limits (for example on Kubernetes clusters).
## Downloading a prebuilt image
```bash
# gets the latest published version
# from Docker Hub
docker pull etherpad/etherpad
docker pull etherpad/etherpad:2.6.1
# gets a specific version
docker pull etherpad/etherpad:1.8.0
# from GHCR (same image, same tags)
docker pull ghcr.io/ether/etherpad
docker pull ghcr.io/ether/etherpad:2.6.1
```
## Build a personalized container
@ -29,24 +35,11 @@ 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/PDF/ODT
### Rebuilding including export functionality for DOC/DOCX/PDF/ODT
If you want to be able to export your pads to DOC/PDF/ODT files, you can install
either Abiword or Libreoffice via setting a build variable.
#### Via Abiword
For installing Abiword, set the `INSTALL_ABIWORD` build variable to any value.
Also, you will need to configure the path to the abiword executable
via setting the `abiword` property in `<BASEDIR>/settings.json.docker` to
`/usr/bin/abiword` or via setting the environment variable `ABIWORD` to
`/usr/bin/abiword`.
#### Via Libreoffice
For installing Libreoffice instead, set the `INSTALL_SOFFICE` build variable
to any value.
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.
Also, you will need to configure the path to the libreoffice executable
via setting the `soffice` property in `<BASEDIR>/settings.json.docker` to
@ -202,8 +195,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) |
| `ABIWORD` | Absolute path to the Abiword executable. Abiword is needed to get advanced import/export features of pads. Setting it to null disables Abiword and will only allow plain text and HTML import/exports. | `null` |
| `SOFFICE` | This is the absolute path to the soffice executable. LibreOffice can be used in lieu of Abiword to export pads. Setting it to null disables LibreOffice exporting. | `null` |
| `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` |
| `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

@ -0,0 +1,137 @@
# npm Trusted Publishing (OIDC)
Etherpad and every `ether/ep_*` plugin publish to npm using
[npm Trusted Publishing][npm-tp] over OpenID Connect. This eliminates the need
to store, rotate, or accidentally leak long-lived `NPM_TOKEN` secrets — each
publish is authenticated against the GitHub Actions runner with a short-lived
OIDC token instead.
[npm-tp]: https://docs.npmjs.com/trusted-publishers
## How it works
1. The publish workflow declares `permissions: id-token: write`.
2. GitHub Actions issues a signed OIDC token to the runner.
3. The npm CLI (>= 11.5.1) trades that OIDC token for a short-lived publish
credential against npmjs.com.
4. npmjs.com checks the OIDC claims (org, repo, workflow file, branch /
environment) against the package's configured *trusted publisher* and, if
they match, accepts the publish. Provenance attestations are recorded
automatically.
No `NPM_TOKEN` secret is needed in any plugin or in core.
## One-time setup per package
Trusted publishing has to be enabled **once per package**. Use the bundled
script to do every package in one go via the `npm trust` CLI (npm >= 11.5.1):
```sh
# 1. Make sure npm CLI is recent enough
npm install -g npm@latest
# 2. Log in to npmjs.com as a maintainer
npm login
# 3. Bulk-configure every ether/ep_* plugin + ep_etherpad
bin/setup-trusted-publishers.sh
# Or preview without changing anything
bin/setup-trusted-publishers.sh --dry-run
# Or target a specific subset
bin/setup-trusted-publishers.sh --packages ep_align,ep_webrtc
# Or ignore packages that are already configured (the registry only allows
# one trust relationship per package today)
bin/setup-trusted-publishers.sh --skip-existing
# Supply a 2FA OTP up front (required if your npm account has 2FA enabled —
# it should). The same OTP is reused for every package call inside the same
# minute, so for large batches you may need to chunk via --packages.
bin/setup-trusted-publishers.sh --otp 123456
```
> **2FA / OTP note.** `npm trust github` requires an OTP whenever the
> account has 2FA enabled. Without `--otp`, npm will prompt interactively
> per package, which is unworkable in bulk. Pass `--otp <code>` once and the
> script will forward it to every `npm trust github` call. TOTP codes
> typically expire every 30 seconds, so for >30s runs split the work with
> `--packages ep_a,ep_b,...` and re-run with a fresh code.
The script discovers all non-archived `ether/ep_*` repos via `gh repo list`
and runs `npm trust github <pkg> --repository <org>/<repo> --file <workflow>
--yes` for each one. `ep_etherpad` is mapped to the `etherpad-lite` repo and
the `releaseEtherpad.yml` workflow; everything else is mapped to its
same-named repo and `test-and-release.yml`.
If you'd rather click through the npmjs.com UI for a single package: open
`https://www.npmjs.com/package/<name>/access`**Trusted Publisher**
**Add trusted publisher** → Publisher: GitHub Actions, Organization: `ether`,
Repository: as above, Workflow filename: as above, Environment: blank.
Once added, the next push to `main`/`master` will publish via OIDC with no
token at all.
## Migrating an existing package
If a package previously had an `NPM_TOKEN` secret in CI:
1. Add the trusted publisher on npmjs.com (steps above).
2. Bump the workflow to the OIDC version — done in
`bin/plugins/lib/npmpublish.yml` (which is propagated to every plugin by
the `update-plugins` workflow).
3. Remove the now-unused `NPM_TOKEN` secret from the GitHub repo settings.
## Requirements
- **Node.js**: >= 20.17.0 on the runner. npm 11 requires
`^20.17.0 || >=22.9.0`. The npm docs nominally recommend Node 22.14+, but
Node 20.17+ works fine — the project's `engines.node` already requires
`>=20.0.0`, and `setup-node@v6 with version: 20` resolves to the latest 20.x.
- **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
not yet supported by npm trusted publishing.
- **`package.json`**: must declare a `repository` field pointing at the
GitHub repo so npm can verify the OIDC claim. Example:
```json
{
"repository": {
"type": "git",
"url": "https://github.com/ether/ep_align.git"
}
}
```
## Why call `npm publish` directly?
The publish workflows run `npm publish --provenance --access public` rather
than `pnpm publish` or `gnpm publish`. Both wrappers shell out to whichever
`npm` is on `PATH`, but they obscure version requirements: trusted publishing
requires npm >= 11.5.1, and going through the wrapper makes it easy to end up
with the wrong CLI version. Invoking `npm` directly removes that ambiguity.
`pnpm` is still used for everything else (install, build, version bump) — only
the final publish step calls `npm` directly.
## Troubleshooting
**`npm error 404 Not Found - PUT https://registry.npmjs.org/<pkg>`**
The trusted publisher hasn't been configured on npmjs.com for that package, or
the repository / workflow filename in the trusted publisher config doesn't
match the running workflow. Double-check the workflow filename — it must be the
*basename* of the workflow YAML, not the job name.
**`npm error code E_OIDC_NO_TOKEN`**
The workflow is missing `permissions: id-token: write`. Add it to the job
(or to the top-level `permissions:` block).
**`npm error need: 11.5.1`**
The runner is using an older bundled npm. The workflow runs
`npm install -g npm@latest` to fix this — make sure that step ran before the
publish step.

View File

@ -1,6 +1,6 @@
{
"devDependencies": {
"vitepress": "^2.0.0-alpha.15"
"vitepress": "^2.0.0-alpha.17"
},
"scripts": {
"docs:dev": "vitepress dev",

View File

@ -38,7 +38,7 @@ ep_<plugin>/
├ locales/
│ ├ en.json ◄─ English (US) strings
│ └ qqq.json ◄─ optional hints for translators
├ .travis.yml ◄─ Travis CI config
├ .github/workflows/ ◄─ CI workflows (backend / frontend tests, npm publish)
├ LICENSE
├ README.md
├ ep.json ◄─ Etherpad plugin definition

View File

@ -0,0 +1,863 @@
# Fix #7570 (ueberdb2 driver bundling) — 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:** Restore Etherpad Docker production startup for every supported DB backend (broken by `ueberdb2@5.0.45` moving drivers to optional peer deps), and add CI that would have caught it.
**Architecture:** Two coupled PRs. Upstream PR to `ether/ueberDB` moves the 10 DB drivers back from `peerDependencies` (optional) to `dependencies` and publishes `5.0.46`. Downstream PR to `ether/etherpad-lite` bumps `ueberdb2`, declares the same 10 drivers as direct deps as a defensive safety net, and adds a `build-test-db-drivers` CI job with a require-each-driver presence check plus MySQL + Postgres smoke tests that gate publish.
**Tech Stack:** Node.js / pnpm / TypeScript, ueberdb2 KV abstraction, Docker Buildx, GitHub Actions service containers, vitest + testcontainers (upstream tests).
**Spec:** `docs/superpowers/specs/2026-04-20-issue-7570-ueberdb2-drivers-design.md`
**Conventions:**
- All pushes land on `johnmclear/` forks — never `ether/*` directly.
- The branch `fix/issue-7570-ueberdb2-drivers` already exists in `/home/jose/etherpad/etherpad-lite` with the design spec committed.
- Working dirs:
- Downstream: `/home/jose/etherpad/etherpad-lite`
- Upstream ueberDB will be cloned to: `/home/jose/etherpad/ueberDB`
---
## Phase A — Upstream (`ether/ueberDB`)
### Task A1: Create johnmclear/ueberDB fork and clone it
**Files:** none (git/gh only)
- [ ] **Step 1: Create the fork on GitHub**
Run:
```bash
gh repo fork ether/ueberDB --clone=false --default-branch-only
```
Expected: `✓ Created fork johnmclear/ueberDB`.
If it already exists the command prints `johnmclear/ueberDB already exists` — that is fine, continue.
- [ ] **Step 2: Clone the fork locally**
Run:
```bash
git clone https://github.com/johnmclear/ueberDB.git /home/jose/etherpad/ueberDB
cd /home/jose/etherpad/ueberDB
git remote add upstream https://github.com/ether/ueberDB.git
git fetch upstream
```
Expected: clone succeeds, `git remote -v` shows both `origin` (johnmclear) and `upstream` (ether).
- [ ] **Step 3: Identify default branch and sync**
Run:
```bash
git -C /home/jose/etherpad/ueberDB remote show upstream | grep 'HEAD branch'
```
Expected: prints either `HEAD branch: develop` or `HEAD branch: main`. Record the name — subsequent steps refer to it as `<default>`.
Run:
```bash
git -C /home/jose/etherpad/ueberDB checkout <default>
git -C /home/jose/etherpad/ueberDB pull upstream <default>
git -C /home/jose/etherpad/ueberDB push origin <default>
```
Expected: fork's default branch now matches upstream.
- [ ] **Step 4: Create feature branch**
Run:
```bash
git -C /home/jose/etherpad/ueberDB checkout -b fix/bundle-driver-deps
```
Expected: switched to a new branch.
---
### Task A2: Confirm the baseline (existing tests green)
**Files:** none — read-only validation
- [ ] **Step 1: Install deps**
Run:
```bash
cd /home/jose/etherpad/ueberDB && pnpm install
```
Expected: install succeeds, no unusual errors.
- [ ] **Step 2: Run type check and lint**
Run:
```bash
pnpm run ts-check && pnpm run lint
```
Expected: both pass with exit 0.
- [ ] **Step 3: Skip the full driver test suite**
Do **not** run `pnpm test` here — it uses testcontainers and spins up every database, which is slow and requires Docker. CI will run it. If Docker is available and the engineer wants to sanity-run it:
```bash
pnpm test
```
Expected: all driver suites pass.
---
### Task A3: Move drivers from optional peer deps to dependencies
**Files:**
- Modify: `/home/jose/etherpad/ueberDB/package.json`
- [ ] **Step 1: Read current package.json**
Open `/home/jose/etherpad/ueberDB/package.json`. Locate the three relevant blocks: `dependencies`, `peerDependencies`, `peerDependenciesMeta`. Current state (as of 5.0.45):
```json
"dependencies": {
"async": "^3.2.6",
"dirty-ts": "^1.1.8",
"rusty-store-kv": "^1.3.1",
"simple-git": "^3.36.0"
},
"peerDependencies": {
"@elastic/elasticsearch": "^9.3.4",
"cassandra-driver": "^4.8.0",
"mongodb": "^7.1.1",
"mssql": "^12.2.1",
"mysql2": "^3.22.0",
"nano": "^11.0.5",
"pg": "^8.20.0",
"redis": "^5.12.1",
"rethinkdb": "^2.4.2",
"surrealdb": "^2.0.3"
},
"peerDependenciesMeta": {
"@elastic/elasticsearch": {"optional": true},
"cassandra-driver": {"optional": true},
"mongodb": {"optional": true},
"mssql": {"optional": true},
"mysql2": {"optional": true},
"nano": {"optional": true},
"pg": {"optional": true},
"redis": {"optional": true},
"rethinkdb": {"optional": true},
"surrealdb": {"optional": true}
},
```
- [ ] **Step 2: Rewrite the three blocks**
Replace the three blocks with:
```json
"dependencies": {
"@elastic/elasticsearch": "^9.3.4",
"async": "^3.2.6",
"cassandra-driver": "^4.8.0",
"dirty-ts": "^1.1.8",
"mongodb": "^7.1.1",
"mssql": "^12.2.1",
"mysql2": "^3.22.0",
"nano": "^11.0.5",
"pg": "^8.20.0",
"redis": "^5.12.1",
"rethinkdb": "^2.4.2",
"rusty-store-kv": "^1.3.1",
"simple-git": "^3.36.0",
"surrealdb": "^2.0.3"
},
```
Delete the `peerDependencies` and `peerDependenciesMeta` blocks entirely.
Keys must be alphabetically sorted in the merged `dependencies` block (matches existing convention).
- [ ] **Step 3: Bump version**
In the same file, change `"version": "5.0.45"` to `"version": "5.0.46"`.
- [ ] **Step 4: Regenerate lockfile**
Run:
```bash
cd /home/jose/etherpad/ueberDB && pnpm install
```
Expected: `pnpm-lock.yaml` updates. No errors. No warnings about missing peer deps (since there are now none).
- [ ] **Step 5: Re-run ts-check and lint**
Run:
```bash
pnpm run ts-check && pnpm run lint
```
Expected: exit 0.
- [ ] **Step 6: Verify drivers now resolve in a fresh node_modules**
Run:
```bash
cd /tmp && rm -rf uberdb-smoke && mkdir uberdb-smoke && cd uberdb-smoke
npm init -y >/dev/null
npm install file:/home/jose/etherpad/ueberDB 2>&1 | tail -3
node -e "
const mods = [
'@elastic/elasticsearch','cassandra-driver','mongodb','mssql',
'mysql2','nano','pg','redis','rethinkdb','surrealdb'
];
for (const m of mods) {
try { require(m); console.log('ok', m); }
catch (e) { console.error('MISSING', m, e.message); process.exit(1); }
}
"
```
Expected: prints `ok` ten times, exits 0.
This is the direct repro test for the issue.
---
### Task A4: Commit and push upstream fix
**Files:**
- Stage: `/home/jose/etherpad/ueberDB/package.json`, `/home/jose/etherpad/ueberDB/pnpm-lock.yaml`
- [ ] **Step 1: Review staged diff**
Run:
```bash
cd /home/jose/etherpad/ueberDB
git diff package.json
git status
```
Expected: only `package.json` and `pnpm-lock.yaml` modified. Confirm the diff matches Task A3.
- [ ] **Step 2: Commit**
Run:
```bash
git add package.json pnpm-lock.yaml
git commit -m "$(cat <<'EOF'
fix: bundle DB drivers as dependencies (restore pre-5.0.45 behavior)
Moves the ten DB drivers back from optional peerDependencies to
dependencies so consumers (notably Etherpad Docker production images)
get them installed automatically.
Fixes Etherpad issue ether/etherpad-lite#7570:
"Cannot find module 'mysql2'" at startup when pnpm production install
skips optional peer deps.
EOF
)"
```
Expected: one commit created on `fix/bundle-driver-deps`.
- [ ] **Step 3: Push to fork**
Run:
```bash
git push -u origin fix/bundle-driver-deps
```
Expected: branch pushed to `johnmclear/ueberDB`.
---
### Task A5: Open upstream PR
**Files:** none
- [ ] **Step 1: Create PR**
Run:
```bash
gh pr create \
--repo ether/ueberDB \
--base <default> \
--head johnmclear:fix/bundle-driver-deps \
--title "fix: bundle DB drivers as dependencies (fix Etherpad #7570)" \
--body "$(cat <<'EOF'
## Summary
- Moves all ten DB drivers (`@elastic/elasticsearch`, `cassandra-driver`, `mongodb`, `mssql`, `mysql2`, `nano`, `pg`, `redis`, `rethinkdb`, `surrealdb`) from `peerDependencies` + `peerDependenciesMeta.optional` back to `dependencies`.
- Deletes `peerDependenciesMeta`.
- Bumps version to 5.0.46.
## Why
`ueberdb2@5.0.45` declared the drivers as optional peer deps. Production installs (e.g., `pnpm install --prod`) skip optional peer deps, so consumers hit `Error: Cannot find module 'mysql2'` at first `require` of a driver.
Reported against Etherpad Docker: https://github.com/ether/etherpad-lite/issues/7570
## Test plan
- [ ] CI green (vitest + testcontainers exercises every driver)
- [ ] Local smoke: `npm install ueberdb2@next` into a fresh project, then `require()` each of the ten driver modules — all resolve
EOF
)"
```
Replace `<default>` with whatever Task A1 step 3 recorded (`develop` or `main`).
Expected: PR URL printed.
- [ ] **Step 2: Record PR URL**
Note the PR URL — downstream task (closing #7571) references it.
---
### Task A6: Await merge + publish to npm
**Files:** none — gating task
- [ ] **Step 1: Merge upstream PR**
Once review passes on the PR opened in Task A5, merge it. Then:
```bash
cd /home/jose/etherpad/ueberDB
git checkout <default>
git pull upstream <default>
```
Expected: local default branch is at the merge commit.
- [ ] **Step 2: Publish to npm**
Run the project's existing release workflow. Typically:
```bash
cd /home/jose/etherpad/ueberDB
npm publish
```
(Or trigger the CI publish workflow if one exists — check `.github/workflows/` first.)
Expected: `ueberdb2@5.0.46` is live on npm. Verify:
```bash
npm view ueberdb2 version
```
Expected output: `5.0.46`.
**Do not start Phase B until this step completes** — downstream `pnpm install` must be able to resolve `ueberdb2@^5.0.46` from the public registry.
---
## Phase B — Downstream (`ether/etherpad-lite`)
### Task B1: Close Copilot's PR #7571
**Files:** none — GitHub issue action
- [ ] **Step 1: Comment and close**
Run:
```bash
gh pr close 7571 \
--repo ether/etherpad-lite \
--comment "Superseded by the upstream ueberdb2 fix (<Task A5 PR URL>) plus a clean downstream replacement PR incoming on branch \`fix/issue-7570-ueberdb2-drivers\`. See design spec: docs/superpowers/specs/2026-04-20-issue-7570-ueberdb2-drivers-design.md."
```
Expected: PR #7571 closed with comment.
---
### Task B2: Reproduce the bug locally (baseline)
**Files:** none — validation
- [ ] **Step 1: Confirm we are on the feature branch**
Run:
```bash
cd /home/jose/etherpad/etherpad-lite
git status
```
Expected: `On branch fix/issue-7570-ueberdb2-drivers`.
- [ ] **Step 2: Build current Docker production image**
Run:
```bash
cd /home/jose/etherpad/etherpad-lite
docker build --target production -t etherpad:pre-fix .
```
Expected: build succeeds.
- [ ] **Step 3: Demonstrate the missing module**
Run:
```bash
docker run --rm etherpad:pre-fix node -e "try { require('mysql2'); console.log('HAS mysql2'); } catch(e) { console.log('MISSING mysql2:', e.message); }"
```
Expected output: `MISSING mysql2: Cannot find module 'mysql2'`.
This confirms the bug reproduces in the current prod image. Keep this evidence — the same command run in Task B5 Step 3 against the fixed image should print `HAS mysql2`.
---
### Task B3: Bump ueberdb2 and add drivers to `src/package.json`
**Files:**
- Modify: `/home/jose/etherpad/etherpad-lite/src/package.json`
- [ ] **Step 1: Read current dependencies block**
Open `/home/jose/etherpad/etherpad-lite/src/package.json`. Locate the `dependencies` object.
- [ ] **Step 2: Add ten driver entries, keep alphabetical order, bump ueberdb2**
Within `dependencies`, add these keys (inserted in alphabetical position; do not touch any other key):
```json
"@elastic/elasticsearch": "^9.3.4",
"cassandra-driver": "^4.8.0",
"mongodb": "^7.1.1",
"mssql": "^12.2.1",
"mysql2": "^3.22.0",
"nano": "^11.0.5",
"pg": "^8.20.0",
"redis": "^5.12.1",
"rethinkdb": "^2.4.2",
"surrealdb": "^2.0.3",
```
And change the existing `ueberdb2` entry from its current value to:
```json
"ueberdb2": "^5.0.46",
```
**Do not touch** any other dependency, devDependency, script, or metadata field. Specifically the following must remain unchanged vs `origin/develop`:
- `typescript`, `oidc-provider`, `eslint-config-etherpad`, `@types/express-session`, `resolve` versions
- `repository.url`
- `test-admin` npm script and its project list
- Every other field.
- [ ] **Step 3: Verify diff is minimal**
Run:
```bash
cd /home/jose/etherpad/etherpad-lite
git diff src/package.json
```
Expected: exactly 11 lines added (10 new drivers + no other lines) and exactly 1 line modified (the `ueberdb2` version bump). If anything else shows up, revert it.
---
### Task B4: Regenerate `pnpm-lock.yaml`
**Files:**
- Modify: `/home/jose/etherpad/etherpad-lite/pnpm-lock.yaml`
- [ ] **Step 1: Install**
Run:
```bash
cd /home/jose/etherpad/etherpad-lite/src
pnpm install
```
Expected: install succeeds, resolves `ueberdb2@5.0.46` plus the ten drivers. No missing-peer-dep warnings.
- [ ] **Step 2: Sanity-check lockfile scope**
Run:
```bash
cd /home/jose/etherpad/etherpad-lite
git diff --stat pnpm-lock.yaml
```
Expected: only `pnpm-lock.yaml` modified. No unrelated `package.json` files touched.
---
### Task B5: Verify fix locally
**Files:** none — validation
- [ ] **Step 1: Rebuild Docker production image with the fix**
Run:
```bash
cd /home/jose/etherpad/etherpad-lite
docker build --target production -t etherpad:post-fix .
```
Expected: build succeeds.
- [ ] **Step 2: Presence test — all ten drivers resolve**
Run:
```bash
docker run --rm etherpad:post-fix node -e "
const mods = [
'@elastic/elasticsearch','cassandra-driver','mongodb','mssql',
'mysql2','nano','pg','redis','rethinkdb','surrealdb'
];
for (const m of mods) {
try { require(m); console.log('ok', m); }
catch (e) { console.error('MISSING', m, e.message); process.exit(1); }
}
"
```
Expected output: ten lines starting with `ok `, exit code 0.
- [ ] **Step 3: MySQL end-to-end repro (exact issue reporter scenario)**
Create `/tmp/et7570-compose.yml`:
```yaml
services:
app:
image: etherpad:post-fix
depends_on:
- mariadb
environment:
NODE_ENV: production
ADMIN_PASSWORD: admin
DB_CHARSET: utf8mb4
DB_HOST: mariadb
DB_NAME: etherpad
DB_PASS: password
DB_PORT: 3306
DB_TYPE: mysql
DB_USER: user
DEFAULT_PAD_TEXT: "Test "
TRUST_PROXY: "true"
ports:
- "9001:9001"
mariadb:
image: mariadb:11.4
environment:
MYSQL_DATABASE: etherpad
MYSQL_USER: user
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: root
MARIADB_AUTO_UPGRADE: 1
```
Run:
```bash
docker compose -f /tmp/et7570-compose.yml up -d
sleep 30
curl -sf http://localhost:9001/ >/dev/null && echo "OK: Etherpad serves / over MySQL"
docker compose -f /tmp/et7570-compose.yml logs app | grep -c "Cannot find module 'mysql2'" || true
docker compose -f /tmp/et7570-compose.yml down -v
```
Expected: `OK: Etherpad serves / over MySQL`, and the grep count is `0`.
If `curl` fails, inspect `docker compose logs app` — startup may just be slow on the first run; wait 30 more seconds and retry `curl`. If the missing-module error still appears, Task B3/B4 were not applied cleanly.
---
### Task B6: Add the `build-test-db-drivers` CI job
**Files:**
- Modify: `/home/jose/etherpad/etherpad-lite/.github/workflows/docker.yml`
- [ ] **Step 1: Read the current workflow end-to-end**
Open `/home/jose/etherpad/etherpad-lite/.github/workflows/docker.yml` and identify:
- The `jobs:` key and the existing job(s) under it (likely `docker` and/or `publish` after PR #7569).
- The value of `env.TEST_TAG` (expected: `etherpad/etherpad:test`).
- Any `needs:` on a `publish` job.
Do not restructure existing jobs. Only append the new job and extend `needs:`.
- [ ] **Step 2: Append the new job**
Append the following job under `jobs:` (same indentation as existing jobs), placed after the existing `build-test` / `docker` job:
```yaml
build-test-db-drivers:
runs-on: ubuntu-latest
permissions:
contents: read
services:
mysql:
image: mysql:8
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: etherpad
MYSQL_USER: etherpad
MYSQL_PASSWORD: password
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -ppassword"
--health-interval=5s
--health-timeout=5s
--health-retries=20
postgres:
image: postgres:16
env:
POSTGRES_DB: etherpad
POSTGRES_USER: etherpad
POSTGRES_PASSWORD: password
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U etherpad -d etherpad"
--health-interval=5s
--health-timeout=5s
--health-retries=20
steps:
- name: Check out
uses: actions/checkout@v6
with:
path: etherpad
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build production image
uses: docker/build-push-action@v7
with:
context: ./etherpad
target: production
load: true
tags: ${{ env.TEST_TAG }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Driver presence test (all 10 drivers must resolve)
run: |
docker run --rm "$TEST_TAG" node -e "
const mods = [
'@elastic/elasticsearch','cassandra-driver','mongodb','mssql',
'mysql2','nano','pg','redis','rethinkdb','surrealdb'
];
let fail = false;
for (const m of mods) {
try { require(m); console.log('ok', m); }
catch (e) { console.error('MISSING', m, e.message); fail = true; }
}
if (fail) process.exit(1);
"
- name: MySQL smoke — start Etherpad against mysql service
run: |
docker run --rm -d \
--network host \
-e NODE_ENV=production \
-e ADMIN_PASSWORD=admin \
-e DB_TYPE=mysql \
-e DB_HOST=127.0.0.1 \
-e DB_PORT=3306 \
-e DB_NAME=etherpad \
-e DB_USER=etherpad \
-e DB_PASS=password \
-e DB_CHARSET=utf8mb4 \
-e DEFAULT_PAD_TEXT="Test " \
--name et-mysql "$TEST_TAG"
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:9001/ >/dev/null; then
echo "mysql smoke: Etherpad is serving /"
docker rm -f et-mysql
exit 0
fi
sleep 2
done
echo "mysql smoke: timed out waiting for Etherpad"
docker logs et-mysql || true
docker rm -f et-mysql || true
exit 1
- name: Postgres smoke — start Etherpad against postgres service
run: |
docker run --rm -d \
--network host \
-e NODE_ENV=production \
-e ADMIN_PASSWORD=admin \
-e DB_TYPE=postgres \
-e DB_HOST=127.0.0.1 \
-e DB_PORT=5432 \
-e DB_NAME=etherpad \
-e DB_USER=etherpad \
-e DB_PASS=password \
-e DEFAULT_PAD_TEXT="Test " \
--name et-postgres "$TEST_TAG"
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:9001/ >/dev/null; then
echo "postgres smoke: Etherpad is serving /"
docker rm -f et-postgres
exit 0
fi
sleep 2
done
echo "postgres smoke: timed out waiting for Etherpad"
docker logs et-postgres || true
docker rm -f et-postgres || true
exit 1
```
Notes:
- `$TEST_TAG` comes from workflow-level `env.TEST_TAG` — no need to redeclare.
- Port 9001 is Etherpad's default bind port; `--network host` lets us hit the service containers directly.
- Stage order (presence → mysql → postgres) means the fastest, clearest failure mode runs first.
- [ ] **Step 3: Extend `publish` job's `needs:` to gate on this new job**
Locate the `publish` job (added in PR #7569). Its `needs:` currently looks like either:
```yaml
needs: build-test
```
or
```yaml
needs: [build-test]
```
Change to:
```yaml
needs: [build-test, build-test-db-drivers]
```
If no `publish` job exists yet (shouldn't happen post-#7569), the existing job that pushes images (probably containing `docker/login-action`) is the one to gate. Apply the same change to its `needs:`.
- [ ] **Step 4: Lint the YAML**
Run:
```bash
cd /home/jose/etherpad/etherpad-lite
python3 -c "import yaml; yaml.safe_load(open('.github/workflows/docker.yml'))"
```
Expected: exit 0, no output.
- [ ] **Step 5: Confirm no unrelated changes to `docker.yml`**
Run:
```bash
git diff .github/workflows/docker.yml | head -50
git diff --stat .github/workflows/docker.yml
```
Expected: only additions (the new job + the `needs:` change). No other lines modified.
---
### Task B7: Commit and push downstream fix
**Files:**
- Stage: `src/package.json`, `pnpm-lock.yaml`, `.github/workflows/docker.yml`
- [ ] **Step 1: Verify final diff scope**
Run:
```bash
cd /home/jose/etherpad/etherpad-lite
git status
git diff --stat
```
Expected (modified files only):
```
.github/workflows/docker.yml | 90+ additions
pnpm-lock.yaml | large change
src/package.json | 11 additions, 1 change
```
The spec commit from earlier (branch `fix/issue-7570-ueberdb2-drivers`) is already present from previous work — `git log --oneline` should show it as the tip.
- [ ] **Step 2: Commit**
Run:
```bash
cd /home/jose/etherpad/etherpad-lite
git add src/package.json pnpm-lock.yaml .github/workflows/docker.yml
git commit -m "$(cat <<'EOF'
fix(#7570): bundle DB drivers, add regression CI
- Bump ueberdb2 to ^5.0.46 (upstream now re-bundles drivers).
- Declare all 10 ueberdb2 DB drivers as direct src dependencies as a
defensive safety net against a future upstream drift.
- Add build-test-db-drivers CI job that blocks the publish job:
* all-10-drivers presence check in the built prod image
* end-to-end MySQL smoke (reproduces #7570)
* end-to-end Postgres smoke
Any stage failure blocks Docker Hub / GHCR publish.
EOF
)"
```
Expected: one commit on `fix/issue-7570-ueberdb2-drivers`.
- [ ] **Step 3: Add johnmclear fork remote if needed**
Run:
```bash
cd /home/jose/etherpad/etherpad-lite
git remote -v | grep -q '^fork' || git remote add fork https://github.com/johnmclear/etherpad-lite.git
```
Expected: `fork` remote exists.
- [ ] **Step 4: Push branch to fork**
Run:
```bash
git push -u fork fix/issue-7570-ueberdb2-drivers
```
Expected: branch pushed to `johnmclear/etherpad-lite`.
---
### Task B8: Open downstream PR
**Files:** none
- [ ] **Step 1: Create PR**
Run:
```bash
gh pr create \
--repo ether/etherpad-lite \
--base develop \
--head johnmclear:fix/issue-7570-ueberdb2-drivers \
--title "fix(#7570): bundle DB drivers, add regression CI" \
--body "$(cat <<'EOF'
## Summary
- Bumps `ueberdb2` to `^5.0.46` — upstream PR `<Task A5 PR URL>` restored drivers as real dependencies.
- Declares all 10 ueberdb2 DB drivers as direct `src/package.json` dependencies as a defensive safety net against any future upstream drift.
- Adds a new `build-test-db-drivers` CI job that blocks `publish`:
- presence test for all 10 drivers in the built production image
- MySQL service-container smoke (reproduces #7570)
- Postgres service-container smoke
- Supersedes #7571 (which mixed scope).
## Test plan
- [ ] `build-test` passes on this PR (existing coverage)
- [ ] `build-test-db-drivers` passes — specifically the MySQL stage is the live reproduction of #7570
- [ ] Local: `docker compose up` with reporter's MySQL config reaches healthy and serves `/`
EOF
)"
```
Expected: PR URL printed. Record it.
---
### Task B9: Verify downstream CI is green
**Files:** none — validation + iteration
- [ ] **Step 1: Watch CI**
Run:
```bash
gh pr checks <PR number from B8> --repo ether/etherpad-lite --watch
```
Expected: all checks green. The two critical ones:
- `build-test` (pre-existing)
- `build-test-db-drivers` (new, with its four stages)
- [ ] **Step 2: If a stage fails, read the logs and fix**
Run:
```bash
gh run view --log-failed --repo ether/etherpad-lite
```
Common failure modes and fixes:
- **Presence test fails for a specific module**`src/package.json` missing that driver. Re-check Task B3.
- **MySQL smoke times out** → increase retry loop from 60 to 90 iterations, or increase `--health-retries` on the service, and push again. Do not skip the stage.
- **Postgres smoke times out** → same pattern.
- **YAML parse error** → re-run Task B6 Step 4 locally, fix, force-push.
Iterate until green. Per project rule, update PR title/description after every push and post `/review` as a comment to trigger Qodo re-review.
- [ ] **Step 3: Hand off for human review**
Once CI is green, notify the user the PR is ready for review. No further automated action.
---
## Self-review
**Spec coverage:**
- Upstream ueberdb2 driver move → Tasks A1A6
- Downstream `ueberdb2` bump + driver safety-net list → B3, B4
- Close #7571 → B1
- CI job (presence + MySQL + Postgres, gating publish) → B6
- No unrelated bumps → explicit guard in B3 Step 2 + B7 Step 1 diff check
- Local verification matches spec's "Local verification before pushing" → B5 Steps 13
- Rollout order matches spec → Phase A gates Phase B via A6
- Commit targets use johnmclear fork → A1 for upstream, B7 Step 3 for downstream
**Placeholders:** One deliberate placeholder remains (`<default>` for ueberDB's default branch name in A1/A4/A5, and `<Task A5 PR URL>` referenced from B1/B8). These are values the engineer fills in once observed from `gh`/`git`; they are not "TBD implementation details."
**Consistency:** `build-test-db-drivers` job name used identically in B6, B7 commit message, and B8 PR body. Driver list of ten appears identically in A3, B3, B5, and B6. Version `5.0.46` used consistently across A3, A5, A6, B3.

View File

@ -0,0 +1,222 @@
# Design: Fix #7570`Cannot find module 'mysql2'` in Docker production image
Upstream: <https://github.com/ether/etherpad-lite/issues/7570>
## Problem
`ueberdb2@5.0.45` moved its ten database driver dependencies from normal
`dependencies` to `peerDependencies` with `peerDependenciesMeta.optional = true`.
Production `pnpm install` in the Etherpad Docker image does not install
optional peer dependencies, so the drivers are absent. At startup,
`ueberdb2`'s driver loader does `require('mysql2')` (or the configured
driver) and crashes:
```
Error: Cannot find module 'mysql2'
Require stack:
- /opt/etherpad-lite/node_modules/.pnpm/ueberdb2@5.0.45/node_modules/ueberdb2/dist/mysql_db-*.js
- /opt/etherpad-lite/node_modules/.pnpm/ueberdb2@5.0.45/node_modules/ueberdb2/dist/index.js
- /opt/etherpad-lite/src/node/db/DB.ts
```
The bug affects every non-default DB backend (mysql, postgres, mongodb,
mssql, redis, couchdb, cassandra, elasticsearch, rethinkdb, surrealdb).
The reporter hit it with MySQL because MySQL is the most common prod
backend; the class of failure is identical for the other nine.
The earlier Copilot PR #7571 attempted a fix but (a) initially only listed
`mysql2`, (b) now lists all ten but only regression-tests MySQL in CI,
and (c) accumulated unrelated scope (bumps to `typescript`,
`oidc-provider`, `eslint-config-etherpad`, `@types/express-session`,
`resolve`; firefox removed from `test-admin`; a repo URL change; a
docker.yml restructure that collides with the already-merged GHCR PR
#7569). This spec replaces it.
## Goals
1. Restore Docker prod startup for every supported DB backend.
2. Prevent the same class of regression from silently returning.
3. Keep the downstream diff minimal and reviewable.
## Non-goals
- Refactoring ueberdb2 to a plugin-per-driver architecture.
- Changing which backends Etherpad officially supports.
- Addressing other unrelated dependency bumps.
## Architecture
Two independent changes that stack:
**Upstream fix (`ether/ueberDB`)** — the real "reintroduce bundling."
Move the ten DB drivers from `peerDependencies` + `peerDependenciesMeta`
back to `dependencies`. Publish as a new patch version. Restores
pre-5.0.45 behavior where `npm install ueberdb2` pulls the drivers.
**Downstream fix (`ether/etherpad-lite`)** — bump `ueberdb2` to the new
version, additionally declare the ten drivers as direct `dependencies`
in `src/package.json` as a defensive safety net, and add a CI regression
test that would have caught #7570.
The downstream driver listing is redundant on a good day and
load-bearing on a bad day: if a future ueberdb2 release drifts the
peer-vs-dep classification again, Etherpad stays buildable and the CI
job reports the drift loudly rather than only at Docker startup in
production.
## Upstream change (`ether/ueberDB`)
**File:** `package.json`
1. Move these ten entries from `peerDependencies``dependencies`,
preserving version ranges exactly as currently declared in 5.0.45:
- `@elastic/elasticsearch` `^9.3.4`
- `cassandra-driver` `^4.8.0`
- `mongodb` `^7.1.1`
- `mssql` `^12.2.1`
- `mysql2` `^3.22.0`
- `nano` `^11.0.5`
- `pg` `^8.20.0`
- `redis` `^5.12.1`
- `rethinkdb` `^2.4.2`
- `surrealdb` `^2.0.3`
2. Delete the `peerDependenciesMeta` block entirely.
3. Version bump: `5.0.45``5.0.46`.
**Tradeoff accepted:** every ueberdb2 consumer now installs ~250 MB of
driver code regardless of which backend they use. This is the pre-5.0.45
behavior, is what Etherpad needs, and avoids a larger
plugin-per-driver refactor.
**Verification:** ueberdb2's existing vitest + testcontainers suite
exercises every driver end-to-end. If the migration is correct, CI stays
green. Nothing else needs adding upstream.
**Delivery:** PR from `johnmclear/ueberDB` fork into `ether/ueberDB`
default branch. After merge, publish `ueberdb2@5.0.46` to npm via the
existing release workflow.
## Downstream change (`ether/etherpad-lite`)
### Decision: replace PR #7571 rather than rebase
Copilot's #7571 has correct direction but mixed scope. Cleanest path is
to close it with a pointer to the replacement and open a fresh branch
off the current `ether/etherpad-lite:develop` (post #7569 merge). No
shared commits.
### `src/package.json`
- Bump `ueberdb2``^5.0.46`.
- Add the ten drivers to `dependencies`, with the **same version ranges
ueberdb2 itself declares**, so the two lists can't drift on the
range:
- `@elastic/elasticsearch` `^9.3.4`
- `cassandra-driver` `^4.8.0`
- `mongodb` `^7.1.1`
- `mssql` `^12.2.1`
- `mysql2` `^3.22.0`
- `nano` `^11.0.5`
- `pg` `^8.20.0`
- `redis` `^5.12.1`
- `rethinkdb` `^2.4.2`
- `surrealdb` `^2.0.3`
- No other edits. Specifically do **not** bump
`typescript`, `oidc-provider`, `eslint-config-etherpad`,
`@types/express-session`, or `resolve`, and do **not** change the
`repository.url` field or the `test-admin` project list.
### `pnpm-lock.yaml`
Regenerated via `pnpm install` on the clean branch. Not cherry-picked
from #7571.
### `.github/workflows/docker.yml`
Add one new job, `build-test-db-drivers`, alongside the existing
`build-test`. **No restructuring of existing jobs.** The `publish` job's
`needs:` is extended from `[build-test]` to `[build-test, build-test-db-drivers]`
so driver regressions block publication.
The new job runs four sequential stages in a single job (so any failure
blocks `publish` via `needs:`):
1. **Build production image** — reuse the existing buildx + GHA cache
pattern from `build-test`.
2. **Driver presence test (all ten, fast).** Run one container:
```
docker run --rm "$TEST_TAG" node -e "
const mods = [
'@elastic/elasticsearch','cassandra-driver','mongodb','mssql',
'mysql2','nano','pg','redis','rethinkdb','surrealdb'
];
for (const m of mods) {
try { require(m); console.log('ok', m); }
catch (e) { console.error('MISSING', m, e.message); process.exit(1); }
}
"
```
This is the precise regression test for the #7570 class — catches
"driver missing from production image" for every backend in seconds.
3. **MySQL smoke test.** `mysql:8` service container, launch Etherpad
with `DB_TYPE=mysql` against the service, poll container health for
up to ~2 minutes, fail on unhealthy. Reproduces the issue reporter's
scenario.
4. **Postgres smoke test.** `postgres:16` service container, same
pattern with `DB_TYPE=postgres`. Covers the other common prod
backend.
Stages 24 are the actual regression signal; stage 1 just prepares the
image they run against.
The other seven backends (mongodb, mssql, redis, couchdb/nano,
cassandra, elasticsearch, rethinkdb, surrealdb) are covered by the
presence test only. Full service-container smokes for all ten would
10× CI time and several of them (SurrealDB, RethinkDB, Cassandra) are
awkward to stand up reliably on GitHub-hosted runners. If a specific
backend regresses in practice, we upgrade its coverage then.
## Rollout order
1. Open fork PR against `ether/ueberDB` with the ten-driver move + version bump.
2. Merge and publish `ueberdb2@5.0.46` to npm.
3. Close `ether/etherpad-lite#7571` with a comment linking here.
4. Open fork PR against `ether/etherpad-lite:develop` with the
`src/package.json` + `pnpm-lock.yaml` + `docker.yml` changes.
5. Confirm CI goes green on the new PR — specifically the MySQL stage
of `build-test-db-drivers` is the live reproduction of #7570.
Step 4 depends on step 2 (the new `pnpm-lock.yaml` must be able to
resolve `ueberdb2@^5.0.46` from the public registry).
## Local verification before pushing
- `pnpm install` resolves cleanly with no warnings about missing peer deps.
- `docker build --target production -t etherpad:test .` succeeds.
- `docker run --rm etherpad:test node -e "<presence script>"` prints `ok` for all ten.
- `docker compose up` with the issue reporter's exact compose file
(mariadb:11.4, `DB_TYPE=mysql`) reaches a healthy state and serves
`/`.
## Testing
The new CI job is itself the regression test. No separate unit tests
added — the failure mode is a packaging concern, not a code-path
concern, and unit tests cannot observe it.
## Out of scope
- Reverting or auditing the unrelated bumps included in #7571. If any
of those bumps is wanted independently, it gets its own PR.
- Reworking the Docker image to slim down the ~250 MB driver payload
for users who only need SQLite. If this matters, future work could
introduce a build arg that prunes unneeded drivers post-install.
## Commit targets
Per project rules, both PRs originate from `johnmclear/` forks, never
direct commits to `ether/*`. ueberDB fork to be created if it does not
already exist.

View File

@ -46,9 +46,35 @@
},
"repository": {
"type": "git",
"url": "https://github.com/ether/etherpad-lite.git"
"url": "https://github.com/ether/etherpad.git"
},
"engineStrict": true,
"version": "2.6.1",
"license": "Apache-2.0"
"version": "2.7.0",
"license": "Apache-2.0",
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
],
"ignoredBuiltDependencies": [
"@scarf/scarf"
],
"overrides": {
"basic-ftp": ">=5.3.0",
"brace-expansion@>=2.0.0 <2.0.3": ">=2.0.3",
"diff@>=6.0.0 <8.0.3": ">=8.0.3",
"flatted": ">=3.4.2",
"follow-redirects": ">=1.16.0",
"glob@>=10.2.0 <10.5.0": ">=10.5.0",
"js-yaml@>=4.0.0 <4.1.1": ">=4.1.1",
"lodash": ">=4.18.0",
"minimatch@>=9.0.0 <9.0.7": ">=9.0.7",
"path-to-regexp@>=8.0.0 <8.4.0": ">=8.4.0",
"picomatch@>=4.0.0 <4.0.4": ">=4.0.4",
"qs@>=6.7.0 <6.14.2": ">=6.14.2",
"serialize-javascript": ">=7.0.5",
"socket.io-parser@>=4.0.0 <4.2.6": ">=4.2.6",
"tar@<7.5.11": ">=7.5.11",
"vite@>=7.0.0 <7.3.2": ">=7.3.2"
}
}
}

6302
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,13 @@ packages:
- doc
- ui
onlyBuiltDependencies:
- '@scarf/scarf'
- '@swc/core'
- esbuild
# Explicitly ignore build scripts we don't want to run. Listing them here
# stops pnpm from failing with ERR_PNPM_IGNORED_BUILDS when they're
# encountered as transitive deps (e.g. scarf pulled in via swagger-ui-dist).
ignoredBuiltDependencies:
- '@scarf/scarf'
# Belt-and-suspenders: even if a fresh transitive dep slips through with a
# postinstall script, downgrade to a warning so CI doesn't break for
# downstream plugin repos that pull etherpad-lite as their core install.
strictDepBuilds: false

View File

@ -91,7 +91,7 @@
* "password": "${PASSW:}" // if PASSW is not defined would result in password === ''
*
* If you want to use an empty value (null) as default value for a variable,
* simply do not set it, without putting any colons: "${ABIWORD}".
* simply do not set it, without putting any colons: "${SOFFICE}".
*
* 3) if you want to use newlines in the default value of a string parameter,
* use "\n" as usual.
@ -205,6 +205,12 @@
**/
"enableDarkMode": "${ENABLE_DARK_MODE:true}",
/**
* Enable creator-owned Pad-wide Settings and new-pad default seeding from My View.
* Disabled by default to preserve the legacy single-settings behavior.
**/
"enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}",
/*
* Node native SSL support
*
@ -348,25 +354,23 @@
*/
"maxAge": "${MAX_AGE:21600}", // 60 * 60 * 6 = 6 hours
/*
* Absolute path to the Abiword executable.
*
* Abiword is needed to get advanced import/export features of pads. Setting
* it to null disables Abiword and will only allow plain text and HTML
* import/exports.
*/
"abiword": "${ABIWORD:null}",
/*
* This is the absolute path to the soffice executable.
*
* LibreOffice can be used in lieu of Abiword to export pads.
* Setting it to null disables LibreOffice exporting.
* LibreOffice is used 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.
*/
"soffice": "${SOFFICE:null}",
/*
* Allow import of file types other than the supported ones:
* When true (the default), the "Microsoft Word" export button downloads a .docx file via
* LibreOffice (requires "soffice" to be set). Set to false to revert to legacy .doc output
* (which also requires "soffice").
*/
"docxExport": "${DOCX_EXPORT:true}",
/*
* txt, doc, docx, rtf, odt, html & htm
*/
"allowUnknownFileEnds": "${ALLOW_UNKNOWN_FILE_ENDS:true}",
@ -574,13 +578,17 @@
"socketIo": {
/*
* Maximum permitted client message size (in bytes). All messages from
* clients that are larger than this will be rejected. Large values make it
* possible to paste large amounts of text, and plugins may require a larger
* value to work properly, but increasing the value increases susceptibility
* to denial of service attacks (malicious clients can exhaust memory).
* Maximum permitted client message size (in bytes). This controls the
* maximum single-message size for socket.io and directly affects large
* paste operations. All messages from clients that are larger than this
* will be rejected. Large values make it possible to paste large amounts
* of text, and plugins may require a larger value to work properly, but
* increasing the value increases susceptibility to denial of service
* attacks (malicious clients can exhaust memory).
*
* 1MB accommodates large pastes while still preventing abuse.
*/
"maxHttpBufferSize": "${SOCKETIO_MAX_HTTP_BUFFER_SIZE:50000}"
"maxHttpBufferSize": "${SOCKETIO_MAX_HTTP_BUFFER_SIZE:1000000}"
},
/*

View File

@ -82,7 +82,7 @@
* "password": "${PASSW:}" // if PASSW is not defined would result in password === ''
*
* If you want to use an empty value (null) as default value for a variable,
* simply do not set it, without putting any colons: "${ABIWORD}".
* simply do not set it, without putting any colons: "${SOFFICE}".
*
* 3) if you want to use newlines in the default value of a string parameter,
* use "\n" as usual.
@ -225,7 +225,7 @@
/*
* An Example of MySQL Configuration (commented out).
*
* See: https://github.com/ether/etherpad-lite/wiki/How-to-use-Etherpad-Lite-with-MySQL
* See: https://github.com/ether/etherpad/wiki/How-to-use-Etherpad-Lite-with-MySQL
*/
/*
@ -335,25 +335,23 @@
*/
"maxAge": 21600, // 60 * 60 * 6 = 6 hours
/*
* Absolute path to the Abiword executable.
*
* Abiword is needed to get advanced import/export features of pads. Setting
* it to null disables Abiword and will only allow plain text and HTML
* import/exports.
*/
"abiword": null,
/*
* This is the absolute path to the soffice executable.
*
* LibreOffice can be used in lieu of Abiword to export pads.
* Setting it to null disables LibreOffice exporting.
* LibreOffice is used 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.
*/
"soffice": null,
/*
* Allow import of file types other than the supported ones:
* When true (the default), the "Microsoft Word" export button downloads a .docx file via
* LibreOffice (requires "soffice" to be set). Set to false to revert to legacy .doc output
* (which also requires "soffice").
*/
"docxExport": true,
/*
* txt, doc, docx, rtf, odt, html & htm
*/
"allowUnknownFileEnds": true,
@ -385,6 +383,13 @@
* Settings controlling the session cookie issued by Etherpad.
*/
"cookie": {
/*
* Prefix for all cookie names set by Etherpad. Set this to "ep_" or similar
* if Etherpad's cookie names (token, sessionID, etc.) conflict with those
* of another application on the same domain. Default: "" (no prefix).
*/
// "prefix": "ep_",
/*
* How often (in milliseconds) the key used to sign the express_sid cookie
* should be rotated. Long rotation intervals reduce signature verification
@ -456,7 +461,13 @@
* Automatic session refreshes can be disabled (not recommended) by setting
* this to null.
*/
"sessionRefreshInterval": 86400000 // = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s
"sessionRefreshInterval": 86400000, // = 1d * 24h/d * 60m/h * 60s/m * 1000ms/s
/*
* Whether to periodically clean up expired and stale sessions from the
* database. Set to false to disable. Default: true.
*/
"sessionCleanup": true
},
/*
@ -560,13 +571,17 @@
"socketIo": {
/*
* Maximum permitted client message size (in bytes). All messages from
* clients that are larger than this will be rejected. Large values make it
* possible to paste large amounts of text, and plugins may require a larger
* value to work properly, but increasing the value increases susceptibility
* to denial of service attacks (malicious clients can exhaust memory).
* Maximum permitted client message size (in bytes). This controls the
* maximum single-message size for socket.io and directly affects large
* paste operations. All messages from clients that are larger than this
* will be rejected. Large values make it possible to paste large amounts
* of text, and plugins may require a larger value to work properly, but
* increasing the value increases susceptibility to denial of service
* attacks (malicious clients can exhaust memory).
*
* 1MB accommodates large pastes while still preventing abuse.
*/
"maxHttpBufferSize": 50000
"maxHttpBufferSize": 1000000
},
/*
@ -628,6 +643,12 @@
**/
"enableDarkMode": "${ENABLE_DARK_MODE:true}",
/**
* Enable creator-owned Pad-wide Settings and new-pad default seeding from My View.
* Disabled by default to preserve the legacy single-settings behavior.
**/
"enablePadWideSettings": "${ENABLE_PAD_WIDE_SETTINGS:false}",
/*
* From Etherpad 1.8.5 onwards, when Etherpad is in production mode commits from individual users are rate limited
*

View File

@ -1 +0,0 @@
auto-install-peers=false

View File

@ -117,7 +117,6 @@
"pad.importExport.exportword": "مايكروسوفت وورد",
"pad.importExport.exportpdf": "صيغة المستندات المحمولة",
"pad.importExport.exportopen": "ODF (نسق المستند المفتوح)",
"pad.importExport.abiword.innerHTML": "لا يمكنك الاستيراد إلا من نص عادي أو من تنسيقات HTML. للحصول على المزيد من ميزات الاستيراد المتقدمة، يرجى <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">تثبيت AbiWord أو LibreOffice</a>.",
"pad.modals.connected": "متصل.",
"pad.modals.reconnecting": "إعادة الاتصال ببادك..",
"pad.modals.forcereconnect": "فرض إعادة الاتصال",

View File

@ -52,7 +52,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Sólo se pue importar dende los formatos de testu planu o HTML. Pa carauterístiques d'importación más avanzaes <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">instala Abiword o LibreOffice</a>.",
"pad.modals.connected": "Coneutáu.",
"pad.modals.reconnecting": "Reconeutando col to bloc...",
"pad.modals.forcereconnect": "Forzar la reconexón",

View File

@ -66,7 +66,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (açıq sənəd formatı)",
"pad.importExport.abiword.innerHTML": "Siz yalnız adi mətndən və ya HTML-dən idxal edə bilərsiniz. İdxalın daha mürəkkəb funksiyaları üçün, zəhmət olmasa, <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">AbiWord və ya LibreOffice quraşdırın</a>.",
"pad.modals.connected": "Bağlandı.",
"pad.modals.reconnecting": "Sizin lövhə yenidən qoşulur…",
"pad.modals.forcereconnect": "Məcbur təkrarən bağlan",

View File

@ -62,7 +62,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (قالب سند باز)",
"pad.importExport.abiword.innerHTML": "شما تنها می‌توانید از قالب متن ساده یا اچ‌تی‌ام‌ال درون‌ریزی کنید. برای بیشتر شدن ویژگی‌های درون‌ریزی پیشرفته <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">AbiWord</a> را نصب کنید.",
"pad.modals.connected": "متصل شد.",
"pad.modals.reconnecting": "در حال اتصال دوباره به دفترچه یادداشت شما..",
"pad.modals.forcereconnect": "واداشتن به اتصال دوباره",

View File

@ -4,6 +4,7 @@
"Jim-by",
"Red Winged Duck",
"Renessaince",
"Ucukor",
"Wizardist"
]
},
@ -69,7 +70,7 @@
"pad.toolbar.showusers.title": "Паказаць карыстальнікаў у гэтым дакумэнце",
"pad.colorpicker.save": "Захаваць",
"pad.colorpicker.cancel": "Скасаваць",
"pad.loading": "Загрузка...",
"pad.loading": "Загрузка",
"pad.noCookie": "Кукі ня знойдзеныя. Калі ласка, дазвольце кукі ў вашым браўзэры! Паміж наведваньнямі вашая сэсія і налады ня будуць захаваныя. Гэта можа адбывацца таму, што ў некаторых броўзэрах Etherpad заключаны ўнутры iFrame. Праверце, калі ласка, што Etherpad знаходзіцца ў тым жа паддамэне/дамэне, што і бацькоўскі iFrame",
"pad.permissionDenied": "Вы ня маеце дазволу на доступ да гэтага дакумэнта",
"pad.settings.padSettings": "Налады дакумэнта",
@ -87,7 +88,7 @@
"pad.settings.about": "Пра",
"pad.settings.poweredBy": "Працуе на",
"pad.importExport.import_export": "Імпарт/Экспарт",
"pad.importExport.import": "Загрузіжайце любыя тэкставыя файлы або дакумэнты",
"pad.importExport.import": "Даслаць будзь-які тэкставы файл ці дакумэнт",
"pad.importExport.importSuccessful": "Пасьпяхова!",
"pad.importExport.export": "Экспартаваць бягучы дакумэнт як:",
"pad.importExport.exportetherpad": "Etherpad",
@ -96,7 +97,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Вы можаце імпартаваць толькі з звычайнага тэксту або HTML. Дзеля больш пашыраных магчымасьцяў імпарту, калі ласка, <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">усталюйце AbiWord альбо LibreOffice</a>.",
"pad.modals.connected": "Падлучыліся.",
"pad.modals.reconnecting": "Перападлучэньне да вашага дакумэнта…",
"pad.modals.forcereconnect": "Прымусовае перападлучэньне",
@ -115,7 +115,7 @@
"pad.modals.slowcommit.explanation": "Сэрвэр не адказвае.",
"pad.modals.slowcommit.cause": "Гэта можа быць выклікана праблемамі зь сеткавым падлучэньнем.",
"pad.modals.badChangeset.explanation": "Сэрвэр сынхранізацыі вызначыў зробленае вамі рэдагаваньне як недапушчальнае.",
"pad.modals.badChangeset.cause": "Гэта можа адбывацца празь няслушную канфігурацыю сэрвэра або празь іншыя нечаканыя дзеяньні. Калі ласка, скантактуйцеся з адміністратарам, калі вы лічыце, што гэта памылка. Паспрабуйце перападлучыцца, каб працягнуць рэдагаваньне.",
"pad.modals.badChangeset.cause": "Гэта можа адбывацца празь няслушную канфіґурацыю сэрвэра або празь іншыя нечаканыя дзеяньні. Калі ласка, скантактуйцеся з адміністратарам, калі вы думаеце, што гэта памылка. Паспрабуйце перападлучыцца, каб працягнуць рэдагаваньне.",
"pad.modals.corruptPad.explanation": "Дакумэнт, да якога вы спрабуеце атрымаць доступ, пашкоджаны.",
"pad.modals.corruptPad.cause": "Гэта можа быць выклікана няправільнай канфігурацыяй сэрвэру або іншымі нечаканымі дзеяньнямі. Калі ласка, скантактуйцеся з адміністратарам службы.",
"pad.modals.deleted": "Выдалены.",
@ -125,7 +125,7 @@
"pad.modals.rejected.explanation": "Сэрвэр адхіліў паведамленьне, адасланае вашым броўзэрам.",
"pad.modals.disconnected": "Вы былі адключаныя.",
"pad.modals.disconnected.explanation": "Злучэньне з сэрвэрам было страчанае",
"pad.modals.disconnected.cause": "Магчыма, сэрвэр недаступны. Калі ласка, паведаміце адміністратару службы, калі праблема будзе паўтарацца.",
"pad.modals.disconnected.cause": "Магчыма, сэрвэр недаступны. Калі ласка, абвясьціце адміністратара службы, калі праблема будзе паўтарацца.",
"pad.share": "Падзяліцца дакумэнтам",
"pad.share.readonly": "Толькі для чытаньня",
"pad.share.link": "Спасылка",

View File

@ -38,7 +38,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (پاچین سندئ قالب)",
"pad.importExport.abiword.innerHTML": "شما تا توانیت که شه ساده گین متنی ئین قالب یا اچ‌تی‌ام‌ال بی تئ کنیت . په گیشتیرین کارا ئییان پیشرفته ئین بی تئ کورتینا <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">AbiWord</a> نصب کنیت.",
"pad.modals.connected": "وصل بوت.",
"pad.modals.userdup": "نوکین دروازه گئ پاچ کورتین",
"pad.modals.unauth": "مجاز نه اینت",

View File

@ -52,7 +52,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Ne c'hallit enporzhiañ nemet furmadoù testennoù plaen pe HTML. Evit arc'hwelioù enporzhiañ emdroetoc'h, staliit <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">Abiword pe LibreOffice</a>.",
"pad.modals.connected": "Kevreet.",
"pad.modals.reconnecting": "Adkevreañ war-zu ho pad...",
"pad.modals.forcereconnect": "Adkevreañ dre heg",

View File

@ -93,7 +93,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Només podeu importar de text sense format o HTML. Per a opcions d'importació més avançades <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">instal·leu l'Abiword</a>.",
"pad.modals.connected": "Connectat.",
"pad.modals.reconnecting": "S'està tornant a connectar al vostre pad…",
"pad.modals.forcereconnect": "Força tornar a connectar",

View File

@ -117,7 +117,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Importovat lze pouze z formátů prostého textu nebo HTML. Pokročilejší funkce pro import naleznete v <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">instalaci AbiWord nebo LibreOffice</a>.",
"pad.modals.connected": "Připojeno.",
"pad.modals.reconnecting": "Opětovné připojení k Padu...",
"pad.modals.forcereconnect": "Vynutit znovupřipojení",

View File

@ -29,7 +29,7 @@
"admin_plugins_info.hooks": "Installerede hooks",
"admin_settings": "Indstillinger",
"index.newPad": "Ny Pad",
"index.createOpenPad": "eller opret/åbn en Pad med navnet:",
"index.createOpenPad": "Åbn pad efter navn",
"pad.toolbar.bold.title": "Fed (Ctrl-B)",
"pad.toolbar.italic.title": "Kursiv (Ctrl-I)",
"pad.toolbar.underline.title": "Understregning (Ctrl-U)",
@ -50,7 +50,7 @@
"pad.colorpicker.save": "Gem",
"pad.colorpicker.cancel": "Afbryd",
"pad.loading": "Indlæser ...",
"pad.noCookie": "Cookie kunne ikke findes. Tillad venligst cookier i din browser!",
"pad.noCookie": "Cookie kunne ikke findes. Tillad venligst cookies i din browser! Din session og dine indstillinger gemmes ikke mellem besøg. Dette kan skyldes, at Etherpad er inkluderet i en iFrame i nogle browsere. Sørg for, at Etherpad er på samme underdomæne/domæne som den overordnede iFrame.",
"pad.permissionDenied": "Du har ikke tilladelse til at få adgang til denne pad.",
"pad.settings.padSettings": "Pad indstillinger",
"pad.settings.myView": "Min visning",
@ -74,7 +74,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Du kan kun importere fra almindelig tekst eller HTML-formater. For mere avancerede importfunktioner, <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">installer AbiWord</a>.",
"pad.modals.connected": "Forbundet.",
"pad.modals.reconnecting": "Genopretter forbindelsen til din pad...",
"pad.modals.forcereconnect": "Gennemtving genoprettelse af forbindelsen",

View File

@ -122,7 +122,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Du kannst nur aus reinen Text- oder HTML-Formaten importieren. Für umfangreichere Importfunktionen <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">muss AbiWord oder LibreOffice auf dem Server installiert werden</a>.",
"pad.modals.connected": "Verbunden.",
"pad.modals.reconnecting": "Dein Pad wird neu verbunden...",
"pad.modals.forcereconnect": "Erneutes Verbinden erzwingen",

View File

@ -93,7 +93,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": ıma şenê tenya metınanê zelalan ya zi formatanê HTML-i biyarê. Seba vêşi xısusiyetanê arezekerdışi ra gırey <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">AbiWordi ya zi LibreOfficeyi bar kerên</a>.",
"pad.modals.connected": "Gıre diya.",
"pad.modals.reconnecting": "Pada şıma rê fına irtibat kewê no",
"pad.modals.forcereconnect": "Mecbur anciya gırê de",

View File

@ -82,13 +82,19 @@
"pad.loading": "Zacytujo se...",
"pad.noCookie": "Cookie njejo se namakał. Pšosym dowólśo cookieje w swójom wobglědowaku! Wašo pósejźenje a waše nastajenja se mjazy dwěma woglědoma njeskładuju. To móžo se stas, gaž Etherpad jo w někotarych wobglědowakach w iFrame wopśimjony. Pšosym zawěsććo, až Etherpad jo na samskej póddomenje/domenje ako nadrědowany iFrame",
"pad.permissionDenied": "Njamaš pśistupne pšawo za toś ten zapisnik.",
"pad.settings.padSettings": "Nastajenja zapisnika",
"pad.settings.title": "Nastajenja",
"pad.settings.padSettings": "Nastajenja cełego zapisnika",
"pad.settings.userSettings": "Wužywaŕske nastajenja",
"pad.settings.myView": "Mój naglěd",
"pad.settings.disablechat": "Chat znjemóžniś",
"pad.settings.darkMode": "Śamny modus",
"pad.settings.stickychat": "Chat pśecej na wobrazowce pokazaś",
"pad.settings.chatandusers": "Chat a wužywarje pokazaś",
"pad.settings.colorcheck": "Awtorowe barwy",
"pad.settings.linenocheck": "Smužkowe numery",
"pad.settings.rtlcheck": "Wopśimjeśe wótpšawa nalěwo cytaś?",
"pad.settings.enforceSettings": "Nastajenja za drugich wužywarjow wunuzkaś",
"pad.settings.enforcedNotice": "Toś te nastajenja su se zastajili za tebje wót awtora toś togo zapisnika. Pšašaj se awtora zapisnika, lěc musyš je změniś.",
"pad.settings.fontType": "Pismowa družyna:",
"pad.settings.fontType.normal": "Normalny",
"pad.settings.language": "Rěc:",
@ -106,7 +112,7 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Móžoš jano z fprmatow lutnego teksta abo z HTML-formata importěrowaś. Za wěcej rozšyrjone importěrowańske funkcije <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">instalěruj pšosym Abiword abo LibreOffice</a>.",
"pad.importExport.noConverter.innerHTML": "Móžoš jano z formatow lutnego teksta abo z HTML-formata importěrowaś. Za wěcej rozšyrjone importowe funkcije <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">instalěruj pšosym LibreOffice</a>.",
"pad.modals.connected": "Zwězany.",
"pad.modals.reconnecting": "Zwězujo se znowego z twójim zapisnikom...",
"pad.modals.forcereconnect": "Znowego zwězaś",
@ -137,6 +143,8 @@
"pad.modals.disconnected": "Zwisk jo pśetergnjony.",
"pad.modals.disconnected.explanation": "Zwisk ze serwerom jo se zgubił",
"pad.modals.disconnected.cause": "Serwer njestoj k dispoziciji. Pšosym informěruj słužbowego administratora, jolic to se dalej stawa.",
"pad.gritter.unacceptedCommit.title": "Njeskłaźona změna",
"pad.gritter.unacceptedCommit.text": "Waša nejnowša změna hyšći njejo skłaźona. Zwěžćo znowego a wopytaj hyšći raz.",
"pad.share": "Toś ten zapisnik źěliś",
"pad.share.readonly": "Jano cytajobny",
"pad.share.link": "Wótkaz",
@ -155,6 +163,12 @@
"timeslider.exportCurrent": "Aktualnu wersiju eksportěrowaś ako:",
"timeslider.version": "Wersija {{version}}",
"timeslider.saved": "Składowany {{day}}. {{month}} {{year}}",
"timeslider.settings.playbackSpeed": "Malsnosć wótgrawanja:",
"timeslider.settings.playbackSpeed.original": "Originalna malsnosć",
"timeslider.settings.playbackSpeed.realtime": "Napšawdny cas",
"timeslider.settings.playbackSpeed.200ms": "200 ms",
"timeslider.settings.playbackSpeed.500ms": "500 ms",
"timeslider.settings.playbackSpeed.1000ms": "1000 ms",
"timeslider.playPause": "Wopśimjeśe zapisnika wótgraś/pawzěrowaś",
"timeslider.backRevision": "Wó jadnu wersiju w toś tom dokumenśe slědk hyś",
"timeslider.forwardRevision": "Wó jadnu wersiju w toś tom dokumenśe doprědka hyś",

View File

@ -50,7 +50,6 @@
"pad.importExport.exportword": "माइक्रोसफ्ट वर्ड",
"pad.importExport.exportpdf": "पिडिएफ",
"pad.importExport.exportopen": "ओडिएफ (खुल्ला कागजात ढाँचा)",
"pad.importExport.abiword.innerHTML": "तम सादा पाठ या HTML ढाँचा बठेइ मात्तरी आयात अरीसकन्छऽ। विस्तारित आयात विशेषता खिलाई कृपया <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">abiword स्थापना अरऽ</a>।",
"pad.modals.connected": "जोडीयाको।",
"pad.modals.reconnecting": "तमरा प्याडमि दोबरा जडान अद्‍दाछ़..",
"pad.modals.forcereconnect": "बलात् पुन:जडान",

View File

@ -103,7 +103,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Μπορείτε να εισάγετε απλό κείμενο ή HTML. Για προηγμένες δυνατότητες εισαγωγής παρακαλούμε <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">εγκαταστήστε το AbiWord ή το LibreOffice</a>.",
"pad.modals.connected": "Συνδεμένοι.",
"pad.modals.reconnecting": "Επανασύνδεση στο pad σας…",
"pad.modals.forcereconnect": "Επιβολή επανασύνδεσης",

View File

@ -53,7 +53,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "You only can import from plain text or HTML formats. For more advanced import features please <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">install AbiWord or LibreOffice</a>.",
"pad.modals.connected": "Connected.",
"pad.modals.reconnecting": "Reconnecting to your pad…",
"pad.modals.forcereconnect": "Force reconnect",

View File

@ -83,13 +83,19 @@
"pad.noCookie": "Cookie could not be found. Please allow cookies in your browser! Your session and settings will not be saved between visits. This may be due to Etherpad being included in an iFrame in some Browsers. Please ensure Etherpad is on the same subdomain/domain as the parent iFrame",
"pad.permissionDenied": "You do not have permission to access this pad",
"pad.settings.padSettings": "Pad Settings",
"pad.settings.title": "Settings",
"pad.settings.padSettings": "Pad-wide Settings",
"pad.settings.userSettings": "User Settings",
"pad.settings.myView": "My View",
"pad.settings.disablechat": "Disable Chat",
"pad.settings.darkMode": "Dark mode",
"pad.settings.stickychat": "Chat always on screen",
"pad.settings.chatandusers": "Show Chat and Users",
"pad.settings.colorcheck": "Authorship colors",
"pad.settings.linenocheck": "Line numbers",
"pad.settings.rtlcheck": "Read content from right to left?",
"pad.settings.enforceSettings": "Enforce settings for other users",
"pad.settings.enforcedNotice": "These settings are locked for you by this pad's creator. Ask the pad creator if you need them changed.",
"pad.settings.fontType": "Font type:",
"pad.settings.fontType.normal": "Normal",
"pad.settings.language": "Language:",
@ -108,7 +114,7 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "You only can import from plain text or HTML formats. For more advanced import features please <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">install AbiWord or LibreOffice</a>.",
"pad.importExport.noConverter.innerHTML": "You can only import from plain text or HTML formats. For more advanced import features, please <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">install LibreOffice</a>.",
"pad.modals.connected": "Connected.",
"pad.modals.reconnecting": "Reconnecting to your pad…",
@ -151,6 +157,8 @@
"pad.modals.disconnected": "You have been disconnected.",
"pad.modals.disconnected.explanation": "The connection to the server was lost",
"pad.modals.disconnected.cause": "The server may be unavailable. Please notify the service administrator if this continues to happen.",
"pad.gritter.unacceptedCommit.title": "Unsaved edit",
"pad.gritter.unacceptedCommit.text": "Your recent edit is still not saved. Reconnect and try again.",
"pad.share": "Share this pad",
"pad.share.readonly": "Read only",
@ -171,6 +179,12 @@
"timeslider.exportCurrent": "Export current version as:",
"timeslider.version": "Version {{version}}",
"timeslider.saved": "Saved {{month}} {{day}}, {{year}}",
"timeslider.settings.playbackSpeed": "Playback speed:",
"timeslider.settings.playbackSpeed.original": "Original speed",
"timeslider.settings.playbackSpeed.realtime": "Realtime",
"timeslider.settings.playbackSpeed.200ms": "200 ms",
"timeslider.settings.playbackSpeed.500ms": "500 ms",
"timeslider.settings.playbackSpeed.1000ms": "1000 ms",
"timeslider.playPause": "Playback / Pause Pad Contents",
"timeslider.backRevision":"Go back a revision in this Pad",

View File

@ -52,7 +52,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Formato “OpenDocument”)",
"pad.importExport.abiword.innerHTML": "Nur kapablas enporti de plata teksto aŭ HTML. Por pli speciala importkapablo, bonvolu <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">instalu la programon, Abiword</a>.",
"pad.modals.connected": "Konektita.",
"pad.modals.reconnecting": "Rekonektanta al via redaktilo..",
"pad.modals.forcereconnect": "Perforte rekonekti",

View File

@ -104,7 +104,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Solo se puede importar desde texto plano o formatos HTML. Para obtener funciones de importación más avanzadas, <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">instale AbiWord o LibreOffice</a>.",
"pad.modals.connected": "Conectado.",
"pad.modals.reconnecting": "Reconectando a tu pad...",
"pad.modals.forcereconnect": "Forzar reconexión",

View File

@ -46,7 +46,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Paraku on ainult lihttekstis voi HTML-vormingus dokumentide importimine võimaldatud. Rohkem võimaluste jaoks peab <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-in-Ubuntu-or-OpenSuse-or-SLES-with-AbiWord\">paigaldama abiword</a>.",
"pad.modals.connected": "Ühendatud.",
"pad.modals.reconnecting": "Proovitakse luua ühendus klade juurde...",
"pad.modals.forcereconnect": "Sunni ühenduse taasloomist",

View File

@ -46,6 +46,14 @@
"admin_settings.current_save.value": "Gorde Ezarpenak",
"admin_settings.page-title": "Ezarpenak - Etherpad",
"index.newPad": "Pad berria",
"index.settings": "Ezarpenak",
"index.receiveSessionDescription": "Hemen beste nabigatzaile batetik edo gailu batetik Etherpad saioa jaso ahal izango duzu. Kontuan izan, ordea, honek zure egungo saioa ezabatuko duela.",
"index.copyLink": "2. Kopiatu esteka",
"index.copyLinkDescription": "Egin klik beheko botoian esteka arbelean kopiatzeko.",
"index.copyLinkButton": "Kopiatu esteka arbelean",
"index.transferToSystem": "3. Kopiatu saioa sistema berrira",
"index.transferToSystemDescription": "Ireki kopiatutako esteka helburuko nabigatzailean edo gailuan zure saioa transferitzeko.",
"index.transferSessionDescription": "Transferitu zure uneko saioa nabigatzailera edo gailura beheko botoian klik eginez. Honek zure saioa transferituko duen orrialde baterako esteka bat kopiatuko du helburuko nabigatzailean edo gailuan irekitzean.",
"index.createOpenPad": "Ireki Pad bat honako izenarekin:",
"index.openPad": "ireki existitzen den eta hurrengo izena duen Pad-a:",
"index.recentPadsEmpty": "Ez da aurkitu duela gutxiko pad-ik",
@ -101,7 +109,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (Open Document Format)",
"pad.importExport.abiword.innerHTML": "Testu laua edo HTML formatudun testuak bakarrik inporta ditzakezu. Aurreratuagoak diren inportazio aukerak izateko <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">AbiWord edo LibreOffice instala ezazu</a>.",
"pad.modals.connected": "Konektatuta.",
"pad.modals.reconnecting": "Zure pad-era birkonektatzen...",
"pad.modals.forcereconnect": "Behartu berkonexioa",

View File

@ -98,7 +98,6 @@
"pad.importExport.exportword": "Microsoft Word",
"pad.importExport.exportpdf": "PDF",
"pad.importExport.exportopen": "ODF (قالب سند باز)",
"pad.importExport.abiword.innerHTML": "شما تنها می‌توانید از قالب متن ساده یا اچ‌تی‌ام‌ال درون‌ریزی کنید. برای بیشتر شدن ویژگی‌های درون‌ریزی پیشرفته <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">AbiWord یا LibreOffice را نصب کنید</a>.",
"pad.modals.connected": "متصل شد.",
"pad.modals.reconnecting": "در حال اتصال دوباره به پد شما...",
"pad.modals.forcereconnect": "واداشتن به اتصال دوباره",

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