mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-18 17:13:58 +00:00
Compare commits
110 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a6109bd1d | ||
|
|
037f4a25b5 | ||
|
|
4755bd3e17 | ||
|
|
05d0f62403 | ||
|
|
3efcbfd358 | ||
|
|
2b5d521923 | ||
|
|
631caf911c | ||
|
|
0aba424357 | ||
|
|
194f144940 | ||
|
|
c045fcf698 | ||
|
|
451df9f01c | ||
|
|
ce27525d97 | ||
|
|
9c48900092 | ||
|
|
173dbb3448 | ||
|
|
24e0c44d91 | ||
|
|
f65701eaaf | ||
|
|
2f766d075d | ||
|
|
2c94ef9493 | ||
|
|
fdc23c658f | ||
|
|
e5d6142061 | ||
|
|
b7d2477840 | ||
|
|
4bc80d1439 | ||
|
|
8a9c0a6e20 | ||
|
|
df1666cffa | ||
|
|
afdd9b9500 | ||
|
|
3b9acd487c | ||
|
|
82d1fd63a5 | ||
|
|
75be62c801 | ||
|
|
ab4c7beded | ||
|
|
be8120b14e | ||
|
|
33160db454 | ||
|
|
9e850e17f3 | ||
|
|
3ac0b0f0a4 | ||
|
|
cce7cc6cd4 | ||
|
|
bce18df898 | ||
|
|
1442aee756 | ||
|
|
00e1892704 | ||
|
|
2ecd748fca | ||
|
|
0cd01fce41 | ||
|
|
40327d59d6 | ||
|
|
b45f60e20a | ||
|
|
f5b61c3388 | ||
|
|
ff0bd2e819 | ||
|
|
8e04130969 | ||
|
|
c8b544802a | ||
|
|
01500ca70c | ||
|
|
7168f14f0a | ||
|
|
01d0b08a4e | ||
|
|
41dc87edf9 | ||
|
|
93e5bcc1e2 | ||
|
|
3c90fa07c3 | ||
|
|
851b1fb613 | ||
|
|
9047aacc0a | ||
|
|
773a9042a7 | ||
|
|
926c94ea8a | ||
|
|
bf5988580e | ||
|
|
b9ec85779f | ||
|
|
acba429e1a | ||
|
|
8c5de8446c | ||
|
|
9d2dae1b63 | ||
|
|
32249d99e2 | ||
|
|
2109c05ad4 | ||
|
|
f6f665ff60 | ||
|
|
b680076534 | ||
|
|
b1792469b1 | ||
|
|
3aa13197e4 | ||
|
|
dfdbceebe2 | ||
|
|
a698e34772 | ||
|
|
ce038b0b0f | ||
|
|
d67aa3ebd3 | ||
|
|
4ba72b3919 | ||
|
|
bfe3fad256 | ||
|
|
3e53962fb5 | ||
|
|
a59b310dbf | ||
|
|
0c0f5a8ec4 | ||
|
|
099de84cd1 | ||
|
|
8a4e1feed8 | ||
|
|
2fdf202089 | ||
|
|
abdfa03f84 | ||
|
|
c8e3248bcd | ||
|
|
4371af77c2 | ||
|
|
2ba72de1de | ||
|
|
6a5ad2d90d | ||
|
|
f79cabe74c | ||
|
|
662678275a | ||
|
|
8e4eb2646a | ||
|
|
e313366b49 | ||
|
|
3cddff7b27 | ||
|
|
817ca2546e | ||
|
|
eefdfc44b9 | ||
|
|
73a9b88bb4 | ||
|
|
a65e3b75a4 | ||
|
|
8b6a0a2f3a | ||
|
|
92e23bf7d4 | ||
|
|
4fe94c9b68 | ||
|
|
59062ce11a | ||
|
|
fb7e7af6c9 | ||
|
|
ddf84fa6f2 | ||
|
|
f47626049c | ||
|
|
1e7c4b8c21 | ||
|
|
ad2e8b18a9 | ||
|
|
12bd199f94 | ||
|
|
d828185efc | ||
|
|
68d322957f | ||
|
|
5b146f0b03 | ||
|
|
67c0e391fd | ||
|
|
b420cf4850 | ||
|
|
e56a33efd0 | ||
|
|
860ab68c04 | ||
|
|
9af3a4de77 |
108 changed files with 7153 additions and 2434 deletions
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
|
|
@ -17,4 +17,11 @@ updates:
|
|||
open-pull-requests-limit: 30
|
||||
groups:
|
||||
dev-dependencies:
|
||||
dependency-type: "development"
|
||||
dependency-type: "development"
|
||||
cooldown:
|
||||
default-days: 1 # fallback for anything not covered below
|
||||
semver-major-days: 7
|
||||
semver-minor-days: 3
|
||||
semver-patch-days: 1
|
||||
include:
|
||||
- "*"
|
||||
|
|
|
|||
20
.github/workflows/backend-tests.yml
vendored
20
.github/workflows/backend-tests.yml
vendored
|
|
@ -32,8 +32,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
cache: pnpm
|
||||
-
|
||||
name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: libreoffice libreoffice-pdfimport
|
||||
version: 1.0
|
||||
|
|
@ -88,8 +88,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -107,7 +107,7 @@ jobs:
|
|||
cache: pnpm
|
||||
-
|
||||
name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: libreoffice libreoffice-pdfimport
|
||||
version: 1.0
|
||||
|
|
@ -168,8 +168,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -234,8 +234,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
6
.github/workflows/build-and-deploy-docs.yml
vendored
6
.github/workflows/build-and-deploy-docs.yml
vendored
|
|
@ -36,15 +36,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache vitepress build
|
||||
with:
|
||||
path: doc/.vitepress/cache
|
||||
|
|
|
|||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
|
|
|
|||
22
.github/workflows/deb-package.yml
vendored
22
.github/workflows/deb-package.yml
vendored
|
|
@ -15,6 +15,24 @@ on:
|
|||
- 'src/node/utils/run_cmd.ts'
|
||||
- 'src/static/js/pluginfw/**'
|
||||
- 'settings.json.template'
|
||||
# Also build + smoke-test the package on PRs that touch the production
|
||||
# footprint. The smoke step boots the packaged server and waits for /health,
|
||||
# which is the only check that catches "server starts then exits before
|
||||
# binding the port" startup regressions (e.g. a dependency bump whose change
|
||||
# lets the event loop drain mid-boot). Previously this workflow ran only on
|
||||
# push to develop, so such a regression was caught *after* merge — by which
|
||||
# point develop was already red. Running it pre-merge blocks the PR instead.
|
||||
# The release/apt-publish jobs are tag-guarded, so PRs run the build job only.
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packaging/**'
|
||||
- '.github/workflows/deb-package.yml'
|
||||
- 'src/package.json'
|
||||
- 'pnpm-lock.yaml'
|
||||
- 'src/node/server.ts'
|
||||
- 'src/node/utils/run_cmd.ts'
|
||||
- 'src/static/js/pluginfw/**'
|
||||
- 'settings.json.template'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
|
|
@ -44,7 +62,7 @@ jobs:
|
|||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
|
|
@ -328,7 +346,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout etherpad source (for packaging/apt/key.asc)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
|
|
|||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
|
|
@ -15,6 +15,6 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@v5
|
||||
|
|
|
|||
38
.github/workflows/docker.yml
vendored
38
.github/workflows/docker.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
tags: ${{ env.TEST_TAG }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -151,6 +151,32 @@ jobs:
|
|||
docker volume rm ep7718-plugins >/dev/null 2>&1 || true
|
||||
[ "$ok" = "1" ] || exit 1
|
||||
|
||||
-
|
||||
# Regression test: Etherpad container must boot without external
|
||||
# network access (e.g., air-gapped host or restrictive runtime policy).
|
||||
name: Regression — boot with disabled network (--network none)
|
||||
working-directory: etherpad
|
||||
run: |
|
||||
docker run --rm -d \
|
||||
--network none \
|
||||
--name test-offline ${{ env.TEST_TAG }}
|
||||
docker logs -f test-offline &
|
||||
ok=0
|
||||
for i in $(seq 1 60); do
|
||||
status=$(docker container inspect -f '{{.State.Health.Status}}' test-offline 2>/dev/null) || {
|
||||
echo "container exited prematurely while offline"
|
||||
docker logs test-offline || true
|
||||
break
|
||||
}
|
||||
case ${status} in
|
||||
healthy) ok=1; echo "offline boot healthy after $((i*2))s"; break;;
|
||||
starting) sleep 2;;
|
||||
*) echo "unexpected status: ${status}"; docker logs test-offline || true; break;;
|
||||
esac
|
||||
done
|
||||
docker rm -f test-offline >/dev/null 2>&1 || true
|
||||
[ "$ok" = "1" ] || exit 1
|
||||
|
||||
build-test-local-plugin:
|
||||
# Regression coverage for #7687: the Docker image's
|
||||
# `bin/installLocalPlugins.sh` step runs as the `etherpad` user and
|
||||
|
|
@ -164,7 +190,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -235,7 +261,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -332,7 +358,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -390,7 +416,7 @@ jobs:
|
|||
enable-url-completion: true
|
||||
- name: Check out ether-charts
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: ether-charts
|
||||
repository: ether/ether-charts
|
||||
|
|
|
|||
108
.github/workflows/downstream-smoke.yml
vendored
Normal file
108
.github/workflows/downstream-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
name: Downstream smoke
|
||||
|
||||
# Boots a real Etherpad from the PR and verifies the separate downstream clients
|
||||
# (Rust terminal editor, Node CLI, desktop/mobile) still round-trip against it.
|
||||
# Phase 1 lands the boot/healthcheck/self-check/teardown harness + manifest; the
|
||||
# per-client matrix activates as each client flips `enabled:true` in clients.json.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "doc/**"
|
||||
- "docs/**"
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # nightly against the default branch
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Boot + downstream clients
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
PNPM_HOME: ~/.pnpm-store
|
||||
APIKEY: downstream-smoke-key
|
||||
steps:
|
||||
- name: Checkout core (PR)
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm i --frozen-lockfile
|
||||
|
||||
- name: Boot Etherpad on :9003 (apikey auth)
|
||||
run: |
|
||||
# The template ships a literal "port": 9001 and sso auth; rewrite both.
|
||||
# (PORT env is ignored once the settings file specifies a port.)
|
||||
sed -e 's#"port": 9001,#"port": 9003,#' \
|
||||
-e 's#${AUTHENTICATION_METHOD:sso}#apikey#' \
|
||||
settings.json.template > settings.json
|
||||
# Fail fast if the template format drifted and sed silently no-op'd.
|
||||
grep -q '"port": 9003,' settings.json \
|
||||
|| { echo "::error::port rewrite failed — settings.json.template format changed"; exit 1; }
|
||||
grep -q '"authenticationMethod": "apikey"' settings.json \
|
||||
|| { echo "::error::auth rewrite failed — settings.json.template format changed"; exit 1; }
|
||||
printf '%s' "$APIKEY" > APIKEY.txt
|
||||
pnpm run prod > /tmp/ep.log 2>&1 &
|
||||
echo $! > /tmp/ep.pid
|
||||
echo "booted pid $(cat /tmp/ep.pid)"
|
||||
|
||||
- name: Wait for healthcheck
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS "http://localhost:9003/api/" >/dev/null 2>&1; then
|
||||
echo "server up after ${i} tries"; exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "::error::server did not come up"; tail -50 /tmp/ep.log; exit 1
|
||||
|
||||
- name: Self-check — authenticated create + read roundtrip
|
||||
run: |
|
||||
K="$APIKEY"
|
||||
curl -fsS "http://localhost:9003/api/1/createPad?apikey=${K}&padID=smoke&text=hi%0A" | tee /tmp/create.json
|
||||
grep -q '"code":0' /tmp/create.json
|
||||
curl -fsS "http://localhost:9003/api/1/getText?apikey=${K}&padID=smoke" | tee /tmp/get.json
|
||||
grep -q '"text":"hi' /tmp/get.json
|
||||
|
||||
- name: Generate canonical wire-vectors
|
||||
run: cd src && pnpm run vectors:gen
|
||||
|
||||
# Rust toolchain for the `rust`-kind client (etherpad-pad). Other kinds
|
||||
# use the node+pnpm already set up above.
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Run enabled downstream clients
|
||||
env:
|
||||
SMOKE_URL: http://localhost:9003
|
||||
SMOKE_APIKEY: ${{ env.APIKEY }}
|
||||
run: bash src/tests/downstream/run-clients.sh
|
||||
|
||||
- name: Teardown (by PID, never pkill)
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ep.pid ]; then
|
||||
kill "$(cat /tmp/ep.pid)" 2>/dev/null || true
|
||||
echo "killed $(cat /tmp/ep.pid)"
|
||||
fi
|
||||
6
.github/workflows/frontend-admin-tests.yml
vendored
6
.github/workflows/frontend-admin-tests.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: pnpm i
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
|
|||
24
.github/workflows/frontend-tests.yml
vendored
24
.github/workflows/frontend-tests.yml
vendored
|
|
@ -20,15 +20,15 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -92,15 +92,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -168,15 +168,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -269,15 +269,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
|
|||
4
.github/workflows/handleRelease.yml
vendored
4
.github/workflows/handleRelease.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
27
.github/workflows/installer-test.yml
vendored
27
.github/workflows/installer-test.yml
vendored
|
|
@ -23,7 +23,7 @@ jobs:
|
|||
name: shellcheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run shellcheck on installer.sh
|
||||
run: |
|
||||
sudo apt-get update
|
||||
|
|
@ -38,7 +38,7 @@ jobs:
|
|||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
@ -70,13 +70,30 @@ jobs:
|
|||
|
||||
- name: Smoke test - start Etherpad and curl /api
|
||||
shell: bash
|
||||
# Hard backstop: if teardown ever fails to reap the server the step
|
||||
# fails in minutes instead of burning to GitHub's 6h job ceiling.
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
ETHERPAD_DIR: ${{ runner.temp }}/etherpad-installer-test
|
||||
run: |
|
||||
set -eu
|
||||
# Enable job control so the backgrounded launcher gets its own
|
||||
# process group, letting us reap the whole pnpm -> node tree below.
|
||||
set -m
|
||||
cd "$ETHERPAD_DIR"
|
||||
pnpm run prod >/tmp/etherpad.log 2>&1 &
|
||||
PID=$!
|
||||
# `pnpm run prod` is a nested launcher (pnpm -> pnpm --filter -> node),
|
||||
# so killing $PID alone orphans the node server, which keeps the step's
|
||||
# output pipe open and hangs CI. Kill the entire process group, with a
|
||||
# SIGKILL fallback in case SIGTERM is swallowed (e.g. a live flush timer
|
||||
# keeping the event loop alive), so the step always exits cleanly.
|
||||
reap() {
|
||||
kill -TERM "-$PID" 2>/dev/null || true
|
||||
sleep 5
|
||||
kill -KILL "-$PID" 2>/dev/null || true
|
||||
}
|
||||
trap reap EXIT
|
||||
# Wait up to 60s for the API to come up.
|
||||
ok=0
|
||||
for i in $(seq 1 60); do
|
||||
|
|
@ -90,17 +107,14 @@ jobs:
|
|||
if [ "$ok" != "1" ]; then
|
||||
echo "Etherpad did not start within 60s. Last 200 lines of log:" >&2
|
||||
tail -200 /tmp/etherpad.log >&2 || true
|
||||
kill "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
kill "$PID" 2>/dev/null || true
|
||||
wait "$PID" 2>/dev/null || true
|
||||
|
||||
installer-windows:
|
||||
name: end-to-end install (windows-latest)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
@ -131,6 +145,7 @@ jobs:
|
|||
|
||||
- name: Smoke test - start Etherpad and curl /api
|
||||
shell: pwsh
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
|
||||
run: |
|
||||
|
|
|
|||
12
.github/workflows/load-test.yml
vendored
12
.github/workflows/load-test.yml
vendored
|
|
@ -24,8 +24,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -62,8 +62,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -125,8 +125,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
4
.github/workflows/perform-type-check.yml
vendored
4
.github/workflows/perform-type-check.yml
vendored
|
|
@ -24,8 +24,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
4
.github/workflows/rate-limit.yml
vendored
4
.github/workflows/rate-limit.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ether/etherpad-lite
|
||||
path: etherpad
|
||||
|
|
@ -42,12 +42,12 @@ jobs:
|
|||
git checkout develop
|
||||
git reset --hard origin/develop
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ether/ether.github.com
|
||||
path: ether.github.com
|
||||
token: '${{ secrets.ETHER_RELEASE_TOKEN }}'
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -82,7 +82,7 @@ jobs:
|
|||
with:
|
||||
ruby-version: 2.7
|
||||
|
||||
- uses: reitzig/actions-asciidoctor@v2.0.4
|
||||
- uses: reitzig/actions-asciidoctor@v2.0.5
|
||||
with:
|
||||
version: 2.0.18
|
||||
- name: Prepare release
|
||||
|
|
|
|||
36
.github/workflows/releaseEtherpad.yml
vendored
36
.github/workflows/releaseEtherpad.yml
vendored
|
|
@ -1,9 +1,33 @@
|
|||
# PARKED — npm publish of the core package is not part of the standard release.
|
||||
#
|
||||
# This workflow renames `ep_etherpad-lite` -> `ep_etherpad` and publishes
|
||||
# `./src` to npm. As of 2026-06, that publish serves no load-bearing purpose:
|
||||
# - `ep_etherpad` has 0 dependents on npm and nothing in this repo depends on it;
|
||||
# - plugins import `ep_etherpad-lite` resolved from the LOCAL core install,
|
||||
# and plugin CI clones `ether/etherpad` rather than `npm install`-ing core;
|
||||
# - Etherpad is run via git clone / Docker / zip / snap, never `npm install`.
|
||||
# The publish has been failing (E404 PUT — the `ep_etherpad` package has no OIDC
|
||||
# trusted publisher configured on npmjs.com), which is why npm is stuck at 2.5.0
|
||||
# while 3.x shipped fine without it.
|
||||
#
|
||||
# It is therefore gated behind an explicit `confirm: true` dispatch input so a
|
||||
# stray run fails fast with a clear message instead of a confusing 404. To
|
||||
# actually publish, the npm owner of `ep_etherpad` (samtv12345) must first
|
||||
# configure a trusted publisher: npmjs.com -> ep_etherpad -> Settings ->
|
||||
# Trusted Publisher -> repo `ether/etherpad`, workflow `releaseEtherpad.yml`.
|
||||
# Decision pending: finish that config, or remove this workflow. See AGENTS.MD.
|
||||
name: releaseEtherpad.yaml
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # for npm OIDC trusted publishing
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm:
|
||||
description: 'PARKED — publish ep_etherpad to npm? Requires a trusted publisher configured on npmjs.com first (see workflow header). Set true only if that is done.'
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
PNPM_HOME: ~/.pnpm-store
|
||||
|
|
@ -12,8 +36,16 @@ jobs:
|
|||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Guard — refuse unless explicitly confirmed
|
||||
if: ${{ inputs.confirm != true }}
|
||||
run: |
|
||||
echo "::error::releaseEtherpad is PARKED. The ep_etherpad npm publish is non-functional"
|
||||
echo "::error::(no trusted publisher configured on npmjs.com; 0 dependents on npm)."
|
||||
echo "::error::Re-run with confirm=true only after the owner configures a trusted"
|
||||
echo "::error::publisher. See the workflow header / AGENTS.MD 'Releasing' section."
|
||||
exit 1
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
# OIDC trusted publishing needs npm >= 11.5.1, which requires
|
||||
|
|
@ -22,7 +54,7 @@ jobs:
|
|||
registry-url: https://registry.npmjs.org/
|
||||
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
|
||||
run: npm install -g npm@latest
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
4
.github/workflows/snap-build.yml
vendored
4
.github/workflows/snap-build.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
name: Wrapper unit tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run snap/tests/run-all.sh
|
||||
run: bash snap/tests/run-all.sh
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
needs: wrapper-tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install snapcraft
|
||||
run: sudo snap install --classic snapcraft
|
||||
|
|
|
|||
2
.github/workflows/snap-publish.yml
vendored
2
.github/workflows/snap-publish.yml
vendored
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
snap-file: ${{ steps.build.outputs.snap }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build snap
|
||||
id: build
|
||||
|
|
|
|||
2
.github/workflows/update-plugins.yml
vendored
2
.github/workflows/update-plugins.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out etherpad-lite
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
|
|
|||
|
|
@ -32,13 +32,13 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out latest release
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
-
|
||||
name: Check out latest release tag
|
||||
run: git checkout "$(git tag --list 'v*' --sort=-version:refname | head -n1)"
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -55,7 +55,7 @@ jobs:
|
|||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
- name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: libreoffice libreoffice-pdfimport
|
||||
version: 1.0
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ Releases are driven almost entirely by GitHub Actions. A maintainer dispatches *
|
|||
- `handleRelease.yml` → builds Etherpad, extracts the matching changelog section via `generateChangelog` (`bin/generateReleaseNotes.ts`), and publishes the **GitHub Release** (`make_latest: true`);
|
||||
- `docker.yml` → builds & pushes the Docker images;
|
||||
- `snap-publish.yml` → publishes the snap.
|
||||
5. **npm publish is a separate manual step:** dispatch **"releaseEtherpad.yaml"** (`workflow_dispatch`), which runs `npm publish --provenance --access public` via npm **OIDC trusted publishing**. It is *not* fired by the tag.
|
||||
5. **npm publish — PARKED, not part of the release.** `releaseEtherpad.yaml` publishes the core as `ep_etherpad` to npm, but that package is **not load-bearing**: it has 0 dependents, nothing depends on it (plugins import `ep_etherpad-lite` from the *local* core install; plugin CI clones the repo), and Etherpad is run via clone/Docker/zip/snap — never `npm install`. The publish currently fails with `E404` because `ep_etherpad` has no OIDC trusted publisher configured on npmjs.com, which is why npm sits at 2.5.0 while 3.x shipped fine without it. The workflow is gated behind a `confirm: true` input so it can't run by accident. **Skip it for a normal release.** To revive it, the npm owner of `ep_etherpad` (`samtv12345`) configures a trusted publisher (npmjs.com → ep_etherpad → Settings → Trusted Publisher → repo `ether/etherpad`, workflow `releaseEtherpad.yml`); otherwise the workflow can be removed. Decision pending.
|
||||
|
||||
### Documentation
|
||||
|
||||
|
|
|
|||
61
CHANGELOG.md
61
CHANGELOG.md
|
|
@ -1,3 +1,64 @@
|
|||
# 3.3.2
|
||||
|
||||
3.3.2 is a bug-fix and dependency-hardening follow-up to 3.3.1. It rounds out the pad-deletion UX rework (suppressing the recovery token for durable identities, keeping the token-less Delete button reachable, and closing a read-only deletion hole), restores the saved-revision markers that went missing from in-pad history mode in 3.3.x, and adds env-var overrides so air-gapped installs can switch off Etherpad's outbound calls without editing the image. It also fixes the `migrateDB` / `importSqlFile` / `migrateDirtyDBtoRealDB` CLI scripts against the promise-based ueberdb2 API, rejects unreachable `.`/`..` pad ids, and clears a batch of dependency security advisories (including CVE-2026-54285). On the CI side it unblocks the installer smoke test (which had been hanging the full 6-hour job ceiling since 3.2.0) and pins ueberdb2 past a startup-exit regression in the packaged boot.
|
||||
|
||||
### Security
|
||||
|
||||
- **Force `@opentelemetry/core` ≥ 2.8.0 (GHSA-8988-4f7v-96qf / CVE-2026-54285, #7975).** The transitive dep (pulled in via `@elastic/elasticsearch` → `@elastic/transport`) had a `W3CBaggagePropagator.extract()` that did not enforce W3C size limits on inbound baggage headers, allowing unbounded memory allocation. Pinned via a `pnpm-workspace.yaml` override; satisfies the existing `2.x` range with no parent bump.
|
||||
- **Resolve open Dependabot security alerts (#7967).** Refreshes stale override floors and adds new ones via `pnpm-workspace` overrides: `form-data` ≥ 4.0.6, `ws` ≥ 8.21.0, `esbuild` ≥ 0.28.1, `basic-ftp` ≥ 5.3.1 (capped `<6.0.0` to avoid a surprise major on the plugin-install path), `tar` ≥ 7.5.16, `js-yaml` ≥ 4.2.0, `qs` ≥ 6.15.2, `ip-address` ≥ 10.1.1, and `@babel/core` ≥ 7.29.6.
|
||||
- **Reject read-only deletion via token-less paths (part of #7959 / #7960).** Under `allowPadDeletionByAllUsers` a read-only viewer was granted `canDeletePad=true`, and the server's `flagOk`/`creatorOk` branches never checked `session.readonly` — so a read-only link holder could delete a pad without a token. Read-only sessions are now excluded from both the client var and the server's token-less authorization paths; a valid recovery token stays sufficient regardless of session mode.
|
||||
|
||||
### Notable enhancements
|
||||
|
||||
- **Pad deletion — suppress the recovery token for durable identities and relabel the action (#7926 / #7930).** Building on the `allowPadDeletionByAllUsers` suppression, a creator's deletion token is now also withheld when they have a *durable* identity — authenticated (`req.session.user` with a username) **and** the deployment pins that identity to a stable `authorID` via a `getAuthorId` hook — since only then does the creator survive a cookie clear or a different device, making the token redundant. This tightens the previous "require authentication ⇒ always suppress" rule: without `getAuthorId` the authorID still comes from the per-browser cookie, so an authenticated user on a second device is *not* the creator and keeps getting a token. A new `canDeleteWithoutToken` client var hides the whole recovery-token disclosure (label, field, submit) when no token is needed, and the recovery form now renders for all sessions (hidden by default) so an authenticated creator without a durable mapping still has UI to enter their token. `API.createPad` returns a `null` `deletionToken` under `allowPadDeletionByAllUsers`, matching the socket/UI path.
|
||||
- **Offline/air-gapped installs — env-var overrides for the update check, plugin catalog, and updater (#7917, addresses #7911).** Firewalled deployments could not disable Etherpad's outbound calls without editing `settings.json` inside the image. The relevant keys are now wired through the `${ENV:default}` substitution in `settings.json.docker` and `settings.json.template`: `PRIVACY_UPDATE_CHECK`, `PRIVACY_PLUGIN_CATALOG`, `UPDATES_TIER` (`off` = no calls), `UPDATE_SERVER`, plus the docker-only `UPDATES_SOURCE` / `UPDATES_CHANNEL` / `UPDATES_CHECK_INTERVAL_HOURS` / `UPDATES_GITHUB_REPO` / `UPDATES_REQUIRE_ADMIN_FOR_STATUS`. A new "Updates & privacy" section in `doc/docker.md` documents the set; backend tests parse the shipped configs and fail if the `${ENV}` placeholders are dropped. Config, docs, and tests only — no runtime code change.
|
||||
|
||||
### Notable fixes
|
||||
|
||||
- **Pad — keep the token-less Delete button reachable without pad-wide settings (#7959 / #7960).** The token-less `#delete-pad` button was nested inside the `enablePadWideSettings`-gated section, so disabling pad-wide settings removed the only no-token deletion path — and combined with #7926 hiding the token disclosure when no token is needed, a user allowed to delete could be left with no deletion UI at all. The button is now always rendered (hidden by default) and driven by a `canDeletePad` client var (creator or `allowPadDeletionByAllUsers`, excluding read-only sessions), so the plain button and the recovery-token disclosure are mutually coherent and neither depends on pad-wide settings.
|
||||
- **History mode — restore the saved-revision markers (#7946 / #7948).** When #7659 moved the timeslider into the pad as an embedded iframe, the user-facing control became the outer `#history-slider-input`, but the saved-revision stars were still drawn into the now-hidden iframe `#ui-slider-bar`, so "Save Revision" appeared to do nothing in in-pad history mode (a 3.3.x regression). `pad_mode.ts` now bridges the embedded slider's saved revisions onto the outer slider as percentage-positioned, aria-hidden star markers (with click-to-seek for mouse users), and the server's `SAVE_REVISION` handler broadcasts `NEW_SAVEDREV` to the pad room so a revision saved by a collaborator appears live on an already-open history slider. A single revision saved at rev 0 now renders too. Adds Playwright coverage for both the single-client and two-client live paths.
|
||||
- **Import dialog — correct the outdated "no converter" help message (#7988 / #7989).** The notice claimed only plain text and HTML could be imported and linked to the legacy AbiWord wiki, prompting LibreOffice installs for formats that already work natively. Etherpad imports `.txt`, `.html`, `.docx` (via mammoth) and `.etherpad` without LibreOffice; only `.pdf`/`.odt`/`.doc`/`.rtf` still need it. The message now says so and points at the documentation site.
|
||||
- **PadManager — reject unreachable `.` and `..` pad ids (#7962).** `isValidPadId` accepted ids consisting only of URL dot-segments, but per the WHATWG URL standard a browser normalises `/p/.` to `/p/` and `/p/..` to `/`, so such a pad could be created in the database yet never opened or exported. These ids are now rejected, and the admin `deletePad` handler falls back to a raw key purge when `getPad()` throws so any legacy `.`/`..` pad can still be removed.
|
||||
|
||||
### Internal / contributor-facing
|
||||
|
||||
- **CLI — fix the database migration/import scripts against the ueberdb2 promise API (#7982 / #7983).** `migrateDB.ts` opened source and target databases, copied all keys, then resolved without closing either — so under ueberdb2 6.1.x the keep-alive timer kept the process hanging after "Done syncing dbs", and buffered target writes were only guaranteed flushed on `close()`. It now closes both databases (flushing writes, clearing the timer) on success and error paths and exits with an explicit status. `importSqlFile.ts` and `migrateDirtyDBtoRealDB.ts` were ported off the pre-v6 callback API to `await db.init()` / `db.set(k, v)` / `db.close()`, removing two `@ts-ignore`s that hid broken calls and fixing an undefined `length` in a progress log; `tsc --noEmit` on the bin package is now clean.
|
||||
- **CI — stop the installer smoke test hanging the 6-hour job ceiling (#7981).** The "Installer test" had hung on every ubuntu/macOS run since 3.2.0: `pnpm run prod` is a nested launcher, so `kill "$PID"; wait "$PID"` only signalled the outer pnpm and blocked forever if the node server didn't exit on SIGTERM. Teardown now runs the launcher in its own process group, kills the whole group (SIGTERM then SIGKILL), drops the blocking `wait`, and adds an 8-minute `timeout-minutes` backstop to both smoke steps.
|
||||
- **CI — run the Debian-package smoke test on PRs (#7969).** The packaged-boot smoke test previously ran only on push to `develop` — i.e. after merge — which is why the ueberdb2 startup-exit regression turned `develop` red instead of being blocked at PR time. A `pull_request` trigger (scoped to production-footprint paths) now runs the build+smoke job on PRs; the release/apt-publish jobs stay tag-guarded.
|
||||
- **Release — park the non-functional `ep_etherpad` npm publish (#7922).** The `releaseEtherpad` workflow republished `./src` as `ep_etherpad`, a package with zero dependents that nothing in the repo or any deployment path consumes, and it had been failing with E404 (no OIDC trusted publisher configured). The job is now gated behind an explicit `confirm: true` dispatch input so a stray run fails fast with a clear message, with the status documented in the workflow header and `AGENTS.MD`.
|
||||
- **Tests — port the orphaned legacy timeslider specs to Playwright (#7949).** The `src/tests/frontend/specs/` mocha suite is run by no CI workflow, so its timeslider coverage was dead — which is how the #7946 history-mode regression reached a release. The still-meaningful cases (revision labels, export links, deep-link entry) were ported to `frontend-new` Playwright specs re-targeted at the real in-pad UI, and the three now-ported legacy specs were deleted.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `ueberdb2` pinned to `6.1.13`. 6.1.10 rewrote the cache/buffer layer to lazily arm an `.unref()`'d flush timer only when there are dirty keys, so on a fresh empty dirty DB nothing anchored Node's event loop and the packaged (.deb/systemd) boot could exit cleanly (code 0) before `server.listen()` bound the port — failing the Debian-package health check. The dep was pinned back to the last green release (6.1.9, #7969) and then rolled forward to the now-fixed `6.1.13` (#7979), pinned exactly rather than with a caret.
|
||||
- `nodemailer` 8.x → 9.0.1 (#7965 / #7950 / #7976), `mongodb` 7.1.1 → 7.3.0 (#7941), `pg` 8.21.0 → 8.22.0 (#7985), `undici` → 8.5.0 (#7980 etc.), `oidc-provider` 9.8.4 → 9.8.5 (#7973), `pdfkit` 0.19.0 → 0.19.1 (#7945), `semver` 7.8.3 → 7.8.4 (#7943), and `@radix-ui/react-switch` 1.3.0 → 1.3.1 (#7974).
|
||||
- Dev/build dependency group updates (#7964, #7970, #7978, #7987, #7944, #7951, #7952, and others), including `@types/node` 25 → 26, `esbuild` 0.28.0 → 0.28.1, `eslint` 10.4.1 → 10.5.0, `@playwright/test` 1.60 → 1.61, `vitest` 4.1.8 → 4.1.9, and `actions/checkout` 6 → 7 (#7977).
|
||||
|
||||
# 3.3.1
|
||||
|
||||
3.3.1 is a small bug-fix and hardening follow-up to 3.3.0. It closes a stored-XSS vector in the numbered-list `start` attribute, hardens the database layer so a dropped connection to PostgreSQL / Redis / RethinkDB no longer crashes the process (via ueberdb2 6.1.9), and fixes a handful of pad and admin regressions — the iOS dark-mode status bar, the settings language dropdown, the pad-deletion modal under `allowPadDeletionByAllUsers`, and a single unreadable pad blanking the admin Manage-pads list.
|
||||
|
||||
### Security
|
||||
|
||||
- **Pad editor — escape and integer-coerce the numbered-list `start` attribute (GHSA-f7h5-v9hm-548j, #7937).** A crafted `<ol start>` value flowed unescaped into `domline.ts`, a distinct client-side sink from the export-path fix in 3.3.0's #7905. The value is now integer-coerced and HTML-escaped before it reaches the DOM. A jsdom regression test covers the sink.
|
||||
|
||||
### Notable fixes
|
||||
|
||||
- **Skin — paint the root canvas so iOS dark mode has no white status bar (#7606 / #7931).** iOS Safari paints the top safe area from the `html` root background, which `theme-color` (an Android address-bar hint) does not affect, so dark-mode pads showed a white status-bar strip on iOS. Colibris now sets the root background and `color-scheme` so the safe area matches the editor.
|
||||
- **Settings — show the detected language in the dropdown (#7925 / #7928).** The settings language `<select>` did not reflect the language Etherpad had actually auto-detected; it now shows the active selection.
|
||||
- **Pad — don't issue a deletion token (or show its modal) when `allowPadDeletionByAllUsers` is on (#7929).** With pad deletion open to all users the client still minted a deletion token and surfaced the confirm modal; both are now suppressed in that configuration.
|
||||
- **Admin — one unreadable pad no longer empties the Manage-pads list (#7935 / #7938).** A single pad that failed to read could throw out of the list-hydration path and blank the entire admin Manage-pads view; the read is now guarded per-pad so the rest of the list still renders.
|
||||
|
||||
### Internal / contributor-facing
|
||||
|
||||
- **CI — downstream client compatibility gate (#7923 / #7924 / #7927).** A new gate smoke-tests the published `etherpad-pad`, `etherpad-cli`, and `etherpad-desktop` clients against the server build (Phase 1 + Phase 2), with robust per-client error handling in `run-clients.sh` so one client's failure is reported rather than masking the others.
|
||||
- **CI — verify Etherpad boots offline (#7936).** Adds a test step that confirms a built Etherpad starts with no network access.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- `ueberdb2` 6.1.8 → 6.1.9 — PostgreSQL pool errors are now handled and TCP keep-alive is enabled (fixes #7878), and the Redis and RethinkDB drivers attach connection-error handlers so a dropped database connection no longer crashes the Etherpad process.
|
||||
- `semver` 7.8.2 → 7.8.3 (#7933), `rate-limiter-flexible` 11.1.1 → 11.2.0 (#7934), plus a dev-dependencies group update (#7932).
|
||||
|
||||
# 3.3.0
|
||||
|
||||
3.3 is primarily a security-hardening release. A defence-in-depth pass tightens the HTTP API entry points, switches random-id generation to a CSPRNG, escapes exported `data-*` attributes, and flips the shipped Docker deployment defaults so a fresh install no longer boots with implicit credentials or a trusting proxy. Alongside that, the `ep_*` pad-options passthrough that shipped opt-in in 3.0.0 is now on by default, the in-pad timeslider learns to honour the editor's view settings (authorship colours, font family, line numbers), and a long tail of pad-editor layout, RTL, and URL-encoding fixes lands. The release also carries the root-cause fix for the long-standing Windows backend-test "silent ELIFECYCLE" flake.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm gen:api && vite",
|
||||
|
|
@ -14,39 +14,39 @@
|
|||
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@radix-ui/react-switch": "^1.3.3",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"@tanstack/react-query-devtools": "^5.101.2",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"openapi-react-query": "^0.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.16",
|
||||
"@radix-ui/react-toast": "^1.2.16",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.5",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
"@radix-ui/react-toast": "^1.2.19",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.7",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.63.0",
|
||||
"@typescript-eslint/parser": "^8.63.0",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"i18next": "^26.3.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"lucide-react": "^1.23.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tsx": "^4.22.4",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vite": "^8.1.4",
|
||||
"vite-plugin-babel": "^1.7.3",
|
||||
"zustand": "^5.0.14"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an
|
||||
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
|
||||
import util from "node:util";
|
||||
import fs from 'node:fs';
|
||||
import log4js from 'log4js';
|
||||
import readline from 'readline';
|
||||
|
|
@ -69,8 +68,7 @@ const unescape = (val: string) => {
|
|||
if (!sqlFile) throw new Error('Use: node importSqlFile.js $SQLFILE');
|
||||
|
||||
log('initializing db');
|
||||
const initDb = await util.promisify(db.init.bind(db));
|
||||
await initDb(null);
|
||||
await db.init();
|
||||
log('done');
|
||||
|
||||
log(`Opening ${sqlFile}...`);
|
||||
|
|
@ -86,8 +84,7 @@ const unescape = (val: string) => {
|
|||
value = value.substring(0, value.length - 2);
|
||||
console.log(`key: ${key} val: ${value}`);
|
||||
console.log(`unval: ${unescape(value)}`);
|
||||
// @ts-ignore
|
||||
db.set(key, unescape(value), null);
|
||||
await db.set(key, unescape(value));
|
||||
keyNo++;
|
||||
if (keyNo % 1000 === 0) log(` ${keyNo}`);
|
||||
}
|
||||
|
|
@ -96,9 +93,7 @@ const unescape = (val: string) => {
|
|||
process.stdout.write('done. waiting for db to finish transaction. ' +
|
||||
'depended on dbms this may take some time..\n');
|
||||
|
||||
const closeDB = util.promisify(db.close.bind(db));
|
||||
// @ts-ignore
|
||||
await closeDB(null);
|
||||
await db.close();
|
||||
log(`finished, imported ${keyNo} keys.`);
|
||||
process.exit(0)
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -74,10 +74,20 @@ const handleSync = async ()=>{
|
|||
}
|
||||
}
|
||||
|
||||
handleSync().then(()=>{
|
||||
handleSync().then(async ()=>{
|
||||
// Closing flushes any buffered writes to the target DB and clears
|
||||
// ueberdb2's keep-alive timer (added in 6.1.x). Without this the migrated
|
||||
// data may not be fully persisted and the process would hang forever
|
||||
// instead of exiting once the sync is done.
|
||||
await ueberdb2.close()
|
||||
await ueberdb1.close()
|
||||
console.log("Done syncing dbs")
|
||||
}).catch(e=>{
|
||||
process.exit(0)
|
||||
}).catch(async e=>{
|
||||
console.log(`Error syncing db ${e}`)
|
||||
await ueberdb2.close().catch(()=>{})
|
||||
await ueberdb1.close().catch(()=>{})
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,26 +35,16 @@ process.on('unhandledRejection', (err) => { throw err; });
|
|||
const keys = await dirty.findKeys('*', '')
|
||||
|
||||
console.log(`Found ${keys.length} records, processing now.`);
|
||||
const p: Promise<void>[] = [];
|
||||
let numWritten = 0;
|
||||
for (const key of keys) {
|
||||
let value = await dirty.get(key);
|
||||
let bcb, wcb;
|
||||
p.push(new Promise((resolve, reject) => {
|
||||
bcb = (err:any) => { if (err != null) return reject(err); };
|
||||
wcb = (err:any) => {
|
||||
if (err != null) return reject(err);
|
||||
if (++numWritten % 100 === 0) console.log(`Wrote record ${numWritten} of ${length}`);
|
||||
resolve();
|
||||
};
|
||||
}));
|
||||
db.set(key, value, bcb, wcb);
|
||||
const value = await dirty.get(key);
|
||||
await db.set(key, value);
|
||||
if (++numWritten % 100 === 0) console.log(`Wrote record ${numWritten} of ${keys.length}`);
|
||||
}
|
||||
await Promise.all(p);
|
||||
console.log(`Wrote all ${numWritten} records`);
|
||||
|
||||
await db.close(null);
|
||||
await dirty.close(null);
|
||||
await db.close();
|
||||
await dirty.close();
|
||||
console.log('Finished.');
|
||||
process.exit(0)
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bin",
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.2",
|
||||
"description": "",
|
||||
"main": "checkAllPads.js",
|
||||
"directories": {
|
||||
|
|
@ -9,12 +9,12 @@
|
|||
"dependencies": {
|
||||
"ep_etherpad-lite": "workspace:../src",
|
||||
"log4js": "^6.9.1",
|
||||
"semver": "^7.8.2",
|
||||
"tsx": "^4.22.4",
|
||||
"ueberdb2": "^6.1.8"
|
||||
"semver": "^7.8.5",
|
||||
"tsx": "^4.23.0",
|
||||
"ueberdb2": "6.1.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/semver": "^7.7.1",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -27,13 +27,18 @@ export default defineConfig({
|
|||
items: [
|
||||
{ text: 'Docker', link: '/docker.md' },
|
||||
{ text: 'Configuration', link: '/configuration.md' },
|
||||
{ text: 'Deployment', link: '/deployment.md' },
|
||||
{ text: 'Database', link: '/database.md' },
|
||||
{ text: 'Localization', link: '/localization.md' },
|
||||
{ text: 'Cookies', link: '/cookies.md' },
|
||||
{ text: 'Plugins', link: '/plugins.md' },
|
||||
{ text: 'Stats', link: '/stats.md' },
|
||||
{text: 'Skins', link: '/skins.md' },
|
||||
{ text: 'Accessibility', link: '/accessibility.md' },
|
||||
{text: 'Demo', link: '/demo.md' },
|
||||
{text: 'CLI', link: '/cli.md'},
|
||||
{ text: 'Development', link: '/development.md' },
|
||||
{ text: 'FAQ', link: '/faq.md' },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
91
doc/accessibility.md
Normal file
91
doc/accessibility.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Accessibility
|
||||
|
||||
Etherpad aims to be usable by everyone, including people who rely on a
|
||||
keyboard, a screen reader, or other assistive technology. The editor follows
|
||||
common conventions so that selecting, formatting, and navigating text works the
|
||||
way you would expect in other applications, and the toolbar can be reached and
|
||||
operated without a mouse.
|
||||
|
||||
If you find a feature that is not accessible, please let us know by opening an
|
||||
issue so it can be improved.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
The following shortcuts are built into the editor. On macOS use the Command
|
||||
(`Cmd`) key wherever `Ctrl` is listed.
|
||||
|
||||
::: tip
|
||||
Most shortcuts can be individually enabled or disabled through the
|
||||
`padShortcutEnabled` settings, so a deployment may have customised which of
|
||||
these are active.
|
||||
:::
|
||||
|
||||
### Editor
|
||||
|
||||
| Action | Shortcut |
|
||||
| --- | --- |
|
||||
| Bold | `Ctrl` + `B` |
|
||||
| Italic | `Ctrl` + `I` |
|
||||
| Underline | `Ctrl` + `U` |
|
||||
| Strikethrough | `Ctrl` + `5` |
|
||||
| Ordered (numbered) list | `Ctrl` + `Shift` + `N` or `Ctrl` + `Shift` + `1` |
|
||||
| Unordered (bulleted) list | `Ctrl` + `Shift` + `L` |
|
||||
| Indent line or selection | `Tab` |
|
||||
| Outdent line or selection | `Shift` + `Tab` |
|
||||
| Undo | `Ctrl` + `Z` |
|
||||
| Redo | `Ctrl` + `Y` or `Ctrl` + `Shift` + `Z` |
|
||||
| Save a named revision | `Ctrl` + `S` |
|
||||
| Duplicate the current line(s) | `Ctrl` + `Shift` + `D` |
|
||||
| Delete the current line(s) | `Ctrl` + `Shift` + `K` |
|
||||
| Clear authorship colors on the pad or selection | `Ctrl` + `Shift` + `C` |
|
||||
| Show the authors of the current line | `Ctrl` + `Shift` + `2` |
|
||||
| Focus the toolbar (see below) | `Alt` + `F9` |
|
||||
| Focus the chat input | `Alt` + `C` |
|
||||
|
||||
Text selection, cut (`Ctrl` + `X`), copy (`Ctrl` + `C`), paste
|
||||
(`Ctrl` + `V`), and the arrow keys behave as they do in any standard text
|
||||
editor.
|
||||
|
||||
### Timeslider
|
||||
|
||||
The timeslider (revision history) provides its own shortcuts:
|
||||
|
||||
| Action | Shortcut |
|
||||
| --- | --- |
|
||||
| Play / pause history playback | `Space` |
|
||||
| Step back one revision | `Left Arrow` |
|
||||
| Step forward one revision | `Right Arrow` |
|
||||
| Jump back to the previous starred revision | `Shift` + `Left Arrow` |
|
||||
| Jump forward to the next starred revision | `Shift` + `Right Arrow` |
|
||||
|
||||
## Toolbar navigation
|
||||
|
||||
The toolbar holds the formatting controls (bold, italic, lists, and so on) and
|
||||
can be reached and operated entirely from the keyboard:
|
||||
|
||||
* Press `Alt` + `F9` from the editor to move focus to the first button in the
|
||||
toolbar.
|
||||
* Use the `Left Arrow` and `Right Arrow` keys to move between buttons. `Tab`
|
||||
also moves to the next focusable control.
|
||||
* Press `Enter` to activate the focused button.
|
||||
* Press `Alt` + `F9` again, or `Escape`, to return focus to the pad.
|
||||
|
||||
Pressing `Escape` while a toolbar dropdown (such as the settings or color
|
||||
picker) is open closes that dropdown first.
|
||||
|
||||
## Screen readers
|
||||
|
||||
Etherpad provides as much screen reader support as possible. Support quality
|
||||
varies between platforms and browsers, so the following combinations are
|
||||
recommended:
|
||||
|
||||
* On Windows, Firefox with [NVDA](https://www.nvaccess.org/) currently gives the
|
||||
best experience.
|
||||
|
||||
To reduce verbose feedback while typing collaboratively in NVDA, open the
|
||||
keyboard settings (`NVDA` + `Ctrl` + `K`) and turn off **Speak typed characters**
|
||||
and **Speak typed words**.
|
||||
|
||||
Support in other screen readers and browsers (for example Orca on Linux, or
|
||||
Chrome) is more limited. Contributions to improve coverage on these platforms
|
||||
are very welcome.
|
||||
|
|
@ -102,7 +102,9 @@ The notice auto-fades after 8 seconds and can be dismissed immediately. The publ
|
|||
|
||||
## Disabling everything
|
||||
|
||||
Set `updates.tier` to `"off"`. No HTTP request will leave the instance and no banner or badge will render.
|
||||
Set `updates.tier` to `"off"`. The self-updater goes silent — no request to the GitHub Releases API leaves the instance and no banner or badge renders. Note this does **not** cover the separate legacy version check in `UpdateCheck.ts`, which still fetches `${updateServer}/info.json` until you also set `privacy.updateCheck` to `false` (see [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md)).
|
||||
|
||||
On Docker / air-gapped installs you can do both without editing `settings.json` inside the image by setting `UPDATES_TIER=off` **and** `PRIVACY_UPDATE_CHECK=false` (add `PRIVACY_PLUGIN_CATALOG=false` to also disable the admin plugin browser's catalogue fetch). See the [Updates & privacy](../docker.md#updates--privacy-offline--air-gapped) table in the Docker docs for the full set of environment variables.
|
||||
|
||||
## Privacy
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Changeset Library
|
||||
|
||||
The [changeset
|
||||
library](https://github.com/ether/etherpad-lite/blob/develop/src/static/js/Changeset.ts)
|
||||
library](https://github.com/ether/etherpad/blob/develop/src/static/js/Changeset.ts)
|
||||
provides tools to create, read, and apply changesets.
|
||||
|
||||
## Changeset
|
||||
|
|
@ -21,6 +21,42 @@ A transmitted changeset looks like this:
|
|||
'Z:z>1|2=m=b*0|1+1$\n'
|
||||
```
|
||||
|
||||
### Reading a changeset
|
||||
|
||||
`unpack()` splits a changeset string into its parts:
|
||||
|
||||
```javascript
|
||||
const unpacked = Changeset.unpack('Z:z>1|2=m=b*0|1+1$\n');
|
||||
// { oldLen: 35, newLen: 36, ops: '|2=m=b*0|1+1', charBank: '\n' }
|
||||
```
|
||||
|
||||
`oldLen` is the document length before the change and `newLen` the length after.
|
||||
`ops` is the list of operations, and `charBank` holds the characters inserted by
|
||||
those operations.
|
||||
|
||||
Iterate the operations with `deserializeOps()`, which yields one `Op` at a time:
|
||||
|
||||
```javascript
|
||||
for (const op of Changeset.deserializeOps(unpacked.ops)) {
|
||||
console.log(op);
|
||||
}
|
||||
// Op { opcode: '=', chars: 22, lines: 2, attribs: '' }
|
||||
// Op { opcode: '=', chars: 11, lines: 0, attribs: '' }
|
||||
// Op { opcode: '+', chars: 1, lines: 1, attribs: '*0' }
|
||||
```
|
||||
|
||||
There are three kinds of operation, each applied starting from the current
|
||||
position in the text:
|
||||
|
||||
- `=` keeps text (it may still change the text's attributes, e.g. make it bold).
|
||||
- `-` removes text.
|
||||
- `+` inserts text (taking the characters from the changeset's `charBank`).
|
||||
|
||||
`opcode` is the operation type; `chars` and `lines` are how much text it covers;
|
||||
and `attribs` are the attributes applied, written as `*` references into the
|
||||
pad's attribute pool. In the example above the final op inserts one character
|
||||
(the newline from `charBank`) carrying attribute `*0`.
|
||||
|
||||
## Attribute Pool
|
||||
|
||||
```javascript
|
||||
|
|
@ -36,6 +72,59 @@ are used many times.
|
|||
There is one attribute pool per pad, and it includes every current and
|
||||
historical attribute used in the pad.
|
||||
|
||||
A pool can be serialized to and from a plain object with `toJsonable()` and
|
||||
`fromJsonable()`:
|
||||
|
||||
```javascript
|
||||
const pool = new AttributePool();
|
||||
pool.fromJsonable({
|
||||
numToAttrib: {
|
||||
0: ['author', 'a.kVnWeomPADAT2pn9'],
|
||||
1: ['bold', 'true'],
|
||||
2: ['italic', 'true'],
|
||||
},
|
||||
nextNum: 3,
|
||||
});
|
||||
|
||||
pool.getAttrib(1); // [ 'bold', 'true' ]
|
||||
pool.getAttribKey(1); // 'bold'
|
||||
pool.getAttribValue(1); // 'true'
|
||||
```
|
||||
|
||||
Each attribute is a `[key, value]` pair — `['bold', 'true']`, or
|
||||
`['author', '<authorId>']`. A character can carry several attributes (bold *and*
|
||||
italic), but only one value per key (so it cannot belong to two authors).
|
||||
|
||||
## Attributed text (atext)
|
||||
|
||||
A pad's content is stored as *attributed text* (`atext`): the plain text plus an
|
||||
attribute string describing which attributes apply to each span.
|
||||
|
||||
```javascript
|
||||
const atext = {
|
||||
text: 'bold text\nitalic text\nnormal text\n\n',
|
||||
attribs: '*0*1+9*0|1+1*0*1*2+b|1+1*0+b|2+2',
|
||||
};
|
||||
```
|
||||
|
||||
The attribute string is a sequence of `+` operations — the same encoding used by
|
||||
changesets — which you can read with `deserializeOps()`:
|
||||
|
||||
```javascript
|
||||
for (const op of Changeset.deserializeOps(atext.attribs)) {
|
||||
console.log(op);
|
||||
}
|
||||
// Op { opcode: '+', chars: 9, lines: 0, attribs: '*0*1' }
|
||||
// Op { opcode: '+', chars: 1, lines: 1, attribs: '*0' }
|
||||
// Op { opcode: '+', chars: 11, lines: 0, attribs: '*0*1*2' }
|
||||
// Op { opcode: '+', chars: 1, lines: 1, attribs: '' }
|
||||
// Op { opcode: '+', chars: 11, lines: 0, attribs: '*0' }
|
||||
// Op { opcode: '+', chars: 2, lines: 2, attribs: '' }
|
||||
```
|
||||
|
||||
Read against the pool above, the first nine characters (`bold text`) carry
|
||||
attributes `*0*1` (author + bold), the following newline carries `*0`, and so on.
|
||||
|
||||
## Further Reading
|
||||
|
||||
Detailed information about the changesets & Easysync protocol:
|
||||
|
|
|
|||
204
doc/database.md
Normal file
204
doc/database.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# Database structure
|
||||
|
||||
## Keys and their values
|
||||
|
||||
### groups
|
||||
A list of all existing groups (a JSON object with groupIDs as keys and `1` as values).
|
||||
|
||||
### pad:$PADID
|
||||
Contains all information about pads
|
||||
|
||||
- **atext** - the latest attributed text
|
||||
- **pool** - the attribute pool
|
||||
- **head** - the number of the latest revision
|
||||
- **chatHead** - the number of the latest chat entry
|
||||
- **public** - flag that disables security for this pad
|
||||
- **passwordHash** - string that contains a salted sha512 sum of this pad's password
|
||||
|
||||
### pad:$PADID:revs:$REVNUM
|
||||
Saves a revision $REVNUM of pad $PADID
|
||||
|
||||
- **meta**
|
||||
- **author** - the autorID of this revision
|
||||
- **timestamp** - the timestamp of when this revision was created
|
||||
- **changeset** - the changeset of this revision
|
||||
|
||||
### pad:$PADID:chat:$CHATNUM
|
||||
Saves a chat entry with num $CHATNUM of pad $PADID
|
||||
|
||||
- **text** - the text of this chat entry
|
||||
- **userId** - the authorID of this chat entry
|
||||
- **time** - the timestamp of this chat entry
|
||||
|
||||
### pad2readonly:$PADID
|
||||
Translates a padID to a readonlyID
|
||||
|
||||
### readonly2pad:$READONLYID
|
||||
Translates a readonlyID to a padID
|
||||
|
||||
### token2author:$TOKENID
|
||||
Translates a token to an authorID
|
||||
|
||||
### globalAuthor:$AUTHORID
|
||||
Information about an author
|
||||
|
||||
- **name** - the name of this author as shown in the pad
|
||||
- **colorID** - the colorID of this author as shown in the pad
|
||||
|
||||
### mapper2group:$MAPPER
|
||||
Maps an external application identifier to an internal group
|
||||
|
||||
### mapper2author:$MAPPER
|
||||
Maps an external application identifier to an internal author
|
||||
|
||||
### group:$GROUPID
|
||||
a group of pads
|
||||
|
||||
- **pads** - object with pad names in it, values are 1
|
||||
|
||||
### session:$SESSIONID
|
||||
a session between an author and a group
|
||||
|
||||
- **groupID** - the groupID the session belongs too
|
||||
- **authorID** - the authorID the session belongs too
|
||||
- **validUntil** - the timestamp until this session is valid
|
||||
|
||||
### author2sessions:$AUTHORID
|
||||
saves the sessions of an author
|
||||
|
||||
- **sessionsIDs** - object with sessionIDs in it, values are 1
|
||||
|
||||
### group2sessions:$GROUPID
|
||||
|
||||
- **sessionsIDs** - object with sessionIDs in it, values are 1
|
||||
|
||||
# Connecting to a database backend
|
||||
|
||||
Etherpad stores everything in a single key/value table through
|
||||
[ueberDB](https://www.npmjs.com/package/ueberdb2), so the same data model works
|
||||
across many backends. The backend is selected with `dbType` in `settings.json`,
|
||||
and backend-specific connection options go in `dbSettings`.
|
||||
|
||||
The default `dirty` backend writes to a local file (`var/dirty.db`) and needs no
|
||||
setup, which is convenient for development but not recommended for production.
|
||||
For a production instance, point Etherpad at a real database such as MySQL/MariaDB,
|
||||
PostgreSQL or Redis. Etherpad creates its own table on first run; you only need
|
||||
to provision an empty database and a user with access to it.
|
||||
|
||||
## MySQL / MariaDB
|
||||
|
||||
Create the database and a user, then grant access:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE `etherpad` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
|
||||
CREATE USER 'etherpad'@'localhost' IDENTIFIED BY 'a-secure-password';
|
||||
GRANT CREATE,ALTER,SELECT,INSERT,UPDATE,DELETE ON `etherpad`.* TO 'etherpad'@'localhost';
|
||||
```
|
||||
|
||||
Then configure `settings.json`:
|
||||
|
||||
```json
|
||||
"dbType": "mysql",
|
||||
"dbSettings": {
|
||||
"user": "etherpad",
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
"password": "a-secure-password",
|
||||
"database": "etherpad",
|
||||
"charset": "utf8mb4"
|
||||
}
|
||||
```
|
||||
|
||||
Setting `charset` to `utf8mb4` is strongly recommended so that the full range of
|
||||
Unicode (including emoji) is stored correctly. To connect over a local socket
|
||||
instead of TCP, replace `host`/`port` with `"socketPath": "/var/run/mysqld/mysqld.sock"`.
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
Create the user and a database owned by it:
|
||||
|
||||
```sql
|
||||
CREATE USER etherpad WITH PASSWORD 'a-secure-password';
|
||||
CREATE DATABASE etherpad OWNER etherpad;
|
||||
```
|
||||
|
||||
Then configure `settings.json`:
|
||||
|
||||
```json
|
||||
"dbType": "postgres",
|
||||
"dbSettings": {
|
||||
"user": "etherpad",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"password": "a-secure-password",
|
||||
"database": "etherpad"
|
||||
}
|
||||
```
|
||||
|
||||
The `dbSettings` object is passed straight to the `node-postgres` connection
|
||||
pool, so any option it accepts (including a single `"connectionString"`) works.
|
||||
On Debian/Ubuntu you can use peer authentication over the local socket by
|
||||
setting `"host": "/var/run/postgresql"` and an empty password, provided the
|
||||
operating-system user that runs Etherpad matches the PostgreSQL role.
|
||||
|
||||
## Redis
|
||||
|
||||
Install Redis and make sure it persists data to disk. Configure `settings.json`
|
||||
with either discrete fields or a single connection URL:
|
||||
|
||||
```json
|
||||
"dbType": "redis",
|
||||
"dbSettings": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": "a-secure-redis-password"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
"dbType": "redis",
|
||||
"dbSettings": {
|
||||
"url": "redis://:a-secure-redis-password@localhost:6379"
|
||||
}
|
||||
```
|
||||
|
||||
## Migrating from MySQL to PostgreSQL
|
||||
|
||||
[pgloader](https://pgloader.io/) can copy an existing Etherpad database from
|
||||
MySQL to PostgreSQL. Stop Etherpad first so the source database is quiescent.
|
||||
|
||||
```bash
|
||||
sudo apt-get install postgresql pgloader
|
||||
|
||||
# Create the target role and database
|
||||
sudo -u postgres createuser etherpad
|
||||
sudo -u postgres createdb -O etherpad etherpad
|
||||
|
||||
# Describe and run the migration
|
||||
cat > pgloader.load <<'EOF'
|
||||
LOAD DATABASE
|
||||
FROM mysql://etherpad:MYSQL_PASSWORD@127.0.0.1/etherpad
|
||||
INTO postgresql:///etherpad
|
||||
WITH preserve index names, prefetch rows = 100
|
||||
ALTER SCHEMA 'etherpad' RENAME TO 'public';
|
||||
EOF
|
||||
|
||||
pgloader --verbose pgloader.load
|
||||
```
|
||||
|
||||
Afterwards set the PostgreSQL user's password and make sure it can read and
|
||||
write the migrated table:
|
||||
|
||||
```sql
|
||||
ALTER USER etherpad WITH PASSWORD 'a-secure-password';
|
||||
GRANT pg_read_all_data TO etherpad;
|
||||
GRANT pg_write_all_data TO etherpad;
|
||||
```
|
||||
|
||||
Then point `settings.json` at PostgreSQL as shown above and start Etherpad.
|
||||
|
||||
::: tip
|
||||
To move data between *any* two backends supported by ueberDB, you can also
|
||||
use the `migrateDB` CLI tool, which reads every record from a source database
|
||||
descriptor and writes it to a target one. See the [CLI chapter](./cli.md).
|
||||
:::
|
||||
359
doc/deployment.md
Normal file
359
doc/deployment.md
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
# Deployment
|
||||
|
||||
This page collects working configurations for deploying Etherpad in production:
|
||||
running it behind a reverse proxy, hosting it under a subdirectory, terminating
|
||||
HTTPS natively, running it as a system service, and deploying it on Kubernetes.
|
||||
|
||||
Etherpad listens on port `9001` by default. Throughout this page the upstream
|
||||
Etherpad server is assumed to be reachable at `http://127.0.0.1:9001`.
|
||||
|
||||
## Running behind a reverse proxy
|
||||
|
||||
The recommended production setup is to run Etherpad on `127.0.0.1:9001` and put a
|
||||
reverse proxy in front of it to terminate TLS, serve a virtual host, and forward
|
||||
requests.
|
||||
|
||||
Etherpad uses WebSockets (via socket.io). The load-bearing part of every proxy
|
||||
config below is the WebSocket upgrade: the proxy **must** forward the `Upgrade`
|
||||
and `Connection` headers, or real-time editing will silently fail back to slow
|
||||
long-polling (or break entirely).
|
||||
|
||||
When Etherpad runs behind a proxy you should also set `trustProxy: true` in your
|
||||
settings so that Etherpad honours the `X-Forwarded-*` headers (correct client IP,
|
||||
secure-cookie flag, etc.). See the `trustProxy` section in the [Configuration documentation](./configuration.md) for the full details of which headers are trusted.
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
# Map the Upgrade header so WebSockets work. Place this in the http context.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name pad.example.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/etherpad.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/etherpad.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9001;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_pass_header Server;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect plain HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name pad.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
### Apache
|
||||
|
||||
Enable `mod_proxy`, `mod_proxy_http`, `mod_proxy_wstunnel` and `mod_headers`.
|
||||
The `mod_proxy_wstunnel` `upgrade=websocket` syntax requires Apache 2.4.47 or
|
||||
newer.
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName pad.example.com
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/ssl/etherpad/etherpad.crt
|
||||
SSLCertificateKeyFile /etc/ssl/etherpad/etherpad.key
|
||||
|
||||
ProxyVia On
|
||||
ProxyRequests Off
|
||||
ProxyPreserveHost On
|
||||
|
||||
# WebSocket traffic (socket.io) must be matched first.
|
||||
<Location "/socket.io">
|
||||
ProxyPass "ws://127.0.0.1:9001/socket.io" upgrade=websocket timeout=30
|
||||
ProxyPassReverse "ws://127.0.0.1:9001/socket.io"
|
||||
</Location>
|
||||
|
||||
<Location "/">
|
||||
ProxyPass "http://127.0.0.1:9001/" retry=0 timeout=30
|
||||
ProxyPassReverse "http://127.0.0.1:9001/"
|
||||
</Location>
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
### Caddy
|
||||
|
||||
Caddy v2 proxies WebSocket connections automatically and obtains/renews a
|
||||
certificate for you, so the configuration is minimal:
|
||||
|
||||
```caddy
|
||||
pad.example.com {
|
||||
reverse_proxy 127.0.0.1:9001
|
||||
}
|
||||
```
|
||||
|
||||
### Traefik
|
||||
|
||||
Traefik v2 also proxies WebSockets transparently. For a Docker deployment, attach
|
||||
these labels to the Etherpad container:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.etherpad.rule=Host(`pad.example.com`)"
|
||||
- "traefik.http.routers.etherpad.entrypoints=websecure"
|
||||
- "traefik.http.routers.etherpad.tls.certresolver=myresolver"
|
||||
- "traefik.http.services.etherpad.loadbalancer.server.port=9001"
|
||||
- "traefik.http.services.etherpad.loadbalancer.passhostheader=true"
|
||||
```
|
||||
|
||||
### HAProxy
|
||||
|
||||
HAProxy detects the `Connection: Upgrade` exchange automatically and switches to
|
||||
tunnel mode once the WebSocket is established. The important value is
|
||||
`timeout tunnel`, which governs the lifetime of the upgraded connection.
|
||||
|
||||
```haproxy
|
||||
frontend http
|
||||
mode http
|
||||
bind *:80
|
||||
bind *:443 ssl crt /etc/haproxy/certs/etherpad.pem alpn h2,http/1.1
|
||||
http-request redirect scheme https code 301 unless { ssl_fc }
|
||||
http-request add-header X-Forwarded-Proto https if { ssl_fc }
|
||||
default_backend etherpad
|
||||
|
||||
backend etherpad
|
||||
mode http
|
||||
option forwardfor
|
||||
timeout client 25s
|
||||
timeout server 25s
|
||||
timeout tunnel 3600s
|
||||
server pad 127.0.0.1:9001
|
||||
```
|
||||
|
||||
## Hosting under a subdirectory
|
||||
|
||||
To serve Etherpad from a path such as `https://example.com/pad` rather than from
|
||||
the root of a domain, the proxy must send the `X-Proxy-Path` header so that
|
||||
Etherpad rewrites its own asset and API URLs to include the prefix. This header
|
||||
is honoured regardless of the `trustProxy` setting — see the [Configuration documentation](./configuration.md).
|
||||
|
||||
```nginx
|
||||
location /pad/ {
|
||||
rewrite ^/pad/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:9001;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Proxy-Path /pad;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
```
|
||||
|
||||
## Native HTTPS without a proxy
|
||||
|
||||
Etherpad can terminate TLS itself using Node's native HTTPS server, with no
|
||||
reverse proxy required. Configure the `ssl` block in `settings.json`:
|
||||
|
||||
```json
|
||||
"ssl": {
|
||||
"key": "/path-to-your/etherpad-server.key",
|
||||
"cert": "/path-to-your/etherpad-server.crt",
|
||||
"ca": ["/path-to-your/intermediate-cert1.crt", "/path-to-your/intermediate-cert2.crt"]
|
||||
}
|
||||
```
|
||||
|
||||
* `key` — path to the private key file.
|
||||
* `cert` — path to the certificate file.
|
||||
* `ca` — an (optional) array of intermediate/chain certificate paths.
|
||||
|
||||
Restart Etherpad after editing the settings. It will now serve HTTPS on its
|
||||
configured port.
|
||||
|
||||
For local testing you can generate a self-signed certificate with a single
|
||||
command:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:4096 -nodes -days 365 \
|
||||
-keyout etherpad-server.key -out etherpad-server.crt \
|
||||
-subj "/CN=localhost"
|
||||
```
|
||||
|
||||
Make sure the files are readable only by the user that runs Etherpad:
|
||||
|
||||
```bash
|
||||
chmod 400 etherpad-server.key etherpad-server.crt
|
||||
chown etherpad etherpad-server.key etherpad-server.crt
|
||||
```
|
||||
|
||||
::: tip
|
||||
Self-signed certificates trigger browser warnings and are only suitable for
|
||||
testing. For production, obtain a free, trusted certificate from
|
||||
[Let's Encrypt](https://letsencrypt.org/), or terminate TLS at a reverse proxy
|
||||
(see above) and let it manage certificate issuance and renewal.
|
||||
:::
|
||||
|
||||
## Running as a service (systemd)
|
||||
|
||||
On a modern Linux distribution, run Etherpad as a `systemd` service so it starts
|
||||
on boot and restarts automatically on failure.
|
||||
|
||||
Create a dedicated unprivileged user and install Etherpad into its home
|
||||
directory (for example `/opt/etherpad`), owned by that user. Etherpad refuses to
|
||||
start as root.
|
||||
|
||||
Create `/etc/systemd/system/etherpad.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Etherpad collaborative editor
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=etherpad
|
||||
Group=etherpad
|
||||
WorkingDirectory=/opt/etherpad
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/usr/bin/pnpm run prod
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Adjust `WorkingDirectory` to your install path and the `ExecStart` path to
|
||||
wherever `pnpm` lives (`which pnpm`). Then enable and start the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now etherpad.service
|
||||
|
||||
# check status and follow logs
|
||||
sudo systemctl status etherpad.service
|
||||
sudo journalctl -u etherpad.service -f
|
||||
```
|
||||
|
||||
## Kubernetes (Istio)
|
||||
|
||||
The following manifest deploys Etherpad behind an Istio ingress gateway. It
|
||||
defines three resources: a `Gateway` (TLS + hostname), a `VirtualService`
|
||||
(routing with WebSocket-friendly timeouts), and a `DestinationRule` (sticky
|
||||
sessions via the socket.io `io` cookie).
|
||||
|
||||
It assumes:
|
||||
|
||||
* Istio >= 1.18
|
||||
* A `Service` named `etherpad` in the `etherpad` namespace, on port `9001`
|
||||
* A TLS secret `etherpad-tls` provisioned in the gateway namespace
|
||||
* You replace `<your-host>` with your own hostname
|
||||
|
||||
::: warning
|
||||
Sticky sessions are necessary but **not** sufficient for a multi-replica
|
||||
Etherpad deployment. Multi-replica also needs the socket.io Redis adapter so
|
||||
that pad state is shared across pods. Without it, two clients editing the same
|
||||
pad but routed to different pods will see divergent state.
|
||||
|
||||
Recommendation: start with `replicas: 1` plus good failover, and only go
|
||||
multi-replica once the Redis adapter is wired up.
|
||||
:::
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.istio.io/v1beta1
|
||||
kind: Gateway
|
||||
metadata:
|
||||
name: etherpad
|
||||
namespace: etherpad
|
||||
spec:
|
||||
selector:
|
||||
istio: ingressgateway
|
||||
servers:
|
||||
- port:
|
||||
number: 443
|
||||
name: https
|
||||
protocol: HTTPS
|
||||
tls:
|
||||
mode: SIMPLE
|
||||
credentialName: etherpad-tls
|
||||
hosts:
|
||||
- <your-host>
|
||||
- port:
|
||||
number: 80
|
||||
name: http
|
||||
protocol: HTTP
|
||||
hosts:
|
||||
- <your-host>
|
||||
tls:
|
||||
httpsRedirect: true
|
||||
|
||||
---
|
||||
apiVersion: networking.istio.io/v1beta1
|
||||
kind: VirtualService
|
||||
metadata:
|
||||
name: etherpad
|
||||
namespace: etherpad
|
||||
spec:
|
||||
hosts:
|
||||
- <your-host>
|
||||
gateways:
|
||||
- etherpad
|
||||
http:
|
||||
- match:
|
||||
- uri:
|
||||
prefix: /
|
||||
route:
|
||||
- destination:
|
||||
host: etherpad
|
||||
port:
|
||||
number: 9001
|
||||
# No per-request timeout — websockets and long-polling sit on the
|
||||
# connection indefinitely. The default of 15s kills WS upgrades.
|
||||
timeout: 0s
|
||||
|
||||
---
|
||||
apiVersion: networking.istio.io/v1beta1
|
||||
kind: DestinationRule
|
||||
metadata:
|
||||
name: etherpad
|
||||
namespace: etherpad
|
||||
spec:
|
||||
host: etherpad
|
||||
trafficPolicy:
|
||||
loadBalancer:
|
||||
# Sticky sessions on the socket.io session cookie. Required so that
|
||||
# long-polling fallback requests land on the same pod that owns the
|
||||
# session state.
|
||||
consistentHash:
|
||||
httpCookie:
|
||||
name: io
|
||||
ttl: 0s # session cookie, expires with the browser tab
|
||||
connectionPool:
|
||||
tcp:
|
||||
maxConnections: 10000
|
||||
http:
|
||||
# Must comfortably exceed socket.io's pingInterval (25s) +
|
||||
# pingTimeout (20s). 1h is conservative.
|
||||
idleTimeout: 3600s
|
||||
h2UpgradePolicy: UPGRADE
|
||||
http1MaxPendingRequests: 1000
|
||||
```
|
||||
240
doc/development.md
Normal file
240
doc/development.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Development
|
||||
|
||||
This page is a contributor-oriented tour of the Etherpad source tree and of a
|
||||
few internals that plugin authors and core contributors commonly need to
|
||||
understand: how the source is laid out, how pads are converted to and from
|
||||
other formats, and how to access the database from server-side code.
|
||||
|
||||
The Etherpad server is written in TypeScript (`.ts`). Most server code lives
|
||||
under `src/node/` and most client code under `src/static/js/`.
|
||||
|
||||
## Source tree overview
|
||||
|
||||
The repository root contains, among others, the following directories:
|
||||
|
||||
```
|
||||
etherpad/
|
||||
|- bin/ # maintenance and build scripts (run.sh, pad tools, docs, release)
|
||||
|- doc/ # this manual, in AsciiDoc and Markdown
|
||||
|- src/ # the Etherpad source code
|
||||
|- packaging/ # OS/distribution packaging helpers
|
||||
|- var/ # runtime data (e.g. the dirty.db database file)
|
||||
```
|
||||
|
||||
`bin/` contains scripts for running and maintaining Etherpad. For example
|
||||
`bin/run.sh` starts the server, and there are TypeScript utilities such as
|
||||
`bin/checkPad.ts`, `bin/deletePad.ts`, `bin/repairPad.ts`,
|
||||
`bin/rebuildPad.ts`, `bin/migrateDB.ts` and `bin/make_docs.ts`.
|
||||
|
||||
The HTML manual is built from the AsciiDoc sources in `doc/` by
|
||||
`bin/make_docs.ts` (exposed as the `makeDocs` script), which shells out to
|
||||
`asciidoctor` and writes the result to `out/doc/`. From the repository root you
|
||||
can run it with `pnpm run makeDocs`. (`asciidoctor` must be installed.)
|
||||
|
||||
The `src/` directory looks like this:
|
||||
|
||||
```
|
||||
src/
|
||||
|- locales/ # translations, managed via https://translatewiki.net
|
||||
|- node/ # server-side code
|
||||
|- static/ # client-side code, CSS and fonts
|
||||
|- templates/ # server-rendered page templates
|
||||
|- ep.json # core plugin/hook registration
|
||||
|- package.json # package name: ep_etherpad-lite
|
||||
```
|
||||
|
||||
### src/node/ (server side)
|
||||
|
||||
```
|
||||
src/node/
|
||||
|- db/ # database access and pad/author/group/session state
|
||||
|- eejs/ # server-side embedded-JS templating
|
||||
|- handler/ # import/export and collaboration message handling
|
||||
|- hooks/ # express route registration and i18n
|
||||
|- security/ # crypto, OAuth2/OIDC, secret rotation
|
||||
|- types/ # shared TypeScript types
|
||||
|- updater/ # in-place self-update machinery
|
||||
|- utils/ # settings, import/export format helpers, toolbar, minification
|
||||
|- server.ts # entry point
|
||||
```
|
||||
|
||||
`db/` contains the modules that read and write pad state. `Pad.ts` manages an
|
||||
individual pad; `PadManager.ts`, `AuthorManager.ts`, `GroupManager.ts`,
|
||||
`SessionManager.ts` and `ReadOnlyManager.ts` manage the corresponding records;
|
||||
`DB.ts` exposes the low-level key/value store (see
|
||||
[Accessing the database from server code / plugins](#accessing-the-database-from-server-code-plugins)); and `API.ts` implements
|
||||
the public HTTP API.
|
||||
|
||||
`handler/` contains the request and message handlers. `PadMessageHandler.ts`
|
||||
drives real-time collaboration, while `ImportHandler.ts` and `ExportHandler.ts`
|
||||
handle import and export.
|
||||
|
||||
`hooks/` contains mostly Express-related code. `i18n.ts` builds the translation
|
||||
files and registers routes to serve them, and `hooks/express/` registers the
|
||||
routes that serve pads, the timeslider, static assets and the admin pages.
|
||||
|
||||
`utils/` contains the import/export format converters (`ImportHtml.ts`,
|
||||
`ExportHtml.ts`, `ExportTxt.ts`, `ExportEtherpad.ts`, `ImportEtherpad.ts`,
|
||||
`ExportHelper.ts`, and native converters such as `ExportPdfNative.ts` and
|
||||
`ImportDocxNative.ts`), the settings parser (`Settings.ts`), the toolbar builder
|
||||
(`toolbar.ts`) and the asset minifier (`Minify.ts`).
|
||||
|
||||
### src/static/ (client side)
|
||||
|
||||
```
|
||||
src/static/
|
||||
|- css/ # stylesheets, including css/pad/icons.css
|
||||
|- font/ # web fonts, including the fontawesome-etherpad icon font
|
||||
|- img/
|
||||
|- js/ # client-side TypeScript
|
||||
|- skins/ # bundled UI skins
|
||||
|- vendor/
|
||||
```
|
||||
|
||||
`js/` contains the client-side editor code. Notable modules include
|
||||
`ace2_inner.ts` and `ace2_common.ts` (the editor core), `contentcollector.ts`,
|
||||
`linestylefilter.ts` and `domline.ts` (content/attribute processing, shared
|
||||
with the server import/export pipeline), `Changeset.ts` and `AttributePool.ts`
|
||||
(the changeset and attribute model), and `collab_client.ts` (the
|
||||
client side of real-time collaboration).
|
||||
|
||||
### src/templates/
|
||||
|
||||
`templates/` contains the server-rendered page templates for the index, the
|
||||
pad, the timeslider and the admin pages, plus the bootstrap scripts that load
|
||||
the client bundles. The templates expose named `eejs` blocks that plugins can
|
||||
hook into to inject custom HTML.
|
||||
|
||||
## How Etherpad converts pads to and from other formats
|
||||
|
||||
Internally a pad is not stored as HTML. A pad is a sequence of lines, and each
|
||||
line carries **attributes** (for example `heading1`, `bullet` or a list number).
|
||||
The set of attributes that a pad can use is stored in its **attribute pool**; the
|
||||
pool only records which attributes exist, not where they are applied. The
|
||||
pool grows over the history of the pad.
|
||||
|
||||
Where an attribute is applied to a line is recorded in an **attribute string**,
|
||||
and a line that carries a line-level attribute is prefixed with a **line marker**
|
||||
(`lmkr`). Attribute strings and changesets are defined by
|
||||
`src/static/js/Changeset.ts` and `src/static/js/AttributePool.ts`.
|
||||
|
||||
### Collecting content
|
||||
|
||||
`src/static/js/contentcollector.ts` is the shared starting point for both the
|
||||
client (when content is typed or pasted) and the server (when content is
|
||||
imported). It walks the incoming DOM/HTML, decides which attributes apply to
|
||||
each line, adds the discovered attributes to the attribute pool, and emits the
|
||||
resulting attribute strings. On import, `src/node/utils/ImportHtml.ts` calls
|
||||
`contentcollector.makeContentCollector(...)` to do exactly this, and the HTML
|
||||
import path in `src/node/handler/ImportHandler.ts` ultimately drives it.
|
||||
|
||||
### From attributes to HTML/text (export)
|
||||
|
||||
On export the flow is, conceptually:
|
||||
|
||||
```
|
||||
contentcollector.ts
|
||||
-> linestylefilter.ts
|
||||
-> ExportHtml.ts / ExportTxt.ts (helped by ExportHelper.ts)
|
||||
-> ExportHandler.ts
|
||||
-> the HTTP API / /export/* route
|
||||
```
|
||||
|
||||
- `src/static/js/linestylefilter.ts` walks each line, reads its attributes,
|
||||
and turns them into the classes/markup the line should render with.
|
||||
- `src/node/utils/ExportHelper.ts` adds export-only logic that does not belong
|
||||
in the live editor. The clearest example is lists: in the editor each list
|
||||
item is rendered as its own line-level block, but a clean export needs the
|
||||
items collapsed into a single properly nested list. The helper performs that
|
||||
reshaping for export only.
|
||||
- `src/node/utils/ExportHtml.ts` and `src/node/utils/ExportTxt.ts` (and
|
||||
`ExportEtherpad.ts` for the native `.etherpad` format) turn the attributed
|
||||
text (`atext`) into the final HTML or plain text.
|
||||
- `src/node/handler/ExportHandler.ts` receives the export request and dispatches
|
||||
on the requested format — for instance, office formats such as `.docx` and
|
||||
`.pdf` are routed through the native converters / LibreOffice rather than
|
||||
through the plain HTML/text path.
|
||||
|
||||
On the client side, edits are turned into changesets by the editor, attributes
|
||||
are translated into CSS classes (so `heading2` becomes
|
||||
`class="heading2"`), and `src/static/js/domline.ts` (`createDomLine`) renders
|
||||
the final DOM for each line.
|
||||
|
||||
## Accessing the database from server code / plugins
|
||||
|
||||
Etherpad stores everything in a single key/value store backed by
|
||||
[ueberDB](https://www.npmjs.com/package/ueberdb2), which abstracts over the
|
||||
configured database (dirtyDB, MySQL/MariaDB, PostgreSQL, SQLite, MongoDB, Redis,
|
||||
and others). Server-side code and plugins access it through
|
||||
`src/node/db/DB.ts`.
|
||||
|
||||
The package name of the core module is, for historical reasons, still
|
||||
`ep_etherpad-lite`, so plugins import the database module like this:
|
||||
|
||||
```javascript
|
||||
const db = require('ep_etherpad-lite/node/db/DB');
|
||||
```
|
||||
|
||||
The exposed methods are asynchronous and return promises (use `await`), not the
|
||||
old callback style. The available methods are `get`, `set`, `remove`, `getSub`,
|
||||
`setSub`, `findKeys` and `findKeysPaged`:
|
||||
|
||||
```javascript
|
||||
// Read a record (returns undefined/null if it does not exist)
|
||||
const value = await db.get('record_key');
|
||||
|
||||
// Create or replace a record
|
||||
await db.set('record_key', data);
|
||||
|
||||
// Read or write a nested value inside a record
|
||||
const colorId = await db.getSub('author_key', ['colorId']);
|
||||
await db.setSub('author_key', ['email'], 'tutti@frutti.org');
|
||||
|
||||
// Delete a record
|
||||
await db.remove('record_key');
|
||||
```
|
||||
|
||||
For example, given the author record:
|
||||
|
||||
```json
|
||||
{"colorId":"#79d9d9","name":"tutti","timestamp":1364832712430,"padIDs":{"mypad":1}}
|
||||
```
|
||||
|
||||
calling `await db.setSub('author_key', ['email'], 'tutti@frutti.org')` yields:
|
||||
|
||||
```json
|
||||
{"colorId":"#79d9d9","name":"tutti","timestamp":1364832712430,"padIDs":{"mypad":1},"email":"tutti@frutti.org"}
|
||||
```
|
||||
|
||||
::: warning
|
||||
Keys are namespaced (for example `pad:<padId>`,
|
||||
`pad:<padId>:revs:<rev>`, `globalAuthor:<authorId>`). Prefer the high-level
|
||||
managers (`Pad.ts`, `AuthorManager.ts`, etc.) over direct `DB` access where one
|
||||
exists; reach for `DB` directly only for data your plugin owns, and use a key
|
||||
prefix unique to your plugin to avoid collisions.
|
||||
:::
|
||||
|
||||
## Adding a toolbar icon
|
||||
|
||||
Etherpad's toolbar icons come from the bundled `fontawesome-etherpad` icon
|
||||
font in `src/static/font/`. Toolbar buttons reference an icon by a
|
||||
`buttonicon-<name>` CSS class (see `src/node/utils/toolbar.ts`, which builds
|
||||
each button's class as `buttonicon buttonicon-<name>`), and those classes are
|
||||
defined in `src/static/css/pad/icons.css`. The font itself is generated with
|
||||
[Fontello](http://fontello.com) from `src/static/font/config.json` (whose
|
||||
`css_prefix_text` is `buttonicon-`).
|
||||
|
||||
To add a new icon:
|
||||
|
||||
1. Go to [Fontello](http://fontello.com) and import the existing
|
||||
`src/static/font/config.json` (Fontello's "import" loads the current icon
|
||||
set and pre-selects the icons it contains).
|
||||
2. Select the additional icon(s) you want, then click **Download webfont**.
|
||||
3. From the unzipped download, copy `config.json` and the
|
||||
`font/fontawesome-etherpad.*` files over the ones in `src/static/font/`.
|
||||
4. From the unzipped `css/fontawesome-etherpad.css`, copy the new
|
||||
`.buttonicon-<name>:before { content: '\\eXXX'; }` rules into
|
||||
`src/static/css/pad/icons.css`, replacing the existing block of icon rules.
|
||||
|
||||
The icon is then available wherever a `buttonicon-<name>` class can be used,
|
||||
including toolbar button definitions.
|
||||
|
|
@ -118,6 +118,25 @@ The `settings.json.docker` available by default allows to control almost every s
|
|||
| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
|
||||
|
||||
|
||||
### Updates & privacy (offline / air-gapped)
|
||||
|
||||
Etherpad makes a small number of outbound calls (a periodic version check and the admin plugin catalogue). In an air-gapped or firewalled deployment these can be disabled entirely without editing `settings.json` inside the image — set the variables below. See [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md) and [doc/admin/updates.md](admin/updates.md) for what each call sends.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| `PRIVACY_UPDATE_CHECK` | Set to `false` to disable the hourly version check (`UpdateCheck.ts`). | `true` |
|
||||
| `PRIVACY_PLUGIN_CATALOG` | Set to `false` to disable the admin plugin browser (manual install-by-name via CLI still works). | `true` |
|
||||
| `UPDATES_TIER` | Self-updater tier: `off` \| `notify` \| `manual` \| `auto` \| `autonomous`. Set to `off` to suppress the GitHub Releases check entirely. | `notify` |
|
||||
| `UPDATES_SOURCE` | Where update metadata is fetched from. | `github` |
|
||||
| `UPDATES_CHANNEL` | Release channel to track. | `stable` |
|
||||
| `UPDATES_CHECK_INTERVAL_HOURS` | How often (hours) the updater polls when not `off`. | `6` |
|
||||
| `UPDATES_GITHUB_REPO` | Repository the updater checks for releases. | `ether/etherpad` |
|
||||
| `UPDATES_REQUIRE_ADMIN_FOR_STATUS`| Lock `/admin/update/status` to authenticated admins. | `false` |
|
||||
| `UPDATE_SERVER` | Endpoint backing the version check. Point elsewhere (or disable the check above) for offline installs. | `https://etherpad.org/ep_infos` |
|
||||
|
||||
> **Fully offline:** set `UPDATES_TIER=off`, `PRIVACY_UPDATE_CHECK=false`, and `PRIVACY_PLUGIN_CATALOG=false`. The version check is fire-and-forget and already fails closed (a blocked endpoint is caught and logged, it does not prevent startup), but disabling it removes the outbound attempt and the log noise.
|
||||
|
||||
|
||||
### Database
|
||||
|
||||
| Variable | Description | Default |
|
||||
|
|
|
|||
204
doc/faq.md
Normal file
204
doc/faq.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# FAQ
|
||||
|
||||
This page answers common operational questions about running and maintaining
|
||||
an Etherpad instance. It collects material previously kept on the project wiki.
|
||||
|
||||
## How do I install Etherpad?
|
||||
|
||||
There are several supported ways to install Etherpad. Pick whichever suits your
|
||||
environment.
|
||||
|
||||
### Docker
|
||||
|
||||
The official image is published to Docker Hub (`etherpad/etherpad`) and to the
|
||||
GitHub Container Registry (`ghcr.io/ether/etherpad`) with identical tags.
|
||||
|
||||
```bash
|
||||
docker pull etherpad/etherpad
|
||||
docker run -p 9001:9001 etherpad/etherpad
|
||||
```
|
||||
|
||||
See the [Docker chapter](./docker.md) for building personalized images, enabling plugins, and
|
||||
configuring office-format import/export.
|
||||
|
||||
### One-line installer (macOS / Linux / WSL)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.sh | sh
|
||||
```
|
||||
|
||||
On Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.ps1 | iex
|
||||
```
|
||||
|
||||
The installer clones Etherpad, installs dependencies and builds the frontend.
|
||||
Set `ETHERPAD_RUN=1` to also start it once the install finishes.
|
||||
|
||||
### apt repository (Debian / Ubuntu)
|
||||
|
||||
Etherpad publishes a signed APT repository (`stable` channel). Import the signing
|
||||
key, add the repository and install:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://etherpad.org/key.asc \
|
||||
| sudo gpg --dearmor -o /usr/share/keyrings/etherpad.gpg
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/etherpad.gpg] https://etherpad.org/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/etherpad.list
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install etherpad
|
||||
```
|
||||
|
||||
The repository provides `amd64` and `arm64` builds. Etherpad depends on
|
||||
Node.js >= 24, so on older distributions you may also need NodeSource's apt
|
||||
repository to satisfy that dependency.
|
||||
|
||||
### From source
|
||||
|
||||
Etherpad requires [Node.js](https://nodejs.org/) >= 24 and `pnpm`.
|
||||
|
||||
```bash
|
||||
git clone -b master https://github.com/ether/etherpad
|
||||
cd etherpad
|
||||
pnpm i
|
||||
pnpm run build:etherpad
|
||||
pnpm run prod
|
||||
```
|
||||
|
||||
Then open `http://localhost:9001`.
|
||||
|
||||
## What URL paths does Etherpad serve?
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/admin` | Administration dashboard (requires admin login). |
|
||||
| `/admin/plugins` | Install, update and remove plugins from the web UI. |
|
||||
| `/admin/settings` | Edit `settings.json` from the web UI. |
|
||||
| `/p/:padID` | Open (or create) the pad with the given `padID`, e.g. `/p/foo`. |
|
||||
| `/p/:padID/timeslider` | Open the pad's history/timeslider view. Append `#N` to jump to a specific revision, e.g. `/p/foo/timeslider#5`. |
|
||||
| `/p/:padID/export/:type` | Export the pad in the given format, e.g. `/p/foo/export/html`. Append `?revs=N` to export a specific revision. |
|
||||
|
||||
Supported export types:
|
||||
|
||||
- **Native (no extra dependencies):** `txt`, `html`, `etherpad`, `docx`, `pdf`.
|
||||
- **Via LibreOffice:** `odt`, `doc`, `rtf` — these require the `soffice` setting
|
||||
to point at a LibreOffice executable. See the office-format notes in the
|
||||
[Docker chapter](./docker.md).
|
||||
|
||||
## How do I list all pads?
|
||||
|
||||
The recommended way is the HTTP API method `listAllPads`, combined with `jq`:
|
||||
|
||||
```bash
|
||||
ETHERPAD_HOST='https://pad.example.com'
|
||||
ETHERPAD_API_KEY='...' # the APIKEY.txt file in the Etherpad root
|
||||
ETHERPAD_API_VERSION='...' # see https://pad.example.com/api
|
||||
|
||||
curl -s "${ETHERPAD_HOST}/api/${ETHERPAD_API_VERSION}/listAllPads?apikey=${ETHERPAD_API_KEY}" \
|
||||
| jq -r '.data.padIDs[]'
|
||||
```
|
||||
|
||||
For an interactive list with management actions, install the `ep_adminpads2`
|
||||
plugin and browse to `/admin/pads`.
|
||||
|
||||
As a last resort you can query the database directly. The exact query depends on
|
||||
your configured backend; pad records use keys of the form `pad:<padID>` and
|
||||
`pad:<padID>:revs:<n>`. For example, with SQLite:
|
||||
|
||||
```bash
|
||||
sqlite3 ./var/sqlite.db "select key from store where key like 'pad:%'" \
|
||||
| grep -Eo '^pad:[^:]+' \
|
||||
| sed -e 's/pad://' \
|
||||
| sort -u
|
||||
```
|
||||
|
||||
Prefer the API or admin plugin over direct SQL: the schema is an implementation
|
||||
detail and may change.
|
||||
|
||||
## How do I delete or manage pads?
|
||||
|
||||
Use the HTTP API `deletePad` method:
|
||||
|
||||
```bash
|
||||
curl -s "${ETHERPAD_HOST}/api/${ETHERPAD_API_VERSION}/deletePad?apikey=${ETHERPAD_API_KEY}&padID=foo"
|
||||
```
|
||||
|
||||
The API also offers `copyPad`, `movePad`, `getRevisionsCount` and more — see the
|
||||
[HTTP API chapter](./api/http_api.md).
|
||||
|
||||
For a web UI, install the `ep_adminpads2` plugin and manage pads from
|
||||
`/admin/pads`, where you can search, view and delete pads.
|
||||
|
||||
The `deletePad` CLI tool is also available for operators:
|
||||
|
||||
```bash
|
||||
pnpm run --filter bin deletePad <padID>
|
||||
```
|
||||
|
||||
## How do I back up and restore pads?
|
||||
|
||||
### Back up the whole instance
|
||||
|
||||
All pad data lives in the configured database. Back it up using the tool
|
||||
appropriate to your backend (for example `mysqldump` for MySQL/MariaDB,
|
||||
`pg_dump` for PostgreSQL, or a file copy of `var/*.db` for the file-based
|
||||
`dirty`/`rusty` engines while Etherpad is stopped). A regular, automated dump of
|
||||
the database is the canonical backup for a production instance.
|
||||
|
||||
### Back up a single pad
|
||||
|
||||
Export the pad over HTTP by appending `/export/<type>` to its URL. Plain text,
|
||||
HTML and the round-trippable `etherpad` format are most useful for backups:
|
||||
|
||||
```bash
|
||||
curl -o mypad.txt https://pad.example.com/p/foo/export/txt
|
||||
curl -o mypad.html https://pad.example.com/p/foo/export/html
|
||||
curl -o mypad.etherpad https://pad.example.com/p/foo/export/etherpad
|
||||
```
|
||||
|
||||
The `etherpad` export preserves the pad's full history and can be re-imported,
|
||||
making it the best choice for migrating or archiving an individual pad.
|
||||
|
||||
### Restore or inspect an old revision
|
||||
|
||||
Every state the pad has been in is stored in the database, so you can retrieve
|
||||
an earlier revision without a separate backup:
|
||||
|
||||
- Open `/p/:padID/timeslider` to browse the history and find the revision
|
||||
number you want.
|
||||
- Export a specific revision directly with the `?revs=N` query parameter, e.g.
|
||||
`https://pad.example.com/p/foo/export/html?revs=1000`.
|
||||
|
||||
### Repairing a damaged pad
|
||||
|
||||
If a pad is corrupt, use the CLI repair tools (`checkPad`, `repairPad`,
|
||||
`rebuildPad`) documented in the [CLI chapter](./cli.md). Always back up the database before
|
||||
running write operations.
|
||||
|
||||
## How do I limit history or prune revisions?
|
||||
|
||||
Etherpad keeps the full revision history of every pad, so the database grows
|
||||
over time. To reclaim space, use the pad-compaction CLI tools, which collapse or
|
||||
trim revision history for one pad, every pad, or only stale pads:
|
||||
|
||||
```bash
|
||||
# Collapse all history of one pad
|
||||
pnpm run --filter bin compactPad <padID>
|
||||
|
||||
# Keep only the last 50 revisions of one pad
|
||||
pnpm run --filter bin compactPad <padID> --keep 50
|
||||
|
||||
# Compact every pad on the instance
|
||||
pnpm run --filter bin compactAllPads
|
||||
|
||||
# Compact only pads not edited in the last 90 days, keeping the last 50 revisions
|
||||
pnpm run --filter bin compactStalePads --older-than 90 --keep 50
|
||||
```
|
||||
|
||||
These tools require `cleanup.enabled = true` in `settings.json` and are
|
||||
**destructive** — history is collapsed or trimmed. Export anything you can't
|
||||
afford to lose via the pad's `/export/etherpad` route first. The same primitive
|
||||
is available over the wire as the `compactPad` HTTP API method. See the [CLI chapter](./cli.md) for full details.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"oxc-minify": "^0.135.0",
|
||||
"vitepress": "^2.0.0-alpha.17"
|
||||
"oxc-minify": "^0.139.0",
|
||||
"vitepress": "^2.0.0-alpha.18"
|
||||
},
|
||||
"scripts": {
|
||||
"docs:dev": "vitepress dev",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,633 @@
|
|||
# Downstream Client Compatibility Tests — Phase 1 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:** Add a core-side compatibility gate (golden-vector + socket-sequence + HTTP-API-shape contract tests, plus a downstream-smoke workflow scaffold) so a PR against `develop` detects changes that would break the separate downstream clients.
|
||||
|
||||
**Architecture:** Layer A — hermetic contract tests run inside core's existing mocha backend suite, anchored to a committed `wire-vectors.json` fixture generated from core's own `Changeset`/`AttributePool`. Layer B — a `downstream-smoke.yml` workflow that boots a real Etherpad on :9003, proves the boot→healthcheck→teardown cycle with a self-check, and is ready to matrix over a `clients.json` manifest as clients register in Phase 2.
|
||||
|
||||
**Tech Stack:** TypeScript, mocha (`--import=tsx`), `assert/strict`, supertest + socket.io-client via `common.ts` helpers, GitHub Actions.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md`
|
||||
|
||||
**Scope note:** This plan is **Phase 1 only** (all changes in `ether/etherpad`). Phase 2 (wiring each client repo's `test:vectors` + smoke and registering it in the manifest) is one separate plan/PR per client repo and is out of scope here.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create `src/tests/backend/specs/downstream/generate-vectors.ts` — pure module exporting `generateVectors(): WireVector[]`; the single source of truth for the canonical wire fixtures. Also runnable as a CLI to (re)write the fixture.
|
||||
- Create `src/tests/fixtures/wire-vectors.json` — committed canonical fixture (generated, never hand-edited).
|
||||
- Create `src/tests/backend/specs/downstream/wire-vectors.ts` — mocha spec asserting the committed fixture is stable and self-consistent.
|
||||
- Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts` — mocha spec asserting the socket.io handshake + USER_CHANGES→ACCEPT_COMMIT sequence/shapes.
|
||||
- Create `src/tests/backend/specs/downstream/wire-http-api.ts` — mocha spec snapshotting client-facing HTTP API response shapes.
|
||||
- Create `src/tests/downstream/clients.json` — manifest of downstream clients (data; entries `enabled:false` until their Phase-2 smoke lands).
|
||||
- Create `.github/workflows/downstream-smoke.yml` — boot/healthcheck/self-check/teardown + manifest matrix scaffold.
|
||||
- Modify `src/package.json` — add `vectors:gen` script.
|
||||
|
||||
All backend specs live under `specs/downstream/` so the existing `mocha ... --recursive tests/backend/specs` glob picks them up with zero config change.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Golden-vector generator module
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/generate-vectors.ts`
|
||||
- Test: `src/tests/backend/specs/downstream/wire-vectors.ts` (created in Task 3; this task is tested via Task 2's run)
|
||||
|
||||
- [ ] **Step 1: Write the generator module**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/generate-vectors.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Single source of truth for the downstream wire-compatibility fixtures.
|
||||
*
|
||||
* Each vector is a self-contained changeset application: given `initialAText`
|
||||
* and `pool`, applying `changeset` yields `resultAText`. Downstream clients
|
||||
* (which reimplement Etherpad's changeset/attribpool decoders) consume the
|
||||
* exact same JSON and must reproduce `resultAText`. See the Phase 1 plan.
|
||||
*
|
||||
* Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json:
|
||||
* pnpm run vectors:gen
|
||||
*/
|
||||
|
||||
import Changeset from '../../../../static/js/Changeset';
|
||||
import AttributePool from '../../../../static/js/AttributePool';
|
||||
|
||||
export type WireVector = {
|
||||
name: string;
|
||||
initialText: string;
|
||||
changeset: string;
|
||||
pool: ReturnType<AttributePool['toJsonable']>;
|
||||
resultText: string;
|
||||
};
|
||||
|
||||
const vector = (
|
||||
name: string,
|
||||
initialText: string,
|
||||
build: (pool: AttributePool) => string,
|
||||
): WireVector => {
|
||||
const pool = new AttributePool();
|
||||
const changeset = build(pool);
|
||||
Changeset.checkRep(changeset);
|
||||
return {
|
||||
name,
|
||||
initialText,
|
||||
changeset,
|
||||
pool: pool.toJsonable(),
|
||||
resultText: Changeset.applyToText(changeset, initialText),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateVectors = (): WireVector[] => [
|
||||
vector('plain-insert', 'abc\n', () =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'XYZ')),
|
||||
|
||||
vector('plain-delete', 'abcdef\n', () =>
|
||||
Changeset.makeSplice('abcdef\n', 1, 3, '')),
|
||||
|
||||
vector('formatted-insert', 'abc\n', (pool) =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)),
|
||||
|
||||
vector('multiline-insert', 'abc\n', () =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')),
|
||||
|
||||
vector('attrib-reuse', 'abc\n', (pool) => {
|
||||
// Two formatted inserts sharing one pool entry exercises pool index reuse.
|
||||
const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool);
|
||||
const mid = Changeset.applyToText(cs1, 'abc\n');
|
||||
const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool);
|
||||
return Changeset.compose(cs1, cs2, pool);
|
||||
}),
|
||||
];
|
||||
|
||||
// CLI entry: write the canonical fixture to disk.
|
||||
if (require.main === module) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path');
|
||||
const out = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||
fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`wrote ${out}`);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `vectors:gen` script**
|
||||
|
||||
In `src/package.json`, add to `"scripts"` (alongside the existing `test` entry):
|
||||
|
||||
```json
|
||||
"vectors:gen": "tsx tests/backend/specs/downstream/generate-vectors.ts",
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Sanity-run the generator (no fixture committed yet)**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm run vectors:gen
|
||||
```
|
||||
Expected: prints `wrote .../src/tests/fixtures/wire-vectors.json` and the file exists with 5 vectors. Verify each has non-empty `changeset` and a `resultText` that differs from `initialText`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/generate-vectors.ts src/package.json
|
||||
git commit -m "test(downstream): add golden wire-vector generator"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Commit the generated fixture
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/fixtures/wire-vectors.json`
|
||||
|
||||
- [ ] **Step 1: Generate the fixture**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm run vectors:gen
|
||||
```
|
||||
Expected: `src/tests/fixtures/wire-vectors.json` written.
|
||||
|
||||
- [ ] **Step 2: Eyeball the fixture**
|
||||
|
||||
Open `src/tests/fixtures/wire-vectors.json`. Confirm it is a JSON array of 5 objects, each with keys `name, initialText, changeset, pool, resultText`. The `pool` for `plain-insert`/`plain-delete`/`multiline-insert` has empty `numToAttrib`; `formatted-insert` and `attrib-reuse` contain a `bold,true` entry.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/fixtures/wire-vectors.json
|
||||
git commit -m "test(downstream): add committed golden wire-vectors fixture"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Fixture stability + self-consistency spec
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/wire-vectors.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/wire-vectors.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Guards the downstream wire-format contract:
|
||||
* - the committed fixture exactly matches a fresh regeneration (any drift is a
|
||||
* deliberate wire change and must be re-generated + reviewed in the same PR), and
|
||||
* - every vector is internally consistent under core's own Changeset engine.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import Changeset from '../../../../static/js/Changeset';
|
||||
import {generateVectors} from './generate-vectors';
|
||||
|
||||
const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||
|
||||
describe(__filename, function () {
|
||||
it('committed fixture matches a fresh regeneration', function () {
|
||||
const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||
const fresh = generateVectors();
|
||||
assert.deepEqual(committed, fresh,
|
||||
'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result');
|
||||
});
|
||||
|
||||
it('every vector applies to its result under core Changeset', function () {
|
||||
for (const v of generateVectors()) {
|
||||
Changeset.checkRep(v.changeset);
|
||||
assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText,
|
||||
`vector ${v.name} result mismatch`);
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes (fixture already committed)**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-vectors.ts
|
||||
```
|
||||
Expected: 2 passing.
|
||||
|
||||
- [ ] **Step 3: Prove the guard bites (temporary edit)**
|
||||
|
||||
Hand-edit one `resultText` in `src/tests/fixtures/wire-vectors.json`, re-run the command above.
|
||||
Expected: the "committed fixture matches a fresh regeneration" test FAILS. Then `git checkout src/tests/fixtures/wire-vectors.json` to restore.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/wire-vectors.ts
|
||||
git commit -m "test(downstream): assert wire-vectors fixture stability + consistency"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Socket message-sequence spec
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/wire-socket-sequence.ts`
|
||||
|
||||
Reference for helpers: `src/tests/backend/specs/messages.ts` (`common.connect`, `common.handshake`) and `src/tests/backend/common.ts`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Pins the socket.io message sequence + shapes that every realtime client
|
||||
* depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT.
|
||||
* A change here is a wire-protocol change that will break downstream clients.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../../common');
|
||||
const padManager = require('../../../node/db/PadManager');
|
||||
import AttributePool from '../../../../static/js/AttributePool';
|
||||
import Changeset from '../../../../static/js/Changeset';
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent: any;
|
||||
let socket: any;
|
||||
let padId: string;
|
||||
|
||||
before(async function () { agent = await common.init(); });
|
||||
|
||||
beforeEach(async function () {
|
||||
padId = common.randomString();
|
||||
const pad = await padManager.getPad(padId, 'init\n');
|
||||
await pad.setText('init\n');
|
||||
const res = await agent.get(`/p/${padId}`).expect(200);
|
||||
socket = await common.connect(res);
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
if (socket != null) socket.close();
|
||||
socket = null;
|
||||
});
|
||||
|
||||
it('handshake returns CLIENT_VARS with the client-facing shape', async function () {
|
||||
const {type, data} = await common.handshake(socket, padId);
|
||||
assert.equal(type, 'CLIENT_VARS');
|
||||
assert.ok(data.userId, 'CLIENT_VARS.userId missing');
|
||||
assert.ok(data.collab_client_vars, 'collab_client_vars missing');
|
||||
assert.equal(typeof data.collab_client_vars.rev, 'number');
|
||||
assert.ok(data.collab_client_vars.initialAttributedText, 'initialAttributedText missing');
|
||||
});
|
||||
|
||||
it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () {
|
||||
const {data: clientVars} = await common.handshake(socket, padId);
|
||||
const rev = clientVars.collab_client_vars.rev;
|
||||
const pool = new AttributePool();
|
||||
const cs = Changeset.makeSplice('init\n', 4, 0, '-typed', [], pool);
|
||||
|
||||
const accepted = common.waitForSocketEvent(socket, 'message');
|
||||
socket.emit('message', {
|
||||
type: 'COLLABROOM',
|
||||
component: 'pad',
|
||||
data: {type: 'USER_CHANGES', baseRev: rev, changeset: cs, apool: pool.toJsonable()},
|
||||
});
|
||||
const msg: any = await accepted;
|
||||
assert.equal(msg.type, 'COLLABROOM');
|
||||
assert.equal(msg.data.type, 'ACCEPT_COMMIT');
|
||||
assert.equal(msg.data.newRev, rev + 1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-socket-sequence.ts
|
||||
```
|
||||
Expected: 2 passing. If `waitForSocketEvent`'s default 1s timeout is too tight on the ACCEPT_COMMIT, pass a larger `timeoutMs` (its 3rd arg) — e.g. `common.waitForSocketEvent(socket, 'message', 5000)`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/wire-socket-sequence.ts
|
||||
git commit -m "test(downstream): pin socket.io handshake + USER_CHANGES sequence"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: HTTP API shape spec
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/backend/specs/downstream/wire-http-api.ts`
|
||||
|
||||
Reference: `src/tests/backend/specs/api/api.ts` for the `common.init()` agent + API-version pattern.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `src/tests/backend/specs/downstream/wire-http-api.ts`:
|
||||
|
||||
```typescript
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API
|
||||
* endpoints downstream clients call to create pads and round-trip text.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../../common');
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent: any;
|
||||
let apiVersion = 1;
|
||||
const apiKey = common.apiKey;
|
||||
const padId = common.randomString();
|
||||
const ep = (point: string, qs: string) =>
|
||||
`/api/${apiVersion}/${point}?apikey=${apiKey}&${qs}`;
|
||||
|
||||
before(async function () {
|
||||
agent = await common.init();
|
||||
const res = await agent.get('/api/').expect(200);
|
||||
apiVersion = res.body.currentVersion;
|
||||
});
|
||||
|
||||
it('createPad returns the standard {code,message,data} envelope', async function () {
|
||||
const res = await agent.get(ep('createPad', `padID=${padId}&text=hello%0A`)).expect(200);
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']);
|
||||
assert.equal(res.body.code, 0);
|
||||
});
|
||||
|
||||
it('setText + getText round-trips text through the documented shape', async function () {
|
||||
await agent.get(ep('setText', `padID=${padId}&text=world%0A`)).expect(200);
|
||||
const res = await agent.get(ep('getText', `padID=${padId}`)).expect(200);
|
||||
assert.equal(res.body.code, 0);
|
||||
assert.equal(typeof res.body.data.text, 'string');
|
||||
assert.equal(res.body.data.text, 'world\n');
|
||||
});
|
||||
|
||||
it('getRevisionsCount exposes a numeric revisions field', async function () {
|
||||
const res = await agent.get(ep('getRevisionsCount', `padID=${padId}`)).expect(200);
|
||||
assert.equal(res.body.code, 0);
|
||||
assert.equal(typeof res.body.data.revisions, 'number');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm the apiKey helper name**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && grep -n "apiKey\|apikey" tests/backend/common.ts | head
|
||||
```
|
||||
Expected: a `common.apiKey` export (or similar). If the export is named differently, adjust the `apiKey` reference in the spec to match before running.
|
||||
|
||||
- [ ] **Step 3: Run test to verify it passes**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-http-api.ts
|
||||
```
|
||||
Expected: 3 passing.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/backend/specs/downstream/wire-http-api.ts
|
||||
git commit -m "test(downstream): snapshot client-facing HTTP API shapes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Client manifest
|
||||
|
||||
**Files:**
|
||||
- Create: `src/tests/downstream/clients.json`
|
||||
|
||||
- [ ] **Step 1: Write the manifest**
|
||||
|
||||
Create `src/tests/downstream/clients.json` (SHAs are current `main` HEADs at authoring; `enabled:false` until each client's Phase-2 smoke lands):
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "etherpad-pad",
|
||||
"repo": "https://github.com/ether/pad.git",
|
||||
"ref": "31176d64ce746d45349e58ee6c0bb043052c6e66",
|
||||
"kind": "rust",
|
||||
"enabled": false,
|
||||
"vectorTest": "cargo test --test vectors",
|
||||
"smokeCmd": "cargo test --test smoke -- --ignored"
|
||||
},
|
||||
{
|
||||
"name": "etherpad-cli-client",
|
||||
"repo": "https://github.com/ether/etherpad-cli-client.git",
|
||||
"ref": "edbe0bb70971e54514ebea672e4ad9b51fc55bff",
|
||||
"kind": "node",
|
||||
"enabled": false,
|
||||
"vectorTest": "pnpm run test:vectors",
|
||||
"smokeCmd": "pnpm run test:smoke"
|
||||
},
|
||||
{
|
||||
"name": "etherpad-desktop",
|
||||
"repo": "https://github.com/ether/etherpad-desktop.git",
|
||||
"ref": "ad273c119f1926a8390c9908fc91f62fa2cf740f",
|
||||
"kind": "desktop",
|
||||
"enabled": false,
|
||||
"vectorTest": "pnpm run test:vectors",
|
||||
"smokeCmd": "pnpm run test:smoke"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validate it is well-formed JSON**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
node -e "const c=require('./src/tests/downstream/clients.json'); console.log(c.length, c.map(x=>x.name).join(','))"
|
||||
```
|
||||
Expected: `3 etherpad-pad,etherpad-cli-client,etherpad-desktop`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/tests/downstream/clients.json
|
||||
git commit -m "test(downstream): add client manifest (entries disabled pending Phase 2)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Downstream-smoke workflow
|
||||
|
||||
**Files:**
|
||||
- Create: `.github/workflows/downstream-smoke.yml`
|
||||
|
||||
Reference an existing workflow (`.github/workflows/backend-tests.yml`) for the checkout/pnpm/node setup block this repo uses, and copy that setup verbatim into the job below.
|
||||
|
||||
- [ ] **Step 1: Write the workflow**
|
||||
|
||||
Create `.github/workflows/downstream-smoke.yml`:
|
||||
|
||||
```yaml
|
||||
name: Downstream smoke
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # nightly against develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout core (PR)
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# --- Reuse core's standard node+pnpm setup (copy from backend-tests.yml) ---
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- name: Install deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Boot Etherpad on :9003
|
||||
env:
|
||||
APIKEY: downstream-smoke-key
|
||||
run: |
|
||||
mkdir -p var
|
||||
echo -n "$APIKEY" > APIKEY.txt
|
||||
PORT=9003 pnpm run prod &
|
||||
echo $! > /tmp/ep.pid
|
||||
|
||||
- name: Wait for healthcheck
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
if curl -fsS http://localhost:9003/api/ >/dev/null; then
|
||||
echo "up"; exit 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "server did not come up"; exit 1
|
||||
|
||||
- name: Self-check (boot + API roundtrip proves the harness)
|
||||
run: |
|
||||
K=downstream-smoke-key
|
||||
curl -fsS "http://localhost:9003/api/1/createPad?apikey=$K&padID=smoke&text=hi%0A"
|
||||
curl -fsS "http://localhost:9003/api/1/getText?apikey=$K&padID=smoke" | grep -q '"text":"hi'
|
||||
|
||||
- name: Generate canonical wire-vectors
|
||||
run: cd src && pnpm run vectors:gen
|
||||
|
||||
- name: Run enabled downstream clients
|
||||
run: |
|
||||
node -e '
|
||||
const fs=require("fs");
|
||||
const clients=require("./src/tests/downstream/clients.json").filter(c=>c.enabled);
|
||||
if(!clients.length){console.log("No clients enabled yet (Phase 1).");process.exit(0);}
|
||||
fs.writeFileSync("/tmp/clients.json",JSON.stringify(clients));
|
||||
'
|
||||
# Phase 2 wires per-kind clone + toolchain + vector injection + smoke here,
|
||||
# iterating /tmp/clients.json. Until a client is enabled this is a no-op.
|
||||
|
||||
- name: Teardown (by PID, never pkill)
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ep.pid ]; then kill "$(cat /tmp/ep.pid)" 2>/dev/null || true; fi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm the boot command + port env**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /home/jose/etherpad/etherpad-core-fresh && grep -nE '"prod"|"dev"|"start"' src/package.json
|
||||
grep -rn "process.env.PORT\|settings.port" src/node/utils/Settings.ts | head
|
||||
```
|
||||
Expected: confirm the script that starts a production server and that `PORT`/`APIKEY` are honored (Settings reads `process.env.PORT`). If the runnable script is named differently (e.g. `prod` vs `dev`), update the "Boot Etherpad" step to match. If APIKEY is read from a file rather than env, the `echo ... > APIKEY.txt` line already covers it.
|
||||
|
||||
- [ ] **Step 3: Lint the workflow YAML**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
node -e "require('js-yaml')" 2>/dev/null && npx --yes js-yaml .github/workflows/downstream-smoke.yml >/dev/null && echo "valid yaml" || python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/downstream-smoke.yml')); print('valid yaml')"
|
||||
```
|
||||
Expected: `valid yaml`.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add .github/workflows/downstream-smoke.yml
|
||||
git commit -m "ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full backend-suite run + push
|
||||
|
||||
**Files:** none (verification + integration)
|
||||
|
||||
- [ ] **Step 1: Run the whole downstream spec group**
|
||||
|
||||
Per the "always run backend tests" rule, run the new specs through the real mocha invocation the suite uses, from `src/`:
|
||||
```bash
|
||||
cd src && cross-env NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 --extension ts --recursive tests/backend/specs/downstream
|
||||
```
|
||||
Expected: all specs in `tests/backend/specs/downstream/` pass (7 tests total across 3 files).
|
||||
|
||||
- [ ] **Step 2: Confirm no regression in the fixture guard**
|
||||
|
||||
Run from `src/`:
|
||||
```bash
|
||||
cd src && pnpm run vectors:gen && git diff --exit-code src/tests/fixtures/wire-vectors.json
|
||||
```
|
||||
Expected: exit 0 (regeneration is byte-identical to the committed fixture).
|
||||
|
||||
- [ ] **Step 3: Push the branch and open the PR**
|
||||
|
||||
```bash
|
||||
git push -u origin feat/downstream-client-compat-tests
|
||||
gh pr create --base develop \
|
||||
--title "test: downstream client compatibility gate (Phase 1)" \
|
||||
--body "Adds core-side contract tests (golden wire-vectors, socket-sequence, HTTP API shapes) and a downstream-smoke workflow scaffold so PRs detect changes that would break the separate CLI / terminal / desktop clients. Phase 2 wires each client repo's vector+smoke tests and flips its manifest entry to enabled. Spec + plan under docs/superpowers/. Closes nothing; tracks the downstream-compat initiative."
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Watch CI**
|
||||
|
||||
Per the "check CI after PRs" rule, wait ~20s then:
|
||||
```bash
|
||||
gh pr checks --watch
|
||||
```
|
||||
Expected: backend tests green (now including the downstream specs); `Downstream smoke` green (self-check passes, no clients enabled yet). Fix any red before moving on.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- Layer A golden vectors → Tasks 1–3. ✅
|
||||
- Layer A socket sequence → Task 4. ✅
|
||||
- Layer A HTTP API shapes → Task 5. ✅
|
||||
- Layer B manifest (pinned SHAs, `enabled` gate) → Task 6. ✅
|
||||
- Layer B workflow (boot :9003, healthcheck, vector generation, PID teardown, nightly+PR triggers) → Task 7. ✅
|
||||
- Flakiness mitigations: healthcheck-poll (Task 7 step), PID teardown (Task 7), :9003 (Task 7), no external clients on the gate yet so no flake surface (Task 6 `enabled:false`). ✅
|
||||
- Phasing: Phase 1 self-contained and green; Phase 2 explicitly out of scope. ✅
|
||||
|
||||
**Verification-required tasks** (Task 5 step 2, Task 7 step 2) ask the engineer to confirm the exact `common.apiKey` export name and the production boot script/port handling against the live repo before running — these are real lookups, not placeholders, because those names are repo-version-specific.
|
||||
|
||||
**Type consistency:** `WireVector` fields (`name/initialText/changeset/pool/resultText`) are defined in Task 1 and used identically in Tasks 2–3. `generateVectors()` signature is stable across Tasks 1/3. Manifest keys (`enabled`, `vectorTest`, `smokeCmd`) in Task 6 match the workflow's `.filter(c=>c.enabled)` consumer in Task 7.
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
# Downstream Client Compatibility Tests — Design
|
||||
|
||||
**Date:** 2026-06-09
|
||||
**Status:** Approved (brainstorming complete)
|
||||
**Target repo:** `ether/etherpad` (core), branch `develop`
|
||||
**Downstream repos in scope:** `etherpad-pad` (Rust terminal editor + `etherpad-client` crate), `etherpad-cli-client` (Node/TS CLI), `etherpad-desktop` (Electron desktop + Capacitor mobile)
|
||||
|
||||
## Problem
|
||||
|
||||
The three downstream clients live in **separate repos** and consume core's wire
|
||||
protocols rather than importing core as a library:
|
||||
|
||||
- **etherpad-cli-client** ships its *own copies* of `Changeset.ts` + `AttributePool.ts`
|
||||
and talks the socket.io `message` protocol. No test script today.
|
||||
- **etherpad-pad** (Rust) hand-rolls engine.io v4 / socket.io v4 + changeset
|
||||
decoding in its `etherpad-client` crate. Has CI + a `mock-socket` test feature.
|
||||
- **etherpad-desktop** wraps / points at a core server URL. Has vitest + Playwright e2e + CI.
|
||||
|
||||
A core PR can change the **HTTP API**, the **socket.io handshake / `message`
|
||||
sequence**, or the **changeset / attribpool wire format** and break these clients
|
||||
**silently**, because their CI never runs against the new core. The goal: a PR
|
||||
against core `develop` (and friends) must detect downstream breakage before merge.
|
||||
|
||||
## Decisions (locked during brainstorming)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| Detection strategy | **Hybrid**: fast contract tests in core (every PR) + downstream smoke (every PR) |
|
||||
| Smoke cadence | **Every PR** (with strong flakiness mitigations) |
|
||||
| How CI gets clients | **Git clone at pinned refs**, recorded in a manifest in core |
|
||||
| Contract depth | **Shared golden vectors** — core generates canonical fixtures, each client decodes the same fixtures with its own decoder |
|
||||
| Sequencing | **Phase by layer** — Phase 1 all core-side; Phase 2 one client repo at a time |
|
||||
|
||||
## Architecture
|
||||
|
||||
Two-layer compatibility gate on every core PR:
|
||||
|
||||
```
|
||||
core PR ──┬─► Layer A: Contract tests (hermetic, fast, no network/clients)
|
||||
│ • golden-vector assertions (changeset/attribpool roundtrip)
|
||||
│ • socket.io message-sequence test (CLIENT_READY → CLIENT_VARS,
|
||||
│ USER_CHANGES → ACCEPT_COMMIT / NEW_CHANGES)
|
||||
│ • HTTP API shape snapshots
|
||||
│
|
||||
└─► Layer B: Downstream smoke (boots a real server, runs real clients)
|
||||
• build + boot Etherpad from the PR on :9003 with a known API key
|
||||
• healthcheck-poll until ready
|
||||
• matrix over manifest: clone client @ pinned ref, set up toolchain,
|
||||
inject core's freshly-generated vectors, run client `test:vectors`,
|
||||
run client smoke: connect → create/open pad → write text →
|
||||
read back via HTTP API → assert equality
|
||||
• tear down server by PID
|
||||
```
|
||||
|
||||
## Layer A — Contract tests (Phase 1, core)
|
||||
|
||||
### Golden vectors
|
||||
- Generator script: `src/tests/downstream/generate-vectors.ts` (run via a package
|
||||
script, e.g. `pnpm run vectors:gen`).
|
||||
- Output fixture: `src/tests/fixtures/wire-vectors.json`. Each record:
|
||||
`{ name, initialAText, changeset, pool, resultAText }` covering the operation
|
||||
classes clients must decode: plain insert, delete, format/attrib op,
|
||||
multi-line insert (char_bank ending in `\n`), attribpool reuse across ops.
|
||||
- Core test `src/tests/backend/specs/wire-vectors.ts`: regenerate in-memory and
|
||||
assert it matches the committed fixture exactly (drift requires a deliberate
|
||||
commit, which is the signal a wire change happened).
|
||||
|
||||
### Socket message-sequence test
|
||||
- `src/tests/backend/specs/wire-socket-sequence.ts`: drive a socket.io client
|
||||
against the in-process server, assert the handshake message sequence and the
|
||||
shape of `CLIENT_VARS`, `USER_CHANGES` → `ACCEPT_COMMIT`, and broadcast
|
||||
`NEW_CHANGES`. Reuses the existing backend socket test helpers.
|
||||
|
||||
### HTTP API shape snapshots
|
||||
- `src/tests/backend/specs/wire-http-api.ts`: snapshot the response *shapes*
|
||||
(keys / types, not volatile values) of the API endpoints clients call
|
||||
(`createPad`, `setText`, `getText`, `getRevisionsCount`, session/auth as needed).
|
||||
|
||||
These join the existing `backend-tests.yml` run — no new per-PR job, no new infra.
|
||||
|
||||
## Layer B — Downstream smoke (Phase 1 scaffold, Phase 2 per client)
|
||||
|
||||
### Manifest
|
||||
`src/tests/downstream/clients.json`:
|
||||
```json
|
||||
[
|
||||
{ "name": "etherpad-pad", "repo": "https://github.com/ether/pad.git", "ref": "<sha on main>", "kind": "rust", "smokeCmd": "..." },
|
||||
{ "name": "etherpad-cli-client", "repo": "https://github.com/ether/etherpad-cli-client.git", "ref": "<sha on main>", "kind": "node", "smokeCmd": "..." },
|
||||
{ "name": "etherpad-desktop", "repo": "https://github.com/ether/etherpad-desktop.git", "ref": "<sha on main>", "kind": "desktop", "smokeCmd": "..." }
|
||||
]
|
||||
```
|
||||
Refs are pinned to a specific commit SHA (not `main`) so a client's own pushes
|
||||
cannot redden core CI; bumping a ref is a deliberate PR. Current `main` HEADs at
|
||||
authoring time: pad `31176d6`, cli-client `edbe0bb`, desktop `ad273c1`.
|
||||
Pinned refs mean a client's *own* breakage never randomly reddens core; picking up
|
||||
a client fix is a deliberate ref-bump PR. Clients are added to the manifest as
|
||||
their Phase-2 smoke lands — the workflow only runs what's registered.
|
||||
|
||||
### Workflow
|
||||
`.github/workflows/downstream-smoke.yml` (triggers: `pull_request` + nightly
|
||||
`schedule` against `develop`):
|
||||
1. Build core from the PR, install deps.
|
||||
2. Boot Etherpad on **:9003** with a known `APIKEY` in the background; record PID.
|
||||
3. Healthcheck-poll the server (bounded timeout) before proceeding.
|
||||
4. Matrix over manifest entries: clone @ pinned ref → set up toolchain
|
||||
(node+pnpm / rust / electron+xvfb) → copy core's freshly-generated
|
||||
`wire-vectors.json` into the client → run `test:vectors` → run smoke.
|
||||
5. Tear down: kill the recorded **PID** (never `pkill -f`).
|
||||
|
||||
### Per-client smoke (Phase 2)
|
||||
Minimal roundtrip exercising the real protocol end-to-end:
|
||||
- **etherpad-pad** (`rust`): integration test gated by `ETHERPAD_SMOKE_URL`, using
|
||||
the real tungstenite socket — connect, open pad, send a changeset, read back
|
||||
via HTTP `getText`, assert. Plus `cargo test` vector consumer reading the
|
||||
injected fixture.
|
||||
- **etherpad-cli-client** (`node`): add a minimal test runner (none today —
|
||||
`node:test` or vitest), a `test:vectors` decoding the fixture, and a smoke
|
||||
using the client lib: connect → write → verify via HTTP `getText`.
|
||||
- **etherpad-desktop** (`desktop`): **headless-light** vitest smoke that points
|
||||
the shell/webview at the booted URL and roundtrips. The full Electron e2e stays
|
||||
in desktop's own CI — it is **not** in the core gate. If Electron is
|
||||
unavoidable here, run under `xvfb-run`.
|
||||
|
||||
## Flakiness mitigations (because smoke runs on every PR)
|
||||
|
||||
- Healthcheck-poll-with-timeout before any client runs.
|
||||
- Bounded timeout + 1 retry per client smoke.
|
||||
- Desktop kept headless-light; heavy Electron e2e excluded from the gate.
|
||||
- PID-based teardown, never `pkill -f` (would kill the developer's other servers).
|
||||
- Pinned manifest refs isolate core CI from clients' own breakage.
|
||||
- Tests that bind a port use **:9003** (9001 is reserved for ad-hoc local use).
|
||||
|
||||
## Phasing
|
||||
|
||||
- **Phase 1 (core only, lands first, immediately useful):** vector generator +
|
||||
`wire-vectors.json` + three contract specs + `downstream-smoke.yml` +
|
||||
`clients.json` manifest. Harness proven with **one** reference client wired in
|
||||
(the Rust `etherpad-pad`, which already has test infra).
|
||||
- **Phase 2 (one client repo at a time):** order `etherpad-pad` →
|
||||
`etherpad-cli-client` → `etherpad-desktop`. Each PR adds that client's
|
||||
`test:vectors` + smoke and registers it in the core manifest.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Replacing clients' existing full e2e suites (they stay in their own repos).
|
||||
- ep_kaput (excluded from all sweeps per standing instruction).
|
||||
- Changing the wire protocol itself — this work only *observes* it.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- A core PR that alters changeset serialization, the socket message sequence, or a
|
||||
client-facing API shape fails Layer A (contract) and/or Layer B (smoke) before merge.
|
||||
- Phase 1 lands green on core `develop` with the Rust client wired into the smoke matrix.
|
||||
- Bumping a client's pinned ref is the only way a client's own changes affect core CI.
|
||||
|
|
@ -45,13 +45,13 @@
|
|||
"node": ">=24.0.0",
|
||||
"pnpm": ">=11.1.2"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.2",
|
||||
"packageManager": "pnpm@11.10.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ether/etherpad.git"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.2",
|
||||
"license": "Apache-2.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
|
|||
3790
pnpm-lock.yaml
generated
3790
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -21,20 +21,38 @@ strictDepBuilds: false
|
|||
# As of pnpm 11, overrides must live here (root package.json's pnpm.overrides
|
||||
# is no longer read). Force-bump transitive deps with known CVEs.
|
||||
overrides:
|
||||
basic-ftp: '>=5.3.0'
|
||||
'@babel/core@<7.29.6': '>=7.29.6'
|
||||
'@opentelemetry/core@<2.8.0': '>=2.8.0'
|
||||
basic-ftp@<5.3.1: '>=5.3.1 <6.0.0'
|
||||
brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3'
|
||||
diff@>=6.0.0 <8.0.3: '>=8.0.3'
|
||||
esbuild@<0.28.1: '>=0.28.1'
|
||||
flatted: '>=3.4.2'
|
||||
follow-redirects: '>=1.16.0'
|
||||
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
|
||||
glob@>=10.2.0 <10.5.0: '>=10.5.0'
|
||||
js-yaml@>=4.0.0 <4.1.1: '>=4.1.1'
|
||||
ip-address@<10.1.1: '>=10.1.1'
|
||||
js-yaml@>=4.0.0 <4.2.0: '>=4.2.0'
|
||||
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'
|
||||
qs@>=6.7.0 <6.15.2: '>=6.15.2'
|
||||
serialize-javascript@<7.0.5: '>=7.0.5'
|
||||
socket.io-parser@>=4.0.0 <4.2.6: '>=4.2.6'
|
||||
tar@<7.5.11: '>=7.5.11'
|
||||
tar@<7.5.16: '>=7.5.16'
|
||||
uuid@<14.0.0: '>=14.0.0'
|
||||
vite@>=7.0.0 <7.3.2: '>=7.3.2'
|
||||
ws@>=8.0.0 <8.21.0: '>=8.21.0'
|
||||
minimumReleaseAgeExclude:
|
||||
- '@radix-ui/primitive@1.1.5'
|
||||
- '@radix-ui/react-collection@1.1.12'
|
||||
- '@radix-ui/react-context@1.2.0'
|
||||
- '@radix-ui/react-dialog@1.1.19'
|
||||
- '@radix-ui/react-dismissable-layer@1.1.15'
|
||||
- '@radix-ui/react-focus-scope@1.1.12'
|
||||
- '@radix-ui/react-presence@1.1.7'
|
||||
- '@radix-ui/react-toast@1.2.19'
|
||||
- mysql2@3.22.6
|
||||
- oidc-provider@9.9.0
|
||||
- sql-escaper@1.4.0
|
||||
|
|
|
|||
|
|
@ -210,15 +210,17 @@
|
|||
* tier: "off" | "notify" | "manual" | "auto" | "autonomous"
|
||||
* Default "notify" shows a banner when an update is available.
|
||||
* Docker installs are read-only — tiers above "notify" are not applied even if requested.
|
||||
* Air-gapped / offline deployments should set UPDATES_TIER=off to suppress the
|
||||
* periodic check against the GitHub Releases API entirely.
|
||||
*/
|
||||
"updates": {
|
||||
"tier": "notify",
|
||||
"source": "github",
|
||||
"channel": "stable",
|
||||
"tier": "${UPDATES_TIER:notify}",
|
||||
"source": "${UPDATES_SOURCE:github}",
|
||||
"channel": "${UPDATES_CHANNEL:stable}",
|
||||
"installMethod": "docker",
|
||||
"checkIntervalHours": 6,
|
||||
"githubRepo": "ether/etherpad",
|
||||
"requireAdminForStatus": false,
|
||||
"checkIntervalHours": "${UPDATES_CHECK_INTERVAL_HOURS:6}",
|
||||
"githubRepo": "${UPDATES_GITHUB_REPO:ether/etherpad}",
|
||||
"requireAdminForStatus": "${UPDATES_REQUIRE_ADMIN_FOR_STATUS:false}",
|
||||
"preApplyGraceMinutes": 0,
|
||||
"drainSeconds": 60,
|
||||
"rollbackHealthCheckSeconds": 60,
|
||||
|
|
@ -321,7 +323,19 @@
|
|||
* https://etherpad.org/ep_infos
|
||||
*/
|
||||
|
||||
"updateServer": "https://etherpad.org/ep_infos",
|
||||
"updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}",
|
||||
|
||||
/*
|
||||
* Outbound network calls. See PRIVACY.md for what each one sends.
|
||||
* - PRIVACY_UPDATE_CHECK=false : disables the hourly version check (UpdateCheck.ts)
|
||||
* - PRIVACY_PLUGIN_CATALOG=false : disables the admin plugin browser
|
||||
* (manual install-by-name via CLI still works)
|
||||
* Air-gapped / firewalled deployments should set both to false.
|
||||
*/
|
||||
"privacy": {
|
||||
"updateCheck": "${PRIVACY_UPDATE_CHECK:true}",
|
||||
"pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}"
|
||||
},
|
||||
|
||||
/*
|
||||
* The type of the database.
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@
|
|||
* Default "notify" shows a banner when an update is available. "off" disables the version check.
|
||||
*/
|
||||
"updates": {
|
||||
"tier": "notify",
|
||||
"tier": "${UPDATES_TIER:notify}",
|
||||
"source": "github",
|
||||
"channel": "stable",
|
||||
"installMethod": "auto",
|
||||
|
|
@ -443,17 +443,19 @@
|
|||
* https://etherpad.org/ep_infos
|
||||
*/
|
||||
|
||||
"updateServer": "https://etherpad.org/ep_infos",
|
||||
"updateServer": "${UPDATE_SERVER:https://etherpad.org/ep_infos}",
|
||||
|
||||
/*
|
||||
* Outbound network calls. See PRIVACY.md for what each one sends.
|
||||
* - updateCheck=false : disables hourly version check (UpdateCheck.ts)
|
||||
* - pluginCatalog=false: disables admin plugin browser
|
||||
* (manual install-by-name via CLI still works)
|
||||
* Air-gapped / firewalled deployments should set both PRIVACY_UPDATE_CHECK and
|
||||
* PRIVACY_PLUGIN_CATALOG to false (or UPDATES_TIER=off, which covers the version check).
|
||||
*/
|
||||
"privacy": {
|
||||
"updateCheck": true,
|
||||
"pluginCatalog": true
|
||||
"updateCheck": "${PRIVACY_UPDATE_CHECK:true}",
|
||||
"pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}"
|
||||
},
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@
|
|||
"pad.importExport.exportword": "مايكروسوفت وورد",
|
||||
"pad.importExport.exportpdf": "صيغة المستندات المحمولة",
|
||||
"pad.importExport.exportopen": "ODF (نسق المستند المفتوح)",
|
||||
"pad.importExport.noConverter.innerHTML": "لا يمكنك الاستيراد إلا من تنسيقات النصوص العادية أو تنسيقات HTML. لمزيد من ميزات الاستيراد المتقدمة، يرجى <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">تثبيت LibreOffice</a> .",
|
||||
"pad.importExport.noConverter.innerHTML": "يمكنك استيراد النصوص العادية، وملفات HTML، وملفات Microsoft Word (.docx)، وملفات Etherpad مباشرةً. لاستيراد تنسيقات أخرى مثل PDF، وODT، وDOC، وRTF، يحتاج مسؤول الخادم إلى تثبيت LibreOffice - راجع <a href=\"https://docs.etherpad.org/\">وثائق Etherpad</a> .",
|
||||
"pad.modals.connected": "متصل.",
|
||||
"pad.modals.reconnecting": "إعادة الاتصال ببادك..",
|
||||
"pad.modals.forcereconnect": "فرض إعادة الاتصال",
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@
|
|||
"pad.modals.looping.explanation": "Праблемы далучэньня да сэрвэра сынхранізацыі.",
|
||||
"pad.modals.looping.cause": "Магчыма, вы падключыліся празь несумяшчальны брандмаўэр або проксі.",
|
||||
"pad.modals.initsocketfail": "Сэрвэр недаступны.",
|
||||
"pad.modals.initsocketfail.explanation": "Не атрымалася падлучыцца да сэрвэра сынхранізацыі.",
|
||||
"pad.modals.initsocketfail.explanation": "Не ўдалося падлучыцца да сэрвэра сынхранізацыі.",
|
||||
"pad.modals.initsocketfail.cause": "Імаверна, гэта зьвязана з праблемамі з вашым браўзэрам або інтэрнэт-злучэньнем.",
|
||||
"pad.modals.slowcommit.explanation": "Сэрвэр не адказвае.",
|
||||
"pad.modals.slowcommit.cause": "Гэта можа быць выклікана праблемамі зь сеткавым падлучэньнем.",
|
||||
|
|
@ -178,9 +178,9 @@
|
|||
"pad.impexp.importbutton": "Імпартаваць зараз",
|
||||
"pad.impexp.importing": "Імпартаваньне…",
|
||||
"pad.impexp.confirmimport": "Імпарт файла перазапіша цяперашні тэкст дакумэнту. Вы ўпэўненыя, што хочаце працягваць?",
|
||||
"pad.impexp.convertFailed": "Не атрымалася імпартаваць гэты файл. Калі ласка, выкарыстайце іншы фармат дакумэнту або скапіюйце ўручную.",
|
||||
"pad.impexp.convertFailed": "Не ўдалося імпартаваць гэты файл. Калі ласка, выкарыстайце іншы фармат дакумэнту або скапіюйце рукамі.",
|
||||
"pad.impexp.padHasData": "Мы не змаглі імпартаваць гэты файл, бо дакумэнт ужо мае зьмены, калі ласка, імпартуйце ў новы дакумэнт",
|
||||
"pad.impexp.uploadFailed": "Загрузка не атрымалася, калі ласка, паспрабуйце яшчэ раз",
|
||||
"pad.impexp.uploadFailed": "Загрузка не ўдалася, калі ласка, паспрабуйце яшчэ раз",
|
||||
"pad.impexp.importfailed": "Памылка імпарту",
|
||||
"pad.impexp.copypaste": "Калі ласка, скапіюйце і ўстаўце",
|
||||
"pad.impexp.exportdisabled": "Экспарт у фармаце {{type}} адключаны. Калі ласка, зьвярніцеся да вашага сыстэмнага адміністратара па падрабязнасьці.",
|
||||
|
|
|
|||
|
|
@ -16,13 +16,80 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Ovládací panel Správce - Etherpad",
|
||||
"admin.loading": "Načítám…",
|
||||
"admin.loading_description": "Počkejte prosím, než se stránka načte.",
|
||||
"admin.toggle_sidebar": "Přepnout postranní panel",
|
||||
"admin.shout": "Komunikace",
|
||||
"admin_shout.online_one": "Aktuálně je online {{count}} uživatelů",
|
||||
"admin_shout.online_other": "Aktuálně je online {{count}} uživatelů",
|
||||
"admin_shout.sticky_toggle": "Změnit připevněnou zprávu",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Uživatelské jméno",
|
||||
"admin_login.password": "Heslo",
|
||||
"admin_login.submit": "Přihlásit se",
|
||||
"admin_login.failed": "Přihlášení se nezdařilo",
|
||||
"admin_pads.all_pads": "Všechny Pady",
|
||||
"admin_pads.bulk.cleanup_history": "Vyčistit historii",
|
||||
"admin_pads.bulk.clear_selection": "Vymazat výběr",
|
||||
"admin_pads.bulk.delete": "Smazat",
|
||||
"admin_pads.cancel": "Zrušit",
|
||||
"admin_pads.col.pad": "Pad",
|
||||
"admin_pads.col.revisions": "Revize",
|
||||
"admin_pads.col.users": "Uživatelé",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Vyberte název pro nový Pad.",
|
||||
"admin_pads.delete_pad_dialog_description": "Potvrdit nebo zrušit smazání Padu.",
|
||||
"admin_pads.delete_pad_dialog_title": "Smazat pad",
|
||||
"admin_pads.empty_never_edited": "prázdné · nikdy neupravené",
|
||||
"admin_pads.error_dialog_description": "Došlo k chybě.",
|
||||
"admin_pads.error_prefix": "Chyba",
|
||||
"admin_pads.filter.active": "Aktivní",
|
||||
"admin_pads.filter.all": "Vše",
|
||||
"admin_pads.filter.empty": "Prázdné",
|
||||
"admin_pads.filter.recent": "Tento týden",
|
||||
"admin_pads.filter.stale": "Zastaralé (>1 rok)",
|
||||
"admin_pads.open": "Otevřít",
|
||||
"admin_pads.pagination.next": "Další",
|
||||
"admin_pads.pagination.previous": "Předchozí",
|
||||
"admin_pads.refresh": "Obnovit",
|
||||
"admin_pads.relative.days": "před {{count}} dny",
|
||||
"admin_pads.relative.hours": "před {{count}}h",
|
||||
"admin_pads.relative.just_now": "právě teď",
|
||||
"admin_pads.relative.minutes": "před {{count}} minutami",
|
||||
"admin_pads.relative.months": "před {{count}} měsíci",
|
||||
"admin_pads.relative.weeks": "před {{count}} týdny",
|
||||
"admin_pads.relative.years": "před {{count}} lety",
|
||||
"admin_pads.revisions_count": "{{count}} revizí",
|
||||
"admin_pads.selected_count": "Vybráno {{count}}",
|
||||
"admin_pads.show": "Zobrazit",
|
||||
"admin_pads.sort.name": "Jméno (A–Z)",
|
||||
"admin_pads.sort.revision_number": "Revize",
|
||||
"admin_pads.sort.user_count": "Uživatelé",
|
||||
"admin_pads.stats.across_pads": "napříč všemi pady",
|
||||
"admin_pads.stats.active_users": "Aktivní uživatelé",
|
||||
"admin_pads.stats.empty_pads": "Prázdné pady",
|
||||
"admin_pads.stats.last_activity": "Poslední aktivita:",
|
||||
"admin_pads.stats.no_active_users": "Žádní aktivní uživatelé",
|
||||
"admin_pads.stats.revisions_zero": "0 revizí",
|
||||
"admin_pads.stats.total": "Celkový počet padů",
|
||||
"admin_pads.stats.users_active": "{{count}} aktuálně aktivních",
|
||||
"admin_pads.subtitle": "Přehled všech padů na této instanci Etherpadu. Vyhledat, vyčistit, otevřít.",
|
||||
"admin_plugins": "Správce zásuvných moodulů",
|
||||
"admin_plugins.available": "Dostupné zásuvné moduly",
|
||||
"admin_plugins.available_not-found": "Nejsou žádné zásuvné moduly",
|
||||
"admin_plugins.available_fetching": "Načítání...",
|
||||
"admin_plugins.available_install.value": "Instalovat",
|
||||
"admin_plugins.available_search.placeholder": "Vyhledat zásuvné moduly k instalaci",
|
||||
"admin_plugins.check_updates": "Zkontrolovat aktualizace",
|
||||
"admin_plugins.core_count": "{{count}} jádro",
|
||||
"admin_plugins.catalog_disabled": "Katalog pluginů je zakázán vaším operátorem (privacy.pluginCatalog=false). Chcete-li plugin nainstalovat, spusťte příkaz `pnpm run plugins i ep_<name> ` ze serveru.",
|
||||
"admin_plugins.crumbs": "Pluginy",
|
||||
"admin_plugins.description": "Popis",
|
||||
"admin_plugins.disables.label": "Vypnuto:",
|
||||
"admin_plugins.disables.warning_title": "Tento plugin záměrně odstraňuje uvedené funkce Etherpadu.",
|
||||
"admin_plugins.error_retrieving": "Chyba při načítání pluginů",
|
||||
"admin_plugins.install_error": "Instalace pluginu {{plugin}} se nezdařila: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Nelze nainstalovat {{plugin}}: vyžaduje novější verzi Etherpadu. Prosím, aktualizujte Etherpad a zkuste to znovu.",
|
||||
"admin_plugins.installed": "Nainstalované zásuvné moduly",
|
||||
"admin_plugins.installed_fetching": "Načítání instalovaných zásuvných modulů...",
|
||||
"admin_plugins.installed_nothing": "Dosud jste nenainstalovali žádné zásuvné moduly.",
|
||||
|
|
@ -30,23 +97,61 @@
|
|||
"admin_plugins.last-update": "Poslední aktualizace",
|
||||
"admin_plugins.name": "Název",
|
||||
"admin_plugins.page-title": "Správce zásuvných modulů - Etherpad",
|
||||
"admin_plugins.reload_catalog": "Obnovit katalog",
|
||||
"admin_plugins.search_npm": "Hledat na npm",
|
||||
"admin_plugins.sort_ascending": "Seřadit vzestupně",
|
||||
"admin_plugins.sort_descending": "Seřadit sestupně",
|
||||
"admin_plugins.sort.last_updated": "Naposledy aktualizováno",
|
||||
"admin_plugins.sort.name": "Jméno (A–Z)",
|
||||
"admin_plugins.sort.version": "Verze",
|
||||
"admin_plugins.source": "Zdroj pluginu",
|
||||
"admin_plugins.subtitle": "Instalace, aktualizace a odebrání pluginů Etherpad. Změny vyžadují restart serveru.",
|
||||
"admin_plugins.tag_core": "Jádro",
|
||||
"admin_plugins.update_tooltip": "Aktualizovat",
|
||||
"admin_plugins.updates_available": "Dostupné aktualizace",
|
||||
"admin_plugins.update_now": "Aktualizovat",
|
||||
"admin_plugins.version": "Verze",
|
||||
"admin_plugins_info": "Informace o řešení problému",
|
||||
"admin_plugins_info.bindings_label": "{{count}} vazeb",
|
||||
"admin_plugins_info.copy_diagnostics": "Diagnostika kopírování",
|
||||
"admin_plugins_info.copy_value": "Kopírovat {{label}}",
|
||||
"admin_plugins_info.git_sha": "SHA v Gitu",
|
||||
"admin_plugins_info.hooks": "Instalované hooks",
|
||||
"admin_plugins_info.hooks_client": "hooks na straně klienta",
|
||||
"admin_plugins_info.hooks_server": "hooks na straně serveru",
|
||||
"admin_plugins_info.parts": "Nainstalované součásti",
|
||||
"admin_plugins_info.plugins": "Nainstalované zásuvné moduly",
|
||||
"admin_plugins_info.page-title": "Informace o zásuvných modulech - Etherpad",
|
||||
"admin_plugins_info.tab_client": "Klient",
|
||||
"admin_plugins_info.tab_server": "Server",
|
||||
"admin_plugins_info.up_to_date": "Aktuální",
|
||||
"admin_plugins_info.update_available": "Aktualizace k dispozici: {{version}}",
|
||||
"admin_plugins_info.version": "Verze Etherpad",
|
||||
"admin_plugins_info.version_latest": "Poslední dostupná verze",
|
||||
"admin_plugins_info.version_number": "Číslo verze",
|
||||
"admin_settings": "Nastavení",
|
||||
"admin_settings.create_pad": "Vytvořit pad",
|
||||
"admin_settings.current": "Aktuální konfugurace",
|
||||
"admin_settings.current_example-devel": "Příklad ukázkové vývojové šablony",
|
||||
"admin_settings.current_example-prod": "Příklad šablony nastavení výroby",
|
||||
"admin_settings.current_restart.value": "Restartovat Etherpad",
|
||||
"admin_settings.current_save.value": "Uložit nastavení",
|
||||
"admin_settings.invalid_json": "Neplatný JSON",
|
||||
"admin_settings.current_test.value": "Ověření JSON",
|
||||
"admin_settings.current_prettify.value": "Zkrášlit JSON",
|
||||
"admin_settings.toast.saved": "Nastavení bylo úspěšně uloženo.",
|
||||
"admin_settings.toast.save_failed": "Uložení se nezdařilo: soubor settings.json se nepodařilo zapsat.",
|
||||
"admin_settings.toast.json_invalid": "Syntaktická chyba: zkontrolujte čárky, závorky a uvozovky.",
|
||||
"admin_settings.toast.disconnected": "Nelze uložit: nejsem připojen k serveru.",
|
||||
"admin_settings.toast.validation_ok": "JSON je platný.",
|
||||
"admin_settings.toast.validation_failed": "JSON je neplatný: opravte syntaktické chyby.",
|
||||
"admin_settings.toast.prettify_failed": "Nelze upravovat: nejprve opravte syntaktické chyby.",
|
||||
"admin_settings.prettify_confirm": "Zkrášlováním odstraníte všechny komentáře. Pokračovat?",
|
||||
"admin_settings.mode.form": "Formulář",
|
||||
"admin_settings.mode.effective": "Efektivní",
|
||||
"admin_settings.mode.effective_tooltip": "Zobrazení hodnot, které Etherpad aktuálně používá, pouze pro čtení, po substituci proměnných prostředí. Tajné kódy jsou redigovány.",
|
||||
"admin_settings.mode.aria_label": "Režim editoru",
|
||||
"admin_settings.envvar_banner.title": "Tento soubor je šablona, nikoli živá konfigurace.",
|
||||
"admin_settings.page-title": "Nastavení - Etherpad",
|
||||
"index.newPad": "Založ nový Pad",
|
||||
"index.settings": "Nastavení",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"Metalhead64",
|
||||
"Mklehr",
|
||||
"Mukeber",
|
||||
"Nbux",
|
||||
"Nipsky",
|
||||
"Predatorix",
|
||||
"SamTV",
|
||||
|
|
@ -22,13 +23,80 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Admin Dashboard - Etherpad",
|
||||
"admin.loading": "Lade …",
|
||||
"admin.loading_description": "Bitte warten, die Seite wird aktualisiert ...",
|
||||
"admin.toggle_sidebar": "Seitenleiste ein-/ausblenden",
|
||||
"admin.shout": "Kommunikation",
|
||||
"admin_shout.online_one": "Es ist derzeit {{count}} Benutzer online",
|
||||
"admin_shout.online_other": "Es sind aktuell {{count}} Benutzer online",
|
||||
"admin_shout.sticky_toggle": "Nachricht bleibt angeheftet",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Benutzername",
|
||||
"admin_login.password": "Passwort",
|
||||
"admin_login.submit": "Login",
|
||||
"admin_login.failed": "Login fehlgeschlagen.",
|
||||
"admin_pads.all_pads": "Alle Pads",
|
||||
"admin_pads.bulk.cleanup_history": "Verlauf löschen",
|
||||
"admin_pads.bulk.clear_selection": "Auswahl löschen",
|
||||
"admin_pads.bulk.delete": "Löschen",
|
||||
"admin_pads.cancel": "Abbrechen",
|
||||
"admin_pads.col.pad": "Pad",
|
||||
"admin_pads.col.revisions": "Bearbeitungen",
|
||||
"admin_pads.col.users": "Benutzer",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Wähle einen Namen für das neue Pad.",
|
||||
"admin_pads.delete_pad_dialog_description": "Bestätigen oder stornieren Sie die Löschung des Pads.",
|
||||
"admin_pads.delete_pad_dialog_title": "Pad löschen",
|
||||
"admin_pads.empty_never_edited": "leer · nie bearbeitet",
|
||||
"admin_pads.error_dialog_description": "Ein Fehler ist aufgetreten",
|
||||
"admin_pads.error_prefix": "Fehler",
|
||||
"admin_pads.filter.active": "Aktiv",
|
||||
"admin_pads.filter.all": "Alle",
|
||||
"admin_pads.filter.empty": "Leer",
|
||||
"admin_pads.filter.recent": "Diese Woche",
|
||||
"admin_pads.filter.stale": "Veraltet (> 1 Jahr)",
|
||||
"admin_pads.open": "Offen",
|
||||
"admin_pads.pagination.next": "Weiter",
|
||||
"admin_pads.pagination.previous": "Zurück",
|
||||
"admin_pads.refresh": "Aktualisieren",
|
||||
"admin_pads.relative.days": "vor {{count}} Tag(en)",
|
||||
"admin_pads.relative.hours": "vor {{count}} Stunde(n)",
|
||||
"admin_pads.relative.just_now": "Soeben",
|
||||
"admin_pads.relative.minutes": "vor {{count}} Minute(n)",
|
||||
"admin_pads.relative.months": "vor {{count}} Monat(en)",
|
||||
"admin_pads.relative.weeks": "vor {{count}} Woche(n)",
|
||||
"admin_pads.relative.years": "vor {{count}} Jahr(en)",
|
||||
"admin_pads.revisions_count": "{{count}} Bearbeitungen",
|
||||
"admin_pads.selected_count": "{{count}} ausgewählt",
|
||||
"admin_pads.show": "Anzeigen",
|
||||
"admin_pads.sort.name": "Name (A–Z)",
|
||||
"admin_pads.sort.revision_number": "Bearbeitungen",
|
||||
"admin_pads.sort.user_count": "Nutzer",
|
||||
"admin_pads.stats.across_pads": "über alle Pads",
|
||||
"admin_pads.stats.active_users": "Aktive Nutzer",
|
||||
"admin_pads.stats.empty_pads": "Leere Pads",
|
||||
"admin_pads.stats.last_activity": "Letzte Aktivität",
|
||||
"admin_pads.stats.no_active_users": "Keine aktiven Benutzer",
|
||||
"admin_pads.stats.revisions_zero": "0 Bearbeitungen",
|
||||
"admin_pads.stats.total": "Gesamtanzahl Pads",
|
||||
"admin_pads.stats.users_active": "{{count}} aktuell aktiv",
|
||||
"admin_pads.subtitle": "Überblick über alle Pads auf dieser Etherpad-Instanz. Suchen, aufräumen, öffnen.",
|
||||
"admin_plugins": "Pluginverwaltung",
|
||||
"admin_plugins.available": "Verfügbare Plugins",
|
||||
"admin_plugins.available_not-found": "Keine Plugins gefunden.",
|
||||
"admin_plugins.available_fetching": "Wird abgerufen...",
|
||||
"admin_plugins.available_install.value": "Installieren",
|
||||
"admin_plugins.available_search.placeholder": "Suche nach Plugins zum Installieren",
|
||||
"admin_plugins.check_updates": "Nach Updates suchen",
|
||||
"admin_plugins.core_count": "{{count}} Kern(e)",
|
||||
"admin_plugins.catalog_disabled": "Plugin-Katalog wurde vom Betreiber deaktiviert (privacy.pluginCatalog=false). Um ein Plugin zu installieren, führen Sie `pnpm run plugins i ep_<name>` auf dem Server aus.",
|
||||
"admin_plugins.crumbs": "Plugins",
|
||||
"admin_plugins.description": "Beschreibung",
|
||||
"admin_plugins.disables.label": "Deaktiviert:",
|
||||
"admin_plugins.disables.warning_title": "Dieses Plugin entfernt absichtlich die aufgeführten Etherpad-Funktionen.",
|
||||
"admin_plugins.error_retrieving": "Fehler beim Abrufen von Plugins",
|
||||
"admin_plugins.install_error": "Installation von {{plugin}} fehlgeschlagen: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Installation von {{plugin}} nicht möglich: Hierfür ist neuere Version von Etherpad notwendig. Bitte führe ein Upgrade von Etherpad durch und versuche es erneut.",
|
||||
"admin_plugins.installed": "Installierte Plugins",
|
||||
"admin_plugins.installed_fetching": "Rufe installierte Plugins ab...",
|
||||
"admin_plugins.installed_nothing": "Du hast bisher noch keine Plugins installiert.",
|
||||
|
|
@ -36,23 +104,67 @@
|
|||
"admin_plugins.last-update": "Letze Aktualisierung",
|
||||
"admin_plugins.name": "Name",
|
||||
"admin_plugins.page-title": "Plugin Manager - Etherpad",
|
||||
"admin_plugins.reload_catalog": "Katalog neu laden",
|
||||
"admin_plugins.search_npm": "Suche auf npm",
|
||||
"admin_plugins.sort_ascending": "Aufsteigend sortieren",
|
||||
"admin_plugins.sort_descending": "Absteigend sortieren",
|
||||
"admin_plugins.sort.last_updated": "Zuletzt aktualisiert",
|
||||
"admin_plugins.sort.name": "Name (A–Z)",
|
||||
"admin_plugins.sort.version": "Version",
|
||||
"admin_plugins.source": "Plugin-Quelle",
|
||||
"admin_plugins.subtitle": "Installieren, aktualisieren und entfernen Sie Etherpad-Plugins. Änderungen erfordern einen Server-Neustart.",
|
||||
"admin_plugins.tag_core": "Kern",
|
||||
"admin_plugins.update_tooltip": "Update",
|
||||
"admin_plugins.updates_available": "Updates verfügbar",
|
||||
"admin_plugins.update_now": "Update",
|
||||
"admin_plugins.version": "Version",
|
||||
"admin_plugins_info": "Hilfestellung",
|
||||
"admin_plugins_info.copy_diagnostics": "Kopiere Diagnose-Daten",
|
||||
"admin_plugins_info.copy_value": "Kopiere {{label}}",
|
||||
"admin_plugins_info.git_sha": "Git SHA",
|
||||
"admin_plugins_info.hook_bindings": "Hook-Bindungen",
|
||||
"admin_plugins_info.hooks": "Installierte Hooks",
|
||||
"admin_plugins_info.hooks_client": "Client-seitige Hooks",
|
||||
"admin_plugins_info.hooks_server": "Server-seitige Hooks",
|
||||
"admin_plugins_info.no_hooks": "Keine Hooks gefunden",
|
||||
"admin_plugins_info.parts": "Installierte Teile",
|
||||
"admin_plugins_info.plugins": "Installierte Plugins",
|
||||
"admin_plugins_info.page-title": "Plugin Informationen - Etherpad",
|
||||
"admin_plugins_info.search_placeholder": "Suche Hook oder Teil…",
|
||||
"admin_plugins_info.subtitle": "Systemdiagnose: installierte Version, registrierte Teile und Hooks.",
|
||||
"admin_plugins_info.tab_client": "Client",
|
||||
"admin_plugins_info.tab_server": "Server",
|
||||
"admin_plugins_info.up_to_date": "Aktuell",
|
||||
"admin_plugins_info.update_available": "Update verfügbar: {{version}}",
|
||||
"admin_plugins_info.version": "Etherpad Version",
|
||||
"admin_plugins_info.version_latest": "Neueste verfügbare Version",
|
||||
"admin_plugins_info.version_number": "Versionsnummer",
|
||||
"admin_settings": "Einstellungen",
|
||||
"admin_settings.create_pad": "Pad erstellen",
|
||||
"admin_settings.current": "Derzeitige Konfiguration",
|
||||
"admin_settings.current_example-devel": "Beispielhafte Entwicklungseinstellungs-Templates",
|
||||
"admin_settings.current_example-prod": "Beispiel eines produktiven Templates",
|
||||
"admin_settings.current_restart.value": "Etherpad neustarten",
|
||||
"admin_settings.current_save.value": "Einstellungen speichern",
|
||||
"admin_settings.invalid_json": "Ungültiges JSON",
|
||||
"admin_settings.current_test.value": "Validiere JSON",
|
||||
"admin_settings.current_prettify.value": "Prettify JSON",
|
||||
"admin_settings.toast.saved": "Einstellungen erfolgreich gespeichert.",
|
||||
"admin_settings.toast.save_failed": "Speichern fehlgeschlagen: settings.json konnte nicht geschrieben werden.",
|
||||
"admin_settings.toast.json_invalid": "Syntaxfehler: Überprüfen Sie Kommas, Klammern und Anführungszeichen.",
|
||||
"admin_settings.toast.disconnected": "Nicht gespeichert: Nicht mit dem Server verbunden.",
|
||||
"admin_settings.toast.validation_ok": "JSON ist gültig.",
|
||||
"admin_settings.toast.validation_failed": "JSON ist ungültig: Bitte beheben Sie Syntaxfehler.",
|
||||
"admin_settings.toast.prettify_failed": "Prettify nicht möglich: Bitte beheben Sie zunächst Syntaxfehler.",
|
||||
"admin_settings.prettify_confirm": "Prettify entfernt alle Kommentare. Weiter?",
|
||||
"admin_settings.mode.form": "Formular",
|
||||
"admin_settings.mode.effective_tooltip": "Read-only-Ansicht der Werte, die Etherpad tatsächlich verwendet, nach Umgebungsvariablen-Ersetzung. Secrets sind geschwärzt.",
|
||||
"admin_settings.mode.aria_label": "Editor-Modus",
|
||||
"admin_settings.envvar_banner.title": "Diese Datei ist eine Vorlage, nicht die Live-Konfiguration.",
|
||||
"admin_settings.envvar_banner.body": "Platzhalter wie ${VAR:default} werden beim Start in den Speicher eingesetzt; sie werden nie in diese Datei zurückgeschrieben. Bearbeiten Sie env vars in Ihrer Umgebung (Docker compose, systemd, .env), um den aufgelösten Wert zu ändern, oder ersetzen Sie den Platzhalter hier durch ein Literal. Wechseln Sie auf die Registerkarte \"Effective\", um zu sehen, was Etherpad gerade verwendet.",
|
||||
"admin_settings.toast.auth_error": "Sie sind nicht als Administrator authentifiziert. Bitte melden Sie sich erneut an.",
|
||||
"admin_settings.section.general": "Allgemein",
|
||||
"admin_settings.parse_error.title": "Parsen von settings.json nicht möglich",
|
||||
"admin_settings.page-title": "Einstellungen - Etherpad",
|
||||
"index.newPad": "Neues Pad",
|
||||
"index.settings": "Einstellungen",
|
||||
|
|
@ -75,7 +187,7 @@
|
|||
"index.labelPad": "Padname (optional)",
|
||||
"index.placeholderPadEnter": "Gib den Namen des Pads ein...",
|
||||
"index.createAndShareDocuments": "Erstelle und teile Dokumente in Echtzeit",
|
||||
"index.createAndShareDocumentsDescription": "Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Ihrem Browser läuft.",
|
||||
"index.createAndShareDocumentsDescription": "Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Deinem Browser läuft.",
|
||||
"pad.toolbar.bold.title": "Fett (Strg-B)",
|
||||
"pad.toolbar.italic.title": "Kursiv (Strg-I)",
|
||||
"pad.toolbar.underline.title": "Unterstrichen (Strg-U)",
|
||||
|
|
@ -99,18 +211,31 @@
|
|||
"pad.loading": "Laden …",
|
||||
"pad.noCookie": "Das Cookie konnte nicht gefunden werden. Bitte erlaube Cookies in deinem Browser! Deine Sitzung und Einstellungen werden zwischen den Besuchen nicht gespeichert. Dies kann darauf zurückzuführen sein, dass Etherpad in einigen Browsern in einem iFrame enthalten ist. Bitte stelle sicher, dass sich Etherpad auf der gleichen Subdomain/Domain wie der übergeordnete iFrame befindet.",
|
||||
"pad.permissionDenied": "Du hast keine Berechtigung, um auf dieses Pad zuzugreifen.",
|
||||
"pad.settings.padSettings": "Pad-Einstellungen",
|
||||
"pad.settings.title": "Einstellungen",
|
||||
"pad.settings.padSettings": "Pad-weite Einstellungen",
|
||||
"pad.settings.userSettings": "Nutzereinstellungen",
|
||||
"pad.settings.myView": "Eigene Ansicht",
|
||||
"pad.settings.disablechat": "Chat deaktivieren",
|
||||
"pad.settings.darkMode": "Dunkler Modus",
|
||||
"pad.settings.stickychat": "Chat immer anzeigen",
|
||||
"pad.settings.chatandusers": "Chat und Benutzer anzeigen",
|
||||
"pad.settings.colorcheck": "Autorenfarben anzeigen",
|
||||
"pad.settings.fadeInactiveAuthorColors": "Farben inaktiver Autoren ausblenden",
|
||||
"pad.settings.linenocheck": "Zeilennummern",
|
||||
"pad.settings.rtlcheck": "Inhalt von rechts nach links lesen?",
|
||||
"pad.settings.enforcedNotice": "Diese Einstellungen wurden vom Ersteller des Pads gesperrt. Wenden Sie sich an den Ersteller, wenn Sie diese ändern möchten.",
|
||||
"pad.settings.fontType": "Schriftart:",
|
||||
"pad.settings.fontType.normal": "Normal",
|
||||
"pad.settings.language": "Sprache:",
|
||||
"pad.settings.deletePad": "Pad löschen",
|
||||
"pad.delete.confirm": "Möchtest du dieses Pad wirklich löschen?",
|
||||
"pad.deletionToken.modalTitle": "Speichere deinen Pad-Löschtoken.",
|
||||
"pad.deletionToken.modalBody": "Dieses Token ist die einzige Möglichkeit, dieses Pad zu löschen, falls Deine Browsersitzung unterbrochen wird oder Du das Gerät wechselst. Speichere es an einem sicheren Ort – es wird hier nur einmal angezeigt.",
|
||||
"pad.deletionToken.deleteWithToken": "Pad mit Token löschen",
|
||||
"pad.deletionToken.tokenFieldLabel": "Pad-Löschtoken",
|
||||
"pad.deletionToken.tokenValueLabel": "Dein Pad-Löschtoken (schreibgeschützt)",
|
||||
"pad.deletionToken.invalid": "Das angegebene Token ist für dieses Pad nicht gültig.",
|
||||
"pad.deletionToken.notCreator": "Sie sind nicht der Ersteller dieses Pads, daher können Sie es nicht löschen.",
|
||||
"pad.settings.about": "Über",
|
||||
"pad.settings.poweredBy": "Betrieben von",
|
||||
"pad.importExport.import_export": "Import/Export",
|
||||
|
|
@ -123,7 +248,13 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.exportetherpada.title": "Export im Etherpad-Format",
|
||||
"pad.importExport.exporthtmla.title": "Exportieren als HTML",
|
||||
"pad.importExport.exportplaina.title": "Export als einfacher Text",
|
||||
"pad.importExport.exportworda.title": "Exportieren als Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Als PDF exportieren",
|
||||
"pad.importExport.exportopena.title": "Export als ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.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",
|
||||
|
|
@ -151,9 +282,11 @@
|
|||
"pad.modals.rateLimited.explanation": "Sie haben zu viele Nachrichten an dieses Pad gesendet, so dass die Verbindung unterbrochen wurde.",
|
||||
"pad.modals.rejected.explanation": "Der Server hat eine Nachricht abgelehnt, die von deinem Browser gesendet wurde.",
|
||||
"pad.modals.rejected.cause": "Möglicherweise wurde der Server aktualisiert, während du das Pad angesehen hast, oder es existiert ein Fehler in Etherpad. Versuche, die Seite neu zu laden.",
|
||||
"pad.modals.disconnected": "Ihre Verbindung wurde getrennt.",
|
||||
"pad.modals.disconnected": "Deine Verbindung wurde getrennt.",
|
||||
"pad.modals.disconnected.explanation": "Die Verbindung zum Server wurde unterbrochen.",
|
||||
"pad.modals.disconnected.cause": "Möglicherweise ist der Server nicht erreichbar. Bitte benachrichtige den Dienstadministrator, falls dies weiterhin passiert.",
|
||||
"pad.gritter.unacceptedCommit.title": "Nicht gespeicherte Bearbeitung",
|
||||
"pad.gritter.unacceptedCommit.text": "Deine letzte Änderung wurde noch nicht gespeichert. Stelle die Verbindung wieder her und versuche es erneut.",
|
||||
"pad.share": "Dieses Pad teilen",
|
||||
"pad.share.readonly": "Eingeschränkter Nur-Lese-Zugriff",
|
||||
"pad.share.link": "Verknüpfung",
|
||||
|
|
@ -166,12 +299,29 @@
|
|||
"timeslider.followContents": "Aktualisierungen des Pad-Inhalts verfolgen",
|
||||
"timeslider.pageTitle": "{{appTitle}} Bearbeitungsverlauf",
|
||||
"timeslider.toolbar.returnbutton": "Zurück zum Pad",
|
||||
"pad.historyMode.banner": "Versionsgeschichte ansehen",
|
||||
"pad.historyMode.return": "Zurück zum Live-Modus",
|
||||
"pad.historyMode.revisionLabel": "Revision {{rev}}",
|
||||
"pad.historyMode.controlsLabel": "Steuerung Pad-Verlauf",
|
||||
"pad.historyMode.sliderLabel": "Pad-Version",
|
||||
"pad.historyMode.settings.title": "Wiedergabe des Bearbeitungsverlaufs",
|
||||
"pad.historyMode.settings.follow": "Aktualisierungen des Pad-Inhalts verfolgen",
|
||||
"pad.historyMode.settings.followShort": "Folgen",
|
||||
"pad.historyMode.followOn": "Pad-Änderungen verfolgen – klicken Sie hier, um die Verfolgung zu beenden.",
|
||||
"pad.historyMode.followOff": "Änderungen der Pads werden nicht verfolgt – zum Folgen klicken",
|
||||
"pad.historyMode.settings.playbackSpeed": "Wiedergabegeschwindigkeit:",
|
||||
"pad.historyMode.chat.replayHeader": "Chat mit Stand von {{time}}",
|
||||
"pad.historyMode.users.authorsHeader": "Autoren bei dieser Revision",
|
||||
"pad.editor.keyboardHint": "Drücken Sie die Escape-Taste, um den Editor zu verlassen. Drücken Sie Alt+F9, um die Symbolleiste aufzurufen.",
|
||||
"pad.editor.toolbar.formatting": "Formatierungsleiste",
|
||||
"pad.editor.toolbar.showMore": "Weitere Schaltflächen anzeigen",
|
||||
"timeslider.toolbar.authors": "Autoren:",
|
||||
"timeslider.toolbar.authorsList": "Keine Autoren",
|
||||
"timeslider.toolbar.exportlink.title": "Diese Version exportieren",
|
||||
"timeslider.exportCurrent": "Exportiere diese Version als:",
|
||||
"timeslider.version": "Version {{version}}",
|
||||
"timeslider.saved": "Gespeichert am {{day}}. {{month}} {{year}}",
|
||||
"timeslider.settings.playbackSpeed": "Wiedergabegeschwindigkeit:",
|
||||
"timeslider.playPause": "Padbearbeitung abspielen/pausieren",
|
||||
"timeslider.backRevision": "Eine Version in diesem Pad zurückgehen",
|
||||
"timeslider.forwardRevision": "Eine Version in diesem Pad vorwärtsgehen",
|
||||
|
|
@ -193,6 +343,7 @@
|
|||
"pad.savedrevs.timeslider": "Du kannst gespeicherte Versionen durch den Aufruf des Bearbeitungsverlaufs ansehen.",
|
||||
"pad.userlist.entername": "Dein Name?",
|
||||
"pad.userlist.unnamed": "unbenannt",
|
||||
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} verbundener Benutzer, other: {{count}} verbundene Benutzer ]}",
|
||||
"pad.editbar.clearcolors": "Autorenfarben im gesamten Dokument zurücksetzen? Dies kann nicht rückgängig gemacht werden",
|
||||
"pad.impexp.importbutton": "Jetzt importieren",
|
||||
"pad.impexp.importing": "Importiere …",
|
||||
|
|
@ -203,5 +354,6 @@
|
|||
"pad.impexp.importfailed": "Import fehlgeschlagen",
|
||||
"pad.impexp.copypaste": "Bitte kopieren und einfügen",
|
||||
"pad.impexp.exportdisabled": "Der Export im {{type}}-Format ist deaktiviert. Für Einzelheiten kontaktiere bitte deinen Systemadministrator.",
|
||||
"pad.impexp.maxFileSize": "Die Datei ist zu groß. Kontaktiere bitte deinen Administrator, um das Limit für den Dateiimport zu erhöhen."
|
||||
"pad.impexp.maxFileSize": "Die Datei ist zu groß. Kontaktiere bitte deinen Administrator, um das Limit für den Dateiimport zu erhöhen.",
|
||||
"pad.social.description": "Ein kollaboratives Dokument, das jeder in Echtzeit bearbeiten kann."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -314,7 +314,7 @@
|
|||
"pad.importExport.exportworda.title": "Export as Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Export as PDF",
|
||||
"pad.importExport.exportopena.title": "Export as ODF (Open Document Format)",
|
||||
"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.importExport.noConverter.innerHTML": "You can import plain text, HTML, Microsoft Word (.docx) and Etherpad files directly. To import other formats such as PDF, ODT, DOC or RTF, the server administrator needs to install LibreOffice — see the <a href=\"https://docs.etherpad.org/\">Etherpad documentation</a>.",
|
||||
|
||||
"pad.modals.connected": "Connected.",
|
||||
"pad.modals.reconnecting": "Reconnecting to your pad…",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,27 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Ylläpitäjän kojelauta - Etherpad",
|
||||
"admin.loading": "Ladataan…",
|
||||
"admin.loading_description": "Odota. Sivu latautuu.",
|
||||
"admin.toggle_sidebar": "Näytä/piilota sivupalkki",
|
||||
"admin.shout": "Viestintä",
|
||||
"admin_shout.online_one": "Tällä hetkellä {{count}} käyttäjä on paikalla",
|
||||
"admin_shout.online_other": "Tällä hetkellä {{count}} käyttäjää on paikalla",
|
||||
"admin_shout.sticky_toggle": "Muuta pysyvää viestiä",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Käyttäjänimi",
|
||||
"admin_login.password": "Salasana",
|
||||
"admin_login.submit": "Kirjaudu sisään",
|
||||
"admin_login.failed": "Kirjautuminen epäonnistui",
|
||||
"admin_pads.all_pads": "Kaikki muistiot",
|
||||
"admin_pads.bulk.cleanup_history": "Tyhjennä historia",
|
||||
"admin_pads.bulk.delete": "Poista",
|
||||
"admin_pads.cancel": "Peru",
|
||||
"admin_pads.col.pad": "Muistio",
|
||||
"admin_pads.col.revisions": "Muokkaushistoria",
|
||||
"admin_pads.col.users": "Käyttäjät",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Valitse nimi uudelle muistiolle.",
|
||||
"admin_plugins": "Lisäosien hallinta",
|
||||
"admin_plugins.available": "Saatavilla olevat liitännäiset",
|
||||
"admin_plugins.available_not-found": "Lisäosia ei löytynyt.",
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@
|
|||
"Chpol",
|
||||
"Cquoi",
|
||||
"Crochet.david",
|
||||
"Crowwhailord",
|
||||
"Derugon",
|
||||
"Envlh",
|
||||
"Framafan",
|
||||
"Framasky",
|
||||
"Fylip22",
|
||||
"Gomoko",
|
||||
"Goofy",
|
||||
|
|
@ -37,13 +39,73 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Tableau de bord administrateur — Etherpad",
|
||||
"admin.loading": "Chargement en cours…",
|
||||
"admin.loading_description": "Veuillez patienter pendant le chargement de la page.",
|
||||
"admin.toggle_sidebar": "Afficher / masquer la barre latérale",
|
||||
"admin.shout": "Communication",
|
||||
"admin_shout.online_one": "Il y a actuellement {{count}} utilisateur·ice en ligne",
|
||||
"admin_shout.online_other": "Il y a actuellement {{count}} utilisateur·ices en ligne",
|
||||
"admin_shout.sticky_toggle": "Modifier le message épinglé",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Nom d’utilisateur",
|
||||
"admin_login.password": "Mot de passe",
|
||||
"admin_login.submit": "Connexion",
|
||||
"admin_login.failed": "Échec de la connexion",
|
||||
"admin_pads.all_pads": "Tous les blocs-notes",
|
||||
"admin_pads.bulk.cleanup_history": "Effacer l’historique",
|
||||
"admin_pads.bulk.clear_selection": "Effacer la sélection",
|
||||
"admin_pads.bulk.delete": "Supprimer",
|
||||
"admin_pads.cancel": "Annuler",
|
||||
"admin_pads.col.pad": "Bloc-notes",
|
||||
"admin_pads.col.revisions": "Révisions",
|
||||
"admin_pads.col.users": "Utilisateur·ices",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Choisissez un nom pour le nouveau bloc-notes.",
|
||||
"admin_pads.delete_pad_dialog_description": "Confirmer ou annuler la suppression du bloc-notes.",
|
||||
"admin_pads.delete_pad_dialog_title": "Supprimer le bloc-notes",
|
||||
"admin_pads.empty_never_edited": "vide · jamais modifié",
|
||||
"admin_pads.error_dialog_description": "Une erreur s’est produite.",
|
||||
"admin_pads.error_prefix": "Erreur",
|
||||
"admin_pads.filter.active": "Actif",
|
||||
"admin_pads.filter.all": "Tous",
|
||||
"admin_pads.filter.empty": "Vide",
|
||||
"admin_pads.filter.recent": "Cette semaine",
|
||||
"admin_pads.filter.stale": "Sans modifications (> 1 an)",
|
||||
"admin_pads.open": "Ouvert",
|
||||
"admin_pads.pagination.next": "Suivant",
|
||||
"admin_pads.pagination.previous": "Précédent",
|
||||
"admin_pads.refresh": "Actualiser",
|
||||
"admin_pads.relative.days": "il y a {{count}} jours",
|
||||
"admin_pads.relative.hours": "il y a {{count}} heures",
|
||||
"admin_pads.relative.just_now": "à l’instant",
|
||||
"admin_pads.show": "Afficher",
|
||||
"admin_pads.sort.name": "Nom (A–Z)",
|
||||
"admin_pads.sort.revision_number": "Révisions",
|
||||
"admin_pads.sort.user_count": "Utilisateur·ices",
|
||||
"admin_pads.stats.across_pads": "sur tous les bloc-notes",
|
||||
"admin_pads.stats.active_users": "Utilisateur·ices actif·ves",
|
||||
"admin_pads.stats.empty_pads": "Bloc-notes vides",
|
||||
"admin_pads.stats.last_activity": "Dernière activité :",
|
||||
"admin_pads.stats.no_active_users": "Aucun·e utilisateur·ice actif·ve",
|
||||
"admin_pads.stats.revisions_zero": "0 révisions",
|
||||
"admin_pads.stats.total": "Nombre total de bloc-notes",
|
||||
"admin_pads.stats.users_active": "{{count}} actuellement actifs",
|
||||
"admin_pads.subtitle": "Aperçu de tous les bloc-notes de cette instance Etherpad. Rechercher, nettoyer, ouvrir.",
|
||||
"admin_plugins": "Gestionnaire de greffons",
|
||||
"admin_plugins.available": "Greffons disponibles",
|
||||
"admin_plugins.available_not-found": "Aucun greffon trouvé.",
|
||||
"admin_plugins.available_fetching": "Récupération en cours...",
|
||||
"admin_plugins.available_install.value": "Installer",
|
||||
"admin_plugins.available_search.placeholder": "Rechercher des greffons à installer",
|
||||
"admin_plugins.check_updates": "Vérifier les mises à jour",
|
||||
"admin_plugins.catalog_disabled": "Le catalogue de greffons est désactivé par votre administrateur (privacy.pluginCatalog=false). Pour installer un greffon, exécutez `pnpm run plugins i ep_<name> ` sur le serveur.",
|
||||
"admin_plugins.crumbs": "Greffons",
|
||||
"admin_plugins.description": "Description",
|
||||
"admin_plugins.disables.label": "Désactive :",
|
||||
"admin_plugins.disables.warning_title": "Ce greffon supprime intentionnellement les fonctionnalités Etherpad listées.",
|
||||
"admin_plugins.error_retrieving": "Erreur lors de la récupération des greffons",
|
||||
"admin_plugins.install_error": "Échec de l'installation de {{plugin}} : {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Impossible d'installer {{plugin}} : une version plus récente d'Etherpad est requise. Veuillez mettre à jour Etherpad et réessayer.",
|
||||
"admin_plugins.installed": "Greffons installés",
|
||||
"admin_plugins.installed_fetching": "Récupération des greffons installés en cours...",
|
||||
"admin_plugins.installed_nothing": "Vous n’avez encore installé aucun greffon.",
|
||||
|
|
@ -51,8 +113,25 @@
|
|||
"admin_plugins.last-update": "Dernière mise à jour",
|
||||
"admin_plugins.name": "Nom",
|
||||
"admin_plugins.page-title": "Gestionnaire de greffons — Etherpad",
|
||||
"admin_plugins.reload_catalog": "Recharger le catalogue",
|
||||
"admin_plugins.search_npm": "Rechercher sur npm",
|
||||
"admin_plugins.sort_ascending": "Tri croissant",
|
||||
"admin_plugins.sort_descending": "Tri décroissant",
|
||||
"admin_plugins.sort.last_updated": "Dernière mise à jour",
|
||||
"admin_plugins.sort.name": "Nom (A–Z)",
|
||||
"admin_plugins.sort.version": "Version",
|
||||
"admin_plugins.source": "Source du greffon",
|
||||
"admin_plugins.subtitle": "Installez, mettez à jour et supprimez les greffons d’Etherpad. Toute modification nécessite un redémarrage du serveur.",
|
||||
"admin_plugins.tag_core": "Cœur",
|
||||
"admin_plugins.update_tooltip": "Mise à jour",
|
||||
"admin_plugins.updates_available": "Mises à jour disponibles",
|
||||
"admin_plugins.update_now": "Mettre à jour",
|
||||
"admin_plugins.version": "Version",
|
||||
"admin_plugins_info": "Informations de résolution de problème",
|
||||
"admin_plugins_info.bindings_label": "{{count}} liaisons",
|
||||
"admin_plugins_info.copy_diagnostics": "Copie des diagnostics",
|
||||
"admin_plugins_info.copy_value": "Copier {{label}}",
|
||||
"admin_plugins_info.git_sha": "Git SHA",
|
||||
"admin_plugins_info.hooks": "Crochets installés",
|
||||
"admin_plugins_info.hooks_client": "Crochets côté client",
|
||||
"admin_plugins_info.hooks_server": "Crochets côté serveur",
|
||||
|
|
@ -156,6 +235,16 @@
|
|||
"pad.settings.language": "Langue :",
|
||||
"pad.settings.deletePad": "Supprimer le bloc-notes",
|
||||
"pad.delete.confirm": "Voulez-vous vraiment supprimer ce bloc-notes ?",
|
||||
"pad.deletionToken.modalTitle": "Enregistrez votre jeton de suppression de bloc-notes",
|
||||
"pad.deletionToken.modalBody": "Ce jeton est le seul moyen de supprimer ce bloc-notes si vous perdez votre session de navigateur ou si vous changez d'appareil. Conservez-le en lieu sûr — il n'apparaît ici qu'une seule fois.",
|
||||
"pad.deletionToken.copy": "Copier",
|
||||
"pad.deletionToken.copied": "Copié",
|
||||
"pad.deletionToken.acknowledge": "Je l'ai enregistré",
|
||||
"pad.deletionToken.deleteWithToken": "Supprimer le bloc-notes avec un jeton",
|
||||
"pad.deletionToken.tokenFieldLabel": "Jeton de suppression de bloc-notes",
|
||||
"pad.deletionToken.tokenValueLabel": "Votre jeton de suppression du bloc-notes (lecture seule)",
|
||||
"pad.deletionToken.invalid": "Ce jeton n'est pas valable pour ce bloc-notes.",
|
||||
"pad.deletionToken.notCreator": "Vous n’êtes pas le créateur du bloc-notes, vous ne pouvez donc pas le supprimer.",
|
||||
"pad.settings.about": "À propos",
|
||||
"pad.settings.poweredBy": "Propulsé par",
|
||||
"pad.importExport.import_export": "Importer/Exporter",
|
||||
|
|
@ -168,7 +257,10 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "Vous pouvez uniquement importer du texte brut ou du HTML. Pour des fonctionnalités d'importation plus avancées, veuillez <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">installer LibreOffice</a>.",
|
||||
"pad.importExport.exportetherpada.title": "Exporter au format Etherpad",
|
||||
"pad.importExport.exporthtmla.title": "Exporter au format HTML",
|
||||
"pad.importExport.exportplaina.title": "Exporter en texte brut",
|
||||
"pad.importExport.noConverter.innerHTML": "Vous pouvez importer directement du texte brut, du HTML,Microsoft Word (.docx) et des fichiers Etherpad. Pour importer d'autres formats tels que PDF, ODT, DOC ou RTF, l'administrateur du serveur doit installer LibreOffice ; consultez la <a href=\"https://docs.etherpad.org/\">documentation Etherpad</a> .",
|
||||
"pad.modals.connected": "Connecté.",
|
||||
"pad.modals.reconnecting": "Reconnexion à votre bloc-notes en cours...",
|
||||
"pad.modals.forcereconnect": "Forcer la reconnexion",
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@
|
|||
"pad.importExport.exportworda.title": "Esporta come Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Esporta in formato PDF",
|
||||
"pad.importExport.exportopena.title": "Esporta in formato ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "È possibile importare solo file di testo semplice o in formato HTML. Per funzionalità di importazione più avanzate, si prega <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">di installare LibreOffice</a> .",
|
||||
"pad.importExport.noConverter.innerHTML": "È possibile importare direttamente file di testo semplice, HTML, Microsoft Word (.docx) e file Etherpad. Per importare altri formati come PDF, ODT, DOC o RTF, l'amministratore del server deve installare LibreOffice: consulta la <a href=\"https://docs.etherpad.org/\">documentazione di Etherpad</a>.",
|
||||
"pad.modals.connected": "Connesso.",
|
||||
"pad.modals.reconnecting": "Riconnessione al pad in corso…",
|
||||
"pad.modals.forcereconnect": "Forza la riconnessione",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"Chqaz",
|
||||
"Omotecho",
|
||||
"Shirayuki",
|
||||
"Tensama0415",
|
||||
"Torinky"
|
||||
]
|
||||
},
|
||||
|
|
@ -30,7 +31,7 @@
|
|||
"admin_plugins_info.hooks_client": "クライアント側のフック",
|
||||
"admin_plugins_info.hooks_server": "サーバー側のフック",
|
||||
"index.newPad": "新規作成",
|
||||
"index.createOpenPad": "または作成/編集するパッド名を入力:",
|
||||
"index.createOpenPad": "編集するパッド名を入力",
|
||||
"index.openPad": "次の名称の既存の Pad を開く:",
|
||||
"pad.toolbar.bold.title": "太字 (Ctrl+B)",
|
||||
"pad.toolbar.italic.title": "斜体 (Ctrl+I)",
|
||||
|
|
@ -39,7 +40,7 @@
|
|||
"pad.toolbar.ol.title": "番号付きリスト (Ctrl+Shift+N)",
|
||||
"pad.toolbar.ul.title": "番号なしリスト (Ctrl+Shift+L)",
|
||||
"pad.toolbar.indent.title": "インデント (Tab)",
|
||||
"pad.toolbar.unindent.title": "インデント解除 (Shift+Tab)",
|
||||
"pad.toolbar.unindent.title": "アウトデント (Shift+Tab)",
|
||||
"pad.toolbar.undo.title": "元に戻す (Ctrl+Z)",
|
||||
"pad.toolbar.redo.title": "やり直し (Ctrl+Y)",
|
||||
"pad.toolbar.clearAuthorship.title": "作者の色分けを消去(Ctrl+Shift+C)",
|
||||
|
|
@ -54,7 +55,7 @@
|
|||
"pad.loading": "読み込み中...",
|
||||
"pad.noCookie": "Cookie could not be found. Please allow cookies in your browser! Your session and settings will not be saved between visits. \n\nクッキーが見つかりません。ブラウザの設定でクッキーの使用を許可するまで、アクセスの記録や設定は引き継がれません。原因はブラウザによって Etherpad が iFrame に組み込まれたからと考えられます。親ドメインの iFrame と同じドメイン/サブドメインに置かれているかどうか、Etherpad の設定を確認してください。",
|
||||
"pad.permissionDenied": "あなたにはこのパッドへのアクセス許可がありません",
|
||||
"pad.settings.padSettings": "パッドの設定",
|
||||
"pad.settings.padSettings": "全パッドの設定",
|
||||
"pad.settings.myView": "個人設定",
|
||||
"pad.settings.stickychat": "画面にチャットを常に表示",
|
||||
"pad.settings.chatandusers": "チャットとユーザーを表示",
|
||||
|
|
@ -77,7 +78,7 @@
|
|||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.modals.connected": "接続されました。",
|
||||
"pad.modals.reconnecting": "パッドに再接続中...",
|
||||
"pad.modals.reconnecting": "パッドに再接続中…",
|
||||
"pad.modals.forcereconnect": "強制的に再接続",
|
||||
"pad.modals.reconnecttimer": "再接続を試行中",
|
||||
"pad.modals.cancel": "中止",
|
||||
|
|
|
|||
|
|
@ -145,14 +145,14 @@
|
|||
"admin_settings.current_save.value": "설정 저장",
|
||||
"admin_settings.invalid_json": "잘못된 JSON",
|
||||
"admin_settings.current_test.value": "JSON 검증",
|
||||
"admin_settings.current_prettify.value": "JSON 정리",
|
||||
"admin_settings.current_prettify.value": "JSON를 보기 좋게 정리",
|
||||
"admin_settings.toast.saved": "설정을 성공적으로 저장했습니다.",
|
||||
"admin_settings.toast.save_failed": "저장 실패: settings.json에 쓰기 할 수 없습니다.",
|
||||
"admin_settings.toast.json_invalid": "구문 오류: 쉼표, 중괄호, 따옴표를 확인하세요.",
|
||||
"admin_settings.toast.disconnected": "저장할 수 없음: 서버에 연결되어 있지 않습니다.",
|
||||
"admin_settings.toast.validation_ok": "JSON이 유효합니다.",
|
||||
"admin_settings.toast.validation_failed": "JSON이 유효하지 않습니다. 구문 오류를 수정하세요.",
|
||||
"admin_settings.toast.prettify_failed": "정리할 수 없습니다. 먼저 구문 오류를 수정하세요.",
|
||||
"admin_settings.toast.prettify_failed": "보기 좋게 정리할 수 없습니다. 먼저 구문 오류를 수정하세요.",
|
||||
"admin_settings.prettify_confirm": "정리하면 모든 의견이 제거됩니다. 계속하시겠습니까?",
|
||||
"admin_settings.mode.form": "형태",
|
||||
"admin_settings.mode.raw": "원본",
|
||||
|
|
@ -325,7 +325,7 @@
|
|||
"pad.importExport.exportworda.title": "Microsoft Word 파일로 내보내기",
|
||||
"pad.importExport.exportpdfa.title": "PDF로 내보내기",
|
||||
"pad.importExport.exportopena.title": "ODF(Open Document Format) 형식으로 내보내기",
|
||||
"pad.importExport.noConverter.innerHTML": "일반 텍스트나 HTML 형식으로만 가져올 수 있습니다. 고급 가져오기 기능에 대해서는 <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">리브레오피스를 설치</a>하세요.",
|
||||
"pad.importExport.noConverter.innerHTML": "일반 텍스트, HTML, 마이크로소프트 워드 (.docx) 또는 이더패드 파일을 직접 가져올 수 있습니다. PDF, ODT, DOC 또는 RTF와 같은 다른 형식을 가져오려면 서버 관리자가 리브레오피스를 설치해야 합니다. 자세한 내용은 <a href=\"https://docs.etherpad.org/\">이더패드 설명서</a>를 참고하세요.",
|
||||
"pad.modals.connected": "연결함.",
|
||||
"pad.modals.reconnecting": "내 패드에 다시 연결하는 중...",
|
||||
"pad.modals.forcereconnect": "강제로 다시 연결",
|
||||
|
|
|
|||
|
|
@ -187,6 +187,39 @@
|
|||
"update.page.policy.rollback-failed-terminal": "Претходната поднова не успеа и не можеше да се отповика. Стиснете на „Прифати“ откако воспоставката ќе стане задрава за да отклучите.",
|
||||
"update.page.policy.up-to-date": "Ја користите најновата верзија.",
|
||||
"update.page.policy.tier-off": "Подновие се оневозможени (updates.tier = „off“).",
|
||||
"update.page.policy.maintenance-window-missing": "Ниво 4 (автономно) бара период на одржување. Задајте го updates.maintenanceWindow во settings.json за да овозможите автономни поднови.",
|
||||
"update.page.policy.maintenance-window-invalid": "Ниво 4 (автономно) е оневозможено бидејќи updates.maintenanceWindow е погрешно срочен. Се очекуваше {start, end, tz} со времиња ЧЧ:ММ и tz да биде „local“ или „utc“.",
|
||||
"update.page.last_result.verified": "Потврдена последната поднова на {{tag}}.",
|
||||
"update.page.last_result.rolled-back": "Отповикан последниот обид за поднова на {{tag}}: {{reason}}.",
|
||||
"update.page.last_result.rollback-failed": "Последниот обид за поднова не успеа И отповикувањето не успеа: {{reason}}. Потребна е рачна интервенција.",
|
||||
"update.page.last_result.preflight-failed": "Не успеа последниот обид за поднова на {{tag}} во подготовката: {{reason}}.",
|
||||
"update.page.last_result.cancelled": "Последниот обид за поднова на {{tag}} е откажан од администратор.",
|
||||
"update.execution.idle": "Неактивно",
|
||||
"update.execution.scheduled": "Подновата е закажана",
|
||||
"update.execution.preflight": "Подготвителни проверки",
|
||||
"update.execution.preflight-failed": "Проверките не успеаја",
|
||||
"update.execution.draining": "Исцрпувачки седници",
|
||||
"update.execution.executing": "Подновувам...",
|
||||
"update.execution.pending-verification": "Чека на потврда",
|
||||
"update.execution.verified": "Потврдено",
|
||||
"update.execution.rolling-back": "Отповикување",
|
||||
"update.execution.rolled-back": "Отповикано",
|
||||
"update.execution.rollback-failed": "Отповикувањето не успеа",
|
||||
"update.banner.terminal.rollback-failed": "Не успеа обидот за поднова и не можеше да биде отповикан. Потребна е рачна интервенција.",
|
||||
"update.banner.scheduled": "Автоподновата на {{tag}} е закажана — на сила за {{remaining}}.",
|
||||
"update.banner.maintenance-window-missing": "Автономните поднови се оневозможени додека не се постави период на одржување.",
|
||||
"update.banner.maintenance-window-invalid": "Автономните поднови се оневозможени бидејќи периодот на одржување е погрешно срочен.",
|
||||
"update.page.scheduled.title": "Подновата е закажана",
|
||||
"update.page.scheduled.countdown": "Etherpad ќе почне со подновување на {{tag}} за {{remaining}}.",
|
||||
"update.page.scheduled.deferred_until": "Вон периодот на одржување. Подновата ќе почне кога периодот ќе започне во {{at}}.",
|
||||
"update.page.scheduled.apply_now": "Примени сега",
|
||||
"update.window.title": "Период на одржување",
|
||||
"update.window.summary": "{{start}}–{{end}} ({{tz}})",
|
||||
"update.window.unset": "Не е наместено.",
|
||||
"update.window.next_opens_at": "Следниот период почнува на {{at}}.",
|
||||
"update.drain.t60": "Etherpad ќе се превклучи за 60 секунди за да направи примена и поднова.",
|
||||
"update.drain.t30": "Etherpad ќе се превклучи за 30 секунди за да направи примена и поднова.",
|
||||
"update.drain.t10": "Etherpad ќе се превклучи за 10 секунди за да направи примена и поднова.",
|
||||
"index.newPad": "Нова тетратка",
|
||||
"index.settings": "Нагодувања",
|
||||
"index.transferSessionTitle": "Префрли седница",
|
||||
|
|
@ -199,6 +232,7 @@
|
|||
"index.copyLinkButton": "Копирај врска во меѓускладот",
|
||||
"index.transferToSystem": "3. Копирај седница во нов систем",
|
||||
"index.transferToSystemDescription": "Отворете ја ископираната врска во целниот прелистувач или уред за да ја префрлите вашата седница.",
|
||||
"index.code": "Код",
|
||||
"index.transferSessionDescription": "Префрлете ја вашата тековна седница на прелистувач или уред стискајќи на копчето подолу. Ова ќе ја прекопира врската во страница која ќе ви ја префрли седницата кога ќе се отвори во целниот прелистувач или уред.",
|
||||
"index.createOpenPad": "Отвори тетратка по име",
|
||||
"index.openPad": "отвори постоечка тетратка наречена:",
|
||||
|
|
@ -273,6 +307,12 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.exportetherpada.title": "Извези како Etherpad",
|
||||
"pad.importExport.exporthtmla.title": "Извези како HTML",
|
||||
"pad.importExport.exportplaina.title": "Извези како прост текст",
|
||||
"pad.importExport.exportworda.title": "Извези како Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Извези како PDF",
|
||||
"pad.importExport.exportopena.title": "Извези како ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "Можете да увезувате само од прост текст или HTML-форматти. Понапредни можности за увоз ќе добиете ако <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">го воспоставите LibreOffice</a>.",
|
||||
"pad.modals.connected": "Поврзано.",
|
||||
"pad.modals.reconnecting": "Ве преповрзувам со тетратката...",
|
||||
|
|
@ -318,13 +358,31 @@
|
|||
"timeslider.followContents": "Следи ги подновите во содржината на тетратката",
|
||||
"timeslider.pageTitle": "{{appTitle}} Историски преглед",
|
||||
"timeslider.toolbar.returnbutton": "Назад на тетратката",
|
||||
"pad.historyMode.banner": "Историја на посети",
|
||||
"pad.historyMode.return": "Назад во живо",
|
||||
"pad.historyMode.revisionLabel": "Преработка {{rev}}",
|
||||
"pad.historyMode.controlsLabel": "Контроли за историја на тетратката",
|
||||
"pad.historyMode.sliderLabel": "Преработка на тетратката",
|
||||
"pad.historyMode.settings.title": "Преглед на историјата",
|
||||
"pad.historyMode.settings.follow": "Следи поднови на содржини тетратки",
|
||||
"pad.historyMode.settings.followShort": "Следи",
|
||||
"pad.historyMode.followOn": "Следите промени во тетратката — стиснете за да престанете со следење",
|
||||
"pad.historyMode.followOff": "Не следите промени во тетратката — стиснете за да следите",
|
||||
"pad.historyMode.settings.playbackSpeed": "Брзина на прегледувањето:",
|
||||
"pad.historyMode.chat.replayHeader": "Разговор од {{time}}",
|
||||
"pad.historyMode.users.authorsHeader": "Авроти на оваа преработка",
|
||||
"pad.editor.skipToContent": "Прејди на уредувачот",
|
||||
"pad.editor.keyboardHint": "Стиснете Escape за да излезете од уредувачот. Стиснете Alt+F9 за алатникот.",
|
||||
"pad.editor.toolbar.formatting": "Алатник за форматирање",
|
||||
"pad.editor.toolbar.actions": "Алатник за дејства врз тетратка",
|
||||
"pad.editor.toolbar.showMore": "Покажи повеќе копчиња на алатникот",
|
||||
"timeslider.toolbar.authors": "Автори:",
|
||||
"timeslider.toolbar.authorsList": "Нема автори",
|
||||
"timeslider.toolbar.exportlink.title": "Извоз",
|
||||
"timeslider.exportCurrent": "Извези ја тековната верзија како:",
|
||||
"timeslider.version": "Верзија {{version}}",
|
||||
"timeslider.saved": "Зачувано на {{day}} {{month}} {{year}} г.",
|
||||
"timeslider.settings.playbackSpeed": "Брзина на репродукција:",
|
||||
"timeslider.settings.playbackSpeed": "Брзина на прегледување:",
|
||||
"timeslider.settings.playbackSpeed.original": "Изворна брзина",
|
||||
"timeslider.settings.playbackSpeed.realtime": "Реално време",
|
||||
"timeslider.settings.playbackSpeed.200ms": "200 мс",
|
||||
|
|
@ -351,6 +409,7 @@
|
|||
"pad.savedrevs.timeslider": "Можете да ги погледате зачуваните преработки посетувајќи го времеследниот лизгач",
|
||||
"pad.userlist.entername": "Внесете го вашето име",
|
||||
"pad.userlist.unnamed": "без име",
|
||||
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} поврзан корисник, other: {{count}} поврзани корисници ]}",
|
||||
"pad.editbar.clearcolors": "Да ги отстранам авторските бои од целиот документ? Ова е неповратно",
|
||||
"pad.impexp.importbutton": "Увези сега",
|
||||
"pad.impexp.importing": "Увезувам...",
|
||||
|
|
|
|||
|
|
@ -28,17 +28,17 @@
|
|||
"pad.modals.cancel": "Xikxolewa",
|
||||
"pad.modals.deleted": "Omopohpoloh.",
|
||||
"pad.modals.deleted.explanation": "Ōmopoloh inīn Pad.",
|
||||
"timeslider.version": "Inīc {{version}} Cuepaliztli",
|
||||
"timeslider.month.january": "Eneroh",
|
||||
"timeslider.month.february": "Febreroh",
|
||||
"timeslider.month.march": "Marsoh",
|
||||
"timeslider.month.april": "April",
|
||||
"timeslider.month.may": "Mayoh",
|
||||
"timeslider.month.june": "Honioh",
|
||||
"timeslider.month.july": "Holioh",
|
||||
"timeslider.month.august": "Ahostoh",
|
||||
"timeslider.month.september": "Septiempreh",
|
||||
"timeslider.month.october": "Oktopreh",
|
||||
"timeslider.month.november": "Noviempreh",
|
||||
"timeslider.month.december": "Tisiempreh"
|
||||
"timeslider.version": "Inic {{version}} Tlacepaliztli",
|
||||
"timeslider.month.january": "Enero",
|
||||
"timeslider.month.february": "Febrero",
|
||||
"timeslider.month.march": "Marzo",
|
||||
"timeslider.month.april": "Abril",
|
||||
"timeslider.month.may": "Mayo",
|
||||
"timeslider.month.june": "Junio",
|
||||
"timeslider.month.july": "Julio",
|
||||
"timeslider.month.august": "Agosto",
|
||||
"timeslider.month.september": "Septiembre",
|
||||
"timeslider.month.october": "Octubre",
|
||||
"timeslider.month.november": "Noviembre",
|
||||
"timeslider.month.december": "Diciembre"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,17 +7,85 @@
|
|||
"Mark",
|
||||
"Rudko",
|
||||
"Teslaton",
|
||||
"Wizzard",
|
||||
"Yardom78"
|
||||
]
|
||||
},
|
||||
"admin.page-title": "Ovládací panel správu - Etherpad",
|
||||
"admin.loading": "Načítava sa…",
|
||||
"admin.loading_description": "Počkajte, prosím, kým sa stránka načíta.",
|
||||
"admin.toggle_sidebar": "Prepnúť bočný panel",
|
||||
"admin.shout": "Komunikácia",
|
||||
"admin_shout.online_one": "Momentálne je online {{count}} používateľ",
|
||||
"admin_shout.online_other": "Momentálne je online {{count}} používateľov",
|
||||
"admin_shout.sticky_toggle": "Zmeniť pripnutú správu",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Používateľské meno",
|
||||
"admin_login.password": "Heslo",
|
||||
"admin_login.submit": "Prihlásiť sa",
|
||||
"admin_login.failed": "Prihlásenie zlyhalo",
|
||||
"admin_pads.all_pads": "Všetky pady",
|
||||
"admin_pads.bulk.cleanup_history": "Vyčistiť históriu",
|
||||
"admin_pads.bulk.clear_selection": "Zrušiť výber",
|
||||
"admin_pads.bulk.delete": "Zmazať",
|
||||
"admin_pads.cancel": "Zrušiť",
|
||||
"admin_pads.col.pad": "Pad",
|
||||
"admin_pads.col.revisions": "Revízie",
|
||||
"admin_pads.col.users": "Používatelia",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Zvoľte názov nového padu.",
|
||||
"admin_pads.delete_pad_dialog_description": "Potvrďte alebo zrušte zmazanie padu.",
|
||||
"admin_pads.delete_pad_dialog_title": "Zmazať pad",
|
||||
"admin_pads.empty_never_edited": "prázdny · nikdy neupravovaný",
|
||||
"admin_pads.error_dialog_description": "Vyskytla sa chyba.",
|
||||
"admin_pads.error_prefix": "Chyba",
|
||||
"admin_pads.filter.active": "Aktívne",
|
||||
"admin_pads.filter.all": "Všetky",
|
||||
"admin_pads.filter.empty": "Prázdne",
|
||||
"admin_pads.filter.recent": "Tento týždeň",
|
||||
"admin_pads.filter.stale": "Neaktívne (>1 r.)",
|
||||
"admin_pads.open": "Otvoriť",
|
||||
"admin_pads.pagination.next": "Ďalej",
|
||||
"admin_pads.pagination.previous": "Späť",
|
||||
"admin_pads.refresh": "Obnoviť",
|
||||
"admin_pads.relative.days": "pred {{count}} d",
|
||||
"admin_pads.relative.hours": "pred {{count}} h",
|
||||
"admin_pads.relative.just_now": "práve teraz",
|
||||
"admin_pads.relative.minutes": "pred {{count}} min",
|
||||
"admin_pads.relative.months": "pred {{count}} mes.",
|
||||
"admin_pads.relative.weeks": "pred {{count}} týž.",
|
||||
"admin_pads.relative.years": "pred {{count}} r.",
|
||||
"admin_pads.revisions_count": "{{count}} revízií",
|
||||
"admin_pads.selected_count": "Vybraté: {{count}}",
|
||||
"admin_pads.show": "Zobraziť",
|
||||
"admin_pads.sort.name": "Názov (A – Z)",
|
||||
"admin_pads.sort.revision_number": "Revízie",
|
||||
"admin_pads.sort.user_count": "Používatelia",
|
||||
"admin_pads.stats.across_pads": "vo všetkých padoch",
|
||||
"admin_pads.stats.active_users": "Aktívni používatelia",
|
||||
"admin_pads.stats.empty_pads": "Prázdne pady",
|
||||
"admin_pads.stats.last_activity": "Posledná aktivita",
|
||||
"admin_pads.stats.no_active_users": "Žiadni aktívni používatelia",
|
||||
"admin_pads.stats.revisions_zero": "0 revízií",
|
||||
"admin_pads.stats.total": "Pady spolu",
|
||||
"admin_pads.stats.users_active": "Momentálne aktívnych: {{count}}",
|
||||
"admin_pads.subtitle": "Prehľad všetkých padov na tejto inštancii Etherpadu. Vyhľadávajte, čistite, otvárajte.",
|
||||
"admin_plugins": "Správca doplnkov",
|
||||
"admin_plugins.available": "Dostupné doplnky",
|
||||
"admin_plugins.available_not-found": "Doplnky neboli nájdené.",
|
||||
"admin_plugins.available_fetching": "Načítavanie...",
|
||||
"admin_plugins.available_install.value": "Inštalovať",
|
||||
"admin_plugins.available_search.placeholder": "Vyhľadať doplnky na inštaláciu",
|
||||
"admin_plugins.check_updates": "Skontrolovať aktualizácie",
|
||||
"admin_plugins.core_count": "{{count}} základných",
|
||||
"admin_plugins.catalog_disabled": "Katalóg zásuvných modulov je vypnutý vaším prevádzkovateľom (privacy.pluginCatalog=false). Zásuvný modul nainštalujete spustením `pnpm run plugins i ep_<name>` na serveri.",
|
||||
"admin_plugins.crumbs": "Zásuvné moduly",
|
||||
"admin_plugins.description": "Popis",
|
||||
"admin_plugins.disables.label": "Vypína:",
|
||||
"admin_plugins.disables.warning_title": "Tento zásuvný modul zámerne odstraňuje uvedené funkcie Etherpadu.",
|
||||
"admin_plugins.error_retrieving": "Chyba pri získavaní zásuvných modulov",
|
||||
"admin_plugins.install_error": "Nepodarilo sa nainštalovať {{plugin}}: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Nemožno nainštalovať {{plugin}}: vyžaduje novšiu verziu Etherpadu. Aktualizujte Etherpad a skúste to znova.",
|
||||
"admin_plugins.installed": "Nainštalované doplnky",
|
||||
"admin_plugins.installed_fetching": "Načítavanie nainštalovaných doplnkov...",
|
||||
"admin_plugins.installed_nothing": "Ešte ste nenainštalovali žiadne doplnky.",
|
||||
|
|
@ -25,27 +93,161 @@
|
|||
"admin_plugins.last-update": "Posledná aktualizácia",
|
||||
"admin_plugins.name": "Názov",
|
||||
"admin_plugins.page-title": "Správca doplnkov - Etherpad",
|
||||
"admin_plugins.reload_catalog": "Znovu načítať katalóg",
|
||||
"admin_plugins.search_npm": "Hľadať na npm",
|
||||
"admin_plugins.sort_ascending": "Zoradiť vzostupne",
|
||||
"admin_plugins.sort_descending": "Zoradiť zostupne",
|
||||
"admin_plugins.sort.last_updated": "Naposledy aktualizované",
|
||||
"admin_plugins.sort.name": "Názov (A – Z)",
|
||||
"admin_plugins.sort.version": "Verzia",
|
||||
"admin_plugins.source": "Zdroj zásuvného modulu",
|
||||
"admin_plugins.subtitle": "Inštalujte, aktualizujte a odstraňujte zásuvné moduly Etherpadu. Zmeny si vyžadujú reštart servera.",
|
||||
"admin_plugins.tag_core": "Základné",
|
||||
"admin_plugins.update_tooltip": "Aktualizovať",
|
||||
"admin_plugins.updates_available": "Dostupné aktualizácie",
|
||||
"admin_plugins.update_now": "Aktualizovať",
|
||||
"admin_plugins.version": "Verzia",
|
||||
"admin_plugins_info": "Informácie k riešeniu problémov",
|
||||
"admin_plugins_info.bindings_label": "{{count}} väzieb",
|
||||
"admin_plugins_info.copy_diagnostics": "Kopírovať diagnostiku",
|
||||
"admin_plugins_info.copy_value": "Kopírovať {{label}}",
|
||||
"admin_plugins_info.git_sha": "Git SHA",
|
||||
"admin_plugins_info.hook_bindings": "Väzby hookov",
|
||||
"admin_plugins_info.hooks": "Nainštalované súčasti",
|
||||
"admin_plugins_info.hooks_client": "Súčasti na strane klienta",
|
||||
"admin_plugins_info.hooks_server": "Súčasti na strane servera",
|
||||
"admin_plugins_info.no_hooks": "Nenašli sa žiadne hooky",
|
||||
"admin_plugins_info.parts": "Nainštalované súčasti",
|
||||
"admin_plugins_info.plugins": "Nainštalované doplnky",
|
||||
"admin_plugins_info.page-title": "Informácie o doplnkoch - Etherpad",
|
||||
"admin_plugins_info.search_placeholder": "Hľadať hook alebo časť…",
|
||||
"admin_plugins_info.subtitle": "Diagnostika systému: nainštalovaná verzia, registrované časti a hooky.",
|
||||
"admin_plugins_info.tab_client": "Klient",
|
||||
"admin_plugins_info.tab_server": "Server",
|
||||
"admin_plugins_info.up_to_date": "Aktuálne",
|
||||
"admin_plugins_info.update_available": "Dostupná aktualizácia: {{version}}",
|
||||
"admin_plugins_info.version": "Verzia Etherpadu",
|
||||
"admin_plugins_info.version_latest": "Posledná dostupná verzia",
|
||||
"admin_plugins_info.version_number": "Číslo verzie",
|
||||
"admin_settings": "Nastavenia",
|
||||
"admin_settings.create_pad": "Vytvoriť pad",
|
||||
"admin_settings.current": "Aktuálne nastavenia",
|
||||
"admin_settings.current_example-devel": "Príklad šablóny vývojárskeho nastavenia",
|
||||
"admin_settings.current_example-prod": "Príklad šablóny výrobného nastavenia",
|
||||
"admin_settings.current_restart.value": "Reštartovať Etherpad",
|
||||
"admin_settings.current_save.value": "Uložiť nastavenia",
|
||||
"admin_settings.invalid_json": "Neplatný JSON",
|
||||
"admin_settings.current_test.value": "Overiť JSON",
|
||||
"admin_settings.current_prettify.value": "Sformátovať JSON",
|
||||
"admin_settings.toast.saved": "Nastavenia boli úspešne uložené.",
|
||||
"admin_settings.toast.save_failed": "Uloženie zlyhalo: súbor settings.json sa nepodarilo zapísať.",
|
||||
"admin_settings.toast.json_invalid": "Syntaktická chyba: skontrolujte čiarky, zátvorky a úvodzovky.",
|
||||
"admin_settings.toast.disconnected": "Nemožno uložiť: bez pripojenia k serveru.",
|
||||
"admin_settings.toast.validation_ok": "JSON je platný.",
|
||||
"admin_settings.toast.validation_failed": "JSON je neplatný: opravte syntaktické chyby.",
|
||||
"admin_settings.toast.prettify_failed": "Nemožno sformátovať: najprv opravte syntaktické chyby.",
|
||||
"admin_settings.prettify_confirm": "Formátovaním sa odstránia všetky komentáre. Pokračovať?",
|
||||
"admin_settings.mode.form": "Formulár",
|
||||
"admin_settings.mode.raw": "Surový",
|
||||
"admin_settings.mode.effective": "Efektívny",
|
||||
"admin_settings.mode.effective_tooltip": "Zobrazenie hodnôt, ktoré Etherpad práve teraz skutočne používa (po dosadení premenných prostredia), iba na čítanie. Tajné hodnoty sú skryté.",
|
||||
"admin_settings.mode.aria_label": "Režim editora",
|
||||
"admin_settings.envvar_banner.title": "Tento súbor je šablóna, nie živá konfigurácia.",
|
||||
"admin_settings.envvar_banner.body": "Zástupné výrazy ako ${VAR:default} sa pri štarte dosadia do pamäte; nikdy sa nezapisujú späť do tohto súboru. Ak chcete zmeniť výslednú hodnotu, upravte premenné prostredia vo svojom prostredí (Docker compose, systemd, .env), alebo tu nahraďte zástupný výraz konkrétnou hodnotou. Prepnutím na kartu Efektívny zistíte, čo Etherpad práve používa.",
|
||||
"admin_settings.toast.auth_error": "Nie ste overený ako správca. Prihláste sa, prosím, znova.",
|
||||
"admin_settings.section.general": "Všeobecné",
|
||||
"admin_settings.parse_error.title": "Nemožno spracovať settings.json",
|
||||
"admin_settings.parse_error.cta": "Prepnite na surový režim na úpravu",
|
||||
"admin_settings.env_pill.tooltip": "Číta sa z premennej prostredia {{variable}}. Hodnota nižšie sa použije, keď {{variable}} nie je nastavená.",
|
||||
"admin_settings.env_pill.default_label": "predvolené",
|
||||
"admin_settings.env_pill.input_aria": "Predvolená hodnota pre {{variable}}",
|
||||
"admin_settings.env_pill.runtime_label": "aktívna hodnota",
|
||||
"admin_settings.env_pill.runtime_tooltip": "Etherpad práve používa túto hodnotu, určenú z {{variable}} alebo jej predvolenej hodnoty.",
|
||||
"admin_settings.env_pill.redacted_tooltip": "Etherpad používa hodnotu pre {{variable}}, je však skrytá, pretože ide o tajnú hodnotu.",
|
||||
"admin_settings.page-title": "Nastavenia - Etherpad",
|
||||
"admin_settings.save_error": "Chyba pri ukladaní nastavení",
|
||||
"admin_settings.saved_success": "Nastavenia boli úspešne uložené",
|
||||
"update.banner.title": "Dostupná aktualizácia",
|
||||
"update.banner.body": "Je dostupný Etherpad {{latest}} (používate {{current}}).",
|
||||
"update.banner.cta": "Zobraziť aktualizáciu",
|
||||
"update.page.title": "Aktualizácie Etherpadu",
|
||||
"update.page.current": "Aktuálna verzia",
|
||||
"update.page.latest": "Najnovšia verzia",
|
||||
"update.page.last_check": "Naposledy skontrolované",
|
||||
"update.page.install_method": "Spôsob inštalácie",
|
||||
"update.page.tier": "Úroveň aktualizácií",
|
||||
"update.page.changelog": "Zoznam zmien",
|
||||
"update.page.up_to_date": "Používate najnovšiu verziu.",
|
||||
"update.page.disabled": "Kontroly aktualizácií sú vypnuté (updates.tier = „off“).",
|
||||
"update.page.unauthorized": "Nemáte oprávnenie zobraziť stav aktualizácií.",
|
||||
"update.page.error": "Nepodarilo sa načítať stav aktualizácií (stav {{status}}).",
|
||||
"update.badge.severe": "Etherpad na tomto serveri je vážne zastaraný. Upozornite správcu.",
|
||||
"update.badge.vulnerable": "Etherpad na tomto serveri používa verziu so známymi bezpečnostnými problémami. Upozornite správcu.",
|
||||
"update.page.apply": "Použiť aktualizáciu",
|
||||
"update.page.cancel": "Zrušiť",
|
||||
"update.page.acknowledge": "Potvrdiť",
|
||||
"update.page.log": "Záznam aktualizácie (posledných 200 riadkov)",
|
||||
"update.page.execution": "Stav",
|
||||
"update.page.policy.install-method-not-writable": "Aktualizácie zo správcovského rozhrania vyžadujú inštaláciu cez git. Aktualizujte cez svojho správcu balíkov.",
|
||||
"update.page.policy.rollback-failed-terminal": "Predchádzajúca aktualizácia zlyhala a nepodarilo sa ju vrátiť späť. Po uvedení inštalácie do poriadku stlačte Potvrdiť, čím sa zámok zruší.",
|
||||
"update.page.policy.up-to-date": "Používate najnovšiu verziu.",
|
||||
"update.page.policy.tier-off": "Aktualizácie sú vypnuté (updates.tier = „off“).",
|
||||
"update.page.policy.maintenance-window-missing": "Úroveň 4 (autonómna) vyžaduje okno údržby. Autonómne aktualizácie zapnete nastavením updates.maintenanceWindow v settings.json.",
|
||||
"update.page.policy.maintenance-window-invalid": "Úroveň 4 (autonómna) je vypnutá, pretože updates.maintenanceWindow má nesprávny formát. Očakáva sa {start, end, tz} s časmi vo formáte HH:MM a tz „local“ alebo „utc“.",
|
||||
"update.page.last_result.verified": "Posledná aktualizácia na {{tag}} bola overená.",
|
||||
"update.page.last_result.rolled-back": "Posledná pokusná aktualizácia na {{tag}} bola vrátená späť: {{reason}}.",
|
||||
"update.page.last_result.rollback-failed": "Posledný pokus o aktualizáciu zlyhal A zlyhalo aj vrátenie späť: {{reason}}. Vyžaduje sa ručný zásah.",
|
||||
"update.page.last_result.preflight-failed": "Posledná pokusná aktualizácia na {{tag}} zlyhala pri kontrole pred spustením: {{reason}}.",
|
||||
"update.page.last_result.cancelled": "Poslednú pokusnú aktualizáciu na {{tag}} zrušil správca.",
|
||||
"update.execution.idle": "Nečinné",
|
||||
"update.execution.scheduled": "Aktualizácia naplánovaná",
|
||||
"update.execution.preflight": "Kontroly pred spustením",
|
||||
"update.execution.preflight-failed": "Kontrola pred spustením zlyhala",
|
||||
"update.execution.draining": "Ukončovanie relácií",
|
||||
"update.execution.executing": "Aktualizuje sa…",
|
||||
"update.execution.pending-verification": "Čaká sa na overenie",
|
||||
"update.execution.verified": "Overené",
|
||||
"update.execution.rolling-back": "Vracia sa späť",
|
||||
"update.execution.rolled-back": "Vrátené späť",
|
||||
"update.execution.rollback-failed": "Vrátenie späť zlyhalo",
|
||||
"update.banner.terminal.rollback-failed": "Pokus o aktualizáciu zlyhal a nepodarilo sa ho vrátiť späť. Vyžaduje sa ručný zásah.",
|
||||
"update.banner.scheduled": "Automatická aktualizácia na {{tag}} je naplánovaná — použije sa o {{remaining}}.",
|
||||
"update.banner.maintenance-window-missing": "Autonómne aktualizácie sú vypnuté, kým sa nenastaví okno údržby.",
|
||||
"update.banner.maintenance-window-invalid": "Autonómne aktualizácie sú vypnuté, pretože okno údržby má nesprávny formát.",
|
||||
"update.page.scheduled.title": "Aktualizácia naplánovaná",
|
||||
"update.page.scheduled.countdown": "Etherpad sa začne aktualizovať na {{tag}} o {{remaining}}.",
|
||||
"update.page.scheduled.deferred_until": "Mimo okna údržby. Aktualizácia sa spustí, keď sa okno otvorí o {{at}}.",
|
||||
"update.page.scheduled.apply_now": "Použiť teraz",
|
||||
"update.window.title": "Okno údržby",
|
||||
"update.window.summary": "{{start}} – {{end}} ({{tz}})",
|
||||
"update.window.unset": "Nie je nastavené.",
|
||||
"update.window.next_opens_at": "Ďalšie okno sa otvorí o {{at}}.",
|
||||
"update.drain.t60": "Etherpad sa o 60 sekúnd reštartuje, aby použil aktualizáciu.",
|
||||
"update.drain.t30": "Etherpad sa o 30 sekúnd reštartuje, aby použil aktualizáciu.",
|
||||
"update.drain.t10": "Etherpad sa o 10 sekúnd reštartuje, aby použil aktualizáciu.",
|
||||
"index.newPad": "Nový poznámkový blok",
|
||||
"index.createOpenPad": "alebo vytvoriť/otvoriť poznámkový blok s názvom:",
|
||||
"index.settings": "Nastavenia",
|
||||
"index.transferSessionTitle": "Preniesť reláciu",
|
||||
"index.receiveSessionTitle": "Prijať reláciu",
|
||||
"index.receiveSessionDescription": "Tu môžete prijať reláciu Etherpadu z iného prehliadača alebo zariadenia. Upozorňujeme však, že tým sa odstráni vaša súčasná relácia, ak nejakú máte.",
|
||||
"index.transferSession": "1. Preniesť reláciu",
|
||||
"index.transferSessionNow": "Preniesť reláciu teraz",
|
||||
"index.copyLink": "2. Kopírovať odkaz",
|
||||
"index.copyLinkDescription": "Kliknutím na tlačidlo nižšie skopírujete odkaz do schránky.",
|
||||
"index.copyLinkButton": "Kopírovať odkaz do schránky",
|
||||
"index.transferToSystem": "3. Skopírovať reláciu do nového systému",
|
||||
"index.transferToSystemDescription": "Otvorením skopírovaného odkazu v cieľovom prehliadači alebo zariadení prenesiete svoju reláciu.",
|
||||
"index.code": "Kód",
|
||||
"index.transferSessionDescription": "Kliknutím na tlačidlo nižšie prenesiete svoju súčasnú reláciu do prehliadača alebo zariadenia. Skopíruje sa odkaz na stránku, ktorá po otvorení v cieľovom prehliadači alebo zariadení prenesie vašu reláciu.",
|
||||
"index.createOpenPad": "Otvoriť pad podľa názvu",
|
||||
"index.openPad": "otvoriť poznámkový blok s názvom:",
|
||||
"index.recentPads": "Nedávne pady",
|
||||
"index.recentPadsEmpty": "Nenašli sa žiadne nedávne pady.",
|
||||
"index.generateNewPad": "Vygenerovať náhodný názov padu",
|
||||
"index.labelPad": "Názov padu (nepovinné)",
|
||||
"index.placeholderPadEnter": "Zadajte názov padu…",
|
||||
"index.createAndShareDocuments": "Vytvárajte a zdieľajte dokumenty v reálnom čase",
|
||||
"index.createAndShareDocumentsDescription": "Etherpad vám umožňuje spoločne upravovať dokumenty v reálnom čase, podobne ako živý viacpoužívateľský editor, ktorý beží vo vašom prehliadači.",
|
||||
"pad.toolbar.bold.title": "Tučné (Ctrl+B)",
|
||||
"pad.toolbar.italic.title": "Kurzíva (Ctrl+I)",
|
||||
"pad.toolbar.underline.title": "Podčiarknuté (Ctrl+U)",
|
||||
|
|
@ -62,22 +264,42 @@
|
|||
"pad.toolbar.savedRevision.title": "Uložiť revíziu",
|
||||
"pad.toolbar.settings.title": "Nastavenia",
|
||||
"pad.toolbar.embed.title": "Zdieľať alebo vložiť tento poznámkový blok",
|
||||
"pad.toolbar.home.title": "Späť na domovskú stránku",
|
||||
"pad.toolbar.showusers.title": "Zobraziť používateľov tohoto poznámkového bloku",
|
||||
"pad.colorpicker.save": "Uložiť",
|
||||
"pad.colorpicker.cancel": "Zrušiť",
|
||||
"pad.loading": "Načítava sa...",
|
||||
"pad.noCookie": "Cookie nebolo možné nájsť. Povoľte prosím cookies vo vašom prehliadači. Vaše sedenie a nastavenia sa medzi návštevami stránky neuložia. To môže byť spôsobené tým že Etherpad je zahrnutý do iFrame v niektorých prehliadačoch. Prosím uistite sa, že Etherpad sa nachádza na tej istej doméne ako hlavný iFrame",
|
||||
"pad.permissionDenied": "Ľutujeme, nemáte oprávnenie pristupovať k tomuto poznámkovému bloku",
|
||||
"pad.settings.padSettings": "Nastavenia poznámkového bloku",
|
||||
"pad.settings.title": "Nastavenia",
|
||||
"pad.settings.padSettings": "Nastavenia pre celý pad",
|
||||
"pad.settings.userSettings": "Používateľské nastavenia",
|
||||
"pad.settings.myView": "Vlastný pohľad",
|
||||
"pad.settings.disablechat": "Vypnúť chat",
|
||||
"pad.settings.darkMode": "Tmavý režim",
|
||||
"pad.settings.stickychat": "Rozhovor stále na obrazovke",
|
||||
"pad.settings.chatandusers": "Zobraziť rozhovor a používateľov",
|
||||
"pad.settings.colorcheck": "Farby autorov",
|
||||
"pad.settings.fadeInactiveAuthorColors": "Stlmiť farby neaktívnych autorov",
|
||||
"pad.settings.linenocheck": "Čísla riadkov",
|
||||
"pad.settings.rtlcheck": "Čítať obsah sprava doľava?",
|
||||
"pad.settings.enforceSettings": "Vynútiť nastavenia pre ostatných používateľov",
|
||||
"pad.settings.enforcedNotice": "Tieto nastavenia vám uzamkol tvorca tohto padu. Ak ich potrebujete zmeniť, požiadajte tvorcu padu.",
|
||||
"pad.settings.fontType": "Typ písma:",
|
||||
"pad.settings.fontType.normal": "Normálne",
|
||||
"pad.settings.language": "Jazyk:",
|
||||
"pad.settings.deletePad": "Zmazať pad",
|
||||
"pad.delete.confirm": "Naozaj chcete zmazať tento pad?",
|
||||
"pad.deletionToken.modalTitle": "Uložte si token na zmazanie padu",
|
||||
"pad.deletionToken.modalBody": "Tento token je jediný spôsob, ako zmazať tento pad, ak stratíte reláciu prehliadača alebo zmeníte zariadenie. Uložte si ho na bezpečné miesto — zobrazí sa tu iba raz.",
|
||||
"pad.deletionToken.copy": "Kopírovať",
|
||||
"pad.deletionToken.copied": "Skopírované",
|
||||
"pad.deletionToken.acknowledge": "Uložil som si ho",
|
||||
"pad.deletionToken.deleteWithToken": "Zmazať pad pomocou tokenu",
|
||||
"pad.deletionToken.tokenFieldLabel": "Token na zmazanie padu",
|
||||
"pad.deletionToken.tokenValueLabel": "Váš token na zmazanie padu (iba na čítanie)",
|
||||
"pad.deletionToken.invalid": "Tento token nie je platný pre tento pad.",
|
||||
"pad.deletionToken.notCreator": "Nie ste tvorcom tohto padu, takže ho nemôžete zmazať.",
|
||||
"pad.settings.about": "O Etherpade",
|
||||
"pad.settings.poweredBy": "Poháňané cez",
|
||||
"pad.importExport.import_export": "Import/Export",
|
||||
|
|
@ -90,6 +312,13 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.exportetherpada.title": "Exportovať ako Etherpad",
|
||||
"pad.importExport.exporthtmla.title": "Exportovať ako HTML",
|
||||
"pad.importExport.exportplaina.title": "Exportovať ako čistý text",
|
||||
"pad.importExport.exportworda.title": "Exportovať ako Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Exportovať ako PDF",
|
||||
"pad.importExport.exportopena.title": "Exportovať ako ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "Importovať môžete iba z formátov čistého textu alebo HTML. Pre pokročilejšie funkcie importu si <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">nainštalujte LibreOffice</a>.",
|
||||
"pad.modals.connected": "Pripojené.",
|
||||
"pad.modals.reconnecting": "Opätovné pripájanie k vášmu poznámkovému bloku...",
|
||||
"pad.modals.forcereconnect": "Vynútiť znovupripojenie",
|
||||
|
|
@ -120,6 +349,8 @@
|
|||
"pad.modals.disconnected": "Boli ste odpojení.",
|
||||
"pad.modals.disconnected.explanation": "Spojenie so serverom sa prerušilo",
|
||||
"pad.modals.disconnected.cause": "Server môže byť nedostupný. Ak by problém pretrvával, informujte správcu služby.",
|
||||
"pad.gritter.unacceptedCommit.title": "Neuložená úprava",
|
||||
"pad.gritter.unacceptedCommit.text": "Vaša nedávna úprava stále nie je uložená. Znova sa pripojte a skúste to ešte raz.",
|
||||
"pad.share": "Zdieľať tento poznámkový blok",
|
||||
"pad.share.readonly": "Len na čítanie",
|
||||
"pad.share.link": "Odkaz",
|
||||
|
|
@ -132,12 +363,36 @@
|
|||
"timeslider.followContents": "Sledovať aktualizácie obsahu poznámkového bloku",
|
||||
"timeslider.pageTitle": "Časová os {{appTitle}}",
|
||||
"timeslider.toolbar.returnbutton": "Späť do poznámkového bloku",
|
||||
"pad.historyMode.banner": "Prezeranie histórie",
|
||||
"pad.historyMode.return": "Návrat k živej verzii",
|
||||
"pad.historyMode.revisionLabel": "Revízia {{rev}}",
|
||||
"pad.historyMode.controlsLabel": "Ovládanie histórie padu",
|
||||
"pad.historyMode.sliderLabel": "Revízia padu",
|
||||
"pad.historyMode.settings.title": "Prehrávanie histórie",
|
||||
"pad.historyMode.settings.follow": "Sledovať zmeny obsahu padu",
|
||||
"pad.historyMode.settings.followShort": "Sledovať",
|
||||
"pad.historyMode.followOn": "Sledujú sa zmeny padu — kliknutím sledovanie ukončíte",
|
||||
"pad.historyMode.followOff": "Zmeny padu sa nesledujú — kliknutím začnete sledovať",
|
||||
"pad.historyMode.settings.playbackSpeed": "Rýchlosť prehrávania:",
|
||||
"pad.historyMode.chat.replayHeader": "Chat k {{time}}",
|
||||
"pad.historyMode.users.authorsHeader": "Autori v tejto revízii",
|
||||
"pad.editor.skipToContent": "Preskočiť na editor",
|
||||
"pad.editor.keyboardHint": "Stlačením Escape opustíte editor. Stlačením Alt+F9 sa dostanete na panel nástrojov.",
|
||||
"pad.editor.toolbar.formatting": "Panel nástrojov formátovania",
|
||||
"pad.editor.toolbar.actions": "Panel akcií padu",
|
||||
"pad.editor.toolbar.showMore": "Zobraziť viac tlačidiel panela",
|
||||
"timeslider.toolbar.authors": "Autori:",
|
||||
"timeslider.toolbar.authorsList": "Bez autorov",
|
||||
"timeslider.toolbar.exportlink.title": "Export",
|
||||
"timeslider.exportCurrent": "Exportovať aktuálnu verziu ako:",
|
||||
"timeslider.version": "Verzia {{version}}",
|
||||
"timeslider.saved": "Uložené {{day}}. {{month}} {{year}}",
|
||||
"timeslider.settings.playbackSpeed": "Rýchlosť prehrávania:",
|
||||
"timeslider.settings.playbackSpeed.original": "Pôvodná rýchlosť",
|
||||
"timeslider.settings.playbackSpeed.realtime": "Reálny čas",
|
||||
"timeslider.settings.playbackSpeed.200ms": "200 ms",
|
||||
"timeslider.settings.playbackSpeed.500ms": "500 ms",
|
||||
"timeslider.settings.playbackSpeed.1000ms": "1000 ms",
|
||||
"timeslider.playPause": "Pustiť / Pozastaviť obsah poznámkového bloku",
|
||||
"timeslider.backRevision": "Ísť v tomto poznámkovom bloku o jednu revíziu späť",
|
||||
"timeslider.forwardRevision": "Ísť v tomto poznámkovom bloku o jednu revíziu vpred",
|
||||
|
|
@ -159,6 +414,7 @@
|
|||
"pad.savedrevs.timeslider": "Návštevou časovej osi môžete zobraziť uložené revízie",
|
||||
"pad.userlist.entername": "Zadajte svoje meno",
|
||||
"pad.userlist.unnamed": "nemenovaný",
|
||||
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} pripojený používateľ, few: {{count}} pripojení používatelia, many: {{count}} pripojených používateľov, other: {{count}} pripojených používateľov ]}",
|
||||
"pad.editbar.clearcolors": "Odstrániť farby autorov z celého dokumentu? Táto akcia sa nedá vrátiť",
|
||||
"pad.impexp.importbutton": "Importovať teraz",
|
||||
"pad.impexp.importing": "Prebieha import...",
|
||||
|
|
@ -169,5 +425,6 @@
|
|||
"pad.impexp.importfailed": "Import zlyhal",
|
||||
"pad.impexp.copypaste": "Vložte prosím kópiu cez schránku",
|
||||
"pad.impexp.exportdisabled": "Export do formátu {{type}} nie je povolený. Kontaktujte prosím administrátora pre zistenie detailov.",
|
||||
"pad.impexp.maxFileSize": "Súbor je príliš veľký. Kontaktujte správcu pre zväčšenie povolenej veľkosti súborov pre import"
|
||||
"pad.impexp.maxFileSize": "Súbor je príliš veľký. Kontaktujte správcu pre zväčšenie povolenej veľkosti súborov pre import",
|
||||
"pad.social.description": "Kolaboratívny dokument, ktorý môže každý upravovať v reálnom čase."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"Stang",
|
||||
"TFX202X",
|
||||
"VulpesVulpes825",
|
||||
"XtexChooser",
|
||||
"Yfdyh000",
|
||||
"乌拉跨氪",
|
||||
"列维劳德",
|
||||
|
|
@ -26,6 +27,30 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "管理员面板 - Etherpad",
|
||||
"admin.loading": "正在加载…",
|
||||
"admin.loading_description": "页面加载中,请稍安勿躁。",
|
||||
"admin.toggle_sidebar": "开关侧边栏",
|
||||
"admin.shout": "通讯",
|
||||
"admin_shout.online_one": "目前有{{count}}位用户在线",
|
||||
"admin_shout.online_other": "目前有{{count}}位用户在线",
|
||||
"admin_shout.sticky_toggle": "更改置顶消息",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "用户名",
|
||||
"admin_login.password": "密码",
|
||||
"admin_login.submit": "登录",
|
||||
"admin_login.failed": "登录失败",
|
||||
"admin_pads.all_pads": "所有记事本",
|
||||
"admin_pads.bulk.cleanup_history": "清理历史",
|
||||
"admin_pads.bulk.clear_selection": "清除选择",
|
||||
"admin_pads.bulk.delete": "删除",
|
||||
"admin_pads.cancel": "取消",
|
||||
"admin_pads.col.users": "用户",
|
||||
"admin_pads.confirm_button": "确定",
|
||||
"admin_pads.error_prefix": "错误",
|
||||
"admin_pads.pagination.next": "下一页",
|
||||
"admin_pads.pagination.previous": "上一页",
|
||||
"admin_pads.refresh": "刷新",
|
||||
"admin_pads.relative.just_now": "刚刚",
|
||||
"admin_plugins": "插件管理器",
|
||||
"admin_plugins.available": "可用插件",
|
||||
"admin_plugins.available_not-found": "找不到插件。",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "管理員面板 - Etherpad",
|
||||
"admin.loading": "載入中…",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "使用者名稱",
|
||||
"admin_login.password": "密碼",
|
||||
"admin_login.submit": "登入",
|
||||
"admin_login.failed": "登入失敗",
|
||||
"admin_pads.bulk.delete": "刪除",
|
||||
"admin_pads.cancel": "取消",
|
||||
"admin_pads.col.users": "使用者",
|
||||
"admin_pads.error_prefix": "錯誤",
|
||||
"admin_plugins": "外掛程式管理器",
|
||||
"admin_plugins.available": "可用套件",
|
||||
"admin_plugins.available_not-found": "沒有找到套件。",
|
||||
|
|
@ -29,6 +39,9 @@
|
|||
"admin_plugins.last-update": "最後更新",
|
||||
"admin_plugins.name": "名稱",
|
||||
"admin_plugins.page-title": "套件管理 - Etherpad",
|
||||
"admin_plugins.update_tooltip": "更新",
|
||||
"admin_plugins.updates_available": "有可用更新",
|
||||
"admin_plugins.update_now": "更新",
|
||||
"admin_plugins.version": "版本",
|
||||
"admin_plugins_info": "問題排除資訊",
|
||||
"admin_plugins_info.hooks": "已安裝的掛勾",
|
||||
|
|
@ -37,6 +50,8 @@
|
|||
"admin_plugins_info.parts": "已安裝部分",
|
||||
"admin_plugins_info.plugins": "已安裝的套件",
|
||||
"admin_plugins_info.page-title": "套件資訊 - Etherpad",
|
||||
"admin_plugins_info.tab_client": "客戶端",
|
||||
"admin_plugins_info.tab_server": "伺服器",
|
||||
"admin_plugins_info.version": "Etherpad 版本",
|
||||
"admin_plugins_info.version_latest": "最新可用版本",
|
||||
"admin_plugins_info.version_number": "版本號",
|
||||
|
|
@ -46,6 +61,9 @@
|
|||
"admin_settings.current_example-prod": "生產設定模板範例",
|
||||
"admin_settings.current_restart.value": "重新啟動 Etherpad",
|
||||
"admin_settings.current_save.value": "儲存設定",
|
||||
"admin_settings.invalid_json": "無效 JSON",
|
||||
"admin_settings.current_test.value": "驗證 JSON",
|
||||
"admin_settings.current_prettify.value": "美化 JSON 格式",
|
||||
"admin_settings.page-title": "設定 - Etherpad",
|
||||
"index.newPad": "新記事本",
|
||||
"index.settings": "設定",
|
||||
|
|
@ -116,6 +134,11 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF(開放文件格式)",
|
||||
"pad.importExport.exporthtmla.title": "匯出成 HTML",
|
||||
"pad.importExport.exportplaina.title": "匯出成純文字",
|
||||
"pad.importExport.exportworda.title": "匯出成 Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "匯出成 PDF",
|
||||
"pad.importExport.exportopena.title": "匯出成 ODF(開放文件格式)",
|
||||
"pad.modals.connected": "已連線。",
|
||||
"pad.modals.reconnecting": "重新連線到您的記事本…",
|
||||
"pad.modals.forcereconnect": "強制重新連線",
|
||||
|
|
|
|||
|
|
@ -551,10 +551,11 @@ exports.createPad = async (padID: string, text: string, authorId = '') => {
|
|||
|
||||
// create pad
|
||||
await getPadSafe(padID, false, text, authorId);
|
||||
// When requireAuthentication is on, every creator has a stable identity, so
|
||||
// the cookie/identity path covers recovery and the one-time token is just
|
||||
// an extra surface to leak.
|
||||
const deletionToken = settings.requireAuthentication
|
||||
// No recovery token when it cannot help: requireAuthentication gives every
|
||||
// creator a stable identity, and allowPadDeletionByAllUsers lets anyone delete
|
||||
// the pad with no token at all (issue #7926). Either way the token is just an
|
||||
// extra surface to leak.
|
||||
const deletionToken = settings.requireAuthentication || settings.allowPadDeletionByAllUsers
|
||||
? null
|
||||
: await padDeletionManager.createDeletionTokenIfAbsent(padID);
|
||||
return {deletionToken};
|
||||
|
|
|
|||
|
|
@ -868,6 +868,8 @@ class Pad {
|
|||
await this.saveToDatabase();
|
||||
}
|
||||
|
||||
// Returns the newly created saved revision, or undefined if this revision
|
||||
// was already saved (so callers can broadcast only genuine additions).
|
||||
async addSavedRevision(revNum: string, savedById: string, label: string) {
|
||||
// if this revision is already saved, return silently
|
||||
for (const i in this.savedRevisions) {
|
||||
|
|
@ -887,6 +889,7 @@ class Pad {
|
|||
// save this new saved revision
|
||||
this.savedRevisions.push(savedRevision);
|
||||
await this.saveToDatabase();
|
||||
return savedRevision;
|
||||
}
|
||||
|
||||
getSavedRevisions() {
|
||||
|
|
|
|||
|
|
@ -192,7 +192,15 @@ exports.sanitizePadId = async (padId: string) => {
|
|||
return padId;
|
||||
};
|
||||
|
||||
exports.isValidPadId = (padId: string) => /^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId);
|
||||
// Pad IDs consisting only of URL "dot-segments" ('.' or '..') are unreachable:
|
||||
// per the WHATWG URL standard a browser normalises "/p/." to "/p/" and "/p/.."
|
||||
// to "/", so the pad can never be opened or exported — the request arrives as
|
||||
// "/p/" and Etherpad answers "Cannot GET /p/". Reject them so such (broken) pads
|
||||
// can never be created.
|
||||
const dotSegmentPadId = /^\.{1,2}$/;
|
||||
|
||||
exports.isValidPadId = (padId: string) =>
|
||||
/^(g.[a-zA-Z0-9]{16}\$)?[^$]{1,50}$/.test(padId) && !dotSegmentPadId.test(padId);
|
||||
|
||||
/**
|
||||
* Removes the pad from database and unloads it.
|
||||
|
|
|
|||
|
|
@ -305,8 +305,13 @@ const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage)
|
|||
// back to the creator-cookie path, otherwise a creator pasting a wrong
|
||||
// recovery token into the disclosure field would still succeed — masking a
|
||||
// typo and contradicting the UI.
|
||||
const creatorOk = !tokenSupplied && isCreator;
|
||||
const flagOk = !tokenSupplied && !isCreator && settings.allowPadDeletionByAllUsers;
|
||||
// Readonly sessions can never delete via the token-less paths: they cannot
|
||||
// edit the pad, so they must not be able to destroy it just because
|
||||
// allowPadDeletionByAllUsers is on (issue #7959). A valid recovery token
|
||||
// (tokenOk) remains a sufficient credential regardless of session mode.
|
||||
const writable = !session.readonly;
|
||||
const creatorOk = !tokenSupplied && isCreator && writable;
|
||||
const flagOk = !tokenSupplied && !isCreator && settings.allowPadDeletionByAllUsers && writable;
|
||||
|
||||
if (creatorOk || tokenOk || flagOk) {
|
||||
await retrievedPad.remove();
|
||||
|
|
@ -622,7 +627,18 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
|
|||
const handleSaveRevisionMessage = async (socket:any, message: ClientSaveRevisionMessage) => {
|
||||
const {padId, author: authorId} = sessioninfos[socket.id];
|
||||
const pad = await padManager.getPad(padId, null, authorId);
|
||||
await pad.addSavedRevision(pad.head, authorId);
|
||||
const savedRevision = await pad.addSavedRevision(pad.head, authorId);
|
||||
// Notify every client in the pad room — including any open timeslider —
|
||||
// so saved-revision markers appear live instead of only on the next
|
||||
// timeslider load (#7946). The client's NEW_SAVEDREV handler existed but
|
||||
// was never reached because this broadcast was missing; live editors that
|
||||
// don't handle the type ignore it. Skip the emit for duplicate saves.
|
||||
if (savedRevision) {
|
||||
socketio.sockets.in(padId).emit('message', {
|
||||
type: 'COLLABROOM',
|
||||
data: {type: 'NEW_SAVEDREV', savedRev: savedRevision},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -1287,9 +1303,41 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
|
|||
// once. Readonly sessions never see it.
|
||||
const isCreator =
|
||||
!sessionInfo.readonly && sessionInfo.author === await pad.getRevisionAuthor(0);
|
||||
// Skip token issuance when requireAuthentication is on: every creator has a
|
||||
// stable identity so the cookie/identity path is sufficient.
|
||||
const padDeletionToken = isCreator && !settings.requireAuthentication
|
||||
// The deletion token is a recovery handle for the one class of creator that
|
||||
// can otherwise lose the ability to delete their pad: a user whose creator
|
||||
// status lives only in a per-browser author-token cookie. It is pointless —
|
||||
// and the "Save your pad deletion token" modal only overwhelms users who
|
||||
// will never need it (issue #7926) — when either of these holds:
|
||||
//
|
||||
// - allowPadDeletionByAllUsers: anyone can delete the pad with no token at
|
||||
// all (see handlePadDelete's flagOk branch).
|
||||
// - the creator has a *durable* identity: authenticated (req.session.user
|
||||
// with a username) AND the deployment maps that identity to a stable
|
||||
// authorID via a getAuthorId hook. Only then does `isCreator`
|
||||
// (author === revision-0 author) survive a cookie clear or a different
|
||||
// device, so the creator path replaces the token on any device.
|
||||
//
|
||||
// Note we deliberately do NOT treat requireAuthentication alone as durable:
|
||||
// without a getAuthorId hook the authorID still comes from the per-browser
|
||||
// token cookie (AuthorManager.getAuthorId -> getAuthor4Token), so an
|
||||
// authenticated user on a second device is NOT the creator and would be
|
||||
// stranded if we also withheld the token. The getAuthorId hook is the
|
||||
// documented way (doc/api/hooks_server-side) to pin authorID to username.
|
||||
const hasGetAuthorIdHook = (plugins.hooks.getAuthorId || []).length > 0;
|
||||
const hasDurableIdentity = hasGetAuthorIdHook && !!(user && user.username);
|
||||
const canDeleteWithoutToken = settings.allowPadDeletionByAllUsers || hasDurableIdentity;
|
||||
// Whether this session may delete the pad with no token at all: the creator
|
||||
// on this device (creator-cookie still present), or any user when the
|
||||
// instance opted everyone in. Drives the plain "Delete pad" button, which is
|
||||
// independent of enablePadWideSettings (issue #7959) — deletion is not a
|
||||
// pad-wide setting and must stay reachable when that section is disabled.
|
||||
// Readonly viewers are excluded: they cannot edit, let alone delete, so
|
||||
// allowPadDeletionByAllUsers must not hand them a delete button (the server
|
||||
// enforces the same in handlePadDelete).
|
||||
const canDeletePad =
|
||||
!sessionInfo.readonly && (isCreator || settings.allowPadDeletionByAllUsers);
|
||||
const padDeletionToken =
|
||||
isCreator && !canDeleteWithoutToken
|
||||
? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId)
|
||||
: null;
|
||||
|
||||
|
|
@ -1308,6 +1356,12 @@ const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
|
|||
enablePadWideSettings: settings.enablePadWideSettings,
|
||||
enablePluginPadOptions: settings.enablePluginPadOptions,
|
||||
padDeletionToken,
|
||||
// Drives the deletion-button label/visibility in pad settings: when the
|
||||
// user can already delete without a token the recovery-token disclosure is
|
||||
// redundant, so the client labels the action "Delete Pad" instead of
|
||||
// "Delete with token" (issue #7926). See showDeletionTokenModalIfPresent.
|
||||
canDeleteWithoutToken,
|
||||
canDeletePad,
|
||||
// Allow-listed copy — settings.privacyBanner could carry extra nested
|
||||
// keys from a hand-edited settings.json; sending those by reference
|
||||
// would leak them to every browser. See getPublicPrivacyBanner().
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import settings, {getEpVersion, getGitCommit, reloadSettings} from '../../utils/
|
|||
import {getLatestVersion} from '../../utils/UpdateCheck';
|
||||
import {redactSettings} from '../../utils/AdminSettingsRedact';
|
||||
const padManager = require('../../db/PadManager');
|
||||
const db = require('../../db/DB');
|
||||
const api = require('../../db/API');
|
||||
import {deleteRevisions} from '../../utils/Cleanup';
|
||||
|
||||
|
|
@ -26,6 +27,24 @@ const queryPadLimit = 12;
|
|||
const PAD_HYDRATE_CONCURRENCY = 16;
|
||||
const logger = log4js.getLogger('adminSettings');
|
||||
|
||||
// Errors thrown while reading a pad record can embed the raw stored value
|
||||
// in their message — e.g. Pad.init's `'pool' in value` TypeError stringifies
|
||||
// the offending value ("Cannot use 'in' operator to search for 'pool' in
|
||||
// <value>"). For a corrupt record that value may be actual pad text, so
|
||||
// logging it verbatim would leak content, bloat the log, and let embedded
|
||||
// newlines forge log lines. Reduce any error to its name plus a single-line,
|
||||
// length-capped message before logging.
|
||||
const safeErr = (err: unknown): string => {
|
||||
const e = err as {name?: unknown, message?: unknown} | null;
|
||||
const name = (e && typeof e.name === 'string' && e.name) || 'Error';
|
||||
const msg = String((e && e.message) ?? err ?? '')
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/\s{2,}/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120);
|
||||
return `${name}: ${msg}`;
|
||||
};
|
||||
|
||||
// Concurrency-limited Promise.all replacement. Preserves the input
|
||||
// order in the returned array (caller slices later). Used by padLoad
|
||||
// to bound DB reads during hydration.
|
||||
|
|
@ -147,6 +166,7 @@ exports.socketio = (hookName: string, {io}: any) => {
|
|||
|
||||
|
||||
socket.on('padLoad', async (query: PadSearchQuery) => {
|
||||
try {
|
||||
const {padIDs} = await padManager.listAllPads();
|
||||
|
||||
// ── 1. Pattern filter (cheap, by name only) ─────────────────────
|
||||
|
|
@ -172,13 +192,27 @@ exports.socketio = (hookName: string, {io}: any) => {
|
|||
const needsFullScan = filter !== 'all' || query.sortBy !== 'padName';
|
||||
|
||||
const loadMeta = async (padName: string): Promise<PadQueryResult> => {
|
||||
const pad = await padManager.getPad(padName);
|
||||
return {
|
||||
padName,
|
||||
lastEdited: await pad.getLastEdit(),
|
||||
userCount: api.padUsersCount(padName).padUsersCount,
|
||||
revisionNumber: pad.getHeadRevisionNumber(),
|
||||
};
|
||||
// A single unreadable record must not take out the whole listing.
|
||||
// `findKeys('pad:*', '*:*:*')` returns every key under the `pad:`
|
||||
// prefix, including legacy/foreign or migration-corrupted records
|
||||
// (e.g. a value stored as a JSON *string* rather than a pad object,
|
||||
// which makes Pad.init throw `'pool' in value`). Before this guard
|
||||
// one such key rejected the whole `padLoad` handler — the admin
|
||||
// "Manage pads" page then showed *no* pads at all (issue #7935) and
|
||||
// the unhandled rejection could exit the server. Surfacing the bad
|
||||
// pad with zeroed metadata lets an admin see and delete it instead.
|
||||
try {
|
||||
const pad = await padManager.getPad(padName);
|
||||
return {
|
||||
padName,
|
||||
lastEdited: await pad.getLastEdit(),
|
||||
userCount: api.padUsersCount(padName).padUsersCount,
|
||||
revisionNumber: pad.getHeadRevisionNumber(),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn(`padLoad: skipping unreadable pad "${padName}": ${safeErr(err)}`);
|
||||
return {padName, lastEdited: 0 as any, userCount: 0, revisionNumber: 0};
|
||||
}
|
||||
};
|
||||
|
||||
// Lazily lifted so we don't load every pad twice on the fast path.
|
||||
|
|
@ -256,16 +290,60 @@ exports.socketio = (hookName: string, {io}: any) => {
|
|||
|
||||
const data: {total: number, results?: PadQueryResult[]} = {total, results};
|
||||
socket.emit('results:padLoad', data);
|
||||
} catch (err) {
|
||||
// Never leave the SPA hanging on a missing reply (it would show an
|
||||
// empty "No results" state forever) and never let this bubble up to
|
||||
// the process-level unhandledRejection handler, which would exit the
|
||||
// whole server. Always emit a terminal reply for the request.
|
||||
logger.error(`padLoad failed: ${safeErr(err)}`);
|
||||
socket.emit('results:padLoad',
|
||||
{total: 0, results: [], error: safeErr(err)});
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
socket.on('deletePad', async (padId: string) => {
|
||||
const padExists = await padManager.doesPadExists(padId);
|
||||
if (padExists) {
|
||||
logger.info(`Deleting pad: ${padId}`);
|
||||
const pad = await padManager.getPad(padId);
|
||||
await pad.remove();
|
||||
try {
|
||||
if (await padManager.doesPadExists(padId)) {
|
||||
try {
|
||||
// Healthy pad — full relational cleanup (revs, chat, readonly,
|
||||
// authors, deletion token, hooks).
|
||||
logger.info(`Deleting pad: ${padId}`);
|
||||
const pad = await padManager.getPad(padId);
|
||||
await pad.remove();
|
||||
socket.emit('results:deletePad', padId);
|
||||
return;
|
||||
} catch (err) {
|
||||
// getPad() runs isValidPadId() and rejects ids that are no longer
|
||||
// valid — e.g. legacy '.'/'..' pads created before that validation
|
||||
// was tightened. Don't give up: fall through to the raw key purge
|
||||
// below so the orphan can still be deleted from the admin UI.
|
||||
logger.warn(`Relational cleanup failed for "${padId}" ` +
|
||||
`(${safeErr(err)}); falling back to raw key purge`);
|
||||
}
|
||||
}
|
||||
|
||||
// doesPadExists() is false either because nothing is stored under
|
||||
// this id, or because the record is unreadable (a non-object value
|
||||
// leaves `value.atext` undefined). The latter is exactly what
|
||||
// padLoad now surfaces with zeroed metadata — getPad()/Pad.remove()
|
||||
// would throw on it, so fall back to a raw key purge. Without this
|
||||
// the surfaced corrupt pad is undeletable from the admin UI.
|
||||
const raw = await db.get(`pad:${padId}`);
|
||||
if (raw != null) {
|
||||
logger.info(`Deleting unreadable pad record via raw key purge: ${padId}`);
|
||||
// Best-effort sweep of sub-keys (revs/chat/deletionToken/…) and
|
||||
// the readonly mapping, then the main key + pad-list/cache entry.
|
||||
const subKeys: string[] = (await db.findKeys(`pad:${padId}:*`, null)) || [];
|
||||
await Promise.all(subKeys.map((k) => db.remove(k)));
|
||||
await db.remove(`pad2readonly:${padId}`);
|
||||
await padManager.removePad(padId);
|
||||
}
|
||||
// Always emit a terminal reply (even for an already-absent id) so the
|
||||
// UI clears the row instead of silently doing nothing.
|
||||
socket.emit('results:deletePad', padId);
|
||||
} catch (err) {
|
||||
logger.error(`deletePad failed for "${padId}": ${safeErr(err)}`);
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
import log4js from 'log4js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import _ from 'underscore';
|
||||
|
||||
|
|
@ -30,6 +31,25 @@ const absPathLogger = log4js.getLogger('AbsolutePaths');
|
|||
*/
|
||||
let etherpadRoot: string|null = null;
|
||||
|
||||
/**
|
||||
* Walks up the directory tree from `start`, returning the closest ancestor
|
||||
* directory (including `start` itself) that contains a package.json. Replaces
|
||||
* the unmaintained `find-root` package, mirroring its semantics: it throws if
|
||||
* no package.json is found before reaching the filesystem root.
|
||||
*
|
||||
* @param {string} start - The directory to start searching from.
|
||||
* @return {string} The closest ancestor directory containing a package.json.
|
||||
*/
|
||||
const findRoot = (start: string): string => {
|
||||
let dir = start;
|
||||
for (;;) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) throw new Error('package.json not found in path');
|
||||
dir = parent;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* If stringArray's last elements are exactly equal to lastDesiredElements,
|
||||
* returns a copy in which those last elements are popped, or false otherwise.
|
||||
|
|
@ -79,7 +99,6 @@ export const findEtherpadRoot = () => {
|
|||
return etherpadRoot;
|
||||
}
|
||||
|
||||
const findRoot = require('find-root');
|
||||
const foundRoot = findRoot(__dirname);
|
||||
const splitFoundRoot = foundRoot.split(path.sep);
|
||||
|
||||
|
|
|
|||
|
|
@ -39,11 +39,9 @@ const ROOT_DIR = path.join(settings.root, 'src/static/');
|
|||
const LIBRARY_WHITELIST = [
|
||||
'async',
|
||||
'js-cookie',
|
||||
'security',
|
||||
'split-grid',
|
||||
'tinycon',
|
||||
'underscore',
|
||||
'unorm',
|
||||
];
|
||||
|
||||
// What follows is a terrible hack to avoid loop-back within the server.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import fs from 'node:fs';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {argv} from './Cli'
|
||||
import jsonminify from 'jsonminify';
|
||||
import {parse as parseJsonc, printParseErrorCode, ParseError} from 'jsonc-parser';
|
||||
import log4js from 'log4js';
|
||||
import {createHash} from 'node:crypto';
|
||||
import randomString from './randomstring';
|
||||
|
|
@ -116,9 +116,18 @@ const parseSettings = (settingsFilename: string, isSettings: boolean) => {
|
|||
}
|
||||
|
||||
try {
|
||||
settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}');
|
||||
// jsonc-parser tolerates comments and trailing commas, so settings files
|
||||
// can stay annotated. Unlike the old jsonminify + naive ',]'/',}' string
|
||||
// replace, it fixes *every* trailing comma (not just the first of each
|
||||
// kind) and never mangles those sequences when they appear inside strings.
|
||||
const errors: ParseError[] = [];
|
||||
const settings = parseJsonc(settingsStr, errors, {allowTrailingComma: true});
|
||||
|
||||
const settings = JSON.parse(settingsStr);
|
||||
if (errors.length > 0) {
|
||||
const {error, offset} = errors[0];
|
||||
throw new Error(`${printParseErrorCode(error)} at offset ${offset}`);
|
||||
}
|
||||
if (settings === undefined) throw new Error('file is empty or not valid JSON');
|
||||
|
||||
logger.info(`${settingsType} loaded from: ${settingsFilename}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -67,7 +67,6 @@
|
|||
, "skiplist.js"
|
||||
, "colorutils.js"
|
||||
, "undomodule.js"
|
||||
, "$unorm/lib/unorm.js"
|
||||
, "contentcollector.js"
|
||||
, "changesettracker.js"
|
||||
, "linestylefilter.js"
|
||||
|
|
|
|||
|
|
@ -38,11 +38,10 @@
|
|||
"cross-spawn": "^7.0.6",
|
||||
"dirty-ts": "^1.1.8",
|
||||
"ejs": "^6.0.1",
|
||||
"esbuild": "^0.28.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.5.1",
|
||||
"express-session": "^1.19.0",
|
||||
"find-root": "1.1.0",
|
||||
"formidable": "^3.5.4",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
|
|
@ -50,47 +49,45 @@
|
|||
"jose": "^6.2.3",
|
||||
"js-cookie": "^3.0.8",
|
||||
"jsdom": "^29.1.1",
|
||||
"jsonminify": "0.4.2",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"languages4translatewiki": "0.1.3",
|
||||
"live-plugin-manager": "^1.1.0",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"log4js": "^6.9.1",
|
||||
"lru-cache": "^11.5.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"measured-core": "^2.0.0",
|
||||
"mime-types": "^3.0.2",
|
||||
"mongodb": "^7.1.1",
|
||||
"mssql": "^12.5.5",
|
||||
"mysql2": "^3.22.5",
|
||||
"nano": "^11.0.5",
|
||||
"nodemailer": "^8.0.10",
|
||||
"oidc-provider": "9.8.4",
|
||||
"openapi-backend": "^5.17.0",
|
||||
"pdfkit": "^0.19.0",
|
||||
"pg": "^8.21.0",
|
||||
"mongodb": "^7.4.0",
|
||||
"mssql": "^12.6.0",
|
||||
"mysql2": "^3.22.6",
|
||||
"nano": "^11.0.6",
|
||||
"nodemailer": "^9.0.3",
|
||||
"oidc-provider": "9.9.1",
|
||||
"openapi-backend": "^5.18.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.22.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"rate-limiter-flexible": "^11.1.1",
|
||||
"redis": "^6.0.0",
|
||||
"rate-limiter-flexible": "^11.2.0",
|
||||
"redis": "^6.1.0",
|
||||
"rehype": "^13.0.2",
|
||||
"rehype-minify-whitespace": "^6.0.2",
|
||||
"resolve": "1.22.12",
|
||||
"rethinkdb": "^2.4.2",
|
||||
"rusty-store-kv": "^1.3.1",
|
||||
"security": "1.0.0",
|
||||
"semver": "^7.8.2",
|
||||
"semver": "^7.8.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"superagent": "10.3.0",
|
||||
"surrealdb": "^2.0.3",
|
||||
"surrealdb": "^2.0.4",
|
||||
"tinycon": "0.6.8",
|
||||
"tsx": "4.22.4",
|
||||
"ueberdb2": "^6.1.8",
|
||||
"tsx": "4.23.0",
|
||||
"ueberdb2": "6.1.16",
|
||||
"underscore": "1.13.8",
|
||||
"undici": "^8.4.1",
|
||||
"unorm": "1.6.0",
|
||||
"undici": "^8.7.0",
|
||||
"wtfnode": "^0.10.1"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -98,7 +95,7 @@
|
|||
"etherpad-lite": "node/server.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/async": "^3.2.25",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
|
|
@ -110,33 +107,32 @@
|
|||
"@types/jquery": "^4.0.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/jsonminify": "^0.4.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/mocha": "^10.0.9",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/oidc-provider": "^9.5.0",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/sinon": "^21.0.1",
|
||||
"@types/sinon": "^22.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/underscore": "^1.13.0",
|
||||
"@types/whatwg-mimetype": "^5.0.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-config-etherpad": "^4.0.5",
|
||||
"etherpad-cli-client": "^4.0.3",
|
||||
"mocha": "^11.7.6",
|
||||
"mocha-froth": "^0.2.10",
|
||||
"nodeify": "^1.0.1",
|
||||
"openapi-schema-validation": "^0.4.2",
|
||||
"set-cookie-parser": "^3.1.0",
|
||||
"set-cookie-parser": "^3.1.1",
|
||||
"sinon": "^22.0.0",
|
||||
"split-grid": "^1.0.11",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
|
|
@ -154,6 +150,7 @@
|
|||
"test-container": "mocha --import=tsx --timeout 30000 --extension ts,js tests/container/specs/api",
|
||||
"dev": "cross-env NODE_ENV=development node --require tsx/cjs node/server.ts",
|
||||
"prod": "cross-env NODE_ENV=production node --require tsx/cjs node/server.ts",
|
||||
"vectors:gen": "node --require tsx/cjs tests/backend/specs/downstream/generate-vectors.ts",
|
||||
"ts-check": "tsc --noEmit",
|
||||
"ts-check:watch": "tsc --noEmit --watch",
|
||||
"test-ui": "cross-env NODE_ENV=production npx playwright test",
|
||||
|
|
@ -163,6 +160,6 @@
|
|||
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts",
|
||||
"test:vitest": "vitest"
|
||||
},
|
||||
"version": "3.3.0",
|
||||
"version": "3.3.2",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,12 +214,52 @@ body.history-mode #history-controls { display: flex; }
|
|||
.history-controls button.buttonicon.buttonicon-play.pause::before {
|
||||
content: "\e829";
|
||||
}
|
||||
.history-slider-input {
|
||||
.history-slider-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
min-width: 80px;
|
||||
margin: 0 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.history-slider-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Saved-revision markers overlaid on the slider track (issue #7946). Inset
|
||||
* left/right by ~half the native range thumb so a marker lines up with the
|
||||
* thumb centre at the track extremes. pointer-events:none on the layer keeps
|
||||
* slider dragging unobstructed; the stars themselves re-enable clicks to seek. */
|
||||
.history-slider-stars {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.history-slider-stars .history-star {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.history-slider-stars .history-star::before {
|
||||
font-family: fontawesome-etherpad;
|
||||
content: "\e856";
|
||||
color: #da9700;
|
||||
font-size: 14px;
|
||||
}
|
||||
.history-timer {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
|
|
@ -282,7 +322,7 @@ body.history-mode #history-controls { display: flex; }
|
|||
@media (max-width: 800px) {
|
||||
.history-controls { padding: 0 6px; gap: 4px; min-height: 36px; }
|
||||
.history-controls button.buttonicon { padding: 4px 6px; min-width: 32px; }
|
||||
.history-slider-input { min-width: 60px; margin: 0 2px; }
|
||||
.history-slider-wrap { min-width: 60px; margin: 0 2px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.history-controls #history-leftstep,
|
||||
|
|
|
|||
|
|
@ -31,12 +31,13 @@ import Op from "./Op";
|
|||
const _MAX_LIST_LEVEL = 16;
|
||||
|
||||
import AttributeMap from './AttributeMap';
|
||||
import UNorm from 'unorm';
|
||||
import {subattribution} from './Changeset';
|
||||
import {SmartOpAssembler} from "./SmartOpAssembler";
|
||||
const hooks = require('./pluginfw/hooks');
|
||||
|
||||
const sanitizeUnicode = (s) => UNorm.nfc(s);
|
||||
// NFC-normalize via the native String API (replaces the unmaintained `unorm`
|
||||
// polyfill; String.prototype.normalize is available in every supported runtime).
|
||||
const sanitizeUnicode = (s: string) => s.normalize('NFC');
|
||||
const tagName = (n) => n.tagName && n.tagName.toLowerCase();
|
||||
// supportedElems are Supported natively within Etherpad and don't require a plugin
|
||||
const supportedElems = new Set([
|
||||
|
|
|
|||
|
|
@ -102,12 +102,21 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
|
|||
postHtml = `</li></ul>${postHtml}`;
|
||||
} else {
|
||||
if (start) { // is it a start of a list with more than one item in?
|
||||
if (Number.parseInt(start[1]) === 1) { // if its the first one at this level?
|
||||
// The `start` value comes verbatim from the attribute pool (which
|
||||
// an attacker can populate via a crafted `.etherpad` import), so it
|
||||
// must never be interpolated raw into the markup. An <ol> start is
|
||||
// only ever an integer: coerce it and HTML-escape it defensively so
|
||||
// a value such as `1><svg/onload=...>` cannot break out of the tag.
|
||||
const startNum = Number.parseInt(start[1]);
|
||||
if (startNum === 1) { // if its the first one at this level?
|
||||
// Add start class to DIV node
|
||||
lineClass = `${lineClass} ` + `list-start-${listType}`;
|
||||
}
|
||||
const startAttr = Number.isNaN(startNum)
|
||||
? ''
|
||||
: ` start="${Security.escapeHTMLAttribute(String(startNum))}"`;
|
||||
preHtml +=
|
||||
`<ol start=${start[1]} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
|
||||
`<ol${startAttr} class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
|
||||
} else {
|
||||
// Handles pasted contents into existing lists
|
||||
preHtml += `<ol class="list-${Security.escapeHTMLAttribute(listType)}"><li>`;
|
||||
|
|
|
|||
|
|
@ -581,7 +581,10 @@ const pad = {
|
|||
$('#padsettings-options-linenoscheck').prop('checked', view.showLineNumbers !== false);
|
||||
$('#padsettings-options-rtlcheck').prop('checked', !!view.rtlIsTrue);
|
||||
$('#padsettings-viewfontmenu').val(view.padFontFamily || '');
|
||||
$('#padsettings-languagemenu').val(padOptions.lang || 'en');
|
||||
// When no pad-wide lang is set, reflect the language html10n actually
|
||||
// detected and rendered (e.g. from the browser) instead of defaulting the
|
||||
// dropdown to English while the UI is in another language. See #7925.
|
||||
$('#padsettings-languagemenu').val(padOptions.lang || html10n.getLanguage() || 'en');
|
||||
$('#padsettings-enforcecheck').prop('checked', !!padOptions.enforceSettings);
|
||||
$('#padsettings-options-stickychat, #padsettings-options-chatandusers')
|
||||
.prop('disabled', padOptions.showChat === false);
|
||||
|
|
@ -599,7 +602,10 @@ const pad = {
|
|||
$('#options-linenoscheck').prop('checked', effectiveOptions.view?.showLineNumbers !== false);
|
||||
$('#options-rtlcheck').prop('checked', !!effectiveOptions.view?.rtlIsTrue);
|
||||
$('#viewfontmenu').val(effectiveOptions.view?.padFontFamily || '');
|
||||
$('#languagemenu').val(effectiveOptions.lang || 'en');
|
||||
// Fall back to the detected language rather than hardcoded English when the
|
||||
// user has not explicitly chosen one, so the dropdown matches the rendered
|
||||
// UI language. See #7925.
|
||||
$('#languagemenu').val(effectiveOptions.lang || html10n.getLanguage() || 'en');
|
||||
$('#settings input[id^="options-"]').prop('disabled', disabled);
|
||||
$('#viewfontmenu, #languagemenu').prop('disabled', disabled);
|
||||
$('#options-stickychat, #options-chatandusers')
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ const padeditor = (() => {
|
|||
}
|
||||
});
|
||||
|
||||
// The recovery-token disclosure (#delete-pad-with-token) is rendered for
|
||||
// every session because a token may now be issued under requireAuthentication
|
||||
// too (when no getAuthorId hook pins a durable authorID — issue #7926).
|
||||
// Show it only when the user actually needs a token: when they can already
|
||||
// delete without one (everyone may delete, or they have a durable
|
||||
// authenticated identity) the plain "Delete Pad" button suffices, so hide
|
||||
// the disclosure and all its token wording entirely.
|
||||
$('#delete-pad-with-token').prop(
|
||||
'hidden', !!(window as any).clientVars?.canDeleteWithoutToken);
|
||||
|
||||
// The plain "Delete pad" button is shown whenever this session can delete
|
||||
// without a token (creator on this device, or allowPadDeletionByAllUsers).
|
||||
// It is independent of pad-wide settings so it stays reachable when that
|
||||
// section is disabled (issue #7959).
|
||||
$('#delete-pad').prop(
|
||||
'hidden', !(window as any).clientVars?.canDeletePad);
|
||||
|
||||
// delete pad using a recovery token (second device / no creator cookie)
|
||||
$('#delete-pad-token-submit').on('click', () => {
|
||||
const token = String($('#delete-pad-token-input').val() || '').trim();
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ class PadModeController {
|
|||
private padId: string;
|
||||
private innerHashChangeHandler: (() => void) | null = null;
|
||||
private revObserver: MutationObserver | null = null;
|
||||
// Watches the embedded slider's #ui-slider-bar so saved revisions added live
|
||||
// (NEW_SAVEDREV from a collaborator while we're in history mode) get mirrored
|
||||
// onto the outer slider — clientVars only carries the entry-time snapshot.
|
||||
private savedRevObserver: MutationObserver | null = null;
|
||||
private syncingHash = false;
|
||||
|
||||
// History-mode bridges — populated on enter, torn down on exit.
|
||||
|
|
@ -194,6 +198,11 @@ class PadModeController {
|
|||
}
|
||||
this.revLabel.textContent = '';
|
||||
this.dateLabel.textContent = '';
|
||||
const stars = document.getElementById('history-slider-stars');
|
||||
if (stars) {
|
||||
stars.replaceChildren();
|
||||
stars.dataset.sig = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Restore everything entry-time we stashed: chat message visibility, the
|
||||
|
|
@ -248,6 +257,10 @@ class PadModeController {
|
|||
this.revObserver.disconnect();
|
||||
this.revObserver = null;
|
||||
}
|
||||
if (this.savedRevObserver) {
|
||||
this.savedRevObserver.disconnect();
|
||||
this.savedRevObserver = null;
|
||||
}
|
||||
if (this.iframe) {
|
||||
try {
|
||||
if (this.innerHashChangeHandler && this.iframe.contentWindow) {
|
||||
|
|
@ -374,6 +387,10 @@ class PadModeController {
|
|||
playBtn.classList.toggle('pause', playing);
|
||||
playBtn.setAttribute('aria-pressed', playing ? 'true' : 'false');
|
||||
}
|
||||
// Saved-revision markers depend on the slider max, which is only known
|
||||
// once the inner slider has reported its length — render them here so we
|
||||
// pick up the correct positions on first sync and on any max change.
|
||||
this.renderSavedRevisionStars(innerWin);
|
||||
};
|
||||
// The hook registered earlier in attachInnerBridges already calls
|
||||
// onRevChange — piggyback on it for slider input/timer updates by
|
||||
|
|
@ -386,10 +403,87 @@ class PadModeController {
|
|||
}
|
||||
BS.onSlider(sync);
|
||||
sync(BS.getSliderPosition?.() ?? 0);
|
||||
// Now that the inner slider exists, watch it for live NEW_SAVEDREV stars.
|
||||
this.observeInnerSavedRevisions(innerWin);
|
||||
};
|
||||
registerSync();
|
||||
}
|
||||
|
||||
// Mirror the embedded timeslider's saved revisions onto the outer slider as
|
||||
// clickable star markers (issue #7946). The inner slider draws its own stars
|
||||
// on #ui-slider-bar, but that DOM is hidden in embed mode, so users only see
|
||||
// the outer #history-slider-input — which had no markers.
|
||||
//
|
||||
// The inner #ui-slider-bar .star elements are the live source of truth: the
|
||||
// timeslider keeps them current as NEW_SAVEDREV messages arrive (each carries
|
||||
// a `pos` attribute = revNum), whereas clientVars.savedRevisions is only the
|
||||
// entry-time snapshot. We read positions from those stars and pull labels
|
||||
// from the snapshot where available. A signature guard keeps this cheap when
|
||||
// sync() fires on every scrub; positions are percentage-based so they reflow
|
||||
// on resize for free.
|
||||
private renderSavedRevisionStars(innerWin: Window): void {
|
||||
const inner: any = innerWin as any;
|
||||
const layer = document.getElementById('history-slider-stars');
|
||||
const sliderInput = document.getElementById('history-slider-input') as HTMLInputElement | null;
|
||||
if (!layer || !sliderInput || !innerWin.document) return;
|
||||
|
||||
const max = Number(sliderInput.max) || 0;
|
||||
const revNums = Array.from(innerWin.document.querySelectorAll('#ui-slider-bar .star'))
|
||||
.map((el) => Number(el.getAttribute('pos')))
|
||||
// max === 0 is a valid single-revision pad: only rev 0 belongs there.
|
||||
.filter((n) => Number.isFinite(n) && n >= 0 && (max === 0 ? n === 0 : n <= max));
|
||||
|
||||
if (revNums.length === 0 || max < 0) {
|
||||
if (layer.childElementCount) layer.replaceChildren();
|
||||
layer.dataset.sig = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Labels live in the clientVars snapshot, keyed by revNum.
|
||||
const labels = new Map<number, string>();
|
||||
const snapshot = inner.clientVars?.savedRevisions;
|
||||
if (Array.isArray(snapshot)) {
|
||||
for (const r of snapshot) {
|
||||
const n = Number(r && r.revNum);
|
||||
if (Number.isFinite(n) && r && typeof r.label === 'string' && r.label) labels.set(n, r.label);
|
||||
}
|
||||
}
|
||||
|
||||
const sig = `${max}:${[...revNums].sort((a, b) => a - b).join(',')}`;
|
||||
if (layer.dataset.sig === sig) return;
|
||||
layer.dataset.sig = sig;
|
||||
layer.replaceChildren();
|
||||
|
||||
for (const revNum of revNums) {
|
||||
const frac = max === 0 ? 0 : revNum / max;
|
||||
// A purely visual marker (the layer is aria-hidden): keyboard/screen
|
||||
// reader users already reach any revision via the slider and step
|
||||
// buttons, so we mirror the legacy timeslider's mouse-only stars rather
|
||||
// than inject extra tab stops. The hover title aids mouse users; the
|
||||
// click is a convenience to jump straight to the saved point.
|
||||
const star = document.createElement('span');
|
||||
star.className = 'history-star';
|
||||
star.style.left = `${(frac * 100).toFixed(4)}%`;
|
||||
star.title = labels.get(revNum) || `Revision ${revNum}`;
|
||||
star.addEventListener('click', () => {
|
||||
try { inner.BroadcastSlider?.setSliderPosition?.(revNum); } catch (_e) { /* inner gone */ }
|
||||
});
|
||||
layer.appendChild(star);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render the outer markers whenever the embedded slider adds a star
|
||||
// (NEW_SAVEDREV). Observing the inner #ui-slider-bar covers saved revisions
|
||||
// created live while history mode is open, which sync()'s scrub-driven
|
||||
// callback would otherwise miss until the next slider move.
|
||||
private observeInnerSavedRevisions(innerWin: Window): void {
|
||||
if (this.savedRevObserver) return;
|
||||
const bar = innerWin.document && innerWin.document.getElementById('ui-slider-bar');
|
||||
if (!bar) return;
|
||||
this.savedRevObserver = new MutationObserver(() => { this.renderSavedRevisionStars(innerWin); });
|
||||
this.savedRevObserver.observe(bar, {childList: true});
|
||||
}
|
||||
|
||||
// Capture the live state we'll restore on exit: live chat message
|
||||
// visibility (just the timestamps — actual messages stay), live users
|
||||
// panel HTML, and current Export hrefs.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {binarySearch} from "./ace2_common";
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const Security = require('security');
|
||||
import * as Security from './security';
|
||||
import jsCookie, {CookiesStatic} from 'js-cookie'
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,20 +1,73 @@
|
|||
// @ts-nocheck
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Copyright 2009 Google Inc.
|
||||
* OWASP-style output-escaping helpers.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* Vendored from the `security` npm package (v1.0.0), which has been
|
||||
* unmaintained since 2012. The implementation below is reproduced verbatim
|
||||
* (behaviour is byte-identical) so the dependency can be dropped from core.
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Original work Copyright (c) 2011 Chad Weider, MIT licensed:
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS-IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
module.exports = require('security');
|
||||
const HTML_ENTITY_MAP: {[c: string]: string} = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
};
|
||||
|
||||
// OWASP Guidelines: &, <, >, ", ' plus forward slash.
|
||||
const HTML_CHARACTERS_EXPRESSION = /[&"'<>/]/gm;
|
||||
export const escapeHTML = (text: string) => text && text.replace(HTML_CHARACTERS_EXPRESSION,
|
||||
(c: string) => HTML_ENTITY_MAP[c] || c);
|
||||
|
||||
// OWASP Guidelines: escape all non alphanumeric characters in ASCII space.
|
||||
const HTML_ATTRIBUTE_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]/gm;
|
||||
export const escapeHTMLAttribute = (text: string) => text && text.replace(HTML_ATTRIBUTE_CHARACTERS_EXPRESSION,
|
||||
(c: string) => HTML_ENTITY_MAP[c] || `&#x${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)};`);
|
||||
|
||||
// OWASP Guidelines: escape all non alphanumeric characters in ASCII space.
|
||||
// Also include line breaks (for literal).
|
||||
const JAVASCRIPT_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF\u2028\u2029]/gm;
|
||||
export const encodeJavaScriptIdentifier = (text: string) => text && text.replace(JAVASCRIPT_CHARACTERS_EXPRESSION,
|
||||
(c: string) => `\\u${(`0000${c.charCodeAt(0).toString(16)}`).slice(-4)}`);
|
||||
|
||||
export const encodeJavaScriptString = (text: string) => text && `"${encodeJavaScriptIdentifier(text)}"`;
|
||||
|
||||
// This is not great, but it is useful.
|
||||
// NB: the original `security` package used /"(?:\\.|[^"])*"/, where `[^"]` also
|
||||
// matches a backslash and so overlaps with `\\.`, causing exponential
|
||||
// backtracking (ReDoS) on adversarial input. We exclude the backslash from the
|
||||
// character class so the two alternatives are mutually exclusive — this matches
|
||||
// exactly the same well-formed JSON string literals but in linear time.
|
||||
const JSON_STRING_LITERAL_EXPRESSION = /"(?:[^"\\]|\\.)*"/gm;
|
||||
export const encodeJavaScriptData = (object: any) => JSON.stringify(object).replace(JSON_STRING_LITERAL_EXPRESSION,
|
||||
(string: string) => encodeJavaScriptString(JSON.parse(string)));
|
||||
|
||||
// OWASP Guidelines: escape all non alphanumeric characters in ASCII space.
|
||||
const CSS_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]/gm;
|
||||
export const encodeCSSIdentifier = (text: string) => text && text.replace(CSS_CHARACTERS_EXPRESSION,
|
||||
(c: string) => `\\${(`000000${c.charCodeAt(0).toString(16)}`).slice(-6)}`);
|
||||
|
||||
export const encodeCSSString = (text: string) => text && `"${encodeCSSIdentifier(text)}"`;
|
||||
|
|
|
|||
|
|
@ -133,6 +133,22 @@
|
|||
box-shadow: 0 0 14px 0px var(--super-dark-color);
|
||||
}
|
||||
|
||||
/* == Root canvas / iOS status-bar safe area (issue #7606) == */
|
||||
/* The variant rules above only paint inner containers, leaving the <html> root
|
||||
transparent. iOS Safari fills the status-bar safe area above the page from
|
||||
the ROOT canvas background (not `theme-color`), so a dark-mode pad showed a
|
||||
white strip above the dark toolbar. Paint the root with the TOOLBAR colour
|
||||
(matching `theme-color` and the OP's request that the bar match the toolbar)
|
||||
so the safe area, toolbar, and address bar are one seamless colour. The
|
||||
colours mirror toolbarColorForTokens / skin_toolbar_colors.ts. `color-scheme`
|
||||
keeps UA-painted chrome (overscroll, scrollbars, form controls) in step. */
|
||||
html.super-light-toolbar, html.light-toolbar { color-scheme: light; }
|
||||
html.super-dark-toolbar, html.dark-toolbar { color-scheme: dark; }
|
||||
html.super-light-toolbar { background-color: var(--super-light-color); }
|
||||
html.light-toolbar { background-color: var(--light-color); }
|
||||
html.super-dark-toolbar { background-color: var(--super-dark-color); }
|
||||
html.dark-toolbar { background-color: var(--dark-color); }
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -159,8 +159,15 @@
|
|||
</button>
|
||||
<button type="button" id="history-rightstep" class="buttonicon buttonicon-step-forward">
|
||||
</button>
|
||||
<input type="range" id="history-slider-input" class="history-slider-input"
|
||||
min="0" max="0" value="0" step="1">
|
||||
<!-- Slider + saved-revision markers. The stars overlay sits above the
|
||||
range track; pad_mode.ts fills it from the embedded timeslider's
|
||||
savedRevisions so explicit "Save Revision" points stay visible in
|
||||
in-pad history mode (issue #7946). -->
|
||||
<span class="history-slider-wrap">
|
||||
<input type="range" id="history-slider-input" class="history-slider-input"
|
||||
min="0" max="0" value="0" step="1">
|
||||
<span id="history-slider-stars" class="history-slider-stars" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span id="history-timer" class="history-timer hide-for-mobile" aria-live="polite"></span>
|
||||
<!-- Follow toggle: an icon-only button. The eye is always rendered;
|
||||
the diagonal slash overlay is shown only when aria-pressed is
|
||||
|
|
@ -384,18 +391,24 @@
|
|||
</p>
|
||||
<% e.end_block(); %>
|
||||
</div>
|
||||
<button class="btn btn-danger" data-l10n-id="pad.settings.deletePad" id="delete-pad">Delete pad</button>
|
||||
</div><% } %>
|
||||
</div>
|
||||
<% if (!settings.requireAuthentication) { %>
|
||||
<details id="delete-pad-with-token">
|
||||
<!-- Pad deletion is independent of pad-wide settings (issue #7959):
|
||||
both controls are always rendered and pad_editor.ts shows exactly
|
||||
the one that applies. #delete-pad (token-less) is shown when
|
||||
clientVars.canDeletePad — the creator on this device, or any user
|
||||
when allowPadDeletionByAllUsers is on. The recovery-token
|
||||
disclosure is shown when clientVars.canDeleteWithoutToken is false
|
||||
(issue #7926): a creator on a second device, or under
|
||||
requireAuthentication without a durable cross-device identity. -->
|
||||
<button class="btn btn-danger" data-l10n-id="pad.settings.deletePad" id="delete-pad" hidden>Delete pad</button>
|
||||
<details id="delete-pad-with-token" hidden>
|
||||
<summary data-l10n-id="pad.deletionToken.deleteWithToken">Delete with token</summary>
|
||||
<label for="delete-pad-token-input" data-l10n-id="pad.deletionToken.tokenFieldLabel">Pad deletion token</label>
|
||||
<input type="password" id="delete-pad-token-input" autocomplete="off" spellcheck="false">
|
||||
<button id="delete-pad-token-submit" type="button" class="btn btn-danger"
|
||||
data-l10n-id="pad.settings.deletePad">Delete pad</button>
|
||||
</details>
|
||||
<% } %>
|
||||
<h2 data-l10n-id="pad.settings.about">About</h2>
|
||||
<span data-l10n-id="pad.settings.poweredBy">Powered by</span>
|
||||
<a href="https://etherpad.org" target="_blank" referrerpolicy="no-referrer" rel="noopener">Etherpad</a>
|
||||
|
|
|
|||
48
src/tests/backend/specs/PadManager.ts
Normal file
48
src/tests/backend/specs/PadManager.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use strict';
|
||||
|
||||
// Unit coverage for PadManager.isValidPadId.
|
||||
//
|
||||
// isValidPadId is a pure function (a regex test), so this spec just requires
|
||||
// PadManager and exercises it directly — no running database is needed.
|
||||
// PadManager's import-time `require`s (DB, Pad, customError) only *define*
|
||||
// things; the database connection happens lazily in DB.init(), so loading the
|
||||
// module here has no side effects. This runs under the mocha backend suite
|
||||
// (`--import=tsx`), where `require()` resolves the `.ts` sources natively.
|
||||
|
||||
import {strict as assert} from 'assert';
|
||||
|
||||
const padManager = require('../../../node/db/PadManager');
|
||||
|
||||
describe(__filename, function () {
|
||||
describe('isValidPadId', function () {
|
||||
it('accepts ordinary pad ids', async function () {
|
||||
for (const id of [
|
||||
'foo',
|
||||
'TF-EVC',
|
||||
'TF-LEC_IP03-EMS-CSM',
|
||||
'a.b',
|
||||
'.foo',
|
||||
'foo.',
|
||||
"a'b",
|
||||
'g.s8oes9dhwrvt0zif$bar', // group pad
|
||||
]) {
|
||||
assert.equal(padManager.isValidPadId(id), true, `expected "${id}" to be valid`);
|
||||
}
|
||||
});
|
||||
|
||||
// Regression test for "Cannot GET /p/": a pad id that is a URL dot-segment
|
||||
// ('.' or '..') is normalised away by the browser per the WHATWG URL
|
||||
// standard ('/p/.' -> '/p/', '/p/..' -> '/'), so the pad can never be
|
||||
// opened or exported. Such ids must be rejected. Before the fix
|
||||
// isValidPadId returned true for both, so this test would fail.
|
||||
it('rejects URL dot-segment pad ids that would be unreachable', async function () {
|
||||
assert.equal(padManager.isValidPadId('.'), false);
|
||||
assert.equal(padManager.isValidPadId('..'), false);
|
||||
});
|
||||
|
||||
it('still rejects empty ids and ids containing "$"', async function () {
|
||||
assert.equal(padManager.isValidPadId(''), false);
|
||||
assert.equal(padManager.isValidPadId('a$b'), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
203
src/tests/backend/specs/admin/padLoadResilience.ts
Normal file
203
src/tests/backend/specs/admin/padLoadResilience.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
'use strict';
|
||||
|
||||
// Regression test for issue #7935 ("Display issue of notes"): pads exist
|
||||
// (visible on the welcome page, returned by the API `listAllPads`) but the
|
||||
// admin "Manage pads" UI shows none.
|
||||
//
|
||||
// Root cause: the admin /settings `padLoad` handler hydrates every pad via
|
||||
// `padManager.getPad()` to build the listing (the default `lastEdited` sort
|
||||
// forces a full scan). `findKeys('pad:*', '*:*:*')` returns *every* key under
|
||||
// the `pad:` prefix, including legacy / foreign / migration-corrupted records
|
||||
// — e.g. a value stored as a JSON *string* rather than a pad object, which is
|
||||
// exactly what a botched dirty.db → PostgreSQL migration produces. Loading
|
||||
// such a record makes `Pad.init` throw `Cannot use 'in' operator to search
|
||||
// for 'pool' in <string>`. Before the fix that single rejection took out the
|
||||
// whole handler: no `results:padLoad` was ever emitted (the SPA showed an
|
||||
// empty "No results" state forever) and the unhandled rejection could exit
|
||||
// the server. The handler now skips unreadable pads (surfacing them with
|
||||
// zeroed metadata so an admin can still delete them) and always emits a
|
||||
// terminal reply.
|
||||
|
||||
import {strict as assert} from 'assert';
|
||||
import setCookieParser from 'set-cookie-parser';
|
||||
|
||||
const io = require('socket.io-client');
|
||||
const common = require('../../common');
|
||||
const settings = require('../../../../node/utils/Settings');
|
||||
const padManager = require('../../../../node/db/PadManager');
|
||||
const db = require('../../../../node/db/DB');
|
||||
|
||||
const adminSocket = async () => {
|
||||
settings.users = settings.users || {};
|
||||
settings.users['test-admin'] = {password: 'test-admin-password', is_admin: true};
|
||||
const savedRequireAuthentication = settings.requireAuthentication;
|
||||
settings.requireAuthentication = true;
|
||||
let res: any;
|
||||
try {
|
||||
res = await (common.agent as any)
|
||||
.get('/admin/')
|
||||
.auth('test-admin', 'test-admin-password');
|
||||
} finally {
|
||||
settings.requireAuthentication = savedRequireAuthentication;
|
||||
}
|
||||
const resCookies = setCookieParser.parse(res, {map: true});
|
||||
const reqCookieHdr = Object.entries(resCookies)
|
||||
.map(([name, cookie]: [string, any]) =>
|
||||
`${name}=${encodeURIComponent(cookie.value)}`)
|
||||
.join('; ');
|
||||
const socket = io(`${common.baseUrl}/settings`, {
|
||||
forceNew: true,
|
||||
query: {cookie: reqCookieHdr},
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onErr = (err: any) => { socket.off('connect', onConn); reject(err); };
|
||||
const onConn = () => { socket.off('connect_error', onErr); resolve(); };
|
||||
socket.once('connect', onConn);
|
||||
socket.once('connect_error', onErr);
|
||||
});
|
||||
return socket;
|
||||
};
|
||||
|
||||
const ask = (socket: any, evt: string, payload: any, replyEvt: string, timeoutMs = 10000) =>
|
||||
new Promise<any>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`no \`${replyEvt}\` reply within ${timeoutMs}ms`)), timeoutMs);
|
||||
socket.once(replyEvt, (data: any) => { clearTimeout(timer); resolve(data); });
|
||||
socket.emit(evt, payload);
|
||||
});
|
||||
|
||||
describe(__filename, function () {
|
||||
let socket: any;
|
||||
let savedUsers: any;
|
||||
let savedRequireAuthentication: boolean;
|
||||
let setupCompleted = false;
|
||||
const tag = `padLoadResilience-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
|
||||
const goodId = `${tag}-good`;
|
||||
const corruptId = `${tag}-corrupt`;
|
||||
|
||||
before(async function () {
|
||||
this.timeout(120000);
|
||||
await common.init();
|
||||
|
||||
savedUsers = settings.users;
|
||||
savedRequireAuthentication = settings.requireAuthentication;
|
||||
setupCompleted = true;
|
||||
|
||||
try {
|
||||
socket = await adminSocket();
|
||||
} catch (err: any) {
|
||||
console.warn(
|
||||
`[padLoadResilience] admin socket connect failed (${err && err.message}); ` +
|
||||
"skipping suite — likely an authenticate-hook plugin rejecting the test's " +
|
||||
'admin credentials.');
|
||||
this.skip();
|
||||
return;
|
||||
}
|
||||
|
||||
// A normal, readable pad — this is what must still show up.
|
||||
await padManager.getPad(goodId, 'good content\n');
|
||||
|
||||
// A pad that enters the pad-name index normally, then has its stored
|
||||
// value clobbered into a non-object (a JSON string) to mimic a
|
||||
// migration-corrupted / foreign `pad:*` record. Evicting it from the
|
||||
// in-memory cache forces the next getPad() to re-read the bad value.
|
||||
await padManager.getPad(corruptId, 'temp\n');
|
||||
await db.set(`pad:${corruptId}`, 'corrupt-non-object-value');
|
||||
padManager.unloadPad(corruptId);
|
||||
|
||||
// Sanity-check that the setup actually reproduces the failing read; if
|
||||
// this stops throwing the test is no longer exercising the bug.
|
||||
await assert.rejects(padManager.getPad(corruptId),
|
||||
'expected the corrupted pad record to make getPad throw');
|
||||
});
|
||||
|
||||
after(async function () {
|
||||
if (socket) socket.disconnect();
|
||||
if (!setupCompleted) return;
|
||||
if (settings.users) delete settings.users['test-admin'];
|
||||
settings.users = savedUsers;
|
||||
settings.requireAuthentication = savedRequireAuthentication;
|
||||
for (const id of [goodId, corruptId]) {
|
||||
try { await db.remove(`pad:${id}`); } catch { /* ignore */ }
|
||||
try { await db.remove(`pad:${id}:revs:0`); } catch { /* ignore */ }
|
||||
try { padManager.unloadPad(id); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
|
||||
it('a single corrupt pad does not hide every other pad (issue #7935)', async function () {
|
||||
this.timeout(30000);
|
||||
// The default query the SPA sends on initial load: lastEdited sort forces
|
||||
// the full-scan hydration path that touches every pad — including the
|
||||
// corrupt one.
|
||||
const res = await ask(socket, 'padLoad', {
|
||||
pattern: tag, offset: 0, limit: 12,
|
||||
sortBy: 'lastEdited', ascending: false, filter: 'all',
|
||||
}, 'results:padLoad');
|
||||
|
||||
const names = res.results.map((r: any) => r.padName);
|
||||
assert.ok(names.includes(goodId),
|
||||
`the readable pad must still be listed; got ${JSON.stringify(names)}`);
|
||||
// The bad pad is surfaced (zeroed metadata) rather than silently dropped,
|
||||
// so an admin can see and delete it.
|
||||
assert.ok(names.includes(corruptId),
|
||||
`the corrupt pad should surface for deletion; got ${JSON.stringify(names)}`);
|
||||
assert.equal(res.total, 2, `expected total=2, got ${JSON.stringify(res)}`);
|
||||
});
|
||||
|
||||
it('still replies on the fast path (padName sort) with a corrupt pad present', async function () {
|
||||
this.timeout(30000);
|
||||
const res = await ask(socket, 'padLoad', {
|
||||
pattern: tag, offset: 0, limit: 12,
|
||||
sortBy: 'padName', ascending: true, filter: 'all',
|
||||
}, 'results:padLoad');
|
||||
const names = res.results.map((r: any) => r.padName);
|
||||
assert.ok(names.includes(goodId), `got ${JSON.stringify(names)}`);
|
||||
assert.ok(names.includes(corruptId), `got ${JSON.stringify(names)}`);
|
||||
});
|
||||
|
||||
// Runs last: surfacing a corrupt pad is only useful if it can be removed.
|
||||
// deletePad's normal path (doesPadExists + getPad + Pad.remove) can't touch
|
||||
// an unreadable record, so it must fall back to a raw key purge.
|
||||
it('a surfaced corrupt pad can be deleted from the admin UI', async function () {
|
||||
this.timeout(30000);
|
||||
const ack = await ask(socket, 'deletePad', corruptId, 'results:deletePad');
|
||||
assert.equal(ack, corruptId, `expected deletePad to ack "${corruptId}", got ${JSON.stringify(ack)}`);
|
||||
|
||||
// Assert the user-facing outcome — the corrupt pad is gone from the
|
||||
// listing (its pad-list entry was dropped) while the good pad stays.
|
||||
// (We deliberately don't probe `db.get('pad:<id>')` here: ueberdb2's
|
||||
// per-backend read/write buffering can still return the just-removed
|
||||
// value immediately after `remove()`, so that would be a flaky
|
||||
// storage-internals assertion rather than a behavioural one.)
|
||||
const res = await ask(socket, 'padLoad', {
|
||||
pattern: tag, offset: 0, limit: 12,
|
||||
sortBy: 'padName', ascending: true, filter: 'all',
|
||||
}, 'results:padLoad');
|
||||
const names = res.results.map((r: any) => r.padName);
|
||||
assert.ok(!names.includes(corruptId), `corrupt pad still listed: ${JSON.stringify(names)}`);
|
||||
assert.ok(names.includes(goodId), `good pad missing after delete: ${JSON.stringify(names)}`);
|
||||
});
|
||||
|
||||
// Regression for the isValidPadId tightening that rejects '.' and '..': a
|
||||
// legacy pad with such an id predates the validation, so it still exists in
|
||||
// the DB (doesPadExists is true → deletePad takes the "healthy" branch) but
|
||||
// getPad() now throws on it. deletePad must fall back to the raw key purge
|
||||
// instead of failing silently, otherwise the orphan is undeletable from the
|
||||
// admin UI. Before the fallback this `ask()` never gets `results:deletePad`
|
||||
// and times out.
|
||||
it("a legacy '.' pad (now an invalid id) can still be deleted", async function () {
|
||||
this.timeout(30000);
|
||||
const dotId = '.';
|
||||
// getPad('.') would now throw, so seed the record directly with a truthy
|
||||
// `atext` so doesPadExists() returns true and the handler enters the branch
|
||||
// where getPad() throws.
|
||||
await db.set(`pad:${dotId}`, {atext: {text: '\n', attribs: ''}, pool: {}, head: -1, savedRevisions: []});
|
||||
try {
|
||||
const ack = await ask(socket, 'deletePad', dotId, 'results:deletePad');
|
||||
assert.equal(ack, dotId, `expected deletePad to ack "${dotId}", got ${JSON.stringify(ack)}`);
|
||||
} finally {
|
||||
try { await db.remove(`pad:${dotId}`); } catch { /* ignore */ }
|
||||
try { padManager.unloadPad(dotId); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -80,6 +80,17 @@ describe(__filename, function () {
|
|||
await callApi('deletePad', {padID: padId});
|
||||
});
|
||||
|
||||
it('createPad returns null deletionToken when allowPadDeletionByAllUsers is on', async function () {
|
||||
// Anyone can delete the pad with no token at all, so the recovery token is
|
||||
// pointless — matches the socket/UI path (issue #7926).
|
||||
settings.allowPadDeletionByAllUsers = true;
|
||||
const padId = makeId();
|
||||
const res = await callApi('createPad', {padID: padId});
|
||||
assert.equal(res.body.code, 0, JSON.stringify(res.body));
|
||||
assert.equal(res.body.data.deletionToken, null);
|
||||
await callApi('deletePad', {padID: padId});
|
||||
});
|
||||
|
||||
it('JWT admin call (no deletionToken) still works — admins stay trusted', async function () {
|
||||
const padId = makeId();
|
||||
await callApi('createPad', {padID: padId});
|
||||
|
|
|
|||
62
src/tests/backend/specs/domline_list_start.ts
Normal file
62
src/tests/backend/specs/domline_list_start.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use strict';
|
||||
|
||||
/*
|
||||
* Regression test for GHSA-f7h5-v9hm-548j.
|
||||
*
|
||||
* The numbered-list branch of `domline.appendSpan` used to interpolate the
|
||||
* line's `start` attribute value into an `<ol start=...>` tag unquoted and
|
||||
* unescaped, then assign the result to `node.innerHTML`. The value comes
|
||||
* verbatim from the attribute pool, which an attacker can populate via a
|
||||
* crafted `.etherpad` import, so a value such as `1><svg/onload=alert(1)>`
|
||||
* broke out of the tag and produced a live element -> stored XSS for every
|
||||
* viewer of the pad/timeslider.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const domline = require('../../../static/js/domline').domline;
|
||||
const {lineAttributeMarker} = require('../../../static/js/linestylefilter');
|
||||
import jsdom from 'jsdom';
|
||||
|
||||
// Build the per-span class string exactly as linestylefilter would for a
|
||||
// numbered-list line marker carrying the given `start` value.
|
||||
const listCls = (start: string) =>
|
||||
`${lineAttributeMarker} list:number1 start:${start}`;
|
||||
|
||||
// Render a single line-marker span through the real domline sink and return
|
||||
// the resulting DOM node.
|
||||
const renderLine = (cls: string) => {
|
||||
const {window} = new jsdom.JSDOM('<!DOCTYPE html><html><body></body></html>');
|
||||
const node = domline.createDomLine(true, false, window, window.document);
|
||||
node.clearSpans();
|
||||
node.appendSpan('*', cls);
|
||||
node.finishUpdate();
|
||||
return node.node as HTMLElement;
|
||||
};
|
||||
|
||||
describe(__filename, function () {
|
||||
it('does not create a live element from a malicious start value', async function () {
|
||||
// Space-free payload: satisfies the `\S+` capture the sink matches on.
|
||||
const node = renderLine(listCls('1><svg/onload=alert(document.domain)>'));
|
||||
assert.equal(node.querySelector('svg'), null,
|
||||
'malicious start value must not be parsed into a live <svg> element');
|
||||
assert.ok(!node.innerHTML.includes('<svg'),
|
||||
`rendered markup must not contain a raw <svg> tag: ${node.innerHTML}`);
|
||||
// The numbered list itself still renders; only the bogus value is dropped.
|
||||
assert.ok(node.querySelector('ol'), 'a numbered list should still render');
|
||||
});
|
||||
|
||||
it('renders a legitimate integer start value safely', async function () {
|
||||
const node = renderLine(listCls('2'));
|
||||
const ol = node.querySelector('ol');
|
||||
assert.ok(ol, 'a numbered list should render');
|
||||
assert.equal(ol!.getAttribute('start'), '2');
|
||||
});
|
||||
|
||||
it('coerces a non-integer start value away instead of emitting it', async function () {
|
||||
const node = renderLine(listCls('notanumber'));
|
||||
const ol = node.querySelector('ol');
|
||||
assert.ok(ol, 'a numbered list should still render');
|
||||
assert.equal(ol!.getAttribute('start'), null,
|
||||
'a non-integer start value must not reach the <ol> start attribute');
|
||||
});
|
||||
});
|
||||
77
src/tests/backend/specs/downstream/generate-vectors.ts
Normal file
77
src/tests/backend/specs/downstream/generate-vectors.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Single source of truth for the downstream wire-compatibility fixtures.
|
||||
*
|
||||
* Each vector is a self-contained changeset application: given `initialText`
|
||||
* and `pool`, applying `changeset` yields `resultText`. Downstream clients
|
||||
* (which reimplement Etherpad's changeset/attribpool decoders) consume the
|
||||
* exact same JSON and must reproduce `resultText`. See the Phase 1 plan
|
||||
* at docs/superpowers/plans/2026-06-09-downstream-client-compat-tests-phase1.md.
|
||||
*
|
||||
* Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json:
|
||||
* pnpm run vectors:gen
|
||||
*/
|
||||
|
||||
import * as Changeset from '../../../../static/js/Changeset';
|
||||
import AttributePool from '../../../../static/js/AttributePool';
|
||||
|
||||
export type WireVector = {
|
||||
name: string;
|
||||
initialText: string;
|
||||
changeset: string;
|
||||
pool: ReturnType<AttributePool['toJsonable']>;
|
||||
resultText: string;
|
||||
};
|
||||
|
||||
const vector = (
|
||||
name: string,
|
||||
initialText: string,
|
||||
build: (pool: AttributePool) => string,
|
||||
): WireVector => {
|
||||
const pool = new AttributePool();
|
||||
const changeset = build(pool);
|
||||
Changeset.checkRep(changeset);
|
||||
return {
|
||||
name,
|
||||
initialText,
|
||||
changeset,
|
||||
pool: pool.toJsonable(),
|
||||
resultText: Changeset.applyToText(changeset, initialText),
|
||||
};
|
||||
};
|
||||
|
||||
export const generateVectors = (): WireVector[] => [
|
||||
vector('plain-insert', 'abc\n', () =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'XYZ')),
|
||||
|
||||
vector('plain-delete', 'abcdef\n', () =>
|
||||
Changeset.makeSplice('abcdef\n', 1, 3, '')),
|
||||
|
||||
vector('formatted-insert', 'abc\n', (pool) =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)),
|
||||
|
||||
vector('multiline-insert', 'abc\n', () =>
|
||||
Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')),
|
||||
|
||||
vector('attrib-reuse', 'abc\n', (pool) => {
|
||||
// Two formatted inserts sharing one pool entry exercises pool index reuse.
|
||||
const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool);
|
||||
const mid = Changeset.applyToText(cs1, 'abc\n');
|
||||
const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool);
|
||||
return Changeset.compose(cs1, cs2, pool);
|
||||
}),
|
||||
];
|
||||
|
||||
// CLI entry: write the canonical fixture to disk.
|
||||
if (require.main === module) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require('path');
|
||||
const out = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||
fs.mkdirSync(path.dirname(out), {recursive: true});
|
||||
fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`wrote ${out}`);
|
||||
}
|
||||
54
src/tests/backend/specs/downstream/wire-http-api.ts
Normal file
54
src/tests/backend/specs/downstream/wire-http-api.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API
|
||||
* endpoints downstream clients call to create pads and round-trip text.
|
||||
* Auth in the test harness is via JWT (common.generateJWTToken), matching the
|
||||
* rest of the api specs — see api/createDiffHTML.ts.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../../common');
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent: any;
|
||||
let apiVersion = 1;
|
||||
const padId = `wireHttp_${common.randomString()}`;
|
||||
const endPoint = (point: string) => `/api/${apiVersion}/${point}`;
|
||||
|
||||
before(async function () {
|
||||
agent = await common.init();
|
||||
const res = await agent.get('/api/').expect(200).expect('Content-Type', /json/);
|
||||
apiVersion = res.body.currentVersion;
|
||||
assert(apiVersion);
|
||||
});
|
||||
|
||||
it('createPad returns the standard {code,data,message} envelope', async function () {
|
||||
const res = await agent.get(`${endPoint('createPad')}?padID=${padId}&text=hello%0A`)
|
||||
.set('Authorization', await common.generateJWTToken())
|
||||
.expect(200);
|
||||
assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']);
|
||||
assert.equal(res.body.code, 0);
|
||||
});
|
||||
|
||||
it('setText + getText round-trips text through the documented shape', async function () {
|
||||
await agent.post(endPoint('setText'))
|
||||
.set('Authorization', await common.generateJWTToken())
|
||||
.send({padID: padId, text: 'world\n'})
|
||||
.expect(200);
|
||||
const res = await agent.get(`${endPoint('getText')}?padID=${padId}`)
|
||||
.set('Authorization', await common.generateJWTToken())
|
||||
.expect(200);
|
||||
assert.equal(res.body.code, 0);
|
||||
assert.equal(typeof res.body.data.text, 'string');
|
||||
assert.equal(res.body.data.text, 'world\n');
|
||||
});
|
||||
|
||||
it('getRevisionsCount exposes a numeric revisions field', async function () {
|
||||
const res = await agent.get(`${endPoint('getRevisionsCount')}?padID=${padId}`)
|
||||
.set('Authorization', await common.generateJWTToken())
|
||||
.expect(200);
|
||||
assert.equal(res.body.code, 0);
|
||||
assert.equal(typeof res.body.data.revisions, 'number');
|
||||
});
|
||||
});
|
||||
60
src/tests/backend/specs/downstream/wire-socket-sequence.ts
Normal file
60
src/tests/backend/specs/downstream/wire-socket-sequence.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Pins the socket.io message sequence + shapes that every realtime client
|
||||
* depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT.
|
||||
* A change here is a wire-protocol change that will break downstream clients
|
||||
* (the Rust terminal editor and the Node CLI both speak this sequence by hand).
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../../common');
|
||||
const padManager = require('../../../../node/db/PadManager');
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent: any;
|
||||
let socket: any;
|
||||
let pad: any;
|
||||
let padId: string;
|
||||
|
||||
before(async function () { agent = await common.init(); });
|
||||
|
||||
beforeEach(async function () {
|
||||
padId = common.randomString();
|
||||
pad = await padManager.getPad(padId, 'dummy\n');
|
||||
await pad.setText('\n'); // ensure the pad exists at a known empty state
|
||||
const res = await agent.get(`/p/${padId}`).expect(200);
|
||||
socket = await common.connect(res);
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
if (socket != null) socket.close();
|
||||
socket = null;
|
||||
if (pad != null) await pad.remove();
|
||||
pad = null;
|
||||
});
|
||||
|
||||
it('handshake returns CLIENT_VARS with the client-facing shape', async function () {
|
||||
const {type, data} = await common.handshake(socket, padId);
|
||||
assert.equal(type, 'CLIENT_VARS');
|
||||
assert.ok(data.userId, 'CLIENT_VARS.userId missing');
|
||||
assert.ok(data.collab_client_vars, 'collab_client_vars missing');
|
||||
assert.equal(typeof data.collab_client_vars.rev, 'number');
|
||||
assert.ok(data.collab_client_vars.initialAttributedText,
|
||||
'collab_client_vars.initialAttributedText missing');
|
||||
});
|
||||
|
||||
it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () {
|
||||
const {data: clientVars} = await common.handshake(socket, padId);
|
||||
const rev = clientVars.collab_client_vars.rev;
|
||||
const authorId = clientVars.userId;
|
||||
// Insert ops must carry the session author attribute (`*0+N` + matching
|
||||
// apool entry) or the server rejects with {disconnect:'badChangeset'}.
|
||||
const apool = {numToAttrib: {0: ['author', authorId]}, nextNum: 1};
|
||||
await Promise.all([
|
||||
common.waitForAcceptCommit(socket, rev + 1),
|
||||
common.sendUserChanges(socket, {baseRev: rev, changeset: 'Z:1>5*0+5$hello', apool}),
|
||||
]);
|
||||
assert.equal(pad.text(), 'hello\n');
|
||||
});
|
||||
});
|
||||
33
src/tests/backend/specs/downstream/wire-vectors.ts
Normal file
33
src/tests/backend/specs/downstream/wire-vectors.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Guards the downstream wire-format contract:
|
||||
* - the committed fixture exactly matches a fresh regeneration (any drift is a
|
||||
* deliberate wire change and must be re-generated + reviewed in the same PR), and
|
||||
* - every vector is internally consistent under core's own Changeset engine.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import * as Changeset from '../../../../static/js/Changeset';
|
||||
import {generateVectors} from './generate-vectors';
|
||||
|
||||
const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||
|
||||
describe(__filename, function () {
|
||||
it('committed fixture matches a fresh regeneration', function () {
|
||||
const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||
const fresh = generateVectors();
|
||||
assert.deepEqual(committed, fresh,
|
||||
'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result');
|
||||
});
|
||||
|
||||
it('every vector applies to its result under core Changeset', function () {
|
||||
for (const v of generateVectors()) {
|
||||
Changeset.checkRep(v.changeset);
|
||||
assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText,
|
||||
`vector ${v.name} result mismatch`);
|
||||
}
|
||||
});
|
||||
});
|
||||
44
src/tests/backend/specs/padDeletionUiPlacement.ts
Normal file
44
src/tests/backend/specs/padDeletionUiPlacement.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use strict';
|
||||
|
||||
import {MapArrayType} from '../../../node/types/MapType';
|
||||
import settings from '../../../node/utils/Settings';
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../common');
|
||||
|
||||
// Regression coverage for issue #7959. The token-less "Delete pad" button
|
||||
// (#delete-pad) used to be nested inside the `enablePadWideSettings`-gated
|
||||
// pad-settings section, so disabling pad-wide settings removed the only way to
|
||||
// delete a pad without a recovery token. Pad deletion is unrelated to pad-wide
|
||||
// settings, so the button must be rendered regardless of that flag (its
|
||||
// visibility is then driven at runtime by clientVars.canDeletePad).
|
||||
describe(__filename, function () {
|
||||
this.timeout(30000);
|
||||
let agent: any;
|
||||
const backup: MapArrayType<any> = {};
|
||||
|
||||
before(async function () { agent = await common.init(); });
|
||||
|
||||
beforeEach(async function () {
|
||||
backup.enablePadWideSettings = settings.enablePadWideSettings;
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
settings.enablePadWideSettings = backup.enablePadWideSettings;
|
||||
});
|
||||
|
||||
const hasDeletePadButton = (html: string): boolean =>
|
||||
/id="delete-pad"/.test(html);
|
||||
|
||||
it('renders the Delete pad button with pad-wide settings enabled', async function () {
|
||||
settings.enablePadWideSettings = true;
|
||||
const res = await agent.get('/p/deleteUiPlacementOn').expect(200);
|
||||
assert.equal(hasDeletePadButton(res.text), true);
|
||||
});
|
||||
|
||||
it('renders the Delete pad button with pad-wide settings disabled (#7959)', async function () {
|
||||
settings.enablePadWideSettings = false;
|
||||
const res = await agent.get('/p/deleteUiPlacementOff').expect(200);
|
||||
assert.equal(hasDeletePadButton(res.text), true);
|
||||
});
|
||||
});
|
||||
78
src/tests/backend/specs/security.ts
Normal file
78
src/tests/backend/specs/security.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
'use strict';
|
||||
|
||||
const assert = require('assert').strict;
|
||||
// The escaping helpers are a client module, but they are pure (no browser
|
||||
// globals) so they can be exercised directly from a backend spec. This locks
|
||||
// the byte-for-byte output of the helpers vendored from the (now removed)
|
||||
// `security` npm package, so the vendoring can never silently drift.
|
||||
const Security = require('../../../static/js/security');
|
||||
|
||||
describe(__filename, function () {
|
||||
describe('public API', function () {
|
||||
it('exposes the full set of helpers plugins may rely on', function () {
|
||||
for (const fn of [
|
||||
'escapeHTML', 'escapeHTMLAttribute',
|
||||
'encodeJavaScriptIdentifier', 'encodeJavaScriptString', 'encodeJavaScriptData',
|
||||
'encodeCSSIdentifier', 'encodeCSSString',
|
||||
]) {
|
||||
assert.equal(typeof Security[fn], 'function', `Security.${fn} must be a function`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeHTML', function () {
|
||||
it('escapes &, <, >, ", \' and / per OWASP', function () {
|
||||
assert.equal(Security.escapeHTML('<a href="x">/&\''),
|
||||
'<a href="x">/&'');
|
||||
});
|
||||
it('neutralises a script tag', function () {
|
||||
assert.equal(Security.escapeHTML('<script>alert(1)</script>'),
|
||||
'<script>alert(1)</script>');
|
||||
});
|
||||
it('leaves plain alphanumerics untouched', function () {
|
||||
assert.equal(Security.escapeHTML('Hello World 123'), 'Hello World 123');
|
||||
});
|
||||
it('passes falsy input straight through', function () {
|
||||
assert.equal(Security.escapeHTML(''), '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escapeHTMLAttribute', function () {
|
||||
it('hex-encodes non-alphanumeric ASCII not covered by named entities', function () {
|
||||
assert.equal(Security.escapeHTMLAttribute('a b'), 'a b');
|
||||
// hex is lowercased, matching the original `security` package output.
|
||||
assert.equal(Security.escapeHTMLAttribute('javascript:alert(1)'),
|
||||
'javascript:alert(1)');
|
||||
});
|
||||
it('prefers named entities for &, <, >, ", \', /', function () {
|
||||
assert.equal(Security.escapeHTMLAttribute('<>&"\'/'),
|
||||
'<>&"'/');
|
||||
});
|
||||
it('leaves alphanumerics untouched', function () {
|
||||
assert.equal(Security.escapeHTMLAttribute('abcXYZ0189'), 'abcXYZ0189');
|
||||
});
|
||||
});
|
||||
|
||||
describe('javascript / css encoders', function () {
|
||||
it('encodeJavaScriptString quotes and backslash-u-escapes specials', function () {
|
||||
assert.equal(Security.encodeJavaScriptString('a<b'), '"a\\u003cb"');
|
||||
});
|
||||
it('encodeCSSString quotes and backslash-escapes specials', function () {
|
||||
assert.equal(Security.encodeCSSString('a;b'), '"a\\00003bb"');
|
||||
});
|
||||
it('encodeJavaScriptData escapes specials inside JSON string literals', function () {
|
||||
assert.equal(
|
||||
Security.encodeJavaScriptData({a: '<b>', c: 'x"y', d: 'a\\b'}),
|
||||
'{"a":"\\u003cb\\u003e","c":"x\\u0022y","d":"a\\u005cb"}');
|
||||
});
|
||||
it('encodeJavaScriptData regex is linear (ReDoS guard)', function () {
|
||||
// The JSON-string-literal regex used to be /"(?:\\.|[^"])*"/, which
|
||||
// backtracks exponentially on an unterminated string of `\!` repeats.
|
||||
// Run the regex directly on adversarial input and assert it returns fast.
|
||||
const evil = `"${'\\!'.repeat(50000)}`; // no closing quote
|
||||
const start = Date.now();
|
||||
/"(?:[^"\\]|\\.)*"/gm.test(evil);
|
||||
assert.ok(Date.now() - start < 1000, 'regex must not backtrack exponentially');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -197,4 +197,143 @@ describe(__filename, function () {
|
|||
assert.strictEqual(settings.padOptions.fadeInactiveAuthorColors, true);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for ether/etherpad#7911.
|
||||
// Air-gapped / firewalled deployments must be able to disable Etherpad's
|
||||
// outbound calls (version check + plugin catalogue + self-updater) purely
|
||||
// via environment variables, without editing settings.json inside the image.
|
||||
// These assertions parse the *shipped* settings.json.docker so a future edit
|
||||
// that drops the ${ENV} placeholders fails loudly here.
|
||||
describe('offline / air-gapped env overrides (issue #7911)', function () {
|
||||
const dockerSettings = path.join(__dirname, '../../../../settings.json.docker');
|
||||
const templateSettings = path.join(__dirname, '../../../../settings.json.template');
|
||||
const envVars = [
|
||||
'PRIVACY_UPDATE_CHECK', 'PRIVACY_PLUGIN_CATALOG', 'UPDATES_TIER',
|
||||
'UPDATES_SOURCE', 'UPDATES_CHANNEL', 'UPDATES_CHECK_INTERVAL_HOURS',
|
||||
'UPDATES_GITHUB_REPO', 'UPDATES_REQUIRE_ADMIN_FOR_STATUS', 'UPDATE_SERVER',
|
||||
];
|
||||
const saved: {[k: string]: string | undefined} = {};
|
||||
|
||||
before(function () { for (const v of envVars) saved[v] = process.env[v]; });
|
||||
afterEach(function () {
|
||||
for (const v of envVars) {
|
||||
if (saved[v] == null) delete process.env[v];
|
||||
else process.env[v] = saved[v];
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps shipped defaults when no env vars are set', function () {
|
||||
for (const v of envVars) delete process.env[v];
|
||||
const s = exportedForTestingOnly.parseSettings(dockerSettings, true);
|
||||
assert.strictEqual(s!.privacy.updateCheck, true);
|
||||
assert.strictEqual(s!.privacy.pluginCatalog, true);
|
||||
assert.strictEqual(s!.updates.tier, 'notify');
|
||||
assert.strictEqual(s!.updates.checkIntervalHours, 6);
|
||||
assert.strictEqual(s!.updateServer, 'https://etherpad.org/ep_infos');
|
||||
});
|
||||
|
||||
it('disables all outbound calls when the offline env vars are set', function () {
|
||||
process.env.PRIVACY_UPDATE_CHECK = 'false';
|
||||
process.env.PRIVACY_PLUGIN_CATALOG = 'false';
|
||||
process.env.UPDATES_TIER = 'off';
|
||||
const s = exportedForTestingOnly.parseSettings(dockerSettings, true);
|
||||
// Coerced to real booleans, not the strings "false".
|
||||
assert.strictEqual(s!.privacy.updateCheck, false);
|
||||
assert.strictEqual(s!.privacy.pluginCatalog, false);
|
||||
assert.strictEqual(s!.updates.tier, 'off');
|
||||
});
|
||||
|
||||
it('honours the remaining updates.* and updateServer overrides', function () {
|
||||
process.env.UPDATES_SOURCE = 'gitlab';
|
||||
process.env.UPDATES_CHANNEL = 'beta';
|
||||
process.env.UPDATES_CHECK_INTERVAL_HOURS = '24';
|
||||
process.env.UPDATES_GITHUB_REPO = 'acme/etherpad-fork';
|
||||
process.env.UPDATES_REQUIRE_ADMIN_FOR_STATUS = 'true';
|
||||
process.env.UPDATE_SERVER = 'https://mirror.internal/ep_infos';
|
||||
const s = exportedForTestingOnly.parseSettings(dockerSettings, true);
|
||||
assert.strictEqual(s!.updates.source, 'gitlab');
|
||||
assert.strictEqual(s!.updates.channel, 'beta');
|
||||
assert.strictEqual(s!.updates.checkIntervalHours, 24); // numeric coercion
|
||||
assert.strictEqual(s!.updates.githubRepo, 'acme/etherpad-fork');
|
||||
assert.strictEqual(s!.updates.requireAdminForStatus, true); // boolean coercion
|
||||
assert.strictEqual(s!.updateServer, 'https://mirror.internal/ep_infos');
|
||||
});
|
||||
|
||||
// The source-install template carries the same placeholders so non-Docker
|
||||
// deployments get the offline knobs too.
|
||||
it('settings.json.template exposes the same offline overrides', function () {
|
||||
for (const v of envVars) delete process.env[v];
|
||||
const dflt = exportedForTestingOnly.parseSettings(templateSettings, true);
|
||||
assert.strictEqual(dflt!.privacy.updateCheck, true);
|
||||
assert.strictEqual(dflt!.privacy.pluginCatalog, true);
|
||||
assert.strictEqual(dflt!.updates.tier, 'notify');
|
||||
assert.strictEqual(dflt!.updateServer, 'https://etherpad.org/ep_infos');
|
||||
|
||||
process.env.UPDATES_TIER = 'off';
|
||||
process.env.PRIVACY_UPDATE_CHECK = 'false';
|
||||
process.env.PRIVACY_PLUGIN_CATALOG = 'false';
|
||||
const over = exportedForTestingOnly.parseSettings(templateSettings, true);
|
||||
assert.strictEqual(over!.updates.tier, 'off');
|
||||
assert.strictEqual(over!.privacy.updateCheck, false);
|
||||
assert.strictEqual(over!.privacy.pluginCatalog, false);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for the jsonminify -> jsonc-parser migration.
|
||||
// The old parser was `jsonminify(str).replace(',]', ']').replace(',}', '}')`,
|
||||
// which had two correctness bugs that jsonc-parser (allowTrailingComma) fixes:
|
||||
// 1. String#replace with a string needle only swaps the FIRST match, so a
|
||||
// file with more than one trailing comma of the same kind stayed invalid.
|
||||
// 2. The blind ',]' / ',}' replace also corrupted those byte sequences when
|
||||
// they appeared *inside* a string value (e.g. a URL or literal text).
|
||||
describe('JSONC settings parsing (jsonminify -> jsonc-parser)', function () {
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
let tmpFile: string;
|
||||
|
||||
const writeTmp = (contents: string) => {
|
||||
tmpFile = path.join(os.tmpdir(), `ep-settings-jsonc-${process.pid}.json`);
|
||||
fs.writeFileSync(tmpFile, contents);
|
||||
return tmpFile;
|
||||
};
|
||||
|
||||
afterEach(function () {
|
||||
if (tmpFile) { try { fs.unlinkSync(tmpFile); } catch (e) { /* ignore */ } }
|
||||
});
|
||||
|
||||
it('strips comments and tolerates multiple trailing commas', function () {
|
||||
const file = writeTmp(`// leading line comment
|
||||
/* block comment */
|
||||
{
|
||||
"list": [
|
||||
"a",
|
||||
"b",
|
||||
],
|
||||
"nested": [
|
||||
[1, 2,],
|
||||
[3, 4,],
|
||||
],
|
||||
"obj": {
|
||||
"x": 1,
|
||||
"y": 2,
|
||||
},
|
||||
}`);
|
||||
const s: any = exportedForTestingOnly.parseSettings(file, true);
|
||||
assert.deepEqual(s.list, ['a', 'b']);
|
||||
assert.deepEqual(s.nested, [[1, 2], [3, 4]]);
|
||||
assert.deepEqual(s.obj, {x: 1, y: 2});
|
||||
});
|
||||
|
||||
it('does not corrupt ",]" / ",}" sequences inside string values', function () {
|
||||
const file = writeTmp(`{
|
||||
"url": "http://example.com/a,]b,}c",
|
||||
"text": "trailing-comma-like ,] and ,} must survive"
|
||||
}`);
|
||||
const s: any = exportedForTestingOnly.parseSettings(file, true);
|
||||
// The old replace() would have stripped the comma and produced
|
||||
// "http://example.com/a]b}c" here.
|
||||
assert.strictEqual(s.url, 'http://example.com/a,]b,}c');
|
||||
assert.strictEqual(s.text, 'trailing-comma-like ,] and ,} must survive');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ describe(__filename, function () {
|
|||
plugins.hooks[hookName] = [];
|
||||
}
|
||||
backups.settings = {};
|
||||
for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users', 'enablePadWideSettings']) {
|
||||
for (const setting of ['editOnly', 'requireAuthentication', 'requireAuthorization', 'users', 'enablePadWideSettings', 'allowPadDeletionByAllUsers']) {
|
||||
// @ts-ignore
|
||||
backups.settings[setting] = settings[setting];
|
||||
}
|
||||
|
|
@ -492,6 +492,170 @@ describe(__filename, function () {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Pad deletion token issuance (#7926)', function () {
|
||||
let getAuthorIdBackup: any;
|
||||
|
||||
const removeIfExists = async (padId: string) => {
|
||||
if (await padManager.doesPadExist(padId)) {
|
||||
const p = await padManager.getPad(padId);
|
||||
await p.remove();
|
||||
}
|
||||
};
|
||||
|
||||
// A getAuthorId hook that pins authorID to the authenticated username — the
|
||||
// documented way (doc/api/hooks_server-side) to give a user a stable
|
||||
// identity across cookie clears and devices. Its mere presence is what makes
|
||||
// an authenticated session "durable" for token-suppression purposes.
|
||||
const installStableIdentityHook = () => {
|
||||
plugins.hooks.getAuthorId = [{hook_fn: async (hookName: string, context: any) => {
|
||||
const username = context.user && context.user.username;
|
||||
if (!username) return;
|
||||
context.dbKey = `username=${username}`;
|
||||
return '';
|
||||
}}];
|
||||
};
|
||||
|
||||
beforeEach(async function () {
|
||||
// @ts-ignore - public setting toggled per test
|
||||
settings.allowPadDeletionByAllUsers = false;
|
||||
// The outer harness only backs up preAuthorize/authenticate/authorize, so
|
||||
// manage getAuthorId ourselves to avoid leaking it into later specs.
|
||||
getAuthorIdBackup = plugins.hooks.getAuthorId;
|
||||
plugins.hooks.getAuthorId = [];
|
||||
await removeIfExists('pad');
|
||||
});
|
||||
afterEach(async function () {
|
||||
if (socket) socket.close();
|
||||
socket = null;
|
||||
plugins.hooks.getAuthorId = getAuthorIdBackup;
|
||||
await removeIfExists('pad');
|
||||
});
|
||||
|
||||
it('anonymous creator receives a deletion token by default', async function () {
|
||||
const res = await agent.get('/p/pad').expect(200);
|
||||
socket = await common.connect(res);
|
||||
const cv: any = await common.handshake(socket, 'pad');
|
||||
assert.equal(cv.type, 'CLIENT_VARS');
|
||||
assert.equal(typeof cv.data.padDeletionToken, 'string',
|
||||
'creator should get a token so the client can show the save-token modal');
|
||||
assert.ok(cv.data.padDeletionToken.length >= 32);
|
||||
assert.equal(cv.data.canDeleteWithoutToken, false);
|
||||
// The creator can always delete without a token on this device, so the
|
||||
// plain "Delete pad" button is offered (issue #7959).
|
||||
assert.equal(cv.data.canDeletePad, true);
|
||||
});
|
||||
|
||||
it('no token (and so no modal) when allowPadDeletionByAllUsers is true', async function () {
|
||||
// @ts-ignore - public setting
|
||||
settings.allowPadDeletionByAllUsers = true;
|
||||
const res = await agent.get('/p/pad').expect(200);
|
||||
socket = await common.connect(res);
|
||||
const cv: any = await common.handshake(socket, 'pad');
|
||||
assert.equal(cv.type, 'CLIENT_VARS');
|
||||
// A null token means showDeletionTokenModalIfPresent() returns early on the
|
||||
// client, so the "Save your pad deletion token" modal never appears. Anyone
|
||||
// can already delete the pad without a token in this configuration.
|
||||
assert.equal(cv.data.padDeletionToken, null);
|
||||
assert.equal(cv.data.canDeleteWithoutToken, true);
|
||||
assert.equal(cv.data.canDeletePad, true);
|
||||
});
|
||||
|
||||
it('non-creator gets canDeletePad=false by default, true under allowPadDeletionByAllUsers (#7959)',
|
||||
async function () {
|
||||
const supertest = require('supertest');
|
||||
// The creator (default cookie jar) establishes the pad's rev-0 author.
|
||||
const resCreator = await agent.get('/p/pad').expect(200);
|
||||
socket = await common.connect(resCreator);
|
||||
const cvCreator: any = await common.handshake(socket, 'pad');
|
||||
assert.equal(cvCreator.data.canDeletePad, true, 'creator can always delete');
|
||||
|
||||
// A different browser (separate cookie jar) is NOT the creator, so with
|
||||
// allowPadDeletionByAllUsers off it must not be offered the token-less
|
||||
// Delete pad button.
|
||||
const otherBrowser = supertest(common.baseUrl);
|
||||
const resOther = await otherBrowser.get('/p/pad').expect(200);
|
||||
const otherSocket = await common.connect(resOther);
|
||||
try {
|
||||
const cvOther: any = await common.handshake(otherSocket, 'pad');
|
||||
assert.equal(cvOther.data.canDeletePad, false,
|
||||
'non-creator must not see Delete pad by default');
|
||||
} finally {
|
||||
otherSocket.close();
|
||||
}
|
||||
|
||||
// With everyone opted in, the same non-creator CAN delete, so the
|
||||
// button must be offered — independent of enablePadWideSettings (#7959).
|
||||
// @ts-ignore - public setting toggled per test
|
||||
settings.allowPadDeletionByAllUsers = true;
|
||||
const otherBrowser2 = supertest(common.baseUrl);
|
||||
const resOther2 = await otherBrowser2.get('/p/pad').expect(200);
|
||||
const otherSocket2 = await common.connect(resOther2);
|
||||
try {
|
||||
const cvOther2: any = await common.handshake(otherSocket2, 'pad');
|
||||
assert.equal(cvOther2.data.canDeletePad, true,
|
||||
'allowPadDeletionByAllUsers must offer Delete pad to everyone');
|
||||
} finally {
|
||||
otherSocket2.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('readonly viewer is denied canDeletePad and token-less deletion under allowPadDeletionByAllUsers (#7959)',
|
||||
async function () {
|
||||
// @ts-ignore - public setting toggled per test
|
||||
settings.allowPadDeletionByAllUsers = true;
|
||||
// Creator establishes the pad (rev-0 author) and yields its read-only id.
|
||||
const resCreator = await agent.get('/p/pad').expect(200);
|
||||
const creatorSocket = await common.connect(resCreator);
|
||||
const cvCreator: any = await common.handshake(creatorSocket, 'pad');
|
||||
const readOnlyId = cvCreator.data.readOnlyId;
|
||||
assert.ok(readOnlyManager.isReadOnlyId(readOnlyId));
|
||||
creatorSocket.close();
|
||||
|
||||
// A read-only viewer must NOT be offered the token-less delete button,
|
||||
// even with deletion opened to all users — readonly viewers cannot edit,
|
||||
// let alone delete (issue #7959).
|
||||
const resRo = await agent.get(`/p/${readOnlyId}`).expect(200);
|
||||
socket = await common.connect(resRo);
|
||||
const cvRo: any = await common.handshake(socket, readOnlyId);
|
||||
assert.equal(cvRo.data.readonly, true);
|
||||
assert.equal(cvRo.data.canDeletePad, false,
|
||||
'readonly viewers must not get the token-less Delete pad button');
|
||||
|
||||
// ...and the server must refuse a token-less PAD_DELETE from a readonly
|
||||
// session, or allowPadDeletionByAllUsers becomes a data-loss hole.
|
||||
await common.sendPadDelete(socket, {padId: 'pad'}).catch(() => {});
|
||||
assert.ok(await padManager.doesPadExist('pad'),
|
||||
'readonly session must not be able to delete the pad without a token');
|
||||
});
|
||||
|
||||
it('authenticated creator WITHOUT a getAuthorId hook still gets a token', async function () {
|
||||
// requireAuthentication alone is NOT durable: the authorID still comes from
|
||||
// the per-browser token cookie, so this user would be stranded on a second
|
||||
// device if the token were withheld. They must keep getting one.
|
||||
settings.requireAuthentication = true;
|
||||
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
||||
socket = await common.connect(res);
|
||||
const cv: any = await common.handshake(socket, 'pad');
|
||||
assert.equal(cv.type, 'CLIENT_VARS');
|
||||
assert.equal(typeof cv.data.padDeletionToken, 'string');
|
||||
assert.equal(cv.data.canDeleteWithoutToken, false);
|
||||
assert.equal(cv.data.canDeletePad, true);
|
||||
});
|
||||
|
||||
it('authenticated creator WITH a getAuthorId hook gets no token (durable identity)',
|
||||
async function () {
|
||||
settings.requireAuthentication = true;
|
||||
installStableIdentityHook();
|
||||
const res = await agent.get('/p/pad').auth('user', 'user-password').expect(200);
|
||||
socket = await common.connect(res);
|
||||
const cv: any = await common.handshake(socket, 'pad');
|
||||
assert.equal(cv.type, 'CLIENT_VARS');
|
||||
assert.equal(cv.data.padDeletionToken, null);
|
||||
assert.equal(cv.data.canDeleteWithoutToken, true);
|
||||
assert.equal(cv.data.canDeletePad, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SocketIORouter.js', function () {
|
||||
const Module = class {
|
||||
setSocketIO(io:any) {}
|
||||
|
|
|
|||
|
|
@ -13,15 +13,16 @@
|
|||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const jsonminify = require('jsonminify');
|
||||
const {parse: parseJsonc} = require('jsonc-parser');
|
||||
|
||||
function loadSettings() {
|
||||
let settingsStr = fs.readFileSync(`${__dirname}/../../../settings.json.docker`).toString();
|
||||
// try to parse the settings
|
||||
try {
|
||||
if (settingsStr) {
|
||||
settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}');
|
||||
const settings = JSON.parse(settingsStr);
|
||||
// jsonc-parser tolerates the comments and trailing commas in the docker
|
||||
// settings file, matching node/utils/Settings.ts.
|
||||
const settings = parseJsonc(settingsStr, [], {allowTrailingComma: true});
|
||||
|
||||
// custom settings for running in a container
|
||||
settings.ip = 'localhost';
|
||||
|
|
|
|||
29
src/tests/downstream/clients.json
Normal file
29
src/tests/downstream/clients.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[
|
||||
{
|
||||
"name": "etherpad-pad",
|
||||
"repo": "https://github.com/ether/pad.git",
|
||||
"ref": "91620c67c49536bb77e90b39f298ea70ae93c4a0",
|
||||
"kind": "rust",
|
||||
"enabled": true,
|
||||
"vectorTest": "cargo test --test vectors",
|
||||
"smokeCmd": "cargo test --test smoke -- --ignored"
|
||||
},
|
||||
{
|
||||
"name": "etherpad-cli-client",
|
||||
"repo": "https://github.com/ether/etherpad-cli-client.git",
|
||||
"ref": "ebc516ef1a4e7a0c97ccd7a3f2db65e99f8e177c",
|
||||
"kind": "node",
|
||||
"enabled": true,
|
||||
"vectorTest": "pnpm run test:vectors",
|
||||
"smokeCmd": "pnpm run test:smoke"
|
||||
},
|
||||
{
|
||||
"name": "etherpad-desktop",
|
||||
"repo": "https://github.com/ether/etherpad-desktop.git",
|
||||
"ref": "ab83da645b8683afbbc203e4a3fa6f3622a55709",
|
||||
"kind": "desktop",
|
||||
"enabled": true,
|
||||
"vectorTest": "pnpm run test:vectors",
|
||||
"smokeCmd": "pnpm run test:smoke"
|
||||
}
|
||||
]
|
||||
86
src/tests/downstream/run-clients.sh
Executable file
86
src/tests/downstream/run-clients.sh
Executable file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Runs each enabled downstream client from clients.json against an already-booted
|
||||
# Etherpad: clone @ pinned ref, set up its toolchain, point it at core's freshly
|
||||
# generated wire-vectors fixture, then run the client's vectorTest + smokeCmd.
|
||||
#
|
||||
# The fixture is injected via $ETHERPAD_WIRE_VECTORS (absolute) so clients test
|
||||
# against CURRENT core's serialization, not their vendored snapshot. The smoke
|
||||
# reaches the server via $ETHERPAD_SMOKE_URL + $ETHERPAD_SMOKE_APIKEY.
|
||||
#
|
||||
# Env (all optional except APIKEY):
|
||||
# SMOKE_URL default http://localhost:9003
|
||||
# SMOKE_APIKEY required for the live smoke (clients skip cleanly without it)
|
||||
# MANIFEST default src/tests/downstream/clients.json (relative to repo root)
|
||||
# WIRE_VECTORS default <repo>/src/tests/fixtures/wire-vectors.json
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
||||
MANIFEST="${MANIFEST:-$REPO_ROOT/src/tests/downstream/clients.json}"
|
||||
WIRE_VECTORS="${WIRE_VECTORS:-$REPO_ROOT/src/tests/fixtures/wire-vectors.json}"
|
||||
SMOKE_URL="${SMOKE_URL:-http://localhost:9003}"
|
||||
SMOKE_APIKEY="${SMOKE_APIKEY:-}"
|
||||
|
||||
[ -f "$WIRE_VECTORS" ] || { echo "::error::fixture not found: $WIRE_VECTORS"; exit 1; }
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
MANIFEST="$MANIFEST" node -e '
|
||||
const c = require(process.env.MANIFEST).filter((x) => x.enabled);
|
||||
for (const x of c) {
|
||||
process.stdout.write([x.name, x.repo, x.ref, x.kind, x.vectorTest, x.smokeCmd].join("\t") + "\n");
|
||||
}
|
||||
' > "$WORK/clients.tsv"
|
||||
|
||||
if [ ! -s "$WORK/clients.tsv" ]; then
|
||||
echo "No downstream clients enabled. Nothing to run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
export ETHERPAD_WIRE_VECTORS="$WIRE_VECTORS"
|
||||
export ETHERPAD_SMOKE_URL="$SMOKE_URL"
|
||||
export ETHERPAD_SMOKE_APIKEY="$SMOKE_APIKEY"
|
||||
|
||||
fail=0
|
||||
while IFS=$'\t' read -r name repo ref kind vectorTest smokeCmd; do
|
||||
echo "::group::$name ($kind) @ ${ref:0:12}"
|
||||
dir="$WORK/$name"
|
||||
# Everything — clone, checkout, AND the tests — runs inside one guarded
|
||||
# subshell so a single client's failure becomes a per-client failure (fail=1)
|
||||
# and the loop continues to the rest. NOTE: `set -e` is suspended inside a
|
||||
# subshell used as an `||` operand, so every step is guarded with an explicit
|
||||
# `|| exit 1` rather than relying on `set -e`. The manifest commands are a
|
||||
# trusted in-repo allowlist; running them via `bash -euo pipefail -c` (not
|
||||
# `eval`) keeps them out of this script's own shell and applies strict mode
|
||||
# (pipeline-stage failures surface) inside the child.
|
||||
(
|
||||
git clone --quiet "$repo" "$dir" || exit 1
|
||||
# A default clone has all branch heads; fetch the pinned commit only if it
|
||||
# is not already reachable (e.g. a non-branch-tip SHA). Fetch errors are
|
||||
# NOT suppressed so the real cause surfaces instead of a vague checkout fail.
|
||||
if ! git -C "$dir" cat-file -e "${ref}^{commit}" 2>/dev/null; then
|
||||
git -C "$dir" fetch --quiet origin "$ref" || exit 1
|
||||
fi
|
||||
git -C "$dir" checkout --quiet "$ref" || exit 1
|
||||
|
||||
cd "$dir" || exit 1
|
||||
case "$kind" in
|
||||
rust)
|
||||
bash -euo pipefail -c "$vectorTest" || exit 1
|
||||
bash -euo pipefail -c "$smokeCmd" || exit 1
|
||||
;;
|
||||
node|desktop)
|
||||
pnpm install || exit 1
|
||||
bash -euo pipefail -c "$vectorTest" || exit 1
|
||||
bash -euo pipefail -c "$smokeCmd" || exit 1
|
||||
;;
|
||||
*)
|
||||
echo "::error::unknown client kind: $kind"; exit 1
|
||||
;;
|
||||
esac
|
||||
) || { echo "::error::downstream client '$name' failed (clone/checkout/test)"; fail=1; }
|
||||
echo "::endgroup::"
|
||||
done < "$WORK/clients.tsv"
|
||||
|
||||
exit "$fail"
|
||||
62
src/tests/fixtures/wire-vectors.json
vendored
Normal file
62
src/tests/fixtures/wire-vectors.json
vendored
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
[
|
||||
{
|
||||
"name": "plain-insert",
|
||||
"initialText": "abc\n",
|
||||
"changeset": "Z:4>3=3+3$XYZ",
|
||||
"pool": {
|
||||
"numToAttrib": {},
|
||||
"nextNum": 0
|
||||
},
|
||||
"resultText": "abcXYZ\n"
|
||||
},
|
||||
{
|
||||
"name": "plain-delete",
|
||||
"initialText": "abcdef\n",
|
||||
"changeset": "Z:7<3=1-3$",
|
||||
"pool": {
|
||||
"numToAttrib": {},
|
||||
"nextNum": 0
|
||||
},
|
||||
"resultText": "aef\n"
|
||||
},
|
||||
{
|
||||
"name": "formatted-insert",
|
||||
"initialText": "abc\n",
|
||||
"changeset": "Z:4>4=3*0+4$bold",
|
||||
"pool": {
|
||||
"numToAttrib": {
|
||||
"0": [
|
||||
"bold",
|
||||
"true"
|
||||
]
|
||||
},
|
||||
"nextNum": 1
|
||||
},
|
||||
"resultText": "abcbold\n"
|
||||
},
|
||||
{
|
||||
"name": "multiline-insert",
|
||||
"initialText": "abc\n",
|
||||
"changeset": "Z:4>8=3|2+8$one\ntwo\n",
|
||||
"pool": {
|
||||
"numToAttrib": {},
|
||||
"nextNum": 0
|
||||
},
|
||||
"resultText": "abcone\ntwo\n\n"
|
||||
},
|
||||
{
|
||||
"name": "attrib-reuse",
|
||||
"initialText": "abc\n",
|
||||
"changeset": "Z:4>2*0+1=3*0+1$AB",
|
||||
"pool": {
|
||||
"numToAttrib": {
|
||||
"0": [
|
||||
"bold",
|
||||
"true"
|
||||
]
|
||||
},
|
||||
"nextNum": 1
|
||||
},
|
||||
"resultText": "AabcB\n"
|
||||
}
|
||||
]
|
||||
54
src/tests/frontend-new/admin-spec/admin_pads_page.spec.ts
Normal file
54
src/tests/frontend-new/admin-spec/admin_pads_page.spec.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import {expect, test} from "@playwright/test";
|
||||
import {loginToAdmin} from "../helper/adminhelper";
|
||||
import {goToPad, writeToPad} from "../helper/padHelper";
|
||||
|
||||
// End-to-end coverage for issue #7935: a pad that exists must be visible
|
||||
// both on the welcome page's "recent pads" list (driven by localStorage,
|
||||
// i.e. gated on the browser having opened the pad) and in the admin
|
||||
// "Manage pads" UI (driven by the /settings socket `padLoad` handler,
|
||||
// which enumerates the DB). The admin side regressed when a single
|
||||
// unreadable pad record made `padLoad` throw and silently return nothing
|
||||
// — see specs/admin/padLoadResilience.ts for the server-side guard.
|
||||
|
||||
// /admin tests mutate global server state, so keep them serial.
|
||||
test.describe.configure({mode: 'serial'});
|
||||
|
||||
const ADMIN_URL = 'http://localhost:9001/admin';
|
||||
|
||||
test.describe('a created pad shows up on the home page and in /admin', () => {
|
||||
// Unique, URL-safe id so the recent-pads localStorage entry and the admin
|
||||
// search both target exactly this pad and ignore leftovers from other suites.
|
||||
const padId = `pw-pads-7935-${Date.now()}`;
|
||||
|
||||
test('opening a pad lists it in the welcome page recent-pads', async ({page}) => {
|
||||
await goToPad(page, padId);
|
||||
await writeToPad(page, 'hello from 7935');
|
||||
|
||||
// Opening the pad writes it to `recentPads` localStorage (colibris
|
||||
// pad.js). The welcome page renders that list — same browser context,
|
||||
// so the entry carries over.
|
||||
await page.goto('http://localhost:9001/');
|
||||
const recentPad = page.locator('.recent-pad', {hasText: padId});
|
||||
await expect(recentPad).toBeVisible({timeout: 10000});
|
||||
await expect(recentPad.locator('a')).toHaveText(padId);
|
||||
});
|
||||
|
||||
test('the same pad is listed in the admin Manage pads UI', async ({page}) => {
|
||||
await loginToAdmin(page, 'admin', 'changeme1');
|
||||
await page.goto(`${ADMIN_URL}/pads`);
|
||||
|
||||
await expect(page.getByRole('heading', {name: 'Manage pads'}))
|
||||
.toBeVisible({timeout: 30000});
|
||||
|
||||
// Narrow the (full-scan) listing to our pad. The search is debounced
|
||||
// server-side; allow the round-trip to settle.
|
||||
const search = page.getByPlaceholder('Search for pads');
|
||||
await search.fill(padId);
|
||||
|
||||
await expect(page.locator('.pm-pad-title', {hasText: padId}))
|
||||
.toBeVisible({timeout: 15000});
|
||||
// The "No results" empty state must NOT be showing — the exact #7935
|
||||
// symptom was an empty Manage-pads list for pads that demonstrably exist.
|
||||
await expect(page.locator('.pm-empty')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -90,4 +90,28 @@ test.describe('Language select and change', function () {
|
|||
await page.waitForSelector('html[dir="ltr"]')
|
||||
|
||||
});
|
||||
|
||||
// Regression test for #7925: when the UI language is auto-detected from the
|
||||
// browser (no language cookie, no pad-wide lang set), the language dropdown
|
||||
// must reflect the detected language instead of defaulting to English.
|
||||
test('dropdown reflects the browser-detected language', async function ({browser}) {
|
||||
const context = await browser.newContext({locale: 'de-DE'})
|
||||
await context.clearCookies()
|
||||
const page = await context.newPage()
|
||||
try {
|
||||
await goToNewPad(page)
|
||||
|
||||
// The toolbar should have rendered in German (detection works) — the
|
||||
// bold button's parent <li> carries the localized German tooltip.
|
||||
await expect(page.locator('.buttonicon-bold').locator('..'))
|
||||
.toHaveAttribute('title', 'Fett (Strg-B)')
|
||||
|
||||
// ... and the language dropdown must show the detected language, not
|
||||
// English, even though it was never explicitly selected.
|
||||
await showSettings(page)
|
||||
await expect(langDropdown(page).locator('.current')).toHaveText('Deutsch')
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {goToNewPad, goToPad, sendChatMessage, showChat} from "../helper/padHelpe
|
|||
import {showSettings} from "../helper/settingsHelper";
|
||||
|
||||
test.describe('creator-owned pad settings', () => {
|
||||
test('shows pad settings only to the creator and keeps delete pad there', async ({page, browser}) => {
|
||||
test('shows pad settings only to the creator; delete pad is creator-gated but separate', async ({page, browser}) => {
|
||||
const padId = await goToNewPad(page);
|
||||
|
||||
const context2 = await browser.newContext();
|
||||
|
|
@ -19,6 +19,9 @@ test.describe('creator-owned pad settings', () => {
|
|||
await expect(page.locator('#pad-settings-section')).toBeVisible();
|
||||
await expect(page.locator('#delete-pad')).toBeVisible();
|
||||
await expect(page.locator('#padsettings-enforcecheck')).toBeVisible();
|
||||
// The delete-pad button is no longer nested inside the pad-wide settings
|
||||
// section: deletion is independent of enablePadWideSettings (issue #7959).
|
||||
await expect(page.locator('#pad-settings-section #delete-pad')).toHaveCount(0);
|
||||
|
||||
await expect(page2.locator('#user-settings-section > h2')).toHaveText('User Settings');
|
||||
await expect(page2.locator('#theme-toggle-row')).toBeVisible();
|
||||
|
|
|
|||
|
|
@ -59,4 +59,18 @@ test.describe('dark color scheme', () => {
|
|||
// before pad.css so it takes effect at first paint.
|
||||
await expect(page.locator('html')).toHaveClass(/super-dark-editor/);
|
||||
});
|
||||
|
||||
test('root canvas matches the toolbar so the iOS status-bar area is not white',
|
||||
async ({page}) => {
|
||||
await goToNewPad(page);
|
||||
// The <html> root must carry the toolbar colour as its background — iOS
|
||||
// Safari paints the status-bar safe area from the root canvas, not from
|
||||
// theme-color, so leaving it transparent produced a white strip above
|
||||
// the dark pad (issue #7606). #485365 == --super-dark-color, the dark
|
||||
// toolbar colour.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).backgroundColor))
|
||||
.toBe('rgb(72, 83, 101)');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue