mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-08-03 15:34:19 +00:00
Compare commits
130 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce4c251080 | ||
|
|
031c16317d | ||
|
|
0d9c0c7933 | ||
|
|
a267126bdb | ||
|
|
5699d43178 | ||
|
|
de3d060153 | ||
|
|
2726b11b4c | ||
|
|
51f177c3e0 | ||
|
|
a90e0f191c | ||
|
|
9c8d16d50e | ||
|
|
f8686dda10 | ||
|
|
da03e99676 | ||
|
|
e829ca77fe | ||
|
|
e1c2fcd1f1 | ||
|
|
63f95d6029 | ||
|
|
0aee6a7d7d | ||
|
|
66dd455124 | ||
|
|
b7bbacc2b2 | ||
|
|
682cb65625 | ||
|
|
9706e08260 | ||
|
|
a0a56ad406 | ||
|
|
2eec87cfe4 | ||
|
|
492b158d93 | ||
|
|
f3ccc6c718 | ||
|
|
fa6df9a9ff | ||
|
|
4d95a12845 | ||
|
|
87a8b2a5c2 | ||
|
|
111c9c3864 | ||
|
|
c6db747089 | ||
|
|
5dedafe515 | ||
|
|
3f965fae95 | ||
|
|
80c0910f8b | ||
|
|
64c67252a7 | ||
|
|
9f288a730d | ||
|
|
36aba18c65 | ||
|
|
2de5d10caf | ||
|
|
9a6109bd1d | ||
|
|
037f4a25b5 | ||
|
|
4755bd3e17 | ||
|
|
05d0f62403 | ||
|
|
3efcbfd358 | ||
|
|
2b5d521923 | ||
|
|
631caf911c | ||
|
|
0aba424357 | ||
|
|
194f144940 | ||
|
|
c045fcf698 | ||
|
|
451df9f01c | ||
|
|
ce27525d97 | ||
|
|
9c48900092 | ||
|
|
173dbb3448 | ||
|
|
24e0c44d91 | ||
|
|
f65701eaaf | ||
|
|
2f766d075d | ||
|
|
2c94ef9493 | ||
|
|
fdc23c658f | ||
|
|
e5d6142061 | ||
|
|
b7d2477840 | ||
|
|
4bc80d1439 | ||
|
|
8a9c0a6e20 | ||
|
|
df1666cffa | ||
|
|
afdd9b9500 | ||
|
|
3b9acd487c | ||
|
|
82d1fd63a5 | ||
|
|
75be62c801 | ||
|
|
ab4c7beded | ||
|
|
be8120b14e | ||
|
|
33160db454 | ||
|
|
9e850e17f3 | ||
|
|
3ac0b0f0a4 | ||
|
|
cce7cc6cd4 | ||
|
|
bce18df898 | ||
|
|
1442aee756 | ||
|
|
00e1892704 | ||
|
|
2ecd748fca | ||
|
|
0cd01fce41 | ||
|
|
40327d59d6 | ||
|
|
b45f60e20a | ||
|
|
f5b61c3388 | ||
|
|
ff0bd2e819 | ||
|
|
8e04130969 | ||
|
|
c8b544802a | ||
|
|
01500ca70c | ||
|
|
7168f14f0a | ||
|
|
01d0b08a4e | ||
|
|
41dc87edf9 | ||
|
|
3c90fa07c3 | ||
|
|
93e5bcc1e2 | ||
|
|
851b1fb613 | ||
|
|
9047aacc0a | ||
|
|
773a9042a7 | ||
|
|
926c94ea8a | ||
|
|
bf5988580e | ||
|
|
b9ec85779f | ||
|
|
acba429e1a | ||
|
|
8c5de8446c | ||
|
|
9d2dae1b63 | ||
|
|
32249d99e2 | ||
|
|
2109c05ad4 | ||
|
|
f6f665ff60 | ||
|
|
b680076534 | ||
|
|
b1792469b1 | ||
|
|
3aa13197e4 | ||
|
|
dfdbceebe2 | ||
|
|
a698e34772 | ||
|
|
ce038b0b0f | ||
|
|
d67aa3ebd3 | ||
|
|
4ba72b3919 | ||
|
|
bfe3fad256 | ||
|
|
3e53962fb5 | ||
|
|
a59b310dbf | ||
|
|
0c0f5a8ec4 | ||
|
|
099de84cd1 | ||
|
|
8a4e1feed8 | ||
|
|
2fdf202089 | ||
|
|
abdfa03f84 | ||
|
|
c8e3248bcd | ||
|
|
4371af77c2 | ||
|
|
2ba72de1de | ||
|
|
6a5ad2d90d | ||
|
|
f79cabe74c | ||
|
|
662678275a | ||
|
|
8e4eb2646a | ||
|
|
e313366b49 | ||
|
|
3cddff7b27 | ||
|
|
817ca2546e | ||
|
|
eefdfc44b9 | ||
|
|
73a9b88bb4 | ||
|
|
a65e3b75a4 | ||
|
|
8b6a0a2f3a | ||
|
|
4fe94c9b68 |
126 changed files with 7841 additions and 2820 deletions
10
.env.default
10
.env.default
|
|
@ -11,8 +11,18 @@ DOCKER_COMPOSE_APP_PORT_TARGET=9001
|
|||
# The env var DEFAULT_PAD_TEXT seems to be mandatory in the latest version of etherpad.
|
||||
DOCKER_COMPOSE_APP_DEV_ENV_DEFAULT_PAD_TEXT="Welcome to etherpad"
|
||||
|
||||
# REQUIRED. The /admin account password. docker-compose refuses to start while
|
||||
# this is empty (the value has no insecure fallback). Set a strong value — the
|
||||
# /admin UI can install plugins, which is arbitrary code execution.
|
||||
DOCKER_COMPOSE_APP_ADMIN_PASSWORD=
|
||||
|
||||
# Set to true ONLY when Etherpad runs behind a trusted reverse proxy that sets
|
||||
# the X-Forwarded-* headers (Traefik, Nginx, Kubernetes Ingress, …). On a
|
||||
# directly-exposed instance keep it false so clients can't spoof their IP. If you
|
||||
# DO run behind a proxy you must set this to true, otherwise HTTPS detection
|
||||
# (secure cookies) and client-IP / rate-limiting will be wrong.
|
||||
DOCKER_COMPOSE_APP_TRUST_PROXY=false
|
||||
|
||||
DOCKER_COMPOSE_POSTGRES_DATABASE=db
|
||||
DOCKER_COMPOSE_POSTGRES_PASSWORD=etherpad-lite-password
|
||||
DOCKER_COMPOSE_POSTGRES_USER=etherpad-lite-user
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ DOCKER_COMPOSE_APP_DEV_ENV_DEFAULT_PAD_TEXT="Welcome to etherpad"
|
|||
|
||||
DOCKER_COMPOSE_APP_DEV_ADMIN_PASSWORD=
|
||||
|
||||
# docker-compose.dev.yml defaults this to true (dev convenience). Set to false if
|
||||
# you are not running the dev container behind a reverse proxy.
|
||||
DOCKER_COMPOSE_APP_DEV_ENV_TRUST_PROXY=true
|
||||
|
||||
DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_DATABASE=db
|
||||
DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_PASSWORD=etherpad-lite-password
|
||||
DOCKER_COMPOSE_POSTGRES_DEV_ENV_POSTGRES_USER=etherpad-lite-user
|
||||
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
|
|
@ -18,3 +18,10 @@ updates:
|
|||
groups:
|
||||
dev-dependencies:
|
||||
dependency-type: "development"
|
||||
cooldown:
|
||||
default-days: 1 # fallback for anything not covered below
|
||||
semver-major-days: 7
|
||||
semver-minor-days: 3
|
||||
semver-patch-days: 1
|
||||
include:
|
||||
- "*"
|
||||
|
|
|
|||
28
.github/workflows/backend-tests.yml
vendored
28
.github/workflows/backend-tests.yml
vendored
|
|
@ -32,8 +32,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -45,13 +45,13 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
-
|
||||
name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: libreoffice libreoffice-pdfimport
|
||||
version: 1.0
|
||||
|
|
@ -88,8 +88,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -101,13 +101,13 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
-
|
||||
name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: libreoffice libreoffice-pdfimport
|
||||
version: 1.0
|
||||
|
|
@ -168,8 +168,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -181,7 +181,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
|
|
@ -234,8 +234,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -247,7 +247,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
|
|
|
|||
8
.github/workflows/build-and-deploy-docs.yml
vendored
8
.github/workflows/build-and-deploy-docs.yml
vendored
|
|
@ -36,15 +36,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ~/.pnpm-store
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache vitepress build
|
||||
with:
|
||||
path: doc/.vitepress/cache
|
||||
|
|
@ -58,7 +58,7 @@ jobs:
|
|||
# Pin Node so the build does not silently fall back to whatever the
|
||||
# runner image ships with. The repo declares engines.node >=24.0.0.
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
|
|||
8
.github/workflows/codeql-analysis.yml
vendored
8
.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.
|
||||
|
|
@ -37,10 +37,10 @@ jobs:
|
|||
if: ${{ github.event_name == 'pull_request' }}
|
||||
-
|
||||
name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v4.37.3
|
||||
-
|
||||
name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@v4.37.3
|
||||
-
|
||||
name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v4.37.3
|
||||
|
|
|
|||
24
.github/workflows/deb-package.yml
vendored
24
.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,14 +62,14 @@ jobs:
|
|||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: pnpm
|
||||
|
|
@ -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
|
||||
|
|
|
|||
18
.github/workflows/docker.yml
vendored
18
.github/workflows/docker.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
path: etherpad
|
||||
-
|
||||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
tags: ${{ env.TEST_TAG }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -57,7 +57,7 @@ jobs:
|
|||
# packageManager in a root package.json.
|
||||
package_json_file: etherpad/package.json
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
@ -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
|
||||
-
|
||||
|
|
@ -382,13 +382,13 @@ jobs:
|
|||
type=semver,pattern={{major}}
|
||||
-
|
||||
name: Log in to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Log in to GHCR
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.5.2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/downstream-smoke.yml
vendored
6
.github/workflows/downstream-smoke.yml
vendored
|
|
@ -26,9 +26,9 @@ jobs:
|
|||
APIKEY: downstream-smoke-key
|
||||
steps:
|
||||
- name: Checkout core (PR)
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
|
|||
8
.github/workflows/frontend-admin-tests.yml
vendored
8
.github/workflows/frontend-admin-tests.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
|
|
@ -48,7 +48,7 @@ jobs:
|
|||
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||
run: pnpm i
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v6
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
|
|||
32
.github/workflows/frontend-tests.yml
vendored
32
.github/workflows/frontend-tests.yml
vendored
|
|
@ -20,15 +20,15 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
@ -92,15 +92,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -112,7 +112,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
@ -168,15 +168,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -188,7 +188,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
@ -269,15 +269,15 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache Playwright browsers
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
|
|
@ -289,7 +289,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
|
|||
6
.github/workflows/handleRelease.yml
vendored
6
.github/workflows/handleRelease.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
|
|||
31
.github/workflows/installer-test.yml
vendored
31
.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,9 +38,9 @@ jobs:
|
|||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
|
|
@ -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,19 +107,16 @@ 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
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
|
|
@ -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: |
|
||||
|
|
|
|||
18
.github/workflows/load-test.yml
vendored
18
.github/workflows/load-test.yml
vendored
|
|
@ -24,8 +24,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
@ -62,8 +62,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -75,7 +75,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
@ -125,8 +125,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -138,7 +138,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
|
|||
6
.github/workflows/perform-type-check.yml
vendored
6
.github/workflows/perform-type-check.yml
vendored
|
|
@ -24,8 +24,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
|
|||
6
.github/workflows/rate-limit.yml
vendored
6
.github/workflows/rate-limit.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
- uses: actions/cache@v5
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
|
|||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ether/etherpad-lite
|
||||
path: etherpad
|
||||
|
|
@ -42,12 +42,12 @@ jobs:
|
|||
git checkout develop
|
||||
git reset --hard origin/develop
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: ether/ether.github.com
|
||||
path: ether.github.com
|
||||
token: '${{ secrets.ETHER_RELEASE_TOKEN }}'
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -62,7 +62,7 @@ jobs:
|
|||
# packageManager in a root package.json.
|
||||
package_json_file: etherpad/package.json
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
|
@ -82,7 +82,7 @@ jobs:
|
|||
with:
|
||||
ruby-version: 2.7
|
||||
|
||||
- uses: reitzig/actions-asciidoctor@v2.0.4
|
||||
- uses: reitzig/actions-asciidoctor@v2.0.5
|
||||
with:
|
||||
version: 2.0.18
|
||||
- name: Prepare release
|
||||
|
|
|
|||
38
.github/workflows/releaseEtherpad.yml
vendored
38
.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,9 +36,17 @@ 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/setup-node@v6
|
||||
uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
# OIDC trusted publishing needs npm >= 11.5.1, which requires
|
||||
# Node >= 22.9.0. Node 24 satisfies that and matches the rest of CI.
|
||||
|
|
@ -22,7 +54,7 @@ jobs:
|
|||
registry-url: https://registry.npmjs.org/
|
||||
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
|
||||
run: npm install -g npm@latest
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
|
|||
4
.github/workflows/snap-build.yml
vendored
4
.github/workflows/snap-build.yml
vendored
|
|
@ -34,7 +34,7 @@ jobs:
|
|||
name: Wrapper unit tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Run snap/tests/run-all.sh
|
||||
run: bash snap/tests/run-all.sh
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
needs: wrapper-tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install snapcraft
|
||||
run: sudo snap install --classic snapcraft
|
||||
|
|
|
|||
2
.github/workflows/snap-publish.yml
vendored
2
.github/workflows/snap-publish.yml
vendored
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
snap-file: ${{ steps.build.outputs.snap }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Build snap
|
||||
id: build
|
||||
|
|
|
|||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
|||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v11
|
||||
with:
|
||||
close-issue-label: wontfix
|
||||
close-pr-label: wontfix
|
||||
|
|
|
|||
4
.github/workflows/update-plugins.yml
vendored
4
.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
|
||||
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
run_install: false
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
|
|
|
|||
|
|
@ -32,13 +32,13 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Check out latest release
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
-
|
||||
name: Check out latest release tag
|
||||
run: git checkout "$(git tag --list 'v*' --sort=-version:refname | head -n1)"
|
||||
- uses: actions/cache@v5
|
||||
- uses: actions/cache@v6
|
||||
name: Cache pnpm store
|
||||
with:
|
||||
path: ${{ env.PNPM_HOME }}
|
||||
|
|
@ -50,12 +50,12 @@ jobs:
|
|||
with:
|
||||
run_install: false
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: pnpm
|
||||
- name: Install libreoffice
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||
with:
|
||||
packages: libreoffice libreoffice-pdfimport
|
||||
version: 1.0
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ Releases are driven almost entirely by GitHub Actions. A maintainer dispatches *
|
|||
- `handleRelease.yml` → builds Etherpad, extracts the matching changelog section via `generateChangelog` (`bin/generateReleaseNotes.ts`), and publishes the **GitHub Release** (`make_latest: true`);
|
||||
- `docker.yml` → builds & pushes the Docker images;
|
||||
- `snap-publish.yml` → publishes the snap.
|
||||
5. **npm publish is a separate manual step:** dispatch **"releaseEtherpad.yaml"** (`workflow_dispatch`), which runs `npm publish --provenance --access public` via npm **OIDC trusted publishing**. It is *not* fired by the tag.
|
||||
5. **npm publish — PARKED, not part of the release.** `releaseEtherpad.yaml` publishes the core as `ep_etherpad` to npm, but that package is **not load-bearing**: it has 0 dependents, nothing depends on it (plugins import `ep_etherpad-lite` from the *local* core install; plugin CI clones the repo), and Etherpad is run via clone/Docker/zip/snap — never `npm install`. The publish currently fails with `E404` because `ep_etherpad` has no OIDC trusted publisher configured on npmjs.com, which is why npm sits at 2.5.0 while 3.x shipped fine without it. The workflow is gated behind a `confirm: true` input so it can't run by accident. **Skip it for a normal release.** To revive it, the npm owner of `ep_etherpad` (`samtv12345`) configures a trusted publisher (npmjs.com → ep_etherpad → Settings → Trusted Publisher → repo `ether/etherpad`, workflow `releaseEtherpad.yml`); otherwise the workflow can be removed. Decision pending.
|
||||
|
||||
### Documentation
|
||||
|
||||
|
|
|
|||
57
CHANGELOG.md
57
CHANGELOG.md
|
|
@ -1,3 +1,60 @@
|
|||
# 3.3.3
|
||||
|
||||
3.3.3 is a security release. It closes a **critical unauthenticated arbitrary-file-read** in the `/static/*` handler (GHSA-mc8w-wjhw-45x5) and bundles the fixes for a batch of privately reported issues that had already landed on `develop`: an OpenID Connect provider hardcoded cookie key and permissive CORS reflection (GHSA-pp5v-mvwg-76mp), session-fixation on authentication (GHSA-73h9-c5xp-gfg4), a same-socket cross-pad write TOCTOU (GHSA-6mcx-x5h6-rpw2), and a pad-id delimiter injection in `copyPad`/`movePad` (GHSA-wg58-mhwv-35pq). Alongside the security work it migrates the server build to TypeScript 7 (`tsgo`), fixes PageDown/PageUp navigation across consecutive long wrapped lines, and makes the docker `plugin_packages` volume mountpoint writable.
|
||||
|
||||
### Security
|
||||
|
||||
- **Prevent pre-auth path traversal / arbitrary file read in `/static/*` (GHSA-mc8w-wjhw-45x5, #8081).** On POSIX a backslash is an ordinary filename byte, so `sanitizePathname()` deliberately leaves an `..\..\..` segment untouched — but `Minify.ts` then converted backslashes to forward slashes *unconditionally, after* the sanitiser, turning those bytes back into `../` traversal components with no re-check. Because the route is mounted on `expressPreSession` (before the auth middleware), any unauthenticated client could read any file readable by the Etherpad process — e.g. `GET /static/plugins/ep_etherpad-lite/static/..%5C..%5C..%5Cetc/passwd` — escalating via disclosed `settings.json`/`credentials.json`/`/proc/self/environ` to an admin session and, through the plugin installer, RCE. The backslash conversion is now guarded to Windows only (`path.sep === '\\'`), matching the invariant already enforced in `sanitizePathname.ts`. Adds a backend regression test that fails on the pre-fix code. Reported by @gcm-explo1t.
|
||||
- **Stop shipping a hardcoded OIDC cookie key and reflecting arbitrary CORS origins (GHSA-pp5v-mvwg-76mp, #8070, #8071, #8072).** The embedded OpenID Connect provider shipped a hardcoded cookie-signing key (allowing forged provider cookies) and `clientBasedCORS` reflected any request `Origin`. The provider now derives its cookie keys from the instance secret, CORS reflection is constrained, the soffice export path strips remote images to match the native path, and public routes that echo `x-proxy-path` set `Vary` to prevent cache poisoning. Reported by meifukun.
|
||||
- **Regenerate the session id on authentication (GHSA-73h9-c5xp-gfg4, #8074).** Etherpad did not rotate the session identifier when a user authenticated, so a pre-auth session id fixed by an attacker (most impactfully via `ep_openid_connect` SSO) survived login, enabling session-fixation account/admin takeover. The session id is now regenerated on the authentication boundary.
|
||||
- **Apply queued `USER_CHANGES` to the enqueue-time pad (GHSA-6mcx-x5h6-rpw2, #8075).** A same-socket `CLIENT_READY` pad-swap could redirect an already-queued `USER_CHANGES` onto a different (read-only or unauthorized) pad, a cross-pad write. Queued changes are now bound to the pad they were enqueued against.
|
||||
- **Reject the ueberdb key delimiter `:` in `copyPad`/`movePad` destination ids (GHSA-wg58-mhwv-35pq, #8073).** A destination id containing `:` could bypass the `force=false` overwrite guard and corrupt another pad's revision records. Such ids are now rejected.
|
||||
|
||||
### Notable enhancements
|
||||
|
||||
- **Migrate the server build to TypeScript 7 / `tsgo` (#8039).** The server now type-checks and builds under the native-Go TypeScript compiler.
|
||||
|
||||
### Notable fixes
|
||||
|
||||
- **Editor — PageDown/PageUp now advance across consecutive long wrapped lines (#7555).** Paging no longer stalls when several long soft-wrapped lines follow one another.
|
||||
- **Docker — make the `plugin_packages` volume mountpoint writable (#8042).** Mounting a plugin-packages volume no longer fails on a read-only mountpoint.
|
||||
|
||||
# 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.
|
||||
|
|
|
|||
12
Dockerfile
12
Dockerfile
|
|
@ -171,6 +171,12 @@ ARG ETHERPAD_GITHUB_PLUGINS=
|
|||
COPY --chown=etherpad:etherpad ./src/ ./src/
|
||||
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/templates/admin ./src/templates/admin
|
||||
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc
|
||||
# docker-compose mounts a named volume over src/plugin_packages. Docker seeds a
|
||||
# fresh named volume from the mountpoint in the image, so the directory has to
|
||||
# exist here (owned by etherpad, since USER is already etherpad) — otherwise
|
||||
# Docker creates it root:root and plugin installs cannot write install.lock.
|
||||
# See ether/etherpad#8026.
|
||||
RUN mkdir -p ./src/plugin_packages
|
||||
|
||||
COPY --chown=etherpad:etherpad ./local_plugin[s] ./local_plugins/
|
||||
|
||||
|
|
@ -203,6 +209,12 @@ RUN printf 'packages:\n - src\n - bin\nonlyBuiltDependencies:\n - esbuild\nig
|
|||
COPY --chown=etherpad:etherpad ./src ./src
|
||||
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/templates/admin ./src/templates/admin
|
||||
COPY --chown=etherpad:etherpad --from=adminbuild /opt/etherpad-lite/src/static/oidc ./src/static/oidc
|
||||
# docker-compose mounts a named volume over src/plugin_packages. Docker seeds a
|
||||
# fresh named volume from the mountpoint in the image, so the directory has to
|
||||
# exist here (owned by etherpad, since USER is already etherpad) — otherwise
|
||||
# Docker creates it root:root and plugin installs cannot write install.lock.
|
||||
# See ether/etherpad#8026.
|
||||
RUN mkdir -p ./src/plugin_packages
|
||||
|
||||
COPY --chown=etherpad:etherpad ./local_plugin[s] ./local_plugins/
|
||||
|
||||
|
|
|
|||
|
|
@ -117,11 +117,11 @@ services:
|
|||
- postgres
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin}
|
||||
ADMIN_PASSWORD: "${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:?Set DOCKER_COMPOSE_APP_ADMIN_PASSWORD to a strong value}"
|
||||
DB_CHARSET: ${DOCKER_COMPOSE_APP_DB_CHARSET:-utf8mb4}
|
||||
DB_HOST: postgres
|
||||
DB_NAME: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}
|
||||
DB_PASS: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}
|
||||
DB_PASS: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}"
|
||||
DB_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}
|
||||
DB_TYPE: "postgres"
|
||||
DB_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
|
||||
|
|
@ -129,7 +129,7 @@ services:
|
|||
DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEFAULT_PAD_TEXT:- }
|
||||
DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DISABLE_IP_LOGGING:-false}
|
||||
SOFFICE: ${DOCKER_COMPOSE_APP_SOFFICE:-null}
|
||||
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-true}
|
||||
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-false}
|
||||
restart: always
|
||||
ports:
|
||||
- "${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}"
|
||||
|
|
@ -138,7 +138,7 @@ services:
|
|||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}
|
||||
POSTGRES_PASSWORD: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}
|
||||
POSTGRES_PASSWORD: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}"
|
||||
POSTGRES_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}
|
||||
POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm gen:api && vite",
|
||||
|
|
@ -14,39 +14,39 @@
|
|||
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@tanstack/react-query": "^5.101.0",
|
||||
"@tanstack/react-query-devtools": "^5.101.0",
|
||||
"@radix-ui/react-switch": "^1.3.7",
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-query-devtools": "^5.101.4",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"openapi-react-query": "^0.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.16",
|
||||
"@radix-ui/react-toast": "^1.2.16",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.5",
|
||||
"@radix-ui/react-dialog": "^1.1.23",
|
||||
"@radix-ui/react-toast": "^1.2.23",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.11",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||
"@typescript-eslint/parser": "^8.65.0",
|
||||
"@vitejs/plugin-react": "^6.0.4",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"i18next": "^26.3.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"lucide-react": "^1.26.0",
|
||||
"openapi-typescript": "^7.13.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.78.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.82.0",
|
||||
"react-i18next": "^17.0.11",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tsx": "^4.22.4",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-babel": "^1.7.3",
|
||||
"zustand": "^5.0.14"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an
|
||||
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
|
||||
import util from "node:util";
|
||||
import fs from 'node:fs';
|
||||
import log4js from 'log4js';
|
||||
import readline from 'readline';
|
||||
|
|
@ -69,8 +68,7 @@ const unescape = (val: string) => {
|
|||
if (!sqlFile) throw new Error('Use: node importSqlFile.js $SQLFILE');
|
||||
|
||||
log('initializing db');
|
||||
const initDb = await util.promisify(db.init.bind(db));
|
||||
await initDb(null);
|
||||
await db.init();
|
||||
log('done');
|
||||
|
||||
log(`Opening ${sqlFile}...`);
|
||||
|
|
@ -86,8 +84,7 @@ const unescape = (val: string) => {
|
|||
value = value.substring(0, value.length - 2);
|
||||
console.log(`key: ${key} val: ${value}`);
|
||||
console.log(`unval: ${unescape(value)}`);
|
||||
// @ts-ignore
|
||||
db.set(key, unescape(value), null);
|
||||
await db.set(key, unescape(value));
|
||||
keyNo++;
|
||||
if (keyNo % 1000 === 0) log(` ${keyNo}`);
|
||||
}
|
||||
|
|
@ -96,9 +93,7 @@ const unescape = (val: string) => {
|
|||
process.stdout.write('done. waiting for db to finish transaction. ' +
|
||||
'depended on dbms this may take some time..\n');
|
||||
|
||||
const closeDB = util.promisify(db.close.bind(db));
|
||||
// @ts-ignore
|
||||
await closeDB(null);
|
||||
await db.close();
|
||||
log(`finished, imported ${keyNo} keys.`);
|
||||
process.exit(0)
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -74,10 +74,20 @@ const handleSync = async ()=>{
|
|||
}
|
||||
}
|
||||
|
||||
handleSync().then(()=>{
|
||||
handleSync().then(async ()=>{
|
||||
// Closing flushes any buffered writes to the target DB and clears
|
||||
// ueberdb2's keep-alive timer (added in 6.1.x). Without this the migrated
|
||||
// data may not be fully persisted and the process would hang forever
|
||||
// instead of exiting once the sync is done.
|
||||
await ueberdb2.close()
|
||||
await ueberdb1.close()
|
||||
console.log("Done syncing dbs")
|
||||
}).catch(e=>{
|
||||
process.exit(0)
|
||||
}).catch(async e=>{
|
||||
console.log(`Error syncing db ${e}`)
|
||||
await ueberdb2.close().catch(()=>{})
|
||||
await ueberdb1.close().catch(()=>{})
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -35,26 +35,16 @@ process.on('unhandledRejection', (err) => { throw err; });
|
|||
const keys = await dirty.findKeys('*', '')
|
||||
|
||||
console.log(`Found ${keys.length} records, processing now.`);
|
||||
const p: Promise<void>[] = [];
|
||||
let numWritten = 0;
|
||||
for (const key of keys) {
|
||||
let value = await dirty.get(key);
|
||||
let bcb, wcb;
|
||||
p.push(new Promise((resolve, reject) => {
|
||||
bcb = (err:any) => { if (err != null) return reject(err); };
|
||||
wcb = (err:any) => {
|
||||
if (err != null) return reject(err);
|
||||
if (++numWritten % 100 === 0) console.log(`Wrote record ${numWritten} of ${length}`);
|
||||
resolve();
|
||||
};
|
||||
}));
|
||||
db.set(key, value, bcb, wcb);
|
||||
const value = await dirty.get(key);
|
||||
await db.set(key, value);
|
||||
if (++numWritten % 100 === 0) console.log(`Wrote record ${numWritten} of ${keys.length}`);
|
||||
}
|
||||
await Promise.all(p);
|
||||
console.log(`Wrote all ${numWritten} records`);
|
||||
|
||||
await db.close(null);
|
||||
await dirty.close(null);
|
||||
await db.close();
|
||||
await dirty.close();
|
||||
console.log('Finished.');
|
||||
process.exit(0)
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "bin",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.3",
|
||||
"description": "",
|
||||
"main": "checkAllPads.js",
|
||||
"directories": {
|
||||
|
|
@ -9,14 +9,14 @@
|
|||
"dependencies": {
|
||||
"ep_etherpad-lite": "workspace:../src",
|
||||
"log4js": "^6.9.1",
|
||||
"semver": "^7.8.3",
|
||||
"tsx": "^4.22.4",
|
||||
"ueberdb2": "^6.1.9"
|
||||
"semver": "^7.8.5",
|
||||
"tsx": "^4.23.1",
|
||||
"ueberdb2": "6.1.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/semver": "^7.7.1",
|
||||
"typescript": "^6.0.3"
|
||||
"typescript": "^7.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"makeDocs": "node --import tsx make_docs.ts",
|
||||
|
|
|
|||
|
|
@ -27,13 +27,18 @@ export default defineConfig({
|
|||
items: [
|
||||
{ text: 'Docker', link: '/docker.md' },
|
||||
{ text: 'Configuration', link: '/configuration.md' },
|
||||
{ text: 'Deployment', link: '/deployment.md' },
|
||||
{ text: 'Database', link: '/database.md' },
|
||||
{ text: 'Localization', link: '/localization.md' },
|
||||
{ text: 'Cookies', link: '/cookies.md' },
|
||||
{ text: 'Plugins', link: '/plugins.md' },
|
||||
{ text: 'Stats', link: '/stats.md' },
|
||||
{text: 'Skins', link: '/skins.md' },
|
||||
{ text: 'Accessibility', link: '/accessibility.md' },
|
||||
{text: 'Demo', link: '/demo.md' },
|
||||
{text: 'CLI', link: '/cli.md'},
|
||||
{ text: 'Development', link: '/development.md' },
|
||||
{ text: 'FAQ', link: '/faq.md' },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
91
doc/accessibility.md
Normal file
91
doc/accessibility.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
# Accessibility
|
||||
|
||||
Etherpad aims to be usable by everyone, including people who rely on a
|
||||
keyboard, a screen reader, or other assistive technology. The editor follows
|
||||
common conventions so that selecting, formatting, and navigating text works the
|
||||
way you would expect in other applications, and the toolbar can be reached and
|
||||
operated without a mouse.
|
||||
|
||||
If you find a feature that is not accessible, please let us know by opening an
|
||||
issue so it can be improved.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
The following shortcuts are built into the editor. On macOS use the Command
|
||||
(`Cmd`) key wherever `Ctrl` is listed.
|
||||
|
||||
::: tip
|
||||
Most shortcuts can be individually enabled or disabled through the
|
||||
`padShortcutEnabled` settings, so a deployment may have customised which of
|
||||
these are active.
|
||||
:::
|
||||
|
||||
### Editor
|
||||
|
||||
| Action | Shortcut |
|
||||
| --- | --- |
|
||||
| Bold | `Ctrl` + `B` |
|
||||
| Italic | `Ctrl` + `I` |
|
||||
| Underline | `Ctrl` + `U` |
|
||||
| Strikethrough | `Ctrl` + `5` |
|
||||
| Ordered (numbered) list | `Ctrl` + `Shift` + `N` or `Ctrl` + `Shift` + `1` |
|
||||
| Unordered (bulleted) list | `Ctrl` + `Shift` + `L` |
|
||||
| Indent line or selection | `Tab` |
|
||||
| Outdent line or selection | `Shift` + `Tab` |
|
||||
| Undo | `Ctrl` + `Z` |
|
||||
| Redo | `Ctrl` + `Y` or `Ctrl` + `Shift` + `Z` |
|
||||
| Save a named revision | `Ctrl` + `S` |
|
||||
| Duplicate the current line(s) | `Ctrl` + `Shift` + `D` |
|
||||
| Delete the current line(s) | `Ctrl` + `Shift` + `K` |
|
||||
| Clear authorship colors on the pad or selection | `Ctrl` + `Shift` + `C` |
|
||||
| Show the authors of the current line | `Ctrl` + `Shift` + `2` |
|
||||
| Focus the toolbar (see below) | `Alt` + `F9` |
|
||||
| Focus the chat input | `Alt` + `C` |
|
||||
|
||||
Text selection, cut (`Ctrl` + `X`), copy (`Ctrl` + `C`), paste
|
||||
(`Ctrl` + `V`), and the arrow keys behave as they do in any standard text
|
||||
editor.
|
||||
|
||||
### Timeslider
|
||||
|
||||
The timeslider (revision history) provides its own shortcuts:
|
||||
|
||||
| Action | Shortcut |
|
||||
| --- | --- |
|
||||
| Play / pause history playback | `Space` |
|
||||
| Step back one revision | `Left Arrow` |
|
||||
| Step forward one revision | `Right Arrow` |
|
||||
| Jump back to the previous starred revision | `Shift` + `Left Arrow` |
|
||||
| Jump forward to the next starred revision | `Shift` + `Right Arrow` |
|
||||
|
||||
## Toolbar navigation
|
||||
|
||||
The toolbar holds the formatting controls (bold, italic, lists, and so on) and
|
||||
can be reached and operated entirely from the keyboard:
|
||||
|
||||
* Press `Alt` + `F9` from the editor to move focus to the first button in the
|
||||
toolbar.
|
||||
* Use the `Left Arrow` and `Right Arrow` keys to move between buttons. `Tab`
|
||||
also moves to the next focusable control.
|
||||
* Press `Enter` to activate the focused button.
|
||||
* Press `Alt` + `F9` again, or `Escape`, to return focus to the pad.
|
||||
|
||||
Pressing `Escape` while a toolbar dropdown (such as the settings or color
|
||||
picker) is open closes that dropdown first.
|
||||
|
||||
## Screen readers
|
||||
|
||||
Etherpad provides as much screen reader support as possible. Support quality
|
||||
varies between platforms and browsers, so the following combinations are
|
||||
recommended:
|
||||
|
||||
* On Windows, Firefox with [NVDA](https://www.nvaccess.org/) currently gives the
|
||||
best experience.
|
||||
|
||||
To reduce verbose feedback while typing collaboratively in NVDA, open the
|
||||
keyboard settings (`NVDA` + `Ctrl` + `K`) and turn off **Speak typed characters**
|
||||
and **Speak typed words**.
|
||||
|
||||
Support in other screen readers and browsers (for example Orca on Linux, or
|
||||
Chrome) is more limited. Contributions to improve coverage on these platforms
|
||||
are very welcome.
|
||||
|
|
@ -102,7 +102,9 @@ The notice auto-fades after 8 seconds and can be dismissed immediately. The publ
|
|||
|
||||
## Disabling everything
|
||||
|
||||
Set `updates.tier` to `"off"`. No HTTP request will leave the instance and no banner or badge will render.
|
||||
Set `updates.tier` to `"off"`. The self-updater goes silent — no request to the GitHub Releases API leaves the instance and no banner or badge renders. Note this does **not** cover the separate legacy version check in `UpdateCheck.ts`, which still fetches `${updateServer}/info.json` until you also set `privacy.updateCheck` to `false` (see [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md)).
|
||||
|
||||
On Docker / air-gapped installs you can do both without editing `settings.json` inside the image by setting `UPDATES_TIER=off` **and** `PRIVACY_UPDATE_CHECK=false` (add `PRIVACY_PLUGIN_CATALOG=false` to also disable the admin plugin browser's catalogue fetch). See the [Updates & privacy](../docker.md#updates--privacy-offline--air-gapped) table in the Docker docs for the full set of environment variables.
|
||||
|
||||
## Privacy
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Changeset Library
|
||||
|
||||
The [changeset
|
||||
library](https://github.com/ether/etherpad-lite/blob/develop/src/static/js/Changeset.ts)
|
||||
library](https://github.com/ether/etherpad/blob/develop/src/static/js/Changeset.ts)
|
||||
provides tools to create, read, and apply changesets.
|
||||
|
||||
## Changeset
|
||||
|
|
@ -21,6 +21,42 @@ A transmitted changeset looks like this:
|
|||
'Z:z>1|2=m=b*0|1+1$\n'
|
||||
```
|
||||
|
||||
### Reading a changeset
|
||||
|
||||
`unpack()` splits a changeset string into its parts:
|
||||
|
||||
```javascript
|
||||
const unpacked = Changeset.unpack('Z:z>1|2=m=b*0|1+1$\n');
|
||||
// { oldLen: 35, newLen: 36, ops: '|2=m=b*0|1+1', charBank: '\n' }
|
||||
```
|
||||
|
||||
`oldLen` is the document length before the change and `newLen` the length after.
|
||||
`ops` is the list of operations, and `charBank` holds the characters inserted by
|
||||
those operations.
|
||||
|
||||
Iterate the operations with `deserializeOps()`, which yields one `Op` at a time:
|
||||
|
||||
```javascript
|
||||
for (const op of Changeset.deserializeOps(unpacked.ops)) {
|
||||
console.log(op);
|
||||
}
|
||||
// Op { opcode: '=', chars: 22, lines: 2, attribs: '' }
|
||||
// Op { opcode: '=', chars: 11, lines: 0, attribs: '' }
|
||||
// Op { opcode: '+', chars: 1, lines: 1, attribs: '*0' }
|
||||
```
|
||||
|
||||
There are three kinds of operation, each applied starting from the current
|
||||
position in the text:
|
||||
|
||||
- `=` keeps text (it may still change the text's attributes, e.g. make it bold).
|
||||
- `-` removes text.
|
||||
- `+` inserts text (taking the characters from the changeset's `charBank`).
|
||||
|
||||
`opcode` is the operation type; `chars` and `lines` are how much text it covers;
|
||||
and `attribs` are the attributes applied, written as `*` references into the
|
||||
pad's attribute pool. In the example above the final op inserts one character
|
||||
(the newline from `charBank`) carrying attribute `*0`.
|
||||
|
||||
## Attribute Pool
|
||||
|
||||
```javascript
|
||||
|
|
@ -36,6 +72,59 @@ are used many times.
|
|||
There is one attribute pool per pad, and it includes every current and
|
||||
historical attribute used in the pad.
|
||||
|
||||
A pool can be serialized to and from a plain object with `toJsonable()` and
|
||||
`fromJsonable()`:
|
||||
|
||||
```javascript
|
||||
const pool = new AttributePool();
|
||||
pool.fromJsonable({
|
||||
numToAttrib: {
|
||||
0: ['author', 'a.kVnWeomPADAT2pn9'],
|
||||
1: ['bold', 'true'],
|
||||
2: ['italic', 'true'],
|
||||
},
|
||||
nextNum: 3,
|
||||
});
|
||||
|
||||
pool.getAttrib(1); // [ 'bold', 'true' ]
|
||||
pool.getAttribKey(1); // 'bold'
|
||||
pool.getAttribValue(1); // 'true'
|
||||
```
|
||||
|
||||
Each attribute is a `[key, value]` pair — `['bold', 'true']`, or
|
||||
`['author', '<authorId>']`. A character can carry several attributes (bold *and*
|
||||
italic), but only one value per key (so it cannot belong to two authors).
|
||||
|
||||
## Attributed text (atext)
|
||||
|
||||
A pad's content is stored as *attributed text* (`atext`): the plain text plus an
|
||||
attribute string describing which attributes apply to each span.
|
||||
|
||||
```javascript
|
||||
const atext = {
|
||||
text: 'bold text\nitalic text\nnormal text\n\n',
|
||||
attribs: '*0*1+9*0|1+1*0*1*2+b|1+1*0+b|2+2',
|
||||
};
|
||||
```
|
||||
|
||||
The attribute string is a sequence of `+` operations — the same encoding used by
|
||||
changesets — which you can read with `deserializeOps()`:
|
||||
|
||||
```javascript
|
||||
for (const op of Changeset.deserializeOps(atext.attribs)) {
|
||||
console.log(op);
|
||||
}
|
||||
// Op { opcode: '+', chars: 9, lines: 0, attribs: '*0*1' }
|
||||
// Op { opcode: '+', chars: 1, lines: 1, attribs: '*0' }
|
||||
// Op { opcode: '+', chars: 11, lines: 0, attribs: '*0*1*2' }
|
||||
// Op { opcode: '+', chars: 1, lines: 1, attribs: '' }
|
||||
// Op { opcode: '+', chars: 11, lines: 0, attribs: '*0' }
|
||||
// Op { opcode: '+', chars: 2, lines: 2, attribs: '' }
|
||||
```
|
||||
|
||||
Read against the pool above, the first nine characters (`bold text`) carry
|
||||
attributes `*0*1` (author + bold), the following newline carries `*0`, and so on.
|
||||
|
||||
## Further Reading
|
||||
|
||||
Detailed information about the changesets & Easysync protocol:
|
||||
|
|
|
|||
204
doc/database.md
Normal file
204
doc/database.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# Database structure
|
||||
|
||||
## Keys and their values
|
||||
|
||||
### groups
|
||||
A list of all existing groups (a JSON object with groupIDs as keys and `1` as values).
|
||||
|
||||
### pad:$PADID
|
||||
Contains all information about pads
|
||||
|
||||
- **atext** - the latest attributed text
|
||||
- **pool** - the attribute pool
|
||||
- **head** - the number of the latest revision
|
||||
- **chatHead** - the number of the latest chat entry
|
||||
- **public** - flag that disables security for this pad
|
||||
- **passwordHash** - string that contains a salted sha512 sum of this pad's password
|
||||
|
||||
### pad:$PADID:revs:$REVNUM
|
||||
Saves a revision $REVNUM of pad $PADID
|
||||
|
||||
- **meta**
|
||||
- **author** - the autorID of this revision
|
||||
- **timestamp** - the timestamp of when this revision was created
|
||||
- **changeset** - the changeset of this revision
|
||||
|
||||
### pad:$PADID:chat:$CHATNUM
|
||||
Saves a chat entry with num $CHATNUM of pad $PADID
|
||||
|
||||
- **text** - the text of this chat entry
|
||||
- **userId** - the authorID of this chat entry
|
||||
- **time** - the timestamp of this chat entry
|
||||
|
||||
### pad2readonly:$PADID
|
||||
Translates a padID to a readonlyID
|
||||
|
||||
### readonly2pad:$READONLYID
|
||||
Translates a readonlyID to a padID
|
||||
|
||||
### token2author:$TOKENID
|
||||
Translates a token to an authorID
|
||||
|
||||
### globalAuthor:$AUTHORID
|
||||
Information about an author
|
||||
|
||||
- **name** - the name of this author as shown in the pad
|
||||
- **colorID** - the colorID of this author as shown in the pad
|
||||
|
||||
### mapper2group:$MAPPER
|
||||
Maps an external application identifier to an internal group
|
||||
|
||||
### mapper2author:$MAPPER
|
||||
Maps an external application identifier to an internal author
|
||||
|
||||
### group:$GROUPID
|
||||
a group of pads
|
||||
|
||||
- **pads** - object with pad names in it, values are 1
|
||||
|
||||
### session:$SESSIONID
|
||||
a session between an author and a group
|
||||
|
||||
- **groupID** - the groupID the session belongs too
|
||||
- **authorID** - the authorID the session belongs too
|
||||
- **validUntil** - the timestamp until this session is valid
|
||||
|
||||
### author2sessions:$AUTHORID
|
||||
saves the sessions of an author
|
||||
|
||||
- **sessionsIDs** - object with sessionIDs in it, values are 1
|
||||
|
||||
### group2sessions:$GROUPID
|
||||
|
||||
- **sessionsIDs** - object with sessionIDs in it, values are 1
|
||||
|
||||
# Connecting to a database backend
|
||||
|
||||
Etherpad stores everything in a single key/value table through
|
||||
[ueberDB](https://www.npmjs.com/package/ueberdb2), so the same data model works
|
||||
across many backends. The backend is selected with `dbType` in `settings.json`,
|
||||
and backend-specific connection options go in `dbSettings`.
|
||||
|
||||
The default `dirty` backend writes to a local file (`var/dirty.db`) and needs no
|
||||
setup, which is convenient for development but not recommended for production.
|
||||
For a production instance, point Etherpad at a real database such as MySQL/MariaDB,
|
||||
PostgreSQL or Redis. Etherpad creates its own table on first run; you only need
|
||||
to provision an empty database and a user with access to it.
|
||||
|
||||
## MySQL / MariaDB
|
||||
|
||||
Create the database and a user, then grant access:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE `etherpad` CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;
|
||||
CREATE USER 'etherpad'@'localhost' IDENTIFIED BY 'a-secure-password';
|
||||
GRANT CREATE,ALTER,SELECT,INSERT,UPDATE,DELETE ON `etherpad`.* TO 'etherpad'@'localhost';
|
||||
```
|
||||
|
||||
Then configure `settings.json`:
|
||||
|
||||
```json
|
||||
"dbType": "mysql",
|
||||
"dbSettings": {
|
||||
"user": "etherpad",
|
||||
"host": "localhost",
|
||||
"port": 3306,
|
||||
"password": "a-secure-password",
|
||||
"database": "etherpad",
|
||||
"charset": "utf8mb4"
|
||||
}
|
||||
```
|
||||
|
||||
Setting `charset` to `utf8mb4` is strongly recommended so that the full range of
|
||||
Unicode (including emoji) is stored correctly. To connect over a local socket
|
||||
instead of TCP, replace `host`/`port` with `"socketPath": "/var/run/mysqld/mysqld.sock"`.
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
Create the user and a database owned by it:
|
||||
|
||||
```sql
|
||||
CREATE USER etherpad WITH PASSWORD 'a-secure-password';
|
||||
CREATE DATABASE etherpad OWNER etherpad;
|
||||
```
|
||||
|
||||
Then configure `settings.json`:
|
||||
|
||||
```json
|
||||
"dbType": "postgres",
|
||||
"dbSettings": {
|
||||
"user": "etherpad",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"password": "a-secure-password",
|
||||
"database": "etherpad"
|
||||
}
|
||||
```
|
||||
|
||||
The `dbSettings` object is passed straight to the `node-postgres` connection
|
||||
pool, so any option it accepts (including a single `"connectionString"`) works.
|
||||
On Debian/Ubuntu you can use peer authentication over the local socket by
|
||||
setting `"host": "/var/run/postgresql"` and an empty password, provided the
|
||||
operating-system user that runs Etherpad matches the PostgreSQL role.
|
||||
|
||||
## Redis
|
||||
|
||||
Install Redis and make sure it persists data to disk. Configure `settings.json`
|
||||
with either discrete fields or a single connection URL:
|
||||
|
||||
```json
|
||||
"dbType": "redis",
|
||||
"dbSettings": {
|
||||
"host": "localhost",
|
||||
"port": 6379,
|
||||
"password": "a-secure-redis-password"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
"dbType": "redis",
|
||||
"dbSettings": {
|
||||
"url": "redis://:a-secure-redis-password@localhost:6379"
|
||||
}
|
||||
```
|
||||
|
||||
## Migrating from MySQL to PostgreSQL
|
||||
|
||||
[pgloader](https://pgloader.io/) can copy an existing Etherpad database from
|
||||
MySQL to PostgreSQL. Stop Etherpad first so the source database is quiescent.
|
||||
|
||||
```bash
|
||||
sudo apt-get install postgresql pgloader
|
||||
|
||||
# Create the target role and database
|
||||
sudo -u postgres createuser etherpad
|
||||
sudo -u postgres createdb -O etherpad etherpad
|
||||
|
||||
# Describe and run the migration
|
||||
cat > pgloader.load <<'EOF'
|
||||
LOAD DATABASE
|
||||
FROM mysql://etherpad:MYSQL_PASSWORD@127.0.0.1/etherpad
|
||||
INTO postgresql:///etherpad
|
||||
WITH preserve index names, prefetch rows = 100
|
||||
ALTER SCHEMA 'etherpad' RENAME TO 'public';
|
||||
EOF
|
||||
|
||||
pgloader --verbose pgloader.load
|
||||
```
|
||||
|
||||
Afterwards set the PostgreSQL user's password and make sure it can read and
|
||||
write the migrated table:
|
||||
|
||||
```sql
|
||||
ALTER USER etherpad WITH PASSWORD 'a-secure-password';
|
||||
GRANT pg_read_all_data TO etherpad;
|
||||
GRANT pg_write_all_data TO etherpad;
|
||||
```
|
||||
|
||||
Then point `settings.json` at PostgreSQL as shown above and start Etherpad.
|
||||
|
||||
::: tip
|
||||
To move data between *any* two backends supported by ueberDB, you can also
|
||||
use the `migrateDB` CLI tool, which reads every record from a source database
|
||||
descriptor and writes it to a target one. See the [CLI chapter](./cli.md).
|
||||
:::
|
||||
359
doc/deployment.md
Normal file
359
doc/deployment.md
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
# Deployment
|
||||
|
||||
This page collects working configurations for deploying Etherpad in production:
|
||||
running it behind a reverse proxy, hosting it under a subdirectory, terminating
|
||||
HTTPS natively, running it as a system service, and deploying it on Kubernetes.
|
||||
|
||||
Etherpad listens on port `9001` by default. Throughout this page the upstream
|
||||
Etherpad server is assumed to be reachable at `http://127.0.0.1:9001`.
|
||||
|
||||
## Running behind a reverse proxy
|
||||
|
||||
The recommended production setup is to run Etherpad on `127.0.0.1:9001` and put a
|
||||
reverse proxy in front of it to terminate TLS, serve a virtual host, and forward
|
||||
requests.
|
||||
|
||||
Etherpad uses WebSockets (via socket.io). The load-bearing part of every proxy
|
||||
config below is the WebSocket upgrade: the proxy **must** forward the `Upgrade`
|
||||
and `Connection` headers, or real-time editing will silently fail back to slow
|
||||
long-polling (or break entirely).
|
||||
|
||||
When Etherpad runs behind a proxy you should also set `trustProxy: true` in your
|
||||
settings so that Etherpad honours the `X-Forwarded-*` headers (correct client IP,
|
||||
secure-cookie flag, etc.). See the `trustProxy` section in the [Configuration documentation](./configuration.md) for the full details of which headers are trusted.
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
# Map the Upgrade header so WebSockets work. Place this in the http context.
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name pad.example.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/etherpad.crt;
|
||||
ssl_certificate_key /etc/nginx/ssl/etherpad.key;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9001;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_pass_header Server;
|
||||
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
}
|
||||
|
||||
# Redirect plain HTTP to HTTPS
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name pad.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
### Apache
|
||||
|
||||
Enable `mod_proxy`, `mod_proxy_http`, `mod_proxy_wstunnel` and `mod_headers`.
|
||||
The `mod_proxy_wstunnel` `upgrade=websocket` syntax requires Apache 2.4.47 or
|
||||
newer.
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName pad.example.com
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/ssl/etherpad/etherpad.crt
|
||||
SSLCertificateKeyFile /etc/ssl/etherpad/etherpad.key
|
||||
|
||||
ProxyVia On
|
||||
ProxyRequests Off
|
||||
ProxyPreserveHost On
|
||||
|
||||
# WebSocket traffic (socket.io) must be matched first.
|
||||
<Location "/socket.io">
|
||||
ProxyPass "ws://127.0.0.1:9001/socket.io" upgrade=websocket timeout=30
|
||||
ProxyPassReverse "ws://127.0.0.1:9001/socket.io"
|
||||
</Location>
|
||||
|
||||
<Location "/">
|
||||
ProxyPass "http://127.0.0.1:9001/" retry=0 timeout=30
|
||||
ProxyPassReverse "http://127.0.0.1:9001/"
|
||||
</Location>
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
### Caddy
|
||||
|
||||
Caddy v2 proxies WebSocket connections automatically and obtains/renews a
|
||||
certificate for you, so the configuration is minimal:
|
||||
|
||||
```caddy
|
||||
pad.example.com {
|
||||
reverse_proxy 127.0.0.1:9001
|
||||
}
|
||||
```
|
||||
|
||||
### Traefik
|
||||
|
||||
Traefik v2 also proxies WebSockets transparently. For a Docker deployment, attach
|
||||
these labels to the Etherpad container:
|
||||
|
||||
```yaml
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.etherpad.rule=Host(`pad.example.com`)"
|
||||
- "traefik.http.routers.etherpad.entrypoints=websecure"
|
||||
- "traefik.http.routers.etherpad.tls.certresolver=myresolver"
|
||||
- "traefik.http.services.etherpad.loadbalancer.server.port=9001"
|
||||
- "traefik.http.services.etherpad.loadbalancer.passhostheader=true"
|
||||
```
|
||||
|
||||
### HAProxy
|
||||
|
||||
HAProxy detects the `Connection: Upgrade` exchange automatically and switches to
|
||||
tunnel mode once the WebSocket is established. The important value is
|
||||
`timeout tunnel`, which governs the lifetime of the upgraded connection.
|
||||
|
||||
```haproxy
|
||||
frontend http
|
||||
mode http
|
||||
bind *:80
|
||||
bind *:443 ssl crt /etc/haproxy/certs/etherpad.pem alpn h2,http/1.1
|
||||
http-request redirect scheme https code 301 unless { ssl_fc }
|
||||
http-request add-header X-Forwarded-Proto https if { ssl_fc }
|
||||
default_backend etherpad
|
||||
|
||||
backend etherpad
|
||||
mode http
|
||||
option forwardfor
|
||||
timeout client 25s
|
||||
timeout server 25s
|
||||
timeout tunnel 3600s
|
||||
server pad 127.0.0.1:9001
|
||||
```
|
||||
|
||||
## Hosting under a subdirectory
|
||||
|
||||
To serve Etherpad from a path such as `https://example.com/pad` rather than from
|
||||
the root of a domain, the proxy must send the `X-Proxy-Path` header so that
|
||||
Etherpad rewrites its own asset and API URLs to include the prefix. This header
|
||||
is honoured regardless of the `trustProxy` setting — see the [Configuration documentation](./configuration.md).
|
||||
|
||||
```nginx
|
||||
location /pad/ {
|
||||
rewrite ^/pad/(.*)$ /$1 break;
|
||||
proxy_pass http://127.0.0.1:9001;
|
||||
proxy_buffering off;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Proxy-Path /pad;
|
||||
|
||||
# WebSocket support
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
```
|
||||
|
||||
## Native HTTPS without a proxy
|
||||
|
||||
Etherpad can terminate TLS itself using Node's native HTTPS server, with no
|
||||
reverse proxy required. Configure the `ssl` block in `settings.json`:
|
||||
|
||||
```json
|
||||
"ssl": {
|
||||
"key": "/path-to-your/etherpad-server.key",
|
||||
"cert": "/path-to-your/etherpad-server.crt",
|
||||
"ca": ["/path-to-your/intermediate-cert1.crt", "/path-to-your/intermediate-cert2.crt"]
|
||||
}
|
||||
```
|
||||
|
||||
* `key` — path to the private key file.
|
||||
* `cert` — path to the certificate file.
|
||||
* `ca` — an (optional) array of intermediate/chain certificate paths.
|
||||
|
||||
Restart Etherpad after editing the settings. It will now serve HTTPS on its
|
||||
configured port.
|
||||
|
||||
For local testing you can generate a self-signed certificate with a single
|
||||
command:
|
||||
|
||||
```bash
|
||||
openssl req -x509 -newkey rsa:4096 -nodes -days 365 \
|
||||
-keyout etherpad-server.key -out etherpad-server.crt \
|
||||
-subj "/CN=localhost"
|
||||
```
|
||||
|
||||
Make sure the files are readable only by the user that runs Etherpad:
|
||||
|
||||
```bash
|
||||
chmod 400 etherpad-server.key etherpad-server.crt
|
||||
chown etherpad etherpad-server.key etherpad-server.crt
|
||||
```
|
||||
|
||||
::: tip
|
||||
Self-signed certificates trigger browser warnings and are only suitable for
|
||||
testing. For production, obtain a free, trusted certificate from
|
||||
[Let's Encrypt](https://letsencrypt.org/), or terminate TLS at a reverse proxy
|
||||
(see above) and let it manage certificate issuance and renewal.
|
||||
:::
|
||||
|
||||
## Running as a service (systemd)
|
||||
|
||||
On a modern Linux distribution, run Etherpad as a `systemd` service so it starts
|
||||
on boot and restarts automatically on failure.
|
||||
|
||||
Create a dedicated unprivileged user and install Etherpad into its home
|
||||
directory (for example `/opt/etherpad`), owned by that user. Etherpad refuses to
|
||||
start as root.
|
||||
|
||||
Create `/etc/systemd/system/etherpad.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Etherpad collaborative editor
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=etherpad
|
||||
Group=etherpad
|
||||
WorkingDirectory=/opt/etherpad
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/usr/bin/pnpm run prod
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Adjust `WorkingDirectory` to your install path and the `ExecStart` path to
|
||||
wherever `pnpm` lives (`which pnpm`). Then enable and start the service:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now etherpad.service
|
||||
|
||||
# check status and follow logs
|
||||
sudo systemctl status etherpad.service
|
||||
sudo journalctl -u etherpad.service -f
|
||||
```
|
||||
|
||||
## Kubernetes (Istio)
|
||||
|
||||
The following manifest deploys Etherpad behind an Istio ingress gateway. It
|
||||
defines three resources: a `Gateway` (TLS + hostname), a `VirtualService`
|
||||
(routing with WebSocket-friendly timeouts), and a `DestinationRule` (sticky
|
||||
sessions via the socket.io `io` cookie).
|
||||
|
||||
It assumes:
|
||||
|
||||
* Istio >= 1.18
|
||||
* A `Service` named `etherpad` in the `etherpad` namespace, on port `9001`
|
||||
* A TLS secret `etherpad-tls` provisioned in the gateway namespace
|
||||
* You replace `<your-host>` with your own hostname
|
||||
|
||||
::: warning
|
||||
Sticky sessions are necessary but **not** sufficient for a multi-replica
|
||||
Etherpad deployment. Multi-replica also needs the socket.io Redis adapter so
|
||||
that pad state is shared across pods. Without it, two clients editing the same
|
||||
pad but routed to different pods will see divergent state.
|
||||
|
||||
Recommendation: start with `replicas: 1` plus good failover, and only go
|
||||
multi-replica once the Redis adapter is wired up.
|
||||
:::
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.istio.io/v1beta1
|
||||
kind: Gateway
|
||||
metadata:
|
||||
name: etherpad
|
||||
namespace: etherpad
|
||||
spec:
|
||||
selector:
|
||||
istio: ingressgateway
|
||||
servers:
|
||||
- port:
|
||||
number: 443
|
||||
name: https
|
||||
protocol: HTTPS
|
||||
tls:
|
||||
mode: SIMPLE
|
||||
credentialName: etherpad-tls
|
||||
hosts:
|
||||
- <your-host>
|
||||
- port:
|
||||
number: 80
|
||||
name: http
|
||||
protocol: HTTP
|
||||
hosts:
|
||||
- <your-host>
|
||||
tls:
|
||||
httpsRedirect: true
|
||||
|
||||
---
|
||||
apiVersion: networking.istio.io/v1beta1
|
||||
kind: VirtualService
|
||||
metadata:
|
||||
name: etherpad
|
||||
namespace: etherpad
|
||||
spec:
|
||||
hosts:
|
||||
- <your-host>
|
||||
gateways:
|
||||
- etherpad
|
||||
http:
|
||||
- match:
|
||||
- uri:
|
||||
prefix: /
|
||||
route:
|
||||
- destination:
|
||||
host: etherpad
|
||||
port:
|
||||
number: 9001
|
||||
# No per-request timeout — websockets and long-polling sit on the
|
||||
# connection indefinitely. The default of 15s kills WS upgrades.
|
||||
timeout: 0s
|
||||
|
||||
---
|
||||
apiVersion: networking.istio.io/v1beta1
|
||||
kind: DestinationRule
|
||||
metadata:
|
||||
name: etherpad
|
||||
namespace: etherpad
|
||||
spec:
|
||||
host: etherpad
|
||||
trafficPolicy:
|
||||
loadBalancer:
|
||||
# Sticky sessions on the socket.io session cookie. Required so that
|
||||
# long-polling fallback requests land on the same pod that owns the
|
||||
# session state.
|
||||
consistentHash:
|
||||
httpCookie:
|
||||
name: io
|
||||
ttl: 0s # session cookie, expires with the browser tab
|
||||
connectionPool:
|
||||
tcp:
|
||||
maxConnections: 10000
|
||||
http:
|
||||
# Must comfortably exceed socket.io's pingInterval (25s) +
|
||||
# pingTimeout (20s). 1h is conservative.
|
||||
idleTimeout: 3600s
|
||||
h2UpgradePolicy: UPGRADE
|
||||
http1MaxPendingRequests: 1000
|
||||
```
|
||||
240
doc/development.md
Normal file
240
doc/development.md
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
# Development
|
||||
|
||||
This page is a contributor-oriented tour of the Etherpad source tree and of a
|
||||
few internals that plugin authors and core contributors commonly need to
|
||||
understand: how the source is laid out, how pads are converted to and from
|
||||
other formats, and how to access the database from server-side code.
|
||||
|
||||
The Etherpad server is written in TypeScript (`.ts`). Most server code lives
|
||||
under `src/node/` and most client code under `src/static/js/`.
|
||||
|
||||
## Source tree overview
|
||||
|
||||
The repository root contains, among others, the following directories:
|
||||
|
||||
```
|
||||
etherpad/
|
||||
|- bin/ # maintenance and build scripts (run.sh, pad tools, docs, release)
|
||||
|- doc/ # this manual, in AsciiDoc and Markdown
|
||||
|- src/ # the Etherpad source code
|
||||
|- packaging/ # OS/distribution packaging helpers
|
||||
|- var/ # runtime data (e.g. the dirty.db database file)
|
||||
```
|
||||
|
||||
`bin/` contains scripts for running and maintaining Etherpad. For example
|
||||
`bin/run.sh` starts the server, and there are TypeScript utilities such as
|
||||
`bin/checkPad.ts`, `bin/deletePad.ts`, `bin/repairPad.ts`,
|
||||
`bin/rebuildPad.ts`, `bin/migrateDB.ts` and `bin/make_docs.ts`.
|
||||
|
||||
The HTML manual is built from the AsciiDoc sources in `doc/` by
|
||||
`bin/make_docs.ts` (exposed as the `makeDocs` script), which shells out to
|
||||
`asciidoctor` and writes the result to `out/doc/`. From the repository root you
|
||||
can run it with `pnpm run makeDocs`. (`asciidoctor` must be installed.)
|
||||
|
||||
The `src/` directory looks like this:
|
||||
|
||||
```
|
||||
src/
|
||||
|- locales/ # translations, managed via https://translatewiki.net
|
||||
|- node/ # server-side code
|
||||
|- static/ # client-side code, CSS and fonts
|
||||
|- templates/ # server-rendered page templates
|
||||
|- ep.json # core plugin/hook registration
|
||||
|- package.json # package name: ep_etherpad-lite
|
||||
```
|
||||
|
||||
### src/node/ (server side)
|
||||
|
||||
```
|
||||
src/node/
|
||||
|- db/ # database access and pad/author/group/session state
|
||||
|- eejs/ # server-side embedded-JS templating
|
||||
|- handler/ # import/export and collaboration message handling
|
||||
|- hooks/ # express route registration and i18n
|
||||
|- security/ # crypto, OAuth2/OIDC, secret rotation
|
||||
|- types/ # shared TypeScript types
|
||||
|- updater/ # in-place self-update machinery
|
||||
|- utils/ # settings, import/export format helpers, toolbar, minification
|
||||
|- server.ts # entry point
|
||||
```
|
||||
|
||||
`db/` contains the modules that read and write pad state. `Pad.ts` manages an
|
||||
individual pad; `PadManager.ts`, `AuthorManager.ts`, `GroupManager.ts`,
|
||||
`SessionManager.ts` and `ReadOnlyManager.ts` manage the corresponding records;
|
||||
`DB.ts` exposes the low-level key/value store (see
|
||||
[Accessing the database from server code / plugins](#accessing-the-database-from-server-code-plugins)); and `API.ts` implements
|
||||
the public HTTP API.
|
||||
|
||||
`handler/` contains the request and message handlers. `PadMessageHandler.ts`
|
||||
drives real-time collaboration, while `ImportHandler.ts` and `ExportHandler.ts`
|
||||
handle import and export.
|
||||
|
||||
`hooks/` contains mostly Express-related code. `i18n.ts` builds the translation
|
||||
files and registers routes to serve them, and `hooks/express/` registers the
|
||||
routes that serve pads, the timeslider, static assets and the admin pages.
|
||||
|
||||
`utils/` contains the import/export format converters (`ImportHtml.ts`,
|
||||
`ExportHtml.ts`, `ExportTxt.ts`, `ExportEtherpad.ts`, `ImportEtherpad.ts`,
|
||||
`ExportHelper.ts`, and native converters such as `ExportPdfNative.ts` and
|
||||
`ImportDocxNative.ts`), the settings parser (`Settings.ts`), the toolbar builder
|
||||
(`toolbar.ts`) and the asset minifier (`Minify.ts`).
|
||||
|
||||
### src/static/ (client side)
|
||||
|
||||
```
|
||||
src/static/
|
||||
|- css/ # stylesheets, including css/pad/icons.css
|
||||
|- font/ # web fonts, including the fontawesome-etherpad icon font
|
||||
|- img/
|
||||
|- js/ # client-side TypeScript
|
||||
|- skins/ # bundled UI skins
|
||||
|- vendor/
|
||||
```
|
||||
|
||||
`js/` contains the client-side editor code. Notable modules include
|
||||
`ace2_inner.ts` and `ace2_common.ts` (the editor core), `contentcollector.ts`,
|
||||
`linestylefilter.ts` and `domline.ts` (content/attribute processing, shared
|
||||
with the server import/export pipeline), `Changeset.ts` and `AttributePool.ts`
|
||||
(the changeset and attribute model), and `collab_client.ts` (the
|
||||
client side of real-time collaboration).
|
||||
|
||||
### src/templates/
|
||||
|
||||
`templates/` contains the server-rendered page templates for the index, the
|
||||
pad, the timeslider and the admin pages, plus the bootstrap scripts that load
|
||||
the client bundles. The templates expose named `eejs` blocks that plugins can
|
||||
hook into to inject custom HTML.
|
||||
|
||||
## How Etherpad converts pads to and from other formats
|
||||
|
||||
Internally a pad is not stored as HTML. A pad is a sequence of lines, and each
|
||||
line carries **attributes** (for example `heading1`, `bullet` or a list number).
|
||||
The set of attributes that a pad can use is stored in its **attribute pool**; the
|
||||
pool only records which attributes exist, not where they are applied. The
|
||||
pool grows over the history of the pad.
|
||||
|
||||
Where an attribute is applied to a line is recorded in an **attribute string**,
|
||||
and a line that carries a line-level attribute is prefixed with a **line marker**
|
||||
(`lmkr`). Attribute strings and changesets are defined by
|
||||
`src/static/js/Changeset.ts` and `src/static/js/AttributePool.ts`.
|
||||
|
||||
### Collecting content
|
||||
|
||||
`src/static/js/contentcollector.ts` is the shared starting point for both the
|
||||
client (when content is typed or pasted) and the server (when content is
|
||||
imported). It walks the incoming DOM/HTML, decides which attributes apply to
|
||||
each line, adds the discovered attributes to the attribute pool, and emits the
|
||||
resulting attribute strings. On import, `src/node/utils/ImportHtml.ts` calls
|
||||
`contentcollector.makeContentCollector(...)` to do exactly this, and the HTML
|
||||
import path in `src/node/handler/ImportHandler.ts` ultimately drives it.
|
||||
|
||||
### From attributes to HTML/text (export)
|
||||
|
||||
On export the flow is, conceptually:
|
||||
|
||||
```
|
||||
contentcollector.ts
|
||||
-> linestylefilter.ts
|
||||
-> ExportHtml.ts / ExportTxt.ts (helped by ExportHelper.ts)
|
||||
-> ExportHandler.ts
|
||||
-> the HTTP API / /export/* route
|
||||
```
|
||||
|
||||
- `src/static/js/linestylefilter.ts` walks each line, reads its attributes,
|
||||
and turns them into the classes/markup the line should render with.
|
||||
- `src/node/utils/ExportHelper.ts` adds export-only logic that does not belong
|
||||
in the live editor. The clearest example is lists: in the editor each list
|
||||
item is rendered as its own line-level block, but a clean export needs the
|
||||
items collapsed into a single properly nested list. The helper performs that
|
||||
reshaping for export only.
|
||||
- `src/node/utils/ExportHtml.ts` and `src/node/utils/ExportTxt.ts` (and
|
||||
`ExportEtherpad.ts` for the native `.etherpad` format) turn the attributed
|
||||
text (`atext`) into the final HTML or plain text.
|
||||
- `src/node/handler/ExportHandler.ts` receives the export request and dispatches
|
||||
on the requested format — for instance, office formats such as `.docx` and
|
||||
`.pdf` are routed through the native converters / LibreOffice rather than
|
||||
through the plain HTML/text path.
|
||||
|
||||
On the client side, edits are turned into changesets by the editor, attributes
|
||||
are translated into CSS classes (so `heading2` becomes
|
||||
`class="heading2"`), and `src/static/js/domline.ts` (`createDomLine`) renders
|
||||
the final DOM for each line.
|
||||
|
||||
## Accessing the database from server code / plugins
|
||||
|
||||
Etherpad stores everything in a single key/value store backed by
|
||||
[ueberDB](https://www.npmjs.com/package/ueberdb2), which abstracts over the
|
||||
configured database (dirtyDB, MySQL/MariaDB, PostgreSQL, SQLite, MongoDB, Redis,
|
||||
and others). Server-side code and plugins access it through
|
||||
`src/node/db/DB.ts`.
|
||||
|
||||
The package name of the core module is, for historical reasons, still
|
||||
`ep_etherpad-lite`, so plugins import the database module like this:
|
||||
|
||||
```javascript
|
||||
const db = require('ep_etherpad-lite/node/db/DB');
|
||||
```
|
||||
|
||||
The exposed methods are asynchronous and return promises (use `await`), not the
|
||||
old callback style. The available methods are `get`, `set`, `remove`, `getSub`,
|
||||
`setSub`, `findKeys` and `findKeysPaged`:
|
||||
|
||||
```javascript
|
||||
// Read a record (returns undefined/null if it does not exist)
|
||||
const value = await db.get('record_key');
|
||||
|
||||
// Create or replace a record
|
||||
await db.set('record_key', data);
|
||||
|
||||
// Read or write a nested value inside a record
|
||||
const colorId = await db.getSub('author_key', ['colorId']);
|
||||
await db.setSub('author_key', ['email'], 'tutti@frutti.org');
|
||||
|
||||
// Delete a record
|
||||
await db.remove('record_key');
|
||||
```
|
||||
|
||||
For example, given the author record:
|
||||
|
||||
```json
|
||||
{"colorId":"#79d9d9","name":"tutti","timestamp":1364832712430,"padIDs":{"mypad":1}}
|
||||
```
|
||||
|
||||
calling `await db.setSub('author_key', ['email'], 'tutti@frutti.org')` yields:
|
||||
|
||||
```json
|
||||
{"colorId":"#79d9d9","name":"tutti","timestamp":1364832712430,"padIDs":{"mypad":1},"email":"tutti@frutti.org"}
|
||||
```
|
||||
|
||||
::: warning
|
||||
Keys are namespaced (for example `pad:<padId>`,
|
||||
`pad:<padId>:revs:<rev>`, `globalAuthor:<authorId>`). Prefer the high-level
|
||||
managers (`Pad.ts`, `AuthorManager.ts`, etc.) over direct `DB` access where one
|
||||
exists; reach for `DB` directly only for data your plugin owns, and use a key
|
||||
prefix unique to your plugin to avoid collisions.
|
||||
:::
|
||||
|
||||
## Adding a toolbar icon
|
||||
|
||||
Etherpad's toolbar icons come from the bundled `fontawesome-etherpad` icon
|
||||
font in `src/static/font/`. Toolbar buttons reference an icon by a
|
||||
`buttonicon-<name>` CSS class (see `src/node/utils/toolbar.ts`, which builds
|
||||
each button's class as `buttonicon buttonicon-<name>`), and those classes are
|
||||
defined in `src/static/css/pad/icons.css`. The font itself is generated with
|
||||
[Fontello](http://fontello.com) from `src/static/font/config.json` (whose
|
||||
`css_prefix_text` is `buttonicon-`).
|
||||
|
||||
To add a new icon:
|
||||
|
||||
1. Go to [Fontello](http://fontello.com) and import the existing
|
||||
`src/static/font/config.json` (Fontello's "import" loads the current icon
|
||||
set and pre-selects the icons it contains).
|
||||
2. Select the additional icon(s) you want, then click **Download webfont**.
|
||||
3. From the unzipped download, copy `config.json` and the
|
||||
`font/fontawesome-etherpad.*` files over the ones in `src/static/font/`.
|
||||
4. From the unzipped `css/fontawesome-etherpad.css`, copy the new
|
||||
`.buttonicon-<name>:before { content: '\\eXXX'; }` rules into
|
||||
`src/static/css/pad/icons.css`, replacing the existing block of icon rules.
|
||||
|
||||
The icon is then available wherever a `buttonicon-<name>` class can be used,
|
||||
including toolbar button definitions.
|
||||
|
|
@ -118,6 +118,25 @@ The `settings.json.docker` available by default allows to control almost every s
|
|||
| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
|
||||
|
||||
|
||||
### Updates & privacy (offline / air-gapped)
|
||||
|
||||
Etherpad makes a small number of outbound calls (a periodic version check and the admin plugin catalogue). In an air-gapped or firewalled deployment these can be disabled entirely without editing `settings.json` inside the image — set the variables below. See [PRIVACY.md](https://github.com/ether/etherpad/blob/develop/PRIVACY.md) and [doc/admin/updates.md](admin/updates.md) for what each call sends.
|
||||
|
||||
| Variable | Description | Default |
|
||||
| --------------------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------- |
|
||||
| `PRIVACY_UPDATE_CHECK` | Set to `false` to disable the hourly version check (`UpdateCheck.ts`). | `true` |
|
||||
| `PRIVACY_PLUGIN_CATALOG` | Set to `false` to disable the admin plugin browser (manual install-by-name via CLI still works). | `true` |
|
||||
| `UPDATES_TIER` | Self-updater tier: `off` \| `notify` \| `manual` \| `auto` \| `autonomous`. Set to `off` to suppress the GitHub Releases check entirely. | `notify` |
|
||||
| `UPDATES_SOURCE` | Where update metadata is fetched from. | `github` |
|
||||
| `UPDATES_CHANNEL` | Release channel to track. | `stable` |
|
||||
| `UPDATES_CHECK_INTERVAL_HOURS` | How often (hours) the updater polls when not `off`. | `6` |
|
||||
| `UPDATES_GITHUB_REPO` | Repository the updater checks for releases. | `ether/etherpad` |
|
||||
| `UPDATES_REQUIRE_ADMIN_FOR_STATUS`| Lock `/admin/update/status` to authenticated admins. | `false` |
|
||||
| `UPDATE_SERVER` | Endpoint backing the version check. Point elsewhere (or disable the check above) for offline installs. | `https://etherpad.org/ep_infos` |
|
||||
|
||||
> **Fully offline:** set `UPDATES_TIER=off`, `PRIVACY_UPDATE_CHECK=false`, and `PRIVACY_PLUGIN_CATALOG=false`. The version check is fire-and-forget and already fails closed (a blocked endpoint is caught and logged, it does not prevent startup), but disabling it removes the outbound attempt and the log noise.
|
||||
|
||||
|
||||
### Database
|
||||
|
||||
| Variable | Description | Default |
|
||||
|
|
@ -355,11 +374,11 @@ services:
|
|||
- postgres
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin}
|
||||
ADMIN_PASSWORD: "${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:?Set DOCKER_COMPOSE_APP_ADMIN_PASSWORD to a strong value}"
|
||||
DB_CHARSET: ${DOCKER_COMPOSE_APP_DB_CHARSET:-utf8mb4}
|
||||
DB_HOST: postgres
|
||||
DB_NAME: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}
|
||||
DB_PASS: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}
|
||||
DB_PASS: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}"
|
||||
DB_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}
|
||||
DB_TYPE: "postgres"
|
||||
DB_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
|
||||
|
|
@ -367,7 +386,7 @@ services:
|
|||
DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEFAULT_PAD_TEXT:- }
|
||||
DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DISABLE_IP_LOGGING:-false}
|
||||
SOFFICE: ${DOCKER_COMPOSE_APP_SOFFICE:-null}
|
||||
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-true}
|
||||
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-false}
|
||||
restart: always
|
||||
ports:
|
||||
- "${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}"
|
||||
|
|
@ -376,7 +395,7 @@ services:
|
|||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}
|
||||
POSTGRES_PASSWORD: ${DOCKER_COMPOSE_POSTGRES_PASSWORD:-admin}
|
||||
POSTGRES_PASSWORD: "${DOCKER_COMPOSE_POSTGRES_PASSWORD:?Set DOCKER_COMPOSE_POSTGRES_PASSWORD to a strong value}"
|
||||
POSTGRES_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}
|
||||
POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
|
|
|
|||
204
doc/faq.md
Normal file
204
doc/faq.md
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
# FAQ
|
||||
|
||||
This page answers common operational questions about running and maintaining
|
||||
an Etherpad instance. It collects material previously kept on the project wiki.
|
||||
|
||||
## How do I install Etherpad?
|
||||
|
||||
There are several supported ways to install Etherpad. Pick whichever suits your
|
||||
environment.
|
||||
|
||||
### Docker
|
||||
|
||||
The official image is published to Docker Hub (`etherpad/etherpad`) and to the
|
||||
GitHub Container Registry (`ghcr.io/ether/etherpad`) with identical tags.
|
||||
|
||||
```bash
|
||||
docker pull etherpad/etherpad
|
||||
docker run -p 9001:9001 etherpad/etherpad
|
||||
```
|
||||
|
||||
See the [Docker chapter](./docker.md) for building personalized images, enabling plugins, and
|
||||
configuring office-format import/export.
|
||||
|
||||
### One-line installer (macOS / Linux / WSL)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.sh | sh
|
||||
```
|
||||
|
||||
On Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/ether/etherpad/master/bin/installer.ps1 | iex
|
||||
```
|
||||
|
||||
The installer clones Etherpad, installs dependencies and builds the frontend.
|
||||
Set `ETHERPAD_RUN=1` to also start it once the install finishes.
|
||||
|
||||
### apt repository (Debian / Ubuntu)
|
||||
|
||||
Etherpad publishes a signed APT repository (`stable` channel). Import the signing
|
||||
key, add the repository and install:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://etherpad.org/key.asc \
|
||||
| sudo gpg --dearmor -o /usr/share/keyrings/etherpad.gpg
|
||||
|
||||
echo "deb [signed-by=/usr/share/keyrings/etherpad.gpg] https://etherpad.org/apt stable main" \
|
||||
| sudo tee /etc/apt/sources.list.d/etherpad.list
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install etherpad
|
||||
```
|
||||
|
||||
The repository provides `amd64` and `arm64` builds. Etherpad depends on
|
||||
Node.js >= 24, so on older distributions you may also need NodeSource's apt
|
||||
repository to satisfy that dependency.
|
||||
|
||||
### From source
|
||||
|
||||
Etherpad requires [Node.js](https://nodejs.org/) >= 24 and `pnpm`.
|
||||
|
||||
```bash
|
||||
git clone -b master https://github.com/ether/etherpad
|
||||
cd etherpad
|
||||
pnpm i
|
||||
pnpm run build:etherpad
|
||||
pnpm run prod
|
||||
```
|
||||
|
||||
Then open `http://localhost:9001`.
|
||||
|
||||
## What URL paths does Etherpad serve?
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/admin` | Administration dashboard (requires admin login). |
|
||||
| `/admin/plugins` | Install, update and remove plugins from the web UI. |
|
||||
| `/admin/settings` | Edit `settings.json` from the web UI. |
|
||||
| `/p/:padID` | Open (or create) the pad with the given `padID`, e.g. `/p/foo`. |
|
||||
| `/p/:padID/timeslider` | Open the pad's history/timeslider view. Append `#N` to jump to a specific revision, e.g. `/p/foo/timeslider#5`. |
|
||||
| `/p/:padID/export/:type` | Export the pad in the given format, e.g. `/p/foo/export/html`. Append `?revs=N` to export a specific revision. |
|
||||
|
||||
Supported export types:
|
||||
|
||||
- **Native (no extra dependencies):** `txt`, `html`, `etherpad`, `docx`, `pdf`.
|
||||
- **Via LibreOffice:** `odt`, `doc`, `rtf` — these require the `soffice` setting
|
||||
to point at a LibreOffice executable. See the office-format notes in the
|
||||
[Docker chapter](./docker.md).
|
||||
|
||||
## How do I list all pads?
|
||||
|
||||
The recommended way is the HTTP API method `listAllPads`, combined with `jq`:
|
||||
|
||||
```bash
|
||||
ETHERPAD_HOST='https://pad.example.com'
|
||||
ETHERPAD_API_KEY='...' # the APIKEY.txt file in the Etherpad root
|
||||
ETHERPAD_API_VERSION='...' # see https://pad.example.com/api
|
||||
|
||||
curl -s "${ETHERPAD_HOST}/api/${ETHERPAD_API_VERSION}/listAllPads?apikey=${ETHERPAD_API_KEY}" \
|
||||
| jq -r '.data.padIDs[]'
|
||||
```
|
||||
|
||||
For an interactive list with management actions, install the `ep_adminpads2`
|
||||
plugin and browse to `/admin/pads`.
|
||||
|
||||
As a last resort you can query the database directly. The exact query depends on
|
||||
your configured backend; pad records use keys of the form `pad:<padID>` and
|
||||
`pad:<padID>:revs:<n>`. For example, with SQLite:
|
||||
|
||||
```bash
|
||||
sqlite3 ./var/sqlite.db "select key from store where key like 'pad:%'" \
|
||||
| grep -Eo '^pad:[^:]+' \
|
||||
| sed -e 's/pad://' \
|
||||
| sort -u
|
||||
```
|
||||
|
||||
Prefer the API or admin plugin over direct SQL: the schema is an implementation
|
||||
detail and may change.
|
||||
|
||||
## How do I delete or manage pads?
|
||||
|
||||
Use the HTTP API `deletePad` method:
|
||||
|
||||
```bash
|
||||
curl -s "${ETHERPAD_HOST}/api/${ETHERPAD_API_VERSION}/deletePad?apikey=${ETHERPAD_API_KEY}&padID=foo"
|
||||
```
|
||||
|
||||
The API also offers `copyPad`, `movePad`, `getRevisionsCount` and more — see the
|
||||
[HTTP API chapter](./api/http_api.md).
|
||||
|
||||
For a web UI, install the `ep_adminpads2` plugin and manage pads from
|
||||
`/admin/pads`, where you can search, view and delete pads.
|
||||
|
||||
The `deletePad` CLI tool is also available for operators:
|
||||
|
||||
```bash
|
||||
pnpm run --filter bin deletePad <padID>
|
||||
```
|
||||
|
||||
## How do I back up and restore pads?
|
||||
|
||||
### Back up the whole instance
|
||||
|
||||
All pad data lives in the configured database. Back it up using the tool
|
||||
appropriate to your backend (for example `mysqldump` for MySQL/MariaDB,
|
||||
`pg_dump` for PostgreSQL, or a file copy of `var/*.db` for the file-based
|
||||
`dirty`/`rusty` engines while Etherpad is stopped). A regular, automated dump of
|
||||
the database is the canonical backup for a production instance.
|
||||
|
||||
### Back up a single pad
|
||||
|
||||
Export the pad over HTTP by appending `/export/<type>` to its URL. Plain text,
|
||||
HTML and the round-trippable `etherpad` format are most useful for backups:
|
||||
|
||||
```bash
|
||||
curl -o mypad.txt https://pad.example.com/p/foo/export/txt
|
||||
curl -o mypad.html https://pad.example.com/p/foo/export/html
|
||||
curl -o mypad.etherpad https://pad.example.com/p/foo/export/etherpad
|
||||
```
|
||||
|
||||
The `etherpad` export preserves the pad's full history and can be re-imported,
|
||||
making it the best choice for migrating or archiving an individual pad.
|
||||
|
||||
### Restore or inspect an old revision
|
||||
|
||||
Every state the pad has been in is stored in the database, so you can retrieve
|
||||
an earlier revision without a separate backup:
|
||||
|
||||
- Open `/p/:padID/timeslider` to browse the history and find the revision
|
||||
number you want.
|
||||
- Export a specific revision directly with the `?revs=N` query parameter, e.g.
|
||||
`https://pad.example.com/p/foo/export/html?revs=1000`.
|
||||
|
||||
### Repairing a damaged pad
|
||||
|
||||
If a pad is corrupt, use the CLI repair tools (`checkPad`, `repairPad`,
|
||||
`rebuildPad`) documented in the [CLI chapter](./cli.md). Always back up the database before
|
||||
running write operations.
|
||||
|
||||
## How do I limit history or prune revisions?
|
||||
|
||||
Etherpad keeps the full revision history of every pad, so the database grows
|
||||
over time. To reclaim space, use the pad-compaction CLI tools, which collapse or
|
||||
trim revision history for one pad, every pad, or only stale pads:
|
||||
|
||||
```bash
|
||||
# Collapse all history of one pad
|
||||
pnpm run --filter bin compactPad <padID>
|
||||
|
||||
# Keep only the last 50 revisions of one pad
|
||||
pnpm run --filter bin compactPad <padID> --keep 50
|
||||
|
||||
# Compact every pad on the instance
|
||||
pnpm run --filter bin compactAllPads
|
||||
|
||||
# Compact only pads not edited in the last 90 days, keeping the last 50 revisions
|
||||
pnpm run --filter bin compactStalePads --older-than 90 --keep 50
|
||||
```
|
||||
|
||||
These tools require `cleanup.enabled = true` in `settings.json` and are
|
||||
**destructive** — history is collapsed or trimmed. Export anything you can't
|
||||
afford to lose via the pad's `/export/etherpad` route first. The same primitive
|
||||
is available over the wire as the `compactPad` HTTP API method. See the [CLI chapter](./cli.md) for full details.
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"devDependencies": {
|
||||
"oxc-minify": "^0.135.0",
|
||||
"vitepress": "^2.0.0-alpha.17"
|
||||
"oxc-minify": "^0.141.0",
|
||||
"vitepress": "^2.0.0-alpha.18"
|
||||
},
|
||||
"scripts": {
|
||||
"docs:dev": "vitepress dev",
|
||||
|
|
|
|||
|
|
@ -45,13 +45,13 @@
|
|||
"node": ">=24.0.0",
|
||||
"pnpm": ">=11.1.2"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.2",
|
||||
"packageManager": "pnpm@11.10.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ether/etherpad.git"
|
||||
},
|
||||
"engineStrict": true,
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.3",
|
||||
"license": "Apache-2.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
|
|
|||
4388
pnpm-lock.yaml
generated
4388
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -21,20 +21,59 @@ strictDepBuilds: false
|
|||
# As of pnpm 11, overrides must live here (root package.json's pnpm.overrides
|
||||
# is no longer read). Force-bump transitive deps with known CVEs.
|
||||
overrides:
|
||||
basic-ftp: '>=5.3.0'
|
||||
'@babel/core@<7.29.6': '>=7.29.6'
|
||||
'@opentelemetry/core@<2.8.0': '>=2.8.0'
|
||||
basic-ftp@<5.3.1: '>=5.3.1 <6.0.0'
|
||||
brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3'
|
||||
diff@>=6.0.0 <8.0.3: '>=8.0.3'
|
||||
esbuild@<0.28.1: '>=0.28.1'
|
||||
flatted: '>=3.4.2'
|
||||
follow-redirects: '>=1.16.0'
|
||||
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
|
||||
glob@>=10.2.0 <10.5.0: '>=10.5.0'
|
||||
js-yaml@>=4.0.0 <4.1.1: '>=4.1.1'
|
||||
ip-address@<10.1.1: '>=10.1.1'
|
||||
js-yaml@>=4.0.0 <4.2.0: '>=4.2.0'
|
||||
lodash: '>=4.18.0'
|
||||
minimatch@>=9.0.0 <9.0.7: '>=9.0.7'
|
||||
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
||||
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
|
||||
qs@>=6.7.0 <6.14.2: '>=6.14.2'
|
||||
qs@>=6.7.0 <6.15.2: '>=6.15.2'
|
||||
serialize-javascript@<7.0.5: '>=7.0.5'
|
||||
socket.io-parser@>=4.0.0 <4.2.6: '>=4.2.6'
|
||||
tar@<7.5.11: '>=7.5.11'
|
||||
tar@<7.5.16: '>=7.5.16'
|
||||
uuid@<14.0.0: '>=14.0.0'
|
||||
vite@>=7.0.0 <7.3.2: '>=7.3.2'
|
||||
ws@>=8.0.0 <8.21.0: '>=8.21.0'
|
||||
minimumReleaseAgeExclude:
|
||||
- '@radix-ui/primitive@1.1.5'
|
||||
- '@radix-ui/react-collection@1.1.12'
|
||||
- '@radix-ui/react-context@1.2.0'
|
||||
- '@radix-ui/react-dialog@1.1.19'
|
||||
- '@radix-ui/react-dismissable-layer@1.1.15'
|
||||
- '@radix-ui/react-focus-scope@1.1.12'
|
||||
- '@radix-ui/react-presence@1.1.7'
|
||||
- '@radix-ui/react-toast@1.2.19'
|
||||
- mysql2@3.22.6
|
||||
- oidc-provider@9.9.0
|
||||
- sql-escaper@1.4.0
|
||||
- '@typescript/typescript-aix-ppc64@7.0.2'
|
||||
- '@typescript/typescript-darwin-arm64@7.0.2'
|
||||
- '@typescript/typescript-darwin-x64@7.0.2'
|
||||
- '@typescript/typescript-freebsd-arm64@7.0.2'
|
||||
- '@typescript/typescript-freebsd-x64@7.0.2'
|
||||
- '@typescript/typescript-linux-arm64@7.0.2'
|
||||
- '@typescript/typescript-linux-arm@7.0.2'
|
||||
- '@typescript/typescript-linux-loong64@7.0.2'
|
||||
- '@typescript/typescript-linux-mips64el@7.0.2'
|
||||
- '@typescript/typescript-linux-ppc64@7.0.2'
|
||||
- '@typescript/typescript-linux-riscv64@7.0.2'
|
||||
- '@typescript/typescript-linux-s390x@7.0.2'
|
||||
- '@typescript/typescript-linux-x64@7.0.2'
|
||||
- '@typescript/typescript-netbsd-arm64@7.0.2'
|
||||
- '@typescript/typescript-netbsd-x64@7.0.2'
|
||||
- '@typescript/typescript-openbsd-arm64@7.0.2'
|
||||
- '@typescript/typescript-openbsd-x64@7.0.2'
|
||||
- '@typescript/typescript-sunos-x64@7.0.2'
|
||||
- '@typescript/typescript-win32-arm64@7.0.2'
|
||||
- '@typescript/typescript-win32-x64@7.0.2'
|
||||
- typescript@7.0.2
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
},
|
||||
|
||||
/*
|
||||
|
|
@ -964,6 +966,14 @@
|
|||
|
||||
"sso": {
|
||||
"issuer": "${SSO_ISSUER:http://localhost:9001}",
|
||||
/*
|
||||
* Signing keys for the embedded OIDC provider's cookies. Leave empty and
|
||||
* Etherpad derives a secret key from the persisted session secret
|
||||
* (SESSIONKEY.txt), which is stable across restarts and shared across
|
||||
* horizontally-scaled pods. Set an explicit ordered array to rotate: the
|
||||
* first key signs, the rest are still accepted for verify.
|
||||
*/
|
||||
"cookieKeys": ["${OIDC_COOKIE_KEY:}"],
|
||||
"clients": [
|
||||
{
|
||||
"client_id": "${ADMIN_CLIENT:admin_client}",
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@
|
|||
"pad.importExport.exportword": "مايكروسوفت وورد",
|
||||
"pad.importExport.exportpdf": "صيغة المستندات المحمولة",
|
||||
"pad.importExport.exportopen": "ODF (نسق المستند المفتوح)",
|
||||
"pad.importExport.noConverter.innerHTML": "لا يمكنك الاستيراد إلا من تنسيقات النصوص العادية أو تنسيقات HTML. لمزيد من ميزات الاستيراد المتقدمة، يرجى <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">تثبيت LibreOffice</a> .",
|
||||
"pad.importExport.noConverter.innerHTML": "يمكنك استيراد النصوص العادية، وملفات HTML، وملفات Microsoft Word (.docx)، وملفات Etherpad مباشرةً. لاستيراد تنسيقات أخرى مثل PDF، وODT، وDOC، وRTF، يحتاج مسؤول الخادم إلى تثبيت LibreOffice - راجع <a href=\"https://docs.etherpad.org/\">وثائق Etherpad</a> .",
|
||||
"pad.modals.connected": "متصل.",
|
||||
"pad.modals.reconnecting": "إعادة الاتصال ببادك..",
|
||||
"pad.modals.forcereconnect": "فرض إعادة الاتصال",
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@
|
|||
"pad.modals.looping.explanation": "Праблемы далучэньня да сэрвэра сынхранізацыі.",
|
||||
"pad.modals.looping.cause": "Магчыма, вы падключыліся празь несумяшчальны брандмаўэр або проксі.",
|
||||
"pad.modals.initsocketfail": "Сэрвэр недаступны.",
|
||||
"pad.modals.initsocketfail.explanation": "Не атрымалася падлучыцца да сэрвэра сынхранізацыі.",
|
||||
"pad.modals.initsocketfail.explanation": "Не ўдалося падлучыцца да сэрвэра сынхранізацыі.",
|
||||
"pad.modals.initsocketfail.cause": "Імаверна, гэта зьвязана з праблемамі з вашым браўзэрам або інтэрнэт-злучэньнем.",
|
||||
"pad.modals.slowcommit.explanation": "Сэрвэр не адказвае.",
|
||||
"pad.modals.slowcommit.cause": "Гэта можа быць выклікана праблемамі зь сеткавым падлучэньнем.",
|
||||
|
|
@ -178,9 +178,9 @@
|
|||
"pad.impexp.importbutton": "Імпартаваць зараз",
|
||||
"pad.impexp.importing": "Імпартаваньне…",
|
||||
"pad.impexp.confirmimport": "Імпарт файла перазапіша цяперашні тэкст дакумэнту. Вы ўпэўненыя, што хочаце працягваць?",
|
||||
"pad.impexp.convertFailed": "Не атрымалася імпартаваць гэты файл. Калі ласка, выкарыстайце іншы фармат дакумэнту або скапіюйце ўручную.",
|
||||
"pad.impexp.convertFailed": "Не ўдалося імпартаваць гэты файл. Калі ласка, выкарыстайце іншы фармат дакумэнту або скапіюйце рукамі.",
|
||||
"pad.impexp.padHasData": "Мы не змаглі імпартаваць гэты файл, бо дакумэнт ужо мае зьмены, калі ласка, імпартуйце ў новы дакумэнт",
|
||||
"pad.impexp.uploadFailed": "Загрузка не атрымалася, калі ласка, паспрабуйце яшчэ раз",
|
||||
"pad.impexp.uploadFailed": "Загрузка не ўдалася, калі ласка, паспрабуйце яшчэ раз",
|
||||
"pad.impexp.importfailed": "Памылка імпарту",
|
||||
"pad.impexp.copypaste": "Калі ласка, скапіюйце і ўстаўце",
|
||||
"pad.impexp.exportdisabled": "Экспарт у фармаце {{type}} адключаны. Калі ласка, зьвярніцеся да вашага сыстэмнага адміністратара па падрабязнасьці.",
|
||||
|
|
|
|||
|
|
@ -16,13 +16,80 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Ovládací panel Správce - Etherpad",
|
||||
"admin.loading": "Načítám…",
|
||||
"admin.loading_description": "Počkejte prosím, než se stránka načte.",
|
||||
"admin.toggle_sidebar": "Přepnout postranní panel",
|
||||
"admin.shout": "Komunikace",
|
||||
"admin_shout.online_one": "Aktuálně je online {{count}} uživatelů",
|
||||
"admin_shout.online_other": "Aktuálně je online {{count}} uživatelů",
|
||||
"admin_shout.sticky_toggle": "Změnit připevněnou zprávu",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Uživatelské jméno",
|
||||
"admin_login.password": "Heslo",
|
||||
"admin_login.submit": "Přihlásit se",
|
||||
"admin_login.failed": "Přihlášení se nezdařilo",
|
||||
"admin_pads.all_pads": "Všechny Pady",
|
||||
"admin_pads.bulk.cleanup_history": "Vyčistit historii",
|
||||
"admin_pads.bulk.clear_selection": "Vymazat výběr",
|
||||
"admin_pads.bulk.delete": "Smazat",
|
||||
"admin_pads.cancel": "Zrušit",
|
||||
"admin_pads.col.pad": "Pad",
|
||||
"admin_pads.col.revisions": "Revize",
|
||||
"admin_pads.col.users": "Uživatelé",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Vyberte název pro nový Pad.",
|
||||
"admin_pads.delete_pad_dialog_description": "Potvrdit nebo zrušit smazání Padu.",
|
||||
"admin_pads.delete_pad_dialog_title": "Smazat pad",
|
||||
"admin_pads.empty_never_edited": "prázdné · nikdy neupravené",
|
||||
"admin_pads.error_dialog_description": "Došlo k chybě.",
|
||||
"admin_pads.error_prefix": "Chyba",
|
||||
"admin_pads.filter.active": "Aktivní",
|
||||
"admin_pads.filter.all": "Vše",
|
||||
"admin_pads.filter.empty": "Prázdné",
|
||||
"admin_pads.filter.recent": "Tento týden",
|
||||
"admin_pads.filter.stale": "Zastaralé (>1 rok)",
|
||||
"admin_pads.open": "Otevřít",
|
||||
"admin_pads.pagination.next": "Další",
|
||||
"admin_pads.pagination.previous": "Předchozí",
|
||||
"admin_pads.refresh": "Obnovit",
|
||||
"admin_pads.relative.days": "před {{count}} dny",
|
||||
"admin_pads.relative.hours": "před {{count}}h",
|
||||
"admin_pads.relative.just_now": "právě teď",
|
||||
"admin_pads.relative.minutes": "před {{count}} minutami",
|
||||
"admin_pads.relative.months": "před {{count}} měsíci",
|
||||
"admin_pads.relative.weeks": "před {{count}} týdny",
|
||||
"admin_pads.relative.years": "před {{count}} lety",
|
||||
"admin_pads.revisions_count": "{{count}} revizí",
|
||||
"admin_pads.selected_count": "Vybráno {{count}}",
|
||||
"admin_pads.show": "Zobrazit",
|
||||
"admin_pads.sort.name": "Jméno (A–Z)",
|
||||
"admin_pads.sort.revision_number": "Revize",
|
||||
"admin_pads.sort.user_count": "Uživatelé",
|
||||
"admin_pads.stats.across_pads": "napříč všemi pady",
|
||||
"admin_pads.stats.active_users": "Aktivní uživatelé",
|
||||
"admin_pads.stats.empty_pads": "Prázdné pady",
|
||||
"admin_pads.stats.last_activity": "Poslední aktivita:",
|
||||
"admin_pads.stats.no_active_users": "Žádní aktivní uživatelé",
|
||||
"admin_pads.stats.revisions_zero": "0 revizí",
|
||||
"admin_pads.stats.total": "Celkový počet padů",
|
||||
"admin_pads.stats.users_active": "{{count}} aktuálně aktivních",
|
||||
"admin_pads.subtitle": "Přehled všech padů na této instanci Etherpadu. Vyhledat, vyčistit, otevřít.",
|
||||
"admin_plugins": "Správce zásuvných moodulů",
|
||||
"admin_plugins.available": "Dostupné zásuvné moduly",
|
||||
"admin_plugins.available_not-found": "Nejsou žádné zásuvné moduly",
|
||||
"admin_plugins.available_fetching": "Načítání...",
|
||||
"admin_plugins.available_install.value": "Instalovat",
|
||||
"admin_plugins.available_search.placeholder": "Vyhledat zásuvné moduly k instalaci",
|
||||
"admin_plugins.check_updates": "Zkontrolovat aktualizace",
|
||||
"admin_plugins.core_count": "{{count}} jádro",
|
||||
"admin_plugins.catalog_disabled": "Katalog pluginů je zakázán vaším operátorem (privacy.pluginCatalog=false). Chcete-li plugin nainstalovat, spusťte příkaz `pnpm run plugins i ep_<name> ` ze serveru.",
|
||||
"admin_plugins.crumbs": "Pluginy",
|
||||
"admin_plugins.description": "Popis",
|
||||
"admin_plugins.disables.label": "Vypnuto:",
|
||||
"admin_plugins.disables.warning_title": "Tento plugin záměrně odstraňuje uvedené funkce Etherpadu.",
|
||||
"admin_plugins.error_retrieving": "Chyba při načítání pluginů",
|
||||
"admin_plugins.install_error": "Instalace pluginu {{plugin}} se nezdařila: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Nelze nainstalovat {{plugin}}: vyžaduje novější verzi Etherpadu. Prosím, aktualizujte Etherpad a zkuste to znovu.",
|
||||
"admin_plugins.installed": "Nainstalované zásuvné moduly",
|
||||
"admin_plugins.installed_fetching": "Načítání instalovaných zásuvných modulů...",
|
||||
"admin_plugins.installed_nothing": "Dosud jste nenainstalovali žádné zásuvné moduly.",
|
||||
|
|
@ -30,23 +97,61 @@
|
|||
"admin_plugins.last-update": "Poslední aktualizace",
|
||||
"admin_plugins.name": "Název",
|
||||
"admin_plugins.page-title": "Správce zásuvných modulů - Etherpad",
|
||||
"admin_plugins.reload_catalog": "Obnovit katalog",
|
||||
"admin_plugins.search_npm": "Hledat na npm",
|
||||
"admin_plugins.sort_ascending": "Seřadit vzestupně",
|
||||
"admin_plugins.sort_descending": "Seřadit sestupně",
|
||||
"admin_plugins.sort.last_updated": "Naposledy aktualizováno",
|
||||
"admin_plugins.sort.name": "Jméno (A–Z)",
|
||||
"admin_plugins.sort.version": "Verze",
|
||||
"admin_plugins.source": "Zdroj pluginu",
|
||||
"admin_plugins.subtitle": "Instalace, aktualizace a odebrání pluginů Etherpad. Změny vyžadují restart serveru.",
|
||||
"admin_plugins.tag_core": "Jádro",
|
||||
"admin_plugins.update_tooltip": "Aktualizovat",
|
||||
"admin_plugins.updates_available": "Dostupné aktualizace",
|
||||
"admin_plugins.update_now": "Aktualizovat",
|
||||
"admin_plugins.version": "Verze",
|
||||
"admin_plugins_info": "Informace o řešení problému",
|
||||
"admin_plugins_info.bindings_label": "{{count}} vazeb",
|
||||
"admin_plugins_info.copy_diagnostics": "Diagnostika kopírování",
|
||||
"admin_plugins_info.copy_value": "Kopírovat {{label}}",
|
||||
"admin_plugins_info.git_sha": "SHA v Gitu",
|
||||
"admin_plugins_info.hooks": "Instalované hooks",
|
||||
"admin_plugins_info.hooks_client": "hooks na straně klienta",
|
||||
"admin_plugins_info.hooks_server": "hooks na straně serveru",
|
||||
"admin_plugins_info.parts": "Nainstalované součásti",
|
||||
"admin_plugins_info.plugins": "Nainstalované zásuvné moduly",
|
||||
"admin_plugins_info.page-title": "Informace o zásuvných modulech - Etherpad",
|
||||
"admin_plugins_info.tab_client": "Klient",
|
||||
"admin_plugins_info.tab_server": "Server",
|
||||
"admin_plugins_info.up_to_date": "Aktuální",
|
||||
"admin_plugins_info.update_available": "Aktualizace k dispozici: {{version}}",
|
||||
"admin_plugins_info.version": "Verze Etherpad",
|
||||
"admin_plugins_info.version_latest": "Poslední dostupná verze",
|
||||
"admin_plugins_info.version_number": "Číslo verze",
|
||||
"admin_settings": "Nastavení",
|
||||
"admin_settings.create_pad": "Vytvořit pad",
|
||||
"admin_settings.current": "Aktuální konfugurace",
|
||||
"admin_settings.current_example-devel": "Příklad ukázkové vývojové šablony",
|
||||
"admin_settings.current_example-prod": "Příklad šablony nastavení výroby",
|
||||
"admin_settings.current_restart.value": "Restartovat Etherpad",
|
||||
"admin_settings.current_save.value": "Uložit nastavení",
|
||||
"admin_settings.invalid_json": "Neplatný JSON",
|
||||
"admin_settings.current_test.value": "Ověření JSON",
|
||||
"admin_settings.current_prettify.value": "Zkrášlit JSON",
|
||||
"admin_settings.toast.saved": "Nastavení bylo úspěšně uloženo.",
|
||||
"admin_settings.toast.save_failed": "Uložení se nezdařilo: soubor settings.json se nepodařilo zapsat.",
|
||||
"admin_settings.toast.json_invalid": "Syntaktická chyba: zkontrolujte čárky, závorky a uvozovky.",
|
||||
"admin_settings.toast.disconnected": "Nelze uložit: nejsem připojen k serveru.",
|
||||
"admin_settings.toast.validation_ok": "JSON je platný.",
|
||||
"admin_settings.toast.validation_failed": "JSON je neplatný: opravte syntaktické chyby.",
|
||||
"admin_settings.toast.prettify_failed": "Nelze upravovat: nejprve opravte syntaktické chyby.",
|
||||
"admin_settings.prettify_confirm": "Zkrášlováním odstraníte všechny komentáře. Pokračovat?",
|
||||
"admin_settings.mode.form": "Formulář",
|
||||
"admin_settings.mode.effective": "Efektivní",
|
||||
"admin_settings.mode.effective_tooltip": "Zobrazení hodnot, které Etherpad aktuálně používá, pouze pro čtení, po substituci proměnných prostředí. Tajné kódy jsou redigovány.",
|
||||
"admin_settings.mode.aria_label": "Režim editoru",
|
||||
"admin_settings.envvar_banner.title": "Tento soubor je šablona, nikoli živá konfigurace.",
|
||||
"admin_settings.page-title": "Nastavení - Etherpad",
|
||||
"index.newPad": "Založ nový Pad",
|
||||
"index.settings": "Nastavení",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"Metalhead64",
|
||||
"Mklehr",
|
||||
"Mukeber",
|
||||
"Nbux",
|
||||
"Nipsky",
|
||||
"Predatorix",
|
||||
"SamTV",
|
||||
|
|
@ -22,13 +23,80 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Admin Dashboard - Etherpad",
|
||||
"admin.loading": "Lade …",
|
||||
"admin.loading_description": "Bitte warten, die Seite wird aktualisiert ...",
|
||||
"admin.toggle_sidebar": "Seitenleiste ein-/ausblenden",
|
||||
"admin.shout": "Kommunikation",
|
||||
"admin_shout.online_one": "Es ist derzeit {{count}} Benutzer online",
|
||||
"admin_shout.online_other": "Es sind aktuell {{count}} Benutzer online",
|
||||
"admin_shout.sticky_toggle": "Nachricht bleibt angeheftet",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Benutzername",
|
||||
"admin_login.password": "Passwort",
|
||||
"admin_login.submit": "Login",
|
||||
"admin_login.failed": "Login fehlgeschlagen.",
|
||||
"admin_pads.all_pads": "Alle Pads",
|
||||
"admin_pads.bulk.cleanup_history": "Verlauf löschen",
|
||||
"admin_pads.bulk.clear_selection": "Auswahl löschen",
|
||||
"admin_pads.bulk.delete": "Löschen",
|
||||
"admin_pads.cancel": "Abbrechen",
|
||||
"admin_pads.col.pad": "Pad",
|
||||
"admin_pads.col.revisions": "Bearbeitungen",
|
||||
"admin_pads.col.users": "Benutzer",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Wähle einen Namen für das neue Pad.",
|
||||
"admin_pads.delete_pad_dialog_description": "Bestätigen oder stornieren Sie die Löschung des Pads.",
|
||||
"admin_pads.delete_pad_dialog_title": "Pad löschen",
|
||||
"admin_pads.empty_never_edited": "leer · nie bearbeitet",
|
||||
"admin_pads.error_dialog_description": "Ein Fehler ist aufgetreten",
|
||||
"admin_pads.error_prefix": "Fehler",
|
||||
"admin_pads.filter.active": "Aktiv",
|
||||
"admin_pads.filter.all": "Alle",
|
||||
"admin_pads.filter.empty": "Leer",
|
||||
"admin_pads.filter.recent": "Diese Woche",
|
||||
"admin_pads.filter.stale": "Veraltet (> 1 Jahr)",
|
||||
"admin_pads.open": "Offen",
|
||||
"admin_pads.pagination.next": "Weiter",
|
||||
"admin_pads.pagination.previous": "Zurück",
|
||||
"admin_pads.refresh": "Aktualisieren",
|
||||
"admin_pads.relative.days": "vor {{count}} Tag(en)",
|
||||
"admin_pads.relative.hours": "vor {{count}} Stunde(n)",
|
||||
"admin_pads.relative.just_now": "Soeben",
|
||||
"admin_pads.relative.minutes": "vor {{count}} Minute(n)",
|
||||
"admin_pads.relative.months": "vor {{count}} Monat(en)",
|
||||
"admin_pads.relative.weeks": "vor {{count}} Woche(n)",
|
||||
"admin_pads.relative.years": "vor {{count}} Jahr(en)",
|
||||
"admin_pads.revisions_count": "{{count}} Bearbeitungen",
|
||||
"admin_pads.selected_count": "{{count}} ausgewählt",
|
||||
"admin_pads.show": "Anzeigen",
|
||||
"admin_pads.sort.name": "Name (A–Z)",
|
||||
"admin_pads.sort.revision_number": "Bearbeitungen",
|
||||
"admin_pads.sort.user_count": "Nutzer",
|
||||
"admin_pads.stats.across_pads": "über alle Pads",
|
||||
"admin_pads.stats.active_users": "Aktive Nutzer",
|
||||
"admin_pads.stats.empty_pads": "Leere Pads",
|
||||
"admin_pads.stats.last_activity": "Letzte Aktivität",
|
||||
"admin_pads.stats.no_active_users": "Keine aktiven Benutzer",
|
||||
"admin_pads.stats.revisions_zero": "0 Bearbeitungen",
|
||||
"admin_pads.stats.total": "Gesamtanzahl Pads",
|
||||
"admin_pads.stats.users_active": "{{count}} aktuell aktiv",
|
||||
"admin_pads.subtitle": "Überblick über alle Pads auf dieser Etherpad-Instanz. Suchen, aufräumen, öffnen.",
|
||||
"admin_plugins": "Pluginverwaltung",
|
||||
"admin_plugins.available": "Verfügbare Plugins",
|
||||
"admin_plugins.available_not-found": "Keine Plugins gefunden.",
|
||||
"admin_plugins.available_fetching": "Wird abgerufen...",
|
||||
"admin_plugins.available_install.value": "Installieren",
|
||||
"admin_plugins.available_search.placeholder": "Suche nach Plugins zum Installieren",
|
||||
"admin_plugins.check_updates": "Nach Updates suchen",
|
||||
"admin_plugins.core_count": "{{count}} Kern(e)",
|
||||
"admin_plugins.catalog_disabled": "Plugin-Katalog wurde vom Betreiber deaktiviert (privacy.pluginCatalog=false). Um ein Plugin zu installieren, führen Sie `pnpm run plugins i ep_<name>` auf dem Server aus.",
|
||||
"admin_plugins.crumbs": "Plugins",
|
||||
"admin_plugins.description": "Beschreibung",
|
||||
"admin_plugins.disables.label": "Deaktiviert:",
|
||||
"admin_plugins.disables.warning_title": "Dieses Plugin entfernt absichtlich die aufgeführten Etherpad-Funktionen.",
|
||||
"admin_plugins.error_retrieving": "Fehler beim Abrufen von Plugins",
|
||||
"admin_plugins.install_error": "Installation von {{plugin}} fehlgeschlagen: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Installation von {{plugin}} nicht möglich: Hierfür ist neuere Version von Etherpad notwendig. Bitte führe ein Upgrade von Etherpad durch und versuche es erneut.",
|
||||
"admin_plugins.installed": "Installierte Plugins",
|
||||
"admin_plugins.installed_fetching": "Rufe installierte Plugins ab...",
|
||||
"admin_plugins.installed_nothing": "Du hast bisher noch keine Plugins installiert.",
|
||||
|
|
@ -36,23 +104,67 @@
|
|||
"admin_plugins.last-update": "Letze Aktualisierung",
|
||||
"admin_plugins.name": "Name",
|
||||
"admin_plugins.page-title": "Plugin Manager - Etherpad",
|
||||
"admin_plugins.reload_catalog": "Katalog neu laden",
|
||||
"admin_plugins.search_npm": "Suche auf npm",
|
||||
"admin_plugins.sort_ascending": "Aufsteigend sortieren",
|
||||
"admin_plugins.sort_descending": "Absteigend sortieren",
|
||||
"admin_plugins.sort.last_updated": "Zuletzt aktualisiert",
|
||||
"admin_plugins.sort.name": "Name (A–Z)",
|
||||
"admin_plugins.sort.version": "Version",
|
||||
"admin_plugins.source": "Plugin-Quelle",
|
||||
"admin_plugins.subtitle": "Installieren, aktualisieren und entfernen Sie Etherpad-Plugins. Änderungen erfordern einen Server-Neustart.",
|
||||
"admin_plugins.tag_core": "Kern",
|
||||
"admin_plugins.update_tooltip": "Update",
|
||||
"admin_plugins.updates_available": "Updates verfügbar",
|
||||
"admin_plugins.update_now": "Update",
|
||||
"admin_plugins.version": "Version",
|
||||
"admin_plugins_info": "Hilfestellung",
|
||||
"admin_plugins_info.copy_diagnostics": "Kopiere Diagnose-Daten",
|
||||
"admin_plugins_info.copy_value": "Kopiere {{label}}",
|
||||
"admin_plugins_info.git_sha": "Git SHA",
|
||||
"admin_plugins_info.hook_bindings": "Hook-Bindungen",
|
||||
"admin_plugins_info.hooks": "Installierte Hooks",
|
||||
"admin_plugins_info.hooks_client": "Client-seitige Hooks",
|
||||
"admin_plugins_info.hooks_server": "Server-seitige Hooks",
|
||||
"admin_plugins_info.no_hooks": "Keine Hooks gefunden",
|
||||
"admin_plugins_info.parts": "Installierte Teile",
|
||||
"admin_plugins_info.plugins": "Installierte Plugins",
|
||||
"admin_plugins_info.page-title": "Plugin Informationen - Etherpad",
|
||||
"admin_plugins_info.search_placeholder": "Suche Hook oder Teil…",
|
||||
"admin_plugins_info.subtitle": "Systemdiagnose: installierte Version, registrierte Teile und Hooks.",
|
||||
"admin_plugins_info.tab_client": "Client",
|
||||
"admin_plugins_info.tab_server": "Server",
|
||||
"admin_plugins_info.up_to_date": "Aktuell",
|
||||
"admin_plugins_info.update_available": "Update verfügbar: {{version}}",
|
||||
"admin_plugins_info.version": "Etherpad Version",
|
||||
"admin_plugins_info.version_latest": "Neueste verfügbare Version",
|
||||
"admin_plugins_info.version_number": "Versionsnummer",
|
||||
"admin_settings": "Einstellungen",
|
||||
"admin_settings.create_pad": "Pad erstellen",
|
||||
"admin_settings.current": "Derzeitige Konfiguration",
|
||||
"admin_settings.current_example-devel": "Beispielhafte Entwicklungseinstellungs-Templates",
|
||||
"admin_settings.current_example-prod": "Beispiel eines produktiven Templates",
|
||||
"admin_settings.current_restart.value": "Etherpad neustarten",
|
||||
"admin_settings.current_save.value": "Einstellungen speichern",
|
||||
"admin_settings.invalid_json": "Ungültiges JSON",
|
||||
"admin_settings.current_test.value": "Validiere JSON",
|
||||
"admin_settings.current_prettify.value": "Prettify JSON",
|
||||
"admin_settings.toast.saved": "Einstellungen erfolgreich gespeichert.",
|
||||
"admin_settings.toast.save_failed": "Speichern fehlgeschlagen: settings.json konnte nicht geschrieben werden.",
|
||||
"admin_settings.toast.json_invalid": "Syntaxfehler: Überprüfen Sie Kommas, Klammern und Anführungszeichen.",
|
||||
"admin_settings.toast.disconnected": "Nicht gespeichert: Nicht mit dem Server verbunden.",
|
||||
"admin_settings.toast.validation_ok": "JSON ist gültig.",
|
||||
"admin_settings.toast.validation_failed": "JSON ist ungültig: Bitte beheben Sie Syntaxfehler.",
|
||||
"admin_settings.toast.prettify_failed": "Prettify nicht möglich: Bitte beheben Sie zunächst Syntaxfehler.",
|
||||
"admin_settings.prettify_confirm": "Prettify entfernt alle Kommentare. Weiter?",
|
||||
"admin_settings.mode.form": "Formular",
|
||||
"admin_settings.mode.effective_tooltip": "Read-only-Ansicht der Werte, die Etherpad tatsächlich verwendet, nach Umgebungsvariablen-Ersetzung. Secrets sind geschwärzt.",
|
||||
"admin_settings.mode.aria_label": "Editor-Modus",
|
||||
"admin_settings.envvar_banner.title": "Diese Datei ist eine Vorlage, nicht die Live-Konfiguration.",
|
||||
"admin_settings.envvar_banner.body": "Platzhalter wie ${VAR:default} werden beim Start in den Speicher eingesetzt; sie werden nie in diese Datei zurückgeschrieben. Bearbeiten Sie env vars in Ihrer Umgebung (Docker compose, systemd, .env), um den aufgelösten Wert zu ändern, oder ersetzen Sie den Platzhalter hier durch ein Literal. Wechseln Sie auf die Registerkarte \"Effective\", um zu sehen, was Etherpad gerade verwendet.",
|
||||
"admin_settings.toast.auth_error": "Sie sind nicht als Administrator authentifiziert. Bitte melden Sie sich erneut an.",
|
||||
"admin_settings.section.general": "Allgemein",
|
||||
"admin_settings.parse_error.title": "Parsen von settings.json nicht möglich",
|
||||
"admin_settings.page-title": "Einstellungen - Etherpad",
|
||||
"index.newPad": "Neues Pad",
|
||||
"index.settings": "Einstellungen",
|
||||
|
|
@ -75,7 +187,7 @@
|
|||
"index.labelPad": "Padname (optional)",
|
||||
"index.placeholderPadEnter": "Gib den Namen des Pads ein...",
|
||||
"index.createAndShareDocuments": "Erstelle und teile Dokumente in Echtzeit",
|
||||
"index.createAndShareDocumentsDescription": "Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Ihrem Browser läuft.",
|
||||
"index.createAndShareDocumentsDescription": "Etherpad ermöglicht die gemeinsame Bearbeitung von Dokumenten in Echtzeit, ähnlich wie ein Live-Multiplayer-Editor, der in Deinem Browser läuft.",
|
||||
"pad.toolbar.bold.title": "Fett (Strg-B)",
|
||||
"pad.toolbar.italic.title": "Kursiv (Strg-I)",
|
||||
"pad.toolbar.underline.title": "Unterstrichen (Strg-U)",
|
||||
|
|
@ -99,18 +211,31 @@
|
|||
"pad.loading": "Laden …",
|
||||
"pad.noCookie": "Das Cookie konnte nicht gefunden werden. Bitte erlaube Cookies in deinem Browser! Deine Sitzung und Einstellungen werden zwischen den Besuchen nicht gespeichert. Dies kann darauf zurückzuführen sein, dass Etherpad in einigen Browsern in einem iFrame enthalten ist. Bitte stelle sicher, dass sich Etherpad auf der gleichen Subdomain/Domain wie der übergeordnete iFrame befindet.",
|
||||
"pad.permissionDenied": "Du hast keine Berechtigung, um auf dieses Pad zuzugreifen.",
|
||||
"pad.settings.padSettings": "Pad-Einstellungen",
|
||||
"pad.settings.title": "Einstellungen",
|
||||
"pad.settings.padSettings": "Pad-weite Einstellungen",
|
||||
"pad.settings.userSettings": "Nutzereinstellungen",
|
||||
"pad.settings.myView": "Eigene Ansicht",
|
||||
"pad.settings.disablechat": "Chat deaktivieren",
|
||||
"pad.settings.darkMode": "Dunkler Modus",
|
||||
"pad.settings.stickychat": "Chat immer anzeigen",
|
||||
"pad.settings.chatandusers": "Chat und Benutzer anzeigen",
|
||||
"pad.settings.colorcheck": "Autorenfarben anzeigen",
|
||||
"pad.settings.fadeInactiveAuthorColors": "Farben inaktiver Autoren ausblenden",
|
||||
"pad.settings.linenocheck": "Zeilennummern",
|
||||
"pad.settings.rtlcheck": "Inhalt von rechts nach links lesen?",
|
||||
"pad.settings.enforcedNotice": "Diese Einstellungen wurden vom Ersteller des Pads gesperrt. Wenden Sie sich an den Ersteller, wenn Sie diese ändern möchten.",
|
||||
"pad.settings.fontType": "Schriftart:",
|
||||
"pad.settings.fontType.normal": "Normal",
|
||||
"pad.settings.language": "Sprache:",
|
||||
"pad.settings.deletePad": "Pad löschen",
|
||||
"pad.delete.confirm": "Möchtest du dieses Pad wirklich löschen?",
|
||||
"pad.deletionToken.modalTitle": "Speichere deinen Pad-Löschtoken.",
|
||||
"pad.deletionToken.modalBody": "Dieses Token ist die einzige Möglichkeit, dieses Pad zu löschen, falls Deine Browsersitzung unterbrochen wird oder Du das Gerät wechselst. Speichere es an einem sicheren Ort – es wird hier nur einmal angezeigt.",
|
||||
"pad.deletionToken.deleteWithToken": "Pad mit Token löschen",
|
||||
"pad.deletionToken.tokenFieldLabel": "Pad-Löschtoken",
|
||||
"pad.deletionToken.tokenValueLabel": "Dein Pad-Löschtoken (schreibgeschützt)",
|
||||
"pad.deletionToken.invalid": "Das angegebene Token ist für dieses Pad nicht gültig.",
|
||||
"pad.deletionToken.notCreator": "Sie sind nicht der Ersteller dieses Pads, daher können Sie es nicht löschen.",
|
||||
"pad.settings.about": "Über",
|
||||
"pad.settings.poweredBy": "Betrieben von",
|
||||
"pad.importExport.import_export": "Import/Export",
|
||||
|
|
@ -123,7 +248,13 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.exportetherpada.title": "Export im Etherpad-Format",
|
||||
"pad.importExport.exporthtmla.title": "Exportieren als HTML",
|
||||
"pad.importExport.exportplaina.title": "Export als einfacher Text",
|
||||
"pad.importExport.exportworda.title": "Exportieren als Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Als PDF exportieren",
|
||||
"pad.importExport.exportopena.title": "Export als ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "Du kannst nur aus reinen Text- oder HTML-Formaten importieren. Für umfangreichere Importfunktionen <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">muss AbiWord oder LibreOffice auf dem Server installiert werden</a>.",
|
||||
"pad.modals.connected": "Verbunden.",
|
||||
"pad.modals.reconnecting": "Dein Pad wird neu verbunden...",
|
||||
"pad.modals.forcereconnect": "Erneutes Verbinden erzwingen",
|
||||
|
|
@ -151,9 +282,11 @@
|
|||
"pad.modals.rateLimited.explanation": "Sie haben zu viele Nachrichten an dieses Pad gesendet, so dass die Verbindung unterbrochen wurde.",
|
||||
"pad.modals.rejected.explanation": "Der Server hat eine Nachricht abgelehnt, die von deinem Browser gesendet wurde.",
|
||||
"pad.modals.rejected.cause": "Möglicherweise wurde der Server aktualisiert, während du das Pad angesehen hast, oder es existiert ein Fehler in Etherpad. Versuche, die Seite neu zu laden.",
|
||||
"pad.modals.disconnected": "Ihre Verbindung wurde getrennt.",
|
||||
"pad.modals.disconnected": "Deine Verbindung wurde getrennt.",
|
||||
"pad.modals.disconnected.explanation": "Die Verbindung zum Server wurde unterbrochen.",
|
||||
"pad.modals.disconnected.cause": "Möglicherweise ist der Server nicht erreichbar. Bitte benachrichtige den Dienstadministrator, falls dies weiterhin passiert.",
|
||||
"pad.gritter.unacceptedCommit.title": "Nicht gespeicherte Bearbeitung",
|
||||
"pad.gritter.unacceptedCommit.text": "Deine letzte Änderung wurde noch nicht gespeichert. Stelle die Verbindung wieder her und versuche es erneut.",
|
||||
"pad.share": "Dieses Pad teilen",
|
||||
"pad.share.readonly": "Eingeschränkter Nur-Lese-Zugriff",
|
||||
"pad.share.link": "Verknüpfung",
|
||||
|
|
@ -166,12 +299,29 @@
|
|||
"timeslider.followContents": "Aktualisierungen des Pad-Inhalts verfolgen",
|
||||
"timeslider.pageTitle": "{{appTitle}} Bearbeitungsverlauf",
|
||||
"timeslider.toolbar.returnbutton": "Zurück zum Pad",
|
||||
"pad.historyMode.banner": "Versionsgeschichte ansehen",
|
||||
"pad.historyMode.return": "Zurück zum Live-Modus",
|
||||
"pad.historyMode.revisionLabel": "Revision {{rev}}",
|
||||
"pad.historyMode.controlsLabel": "Steuerung Pad-Verlauf",
|
||||
"pad.historyMode.sliderLabel": "Pad-Version",
|
||||
"pad.historyMode.settings.title": "Wiedergabe des Bearbeitungsverlaufs",
|
||||
"pad.historyMode.settings.follow": "Aktualisierungen des Pad-Inhalts verfolgen",
|
||||
"pad.historyMode.settings.followShort": "Folgen",
|
||||
"pad.historyMode.followOn": "Pad-Änderungen verfolgen – klicken Sie hier, um die Verfolgung zu beenden.",
|
||||
"pad.historyMode.followOff": "Änderungen der Pads werden nicht verfolgt – zum Folgen klicken",
|
||||
"pad.historyMode.settings.playbackSpeed": "Wiedergabegeschwindigkeit:",
|
||||
"pad.historyMode.chat.replayHeader": "Chat mit Stand von {{time}}",
|
||||
"pad.historyMode.users.authorsHeader": "Autoren bei dieser Revision",
|
||||
"pad.editor.keyboardHint": "Drücken Sie die Escape-Taste, um den Editor zu verlassen. Drücken Sie Alt+F9, um die Symbolleiste aufzurufen.",
|
||||
"pad.editor.toolbar.formatting": "Formatierungsleiste",
|
||||
"pad.editor.toolbar.showMore": "Weitere Schaltflächen anzeigen",
|
||||
"timeslider.toolbar.authors": "Autoren:",
|
||||
"timeslider.toolbar.authorsList": "Keine Autoren",
|
||||
"timeslider.toolbar.exportlink.title": "Diese Version exportieren",
|
||||
"timeslider.exportCurrent": "Exportiere diese Version als:",
|
||||
"timeslider.version": "Version {{version}}",
|
||||
"timeslider.saved": "Gespeichert am {{day}}. {{month}} {{year}}",
|
||||
"timeslider.settings.playbackSpeed": "Wiedergabegeschwindigkeit:",
|
||||
"timeslider.playPause": "Padbearbeitung abspielen/pausieren",
|
||||
"timeslider.backRevision": "Eine Version in diesem Pad zurückgehen",
|
||||
"timeslider.forwardRevision": "Eine Version in diesem Pad vorwärtsgehen",
|
||||
|
|
@ -193,6 +343,7 @@
|
|||
"pad.savedrevs.timeslider": "Du kannst gespeicherte Versionen durch den Aufruf des Bearbeitungsverlaufs ansehen.",
|
||||
"pad.userlist.entername": "Dein Name?",
|
||||
"pad.userlist.unnamed": "unbenannt",
|
||||
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} verbundener Benutzer, other: {{count}} verbundene Benutzer ]}",
|
||||
"pad.editbar.clearcolors": "Autorenfarben im gesamten Dokument zurücksetzen? Dies kann nicht rückgängig gemacht werden",
|
||||
"pad.impexp.importbutton": "Jetzt importieren",
|
||||
"pad.impexp.importing": "Importiere …",
|
||||
|
|
@ -203,5 +354,6 @@
|
|||
"pad.impexp.importfailed": "Import fehlgeschlagen",
|
||||
"pad.impexp.copypaste": "Bitte kopieren und einfügen",
|
||||
"pad.impexp.exportdisabled": "Der Export im {{type}}-Format ist deaktiviert. Für Einzelheiten kontaktiere bitte deinen Systemadministrator.",
|
||||
"pad.impexp.maxFileSize": "Die Datei ist zu groß. Kontaktiere bitte deinen Administrator, um das Limit für den Dateiimport zu erhöhen."
|
||||
"pad.impexp.maxFileSize": "Die Datei ist zu groß. Kontaktiere bitte deinen Administrator, um das Limit für den Dateiimport zu erhöhen.",
|
||||
"pad.social.description": "Ein kollaboratives Dokument, das jeder in Echtzeit bearbeiten kann."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -314,7 +314,7 @@
|
|||
"pad.importExport.exportworda.title": "Export as Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Export as PDF",
|
||||
"pad.importExport.exportopena.title": "Export as ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "You can only import from plain text or HTML formats. For more advanced import features, please <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">install LibreOffice</a>.",
|
||||
"pad.importExport.noConverter.innerHTML": "You can import plain text, HTML, Microsoft Word (.docx) and Etherpad files directly. To import other formats such as PDF, ODT, DOC or RTF, the server administrator needs to install LibreOffice — see the <a href=\"https://docs.etherpad.org/\">Etherpad documentation</a>.",
|
||||
|
||||
"pad.modals.connected": "Connected.",
|
||||
"pad.modals.reconnecting": "Reconnecting to your pad…",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,27 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Ylläpitäjän kojelauta - Etherpad",
|
||||
"admin.loading": "Ladataan…",
|
||||
"admin.loading_description": "Odota. Sivu latautuu.",
|
||||
"admin.toggle_sidebar": "Näytä/piilota sivupalkki",
|
||||
"admin.shout": "Viestintä",
|
||||
"admin_shout.online_one": "Tällä hetkellä {{count}} käyttäjä on paikalla",
|
||||
"admin_shout.online_other": "Tällä hetkellä {{count}} käyttäjää on paikalla",
|
||||
"admin_shout.sticky_toggle": "Muuta pysyvää viestiä",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Käyttäjänimi",
|
||||
"admin_login.password": "Salasana",
|
||||
"admin_login.submit": "Kirjaudu sisään",
|
||||
"admin_login.failed": "Kirjautuminen epäonnistui",
|
||||
"admin_pads.all_pads": "Kaikki muistiot",
|
||||
"admin_pads.bulk.cleanup_history": "Tyhjennä historia",
|
||||
"admin_pads.bulk.delete": "Poista",
|
||||
"admin_pads.cancel": "Peru",
|
||||
"admin_pads.col.pad": "Muistio",
|
||||
"admin_pads.col.revisions": "Muokkaushistoria",
|
||||
"admin_pads.col.users": "Käyttäjät",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Valitse nimi uudelle muistiolle.",
|
||||
"admin_plugins": "Lisäosien hallinta",
|
||||
"admin_plugins.available": "Saatavilla olevat liitännäiset",
|
||||
"admin_plugins.available_not-found": "Lisäosia ei löytynyt.",
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@
|
|||
"Chpol",
|
||||
"Cquoi",
|
||||
"Crochet.david",
|
||||
"Crowwhailord",
|
||||
"Derugon",
|
||||
"Envlh",
|
||||
"Framafan",
|
||||
"Framasky",
|
||||
"Fylip22",
|
||||
"Gomoko",
|
||||
"Goofy",
|
||||
|
|
@ -37,13 +39,73 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "Tableau de bord administrateur — Etherpad",
|
||||
"admin.loading": "Chargement en cours…",
|
||||
"admin.loading_description": "Veuillez patienter pendant le chargement de la page.",
|
||||
"admin.toggle_sidebar": "Afficher / masquer la barre latérale",
|
||||
"admin.shout": "Communication",
|
||||
"admin_shout.online_one": "Il y a actuellement {{count}} utilisateur·ice en ligne",
|
||||
"admin_shout.online_other": "Il y a actuellement {{count}} utilisateur·ices en ligne",
|
||||
"admin_shout.sticky_toggle": "Modifier le message épinglé",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Nom d’utilisateur",
|
||||
"admin_login.password": "Mot de passe",
|
||||
"admin_login.submit": "Connexion",
|
||||
"admin_login.failed": "Échec de la connexion",
|
||||
"admin_pads.all_pads": "Tous les blocs-notes",
|
||||
"admin_pads.bulk.cleanup_history": "Effacer l’historique",
|
||||
"admin_pads.bulk.clear_selection": "Effacer la sélection",
|
||||
"admin_pads.bulk.delete": "Supprimer",
|
||||
"admin_pads.cancel": "Annuler",
|
||||
"admin_pads.col.pad": "Bloc-notes",
|
||||
"admin_pads.col.revisions": "Révisions",
|
||||
"admin_pads.col.users": "Utilisateur·ices",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Choisissez un nom pour le nouveau bloc-notes.",
|
||||
"admin_pads.delete_pad_dialog_description": "Confirmer ou annuler la suppression du bloc-notes.",
|
||||
"admin_pads.delete_pad_dialog_title": "Supprimer le bloc-notes",
|
||||
"admin_pads.empty_never_edited": "vide · jamais modifié",
|
||||
"admin_pads.error_dialog_description": "Une erreur s’est produite.",
|
||||
"admin_pads.error_prefix": "Erreur",
|
||||
"admin_pads.filter.active": "Actif",
|
||||
"admin_pads.filter.all": "Tous",
|
||||
"admin_pads.filter.empty": "Vide",
|
||||
"admin_pads.filter.recent": "Cette semaine",
|
||||
"admin_pads.filter.stale": "Sans modifications (> 1 an)",
|
||||
"admin_pads.open": "Ouvert",
|
||||
"admin_pads.pagination.next": "Suivant",
|
||||
"admin_pads.pagination.previous": "Précédent",
|
||||
"admin_pads.refresh": "Actualiser",
|
||||
"admin_pads.relative.days": "il y a {{count}} jours",
|
||||
"admin_pads.relative.hours": "il y a {{count}} heures",
|
||||
"admin_pads.relative.just_now": "à l’instant",
|
||||
"admin_pads.show": "Afficher",
|
||||
"admin_pads.sort.name": "Nom (A–Z)",
|
||||
"admin_pads.sort.revision_number": "Révisions",
|
||||
"admin_pads.sort.user_count": "Utilisateur·ices",
|
||||
"admin_pads.stats.across_pads": "sur tous les bloc-notes",
|
||||
"admin_pads.stats.active_users": "Utilisateur·ices actif·ves",
|
||||
"admin_pads.stats.empty_pads": "Bloc-notes vides",
|
||||
"admin_pads.stats.last_activity": "Dernière activité :",
|
||||
"admin_pads.stats.no_active_users": "Aucun·e utilisateur·ice actif·ve",
|
||||
"admin_pads.stats.revisions_zero": "0 révisions",
|
||||
"admin_pads.stats.total": "Nombre total de bloc-notes",
|
||||
"admin_pads.stats.users_active": "{{count}} actuellement actifs",
|
||||
"admin_pads.subtitle": "Aperçu de tous les bloc-notes de cette instance Etherpad. Rechercher, nettoyer, ouvrir.",
|
||||
"admin_plugins": "Gestionnaire de greffons",
|
||||
"admin_plugins.available": "Greffons disponibles",
|
||||
"admin_plugins.available_not-found": "Aucun greffon trouvé.",
|
||||
"admin_plugins.available_fetching": "Récupération en cours...",
|
||||
"admin_plugins.available_install.value": "Installer",
|
||||
"admin_plugins.available_search.placeholder": "Rechercher des greffons à installer",
|
||||
"admin_plugins.check_updates": "Vérifier les mises à jour",
|
||||
"admin_plugins.catalog_disabled": "Le catalogue de greffons est désactivé par votre administrateur (privacy.pluginCatalog=false). Pour installer un greffon, exécutez `pnpm run plugins i ep_<name> ` sur le serveur.",
|
||||
"admin_plugins.crumbs": "Greffons",
|
||||
"admin_plugins.description": "Description",
|
||||
"admin_plugins.disables.label": "Désactive :",
|
||||
"admin_plugins.disables.warning_title": "Ce greffon supprime intentionnellement les fonctionnalités Etherpad listées.",
|
||||
"admin_plugins.error_retrieving": "Erreur lors de la récupération des greffons",
|
||||
"admin_plugins.install_error": "Échec de l'installation de {{plugin}} : {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Impossible d'installer {{plugin}} : une version plus récente d'Etherpad est requise. Veuillez mettre à jour Etherpad et réessayer.",
|
||||
"admin_plugins.installed": "Greffons installés",
|
||||
"admin_plugins.installed_fetching": "Récupération des greffons installés en cours...",
|
||||
"admin_plugins.installed_nothing": "Vous n’avez encore installé aucun greffon.",
|
||||
|
|
@ -51,8 +113,25 @@
|
|||
"admin_plugins.last-update": "Dernière mise à jour",
|
||||
"admin_plugins.name": "Nom",
|
||||
"admin_plugins.page-title": "Gestionnaire de greffons — Etherpad",
|
||||
"admin_plugins.reload_catalog": "Recharger le catalogue",
|
||||
"admin_plugins.search_npm": "Rechercher sur npm",
|
||||
"admin_plugins.sort_ascending": "Tri croissant",
|
||||
"admin_plugins.sort_descending": "Tri décroissant",
|
||||
"admin_plugins.sort.last_updated": "Dernière mise à jour",
|
||||
"admin_plugins.sort.name": "Nom (A–Z)",
|
||||
"admin_plugins.sort.version": "Version",
|
||||
"admin_plugins.source": "Source du greffon",
|
||||
"admin_plugins.subtitle": "Installez, mettez à jour et supprimez les greffons d’Etherpad. Toute modification nécessite un redémarrage du serveur.",
|
||||
"admin_plugins.tag_core": "Cœur",
|
||||
"admin_plugins.update_tooltip": "Mise à jour",
|
||||
"admin_plugins.updates_available": "Mises à jour disponibles",
|
||||
"admin_plugins.update_now": "Mettre à jour",
|
||||
"admin_plugins.version": "Version",
|
||||
"admin_plugins_info": "Informations de résolution de problème",
|
||||
"admin_plugins_info.bindings_label": "{{count}} liaisons",
|
||||
"admin_plugins_info.copy_diagnostics": "Copie des diagnostics",
|
||||
"admin_plugins_info.copy_value": "Copier {{label}}",
|
||||
"admin_plugins_info.git_sha": "Git SHA",
|
||||
"admin_plugins_info.hooks": "Crochets installés",
|
||||
"admin_plugins_info.hooks_client": "Crochets côté client",
|
||||
"admin_plugins_info.hooks_server": "Crochets côté serveur",
|
||||
|
|
@ -156,6 +235,16 @@
|
|||
"pad.settings.language": "Langue :",
|
||||
"pad.settings.deletePad": "Supprimer le bloc-notes",
|
||||
"pad.delete.confirm": "Voulez-vous vraiment supprimer ce bloc-notes ?",
|
||||
"pad.deletionToken.modalTitle": "Enregistrez votre jeton de suppression de bloc-notes",
|
||||
"pad.deletionToken.modalBody": "Ce jeton est le seul moyen de supprimer ce bloc-notes si vous perdez votre session de navigateur ou si vous changez d'appareil. Conservez-le en lieu sûr — il n'apparaît ici qu'une seule fois.",
|
||||
"pad.deletionToken.copy": "Copier",
|
||||
"pad.deletionToken.copied": "Copié",
|
||||
"pad.deletionToken.acknowledge": "Je l'ai enregistré",
|
||||
"pad.deletionToken.deleteWithToken": "Supprimer le bloc-notes avec un jeton",
|
||||
"pad.deletionToken.tokenFieldLabel": "Jeton de suppression de bloc-notes",
|
||||
"pad.deletionToken.tokenValueLabel": "Votre jeton de suppression du bloc-notes (lecture seule)",
|
||||
"pad.deletionToken.invalid": "Ce jeton n'est pas valable pour ce bloc-notes.",
|
||||
"pad.deletionToken.notCreator": "Vous n’êtes pas le créateur du bloc-notes, vous ne pouvez donc pas le supprimer.",
|
||||
"pad.settings.about": "À propos",
|
||||
"pad.settings.poweredBy": "Propulsé par",
|
||||
"pad.importExport.import_export": "Importer/Exporter",
|
||||
|
|
@ -168,7 +257,10 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "Vous pouvez uniquement importer du texte brut ou du HTML. Pour des fonctionnalités d'importation plus avancées, veuillez <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">installer LibreOffice</a>.",
|
||||
"pad.importExport.exportetherpada.title": "Exporter au format Etherpad",
|
||||
"pad.importExport.exporthtmla.title": "Exporter au format HTML",
|
||||
"pad.importExport.exportplaina.title": "Exporter en texte brut",
|
||||
"pad.importExport.noConverter.innerHTML": "Vous pouvez importer directement du texte brut, du HTML,Microsoft Word (.docx) et des fichiers Etherpad. Pour importer d'autres formats tels que PDF, ODT, DOC ou RTF, l'administrateur du serveur doit installer LibreOffice ; consultez la <a href=\"https://docs.etherpad.org/\">documentation Etherpad</a> .",
|
||||
"pad.modals.connected": "Connecté.",
|
||||
"pad.modals.reconnecting": "Reconnexion à votre bloc-notes en cours...",
|
||||
"pad.modals.forcereconnect": "Forcer la reconnexion",
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@
|
|||
"pad.importExport.exportworda.title": "Esporta come Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Esporta in formato PDF",
|
||||
"pad.importExport.exportopena.title": "Esporta in formato ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "È possibile importare solo file di testo semplice o in formato HTML. Per funzionalità di importazione più avanzate, si prega <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">di installare LibreOffice</a> .",
|
||||
"pad.importExport.noConverter.innerHTML": "È possibile importare direttamente file di testo semplice, HTML, Microsoft Word (.docx) e file Etherpad. Per importare altri formati come PDF, ODT, DOC o RTF, l'amministratore del server deve installare LibreOffice: consulta la <a href=\"https://docs.etherpad.org/\">documentazione di Etherpad</a>.",
|
||||
"pad.modals.connected": "Connesso.",
|
||||
"pad.modals.reconnecting": "Riconnessione al pad in corso…",
|
||||
"pad.modals.forcereconnect": "Forza la riconnessione",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
"Chqaz",
|
||||
"Omotecho",
|
||||
"Shirayuki",
|
||||
"Tensama0415",
|
||||
"Torinky"
|
||||
]
|
||||
},
|
||||
|
|
@ -30,7 +31,7 @@
|
|||
"admin_plugins_info.hooks_client": "クライアント側のフック",
|
||||
"admin_plugins_info.hooks_server": "サーバー側のフック",
|
||||
"index.newPad": "新規作成",
|
||||
"index.createOpenPad": "または作成/編集するパッド名を入力:",
|
||||
"index.createOpenPad": "編集するパッド名を入力",
|
||||
"index.openPad": "次の名称の既存の Pad を開く:",
|
||||
"pad.toolbar.bold.title": "太字 (Ctrl+B)",
|
||||
"pad.toolbar.italic.title": "斜体 (Ctrl+I)",
|
||||
|
|
@ -39,7 +40,7 @@
|
|||
"pad.toolbar.ol.title": "番号付きリスト (Ctrl+Shift+N)",
|
||||
"pad.toolbar.ul.title": "番号なしリスト (Ctrl+Shift+L)",
|
||||
"pad.toolbar.indent.title": "インデント (Tab)",
|
||||
"pad.toolbar.unindent.title": "インデント解除 (Shift+Tab)",
|
||||
"pad.toolbar.unindent.title": "アウトデント (Shift+Tab)",
|
||||
"pad.toolbar.undo.title": "元に戻す (Ctrl+Z)",
|
||||
"pad.toolbar.redo.title": "やり直し (Ctrl+Y)",
|
||||
"pad.toolbar.clearAuthorship.title": "作者の色分けを消去(Ctrl+Shift+C)",
|
||||
|
|
@ -54,7 +55,7 @@
|
|||
"pad.loading": "読み込み中...",
|
||||
"pad.noCookie": "Cookie could not be found. Please allow cookies in your browser! Your session and settings will not be saved between visits. \n\nクッキーが見つかりません。ブラウザの設定でクッキーの使用を許可するまで、アクセスの記録や設定は引き継がれません。原因はブラウザによって Etherpad が iFrame に組み込まれたからと考えられます。親ドメインの iFrame と同じドメイン/サブドメインに置かれているかどうか、Etherpad の設定を確認してください。",
|
||||
"pad.permissionDenied": "あなたにはこのパッドへのアクセス許可がありません",
|
||||
"pad.settings.padSettings": "パッドの設定",
|
||||
"pad.settings.padSettings": "全パッドの設定",
|
||||
"pad.settings.myView": "個人設定",
|
||||
"pad.settings.stickychat": "画面にチャットを常に表示",
|
||||
"pad.settings.chatandusers": "チャットとユーザーを表示",
|
||||
|
|
@ -77,7 +78,7 @@
|
|||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.modals.connected": "接続されました。",
|
||||
"pad.modals.reconnecting": "パッドに再接続中...",
|
||||
"pad.modals.reconnecting": "パッドに再接続中…",
|
||||
"pad.modals.forcereconnect": "強制的に再接続",
|
||||
"pad.modals.reconnecttimer": "再接続を試行中",
|
||||
"pad.modals.cancel": "中止",
|
||||
|
|
|
|||
|
|
@ -145,14 +145,14 @@
|
|||
"admin_settings.current_save.value": "설정 저장",
|
||||
"admin_settings.invalid_json": "잘못된 JSON",
|
||||
"admin_settings.current_test.value": "JSON 검증",
|
||||
"admin_settings.current_prettify.value": "JSON 정리",
|
||||
"admin_settings.current_prettify.value": "JSON를 보기 좋게 정리",
|
||||
"admin_settings.toast.saved": "설정을 성공적으로 저장했습니다.",
|
||||
"admin_settings.toast.save_failed": "저장 실패: settings.json에 쓰기 할 수 없습니다.",
|
||||
"admin_settings.toast.json_invalid": "구문 오류: 쉼표, 중괄호, 따옴표를 확인하세요.",
|
||||
"admin_settings.toast.disconnected": "저장할 수 없음: 서버에 연결되어 있지 않습니다.",
|
||||
"admin_settings.toast.validation_ok": "JSON이 유효합니다.",
|
||||
"admin_settings.toast.validation_failed": "JSON이 유효하지 않습니다. 구문 오류를 수정하세요.",
|
||||
"admin_settings.toast.prettify_failed": "정리할 수 없습니다. 먼저 구문 오류를 수정하세요.",
|
||||
"admin_settings.toast.prettify_failed": "보기 좋게 정리할 수 없습니다. 먼저 구문 오류를 수정하세요.",
|
||||
"admin_settings.prettify_confirm": "정리하면 모든 의견이 제거됩니다. 계속하시겠습니까?",
|
||||
"admin_settings.mode.form": "형태",
|
||||
"admin_settings.mode.raw": "원본",
|
||||
|
|
@ -325,7 +325,7 @@
|
|||
"pad.importExport.exportworda.title": "Microsoft Word 파일로 내보내기",
|
||||
"pad.importExport.exportpdfa.title": "PDF로 내보내기",
|
||||
"pad.importExport.exportopena.title": "ODF(Open Document Format) 형식으로 내보내기",
|
||||
"pad.importExport.noConverter.innerHTML": "일반 텍스트나 HTML 형식으로만 가져올 수 있습니다. 고급 가져오기 기능에 대해서는 <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">리브레오피스를 설치</a>하세요.",
|
||||
"pad.importExport.noConverter.innerHTML": "일반 텍스트, HTML, 마이크로소프트 워드 (.docx) 또는 이더패드 파일을 직접 가져올 수 있습니다. PDF, ODT, DOC 또는 RTF와 같은 다른 형식을 가져오려면 서버 관리자가 리브레오피스를 설치해야 합니다. 자세한 내용은 <a href=\"https://docs.etherpad.org/\">이더패드 설명서</a>를 참고하세요.",
|
||||
"pad.modals.connected": "연결함.",
|
||||
"pad.modals.reconnecting": "내 패드에 다시 연결하는 중...",
|
||||
"pad.modals.forcereconnect": "강제로 다시 연결",
|
||||
|
|
|
|||
|
|
@ -51,14 +51,19 @@
|
|||
"admin_plugins.updates_available": "Aktualiséierungen disponibel",
|
||||
"admin_plugins.update_now": "Aktualiséieren",
|
||||
"admin_plugins.version": "Versioun",
|
||||
"admin_plugins_info.tab_server": "Server",
|
||||
"admin_plugins_info.update_available": "Aktualiséierung disponibel: {{version}}",
|
||||
"admin_plugins_info.version": "Etherpad-Versioun",
|
||||
"admin_plugins_info.version_latest": "Lescht disponibel Versioun",
|
||||
"admin_plugins_info.version_number": "Versiounsnummer",
|
||||
"admin_settings": "Astellungen",
|
||||
"admin_settings.current": "Aktuell Konfiguratioun",
|
||||
"admin_settings.current_save.value": "Astellunge späicheren",
|
||||
"admin_settings.invalid_json": "Ongültegen JSON",
|
||||
"admin_settings.toast.validation_ok": "Den JSON ass gülteg.",
|
||||
"admin_settings.toast.validation_failed": "Den JSON ass ongülteg: Verbessert wgl. d'Syntaxfeeler.",
|
||||
"admin_settings.toast.auth_error": "Dir sidd net als Admin authentifizéiert. Loggt Iech wgl. nei an.",
|
||||
"admin_settings.env_pill.default_label": "Standard",
|
||||
"admin_settings.env_pill.runtime_label": "aktive Wäert",
|
||||
"admin_settings.page-title": "Astellungen - Etherpad",
|
||||
"admin_settings.save_error": "Feeler beim Späichere vun den Astellungen",
|
||||
|
|
@ -68,7 +73,9 @@
|
|||
"index.newPad": "Neie Pad",
|
||||
"index.settings": "Astellungen",
|
||||
"index.copyLink": "2. Link kopéieren",
|
||||
"index.copyLinkDescription": "Klickt op de Knäppchen ënnen, fir de Link an Ären Tëschespäicher ze kopéieren.",
|
||||
"index.copyLinkButton": "Link an den Tëschespäicher kopéieren",
|
||||
"index.code": "Code",
|
||||
"index.createOpenPad": "oder maacht ee Pad mat dësem Numm op:",
|
||||
"pad.toolbar.bold.title": "Fett (Strg-B)",
|
||||
"pad.toolbar.italic.title": "Schréi (Ctrl+I)",
|
||||
|
|
|
|||
|
|
@ -187,6 +187,39 @@
|
|||
"update.page.policy.rollback-failed-terminal": "Претходната поднова не успеа и не можеше да се отповика. Стиснете на „Прифати“ откако воспоставката ќе стане задрава за да отклучите.",
|
||||
"update.page.policy.up-to-date": "Ја користите најновата верзија.",
|
||||
"update.page.policy.tier-off": "Подновие се оневозможени (updates.tier = „off“).",
|
||||
"update.page.policy.maintenance-window-missing": "Ниво 4 (автономно) бара период на одржување. Задајте го updates.maintenanceWindow во settings.json за да овозможите автономни поднови.",
|
||||
"update.page.policy.maintenance-window-invalid": "Ниво 4 (автономно) е оневозможено бидејќи updates.maintenanceWindow е погрешно срочен. Се очекуваше {start, end, tz} со времиња ЧЧ:ММ и tz да биде „local“ или „utc“.",
|
||||
"update.page.last_result.verified": "Потврдена последната поднова на {{tag}}.",
|
||||
"update.page.last_result.rolled-back": "Отповикан последниот обид за поднова на {{tag}}: {{reason}}.",
|
||||
"update.page.last_result.rollback-failed": "Последниот обид за поднова не успеа И отповикувањето не успеа: {{reason}}. Потребна е рачна интервенција.",
|
||||
"update.page.last_result.preflight-failed": "Не успеа последниот обид за поднова на {{tag}} во подготовката: {{reason}}.",
|
||||
"update.page.last_result.cancelled": "Последниот обид за поднова на {{tag}} е откажан од администратор.",
|
||||
"update.execution.idle": "Неактивно",
|
||||
"update.execution.scheduled": "Подновата е закажана",
|
||||
"update.execution.preflight": "Подготвителни проверки",
|
||||
"update.execution.preflight-failed": "Проверките не успеаја",
|
||||
"update.execution.draining": "Исцрпувачки седници",
|
||||
"update.execution.executing": "Подновувам...",
|
||||
"update.execution.pending-verification": "Чека на потврда",
|
||||
"update.execution.verified": "Потврдено",
|
||||
"update.execution.rolling-back": "Отповикување",
|
||||
"update.execution.rolled-back": "Отповикано",
|
||||
"update.execution.rollback-failed": "Отповикувањето не успеа",
|
||||
"update.banner.terminal.rollback-failed": "Не успеа обидот за поднова и не можеше да биде отповикан. Потребна е рачна интервенција.",
|
||||
"update.banner.scheduled": "Автоподновата на {{tag}} е закажана — на сила за {{remaining}}.",
|
||||
"update.banner.maintenance-window-missing": "Автономните поднови се оневозможени додека не се постави период на одржување.",
|
||||
"update.banner.maintenance-window-invalid": "Автономните поднови се оневозможени бидејќи периодот на одржување е погрешно срочен.",
|
||||
"update.page.scheduled.title": "Подновата е закажана",
|
||||
"update.page.scheduled.countdown": "Etherpad ќе почне со подновување на {{tag}} за {{remaining}}.",
|
||||
"update.page.scheduled.deferred_until": "Вон периодот на одржување. Подновата ќе почне кога периодот ќе започне во {{at}}.",
|
||||
"update.page.scheduled.apply_now": "Примени сега",
|
||||
"update.window.title": "Период на одржување",
|
||||
"update.window.summary": "{{start}}–{{end}} ({{tz}})",
|
||||
"update.window.unset": "Не е наместено.",
|
||||
"update.window.next_opens_at": "Следниот период почнува на {{at}}.",
|
||||
"update.drain.t60": "Etherpad ќе се превклучи за 60 секунди за да направи примена и поднова.",
|
||||
"update.drain.t30": "Etherpad ќе се превклучи за 30 секунди за да направи примена и поднова.",
|
||||
"update.drain.t10": "Etherpad ќе се превклучи за 10 секунди за да направи примена и поднова.",
|
||||
"index.newPad": "Нова тетратка",
|
||||
"index.settings": "Нагодувања",
|
||||
"index.transferSessionTitle": "Префрли седница",
|
||||
|
|
@ -199,6 +232,7 @@
|
|||
"index.copyLinkButton": "Копирај врска во меѓускладот",
|
||||
"index.transferToSystem": "3. Копирај седница во нов систем",
|
||||
"index.transferToSystemDescription": "Отворете ја ископираната врска во целниот прелистувач или уред за да ја префрлите вашата седница.",
|
||||
"index.code": "Код",
|
||||
"index.transferSessionDescription": "Префрлете ја вашата тековна седница на прелистувач или уред стискајќи на копчето подолу. Ова ќе ја прекопира врската во страница која ќе ви ја префрли седницата кога ќе се отвори во целниот прелистувач или уред.",
|
||||
"index.createOpenPad": "Отвори тетратка по име",
|
||||
"index.openPad": "отвори постоечка тетратка наречена:",
|
||||
|
|
@ -273,6 +307,12 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.exportetherpada.title": "Извези како Etherpad",
|
||||
"pad.importExport.exporthtmla.title": "Извези како HTML",
|
||||
"pad.importExport.exportplaina.title": "Извези како прост текст",
|
||||
"pad.importExport.exportworda.title": "Извези како Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Извези како PDF",
|
||||
"pad.importExport.exportopena.title": "Извези како ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "Можете да увезувате само од прост текст или HTML-форматти. Понапредни можности за увоз ќе добиете ако <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">го воспоставите LibreOffice</a>.",
|
||||
"pad.modals.connected": "Поврзано.",
|
||||
"pad.modals.reconnecting": "Ве преповрзувам со тетратката...",
|
||||
|
|
@ -318,13 +358,31 @@
|
|||
"timeslider.followContents": "Следи ги подновите во содржината на тетратката",
|
||||
"timeslider.pageTitle": "{{appTitle}} Историски преглед",
|
||||
"timeslider.toolbar.returnbutton": "Назад на тетратката",
|
||||
"pad.historyMode.banner": "Историја на посети",
|
||||
"pad.historyMode.return": "Назад во живо",
|
||||
"pad.historyMode.revisionLabel": "Преработка {{rev}}",
|
||||
"pad.historyMode.controlsLabel": "Контроли за историја на тетратката",
|
||||
"pad.historyMode.sliderLabel": "Преработка на тетратката",
|
||||
"pad.historyMode.settings.title": "Преглед на историјата",
|
||||
"pad.historyMode.settings.follow": "Следи поднови на содржини тетратки",
|
||||
"pad.historyMode.settings.followShort": "Следи",
|
||||
"pad.historyMode.followOn": "Следите промени во тетратката — стиснете за да престанете со следење",
|
||||
"pad.historyMode.followOff": "Не следите промени во тетратката — стиснете за да следите",
|
||||
"pad.historyMode.settings.playbackSpeed": "Брзина на прегледувањето:",
|
||||
"pad.historyMode.chat.replayHeader": "Разговор од {{time}}",
|
||||
"pad.historyMode.users.authorsHeader": "Авроти на оваа преработка",
|
||||
"pad.editor.skipToContent": "Прејди на уредувачот",
|
||||
"pad.editor.keyboardHint": "Стиснете Escape за да излезете од уредувачот. Стиснете Alt+F9 за алатникот.",
|
||||
"pad.editor.toolbar.formatting": "Алатник за форматирање",
|
||||
"pad.editor.toolbar.actions": "Алатник за дејства врз тетратка",
|
||||
"pad.editor.toolbar.showMore": "Покажи повеќе копчиња на алатникот",
|
||||
"timeslider.toolbar.authors": "Автори:",
|
||||
"timeslider.toolbar.authorsList": "Нема автори",
|
||||
"timeslider.toolbar.exportlink.title": "Извоз",
|
||||
"timeslider.exportCurrent": "Извези ја тековната верзија како:",
|
||||
"timeslider.version": "Верзија {{version}}",
|
||||
"timeslider.saved": "Зачувано на {{day}} {{month}} {{year}} г.",
|
||||
"timeslider.settings.playbackSpeed": "Брзина на репродукција:",
|
||||
"timeslider.settings.playbackSpeed": "Брзина на прегледување:",
|
||||
"timeslider.settings.playbackSpeed.original": "Изворна брзина",
|
||||
"timeslider.settings.playbackSpeed.realtime": "Реално време",
|
||||
"timeslider.settings.playbackSpeed.200ms": "200 мс",
|
||||
|
|
@ -351,6 +409,7 @@
|
|||
"pad.savedrevs.timeslider": "Можете да ги погледате зачуваните преработки посетувајќи го времеследниот лизгач",
|
||||
"pad.userlist.entername": "Внесете го вашето име",
|
||||
"pad.userlist.unnamed": "без име",
|
||||
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} поврзан корисник, other: {{count}} поврзани корисници ]}",
|
||||
"pad.editbar.clearcolors": "Да ги отстранам авторските бои од целиот документ? Ова е неповратно",
|
||||
"pad.impexp.importbutton": "Увези сега",
|
||||
"pad.impexp.importing": "Увезувам...",
|
||||
|
|
|
|||
|
|
@ -28,17 +28,17 @@
|
|||
"pad.modals.cancel": "Xikxolewa",
|
||||
"pad.modals.deleted": "Omopohpoloh.",
|
||||
"pad.modals.deleted.explanation": "Ōmopoloh inīn Pad.",
|
||||
"timeslider.version": "Inīc {{version}} Cuepaliztli",
|
||||
"timeslider.month.january": "Eneroh",
|
||||
"timeslider.month.february": "Febreroh",
|
||||
"timeslider.month.march": "Marsoh",
|
||||
"timeslider.month.april": "April",
|
||||
"timeslider.month.may": "Mayoh",
|
||||
"timeslider.month.june": "Honioh",
|
||||
"timeslider.month.july": "Holioh",
|
||||
"timeslider.month.august": "Ahostoh",
|
||||
"timeslider.month.september": "Septiempreh",
|
||||
"timeslider.month.october": "Oktopreh",
|
||||
"timeslider.month.november": "Noviempreh",
|
||||
"timeslider.month.december": "Tisiempreh"
|
||||
"timeslider.version": "Inic {{version}} Tlacepaliztli",
|
||||
"timeslider.month.january": "Enero",
|
||||
"timeslider.month.february": "Febrero",
|
||||
"timeslider.month.march": "Marzo",
|
||||
"timeslider.month.april": "Abril",
|
||||
"timeslider.month.may": "Mayo",
|
||||
"timeslider.month.june": "Junio",
|
||||
"timeslider.month.july": "Julio",
|
||||
"timeslider.month.august": "Agosto",
|
||||
"timeslider.month.september": "Septiembre",
|
||||
"timeslider.month.october": "Octubre",
|
||||
"timeslider.month.november": "Noviembre",
|
||||
"timeslider.month.december": "Diciembre"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,243 @@
|
|||
"हिमाल सुबेदी"
|
||||
]
|
||||
},
|
||||
"admin.page-title": "प्रबन्धक ड्यासबोर्ड - इथरप्याड",
|
||||
"admin.loading": "लोड हुँदैछ...",
|
||||
"admin.loading_description": "कृपया पृष्ठ लोड हुदै गर्दा प्रतिक्षा गर्नुहोस् ।",
|
||||
"admin.toggle_sidebar": "छेउपट्टी टगल गर्नुहोस्",
|
||||
"admin.shout": "सञ्चार",
|
||||
"admin_shout.online_one": "हाल {{count}} प्रयोगकर्ता अनलाइन छ",
|
||||
"admin_shout.online_other": "हाल {{count}} प्रयोगकर्ताहरू अनलाइन छन्",
|
||||
"admin_shout.sticky_toggle": "टाँसिने सन्देश परिवर्तन गर्नुहोस्",
|
||||
"admin_login.title": "इथरप्याड",
|
||||
"admin_login.username": "प्रयोगकर्ता नाम",
|
||||
"admin_login.password": "पासवर्ड",
|
||||
"admin_login.submit": "लगइन",
|
||||
"admin_login.failed": "लगइन असफल भयो",
|
||||
"admin_pads.all_pads": "सबै प्याडहरू",
|
||||
"admin_pads.bulk.cleanup_history": "इतिहास खाली गर्नुहोस्",
|
||||
"admin_pads.bulk.clear_selection": "चयन खाली गर्नुहोस्",
|
||||
"admin_pads.bulk.delete": "मेट्नुहोस्",
|
||||
"admin_pads.cancel": "रद्द गर्नुहोस्",
|
||||
"admin_pads.col.pad": "प्याड",
|
||||
"admin_pads.col.revisions": "संशोधनहरू",
|
||||
"admin_pads.col.users": "प्रयोगकर्ताहरू",
|
||||
"admin_pads.confirm_button": "ठीक छ",
|
||||
"admin_pads.create_pad_dialog_description": "नयाँ प्याडका लागि नाम रोज्नुहोस् ।",
|
||||
"admin_pads.delete_pad_dialog_description": "प्याड मेटाइ यकीन गर्नुहोस् वा रद्द गर्नुहोस् ।",
|
||||
"admin_pads.delete_pad_dialog_title": "प्याड मेट्नुहोस्",
|
||||
"admin_pads.empty_never_edited": "खाली · कहिल्यै सम्पादन गरिएन",
|
||||
"admin_pads.error_dialog_description": "एउटा त्रुटि देखापर्यो ।",
|
||||
"admin_pads.error_prefix": "त्रुटि",
|
||||
"admin_pads.filter.active": "सक्रिय",
|
||||
"admin_pads.filter.all": "सबै",
|
||||
"admin_pads.filter.empty": "खाली",
|
||||
"admin_pads.filter.recent": "यो हप्ता",
|
||||
"admin_pads.filter.stale": "स्थिर (>1y)",
|
||||
"admin_pads.open": "खोल्नुहोस्",
|
||||
"admin_pads.pagination.next": "पछिल्लो",
|
||||
"admin_pads.pagination.previous": "अघिल्लो",
|
||||
"admin_pads.refresh": "ताजा पार्नुहोस्",
|
||||
"admin_pads.relative.days": "{{count}}दिन अगाडि",
|
||||
"admin_pads.relative.hours": "{{count}}घण्टा अगाडि",
|
||||
"admin_pads.relative.just_now": "अहिले",
|
||||
"admin_pads.relative.minutes": "{{count}}मिनेट अगाडि",
|
||||
"admin_pads.relative.months": "{{count}}mo पहिले",
|
||||
"admin_pads.relative.weeks": "{{count}}w अगाडि",
|
||||
"admin_pads.relative.years": "{{county}} पहिले",
|
||||
"admin_pads.revisions_count": "{{count}} संशोधनहरू",
|
||||
"admin_pads.selected_count": "{{count}} चयन गरियो",
|
||||
"admin_pads.show": "देखाउनुहोस्",
|
||||
"admin_pads.sort.name": "नाम (AZ)",
|
||||
"admin_pads.sort.revision_number": "संशोधनहरू",
|
||||
"admin_pads.sort.user_count": "प्रयोगकर्ताहरू",
|
||||
"admin_pads.stats.across_pads": "सबै प्याडहरूमा",
|
||||
"admin_pads.stats.active_users": "सक्रिय प्रयोगकर्ताहरू",
|
||||
"admin_pads.stats.empty_pads": "खाली प्याडहरू",
|
||||
"admin_pads.stats.last_activity": "अन्तिम क्रियाकलाप",
|
||||
"admin_pads.stats.no_active_users": "सक्रिय प्रयोगकर्ता छैन",
|
||||
"admin_pads.stats.revisions_zero": "0 पुनरावलोकन",
|
||||
"admin_pads.stats.total": "जम्मा प्याडहरू",
|
||||
"admin_pads.stats.users_active": "{{count}} हाल सक्रिय",
|
||||
"admin_pads.subtitle": "यस इथरप्याड दृष्टान्तमा सबै प्याडहरूको सिंहावलोकन । खोजी गर्नुहोस्, सफा गर्नुहोस्, खोल्नुहोस् ।",
|
||||
"admin_plugins": "प्लगइन प्रबन्धक",
|
||||
"admin_plugins.available": "उपलब्ध प्लगइनहरू",
|
||||
"admin_plugins.available_not-found": "कुनै प्लगइन फेला परेन ।",
|
||||
"admin_plugins.available_fetching": "तान्दै...",
|
||||
"admin_plugins.available_install.value": "स्थापना गर्नुहोस्",
|
||||
"admin_plugins.available_search.placeholder": "स्थापना गर्नका लागि प्लगइन खोजी गर्नुहोस्",
|
||||
"admin_plugins.check_updates": "अद्यावधिकका लागि जाँच गर्नुहोस्",
|
||||
"admin_plugins.core_count": "{{count}} कोर",
|
||||
"admin_plugins.catalog_disabled": "प्लगइन विवरणिका तपाईँको सञ्चालकद्वारा अक्षम पारिएको छ (privacy.pluginCatalog=false) । प्लगइन स्थापना गर्न, `pnpm run plugins i ep_<name>` सर्भरबाट चलाउनुहोस् ।",
|
||||
"admin_plugins.crumbs": "प्लगइनहरू",
|
||||
"admin_plugins.description": "विवरण",
|
||||
"admin_plugins.disables.label": "अक्षम पार्दछ:",
|
||||
"admin_plugins.disables.warning_title": "यो प्लगइनले जानेर सूचीकृत इथरप्याड सुविधाहरू हटाउँछ ।",
|
||||
"admin_plugins.error_retrieving": "प्लगइन पुन: प्राप्त गर्दा त्रुटि",
|
||||
"admin_plugins.install_error": "{{plugin}} स्थापना गर्न असफल: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "{{plugin}} स्थापना गर्न सकिँदैन: यसलाई इथरप्याडको नयाँ संस्करण चाहिन्छ । कृपया इथरप्याड स्तरवृद्धि गर्नुहोस् र फेरि प्रयास गर्नुहोस् ।",
|
||||
"admin_plugins.installed": "स्थापित प्लगइनहरू",
|
||||
"admin_plugins.installed_fetching": "स्थापित प्लगइनहरू तान्दैछ...",
|
||||
"admin_plugins.installed_nothing": "तपाईँले अहिलेसम्म कुनै पनि प्लगइन स्थापना गर्नुभएको छैन ।",
|
||||
"admin_plugins.installed_uninstall.value": "स्थापना रद्द गर्नुहोस्",
|
||||
"admin_plugins.last-update": "अन्तिम अद्यावधिक",
|
||||
"admin_plugins.name": "नाम",
|
||||
"admin_plugins.page-title": "प्लगइन प्रबन्धक - इथरप्याड",
|
||||
"admin_plugins.reload_catalog": "क्याटलग पुन: लोड गर्नुहोस्",
|
||||
"admin_plugins.search_npm": "npm मा खोजी गर्नुहोस्",
|
||||
"admin_plugins.sort_ascending": "बढ्दो क्रममा क्रमबद्ध गर्नुहोस्",
|
||||
"admin_plugins.sort_descending": "घट्दो क्रममा क्रमबद्ध गर्नुहोस्",
|
||||
"admin_plugins.sort.last_updated": "अन्तिम अद्यावधिक",
|
||||
"admin_plugins.sort.name": "नाम (AZ)",
|
||||
"admin_plugins.sort.version": "संस्करण",
|
||||
"admin_plugins.source": "प्लगइन स्रोत",
|
||||
"admin_plugins.subtitle": "इथरप्याड प्लगइनहरू स्थापना, अद्यावधिक, र हटाउनुहोस् । परिवर्तनका लागि सर्भर पुनः सुरु गर्नु आवश्यक हुन्छ ।",
|
||||
"admin_plugins.tag_core": "कोर",
|
||||
"admin_plugins.update_tooltip": "अद्यतन",
|
||||
"admin_plugins.updates_available": "अद्यावधिकहरू उपलब्ध छन्",
|
||||
"admin_plugins.update_now": "अद्यतन",
|
||||
"admin_plugins.version": "संस्करण",
|
||||
"admin_plugins_info": "समस्या निवारण सूचना",
|
||||
"admin_plugins_info.bindings_label": "{{count}} बाइन्डिङ",
|
||||
"admin_plugins_info.copy_diagnostics": "डायग्नोस्टिक्स प्रतिलिपि गर्नुहोस्",
|
||||
"admin_plugins_info.copy_value": "{{label}} प्रतिलिपि बनाउनुहोस्",
|
||||
"admin_plugins_info.git_sha": "Git SHA",
|
||||
"admin_plugins_info.hook_bindings": "हुक बाइन्डिङ",
|
||||
"admin_plugins_info.hooks": "स्थापित हुकहरू",
|
||||
"admin_plugins_info.hooks_client": "क्लाइन्ट-साइड हुक",
|
||||
"admin_plugins_info.hooks_server": "सर्भर-साइड हुकहरू",
|
||||
"admin_plugins_info.no_hooks": "कुनै हुक फेला परेन",
|
||||
"admin_plugins_info.parts": "स्थापना गरिएका भागहरू",
|
||||
"admin_plugins_info.plugins": "स्थापित प्लगइनहरू",
|
||||
"admin_plugins_info.page-title": "प्लगइन सूचना - इथरप्याड",
|
||||
"admin_plugins_info.search_placeholder": "हुक वा भाग खोजी गर्नुहोस्...",
|
||||
"admin_plugins_info.subtitle": "प्रणाली निदान: स्थापित संस्करण, दर्ता गरिएका भागहरू र हुकहरू।",
|
||||
"admin_plugins_info.tab_client": "ग्राहक",
|
||||
"admin_plugins_info.tab_server": "सर्भर",
|
||||
"admin_plugins_info.up_to_date": "अद्यावधिक",
|
||||
"admin_plugins_info.update_available": "अद्यावधिक उपलब्ध: {{version}}",
|
||||
"admin_plugins_info.version": "इथरप्याड संस्करण",
|
||||
"admin_plugins_info.version_latest": "नवीनतम उपलब्ध संस्करण",
|
||||
"admin_plugins_info.version_number": "संस्करण नम्बर",
|
||||
"admin_settings": "सेटिङ",
|
||||
"admin_settings.create_pad": "प्याड सिर्जना गर्नुहोस्",
|
||||
"admin_settings.current": "हालको कन्फिगरेसन",
|
||||
"admin_settings.current_example-devel": "उदाहरण विकास सेटिङ टेम्प्लेट",
|
||||
"admin_settings.current_example-prod": "उदाहरण उत्पादन सेटिङ टेम्प्लेट",
|
||||
"admin_settings.current_restart.value": "इथरप्याड फेरि सुरु गर्नुहोस्",
|
||||
"admin_settings.current_save.value": "सेटिङ बचत गर्नुहोस्",
|
||||
"admin_settings.invalid_json": "अवैध JSON",
|
||||
"admin_settings.current_test.value": "JSON प्रमाणित गर्नुहोस्",
|
||||
"admin_settings.current_prettify.value": "JSON सुन्दर बनाउनुहोस्",
|
||||
"admin_settings.toast.saved": "सेटिङ सफलतापूर्वक बचत गरियो ।",
|
||||
"admin_settings.toast.save_failed": "बचत असफल भयो: settings.json लेख्न सकिएन ।",
|
||||
"admin_settings.toast.json_invalid": "वाक्य संरचना त्रुटि: अल्पविरामहरू, जुँगे कोष्ठकहरू, र उद्धरणहरू जाँच गर्नुहोस् ।",
|
||||
"admin_settings.toast.disconnected": "बचत गर्न सकिँदैन: सर्भरमा जडान भएको छैन ।",
|
||||
"admin_settings.toast.validation_ok": "JSON वैध छ ।",
|
||||
"admin_settings.toast.validation_failed": "JSON अवैध छ: कृपया वाक्य संरचना त्रुटिहरू समाधान गर्नुहोस् ।",
|
||||
"admin_settings.toast.prettify_failed": "प्रशोधन गर्न सकिँदैन: कृपया पहिले वाक्य संरचना त्रुटिहरू समाधान गर्नुहोस् ।",
|
||||
"admin_settings.prettify_confirm": "सुन्दरताले सबै टिप्पणीहरू हटाउनेछ । जारी राख्नुहुन्छ ?",
|
||||
"admin_settings.mode.form": "फाराम",
|
||||
"admin_settings.mode.raw": "कच्चा",
|
||||
"admin_settings.mode.effective": "प्रभावकारी",
|
||||
"admin_settings.mode.effective_tooltip": "वातावरण-चल प्रतिस्थापन पछि, इथरप्याडले अहिले प्रयोग गरिरहेको मानहरूको पढ्ने-मात्र दृश्य । गोप्यहरू सम्पादन गरिएका छन् ।",
|
||||
"admin_settings.mode.aria_label": "सम्पादक मोड",
|
||||
"admin_settings.envvar_banner.title": "यो फाइल टेम्प्लेट हो, प्रत्यक्ष कन्फिग होइन ।",
|
||||
"admin_settings.envvar_banner.body": "${VAR:default} जस्ता प्लेसहोल्डरहरू सुरुआतमा स्मृतिमा प्रतिस्थापन गरिन्छ; तिनीहरू कहिले पनि यो फाइलमा लेखिँदैन । समाधान गरिएको मान परिवर्तन गर्न तपाईँको वातावरणमा env vars सम्पादन गर्नुहोस् (Docker compose, systemd, .env) वा यहाँको प्लेसहोल्डरलाई शाब्दिकसँग बदल्नुहोस् । इथरप्याडले अहिले के प्रयोग गरिरहेको छ हेर्न प्रभावकारी ट्याबमा स्विच गर्नुहोस् ।",
|
||||
"admin_settings.toast.auth_error": "तपाईं प्रशासकको रूपमा प्रमाणित हुनुहुन्न । कृपया पुनः लगइन गर्नुहोस् ।",
|
||||
"admin_settings.section.general": "सामान्य",
|
||||
"admin_settings.parse_error.title": "सेटिङ्स.json पद वर्णन गर्न सकिँदैन",
|
||||
"admin_settings.parse_error.cta": "सम्पादन गर्न कच्चामा स्विच गर्नुहोस्",
|
||||
"admin_settings.env_pill.tooltip": "{{variable}} परिवेश चलबाट पढिन्छ । तलको मान {{variable}} सेट नगरिएको बेला प्रयोग गरिन्छ ।",
|
||||
"admin_settings.env_pill.default_label": "पूर्वनिर्धारित",
|
||||
"admin_settings.env_pill.input_aria": "{{variable}}का लागि पूर्वनिर्धारित मान",
|
||||
"admin_settings.env_pill.runtime_label": "सक्रिय मान",
|
||||
"admin_settings.env_pill.runtime_tooltip": "इथरप्याडले हाल यो मान प्रयोग गरिरहेको छ, {{variable}} वा यसको पूर्वनिर्धारितबाट समाधान गरिएको छ ।",
|
||||
"admin_settings.env_pill.redacted_tooltip": "इथरप्याडले {{variable}}का लागि एउटा मान प्रयोग गरिरहेको छ, तर यो लुकेको छ किनभने यो गोप्य छ ।",
|
||||
"admin_settings.page-title": "सेटिङ - इथरप्याड",
|
||||
"admin_settings.save_error": "सेटिङ बचत गर्दा त्रुटि",
|
||||
"admin_settings.saved_success": "सेटिङ सफलतापूर्वक बचत गरियो",
|
||||
"update.banner.title": "अद्यावधिक उपलब्ध छ",
|
||||
"update.banner.body": "इथरप्याड {{latest}} उपलब्ध छ (तपाईँ {{current}} चलाउँदै हुनुहुन्छ)।",
|
||||
"update.banner.cta": "अद्यावधिक हेर्नुहोस्",
|
||||
"update.page.title": "इथरप्याड अद्यावधिकहरू",
|
||||
"update.page.current": "हालको संस्करण",
|
||||
"update.page.latest": "नवीनतम संस्करण",
|
||||
"update.page.last_check": "अन्तिम जाँच गरियो",
|
||||
"update.page.install_method": "स्थापना विधि",
|
||||
"update.page.tier": "तह अद्यावधिक गर्नुहोस्",
|
||||
"update.page.changelog": "परिवर्तन लग",
|
||||
"update.page.up_to_date": "तपाईँ नयाँ संस्करण चलाइरहनु भएको छ ।",
|
||||
"update.page.disabled": "अद्यावधिक जाँचहरू अक्षम पारिएको छ (updates.tier = \"off\") ।",
|
||||
"update.page.unauthorized": "तपाईंलाई अद्यावधिक स्थिति हेर्न अधिकार छैन ।",
|
||||
"update.page.error": "अद्यावधिक स्थिति (स्थिति {{status}}) लोड गर्न सकेन ।",
|
||||
"update.badge.severe": "यस सर्भरको ईथरप्याड गम्भीर रूपमा अप्रचलित छ । आफ्नो प्रशासकलाई बताउनुहोस् ।",
|
||||
"update.badge.vulnerable": "यस सर्भरमा इथरप्याडले ज्ञात सुरक्षा समस्याहरूको साथमा संस्करण चलाइरहेको छ। आफ्नो प्रशासकलाई बताउनुहोस्।",
|
||||
"update.page.apply": "अद्यावधिक लागू गर्नुहोस्",
|
||||
"update.page.cancel": "रद्द गर्नुहोस्",
|
||||
"update.page.acknowledge": "स्वीकृति",
|
||||
"update.page.log": "अद्यावधिक लग (अन्तिम २०० पङ्क्तिहरू)",
|
||||
"update.page.execution": "वस्तुस्थिति",
|
||||
"update.page.policy.install-method-not-writable": "प्रबन्धक यूआईबाट अद्यावधिक गर्न गिट स्थापना आवश्यक पर्दछ । तपाईँको प्याकेज प्रबन्धक मार्फत अद्यावधिक गर्नुहोस् ।",
|
||||
"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": "टियर ४ (स्वतन्त्र) लाई मर्मत सञ्झ्याल चाहिन्छ । स्वायत्त अद्यावधिक सक्षम पार्न settings.json मा अद्यावधिकहरू.मर्मत सञ्झ्याल सेट गर्नुहोस् ।",
|
||||
"update.page.policy.maintenance-window-invalid": "टियर ४ (स्वतन्त्र) अक्षम पारिएको छ किनभने अद्यावधिकहरू.मर्मत सञ्झ्याल विकृत छ । अपेक्षित {start, end, tz} HH:MM पटक र \"local\" वा \"utc\" को tz सँग ।",
|
||||
"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": "इथरप्याडले {{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": "अद्यावधिक लागू गर्न ईथरप्याड ६० सेकेन्डमा पुनःसुरु हुनेछ ।",
|
||||
"update.drain.t30": "इथरप्याड अद्यावधिक लागू गर्न ३० सेकेन्डमा पुनः सुरु हुनेछ ।",
|
||||
"update.drain.t10": "अद्यावधिक लागू गर्न 10 सेकेन्डमा इथरप्याड पुनः सुरु हुनेछ ।",
|
||||
"index.newPad": "नयाँ प्याड",
|
||||
"index.settings": "अभिरुचिहरू",
|
||||
"index.transferSessionTitle": "सत्र स्थानान्तरण गर्नुहोस्",
|
||||
"index.receiveSessionTitle": "सत्र प्राप्त गर्नुहोस्",
|
||||
"index.receiveSessionDescription": "यहाँ तपाईँले अर्को ब्राउजर वा यन्त्रबाट इथरप्याड सत्र प्राप्त गर्न सक्नुहुन्छ । यद्यपि, यदि कुनै भएमा, यसले तपाईँको हालको सत्र मेट्नेछ भन्ने कृपया याद राख्नुहोस् ।",
|
||||
"index.transferSession": "1. सत्र स्थानान्तरण गर्नुहोस्",
|
||||
"index.transferSessionNow": "अहिले सत्र स्थानान्तरण गर्नुहोस्",
|
||||
"index.copyLink": "२. लिङ्क प्रतिलिपि गर्नुहोस्",
|
||||
"index.copyLinkDescription": "तपाईँको क्लिपबोर्डमा लिङ्क प्रतिलिपि गर्न तलको बटनमा क्लिक गर्नुहोस् ।",
|
||||
"index.copyLinkButton": "क्लिपबोर्डमा लिङ्क प्रतिलिपि गर्नुहोस्",
|
||||
"index.transferToSystem": "३. नयाँ प्रणालीमा सत्र प्रतिलिपि गर्नुहोस्",
|
||||
"index.transferToSystemDescription": "तपाईँको सत्र स्थानान्तरण गर्न लक्षित ब्राउजर वा यन्त्रमा प्रतिलिपि गरिएको लिङ्क खोल्नुहोस् ।",
|
||||
"index.code": "कोड",
|
||||
"index.transferSessionDescription": "तलको बटन क्लिक गरेर तपाईँको हालको सत्र ब्राउजर वा यन्त्रमा स्थानान्तरण गर्नुहोस् । यसले पृष्ठमा लिङ्क प्रतिलिपि गर्नेछ जसले लक्षित ब्राउजर वा यन्त्रमा खोल्दा तपाईँको सत्र स्थानान्तरण गर्नेछ ।",
|
||||
"index.createOpenPad": "नाम सहितको नयाँ प्याड सिर्जना गर्ने / खोल्ने :",
|
||||
"index.openPad": "नामसँग अवस्थित प्याड खोल्नुहोस्:",
|
||||
"index.recentPads": "हालका प्याडहरू",
|
||||
"index.recentPadsEmpty": "कुनै पनि हालैका प्याडहरू फेला परेन ।",
|
||||
"index.generateNewPad": "अनियमित प्याड नाम उत्पन्न गर्नुहोस्",
|
||||
"index.labelPad": "प्याड नाम (वैकल्पिक)",
|
||||
"index.placeholderPadEnter": "कृपया प्याड नाम प्रविष्ट गर्नुहोस्...",
|
||||
"index.createAndShareDocuments": "वास्तविक समयमा कागजातहरू सिर्जना गर्नुहोस् र साझेदारी गर्नुहोस्",
|
||||
"index.createAndShareDocumentsDescription": "इथरप्याडले तपाईँलाई तपाईँको ब्राउजरमा चलिरहेको प्रत्यक्ष बहु-खेलाडी सम्पादक जस्तै वास्तविक समयमा सहकार्यपूर्वक कागजातहरू सम्पादन गर्न अनुमति दिन्छ ।",
|
||||
"pad.toolbar.bold.title": "मोटो (Ctrl-B)",
|
||||
"pad.toolbar.italic.title": "ढल्के (Ctrl-I)",
|
||||
"pad.toolbar.underline.title": "निम्न रेखाङ्कन (Ctrl-U)",
|
||||
|
|
@ -28,25 +259,47 @@
|
|||
"pad.toolbar.undo.title": "रद्द (Ctrl-Z)",
|
||||
"pad.toolbar.redo.title": "पुन:लागु (Ctrl-Y)",
|
||||
"pad.toolbar.clearAuthorship.title": "लेखकत्व रङहरू खाली गर्नुहोस् (Ctrl + Shift + C)",
|
||||
"pad.toolbar.import_export.title": "फरक फाइल ढाँचाबाट/मा आयात/निर्यात गर्नुहोस्",
|
||||
"pad.toolbar.timeslider.title": "टाइमस्लाइडर",
|
||||
"pad.toolbar.savedRevision.title": "पुनरावलोकन संग्रहगर्ने",
|
||||
"pad.toolbar.settings.title": "अभिरुचिहरू",
|
||||
"pad.toolbar.embed.title": "यस प्याडलाई बाड्ने या इम्बेड गर्ने",
|
||||
"pad.toolbar.home.title": "घर फर्कनुहोस्",
|
||||
"pad.toolbar.showusers.title": "यस प्याडमा रहेका प्रयोगकर्ता देखाउने",
|
||||
"pad.colorpicker.save": "सङ्ग्रह गर्नुहोस्",
|
||||
"pad.colorpicker.cancel": "रद्द गर्नुहोस्",
|
||||
"pad.loading": "खुल्दै छ…",
|
||||
"pad.noCookie": "कुकी फेला पार्न सकिएन । कृपया तपाईँको ब्राउजरमा कुकीहरू अनुमति दिनुहोस् ! तपाईँको सत्र र सेटिङ भ्रमणको बीचमा बचत गरिने छैन । यो केही ब्राउजरहरूमा आइफ्रेममा ईथरप्याड समावेश भएको कारणले हुन सक्छ । कृपया ईथरप्याड प्रमूल आईफ्रेमको रूपमा उही उपडोमेन/डोमेनमा छ भनी सुनिश्चित गर्नुहोस्",
|
||||
"pad.permissionDenied": "तपाईंलाई यो प्याड खोल्न अनुमति छैन",
|
||||
"pad.settings.title": "सेटिङ",
|
||||
"pad.settings.padSettings": "प्याड अभिरुचिहरू",
|
||||
"pad.settings.userSettings": "प्रयोगकर्ता सेटिङ",
|
||||
"pad.settings.myView": "मेरो दृष्य",
|
||||
"pad.settings.disablechat": "कुराकानी अक्षम पार्नुहोस्",
|
||||
"pad.settings.darkMode": "गाढा मोड",
|
||||
"pad.settings.stickychat": "पर्दामा सधै च्याट गर्ने",
|
||||
"pad.settings.chatandusers": "वार्ता तथा प्रयोगकर्ताहरू देखाउने",
|
||||
"pad.settings.colorcheck": "लेखकका रङहरू",
|
||||
"pad.settings.fadeInactiveAuthorColors": "निष्क्रिय लेखक रङ फेड गर्नुहोस्",
|
||||
"pad.settings.linenocheck": "हरफ संख्या",
|
||||
"pad.settings.rtlcheck": "के सामग्री दाहिने देखि देब्रे पढ्ने हो ?",
|
||||
"pad.settings.enforceSettings": "अन्य प्रयोगकर्ताका लागि सेटिङहरू लागू गर्नुहोस्",
|
||||
"pad.settings.enforcedNotice": "यी सेटिङहरू यस प्याडको सर्जकद्वारा तपाईँका लागि ताल्चा लगाइएको छ। यदि तपाईँलाई परिवर्तन गर्न आवश्यक छ भने प्याड सर्जकलाई सोध्नुहोस्।",
|
||||
"pad.settings.fontType": "लिपि प्रकार:",
|
||||
"pad.settings.fontType.normal": "सामान्य",
|
||||
"pad.settings.language": "भाषा:",
|
||||
"pad.settings.deletePad": "प्याड मेट्नुहोस्",
|
||||
"pad.delete.confirm": "के तपाईँ साँच्चिकै यो प्याड मेट्न चाहनुहुन्छ?",
|
||||
"pad.deletionToken.modalTitle": "तपाईँको प्याड मेटाइ टोकन बचत गर्नुहोस्",
|
||||
"pad.deletionToken.modalBody": "यदि तपाईँले आफ्नो ब्राउजर सत्र वा स्विच यन्त्र गुमाउनु भयो भने यो टोकन प्याड मेटाउने एक मात्र तरिका हो । यसलाई सुरक्षित स्थानमा बचत गर्नुहोस् - यो यहाँ ठ्याक्कै एक पटक देखाइएको छ ।",
|
||||
"pad.deletionToken.copy": "प्रतिलिपि गर्नुहोस्",
|
||||
"pad.deletionToken.copied": "प्रतिलिपि गरिएको",
|
||||
"pad.deletionToken.acknowledge": "मैले यसलाई सङ्ग्रह गरेको छु",
|
||||
"pad.deletionToken.deleteWithToken": "टोकनसँग प्याड मेट्नुहोस्",
|
||||
"pad.deletionToken.tokenFieldLabel": "प्याड मेट्ने टोकन",
|
||||
"pad.deletionToken.tokenValueLabel": "तपाईँको प्याड मेटाइ टोकन (पढ्ने-मात्र)",
|
||||
"pad.deletionToken.invalid": "त्यो टोकन यो प्याडका लागि वैध छैन ।",
|
||||
"pad.deletionToken.notCreator": "तपाईँ यो प्याडको सिर्जनाकर्ता हुनुहुन्न, त्यसैले तपाईँले यसलाई मेट्न सक्नुहुन्न ।",
|
||||
"pad.settings.about": "बारेमा",
|
||||
"pad.settings.poweredBy": "प्रवर्धक",
|
||||
"pad.importExport.import_export": "आयात/निर्यात",
|
||||
|
|
@ -59,18 +312,45 @@
|
|||
"pad.importExport.exportword": "माइक्रोसफ्ट वर्ड",
|
||||
"pad.importExport.exportpdf": "पिडिएफ",
|
||||
"pad.importExport.exportopen": "ओडिएफ(खुल्ला कागजात ढाँचा)",
|
||||
"pad.importExport.exportetherpada.title": "इथरप्याडको रूपमा निर्यात गर्नुहोस्",
|
||||
"pad.importExport.exporthtmla.title": "HTML को रूपमा निर्यात गर्नुहोस्",
|
||||
"pad.importExport.exportplaina.title": "सादा पाठको रूपमा निर्यात गर्नुहोस्",
|
||||
"pad.importExport.exportworda.title": "माइक्रोसफ्ट वर्डको रूपमा निर्यात गर्नुहोस्",
|
||||
"pad.importExport.exportpdfa.title": "PDF को रूपमा निर्यात गर्नुहोस्",
|
||||
"pad.importExport.exportopena.title": "ODF (खुला कागजात ढाँचा) को रूपमा निर्यात गर्नुहोस्",
|
||||
"pad.importExport.noConverter.innerHTML": "तपाईँले सादा पाठ, HTML, Microsoft Word (.docx) र Etherpad फाइलहरू सिधै आयात गर्न सक्नुहुन्छ । PDF, ODT, DOC वा RTF जस्ता अन्य ढाँचाहरू आयात गर्न, सर्भर प्रशासकले LibreOffice स्थापना गर्न आवश्यक छ - <a href=\"https://docs.etherpad.org/\">Etherpad मिसिलीकरण</a> हेर्नुहोस् ।",
|
||||
"pad.modals.connected": "जोडीएको।",
|
||||
"pad.modals.reconnecting": "तपाईंको प्याडमा पुन: जडान गर्दै",
|
||||
"pad.modals.forcereconnect": "जडानको लागि जोडगर्ने",
|
||||
"pad.modals.reconnecttimer": "पुन: जडान गर्न प्रयास गर्दै",
|
||||
"pad.modals.cancel": "रद्द गर्नुहोस्",
|
||||
"pad.modals.userdup": "अर्को सन्झ्यालमा खोल्ने",
|
||||
"pad.modals.userdup.explanation": "यो प्याड यस कम्प्युटरमा एक भन्दा बढी ब्राउजर सञ्झ्यालमा खोलिएको देखिन्छ ।",
|
||||
"pad.modals.userdup.advice": "सट्टामा यो विन्डो प्रयोग गर्न पुन: जडान गर्नुहोस् ।",
|
||||
"pad.modals.unauth": "अनुमती नदिइएको",
|
||||
"pad.modals.unauth.explanation": "यो पृष्ठ हेर्दा तपाईँको अनुमति परिवर्तन भएको छ। पुन: जडान गर्ने प्रयास गर्नुहोस्।",
|
||||
"pad.modals.looping.explanation": "त्यहाँ समक्रमण सर्भरसँग सञ्चार समस्याहरू छन्।",
|
||||
"pad.modals.looping.cause": "सायद तपाईँले अमिल्दो फायरवाल वा प्रोक्सी मार्फत जडान गर्नुभयो ।",
|
||||
"pad.modals.initsocketfail": "सर्भरमा पहुँच पुर्याउन सकिएन ।",
|
||||
"pad.modals.initsocketfail.explanation": "समक्रमण सर्भरमा जडान गर्न सकेन ।",
|
||||
"pad.modals.initsocketfail.cause": "यो सम्भवतः तपाईँको ब्राउजर वा इन्टरनेट जडानमा समस्या भएको कारणले हो।",
|
||||
"pad.modals.slowcommit.explanation": "सर्भरसँग सम्पर्क हुने सकेन ।",
|
||||
"pad.modals.slowcommit.cause": "यो नेटवर्क जडानमा समस्याको कारणले हुन सक्छ।",
|
||||
"pad.modals.badChangeset.explanation": "तपाईँले बनाउनु भएको सम्पादन समक्रमण सर्भरद्वारा अवैध वर्गीकरण गरिएको थियो ।",
|
||||
"pad.modals.badChangeset.cause": "यो गलत सर्भर कन्फिगरेसन वा केही अन्य अनपेक्षित व्यवहारको कारणले हुन सक्छ। यदि तपाईँलाई यो त्रुटि हो जस्तो लाग्छ भने, कृपया सेवा प्रशासकलाई सम्पर्क गर्नुहोस्। सम्पादन जारी राख्न पुन: जडान प्रयास गर्नुहोस्।",
|
||||
"pad.modals.corruptPad.explanation": "तपाईँले पहुँच गर्न खोज्नु भएको प्याड बिग्रिएको छ।",
|
||||
"pad.modals.corruptPad.cause": "यो गलत सर्भर कन्फिगरेसन वा केही अन्य अनपेक्षित व्यवहारको कारणले हुन सक्छ। कृपया सेवा प्रशासकलाई सम्पर्क गर्नुहोस्।",
|
||||
"pad.modals.deleted": "मेटिएको ।",
|
||||
"pad.modals.deleted.explanation": "यो प्याड हटाइसकेको छ ।",
|
||||
"pad.modals.rateLimited": "दर सिमित ।",
|
||||
"pad.modals.rateLimited.explanation": "तपाईँले यो प्याडमा धेरै सन्देशहरू पठाउनुभयो त्यसैले यसले तपाईँलाई विच्छेदन गर्यो।",
|
||||
"pad.modals.rejected.explanation": "सर्भरले तपाईँको ब्राउजरद्वारा पठाइएको सन्देश अस्वीकार गर्यो ।",
|
||||
"pad.modals.rejected.cause": "तपाईँले प्याड हेरिरहनुभएको बेलामा सर्भर अद्यावधिक भएको हुन सक्छ, वा त्यहाँ इथरप्याडमा बग हुन सक्छ । पृष्ठ पुन: लोड गर्ने प्रयास गर्नुहोस् ।",
|
||||
"pad.modals.disconnected": "तपाईंको जडान अवरुद्ध भयो ।",
|
||||
"pad.modals.disconnected.explanation": "तपाईंको सर्भरसँगको जडान अवरुद्ध भयो",
|
||||
"pad.modals.disconnected.cause": "सर्भर अनुपलब्ध हुन सक्छ । यदि यो हुन जारी छ भने कृपया सेवा प्रशासकलाई सूचित गर्नुहोस् ।",
|
||||
"pad.gritter.unacceptedCommit.title": "बचत नगरिएको सम्पादन",
|
||||
"pad.gritter.unacceptedCommit.text": "तपाईँको हालको सम्पादन अझै पनि बचत गरिएको छैन । पुन: जडान गर्नुहोस् र फेरि प्रयास गर्नुहोस् ।",
|
||||
"pad.share": "यस प्यडलाई बाड्ने",
|
||||
"pad.share.readonly": "पढ्ने मात्र",
|
||||
"pad.share.link": "कडी",
|
||||
|
|
@ -78,14 +358,41 @@
|
|||
"pad.chat": "कुराकानी",
|
||||
"pad.chat.title": "यस प्याडको लागि कुराकानी खोल्ने",
|
||||
"pad.chat.loadmessages": "थप सन्देशहरू खोल्ने",
|
||||
"pad.chat.stick.title": "स्क्रिनमा कुराकानी टाँस्नुहोस्",
|
||||
"pad.chat.writeMessage.placeholder": "तपाईँको सन्देश यहाँ लेख्नुहोस्",
|
||||
"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": "सङ्ग्रह गरिएको {{month}} {{day}}, {{year}}",
|
||||
"timeslider.settings.playbackSpeed": "प्लेब्याक गति:",
|
||||
"timeslider.settings.playbackSpeed.original": "मौलिक गति",
|
||||
"timeslider.settings.playbackSpeed.realtime": "वास्तविक समय",
|
||||
"timeslider.settings.playbackSpeed.200ms": "२०० ms",
|
||||
"timeslider.settings.playbackSpeed.500ms": "५०० ms",
|
||||
"timeslider.settings.playbackSpeed.1000ms": "१००० ms",
|
||||
"timeslider.playPause": "प्याडको सामग्रीहरूलाई चालु / बन्द गर्नुहोस्",
|
||||
"timeslider.backRevision": "यो प्याडको एक संस्करण पहिले जानुहोस्",
|
||||
"timeslider.forwardRevision": "यो प्याडको एक संस्करण पछि जानुहोस्",
|
||||
|
|
@ -104,11 +411,20 @@
|
|||
"timeslider.month.december": "डिसेम्बर",
|
||||
"timeslider.unnamedauthors": "{{num}} unnamed {[plural(num) one: author, other: authors ]}",
|
||||
"pad.savedrevs.marked": "यस संस्करणलाई संग्रहितको रुपमा चिनो लगाइएको छैन",
|
||||
"pad.savedrevs.timeslider": "समय स्लाइडरमा गएर तपाईँले बचत गरिएका संशोधनहरू हेर्न सक्नुहुन्छ",
|
||||
"pad.userlist.entername": "तपाईंको नाम लेख्नुहोस्",
|
||||
"pad.userlist.unnamed": "नाम नखुलाइएको",
|
||||
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} connected user, other: {{count}} connected users ]}",
|
||||
"pad.editbar.clearcolors": "सम्पूर्ण कागजातमा लेखकता रङ खाली गर्नुहोस्? यो पूर्वस्थितिमा फर्काउन सकिँदैन",
|
||||
"pad.impexp.importbutton": "अहिले आयात गर्ने",
|
||||
"pad.impexp.importing": "आयात गर्ने...",
|
||||
"pad.impexp.confirmimport": "फाइल आयात गर्दा प्याडको हालको पाठ अधिलेखन हुनेछ । तपाईँ अगाडि बढ्न निश्चित हुनुहुन्छ ?",
|
||||
"pad.impexp.convertFailed": "हामी यो फाइल आयात गर्न असक्षम भयौँ । कृपया फरक कागजात ढाँचा प्रयोग गर्नुहोस् वा म्यानुअली टाँस्नुहोस्",
|
||||
"pad.impexp.padHasData": "यो प्याडमा पहिले नै परिवर्तन भएकाले हामी यो फाइल आयात गर्न असक्षम भयौँ, कृपया नयाँ प्याडमा आयात गर्नुहोस्",
|
||||
"pad.impexp.uploadFailed": "अपलोड असफल भयो , कृपया पुन: प्रयास गर्नुहोस् ।",
|
||||
"pad.impexp.importfailed": "आयात असफल भयो",
|
||||
"pad.impexp.copypaste": "कृपया कपी पेस्ट गर्नुहोस"
|
||||
"pad.impexp.copypaste": "कृपया कपी पेस्ट गर्नुहोस",
|
||||
"pad.impexp.exportdisabled": "{{type}} ढाँचाको रूपमा निर्यात गर्न अक्षम पारिएको छ। कृपया विस्तृत जानकारीको लागि तपाईँको प्रणाली प्रशासकलाई सम्पर्क गर्नुहोस्।",
|
||||
"pad.impexp.maxFileSize": "फाइल अति ठूलो छ। आयातका लागि अनुमति प्राप्त फाइल साइज बढाउन आफ्नो साइट प्रशासकलाई सम्पर्क गर्नुहोस्",
|
||||
"pad.social.description": "सबैजनाले वास्तविक समयमा सम्पादन गर्न सक्ने सहयोगी कागजात ।"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -317,13 +317,13 @@
|
|||
"pad.importExport.exporthtml": "HTML",
|
||||
"pad.importExport.exportplain": "Tekst zonder opmaak",
|
||||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportpdf": "pdf",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.exportetherpada.title": "Exporteren als Etherpad",
|
||||
"pad.importExport.exporthtmla.title": "Exporteren als HTML",
|
||||
"pad.importExport.exportplaina.title": "Exporteren als platte tekst",
|
||||
"pad.importExport.exportworda.title": "Exporteren als Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Exporteren als PDF",
|
||||
"pad.importExport.exportpdfa.title": "Exporteren als pdf",
|
||||
"pad.importExport.exportopena.title": "Exporteren als ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "U kunt alleen importeren vanuit platte tekst of HTML-bestanden. Voor meer geavanceerde importfuncties kunt u <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">LibreOffice installeren</a>.",
|
||||
"pad.modals.connected": "Verbonden.",
|
||||
|
|
|
|||
|
|
@ -7,17 +7,85 @@
|
|||
"Mark",
|
||||
"Rudko",
|
||||
"Teslaton",
|
||||
"Wizzard",
|
||||
"Yardom78"
|
||||
]
|
||||
},
|
||||
"admin.page-title": "Ovládací panel správu - Etherpad",
|
||||
"admin.loading": "Načítava sa…",
|
||||
"admin.loading_description": "Počkajte, prosím, kým sa stránka načíta.",
|
||||
"admin.toggle_sidebar": "Prepnúť bočný panel",
|
||||
"admin.shout": "Komunikácia",
|
||||
"admin_shout.online_one": "Momentálne je online {{count}} používateľ",
|
||||
"admin_shout.online_other": "Momentálne je online {{count}} používateľov",
|
||||
"admin_shout.sticky_toggle": "Zmeniť pripnutú správu",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "Používateľské meno",
|
||||
"admin_login.password": "Heslo",
|
||||
"admin_login.submit": "Prihlásiť sa",
|
||||
"admin_login.failed": "Prihlásenie zlyhalo",
|
||||
"admin_pads.all_pads": "Všetky pady",
|
||||
"admin_pads.bulk.cleanup_history": "Vyčistiť históriu",
|
||||
"admin_pads.bulk.clear_selection": "Zrušiť výber",
|
||||
"admin_pads.bulk.delete": "Zmazať",
|
||||
"admin_pads.cancel": "Zrušiť",
|
||||
"admin_pads.col.pad": "Pad",
|
||||
"admin_pads.col.revisions": "Revízie",
|
||||
"admin_pads.col.users": "Používatelia",
|
||||
"admin_pads.confirm_button": "OK",
|
||||
"admin_pads.create_pad_dialog_description": "Zvoľte názov nového padu.",
|
||||
"admin_pads.delete_pad_dialog_description": "Potvrďte alebo zrušte zmazanie padu.",
|
||||
"admin_pads.delete_pad_dialog_title": "Zmazať pad",
|
||||
"admin_pads.empty_never_edited": "prázdny · nikdy neupravovaný",
|
||||
"admin_pads.error_dialog_description": "Vyskytla sa chyba.",
|
||||
"admin_pads.error_prefix": "Chyba",
|
||||
"admin_pads.filter.active": "Aktívne",
|
||||
"admin_pads.filter.all": "Všetky",
|
||||
"admin_pads.filter.empty": "Prázdne",
|
||||
"admin_pads.filter.recent": "Tento týždeň",
|
||||
"admin_pads.filter.stale": "Neaktívne (>1 r.)",
|
||||
"admin_pads.open": "Otvoriť",
|
||||
"admin_pads.pagination.next": "Ďalej",
|
||||
"admin_pads.pagination.previous": "Späť",
|
||||
"admin_pads.refresh": "Obnoviť",
|
||||
"admin_pads.relative.days": "pred {{count}} d",
|
||||
"admin_pads.relative.hours": "pred {{count}} h",
|
||||
"admin_pads.relative.just_now": "práve teraz",
|
||||
"admin_pads.relative.minutes": "pred {{count}} min",
|
||||
"admin_pads.relative.months": "pred {{count}} mes.",
|
||||
"admin_pads.relative.weeks": "pred {{count}} týž.",
|
||||
"admin_pads.relative.years": "pred {{count}} r.",
|
||||
"admin_pads.revisions_count": "{{count}} revízií",
|
||||
"admin_pads.selected_count": "Vybraté: {{count}}",
|
||||
"admin_pads.show": "Zobraziť",
|
||||
"admin_pads.sort.name": "Názov (A – Z)",
|
||||
"admin_pads.sort.revision_number": "Revízie",
|
||||
"admin_pads.sort.user_count": "Používatelia",
|
||||
"admin_pads.stats.across_pads": "vo všetkých padoch",
|
||||
"admin_pads.stats.active_users": "Aktívni používatelia",
|
||||
"admin_pads.stats.empty_pads": "Prázdne pady",
|
||||
"admin_pads.stats.last_activity": "Posledná aktivita",
|
||||
"admin_pads.stats.no_active_users": "Žiadni aktívni používatelia",
|
||||
"admin_pads.stats.revisions_zero": "0 revízií",
|
||||
"admin_pads.stats.total": "Pady spolu",
|
||||
"admin_pads.stats.users_active": "Momentálne aktívnych: {{count}}",
|
||||
"admin_pads.subtitle": "Prehľad všetkých padov na tejto inštancii Etherpadu. Vyhľadávajte, čistite, otvárajte.",
|
||||
"admin_plugins": "Správca doplnkov",
|
||||
"admin_plugins.available": "Dostupné doplnky",
|
||||
"admin_plugins.available_not-found": "Doplnky neboli nájdené.",
|
||||
"admin_plugins.available_fetching": "Načítavanie...",
|
||||
"admin_plugins.available_install.value": "Inštalovať",
|
||||
"admin_plugins.available_search.placeholder": "Vyhľadať doplnky na inštaláciu",
|
||||
"admin_plugins.check_updates": "Skontrolovať aktualizácie",
|
||||
"admin_plugins.core_count": "{{count}} základných",
|
||||
"admin_plugins.catalog_disabled": "Katalóg zásuvných modulov je vypnutý vaším prevádzkovateľom (privacy.pluginCatalog=false). Zásuvný modul nainštalujete spustením `pnpm run plugins i ep_<name>` na serveri.",
|
||||
"admin_plugins.crumbs": "Zásuvné moduly",
|
||||
"admin_plugins.description": "Popis",
|
||||
"admin_plugins.disables.label": "Vypína:",
|
||||
"admin_plugins.disables.warning_title": "Tento zásuvný modul zámerne odstraňuje uvedené funkcie Etherpadu.",
|
||||
"admin_plugins.error_retrieving": "Chyba pri získavaní zásuvných modulov",
|
||||
"admin_plugins.install_error": "Nepodarilo sa nainštalovať {{plugin}}: {{error}}",
|
||||
"admin_plugins.install_error_requires_newer_etherpad": "Nemožno nainštalovať {{plugin}}: vyžaduje novšiu verziu Etherpadu. Aktualizujte Etherpad a skúste to znova.",
|
||||
"admin_plugins.installed": "Nainštalované doplnky",
|
||||
"admin_plugins.installed_fetching": "Načítavanie nainštalovaných doplnkov...",
|
||||
"admin_plugins.installed_nothing": "Ešte ste nenainštalovali žiadne doplnky.",
|
||||
|
|
@ -25,27 +93,161 @@
|
|||
"admin_plugins.last-update": "Posledná aktualizácia",
|
||||
"admin_plugins.name": "Názov",
|
||||
"admin_plugins.page-title": "Správca doplnkov - Etherpad",
|
||||
"admin_plugins.reload_catalog": "Znovu načítať katalóg",
|
||||
"admin_plugins.search_npm": "Hľadať na npm",
|
||||
"admin_plugins.sort_ascending": "Zoradiť vzostupne",
|
||||
"admin_plugins.sort_descending": "Zoradiť zostupne",
|
||||
"admin_plugins.sort.last_updated": "Naposledy aktualizované",
|
||||
"admin_plugins.sort.name": "Názov (A – Z)",
|
||||
"admin_plugins.sort.version": "Verzia",
|
||||
"admin_plugins.source": "Zdroj zásuvného modulu",
|
||||
"admin_plugins.subtitle": "Inštalujte, aktualizujte a odstraňujte zásuvné moduly Etherpadu. Zmeny si vyžadujú reštart servera.",
|
||||
"admin_plugins.tag_core": "Základné",
|
||||
"admin_plugins.update_tooltip": "Aktualizovať",
|
||||
"admin_plugins.updates_available": "Dostupné aktualizácie",
|
||||
"admin_plugins.update_now": "Aktualizovať",
|
||||
"admin_plugins.version": "Verzia",
|
||||
"admin_plugins_info": "Informácie k riešeniu problémov",
|
||||
"admin_plugins_info.bindings_label": "{{count}} väzieb",
|
||||
"admin_plugins_info.copy_diagnostics": "Kopírovať diagnostiku",
|
||||
"admin_plugins_info.copy_value": "Kopírovať {{label}}",
|
||||
"admin_plugins_info.git_sha": "Git SHA",
|
||||
"admin_plugins_info.hook_bindings": "Väzby hookov",
|
||||
"admin_plugins_info.hooks": "Nainštalované súčasti",
|
||||
"admin_plugins_info.hooks_client": "Súčasti na strane klienta",
|
||||
"admin_plugins_info.hooks_server": "Súčasti na strane servera",
|
||||
"admin_plugins_info.no_hooks": "Nenašli sa žiadne hooky",
|
||||
"admin_plugins_info.parts": "Nainštalované súčasti",
|
||||
"admin_plugins_info.plugins": "Nainštalované doplnky",
|
||||
"admin_plugins_info.page-title": "Informácie o doplnkoch - Etherpad",
|
||||
"admin_plugins_info.search_placeholder": "Hľadať hook alebo časť…",
|
||||
"admin_plugins_info.subtitle": "Diagnostika systému: nainštalovaná verzia, registrované časti a hooky.",
|
||||
"admin_plugins_info.tab_client": "Klient",
|
||||
"admin_plugins_info.tab_server": "Server",
|
||||
"admin_plugins_info.up_to_date": "Aktuálne",
|
||||
"admin_plugins_info.update_available": "Dostupná aktualizácia: {{version}}",
|
||||
"admin_plugins_info.version": "Verzia Etherpadu",
|
||||
"admin_plugins_info.version_latest": "Posledná dostupná verzia",
|
||||
"admin_plugins_info.version_number": "Číslo verzie",
|
||||
"admin_settings": "Nastavenia",
|
||||
"admin_settings.create_pad": "Vytvoriť pad",
|
||||
"admin_settings.current": "Aktuálne nastavenia",
|
||||
"admin_settings.current_example-devel": "Príklad šablóny vývojárskeho nastavenia",
|
||||
"admin_settings.current_example-prod": "Príklad šablóny výrobného nastavenia",
|
||||
"admin_settings.current_restart.value": "Reštartovať Etherpad",
|
||||
"admin_settings.current_save.value": "Uložiť nastavenia",
|
||||
"admin_settings.invalid_json": "Neplatný JSON",
|
||||
"admin_settings.current_test.value": "Overiť JSON",
|
||||
"admin_settings.current_prettify.value": "Sformátovať JSON",
|
||||
"admin_settings.toast.saved": "Nastavenia boli úspešne uložené.",
|
||||
"admin_settings.toast.save_failed": "Uloženie zlyhalo: súbor settings.json sa nepodarilo zapísať.",
|
||||
"admin_settings.toast.json_invalid": "Syntaktická chyba: skontrolujte čiarky, zátvorky a úvodzovky.",
|
||||
"admin_settings.toast.disconnected": "Nemožno uložiť: bez pripojenia k serveru.",
|
||||
"admin_settings.toast.validation_ok": "JSON je platný.",
|
||||
"admin_settings.toast.validation_failed": "JSON je neplatný: opravte syntaktické chyby.",
|
||||
"admin_settings.toast.prettify_failed": "Nemožno sformátovať: najprv opravte syntaktické chyby.",
|
||||
"admin_settings.prettify_confirm": "Formátovaním sa odstránia všetky komentáre. Pokračovať?",
|
||||
"admin_settings.mode.form": "Formulár",
|
||||
"admin_settings.mode.raw": "Surový",
|
||||
"admin_settings.mode.effective": "Efektívny",
|
||||
"admin_settings.mode.effective_tooltip": "Zobrazenie hodnôt, ktoré Etherpad práve teraz skutočne používa (po dosadení premenných prostredia), iba na čítanie. Tajné hodnoty sú skryté.",
|
||||
"admin_settings.mode.aria_label": "Režim editora",
|
||||
"admin_settings.envvar_banner.title": "Tento súbor je šablóna, nie živá konfigurácia.",
|
||||
"admin_settings.envvar_banner.body": "Zástupné výrazy ako ${VAR:default} sa pri štarte dosadia do pamäte; nikdy sa nezapisujú späť do tohto súboru. Ak chcete zmeniť výslednú hodnotu, upravte premenné prostredia vo svojom prostredí (Docker compose, systemd, .env), alebo tu nahraďte zástupný výraz konkrétnou hodnotou. Prepnutím na kartu Efektívny zistíte, čo Etherpad práve používa.",
|
||||
"admin_settings.toast.auth_error": "Nie ste overený ako správca. Prihláste sa, prosím, znova.",
|
||||
"admin_settings.section.general": "Všeobecné",
|
||||
"admin_settings.parse_error.title": "Nemožno spracovať settings.json",
|
||||
"admin_settings.parse_error.cta": "Prepnite na surový režim na úpravu",
|
||||
"admin_settings.env_pill.tooltip": "Číta sa z premennej prostredia {{variable}}. Hodnota nižšie sa použije, keď {{variable}} nie je nastavená.",
|
||||
"admin_settings.env_pill.default_label": "predvolené",
|
||||
"admin_settings.env_pill.input_aria": "Predvolená hodnota pre {{variable}}",
|
||||
"admin_settings.env_pill.runtime_label": "aktívna hodnota",
|
||||
"admin_settings.env_pill.runtime_tooltip": "Etherpad práve používa túto hodnotu, určenú z {{variable}} alebo jej predvolenej hodnoty.",
|
||||
"admin_settings.env_pill.redacted_tooltip": "Etherpad používa hodnotu pre {{variable}}, je však skrytá, pretože ide o tajnú hodnotu.",
|
||||
"admin_settings.page-title": "Nastavenia - Etherpad",
|
||||
"admin_settings.save_error": "Chyba pri ukladaní nastavení",
|
||||
"admin_settings.saved_success": "Nastavenia boli úspešne uložené",
|
||||
"update.banner.title": "Dostupná aktualizácia",
|
||||
"update.banner.body": "Je dostupný Etherpad {{latest}} (používate {{current}}).",
|
||||
"update.banner.cta": "Zobraziť aktualizáciu",
|
||||
"update.page.title": "Aktualizácie Etherpadu",
|
||||
"update.page.current": "Aktuálna verzia",
|
||||
"update.page.latest": "Najnovšia verzia",
|
||||
"update.page.last_check": "Naposledy skontrolované",
|
||||
"update.page.install_method": "Spôsob inštalácie",
|
||||
"update.page.tier": "Úroveň aktualizácií",
|
||||
"update.page.changelog": "Zoznam zmien",
|
||||
"update.page.up_to_date": "Používate najnovšiu verziu.",
|
||||
"update.page.disabled": "Kontroly aktualizácií sú vypnuté (updates.tier = „off“).",
|
||||
"update.page.unauthorized": "Nemáte oprávnenie zobraziť stav aktualizácií.",
|
||||
"update.page.error": "Nepodarilo sa načítať stav aktualizácií (stav {{status}}).",
|
||||
"update.badge.severe": "Etherpad na tomto serveri je vážne zastaraný. Upozornite správcu.",
|
||||
"update.badge.vulnerable": "Etherpad na tomto serveri používa verziu so známymi bezpečnostnými problémami. Upozornite správcu.",
|
||||
"update.page.apply": "Použiť aktualizáciu",
|
||||
"update.page.cancel": "Zrušiť",
|
||||
"update.page.acknowledge": "Potvrdiť",
|
||||
"update.page.log": "Záznam aktualizácie (posledných 200 riadkov)",
|
||||
"update.page.execution": "Stav",
|
||||
"update.page.policy.install-method-not-writable": "Aktualizácie zo správcovského rozhrania vyžadujú inštaláciu cez git. Aktualizujte cez svojho správcu balíkov.",
|
||||
"update.page.policy.rollback-failed-terminal": "Predchádzajúca aktualizácia zlyhala a nepodarilo sa ju vrátiť späť. Po uvedení inštalácie do poriadku stlačte Potvrdiť, čím sa zámok zruší.",
|
||||
"update.page.policy.up-to-date": "Používate najnovšiu verziu.",
|
||||
"update.page.policy.tier-off": "Aktualizácie sú vypnuté (updates.tier = „off“).",
|
||||
"update.page.policy.maintenance-window-missing": "Úroveň 4 (autonómna) vyžaduje okno údržby. Autonómne aktualizácie zapnete nastavením updates.maintenanceWindow v settings.json.",
|
||||
"update.page.policy.maintenance-window-invalid": "Úroveň 4 (autonómna) je vypnutá, pretože updates.maintenanceWindow má nesprávny formát. Očakáva sa {start, end, tz} s časmi vo formáte HH:MM a tz „local“ alebo „utc“.",
|
||||
"update.page.last_result.verified": "Posledná aktualizácia na {{tag}} bola overená.",
|
||||
"update.page.last_result.rolled-back": "Posledná pokusná aktualizácia na {{tag}} bola vrátená späť: {{reason}}.",
|
||||
"update.page.last_result.rollback-failed": "Posledný pokus o aktualizáciu zlyhal A zlyhalo aj vrátenie späť: {{reason}}. Vyžaduje sa ručný zásah.",
|
||||
"update.page.last_result.preflight-failed": "Posledná pokusná aktualizácia na {{tag}} zlyhala pri kontrole pred spustením: {{reason}}.",
|
||||
"update.page.last_result.cancelled": "Poslednú pokusnú aktualizáciu na {{tag}} zrušil správca.",
|
||||
"update.execution.idle": "Nečinné",
|
||||
"update.execution.scheduled": "Aktualizácia naplánovaná",
|
||||
"update.execution.preflight": "Kontroly pred spustením",
|
||||
"update.execution.preflight-failed": "Kontrola pred spustením zlyhala",
|
||||
"update.execution.draining": "Ukončovanie relácií",
|
||||
"update.execution.executing": "Aktualizuje sa…",
|
||||
"update.execution.pending-verification": "Čaká sa na overenie",
|
||||
"update.execution.verified": "Overené",
|
||||
"update.execution.rolling-back": "Vracia sa späť",
|
||||
"update.execution.rolled-back": "Vrátené späť",
|
||||
"update.execution.rollback-failed": "Vrátenie späť zlyhalo",
|
||||
"update.banner.terminal.rollback-failed": "Pokus o aktualizáciu zlyhal a nepodarilo sa ho vrátiť späť. Vyžaduje sa ručný zásah.",
|
||||
"update.banner.scheduled": "Automatická aktualizácia na {{tag}} je naplánovaná — použije sa o {{remaining}}.",
|
||||
"update.banner.maintenance-window-missing": "Autonómne aktualizácie sú vypnuté, kým sa nenastaví okno údržby.",
|
||||
"update.banner.maintenance-window-invalid": "Autonómne aktualizácie sú vypnuté, pretože okno údržby má nesprávny formát.",
|
||||
"update.page.scheduled.title": "Aktualizácia naplánovaná",
|
||||
"update.page.scheduled.countdown": "Etherpad sa začne aktualizovať na {{tag}} o {{remaining}}.",
|
||||
"update.page.scheduled.deferred_until": "Mimo okna údržby. Aktualizácia sa spustí, keď sa okno otvorí o {{at}}.",
|
||||
"update.page.scheduled.apply_now": "Použiť teraz",
|
||||
"update.window.title": "Okno údržby",
|
||||
"update.window.summary": "{{start}} – {{end}} ({{tz}})",
|
||||
"update.window.unset": "Nie je nastavené.",
|
||||
"update.window.next_opens_at": "Ďalšie okno sa otvorí o {{at}}.",
|
||||
"update.drain.t60": "Etherpad sa o 60 sekúnd reštartuje, aby použil aktualizáciu.",
|
||||
"update.drain.t30": "Etherpad sa o 30 sekúnd reštartuje, aby použil aktualizáciu.",
|
||||
"update.drain.t10": "Etherpad sa o 10 sekúnd reštartuje, aby použil aktualizáciu.",
|
||||
"index.newPad": "Nový poznámkový blok",
|
||||
"index.createOpenPad": "alebo vytvoriť/otvoriť poznámkový blok s názvom:",
|
||||
"index.settings": "Nastavenia",
|
||||
"index.transferSessionTitle": "Preniesť reláciu",
|
||||
"index.receiveSessionTitle": "Prijať reláciu",
|
||||
"index.receiveSessionDescription": "Tu môžete prijať reláciu Etherpadu z iného prehliadača alebo zariadenia. Upozorňujeme však, že tým sa odstráni vaša súčasná relácia, ak nejakú máte.",
|
||||
"index.transferSession": "1. Preniesť reláciu",
|
||||
"index.transferSessionNow": "Preniesť reláciu teraz",
|
||||
"index.copyLink": "2. Kopírovať odkaz",
|
||||
"index.copyLinkDescription": "Kliknutím na tlačidlo nižšie skopírujete odkaz do schránky.",
|
||||
"index.copyLinkButton": "Kopírovať odkaz do schránky",
|
||||
"index.transferToSystem": "3. Skopírovať reláciu do nového systému",
|
||||
"index.transferToSystemDescription": "Otvorením skopírovaného odkazu v cieľovom prehliadači alebo zariadení prenesiete svoju reláciu.",
|
||||
"index.code": "Kód",
|
||||
"index.transferSessionDescription": "Kliknutím na tlačidlo nižšie prenesiete svoju súčasnú reláciu do prehliadača alebo zariadenia. Skopíruje sa odkaz na stránku, ktorá po otvorení v cieľovom prehliadači alebo zariadení prenesie vašu reláciu.",
|
||||
"index.createOpenPad": "Otvoriť pad podľa názvu",
|
||||
"index.openPad": "otvoriť poznámkový blok s názvom:",
|
||||
"index.recentPads": "Nedávne pady",
|
||||
"index.recentPadsEmpty": "Nenašli sa žiadne nedávne pady.",
|
||||
"index.generateNewPad": "Vygenerovať náhodný názov padu",
|
||||
"index.labelPad": "Názov padu (nepovinné)",
|
||||
"index.placeholderPadEnter": "Zadajte názov padu…",
|
||||
"index.createAndShareDocuments": "Vytvárajte a zdieľajte dokumenty v reálnom čase",
|
||||
"index.createAndShareDocumentsDescription": "Etherpad vám umožňuje spoločne upravovať dokumenty v reálnom čase, podobne ako živý viacpoužívateľský editor, ktorý beží vo vašom prehliadači.",
|
||||
"pad.toolbar.bold.title": "Tučné (Ctrl+B)",
|
||||
"pad.toolbar.italic.title": "Kurzíva (Ctrl+I)",
|
||||
"pad.toolbar.underline.title": "Podčiarknuté (Ctrl+U)",
|
||||
|
|
@ -62,22 +264,42 @@
|
|||
"pad.toolbar.savedRevision.title": "Uložiť revíziu",
|
||||
"pad.toolbar.settings.title": "Nastavenia",
|
||||
"pad.toolbar.embed.title": "Zdieľať alebo vložiť tento poznámkový blok",
|
||||
"pad.toolbar.home.title": "Späť na domovskú stránku",
|
||||
"pad.toolbar.showusers.title": "Zobraziť používateľov tohoto poznámkového bloku",
|
||||
"pad.colorpicker.save": "Uložiť",
|
||||
"pad.colorpicker.cancel": "Zrušiť",
|
||||
"pad.loading": "Načítava sa...",
|
||||
"pad.noCookie": "Cookie nebolo možné nájsť. Povoľte prosím cookies vo vašom prehliadači. Vaše sedenie a nastavenia sa medzi návštevami stránky neuložia. To môže byť spôsobené tým že Etherpad je zahrnutý do iFrame v niektorých prehliadačoch. Prosím uistite sa, že Etherpad sa nachádza na tej istej doméne ako hlavný iFrame",
|
||||
"pad.permissionDenied": "Ľutujeme, nemáte oprávnenie pristupovať k tomuto poznámkovému bloku",
|
||||
"pad.settings.padSettings": "Nastavenia poznámkového bloku",
|
||||
"pad.settings.title": "Nastavenia",
|
||||
"pad.settings.padSettings": "Nastavenia pre celý pad",
|
||||
"pad.settings.userSettings": "Používateľské nastavenia",
|
||||
"pad.settings.myView": "Vlastný pohľad",
|
||||
"pad.settings.disablechat": "Vypnúť chat",
|
||||
"pad.settings.darkMode": "Tmavý režim",
|
||||
"pad.settings.stickychat": "Rozhovor stále na obrazovke",
|
||||
"pad.settings.chatandusers": "Zobraziť rozhovor a používateľov",
|
||||
"pad.settings.colorcheck": "Farby autorov",
|
||||
"pad.settings.fadeInactiveAuthorColors": "Stlmiť farby neaktívnych autorov",
|
||||
"pad.settings.linenocheck": "Čísla riadkov",
|
||||
"pad.settings.rtlcheck": "Čítať obsah sprava doľava?",
|
||||
"pad.settings.enforceSettings": "Vynútiť nastavenia pre ostatných používateľov",
|
||||
"pad.settings.enforcedNotice": "Tieto nastavenia vám uzamkol tvorca tohto padu. Ak ich potrebujete zmeniť, požiadajte tvorcu padu.",
|
||||
"pad.settings.fontType": "Typ písma:",
|
||||
"pad.settings.fontType.normal": "Normálne",
|
||||
"pad.settings.language": "Jazyk:",
|
||||
"pad.settings.deletePad": "Zmazať pad",
|
||||
"pad.delete.confirm": "Naozaj chcete zmazať tento pad?",
|
||||
"pad.deletionToken.modalTitle": "Uložte si token na zmazanie padu",
|
||||
"pad.deletionToken.modalBody": "Tento token je jediný spôsob, ako zmazať tento pad, ak stratíte reláciu prehliadača alebo zmeníte zariadenie. Uložte si ho na bezpečné miesto — zobrazí sa tu iba raz.",
|
||||
"pad.deletionToken.copy": "Kopírovať",
|
||||
"pad.deletionToken.copied": "Skopírované",
|
||||
"pad.deletionToken.acknowledge": "Uložil som si ho",
|
||||
"pad.deletionToken.deleteWithToken": "Zmazať pad pomocou tokenu",
|
||||
"pad.deletionToken.tokenFieldLabel": "Token na zmazanie padu",
|
||||
"pad.deletionToken.tokenValueLabel": "Váš token na zmazanie padu (iba na čítanie)",
|
||||
"pad.deletionToken.invalid": "Tento token nie je platný pre tento pad.",
|
||||
"pad.deletionToken.notCreator": "Nie ste tvorcom tohto padu, takže ho nemôžete zmazať.",
|
||||
"pad.settings.about": "O Etherpade",
|
||||
"pad.settings.poweredBy": "Poháňané cez",
|
||||
"pad.importExport.import_export": "Import/Export",
|
||||
|
|
@ -90,6 +312,13 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
||||
"pad.importExport.exportetherpada.title": "Exportovať ako Etherpad",
|
||||
"pad.importExport.exporthtmla.title": "Exportovať ako HTML",
|
||||
"pad.importExport.exportplaina.title": "Exportovať ako čistý text",
|
||||
"pad.importExport.exportworda.title": "Exportovať ako Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "Exportovať ako PDF",
|
||||
"pad.importExport.exportopena.title": "Exportovať ako ODF (Open Document Format)",
|
||||
"pad.importExport.noConverter.innerHTML": "Importovať môžete iba z formátov čistého textu alebo HTML. Pre pokročilejšie funkcie importu si <a href=\"https://github.com/ether/etherpad-lite/wiki/How-to-enable-importing-and-exporting-different-file-formats-with-AbiWord\">nainštalujte LibreOffice</a>.",
|
||||
"pad.modals.connected": "Pripojené.",
|
||||
"pad.modals.reconnecting": "Opätovné pripájanie k vášmu poznámkovému bloku...",
|
||||
"pad.modals.forcereconnect": "Vynútiť znovupripojenie",
|
||||
|
|
@ -120,6 +349,8 @@
|
|||
"pad.modals.disconnected": "Boli ste odpojení.",
|
||||
"pad.modals.disconnected.explanation": "Spojenie so serverom sa prerušilo",
|
||||
"pad.modals.disconnected.cause": "Server môže byť nedostupný. Ak by problém pretrvával, informujte správcu služby.",
|
||||
"pad.gritter.unacceptedCommit.title": "Neuložená úprava",
|
||||
"pad.gritter.unacceptedCommit.text": "Vaša nedávna úprava stále nie je uložená. Znova sa pripojte a skúste to ešte raz.",
|
||||
"pad.share": "Zdieľať tento poznámkový blok",
|
||||
"pad.share.readonly": "Len na čítanie",
|
||||
"pad.share.link": "Odkaz",
|
||||
|
|
@ -132,12 +363,36 @@
|
|||
"timeslider.followContents": "Sledovať aktualizácie obsahu poznámkového bloku",
|
||||
"timeslider.pageTitle": "Časová os {{appTitle}}",
|
||||
"timeslider.toolbar.returnbutton": "Späť do poznámkového bloku",
|
||||
"pad.historyMode.banner": "Prezeranie histórie",
|
||||
"pad.historyMode.return": "Návrat k živej verzii",
|
||||
"pad.historyMode.revisionLabel": "Revízia {{rev}}",
|
||||
"pad.historyMode.controlsLabel": "Ovládanie histórie padu",
|
||||
"pad.historyMode.sliderLabel": "Revízia padu",
|
||||
"pad.historyMode.settings.title": "Prehrávanie histórie",
|
||||
"pad.historyMode.settings.follow": "Sledovať zmeny obsahu padu",
|
||||
"pad.historyMode.settings.followShort": "Sledovať",
|
||||
"pad.historyMode.followOn": "Sledujú sa zmeny padu — kliknutím sledovanie ukončíte",
|
||||
"pad.historyMode.followOff": "Zmeny padu sa nesledujú — kliknutím začnete sledovať",
|
||||
"pad.historyMode.settings.playbackSpeed": "Rýchlosť prehrávania:",
|
||||
"pad.historyMode.chat.replayHeader": "Chat k {{time}}",
|
||||
"pad.historyMode.users.authorsHeader": "Autori v tejto revízii",
|
||||
"pad.editor.skipToContent": "Preskočiť na editor",
|
||||
"pad.editor.keyboardHint": "Stlačením Escape opustíte editor. Stlačením Alt+F9 sa dostanete na panel nástrojov.",
|
||||
"pad.editor.toolbar.formatting": "Panel nástrojov formátovania",
|
||||
"pad.editor.toolbar.actions": "Panel akcií padu",
|
||||
"pad.editor.toolbar.showMore": "Zobraziť viac tlačidiel panela",
|
||||
"timeslider.toolbar.authors": "Autori:",
|
||||
"timeslider.toolbar.authorsList": "Bez autorov",
|
||||
"timeslider.toolbar.exportlink.title": "Export",
|
||||
"timeslider.exportCurrent": "Exportovať aktuálnu verziu ako:",
|
||||
"timeslider.version": "Verzia {{version}}",
|
||||
"timeslider.saved": "Uložené {{day}}. {{month}} {{year}}",
|
||||
"timeslider.settings.playbackSpeed": "Rýchlosť prehrávania:",
|
||||
"timeslider.settings.playbackSpeed.original": "Pôvodná rýchlosť",
|
||||
"timeslider.settings.playbackSpeed.realtime": "Reálny čas",
|
||||
"timeslider.settings.playbackSpeed.200ms": "200 ms",
|
||||
"timeslider.settings.playbackSpeed.500ms": "500 ms",
|
||||
"timeslider.settings.playbackSpeed.1000ms": "1000 ms",
|
||||
"timeslider.playPause": "Pustiť / Pozastaviť obsah poznámkového bloku",
|
||||
"timeslider.backRevision": "Ísť v tomto poznámkovom bloku o jednu revíziu späť",
|
||||
"timeslider.forwardRevision": "Ísť v tomto poznámkovom bloku o jednu revíziu vpred",
|
||||
|
|
@ -159,6 +414,7 @@
|
|||
"pad.savedrevs.timeslider": "Návštevou časovej osi môžete zobraziť uložené revízie",
|
||||
"pad.userlist.entername": "Zadajte svoje meno",
|
||||
"pad.userlist.unnamed": "nemenovaný",
|
||||
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} pripojený používateľ, few: {{count}} pripojení používatelia, many: {{count}} pripojených používateľov, other: {{count}} pripojených používateľov ]}",
|
||||
"pad.editbar.clearcolors": "Odstrániť farby autorov z celého dokumentu? Táto akcia sa nedá vrátiť",
|
||||
"pad.impexp.importbutton": "Importovať teraz",
|
||||
"pad.impexp.importing": "Prebieha import...",
|
||||
|
|
@ -169,5 +425,6 @@
|
|||
"pad.impexp.importfailed": "Import zlyhal",
|
||||
"pad.impexp.copypaste": "Vložte prosím kópiu cez schránku",
|
||||
"pad.impexp.exportdisabled": "Export do formátu {{type}} nie je povolený. Kontaktujte prosím administrátora pre zistenie detailov.",
|
||||
"pad.impexp.maxFileSize": "Súbor je príliš veľký. Kontaktujte správcu pre zväčšenie povolenej veľkosti súborov pre import"
|
||||
"pad.impexp.maxFileSize": "Súbor je príliš veľký. Kontaktujte správcu pre zväčšenie povolenej veľkosti súborov pre import",
|
||||
"pad.social.description": "Kolaboratívny dokument, ktorý môže každý upravovať v reálnom čase."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
"Stang",
|
||||
"TFX202X",
|
||||
"VulpesVulpes825",
|
||||
"XtexChooser",
|
||||
"Yfdyh000",
|
||||
"乌拉跨氪",
|
||||
"列维劳德",
|
||||
|
|
@ -26,6 +27,30 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "管理员面板 - Etherpad",
|
||||
"admin.loading": "正在加载…",
|
||||
"admin.loading_description": "页面加载中,请稍安勿躁。",
|
||||
"admin.toggle_sidebar": "开关侧边栏",
|
||||
"admin.shout": "通讯",
|
||||
"admin_shout.online_one": "目前有{{count}}位用户在线",
|
||||
"admin_shout.online_other": "目前有{{count}}位用户在线",
|
||||
"admin_shout.sticky_toggle": "更改置顶消息",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "用户名",
|
||||
"admin_login.password": "密码",
|
||||
"admin_login.submit": "登录",
|
||||
"admin_login.failed": "登录失败",
|
||||
"admin_pads.all_pads": "所有记事本",
|
||||
"admin_pads.bulk.cleanup_history": "清理历史",
|
||||
"admin_pads.bulk.clear_selection": "清除选择",
|
||||
"admin_pads.bulk.delete": "删除",
|
||||
"admin_pads.cancel": "取消",
|
||||
"admin_pads.col.users": "用户",
|
||||
"admin_pads.confirm_button": "确定",
|
||||
"admin_pads.error_prefix": "错误",
|
||||
"admin_pads.pagination.next": "下一页",
|
||||
"admin_pads.pagination.previous": "上一页",
|
||||
"admin_pads.refresh": "刷新",
|
||||
"admin_pads.relative.just_now": "刚刚",
|
||||
"admin_plugins": "插件管理器",
|
||||
"admin_plugins.available": "可用插件",
|
||||
"admin_plugins.available_not-found": "找不到插件。",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,16 @@
|
|||
]
|
||||
},
|
||||
"admin.page-title": "管理員面板 - Etherpad",
|
||||
"admin.loading": "載入中…",
|
||||
"admin_login.title": "Etherpad",
|
||||
"admin_login.username": "使用者名稱",
|
||||
"admin_login.password": "密碼",
|
||||
"admin_login.submit": "登入",
|
||||
"admin_login.failed": "登入失敗",
|
||||
"admin_pads.bulk.delete": "刪除",
|
||||
"admin_pads.cancel": "取消",
|
||||
"admin_pads.col.users": "使用者",
|
||||
"admin_pads.error_prefix": "錯誤",
|
||||
"admin_plugins": "外掛程式管理器",
|
||||
"admin_plugins.available": "可用套件",
|
||||
"admin_plugins.available_not-found": "沒有找到套件。",
|
||||
|
|
@ -29,6 +39,9 @@
|
|||
"admin_plugins.last-update": "最後更新",
|
||||
"admin_plugins.name": "名稱",
|
||||
"admin_plugins.page-title": "套件管理 - Etherpad",
|
||||
"admin_plugins.update_tooltip": "更新",
|
||||
"admin_plugins.updates_available": "有可用更新",
|
||||
"admin_plugins.update_now": "更新",
|
||||
"admin_plugins.version": "版本",
|
||||
"admin_plugins_info": "問題排除資訊",
|
||||
"admin_plugins_info.hooks": "已安裝的掛勾",
|
||||
|
|
@ -37,6 +50,8 @@
|
|||
"admin_plugins_info.parts": "已安裝部分",
|
||||
"admin_plugins_info.plugins": "已安裝的套件",
|
||||
"admin_plugins_info.page-title": "套件資訊 - Etherpad",
|
||||
"admin_plugins_info.tab_client": "客戶端",
|
||||
"admin_plugins_info.tab_server": "伺服器",
|
||||
"admin_plugins_info.version": "Etherpad 版本",
|
||||
"admin_plugins_info.version_latest": "最新可用版本",
|
||||
"admin_plugins_info.version_number": "版本號",
|
||||
|
|
@ -46,6 +61,9 @@
|
|||
"admin_settings.current_example-prod": "生產設定模板範例",
|
||||
"admin_settings.current_restart.value": "重新啟動 Etherpad",
|
||||
"admin_settings.current_save.value": "儲存設定",
|
||||
"admin_settings.invalid_json": "無效 JSON",
|
||||
"admin_settings.current_test.value": "驗證 JSON",
|
||||
"admin_settings.current_prettify.value": "美化 JSON 格式",
|
||||
"admin_settings.page-title": "設定 - Etherpad",
|
||||
"index.newPad": "新記事本",
|
||||
"index.settings": "設定",
|
||||
|
|
@ -116,6 +134,11 @@
|
|||
"pad.importExport.exportword": "Microsoft Word",
|
||||
"pad.importExport.exportpdf": "PDF",
|
||||
"pad.importExport.exportopen": "ODF(開放文件格式)",
|
||||
"pad.importExport.exporthtmla.title": "匯出成 HTML",
|
||||
"pad.importExport.exportplaina.title": "匯出成純文字",
|
||||
"pad.importExport.exportworda.title": "匯出成 Microsoft Word",
|
||||
"pad.importExport.exportpdfa.title": "匯出成 PDF",
|
||||
"pad.importExport.exportopena.title": "匯出成 ODF(開放文件格式)",
|
||||
"pad.modals.connected": "已連線。",
|
||||
"pad.modals.reconnecting": "重新連線到您的記事本…",
|
||||
"pad.modals.forcereconnect": "強制重新連線",
|
||||
|
|
|
|||
|
|
@ -551,10 +551,11 @@ exports.createPad = async (padID: string, text: string, authorId = '') => {
|
|||
|
||||
// create pad
|
||||
await getPadSafe(padID, false, text, authorId);
|
||||
// When requireAuthentication is on, every creator has a stable identity, so
|
||||
// the cookie/identity path covers recovery and the one-time token is just
|
||||
// an extra surface to leak.
|
||||
const deletionToken = settings.requireAuthentication
|
||||
// No recovery token when it cannot help: requireAuthentication gives every
|
||||
// creator a stable identity, and allowPadDeletionByAllUsers lets anyone delete
|
||||
// the pad with no token at all (issue #7926). Either way the token is just an
|
||||
// extra surface to leak.
|
||||
const deletionToken = settings.requireAuthentication || settings.allowPadDeletionByAllUsers
|
||||
? null
|
||||
: await padDeletionManager.createDeletionTokenIfAbsent(padID);
|
||||
return {deletionToken};
|
||||
|
|
|
|||
|
|
@ -632,6 +632,15 @@ class Pad {
|
|||
}
|
||||
|
||||
async copy(destinationID: string, force: boolean) {
|
||||
// Reject a destinationID that isn't a valid pad id BEFORE any db write. The
|
||||
// copy path writes `pad:${destinationID}...` records directly (bypassing
|
||||
// getPad), so a destinationID carrying the ueberdb delimiter `:` would
|
||||
// otherwise clobber another pad's internal sub-records and slip past the
|
||||
// force=false existence guard. (GHSA-wg58-mhwv-35pq.)
|
||||
if (!padManager.isValidPadId(destinationID)) {
|
||||
throw new CustomError('destinationID is not a valid padId', 'apierror');
|
||||
}
|
||||
|
||||
// Kick everyone from this pad.
|
||||
// This was commented due to https://github.com/ether/etherpad-lite/issues/3183.
|
||||
// Do we really need to kick everyone out?
|
||||
|
|
@ -729,6 +738,12 @@ class Pad {
|
|||
}
|
||||
|
||||
async copyPadWithoutHistory(destinationID: string, force: string|boolean, authorId = '') {
|
||||
// See copy(): reject an invalid destinationID (notably one containing the
|
||||
// ueberdb delimiter `:`) before any db write. (GHSA-wg58-mhwv-35pq.)
|
||||
if (!padManager.isValidPadId(destinationID)) {
|
||||
throw new CustomError('destinationID is not a valid padId', 'apierror');
|
||||
}
|
||||
|
||||
// flush the source pad
|
||||
this.saveToDatabase();
|
||||
|
||||
|
|
@ -868,6 +883,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 +904,7 @@ class Pad {
|
|||
// save this new saved revision
|
||||
this.savedRevisions.push(savedRevision);
|
||||
await this.saveToDatabase();
|
||||
return savedRevision;
|
||||
}
|
||||
|
||||
getSavedRevisions() {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,15 @@ const padList = new class {
|
|||
*/
|
||||
exports.getPad = async (id: string, text?: string|null, authorId:string|null = ''):Promise<PadType> => {
|
||||
// check if this is a valid padId
|
||||
if (!exports.isValidPadId(id)) {
|
||||
//
|
||||
// An id that is no longer valid to *create* is still served when a pad with
|
||||
// that exact id already exists: `:` was accepted until GHSA-wg58-mhwv-35pq, so
|
||||
// pads carrying one exist in the wild (that is why padIdTransforms maps `:` at
|
||||
// all) and rejecting them here would lock their content away. doesPadExist()
|
||||
// requires a top-level `atext`, which only a real pad record has — the
|
||||
// `pad:<id>:revs:<n>` / `:chat:<n>` sub-records an injected id would address do
|
||||
// not have one, so they stay unreachable.
|
||||
if (!exports.isValidPadId(id) && !(await exports.doesPadExist(id))) {
|
||||
throw new CustomError(`${id} is not a valid padId`, 'apierror');
|
||||
}
|
||||
|
||||
|
|
@ -192,7 +200,20 @@ 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}$/;
|
||||
|
||||
// `:` is the ueberdb key-namespace delimiter (records are stored under
|
||||
// `pad:<id>`, `pad:<id>:revs:<n>`, `pad:<id>:chat:<n>`). A pad id containing a
|
||||
// `:` can therefore address another pad's internal sub-records, so it is never
|
||||
// valid — the name portion excludes `$` (group-pad separator) and `:`.
|
||||
// (GHSA-wg58-mhwv-35pq: copyPad/movePad destinationID injection.)
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -75,10 +75,13 @@ exports.checkAccess = async (padID:string, sessionCookie:string, token:string, u
|
|||
}
|
||||
|
||||
// Authentication and authorization checks.
|
||||
if (settings.loadTest) {
|
||||
console.warn(
|
||||
'bypassing socket.io authentication and authorization checks due to settings.loadTest');
|
||||
} else if (settings.requireAuthentication) {
|
||||
// settings.loadTest just short-circuits authn/authz; the user-facing
|
||||
// warning about this configuration choice is logged from Settings.ts
|
||||
// during settings load/reload, not on every request. Re-logging it
|
||||
// here was costing ~4% of process CPU in the 100-400 author dive
|
||||
// sweep (#7756): the routed-console-warn went through log4js's
|
||||
// clustering dispatch on every message.
|
||||
if (!settings.loadTest && settings.requireAuthentication) {
|
||||
if (userSettings == null) {
|
||||
authLogger.debug('access denied: authentication is required');
|
||||
return DENY;
|
||||
|
|
|
|||
|
|
@ -62,18 +62,8 @@ exports.findAuthorID = async (groupID:string, sessionCookie: string) => {
|
|||
* Also, see #3820.
|
||||
*/
|
||||
const sessionIDs = sessionCookie.replace(/^"|"$/g, '').split(',');
|
||||
const sessionInfoPromises = sessionIDs.map(async (id) => {
|
||||
try {
|
||||
return await exports.getSessionInfo(id);
|
||||
} catch (err:any) {
|
||||
if (err.message === 'sessionID does not exist') {
|
||||
console.debug(`SessionManager getAuthorID: no session exists with ID ${id}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
const sessionInfoPromises = sessionIDs.map(async (id) =>
|
||||
(await getSessionInfoOrNull(id)) || undefined);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const isMatch = (si: {
|
||||
groupID: string;
|
||||
|
|
@ -163,9 +153,20 @@ exports.createSession = async (groupID: string, authorID: string, validUntil: nu
|
|||
* @param {String} sessionID The id of the session
|
||||
* @return {Promise<Object>} the sessioninfos
|
||||
*/
|
||||
// Non-throwing variant for hot-path callers. Hot path uses
|
||||
// `findAuthorID` on every CLIENT_READY and `listSessionsWithDBKey` on
|
||||
// session listing; both wrap getSessionInfo in try/catch and discard
|
||||
// "sessionID does not exist" CustomError. Profiling against develop at
|
||||
// 100-400 author sweep (ether/etherpad#7756) attributed ~6% of total
|
||||
// CPU to that throw+catch pair: ~1.8% to CustomError construction and
|
||||
// ~4% to the cascading `console.debug` call routed through log4js. A
|
||||
// null return collapses the cost to a single nullable check.
|
||||
const getSessionInfoOrNull = async (sessionID: string) =>
|
||||
await db.get(`session:${sessionID}`);
|
||||
|
||||
exports.getSessionInfo = async (sessionID:string) => {
|
||||
// check if the database entry of this session exists
|
||||
const session = await db.get(`session:${sessionID}`);
|
||||
const session = await getSessionInfoOrNull(sessionID);
|
||||
|
||||
if (session == null) {
|
||||
// session does not exist
|
||||
|
|
@ -250,15 +251,12 @@ const listSessionsWithDBKey = async (dbkey: string) => {
|
|||
|
||||
// iterate through the sessions and get the sessioninfos
|
||||
for (const sessionID of Object.keys(sessions || {})) {
|
||||
try {
|
||||
sessions[sessionID] = await exports.getSessionInfo(sessionID);
|
||||
} catch (err:any) {
|
||||
if (err.name === 'apierror') {
|
||||
console.warn(`Found bad session ${sessionID} in ${dbkey}`);
|
||||
sessions[sessionID] = null;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
const info = await getSessionInfoOrNull(sessionID);
|
||||
if (info == null) {
|
||||
console.warn(`Found bad session ${sessionID} in ${dbkey}`);
|
||||
sessions[sessionID] = null;
|
||||
} else {
|
||||
sessions[sessionID] = info;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,13 @@ exports.doExport = async (req: any, res: any, padId: string, readOnlyId: string,
|
|||
// for the temp path token (see matching note in ImportHandler.ts).
|
||||
const randNum = crypto.randomBytes(16).toString('hex');
|
||||
const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`;
|
||||
await fsp_writeFile(srcFile, html);
|
||||
// Strip remote <img> tags before handing the document to LibreOffice.
|
||||
// soffice fetches remote image URLs during conversion, so any plugin/hook
|
||||
// that injects an <img src="http://..."> into export HTML would otherwise
|
||||
// turn export into a blind SSRF sink. The native path already does this
|
||||
// (see stripRemoteImages above); apply it here so both paths match.
|
||||
const {stripRemoteImages} = require('../utils/ExportSanitizeHtml');
|
||||
await fsp_writeFile(srcFile, stripRemoteImages(html));
|
||||
|
||||
// ensure html can be collected by the garbage collector
|
||||
html = null;
|
||||
|
|
|
|||
|
|
@ -204,7 +204,14 @@ class Channels {
|
|||
/**
|
||||
* A changeset queue per pad that is processed by handleUserChanges()
|
||||
*/
|
||||
const padChannels = new Channels((ch, {socket, message}) => handleUserChanges(socket, message));
|
||||
// The channel key `ch` is the pad id captured at enqueue time (see the enqueue
|
||||
// call). Pass it through to handleUserChanges so the write targets the pad that
|
||||
// was authorized when the message arrived — NOT whatever pad the mutable
|
||||
// sessioninfos[socket.id].padId happens to point at by apply time. A concurrent
|
||||
// same-socket CLIENT_READY can swap that padId between enqueue and apply, which
|
||||
// otherwise redirects the queued write onto a read-only / unauthorized pad
|
||||
// (GHSA-6mcx-x5h6-rpw2).
|
||||
const padChannels = new Channels((ch, {socket, message}) => handleUserChanges(socket, message, ch));
|
||||
|
||||
/**
|
||||
* This Method is called by server.ts to tell the message handler on which socket it should send
|
||||
|
|
@ -305,8 +312,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();
|
||||
|
|
@ -500,6 +512,17 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
|
|||
throw new Error(`pre-CLIENT_READY message from IP ${ip}: ${msg}`);
|
||||
}
|
||||
|
||||
// Pin the pad this message is authorized against — together with `auth` and
|
||||
// the read-only flag — BEFORE any of the awaits below (checkAccess and the
|
||||
// handleMessageSecurity/handleMessage hooks). A concurrent same-socket
|
||||
// CLIENT_READY can mutate sessioninfos[socket.id] (padId/readonly/auth) IN
|
||||
// PLACE during those awaits (the object identity check below does not catch an
|
||||
// in-place mutation). Using these pinned values for the read-only gate, the
|
||||
// queue key and the write keeps them all referring to the SAME pad, closing
|
||||
// the cross-pad write TOCTOU (GHSA-6mcx-x5h6-rpw2).
|
||||
const messagePadId = thisSession.padId;
|
||||
const messageReadonly = thisSession.readonly;
|
||||
|
||||
const {session: {user} = {}} = socket.client.request as SocketClientRequest;
|
||||
const {accessStatus, authorID} =
|
||||
await securityManager.checkAccess(auth.padID, auth.sessionID, auth.token, user);
|
||||
|
|
@ -521,14 +544,15 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
|
|||
}
|
||||
thisSession.author = authorID;
|
||||
|
||||
// Allow plugins to bypass the readonly message blocker
|
||||
let readOnly = thisSession.readonly;
|
||||
// Allow plugins to bypass the readonly message blocker. Base the decision on
|
||||
// the value pinned above so a concurrent CLIENT_READY can't flip it mid-message.
|
||||
let readOnly = messageReadonly;
|
||||
const context = {
|
||||
message,
|
||||
sessionInfo: {
|
||||
authorId: thisSession.author,
|
||||
padId: thisSession.padId,
|
||||
readOnly: thisSession.readonly,
|
||||
padId: messagePadId,
|
||||
readOnly: messageReadonly,
|
||||
},
|
||||
socket,
|
||||
get client() {
|
||||
|
|
@ -575,7 +599,10 @@ exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
|
|||
switch (type) {
|
||||
case 'USER_CHANGES':
|
||||
stats.counter('pendingEdits').inc();
|
||||
await padChannels.enqueue(thisSession.padId, {socket, message});
|
||||
// Queue key = the pinned pad, NOT the (possibly-swapped) live
|
||||
// session padId. This value is forwarded to handleUserChanges as
|
||||
// the write target (see the padChannels executor).
|
||||
await padChannels.enqueue(messagePadId, {socket, message});
|
||||
break;
|
||||
case 'PAD_DELETE': await handlePadDelete(socket, message.data as unknown as PadDeleteMessage); break;
|
||||
case 'USERINFO_UPDATE': await handleUserInfoUpdate(socket, message as unknown as UserNewInfoMessage); break;
|
||||
|
|
@ -622,7 +649,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},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -807,7 +845,7 @@ const handleUserInfoUpdate = async (socket:any, {data: {userInfo: {name, colorId
|
|||
*/
|
||||
const handleUserChanges = async (socket:any, message: {
|
||||
data: ClientUserChangesMessage
|
||||
}) => {
|
||||
}, authorizedPadId: string) => {
|
||||
// This one's no longer pending, as we're gonna process it now
|
||||
stats.counter('pendingEdits').dec();
|
||||
|
||||
|
|
@ -834,7 +872,9 @@ const handleUserChanges = async (socket:any, message: {
|
|||
if (apool == null) throw new Error('missing apool');
|
||||
if (changeset == null) throw new Error('missing changeset');
|
||||
const wireApool = (new AttributePool()).fromJsonable(apool);
|
||||
const pad = await padManager.getPad(thisSession.padId, null, thisSession.author);
|
||||
// Use the pad id captured at enqueue time, not the (mutable) session padId,
|
||||
// which a concurrent CLIENT_READY may have swapped (GHSA-6mcx-x5h6-rpw2).
|
||||
const pad = await padManager.getPad(authorizedPadId, null, thisSession.author);
|
||||
|
||||
// Verify that the changeset has valid syntax and is in canonical form
|
||||
checkRep(changeset);
|
||||
|
|
@ -988,7 +1028,7 @@ const handleUserChanges = async (socket:any, message: {
|
|||
socket.emit('message', {disconnect: 'badChangeset'});
|
||||
stats.meter('failedChangesets').mark();
|
||||
messageLogger.warn(`Failed to apply USER_CHANGES from author ${thisSession.author} ` +
|
||||
`(socket ${socket.id}) on pad ${thisSession.padId}: ${err.stack || err}`);
|
||||
`(socket ${socket.id}) on pad ${authorizedPadId}: ${err.stack || err}`);
|
||||
} finally {
|
||||
stopWatch.end();
|
||||
}
|
||||
|
|
@ -1287,15 +1327,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 +1380,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
|
||||
|
|
|
|||
|
|
@ -8,14 +8,32 @@ exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Functio
|
|||
// redirects browser to the pad's sanitized url if needed. otherwise, renders the html
|
||||
args.app.param('pad', (req:any, res:any, next:Function, padId:string) => {
|
||||
(async () => {
|
||||
// ensure the padname is valid and the url doesn't end with a /
|
||||
if (!padManager.isValidPadId(padId) || /\/$/.test(req.url)) {
|
||||
// Reject URLs ending in `/` outright.
|
||||
if (/\/$/.test(req.url)) {
|
||||
res.status(404).send('Such a padname is forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize FIRST, then validate the sanitized result. sanitizePadId maps
|
||||
// legacy characters (whitespace and the ueberdb delimiter `:`) to `_`, so
|
||||
// a URL like `/p/foo:bar` still redirects to `/p/foo_bar` even though `:`
|
||||
// is not itself a valid pad id (GHSA-wg58-mhwv-35pq). An id that stays
|
||||
// invalid after sanitizing (e.g. one containing `$`) is forbidden.
|
||||
const sanitizedPadId = await padManager.sanitizePadId(padId);
|
||||
|
||||
// A pad that already exists keeps its URL even if its id is no longer a
|
||||
// valid one to *create*: `:` was accepted by isValidPadId until
|
||||
// GHSA-wg58-mhwv-35pq, so pads carrying one exist in the wild (that is why
|
||||
// padIdTransforms maps `:` in the first place) and 404ing them would lock
|
||||
// their content away. Only ids that are invalid AND unknown are rejected —
|
||||
// opening a pad URL creates the pad, so this is what keeps new invalid ids
|
||||
// out.
|
||||
if (!padManager.isValidPadId(sanitizedPadId) &&
|
||||
!(await padManager.doesPadExist(sanitizedPadId))) {
|
||||
res.status(404).send('Such a padname is forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sanitizedPadId === padId) {
|
||||
// the pad id was fine, so just render it
|
||||
next();
|
||||
|
|
|
|||
|
|
@ -25,6 +25,21 @@ let ioI: { sockets: { sockets: any[]; }; } | null = null
|
|||
// rules. Reused by admin.ts so both call sites share one definition.
|
||||
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath';
|
||||
|
||||
// Public routes echo the proxy-path headers into rendered URLs, social-preview
|
||||
// metadata, manifest links and the legacy timeslider redirect. Advertise the
|
||||
// headers in Vary so a shared cache/CDN in front of Etherpad keys on them and
|
||||
// can't serve a proxy-path injected by one client to another (cache poisoning).
|
||||
// Mirrors the admin-route fix in admin.ts (GHSA-fjgc-3mj7-8rg8).
|
||||
//
|
||||
// Only vary on the headers sanitizeProxyPath() actually consults for the
|
||||
// current config: x-proxy-path is always honored, but x-forwarded-prefix and
|
||||
// x-ingress-path are ignored unless trustProxy is enabled — varying on them
|
||||
// then would only fragment shared caches without affecting the response.
|
||||
const varyOnProxyPath = (res: any) => {
|
||||
res.vary('x-proxy-path');
|
||||
if (settings.trustProxy) res.vary(['x-forwarded-prefix', 'x-ingress-path']);
|
||||
};
|
||||
|
||||
|
||||
exports.socketio = (hookName: string, {io}: any) => {
|
||||
ioI = io
|
||||
|
|
@ -173,6 +188,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
|
|||
})
|
||||
setRouteHandler('/', (req: any, res: any) => {
|
||||
const proxyPath = sanitizeProxyPath(req);
|
||||
varyOnProxyPath(res);
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
|
||||
proxyPath,
|
||||
|
|
@ -202,6 +218,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
|
|||
});
|
||||
|
||||
const proxyPath = sanitizeProxyPath(req);
|
||||
varyOnProxyPath(res);
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
|
||||
proxyPath,
|
||||
|
|
@ -246,6 +263,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
|
|||
});
|
||||
|
||||
const proxyPath = sanitizeProxyPath(req);
|
||||
varyOnProxyPath(res);
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
|
||||
proxyPath,
|
||||
|
|
@ -369,6 +387,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
|
|||
// serve index.html under /
|
||||
args.app.get('/', (req: any, res: any) => {
|
||||
const proxyPath = sanitizeProxyPath(req);
|
||||
varyOnProxyPath(res);
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
|
||||
proxyPath,
|
||||
|
|
@ -389,6 +408,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
|
|||
});
|
||||
|
||||
const proxyPath = sanitizeProxyPath(req);
|
||||
varyOnProxyPath(res);
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
|
||||
proxyPath,
|
||||
|
|
@ -417,6 +437,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
|
|||
// technically well-defined but Firefox dropped a trailing-slash
|
||||
// case once that flaked the legacy-URL test (#7710).
|
||||
const proxyPath = sanitizeProxyPath(req);
|
||||
varyOnProxyPath(res);
|
||||
return res.redirect(302, `${proxyPath}/p/${encodeURIComponent(req.params.pad)}`);
|
||||
}
|
||||
ensureAuthorTokenCookie(req, res, settings);
|
||||
|
|
@ -425,6 +446,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
|
|||
});
|
||||
|
||||
const proxyPath = sanitizeProxyPath(req);
|
||||
varyOnProxyPath(res);
|
||||
const socialMetaHtml = renderSocialMeta({
|
||||
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
|
||||
proxyPath,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,22 @@ const aCallFirst0 =
|
|||
// @ts-ignore
|
||||
async (hookName: string, context:any, pred = null) => (await aCallFirst(hookName, context, pred))[0];
|
||||
|
||||
// Rotate the express-session id while preserving the session's data. Used at the
|
||||
// authentication boundary to prevent session fixation (GHSA-73h9-c5xp-gfg4).
|
||||
// The freshly minted cookie for the new id is kept; all other session data
|
||||
// (notably req.session.user) is carried across onto the new session.
|
||||
const regenerateSessionPreservingData = (req: any) => new Promise<void>((resolve, reject) => {
|
||||
// Session prototype methods (regenerate/save/...) are non-enumerable, so the
|
||||
// spread captures only data properties. Drop `cookie` so the new session keeps
|
||||
// the fresh cookie regenerate() creates.
|
||||
const {cookie, ...data} = req.session;
|
||||
req.session.regenerate((err: any) => {
|
||||
if (err) return reject(err);
|
||||
Object.assign(req.session, data);
|
||||
req.session.save((saveErr: any) => saveErr != null ? reject(saveErr) : resolve());
|
||||
});
|
||||
});
|
||||
|
||||
exports.normalizeAuthzLevel = (level: string|boolean) => {
|
||||
if (!level) return false;
|
||||
switch (level) {
|
||||
|
|
@ -158,6 +174,11 @@ const checkAccess = async (req:any, res:any, next: Function) => {
|
|||
|
||||
if (settings.users == null) settings.users = {};
|
||||
const ctx:WebAccessTypes = {req, res, users: settings.users, next};
|
||||
// Identity carried by the session BEFORE the authenticate step runs. Used
|
||||
// below to decide whether authentication changed the principal (anonymous ->
|
||||
// user, or a privilege/identity change such as non-admin -> admin), which is
|
||||
// the point at which the session id must be rotated (see below).
|
||||
const prevUser = req.session != null ? req.session.user : null;
|
||||
// If the HTTP basic auth header is present, extract the username and password so it can be given
|
||||
// to authn plugins.
|
||||
const httpBasicAuth = req.headers.authorization && req.headers.authorization.startsWith('Basic ');
|
||||
|
|
@ -206,6 +227,26 @@ const checkAccess = async (req:any, res:any, next: Function) => {
|
|||
httpLogger.error('authenticate hook failed to add user settings to session');
|
||||
return res.status(500).send('Internal Server Error');
|
||||
}
|
||||
// Session fixation defense (GHSA-73h9-c5xp-gfg4): rotate the session id
|
||||
// whenever authentication changed the principal — an anonymous session
|
||||
// becoming authenticated, OR an authenticated session changing identity or
|
||||
// privilege level (e.g. non-admin -> admin re-authentication). This prevents a
|
||||
// pre-auth / lower-privilege id (which an attacker may have planted or
|
||||
// captured — e.g. one an SSO plugin persisted before redirecting to the IdP)
|
||||
// from owning the resulting session. A no-op re-authentication of the same
|
||||
// principal is left alone (no churn), and the rotation is skipped when the
|
||||
// session store doesn't expose regenerate().
|
||||
const identityChanged = prevUser == null ||
|
||||
prevUser.username !== req.session.user.username ||
|
||||
!!prevUser.is_admin !== !!req.session.user.is_admin;
|
||||
if (identityChanged && typeof req.session.regenerate === 'function') {
|
||||
try {
|
||||
await regenerateSessionPreservingData(req);
|
||||
} catch (err) {
|
||||
httpLogger.error(`failed to regenerate session on authentication: ${err}`);
|
||||
return res.status(500).send('Internal Server Error');
|
||||
}
|
||||
}
|
||||
const {username = '<no username>'} = req.session.user;
|
||||
httpLogger.info(
|
||||
`Successful authentication from IP ${anonymizeIp(req.ip, settings.ipLogging)} ` +
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@ import {format} from 'url'
|
|||
import {ParsedUrlQuery} from "node:querystring";
|
||||
import {MapArrayType} from "../types/MapType";
|
||||
import crypto from "node:crypto";
|
||||
import {resolveOidcCookieKeys, isOriginAllowedForOidcClient} from "./OidcProviderSecurity";
|
||||
import SecretRotator from "./SecretRotator";
|
||||
|
||||
// Held at module scope so the rotator (and its refresh timer) is not garbage
|
||||
// collected for the lifetime of the process, mirroring express.ts.
|
||||
let oidcCookieSecretRotator: SecretRotator | null = null;
|
||||
|
||||
// Small fixed delay applied to every failed interactive login, mirroring
|
||||
// webaccess.authnFailureDelayMs, so failures take a consistent amount of time.
|
||||
|
|
@ -68,9 +74,13 @@ const configuration: Configuration = {
|
|||
profile: ['name'],
|
||||
admin: ['admin']
|
||||
},
|
||||
cookies: {
|
||||
keys: ['oidc'],
|
||||
},
|
||||
// NOTE: cookies.keys is deliberately NOT set here. A committed literal key
|
||||
// (historically ['oidc']) lets anyone with the public source forge valid
|
||||
// OIDC provider `.sig` cookies. The real key material is resolved at
|
||||
// provider-construction time in expressCreateServer() via
|
||||
// resolveOidcCookieKeys(), which prefers an operator-supplied
|
||||
// settings.sso.cookieKeys and otherwise derives a secret, stable key from
|
||||
// the persisted session secret. See OidcProviderSecurity.ts.
|
||||
features:{
|
||||
devInteractions: {enabled: false},
|
||||
},
|
||||
|
|
@ -92,8 +102,39 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp
|
|||
publicKeyExported = publicKey
|
||||
privateKeyExported = privateKey
|
||||
|
||||
// Resolve the OIDC provider's cookie-signing keys. Never a committed literal.
|
||||
// When the operator hasn't pinned settings.sso.cookieKeys and cookie key
|
||||
// rotation is enabled (the default), reuse the same DB-backed SecretRotator
|
||||
// mechanism as the Express session cookies so the key is stable across
|
||||
// restarts and shared across horizontally-scaled pods. `settings.sessionKey`
|
||||
// is commonly null under default rotation settings, so relying on it alone
|
||||
// would hand the provider an unstable per-process random key.
|
||||
const operatorCookieKeys = (settings.sso as {cookieKeys?: unknown}).cookieKeys;
|
||||
const hasOperatorKeys = Array.isArray(operatorCookieKeys) &&
|
||||
operatorCookieKeys.some((k) => typeof k === 'string' && k.length > 0);
|
||||
const {keyRotationInterval, sessionLifetime} = settings.cookie;
|
||||
let rotatedSecrets: string[] | null = null;
|
||||
if (!hasOperatorKeys && keyRotationInterval && sessionLifetime) {
|
||||
if (oidcCookieSecretRotator == null) {
|
||||
oidcCookieSecretRotator = new SecretRotator(
|
||||
'oidcCookieSecrets', keyRotationInterval, sessionLifetime, settings.sessionKey);
|
||||
await oidcCookieSecretRotator.start();
|
||||
}
|
||||
rotatedSecrets = oidcCookieSecretRotator.secrets;
|
||||
}
|
||||
|
||||
const oidc = new Provider(settings.sso.issuer, {
|
||||
...configuration, jwks: {
|
||||
...configuration,
|
||||
cookies: {
|
||||
// Secret, deployment-stable signing keys — never a committed literal.
|
||||
// See resolveOidcCookieKeys().
|
||||
keys: resolveOidcCookieKeys({
|
||||
cookieKeys: operatorCookieKeys,
|
||||
rotatedSecrets,
|
||||
sessionKey: settings.sessionKey,
|
||||
}),
|
||||
},
|
||||
jwks: {
|
||||
keys: [
|
||||
privateKeyJWK
|
||||
],
|
||||
|
|
@ -127,9 +168,12 @@ export const expressCreateServer = async (hookName: string, args: ArgsExpressTyp
|
|||
},
|
||||
jwtResponseModes: {enabled: true},
|
||||
},
|
||||
clientBasedCORS: (ctx, origin, client) => {
|
||||
return true
|
||||
},
|
||||
clientBasedCORS: (ctx, origin, client) =>
|
||||
// Only allow cross-origin reads from an origin registered as one of
|
||||
// the client's redirect URIs. Returning `true` unconditionally
|
||||
// reflected any Origin into Access-Control-Allow-Origin. See
|
||||
// isOriginAllowedForOidcClient().
|
||||
isOriginAllowedForOidcClient(origin, client as {redirectUris?: unknown}),
|
||||
extraParams: [],
|
||||
extraTokenClaims: async (ctx, token) => {
|
||||
if(token.kind === 'AccessToken') {
|
||||
|
|
|
|||
96
src/node/security/OidcProviderSecurity.ts
Normal file
96
src/node/security/OidcProviderSecurity.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import crypto from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Security helpers for the embedded OIDC provider (`OAuth2Provider.ts`).
|
||||
*
|
||||
* Kept in a dependency-light module (only `node:crypto`) so the pure decisions
|
||||
* can be unit-tested without constructing an `oidc-provider` instance.
|
||||
*/
|
||||
|
||||
// Domain-separation label so the derived cookie key is cryptographically
|
||||
// unrelated to any other use of the session secret. The trailing NUL keeps the
|
||||
// label from ambiguously running into the appended secret.
|
||||
export const COOKIE_KEY_DERIVATION_LABEL = 'etherpad-oidc-provider-cookie-signing-key\0';
|
||||
|
||||
/**
|
||||
* Resolve the cookie-signing keys for the embedded OIDC provider.
|
||||
*
|
||||
* oidc-provider signs its short-lived interaction/session/grant cookies
|
||||
* (`_interaction`, `_interaction_resume`, `_grant`, `_session`) with an HMAC
|
||||
* keyed by these values. Shipping a hardcoded key (historically `['oidc']`) let
|
||||
* anyone with the public source forge valid `.sig` cookies, defeating the
|
||||
* provider's cookie-integrity guarantee. Reported by `meifukun`.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. An explicit operator-supplied `settings.sso.cookieKeys` array — use this
|
||||
* for controlled rotation: `[newKey, ...oldKeys]`.
|
||||
* 2. The live secrets array of a DB-backed `SecretRotator` (the same mechanism
|
||||
* the Express session-cookie stack uses). This is the default path: it is
|
||||
* stable across restarts, shared across horizontally-scaled pods via the
|
||||
* database, and rotates automatically. The array is returned BY REFERENCE
|
||||
* so a later in-place rotation propagates to oidc-provider/keygrip without
|
||||
* reconstructing the provider.
|
||||
* 3. A value derived from the persisted Etherpad session secret
|
||||
* (`SESSIONKEY.txt`) via a domain-separated SHA-256 — used when rotation is
|
||||
* disabled but a static session key exists. Stable across restarts/pods,
|
||||
* never committed to source.
|
||||
* 4. As a last resort (no key material at all), an ephemeral per-process
|
||||
* random key. The integrity boundary holds, but interactions won't survive
|
||||
* a restart or span multiple pods.
|
||||
*/
|
||||
export const resolveOidcCookieKeys = (
|
||||
opts: {cookieKeys?: unknown, rotatedSecrets?: string[] | null, sessionKey?: string | null},
|
||||
): string[] => {
|
||||
const {cookieKeys, rotatedSecrets, sessionKey} = opts;
|
||||
|
||||
if (Array.isArray(cookieKeys)) {
|
||||
const usable = cookieKeys.filter((k): k is string => typeof k === 'string' && k.length > 0);
|
||||
if (usable.length > 0) return usable;
|
||||
}
|
||||
|
||||
// Return the rotator's array by reference (do not copy/filter) so in-place
|
||||
// rotation is observed live by keygrip.
|
||||
if (Array.isArray(rotatedSecrets) &&
|
||||
rotatedSecrets.some((k) => typeof k === 'string' && k.length > 0)) {
|
||||
return rotatedSecrets;
|
||||
}
|
||||
|
||||
if (typeof sessionKey === 'string' && sessionKey.length > 0) {
|
||||
const derived = crypto.createHash('sha256')
|
||||
.update(COOKIE_KEY_DERIVATION_LABEL)
|
||||
.update(sessionKey)
|
||||
.digest('hex');
|
||||
return [derived];
|
||||
}
|
||||
|
||||
return [crypto.randomBytes(32).toString('hex')];
|
||||
};
|
||||
|
||||
/**
|
||||
* Origin allow-list decision for the embedded OIDC provider's CORS-enabled
|
||||
* endpoints (`/oidc/token`, `/oidc/me`, ...). oidc-provider invokes
|
||||
* `clientBasedCORS(ctx, origin, client)` for every cross-origin request;
|
||||
* returning `true` unconditionally (the historical behavior) reflected ANY
|
||||
* `Origin` into `Access-Control-Allow-Origin`, letting unregistered origins read
|
||||
* token/userinfo responses that use non-cookie credentials (Authorization
|
||||
* headers, POST-body client credentials). Reported by `meifukun`.
|
||||
*
|
||||
* An origin is allowed only when it exactly matches the origin (scheme + host +
|
||||
* port) of one of the client's registered redirect URIs.
|
||||
*/
|
||||
export const isOriginAllowedForOidcClient = (
|
||||
origin: string | undefined | null,
|
||||
client: {redirectUris?: unknown} | undefined | null,
|
||||
): boolean => {
|
||||
if (!origin || !client) return false;
|
||||
const uris = (client as {redirectUris?: unknown}).redirectUris;
|
||||
if (!Array.isArray(uris)) return false;
|
||||
return uris.some((uri: unknown) => {
|
||||
if (typeof uri !== 'string') return false;
|
||||
try {
|
||||
return new URL(uri).origin === origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
import log4js from 'log4js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import _ from 'underscore';
|
||||
|
||||
|
|
@ -30,6 +31,25 @@ const absPathLogger = log4js.getLogger('AbsolutePaths');
|
|||
*/
|
||||
let etherpadRoot: string|null = null;
|
||||
|
||||
/**
|
||||
* Walks up the directory tree from `start`, returning the closest ancestor
|
||||
* directory (including `start` itself) that contains a package.json. Replaces
|
||||
* the unmaintained `find-root` package, mirroring its semantics: it throws if
|
||||
* no package.json is found before reaching the filesystem root.
|
||||
*
|
||||
* @param {string} start - The directory to start searching from.
|
||||
* @return {string} The closest ancestor directory containing a package.json.
|
||||
*/
|
||||
const findRoot = (start: string): string => {
|
||||
let dir = start;
|
||||
for (;;) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) throw new Error('package.json not found in path');
|
||||
dir = parent;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* If stringArray's last elements are exactly equal to lastDesiredElements,
|
||||
* returns a copy in which those last elements are popped, or false otherwise.
|
||||
|
|
@ -79,7 +99,6 @@ export const findEtherpadRoot = () => {
|
|||
return etherpadRoot;
|
||||
}
|
||||
|
||||
const findRoot = require('find-root');
|
||||
const foundRoot = findRoot(__dirname);
|
||||
const splitFoundRoot = foundRoot.split(path.sep);
|
||||
|
||||
|
|
|
|||
|
|
@ -208,6 +208,17 @@ export const stripRemoteImages = (html: string): string => {
|
|||
if (VOID_TAGS.has(name)) return;
|
||||
out += `</${name}>`;
|
||||
},
|
||||
// Preserve document-level directives (notably `<!doctype html>`) and
|
||||
// comments. stripRemoteImages() runs on the FULL export document for the
|
||||
// soffice path, so dropping the doctype would flip LibreOffice into quirks
|
||||
// mode. htmlparser2 surfaces the doctype as a processing instruction whose
|
||||
// `data` is e.g. `!doctype html`.
|
||||
onprocessinginstruction(name, data) {
|
||||
out += `<${data}>`;
|
||||
},
|
||||
oncomment(data) {
|
||||
out += `<!--${data}-->`;
|
||||
},
|
||||
}, {decodeEntities: false, lowerCaseTags: true});
|
||||
parser.write(html);
|
||||
parser.end();
|
||||
|
|
|
|||
|
|
@ -39,11 +39,9 @@ const ROOT_DIR = path.join(settings.root, 'src/static/');
|
|||
const LIBRARY_WHITELIST = [
|
||||
'async',
|
||||
'js-cookie',
|
||||
'security',
|
||||
'split-grid',
|
||||
'tinycon',
|
||||
'underscore',
|
||||
'unorm',
|
||||
];
|
||||
|
||||
// What follows is a terrible hack to avoid loop-back within the server.
|
||||
|
|
@ -179,13 +177,18 @@ const _minify = async (req:any, res:any) => {
|
|||
const plugin = plugins.plugins[library];
|
||||
const pluginPath = plugin.package.realPath;
|
||||
filename = path.join(pluginPath, libraryPath);
|
||||
// On Windows, path.relative converts forward slashes to backslashes. Convert them back
|
||||
// because some of the code below assumes forward slashes. Node.js treats both the backlash
|
||||
// and the forward slash characters as pathname component separators on Windows so this does
|
||||
// not change the meaning of the pathname. This conversion does not introduce a directory
|
||||
// traversal vulnerability because all '..\\' substrings have already been removed by
|
||||
// sanitizePathname.
|
||||
filename = filename.replace(/\\/g, '/');
|
||||
// On Windows, path.join converts forward slashes to backslashes. Convert them back because
|
||||
// some of the code below assumes forward slashes. Node.js treats both the backslash and the
|
||||
// forward slash characters as pathname component separators on Windows so this does not
|
||||
// change the meaning of the pathname on Windows.
|
||||
//
|
||||
// THIS CONVERSION MUST ONLY BE DONE ON WINDOWS. On POSIX systems a backslash is an ordinary
|
||||
// filename byte, not a separator, so sanitizePathname() deliberately leaves '..\\' segments
|
||||
// untouched (they are harmless there). Replacing '\\' with '/' unconditionally would turn
|
||||
// those already-sanitized bytes back into '../' path components *after* the traversal check
|
||||
// has run, reintroducing a directory-traversal / arbitrary-file-read vulnerability
|
||||
// (GHSA-mc8w-wjhw-45x5).
|
||||
if (path.sep === '\\') filename = filename.replace(/\\/g, '/');
|
||||
} else if (LIBRARY_WHITELIST.indexOf(library) !== -1) {
|
||||
// Go straight into node_modules
|
||||
// Avoid `require.resolve()`, since 'mustache' and 'mustache/index.js'
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ import fs from 'node:fs';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {argv} from './Cli'
|
||||
import jsonminify from 'jsonminify';
|
||||
import {parse as parseJsonc, printParseErrorCode, ParseError} from 'jsonc-parser';
|
||||
import log4js from 'log4js';
|
||||
import {createHash} from 'node:crypto';
|
||||
import randomString from './randomstring';
|
||||
|
|
@ -116,9 +116,18 @@ const parseSettings = (settingsFilename: string, isSettings: boolean) => {
|
|||
}
|
||||
|
||||
try {
|
||||
settingsStr = jsonminify(settingsStr).replace(',]', ']').replace(',}', '}');
|
||||
// jsonc-parser tolerates comments and trailing commas, so settings files
|
||||
// can stay annotated. Unlike the old jsonminify + naive ',]'/',}' string
|
||||
// replace, it fixes *every* trailing comma (not just the first of each
|
||||
// kind) and never mangles those sequences when they appear inside strings.
|
||||
const errors: ParseError[] = [];
|
||||
const settings = parseJsonc(settingsStr, errors, {allowTrailingComma: true});
|
||||
|
||||
const settings = JSON.parse(settingsStr);
|
||||
if (errors.length > 0) {
|
||||
const {error, offset} = errors[0];
|
||||
throw new Error(`${printParseErrorCode(error)} at offset ${offset}`);
|
||||
}
|
||||
if (settings === undefined) throw new Error('file is empty or not valid JSON');
|
||||
|
||||
logger.info(`${settingsType} loaded from: ${settingsFilename}`);
|
||||
|
||||
|
|
@ -295,6 +304,10 @@ export type SettingsType = {
|
|||
sso: {
|
||||
issuer: string,
|
||||
clients?: {client_id: string}[]
|
||||
// Optional operator-supplied signing keys for the embedded OIDC provider's
|
||||
// cookies. When unset, a secret key is derived from the session secret.
|
||||
// Provide an ordered array `[newKey, ...oldKeys]` to rotate.
|
||||
cookieKeys?: string[]
|
||||
},
|
||||
showSettingsInAdminPage: boolean,
|
||||
cleanup: {
|
||||
|
|
@ -1253,6 +1266,13 @@ export const reloadSettings = () => {
|
|||
logger.warn("logLayoutType: " + settings.logLayoutType);
|
||||
initLogging(settings.logconfig);
|
||||
|
||||
if (settings.loadTest) {
|
||||
logger.warn(
|
||||
'settings.loadTest is true: SecurityManager.checkAccess() will bypass ' +
|
||||
'authentication and authorization for both HTTP and socket.io requests. ' +
|
||||
'Do NOT enable this in production.');
|
||||
}
|
||||
|
||||
if (!settings.skinName) {
|
||||
logger.warn('No "skinName" parameter found. Please check out settings.json.template and ' +
|
||||
'update your settings.json. Falling back to the default "colibris".');
|
||||
|
|
|
|||
|
|
@ -67,7 +67,6 @@
|
|||
, "skiplist.js"
|
||||
, "colorutils.js"
|
||||
, "undomodule.js"
|
||||
, "$unorm/lib/unorm.js"
|
||||
, "contentcollector.js"
|
||||
, "changesettracker.js"
|
||||
, "linestylefilter.js"
|
||||
|
|
|
|||
|
|
@ -38,59 +38,56 @@
|
|||
"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-rate-limit": "^8.6.0",
|
||||
"express-session": "^1.19.0",
|
||||
"find-root": "1.1.0",
|
||||
"formidable": "^3.5.4",
|
||||
"html-to-docx": "^1.8.0",
|
||||
"htmlparser2": "^12.0.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"jose": "^6.2.3",
|
||||
"jose": "^6.2.4",
|
||||
"js-cookie": "^3.0.8",
|
||||
"jsdom": "^29.1.1",
|
||||
"jsonminify": "0.4.2",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"languages4translatewiki": "0.1.3",
|
||||
"live-plugin-manager": "^1.1.0",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"log4js": "^6.9.1",
|
||||
"lru-cache": "^11.5.1",
|
||||
"lru-cache": "^11.5.2",
|
||||
"mammoth": "^1.12.0",
|
||||
"measured-core": "^2.0.0",
|
||||
"mime-types": "^3.0.2",
|
||||
"mongodb": "^7.1.1",
|
||||
"mssql": "^12.5.5",
|
||||
"mysql2": "^3.22.5",
|
||||
"nano": "^11.0.5",
|
||||
"nodemailer": "^8.0.10",
|
||||
"oidc-provider": "9.8.4",
|
||||
"openapi-backend": "^5.17.0",
|
||||
"pdfkit": "^0.19.0",
|
||||
"pg": "^8.21.0",
|
||||
"mongodb": "^7.4.0",
|
||||
"mssql": "^12.6.0",
|
||||
"mysql2": "^3.23.1",
|
||||
"nano": "^11.0.6",
|
||||
"nodemailer": "^9.0.3",
|
||||
"oidc-provider": "9.10.0",
|
||||
"openapi-backend": "^5.18.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"pg": "^8.22.0",
|
||||
"prom-client": "^15.1.3",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"rate-limiter-flexible": "^11.2.0",
|
||||
"redis": "^6.0.0",
|
||||
"redis": "^6.1.0",
|
||||
"rehype": "^13.0.2",
|
||||
"rehype-minify-whitespace": "^6.0.2",
|
||||
"resolve": "1.22.12",
|
||||
"rethinkdb": "^2.4.2",
|
||||
"rusty-store-kv": "^1.3.1",
|
||||
"security": "1.0.0",
|
||||
"semver": "^7.8.3",
|
||||
"semver": "^7.8.5",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"superagent": "10.3.0",
|
||||
"surrealdb": "^2.0.3",
|
||||
"surrealdb": "^2.0.8",
|
||||
"tinycon": "0.6.8",
|
||||
"tsx": "4.22.4",
|
||||
"ueberdb2": "^6.1.9",
|
||||
"tsx": "4.23.1",
|
||||
"ueberdb2": "6.1.16",
|
||||
"underscore": "1.13.8",
|
||||
"undici": "^8.4.1",
|
||||
"unorm": "1.6.0",
|
||||
"undici": "^8.9.0",
|
||||
"wtfnode": "^0.10.1"
|
||||
},
|
||||
"bin": {
|
||||
|
|
@ -98,7 +95,7 @@
|
|||
"etherpad-lite": "node/server.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/async": "^3.2.25",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cross-spawn": "^6.0.6",
|
||||
|
|
@ -110,33 +107,32 @@
|
|||
"@types/jquery": "^4.0.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/jsonminify": "^0.4.3",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/mocha": "^10.0.9",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/oidc-provider": "^9.5.0",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/sinon": "^21.0.1",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@types/sinon": "^22.0.0",
|
||||
"@types/supertest": "^7.2.1",
|
||||
"@types/underscore": "^1.13.0",
|
||||
"@types/whatwg-mimetype": "^5.0.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-etherpad": "^4.0.5",
|
||||
"etherpad-cli-client": "^4.0.3",
|
||||
"mocha": "^11.7.6",
|
||||
"mocha-froth": "^0.2.10",
|
||||
"nodeify": "^1.0.1",
|
||||
"openapi-schema-validation": "^0.4.2",
|
||||
"set-cookie-parser": "^3.1.0",
|
||||
"sinon": "^22.0.0",
|
||||
"set-cookie-parser": "^3.1.2",
|
||||
"sinon": "^22.1.0",
|
||||
"split-grid": "^1.0.11",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.8"
|
||||
"typescript": "^7.0.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
|
|
@ -164,6 +160,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.3",
|
||||
"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,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ import {splitTextLines} from "./Changeset";
|
|||
*/
|
||||
class TextLinesMutator {
|
||||
private _lines: string[];
|
||||
private _curSplice: [number, number?];
|
||||
// Args for a future this._lines.splice(): [index, deleteCount, ...linesToInsert].
|
||||
// Indexing past the two leading numbers yields `string | number` to the compiler even though
|
||||
// elements from index 2 on are always strings, hence the `as string` casts below.
|
||||
private _curSplice: [number, number, ...string[]];
|
||||
private _inSplice: boolean;
|
||||
private _curLine: number;
|
||||
private _curCol: number;
|
||||
|
|
@ -132,9 +135,7 @@ class TextLinesMutator {
|
|||
*/
|
||||
_putCurLineInSplice() {
|
||||
if (!this._isCurLineInSplice()) {
|
||||
// @ts-ignore
|
||||
this._curSplice.push(this._linesGet(this._curSplice[0] + this._curSplice[1]));
|
||||
// @ts-ignore
|
||||
this._curSplice[1]++;
|
||||
}
|
||||
// TODO should be the same as this._curSplice.length - 1
|
||||
|
|
@ -211,7 +212,6 @@ class TextLinesMutator {
|
|||
* @returns {string} joined lines
|
||||
*/
|
||||
const nextKLinesText = (k: number) => {
|
||||
// @ts-ignore
|
||||
const m = this._curSplice[0] + this._curSplice[1];
|
||||
return this._linesSlice(m, m + k).join('');
|
||||
};
|
||||
|
|
@ -219,29 +219,22 @@ class TextLinesMutator {
|
|||
let removed = '';
|
||||
if (this._isCurLineInSplice()) {
|
||||
if (this._curCol === 0) {
|
||||
// @ts-ignore
|
||||
removed = this._curSplice[this._curSplice.length - 1];
|
||||
removed = this._curSplice[this._curSplice.length - 1] as string;
|
||||
this._curSplice.length--;
|
||||
removed += nextKLinesText(L - 1);
|
||||
// @ts-ignore
|
||||
this._curSplice[1] += L - 1;
|
||||
} else {
|
||||
removed = nextKLinesText(L - 1);
|
||||
// @ts-ignore
|
||||
this._curSplice[1] += L - 1;
|
||||
const sline = this._curSplice.length - 1;
|
||||
// @ts-ignore
|
||||
removed = this._curSplice[sline].substring(this._curCol) + removed;
|
||||
// @ts-ignore
|
||||
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) +
|
||||
// @ts-ignore
|
||||
removed = (this._curSplice[sline] as string).substring(this._curCol) + removed;
|
||||
this._curSplice[sline] = (this._curSplice[sline] as string).substring(0, this._curCol) +
|
||||
this._linesGet(this._curSplice[0] + this._curSplice[1]);
|
||||
// @ts-ignore
|
||||
this._curSplice[1] += 1;
|
||||
}
|
||||
} else {
|
||||
removed = nextKLinesText(L);
|
||||
this._curSplice[1]! += L;
|
||||
this._curSplice[1] += L;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
|
@ -260,12 +253,9 @@ class TextLinesMutator {
|
|||
// although the line is put into splice, curLine is not increased, because
|
||||
// only some chars are removed not the whole line
|
||||
const sline = this._putCurLineInSplice();
|
||||
// @ts-ignore
|
||||
const removed = this._curSplice[sline].substring(this._curCol, this._curCol + N);
|
||||
// @ts-ignore
|
||||
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) +
|
||||
// @ts-ignore
|
||||
this._curSplice[sline].substring(this._curCol + N);
|
||||
const line = this._curSplice[sline] as string;
|
||||
const removed = line.substring(this._curCol, this._curCol + N);
|
||||
this._curSplice[sline] = line.substring(0, this._curCol) + line.substring(this._curCol + N);
|
||||
return removed;
|
||||
}
|
||||
|
||||
|
|
@ -275,34 +265,29 @@ class TextLinesMutator {
|
|||
* @param {string} text - the text to insert
|
||||
* @param {number} L - number of newlines in text
|
||||
*/
|
||||
insert(text: string | any[], L: any) {
|
||||
insert(text: string, L: number) {
|
||||
if (!text) return;
|
||||
if (!this._inSplice) this._enterSplice();
|
||||
if (L) {
|
||||
// @ts-ignore
|
||||
const newLines = splitTextLines(text);
|
||||
const newLines = splitTextLines(text) ?? [];
|
||||
if (this._isCurLineInSplice()) {
|
||||
const sline = this._curSplice.length - 1;
|
||||
/** @type {string} */
|
||||
const theLine = this._curSplice[sline];
|
||||
const theLine = this._curSplice[sline] as string;
|
||||
const lineCol = this._curCol;
|
||||
// Insert the chars up to `curCol` and the first new line.
|
||||
// @ts-ignore
|
||||
this._curSplice[sline] = theLine.substring(0, lineCol) + newLines[0];
|
||||
this._curLine++;
|
||||
newLines!.splice(0, 1);
|
||||
newLines.splice(0, 1);
|
||||
// insert the remaining new lines
|
||||
// @ts-ignore
|
||||
this._curSplice.push(...newLines);
|
||||
this._curLine += newLines!.length;
|
||||
this._curLine += newLines.length;
|
||||
// insert the remaining chars from the "old" line (e.g. the line we were in
|
||||
// when we started to insert new lines)
|
||||
// @ts-ignore
|
||||
this._curSplice.push(theLine.substring(lineCol));
|
||||
this._curCol = 0; // TODO(doc) why is this not set to the length of last line?
|
||||
} else {
|
||||
this._curSplice.push(...newLines);
|
||||
this._curLine += newLines!.length;
|
||||
this._curLine += newLines.length;
|
||||
}
|
||||
} else {
|
||||
// There are no additional lines. Although the line is put into splice, curLine is not
|
||||
|
|
@ -315,10 +300,8 @@ class TextLinesMutator {
|
|||
'https://github.com/ether/etherpad-lite/issues/2802');
|
||||
console.error(err.stack || err.toString());
|
||||
}
|
||||
// @ts-ignore
|
||||
this._curSplice[sline] = this._curSplice[sline].substring(0, this._curCol) + text +
|
||||
// @ts-ignore
|
||||
this._curSplice[sline].substring(this._curCol);
|
||||
const line = this._curSplice[sline] as string;
|
||||
this._curSplice[sline] = line.substring(0, this._curCol) + text + line.substring(this._curCol);
|
||||
this._curCol += text.length;
|
||||
}
|
||||
}
|
||||
|
|
@ -331,7 +314,6 @@ class TextLinesMutator {
|
|||
hasMore() {
|
||||
let docLines = this._linesLength();
|
||||
if (this._inSplice) {
|
||||
// @ts-ignore
|
||||
docLines += this._curSplice.length - 2 - this._curSplice[1];
|
||||
}
|
||||
return this._curLine < docLines;
|
||||
|
|
|
|||
|
|
@ -3,24 +3,36 @@
|
|||
// One rep.line(div) can be broken in more than one line in the browser.
|
||||
// This function is useful to get the caret position of the line as
|
||||
// is represented by the browser
|
||||
//
|
||||
// NOTE: every DOM lookup here has to go through the editor document that is
|
||||
// passed in, *not* through the ambient `window` / `document`. This module is
|
||||
// bundled into the pad's top-level window, while the caret lives in the
|
||||
// ace_inner iframe nested inside ace_outer. Reading the top window's selection
|
||||
// always yields an empty selection, which used to make getPosition() return
|
||||
// null and crash the callers below (#8038).
|
||||
import {Position, RepModel, RepNode} from "./types/RepModel";
|
||||
|
||||
export const getPosition = () => {
|
||||
const range = getSelectionRange();
|
||||
// @ts-ignore
|
||||
if (!range || $(range.endContainer).closest('body')[0].id !== 'innerdocbody') return null;
|
||||
export const getPosition = (doc: Document): Position | null => {
|
||||
const range = getSelectionRange(doc);
|
||||
if (!range || getBodyOf(range.endContainer)?.id !== 'innerdocbody') return null;
|
||||
// When there's a <br> or any element that has no height, we can't get the dimension of the
|
||||
// element where the caret is. As we can't get the element height, we create a text node to get
|
||||
// the dimensions on the position.
|
||||
const clonedRange = createSelectionRange(range);
|
||||
const shadowCaret = $(document.createTextNode('|'));
|
||||
clonedRange.insertNode(shadowCaret[0]);
|
||||
clonedRange.selectNode(shadowCaret[0]);
|
||||
const shadowCaret = doc.createTextNode('|');
|
||||
clonedRange.insertNode(shadowCaret);
|
||||
clonedRange.selectNode(shadowCaret);
|
||||
const line = getPositionOfElementOrSelection(clonedRange);
|
||||
shadowCaret.remove();
|
||||
return line;
|
||||
};
|
||||
|
||||
// The caret node is usually a text node, so climb to an element first.
|
||||
const getBodyOf = (node: Node) => {
|
||||
const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
|
||||
return element ? element.closest('body') : null;
|
||||
};
|
||||
|
||||
const createSelectionRange = (range: Range) => {
|
||||
const clonedRange = range.cloneRange();
|
||||
|
||||
|
|
@ -33,7 +45,7 @@ const createSelectionRange = (range: Range) => {
|
|||
return clonedRange;
|
||||
};
|
||||
|
||||
const getPositionOfRepLineAtOffset = (node: any, offset: number) => {
|
||||
const getPositionOfRepLineAtOffset = (node: any, offset: number, doc: Document) => {
|
||||
// it is not a text node, so we cannot make a selection
|
||||
if (node.tagName === 'BR' || node.tagName === 'EMPTY') {
|
||||
return getPositionOfElementOrSelection(node);
|
||||
|
|
@ -43,7 +55,7 @@ const getPositionOfRepLineAtOffset = (node: any, offset: number) => {
|
|||
node = node.nextSibling as any;
|
||||
}
|
||||
|
||||
const newRange = new Range();
|
||||
const newRange = doc.createRange();
|
||||
newRange.setStart(node, offset);
|
||||
newRange.setEnd(node, offset);
|
||||
const linePosition = getPositionOfElementOrSelection(newRange);
|
||||
|
|
@ -66,29 +78,31 @@ const getPositionOfElementOrSelection = (element: Range):Position => {
|
|||
// where is the top of the previous line
|
||||
// [2] the line before is part of another rep line. It's possible this line has different margins
|
||||
// height. So we have to get the exactly position of the line
|
||||
export const getPositionTopOfPreviousBrowserLine = (caretLinePosition: Position, rep: RepModel) => {
|
||||
let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
|
||||
const isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep);
|
||||
export const getPositionTopOfPreviousBrowserLine =
|
||||
(caretLinePosition: Position, rep: RepModel, doc: Document) => {
|
||||
let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
|
||||
const isCaretLineFirstBrowserLine =
|
||||
caretLineIsFirstBrowserLine(caretLinePosition.top, rep, doc);
|
||||
|
||||
// the caret is in the beginning of a rep line, so the previous browser line
|
||||
// is the last line browser line of the a rep line
|
||||
if (isCaretLineFirstBrowserLine) { // [2]
|
||||
const lineBeforeCaretLine = rep.selStart[0] - 1;
|
||||
const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
|
||||
const linePosition =
|
||||
getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep);
|
||||
previousLineTop = linePosition.top;
|
||||
}
|
||||
return previousLineTop;
|
||||
};
|
||||
// the caret is in the beginning of a rep line, so the previous browser line
|
||||
// is the last line browser line of the a rep line
|
||||
if (isCaretLineFirstBrowserLine) { // [2]
|
||||
const lineBeforeCaretLine = rep.selStart[0] - 1;
|
||||
const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
|
||||
const linePosition =
|
||||
getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep, doc);
|
||||
previousLineTop = linePosition.top;
|
||||
}
|
||||
return previousLineTop;
|
||||
};
|
||||
|
||||
const caretLineIsFirstBrowserLine = (caretLineTop: number, rep: RepModel) => {
|
||||
const caretLineIsFirstBrowserLine = (caretLineTop: number, rep: RepModel, doc: Document) => {
|
||||
const caretRepLine = rep.selStart[0];
|
||||
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
|
||||
const firstRootNode = getFirstRootChildNode(lineNode);
|
||||
|
||||
// to get the position of the node we get the position of the first char
|
||||
const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1);
|
||||
const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1, doc);
|
||||
return positionOfFirstRootNode.top === caretLineTop;
|
||||
};
|
||||
|
||||
|
|
@ -101,13 +115,13 @@ const getFirstRootChildNode = (node: RepNode) => {
|
|||
}
|
||||
};
|
||||
|
||||
const getDimensionOfLastBrowserLineOfRepLine = (line: number, rep: RepModel) => {
|
||||
const getDimensionOfLastBrowserLineOfRepLine = (line: number, rep: RepModel, doc: Document) => {
|
||||
const lineNode = rep.lines.atIndex(line).lineNode;
|
||||
const lastRootChildNode = getLastRootChildNode(lineNode);
|
||||
|
||||
// we get the position of the line in the last char of it
|
||||
const lastRootChildNodePosition =
|
||||
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
|
||||
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length, doc);
|
||||
return lastRootChildNodePosition;
|
||||
};
|
||||
|
||||
|
|
@ -127,31 +141,32 @@ const getLastRootChildNode = (node: RepNode) => {
|
|||
// So, we can use the caret line to calculate the bottom of the line.
|
||||
// [2] the next line is part of another rep line.
|
||||
// It's possible this line has different dimensions, so we have to get the exactly dimension of it
|
||||
export const getBottomOfNextBrowserLine = (caretLinePosition: Position, rep: RepModel) => {
|
||||
let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
|
||||
const isCaretLineLastBrowserLine =
|
||||
caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep);
|
||||
export const getBottomOfNextBrowserLine =
|
||||
(caretLinePosition: Position, rep: RepModel, doc: Document) => {
|
||||
let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
|
||||
const isCaretLineLastBrowserLine =
|
||||
caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep, doc);
|
||||
|
||||
// the caret is at the end of a rep line, so we can get the next browser line dimension
|
||||
// using the position of the first char of the next rep line
|
||||
if (isCaretLineLastBrowserLine) { // [2]
|
||||
const nextLineAfterCaretLine = rep.selStart[0] + 1;
|
||||
const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
|
||||
const linePosition =
|
||||
getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep);
|
||||
nextLineBottom = linePosition.bottom;
|
||||
}
|
||||
return nextLineBottom;
|
||||
};
|
||||
// the caret is at the end of a rep line, so we can get the next browser line dimension
|
||||
// using the position of the first char of the next rep line
|
||||
if (isCaretLineLastBrowserLine) { // [2]
|
||||
const nextLineAfterCaretLine = rep.selStart[0] + 1;
|
||||
const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
|
||||
const linePosition =
|
||||
getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep, doc);
|
||||
nextLineBottom = linePosition.bottom;
|
||||
}
|
||||
return nextLineBottom;
|
||||
};
|
||||
|
||||
const caretLineIsLastBrowserLineOfRepLine = (caretLineTop: number, rep: RepModel) => {
|
||||
const caretLineIsLastBrowserLineOfRepLine = (caretLineTop: number, rep: RepModel, doc: Document) => {
|
||||
const caretRepLine = rep.selStart[0];
|
||||
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
|
||||
const lastRootChildNode = getLastRootChildNode(lineNode);
|
||||
|
||||
// we take a rep line and get the position of the last char of it
|
||||
const lastRootChildNodePosition =
|
||||
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
|
||||
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length, doc);
|
||||
return lastRootChildNodePosition.top === caretLineTop;
|
||||
};
|
||||
|
||||
|
|
@ -181,20 +196,21 @@ export const getNextVisibleLine = (line: number, rep: RepModel): number => {
|
|||
|
||||
const isLineVisible = (line: number, rep: RepModel) => rep.lines.atIndex(line).lineNode.offsetHeight > 0;
|
||||
|
||||
const getDimensionOfFirstBrowserLineOfRepLine = (line: number, rep: RepModel) => {
|
||||
const getDimensionOfFirstBrowserLineOfRepLine = (line: number, rep: RepModel, doc: Document) => {
|
||||
const lineNode = rep.lines.atIndex(line).lineNode;
|
||||
const firstRootChildNode = getFirstRootChildNode(lineNode);
|
||||
|
||||
// we can get the position of the line, getting the position of the first char of the rep line
|
||||
const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1);
|
||||
const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1, doc);
|
||||
return firstRootChildNodePosition;
|
||||
};
|
||||
|
||||
const getSelectionRange = () => {
|
||||
if (!window.getSelection) {
|
||||
return;
|
||||
const getSelectionRange = (doc: Document) => {
|
||||
const win = doc.defaultView;
|
||||
if (!win || !win.getSelection) {
|
||||
return null;
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
const selection = win.getSelection();
|
||||
if (selection && selection.type !== 'None' && selection.rangeCount > 0) {
|
||||
return selection.getRangeAt(0);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -31,12 +31,13 @@ import Op from "./Op";
|
|||
const _MAX_LIST_LEVEL = 16;
|
||||
|
||||
import AttributeMap from './AttributeMap';
|
||||
import UNorm from 'unorm';
|
||||
import {subattribution} from './Changeset';
|
||||
import {SmartOpAssembler} from "./SmartOpAssembler";
|
||||
const hooks = require('./pluginfw/hooks');
|
||||
|
||||
const sanitizeUnicode = (s) => UNorm.nfc(s);
|
||||
// NFC-normalize via the native String API (replaces the unmaintained `unorm`
|
||||
// polyfill; String.prototype.normalize is available in every supported runtime).
|
||||
const sanitizeUnicode = (s: string) => s.normalize('NFC');
|
||||
const tagName = (n) => n.tagName && n.tagName.toLowerCase();
|
||||
// supportedElems are Supported natively within Etherpad and don't require a plugin
|
||||
const supportedElems = new Set([
|
||||
|
|
|
|||
|
|
@ -149,6 +149,23 @@ const padeditor = (() => {
|
|||
}
|
||||
});
|
||||
|
||||
// The recovery-token disclosure (#delete-pad-with-token) is rendered for
|
||||
// every session because a token may now be issued under requireAuthentication
|
||||
// too (when no getAuthorId hook pins a durable authorID — issue #7926).
|
||||
// Show it only when the user actually needs a token: when they can already
|
||||
// delete without one (everyone may delete, or they have a durable
|
||||
// authenticated identity) the plain "Delete Pad" button suffices, so hide
|
||||
// the disclosure and all its token wording entirely.
|
||||
$('#delete-pad-with-token').prop(
|
||||
'hidden', !!(window as any).clientVars?.canDeleteWithoutToken);
|
||||
|
||||
// The plain "Delete pad" button is shown whenever this session can delete
|
||||
// without a token (creator on this device, or allowPadDeletionByAllUsers).
|
||||
// It is independent of pad-wide settings so it stays reachable when that
|
||||
// section is disabled (issue #7959).
|
||||
$('#delete-pad').prop(
|
||||
'hidden', !(window as any).clientVars?.canDeletePad);
|
||||
|
||||
// delete pad using a recovery token (second device / no creator cookie)
|
||||
$('#delete-pad-token-submit').on('click', () => {
|
||||
const token = String($('#delete-pad-token-input').val() || '').trim();
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ class PadModeController {
|
|||
private padId: string;
|
||||
private innerHashChangeHandler: (() => void) | null = null;
|
||||
private revObserver: MutationObserver | null = null;
|
||||
// Watches the embedded slider's #ui-slider-bar so saved revisions added live
|
||||
// (NEW_SAVEDREV from a collaborator while we're in history mode) get mirrored
|
||||
// onto the outer slider — clientVars only carries the entry-time snapshot.
|
||||
private savedRevObserver: MutationObserver | null = null;
|
||||
private syncingHash = false;
|
||||
|
||||
// History-mode bridges — populated on enter, torn down on exit.
|
||||
|
|
@ -194,6 +198,11 @@ class PadModeController {
|
|||
}
|
||||
this.revLabel.textContent = '';
|
||||
this.dateLabel.textContent = '';
|
||||
const stars = document.getElementById('history-slider-stars');
|
||||
if (stars) {
|
||||
stars.replaceChildren();
|
||||
stars.dataset.sig = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Restore everything entry-time we stashed: chat message visibility, the
|
||||
|
|
@ -248,6 +257,10 @@ class PadModeController {
|
|||
this.revObserver.disconnect();
|
||||
this.revObserver = null;
|
||||
}
|
||||
if (this.savedRevObserver) {
|
||||
this.savedRevObserver.disconnect();
|
||||
this.savedRevObserver = null;
|
||||
}
|
||||
if (this.iframe) {
|
||||
try {
|
||||
if (this.innerHashChangeHandler && this.iframe.contentWindow) {
|
||||
|
|
@ -374,6 +387,10 @@ class PadModeController {
|
|||
playBtn.classList.toggle('pause', playing);
|
||||
playBtn.setAttribute('aria-pressed', playing ? 'true' : 'false');
|
||||
}
|
||||
// Saved-revision markers depend on the slider max, which is only known
|
||||
// once the inner slider has reported its length — render them here so we
|
||||
// pick up the correct positions on first sync and on any max change.
|
||||
this.renderSavedRevisionStars(innerWin);
|
||||
};
|
||||
// The hook registered earlier in attachInnerBridges already calls
|
||||
// onRevChange — piggyback on it for slider input/timer updates by
|
||||
|
|
@ -386,10 +403,87 @@ class PadModeController {
|
|||
}
|
||||
BS.onSlider(sync);
|
||||
sync(BS.getSliderPosition?.() ?? 0);
|
||||
// Now that the inner slider exists, watch it for live NEW_SAVEDREV stars.
|
||||
this.observeInnerSavedRevisions(innerWin);
|
||||
};
|
||||
registerSync();
|
||||
}
|
||||
|
||||
// Mirror the embedded timeslider's saved revisions onto the outer slider as
|
||||
// clickable star markers (issue #7946). The inner slider draws its own stars
|
||||
// on #ui-slider-bar, but that DOM is hidden in embed mode, so users only see
|
||||
// the outer #history-slider-input — which had no markers.
|
||||
//
|
||||
// The inner #ui-slider-bar .star elements are the live source of truth: the
|
||||
// timeslider keeps them current as NEW_SAVEDREV messages arrive (each carries
|
||||
// a `pos` attribute = revNum), whereas clientVars.savedRevisions is only the
|
||||
// entry-time snapshot. We read positions from those stars and pull labels
|
||||
// from the snapshot where available. A signature guard keeps this cheap when
|
||||
// sync() fires on every scrub; positions are percentage-based so they reflow
|
||||
// on resize for free.
|
||||
private renderSavedRevisionStars(innerWin: Window): void {
|
||||
const inner: any = innerWin as any;
|
||||
const layer = document.getElementById('history-slider-stars');
|
||||
const sliderInput = document.getElementById('history-slider-input') as HTMLInputElement | null;
|
||||
if (!layer || !sliderInput || !innerWin.document) return;
|
||||
|
||||
const max = Number(sliderInput.max) || 0;
|
||||
const revNums = Array.from(innerWin.document.querySelectorAll('#ui-slider-bar .star'))
|
||||
.map((el) => Number(el.getAttribute('pos')))
|
||||
// max === 0 is a valid single-revision pad: only rev 0 belongs there.
|
||||
.filter((n) => Number.isFinite(n) && n >= 0 && (max === 0 ? n === 0 : n <= max));
|
||||
|
||||
if (revNums.length === 0 || max < 0) {
|
||||
if (layer.childElementCount) layer.replaceChildren();
|
||||
layer.dataset.sig = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Labels live in the clientVars snapshot, keyed by revNum.
|
||||
const labels = new Map<number, string>();
|
||||
const snapshot = inner.clientVars?.savedRevisions;
|
||||
if (Array.isArray(snapshot)) {
|
||||
for (const r of snapshot) {
|
||||
const n = Number(r && r.revNum);
|
||||
if (Number.isFinite(n) && r && typeof r.label === 'string' && r.label) labels.set(n, r.label);
|
||||
}
|
||||
}
|
||||
|
||||
const sig = `${max}:${[...revNums].sort((a, b) => a - b).join(',')}`;
|
||||
if (layer.dataset.sig === sig) return;
|
||||
layer.dataset.sig = sig;
|
||||
layer.replaceChildren();
|
||||
|
||||
for (const revNum of revNums) {
|
||||
const frac = max === 0 ? 0 : revNum / max;
|
||||
// A purely visual marker (the layer is aria-hidden): keyboard/screen
|
||||
// reader users already reach any revision via the slider and step
|
||||
// buttons, so we mirror the legacy timeslider's mouse-only stars rather
|
||||
// than inject extra tab stops. The hover title aids mouse users; the
|
||||
// click is a convenience to jump straight to the saved point.
|
||||
const star = document.createElement('span');
|
||||
star.className = 'history-star';
|
||||
star.style.left = `${(frac * 100).toFixed(4)}%`;
|
||||
star.title = labels.get(revNum) || `Revision ${revNum}`;
|
||||
star.addEventListener('click', () => {
|
||||
try { inner.BroadcastSlider?.setSliderPosition?.(revNum); } catch (_e) { /* inner gone */ }
|
||||
});
|
||||
layer.appendChild(star);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render the outer markers whenever the embedded slider adds a star
|
||||
// (NEW_SAVEDREV). Observing the inner #ui-slider-bar covers saved revisions
|
||||
// created live while history mode is open, which sync()'s scrub-driven
|
||||
// callback would otherwise miss until the next slider move.
|
||||
private observeInnerSavedRevisions(innerWin: Window): void {
|
||||
if (this.savedRevObserver) return;
|
||||
const bar = innerWin.document && innerWin.document.getElementById('ui-slider-bar');
|
||||
if (!bar) return;
|
||||
this.savedRevObserver = new MutationObserver(() => { this.renderSavedRevisionStars(innerWin); });
|
||||
this.savedRevObserver.observe(bar, {childList: true});
|
||||
}
|
||||
|
||||
// Capture the live state we'll restore on exit: live chat message
|
||||
// visibility (just the timestamps — actual messages stay), live users
|
||||
// panel HTML, and current Export hrefs.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import {binarySearch} from "./ace2_common";
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const Security = require('security');
|
||||
import * as Security from './security';
|
||||
import jsCookie, {CookiesStatic} from 'js-cookie'
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import {getBottomOfNextBrowserLine, getNextVisibleLine, getPosition, getPositionTopOfPreviousBrowserLine, getPreviousVisibleLine} from './caretPosition';
|
||||
import {Position, RepModel, RepNode, WindowElementWithScrolling} from "./types/RepModel";
|
||||
import {Position, RepModel, RepNode} from "./types/RepModel";
|
||||
|
||||
|
||||
class Scroll {
|
||||
|
|
@ -18,6 +18,15 @@ class Scroll {
|
|||
this.rootDocument = document;
|
||||
}
|
||||
|
||||
// `outerWin` is the ace_outer *iframe element*, not a window — this module is
|
||||
// bundled into the pad's top-level window and ace2_inner.ts hands us the
|
||||
// element. Scrolling therefore has to go through its contentWindow: calling
|
||||
// scrollTo()/scrollBy() on the element itself silently does nothing, because
|
||||
// an iframe element has no scrollable box of its own.
|
||||
_getOuterWin(): Window | null {
|
||||
return this.outerWin.contentWindow;
|
||||
};
|
||||
|
||||
scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(rep: RepModel, isScrollableEvent: boolean, innerHeight: number) {
|
||||
// are we placing the caret on the line at the bottom of viewport?
|
||||
// And if so, do we need to scroll the editor, as defined on the settings.json?
|
||||
|
|
@ -52,7 +61,19 @@ class Scroll {
|
|||
}
|
||||
}
|
||||
|
||||
// The editor document, i.e. the document of the ace_inner iframe nested
|
||||
// inside ace_outer. This module runs in the pad's top-level window, so every
|
||||
// caret measurement has to be made against this document rather than the
|
||||
// ambient one. Resolved on demand because browsers (Firefox in particular)
|
||||
// may replace an iframe's document after load.
|
||||
_getInnerDoc(): Document | null {
|
||||
const innerFrame = this.doc.getElementsByName('ace_inner')[0] as HTMLIFrameElement | undefined;
|
||||
return innerFrame ? innerFrame.contentDocument : null;
|
||||
};
|
||||
|
||||
_isCaretAtTheBottomOfViewport(rep: RepModel) {
|
||||
const innerDoc = this._getInnerDoc();
|
||||
if (!innerDoc) return false;
|
||||
// computing a line position using getBoundingClientRect() is expensive.
|
||||
// (obs: getBoundingClientRect() is called on caretPosition.getPosition())
|
||||
// To avoid that, we only call this function when it is possible that the
|
||||
|
|
@ -66,9 +87,11 @@ class Scroll {
|
|||
this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep);
|
||||
if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) {
|
||||
// check if the caret is in the bottom of the viewport
|
||||
const caretLinePosition = getPosition()!;
|
||||
const caretLinePosition = getPosition(innerDoc);
|
||||
// no caret in the pad (e.g. focus is outside the editor), so nothing to scroll to
|
||||
if (!caretLinePosition) return false;
|
||||
const viewportBottom = this._getViewPortTopBottom().bottom;
|
||||
const nextLineBottom = getBottomOfNextBrowserLine(caretLinePosition, rep);
|
||||
const nextLineBottom = getBottomOfNextBrowserLine(caretLinePosition, rep, innerDoc);
|
||||
return nextLineBottom > viewportBottom;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -124,9 +147,9 @@ class Scroll {
|
|||
};
|
||||
|
||||
_getScrollXY() {
|
||||
const win = this.outerWin as WindowElementWithScrolling;
|
||||
const win = this._getOuterWin();
|
||||
const odoc = this.doc;
|
||||
if (typeof (win.pageYOffset) === 'number') {
|
||||
if (win && typeof (win.pageYOffset) === 'number') {
|
||||
return {
|
||||
x: win.pageXOffset,
|
||||
y: win.pageYOffset,
|
||||
|
|
@ -139,29 +162,33 @@ class Scroll {
|
|||
y: docel.scrollTop,
|
||||
};
|
||||
}
|
||||
return {x: 0, y: 0};
|
||||
};
|
||||
|
||||
getScrollX() {
|
||||
return this._getScrollXY()!.x;
|
||||
return this._getScrollXY().x;
|
||||
};
|
||||
|
||||
getScrollY () {
|
||||
return this._getScrollXY()!.y;
|
||||
return this._getScrollXY().y;
|
||||
};
|
||||
|
||||
setScrollX(x: number) {
|
||||
this.outerWin.scrollTo(x, this.getScrollY());
|
||||
this.setScrollXY(x, this.getScrollY());
|
||||
};
|
||||
|
||||
setScrollY(y: number) {
|
||||
this.outerWin.scrollTo(this.getScrollX(), y);
|
||||
this.setScrollXY(this.getScrollX(), y);
|
||||
};
|
||||
|
||||
setScrollXY(x: number, y: number) {
|
||||
this.outerWin.scrollTo(x, y);
|
||||
const win = this._getOuterWin();
|
||||
if (win) win.scrollTo(x, y);
|
||||
};
|
||||
|
||||
_isCaretAtTheTopOfViewport(rep: RepModel) {
|
||||
const innerDoc = this._getInnerDoc();
|
||||
if (!innerDoc) return false;
|
||||
const caretLine = rep.selStart[0];
|
||||
const linePrevCaretLine = caretLine - 1;
|
||||
const firstLineVisibleBeforeCaretLine =
|
||||
|
|
@ -171,16 +198,18 @@ class Scroll {
|
|||
const lineBeforeCaretLineIsPartiallyVisibleOnViewport =
|
||||
this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
|
||||
if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
|
||||
const caretLinePosition = getPosition(); // get the position of the browser line
|
||||
const caretLinePosition = getPosition(innerDoc); // get the position of the browser line
|
||||
// no caret in the pad (e.g. focus is outside the editor), so nothing to scroll to
|
||||
if (!caretLinePosition) return false;
|
||||
const viewportPosition = this._getViewPortTopBottom();
|
||||
const viewportTop = viewportPosition.top;
|
||||
const viewportBottom = viewportPosition.bottom;
|
||||
const caretLineIsBelowViewportTop = caretLinePosition!.bottom >= viewportTop;
|
||||
const caretLineIsAboveViewportBottom = caretLinePosition!.top < viewportBottom;
|
||||
const caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop;
|
||||
const caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom;
|
||||
const caretLineIsInsideOfViewport =
|
||||
caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
|
||||
if (caretLineIsInsideOfViewport) {
|
||||
const prevLineTop = getPositionTopOfPreviousBrowserLine(caretLinePosition!, rep);
|
||||
const prevLineTop = getPositionTopOfPreviousBrowserLine(caretLinePosition, rep, innerDoc);
|
||||
const previousLineIsAboveViewportTop = prevLineTop < viewportTop;
|
||||
return previousLineIsAboveViewportTop;
|
||||
}
|
||||
|
|
@ -229,7 +258,8 @@ class Scroll {
|
|||
};
|
||||
|
||||
_scrollYPageWithoutAnimation(pixelsToScroll: number) {
|
||||
this.outerWin.scrollBy(0, pixelsToScroll);
|
||||
const win = this._getOuterWin();
|
||||
if (win) win.scrollBy(0, pixelsToScroll);
|
||||
};
|
||||
|
||||
_scrollYPageWithAnimation(pixelsToScroll: number, durationOfAnimationToShowFocusline: number) {
|
||||
|
|
@ -260,12 +290,14 @@ class Scroll {
|
|||
|
||||
|
||||
scrollNodeVerticallyIntoView(rep: RepModel, innerHeight: number) {
|
||||
const innerDoc = this._getInnerDoc();
|
||||
if (!innerDoc) return;
|
||||
const viewport = this._getViewPortTopBottom();
|
||||
|
||||
// when the selection changes outside of the viewport the browser automatically scrolls the line
|
||||
// to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now
|
||||
// So, when the line scrolled gets outside of the viewport we let the browser handle it.
|
||||
const linePosition = getPosition();
|
||||
const linePosition = getPosition(innerDoc);
|
||||
if (linePosition) {
|
||||
const distanceOfTopOfViewport = linePosition.top - viewport.top;
|
||||
const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height;
|
||||
|
|
@ -278,9 +310,11 @@ class Scroll {
|
|||
} else if (caretIsBelowOfViewport) {
|
||||
// setTimeout is required here as line might not be fully rendered onto the pad
|
||||
setTimeout(() => {
|
||||
const outer = window.parent;
|
||||
// scroll to the very end of the pad outer
|
||||
outer.scrollTo(0, outer[0].innerHeight);
|
||||
const outer = this._getOuterWin();
|
||||
// scroll to the very end of the pad outer. The inner frame is as tall
|
||||
// as the whole document, so its scrollHeight is the end of the pad;
|
||||
// scrollTo() clamps to the outer frame's maximum scroll offset.
|
||||
if (outer) outer.scrollTo(0, innerDoc.documentElement.scrollHeight);
|
||||
}, 150);
|
||||
// if the above setTimeout and functionality is removed then hitting an enter
|
||||
// key while on the last line wont be an optimal user experience
|
||||
|
|
|
|||
|
|
@ -1,20 +1,73 @@
|
|||
// @ts-nocheck
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Copyright 2009 Google Inc.
|
||||
* OWASP-style output-escaping helpers.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* Vendored from the `security` npm package (v1.0.0), which has been
|
||||
* unmaintained since 2012. The implementation below is reproduced verbatim
|
||||
* (behaviour is byte-identical) so the dependency can be dropped from core.
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Original work Copyright (c) 2011 Chad Weider, MIT licensed:
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS-IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a
|
||||
* copy of this software and associated documentation files (the "Software"),
|
||||
* to deal in the Software without restriction, including without limitation
|
||||
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
* and/or sell copies of the Software, and to permit persons to whom the
|
||||
* Software is furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
* DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
module.exports = require('security');
|
||||
const HTML_ENTITY_MAP: {[c: string]: string} = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
'/': '/',
|
||||
};
|
||||
|
||||
// OWASP Guidelines: &, <, >, ", ' plus forward slash.
|
||||
const HTML_CHARACTERS_EXPRESSION = /[&"'<>/]/gm;
|
||||
export const escapeHTML = (text: string) => text && text.replace(HTML_CHARACTERS_EXPRESSION,
|
||||
(c: string) => HTML_ENTITY_MAP[c] || c);
|
||||
|
||||
// OWASP Guidelines: escape all non alphanumeric characters in ASCII space.
|
||||
const HTML_ATTRIBUTE_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]/gm;
|
||||
export const escapeHTMLAttribute = (text: string) => text && text.replace(HTML_ATTRIBUTE_CHARACTERS_EXPRESSION,
|
||||
(c: string) => HTML_ENTITY_MAP[c] || `&#x${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)};`);
|
||||
|
||||
// OWASP Guidelines: escape all non alphanumeric characters in ASCII space.
|
||||
// Also include line breaks (for literal).
|
||||
const JAVASCRIPT_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF\u2028\u2029]/gm;
|
||||
export const encodeJavaScriptIdentifier = (text: string) => text && text.replace(JAVASCRIPT_CHARACTERS_EXPRESSION,
|
||||
(c: string) => `\\u${(`0000${c.charCodeAt(0).toString(16)}`).slice(-4)}`);
|
||||
|
||||
export const encodeJavaScriptString = (text: string) => text && `"${encodeJavaScriptIdentifier(text)}"`;
|
||||
|
||||
// This is not great, but it is useful.
|
||||
// NB: the original `security` package used /"(?:\\.|[^"])*"/, where `[^"]` also
|
||||
// matches a backslash and so overlaps with `\\.`, causing exponential
|
||||
// backtracking (ReDoS) on adversarial input. We exclude the backslash from the
|
||||
// character class so the two alternatives are mutually exclusive — this matches
|
||||
// exactly the same well-formed JSON string literals but in linear time.
|
||||
const JSON_STRING_LITERAL_EXPRESSION = /"(?:[^"\\]|\\.)*"/gm;
|
||||
export const encodeJavaScriptData = (object: any) => JSON.stringify(object).replace(JSON_STRING_LITERAL_EXPRESSION,
|
||||
(string: string) => encodeJavaScriptString(JSON.parse(string)));
|
||||
|
||||
// OWASP Guidelines: escape all non alphanumeric characters in ASCII space.
|
||||
const CSS_CHARACTERS_EXPRESSION = /[\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\xFF]/gm;
|
||||
export const encodeCSSIdentifier = (text: string) => text && text.replace(CSS_CHARACTERS_EXPRESSION,
|
||||
(c: string) => `\\${(`000000${c.charCodeAt(0).toString(16)}`).slice(-6)}`);
|
||||
|
||||
export const encodeCSSString = (text: string) => text && `"${encodeCSSIdentifier(text)}"`;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
128
src/tests/backend/specs/OidcProviderSecurity.ts
Normal file
128
src/tests/backend/specs/OidcProviderSecurity.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Unit coverage for the embedded OIDC provider's cookie-signing key derivation
|
||||
* and CORS origin allow-list. Both were reported by `meifukun`:
|
||||
* - the provider historically signed its cookies with the hardcoded key
|
||||
* `['oidc']`, so anyone with the public source could forge valid `.sig`
|
||||
* cookies;
|
||||
* - `clientBasedCORS` returned `true` for every origin, reflecting arbitrary
|
||||
* `Origin` values into `Access-Control-Allow-Origin`.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
import {
|
||||
resolveOidcCookieKeys,
|
||||
isOriginAllowedForOidcClient,
|
||||
} from '../../../node/security/OidcProviderSecurity';
|
||||
|
||||
describe(__filename, function () {
|
||||
describe('resolveOidcCookieKeys', function () {
|
||||
it('never returns the historical hardcoded key', function () {
|
||||
const keys = resolveOidcCookieKeys({sessionKey: 'a-persisted-session-secret'});
|
||||
assert.ok(!keys.includes('oidc'));
|
||||
});
|
||||
|
||||
it('uses operator-supplied cookieKeys when provided', function () {
|
||||
const keys = resolveOidcCookieKeys({cookieKeys: ['k1', 'k2'], sessionKey: 'x'});
|
||||
assert.deepEqual(keys, ['k1', 'k2']);
|
||||
});
|
||||
|
||||
it('ignores empty/invalid entries in cookieKeys and falls through', function () {
|
||||
const keys = resolveOidcCookieKeys({cookieKeys: ['', null as any], sessionKey: 'secret'});
|
||||
assert.equal(keys.length, 1);
|
||||
assert.notEqual(keys[0], 'oidc');
|
||||
assert.ok(keys[0].length >= 32);
|
||||
});
|
||||
|
||||
it('prefers rotated DB-backed secrets over the session-key derivation', function () {
|
||||
const rotated = ['rot-new', 'rot-old'];
|
||||
const keys = resolveOidcCookieKeys({rotatedSecrets: rotated, sessionKey: 'secret'});
|
||||
assert.deepEqual(keys, ['rot-new', 'rot-old']);
|
||||
});
|
||||
|
||||
it('returns the live rotatedSecrets array by reference (so rotation propagates)', function () {
|
||||
// oidc-provider/keygrip holds the array by reference and reads it live on
|
||||
// each sign/verify, so returning the same object means a rotation that
|
||||
// mutates the array in place is picked up without reconstructing the provider.
|
||||
const rotated = ['rot-new'];
|
||||
const keys = resolveOidcCookieKeys({rotatedSecrets: rotated, sessionKey: 'secret'});
|
||||
assert.strictEqual(keys, rotated);
|
||||
});
|
||||
|
||||
it('ignores an empty rotatedSecrets array and falls through', function () {
|
||||
const keys = resolveOidcCookieKeys({rotatedSecrets: [], sessionKey: 'secret'});
|
||||
assert.equal(keys.length, 1);
|
||||
assert.notEqual(keys[0], 'oidc');
|
||||
// fell through to the session-key derivation (stable, deterministic)
|
||||
assert.deepEqual(keys, resolveOidcCookieKeys({sessionKey: 'secret'}));
|
||||
});
|
||||
|
||||
it('lets operator cookieKeys win over rotated secrets', function () {
|
||||
const keys = resolveOidcCookieKeys({
|
||||
cookieKeys: ['operator'], rotatedSecrets: ['rot'], sessionKey: 'secret',
|
||||
});
|
||||
assert.deepEqual(keys, ['operator']);
|
||||
});
|
||||
|
||||
it('derives a stable key from the session secret (survives restart/multi-pod)', function () {
|
||||
const a = resolveOidcCookieKeys({sessionKey: 'secret'});
|
||||
const b = resolveOidcCookieKeys({sessionKey: 'secret'});
|
||||
assert.deepEqual(a, b);
|
||||
});
|
||||
|
||||
it('derives different keys for different session secrets', function () {
|
||||
const a = resolveOidcCookieKeys({sessionKey: 'secret-a'});
|
||||
const b = resolveOidcCookieKeys({sessionKey: 'secret-b'});
|
||||
assert.notDeepEqual(a, b);
|
||||
});
|
||||
|
||||
it('does not reuse the raw session secret as the cookie key', function () {
|
||||
const keys = resolveOidcCookieKeys({sessionKey: 'secret'});
|
||||
assert.ok(!keys.includes('secret'));
|
||||
});
|
||||
|
||||
it('falls back to a fresh random key when no session secret exists', function () {
|
||||
const a = resolveOidcCookieKeys({sessionKey: null});
|
||||
const b = resolveOidcCookieKeys({sessionKey: null});
|
||||
assert.equal(a.length, 1);
|
||||
assert.notEqual(a[0], 'oidc');
|
||||
assert.ok(a[0].length >= 32);
|
||||
assert.notDeepEqual(a, b); // random => different each call
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOriginAllowedForOidcClient', function () {
|
||||
const client = {
|
||||
redirectUris: ['https://app.example.com/admin/', 'https://app.example.com/'],
|
||||
};
|
||||
|
||||
it('allows an origin matching a registered redirect URI', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('https://app.example.com', client), true);
|
||||
});
|
||||
|
||||
it('rejects an unregistered attacker origin', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('https://evil.attacker.com', client), false);
|
||||
});
|
||||
|
||||
it('rejects a look-alike suffix origin (no substring matching)', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('https://app.example.com.evil.com', client), false);
|
||||
});
|
||||
|
||||
it('rejects a scheme mismatch (http vs https)', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('http://app.example.com', client), false);
|
||||
});
|
||||
|
||||
it('rejects when origin is missing', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient(undefined, client), false);
|
||||
});
|
||||
|
||||
it('rejects when client is missing', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('https://app.example.com', null), false);
|
||||
});
|
||||
|
||||
it('rejects when client has no redirect URIs', function () {
|
||||
assert.equal(isOriginAllowedForOidcClient('https://app.example.com', {}), false);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
src/tests/backend/specs/PadManager.ts
Normal file
58
src/tests/backend/specs/PadManager.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
'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);
|
||||
});
|
||||
|
||||
// `:` is the ueberdb key-namespace delimiter (`pad:<id>:revs:<n>`). A pad id
|
||||
// carrying a `:` can address another pad's internal sub-records, so it must
|
||||
// never be a valid pad id. Regression for the copyPad/movePad destinationID
|
||||
// injection (GHSA-wg58-mhwv-35pq).
|
||||
it('rejects ids containing the ueberdb delimiter ":"', async function () {
|
||||
assert.equal(padManager.isValidPadId('victim:revs:0'), false);
|
||||
assert.equal(padManager.isValidPadId('a:b'), false);
|
||||
assert.equal(padManager.isValidPadId('g.s8oes9dhwrvt0zif$bar:revs:0'), 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});
|
||||
|
|
|
|||
57
src/tests/backend/specs/copyPadColonInjection.ts
Normal file
57
src/tests/backend/specs/copyPadColonInjection.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Regression for GHSA-wg58-mhwv-35pq: copyPad/movePad/copyPadWithoutHistory
|
||||
* accepted a `destinationID` containing the ueberdb key delimiter `:`
|
||||
* (e.g. `victim:revs:0`), which bypassed the force=false overwrite guard and
|
||||
* clobbered another pad's internal revision records.
|
||||
*/
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../common');
|
||||
const api = require('../../../node/db/API');
|
||||
const padManager = require('../../../node/db/PadManager');
|
||||
|
||||
describe(__filename, function () {
|
||||
this.timeout(30000);
|
||||
|
||||
before(async function () {
|
||||
await common.init();
|
||||
});
|
||||
|
||||
it('rejects a copyPad destinationID that targets another pad\'s revs record', async function () {
|
||||
const victim = 'wg58_victim';
|
||||
const src = 'wg58_src';
|
||||
await padManager.getPad(victim, 'TOP-SECRET-victim-content\n');
|
||||
await padManager.getPad(src, 'attacker source\n');
|
||||
|
||||
const before = await api.getRevisionChangeset(victim, '0');
|
||||
assert.ok(before, 'victim rev-0 changeset exists before the attack');
|
||||
|
||||
// force=false — the whole point is that `:` bypassed the existence guard.
|
||||
await assert.rejects(
|
||||
api.copyPad(src, `${victim}:revs:0`, false),
|
||||
/valid padId|apierror/i,
|
||||
'copyPad must reject a destinationID containing ":"');
|
||||
|
||||
const after = await api.getRevisionChangeset(victim, '0');
|
||||
assert.equal(after, before, 'victim rev-0 changeset must be untouched');
|
||||
});
|
||||
|
||||
it('rejects copyPadWithoutHistory with a ":" destinationID', async function () {
|
||||
const src = 'wg58_src2';
|
||||
await padManager.getPad(src, 'src2\n');
|
||||
await assert.rejects(
|
||||
api.copyPadWithoutHistory(src, 'other:revs:0', false),
|
||||
/valid padId|apierror/i);
|
||||
});
|
||||
|
||||
it('still allows a normal copyPad to a clean destination', async function () {
|
||||
const src = 'wg58_ok_src';
|
||||
await padManager.getPad(src, 'hello\n');
|
||||
// force=true so the test is idempotent across runs (the backend DB persists
|
||||
// pads between runs); the point here is that a valid destinationID copies.
|
||||
await api.copyPad(src, 'wg58_ok_dst', true);
|
||||
assert.ok(await padManager.doesPadExist('wg58_ok_dst'), 'destination pad exists after copy');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue