mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +00:00
Merge branch 'develop'
This commit is contained in:
commit
3c90fa07c3
67 changed files with 2361 additions and 1160 deletions
12
.github/workflows/backend-tests.yml
vendored
12
.github/workflows/backend-tests.yml
vendored
|
|
@ -32,7 +32,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
@ -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.1
|
||||
with:
|
||||
packages: libreoffice libreoffice-pdfimport
|
||||
version: 1.0
|
||||
|
|
@ -88,7 +88,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
@ -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.1
|
||||
with:
|
||||
packages: libreoffice libreoffice-pdfimport
|
||||
version: 1.0
|
||||
|
|
@ -168,7 +168,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
@ -234,7 +234,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/build-and-deploy-docs.yml
vendored
2
.github/workflows/build-and-deploy-docs.yml
vendored
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
10
.github/workflows/docker.yml
vendored
10
.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
|
||||
-
|
||||
|
|
@ -190,7 +190,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -261,7 +261,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -358,7 +358,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -416,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
|
||||
|
|
|
|||
2
.github/workflows/downstream-smoke.yml
vendored
2
.github/workflows/downstream-smoke.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
APIKEY: downstream-smoke-key
|
||||
steps:
|
||||
- name: Checkout core (PR)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
|
|
|
|||
2
.github/workflows/frontend-admin-tests.yml
vendored
2
.github/workflows/frontend-admin-tests.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
|
|||
8
.github/workflows/frontend-tests.yml
vendored
8
.github/workflows/frontend-tests.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
@ -92,7 +92,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
@ -168,7 +168,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
@ -269,7 +269,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/handleRelease.yml
vendored
2
.github/workflows/handleRelease.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
|
|||
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: |
|
||||
|
|
|
|||
6
.github/workflows/load-test.yml
vendored
6
.github/workflows/load-test.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
@ -62,7 +62,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
@ -125,7 +125,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/perform-type-check.yml
vendored
2
.github/workflows/perform-type-check.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
|
|||
2
.github/workflows/rate-limit.yml
vendored
2
.github/workflows/rate-limit.yml
vendored
|
|
@ -27,7 +27,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v5
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
|
|
|
|||
4
.github/workflows/release.yml
vendored
4
.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,7 +42,7 @@ 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
|
||||
|
|
|
|||
34
.github/workflows/releaseEtherpad.yml
vendored
34
.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
|
||||
|
|
|
|||
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,7 +32,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out latest release
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
-
|
||||
|
|
@ -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.1
|
||||
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
|
||||
|
||||
|
|
|
|||
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -1,3 +1,39 @@
|
|||
# 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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm gen:api && vite",
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@radix-ui/react-switch": "^1.3.1",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
|
|
@ -22,27 +22,27 @@
|
|||
"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.17",
|
||||
"@radix-ui/react-toast": "^1.2.17",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.6",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.1",
|
||||
"@typescript-eslint/parser": "^8.61.1",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"i18next": "^26.3.1",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"lucide-react": "^1.21.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"react-hook-form": "^7.80.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
|
|
|
|||
|
|
@ -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.1",
|
||||
"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.3",
|
||||
"semver": "^7.8.4",
|
||||
"tsx": "^4.22.4",
|
||||
"ueberdb2": "^6.1.9"
|
||||
"ueberdb2": "6.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"oxc-minify": "^0.135.0",
|
||||
"oxc-minify": "^0.137.0",
|
||||
"vitepress": "^2.0.0-alpha.17"
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
"url": "https://github.com/ether/etherpad.git"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.2",
|
||||
"license": "Apache-2.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
|
|||
1576
pnpm-lock.yaml
generated
1576
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -21,20 +21,26 @@ 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'
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
},
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -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}} адключаны. Калі ласка, зьвярніцеся да вашага сыстэмнага адміністратара па падрабязнасьці.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"Derugon",
|
||||
"Envlh",
|
||||
"Framafan",
|
||||
"Framasky",
|
||||
"Fylip22",
|
||||
"Gomoko",
|
||||
"Goofy",
|
||||
|
|
@ -37,13 +38,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 +112,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 +234,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,6 +256,9 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"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 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.modals.connected": "Connecté.",
|
||||
"pad.modals.reconnecting": "Reconnexion à votre bloc-notes en cours...",
|
||||
|
|
|
|||
|
|
@ -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": "Увезувам...",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"Stang",
|
||||
"TFX202X",
|
||||
"VulpesVulpes825",
|
||||
"XtexChooser",
|
||||
"Yfdyh000",
|
||||
"乌拉跨氪",
|
||||
"列维劳德",
|
||||
|
|
@ -26,6 +27,20 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "管理员面板 - Etherpad",
|
||||
"admin.loading": "正在加载…",
|
||||
"admin.loading_description": "页面加载中,请稍安勿躁。",
|
||||
"admin_login.username": "用户名",
|
||||
"admin_login.password": "密码",
|
||||
"admin_login.submit": "登录",
|
||||
"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,15 +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 — and so the client never shows the "Save your pad
|
||||
// deletion token" modal (issue #7926) — when the token cannot help:
|
||||
// - requireAuthentication: every creator already has a stable identity, so
|
||||
// the cookie/identity path is sufficient.
|
||||
// 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), so a recovery token is noise
|
||||
// and the modal only overwhelms users who will never need it.
|
||||
// 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 && !settings.requireAuthentication && !settings.allowPadDeletionByAllUsers
|
||||
isCreator && !canDeleteWithoutToken
|
||||
? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId)
|
||||
: null;
|
||||
|
||||
|
|
@ -1314,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().
|
||||
|
|
|
|||
|
|
@ -305,13 +305,22 @@ exports.socketio = (hookName: string, {io}: any) => {
|
|||
socket.on('deletePad', async (padId: string) => {
|
||||
try {
|
||||
if (await padManager.doesPadExists(padId)) {
|
||||
// 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;
|
||||
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
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@
|
|||
"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",
|
||||
|
|
@ -61,15 +61,15 @@
|
|||
"mammoth": "^1.12.0",
|
||||
"measured-core": "^2.0.0",
|
||||
"mime-types": "^3.0.2",
|
||||
"mongodb": "^7.1.1",
|
||||
"mongodb": "^7.3.0",
|
||||
"mssql": "^12.5.5",
|
||||
"mysql2": "^3.22.5",
|
||||
"nano": "^11.0.5",
|
||||
"nodemailer": "^8.0.10",
|
||||
"oidc-provider": "9.8.4",
|
||||
"nodemailer": "^9.0.1",
|
||||
"oidc-provider": "9.8.5",
|
||||
"openapi-backend": "^5.17.0",
|
||||
"pdfkit": "^0.19.0",
|
||||
"pg": "^8.21.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.22.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"rate-limiter-flexible": "^11.2.0",
|
||||
|
|
@ -80,16 +80,16 @@
|
|||
"rethinkdb": "^2.4.2",
|
||||
"rusty-store-kv": "^1.3.1",
|
||||
"security": "1.0.0",
|
||||
"semver": "^7.8.3",
|
||||
"semver": "^7.8.4",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"superagent": "10.3.0",
|
||||
"surrealdb": "^2.0.3",
|
||||
"tinycon": "0.6.8",
|
||||
"tsx": "4.22.4",
|
||||
"ueberdb2": "^6.1.9",
|
||||
"ueberdb2": "6.1.13",
|
||||
"underscore": "1.13.8",
|
||||
"undici": "^8.4.1",
|
||||
"undici": "^8.5.0",
|
||||
"unorm": "1.6.0",
|
||||
"wtfnode": "^0.10.1"
|
||||
},
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
"etherpad-lite": "node/server.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@playwright/test": "^1.61.0",
|
||||
"@types/async": "^3.2.25",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
|
|
@ -114,8 +114,8 @@
|
|||
"@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.0.0",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/oidc-provider": "^9.5.0",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
|
|
@ -124,7 +124,7 @@
|
|||
"@types/underscore": "^1.13.0",
|
||||
"@types/whatwg-mimetype": "^5.0.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint": "^10.5.0",
|
||||
"eslint-config-etherpad": "^4.0.5",
|
||||
"etherpad-cli-client": "^4.0.3",
|
||||
"mocha": "^11.7.6",
|
||||
|
|
@ -136,7 +136,7 @@
|
|||
"split-grid": "^1.0.11",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
|
|
@ -164,6 +164,6 @@
|
|||
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts",
|
||||
"test:vitest": "vitest"
|
||||
},
|
||||
"version": "3.3.1",
|
||||
"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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -177,4 +177,27 @@ describe(__filename, function () {
|
|||
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});
|
||||
|
|
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -197,4 +197,85 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -493,6 +493,8 @@ 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);
|
||||
|
|
@ -500,14 +502,32 @@ describe(__filename, function () {
|
|||
}
|
||||
};
|
||||
|
||||
// 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');
|
||||
});
|
||||
|
||||
|
|
@ -519,6 +539,10 @@ describe(__filename, function () {
|
|||
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 () {
|
||||
|
|
@ -532,7 +556,104 @@ describe(__filename, function () {
|
|||
// 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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
53
src/tests/frontend-new/specs/timeslider_deeplink.spec.ts
Normal file
53
src/tests/frontend-new/specs/timeslider_deeplink.spec.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import {expect, Page, test} from "@playwright/test";
|
||||
import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper";
|
||||
|
||||
// Ported from the "jumps to a revision given in the url" case of the legacy
|
||||
// timeslider_revisions.js (which no CI workflow ran). Re-targeted at the in-pad
|
||||
// history model (#7659): a #rev/N hash on the pad URL boots straight into
|
||||
// history mode at that revision (pad_mode.bootstrapFromHash), and the legacy
|
||||
// #N shortlink form is still accepted for old bookmarks.
|
||||
test.describe('timeslider deep link', function () {
|
||||
test.describe.configure({mode: 'serial'});
|
||||
|
||||
test.beforeEach(async ({context}) => {
|
||||
await context.clearCookies();
|
||||
});
|
||||
|
||||
const expectHistoryAtRev0 = async (page: Page) => {
|
||||
await expect(page.locator('body.history-mode')).toBeVisible({timeout: 15000});
|
||||
await expect(page.locator('#history-controls')).toBeVisible();
|
||||
// bootstrapFromHash canonicalizes any accepted hash form to #rev/0.
|
||||
await expect.poll(() => new URL(page.url()).hash, {timeout: 15000}).toBe('#rev/0');
|
||||
// The slider lands on revision 0 once pad_mode syncs from the embedded
|
||||
// BroadcastSlider — the signal that we're actually viewing that revision.
|
||||
await expect.poll(
|
||||
async () => await page.locator('#history-slider-input').evaluate(
|
||||
(el) => Number((el as HTMLInputElement).value)),
|
||||
{timeout: 15000}).toBe(0);
|
||||
};
|
||||
|
||||
test('#rev/N hash boots into history mode at that revision', async function ({page}) {
|
||||
const padId = await goToNewPad(page);
|
||||
await clearPadContent(page);
|
||||
await writeToPad(page, 'One ');
|
||||
await page.waitForTimeout(400);
|
||||
await writeToPad(page, 'Two ');
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
// Deep-link to revision 0 of the same pad.
|
||||
await page.goto(`http://localhost:9001/p/${padId}#rev/0`);
|
||||
await expectHistoryAtRev0(page);
|
||||
});
|
||||
|
||||
test('legacy #N shortlink hash still enters history mode', async function ({page}) {
|
||||
const padId = await goToNewPad(page);
|
||||
await clearPadContent(page);
|
||||
await writeToPad(page, 'One ');
|
||||
await page.waitForTimeout(400);
|
||||
await writeToPad(page, 'Two ');
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.goto(`http://localhost:9001/p/${padId}#0`);
|
||||
await expectHistoryAtRev0(page);
|
||||
});
|
||||
});
|
||||
88
src/tests/frontend-new/specs/timeslider_export_links.spec.ts
Normal file
88
src/tests/frontend-new/specs/timeslider_export_links.spec.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import {expect, Page, test} from "@playwright/test";
|
||||
import {clearPadContent, goToPad, writeToPad} from "../helper/padHelper";
|
||||
|
||||
// Ported from the legacy mocha suite (timeslider_numeric_padID.js and the
|
||||
// "checks the export url" case in timeslider_revisions.js), neither of which
|
||||
// ran in CI. Re-targeted at the in-pad history UI (#7659): the export links
|
||||
// live in the outer #exportColumn and pad_mode.ts rewrites their hrefs to
|
||||
// /p/<pad>/<rev>/export/<type> for the revision currently being viewed.
|
||||
test.describe('timeslider export links', function () {
|
||||
test.describe.configure({mode: 'serial'});
|
||||
|
||||
test.beforeEach(async ({context}) => {
|
||||
await context.clearCookies();
|
||||
});
|
||||
|
||||
// Suppress the one-time pad-deletion-token modal (same trick goToNewPad uses)
|
||||
// so it can't steal focus mid-test on a creator session.
|
||||
const suppressDeletionTokenModal = async (page: Page) => {
|
||||
await page.addInitScript(() => {
|
||||
let stored: unknown;
|
||||
Object.defineProperty(window, 'clientVars', {
|
||||
configurable: true,
|
||||
get() { return stored; },
|
||||
set(v) {
|
||||
if (v != null && typeof v === 'object') {
|
||||
(v as {padDeletionToken?: string | null}).padDeletionToken = null;
|
||||
}
|
||||
stored = v;
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const enterHistoryMode = async (page: Page) => {
|
||||
await page.click('.buttonicon-history');
|
||||
await page.waitForSelector('#history-controls:not([hidden])', {state: 'visible'});
|
||||
await page.waitForSelector('#history-frame');
|
||||
};
|
||||
|
||||
const goToRevision = async (page: Page, rev: number) => {
|
||||
await page.locator('#history-slider-input').evaluate((el, value) => {
|
||||
(el as HTMLInputElement).value = String(value);
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}, rev);
|
||||
await expect(page.locator('#history-banner-rev')).toHaveText(`Version ${rev}`, {timeout: 15000});
|
||||
};
|
||||
|
||||
const exportHref = (page: Page, id: string) =>
|
||||
page.locator(`#${id}`).getAttribute('href');
|
||||
|
||||
test('export hrefs target the viewed revision, including a numeric pad id', async function ({page}) {
|
||||
// A numeric pad id is the specific case the legacy test guarded — the
|
||||
// href rewriter must not confuse it with the revision segment. Use a
|
||||
// high-entropy numeric id (timestamp + random) so reruns against a
|
||||
// persistent DB can't collide on the same pad.
|
||||
const padId = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
await suppressDeletionTokenModal(page);
|
||||
await goToPad(page, padId); // navigates and waits for the editor to be ready
|
||||
await clearPadContent(page);
|
||||
|
||||
await writeToPad(page, 'One ');
|
||||
await page.waitForTimeout(400);
|
||||
await writeToPad(page, 'Two ');
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await enterHistoryMode(page);
|
||||
|
||||
// Wait for pad_mode to sync the slider max from the embedded BroadcastSlider.
|
||||
await expect.poll(
|
||||
async () => await page.locator('#history-slider-input').evaluate(
|
||||
(el) => Number((el as HTMLInputElement).max)),
|
||||
{timeout: 15000}).toBeGreaterThan(0);
|
||||
const maxRev = await page.locator('#history-slider-input').evaluate(
|
||||
(el) => Number((el as HTMLInputElement).max));
|
||||
expect(maxRev).toBeGreaterThan(0);
|
||||
|
||||
// On entry the slider is at the latest revision; hrefs point there.
|
||||
await expect.poll(() => exportHref(page, 'exporthtmla'), {timeout: 15000})
|
||||
.toContain(`/${padId}/${maxRev}/export/html`);
|
||||
expect(await exportHref(page, 'exportplaina')).toContain(`/${padId}/${maxRev}/export/txt`);
|
||||
|
||||
// Scrub to revision 0 — the export targets must follow.
|
||||
await goToRevision(page, 0);
|
||||
await expect.poll(() => exportHref(page, 'exporthtmla'), {timeout: 15000})
|
||||
.toContain(`/${padId}/0/export/html`);
|
||||
expect(await exportHref(page, 'exportplaina')).toContain(`/${padId}/0/export/txt`);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import {expect, Page, test} from "@playwright/test";
|
||||
import {clearPadContent, goToNewPad, writeToPad} from "../helper/padHelper";
|
||||
|
||||
// Ported from the legacy mocha suite (src/tests/frontend/specs/timeslider_labels.js),
|
||||
// which no CI workflow ran. Re-targeted at the in-pad history UI (#7659): the
|
||||
// revision label and date are shown in the outer #history-banner, populated by
|
||||
// pad_mode.ts mirroring the embedded timeslider's #revision_label / #revision_date.
|
||||
// This guards the banner bridge against silently breaking again (cf. #7946).
|
||||
test.describe('timeslider revision labels', function () {
|
||||
test.describe.configure({mode: 'serial'});
|
||||
// The "Version N" label and "Saved <Month> <day>, <year>" date are localized
|
||||
// (timeslider.version / timeslider.saved). Pin the locale so the assertions
|
||||
// are deterministic and the date string stays Date-parseable.
|
||||
test.use({locale: 'en-US'});
|
||||
|
||||
test.beforeEach(async ({context}) => {
|
||||
await context.clearCookies();
|
||||
});
|
||||
|
||||
const enterHistoryMode = async (page: Page) => {
|
||||
await page.click('.buttonicon-history');
|
||||
await page.waitForSelector('#history-controls:not([hidden])', {state: 'visible'});
|
||||
await page.waitForSelector('#history-frame');
|
||||
};
|
||||
|
||||
// Drive the outer slider (a remote control for the embedded BroadcastSlider)
|
||||
// to a specific revision and wait for the banner to reflect it.
|
||||
const goToRevision = async (page: Page, rev: number) => {
|
||||
await page.locator('#history-slider-input').evaluate((el, value) => {
|
||||
(el as HTMLInputElement).value = String(value);
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}, rev);
|
||||
await expect(page.locator('#history-banner-rev')).toHaveText(`Version ${rev}`, {timeout: 15000});
|
||||
};
|
||||
|
||||
// "Saved June 12, 2026" -> a parseable Date (the banner mirrors the
|
||||
// timeslider.saved l10n string "Saved {{month}} {{day}}, {{year}}").
|
||||
const parsedBannerDate = async (page: Page) => await page.locator('#history-banner-date').evaluate(
|
||||
(el) => new Date((el.textContent || '').replace(/^Saved\s+/i, '')).getTime());
|
||||
|
||||
test('shows Version label and a valid date that update while scrubbing', async function ({page}) {
|
||||
await goToNewPad(page);
|
||||
await clearPadContent(page);
|
||||
|
||||
// Produce a few revisions.
|
||||
await writeToPad(page, 'Alpha ');
|
||||
await page.waitForTimeout(400);
|
||||
await writeToPad(page, 'Beta ');
|
||||
await page.waitForTimeout(400);
|
||||
await writeToPad(page, 'Gamma ');
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await enterHistoryMode(page);
|
||||
|
||||
// On entry the slider sits at the latest revision; the banner must show a
|
||||
// non-empty "Version N" label and a non-NaN date. Wait for pad_mode to sync
|
||||
// the slider max from the embedded BroadcastSlider before reading it.
|
||||
await expect.poll(
|
||||
async () => await page.locator('#history-slider-input').evaluate(
|
||||
(el) => Number((el as HTMLInputElement).max)),
|
||||
{timeout: 15000}).toBeGreaterThan(0);
|
||||
const maxRev = await page.locator('#history-slider-input').evaluate(
|
||||
(el) => Number((el as HTMLInputElement).max));
|
||||
expect(maxRev).toBeGreaterThan(0);
|
||||
|
||||
await expect(page.locator('#history-banner-rev')).toHaveText(`Version ${maxRev}`);
|
||||
const dateLast = await parsedBannerDate(page);
|
||||
expect(Number.isNaN(dateLast)).toBe(false);
|
||||
// The mirrored timer must also be a real, non-NaN datetime.
|
||||
const timerLast = await page.locator('#history-timer').textContent();
|
||||
expect(Number.isNaN(new Date(timerLast || '').getTime())).toBe(false);
|
||||
|
||||
// Scrub back to revision 0 — label and date must update.
|
||||
await goToRevision(page, 0);
|
||||
await expect(page.locator('#history-banner-rev')).toHaveText('Version 0');
|
||||
const dateFirst = await parsedBannerDate(page);
|
||||
expect(Number.isNaN(dateFirst)).toBe(false);
|
||||
// The latest revision is never older than revision 0.
|
||||
expect(dateLast).toBeGreaterThanOrEqual(dateFirst);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import {expect, Page, test} from "@playwright/test";
|
||||
import {clearPadContent, goToNewPad, goToPad, writeToPad} from "../helper/padHelper";
|
||||
|
||||
// Regression coverage for #7946: after #7659 moved the timeslider into the pad
|
||||
// as an embedded iframe, the user-facing control became the outer
|
||||
// #history-slider-input range input. Saved revisions ("Save Revision" button)
|
||||
// are still drawn as stars inside the now-hidden iframe slider, so they
|
||||
// stopped appearing for users. pad_mode.ts must bridge the saved revisions out
|
||||
// onto the outer slider as markers.
|
||||
test.describe('timeslider saved-revision markers', function () {
|
||||
test.describe.configure({mode: 'serial'});
|
||||
|
||||
test.beforeEach(async ({context}) => {
|
||||
await context.clearCookies();
|
||||
});
|
||||
|
||||
const enterHistoryMode = async (page: Page) => {
|
||||
await page.click('.buttonicon-history');
|
||||
await page.waitForSelector('#history-controls:not([hidden])', {state: 'visible'});
|
||||
await page.waitForSelector('#history-frame');
|
||||
};
|
||||
|
||||
test('a saved revision shows a marker on the outer history slider', async function ({page}) {
|
||||
await goToNewPad(page);
|
||||
await clearPadContent(page);
|
||||
|
||||
// Build a few revisions, save one partway through, then add more so the
|
||||
// saved marker lands in the middle of the slider (not under the thumb).
|
||||
await writeToPad(page, 'One ');
|
||||
await page.waitForTimeout(400);
|
||||
await writeToPad(page, 'Two ');
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
// Save a revision at the current head.
|
||||
await page.click('.buttonicon-savedRevision');
|
||||
// The save confirmation gritter carries class `saved-revision`.
|
||||
await page.waitForSelector('.saved-revision', {state: 'visible'});
|
||||
|
||||
// Add more edits so head advances past the saved revision.
|
||||
await writeToPad(page, 'Three ');
|
||||
await page.waitForTimeout(400);
|
||||
await writeToPad(page, 'Four ');
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await enterHistoryMode(page);
|
||||
|
||||
// The outer slider must show at least one visible saved-revision marker.
|
||||
const marker = page.locator('.history-star');
|
||||
await expect(marker.first()).toBeVisible({timeout: 15000});
|
||||
expect(await marker.count()).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The marker must be positioned within the slider track, not collapsed to
|
||||
// the origin. Assert on the inline left percentage directly (markers are
|
||||
// always positioned via style.left = "N%"); a fallback to a layout-derived
|
||||
// coordinate would let a degenerate left:0% render still pass.
|
||||
const leftPct = await marker.first().evaluate(
|
||||
(el) => parseFloat((el as HTMLElement).style.left));
|
||||
expect(leftPct).toBeGreaterThan(0);
|
||||
expect(leftPct).toBeLessThan(100);
|
||||
});
|
||||
|
||||
test('a revision saved live appears on an already-open history slider', async function ({browser}) {
|
||||
// Reviewer (history viewer).
|
||||
const ctxA = await browser.newContext();
|
||||
const pageA = await ctxA.newPage();
|
||||
const padId = await goToNewPad(pageA);
|
||||
await clearPadContent(pageA);
|
||||
await writeToPad(pageA, 'Alpha Beta ');
|
||||
await pageA.waitForTimeout(600);
|
||||
await pageA.click('.buttonicon-savedRevision');
|
||||
await pageA.waitForSelector('.saved-revision', {state: 'visible'});
|
||||
await writeToPad(pageA, 'Gamma Delta ');
|
||||
await pageA.waitForTimeout(800);
|
||||
await enterHistoryMode(pageA);
|
||||
await expect(pageA.locator('.history-star').first()).toBeVisible({timeout: 15000});
|
||||
const before = await pageA.locator('.history-star').count();
|
||||
|
||||
// A second collaborator keeps editing the live pad and saves a revision
|
||||
// while pageA is sitting in history mode. The server must broadcast
|
||||
// NEW_SAVEDREV so pageA's open timeslider gains a marker without reloading.
|
||||
const ctxB = await browser.newContext();
|
||||
const pageB = await ctxB.newPage();
|
||||
await goToPad(pageB, padId);
|
||||
await writeToPad(pageB, 'Epsilon Zeta Eta ');
|
||||
await pageB.waitForTimeout(800);
|
||||
await pageB.click('.buttonicon-savedRevision');
|
||||
await pageB.waitForSelector('.saved-revision', {state: 'visible'});
|
||||
|
||||
await expect.poll(
|
||||
async () => await pageA.locator('.history-star').count(),
|
||||
{timeout: 20000})
|
||||
.toBeGreaterThan(before);
|
||||
|
||||
await ctxA.close();
|
||||
await ctxB.close();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
describe('timeslider', function () {
|
||||
// create a new pad before each test run
|
||||
beforeEach(async function () {
|
||||
await helper.aNewPad();
|
||||
});
|
||||
|
||||
/**
|
||||
* @todo test authorsList
|
||||
*/
|
||||
it("Shows a date/time in the timeslider and make sure it doesn't include NaN", async function () {
|
||||
this.timeout(12000);
|
||||
// make some changes to produce 3 revisions
|
||||
const revs = 3;
|
||||
|
||||
for (let i = 0; i < revs; i++) {
|
||||
await helper.edit('a\n');
|
||||
}
|
||||
|
||||
await helper.gotoTimeslider(revs);
|
||||
await helper.waitForPromise(() => helper.contentWindow().location.hash === `#${revs}`);
|
||||
|
||||
// the datetime of last edit
|
||||
const timerTimeLast = new Date(helper.timesliderTimerTime()).getTime();
|
||||
|
||||
// the day of this revision, e.g. August 12, 2020 (stripped the string "Saved")
|
||||
const dateLast = new Date(helper.revisionDateElem().substr(6)).getTime();
|
||||
|
||||
// the label/revision, ie Version 3
|
||||
const labelLast = helper.revisionLabelElem().text();
|
||||
|
||||
// the datetime should be a date
|
||||
expect(Number.isNaN(timerTimeLast)).to.eql(false);
|
||||
|
||||
// the Date object of the day should not be NaN
|
||||
expect(Number.isNaN(dateLast)).to.eql(false);
|
||||
|
||||
// the label should be Version `Number`
|
||||
expect(labelLast).to.be(`Version ${revs}`);
|
||||
|
||||
// Click somewhere left on the timeslider to go to revision 0
|
||||
helper.sliderClick(1);
|
||||
|
||||
// the datetime of last edit
|
||||
const timerTime = new Date(helper.timesliderTimerTime()).getTime();
|
||||
|
||||
// the day of this revision, e.g. August 12, 2020
|
||||
const date = new Date(helper.revisionDateElem().substr(6)).getTime();
|
||||
|
||||
// the label/revision, e.g. Version 0
|
||||
const label = helper.revisionLabelElem().text();
|
||||
|
||||
// the datetime should be a date
|
||||
expect(Number.isNaN(timerTime)).to.eql(false);
|
||||
// the last revision should be newer or have the same time
|
||||
expect(timerTimeLast).to.not.be.lessThan(timerTime);
|
||||
|
||||
// the Date object of the day should not be NaN
|
||||
expect(Number.isNaN(date)).to.eql(false);
|
||||
|
||||
// the label should be Version 0
|
||||
expect(label).to.be('Version 0');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
describe('timeslider', function () {
|
||||
const padId = 735773577357 + (Math.round(Math.random() * 1000));
|
||||
|
||||
// create a new pad before each test run
|
||||
beforeEach(async function () {
|
||||
await helper.aNewPad({id: padId});
|
||||
});
|
||||
|
||||
it('Makes sure the export URIs are as expected when the padID is numeric', async function () {
|
||||
await helper.edit('a\n');
|
||||
|
||||
await helper.gotoTimeslider(1);
|
||||
|
||||
// ensure we are on revision 1
|
||||
await helper.waitForPromise(() => helper.contentWindow().location.hash === '#1');
|
||||
|
||||
// expect URI to be similar to
|
||||
// http://192.168.1.48:9001/p/2/1/export/html
|
||||
// http://192.168.1.48:9001/p/735773577399/1/export/html
|
||||
const rev1ExportLink = helper.contentWindow().$('#exporthtmla').attr('href');
|
||||
expect(rev1ExportLink).to.contain('/1/export/html');
|
||||
|
||||
// Click somewhere left on the timeslider to go to revision 0
|
||||
helper.sliderClick(30);
|
||||
|
||||
const rev0ExportLink = helper.contentWindow().$('#exporthtmla').attr('href');
|
||||
expect(rev0ExportLink).to.contain('/0/export/html');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
describe('timeslider', function () {
|
||||
// create a new pad before each test run
|
||||
beforeEach(async function () {
|
||||
await helper.aNewPad();
|
||||
});
|
||||
|
||||
it('loads adds a hundred revisions', async function () {
|
||||
this.timeout(100000);
|
||||
const chrome$ = helper.padChrome$;
|
||||
|
||||
// Create a bunch of revisions.
|
||||
for (let i = 0; i < 99; i++) await helper.edit('a');
|
||||
chrome$('.buttonicon-savedRevision').trigger('click');
|
||||
await helper.waitForPromise(() => helper.padChrome$('.saved-revision').length > 0);
|
||||
// Give some time to send the SAVE_REVISION message to the server before navigating away.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// go to timeslider
|
||||
$('#iframe-container iframe')
|
||||
.attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider`);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
|
||||
const timeslider$ = $('#iframe-container iframe')[0].contentWindow.$;
|
||||
const $sliderBar = timeslider$('#ui-slider-bar');
|
||||
|
||||
const latestContents = timeslider$('#innerdocbody').text();
|
||||
|
||||
// Click somewhere on the timeslider
|
||||
let e = new jQuery.Event('mousedown');
|
||||
// sets y co-ordinate of the pad slider modal.
|
||||
const base = (timeslider$('#ui-slider-bar').offset().top - 24);
|
||||
e.clientX = e.pageX = 150;
|
||||
e.clientY = e.pageY = base + 5;
|
||||
$sliderBar.trigger(e);
|
||||
|
||||
e = new jQuery.Event('mousedown');
|
||||
e.clientX = e.pageX = 150;
|
||||
e.clientY = e.pageY = base;
|
||||
$sliderBar.trigger(e);
|
||||
|
||||
e = new jQuery.Event('mousedown');
|
||||
e.clientX = e.pageX = 150;
|
||||
e.clientY = e.pageY = base - 5;
|
||||
$sliderBar.trigger(e);
|
||||
|
||||
$sliderBar.trigger('mouseup');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// make sure the text has changed
|
||||
expect(timeslider$('#innerdocbody').text()).not.to.eql(latestContents);
|
||||
const starIsVisible = timeslider$('.star').is(':visible');
|
||||
expect(starIsVisible).to.eql(true);
|
||||
});
|
||||
|
||||
|
||||
// Disabled as jquery trigger no longer works properly
|
||||
xit('changes the url when clicking on the timeslider', async function () {
|
||||
// Create some revisions.
|
||||
for (let i = 0; i < 20; i++) await helper.edit('a');
|
||||
|
||||
// go to timeslider
|
||||
$('#iframe-container iframe')
|
||||
.attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider`);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 6000));
|
||||
|
||||
const timeslider$ = $('#iframe-container iframe')[0].contentWindow.$;
|
||||
const $sliderBar = timeslider$('#ui-slider-bar');
|
||||
|
||||
const oldUrl = $('#iframe-container iframe')[0].contentWindow.location.hash;
|
||||
|
||||
// Click somewhere on the timeslider
|
||||
const e = new jQuery.Event('mousedown');
|
||||
e.clientX = e.pageX = 150;
|
||||
e.clientY = e.pageY = 60;
|
||||
$sliderBar.trigger(e);
|
||||
|
||||
await helper.waitForPromise(
|
||||
() => $('#iframe-container iframe')[0].contentWindow.location.hash !== oldUrl, 6000);
|
||||
});
|
||||
|
||||
it('jumps to a revision given in the url', async function () {
|
||||
const inner$ = helper.padInner$;
|
||||
this.timeout(40000);
|
||||
|
||||
// wait for the text to be loaded
|
||||
await helper.waitForPromise(() => inner$('body').text().length !== 0, 10000);
|
||||
|
||||
const newLines = inner$('body div').length;
|
||||
const oldLength = inner$('body').text().length + newLines / 2;
|
||||
expect(oldLength).to.not.eql(0);
|
||||
inner$('div').first().sendkeys('a');
|
||||
let timeslider$;
|
||||
|
||||
// wait for our additional revision to be added
|
||||
await helper.waitForPromise(() => {
|
||||
// newLines takes the new lines into account which are strippen when using
|
||||
// inner$('body').text(), one <div> is used for one line in ACE.
|
||||
const lenOkay = inner$('body').text().length + newLines / 2 !== oldLength;
|
||||
// this waits for the color to be added to our <span>, which means that the revision
|
||||
// was accepted by the server.
|
||||
const colorOkay = inner$('span').first().attr('class').indexOf('author-') === 0;
|
||||
return lenOkay && colorOkay;
|
||||
}, 10000);
|
||||
|
||||
// go to timeslider with a specific revision set
|
||||
$('#iframe-container iframe')
|
||||
.attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider#0`);
|
||||
|
||||
// wait for the timeslider to be loaded
|
||||
await helper.waitForPromise(() => {
|
||||
try {
|
||||
timeslider$ = $('#iframe-container iframe')[0].contentWindow.$;
|
||||
} catch (e) {
|
||||
// Empty catch block <3
|
||||
}
|
||||
return timeslider$ && timeslider$('#innerdocbody').text().length === oldLength;
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
it('checks the export url', async function () {
|
||||
const inner$ = helper.padInner$;
|
||||
this.timeout(11000);
|
||||
inner$('div').first().sendkeys('a');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2500));
|
||||
|
||||
// go to timeslider
|
||||
$('#iframe-container iframe')
|
||||
.attr('src', `${$('#iframe-container iframe').attr('src')}/timeslider#0`);
|
||||
let timeslider$;
|
||||
let exportLink;
|
||||
|
||||
await helper.waitForPromise(() => {
|
||||
try {
|
||||
timeslider$ = $('#iframe-container iframe')[0].contentWindow.$;
|
||||
} catch (e) {
|
||||
// Empty catch block <3
|
||||
}
|
||||
if (!timeslider$) return false;
|
||||
exportLink = timeslider$('#exportplaina').attr('href');
|
||||
if (!exportLink) return false;
|
||||
return exportLink.substr(exportLink.length - 12) === '0/export/txt';
|
||||
}, 6000);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue