mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-19 01:24:19 +00:00
Compare commits
210 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a6109bd1d | ||
|
|
037f4a25b5 | ||
|
|
4755bd3e17 | ||
|
|
05d0f62403 | ||
|
|
3efcbfd358 | ||
|
|
2b5d521923 | ||
|
|
631caf911c | ||
|
|
0aba424357 | ||
|
|
194f144940 | ||
|
|
c045fcf698 | ||
|
|
451df9f01c | ||
|
|
ce27525d97 | ||
|
|
9c48900092 | ||
|
|
173dbb3448 | ||
|
|
24e0c44d91 | ||
|
|
f65701eaaf | ||
|
|
2f766d075d | ||
|
|
2c94ef9493 | ||
|
|
fdc23c658f | ||
|
|
e5d6142061 | ||
|
|
b7d2477840 | ||
|
|
4bc80d1439 | ||
|
|
8a9c0a6e20 | ||
|
|
df1666cffa | ||
|
|
afdd9b9500 | ||
|
|
3b9acd487c | ||
|
|
82d1fd63a5 | ||
|
|
75be62c801 | ||
|
|
ab4c7beded | ||
|
|
be8120b14e | ||
|
|
33160db454 | ||
|
|
9e850e17f3 | ||
|
|
3ac0b0f0a4 | ||
|
|
cce7cc6cd4 | ||
|
|
bce18df898 | ||
|
|
1442aee756 | ||
|
|
00e1892704 | ||
|
|
2ecd748fca | ||
|
|
0cd01fce41 | ||
|
|
40327d59d6 | ||
|
|
b45f60e20a | ||
|
|
f5b61c3388 | ||
|
|
ff0bd2e819 | ||
|
|
8e04130969 | ||
|
|
c8b544802a | ||
|
|
01500ca70c | ||
|
|
7168f14f0a | ||
|
|
01d0b08a4e | ||
|
|
41dc87edf9 | ||
|
|
93e5bcc1e2 | ||
|
|
3c90fa07c3 | ||
|
|
851b1fb613 | ||
|
|
9047aacc0a | ||
|
|
773a9042a7 | ||
|
|
926c94ea8a | ||
|
|
bf5988580e | ||
|
|
b9ec85779f | ||
|
|
acba429e1a | ||
|
|
8c5de8446c | ||
|
|
9d2dae1b63 | ||
|
|
32249d99e2 | ||
|
|
2109c05ad4 | ||
|
|
f6f665ff60 | ||
|
|
b680076534 | ||
|
|
b1792469b1 | ||
|
|
3aa13197e4 | ||
|
|
dfdbceebe2 | ||
|
|
a698e34772 | ||
|
|
ce038b0b0f | ||
|
|
d67aa3ebd3 | ||
|
|
4ba72b3919 | ||
|
|
bfe3fad256 | ||
|
|
3e53962fb5 | ||
|
|
a59b310dbf | ||
|
|
0c0f5a8ec4 | ||
|
|
099de84cd1 | ||
|
|
8a4e1feed8 | ||
|
|
2fdf202089 | ||
|
|
abdfa03f84 | ||
|
|
c8e3248bcd | ||
|
|
4371af77c2 | ||
|
|
2ba72de1de | ||
|
|
6a5ad2d90d | ||
|
|
f79cabe74c | ||
|
|
662678275a | ||
|
|
8e4eb2646a | ||
|
|
e313366b49 | ||
|
|
3cddff7b27 | ||
|
|
817ca2546e | ||
|
|
eefdfc44b9 | ||
|
|
73a9b88bb4 | ||
|
|
a65e3b75a4 | ||
|
|
8b6a0a2f3a | ||
|
|
92e23bf7d4 | ||
|
|
4fe94c9b68 | ||
|
|
59062ce11a | ||
|
|
fb7e7af6c9 | ||
|
|
ddf84fa6f2 | ||
|
|
f47626049c | ||
|
|
1e7c4b8c21 | ||
|
|
ad2e8b18a9 | ||
|
|
12bd199f94 | ||
|
|
d828185efc | ||
|
|
68d322957f | ||
|
|
5b146f0b03 | ||
|
|
67c0e391fd | ||
|
|
b420cf4850 | ||
|
|
e56a33efd0 | ||
|
|
860ab68c04 | ||
|
|
67f7d914f4 | ||
|
|
9af3a4de77 | ||
|
|
bb2ef93a1b | ||
|
|
62b9c711a2 | ||
|
|
53407f090d | ||
|
|
b19ad89eb0 | ||
|
|
809b75ca22 | ||
|
|
d09227b1e7 | ||
|
|
54af41276c | ||
|
|
271ded5e49 | ||
|
|
d6837f9013 | ||
|
|
ef205bf2ac | ||
|
|
c8350782dd | ||
|
|
2c64e85e90 | ||
|
|
78943da92b | ||
|
|
29441cdfba | ||
|
|
1510e1e6f6 | ||
|
|
ab60c80c39 | ||
|
|
3e7645004f | ||
|
|
7ea9970648 | ||
|
|
86c56cf827 | ||
|
|
e417bc1047 | ||
|
|
e1476cf100 | ||
|
|
189ea4a29b | ||
|
|
3e6f9d7bb2 | ||
|
|
2d1a09dbe0 | ||
|
|
7b33892a76 | ||
|
|
9e8f9cb513 | ||
|
|
9af77e7e3a | ||
|
|
61aee16cf6 | ||
|
|
0110d880aa | ||
|
|
6a2673d411 | ||
|
|
a88d2fc275 | ||
|
|
2209e37857 | ||
|
|
4ba2b2953e | ||
|
|
37c8217d4f | ||
|
|
bd627b55fa | ||
|
|
5aebfa8ebf | ||
|
|
1eecaa03bf | ||
|
|
72a7a74915 | ||
|
|
02dad883cd | ||
|
|
5688679fc4 | ||
|
|
1f5946c464 | ||
|
|
e8ff208b32 | ||
|
|
a0e0d776b9 | ||
|
|
ce96504718 | ||
|
|
75600a2598 | ||
|
|
61ed74ec16 | ||
|
|
d426181971 | ||
|
|
eb820ba2fd | ||
|
|
a7016ad7c5 | ||
|
|
d3074e3707 | ||
|
|
d582398826 | ||
|
|
8f2ec5b04c | ||
|
|
42334bcce5 | ||
|
|
854416086e | ||
|
|
8351dad390 | ||
|
|
c4d2467cb3 | ||
|
|
118d48ca9e | ||
|
|
eeac0a6703 | ||
|
|
4b30653a3d | ||
|
|
bfaf442935 | ||
|
|
79e3d46127 | ||
|
|
29ee19bd8a | ||
|
|
98dbba4f1f | ||
|
|
d9dabe352a | ||
|
|
40f0a4021a | ||
|
|
d106ed9c5e | ||
|
|
53aa7a1c5a | ||
|
|
4ff3363b9b | ||
|
|
689dd9d434 | ||
|
|
da00ad7057 | ||
|
|
50cd1d16f4 | ||
|
|
0b392e13da | ||
|
|
6d641c5040 | ||
|
|
58e8978a2d | ||
|
|
3c03794e77 | ||
|
|
4a89af62aa | ||
|
|
7844b216a3 | ||
|
|
887d11cd57 | ||
|
|
9ba21f1151 | ||
|
|
d0180033e4 | ||
|
|
0d40f2d049 | ||
|
|
40eeb214d3 | ||
|
|
1d2374a9ba | ||
|
|
8112b7a05c | ||
|
|
3c4edd1707 | ||
|
|
7bb7d9731b | ||
|
|
9bb39205e3 | ||
|
|
abda0485d3 | ||
|
|
49111f2fc9 | ||
|
|
abff8124e8 | ||
|
|
271eb6ab5c | ||
|
|
4d94b1b465 | ||
|
|
86edd67f58 | ||
|
|
4d998d6ef2 | ||
|
|
f6ab8561ae | ||
|
|
29dac6bfcc | ||
|
|
acc7a5dd4f | ||
|
|
10558ed115 | ||
|
|
228b49f394 |
257 changed files with 18497 additions and 4742 deletions
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
3
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -30,7 +30,8 @@ If applicable, add screenshots to help explain your problem.
|
||||||
- OS: [e.g., Ubuntu 20.04]
|
- OS: [e.g., Ubuntu 20.04]
|
||||||
- Node.js version (`node --version`):
|
- Node.js version (`node --version`):
|
||||||
- npm version (`npm --version`):
|
- npm version (`npm --version`):
|
||||||
- Is the server free of plugins:
|
- Is the server free of plugins:
|
||||||
|
- Are you using any abstraction IE docker?
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
**Desktop (please complete the following information):**
|
||||||
- OS: [e.g. iOS]
|
- OS: [e.g. iOS]
|
||||||
|
|
|
||||||
9
.github/dependabot.yml
vendored
9
.github/dependabot.yml
vendored
|
|
@ -17,4 +17,11 @@ updates:
|
||||||
open-pull-requests-limit: 30
|
open-pull-requests-limit: 30
|
||||||
groups:
|
groups:
|
||||||
dev-dependencies:
|
dev-dependencies:
|
||||||
dependency-type: "development"
|
dependency-type: "development"
|
||||||
|
cooldown:
|
||||||
|
default-days: 1 # fallback for anything not covered below
|
||||||
|
semver-major-days: 7
|
||||||
|
semver-minor-days: 3
|
||||||
|
semver-patch-days: 1
|
||||||
|
include:
|
||||||
|
- "*"
|
||||||
|
|
|
||||||
123
.github/workflows/backend-tests.yml
vendored
123
.github/workflows/backend-tests.yml
vendored
|
|
@ -32,8 +32,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
@ -51,7 +51,7 @@ jobs:
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
-
|
-
|
||||||
name: Install libreoffice
|
name: Install libreoffice
|
||||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||||
with:
|
with:
|
||||||
packages: libreoffice libreoffice-pdfimport
|
packages: libreoffice libreoffice-pdfimport
|
||||||
version: 1.0
|
version: 1.0
|
||||||
|
|
@ -66,24 +66,7 @@ jobs:
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
-
|
-
|
||||||
name: Run the backend tests
|
name: Run the backend tests
|
||||||
env:
|
run: pnpm test
|
||||||
# --report-on-fatalerror and friends write a Node diagnostic report
|
|
||||||
# (V8 stack, libuv handles, OS info) on fatal errors that bypass JS
|
|
||||||
# handlers — the failure mode we've been chasing on Windows + Node
|
|
||||||
# 24 since PR #7663. Reports land in node-report/ and are uploaded
|
|
||||||
# as an artifact if the step fails.
|
|
||||||
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
|
||||||
run: |
|
|
||||||
mkdir -p "${{ github.workspace }}/node-report"
|
|
||||||
pnpm test
|
|
||||||
- name: Upload Node diagnostic reports on failure
|
|
||||||
if: ${{ failure() }}
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
|
||||||
path: node-report/
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
- name: Run the new vitest tests
|
- name: Run the new vitest tests
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: pnpm run test:vitest
|
run: pnpm run test:vitest
|
||||||
|
|
@ -105,8 +88,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
@ -124,7 +107,7 @@ jobs:
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
-
|
-
|
||||||
name: Install libreoffice
|
name: Install libreoffice
|
||||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||||
with:
|
with:
|
||||||
packages: libreoffice libreoffice-pdfimport
|
packages: libreoffice libreoffice-pdfimport
|
||||||
version: 1.0
|
version: 1.0
|
||||||
|
|
@ -146,26 +129,14 @@ jobs:
|
||||||
ep_hash_auth
|
ep_hash_auth
|
||||||
ep_headings2
|
ep_headings2
|
||||||
ep_markdown
|
ep_markdown
|
||||||
ep_readonly_guest
|
ep_guest
|
||||||
ep_set_title_on_pad
|
ep_set_title_on_pad
|
||||||
ep_spellcheck
|
ep_spellcheck
|
||||||
ep_subscript_and_superscript
|
ep_subscript_and_superscript
|
||||||
ep_table_of_contents
|
ep_table_of_contents
|
||||||
-
|
-
|
||||||
name: Run the backend tests
|
name: Run the backend tests
|
||||||
env:
|
run: pnpm test
|
||||||
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
|
||||||
run: |
|
|
||||||
mkdir -p "${{ github.workspace }}/node-report"
|
|
||||||
pnpm test
|
|
||||||
- name: Upload Node diagnostic reports on failure
|
|
||||||
if: ${{ failure() }}
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
|
||||||
path: node-report/
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
- name: Run the new vitest tests
|
- name: Run the new vitest tests
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: pnpm run test:vitest
|
run: pnpm run test:vitest
|
||||||
|
|
@ -180,14 +151,25 @@ jobs:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
# Etherpad requires Node >= 24 (see package.json engines.node).
|
# Etherpad requires Node >= 24 (see package.json engines.node).
|
||||||
node: ${{ fromJSON('[24]') }}
|
# Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's
|
||||||
|
# bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect
|
||||||
|
# path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the
|
||||||
|
# backend suite's heavy localhost connection churn -- the long-standing Windows
|
||||||
|
# backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node
|
||||||
|
# observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0
|
||||||
|
# (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly
|
||||||
|
# because setup-node's default check-latest:false reuses the runner's
|
||||||
|
# pre-cached 24.15.0 for a bare "24".
|
||||||
|
# Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin
|
||||||
|
# back to plain "24" once the fix is across the supported 24.x baseline.
|
||||||
|
node: ${{ fromJSON('["24.16.0"]') }}
|
||||||
name: Windows without plugins
|
name: Windows without plugins
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
@ -218,23 +200,9 @@ jobs:
|
||||||
name: Run the backend tests
|
name: Run the backend tests
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: src
|
working-directory: src
|
||||||
env:
|
# --exit makes mocha call process.exit() after the run so a leaked handle
|
||||||
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
# cannot hang the job on Windows.
|
||||||
run: |
|
run: pnpm test -- --exit
|
||||||
mkdir -p "${{ github.workspace }}/node-report"
|
|
||||||
# --exit forces process.exit(failures) after the suite completes,
|
|
||||||
# closing the post-suite event-loop drain window where Windows +
|
|
||||||
# Node 24 hard-kills the process. Scoped to Windows so Linux/local
|
|
||||||
# runs still surface real handle leaks via natural drain.
|
|
||||||
pnpm test -- --exit
|
|
||||||
- name: Upload Node diagnostic reports on failure
|
|
||||||
if: ${{ failure() }}
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
|
||||||
path: node-report/
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
- name: Run the new vitest tests
|
- name: Run the new vitest tests
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: pnpm run test:vitest
|
run: pnpm run test:vitest
|
||||||
|
|
@ -248,15 +216,26 @@ jobs:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
# Etherpad requires Node >= 24 (see package.json engines.node).
|
# Etherpad requires Node >= 24 (see package.json engines.node).
|
||||||
node: ${{ fromJSON('[24]') }}
|
# Windows pins Node 24.16.0, not the runner-default 24.15.0. Node 24.15.0's
|
||||||
|
# bundled libuv (1.51.0) has a stack buffer overrun in the Windows TCP-connect
|
||||||
|
# path (uv__tcp_connect), proven by a /GS __fastfail full-memory dump under the
|
||||||
|
# backend suite's heavy localhost connection churn -- the long-standing Windows
|
||||||
|
# backend silent-ELIFECYCLE flake (memory corruption, so no JS/Node
|
||||||
|
# observability). Bisect: 24.15.0 crashes (4/4 on 127.0.0.1), 24.16.0
|
||||||
|
# (libuv 1.52.1) is clean (0/8). Stays on the 24 LTS line. Pin explicitly
|
||||||
|
# because setup-node's default check-latest:false reuses the runner's
|
||||||
|
# pre-cached 24.15.0 for a bare "24".
|
||||||
|
# Tracking: https://github.com/nodejs/node/issues/63620 — drop this pin
|
||||||
|
# back to plain "24" once the fix is across the supported 24.x baseline.
|
||||||
|
node: ${{ fromJSON('["24.16.0"]') }}
|
||||||
name: Windows with Plugins
|
name: Windows with Plugins
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
@ -289,7 +268,7 @@ jobs:
|
||||||
ep_hash_auth
|
ep_hash_auth
|
||||||
ep_headings2
|
ep_headings2
|
||||||
ep_markdown
|
ep_markdown
|
||||||
ep_readonly_guest
|
ep_guest
|
||||||
ep_set_title_on_pad
|
ep_set_title_on_pad
|
||||||
ep_spellcheck
|
ep_spellcheck
|
||||||
ep_subscript_and_superscript
|
ep_subscript_and_superscript
|
||||||
|
|
@ -315,23 +294,9 @@ jobs:
|
||||||
name: Run the backend tests
|
name: Run the backend tests
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: src
|
working-directory: src
|
||||||
env:
|
# --exit makes mocha call process.exit() after the run so a leaked handle
|
||||||
NODE_OPTIONS: "--report-on-fatalerror --report-uncaught-exception --report-on-signal --report-compact --report-directory=${{ github.workspace }}/node-report"
|
# cannot hang the job on Windows.
|
||||||
run: |
|
run: pnpm test -- --exit
|
||||||
mkdir -p "${{ github.workspace }}/node-report"
|
|
||||||
# --exit forces process.exit(failures) after the suite completes,
|
|
||||||
# closing the post-suite event-loop drain window where Windows +
|
|
||||||
# Node 24 hard-kills the process. Scoped to Windows so Linux/local
|
|
||||||
# runs still surface real handle leaks via natural drain.
|
|
||||||
pnpm test -- --exit
|
|
||||||
- name: Upload Node diagnostic reports on failure
|
|
||||||
if: ${{ failure() }}
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: node-diagnostic-report-${{ runner.os }}-node${{ matrix.node }}-${{ github.job }}
|
|
||||||
path: node-report/
|
|
||||||
if-no-files-found: ignore
|
|
||||||
retention-days: 7
|
|
||||||
- name: Run the new vitest tests
|
- name: Run the new vitest tests
|
||||||
working-directory: src
|
working-directory: src
|
||||||
run: pnpm run test:vitest
|
run: pnpm run test:vitest
|
||||||
|
|
|
||||||
6
.github/workflows/build-and-deploy-docs.yml
vendored
6
.github/workflows/build-and-deploy-docs.yml
vendored
|
|
@ -36,15 +36,15 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ~/.pnpm-store
|
path: ~/.pnpm-store
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pnpm-store-
|
${{ runner.os }}-pnpm-store-
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache vitepress build
|
name: Cache vitepress build
|
||||||
with:
|
with:
|
||||||
path: doc/.vitepress/cache
|
path: doc/.vitepress/cache
|
||||||
|
|
|
||||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
# We must fetch at least the immediate parents so that if this is
|
# We must fetch at least the immediate parents so that if this is
|
||||||
# a pull request then we can checkout the head.
|
# a pull request then we can checkout the head.
|
||||||
|
|
|
||||||
22
.github/workflows/deb-package.yml
vendored
22
.github/workflows/deb-package.yml
vendored
|
|
@ -15,6 +15,24 @@ on:
|
||||||
- 'src/node/utils/run_cmd.ts'
|
- 'src/node/utils/run_cmd.ts'
|
||||||
- 'src/static/js/pluginfw/**'
|
- 'src/static/js/pluginfw/**'
|
||||||
- 'settings.json.template'
|
- '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:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
ref:
|
ref:
|
||||||
|
|
@ -44,7 +62,7 @@ jobs:
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
ref: ${{ inputs.ref || github.ref }}
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
|
||||||
|
|
@ -328,7 +346,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout etherpad source (for packaging/apt/key.asc)
|
- name: Checkout etherpad source (for packaging/apt/key.asc)
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 'Checkout Repository'
|
- name: 'Checkout Repository'
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- name: 'Dependency Review'
|
- name: 'Dependency Review'
|
||||||
uses: actions/dependency-review-action@v5
|
uses: actions/dependency-review-action@v5
|
||||||
|
|
|
||||||
74
.github/workflows/docker.yml
vendored
74
.github/workflows/docker.yml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Check out
|
name: Check out
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
path: etherpad
|
path: etherpad
|
||||||
-
|
-
|
||||||
|
|
@ -42,7 +42,7 @@ jobs:
|
||||||
tags: ${{ env.TEST_TAG }}
|
tags: ${{ env.TEST_TAG }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
@ -66,7 +66,13 @@ jobs:
|
||||||
name: Test
|
name: Test
|
||||||
working-directory: etherpad
|
working-directory: etherpad
|
||||||
run: |
|
run: |
|
||||||
docker run --rm -d -p 9001:9001 --name test ${{ env.TEST_TAG }}
|
# ADMIN_PASSWORD provisions settings.json.docker's admin user so
|
||||||
|
# the adminSettings_7819 container spec can authenticate against
|
||||||
|
# /admin and the /settings socket. pad.js doesn't touch /admin
|
||||||
|
# so the existing API specs are unaffected.
|
||||||
|
docker run --rm -d -p 9001:9001 \
|
||||||
|
-e ADMIN_PASSWORD=changeme1 \
|
||||||
|
--name test ${{ env.TEST_TAG }}
|
||||||
./bin/installDeps.sh
|
./bin/installDeps.sh
|
||||||
docker logs -f test &
|
docker logs -f test &
|
||||||
while true; do
|
while true; do
|
||||||
|
|
@ -79,6 +85,34 @@ jobs:
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
(cd src && pnpm run test-container)
|
(cd src && pnpm run test-container)
|
||||||
|
# Regression for #7819. adminSettings_7819.ts saves a marker via
|
||||||
|
# the admin /settings socket and intentionally leaves it on
|
||||||
|
# disk. We assert here that the save actually hit the file
|
||||||
|
# (mocha only sees the next `load` reply — this catches a
|
||||||
|
# scenario where the load is served from cache and the file
|
||||||
|
# never actually changed).
|
||||||
|
docker exec test grep -q persist-marker-7819 /opt/etherpad-lite/settings.json || {
|
||||||
|
echo "[#7819] socket-driven save did NOT reach /opt/etherpad-lite/settings.json on disk"
|
||||||
|
docker exec test cat /opt/etherpad-lite/settings.json | head -50
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# Now prove the on-disk file survives an in-place container
|
||||||
|
# restart. This is the scenario a docker-compose user with
|
||||||
|
# `restart: always` hits on every host reboot.
|
||||||
|
docker restart test >/dev/null
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
status=$(docker container inspect -f '{{.State.Health.Status}}' test 2>/dev/null) || { docker logs test; exit 1; }
|
||||||
|
case ${status} in
|
||||||
|
healthy) break;;
|
||||||
|
starting) sleep 2;;
|
||||||
|
*) docker logs test; exit 1;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
docker exec test grep -q persist-marker-7819 /opt/etherpad-lite/settings.json || {
|
||||||
|
echo "[#7819 REGRESSION] settings.json was reset on docker restart — ep_oauth block vanished"
|
||||||
|
docker logs test
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
docker rm -f test
|
docker rm -f test
|
||||||
git clean -dxf .
|
git clean -dxf .
|
||||||
-
|
-
|
||||||
|
|
@ -117,6 +151,32 @@ jobs:
|
||||||
docker volume rm ep7718-plugins >/dev/null 2>&1 || true
|
docker volume rm ep7718-plugins >/dev/null 2>&1 || true
|
||||||
[ "$ok" = "1" ] || exit 1
|
[ "$ok" = "1" ] || exit 1
|
||||||
|
|
||||||
|
-
|
||||||
|
# Regression test: Etherpad container must boot without external
|
||||||
|
# network access (e.g., air-gapped host or restrictive runtime policy).
|
||||||
|
name: Regression — boot with disabled network (--network none)
|
||||||
|
working-directory: etherpad
|
||||||
|
run: |
|
||||||
|
docker run --rm -d \
|
||||||
|
--network none \
|
||||||
|
--name test-offline ${{ env.TEST_TAG }}
|
||||||
|
docker logs -f test-offline &
|
||||||
|
ok=0
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
status=$(docker container inspect -f '{{.State.Health.Status}}' test-offline 2>/dev/null) || {
|
||||||
|
echo "container exited prematurely while offline"
|
||||||
|
docker logs test-offline || true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case ${status} in
|
||||||
|
healthy) ok=1; echo "offline boot healthy after $((i*2))s"; break;;
|
||||||
|
starting) sleep 2;;
|
||||||
|
*) echo "unexpected status: ${status}"; docker logs test-offline || true; break;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
docker rm -f test-offline >/dev/null 2>&1 || true
|
||||||
|
[ "$ok" = "1" ] || exit 1
|
||||||
|
|
||||||
build-test-local-plugin:
|
build-test-local-plugin:
|
||||||
# Regression coverage for #7687: the Docker image's
|
# Regression coverage for #7687: the Docker image's
|
||||||
# `bin/installLocalPlugins.sh` step runs as the `etherpad` user and
|
# `bin/installLocalPlugins.sh` step runs as the `etherpad` user and
|
||||||
|
|
@ -130,7 +190,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Check out
|
name: Check out
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
path: etherpad
|
path: etherpad
|
||||||
-
|
-
|
||||||
|
|
@ -201,7 +261,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Check out
|
name: Check out
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
path: etherpad
|
path: etherpad
|
||||||
-
|
-
|
||||||
|
|
@ -298,7 +358,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Check out
|
name: Check out
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
path: etherpad
|
path: etherpad
|
||||||
-
|
-
|
||||||
|
|
@ -356,7 +416,7 @@ jobs:
|
||||||
enable-url-completion: true
|
enable-url-completion: true
|
||||||
- name: Check out ether-charts
|
- name: Check out ether-charts
|
||||||
if: github.ref == 'refs/heads/develop'
|
if: github.ref == 'refs/heads/develop'
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
path: ether-charts
|
path: ether-charts
|
||||||
repository: ether/ether-charts
|
repository: ether/ether-charts
|
||||||
|
|
|
||||||
108
.github/workflows/downstream-smoke.yml
vendored
Normal file
108
.github/workflows/downstream-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
name: Downstream smoke
|
||||||
|
|
||||||
|
# Boots a real Etherpad from the PR and verifies the separate downstream clients
|
||||||
|
# (Rust terminal editor, Node CLI, desktop/mobile) still round-trip against it.
|
||||||
|
# Phase 1 lands the boot/healthcheck/self-check/teardown harness + manifest; the
|
||||||
|
# per-client matrix activates as each client flips `enabled:true` in clients.json.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- "doc/**"
|
||||||
|
- "docs/**"
|
||||||
|
schedule:
|
||||||
|
- cron: '0 4 * * *' # nightly against the default branch
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
smoke:
|
||||||
|
name: Boot + downstream clients
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
env:
|
||||||
|
PNPM_HOME: ~/.pnpm-store
|
||||||
|
APIKEY: downstream-smoke-key
|
||||||
|
steps:
|
||||||
|
- name: Checkout core (PR)
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- uses: actions/cache@v6
|
||||||
|
name: Cache pnpm store
|
||||||
|
with:
|
||||||
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
name: Install pnpm
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm i --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Boot Etherpad on :9003 (apikey auth)
|
||||||
|
run: |
|
||||||
|
# The template ships a literal "port": 9001 and sso auth; rewrite both.
|
||||||
|
# (PORT env is ignored once the settings file specifies a port.)
|
||||||
|
sed -e 's#"port": 9001,#"port": 9003,#' \
|
||||||
|
-e 's#${AUTHENTICATION_METHOD:sso}#apikey#' \
|
||||||
|
settings.json.template > settings.json
|
||||||
|
# Fail fast if the template format drifted and sed silently no-op'd.
|
||||||
|
grep -q '"port": 9003,' settings.json \
|
||||||
|
|| { echo "::error::port rewrite failed — settings.json.template format changed"; exit 1; }
|
||||||
|
grep -q '"authenticationMethod": "apikey"' settings.json \
|
||||||
|
|| { echo "::error::auth rewrite failed — settings.json.template format changed"; exit 1; }
|
||||||
|
printf '%s' "$APIKEY" > APIKEY.txt
|
||||||
|
pnpm run prod > /tmp/ep.log 2>&1 &
|
||||||
|
echo $! > /tmp/ep.pid
|
||||||
|
echo "booted pid $(cat /tmp/ep.pid)"
|
||||||
|
|
||||||
|
- name: Wait for healthcheck
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -fsS "http://localhost:9003/api/" >/dev/null 2>&1; then
|
||||||
|
echo "server up after ${i} tries"; exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "::error::server did not come up"; tail -50 /tmp/ep.log; exit 1
|
||||||
|
|
||||||
|
- name: Self-check — authenticated create + read roundtrip
|
||||||
|
run: |
|
||||||
|
K="$APIKEY"
|
||||||
|
curl -fsS "http://localhost:9003/api/1/createPad?apikey=${K}&padID=smoke&text=hi%0A" | tee /tmp/create.json
|
||||||
|
grep -q '"code":0' /tmp/create.json
|
||||||
|
curl -fsS "http://localhost:9003/api/1/getText?apikey=${K}&padID=smoke" | tee /tmp/get.json
|
||||||
|
grep -q '"text":"hi' /tmp/get.json
|
||||||
|
|
||||||
|
- name: Generate canonical wire-vectors
|
||||||
|
run: cd src && pnpm run vectors:gen
|
||||||
|
|
||||||
|
# Rust toolchain for the `rust`-kind client (etherpad-pad). Other kinds
|
||||||
|
# use the node+pnpm already set up above.
|
||||||
|
- name: Install Rust toolchain
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Run enabled downstream clients
|
||||||
|
env:
|
||||||
|
SMOKE_URL: http://localhost:9003
|
||||||
|
SMOKE_APIKEY: ${{ env.APIKEY }}
|
||||||
|
run: bash src/tests/downstream/run-clients.sh
|
||||||
|
|
||||||
|
- name: Teardown (by PID, never pkill)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f /tmp/ep.pid ]; then
|
||||||
|
kill "$(cat /tmp/ep.pid)" 2>/dev/null || true
|
||||||
|
echo "killed $(cat /tmp/ep.pid)"
|
||||||
|
fi
|
||||||
6
.github/workflows/frontend-admin-tests.yml
vendored
6
.github/workflows/frontend-admin-tests.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
@ -48,7 +48,7 @@ jobs:
|
||||||
name: Install all dependencies and symlink for ep_etherpad-lite
|
name: Install all dependencies and symlink for ep_etherpad-lite
|
||||||
run: pnpm i
|
run: pnpm i
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v6
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
|
|
|
||||||
28
.github/workflows/frontend-tests.yml
vendored
28
.github/workflows/frontend-tests.yml
vendored
|
|
@ -20,15 +20,15 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pnpm-store-
|
${{ runner.os }}-pnpm-store-
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache Playwright browsers
|
name: Cache Playwright browsers
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
|
|
@ -92,15 +92,15 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pnpm-store-
|
${{ runner.os }}-pnpm-store-
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache Playwright browsers
|
name: Cache Playwright browsers
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
|
|
@ -168,15 +168,15 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pnpm-store-
|
${{ runner.os }}-pnpm-store-
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache Playwright browsers
|
name: Cache Playwright browsers
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
|
|
@ -219,7 +219,7 @@ jobs:
|
||||||
ep_hash_auth
|
ep_hash_auth
|
||||||
ep_headings2
|
ep_headings2
|
||||||
ep_markdown
|
ep_markdown
|
||||||
ep_readonly_guest
|
ep_guest
|
||||||
ep_set_title_on_pad
|
ep_set_title_on_pad
|
||||||
ep_spellcheck
|
ep_spellcheck
|
||||||
ep_subscript_and_superscript
|
ep_subscript_and_superscript
|
||||||
|
|
@ -269,15 +269,15 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pnpm-store-
|
${{ runner.os }}-pnpm-store-
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache Playwright browsers
|
name: Cache Playwright browsers
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
|
|
@ -308,7 +308,7 @@ jobs:
|
||||||
ep_hash_auth
|
ep_hash_auth
|
||||||
ep_headings2
|
ep_headings2
|
||||||
ep_markdown
|
ep_markdown
|
||||||
ep_readonly_guest
|
ep_guest
|
||||||
ep_set_title_on_pad
|
ep_set_title_on_pad
|
||||||
ep_spellcheck
|
ep_spellcheck
|
||||||
ep_subscript_and_superscript
|
ep_subscript_and_superscript
|
||||||
|
|
|
||||||
4
.github/workflows/handleRelease.yml
vendored
4
.github/workflows/handleRelease.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
|
||||||
27
.github/workflows/installer-test.yml
vendored
27
.github/workflows/installer-test.yml
vendored
|
|
@ -23,7 +23,7 @@ jobs:
|
||||||
name: shellcheck
|
name: shellcheck
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Run shellcheck on installer.sh
|
- name: Run shellcheck on installer.sh
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
|
|
@ -38,7 +38,7 @@ jobs:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest]
|
os: [ubuntu-latest, macos-latest]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -70,13 +70,30 @@ jobs:
|
||||||
|
|
||||||
- name: Smoke test - start Etherpad and curl /api
|
- name: Smoke test - start Etherpad and curl /api
|
||||||
shell: bash
|
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:
|
env:
|
||||||
ETHERPAD_DIR: ${{ runner.temp }}/etherpad-installer-test
|
ETHERPAD_DIR: ${{ runner.temp }}/etherpad-installer-test
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
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"
|
cd "$ETHERPAD_DIR"
|
||||||
pnpm run prod >/tmp/etherpad.log 2>&1 &
|
pnpm run prod >/tmp/etherpad.log 2>&1 &
|
||||||
PID=$!
|
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.
|
# Wait up to 60s for the API to come up.
|
||||||
ok=0
|
ok=0
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
|
|
@ -90,17 +107,14 @@ jobs:
|
||||||
if [ "$ok" != "1" ]; then
|
if [ "$ok" != "1" ]; then
|
||||||
echo "Etherpad did not start within 60s. Last 200 lines of log:" >&2
|
echo "Etherpad did not start within 60s. Last 200 lines of log:" >&2
|
||||||
tail -200 /tmp/etherpad.log >&2 || true
|
tail -200 /tmp/etherpad.log >&2 || true
|
||||||
kill "$PID" 2>/dev/null || true
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
kill "$PID" 2>/dev/null || true
|
|
||||||
wait "$PID" 2>/dev/null || true
|
|
||||||
|
|
||||||
installer-windows:
|
installer-windows:
|
||||||
name: end-to-end install (windows-latest)
|
name: end-to-end install (windows-latest)
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
|
|
@ -131,6 +145,7 @@ jobs:
|
||||||
|
|
||||||
- name: Smoke test - start Etherpad and curl /api
|
- name: Smoke test - start Etherpad and curl /api
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
timeout-minutes: 8
|
||||||
env:
|
env:
|
||||||
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
|
ETHERPAD_DIR: ${{ runner.temp }}\etherpad-installer-test
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
14
.github/workflows/load-test.yml
vendored
14
.github/workflows/load-test.yml
vendored
|
|
@ -24,8 +24,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
@ -62,8 +62,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
@ -93,7 +93,7 @@ jobs:
|
||||||
ep_hash_auth
|
ep_hash_auth
|
||||||
ep_headings2
|
ep_headings2
|
||||||
ep_markdown
|
ep_markdown
|
||||||
ep_readonly_guest
|
ep_guest
|
||||||
ep_set_title_on_pad
|
ep_set_title_on_pad
|
||||||
ep_spellcheck
|
ep_spellcheck
|
||||||
ep_subscript_and_superscript
|
ep_subscript_and_superscript
|
||||||
|
|
@ -125,8 +125,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
|
||||||
4
.github/workflows/perform-type-check.yml
vendored
4
.github/workflows/perform-type-check.yml
vendored
|
|
@ -24,8 +24,8 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
|
||||||
4
.github/workflows/rate-limit.yml
vendored
4
.github/workflows/rate-limit.yml
vendored
|
|
@ -27,8 +27,8 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Checkout repository
|
name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
|
||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
repository: ether/etherpad-lite
|
repository: ether/etherpad-lite
|
||||||
path: etherpad
|
path: etherpad
|
||||||
|
|
@ -42,12 +42,12 @@ jobs:
|
||||||
git checkout develop
|
git checkout develop
|
||||||
git reset --hard origin/develop
|
git reset --hard origin/develop
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
repository: ether/ether.github.com
|
repository: ether/ether.github.com
|
||||||
path: ether.github.com
|
path: ether.github.com
|
||||||
token: '${{ secrets.ETHER_RELEASE_TOKEN }}'
|
token: '${{ secrets.ETHER_RELEASE_TOKEN }}'
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
@ -82,7 +82,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
ruby-version: 2.7
|
ruby-version: 2.7
|
||||||
|
|
||||||
- uses: reitzig/actions-asciidoctor@v2.0.4
|
- uses: reitzig/actions-asciidoctor@v2.0.5
|
||||||
with:
|
with:
|
||||||
version: 2.0.18
|
version: 2.0.18
|
||||||
- name: Prepare release
|
- name: Prepare release
|
||||||
|
|
|
||||||
36
.github/workflows/releaseEtherpad.yml
vendored
36
.github/workflows/releaseEtherpad.yml
vendored
|
|
@ -1,9 +1,33 @@
|
||||||
|
# PARKED — npm publish of the core package is not part of the standard release.
|
||||||
|
#
|
||||||
|
# This workflow renames `ep_etherpad-lite` -> `ep_etherpad` and publishes
|
||||||
|
# `./src` to npm. As of 2026-06, that publish serves no load-bearing purpose:
|
||||||
|
# - `ep_etherpad` has 0 dependents on npm and nothing in this repo depends on it;
|
||||||
|
# - plugins import `ep_etherpad-lite` resolved from the LOCAL core install,
|
||||||
|
# and plugin CI clones `ether/etherpad` rather than `npm install`-ing core;
|
||||||
|
# - Etherpad is run via git clone / Docker / zip / snap, never `npm install`.
|
||||||
|
# The publish has been failing (E404 PUT — the `ep_etherpad` package has no OIDC
|
||||||
|
# trusted publisher configured on npmjs.com), which is why npm is stuck at 2.5.0
|
||||||
|
# while 3.x shipped fine without it.
|
||||||
|
#
|
||||||
|
# It is therefore gated behind an explicit `confirm: true` dispatch input so a
|
||||||
|
# stray run fails fast with a clear message instead of a confusing 404. To
|
||||||
|
# actually publish, the npm owner of `ep_etherpad` (samtv12345) must first
|
||||||
|
# configure a trusted publisher: npmjs.com -> ep_etherpad -> Settings ->
|
||||||
|
# Trusted Publisher -> repo `ether/etherpad`, workflow `releaseEtherpad.yml`.
|
||||||
|
# Decision pending: finish that config, or remove this workflow. See AGENTS.MD.
|
||||||
name: releaseEtherpad.yaml
|
name: releaseEtherpad.yaml
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
id-token: write # for npm OIDC trusted publishing
|
id-token: write # for npm OIDC trusted publishing
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
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:
|
env:
|
||||||
PNPM_HOME: ~/.pnpm-store
|
PNPM_HOME: ~/.pnpm-store
|
||||||
|
|
@ -12,8 +36,16 @@ jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
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
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
# OIDC trusted publishing needs npm >= 11.5.1, which requires
|
# OIDC trusted publishing needs npm >= 11.5.1, which requires
|
||||||
|
|
@ -22,7 +54,7 @@ jobs:
|
||||||
registry-url: https://registry.npmjs.org/
|
registry-url: https://registry.npmjs.org/
|
||||||
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
|
- name: Upgrade npm to >=11.5.1 (required for trusted publishing)
|
||||||
run: npm install -g npm@latest
|
run: npm install -g npm@latest
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
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
|
name: Wrapper unit tests
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
- name: Run snap/tests/run-all.sh
|
- name: Run snap/tests/run-all.sh
|
||||||
run: bash snap/tests/run-all.sh
|
run: bash snap/tests/run-all.sh
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ jobs:
|
||||||
needs: wrapper-tests
|
needs: wrapper-tests
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Install snapcraft
|
- name: Install snapcraft
|
||||||
run: sudo snap install --classic 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 }}
|
snap-file: ${{ steps.build.outputs.snap }}
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Build snap
|
- name: Build snap
|
||||||
id: build
|
id: build
|
||||||
|
|
|
||||||
2
.github/workflows/update-plugins.yml
vendored
2
.github/workflows/update-plugins.yml
vendored
|
|
@ -16,7 +16,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out etherpad-lite
|
- name: Check out etherpad-lite
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
name: Install pnpm
|
name: Install pnpm
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,13 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
-
|
-
|
||||||
name: Check out latest release
|
name: Check out latest release
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v7
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
-
|
-
|
||||||
name: Check out latest release tag
|
name: Check out latest release tag
|
||||||
run: git checkout "$(git tag --list 'v*' --sort=-version:refname | head -n1)"
|
run: git checkout "$(git tag --list 'v*' --sort=-version:refname | head -n1)"
|
||||||
- uses: actions/cache@v5
|
- uses: actions/cache@v6
|
||||||
name: Cache pnpm store
|
name: Cache pnpm store
|
||||||
with:
|
with:
|
||||||
path: ${{ env.PNPM_HOME }}
|
path: ${{ env.PNPM_HOME }}
|
||||||
|
|
@ -55,7 +55,7 @@ jobs:
|
||||||
node-version: ${{ matrix.node }}
|
node-version: ${{ matrix.node }}
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
- name: Install libreoffice
|
- name: Install libreoffice
|
||||||
uses: awalsh128/cache-apt-pkgs-action@v1.6.0
|
uses: awalsh128/cache-apt-pkgs-action@v1.6.3
|
||||||
with:
|
with:
|
||||||
packages: libreoffice libreoffice-pdfimport
|
packages: libreoffice libreoffice-pdfimport
|
||||||
version: 1.0
|
version: 1.0
|
||||||
|
|
@ -76,7 +76,7 @@ jobs:
|
||||||
ep_hash_auth
|
ep_hash_auth
|
||||||
ep_headings2
|
ep_headings2
|
||||||
ep_markdown
|
ep_markdown
|
||||||
ep_readonly_guest
|
ep_guest
|
||||||
ep_set_title_on_pad
|
ep_set_title_on_pad
|
||||||
ep_spellcheck
|
ep_spellcheck
|
||||||
ep_subscript_and_superscript
|
ep_subscript_and_superscript
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
[pr_reviewer]
|
|
||||||
run_on_pr_sync = true
|
|
||||||
|
|
||||||
[pr_description]
|
|
||||||
run_on_pr_sync = true
|
|
||||||
32
AGENTS.MD
32
AGENTS.MD
|
|
@ -211,6 +211,38 @@ pnpm run plugins ls # List installed
|
||||||
### Settings
|
### Settings
|
||||||
Configured via `settings.json`. A template is available at `settings.json.template`. Environment variables can override any setting using `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`.
|
Configured via `settings.json`. A template is available at `settings.json.template`. Environment variables can override any setting using `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`.
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
Releases are driven almost entirely by GitHub Actions. A maintainer dispatches **one** workflow; the version bump, tagging, GitHub Release, Docker images, and snap all cascade off the pushed tag. The npm publish is a separate manual dispatch.
|
||||||
|
|
||||||
|
### Prerequisites (check these before dispatching)
|
||||||
|
- **A `# X.Y.Z` changelog section for the _target_ version must already be at the top of `CHANGELOG.md`.** `bin/release.ts` aborts with `No changelog record for X.Y.Z, please create changelog record` if it's missing. Write the section before dispatching.
|
||||||
|
- **All four `package.json` files must agree on the current version:** root `package.json`, `src/package.json`, `admin/package.json`, `bin/package.json`. `release.ts` reads the *current* version from **`src/package.json`** and computes the next with `semver.inc(current, type)`. If the files are out of sync (e.g. one was hand-edited), the computed target is wrong and the changelog guard fails. *(This exact desync — `src`/`bin` left at 3.3.0 while root/admin were 3.2.0 — blocked the 3.3.0 release in June 2026.)*
|
||||||
|
|
||||||
|
### Cutting a release
|
||||||
|
1. **Actions → "Release etherpad"** → Run workflow (`workflow_dispatch`), choose `patch` / `minor` / `major`. Cadence is monthly **minors** (3.0.0 → 3.1.0 → 3.2.0 → …); use `major` only for breaking changes.
|
||||||
|
2. The **Prepare release** step runs `bin/release.ts`, which:
|
||||||
|
- sanity-checks the tree (clean working dir, on `develop`, `develop`/`master` upstreams in sync, `../ether.github.com` cloned & clean on `master`);
|
||||||
|
- bumps the version in all four `package.json` files;
|
||||||
|
- commits `bump version`, merges `develop` → `master`, creates both `X.Y.Z` **and** `vX.Y.Z` tags, merges `master` back to `develop`;
|
||||||
|
- builds and stages the versioned docs into the website repo (see **Documentation** below).
|
||||||
|
3. The **Push after release** step (`bin/push-after-release.sh`) pushes `master`, `develop`, the tag, `--tags`, and the `ether.github.com` docs commit.
|
||||||
|
4. The pushed **`vX.Y.Z` tag auto-triggers** three workflows:
|
||||||
|
- `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 — 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
|
||||||
|
|
||||||
|
Two distinct things, both important:
|
||||||
|
|
||||||
|
**1. Per-PR doc updates — your responsibility in every behaviour-change PR.**
|
||||||
|
The `doc/` workspace holds the user/admin/API docs (Markdown + AsciiDoc). Whenever a PR changes API responses, CLI flags, settings keys, hooks, or error formats, update the relevant files in `doc/` **in the same PR** — don't defer it to release time. The HTTP API reference is `doc/api/http_api.{md,adoc}` (keep both in sync). Preview locally with `pnpm run makeDocs`.
|
||||||
|
|
||||||
|
**2. Release-time versioned docs publishing — automated, no manual step.**
|
||||||
|
During a release, `release.ts` runs `pnpm run makeDocs` (→ `bin/make_docs.ts`) to render `doc/` into `out/doc/`, then copies it into the sibling website repo at `../ether.github.com/public/doc/vX.Y.Z`, bumps that repo's version, and commits `X.Y.Z docs`; `push-after-release.sh` pushes it, publishing the versioned docs on etherpad.org. This requires `ether.github.com` checked out as a **sibling directory** — the **Release etherpad** workflow checks it out automatically. If you ever run `release.ts` by hand, clone it first: `cd .. && git clone git@github.com:ether/ether.github.com.git`.
|
||||||
|
|
||||||
## Monorepo Structure
|
## Monorepo Structure
|
||||||
|
|
||||||
This project uses pnpm workspaces. The workspaces are:
|
This project uses pnpm workspaces. The workspaces are:
|
||||||
|
|
|
||||||
144
CHANGELOG.md
144
CHANGELOG.md
|
|
@ -1,9 +1,152 @@
|
||||||
|
# 3.3.2
|
||||||
|
|
||||||
|
3.3.2 is a bug-fix and dependency-hardening follow-up to 3.3.1. It rounds out the pad-deletion UX rework (suppressing the recovery token for durable identities, keeping the token-less Delete button reachable, and closing a read-only deletion hole), restores the saved-revision markers that went missing from in-pad history mode in 3.3.x, and adds env-var overrides so air-gapped installs can switch off Etherpad's outbound calls without editing the image. It also fixes the `migrateDB` / `importSqlFile` / `migrateDirtyDBtoRealDB` CLI scripts against the promise-based ueberdb2 API, rejects unreachable `.`/`..` pad ids, and clears a batch of dependency security advisories (including CVE-2026-54285). On the CI side it unblocks the installer smoke test (which had been hanging the full 6-hour job ceiling since 3.2.0) and pins ueberdb2 past a startup-exit regression in the packaged boot.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Force `@opentelemetry/core` ≥ 2.8.0 (GHSA-8988-4f7v-96qf / CVE-2026-54285, #7975).** The transitive dep (pulled in via `@elastic/elasticsearch` → `@elastic/transport`) had a `W3CBaggagePropagator.extract()` that did not enforce W3C size limits on inbound baggage headers, allowing unbounded memory allocation. Pinned via a `pnpm-workspace.yaml` override; satisfies the existing `2.x` range with no parent bump.
|
||||||
|
- **Resolve open Dependabot security alerts (#7967).** Refreshes stale override floors and adds new ones via `pnpm-workspace` overrides: `form-data` ≥ 4.0.6, `ws` ≥ 8.21.0, `esbuild` ≥ 0.28.1, `basic-ftp` ≥ 5.3.1 (capped `<6.0.0` to avoid a surprise major on the plugin-install path), `tar` ≥ 7.5.16, `js-yaml` ≥ 4.2.0, `qs` ≥ 6.15.2, `ip-address` ≥ 10.1.1, and `@babel/core` ≥ 7.29.6.
|
||||||
|
- **Reject read-only deletion via token-less paths (part of #7959 / #7960).** Under `allowPadDeletionByAllUsers` a read-only viewer was granted `canDeletePad=true`, and the server's `flagOk`/`creatorOk` branches never checked `session.readonly` — so a read-only link holder could delete a pad without a token. Read-only sessions are now excluded from both the client var and the server's token-less authorization paths; a valid recovery token stays sufficient regardless of session mode.
|
||||||
|
|
||||||
|
### Notable enhancements
|
||||||
|
|
||||||
|
- **Pad deletion — suppress the recovery token for durable identities and relabel the action (#7926 / #7930).** Building on the `allowPadDeletionByAllUsers` suppression, a creator's deletion token is now also withheld when they have a *durable* identity — authenticated (`req.session.user` with a username) **and** the deployment pins that identity to a stable `authorID` via a `getAuthorId` hook — since only then does the creator survive a cookie clear or a different device, making the token redundant. This tightens the previous "require authentication ⇒ always suppress" rule: without `getAuthorId` the authorID still comes from the per-browser cookie, so an authenticated user on a second device is *not* the creator and keeps getting a token. A new `canDeleteWithoutToken` client var hides the whole recovery-token disclosure (label, field, submit) when no token is needed, and the recovery form now renders for all sessions (hidden by default) so an authenticated creator without a durable mapping still has UI to enter their token. `API.createPad` returns a `null` `deletionToken` under `allowPadDeletionByAllUsers`, matching the socket/UI path.
|
||||||
|
- **Offline/air-gapped installs — env-var overrides for the update check, plugin catalog, and updater (#7917, addresses #7911).** Firewalled deployments could not disable Etherpad's outbound calls without editing `settings.json` inside the image. The relevant keys are now wired through the `${ENV:default}` substitution in `settings.json.docker` and `settings.json.template`: `PRIVACY_UPDATE_CHECK`, `PRIVACY_PLUGIN_CATALOG`, `UPDATES_TIER` (`off` = no calls), `UPDATE_SERVER`, plus the docker-only `UPDATES_SOURCE` / `UPDATES_CHANNEL` / `UPDATES_CHECK_INTERVAL_HOURS` / `UPDATES_GITHUB_REPO` / `UPDATES_REQUIRE_ADMIN_FOR_STATUS`. A new "Updates & privacy" section in `doc/docker.md` documents the set; backend tests parse the shipped configs and fail if the `${ENV}` placeholders are dropped. Config, docs, and tests only — no runtime code change.
|
||||||
|
|
||||||
|
### Notable fixes
|
||||||
|
|
||||||
|
- **Pad — keep the token-less Delete button reachable without pad-wide settings (#7959 / #7960).** The token-less `#delete-pad` button was nested inside the `enablePadWideSettings`-gated section, so disabling pad-wide settings removed the only no-token deletion path — and combined with #7926 hiding the token disclosure when no token is needed, a user allowed to delete could be left with no deletion UI at all. The button is now always rendered (hidden by default) and driven by a `canDeletePad` client var (creator or `allowPadDeletionByAllUsers`, excluding read-only sessions), so the plain button and the recovery-token disclosure are mutually coherent and neither depends on pad-wide settings.
|
||||||
|
- **History mode — restore the saved-revision markers (#7946 / #7948).** When #7659 moved the timeslider into the pad as an embedded iframe, the user-facing control became the outer `#history-slider-input`, but the saved-revision stars were still drawn into the now-hidden iframe `#ui-slider-bar`, so "Save Revision" appeared to do nothing in in-pad history mode (a 3.3.x regression). `pad_mode.ts` now bridges the embedded slider's saved revisions onto the outer slider as percentage-positioned, aria-hidden star markers (with click-to-seek for mouse users), and the server's `SAVE_REVISION` handler broadcasts `NEW_SAVEDREV` to the pad room so a revision saved by a collaborator appears live on an already-open history slider. A single revision saved at rev 0 now renders too. Adds Playwright coverage for both the single-client and two-client live paths.
|
||||||
|
- **Import dialog — correct the outdated "no converter" help message (#7988 / #7989).** The notice claimed only plain text and HTML could be imported and linked to the legacy AbiWord wiki, prompting LibreOffice installs for formats that already work natively. Etherpad imports `.txt`, `.html`, `.docx` (via mammoth) and `.etherpad` without LibreOffice; only `.pdf`/`.odt`/`.doc`/`.rtf` still need it. The message now says so and points at the documentation site.
|
||||||
|
- **PadManager — reject unreachable `.` and `..` pad ids (#7962).** `isValidPadId` accepted ids consisting only of URL dot-segments, but per the WHATWG URL standard a browser normalises `/p/.` to `/p/` and `/p/..` to `/`, so such a pad could be created in the database yet never opened or exported. These ids are now rejected, and the admin `deletePad` handler falls back to a raw key purge when `getPad()` throws so any legacy `.`/`..` pad can still be removed.
|
||||||
|
|
||||||
|
### Internal / contributor-facing
|
||||||
|
|
||||||
|
- **CLI — fix the database migration/import scripts against the ueberdb2 promise API (#7982 / #7983).** `migrateDB.ts` opened source and target databases, copied all keys, then resolved without closing either — so under ueberdb2 6.1.x the keep-alive timer kept the process hanging after "Done syncing dbs", and buffered target writes were only guaranteed flushed on `close()`. It now closes both databases (flushing writes, clearing the timer) on success and error paths and exits with an explicit status. `importSqlFile.ts` and `migrateDirtyDBtoRealDB.ts` were ported off the pre-v6 callback API to `await db.init()` / `db.set(k, v)` / `db.close()`, removing two `@ts-ignore`s that hid broken calls and fixing an undefined `length` in a progress log; `tsc --noEmit` on the bin package is now clean.
|
||||||
|
- **CI — stop the installer smoke test hanging the 6-hour job ceiling (#7981).** The "Installer test" had hung on every ubuntu/macOS run since 3.2.0: `pnpm run prod` is a nested launcher, so `kill "$PID"; wait "$PID"` only signalled the outer pnpm and blocked forever if the node server didn't exit on SIGTERM. Teardown now runs the launcher in its own process group, kills the whole group (SIGTERM then SIGKILL), drops the blocking `wait`, and adds an 8-minute `timeout-minutes` backstop to both smoke steps.
|
||||||
|
- **CI — run the Debian-package smoke test on PRs (#7969).** The packaged-boot smoke test previously ran only on push to `develop` — i.e. after merge — which is why the ueberdb2 startup-exit regression turned `develop` red instead of being blocked at PR time. A `pull_request` trigger (scoped to production-footprint paths) now runs the build+smoke job on PRs; the release/apt-publish jobs stay tag-guarded.
|
||||||
|
- **Release — park the non-functional `ep_etherpad` npm publish (#7922).** The `releaseEtherpad` workflow republished `./src` as `ep_etherpad`, a package with zero dependents that nothing in the repo or any deployment path consumes, and it had been failing with E404 (no OIDC trusted publisher configured). The job is now gated behind an explicit `confirm: true` dispatch input so a stray run fails fast with a clear message, with the status documented in the workflow header and `AGENTS.MD`.
|
||||||
|
- **Tests — port the orphaned legacy timeslider specs to Playwright (#7949).** The `src/tests/frontend/specs/` mocha suite is run by no CI workflow, so its timeslider coverage was dead — which is how the #7946 history-mode regression reached a release. The still-meaningful cases (revision labels, export links, deep-link entry) were ported to `frontend-new` Playwright specs re-targeted at the real in-pad UI, and the three now-ported legacy specs were deleted.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `ueberdb2` pinned to `6.1.13`. 6.1.10 rewrote the cache/buffer layer to lazily arm an `.unref()`'d flush timer only when there are dirty keys, so on a fresh empty dirty DB nothing anchored Node's event loop and the packaged (.deb/systemd) boot could exit cleanly (code 0) before `server.listen()` bound the port — failing the Debian-package health check. The dep was pinned back to the last green release (6.1.9, #7969) and then rolled forward to the now-fixed `6.1.13` (#7979), pinned exactly rather than with a caret.
|
||||||
|
- `nodemailer` 8.x → 9.0.1 (#7965 / #7950 / #7976), `mongodb` 7.1.1 → 7.3.0 (#7941), `pg` 8.21.0 → 8.22.0 (#7985), `undici` → 8.5.0 (#7980 etc.), `oidc-provider` 9.8.4 → 9.8.5 (#7973), `pdfkit` 0.19.0 → 0.19.1 (#7945), `semver` 7.8.3 → 7.8.4 (#7943), and `@radix-ui/react-switch` 1.3.0 → 1.3.1 (#7974).
|
||||||
|
- Dev/build dependency group updates (#7964, #7970, #7978, #7987, #7944, #7951, #7952, and others), including `@types/node` 25 → 26, `esbuild` 0.28.0 → 0.28.1, `eslint` 10.4.1 → 10.5.0, `@playwright/test` 1.60 → 1.61, `vitest` 4.1.8 → 4.1.9, and `actions/checkout` 6 → 7 (#7977).
|
||||||
|
|
||||||
|
# 3.3.1
|
||||||
|
|
||||||
|
3.3.1 is a small bug-fix and hardening follow-up to 3.3.0. It closes a stored-XSS vector in the numbered-list `start` attribute, hardens the database layer so a dropped connection to PostgreSQL / Redis / RethinkDB no longer crashes the process (via ueberdb2 6.1.9), and fixes a handful of pad and admin regressions — the iOS dark-mode status bar, the settings language dropdown, the pad-deletion modal under `allowPadDeletionByAllUsers`, and a single unreadable pad blanking the admin Manage-pads list.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Pad editor — escape and integer-coerce the numbered-list `start` attribute (GHSA-f7h5-v9hm-548j, #7937).** A crafted `<ol start>` value flowed unescaped into `domline.ts`, a distinct client-side sink from the export-path fix in 3.3.0's #7905. The value is now integer-coerced and HTML-escaped before it reaches the DOM. A jsdom regression test covers the sink.
|
||||||
|
|
||||||
|
### Notable fixes
|
||||||
|
|
||||||
|
- **Skin — paint the root canvas so iOS dark mode has no white status bar (#7606 / #7931).** iOS Safari paints the top safe area from the `html` root background, which `theme-color` (an Android address-bar hint) does not affect, so dark-mode pads showed a white status-bar strip on iOS. Colibris now sets the root background and `color-scheme` so the safe area matches the editor.
|
||||||
|
- **Settings — show the detected language in the dropdown (#7925 / #7928).** The settings language `<select>` did not reflect the language Etherpad had actually auto-detected; it now shows the active selection.
|
||||||
|
- **Pad — don't issue a deletion token (or show its modal) when `allowPadDeletionByAllUsers` is on (#7929).** With pad deletion open to all users the client still minted a deletion token and surfaced the confirm modal; both are now suppressed in that configuration.
|
||||||
|
- **Admin — one unreadable pad no longer empties the Manage-pads list (#7935 / #7938).** A single pad that failed to read could throw out of the list-hydration path and blank the entire admin Manage-pads view; the read is now guarded per-pad so the rest of the list still renders.
|
||||||
|
|
||||||
|
### Internal / contributor-facing
|
||||||
|
|
||||||
|
- **CI — downstream client compatibility gate (#7923 / #7924 / #7927).** A new gate smoke-tests the published `etherpad-pad`, `etherpad-cli`, and `etherpad-desktop` clients against the server build (Phase 1 + Phase 2), with robust per-client error handling in `run-clients.sh` so one client's failure is reported rather than masking the others.
|
||||||
|
- **CI — verify Etherpad boots offline (#7936).** Adds a test step that confirms a built Etherpad starts with no network access.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `ueberdb2` 6.1.8 → 6.1.9 — PostgreSQL pool errors are now handled and TCP keep-alive is enabled (fixes #7878), and the Redis and RethinkDB drivers attach connection-error handlers so a dropped database connection no longer crashes the Etherpad process.
|
||||||
|
- `semver` 7.8.2 → 7.8.3 (#7933), `rate-limiter-flexible` 11.1.1 → 11.2.0 (#7934), plus a dev-dependencies group update (#7932).
|
||||||
|
|
||||||
|
# 3.3.0
|
||||||
|
|
||||||
|
3.3 is primarily a security-hardening release. A defence-in-depth pass tightens the HTTP API entry points, switches random-id generation to a CSPRNG, escapes exported `data-*` attributes, and flips the shipped Docker deployment defaults so a fresh install no longer boots with implicit credentials or a trusting proxy. Alongside that, the `ep_*` pad-options passthrough that shipped opt-in in 3.0.0 is now on by default, the in-pad timeslider learns to honour the editor's view settings (authorship colours, font family, line numbers), and a long tail of pad-editor layout, RTL, and URL-encoding fixes lands. The release also carries the root-cause fix for the long-standing Windows backend-test "silent ELIFECYCLE" flake.
|
||||||
|
|
||||||
|
### Notable enhancements
|
||||||
|
|
||||||
|
- **Plugin pad options on by default — `settings.enablePluginPadOptions` now defaults to `true` (#7841).** The flag that gates the `ep_*` passthrough on pad options (shipped opt-in in 3.0.0, #7698) is flipped to default-on, so plugins such as `ep_plugin_helpers`' `padToggle` / `padSelect` ride the existing broadcast/persist rail out of the box. This closes `ep_comments_page#422` — stock 3.x deployments `console.warn`ed on every pad load because the helper detected `enablePluginPadOptions === false`. The `settings.json.template` env-var default is flipped to match, so Docker/supervisor configs without an explicit value get the new behaviour. Existing deployments with an explicit `"enablePluginPadOptions": false` keep that value — no migration needed — and the protocol shape is unchanged for older clients.
|
||||||
|
- **Timeslider — honour the editor's view settings (#7899).** The in-pad timeslider now respects `showAuthorshipColors`, `padFontFamily`, and line-numbers, bridged from the pad-settings checkboxes into the embedded timeslider iframe so the two views agree. `nice-select.ts` dispatches a native `change` event after the jQuery trigger so the `addEventListener`-based bridge in `pad_mode.ts` fires (jQuery 3.7.1's `trigger()` does not dispatch native DOM events), and the font-family reset is fixed for jQuery 3 (which ignores a `null` css value). The five ad-hoc listener stores in `pad_mode.ts` are consolidated into one `bindOuter()` path and the three view-setting bridges into a single data-driven `bridgeView()` (refactor only).
|
||||||
|
- **Admin settings — explain env-var substitution and surface auth errors (#7819 / #7826).** Three env-var-only UX improvements driven by #7819 (a Docker operator saved an `ep_oauth` block in the Raw view and reported it "disappeared", not realising `settings.json` on disk is a *template*, not the effective config): a banner above the editor explaining the template/substitution model (rendered only when the loaded file contains a `${VAR}` placeholder); a read-only **Effective** tab exposing the redacted runtime settings the backend already emitted as `resolved` (also gated on `${VAR}`); and an `admin_auth_error` event so a misrouted Traefik+SSO session that isn't admin gets a clear toast instead of a silent "save did nothing". A reconnect-loop guard suppresses the SPA's auto-reconnect once an auth error has been received. No behaviour change for installs without `${VAR}` placeholders.
|
||||||
|
|
||||||
|
### Security hardening
|
||||||
|
|
||||||
|
A defence-in-depth pass across the API, token, export, and deployment surfaces:
|
||||||
|
|
||||||
|
- **HTTP API request handling, random IDs, and plugin loading (#7906).** `pad_utils.randomString` now generates random IDs via `crypto.getRandomValues` (CSPRNG) instead of `Math.random`. `OAuth2Provider` compares passwords with `crypto.timingSafeEqual` on the raw UTF-8 bytes (resolving the CodeQL "insufficient computational effort" alert) behind a uniform failure delay, and looks users up via own-property access only. `API.appendChatMessage` throws `padID does not exist` rather than creating the pad, consistent with the other content API methods. The `/api/2` REST router forwards only the `authorization` header (not the full request header set) and falls back to it whenever the field is falsy, matching the `openapi.ts` handler so both routers authenticate identically. `LinkInstaller` validates plugin dependency names before building filesystem paths from them, and the admin file server returns a generic error while logging details server-side.
|
||||||
|
- **Escape exported `data-*` attributes; warn on default/placeholder credentials (#7905).** `ExportHtml` now escapes the name and value of attributes emitted by the `exportHtmlAdditionalTagsWithData` hook, consistent with the URL/text escaping already applied to exported HTML. `Settings` logs a warning (error level under `NODE_ENV=production`) when an account uses a default/placeholder password from the shipped config, and the check is extended to cover `sso.clients[].client_secret` so enabling SSO without setting `ADMIN_SECRET` / `USER_SECRET` is flagged the same way.
|
||||||
|
- **Docker deployment defaults — require explicit credentials, default `TRUST_PROXY` off (#7907).** The shipped `docker-compose` now requires `ADMIN_PASSWORD` and the database password to be provided explicitly (no implicit fallback) and defaults `TRUST_PROXY` to `false`. Operators relying on the previous implicit defaults must now set these values explicitly.
|
||||||
|
|
||||||
|
### Notable fixes
|
||||||
|
|
||||||
|
- **History mode — lay the timeslider iframe in the editor's flex slot (#7903).** In-pad history mode positioned `#history-frame-mount` as an `inset:0` absolute overlay over `#editorcontainerbox`, which took the iframe out of flow and hid any in-flow side panel (e.g. `ep_webrtc`'s `#rtcbox` video column) beneath it — so history mode and live mode disagreed. The iframe now occupies the same in-flow flex slot the live editor uses, and a latent specificity bug (the `body.history-mode #editorcontainer { display: none }` hide rule was outranked by the two-id layout rule, so the live editor was only ever painted over) is fixed by giving the hide rule matching specificity. Adds a `padmode.spec.ts` regression test.
|
||||||
|
- **Pad editor — restore URL wrapping (#7894 / #7896).** Long URLs in the pad editor overflowed instead of wrapping because the global `a { white-space: nowrap }` rule overrode the wrapping properties on `#innerdocbody`. Explicit `white-space` / `word-wrap` / `overflow-wrap` on `#innerdocbody a` restores wrapping inside the editor while preserving no-wrap for links elsewhere in the UI.
|
||||||
|
- **RTL content option no longer flips the whole page (#7900 / #7901).** The per-pad RTL content option (`rtlIsTrue`) wrote the direction to the top-level `document.documentElement`, flipping the entire page — toolbar and chrome included. The content direction is now applied to the inner editor document (`targetDoc.documentElement`); page direction stays owned by the UI language (`l10n.ts`). Adds a frontend test asserting the inner editor flips while the top-level `<html>` dir is unchanged.
|
||||||
|
- **Pad-wide view settings apply to the creator's own view (#7900 / #7902).** Because a creator is never "enforced upon themselves", a stale personal view-override cookie (e.g. `rtlIsTrue=false` from an earlier toggle) silently masked the pad-wide value they later set, so the control appeared to do nothing on their own screen. Changing a pad-wide view option now syncs the creator's personal pref to the chosen value; the precedence model is unchanged (the creator can still override afterwards via "My view").
|
||||||
|
- **URL view-option params lost to a `padeditor.init` race (#7840 / #7843).** `?showLineNumbers=false` and `?useMonospaceFont=true` were silently clobbered shortly after load — the same race #7464 fixed for `?rtl=false`, but the neighbouring `showLineNumbers` / `noColors` / `useMonospaceFontGlobal` blocks were left at the synchronous-tail site. The fix is generalised to all three (moved into `postAceInit`). Mostly observable in cross-context iframe embeds that start with no `prefs` cookie. Adds `url_view_options.spec.ts`.
|
||||||
|
- **Default welcome text attributed to the system author (#7885 / #7887).** Auto-generated default pad content (`settings.defaultPadText` / `padDefaultContent` hook) carried the creating user's `author` attribute and rendered in their authorship colour, even though they never wrote it. The welcome text's `author` *attribute* is now `Pad.SYSTEM_AUTHOR_ID`, while revision 0's `meta.author` stays the real creator so ownership (pad-wide settings gate, deletion token) is preserved. Explicitly provided text (e.g. HTTP API `createPad` with text + author) keeps the real author.
|
||||||
|
- **URL-encode pad names in the admin 'Open' button and recent pads (#7865 / #7895).** Pad names are `encodeURIComponent`-d in the admin `PadPage` Open href and the colibris recent-pads href, and `decodeURIComponent`-d when read back from the URL pathname; legacy URL-encoded recent-pads names are normalised before re-encoding to prevent double-encoding (`%2F` → `%252F`). The admin Open `window.open` gains `noopener,noreferrer`.
|
||||||
|
- **OIDC — fix broken `OIDCAdapter` flows (#7837).** Repairs the adapter flows and widens the storage type to include `string` for the `userCode` index; adds regression tests.
|
||||||
|
- **Accessibility — dialog titles/descriptions and a missing l10n key (#7835 / #7836).** Adds the `index.code` key referenced by `index.html` but never defined (which produced a "Couldn't find translation key" console error on the landing page), and gives every admin `@radix-ui/react-dialog` `Dialog.Content` a `Dialog.Title` and `Dialog.Description` (visually hidden where there's no visible heading), silencing Radix's a11y warnings. A new backend spec fails CI if any `data-l10n-id` in `src/templates/*.html` is missing from `en.json`.
|
||||||
|
- **Offline/air-gapped Docker boot — stop pnpm self-provisioning a pinned version (issue #7911).** The official image installs pnpm directly (corepack was dropped for Node 25+). Because the image's pnpm intentionally lags the `packageManager` pin in `package.json` (pnpm 11.1.x enforces a minimum-release-age policy the frozen-lockfile build can't satisfy), pnpm treated every call — including the informational `pnpm --version` probe Etherpad runs at startup — as a request to download the pinned build. Behind a firewall that download failed (`Failed to get pnpm version: … Command exited with code 1`), breaking startup. The Dockerfile now sets `pnpm_config_pm_on_fail=ignore`, and the startup probe plus the updater's pnpm-on-PATH checks run with the same flag, so pnpm uses the installed version instead of reaching for the network (without changing which pnpm runs the build-time install). A backend spec fails CI if that guard is dropped while a version gap exists.
|
||||||
|
- **Firefox authorship colours — tag early keystrokes with the right author (#7910).** The inner editor's `thisAuthor` starts empty and is only populated when collab_client's queued `setProperty('userAuthor', userId)` reaches the iframe (applied asynchronously via `pendingInit`). Under Firefox timing the first keystrokes could beat it, so freshly typed text — and early line-attribute changes (lists, headings, alignment) — were tagged `author=''`, which canonicalises to an unattributed insert that the server's pad-corruption guard rejects, dropping the whole change and losing authorship (the intermittent `clear_authorship_color` flake, where undo couldn't restore the author colour). A `getLocalAuthor()` helper now falls back to `clientVars.userId` (the same id, available synchronously) whenever `thisAuthor` is still empty, applied at the text-insert sites and to seed `documentAttributeManager.author`; the intentional clear-authorship path and the server-side guard are unchanged.
|
||||||
|
- **Dark mode — fix the white address bar and the light-flash on load (#7909, issue #7606).** Dark-mode users still saw a white mobile address bar above the dark toolbar, and the whole page flashed light before going dark. Both came from rendering the light state server-side and switching to dark only after the JS bundle ran: iOS Safari reads `theme-color` at parse time and doesn't reliably repaint on a later JS mutation, and the page painted light before the bundle applied the dark skin classes. The server now emits a `prefers-color-scheme`-scoped `theme-color` pair so the address bar is correct at first paint, plus a small blocking `<head>` script that applies the dark skin classes before the stylesheet paints. Both are gated on `enableDarkMode` (default on) and the colibris skin; `pad.ts` still runs on init to wire up the `#options-darkmode` toggle (which now updates every `theme-color` meta) and theme the editor iframes. Applies to the pad and timeslider views.
|
||||||
|
|
||||||
|
### Internal / contributor-facing
|
||||||
|
|
||||||
|
- **Root-caused and fixed the Windows backend-test "silent ELIFECYCLE" flake (#7866).** The ~22% Windows flake — rotating across random spec files, no mocha summary, no JS trace — was diagnosed from a full-memory dump as two distinct causes. (1) A timing-fragile test abandoned by mocha keeps running and later throws an *orphan* unhandled rejection; `server.ts`'s process-global `uncaughtException`/`unhandledRejection` handlers (correct for a real Etherpad process) escalated that into a clean `process.exit`. They are now gated behind `require.main === module`, and the backend-test bootstraps (`common.ts`, `diagnostics.ts`) log orphan rejections instead of rethrowing. (2) A stack-buffer overrun in Node 24.x's bundled libuv Windows TCP-connect path (`uv__tcp_connect`) corrupts memory under the suite's localhost-connection churn; CI pins the Windows backend job to Node **24.16.0** (libuv 1.52.1, the bisected fix), referencing upstream `nodejs/node#63620`. Linux stays on Node 24 LTS.
|
||||||
|
- **Removed the now-unneeded ELIFECYCLE diagnostic scaffolding (#7846 / #7838 / #7842 / #7868).** The OS-level sidecar watcher, the diagnostics heartbeat/running-test pointer, and the mid-test snapshot — added to chase the flake above — are removed now that the cause is known.
|
||||||
|
- **Docs — document the Docker `settings.json` writable-layer and env-var-vs-file semantics (#7819 / #7827).** Two operator-facing gaps surfaced by #7819: that the on-disk `settings.json` is a template (env substitution happens in memory at load time), and that the default compose puts `settings.json` in the container's writable layer with no host mount, so admin edits are lost on `down`/`pull`/watchtower but survive a plain `restart`. Adds prose + a recreate-vs-restart table to `doc/docker.md` and a commented-out opt-in bind mount to the compose files.
|
||||||
|
- **Docs refresh for 3.2.0 (#7888)**, **dropped three redundant top-level files (#7839)**, **dropped a fragile viewport assertion in the enter test (#7845)**, and a backend-test fix-up.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Two major bumps: `redis` 5.12.1 → 6.0.0 (#7869) and `ejs` 5.0.2 → 6.0.1 (#7860).
|
||||||
|
- `ueberdb2` 6.1.2 → 6.1.8, `mssql` 12.5.3 → 12.5.5, `nodemailer` 8.0.7 → 8.0.10, `mysql2` 3.22.3 → 3.22.5 (#7915), `undici` 8.3.0 → 8.4.1 (#7914), `pdfkit` 0.18.0 → 0.19.0 (#7916), `oidc-provider` 9.8.3 → 9.8.4, `@elastic/elasticsearch` 9.4.1 → 9.4.2, `lru-cache` 11.5.0 → 11.5.1, `rate-limiter-flexible` 11.1.0 → 11.1.1, `semver` 7.8.1 → 7.8.2, `js-cookie` 3.0.7 → 3.0.8, `tsx` 4.22.3 → 4.22.4, `@radix-ui/react-switch` 1.2.6 → 1.3.0 (#7913), `@tanstack/react-query` 5.100.11 → 5.101.0 (+ devtools), plus `i18next`, `react-router-dom`, and several dev-dependency group bumps (#7912).
|
||||||
|
|
||||||
|
### Localisation
|
||||||
|
|
||||||
|
- Multiple updates from translatewiki.net.
|
||||||
|
|
||||||
|
# 3.2.0
|
||||||
|
|
||||||
|
3.2 adds first-class reverse-proxy / ingress support — `X-Forwarded-Prefix` and `X-Ingress-Path` are now honoured under `trustProxy`, so Etherpad can live under a subpath (Traefik, Nginx, Kubernetes Ingress) without breaking the PWA manifest, social-meta URLs, or any of the bootstrap asset links. The admin settings page learns to show *resolved* runtime values next to `${VAR:default}` placeholders, the v3.1.0 admin pad-list filter chips now apply server-side (so "show empty pads" no longer returns 0–12 of hundreds), and the v3.1.0 redesigned outdated-version gritter actually fires in production now (the session-based author lookup it shipped with always returned null for pad visitors).
|
||||||
|
|
||||||
|
### Notable enhancements
|
||||||
|
|
||||||
|
- **HTTP — accept `X-Forwarded-Prefix` and `X-Ingress-Path` under `trustProxy` (#7802 / #7806).** With `trustProxy: true`, Etherpad now honours `X-Forwarded-Prefix` (de-facto Traefik / Spring) and `X-Ingress-Path` (Kubernetes Ingress) in addition to the prefix it already inferred from the request path. The shared `sanitizeProxyPath` helper added in 3.1.0 (defence-in-depth: `[A-Za-z0-9_./-]` only, `//+` collapsed, `..` traversal rejected) is extended to the new headers and applied consistently across `/manifest.json`, `socialMeta` `og:url` / `og:image`, and the `index.html` / `pad.html` / `timeslider.html` / `export_html.html` templates (manifest links, jslicense links, reconnect URLs). A pre-existing `..` segment-count miscalculation in `pad.html` / `timeslider.html` that broke the manifest link when served from a deep subpath is also fixed in passing. New end-to-end suite covers the prefix-applied / prefix-ignored matrix under `trustProxy=true|false` for both header names. `settings.json.template` documents the new headers alongside the existing `trustProxy` notes.
|
||||||
|
- **Admin settings — resolved runtime values surface on env-pill chips (#7803 / #7807).** The `/admin/settings` socket payload now carries a new `resolved` field alongside the existing raw-file `results` blob, carrying the actual in-memory settings module run through a new redactor (`AdminSettingsRedact`) that replaces known-sensitive paths (`users.*.password`, `dbSettings.password`, `sso.clients[*].client_secret`, `sessionKey`, …) with `[REDACTED]`. The admin SPA's `EnvPill` renders a `→ active value` chip when the path is resolved, or `→ ••••••` with a redacted tooltip when the server returned the sentinel — so `port: ${PORT:9001}` now shows `→ 9001` (or whatever the live value is) instead of silently falling back to the template default. Old admin SPAs that don't read `resolved` continue to work; the save round-trip is unchanged so `${VAR:default}` literals are still preserved verbatim on disk. The admin test script glob picks up `.test.tsx` alongside `.test.ts` so the new `EnvPill` and `resolveByPath` tests run under `tsx --test`.
|
||||||
|
|
||||||
|
### Notable fixes
|
||||||
|
|
||||||
|
- **Admin pads — filter chip now applies server-side, before pagination (#7798).** The 3.1.0 admin pad-list filter chips (`active` / `recent` / `empty` / `stale`) ran on the client *after* the 12-row page slice had already arrived. On a deployment with hundreds of pads, clicking "empty pads" on page 1 only matched the 0–12 empties that happened to land in the current page, with the pagination footer reporting nonsense totals (reported on a 3.1.0 deployment). The filter is now part of the `padLoad` socket query — pattern filter on names runs first (cheap), metadata hydration for the matching pad universe is gated on a non-`all` filter or a non-`padName` sort and runs under a 16-way concurrency cap (was unbounded `Promise.all`, which fanned out to thousands of in-flight `padManager.getPad()` reads on busy deployments), then the filter chip, then sort + slice. `total` reflects the filtered universe so the footer makes sense. Older admin clients that don't send `filter` keep working — the server defaults to `all`. The `if/else if` ladder that duplicated the hydrate-and-sort loop per `sortBy` is folded into one pipeline with a single comparator switch.
|
||||||
|
- **Pad outdated notice — author now resolved from token cookie, not session (Qodo #7804 / #7805).** The 3.1.0 redesigned outdated-version gritter never fired in production. `resolveRequestAuthor()` looked for an `authorID` on `req.session.user`, which Etherpad does not populate for pad visitors (express-session only carries the admin-login user), so `computeOutdated()` always returned EMPTY. The lookup now mirrors how the socket.io handshake resolves pad-visitor identity — read the HttpOnly `token` (or `<prefix>token`) cookie and call `authorManager.getAuthorId(token, user)` via a dynamic import (same circular-init guard pattern the file already uses for `PadManager`). The admin OpenAPI document gains a `description` note clarifying that `/api/version-status` is a public pad-side endpoint that lives in the admin doc only because it shares the same internal route registration.
|
||||||
|
- **Localisation — silence spurious "could not translate element content" warning (#7797).** `<select data-l10n-id="…">` with `<option>` element children — the pattern used by `ep_headings2`, `ep_align`, `ep_font_size`, `ep_font_family`, … — used to drop into the textContent branch of `html10n.translateNode`, hunt for a text-node child to overwrite, find none, and emit `Unexpected error: could not translate element content for key …` on every pad load. The `SELECT` / `INPUT` / `TEXTAREA` aria-label fallback already lived inside the same else-branch *after* the warning, so the accessible name landed correctly but the noisy console line still fired. Form-control elements now short-circuit into the aria-label path *before* the text-node hunt — aria-label is the only sensible localization target for these elements (a `<select>`'s text is its `<option>` labels, not its own name). Closes the console warning reported on Etherpad 3.1.0.
|
||||||
|
|
||||||
|
### Internal / contributor-facing
|
||||||
|
|
||||||
|
- **CI — swap archived `ep_readonly_guest` for `ep_guest` in the plugin matrix (#7795 / #7808).** `ep_readonly_guest` is archived (read-only on GitHub) and its `authenticate` hook unconditionally swapped `req.session.user` with a read-only guest, *even when the request carried an HTTP Authorization header*. That silently demoted admin login attempts and stalled the `anonymizeAuthorSocket` tests for 14 min/run on every with-plugins CI matrix. The pre-fix theory from 3.1.0 (#7796) blamed `ep_hash_auth.handleMessage`; that was a red herring — `handleMessage` only fires on the `/pad` namespace, never on `/settings`. `ep_guest` is the maintained successor (same authors, same purpose); 1.0.72 on npm already defers to basic auth / admin paths. Swapping the matrix unblocks the `anonymizeAuthorSocket` suite on Linux, Windows, and the upgrade-from-latest-release workflow. The runtime probe added in #7796 stays — it still catches any other authenticate-hook plugin that rejects the test's plain-text credentials (e.g. a future hashed-only plugin).
|
||||||
|
- **Tests — admin `saveSettings` round-trip + cross-restart persistence (#7819 / #7820 / #7821).** The admin `saveSettings` socket had zero direct backend coverage and the existing e2e "restart works" test only checked that the page renders after a restart, neither of which catches a deployment that resets `settings.json` on restart, nor the user-visible workflow that triggered #7819 (add a top-level plugin block via Raw, save, watch it disappear). Three new backend specs (`adminSettingsSave.ts`) verify byte-for-byte write, top-level-block augmentation round-tripping through the next `load`, and `/* */` comments surviving the write path. A new e2e spec mirrors the #7819 user workflow — open Raw, prepend an `ep_oauth`-shaped top-level block, save, `restartEtherpad()`, re-login, confirm the block is still in Raw and surfaces as its own Form-view section (`Ep oauth` from `humanize()`). A separate `docker.yml` job (`adminSettings_7819.ts`) authenticates via `POST /admin-auth/` (always-requireAdmin, regardless of `settings.requireAuthentication`), saves a hand-built minimal-but-viable settings document containing a marker block, `docker exec test grep`s for it, `docker restart`s the container, waits for the health probe, and re-greps. Both checks must pass.
|
||||||
|
- **Bug report template** now asks contributors whether the abstraction in their proposed fix matches the rest of the codebase, to head off premature-generalisation fixes earlier in review.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- `ueberdb2` 6.0.3 → 6.1.2 (two patch releases of cleanup on top of the 6.1.0 `findKeysPaged` API that the 3.1.0 sessionstorage OOM fix relies on).
|
||||||
|
- `semver` 7.8.0 → 7.8.1, `lru-cache` 11.3.6 → 11.5.0, `@elastic/elasticsearch` 9.4.0 → 9.4.1, `pg` 8.20.0 → 8.21.0, `openapi-backend` 5.16.1 → 5.17.0, `tsx` 4.22.0 → 4.22.3, `@tanstack/react-query` 5.100.10 → 5.100.11 + `@tanstack/react-query-devtools`, `js-cookie` 3.0.6 → 3.0.7, plus two dev-dependency group bumps.
|
||||||
|
|
||||||
|
### Localisation
|
||||||
|
|
||||||
|
- Multiple updates from translatewiki.net.
|
||||||
|
|
||||||
# 3.1.0
|
# 3.1.0
|
||||||
|
|
||||||
3.1 ships the self-update programme's **Tier 4 — autonomous in a maintenance window** for real (the v3.0.0 notes documented the design; this is the release the code actually lands in), adds first-class SMTP delivery so update failures email the admin, and bundles a defence-in-depth pass across the HTTP/API entry points. Two new admin-facing escape hatches arrive: a preflight check that aborts an update *before* it mutates the working tree when the target tag's `engines.node` doesn't match the running runtime, and email notifications for every auto-rollback / preflight outcome (not just the terminal `rollback-failed` state).
|
3.1 ships the self-update programme's **Tier 4 — autonomous in a maintenance window** for real (the v3.0.0 notes documented the design; this is the release the code actually lands in), adds first-class SMTP delivery so update failures email the admin, and bundles a defence-in-depth pass across the HTTP/API entry points. Two new admin-facing escape hatches arrive: a preflight check that aborts an update *before* it mutates the working tree when the target tag's `engines.node` doesn't match the running runtime, and email notifications for every auto-rollback / preflight outcome (not just the terminal `rollback-failed` state).
|
||||||
|
|
||||||
### Notable enhancements
|
### Notable enhancements
|
||||||
|
|
||||||
|
- **pad: Outdated-version notice redesigned (#7799).** The persistent "severely outdated" banner is replaced by a dismissable gritter notification (auto-fades after 8 seconds), shown only to a pad's first author and only when the server is at least one minor version behind the latest released version. Patch-only deltas no longer fire the notice. The `vulnerable-below` directive scraping, the `severe` and `vulnerable` enum values, and the `vulnerableBelow` state field have been removed.
|
||||||
|
- **API: `GET /api/version-status` updated (#7799).** Now accepts an optional `?padId=<id>` query parameter and returns `{outdated: "minor" | null, isFirstAuthor: boolean}`. The `severe` and `vulnerable` enum values are gone. Results are cached per `(padId, authorId)` for 60 seconds.
|
||||||
|
|
||||||
- **Self-update — Tier 4 (autonomous in a maintenance window).** Set `updates.tier: "autonomous"` together with `updates.maintenanceWindow: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}` to constrain autonomous updates to a nightly window. The scheduler snaps `scheduledFor` forward to the next window opening when grace would otherwise land outside the window, and defers the fire when the window has closed by the timer callback. Cross-midnight windows (`end < start`) are supported; DST transitions are absorbed by host wall-clock arithmetic. A missing or malformed window degrades the policy to Tier 3 with an explicit `policy.reason` of `maintenance-window-missing` / `maintenance-window-invalid`; an admin banner surfaces the misconfiguration so autonomous behaviour is not silently disabled. The admin update page shows a "Maintenance window" section with the parsed window summary, the next opening, and a "deferred until <iso>" subtitle on the scheduled panel when the timer has been snapped forward. Closes #7607 (#7753).
|
- **Self-update — Tier 4 (autonomous in a maintenance window).** Set `updates.tier: "autonomous"` together with `updates.maintenanceWindow: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}` to constrain autonomous updates to a nightly window. The scheduler snaps `scheduledFor` forward to the next window opening when grace would otherwise land outside the window, and defers the fire when the window has closed by the timer callback. Cross-midnight windows (`end < start`) are supported; DST transitions are absorbed by host wall-clock arithmetic. A missing or malformed window degrades the policy to Tier 3 with an explicit `policy.reason` of `maintenance-window-missing` / `maintenance-window-invalid`; an admin banner surfaces the misconfiguration so autonomous behaviour is not silently disabled. The admin update page shows a "Maintenance window" section with the parsed window summary, the next opening, and a "deferred until <iso>" subtitle on the scheduled panel when the timer has been snapped forward. Closes #7607 (#7753).
|
||||||
- **Updater — real SMTP via nodemailer (new top-level `mail.*` block).** Replaces the "(would send email)" stub. New settings: `mail.host`, `mail.port`, `mail.secure`, `mail.from`, `mail.auth.{user,pass}`. `mail.host=null` keeps the legacy log-only behaviour. The `nodemailer` dependency is lazy-imported on first send so installs that don't configure mail pay no runtime cost; the transport is cached on the full SMTP options tuple so a `reloadSettings()` change to host/port/credentials invalidates the cache. `settings.json.docker` reads `MAIL_HOST` / `MAIL_FROM` / `MAIL_PORT` / `MAIL_SECURE` from env. Send errors are logged warn and swallowed so a transient SMTP failure can never poison the updater state machine.
|
- **Updater — real SMTP via nodemailer (new top-level `mail.*` block).** Replaces the "(would send email)" stub. New settings: `mail.host`, `mail.port`, `mail.secure`, `mail.from`, `mail.auth.{user,pass}`. `mail.host=null` keeps the legacy log-only behaviour. The `nodemailer` dependency is lazy-imported on first send so installs that don't configure mail pay no runtime cost; the transport is cached on the full SMTP options tuple so a `reloadSettings()` change to host/port/credentials invalidates the cache. `settings.json.docker` reads `MAIL_HOST` / `MAIL_FROM` / `MAIL_PORT` / `MAIL_SECURE` from env. Send errors are logged warn and swallowed so a transient SMTP failure can never poison the updater state machine.
|
||||||
- **Updater — preflight against the target tag's `engines.node`.** Before mutating the working tree, `runPreflight` now runs `git show <tag>:package.json` and verifies `process.versions.node` satisfies the target's `engines.node`. A mismatch fails cleanly at `preflight-failed` with the detail `target requires Node >=X, running Y` — no drain, no restart, no rollback. The check runs *after* signature verification so we only trust signed `package.json`. New `PreflightReason: 'node-engine-mismatch'`.
|
- **Updater — preflight against the target tag's `engines.node`.** Before mutating the working tree, `runPreflight` now runs `git show <tag>:package.json` and verifies `process.versions.node` satisfies the target's `engines.node`. A mismatch fails cleanly at `preflight-failed` with the detail `target requires Node >=X, running Y` — no drain, no restart, no rollback. The check runs *after* signature verification so we only trust signed `package.json`. New `PreflightReason: 'node-engine-mismatch'`.
|
||||||
|
|
@ -14,6 +157,7 @@
|
||||||
|
|
||||||
- **Export HTML — ordered-list counter no longer poisoned by a sibling unordered list.** When an ordered-list level was the only consumer of `olItemCounts`, closing *any* list at that depth (including a `<ul>` that happened to share the level) reset the counter to 0. A subsequent unrelated `<ol>` at the same depth then took the "counter exists but is 0" branch and emitted `<ol class="...">` without the `start=` attribute. The reset is now gated on `line.listTypeName === 'number'` so closing an unordered list never touches the ol bookkeeping. Fixes #7786 / #7787 (#7791).
|
- **Export HTML — ordered-list counter no longer poisoned by a sibling unordered list.** When an ordered-list level was the only consumer of `olItemCounts`, closing *any* list at that depth (including a `<ul>` that happened to share the level) reset the counter to 0. A subsequent unrelated `<ol>` at the same depth then took the "counter exists but is 0" branch and emitted `<ol class="...">` without the `start=` attribute. The reset is now gated on `line.listTypeName === 'number'` so closing an unordered list never touches the ol bookkeeping. Fixes #7786 / #7787 (#7791).
|
||||||
- **Export — bad `:rev` returns a meaningful 500 body, not Express's HTML error page.** A non-numeric `:rev` (e.g. `/p/foo/test1/export/txt`) reached `checkValidRev` which throws `CustomError('rev is not a number', 'apierror')`; the message fell through `.catch(next)` and Express's default renderer returned an HTML 500 page. The route handler now catches the apierror and emits `err.message` as a deterministic `text/plain` 500. As a follow-up, `checkValidRev` runs *before* `res.attachment()` so an invalid rev no longer leaves a `Content-Disposition` header in place (browsers were offering to save the error message as a file), and unrelated export failures (conversion, fs, soffice) are surfaced as text/plain rather than the HTML stack page. Fixes #7788 (#7792).
|
- **Export — bad `:rev` returns a meaningful 500 body, not Express's HTML error page.** A non-numeric `:rev` (e.g. `/p/foo/test1/export/txt`) reached `checkValidRev` which throws `CustomError('rev is not a number', 'apierror')`; the message fell through `.catch(next)` and Express's default renderer returned an HTML 500 page. The route handler now catches the apierror and emits `err.message` as a deterministic `text/plain` 500. As a follow-up, `checkValidRev` runs *before* `res.attachment()` so an invalid rev no longer leaves a `Content-Disposition` header in place (browsers were offering to save the error message as a file), and unrelated export failures (conversion, fs, soffice) are surfaced as text/plain rather than the HTML stack page. Fixes #7788 (#7792).
|
||||||
|
- **Session cleanup no longer OOMs on huge sessionstorage tables.** Pre-2.7.3 `SessionStore._cleanup()` issued a single unbounded `findKeys('sessionstorage:*', null)` that materialised every key into one JS array; on a decade-old MariaDB install with millions of stale sessions the mysql2 driver retained the rows on the pool connection while the JS array dominated heap, OOMing the process within ~15 minutes of boot. Cleanup now pages the keyspace in 500-key batches via the new `findKeysPaged` API on ueberdb2 6.1.0 (DB-side ranged query on mysql/postgres, JS-side fallback elsewhere), yielding to the event loop between pages. A single run is capped at 10 minutes; the next scheduled run continues. The defensive cursor-stall guard now logs an error rather than silently aborting, and `DB.init()` fails fast if any required wrapper method is missing (a misconfigured ueberdb2 pin surfaces at boot instead of an hour later). Fixes #7830 (#7831).
|
||||||
|
|
||||||
### Security hardening
|
### Security hardening
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
|
|
||||||
## Pull requests
|
## Pull requests
|
||||||
|
|
||||||
|
* PRs MUST include a non-empty description explaining what the change does and why
|
||||||
|
* PRs without a description should be flagged as incomplete
|
||||||
* the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary
|
* the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary
|
||||||
* PRs should be issued against the **develop** branch: we never pull directly into **master**
|
* PRs should be issued against the **develop** branch: we never pull directly into **master**
|
||||||
* PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing
|
* PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing
|
||||||
|
|
@ -123,7 +125,7 @@ Documentation should be kept up-to-date. This means, whenever you add a new API
|
||||||
You can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet.
|
You can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
Front-end tests are found in the `tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.
|
Front-end tests are found in the `src/tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.
|
||||||
|
|
||||||
Back-end tests can be run from the `src` directory, via `npm test`.
|
Back-end tests can be run from the `src` directory, via `npm test`.
|
||||||
You can use `npm test -- --inspect-brk` and navigate to `edge://inspect` or `chrome://inspect` to debug the tests.
|
You can use `npm test -- --inspect-brk` and navigate to `edge://inspect` or `chrome://inspect` to debug the tests.
|
||||||
|
|
|
||||||
16
Dockerfile
16
Dockerfile
|
|
@ -7,6 +7,11 @@
|
||||||
# docker build --build-arg BUILD_ENV=copy .
|
# docker build --build-arg BUILD_ENV=copy .
|
||||||
ARG BUILD_ENV=git
|
ARG BUILD_ENV=git
|
||||||
|
|
||||||
|
# NOTE: this intentionally lags the "packageManager" pin in package.json. pnpm
|
||||||
|
# 11.1.x enforces the minimum-release-age supply-chain policy during install,
|
||||||
|
# which the frozen-lockfile Docker build can't satisfy, so the image stays on
|
||||||
|
# 11.0.x. The version gap is made harmless by pnpm_config_pm_on_fail=ignore in
|
||||||
|
# the build stage below — see ether/etherpad#7911.
|
||||||
ARG PnpmVersion=11.0.6
|
ARG PnpmVersion=11.0.6
|
||||||
|
|
||||||
FROM node:24-alpine AS adminbuild
|
FROM node:24-alpine AS adminbuild
|
||||||
|
|
@ -28,6 +33,17 @@ RUN pnpm run build:ui
|
||||||
FROM node:24-alpine AS build
|
FROM node:24-alpine AS build
|
||||||
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
|
LABEL maintainer="Etherpad team, https://github.com/ether/etherpad"
|
||||||
|
|
||||||
|
# The image's pnpm intentionally lags the "packageManager" pin (see the ARG
|
||||||
|
# note above). pnpm would otherwise try to self-provision the pinned version on
|
||||||
|
# invocation — including the informational `pnpm --version` probe Etherpad runs
|
||||||
|
# at startup — which fails closed with no network and breaks air-gapped boots
|
||||||
|
# (ether/etherpad#7911). pm_on_fail=ignore makes pnpm use the installed version
|
||||||
|
# instead. Inherited by the development and production runtime stages, so it
|
||||||
|
# also covers the updater's pnpm-on-PATH check and ad-hoc `pnpm` in an exec
|
||||||
|
# shell. It does not change which pnpm runs the build-time install (still the
|
||||||
|
# installed 11.0.x), so the frozen-lockfile build is unaffected.
|
||||||
|
ENV pnpm_config_pm_on_fail=ignore
|
||||||
|
|
||||||
# Set these arguments when building the image from behind a proxy
|
# Set these arguments when building the image from behind a proxy
|
||||||
ARG http_proxy=
|
ARG http_proxy=
|
||||||
ARG https_proxy=
|
ARG https_proxy=
|
||||||
|
|
|
||||||
|
|
@ -172,7 +172,7 @@ volumes:
|
||||||
|
|
||||||
### Docker container
|
### Docker container
|
||||||
|
|
||||||
Find [here](doc/docker.adoc) information on running Etherpad in a container.
|
Find [here](doc/docker.md) information on running Etherpad in a container.
|
||||||
|
|
||||||
## Plugins
|
## Plugins
|
||||||
|
|
||||||
|
|
@ -247,8 +247,8 @@ git -P tag --list "v*" --merged
|
||||||
```
|
```
|
||||||
4. Select the version
|
4. Select the version
|
||||||
```sh
|
```sh
|
||||||
git checkout v2.2.5
|
git checkout v3.2.0
|
||||||
git switch -c v2.2.5
|
git switch -c v3.2.0
|
||||||
```
|
```
|
||||||
5. Upgrade Etherpad
|
5. Upgrade Etherpad
|
||||||
```sh
|
```sh
|
||||||
|
|
|
||||||
|
|
@ -64,3 +64,25 @@ const SettingsPanel = () => {
|
||||||
The admin endpoints are not yet present in the OpenAPI spec — this client is
|
The admin endpoints are not yet present in the OpenAPI spec — this client is
|
||||||
in place to support upcoming work (see issue #7638 follow-up). For now, it is
|
in place to support upcoming work (see issue #7638 follow-up). For now, it is
|
||||||
exercised only by the smoke test.
|
exercised only by the smoke test.
|
||||||
|
|
||||||
|
## Socket.io: `padLoad` query shape
|
||||||
|
|
||||||
|
The admin `/settings` namespace's `padLoad` event accepts a `PadSearchQuery`
|
||||||
|
defined in `src/node/types/PadSearchQuery.ts`:
|
||||||
|
|
||||||
|
| field | type | required | notes |
|
||||||
|
| ------------ | ----------------------------------------------------------------- | -------- | ----- |
|
||||||
|
| `pattern` | `string` | yes | Substring match on pad name. |
|
||||||
|
| `offset` | `number` | yes | Pagination start, in items. Clamped server-side. |
|
||||||
|
| `limit` | `number` | yes | Page size. Capped at 12. |
|
||||||
|
| `ascending` | `boolean` | yes | Sort direction. |
|
||||||
|
| `sortBy` | `"padName" \| "lastEdited" \| "userCount" \| "revisionNumber"` | yes | Column to sort by. |
|
||||||
|
| `filter` | `"all" \| "active" \| "recent" \| "empty" \| "stale"` *(opt.)* | no | Filter chip; defaults to `"all"`. Applied **before** pagination so `total` and the page slice both reflect the filtered universe. Older clients that omit the field get the unchanged `"all"` behaviour. |
|
||||||
|
|
||||||
|
Filter semantics — applied after pattern matching, before sort + slice:
|
||||||
|
|
||||||
|
- `active`: `userCount > 0`
|
||||||
|
- `recent`: edited within the last 7 days
|
||||||
|
- `empty`: `revisionNumber === 0`
|
||||||
|
- `stale`: not edited in the last 365 days
|
||||||
|
- `all` / missing: no further filtering
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "admin",
|
"name": "admin",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.1.0",
|
"version": "3.3.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm gen:api && vite",
|
"dev": "pnpm gen:api && vite",
|
||||||
|
|
@ -11,42 +11,43 @@
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
|
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts'"
|
"test": "pnpm gen:api && tsx --test 'src/**/__tests__/*.test.ts' 'src/**/__tests__/*.test.tsx'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.3.3",
|
||||||
"@tanstack/react-query": "^5.100.10",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
"@tanstack/react-query-devtools": "^5.100.10",
|
"@tanstack/react-query-devtools": "^5.101.2",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"openapi-react-query": "^0.5.4"
|
"openapi-react-query": "^0.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.19",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "^1.2.19",
|
||||||
"@types/react": "^19.2.14",
|
"@radix-ui/react-visually-hidden": "^1.2.7",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
"@typescript-eslint/eslint-plugin": "^8.63.0",
|
||||||
"@typescript-eslint/parser": "^8.59.3",
|
"@typescript-eslint/parser": "^8.63.0",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
"babel-plugin-react-compiler": "19.1.0-rc.3",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.6.0",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.3",
|
||||||
"i18next": "^26.2.0",
|
"i18next": "^26.3.6",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.23.0",
|
||||||
"openapi-typescript": "^7.13.0",
|
"openapi-typescript": "^7.13.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.7",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "^7.81.0",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.9",
|
||||||
"react-router-dom": "^7.15.1",
|
"react-router-dom": "^7.18.1",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"tsx": "^4.22.0",
|
"tsx": "^4.23.0",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.13",
|
"vite": "^8.1.4",
|
||||||
"vite-plugin-babel": "^1.7.1",
|
"vite-plugin-babel": "^1.7.3",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@
|
||||||
"cap-warning": "Showing the first 1000 authors. Narrow your search to see more.",
|
"cap-warning": "Showing the first 1000 authors. Narrow your search to see more.",
|
||||||
"feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.",
|
"feature-disabled-banner": "Author erasure is disabled. Set \"gdprAuthorErasure\": {\"enabled\": true} in settings.json to enable.",
|
||||||
"no-results": "No authors match this search.",
|
"no-results": "No authors match this search.",
|
||||||
|
"confirm-dialog-title": "Confirm author erasure",
|
||||||
|
"confirm-dialog-description": "Review the impact of erasing this author and confirm or cancel.",
|
||||||
"confirm-preview-title": "Erase author {{name}}",
|
"confirm-preview-title": "Erase author {{name}}",
|
||||||
"confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.",
|
"confirm-preview-counters": "Will clear {{tokenMappings}} token mappings, {{externalMappings}} mapper bindings, and {{chatMessages}} chat messages across {{affectedPads}} pads.",
|
||||||
"confirm-irreversible": "This cannot be undone.",
|
"confirm-irreversible": "This cannot be undone.",
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,37 @@ textarea.settings:focus {
|
||||||
border-top: 1px solid #ddd;
|
border-top: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- env-var banner --- */
|
||||||
|
/* Shown only when settings.json contains ${VAR} placeholders. The
|
||||||
|
typical reader is a Docker/K8s operator who has just been surprised
|
||||||
|
by env-var substitution semantics, so the copy must explain rather
|
||||||
|
than warn — visual weight matches a note, not an error. */
|
||||||
|
.settings-envvar-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #f5f7ff;
|
||||||
|
border: 1px solid #c7d2fe;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
.settings-envvar-banner svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #4f46e5;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.settings-envvar-banner strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.settings-envvar-banner p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- mode toggle --- */
|
/* --- mode toggle --- */
|
||||||
.settings-mode-toggle {
|
.settings-mode-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
@ -233,6 +264,32 @@ textarea.settings:focus {
|
||||||
outline: 2px solid var(--accent, #2b8a3e);
|
outline: 2px solid var(--accent, #2b8a3e);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
.settings-widget-env-runtime {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #e6f4ea;
|
||||||
|
color: #1e5631;
|
||||||
|
font-family: "Fira Code", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.settings-widget-env-runtime-redacted {
|
||||||
|
background: #ececec;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.settings-widget-env-runtime-arrow {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.settings-widget-env-runtime-label {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.settings-widget-env-runtime-value {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* Radix switch (boolean) */
|
/* Radix switch (boolean) */
|
||||||
.settings-widget-boolean {
|
.settings-widget-boolean {
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,19 @@ export const App = () => {
|
||||||
|
|
||||||
const settingSocket = connect(`${WS_URL}/settings`, {transports: ['websocket']});
|
const settingSocket = connect(`${WS_URL}/settings`, {transports: ['websocket']});
|
||||||
const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {transports: ['websocket']})
|
const pluginsSocket = connect(`${WS_URL}/pluginfw/installer`, {transports: ['websocket']})
|
||||||
|
// When the server explicitly rejects us for not being admin, we must
|
||||||
|
// NOT reconnect on the subsequent `disconnect` event — otherwise the
|
||||||
|
// socket cycles forever (connect → admin_auth_error → server
|
||||||
|
// disconnect → SPA auto-reconnect → …). The flag is reset on each
|
||||||
|
// successful connect.
|
||||||
|
let authErrored = false;
|
||||||
|
|
||||||
pluginsSocket.on('connect', () => {
|
pluginsSocket.on('connect', () => {
|
||||||
useStore.getState().setPluginsSocket(pluginsSocket);
|
useStore.getState().setPluginsSocket(pluginsSocket);
|
||||||
});
|
});
|
||||||
|
|
||||||
settingSocket.on('connect', () => {
|
settingSocket.on('connect', () => {
|
||||||
|
authErrored = false;
|
||||||
useStore.getState().setSettingsSocket(settingSocket);
|
useStore.getState().setSettingsSocket(settingSocket);
|
||||||
useStore.getState().setShowLoading(false)
|
useStore.getState().setShowLoading(false)
|
||||||
settingSocket.emit('load');
|
settingSocket.emit('load');
|
||||||
|
|
@ -46,7 +53,7 @@ export const App = () => {
|
||||||
|
|
||||||
settingSocket.on('disconnect', (reason) => {
|
settingSocket.on('disconnect', (reason) => {
|
||||||
useStore.getState().setShowLoading(true)
|
useStore.getState().setShowLoading(true)
|
||||||
if (reason === 'io server disconnect') settingSocket.connect();
|
if (reason === 'io server disconnect' && !authErrored) settingSocket.connect();
|
||||||
});
|
});
|
||||||
|
|
||||||
settingSocket.on('settings', (settings: any) => {
|
settingSocket.on('settings', (settings: any) => {
|
||||||
|
|
@ -56,10 +63,14 @@ export const App = () => {
|
||||||
}
|
}
|
||||||
if (settings.results === 'NOT_ALLOWED') {
|
if (settings.results === 'NOT_ALLOWED') {
|
||||||
console.log('Not allowed to view settings.json')
|
console.log('Not allowed to view settings.json')
|
||||||
|
useStore.getState().setResolved(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isJSONClean(settings.results)) setSettings(settings.results);
|
if (isJSONClean(settings.results)) setSettings(settings.results);
|
||||||
else alert(t('admin_settings.invalid_json'));
|
else alert(t('admin_settings.invalid_json'));
|
||||||
|
// The resolved field is optional — old servers won't send it,
|
||||||
|
// and the SPA degrades to today's behaviour when it's null.
|
||||||
|
useStore.getState().setResolved(settings.resolved ?? null);
|
||||||
useStore.getState().setShowLoading(false);
|
useStore.getState().setShowLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -71,6 +82,22 @@ export const App = () => {
|
||||||
const detail = payload?.message ?? '';
|
const detail = payload?.message ?? '';
|
||||||
setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false});
|
setToastState({open: true, title: t('admin_settings.toast.save_failed') + (detail ? ` (${detail})` : ''), success: false});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backend emits this when the connecting socket does not have an
|
||||||
|
// admin session. Previously the server just dropped silently, so the
|
||||||
|
// SPA would sit on a loading screen with no clue. Surface it AND
|
||||||
|
// suppress the automatic reconnect — without this flag the SPA would
|
||||||
|
// immediately reconnect to a socket that will reject it again.
|
||||||
|
settingSocket.on('admin_auth_error', (payload?: {message?: string}) => {
|
||||||
|
authErrored = true;
|
||||||
|
const {setToastState} = useStore.getState();
|
||||||
|
setToastState({
|
||||||
|
open: true,
|
||||||
|
title: payload?.message || t('admin_settings.toast.auth_error'),
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
useStore.getState().setShowLoading(false);
|
||||||
})
|
})
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { NumberInput } from './widgets/NumberInput';
|
||||||
import { BooleanToggle } from './widgets/BooleanToggle';
|
import { BooleanToggle } from './widgets/BooleanToggle';
|
||||||
import { NullChip } from './widgets/NullChip';
|
import { NullChip } from './widgets/NullChip';
|
||||||
import { EnvPill } from './widgets/EnvPill';
|
import { EnvPill } from './widgets/EnvPill';
|
||||||
|
import { useResolvedAt } from '../../store/store';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/** The value node (not the property node). */
|
/** The value node (not the property node). */
|
||||||
|
|
@ -36,6 +37,7 @@ const renderLeaf = (
|
||||||
path: JSONPath,
|
path: JSONPath,
|
||||||
text: string,
|
text: string,
|
||||||
onEdit: (path: JSONPath, value: unknown) => void,
|
onEdit: (path: JSONPath, value: unknown) => void,
|
||||||
|
resolvedValue: unknown,
|
||||||
) => {
|
) => {
|
||||||
if (node.type === 'string') {
|
if (node.type === 'string') {
|
||||||
const raw = text.slice(node.offset, node.offset + node.length);
|
const raw = text.slice(node.offset, node.offset + node.length);
|
||||||
|
|
@ -46,6 +48,7 @@ const renderLeaf = (
|
||||||
placeholder={env}
|
placeholder={env}
|
||||||
path={path}
|
path={path}
|
||||||
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
|
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
|
||||||
|
resolvedValue={resolvedValue}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +87,11 @@ const renderLeaf = (
|
||||||
export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => {
|
export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => {
|
||||||
const path = getNodePath(node);
|
const path = getNodePath(node);
|
||||||
const key = propertyKey(property);
|
const key = propertyKey(property);
|
||||||
|
// useResolvedAt must be called unconditionally for every JsoncNode
|
||||||
|
// render (React hook rules). It's cheap: a shallow zustand selector +
|
||||||
|
// an object-walk that returns undefined when the resolved payload is
|
||||||
|
// absent (old server) — in which case EnvPill simply omits the chip.
|
||||||
|
const resolvedValue = useResolvedAt(path);
|
||||||
|
|
||||||
const anchor = property ?? node;
|
const anchor = property ?? node;
|
||||||
const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length);
|
const fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length);
|
||||||
|
|
@ -163,7 +171,7 @@ export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: P
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
<div className="settings-row-control">
|
<div className="settings-row-control">
|
||||||
{renderLeaf(node, path, text, onEdit)}
|
{renderLeaf(node, path, text, onEdit, resolvedValue)}
|
||||||
</div>
|
</div>
|
||||||
{help && (
|
{help && (
|
||||||
<p className="settings-row-help" id={helpId}>{help}</p>
|
<p className="settings-row-help" id={helpId}>{help}</p>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export type Mode = 'form' | 'raw';
|
export type Mode = 'form' | 'raw' | 'effective';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
mode: Mode;
|
mode: Mode;
|
||||||
onChange: (mode: Mode) => void;
|
onChange: (mode: Mode) => void;
|
||||||
|
// When false, the Effective tab is hidden. We hide it for installs that
|
||||||
|
// aren't using env-var substitution at all — there's no useful difference
|
||||||
|
// between the raw file and the effective in-memory config for them.
|
||||||
|
showEffective?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModeToggle = ({ mode, onChange }: Props) => {
|
export const ModeToggle = ({ mode, onChange, showEffective = false }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="settings-mode-toggle" role="tablist" aria-label={t('admin_settings.mode.aria_label')}>
|
<div className="settings-mode-toggle" role="tablist" aria-label={t('admin_settings.mode.aria_label')}>
|
||||||
|
|
@ -31,6 +35,19 @@ export const ModeToggle = ({ mode, onChange }: Props) => {
|
||||||
>
|
>
|
||||||
<Trans i18nKey="admin_settings.mode.raw" />
|
<Trans i18nKey="admin_settings.mode.raw" />
|
||||||
</button>
|
</button>
|
||||||
|
{showEffective && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'effective'}
|
||||||
|
data-testid="mode-toggle-effective"
|
||||||
|
className={mode === 'effective' ? 'active' : ''}
|
||||||
|
onClick={() => onChange('effective')}
|
||||||
|
title={t('admin_settings.mode.effective_tooltip')}
|
||||||
|
>
|
||||||
|
<Trans i18nKey="admin_settings.mode.effective" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,22 +3,29 @@ import { useTranslation } from 'react-i18next';
|
||||||
import type { JSONPath } from 'jsonc-parser';
|
import type { JSONPath } from 'jsonc-parser';
|
||||||
import type { EnvPlaceholder } from '../envPill';
|
import type { EnvPlaceholder } from '../envPill';
|
||||||
|
|
||||||
|
const REDACTED = '[REDACTED]';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
placeholder: EnvPlaceholder;
|
placeholder: EnvPlaceholder;
|
||||||
path: JSONPath;
|
path: JSONPath;
|
||||||
onChange: (newDefault: string) => void;
|
onChange: (newDefault: string) => void;
|
||||||
|
resolvedValue?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitize = (s: string) => s.replace(/[}]/g, '');
|
const sanitize = (s: string) => s.replace(/[}]/g, '');
|
||||||
|
|
||||||
export const EnvPill = ({ placeholder, path, onChange }: Props) => {
|
const formatDisplay = (v: unknown): string => {
|
||||||
|
if (v === null) return 'null';
|
||||||
|
if (typeof v === 'string') return v;
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EnvPill = ({ placeholder, path, onChange, resolvedValue }: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const initial = placeholder.defaultValue ?? '';
|
const initial = placeholder.defaultValue ?? '';
|
||||||
const [draft, setDraft] = useState(initial);
|
const [draft, setDraft] = useState(initial);
|
||||||
const focused = useRef(false);
|
const focused = useRef(false);
|
||||||
|
|
||||||
// Sync local draft from upstream (server canonicalisation, raw-mode edit)
|
|
||||||
// only while the input isn't focused so we don't trample mid-typing.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!focused.current) setDraft(initial);
|
if (!focused.current) setDraft(initial);
|
||||||
}, [initial]);
|
}, [initial]);
|
||||||
|
|
@ -26,6 +33,13 @@ export const EnvPill = ({ placeholder, path, onChange }: Props) => {
|
||||||
const id = `field-${path.join('.')}`;
|
const id = `field-${path.join('.')}`;
|
||||||
const testid = `env-${path.join('.')}`;
|
const testid = `env-${path.join('.')}`;
|
||||||
|
|
||||||
|
// Three runtime states:
|
||||||
|
// undefined → server didn't send resolved (old server, or path absent)
|
||||||
|
// '[REDACTED]' → secret hidden
|
||||||
|
// anything else → live runtime value
|
||||||
|
const hasResolved = resolvedValue !== undefined;
|
||||||
|
const isRedacted = resolvedValue === REDACTED;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="settings-widget settings-widget-env"
|
className="settings-widget settings-widget-env"
|
||||||
|
|
@ -52,6 +66,31 @@ export const EnvPill = ({ placeholder, path, onChange }: Props) => {
|
||||||
onChange(v);
|
onChange(v);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{hasResolved && !isRedacted && (
|
||||||
|
<span
|
||||||
|
className="settings-widget-env-runtime"
|
||||||
|
data-testid={`env-runtime-${path.join('.')}`}
|
||||||
|
title={t('admin_settings.env_pill.runtime_tooltip', { variable: placeholder.variable })}
|
||||||
|
>
|
||||||
|
<span className="settings-widget-env-runtime-arrow" aria-hidden>→</span>
|
||||||
|
<span className="settings-widget-env-runtime-label" aria-hidden>
|
||||||
|
{t('admin_settings.env_pill.runtime_label')}
|
||||||
|
</span>
|
||||||
|
<span className="settings-widget-env-runtime-value">
|
||||||
|
{formatDisplay(resolvedValue)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isRedacted && (
|
||||||
|
<span
|
||||||
|
className="settings-widget-env-runtime settings-widget-env-runtime-redacted"
|
||||||
|
data-testid={`env-runtime-redacted-${path.join('.')}`}
|
||||||
|
title={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
|
||||||
|
aria-label={t('admin_settings.env_pill.redacted_tooltip', { variable: placeholder.variable })}
|
||||||
|
>
|
||||||
|
<span aria-hidden>→ ••••••</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import i18next from 'i18next';
|
||||||
|
|
||||||
|
import { EnvPill } from '../EnvPill.tsx';
|
||||||
|
|
||||||
|
i18next.init({
|
||||||
|
lng: 'en',
|
||||||
|
resources: {
|
||||||
|
en: {
|
||||||
|
translation: {
|
||||||
|
'admin_settings.env_pill.tooltip': 'env {{variable}}',
|
||||||
|
'admin_settings.env_pill.default_label': 'default',
|
||||||
|
'admin_settings.env_pill.input_aria': 'aria {{variable}}',
|
||||||
|
'admin_settings.env_pill.runtime_label': 'active value',
|
||||||
|
'admin_settings.env_pill.runtime_tooltip': 'using {{variable}}',
|
||||||
|
'admin_settings.env_pill.redacted_tooltip': 'hidden {{variable}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrap = (el: React.ReactElement) =>
|
||||||
|
renderToStaticMarkup(
|
||||||
|
React.createElement(I18nextProvider, { i18n: i18next }, el),
|
||||||
|
);
|
||||||
|
|
||||||
|
test('omits runtime chip when resolvedValue is undefined', () => {
|
||||||
|
const html = wrap(React.createElement(EnvPill, {
|
||||||
|
placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' },
|
||||||
|
path: ['dbType'],
|
||||||
|
onChange: () => {},
|
||||||
|
}));
|
||||||
|
assert.ok(!html.includes('settings-widget-env-runtime'),
|
||||||
|
`runtime chip should be absent, got: ${html}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders runtime chip with resolved value', () => {
|
||||||
|
const html = wrap(React.createElement(EnvPill, {
|
||||||
|
placeholder: { variable: 'DB_TYPE', defaultValue: 'dirty' },
|
||||||
|
path: ['dbType'],
|
||||||
|
onChange: () => {},
|
||||||
|
resolvedValue: 'sqlite',
|
||||||
|
} as any));
|
||||||
|
assert.ok(html.includes('settings-widget-env-runtime'),
|
||||||
|
`runtime chip class should appear, got: ${html}`);
|
||||||
|
assert.ok(html.includes('sqlite'),
|
||||||
|
`resolved value text should appear, got: ${html}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders redacted chip when resolvedValue is [REDACTED]', () => {
|
||||||
|
const html = wrap(React.createElement(EnvPill, {
|
||||||
|
placeholder: { variable: 'DB_PASS', defaultValue: '' },
|
||||||
|
path: ['dbSettings', 'password'],
|
||||||
|
onChange: () => {},
|
||||||
|
resolvedValue: '[REDACTED]',
|
||||||
|
} as any));
|
||||||
|
assert.ok(html.includes('settings-widget-env-runtime-redacted'),
|
||||||
|
`redacted chip class should appear, got: ${html}`);
|
||||||
|
assert.ok(!html.includes('[REDACTED]'),
|
||||||
|
`literal sentinel must not be displayed to the user, got: ${html}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coerces non-string resolved values to display strings', () => {
|
||||||
|
const html = wrap(React.createElement(EnvPill, {
|
||||||
|
placeholder: { variable: 'TRUST_PROXY', defaultValue: 'false' },
|
||||||
|
path: ['trustProxy'],
|
||||||
|
onChange: () => {},
|
||||||
|
resolvedValue: true,
|
||||||
|
} as any));
|
||||||
|
assert.ok(html.includes('true'), `expected "true" in ${html}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders null resolved value as the string null', () => {
|
||||||
|
const html = wrap(React.createElement(EnvPill, {
|
||||||
|
placeholder: { variable: 'IP', defaultValue: '' },
|
||||||
|
path: ['ip'],
|
||||||
|
onChange: () => {},
|
||||||
|
resolvedValue: null,
|
||||||
|
} as any));
|
||||||
|
assert.ok(html.includes('null'), `expected "null" in ${html}`);
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {Trans, useTranslation} from "react-i18next";
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
import {useEffect, useMemo, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import {VisuallyHidden} from "@radix-ui/react-visually-hidden";
|
||||||
import {ChevronLeft, ChevronRight, Trash2} from "lucide-react";
|
import {ChevronLeft, ChevronRight, Trash2} from "lucide-react";
|
||||||
import {useStore} from "../store/store.ts";
|
import {useStore} from "../store/store.ts";
|
||||||
import {SearchField} from "../components/SearchField.tsx";
|
import {SearchField} from "../components/SearchField.tsx";
|
||||||
|
|
@ -153,16 +154,20 @@ export const AuthorPage = () => {
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||||
<Dialog.Content className="dialog-confirm-content">
|
<Dialog.Content className="dialog-confirm-content">
|
||||||
|
<VisuallyHidden asChild>
|
||||||
|
<Dialog.Title>{t('ep_admin_authors:confirm-dialog-title')}</Dialog.Title>
|
||||||
|
</VisuallyHidden>
|
||||||
|
<VisuallyHidden asChild>
|
||||||
|
<Dialog.Description>{t('ep_admin_authors:confirm-dialog-description')}</Dialog.Description>
|
||||||
|
</VisuallyHidden>
|
||||||
{dialog.phase === 'loading-preview' && <div>
|
{dialog.phase === 'loading-preview' && <div>
|
||||||
<Trans i18nKey="ep_admin_authors:loading-preview" ns="ep_admin_authors"/>
|
<Trans i18nKey="ep_admin_authors:loading-preview" ns="ep_admin_authors"/>
|
||||||
</div>}
|
</div>}
|
||||||
{(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => {
|
{(dialog.phase === 'preview' || dialog.phase === 'committing') && (() => {
|
||||||
const p = dialog.preview;
|
const p = dialog.preview;
|
||||||
return <div>
|
return <div>
|
||||||
<Dialog.Title asChild>
|
<h3>{t('ep_admin_authors:confirm-preview-title',
|
||||||
<h3>{t('ep_admin_authors:confirm-preview-title',
|
{name: p.name || p.authorID})}</h3>
|
||||||
{name: p.name || p.authorID})}</h3>
|
|
||||||
</Dialog.Title>
|
|
||||||
<p>{t('ep_admin_authors:confirm-preview-counters', {
|
<p>{t('ep_admin_authors:confirm-preview-counters', {
|
||||||
tokenMappings: p.removedTokenMappings,
|
tokenMappings: p.removedTokenMappings,
|
||||||
externalMappings: p.removedExternalMappings,
|
externalMappings: p.removedExternalMappings,
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,17 @@
|
||||||
import {Trans, useTranslation} from "react-i18next";
|
import {Trans, useTranslation} from "react-i18next";
|
||||||
import {useEffect, useMemo, useState} from "react";
|
import {useEffect, useMemo, useState} from "react";
|
||||||
import {useStore} from "../store/store.ts";
|
import {useStore} from "../store/store.ts";
|
||||||
import {PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
|
import {PadFilter, PadSearchQuery, PadSearchResult} from "../utils/PadSearch.ts";
|
||||||
import {useDebounce} from "../utils/useDebounce.ts";
|
import {useDebounce} from "../utils/useDebounce.ts";
|
||||||
import * as Dialog from "@radix-ui/react-dialog";
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import {VisuallyHidden} from "@radix-ui/react-visually-hidden";
|
||||||
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react";
|
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react";
|
||||||
import {useForm} from "react-hook-form";
|
import {useForm} from "react-hook-form";
|
||||||
import type {TFunction} from "i18next";
|
import type {TFunction} from "i18next";
|
||||||
|
|
||||||
type PadCreateProps = { padName: string }
|
type PadCreateProps = { padName: string }
|
||||||
type FilterId = 'all' | 'active' | 'recent' | 'empty' | 'stale'
|
|
||||||
|
|
||||||
const PAD_FILTER_IDS: FilterId[] = ['all', 'active', 'recent', 'empty', 'stale']
|
const PAD_FILTER_IDS: PadFilter[] = ['all', 'active', 'recent', 'empty', 'stale']
|
||||||
|
|
||||||
const isRecent = (ts: number) => (Date.now() - ts) < 86_400_000 * 7
|
|
||||||
const isStale = (ts: number) => (Date.now() - ts) > 86_400_000 * 365
|
|
||||||
|
|
||||||
function relativeTime(t: TFunction, ts: number): string {
|
function relativeTime(t: TFunction, ts: number): string {
|
||||||
const d = (Date.now() - ts) / 1000
|
const d = (Date.now() - ts) / 1000
|
||||||
|
|
@ -58,12 +55,23 @@ function sanitizeLocale(lng?: string): string {
|
||||||
export const PadPage = () => {
|
export const PadPage = () => {
|
||||||
const settingsSocket = useStore(state => state.settingsSocket)
|
const settingsSocket = useStore(state => state.settingsSocket)
|
||||||
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
|
const [searchParams, setSearchParams] = useState<PadSearchQuery>({
|
||||||
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false,
|
offset: 0, limit: 12, pattern: '', sortBy: 'lastEdited', ascending: false, filter: 'all',
|
||||||
})
|
})
|
||||||
const {t, i18n} = useTranslation()
|
const {t, i18n} = useTranslation()
|
||||||
const locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language)
|
const locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language)
|
||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [filter, setFilter] = useState<FilterId>('all')
|
// Read filter off searchParams so chip changes round-trip through
|
||||||
|
// the server (`filter` is applied before pagination there). Clicking
|
||||||
|
// a chip used to filter only the current 12-row page slice.
|
||||||
|
//
|
||||||
|
// All searchParams mutations go through functional updaters because the
|
||||||
|
// debounced pattern handler captures a render-time snapshot and would
|
||||||
|
// otherwise revert a faster chip click / sort change made in between.
|
||||||
|
const filter: PadFilter = searchParams.filter ?? 'all'
|
||||||
|
const setFilter = (f: PadFilter) => {
|
||||||
|
setCurrentPage(0)
|
||||||
|
setSearchParams((sp) => ({...sp, filter: f, offset: 0}))
|
||||||
|
}
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
const pads = useStore(state => state.pads)
|
const pads = useStore(state => state.pads)
|
||||||
const [currentPage, setCurrentPage] = useState(0)
|
const [currentPage, setCurrentPage] = useState(0)
|
||||||
|
|
@ -78,28 +86,23 @@ export const PadPage = () => {
|
||||||
[pads, searchParams.limit]
|
[pads, searchParams.limit]
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredResults = useMemo(() => {
|
// The server applies `filter` before paginating; the page payload is
|
||||||
const r = pads?.results ?? []
|
// already the filtered slice. The stats cards still reflect the
|
||||||
if (filter === 'active') return r.filter(p => p.userCount > 0)
|
// current page (pre-existing behaviour) — making them global would
|
||||||
if (filter === 'recent') return r.filter(p => isRecent(p.lastEdited))
|
// require a separate aggregate query.
|
||||||
if (filter === 'empty') return r.filter(p => p.revisionNumber === 0)
|
const visibleResults = pads?.results ?? []
|
||||||
if (filter === 'stale') return r.filter(p => isStale(p.lastEdited))
|
const totalUsers = useMemo(() => visibleResults.reduce((s, p) => s + p.userCount, 0), [pads])
|
||||||
return r
|
const activeCount = useMemo(() => visibleResults.filter(p => p.userCount > 0).length, [pads])
|
||||||
}, [pads, filter])
|
const emptyCount = useMemo(() => visibleResults.filter(p => p.revisionNumber === 0).length, [pads])
|
||||||
|
|
||||||
const totalUsers = useMemo(() => (pads?.results ?? []).reduce((s, p) => s + p.userCount, 0), [pads])
|
|
||||||
const activeCount = useMemo(() => (pads?.results ?? []).filter(p => p.userCount > 0).length, [pads])
|
|
||||||
const emptyCount = useMemo(() => (pads?.results ?? []).filter(p => p.revisionNumber === 0).length, [pads])
|
|
||||||
const lastActivity = useMemo(() => {
|
const lastActivity = useMemo(() => {
|
||||||
const r = pads?.results ?? []
|
return visibleResults.length ? Math.max(...visibleResults.map(p => p.lastEdited)) : null
|
||||||
return r.length ? Math.max(...r.map(p => p.lastEdited)) : null
|
|
||||||
}, [pads])
|
}, [pads])
|
||||||
|
|
||||||
const allSelected = filteredResults.length > 0 && filteredResults.every(p => selected.has(p.padName))
|
const allSelected = visibleResults.length > 0 && visibleResults.every(p => selected.has(p.padName))
|
||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
const s = new Set(selected)
|
const s = new Set(selected)
|
||||||
if (allSelected) filteredResults.forEach(p => s.delete(p.padName))
|
if (allSelected) visibleResults.forEach(p => s.delete(p.padName))
|
||||||
else filteredResults.forEach(p => s.add(p.padName))
|
else visibleResults.forEach(p => s.add(p.padName))
|
||||||
setSelected(s)
|
setSelected(s)
|
||||||
}
|
}
|
||||||
const toggleOne = (name: string) => {
|
const toggleOne = (name: string) => {
|
||||||
|
|
@ -109,7 +112,10 @@ export const PadPage = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
useDebounce(() => {
|
useDebounce(() => {
|
||||||
setSearchParams({...searchParams, pattern: searchTerm})
|
// Functional updater so this delayed callback can't clobber a faster
|
||||||
|
// user interaction (e.g. clicking a filter chip mid-typing).
|
||||||
|
setSearchParams((sp) => ({...sp, pattern: searchTerm, offset: 0}))
|
||||||
|
setCurrentPage(0)
|
||||||
}, 500, [searchTerm])
|
}, 500, [searchTerm])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -160,7 +166,10 @@ export const PadPage = () => {
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||||
<Dialog.Content className="dialog-confirm-content">
|
<Dialog.Content className="dialog-confirm-content">
|
||||||
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
|
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.delete_pad_dialog_title')}</Dialog.Title></VisuallyHidden>
|
||||||
|
<Dialog.Description asChild>
|
||||||
|
<div>{t('ep_admin_pads:ep_adminpads2_confirm', {padID: padToDelete})}</div>
|
||||||
|
</Dialog.Description>
|
||||||
<div className="settings-button-bar">
|
<div className="settings-button-bar">
|
||||||
<button onClick={() => setDeleteDialog(false)}><Trans i18nKey="admin_pads.cancel"/></button>
|
<button onClick={() => setDeleteDialog(false)}><Trans i18nKey="admin_pads.cancel"/></button>
|
||||||
<button onClick={() => { deletePad(padToDelete); setDeleteDialog(false) }}>{t('admin_pads.confirm_button')}</button>
|
<button onClick={() => { deletePad(padToDelete); setDeleteDialog(false) }}>{t('admin_pads.confirm_button')}</button>
|
||||||
|
|
@ -173,7 +182,10 @@ export const PadPage = () => {
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||||
<Dialog.Content className="dialog-confirm-content">
|
<Dialog.Content className="dialog-confirm-content">
|
||||||
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
|
<VisuallyHidden asChild><Dialog.Title>{t('admin_pads.error_prefix')}</Dialog.Title></VisuallyHidden>
|
||||||
|
<Dialog.Description asChild>
|
||||||
|
<div>{t('admin_pads.error_prefix')}: {errorText}</div>
|
||||||
|
</Dialog.Description>
|
||||||
<div className="settings-button-bar">
|
<div className="settings-button-bar">
|
||||||
<button onClick={() => setErrorText(null)}>{t('admin_pads.confirm_button')}</button>
|
<button onClick={() => setErrorText(null)}>{t('admin_pads.confirm_button')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,6 +198,7 @@ export const PadPage = () => {
|
||||||
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
<Dialog.Overlay className="dialog-confirm-overlay"/>
|
||||||
<Dialog.Content className="dialog-confirm-content">
|
<Dialog.Content className="dialog-confirm-content">
|
||||||
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
|
<Dialog.Title className="dialog-confirm-title"><Trans i18nKey="index.newPad"/></Dialog.Title>
|
||||||
|
<VisuallyHidden asChild><Dialog.Description>{t('admin_pads.create_pad_dialog_description')}</Dialog.Description></VisuallyHidden>
|
||||||
<form onSubmit={handleSubmit(onPadCreate)}>
|
<form onSubmit={handleSubmit(onPadCreate)}>
|
||||||
<button className="dialog-close-button" type="button" onClick={() => setCreatePadDialogOpen(false)}>×</button>
|
<button className="dialog-close-button" type="button" onClick={() => setCreatePadDialogOpen(false)}>×</button>
|
||||||
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>
|
<div style={{display: 'grid', gap: '10px', gridTemplateColumns: 'auto auto', marginBottom: '1rem'}}>
|
||||||
|
|
@ -250,7 +263,7 @@ export const PadPage = () => {
|
||||||
<section className="pm-section">
|
<section className="pm-section">
|
||||||
<div className="pm-section-header">
|
<div className="pm-section-header">
|
||||||
<h2><Trans i18nKey="admin_pads.all_pads"/></h2>
|
<h2><Trans i18nKey="admin_pads.all_pads"/></h2>
|
||||||
<span className="pm-count-badge">{filteredResults.length}</span>
|
<span className="pm-count-badge">{visibleResults.length}</span>
|
||||||
<div className="pm-spacer"/>
|
<div className="pm-spacer"/>
|
||||||
<div className="pm-toolbar">
|
<div className="pm-toolbar">
|
||||||
<div className="pm-search">
|
<div className="pm-search">
|
||||||
|
|
@ -268,12 +281,12 @@ export const PadPage = () => {
|
||||||
<select
|
<select
|
||||||
className="pm-select"
|
className="pm-select"
|
||||||
value={searchParams.sortBy}
|
value={searchParams.sortBy}
|
||||||
onChange={e => setSearchParams({
|
onChange={e => setSearchParams((sp) => ({
|
||||||
...searchParams,
|
...sp,
|
||||||
sortBy: e.target.value,
|
sortBy: e.target.value,
|
||||||
// Keep current direction when only the column changes; the
|
// Keep current direction when only the column changes; the
|
||||||
// ↑/↓ button below is the sole control for direction.
|
// ↑/↓ button below is the sole control for direction.
|
||||||
})}
|
}))}
|
||||||
>
|
>
|
||||||
<option value="lastEdited">{t('ep_admin_pads:ep_adminpads2_last-edited')}</option>
|
<option value="lastEdited">{t('ep_admin_pads:ep_adminpads2_last-edited')}</option>
|
||||||
<option value="padName">{t('admin_pads.sort.name')}</option>
|
<option value="padName">{t('admin_pads.sort.name')}</option>
|
||||||
|
|
@ -282,10 +295,10 @@ export const PadPage = () => {
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
className="pm-sort-dir"
|
className="pm-sort-dir"
|
||||||
onClick={() => setSearchParams({
|
onClick={() => setSearchParams((sp) => ({
|
||||||
...searchParams,
|
...sp,
|
||||||
ascending: !searchParams.ascending,
|
ascending: !sp.ascending,
|
||||||
})}
|
}))}
|
||||||
title={t(searchParams.ascending
|
title={t(searchParams.ascending
|
||||||
? 'admin_plugins.sort_ascending'
|
? 'admin_plugins.sort_ascending'
|
||||||
: 'admin_plugins.sort_descending')}
|
: 'admin_plugins.sort_descending')}
|
||||||
|
|
@ -330,7 +343,7 @@ export const PadPage = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredResults.length > 0 ? (
|
{visibleResults.length > 0 ? (
|
||||||
<div className="pm-table-wrap">
|
<div className="pm-table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -348,7 +361,7 @@ export const PadPage = () => {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredResults.map(pad => {
|
{visibleResults.map(pad => {
|
||||||
const isEmpty = pad.revisionNumber === 0
|
const isEmpty = pad.revisionNumber === 0
|
||||||
const isSel = selected.has(pad.padName)
|
const isSel = selected.has(pad.padName)
|
||||||
return (
|
return (
|
||||||
|
|
@ -405,7 +418,7 @@ export const PadPage = () => {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="pm-btn pm-btn-primary pm-btn--sm"
|
className="pm-btn pm-btn-primary pm-btn--sm"
|
||||||
onClick={() => window.open(`../../p/${pad.padName}`, '_blank')}
|
onClick={() => window.open(`../../p/${encodeURIComponent(pad.padName)}`, '_blank', 'noopener,noreferrer')}
|
||||||
>
|
>
|
||||||
<Eye size={13}/> <Trans i18nKey="admin_pads.open"/>
|
<Eye size={13}/> <Trans i18nKey="admin_pads.open"/>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -432,7 +445,7 @@ export const PadPage = () => {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const p = currentPage - 1
|
const p = currentPage - 1
|
||||||
setCurrentPage(p)
|
setCurrentPage(p)
|
||||||
setSearchParams({...searchParams, offset: p * searchParams.limit})
|
setSearchParams((sp) => ({...sp, offset: p * sp.limit}))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={14}/> <Trans i18nKey="admin_pads.pagination.previous"/>
|
<ChevronLeft size={14}/> <Trans i18nKey="admin_pads.pagination.previous"/>
|
||||||
|
|
@ -444,7 +457,7 @@ export const PadPage = () => {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const p = currentPage + 1
|
const p = currentPage + 1
|
||||||
setCurrentPage(p)
|
setCurrentPage(p)
|
||||||
setSearchParams({...searchParams, offset: p * searchParams.limit})
|
setSearchParams((sp) => ({...sp, offset: p * sp.limit}))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trans i18nKey="admin_pads.pagination.next"/> <ChevronRight size={14}/>
|
<Trans i18nKey="admin_pads.pagination.next"/> <ChevronRight size={14}/>
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,42 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useStore } from '../store/store';
|
import { useStore } from '../store/store';
|
||||||
import { isJSONClean, cleanComments } from '../utils/utils';
|
import { isJSONClean, cleanComments } from '../utils/utils';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { IconButton } from '../components/IconButton';
|
import { IconButton } from '../components/IconButton';
|
||||||
import { RotateCw, Save, AlignLeft, ShieldCheck } from 'lucide-react';
|
import { RotateCw, Save, AlignLeft, ShieldCheck, Info } from 'lucide-react';
|
||||||
import { FormView } from '../components/settings/FormView';
|
import { FormView } from '../components/settings/FormView';
|
||||||
import { ModeToggle, type Mode } from '../components/settings/ModeToggle';
|
import { ModeToggle, type Mode } from '../components/settings/ModeToggle';
|
||||||
|
|
||||||
const TAB_INDENT = ' ';
|
const TAB_INDENT = ' ';
|
||||||
|
|
||||||
|
// Heuristic: `${VAR}` or `${VAR:default}` in the file means the operator is
|
||||||
|
// running with env-var substitution (overwhelmingly Docker / Kubernetes).
|
||||||
|
// We use this to gate the Docker-aware UX (the explanatory banner and the
|
||||||
|
// Effective-config tab) so non-container installs see the existing UI
|
||||||
|
// unchanged. Conservative on purpose — false negatives just keep the old
|
||||||
|
// behaviour.
|
||||||
|
const ENV_VAR_PATTERN = /\$\{[A-Za-z_][A-Za-z0-9_]*(?::[^}]*)?\}/;
|
||||||
|
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const settingsSocket = useStore(state => state.settingsSocket);
|
const settingsSocket = useStore(state => state.settingsSocket);
|
||||||
const settings = useStore(state => state.settings) ?? '';
|
const settings = useStore(state => state.settings) ?? '';
|
||||||
|
const resolved = useStore(state => state.resolved);
|
||||||
|
|
||||||
|
const usesEnvVars = useMemo(() => ENV_VAR_PATTERN.test(settings), [settings]);
|
||||||
|
|
||||||
const [mode, setMode] = useState<Mode>('form');
|
const [mode, setMode] = useState<Mode>('form');
|
||||||
const [exposeExperimental] = useState(false);
|
const [exposeExperimental] = useState(false);
|
||||||
|
|
||||||
|
// The Effective tab is only meaningful when there is a `resolved`
|
||||||
|
// payload AND the file uses substitution. Falling back to Raw on
|
||||||
|
// either condition keeps the toggle honest if the user opens this
|
||||||
|
// page against an older server.
|
||||||
|
const canShowEffective = usesEnvVars && resolved != null;
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === 'effective' && !canShowEffective) setMode('raw');
|
||||||
|
}, [mode, canShowEffective]);
|
||||||
|
|
||||||
// Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders.
|
// Tab in textarea inserts two spaces instead of moving focus; rAF restores caret position after React re-renders.
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
if (e.key !== 'Tab') return;
|
if (e.key !== 'Tab') return;
|
||||||
|
|
@ -57,49 +77,80 @@ export const SettingsPage = () => {
|
||||||
settingsSocket.emit('saveSettings', settings);
|
settingsSocket.emit('saveSettings', settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const effectiveJson = useMemo(() => {
|
||||||
|
if (resolved == null) return '';
|
||||||
|
try { return JSON.stringify(resolved, null, 2); } catch { return ''; }
|
||||||
|
}, [resolved]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-page">
|
<div className="settings-page">
|
||||||
<h1><Trans i18nKey="admin_settings.current" /></h1>
|
<h1><Trans i18nKey="admin_settings.current" /></h1>
|
||||||
|
|
||||||
<ModeToggle mode={mode} onChange={setMode} />
|
{usesEnvVars && (
|
||||||
|
<div
|
||||||
|
className="settings-envvar-banner"
|
||||||
|
role="note"
|
||||||
|
data-testid="settings-envvar-banner"
|
||||||
|
>
|
||||||
|
<Info size={18} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong><Trans i18nKey="admin_settings.envvar_banner.title" /></strong>
|
||||||
|
<p><Trans i18nKey="admin_settings.envvar_banner.body" /></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{mode === 'form'
|
<ModeToggle mode={mode} onChange={setMode} showEffective={canShowEffective} />
|
||||||
? <FormView onSwitchToRaw={() => setMode('raw')} />
|
|
||||||
: (
|
{mode === 'form' && <FormView onSwitchToRaw={() => setMode('raw')} />}
|
||||||
<textarea
|
{mode === 'raw' && (
|
||||||
value={settings}
|
<textarea
|
||||||
className="settings"
|
value={settings}
|
||||||
data-testid="settings-raw-textarea"
|
className="settings"
|
||||||
spellCheck={false}
|
data-testid="settings-raw-textarea"
|
||||||
onKeyDown={handleKeyDown}
|
spellCheck={false}
|
||||||
onChange={v => useStore.getState().setSettings(v.target.value)}
|
onKeyDown={handleKeyDown}
|
||||||
/>
|
onChange={v => useStore.getState().setSettings(v.target.value)}
|
||||||
)
|
/>
|
||||||
}
|
)}
|
||||||
|
{mode === 'effective' && (
|
||||||
|
<textarea
|
||||||
|
value={effectiveJson}
|
||||||
|
className="settings"
|
||||||
|
data-testid="settings-effective-textarea"
|
||||||
|
spellCheck={false}
|
||||||
|
readOnly
|
||||||
|
aria-readonly="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="settings-button-bar">
|
<div className="settings-button-bar">
|
||||||
<IconButton
|
{mode !== 'effective' && (
|
||||||
className="settingsButton"
|
<>
|
||||||
data-testid="save-settings-button"
|
<IconButton
|
||||||
icon={<Save />}
|
className="settingsButton"
|
||||||
title={<Trans i18nKey="admin_settings.current_save.value" />}
|
data-testid="save-settings-button"
|
||||||
onClick={handleSave}
|
icon={<Save />}
|
||||||
/>
|
title={<Trans i18nKey="admin_settings.current_save.value" />}
|
||||||
<IconButton
|
onClick={handleSave}
|
||||||
className="settingsButton"
|
/>
|
||||||
data-testid="test-settings-button"
|
<IconButton
|
||||||
icon={<ShieldCheck />}
|
className="settingsButton"
|
||||||
title={<Trans i18nKey="admin_settings.current_test.value" />}
|
data-testid="test-settings-button"
|
||||||
onClick={testJSON}
|
icon={<ShieldCheck />}
|
||||||
/>
|
title={<Trans i18nKey="admin_settings.current_test.value" />}
|
||||||
{exposeExperimental && (
|
onClick={testJSON}
|
||||||
<IconButton
|
/>
|
||||||
className="settingsButton"
|
{exposeExperimental && (
|
||||||
data-testid="prettify-settings-button"
|
<IconButton
|
||||||
icon={<AlignLeft />}
|
className="settingsButton"
|
||||||
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
|
data-testid="prettify-settings-button"
|
||||||
onClick={prettifyJSON}
|
icon={<AlignLeft />}
|
||||||
/>
|
title={<Trans i18nKey="admin_settings.current_prettify.value" />}
|
||||||
|
onClick={prettifyJSON}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
className="settingsButton"
|
className="settingsButton"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import {create} from "zustand";
|
import {create} from "zustand";
|
||||||
import {Socket} from "socket.io-client";
|
import {Socket} from "socket.io-client";
|
||||||
|
import type {JSONPath} from "jsonc-parser";
|
||||||
import {PadSearchResult} from "../utils/PadSearch.ts";
|
import {PadSearchResult} from "../utils/PadSearch.ts";
|
||||||
import {AuthorSearchResult} from "../utils/AuthorSearch.ts";
|
import {AuthorSearchResult} from "../utils/AuthorSearch.ts";
|
||||||
import {InstalledPlugin} from "../pages/Plugin.ts";
|
import {InstalledPlugin} from "../pages/Plugin.ts";
|
||||||
|
import {resolveByPath} from "../utils/resolveByPath.ts";
|
||||||
|
|
||||||
export type Execution =
|
export type Execution =
|
||||||
| {status: 'idle'}
|
| {status: 'idle'}
|
||||||
|
|
@ -45,7 +47,6 @@ export interface UpdateStatusPayload {
|
||||||
installMethod: string;
|
installMethod: string;
|
||||||
tier: string;
|
tier: string;
|
||||||
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
|
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
|
||||||
vulnerableBelow: Array<{announcedBy: string; threshold: string}>;
|
|
||||||
// Tier 2 additions:
|
// Tier 2 additions:
|
||||||
execution: Execution;
|
execution: Execution;
|
||||||
lastResult: LastResult;
|
lastResult: LastResult;
|
||||||
|
|
@ -66,6 +67,11 @@ type ToastState = {
|
||||||
type StoreState = {
|
type StoreState = {
|
||||||
settings: string|undefined,
|
settings: string|undefined,
|
||||||
setSettings: (settings: string) => void,
|
setSettings: (settings: string) => void,
|
||||||
|
// Resolved runtime values for the /admin/settings page. The server
|
||||||
|
// emits this alongside the raw `settings` string so the SPA can show
|
||||||
|
// env-substituted values; secrets are redacted to "[REDACTED]".
|
||||||
|
resolved: unknown | null,
|
||||||
|
setResolved: (resolved: unknown | null) => void,
|
||||||
settingsSocket: Socket|undefined,
|
settingsSocket: Socket|undefined,
|
||||||
setSettingsSocket: (socket: Socket) => void,
|
setSettingsSocket: (socket: Socket) => void,
|
||||||
showLoading: boolean,
|
showLoading: boolean,
|
||||||
|
|
@ -92,6 +98,8 @@ type StoreState = {
|
||||||
export const useStore = create<StoreState>()((set) => ({
|
export const useStore = create<StoreState>()((set) => ({
|
||||||
settings: undefined,
|
settings: undefined,
|
||||||
setSettings: (settings: string) => set({settings}),
|
setSettings: (settings: string) => set({settings}),
|
||||||
|
resolved: null,
|
||||||
|
setResolved: (resolved) => set({resolved}),
|
||||||
settingsSocket: undefined,
|
settingsSocket: undefined,
|
||||||
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
|
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
|
||||||
showLoading: false,
|
showLoading: false,
|
||||||
|
|
@ -118,3 +126,6 @@ export const useStore = create<StoreState>()((set) => ({
|
||||||
gdprAuthorErasureEnabled: false,
|
gdprAuthorErasureEnabled: false,
|
||||||
setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}),
|
setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const useResolvedAt = (path: JSONPath): unknown =>
|
||||||
|
useStore(s => resolveByPath(s.resolved, path));
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
import {useStore} from "../store/store.ts";
|
import {useStore} from "../store/store.ts";
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
import * as Dialog from '@radix-ui/react-dialog';
|
||||||
|
import {VisuallyHidden} from '@radix-ui/react-visually-hidden';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
import brand from './brand.svg'
|
import brand from './brand.svg'
|
||||||
|
|
||||||
export const LoadingScreen = ()=>{
|
export const LoadingScreen = ()=>{
|
||||||
const showLoading = useStore(state => state.showLoading)
|
const showLoading = useStore(state => state.showLoading)
|
||||||
|
const {t} = useTranslation()
|
||||||
|
|
||||||
return <Dialog.Root open={showLoading}><Dialog.Portal>
|
return <Dialog.Root open={showLoading}><Dialog.Portal>
|
||||||
<Dialog.Overlay className="loading-screen fixed inset-0 bg-black bg-opacity-50 z-50 dialog-overlay" />
|
<Dialog.Overlay className="loading-screen fixed inset-0 bg-black bg-opacity-50 z-50 dialog-overlay" />
|
||||||
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 dialog-content">
|
<Dialog.Content className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50 dialog-content">
|
||||||
|
<VisuallyHidden asChild><Dialog.Title>{t('admin.loading')}</Dialog.Title></VisuallyHidden>
|
||||||
|
<VisuallyHidden asChild><Dialog.Description>{t('admin.loading_description')}</Dialog.Description></VisuallyHidden>
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="animate-spin w-16 h-16 border-t-2 border-b-2 border-[--fg-color] rounded-full"></div>
|
<div className="animate-spin w-16 h-16 border-t-2 border-b-2 border-[--fg-color] rounded-full"></div>
|
||||||
<div className="mt-4 text-[--fg-color]">
|
<div className="mt-4 text-[--fg-color]">
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
|
export type PadFilter = 'all' | 'active' | 'recent' | 'empty' | 'stale';
|
||||||
|
|
||||||
export type PadSearchQuery = {
|
export type PadSearchQuery = {
|
||||||
pattern: string;
|
pattern: string;
|
||||||
offset: number;
|
offset: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
ascending: boolean;
|
ascending: boolean;
|
||||||
sortBy: string;
|
sortBy: string;
|
||||||
|
filter?: PadFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
41
admin/src/utils/__tests__/resolveByPath.test.ts
Normal file
41
admin/src/utils/__tests__/resolveByPath.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { resolveByPath } from '../resolveByPath.ts';
|
||||||
|
|
||||||
|
test('returns undefined for null/undefined root', () => {
|
||||||
|
assert.equal(resolveByPath(null, ['a']), undefined);
|
||||||
|
assert.equal(resolveByPath(undefined, ['a']), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('walks nested object keys', () => {
|
||||||
|
assert.equal(resolveByPath({a: {b: {c: 42}}}, ['a', 'b', 'c']), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('walks arrays with numeric indices', () => {
|
||||||
|
assert.equal(resolveByPath({xs: [10, 20, 30]}, ['xs', 1]), 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('walks mixed objects and arrays', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveByPath({sso: {clients: [{id: 'A'}, {id: 'B'}]}}, ['sso', 'clients', 1, 'id']),
|
||||||
|
'B',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined for missing keys', () => {
|
||||||
|
assert.equal(resolveByPath({a: 1}, ['b']), undefined);
|
||||||
|
assert.equal(resolveByPath({a: {b: 1}}, ['a', 'c']), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns undefined when traversing into a primitive', () => {
|
||||||
|
assert.equal(resolveByPath({a: 1}, ['a', 'b']), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the root when path is empty', () => {
|
||||||
|
const obj = {a: 1};
|
||||||
|
assert.equal(resolveByPath(obj, []), obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles string-form numeric indices for arrays', () => {
|
||||||
|
assert.equal(resolveByPath({xs: [10, 20]}, ['xs', '1']), 20);
|
||||||
|
});
|
||||||
17
admin/src/utils/resolveByPath.ts
Normal file
17
admin/src/utils/resolveByPath.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { JSONPath } from 'jsonc-parser';
|
||||||
|
|
||||||
|
export const resolveByPath = (obj: unknown, path: JSONPath): unknown => {
|
||||||
|
let cur: unknown = obj;
|
||||||
|
for (const seg of path) {
|
||||||
|
if (cur === null || cur === undefined) return undefined;
|
||||||
|
if (typeof cur !== 'object') return undefined;
|
||||||
|
if (Array.isArray(cur)) {
|
||||||
|
const i = typeof seg === 'number' ? seg : Number(seg);
|
||||||
|
if (!Number.isInteger(i)) return undefined;
|
||||||
|
cur = cur[i];
|
||||||
|
} else {
|
||||||
|
cur = (cur as Record<string, unknown>)[String(seg)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cur;
|
||||||
|
};
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
# Contributor Guidelines
|
|
||||||
(Please talk to people on the mailing list before you change this page, see our section on [how to get in touch](https://github.com/ether/etherpad#get-in-touch))
|
|
||||||
|
|
||||||
**We have decided that LLM/Agent/AI contributions are fine as long as they are within the instructions set out by this document.**
|
|
||||||
|
|
||||||
## Pull requests
|
|
||||||
|
|
||||||
* PRs MUST include a non-empty description explaining what the change does and why
|
|
||||||
* PRs without a description should be flagged as incomplete
|
|
||||||
* the commit series in the PR should be _linear_ (it **should not contain merge commits**). This is necessary because we want to be able to [bisect](https://en.wikipedia.org/wiki/Bisection_(software_engineering)) bugs easily. Rewrite history/perform a rebase if necessary
|
|
||||||
* PRs should be issued against the **develop** branch: we never pull directly into **master**
|
|
||||||
* PRs **should not have conflicts** with develop. If there are, please resolve them rebasing and force-pushing
|
|
||||||
* when preparing your PR, please make sure that you have included the relevant **changes to the documentation** (preferably with usage examples)
|
|
||||||
* contain meaningful and detailed **commit messages** in the form:
|
|
||||||
```
|
|
||||||
submodule: description
|
|
||||||
|
|
||||||
longer description of the change you have made, eventually mentioning the
|
|
||||||
number of the issue that is being fixed, in the form: Fixes #someIssueNumber
|
|
||||||
```
|
|
||||||
* if the PR is a **bug fix**:
|
|
||||||
* The commit that fixes the bug should **include a regression test** that
|
|
||||||
would fail if the bug fix was reverted. Adding the regression test in the
|
|
||||||
same commit as the bug fix makes it easier for a reviewer to verify that the
|
|
||||||
test is appropriate for the bug fix.
|
|
||||||
* If there is a bug report, **the pull request description should include the
|
|
||||||
text "`Fixes #xxx`"** so that the bug report is auto-closed when the PR is
|
|
||||||
merged. It is less useful to say the same thing in a commit message because
|
|
||||||
GitHub will spam the bug report every time the commit is rebased, and
|
|
||||||
because a bug number alone becomes meaningless in forks. (A full URL would
|
|
||||||
be better, but ideally each commit is readable on its own without the need
|
|
||||||
to examine an external reference to understand motivation or context.)
|
|
||||||
* think about stability: code has to be backwards compatible as much as possible. Always **assume your code will be run with an older version of the DB/config file**
|
|
||||||
* if you want to remove a feature, **deprecate it instead**:
|
|
||||||
* write an issue with your deprecation plan
|
|
||||||
* output a `WARN` in the log informing that the feature is going to be removed
|
|
||||||
* remove the feature in the next version
|
|
||||||
* if you want to add a new feature, put it under a **feature flag**:
|
|
||||||
* once the new feature has reached a minimal level of stability, do a PR for it, so it can be integrated early
|
|
||||||
* expose a mechanism for enabling/disabling the feature
|
|
||||||
* the new feature should be **disabled** by default. With the feature disabled, the code path should be exactly the same as before your contribution. This is a __necessary condition__ for early integration
|
|
||||||
* think of the PR not as something that __you wrote__, but as something that __someone else is going to read__. The commit series in the PR should tell a novice developer the story of your thoughts when developing it
|
|
||||||
|
|
||||||
## How to write a bug report
|
|
||||||
|
|
||||||
* Please be polite, we all are humans and problems can occur.
|
|
||||||
* Please add as much information as possible, for example
|
|
||||||
* client os(s) and version(s)
|
|
||||||
* browser(s) and version(s), is the problem reproducible on different clients
|
|
||||||
* special environments like firewalls or antivirus
|
|
||||||
* host os and version
|
|
||||||
* npm and nodejs version
|
|
||||||
* Logfiles if available
|
|
||||||
* steps to reproduce
|
|
||||||
* what you expected to happen
|
|
||||||
* what actually happened
|
|
||||||
* Please format logfiles and code examples with markdown see github Markdown help below the issue textarea for more information.
|
|
||||||
|
|
||||||
If you send logfiles, please set the loglevel switch DEBUG in your settings.json file:
|
|
||||||
|
|
||||||
```
|
|
||||||
/* The log level we are using, can be: DEBUG, INFO, WARN, ERROR */
|
|
||||||
"loglevel": "DEBUG",
|
|
||||||
```
|
|
||||||
|
|
||||||
The logfile location is defined in startup script or the log is directly shown in the commandline after you have started etherpad.
|
|
||||||
|
|
||||||
## General goals of Etherpad
|
|
||||||
To make sure everybody is going in the same direction:
|
|
||||||
* easy to install for admins and easy to use for people
|
|
||||||
* easy to integrate into other apps, but also usable as standalone
|
|
||||||
* lightweight and scalable
|
|
||||||
* extensible, as much functionality should be extendable with plugins so changes don't have to be done in core.
|
|
||||||
Also, keep it maintainable. We don't wanna end up as the monster Etherpad was!
|
|
||||||
|
|
||||||
## How to work with git?
|
|
||||||
* Don't work in your master branch.
|
|
||||||
* Make a new branch for every feature you're working on. (This ensures that you can work you can do lots of small, independent pull requests instead of one big one with complete different features)
|
|
||||||
* Don't use the online edit function of github (this only creates ugly and not working commits!)
|
|
||||||
* Try to make clean commits that are easy readable (including descriptive commit messages!)
|
|
||||||
* Test before you push. Sounds easy, it isn't!
|
|
||||||
* Don't check in stuff that gets generated during build or runtime
|
|
||||||
* Make small pull requests that are easy to review but make sure they do add value by themselves / individually
|
|
||||||
|
|
||||||
## Coding style
|
|
||||||
* Do write comments. (You don't have to comment every line, but if you come up with something that's a bit complex/weird, just leave a comment. Bear in mind that you will probably leave the project at some point and that other people will read your code. Undocumented huge amounts of code are worthless!)
|
|
||||||
* Never ever use tabs
|
|
||||||
* Indentation: 2 spaces
|
|
||||||
* Don't overengineer. Don't try to solve any possible problem in one step, but try to solve problems as easy as possible and improve the solution over time!
|
|
||||||
* Do generalize sooner or later! (if an old solution, quickly hacked together, poses more problems than it solves today, refactor it!)
|
|
||||||
* Keep it compatible. Do not introduce changes to the public API, db schema or configurations too lightly. Don't make incompatible changes without good reasons!
|
|
||||||
* If you do make changes, document them! (see below)
|
|
||||||
* Use protocol independent urls "//"
|
|
||||||
|
|
||||||
## Branching model / git workflow
|
|
||||||
see git flow http://nvie.com/posts/a-successful-git-branching-model/
|
|
||||||
|
|
||||||
### `master` branch
|
|
||||||
* the stable
|
|
||||||
* This is the branch everyone should use for production stuff
|
|
||||||
|
|
||||||
### `develop`branch
|
|
||||||
* everything that is READY to go into master at some point in time
|
|
||||||
* This stuff is tested and ready to go out
|
|
||||||
|
|
||||||
### release branches
|
|
||||||
* stuff that should go into master very soon
|
|
||||||
* only bugfixes go into these (see http://nvie.com/posts/a-successful-git-branching-model/ for why)
|
|
||||||
* we should not be blocking new features to develop, just because we feel that we should be releasing it to master soon. This is the situation that release branches solve/handle.
|
|
||||||
|
|
||||||
### hotfix branches
|
|
||||||
* fixes for bugs in master
|
|
||||||
|
|
||||||
### feature branches (in your own repos)
|
|
||||||
* these are the branches where you develop your features in
|
|
||||||
* If it's ready to go out, it will be merged into develop
|
|
||||||
|
|
||||||
Over the time we pull features from feature branches into the develop branch. Every month we pull from develop into master. Bugs in master get fixed in hotfix branches. These branches will get merged into master AND develop. There should never be commits in master that aren't in develop
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
The docs are in the `doc/` folder in the git repository, so people can easily find the suitable docs for the current git revision.
|
|
||||||
|
|
||||||
Documentation should be kept up-to-date. This means, whenever you add a new API method, add a new hook or change the database model, pack the relevant changes to the docs in the same pull request.
|
|
||||||
|
|
||||||
You can build the docs e.g. produce html, using `make docs`. At some point in the future we will provide an online documentation. The current documentation in the github wiki should always reflect the state of `master` (!), since there are no docs in master, yet.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
Front-end tests are found in the `tests/frontend/` folder in the repository. Run them by pointing your browser to `<yourdomainhere>/tests/frontend`.
|
|
||||||
|
|
||||||
Back-end tests can be run from the `src` directory, via `npm test`.
|
|
||||||
You can use `npm test -- --inspect-brk` and navigate to `edge://inspect` or `chrome://inspect` to debug the tests.
|
|
||||||
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
* Compact every pad on the instance to reclaim database space.
|
* Compact every pad on the instance to reclaim database space.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node bin/compactAllPads.js # collapse all history on every pad
|
* pnpm run --filter bin compactAllPads # collapse all history on every pad
|
||||||
* node bin/compactAllPads.js --keep N # keep last N revisions per pad
|
* pnpm run --filter bin compactAllPads --keep N # keep last N revisions per pad
|
||||||
* node bin/compactAllPads.js --dry-run # list pads + rev counts, no writes
|
* pnpm run --filter bin compactAllPads --dry-run # list pads + rev counts, no writes
|
||||||
*
|
*
|
||||||
* Composes the existing `listAllPads` and `compactPad` HTTP APIs — there is
|
* Composes the existing `listAllPads` and `compactPad` HTTP APIs — there is
|
||||||
* deliberately no instance-wide HTTP endpoint, because doing this over a
|
* deliberately no instance-wide HTTP endpoint, because doing this over a
|
||||||
|
|
@ -170,9 +170,9 @@ export const parseArgs = (argv: string[]): CompactAllOpts | null => {
|
||||||
// so the test harness can use `runCompactAll` directly without network.
|
// so the test harness can use `runCompactAll` directly without network.
|
||||||
const usage = () => {
|
const usage = () => {
|
||||||
console.error('Usage:');
|
console.error('Usage:');
|
||||||
console.error(' node bin/compactAllPads.js');
|
console.error(' pnpm run --filter bin compactAllPads');
|
||||||
console.error(' node bin/compactAllPads.js --keep <N>');
|
console.error(' pnpm run --filter bin compactAllPads --keep <N>');
|
||||||
console.error(' node bin/compactAllPads.js --dry-run');
|
console.error(' pnpm run --filter bin compactAllPads --dry-run');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
* Compact a pad's revision history to reclaim database space.
|
* Compact a pad's revision history to reclaim database space.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node bin/compactPad.js <padID> # collapse all history
|
* pnpm run --filter bin compactPad <padID> # collapse all history
|
||||||
* node bin/compactPad.js <padID> --keep N # keep only the last N revisions
|
* pnpm run --filter bin compactPad <padID> --keep N # keep only the last N revisions
|
||||||
*
|
*
|
||||||
* Wraps the existing Cleanup helper (src/node/utils/Cleanup.ts) via the
|
* Wraps the existing Cleanup helper (src/node/utils/Cleanup.ts) via the
|
||||||
* compactPad HTTP API so admins can trigger it from the CLI without
|
* compactPad HTTP API so admins can trigger it from the CLI without
|
||||||
|
|
@ -41,8 +41,8 @@ const apiPost = async (p: string): Promise<any> => {
|
||||||
|
|
||||||
const usage = () => {
|
const usage = () => {
|
||||||
console.error('Usage:');
|
console.error('Usage:');
|
||||||
console.error(' node bin/compactPad.js <padID>');
|
console.error(' pnpm run --filter bin compactPad <padID>');
|
||||||
console.error(' node bin/compactPad.js <padID> --keep <N>');
|
console.error(' pnpm run --filter bin compactPad <padID> --keep <N>');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@
|
||||||
* Compact every pad on the instance that has not been edited recently.
|
* Compact every pad on the instance that has not been edited recently.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node bin/compactStalePads.js --older-than 90 # collapse history on pads not edited in 90 days
|
* pnpm run --filter bin compactStalePads --older-than 90 # collapse history on pads not edited in 90 days
|
||||||
* node bin/compactStalePads.js --older-than 90 --keep 50 # keep last 50 revisions
|
* pnpm run --filter bin compactStalePads --older-than 90 --keep 50 # keep last 50 revisions
|
||||||
* node bin/compactStalePads.js --older-than 90 --dry-run # list, don't write
|
* pnpm run --filter bin compactStalePads --older-than 90 --dry-run # list, don't write
|
||||||
*
|
*
|
||||||
* Composes `listAllPads` → `getLastEdited` → `compactPad`. Same shape as
|
* Composes `listAllPads` → `getLastEdited` → `compactPad`. Same shape as
|
||||||
* `bin/compactAllPads` (per-pad error tolerance, dry-run, tally), but
|
* `bin/compactAllPads` (per-pad error tolerance, dry-run, tally), but
|
||||||
|
|
@ -255,9 +255,9 @@ export const parseArgs = (argv: string[]): CompactStaleOpts | null => {
|
||||||
|
|
||||||
const usage = () => {
|
const usage = () => {
|
||||||
console.error('Usage:');
|
console.error('Usage:');
|
||||||
console.error(' node bin/compactStalePads.js --older-than <days>');
|
console.error(' pnpm run --filter bin compactStalePads --older-than <days>');
|
||||||
console.error(' node bin/compactStalePads.js --older-than <days> --keep <N>');
|
console.error(' pnpm run --filter bin compactStalePads --older-than <days> --keep <N>');
|
||||||
console.error(' node bin/compactStalePads.js --older-than <days> --dry-run');
|
console.error(' pnpm run --filter bin compactStalePads --older-than <days> --dry-run');
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,203 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
#
|
|
||||||
# WARNING: since Etherpad 1.7.0 (2018-08-17), this script is DEPRECATED, and
|
|
||||||
# will be removed/modified in a future version.
|
|
||||||
# It's left here just for documentation.
|
|
||||||
# The branching policies for releases have been changed.
|
|
||||||
#
|
|
||||||
# This script is used to publish a new release/version of etherpad on github
|
|
||||||
#
|
|
||||||
# Work that is done by this script:
|
|
||||||
# ETHER_REPO:
|
|
||||||
# - Add text to CHANGELOG.md
|
|
||||||
# - Replace version of etherpad in src/package.json
|
|
||||||
# - Create a release branch and push it to github
|
|
||||||
# - Merges this release branch into master branch
|
|
||||||
# - Creating the windows build and the docs
|
|
||||||
# ETHER_WEB_REPO:
|
|
||||||
# - Creating a new branch with the docs and the windows build
|
|
||||||
# - Replacing the version numbers in the index.html
|
|
||||||
# - Push this branch and merge it to master
|
|
||||||
# ETHER_REPO:
|
|
||||||
# - Create a new release on github
|
|
||||||
|
|
||||||
printf "WARNING: since Etherpad 1.7.0 this script is DEPRECATED, and will be removed/modified in a future version.\n\n"
|
|
||||||
while true; do
|
|
||||||
read -p "Do you want to continue? This is discouraged. [y/N]" yn
|
|
||||||
case $yn in
|
|
||||||
[Yy]* ) break;;
|
|
||||||
[Nn]* ) exit;;
|
|
||||||
* ) printf "Please answer yes or no.\n\n";;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
ETHER_REPO="https://github.com/ether/etherpad.git"
|
|
||||||
ETHER_WEB_REPO="https://github.com/ether/ether.github.com.git"
|
|
||||||
TMP_DIR="/tmp/"
|
|
||||||
|
|
||||||
echo "WARNING: You can only run this script if your github api token is allowed to create and merge branches on $ETHER_REPO and $ETHER_WEB_REPO."
|
|
||||||
echo "This script automatically changes the version number in package.json and adds a text to CHANGELOG.md."
|
|
||||||
echo "When you use this script you should be in the branch that you want to release (develop probably) on latest version. Any changes that are currently not committed will be committed."
|
|
||||||
echo "-----"
|
|
||||||
|
|
||||||
# Get the latest version
|
|
||||||
LATEST_GIT_TAG=$(git tag | tail -n 1)
|
|
||||||
|
|
||||||
# Current environment
|
|
||||||
echo "Current environment: "
|
|
||||||
echo "- branch: $(git branch | grep '* ')"
|
|
||||||
echo "- last commit date: $(git show --quiet --pretty=format:%ad)"
|
|
||||||
echo "- current version: $LATEST_GIT_TAG"
|
|
||||||
echo "- temp dir: $TMP_DIR"
|
|
||||||
|
|
||||||
# Get new version number
|
|
||||||
# format: x.x.x
|
|
||||||
echo -n "Enter new version (x.x.x): "
|
|
||||||
read VERSION
|
|
||||||
|
|
||||||
# Get the message for the changelogs
|
|
||||||
read -p "Enter new changelog entries (press enter): "
|
|
||||||
tmp=$(mktemp)
|
|
||||||
"${EDITOR:-vi}" $tmp
|
|
||||||
changelogText=$(<$tmp)
|
|
||||||
echo "$changelogText"
|
|
||||||
rm $tmp
|
|
||||||
|
|
||||||
if [ "$changelogText" != "" ]; then
|
|
||||||
changelogText="# $VERSION\n$changelogText"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# get the token for the github api
|
|
||||||
echo -n "Enter your github api token: "
|
|
||||||
read API_TOKEN
|
|
||||||
|
|
||||||
function check_api_token {
|
|
||||||
echo "Checking if github api token is valid..."
|
|
||||||
CURL_RESPONSE=$(curl --silent -i https://api.github.com/user?access_token=$API_TOKEN | iconv -f utf8)
|
|
||||||
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
|
|
||||||
[[ $HTTP_STATUS != "200" ]] && echo "Aborting: Invalid github api token" && exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function modify_files {
|
|
||||||
# Add changelog text to first line of CHANGELOG.md
|
|
||||||
|
|
||||||
msg=""
|
|
||||||
# source: https://unix.stackexchange.com/questions/9784/how-can-i-read-line-by-line-from-a-variable-in-bash#9789
|
|
||||||
while IFS= read -r line
|
|
||||||
do
|
|
||||||
# replace newlines with literal "\n" for using with sed
|
|
||||||
msg+="$line\n"
|
|
||||||
done < <(printf '%s\n' "${changelogText}")
|
|
||||||
|
|
||||||
sed -i "1s/^/${msg}\n/" CHANGELOG.md
|
|
||||||
[[ $? != 0 ]] && echo "Aborting: Error modifying CHANGELOG.md" && exit 1
|
|
||||||
|
|
||||||
# Replace version number of etherpad in package.json
|
|
||||||
sed -i -r "s/(\"version\"[ ]*: \").*(\")/\1$VERSION\2/" src/package.json
|
|
||||||
[[ $? != 0 ]] && echo "Aborting: Error modifying package.json" && exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function create_release_branch {
|
|
||||||
echo "Creating new release branch..."
|
|
||||||
git rev-parse --verify release/$VERSION 2>/dev/null
|
|
||||||
if [ $? == 0 ]; then
|
|
||||||
echo "Aborting: Release branch already present"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
git checkout -b release/$VERSION
|
|
||||||
[[ $? != 0 ]] && echo "Aborting: Error creating release branch" && exit 1
|
|
||||||
|
|
||||||
echo "Committing CHANGELOG.md and package.json"
|
|
||||||
git add CHANGELOG.md
|
|
||||||
git add src/package.json
|
|
||||||
git commit -m "Release version $VERSION"
|
|
||||||
|
|
||||||
echo "Pushing release branch to github..."
|
|
||||||
git push -u $ETHER_REPO release/$VERSION
|
|
||||||
[[ $? != 0 ]] && echo "Aborting: Error pushing release branch to github" && exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function merge_release_branch {
|
|
||||||
echo "Merging release to master branch on github..."
|
|
||||||
API_JSON=$(printf '{"base": "master","head": "release/%s","commit_message": "Merge new release into master branch!"}' $VERSION)
|
|
||||||
CURL_RESPONSE=$(curl --silent -i -N --data "$API_JSON" https://api.github.com/repos/ether/etherpad-lite/merges?access_token=$API_TOKEN | iconv -f utf8)
|
|
||||||
echo $CURL_RESPONSE
|
|
||||||
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
|
|
||||||
[[ $HTTP_STATUS != "200" ]] && echo "Aborting: Error merging release branch on github" && exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function create_builds {
|
|
||||||
echo "Cloning etherpad-lite repo and ether.github.com repo..."
|
|
||||||
cd $TMP_DIR
|
|
||||||
rm -rf etherpad-lite ether.github.com
|
|
||||||
git clone $ETHER_REPO --branch master
|
|
||||||
git clone $ETHER_WEB_REPO
|
|
||||||
echo "Creating windows build..."
|
|
||||||
cd etherpad-lite
|
|
||||||
bin/buildForWindows.sh
|
|
||||||
[[ $? != 0 ]] && echo "Aborting: Error creating build for windows" && exit 1
|
|
||||||
echo "Creating docs..."
|
|
||||||
make docs
|
|
||||||
[[ $? != 0 ]] && echo "Aborting: Error generating docs" && exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function push_builds {
|
|
||||||
cd $TMP_DIR/etherpad-lite/
|
|
||||||
echo "Copying windows build and docs to website repo..."
|
|
||||||
GIT_SHA=$(git rev-parse HEAD | cut -c1-10)
|
|
||||||
mv etherpad-win.zip $TMP_DIR/ether.github.com/downloads/etherpad-win-$VERSION-$GIT_SHA.zip
|
|
||||||
|
|
||||||
mv out/doc $TMP_DIR/ether.github.com/doc/v$VERSION
|
|
||||||
|
|
||||||
cd $TMP_DIR/ether.github.com/
|
|
||||||
sed -i "s/etherpad-win.*\.zip/etherpad-win-$VERSION-$GIT_SHA.zip/" index.html
|
|
||||||
sed -i "s/$LATEST_GIT_TAG/$VERSION/g" index.html
|
|
||||||
git checkout -b release_$VERSION
|
|
||||||
[[ $? != 0 ]] && echo "Aborting: Error creating new release branch" && exit 1
|
|
||||||
git add doc/
|
|
||||||
git add downloads/
|
|
||||||
git commit -a -m "Release version $VERSION"
|
|
||||||
git push -u $ETHER_WEB_REPO release_$VERSION
|
|
||||||
[[ $? != 0 ]] && echo "Aborting: Error pushing release branch to github" && exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function merge_web_branch {
|
|
||||||
echo "Merging release to master branch on github..."
|
|
||||||
API_JSON=$(printf '{"base": "master","head": "release_%s","commit_message": "Release version %s"}' $VERSION $VERSION)
|
|
||||||
CURL_RESPONSE=$(curl --silent -i -N --data "$API_JSON" https://api.github.com/repos/ether/ether.github.com/merges?access_token=$API_TOKEN | iconv -f utf8)
|
|
||||||
echo $CURL_RESPONSE
|
|
||||||
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
|
|
||||||
[[ $HTTP_STATUS != "200" ]] && echo "Aborting: Error merging release branch" && exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function publish_release {
|
|
||||||
echo -n "Do you want to publish a new release on github (y/n)? "
|
|
||||||
read PUBLISH_RELEASE
|
|
||||||
if [ $PUBLISH_RELEASE = "y" ]; then
|
|
||||||
# create a new release on github
|
|
||||||
API_JSON=$(printf '{"tag_name": "%s","target_commitish": "master","name": "Release %s","body": "%s","draft": false,"prerelease": false}' $VERSION $VERSION $changelogText)
|
|
||||||
CURL_RESPONSE=$(curl --silent -i -N --data "$API_JSON" https://api.github.com/repos/ether/etherpad-lite/releases?access_token=$API_TOKEN | iconv -f utf8)
|
|
||||||
HTTP_STATUS=$(echo $CURL_RESPONSE | head -1 | sed -r 's/.* ([0-9]{3}) .*/\1/')
|
|
||||||
[[ $HTTP_STATUS != "201" ]] && echo "Aborting: Error publishing release on github" && exit 1
|
|
||||||
else
|
|
||||||
echo "No release published on github!"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
function todo_notification {
|
|
||||||
echo "Release procedure was successful, but you have to do some steps manually:"
|
|
||||||
echo "- Update the wiki at https://github.com/ether/etherpad/wiki"
|
|
||||||
echo "- Create a pull request on github to merge the master branch back to develop"
|
|
||||||
echo "- Announce the new release on the mailing list, blog.etherpad.org and Twitter"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Call functions
|
|
||||||
check_api_token
|
|
||||||
modify_files
|
|
||||||
create_release_branch
|
|
||||||
merge_release_branch
|
|
||||||
create_builds
|
|
||||||
push_builds
|
|
||||||
merge_web_branch
|
|
||||||
publish_release
|
|
||||||
todo_notification
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
// As of v14, Node.js does not exit when there is an unhandled Promise rejection. Convert an
|
// 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.
|
// unhandled rejection into an uncaught exception, which does cause Node.js to exit.
|
||||||
import util from "node:util";
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import log4js from 'log4js';
|
import log4js from 'log4js';
|
||||||
import readline from 'readline';
|
import readline from 'readline';
|
||||||
|
|
@ -69,8 +68,7 @@ const unescape = (val: string) => {
|
||||||
if (!sqlFile) throw new Error('Use: node importSqlFile.js $SQLFILE');
|
if (!sqlFile) throw new Error('Use: node importSqlFile.js $SQLFILE');
|
||||||
|
|
||||||
log('initializing db');
|
log('initializing db');
|
||||||
const initDb = await util.promisify(db.init.bind(db));
|
await db.init();
|
||||||
await initDb(null);
|
|
||||||
log('done');
|
log('done');
|
||||||
|
|
||||||
log(`Opening ${sqlFile}...`);
|
log(`Opening ${sqlFile}...`);
|
||||||
|
|
@ -86,8 +84,7 @@ const unescape = (val: string) => {
|
||||||
value = value.substring(0, value.length - 2);
|
value = value.substring(0, value.length - 2);
|
||||||
console.log(`key: ${key} val: ${value}`);
|
console.log(`key: ${key} val: ${value}`);
|
||||||
console.log(`unval: ${unescape(value)}`);
|
console.log(`unval: ${unescape(value)}`);
|
||||||
// @ts-ignore
|
await db.set(key, unescape(value));
|
||||||
db.set(key, unescape(value), null);
|
|
||||||
keyNo++;
|
keyNo++;
|
||||||
if (keyNo % 1000 === 0) log(` ${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. ' +
|
process.stdout.write('done. waiting for db to finish transaction. ' +
|
||||||
'depended on dbms this may take some time..\n');
|
'depended on dbms this may take some time..\n');
|
||||||
|
|
||||||
const closeDB = util.promisify(db.close.bind(db));
|
await db.close();
|
||||||
// @ts-ignore
|
|
||||||
await closeDB(null);
|
|
||||||
log(`finished, imported ${keyNo} keys.`);
|
log(`finished, imported ${keyNo} keys.`);
|
||||||
process.exit(0)
|
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")
|
console.log("Done syncing dbs")
|
||||||
}).catch(e=>{
|
process.exit(0)
|
||||||
|
}).catch(async e=>{
|
||||||
console.log(`Error syncing db ${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('*', '')
|
const keys = await dirty.findKeys('*', '')
|
||||||
|
|
||||||
console.log(`Found ${keys.length} records, processing now.`);
|
console.log(`Found ${keys.length} records, processing now.`);
|
||||||
const p: Promise<void>[] = [];
|
|
||||||
let numWritten = 0;
|
let numWritten = 0;
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
let value = await dirty.get(key);
|
const value = await dirty.get(key);
|
||||||
let bcb, wcb;
|
await db.set(key, value);
|
||||||
p.push(new Promise((resolve, reject) => {
|
if (++numWritten % 100 === 0) console.log(`Wrote record ${numWritten} of ${keys.length}`);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
await Promise.all(p);
|
|
||||||
console.log(`Wrote all ${numWritten} records`);
|
console.log(`Wrote all ${numWritten} records`);
|
||||||
|
|
||||||
await db.close(null);
|
await db.close();
|
||||||
await dirty.close(null);
|
await dirty.close();
|
||||||
console.log('Finished.');
|
console.log('Finished.');
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "bin",
|
"name": "bin",
|
||||||
"version": "3.1.0",
|
"version": "3.3.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "checkAllPads.js",
|
"main": "checkAllPads.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|
@ -9,12 +9,12 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ep_etherpad-lite": "workspace:../src",
|
"ep_etherpad-lite": "workspace:../src",
|
||||||
"log4js": "^6.9.1",
|
"log4js": "^6.9.1",
|
||||||
"semver": "^7.8.0",
|
"semver": "^7.8.5",
|
||||||
"tsx": "^4.22.0",
|
"tsx": "^4.23.0",
|
||||||
"ueberdb2": "^6.0.3"
|
"ueberdb2": "6.1.16"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.8.0",
|
"@types/node": "^26.1.1",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"checkAllPads": "node --import tsx checkAllPads.ts",
|
"checkAllPads": "node --import tsx checkAllPads.ts",
|
||||||
"compactPad": "node --import tsx compactPad.ts",
|
"compactPad": "node --import tsx compactPad.ts",
|
||||||
"compactAllPads": "node --import tsx compactAllPads.ts",
|
"compactAllPads": "node --import tsx compactAllPads.ts",
|
||||||
|
"compactStalePads": "node --import tsx compactStalePads.ts",
|
||||||
"createUserSession": "node --import tsx createUserSession.ts",
|
"createUserSession": "node --import tsx createUserSession.ts",
|
||||||
"deletePad": "node --import tsx deletePad.ts",
|
"deletePad": "node --import tsx deletePad.ts",
|
||||||
"repairPad": "node --import tsx repairPad.ts",
|
"repairPad": "node --import tsx repairPad.ts",
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,19 @@ export default defineConfig({
|
||||||
text: 'About',
|
text: 'About',
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Docker', link: '/docker.md' },
|
{ 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: 'Localization', link: '/localization.md' },
|
||||||
{ text: 'Cookies', link: '/cookies.md' },
|
{ text: 'Cookies', link: '/cookies.md' },
|
||||||
{ text: 'Plugins', link: '/plugins.md' },
|
{ text: 'Plugins', link: '/plugins.md' },
|
||||||
{ text: 'Stats', link: '/stats.md' },
|
{ text: 'Stats', link: '/stats.md' },
|
||||||
{text: 'Skins', link: '/skins.md' },
|
{text: 'Skins', link: '/skins.md' },
|
||||||
|
{ text: 'Accessibility', link: '/accessibility.md' },
|
||||||
{text: 'Demo', link: '/demo.md' },
|
{text: 'Demo', link: '/demo.md' },
|
||||||
{text: 'CLI', link: '/cli.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.
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Etherpad ships with a built-in update subsystem.
|
Etherpad ships with a built-in update subsystem.
|
||||||
|
|
||||||
- **Tier 1 (notify)** — default. A banner appears in the admin UI when a new release is available, and pad users see a discreet badge if the running version is severely outdated or flagged as vulnerable. No execution.
|
- **Tier 1 (notify)** — default. A banner appears in the admin UI when a new release is available, and pad users see a dismissable gritter notification if the running version is at least one minor version behind the latest release. No execution.
|
||||||
- **Tier 2 (manual click)** — admins on a git install can click "Apply update" at `/admin/update`. Etherpad drains active sessions, runs `git fetch / checkout / pnpm install / pnpm run build:ui`, and exits with code 75 so a process supervisor restarts it on the new version. Auto-rolls back on failure.
|
- **Tier 2 (manual click)** — admins on a git install can click "Apply update" at `/admin/update`. Etherpad drains active sessions, runs `git fetch / checkout / pnpm install / pnpm run build:ui`, and exits with code 75 so a process supervisor restarts it on the new version. Auto-rolls back on failure.
|
||||||
- **Tier 3 (auto with grace window)** — opt-in. On a git install, a newly detected release transitions execution state to `scheduled` and is applied after `preApplyGraceMinutes`. During the grace window, `/admin/update` shows a live countdown plus Cancel and Apply now buttons; an admin email (if `adminEmail` is set) fires once per scheduled tag.
|
- **Tier 3 (auto with grace window)** — opt-in. On a git install, a newly detected release transitions execution state to `scheduled` and is applied after `preApplyGraceMinutes`. During the grace window, `/admin/update` shows a live countdown plus Cancel and Apply now buttons; an admin email (if `adminEmail` is set) fires once per scheduled tag.
|
||||||
- **Tier 4 (autonomous in maintenance window)** — opt-in. Tier 3 + `updates.maintenanceWindow` is required; the scheduler only fires while the wall clock is inside the configured window. Updates detected outside the window queue for the next opening.
|
- **Tier 4 (autonomous in maintenance window)** — opt-in. Tier 3 + `updates.maintenanceWindow` is required; the scheduler only fires while the wall clock is inside the configured window. Updates detected outside the window queue for the next opening.
|
||||||
|
|
@ -29,13 +29,22 @@ In `settings.json`:
|
||||||
"requireSignature": false,
|
"requireSignature": false,
|
||||||
"trustedKeysPath": null
|
"trustedKeysPath": null
|
||||||
},
|
},
|
||||||
"adminEmail": null
|
"adminEmail": null,
|
||||||
|
// SMTP transport for the admin notification emails. host=null keeps
|
||||||
|
// log-only behaviour ("(would send email)"); set host+from to deliver.
|
||||||
|
"mail": {
|
||||||
|
"host": null,
|
||||||
|
"port": 587,
|
||||||
|
"secure": false,
|
||||||
|
"from": null,
|
||||||
|
"auth": null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Setting | Default | Notes |
|
| Setting | Default | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. Higher tiers are silently downgraded if the install method does not allow them. PR 1 only honors `"notify"` and `"off"`. |
|
| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. All tiers are implemented. Higher tiers are silently downgraded if the install method does not allow them (only `"git"` installs can run the write tiers `manual` / `auto` / `autonomous`). |
|
||||||
| `updates.source` | `"github"` | Reserved for future alternative sources. Only `"github"` is implemented. |
|
| `updates.source` | `"github"` | Reserved for future alternative sources. Only `"github"` is implemented. |
|
||||||
| `updates.channel` | `"stable"` | Reserved. Stable releases only. |
|
| `updates.channel` | `"stable"` | Reserved. Stable releases only. |
|
||||||
| `updates.installMethod` | `"auto"` | One of `"auto"`, `"git"`, `"docker"`, `"npm"`, `"managed"`. Auto-detects via filesystem heuristics. Set explicitly to override. |
|
| `updates.installMethod` | `"auto"` | One of `"auto"`, `"git"`, `"docker"`, `"npm"`, `"managed"`. Auto-detects via filesystem heuristics. Set explicitly to override. |
|
||||||
|
|
@ -49,37 +58,53 @@ In `settings.json`:
|
||||||
| `updates.requireSignature` | `false` | When `true`, refuse updates whose tag is not signed by a trusted key. Verification is done via `git verify-tag <tag>` against the user's GPG keyring. Default `false` because Etherpad's release process does not yet sign tags consistently — turning the check on by default would block every Tier 2 update. Set `true` if you run your own builds or have imported a fork's keys. |
|
| `updates.requireSignature` | `false` | When `true`, refuse updates whose tag is not signed by a trusted key. Verification is done via `git verify-tag <tag>` against the user's GPG keyring. Default `false` because Etherpad's release process does not yet sign tags consistently — turning the check on by default would block every Tier 2 update. Set `true` if you run your own builds or have imported a fork's keys. |
|
||||||
| `updates.trustedKeysPath` | `null` | Override the keyring location passed to `git verify-tag` via the `$GNUPGHOME` env var. Useful when the trusted keys live in a dedicated keyring outside the Etherpad user's home. Only meaningful when `requireSignature: true`. |
|
| `updates.trustedKeysPath` | `null` | Override the keyring location passed to `git verify-tag` via the `$GNUPGHOME` env var. Useful when the trusted keys live in a dedicated keyring outside the Etherpad user's home. Only meaningful when `requireSignature: true`. |
|
||||||
| `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
|
| `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
|
||||||
|
| `mail.host` | `null` | Top-level SMTP host. **`null` keeps log-only behaviour** — notifications are logged as `(would send email)` and never delivered. Set a host (and `mail.from`) to deliver over SMTP via nodemailer. The `nodemailer` dependency is lazy-loaded, so installs that leave `mail.host` unset pay no runtime cost. |
|
||||||
|
| `mail.port` | `587` | SMTP port. |
|
||||||
|
| `mail.secure` | `false` | `true` for an implicit-TLS connection (typically port 465); `false` uses STARTTLS upgrade when offered. |
|
||||||
|
| `mail.from` | `null` | Envelope/From address. **Required for delivery** — if `mail.from` is unset (even with a host) the updater falls back to log-only `(would send email)`. |
|
||||||
|
| `mail.auth` | `null` | SMTP credentials object `{ "user": "...", "pass": "..." }`, passed through to nodemailer. Leave `null` for unauthenticated relays. |
|
||||||
|
|
||||||
## What "outdated" means
|
## What "outdated" means
|
||||||
|
|
||||||
- **`severe`** — running at least one major version behind the latest release.
|
- **`minor`** — the running server is at least one minor version behind the latest published release. Patch-only deltas (same major and minor, higher patch) do not fire the notice.
|
||||||
- **`vulnerable`** — the running version is below a `vulnerable-below` threshold announced in a recent release. Releases declare these via a `<!-- updater: vulnerable-below X.Y.Z -->` HTML comment in their body. The newest such directive wins.
|
|
||||||
|
|
||||||
## Email cadence (when `adminEmail` is set)
|
## Email cadence (when `adminEmail` is set)
|
||||||
|
|
||||||
|
These are the "nudge" emails sent by the periodic checker when the instance is behind:
|
||||||
|
|
||||||
| Trigger | First send | Repeat |
|
| Trigger | First send | Repeat |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Vulnerable status detected | Immediate | Weekly while still vulnerable |
|
| Outdated (minor or more behind) detected | Immediate | Every 30 days while still outdated (`SEVERE_INTERVAL`) |
|
||||||
| New release announced while still vulnerable | Immediate | n/a (one event per tag change) |
|
|
||||||
| Severely outdated detected | Immediate | Monthly while still severely outdated |
|
|
||||||
| Up to date | No email | — |
|
| Up to date | No email | — |
|
||||||
|
|
||||||
If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side badge still work without it.
|
The write tiers also email about **apply outcomes** so admins learn about failures without watching the UI:
|
||||||
|
|
||||||
PR 1 ships the cadence machinery but does not yet wire a real SMTP transport — emails are logged with `(would send email)` until a future PR adds the transport. The dedupe state still advances correctly so admins are not bombarded once SMTP is wired.
|
| Outcome | When | Dedupe |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `update-preflight-failed` | An auto/autonomous apply was blocked at preflight (e.g. `node-engine-mismatch`, dirty tree, low disk). Subject: *Auto-update to `<tag>` blocked at preflight*. | Deduped on `<outcome>:<targetTag>` — one email per outcome per target tag. |
|
||||||
|
| `update-rolled-back` | An apply failed mid-flow and Etherpad auto-recovered to the previous version. Subject: *Auto-update to `<tag>` rolled back*. | Deduped on `<outcome>:<targetTag>`. |
|
||||||
|
| `update-rollback-failed` | **Terminal.** The apply failed *and* the rollback failed — manual intervention required. Subject: *Auto-update FAILED and could not be rolled back — manual intervention required*. | **Always sends**, bypassing dedupe, because the admin must learn about it even if a transient failure shared the same key. |
|
||||||
|
|
||||||
## Pad-side badge
|
A different outcome or a different target tag resets the dedupe key and fires a fresh email. Manual (Tier 2) failures surface in the admin UI banner; the outcome emails are tied to the auto/autonomous flows.
|
||||||
|
|
||||||
Pad users see no version information by default. A small badge appears in the bottom-right corner only when:
|
If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side notice still work without it.
|
||||||
|
|
||||||
- The instance is `severe` (one or more major versions behind), or
|
SMTP delivery is wired via [nodemailer](https://nodemailer.com/) (lazy-loaded). When `mail.host` and `mail.from` are both set, emails are delivered over SMTP. When either is unset the updater falls back to logging each message as `(would send email)` — the dedupe state still advances correctly, so admins are not bombarded once SMTP is configured. An SMTP send failure is caught and logged (`email send failed: …`) and never disrupts the updater state machine.
|
||||||
- The instance is `vulnerable` (running below an announced threshold).
|
|
||||||
|
|
||||||
The public endpoint `/api/version-status` returns only `{outdated: null|"severe"|"vulnerable"}` — it never leaks the running version, so attackers do not gain a fingerprint vector.
|
## Pad-side notice
|
||||||
|
|
||||||
|
Pad users see no version information by default. A dismissable gritter notification appears only when:
|
||||||
|
|
||||||
|
- The running server is at least one minor version behind the latest published release (patch-only deltas do not fire), **and**
|
||||||
|
- The requesting user is the first author of the pad.
|
||||||
|
|
||||||
|
The notice auto-fades after 8 seconds and can be dismissed immediately. The public endpoint `/api/version-status` accepts an optional `?padId=<id>` query parameter and returns `{outdated: "minor" | null, isFirstAuthor: boolean}` — it never leaks the running version, so attackers do not gain a fingerprint vector. Results are cached per `(padId, authorId)` for 60 seconds.
|
||||||
|
|
||||||
## Disabling everything
|
## 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
|
## Privacy
|
||||||
|
|
||||||
|
|
@ -96,7 +121,7 @@ The version check sends no telemetry. Etherpad fetches the public GitHub Release
|
||||||
|
|
||||||
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
|
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
|
||||||
|
|
||||||
In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only `"git"` is initially supported for write tiers.
|
Every install method gets the Tier 1 banner. The install method gates whether the write tiers (manual click, auto, autonomous) can run: only `"git"` installs are supported for the write tiers — other methods are silently downgraded to notify.
|
||||||
|
|
||||||
## Tier 2 — manual click
|
## Tier 2 — manual click
|
||||||
|
|
||||||
|
|
@ -113,7 +138,7 @@ Etherpad applies an update by **exiting with code 75** so a process supervisor r
|
||||||
### What clicking "Apply update" does
|
### What clicking "Apply update" does
|
||||||
|
|
||||||
1. **Lock acquire** — `var/update.lock` (PID-based, stale locks reaped automatically).
|
1. **Lock acquire** — `var/update.lock` (PID-based, stale locks reaped automatically).
|
||||||
2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, target tag exists at the configured remote, signature verifies (if `requireSignature: true`). On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back.
|
2. **Pre-flight checks** — install method writable, working tree clean, free disk ≥ `diskSpaceMinMB`, `pnpm` on `PATH`, no lock held, target tag exists at the configured remote, signature verifies (if `requireSignature: true`), and the target's Node engine matches the running Node. The Node-engine check runs *after* signature verification (so the `engines.node` range comes from a trusted tag): Etherpad reads `engines.node` from the target tag's `package.json` via `git show <tag>:package.json` and refuses the update via `semver.satisfies` if the running Node does not satisfy it. On failure, state goes to `preflight-failed` with a typed reason; the admin sees a banner and clicks **Acknowledge** to clear it. No filesystem mutation has happened — nothing to roll back.
|
||||||
3. **Drain** — `drainSeconds` window during which T-60 / T-30 / T-10 announcements broadcast to every connected pad and new socket connections are refused. Click **Cancel** during this window to abort cleanly.
|
3. **Drain** — `drainSeconds` window during which T-60 / T-30 / T-10 announcements broadcast to every connected pad and new socket connections are refused. Click **Cancel** during this window to abort cleanly.
|
||||||
4. **Execute** — `git fetch --tags origin`, `git checkout <tag>`, `pnpm install --frozen-lockfile`, `pnpm run build:ui`. Output streams to `var/log/update.log` (rotated 10 MB × 5).
|
4. **Execute** — `git fetch --tags origin`, `git checkout <tag>`, `pnpm install --frozen-lockfile`, `pnpm run build:ui`. Output streams to `var/log/update.log` (rotated 10 MB × 5).
|
||||||
5. **Exit 75** — the supervisor restarts on the new version.
|
5. **Exit 75** — the supervisor restarts on the new version.
|
||||||
|
|
@ -124,6 +149,7 @@ Etherpad applies an update by **exiting with code 75** so a process supervisor r
|
||||||
| What went wrong | Resulting state | Admin action |
|
| What went wrong | Resulting state | Admin action |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Pre-flight check fails | `preflight-failed` | Click **Acknowledge** after fixing the underlying issue (free up disk, clean working tree, etc.). |
|
| Pre-flight check fails | `preflight-failed` | Click **Acknowledge** after fixing the underlying issue (free up disk, clean working tree, etc.). |
|
||||||
|
| Target tag requires a newer (or different) Node than the one running | `preflight-failed` (reason `node-engine-mismatch`) | Fails cleanly at preflight with a detail like *"target requires Node >=X, running Y"*. No drain, no `git checkout`, no restart, nothing to roll back — the install is untouched. Upgrade Node to a version that satisfies the target's `engines.node`, then **Acknowledge** and retry. |
|
||||||
| `git fetch` / `git checkout` fails mid-flow | `rolled-back` | Informational. The working tree is back where it started; click **Acknowledge** to clear. |
|
| `git fetch` / `git checkout` fails mid-flow | `rolled-back` | Informational. The working tree is back where it started; click **Acknowledge** to clear. |
|
||||||
| `pnpm install` or `pnpm run build:ui` fails | `rolled-back` | Same as above. The lockfile and SHA are restored. |
|
| `pnpm install` or `pnpm run build:ui` fails | `rolled-back` | Same as above. The lockfile and SHA are restored. |
|
||||||
| `/health` doesn't come up within `rollbackHealthCheckSeconds` | `rolled-back` | Same — RollbackHandler restores the previous SHA + lockfile and exits 75 again. |
|
| `/health` doesn't come up within `rollbackHealthCheckSeconds` | `rolled-back` | Same — RollbackHandler restores the previous SHA + lockfile and exits 75 again. |
|
||||||
|
|
@ -184,7 +210,7 @@ A single `grace-start` notification fires per scheduled tag:
|
||||||
|
|
||||||
> [Etherpad] Auto-update scheduled for 2.7.2
|
> [Etherpad] Auto-update scheduled for 2.7.2
|
||||||
|
|
||||||
with the `scheduledFor` timestamp. Etherpad core does not yet wire SMTP; the message logs as `(would send email)` until a future PR adds a transport. Cadence and dedupe still update correctly.
|
with the `scheduledFor` timestamp. Delivery follows the same SMTP path as every other notification: when `mail.host` and `mail.from` are set the message is sent via nodemailer, otherwise it logs as `(would send email)`. Cadence and dedupe update correctly either way.
|
||||||
|
|
||||||
The right way to give docker admins an in-product Apply button is to delegate to the orchestrator rather than mutate the container. Two patterns to consider in a follow-up PR:
|
The right way to give docker admins an in-product Apply button is to delegate to the orchestrator rather than mutate the container. Two patterns to consider in a follow-up PR:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Changeset Library
|
# Changeset Library
|
||||||
|
|
||||||
The [changeset
|
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.
|
provides tools to create, read, and apply changesets.
|
||||||
|
|
||||||
## Changeset
|
## Changeset
|
||||||
|
|
@ -21,6 +21,42 @@ A transmitted changeset looks like this:
|
||||||
'Z:z>1|2=m=b*0|1+1$\n'
|
'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
|
## Attribute Pool
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
|
|
@ -36,6 +72,59 @@ are used many times.
|
||||||
There is one attribute pool per pad, and it includes every current and
|
There is one attribute pool per pad, and it includes every current and
|
||||||
historical attribute used in the pad.
|
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
|
## Further Reading
|
||||||
|
|
||||||
Detailed information about the changesets & Easysync protocol:
|
Detailed information about the changesets & Easysync protocol:
|
||||||
|
|
|
||||||
|
|
@ -465,6 +465,20 @@ also use this to handle existing types.
|
||||||
`collab_client.js` has a pretty extensive list of message types, if you want to
|
`collab_client.js` has a pretty extensive list of message types, if you want to
|
||||||
take a look.
|
take a look.
|
||||||
|
|
||||||
|
## handleClientTimesliderMessage_`name`
|
||||||
|
|
||||||
|
Called from: `src/static/js/broadcast.ts`
|
||||||
|
|
||||||
|
Things in context:
|
||||||
|
|
||||||
|
1. payload - the data that got sent with the message (use it for custom message
|
||||||
|
content)
|
||||||
|
|
||||||
|
This is the timeslider analog of `handleClientMessage_name`. It gets called
|
||||||
|
every time the timeslider receives a message of type `name`. Use it to handle
|
||||||
|
custom message types (or react to existing ones) while a user is viewing the
|
||||||
|
timeslider rather than editing the pad.
|
||||||
|
|
||||||
## aceStartLineAndCharForPoint-aceEndLineAndCharForPoint
|
## aceStartLineAndCharForPoint-aceEndLineAndCharForPoint
|
||||||
|
|
||||||
Called from: src/static/js/ace2_inner.js
|
Called from: src/static/js/ace2_inner.js
|
||||||
|
|
@ -496,6 +510,34 @@ Things in context:
|
||||||
This hook is provided to allow a plugin to handle key events.
|
This hook is provided to allow a plugin to handle key events.
|
||||||
The return value should be true if you have handled the event.
|
The return value should be true if you have handled the event.
|
||||||
|
|
||||||
|
## acePaste
|
||||||
|
|
||||||
|
Called from: `src/static/js/ace2_inner.ts`
|
||||||
|
|
||||||
|
Things in context:
|
||||||
|
|
||||||
|
1. editorInfo - information about the user who is making the change
|
||||||
|
2. rep - information about where the change is being made
|
||||||
|
3. documentAttributeManager - information about attributes in the document
|
||||||
|
4. e - the fired paste event
|
||||||
|
|
||||||
|
This hook is called when content is pasted into the editor, before Etherpad
|
||||||
|
processes the pasted content. Use it to inspect or react to paste events.
|
||||||
|
|
||||||
|
## aceDrop
|
||||||
|
|
||||||
|
Called from: `src/static/js/ace2_inner.ts`
|
||||||
|
|
||||||
|
Things in context:
|
||||||
|
|
||||||
|
1. editorInfo - information about the user who is making the change
|
||||||
|
2. rep - information about where the change is being made
|
||||||
|
3. documentAttributeManager - information about attributes in the document
|
||||||
|
4. e - the fired drop event
|
||||||
|
|
||||||
|
This hook is called when content is dropped into the editor via drag-and-drop.
|
||||||
|
Use it to inspect or react to drop events.
|
||||||
|
|
||||||
## collectContentLineText
|
## collectContentLineText
|
||||||
|
|
||||||
Called from: `src/static/js/contentcollector.js`
|
Called from: `src/static/js/contentcollector.js`
|
||||||
|
|
|
||||||
|
|
@ -856,6 +856,39 @@ exports.exportHTMLAdditionalContent = async (hookName, {padId}) => {
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## exportHTMLSend
|
||||||
|
|
||||||
|
Called from: `src/node/handler/ExportHandler.ts`
|
||||||
|
|
||||||
|
Things in context:
|
||||||
|
|
||||||
|
1. html - the full HTML body of the pad that is about to be sent to the client
|
||||||
|
as an HTML export.
|
||||||
|
|
||||||
|
This hook is invoked via `aCallFirst` for HTML exports, just before the HTML is
|
||||||
|
sent in the response. It lets a plugin rewrite or replace the exported HTML
|
||||||
|
body. Return the modified HTML string; if a non-empty value is returned, it
|
||||||
|
replaces the HTML that would otherwise be sent.
|
||||||
|
|
||||||
|
## exportConvert
|
||||||
|
|
||||||
|
Called from: `src/node/handler/ExportHandler.ts`
|
||||||
|
|
||||||
|
Things in context:
|
||||||
|
|
||||||
|
1. srcFile - the path to the source file (the intermediate file Etherpad has
|
||||||
|
produced) that needs to be converted.
|
||||||
|
2. destFile - the path to the destination file Etherpad expects the converted
|
||||||
|
output to be written to.
|
||||||
|
3. req - the express request object.
|
||||||
|
4. res - the express response object.
|
||||||
|
|
||||||
|
This hook is invoked via `aCallAll` for non-HTML export formats. It lets a
|
||||||
|
plugin handle the export format conversion itself instead of letting Etherpad
|
||||||
|
fall back to its bundled LibreOffice converter. If any plugin returns a value
|
||||||
|
(making the hook result non-empty), Etherpad assumes the conversion was handled
|
||||||
|
by the plugin and skips the built-in converter.
|
||||||
|
|
||||||
## stylesForExport
|
## stylesForExport
|
||||||
Called from: `src/node/utils/ExportHtml.js`
|
Called from: `src/node/utils/ExportHtml.js`
|
||||||
|
|
||||||
|
|
@ -1101,3 +1134,66 @@ Context properties:
|
||||||
value with the user's actual author ID before this hook runs).
|
value with the user's actual author ID before this hook runs).
|
||||||
* `padId`: The pad's real (not read-only) identifier.
|
* `padId`: The pad's real (not read-only) identifier.
|
||||||
* `pad`: The pad's Pad object.
|
* `pad`: The pad's Pad object.
|
||||||
|
|
||||||
|
## ccRegisterBlockElements
|
||||||
|
|
||||||
|
Called from: `src/node/handler/ImportHandler.ts`,
|
||||||
|
`src/node/utils/ImportEtherpad.ts`, and `src/static/js/contentcollector.ts`
|
||||||
|
|
||||||
|
Things in context: None
|
||||||
|
|
||||||
|
This is the **server-side companion** to the client-only
|
||||||
|
`aceRegisterBlockElements` hook (see the client-side hooks documentation), and
|
||||||
|
it is the half that plugin authors commonly forget to implement. Registering a
|
||||||
|
block element only on the client means imports and server-side content
|
||||||
|
collection do not treat your element as block-level.
|
||||||
|
|
||||||
|
The return value of this hook should be an array of tag names. Those tags are
|
||||||
|
added to the set of block-level elements that the content collector and the
|
||||||
|
import path (`.etherpad` and HTML/document imports) recognise, so the
|
||||||
|
collected/imported content is segmented into the correct lines. Plugins that
|
||||||
|
declare block elements via `aceRegisterBlockElements` should declare the same
|
||||||
|
elements here too.
|
||||||
|
|
||||||
|
```js
|
||||||
|
exports.ccRegisterBlockElements = () => ['pre', 'blockquote'];
|
||||||
|
```
|
||||||
|
|
||||||
|
## createServer
|
||||||
|
|
||||||
|
Called from: `src/node/server.ts`
|
||||||
|
|
||||||
|
Things in context: None
|
||||||
|
|
||||||
|
Invoked via `aCallAll` once during Etherpad startup, after the HTTP server has
|
||||||
|
been created. Use it to run any plugin initialisation that needs to happen when
|
||||||
|
the server comes up.
|
||||||
|
|
||||||
|
## restartServer
|
||||||
|
|
||||||
|
Called from: `src/node/hooks/express/adminsettings.ts` (and the plugin
|
||||||
|
installer)
|
||||||
|
|
||||||
|
Things in context: None
|
||||||
|
|
||||||
|
Invoked via `aCallAll` when the server is restarted from the admin interface
|
||||||
|
(for example after settings are saved or a plugin is installed/uninstalled). Use
|
||||||
|
it to re-initialise plugin state that needs to survive an admin-triggered
|
||||||
|
restart.
|
||||||
|
|
||||||
|
## clientReady
|
||||||
|
|
||||||
|
> **Deprecated:** use the `userJoin` hook instead. This hook is fired with an
|
||||||
|
> awkward context (the raw `CLIENT_READY` message rather than a structured set
|
||||||
|
> of context properties) and is kept only for backwards compatibility.
|
||||||
|
|
||||||
|
Called from: `src/node/handler/PadMessageHandler.ts`
|
||||||
|
|
||||||
|
Things in context:
|
||||||
|
|
||||||
|
1. message - the raw `CLIENT_READY` message sent by the client as it joins a
|
||||||
|
pad.
|
||||||
|
|
||||||
|
Invoked via `aCallAll` while a client is joining a pad, before the author and
|
||||||
|
session are fully set up. Because the context is just the raw client message,
|
||||||
|
new plugins should use `userJoin` instead.
|
||||||
|
|
|
||||||
|
|
@ -712,3 +712,31 @@ get stats of the etherpad instance
|
||||||
_Example returns_:
|
_Example returns_:
|
||||||
|
|
||||||
* `{"code":0,"message":"ok","data":{"totalPads":3,"totalSessions": 2,"totalActivePads": 1}}`
|
* `{"code":0,"message":"ok","data":{"totalPads":3,"totalSessions": 2,"totalActivePads": 1}}`
|
||||||
|
|
||||||
|
===== `GET /api/version-status`
|
||||||
|
|
||||||
|
Returns an outdated-version signal intended for the pad-side gritter.
|
||||||
|
|
||||||
|
*Query parameters:*
|
||||||
|
|
||||||
|
[cols="1,1,1,3"]
|
||||||
|
|===
|
||||||
|
| name | type | required | description
|
||||||
|
|
||||||
|
| `padId`
|
||||||
|
| string
|
||||||
|
| no
|
||||||
|
| Pad whose first-author membership is being checked.
|
||||||
|
|===
|
||||||
|
|
||||||
|
*Response 200 (`application/json`):*
|
||||||
|
|
||||||
|
[source,json]
|
||||||
|
----
|
||||||
|
{
|
||||||
|
"outdated": "minor",
|
||||||
|
"isFirstAuthor": true
|
||||||
|
}
|
||||||
|
----
|
||||||
|
|
||||||
|
`outdated` is `"minor"` only when the running server is at least one minor version behind the latest published release AND the request resolves to the pad's first author. Otherwise it is `null`. Result is cached per `(padId, authorId)` for 60s. The endpoint is disabled entirely when `updates.tier = 'off'`.
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,18 @@ Returns the Author Name of the author
|
||||||
|
|
||||||
-> can't be deleted cause this would involve scanning all the pads where this author was
|
-> can't be deleted cause this would involve scanning all the pads where this author was
|
||||||
|
|
||||||
|
#### anonymizeAuthor(authorID)
|
||||||
|
* API >= 1.3.1
|
||||||
|
|
||||||
|
Erases an author's identity across all pads they contributed to (GDPR Article 17, the "right to erasure"). The author's name and external/token mappings are removed and their chat messages are cleared, so the author can no longer be re-identified from pad data.
|
||||||
|
|
||||||
|
This endpoint is **disabled by default** and is gated on `gdprAuthorErasure.enabled = true` in `settings.json`. If GDPR author erasure is not enabled, the call returns an error and performs no changes. See [doc/privacy.md](../privacy.md) for the privacy/data-retention context.
|
||||||
|
|
||||||
|
*Example returns:*
|
||||||
|
* `{code: 0, message:"ok", data: {affectedPads: 3, removedTokenMappings: 1, removedExternalMappings: 1, clearedChatMessages: 7}}`
|
||||||
|
* `{code: 1, message:"anonymizeAuthor is disabled — set gdprAuthorErasure.enabled = true in settings.json to enable GDPR Art. 17 erasure", data: null}`
|
||||||
|
* `{code: 1, message:"authorID is required", data: null}`
|
||||||
|
|
||||||
### Session
|
### Session
|
||||||
Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. Only users with a valid session for this group, can access group pads. You can create a session after you authenticated the user at your web application, to give them access to the pads. You should save the sessionID of this session and delete it after the user logged out.
|
Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-separated sessionIDs, allowing a user to edit pads in different groups at the same time. Only users with a valid session for this group, can access group pads. You can create a session after you authenticated the user at your web application, to give them access to the pads. You should save the sessionID of this session and delete it after the user logged out.
|
||||||
|
|
||||||
|
|
@ -609,7 +621,7 @@ token is ignored.
|
||||||
* `{code: 1, message:"invalid deletionToken", data: null}`
|
* `{code: 1, message:"invalid deletionToken", data: null}`
|
||||||
|
|
||||||
#### copyPad(sourceID, destinationID[, force=false])
|
#### copyPad(sourceID, destinationID[, force=false])
|
||||||
* API >= 1.2.8
|
* API >= 1.2.9
|
||||||
|
|
||||||
copies a pad with full history and chat. If force is true and the destination pad exists, it will be overwritten.
|
copies a pad with full history and chat. If force is true and the destination pad exists, it will be overwritten.
|
||||||
|
|
||||||
|
|
@ -629,7 +641,7 @@ Note that all the revisions will be lost! In most of the cases one should use `c
|
||||||
* `{code: 1, message:"padID does not exist", data: null}`
|
* `{code: 1, message:"padID does not exist", data: null}`
|
||||||
|
|
||||||
#### movePad(sourceID, destinationID[, force=false])
|
#### movePad(sourceID, destinationID[, force=false])
|
||||||
* API >= 1.2.8
|
* API >= 1.2.9
|
||||||
|
|
||||||
moves a pad. If force is true and the destination pad exists, it will be overwritten.
|
moves a pad. If force is true and the destination pad exists, it will be overwritten.
|
||||||
|
|
||||||
|
|
@ -646,7 +658,7 @@ collapses the pad's revision history to reclaim database space (issue #6194). Wr
|
||||||
|
|
||||||
When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions.
|
When `keepRevisions` is omitted (or null), all history is collapsed into a single base revision that reproduces the current pad text — equivalent to a freshly-imported pad. When set to a positive integer N, the pad keeps only its last N revisions.
|
||||||
|
|
||||||
Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first via `getEtherpad` if you need a backup.**
|
Pad text and chat are preserved in both modes. Saved-revision bookmarks are cleared. **This operation is destructive — export the pad first (for example via the `/p/:padID/export/etherpad` export endpoint) if you need a full-history backup.**
|
||||||
|
|
||||||
*Example returns:*
|
*Example returns:*
|
||||||
* `{code: 0, message:"ok", data: {ok: true, mode: "all"}}`
|
* `{code: 0, message:"ok", data: {ok: true, mode: "all"}}`
|
||||||
|
|
@ -664,10 +676,10 @@ returns the read only link of a pad
|
||||||
* `{code: 0, message:"ok", data: {readOnlyID: "r.s8oes9dhwrvt0zif"}}`
|
* `{code: 0, message:"ok", data: {readOnlyID: "r.s8oes9dhwrvt0zif"}}`
|
||||||
* `{code: 1, message:"padID does not exist", data: null}`
|
* `{code: 1, message:"padID does not exist", data: null}`
|
||||||
|
|
||||||
#### getPadID(readOnlyID)
|
#### getPadID(roID)
|
||||||
* API >= 1.2.10
|
* API >= 1.2.10
|
||||||
|
|
||||||
returns the id of a pad which is assigned to the readOnlyID
|
returns the id of the pad mapped to the given read-only id. The query parameter is named `roID` (the read-only id returned by `getReadOnlyID`).
|
||||||
|
|
||||||
*Example returns:*
|
*Example returns:*
|
||||||
* `{code: 0, message:"ok", data: {padID: "p.s8oes9dhwrvt0zif"}}`
|
* `{code: 0, message:"ok", data: {padID: "p.s8oes9dhwrvt0zif"}}`
|
||||||
|
|
@ -764,3 +776,24 @@ get stats of the etherpad instance
|
||||||
{"code":0,"message":"ok","data":{"totalPads":3,"totalSessions": 2,"totalActivePads": 1}}
|
{"code":0,"message":"ok","data":{"totalPads":3,"totalSessions": 2,"totalActivePads": 1}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `GET /api/version-status`
|
||||||
|
|
||||||
|
Returns an outdated-version signal intended for the pad-side gritter.
|
||||||
|
|
||||||
|
**Query parameters:**
|
||||||
|
|
||||||
|
| name | type | required | description |
|
||||||
|
| ------- | ------ | -------- | --------------------------------------------------------------------------- |
|
||||||
|
| `padId` | string | no | Pad whose first-author membership is being checked. |
|
||||||
|
|
||||||
|
**Response 200 (`application/json`):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"outdated": "minor",
|
||||||
|
"isFirstAuthor": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`outdated` is `"minor"` only when the running server is at least one minor version behind the latest published release AND the request resolves to the pad's first author. Otherwise it is `null`. Result is cached per `(padId, authorId)` for 60s. The endpoint is disabled entirely when `updates.tier = 'off'`.
|
||||||
|
|
||||||
|
|
|
||||||
141
doc/cli.md
141
doc/cli.md
|
|
@ -1,68 +1,151 @@
|
||||||
# CLI
|
# CLI
|
||||||
|
|
||||||
You can find different tools for migrating things, checking your Etherpad health in the bin directory.
|
Etherpad ships a set of operator tools in the `bin/` directory for migrating
|
||||||
One of these is the migrateDB command. It takes two settings.json files and copies data from one source to another one.
|
data, reclaiming database space, repairing damaged pads, managing sessions and
|
||||||
In this example we migrate from the old dirty db to the new rustydb engine. So we copy these files to the root of the etherpad-directory.
|
managing plugins. They are TypeScript scripts run through `tsx`, and each one
|
||||||
|
is registered as a pnpm script in `bin/package.json`. Invoke them from the
|
||||||
|
Etherpad root with:
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm run --filter bin <script> [args]
|
||||||
|
```
|
||||||
|
|
||||||
|
For example `pnpm run --filter bin checkPad my-pad`. Running the `.ts` files
|
||||||
|
directly with `node bin/foo.js` will **not** work — there are no compiled `.js`
|
||||||
|
files and the scripts need the `tsx` loader.
|
||||||
|
|
||||||
|
## Running vs. stopped
|
||||||
|
|
||||||
|
Some tools talk to a **running** Etherpad over its HTTP API (they read the API
|
||||||
|
key from `APIKEY.txt` and connect to `ip`/`port` from `settings.json`). Others
|
||||||
|
open the database **directly** and must be run while Etherpad is **stopped**,
|
||||||
|
otherwise you risk a database lock or a corrupt write. Each tool below is
|
||||||
|
labelled accordingly.
|
||||||
|
|
||||||
|
## Database migration (`migrateDB`)
|
||||||
|
|
||||||
|
`migrateDB` copies every record from one database to another. It takes two
|
||||||
|
settings files — `--file1` is the **source**, `--file2` is the **target** —
|
||||||
|
each describing a database with a `dbType` and `dbSettings`. Both paths are
|
||||||
|
resolved relative to the Etherpad root.
|
||||||
|
|
||||||
|
In this example we migrate from the old `dirty` db to the new `rustydb` engine.
|
||||||
|
Create a source descriptor `source.json` in the Etherpad root:
|
||||||
|
|
||||||
````json
|
````json
|
||||||
{
|
{
|
||||||
"dbType": "dirty",
|
"dbType": "dirty",
|
||||||
|
"dbSettings": {
|
||||||
|
"filename": "./var/dirty.db"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
````
|
||||||
|
|
||||||
|
and a target descriptor `target.json`:
|
||||||
|
|
||||||
|
````json
|
||||||
|
{
|
||||||
|
"dbType": "rustydb",
|
||||||
"dbSettings": {
|
"dbSettings": {
|
||||||
"filename": "./var/rusty.db"
|
"filename": "./var/rusty.db"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
````
|
````
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```
|
||||||
|
pnpm run --filter bin migrateDB --file1 source.json --file2 target.json
|
||||||
|
```
|
||||||
|
|
||||||
````json
|
After some time the data is copied over to the new database. Run this with
|
||||||
{
|
Etherpad **stopped**.
|
||||||
"dbType": "rustydb",
|
|
||||||
"dbSettings": {
|
|
||||||
"filename": "./var/rusty2.db"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
````
|
|
||||||
|
|
||||||
|
|
||||||
After that we need to move the data from dirty to rustydb.
|
|
||||||
Therefore, we call `pnpm run --filter bin migrateDB --file1 test1.json --file2 test2.json` with these two files in our root directories. After some time the data should be copied over to the new database.
|
|
||||||
|
|
||||||
## Pad compaction
|
## Pad compaction
|
||||||
|
|
||||||
Long-lived pads with heavy edit history accumulate revisions in the database. Three CLIs reclaim that space, in increasing scope:
|
Long-lived pads with heavy edit history accumulate revisions in the database.
|
||||||
|
Three CLIs reclaim that space, in increasing scope. All of them drive a
|
||||||
|
**running** Etherpad over the `compactPad` HTTP API.
|
||||||
|
|
||||||
| Tool | Targets | When to use |
|
| Tool | Targets | When to use |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `bin/compactPad.js <padID>` | one pad | you know which pad is fat |
|
| `pnpm run --filter bin compactPad <padID>` | one pad | you know which pad is fat |
|
||||||
| `bin/compactAllPads.js` | every pad | bulk reclaim across the whole instance |
|
| `pnpm run --filter bin compactAllPads` | every pad | bulk reclaim across the whole instance |
|
||||||
| `bin/compactStalePads.js --older-than N` | pads not edited in N days | reclaim the cold tail without touching pads still in active use |
|
| `pnpm run --filter bin compactStalePads --older-than N` | pads not edited in N days | reclaim the cold tail without touching pads still in active use |
|
||||||
|
|
||||||
All three are gated on `cleanup.enabled = true` in `settings.json` and are **destructive**: history is collapsed (or trimmed). Export anything you can't afford to lose with `getEtherpad` first.
|
All three are gated on `cleanup.enabled = true` in `settings.json` and are
|
||||||
|
**destructive**: history is collapsed (or trimmed). Export anything you can't
|
||||||
|
afford to lose with the `getEtherpad` API first.
|
||||||
|
|
||||||
Common flags:
|
Common flags:
|
||||||
|
|
||||||
- `--keep N` — retain the last N revisions instead of collapsing all history.
|
- `--keep N` — retain the last N revisions instead of collapsing all history.
|
||||||
- `--dry-run` — list pads and revision counts without writing.
|
- `--dry-run` — list pads and revision counts without writing (`compactAllPads`
|
||||||
|
and `compactStalePads` only).
|
||||||
|
- `--older-than N` — (`compactStalePads` only, **required**) only consider pads
|
||||||
|
not edited in the last N days.
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
````
|
````
|
||||||
# Compact a specific pad, collapsing all history.
|
# Compact a specific pad, collapsing all history.
|
||||||
node bin/compactPad.js my-pad
|
pnpm run --filter bin compactPad my-pad
|
||||||
|
|
||||||
# Keep only the last 50 revisions of one pad.
|
# Keep only the last 50 revisions of one pad.
|
||||||
node bin/compactPad.js my-pad --keep 50
|
pnpm run --filter bin compactPad my-pad --keep 50
|
||||||
|
|
||||||
# Compact every pad on the instance (per-pad failures don't stop the run).
|
# Compact every pad on the instance (per-pad failures don't stop the run).
|
||||||
node bin/compactAllPads.js
|
pnpm run --filter bin compactAllPads
|
||||||
node bin/compactAllPads.js --dry-run
|
pnpm run --filter bin compactAllPads --dry-run
|
||||||
|
|
||||||
# Compact only pads not edited in the last 90 days, keeping the last 50 revisions.
|
# Compact only pads not edited in the last 90 days, keeping the last 50 revisions.
|
||||||
node bin/compactStalePads.js --older-than 90 --keep 50
|
pnpm run --filter bin compactStalePads --older-than 90 --keep 50
|
||||||
node bin/compactStalePads.js --older-than 90 --dry-run
|
pnpm run --filter bin compactStalePads --older-than 90 --dry-run
|
||||||
````
|
````
|
||||||
|
|
||||||
`bin/compactStalePads.js` is the right tool for periodic operator runs on long-lived instances — hot pads that users are still navigating in timeslider stay untouched, and only the cold tail is rewritten. Per-pad failures (including a `getLastEdited` fault) are counted but do not abort the bulk run; the exit code reflects whether anything failed.
|
`compactStalePads` is the right tool for periodic operator runs on long-lived
|
||||||
|
instances — hot pads that users are still navigating in timeslider stay
|
||||||
|
untouched (staleness is even re-checked right before each compaction), and only
|
||||||
|
the cold tail is rewritten. Per-pad failures (including a `getLastEdited` fault)
|
||||||
|
are counted but do not abort the bulk run; the exit code reflects whether
|
||||||
|
anything failed.
|
||||||
|
|
||||||
See the `compactPad` HTTP API in `doc/api/http_api.md` for the same primitive over the wire (issues #6194, #7642).
|
See the `compactPad` HTTP API in `doc/api/http_api.md` for the same primitive
|
||||||
|
over the wire (issues #6194, #7642).
|
||||||
|
|
||||||
|
## Pad maintenance and debugging
|
||||||
|
|
||||||
|
| Tool | Purpose | Args | Etherpad |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `pnpm run --filter bin checkPad <padID>` | Check one pad's revisions for data corruption. | `<padID>` | stopped |
|
||||||
|
| `pnpm run --filter bin checkAllPads` | Check every pad on the instance for data corruption. | none | stopped |
|
||||||
|
| `pnpm run --filter bin repairPad <padID>` | Repair a pad by extracting all of its data, deleting it and re-inserting it. | `<padID>` | stopped (the script refuses to be useful otherwise) |
|
||||||
|
| `pnpm run --filter bin rebuildPad <padID> <rev> [newPadID]` | Rebuild a damaged pad into a new pad at a known-good revision. The new pad defaults to `<padID>-rebuilt` and must not already exist. | `<padID> <rev> [newPadID]` | stopped |
|
||||||
|
| `node --import tsx extractPadData.ts <padID>` | Export one pad's data to a `<padID>.db` dirtyDB file so a bug can be reproduced in a dev environment. (No pnpm alias — run with the `tsx` loader directly from `bin/`.) | `<padID>` | stopped |
|
||||||
|
|
||||||
|
`checkPad`/`checkAllPads` report corruption but do not modify anything;
|
||||||
|
`repairPad` and `rebuildPad` write. As always, back up first.
|
||||||
|
|
||||||
|
## Database tools
|
||||||
|
|
||||||
|
| Tool | Purpose | Args | Etherpad |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `pnpm run --filter bin migrateDB --file1 <src.json> --file2 <dst.json>` | Copy all records from a source database to a target database (see above). | `--file1 <src.json> --file2 <dst.json>` | stopped |
|
||||||
|
| `pnpm run --filter bin migrateDirtyDBtoRealDB` | One-shot migration of `var/dirty.db` into the real database configured in `settings.json`. Back up `dirty.db` first; may need more memory (e.g. `node --max-old-space-size=4096`). | none (reads target db from `settings.json`) | stopped |
|
||||||
|
| `pnpm run --filter bin importSqlFile <sqlFile>` | Import a SQL dump (rows of `REPLACE INTO store VALUES (...)`) into the configured database. | `<sqlFile>` | stopped |
|
||||||
|
|
||||||
|
## Session and pad management
|
||||||
|
|
||||||
|
| Tool | Purpose | Args | Etherpad |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `pnpm run --filter bin deletePad <padID>` | Delete a single pad. | `<padID>` | running |
|
||||||
|
| `pnpm run --filter bin deleteAllGroupSessions` | Delete all group sessions (useful when a misconfiguration has wedged group access). | none | running |
|
||||||
|
| `pnpm run --filter bin createUserSession` | Create a throwaway group, pad, author and session, printing a `sessionID` you can set as a cookie — handy for debugging session-based configs. | none | running |
|
||||||
|
|
||||||
|
## Plugin tools
|
||||||
|
|
||||||
|
| Tool | Purpose | Args | Etherpad |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `pnpm run --filter bin plugins <action> [names…]` | Manage installed plugins. Actions: `i`/`install`, `rm`/`remove`, `ls`/`list`, `up`/`update`. Install also accepts `--path <dir>` and `--github <repo>` sources. | `<action> [names…]` | stopped |
|
||||||
|
| `pnpm run --filter bin checkPlugin <ep_name> [autofix\|autocommit\|autopush]` | Lint a plugin checkout (a sibling `../ep_name` directory) against the plugin conventions; optional modes auto-fix, commit, or commit+push+publish (the last is dangerous). | `<ep_name> [mode]` | n/a |
|
||||||
|
| `pnpm run --filter bin stalePlugins` | List plugins in the registry not updated in over two years, with maintainer email. Requires `privacy.pluginCatalog` enabled. | none | n/a |
|
||||||
|
|
|
||||||
146
doc/configuration.md
Normal file
146
doc/configuration.md
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
This page explains how Etherpad is configured and documents the
|
||||||
|
reverse-proxy and subpath behaviour in detail. It is **not** an exhaustive list
|
||||||
|
of every setting — for that, see the fully-commented
|
||||||
|
[`settings.json.template`](https://github.com/ether/etherpad/blob/develop/settings.json.template),
|
||||||
|
which is the authoritative reference.
|
||||||
|
|
||||||
|
## Where settings live
|
||||||
|
|
||||||
|
Etherpad reads its configuration from `settings.json` in the installation root.
|
||||||
|
A new install copies `settings.json.template` to `settings.json` on first run.
|
||||||
|
|
||||||
|
* **Override the file location** by passing the `-s` / `--settings` flag to
|
||||||
|
the launcher, e.g. `bin/run.sh -s /etc/etherpad/settings.json`. This lets
|
||||||
|
you run multiple instances from one installation. (`bin/run.sh` forwards the
|
||||||
|
flag to `pnpm run prod`, which is the supported entrypoint — there is no
|
||||||
|
`server.js`; the runtime is `src/node/server.ts`, loaded via `tsx`.)
|
||||||
|
* **Environment-variable substitution** — any string value may reference an
|
||||||
|
environment variable using the syntax `"${ENV_VAR}"` or
|
||||||
|
`"${ENV_VAR:default}"`. The variable name **must** be quoted, even when the
|
||||||
|
resolved value is a number or boolean. A few rules worth remembering:
|
||||||
|
* `"${PORT:9001}"` → the value of `PORT`, or `9001` if unset.
|
||||||
|
* `"${MINIFY:true}"` → the boolean `true`/`false`, not the string.
|
||||||
|
* `"${UNSET_VAR:null}"` → `null`; `"${UNSET_VAR:}"` → the empty string.
|
||||||
|
* Substitution happens at load time, in memory only — env vars never
|
||||||
|
overwrite `settings.json` on disk.
|
||||||
|
|
||||||
|
When running in Docker, almost every setting is wired to an environment
|
||||||
|
variable in the shipped `settings.json.docker`. See the
|
||||||
|
[Docker page](/docker.md) for the full env-var list.
|
||||||
|
|
||||||
|
## Trusting a reverse proxy
|
||||||
|
|
||||||
|
If Etherpad runs behind NGINX, Traefik, HAProxy, a Kubernetes ingress, or any
|
||||||
|
other reverse proxy, set:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"trustProxy": true
|
||||||
|
```
|
||||||
|
|
||||||
|
This makes Etherpad trust the standard `X-Forwarded-*` headers, so it:
|
||||||
|
|
||||||
|
* uses the real client IP (from `X-Forwarded-For`) in logs and rate limits
|
||||||
|
instead of the proxy's IP;
|
||||||
|
* respects the forwarded protocol and host, so the `secure` flag is set on
|
||||||
|
cookies when the proxy terminates TLS (required for `SameSite=None`).
|
||||||
|
|
||||||
|
Leave it at the default `false` when Etherpad is reachable directly on a public
|
||||||
|
IP — otherwise any client could forge these headers.
|
||||||
|
|
||||||
|
## Running under a subpath / ingress
|
||||||
|
|
||||||
|
Etherpad can be served under a URL-path prefix (for example
|
||||||
|
`https://example.com/etherpad/`) without recompiling anything. The prefix is
|
||||||
|
discovered per-request from upstream headers, so the same Etherpad process works
|
||||||
|
whether it is mounted at the root or under a path.
|
||||||
|
|
||||||
|
Three headers are checked, **in this order**; the first non-empty value (after
|
||||||
|
sanitization) wins:
|
||||||
|
|
||||||
|
| Order | Header | Origin | Requires `trustProxy: true`? |
|
||||||
|
| ----- | ------ | ------ | ---------------------------- |
|
||||||
|
| 1 | `x-proxy-path` | Etherpad's own convention | No — always honoured |
|
||||||
|
| 2 | `X-Forwarded-Prefix` | HAProxy / Traefik / Spring | Yes |
|
||||||
|
| 3 | `X-Ingress-Path` | Kubernetes / Home Assistant ingress | Yes |
|
||||||
|
|
||||||
|
`x-proxy-path` is always honoured because an operator must deliberately
|
||||||
|
configure their proxy to send Etherpad's custom header. The two standard
|
||||||
|
headers (`X-Forwarded-Prefix`, `X-Ingress-Path`) are honoured **only when
|
||||||
|
`trustProxy` is `true`**, because otherwise a client on a public IP could forge
|
||||||
|
them.
|
||||||
|
|
||||||
|
Once detected, the prefix is woven into the responses that would otherwise
|
||||||
|
break under a subpath:
|
||||||
|
|
||||||
|
* `manifest.json` (PWA install metadata);
|
||||||
|
* the social-media meta tags (`og:url` / `og:image`), unless an explicit
|
||||||
|
`publicURL` is configured;
|
||||||
|
* the bootstrap script entrypoint and the asset / reconnect links in the pad,
|
||||||
|
index, and timeslider pages.
|
||||||
|
|
||||||
|
### Sanitization
|
||||||
|
|
||||||
|
The header value is treated as untrusted input even when read from a trusted
|
||||||
|
header, because it ends up inside HTML, JS, CSS, and HTTP `Location` headers.
|
||||||
|
The sanitizer (`src/node/utils/sanitizeProxyPath.ts`):
|
||||||
|
|
||||||
|
* strips every character outside `[A-Za-z0-9_./-]`;
|
||||||
|
* collapses a leading `//+` to a single `/`, so the value can never be read as
|
||||||
|
a protocol-relative URL;
|
||||||
|
* prepends `/` if the result doesn't already start with one;
|
||||||
|
* **rejects** any value containing a `..` path segment (returns empty).
|
||||||
|
|
||||||
|
The output is therefore always either empty, or a string that starts with
|
||||||
|
exactly one `/` and contains only `[A-Za-z0-9_./-]`.
|
||||||
|
|
||||||
|
### Example: Traefik
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
etherpad-prefix:
|
||||||
|
stripPrefix:
|
||||||
|
prefixes:
|
||||||
|
- "/etherpad"
|
||||||
|
etherpad-headers:
|
||||||
|
headers:
|
||||||
|
customRequestHeaders:
|
||||||
|
X-Forwarded-Prefix: "/etherpad"
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply both middlewares to the router and set `trustProxy: true` in
|
||||||
|
`settings.json`.
|
||||||
|
|
||||||
|
### Example: NGINX
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location /etherpad/ {
|
||||||
|
proxy_pass http://127.0.0.1:9001/;
|
||||||
|
proxy_set_header X-Proxy-Path /etherpad;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Here `X-Proxy-Path` is used, which works regardless of `trustProxy`. Use
|
||||||
|
`X-Forwarded-Prefix` instead if you prefer the standard header (and set
|
||||||
|
`trustProxy: true`).
|
||||||
|
|
||||||
|
## Self-update, email, database, and metrics
|
||||||
|
|
||||||
|
These areas have their own pages:
|
||||||
|
|
||||||
|
* **Self-update** and **outbound email** (`adminEmail`, `mail.*` SMTP) —
|
||||||
|
see [Updates](/admin/updates.md). The corresponding Docker env vars
|
||||||
|
(`MAIL_HOST`, `MAIL_FROM`, …) are listed on the [Docker page](/docker.md).
|
||||||
|
* **Database** — choose a backend with `dbType` / `dbSettings`. The
|
||||||
|
supported drivers and example settings are documented in
|
||||||
|
[`settings.json.template`](https://github.com/ether/etherpad-lite/blob/develop/settings.json.template),
|
||||||
|
and the Docker equivalents (`DB_TYPE`, `DB_HOST`, …) are listed on the
|
||||||
|
[Docker page](/docker.md). The on-disk keyspace layout is described in
|
||||||
|
[`doc/database.adoc`](https://github.com/ether/etherpad-lite/blob/develop/doc/database.adoc).
|
||||||
|
* **Metrics** — Etherpad exposes Prometheus-compatible metrics; see
|
||||||
|
[Stats](/stats.md).
|
||||||
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.
|
||||||
|
|
@ -29,6 +29,37 @@ Edit `<BASEDIR>/settings.json.docker` at your will. When rebuilding the image, t
|
||||||
|
|
||||||
**Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. For details, refer to `settings.json.template`.
|
**Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. For details, refer to `settings.json.template`.
|
||||||
|
|
||||||
|
### How `settings.json` and environment variables interact
|
||||||
|
|
||||||
|
This trips people up often enough that it's worth calling out explicitly (see [#7819](https://github.com/ether/etherpad/issues/7819)):
|
||||||
|
|
||||||
|
* `settings.json` inside the container is a **template** containing `${VAR:default}` placeholders.
|
||||||
|
* Environment variable substitution happens at **load time, in memory only** — env vars never overwrite `settings.json` on disk.
|
||||||
|
* `docker exec <container> cat /opt/etherpad-lite/settings.json` will therefore always show the *templated* file (e.g. `"port": "${PORT:9001}"`), regardless of what `PORT` is set to in your environment. The resolved value is what Etherpad uses at runtime; the file is unchanged.
|
||||||
|
* The admin /settings page also reads this file directly, so the raw view shows placeholders too. The page now surfaces a banner and an "Effective" tab that displays the in-memory resolved values when placeholders are present.
|
||||||
|
|
||||||
|
### Persisting admin /settings edits across container recreates
|
||||||
|
|
||||||
|
`settings.json` lives in the container's writable layer by default. That means:
|
||||||
|
|
||||||
|
| Operation | Effect on `settings.json` |
|
||||||
|
|------------------------------------------|------------------------------------------|
|
||||||
|
| `docker restart` | Preserved (writable layer is reused) |
|
||||||
|
| `docker compose restart` | Preserved |
|
||||||
|
| `docker compose down && docker compose up` | **Reset** to the image template |
|
||||||
|
| `docker compose pull && docker compose up` | **Reset** to the new image template |
|
||||||
|
| Watchtower / image auto-update | **Reset** to the new image template |
|
||||||
|
| `docker rm` + `docker run` | **Reset** to the image template |
|
||||||
|
|
||||||
|
If you intend to edit `settings.json` through the admin UI (rather than relying solely on env vars), mount the file from the host so edits survive container recreate:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- ./settings.json:/opt/etherpad-lite/settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
(Bootstrap by copying `settings.json.docker` to `./settings.json` on the host before the first `up`.) The default compose example below ships this line commented out — uncomment it if you need persistent on-disk edits.
|
||||||
|
|
||||||
### Rebuilding including some plugins
|
### Rebuilding including some plugins
|
||||||
If you want to install some plugins in your container, it is sufficient to list them in the ETHERPAD_PLUGINS build variable.
|
If you want to install some plugins in your container, it is sufficient to list them in the ETHERPAD_PLUGINS build variable.
|
||||||
The variable value has to be a space separated, double quoted list of plugin names (see examples).
|
The variable value has to be a space separated, double quoted list of plugin names (see examples).
|
||||||
|
|
@ -81,10 +112,31 @@ The `settings.json.docker` available by default allows to control almost every s
|
||||||
| `DEFAULT_PAD_TEXT` | The default text of a pad | `Welcome to Etherpad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! Get involved with Etherpad at https://etherpad.org` |
|
| `DEFAULT_PAD_TEXT` | The default text of a pad | `Welcome to Etherpad! This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents! Get involved with Etherpad at https://etherpad.org` |
|
||||||
| `IP` | IP which etherpad should bind at. Change to `::` for IPv6 | `0.0.0.0` |
|
| `IP` | IP which etherpad should bind at. Change to `::` for IPv6 | `0.0.0.0` |
|
||||||
| `PORT` | port which etherpad should bind at | `9001` |
|
| `PORT` | port which etherpad should bind at | `9001` |
|
||||||
|
| `PUBLIC_URL` | Canonical public origin of this instance, e.g. `https://pad.example.com` (no trailing slash, must include scheme). Used to build absolute URLs in link-preview meta tags. When `null`, falls back to the incoming request's protocol+Host. | `null` |
|
||||||
|
| `ENABLE_DARK_MODE` | Respect the end user's browser dark-mode preference. When enabled this overrides the admin-configured skin variants and skin name for that user. | `true` |
|
||||||
| `ADMIN_PASSWORD` | the password for the `admin` user (leave unspecified if you do not want to create it) | |
|
| `ADMIN_PASSWORD` | the password for the `admin` user (leave unspecified if you do not want to create it) | |
|
||||||
| `USER_PASSWORD` | the password for the first user `user` (leave unspecified if you do not want to create it) | |
|
| `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
|
### Database
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|
|
@ -179,6 +231,31 @@ For the editor container, you can also make it full width by adding `full-width-
|
||||||
| `DISABLE_IP_LOGGING` | Privacy: disable IP logging | `false` |
|
| `DISABLE_IP_LOGGING` | Privacy: disable IP logging | `false` |
|
||||||
|
|
||||||
|
|
||||||
|
### Email (SMTP)
|
||||||
|
|
||||||
|
SMTP transport used by the self-updater and admin notifications. When `MAIL_HOST` is `null` (the default) Etherpad keeps log-only behaviour and sends no real mail; set `MAIL_HOST` and `MAIL_FROM` to send via nodemailer.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ------------- | ------------------------------------------------------------------------------------------- | ------- |
|
||||||
|
| `MAIL_HOST` | SMTP server hostname. Leave `null` to keep log-only behaviour (no outbound mail). | `null` |
|
||||||
|
| `MAIL_PORT` | SMTP server port. | `587` |
|
||||||
|
| `MAIL_SECURE` | Use a secure (TLS) connection to the SMTP server. | `false` |
|
||||||
|
| `MAIL_FROM` | The `From` address used on outbound mail. Required (together with `MAIL_HOST`) to send mail. | `null` |
|
||||||
|
|
||||||
|
|
||||||
|
### Privacy banner
|
||||||
|
|
||||||
|
Optional privacy banner shown to users. See `settings.json.template` for full field docs.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
| ------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `PRIVACY_BANNER_ENABLED` | Show the privacy banner. | `false` |
|
||||||
|
| `PRIVACY_BANNER_TITLE` | Banner title. | `Privacy notice` |
|
||||||
|
| `PRIVACY_BANNER_BODY` | Banner body text. | `This instance processes pad content on our servers. See the linked policy for retention and how to request erasure.` |
|
||||||
|
| `PRIVACY_BANNER_LEARN_MORE_URL` | Optional URL for a "learn more" link in the banner. | `null` |
|
||||||
|
| `PRIVACY_BANNER_DISMISSAL` | Banner dismissal behaviour, e.g. `dismissible`. | `dismissible` |
|
||||||
|
|
||||||
|
|
||||||
### Advanced
|
### Advanced
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|
|
@ -187,6 +264,10 @@ For the editor container, you can also make it full width by adding `full-width-
|
||||||
| `COOKIE_SESSION_LIFETIME` | How long (ms) a user can be away before they must log in again. | `864000000` (10 days) |
|
| `COOKIE_SESSION_LIFETIME` | How long (ms) a user can be away before they must log in again. | `864000000` (10 days) |
|
||||||
| `COOKIE_SESSION_REFRESH_INTERVAL` | How often (ms) to write the latest cookie expiration time. | `86400000` (1 day) |
|
| `COOKIE_SESSION_REFRESH_INTERVAL` | How often (ms) to write the latest cookie expiration time. | `86400000` (1 day) |
|
||||||
| `SHOW_SETTINGS_IN_ADMIN_PAGE` | hide/show the settings.json in admin page | `true` |
|
| `SHOW_SETTINGS_IN_ADMIN_PAGE` | hide/show the settings.json in admin page | `true` |
|
||||||
|
| `AUTHENTICATION_METHOD` | Authentication method used by the server. Use `sso` for the built-in OpenID Connect provider, or `apikey` for the legacy API-key authentication system. | `sso` |
|
||||||
|
| `ENABLE_METRICS` | Enable the Prometheus metrics endpoint used by monitoring plugins to collect metrics about Etherpad. Disable if you do not use any monitoring plugins. | `true` |
|
||||||
|
| `ENABLE_PAD_WIDE_SETTINGS` | Enable creator-owned pad-wide settings and new-pad default seeding from My View. The pad creator gets a "Pad-wide Settings" section to set/enforce defaults; other users see only their own view options. Set to `false` for the legacy single-settings behavior. | `true` |
|
||||||
|
| `GDPR_AUTHOR_ERASURE_ENABLED` | Enable the GDPR Art. 17 author anonymize/erasure REST endpoint and admin UI. Enable only when an operator process exists to authorise erasure requests. | `false` |
|
||||||
| `TRUST_PROXY` | set to `true` if you are using a reverse proxy in front of Etherpad (for example: Traefik for SSL termination via Let's Encrypt). This will affect security and correctness of the logs if not done | `false` |
|
| `TRUST_PROXY` | set to `true` if you are using a reverse proxy in front of Etherpad (for example: Traefik for SSL termination via Let's Encrypt). This will affect security and correctness of the logs if not done | `false` |
|
||||||
| `IMPORT_MAX_FILE_SIZE` | maximum allowed file size when importing a pad, in bytes. | `52428800` (50 MB) |
|
| `IMPORT_MAX_FILE_SIZE` | maximum allowed file size when importing a pad, in bytes. | `52428800` (50 MB) |
|
||||||
| `IMPORT_EXPORT_MAX_REQ_PER_IP` | maximum number of import/export calls per IP. | `10` |
|
| `IMPORT_EXPORT_MAX_REQ_PER_IP` | maximum number of import/export calls per IP. | `10` |
|
||||||
|
|
@ -208,7 +289,7 @@ For the editor container, you can also make it full width by adding `full-width-
|
||||||
| `FOCUS_LINE_PERCENTAGE_ARROW_UP` | Percentage of viewport height to be additionally scrolled when user presses arrow up in the line of the top of the viewport. Set to 0 to let the scroll to be handled as default by Etherpad | `0` |
|
| `FOCUS_LINE_PERCENTAGE_ARROW_UP` | Percentage of viewport height to be additionally scrolled when user presses arrow up in the line of the top of the viewport. Set to 0 to let the scroll to be handled as default by Etherpad | `0` |
|
||||||
| `FOCUS_LINE_DURATION` | Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation | `0` |
|
| `FOCUS_LINE_DURATION` | Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation | `0` |
|
||||||
| `FOCUS_LINE_CARET_SCROLL` | Flag to control if it should scroll when user places the caret in the last line of the viewport | `false` |
|
| `FOCUS_LINE_CARET_SCROLL` | Flag to control if it should scroll when user places the caret in the last line of the viewport | `false` |
|
||||||
| `SOCKETIO_MAX_HTTP_BUFFER_SIZE` | The maximum size (in bytes) of a single message accepted via Socket.IO. If a client sends a larger message, its connection gets closed to prevent DoS (memory exhaustion) attacks. | `50000` |
|
| `SOCKETIO_MAX_HTTP_BUFFER_SIZE` | The maximum size (in bytes) of a single message accepted via Socket.IO. If a client sends a larger message, its connection gets closed to prevent DoS (memory exhaustion) attacks. Larger values allow bigger pastes. | `1000000` (1 MB) |
|
||||||
| `LOAD_TEST` | Allow Load Testing tools to hit the Etherpad Instance. WARNING: this will disable security on the instance. | `false` |
|
| `LOAD_TEST` | Allow Load Testing tools to hit the Etherpad Instance. WARNING: this will disable security on the instance. | `false` |
|
||||||
| `DUMP_ON_UNCLEAN_EXIT` | Enable dumping objects preventing a clean exit of Node.js. WARNING: this has a significant performance impact. | `false` |
|
| `DUMP_ON_UNCLEAN_EXIT` | Enable dumping objects preventing a clean exit of Node.js. WARNING: this has a significant performance impact. | `false` |
|
||||||
| `EXPOSE_VERSION` | Expose Etherpad version in the web interface and in the Server http header. Do not enable on production machines. | `false` |
|
| `EXPOSE_VERSION` | Expose Etherpad version in the web interface and in the Server http header. Do not enable on production machines. | `false` |
|
||||||
|
|
@ -282,6 +363,13 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- plugins:/opt/etherpad-lite/src/plugin_packages
|
- plugins:/opt/etherpad-lite/src/plugin_packages
|
||||||
- etherpad-var:/opt/etherpad-lite/var
|
- etherpad-var:/opt/etherpad-lite/var
|
||||||
|
# OPTIONAL: persist admin /settings edits across container recreates.
|
||||||
|
# Without this mount, settings.json lives in the image's writable
|
||||||
|
# layer — `docker compose restart` preserves it, but `docker compose
|
||||||
|
# down && up`, `pull`, or watchtower reverts it to the image
|
||||||
|
# template. Uncomment if you intend to edit settings.json through
|
||||||
|
# the /admin UI. See https://github.com/ether/etherpad/issues/7819.
|
||||||
|
# - ./settings.json:/opt/etherpad-lite/settings.json
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
environment:
|
environment:
|
||||||
|
|
|
||||||
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.
|
||||||
|
|
@ -13,6 +13,9 @@ hero:
|
||||||
- theme: brand
|
- theme: brand
|
||||||
text: Install
|
text: Install
|
||||||
link: /docker
|
link: /docker
|
||||||
|
- theme: alt
|
||||||
|
text: Configuration
|
||||||
|
link: /configuration
|
||||||
- theme: alt
|
- theme: alt
|
||||||
text: API documentation
|
text: API documentation
|
||||||
link: /api/
|
link: /api/
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,22 @@ alert ('Chat');
|
||||||
```
|
```
|
||||||
to:
|
to:
|
||||||
```js
|
```js
|
||||||
alert(window._('pad.chat'));
|
alert(window.html10n.get('pad.chat'));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** Etherpad core sets up a `window._` gettext-like shortcut as
|
||||||
|
> `window._ = html10n.get`. Because this assignment does **not** bind `this`,
|
||||||
|
> calling the shortcut bare as `window._('pad.chat')` loses its `this` context
|
||||||
|
> and returns `undefined` (the `get` implementation reads `this.translations`).
|
||||||
|
> Prefer one of the patterns that actually works:
|
||||||
|
>
|
||||||
|
> * Call the method on the object directly: `window.html10n.get('pad.chat')`.
|
||||||
|
> * Bind it once, then reuse: `const _ = window.html10n.get.bind(window.html10n);`
|
||||||
|
> followed by `_('pad.chat')`.
|
||||||
|
>
|
||||||
|
> Wherever possible, prefer marking up your templates with `data-l10n-id`
|
||||||
|
> attributes (see above) instead of translating strings in JavaScript at all —
|
||||||
|
> html10n applies those automatically.
|
||||||
### 2. Create translate files in the locales directory of your plugin
|
### 2. Create translate files in the locales directory of your plugin
|
||||||
|
|
||||||
* The name of the file must be the language code of the language it contains translations for (see [supported lang codes](https://joker-x.github.com/languages4translatewiki/test/); e.g. en ? English, es ? Spanish...)
|
* The name of the file must be the language code of the language it contains translations for (see [supported lang codes](https://joker-x.github.com/languages4translatewiki/test/); e.g. en ? English, es ? Spanish...)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"oxc-minify": "^0.131.0",
|
"oxc-minify": "^0.139.0",
|
||||||
"vitepress": "^2.0.0-alpha.17"
|
"vitepress": "^2.0.0-alpha.18"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"docs:dev": "vitepress dev",
|
"docs:dev": "vitepress dev",
|
||||||
|
|
|
||||||
|
|
@ -267,15 +267,16 @@ below.
|
||||||
### Runtime flag
|
### Runtime flag
|
||||||
|
|
||||||
The passthrough is gated by `settings.enablePluginPadOptions`, default
|
The passthrough is gated by `settings.enablePluginPadOptions`, default
|
||||||
`false`. Operators must opt in via `settings.json`:
|
`true`. Operators who want to lock plugins out of pad-wide state can flip
|
||||||
|
it in `settings.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"enablePluginPadOptions": true
|
"enablePluginPadOptions": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
When enabled, the server reflects the value to every client via
|
When enabled (the default), the server reflects the value to every client via
|
||||||
`clientVars.enablePluginPadOptions` so plugins can detect both *capable*
|
`clientVars.enablePluginPadOptions` so plugins can detect both *capable*
|
||||||
(static) and *active* (per-pad request) at the same point.
|
(static) and *active* (per-pad request) at the same point.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ and the URL to use if you embed the timeslider in your own page.
|
||||||
|
|
||||||
You can choose a skin changing the parameter `skinName` in `settings.json`.
|
You can choose a skin changing the parameter `skinName` in `settings.json`.
|
||||||
|
|
||||||
Since Etherpad **1.7.5**, two skins are included:
|
Two skins are included:
|
||||||
|
|
||||||
* `no-skin`: an empty skin, leaving the default Etherpad appearance unchanged, that you can use as guidance to develop your own.
|
* `colibris`: the current default skin, used by Etherpad out of the box. This is what you see in a standard installation.
|
||||||
* `colibris`: a new, experimental skin, that will become the default in Etherpad 2.0.
|
* `no-skin`: an unstyled base skin that leaves the default Etherpad appearance unchanged. Use it as a starting point and guidance to develop your own skin.
|
||||||
|
|
|
||||||
157
doc/stats.md
157
doc/stats.md
|
|
@ -1,18 +1,147 @@
|
||||||
# Statistics
|
# Statistics and metrics
|
||||||
Etherpad keeps track of the goings-on inside the edit machinery. If you'd like to have a look at this, just point your browser to `/stats`.
|
|
||||||
|
|
||||||
We currently measure:
|
Etherpad tracks runtime statistics about the edit machinery, the database
|
||||||
|
layer, and the Node.js process, and can expose them over HTTP for monitoring.
|
||||||
|
|
||||||
- totalUsers (counter)
|
There are two endpoints:
|
||||||
- connects (meter)
|
|
||||||
- disconnects (meter)
|
|
||||||
- pendingEdits (counter)
|
|
||||||
- edits (timer)
|
|
||||||
- failedChangesets (meter)
|
|
||||||
- httpRequests (timer)
|
|
||||||
- http500 (meter)
|
|
||||||
- memoryUsage (gauge)
|
|
||||||
|
|
||||||
Under the hood, we are happy to rely on [measured](https://github.com/felixge/node-measured) for all our metrics needs.
|
- `GET /stats` — a JSON dump of the internal `measured-core` collection.
|
||||||
|
- `GET /stats/prometheus` — the same kind of data (plus process/runtime
|
||||||
|
metrics) in the [Prometheus](https://prometheus.io/) text exposition format.
|
||||||
|
|
||||||
To modify or simply access our stats in your plugin, simply `require('ep_etherpad-lite/stats')` which is a [`measured.Collection`](https://yaorg.github.io/node-measured/packages/measured-core/Collection.html).
|
## Enabling the endpoints
|
||||||
|
|
||||||
|
Both endpoints are gated behind the `enableMetrics` setting, which defaults to
|
||||||
|
`true`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enableMetrics": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `enableMetrics` is `false` the routes are **not registered at all** — a
|
||||||
|
request to `/stats` or `/stats/prometheus` returns the normal 404 handling, not
|
||||||
|
an empty response. The admin-panel statistics view is unaffected by this
|
||||||
|
setting.
|
||||||
|
|
||||||
|
## `GET /stats` (JSON)
|
||||||
|
|
||||||
|
Returns the current snapshot of the `measured-core` collection as JSON. The
|
||||||
|
following metrics are collected:
|
||||||
|
|
||||||
|
| Metric | Type | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `totalUsers` | gauge | Number of users currently connected across all pads. |
|
||||||
|
| `activePads` | gauge | Number of pads with at least one connected user. |
|
||||||
|
| `connects` | meter | Rate of new client connections. |
|
||||||
|
| `disconnects` | meter | Rate of client disconnections. |
|
||||||
|
| `rateLimited` | meter | Rate of messages dropped by the per-connection rate limiter. |
|
||||||
|
| `pendingEdits` | counter | Edits received but not yet fully processed. |
|
||||||
|
| `edits` | timer | Time taken to process an incoming `USER_CHANGES` edit (full handler span). |
|
||||||
|
| `failedChangesets` | meter | Rate of changesets that failed to apply. |
|
||||||
|
| `httpRequests` | timer | Duration of HTTP requests served by Express. |
|
||||||
|
| `http500` | meter | Rate of HTTP 500 responses. |
|
||||||
|
| `memoryUsage` | gauge | Process resident set size (`process.memoryUsage().rss`). |
|
||||||
|
| `memoryUsageHeap` | gauge | Process heap usage (`process.memoryUsage().heapUsed`). |
|
||||||
|
| `lastDisconnect` | gauge | Timestamp (ms) of the most recent socket disconnect. |
|
||||||
|
| `ueberdb_*` | gauge | One gauge per [ueberDB](https://github.com/ether/ueberDB) database statistic, e.g. read/write counts and timings (`ueberdb_reads`, `ueberdb_writes`, …). The exact set depends on the configured database driver. |
|
||||||
|
|
||||||
|
Under the hood these are provided by
|
||||||
|
[`measured-core`](https://github.com/yaorg/node-measured/tree/master/packages/measured-core).
|
||||||
|
To read or extend them from a plugin, require the shared collection:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const stats = require('ep_etherpad-lite/node/stats');
|
||||||
|
// stats is a measured-core Collection
|
||||||
|
stats.counter('my_plugin_events').inc();
|
||||||
|
console.log(stats.toJSON());
|
||||||
|
```
|
||||||
|
|
||||||
|
## `GET /stats/prometheus` (Prometheus exposition format)
|
||||||
|
|
||||||
|
Served from a dedicated [`prom-client`](https://github.com/siimon/prom-client)
|
||||||
|
registry. This is the endpoint you point a Prometheus scraper at.
|
||||||
|
|
||||||
|
### Metrics exposed by default
|
||||||
|
|
||||||
|
| Metric | Type | Labels | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `etherpad_total_users` | gauge | — | Total number of connected users. |
|
||||||
|
| `etherpad_active_pads` | gauge | — | Total number of active pads. |
|
||||||
|
| `ueberdb_stats` | gauge | `type` | ueberDB statistics, one series per numeric ueberDB metric (the metric name is carried in the `type` label). |
|
||||||
|
|
||||||
|
In addition, the registry calls `prom-client`'s `collectDefaultMetrics()`, so
|
||||||
|
the standard Node.js / process metrics are also exposed, including (names as
|
||||||
|
emitted by `prom-client`):
|
||||||
|
|
||||||
|
- `process_cpu_user_seconds_total`, `process_cpu_system_seconds_total`,
|
||||||
|
`process_cpu_seconds_total`
|
||||||
|
- `process_resident_memory_bytes`, `process_heap_bytes`,
|
||||||
|
`process_virtual_memory_bytes`
|
||||||
|
- `process_open_fds`, `process_max_fds`
|
||||||
|
- `process_start_time_seconds`
|
||||||
|
- `nodejs_eventloop_lag_seconds` and the `nodejs_eventloop_lag_*` family
|
||||||
|
- `nodejs_active_handles`, `nodejs_active_requests`, `nodejs_active_resources`
|
||||||
|
(and their `_total` variants)
|
||||||
|
- `nodejs_heap_size_total_bytes`, `nodejs_heap_size_used_bytes`,
|
||||||
|
`nodejs_external_memory_bytes`, `nodejs_heap_space_size_*_bytes`
|
||||||
|
- `nodejs_gc_duration_seconds`
|
||||||
|
- `nodejs_version_info`
|
||||||
|
|
||||||
|
The exact default-metric set is determined by `prom-client` and the Node.js
|
||||||
|
version, not by Etherpad.
|
||||||
|
|
||||||
|
### Opt-in scaling-dive metrics (`scalingDiveMetrics`)
|
||||||
|
|
||||||
|
A second, more detailed instrument set was added for the scaling investigation
|
||||||
|
(PR #7756). It is gated behind the `scalingDiveMetrics` setting, which defaults
|
||||||
|
to `false`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scalingDiveMetrics": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When the flag is off, these metrics are never registered and their recording
|
||||||
|
helpers short-circuit to no-ops, so production deployments pay nothing for the
|
||||||
|
instrumentation. When enabled, the following are added to the
|
||||||
|
`/stats/prometheus` output:
|
||||||
|
|
||||||
|
| Metric | Type | Labels | Meaning |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `etherpad_changeset_apply_duration_seconds` | histogram | — | Time spent applying an incoming `USER_CHANGES` message on the server (apply path only; excludes fan-out to other clients). Buckets: 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 5 seconds. |
|
||||||
|
| `etherpad_socket_emits_total` | counter | `type` | Number of socket.io broadcast emits, bucketed by message type. |
|
||||||
|
| `etherpad_pad_users` | gauge | `padId` | Active users connected to each pad, keyed by pad id. |
|
||||||
|
|
||||||
|
**Cardinality caution.** The scaling-dive metrics carry high-cardinality
|
||||||
|
labels:
|
||||||
|
|
||||||
|
- `etherpad_pad_users` adds one time series **per active pad** (`padId`
|
||||||
|
label). On instances with many pads this can produce a large number of
|
||||||
|
series; stale labels are reset on each scrape so drained pads drop out.
|
||||||
|
- `etherpad_socket_emits_total` uses the `type` label. To keep cardinality
|
||||||
|
bounded, only a fixed allowlist of known message types is reported; any
|
||||||
|
other (or missing) type is rolled into a single `other` bucket, so a
|
||||||
|
misbehaving plugin or API caller cannot explode the label space.
|
||||||
|
|
||||||
|
Enable `scalingDiveMetrics` for targeted load-testing or capacity
|
||||||
|
investigations, not as a permanent production default.
|
||||||
|
|
||||||
|
## Scraping with Prometheus
|
||||||
|
|
||||||
|
Add a scrape job pointing at the `/stats/prometheus` endpoint, for example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: etherpad
|
||||||
|
metrics_path: /stats/prometheus
|
||||||
|
static_configs:
|
||||||
|
- targets: ['localhost:9001']
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure `enableMetrics` is `true` (the default) so the endpoint exists. If
|
||||||
|
your instance is reachable from untrusted networks, restrict access to
|
||||||
|
`/stats` and `/stats/prometheus` at your reverse proxy, since they expose
|
||||||
|
operational details about the deployment.
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,25 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- plugins:/opt/etherpad-lite/src/plugin_packages
|
- plugins:/opt/etherpad-lite/src/plugin_packages
|
||||||
- etherpad-var:/opt/etherpad-lite/var
|
- etherpad-var:/opt/etherpad-lite/var
|
||||||
|
# OPTIONAL: persist admin /settings edits across container recreates.
|
||||||
|
# Without this mount, settings.json lives in the image's writable
|
||||||
|
# layer — `docker compose restart` preserves it, but `docker compose
|
||||||
|
# down && up`, `pull`, or watchtower reverts it to the image
|
||||||
|
# template. Uncomment if you intend to edit settings.json through
|
||||||
|
# the /admin UI instead of (or in addition to) env vars. See
|
||||||
|
# https://github.com/ether/etherpad/issues/7819.
|
||||||
|
# - ./settings.json:/opt/etherpad-lite/settings.json
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
ADMIN_PASSWORD: ${DOCKER_COMPOSE_APP_ADMIN_PASSWORD:-admin}
|
# Required — set a strong value (e.g. in .env). No fallback, so misconfig
|
||||||
|
# surfaces at `docker compose up` rather than at runtime.
|
||||||
|
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_CHARSET: ${DOCKER_COMPOSE_APP_DB_CHARSET:-utf8mb4}
|
||||||
DB_HOST: postgres
|
DB_HOST: postgres
|
||||||
DB_NAME: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}
|
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_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}
|
||||||
DB_TYPE: "postgres"
|
DB_TYPE: "postgres"
|
||||||
DB_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
|
DB_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
|
||||||
|
|
@ -23,7 +33,9 @@ services:
|
||||||
DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEFAULT_PAD_TEXT:- }
|
DEFAULT_PAD_TEXT: ${DOCKER_COMPOSE_APP_DEFAULT_PAD_TEXT:- }
|
||||||
DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DISABLE_IP_LOGGING:-false}
|
DISABLE_IP_LOGGING: ${DOCKER_COMPOSE_APP_DISABLE_IP_LOGGING:-false}
|
||||||
SOFFICE: ${DOCKER_COMPOSE_APP_SOFFICE:-null}
|
SOFFICE: ${DOCKER_COMPOSE_APP_SOFFICE:-null}
|
||||||
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-true}
|
# Default off: only enable when actually behind a trusted reverse proxy
|
||||||
|
# that sets the X-Forwarded-* headers.
|
||||||
|
TRUST_PROXY: ${DOCKER_COMPOSE_APP_TRUST_PROXY:-false}
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}"
|
- "${DOCKER_COMPOSE_APP_PORT_PUBLISHED:-9001}:${DOCKER_COMPOSE_APP_PORT_TARGET:-9001}"
|
||||||
|
|
@ -32,7 +44,7 @@ services:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${DOCKER_COMPOSE_POSTGRES_DATABASE:-etherpad}
|
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_PORT: ${DOCKER_COMPOSE_POSTGRES_PORT:-5432}
|
||||||
POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
|
POSTGRES_USER: ${DOCKER_COMPOSE_POSTGRES_USER:-admin}
|
||||||
PGDATA: /var/lib/postgresql/data/pgdata
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
|
|
|
||||||
1198
docs/superpowers/plans/2026-05-18-admin-settings-resolved-runtime.md
Normal file
1198
docs/superpowers/plans/2026-05-18-admin-settings-resolved-runtime.md
Normal file
File diff suppressed because it is too large
Load diff
1264
docs/superpowers/plans/2026-05-18-issue-7802-url-base-path.md
Normal file
1264
docs/superpowers/plans/2026-05-18-issue-7802-url-base-path.md
Normal file
File diff suppressed because it is too large
Load diff
1200
docs/superpowers/plans/2026-05-18-outdated-notice-redesign.md
Normal file
1200
docs/superpowers/plans/2026-05-18-outdated-notice-redesign.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,633 @@
|
||||||
|
# Downstream Client Compatibility Tests — Phase 1 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a core-side compatibility gate (golden-vector + socket-sequence + HTTP-API-shape contract tests, plus a downstream-smoke workflow scaffold) so a PR against `develop` detects changes that would break the separate downstream clients.
|
||||||
|
|
||||||
|
**Architecture:** Layer A — hermetic contract tests run inside core's existing mocha backend suite, anchored to a committed `wire-vectors.json` fixture generated from core's own `Changeset`/`AttributePool`. Layer B — a `downstream-smoke.yml` workflow that boots a real Etherpad on :9003, proves the boot→healthcheck→teardown cycle with a self-check, and is ready to matrix over a `clients.json` manifest as clients register in Phase 2.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, mocha (`--import=tsx`), `assert/strict`, supertest + socket.io-client via `common.ts` helpers, GitHub Actions.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-09-downstream-client-compat-tests-design.md`
|
||||||
|
|
||||||
|
**Scope note:** This plan is **Phase 1 only** (all changes in `ether/etherpad`). Phase 2 (wiring each client repo's `test:vectors` + smoke and registering it in the manifest) is one separate plan/PR per client repo and is out of scope here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Create `src/tests/backend/specs/downstream/generate-vectors.ts` — pure module exporting `generateVectors(): WireVector[]`; the single source of truth for the canonical wire fixtures. Also runnable as a CLI to (re)write the fixture.
|
||||||
|
- Create `src/tests/fixtures/wire-vectors.json` — committed canonical fixture (generated, never hand-edited).
|
||||||
|
- Create `src/tests/backend/specs/downstream/wire-vectors.ts` — mocha spec asserting the committed fixture is stable and self-consistent.
|
||||||
|
- Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts` — mocha spec asserting the socket.io handshake + USER_CHANGES→ACCEPT_COMMIT sequence/shapes.
|
||||||
|
- Create `src/tests/backend/specs/downstream/wire-http-api.ts` — mocha spec snapshotting client-facing HTTP API response shapes.
|
||||||
|
- Create `src/tests/downstream/clients.json` — manifest of downstream clients (data; entries `enabled:false` until their Phase-2 smoke lands).
|
||||||
|
- Create `.github/workflows/downstream-smoke.yml` — boot/healthcheck/self-check/teardown + manifest matrix scaffold.
|
||||||
|
- Modify `src/package.json` — add `vectors:gen` script.
|
||||||
|
|
||||||
|
All backend specs live under `specs/downstream/` so the existing `mocha ... --recursive tests/backend/specs` glob picks them up with zero config change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Golden-vector generator module
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/tests/backend/specs/downstream/generate-vectors.ts`
|
||||||
|
- Test: `src/tests/backend/specs/downstream/wire-vectors.ts` (created in Task 3; this task is tested via Task 2's run)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the generator module**
|
||||||
|
|
||||||
|
Create `src/tests/backend/specs/downstream/generate-vectors.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for the downstream wire-compatibility fixtures.
|
||||||
|
*
|
||||||
|
* Each vector is a self-contained changeset application: given `initialAText`
|
||||||
|
* and `pool`, applying `changeset` yields `resultAText`. Downstream clients
|
||||||
|
* (which reimplement Etherpad's changeset/attribpool decoders) consume the
|
||||||
|
* exact same JSON and must reproduce `resultAText`. See the Phase 1 plan.
|
||||||
|
*
|
||||||
|
* Runnable as a CLI to (re)write src/tests/fixtures/wire-vectors.json:
|
||||||
|
* pnpm run vectors:gen
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Changeset from '../../../../static/js/Changeset';
|
||||||
|
import AttributePool from '../../../../static/js/AttributePool';
|
||||||
|
|
||||||
|
export type WireVector = {
|
||||||
|
name: string;
|
||||||
|
initialText: string;
|
||||||
|
changeset: string;
|
||||||
|
pool: ReturnType<AttributePool['toJsonable']>;
|
||||||
|
resultText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const vector = (
|
||||||
|
name: string,
|
||||||
|
initialText: string,
|
||||||
|
build: (pool: AttributePool) => string,
|
||||||
|
): WireVector => {
|
||||||
|
const pool = new AttributePool();
|
||||||
|
const changeset = build(pool);
|
||||||
|
Changeset.checkRep(changeset);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
initialText,
|
||||||
|
changeset,
|
||||||
|
pool: pool.toJsonable(),
|
||||||
|
resultText: Changeset.applyToText(changeset, initialText),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateVectors = (): WireVector[] => [
|
||||||
|
vector('plain-insert', 'abc\n', () =>
|
||||||
|
Changeset.makeSplice('abc\n', 3, 0, 'XYZ')),
|
||||||
|
|
||||||
|
vector('plain-delete', 'abcdef\n', () =>
|
||||||
|
Changeset.makeSplice('abcdef\n', 1, 3, '')),
|
||||||
|
|
||||||
|
vector('formatted-insert', 'abc\n', (pool) =>
|
||||||
|
Changeset.makeSplice('abc\n', 3, 0, 'bold', [['bold', 'true']], pool)),
|
||||||
|
|
||||||
|
vector('multiline-insert', 'abc\n', () =>
|
||||||
|
Changeset.makeSplice('abc\n', 3, 0, 'one\ntwo\n')),
|
||||||
|
|
||||||
|
vector('attrib-reuse', 'abc\n', (pool) => {
|
||||||
|
// Two formatted inserts sharing one pool entry exercises pool index reuse.
|
||||||
|
const cs1 = Changeset.makeSplice('abc\n', 0, 0, 'A', [['bold', 'true']], pool);
|
||||||
|
const mid = Changeset.applyToText(cs1, 'abc\n');
|
||||||
|
const cs2 = Changeset.makeSplice(mid, mid.length - 1, 0, 'B', [['bold', 'true']], pool);
|
||||||
|
return Changeset.compose(cs1, cs2, pool);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// CLI entry: write the canonical fixture to disk.
|
||||||
|
if (require.main === module) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const fs = require('fs');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const path = require('path');
|
||||||
|
const out = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||||
|
fs.writeFileSync(out, `${JSON.stringify(generateVectors(), null, 2)}\n`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`wrote ${out}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the `vectors:gen` script**
|
||||||
|
|
||||||
|
In `src/package.json`, add to `"scripts"` (alongside the existing `test` entry):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"vectors:gen": "tsx tests/backend/specs/downstream/generate-vectors.ts",
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Sanity-run the generator (no fixture committed yet)**
|
||||||
|
|
||||||
|
Run from `src/`:
|
||||||
|
```bash
|
||||||
|
cd src && pnpm run vectors:gen
|
||||||
|
```
|
||||||
|
Expected: prints `wrote .../src/tests/fixtures/wire-vectors.json` and the file exists with 5 vectors. Verify each has non-empty `changeset` and a `resultText` that differs from `initialText`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/tests/backend/specs/downstream/generate-vectors.ts src/package.json
|
||||||
|
git commit -m "test(downstream): add golden wire-vector generator"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Commit the generated fixture
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/tests/fixtures/wire-vectors.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate the fixture**
|
||||||
|
|
||||||
|
Run from `src/`:
|
||||||
|
```bash
|
||||||
|
cd src && pnpm run vectors:gen
|
||||||
|
```
|
||||||
|
Expected: `src/tests/fixtures/wire-vectors.json` written.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Eyeball the fixture**
|
||||||
|
|
||||||
|
Open `src/tests/fixtures/wire-vectors.json`. Confirm it is a JSON array of 5 objects, each with keys `name, initialText, changeset, pool, resultText`. The `pool` for `plain-insert`/`plain-delete`/`multiline-insert` has empty `numToAttrib`; `formatted-insert` and `attrib-reuse` contain a `bold,true` entry.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/tests/fixtures/wire-vectors.json
|
||||||
|
git commit -m "test(downstream): add committed golden wire-vectors fixture"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Fixture stability + self-consistency spec
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/tests/backend/specs/downstream/wire-vectors.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/tests/backend/specs/downstream/wire-vectors.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guards the downstream wire-format contract:
|
||||||
|
* - the committed fixture exactly matches a fresh regeneration (any drift is a
|
||||||
|
* deliberate wire change and must be re-generated + reviewed in the same PR), and
|
||||||
|
* - every vector is internally consistent under core's own Changeset engine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert').strict;
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import Changeset from '../../../../static/js/Changeset';
|
||||||
|
import {generateVectors} from './generate-vectors';
|
||||||
|
|
||||||
|
const fixturePath = path.join(__dirname, '../../../fixtures/wire-vectors.json');
|
||||||
|
|
||||||
|
describe(__filename, function () {
|
||||||
|
it('committed fixture matches a fresh regeneration', function () {
|
||||||
|
const committed = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||||
|
const fresh = generateVectors();
|
||||||
|
assert.deepEqual(committed, fresh,
|
||||||
|
'wire-vectors.json is stale — run `pnpm run vectors:gen` and commit the result');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every vector applies to its result under core Changeset', function () {
|
||||||
|
for (const v of generateVectors()) {
|
||||||
|
Changeset.checkRep(v.changeset);
|
||||||
|
assert.equal(Changeset.applyToText(v.changeset, v.initialText), v.resultText,
|
||||||
|
`vector ${v.name} result mismatch`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it passes (fixture already committed)**
|
||||||
|
|
||||||
|
Run from `src/`:
|
||||||
|
```bash
|
||||||
|
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-vectors.ts
|
||||||
|
```
|
||||||
|
Expected: 2 passing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Prove the guard bites (temporary edit)**
|
||||||
|
|
||||||
|
Hand-edit one `resultText` in `src/tests/fixtures/wire-vectors.json`, re-run the command above.
|
||||||
|
Expected: the "committed fixture matches a fresh regeneration" test FAILS. Then `git checkout src/tests/fixtures/wire-vectors.json` to restore.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/tests/backend/specs/downstream/wire-vectors.ts
|
||||||
|
git commit -m "test(downstream): assert wire-vectors fixture stability + consistency"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Socket message-sequence spec
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/tests/backend/specs/downstream/wire-socket-sequence.ts`
|
||||||
|
|
||||||
|
Reference for helpers: `src/tests/backend/specs/messages.ts` (`common.connect`, `common.handshake`) and `src/tests/backend/common.ts`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/tests/backend/specs/downstream/wire-socket-sequence.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pins the socket.io message sequence + shapes that every realtime client
|
||||||
|
* depends on: handshake -> CLIENT_VARS, then USER_CHANGES -> ACCEPT_COMMIT.
|
||||||
|
* A change here is a wire-protocol change that will break downstream clients.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert').strict;
|
||||||
|
const common = require('../../common');
|
||||||
|
const padManager = require('../../../node/db/PadManager');
|
||||||
|
import AttributePool from '../../../../static/js/AttributePool';
|
||||||
|
import Changeset from '../../../../static/js/Changeset';
|
||||||
|
|
||||||
|
describe(__filename, function () {
|
||||||
|
let agent: any;
|
||||||
|
let socket: any;
|
||||||
|
let padId: string;
|
||||||
|
|
||||||
|
before(async function () { agent = await common.init(); });
|
||||||
|
|
||||||
|
beforeEach(async function () {
|
||||||
|
padId = common.randomString();
|
||||||
|
const pad = await padManager.getPad(padId, 'init\n');
|
||||||
|
await pad.setText('init\n');
|
||||||
|
const res = await agent.get(`/p/${padId}`).expect(200);
|
||||||
|
socket = await common.connect(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async function () {
|
||||||
|
if (socket != null) socket.close();
|
||||||
|
socket = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handshake returns CLIENT_VARS with the client-facing shape', async function () {
|
||||||
|
const {type, data} = await common.handshake(socket, padId);
|
||||||
|
assert.equal(type, 'CLIENT_VARS');
|
||||||
|
assert.ok(data.userId, 'CLIENT_VARS.userId missing');
|
||||||
|
assert.ok(data.collab_client_vars, 'collab_client_vars missing');
|
||||||
|
assert.equal(typeof data.collab_client_vars.rev, 'number');
|
||||||
|
assert.ok(data.collab_client_vars.initialAttributedText, 'initialAttributedText missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('USER_CHANGES is acknowledged with ACCEPT_COMMIT and a bumped rev', async function () {
|
||||||
|
const {data: clientVars} = await common.handshake(socket, padId);
|
||||||
|
const rev = clientVars.collab_client_vars.rev;
|
||||||
|
const pool = new AttributePool();
|
||||||
|
const cs = Changeset.makeSplice('init\n', 4, 0, '-typed', [], pool);
|
||||||
|
|
||||||
|
const accepted = common.waitForSocketEvent(socket, 'message');
|
||||||
|
socket.emit('message', {
|
||||||
|
type: 'COLLABROOM',
|
||||||
|
component: 'pad',
|
||||||
|
data: {type: 'USER_CHANGES', baseRev: rev, changeset: cs, apool: pool.toJsonable()},
|
||||||
|
});
|
||||||
|
const msg: any = await accepted;
|
||||||
|
assert.equal(msg.type, 'COLLABROOM');
|
||||||
|
assert.equal(msg.data.type, 'ACCEPT_COMMIT');
|
||||||
|
assert.equal(msg.data.newRev, rev + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run from `src/`:
|
||||||
|
```bash
|
||||||
|
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-socket-sequence.ts
|
||||||
|
```
|
||||||
|
Expected: 2 passing. If `waitForSocketEvent`'s default 1s timeout is too tight on the ACCEPT_COMMIT, pass a larger `timeoutMs` (its 3rd arg) — e.g. `common.waitForSocketEvent(socket, 'message', 5000)`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/tests/backend/specs/downstream/wire-socket-sequence.ts
|
||||||
|
git commit -m "test(downstream): pin socket.io handshake + USER_CHANGES sequence"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: HTTP API shape spec
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/tests/backend/specs/downstream/wire-http-api.ts`
|
||||||
|
|
||||||
|
Reference: `src/tests/backend/specs/api/api.ts` for the `common.init()` agent + API-version pattern.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `src/tests/backend/specs/downstream/wire-http-api.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshots the *shapes* (keys/types, not volatile values) of the HTTP API
|
||||||
|
* endpoints downstream clients call to create pads and round-trip text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert').strict;
|
||||||
|
const common = require('../../common');
|
||||||
|
|
||||||
|
describe(__filename, function () {
|
||||||
|
let agent: any;
|
||||||
|
let apiVersion = 1;
|
||||||
|
const apiKey = common.apiKey;
|
||||||
|
const padId = common.randomString();
|
||||||
|
const ep = (point: string, qs: string) =>
|
||||||
|
`/api/${apiVersion}/${point}?apikey=${apiKey}&${qs}`;
|
||||||
|
|
||||||
|
before(async function () {
|
||||||
|
agent = await common.init();
|
||||||
|
const res = await agent.get('/api/').expect(200);
|
||||||
|
apiVersion = res.body.currentVersion;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createPad returns the standard {code,message,data} envelope', async function () {
|
||||||
|
const res = await agent.get(ep('createPad', `padID=${padId}&text=hello%0A`)).expect(200);
|
||||||
|
assert.deepEqual(Object.keys(res.body).sort(), ['code', 'data', 'message']);
|
||||||
|
assert.equal(res.body.code, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setText + getText round-trips text through the documented shape', async function () {
|
||||||
|
await agent.get(ep('setText', `padID=${padId}&text=world%0A`)).expect(200);
|
||||||
|
const res = await agent.get(ep('getText', `padID=${padId}`)).expect(200);
|
||||||
|
assert.equal(res.body.code, 0);
|
||||||
|
assert.equal(typeof res.body.data.text, 'string');
|
||||||
|
assert.equal(res.body.data.text, 'world\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getRevisionsCount exposes a numeric revisions field', async function () {
|
||||||
|
const res = await agent.get(ep('getRevisionsCount', `padID=${padId}`)).expect(200);
|
||||||
|
assert.equal(res.body.code, 0);
|
||||||
|
assert.equal(typeof res.body.data.revisions, 'number');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm the apiKey helper name**
|
||||||
|
|
||||||
|
Run from `src/`:
|
||||||
|
```bash
|
||||||
|
cd src && grep -n "apiKey\|apikey" tests/backend/common.ts | head
|
||||||
|
```
|
||||||
|
Expected: a `common.apiKey` export (or similar). If the export is named differently, adjust the `apiKey` reference in the spec to match before running.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run from `src/`:
|
||||||
|
```bash
|
||||||
|
cd src && pnpm exec mocha --import=tsx --timeout 120000 --extension ts tests/backend/specs/downstream/wire-http-api.ts
|
||||||
|
```
|
||||||
|
Expected: 3 passing.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/tests/backend/specs/downstream/wire-http-api.ts
|
||||||
|
git commit -m "test(downstream): snapshot client-facing HTTP API shapes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Client manifest
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/tests/downstream/clients.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the manifest**
|
||||||
|
|
||||||
|
Create `src/tests/downstream/clients.json` (SHAs are current `main` HEADs at authoring; `enabled:false` until each client's Phase-2 smoke lands):
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "etherpad-pad",
|
||||||
|
"repo": "https://github.com/ether/pad.git",
|
||||||
|
"ref": "31176d64ce746d45349e58ee6c0bb043052c6e66",
|
||||||
|
"kind": "rust",
|
||||||
|
"enabled": false,
|
||||||
|
"vectorTest": "cargo test --test vectors",
|
||||||
|
"smokeCmd": "cargo test --test smoke -- --ignored"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "etherpad-cli-client",
|
||||||
|
"repo": "https://github.com/ether/etherpad-cli-client.git",
|
||||||
|
"ref": "edbe0bb70971e54514ebea672e4ad9b51fc55bff",
|
||||||
|
"kind": "node",
|
||||||
|
"enabled": false,
|
||||||
|
"vectorTest": "pnpm run test:vectors",
|
||||||
|
"smokeCmd": "pnpm run test:smoke"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "etherpad-desktop",
|
||||||
|
"repo": "https://github.com/ether/etherpad-desktop.git",
|
||||||
|
"ref": "ad273c119f1926a8390c9908fc91f62fa2cf740f",
|
||||||
|
"kind": "desktop",
|
||||||
|
"enabled": false,
|
||||||
|
"vectorTest": "pnpm run test:vectors",
|
||||||
|
"smokeCmd": "pnpm run test:smoke"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate it is well-formed JSON**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
node -e "const c=require('./src/tests/downstream/clients.json'); console.log(c.length, c.map(x=>x.name).join(','))"
|
||||||
|
```
|
||||||
|
Expected: `3 etherpad-pad,etherpad-cli-client,etherpad-desktop`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/tests/downstream/clients.json
|
||||||
|
git commit -m "test(downstream): add client manifest (entries disabled pending Phase 2)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Downstream-smoke workflow
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/workflows/downstream-smoke.yml`
|
||||||
|
|
||||||
|
Reference an existing workflow (`.github/workflows/backend-tests.yml`) for the checkout/pnpm/node setup block this repo uses, and copy that setup verbatim into the job below.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the workflow**
|
||||||
|
|
||||||
|
Create `.github/workflows/downstream-smoke.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Downstream smoke
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 4 * * *' # nightly against develop
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
smoke:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 25
|
||||||
|
steps:
|
||||||
|
- name: Checkout core (PR)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# --- Reuse core's standard node+pnpm setup (copy from backend-tests.yml) ---
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
- name: Install deps
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Boot Etherpad on :9003
|
||||||
|
env:
|
||||||
|
APIKEY: downstream-smoke-key
|
||||||
|
run: |
|
||||||
|
mkdir -p var
|
||||||
|
echo -n "$APIKEY" > APIKEY.txt
|
||||||
|
PORT=9003 pnpm run prod &
|
||||||
|
echo $! > /tmp/ep.pid
|
||||||
|
|
||||||
|
- name: Wait for healthcheck
|
||||||
|
run: |
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -fsS http://localhost:9003/api/ >/dev/null; then
|
||||||
|
echo "up"; exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "server did not come up"; exit 1
|
||||||
|
|
||||||
|
- name: Self-check (boot + API roundtrip proves the harness)
|
||||||
|
run: |
|
||||||
|
K=downstream-smoke-key
|
||||||
|
curl -fsS "http://localhost:9003/api/1/createPad?apikey=$K&padID=smoke&text=hi%0A"
|
||||||
|
curl -fsS "http://localhost:9003/api/1/getText?apikey=$K&padID=smoke" | grep -q '"text":"hi'
|
||||||
|
|
||||||
|
- name: Generate canonical wire-vectors
|
||||||
|
run: cd src && pnpm run vectors:gen
|
||||||
|
|
||||||
|
- name: Run enabled downstream clients
|
||||||
|
run: |
|
||||||
|
node -e '
|
||||||
|
const fs=require("fs");
|
||||||
|
const clients=require("./src/tests/downstream/clients.json").filter(c=>c.enabled);
|
||||||
|
if(!clients.length){console.log("No clients enabled yet (Phase 1).");process.exit(0);}
|
||||||
|
fs.writeFileSync("/tmp/clients.json",JSON.stringify(clients));
|
||||||
|
'
|
||||||
|
# Phase 2 wires per-kind clone + toolchain + vector injection + smoke here,
|
||||||
|
# iterating /tmp/clients.json. Until a client is enabled this is a no-op.
|
||||||
|
|
||||||
|
- name: Teardown (by PID, never pkill)
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f /tmp/ep.pid ]; then kill "$(cat /tmp/ep.pid)" 2>/dev/null || true; fi
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm the boot command + port env**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
cd /home/jose/etherpad/etherpad-core-fresh && grep -nE '"prod"|"dev"|"start"' src/package.json
|
||||||
|
grep -rn "process.env.PORT\|settings.port" src/node/utils/Settings.ts | head
|
||||||
|
```
|
||||||
|
Expected: confirm the script that starts a production server and that `PORT`/`APIKEY` are honored (Settings reads `process.env.PORT`). If the runnable script is named differently (e.g. `prod` vs `dev`), update the "Boot Etherpad" step to match. If APIKEY is read from a file rather than env, the `echo ... > APIKEY.txt` line already covers it.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Lint the workflow YAML**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
node -e "require('js-yaml')" 2>/dev/null && npx --yes js-yaml .github/workflows/downstream-smoke.yml >/dev/null && echo "valid yaml" || python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/downstream-smoke.yml')); print('valid yaml')"
|
||||||
|
```
|
||||||
|
Expected: `valid yaml`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/downstream-smoke.yml
|
||||||
|
git commit -m "ci(downstream): add downstream-smoke workflow (boot/self-check/teardown + manifest scaffold)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Full backend-suite run + push
|
||||||
|
|
||||||
|
**Files:** none (verification + integration)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the whole downstream spec group**
|
||||||
|
|
||||||
|
Per the "always run backend tests" rule, run the new specs through the real mocha invocation the suite uses, from `src/`:
|
||||||
|
```bash
|
||||||
|
cd src && cross-env NODE_ENV=production pnpm exec mocha --import=tsx --timeout 120000 --extension ts --recursive tests/backend/specs/downstream
|
||||||
|
```
|
||||||
|
Expected: all specs in `tests/backend/specs/downstream/` pass (7 tests total across 3 files).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm no regression in the fixture guard**
|
||||||
|
|
||||||
|
Run from `src/`:
|
||||||
|
```bash
|
||||||
|
cd src && pnpm run vectors:gen && git diff --exit-code src/tests/fixtures/wire-vectors.json
|
||||||
|
```
|
||||||
|
Expected: exit 0 (regeneration is byte-identical to the committed fixture).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Push the branch and open the PR**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin feat/downstream-client-compat-tests
|
||||||
|
gh pr create --base develop \
|
||||||
|
--title "test: downstream client compatibility gate (Phase 1)" \
|
||||||
|
--body "Adds core-side contract tests (golden wire-vectors, socket-sequence, HTTP API shapes) and a downstream-smoke workflow scaffold so PRs detect changes that would break the separate CLI / terminal / desktop clients. Phase 2 wires each client repo's vector+smoke tests and flips its manifest entry to enabled. Spec + plan under docs/superpowers/. Closes nothing; tracks the downstream-compat initiative."
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Watch CI**
|
||||||
|
|
||||||
|
Per the "check CI after PRs" rule, wait ~20s then:
|
||||||
|
```bash
|
||||||
|
gh pr checks --watch
|
||||||
|
```
|
||||||
|
Expected: backend tests green (now including the downstream specs); `Downstream smoke` green (self-check passes, no clients enabled yet). Fix any red before moving on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- Layer A golden vectors → Tasks 1–3. ✅
|
||||||
|
- Layer A socket sequence → Task 4. ✅
|
||||||
|
- Layer A HTTP API shapes → Task 5. ✅
|
||||||
|
- Layer B manifest (pinned SHAs, `enabled` gate) → Task 6. ✅
|
||||||
|
- Layer B workflow (boot :9003, healthcheck, vector generation, PID teardown, nightly+PR triggers) → Task 7. ✅
|
||||||
|
- Flakiness mitigations: healthcheck-poll (Task 7 step), PID teardown (Task 7), :9003 (Task 7), no external clients on the gate yet so no flake surface (Task 6 `enabled:false`). ✅
|
||||||
|
- Phasing: Phase 1 self-contained and green; Phase 2 explicitly out of scope. ✅
|
||||||
|
|
||||||
|
**Verification-required tasks** (Task 5 step 2, Task 7 step 2) ask the engineer to confirm the exact `common.apiKey` export name and the production boot script/port handling against the live repo before running — these are real lookups, not placeholders, because those names are repo-version-specific.
|
||||||
|
|
||||||
|
**Type consistency:** `WireVector` fields (`name/initialText/changeset/pool/resultText`) are defined in Task 1 and used identically in Tasks 2–3. `generateVectors()` signature is stable across Tasks 1/3. Manifest keys (`enabled`, `vectorTest`, `smokeCmd`) in Task 6 match the workflow's `.filter(c=>c.enabled)` consumer in Task 7.
|
||||||
|
|
@ -197,7 +197,7 @@ GET /admin/openapi.json (CORS: *)
|
||||||
|
|
||||||
The route is gated by `settings.adminOpenAPI.enabled`, **default `false`**,
|
The route is gated by `settings.adminOpenAPI.enabled`, **default `false`**,
|
||||||
per the project's "new features behind a flag, off by default" policy
|
per the project's "new features behind a flag, off by default" policy
|
||||||
(CONTRIBUTING.md, AGENTS.MD, best_practices.md). When the flag is off,
|
(CONTRIBUTING.md, AGENTS.MD). When the flag is off,
|
||||||
`expressPreSession` returns early and the route is dormant.
|
`expressPreSession` returns early and the route is dormant.
|
||||||
|
|
||||||
When enabled, the route registers in `expressPreSession`, which runs before
|
When enabled, the route registers in `expressPreSession`, which runs before
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
# /admin/settings — emit resolved runtime values alongside raw file
|
||||||
|
|
||||||
|
**Issue:** [ether/etherpad#7803](https://github.com/ether/etherpad/issues/7803)
|
||||||
|
**Date:** 2026-05-18
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`/admin/settings` reads `settings.json` off disk and emits its bytes
|
||||||
|
verbatim. The admin SPA then parses those bytes against its enum
|
||||||
|
dropdowns. Anywhere `${ENV_VAR:default}` appears, the SPA can't resolve
|
||||||
|
the variable and falls back to the template default — so operators see
|
||||||
|
values that don't reflect what Etherpad is actually running with.
|
||||||
|
|
||||||
|
Concrete example: `DB_TYPE=sqlite` in the container env, settings file
|
||||||
|
contains `"dbType": "${DB_TYPE:dirty}"`. Admin UI shows the DB Type
|
||||||
|
dropdown selected on `dirty`. Etherpad is genuinely on SQLite. The
|
||||||
|
admin UI is lying.
|
||||||
|
|
||||||
|
This is the only built-in way operators have to verify runtime config,
|
||||||
|
so a fix is overdue.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. The admin UI accurately shows what `settings.*` values Etherpad is
|
||||||
|
actually using right now, including env-var-substituted values.
|
||||||
|
2. The raw textarea and `saveSettings` round-trip preserve the original
|
||||||
|
`${VAR:default}` literals so an admin can still edit the template
|
||||||
|
without baking env vars into the file.
|
||||||
|
3. Secrets that would otherwise leak (passwords, OIDC client secrets,
|
||||||
|
session-signing material) are redacted from the resolved payload.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Rewriting the save path so admins can edit through the form view
|
||||||
|
without touching env-var bindings. That's a larger UX rework — see
|
||||||
|
Future Work.
|
||||||
|
- Changing `settings.json.template` or `settings.json.docker` on disk.
|
||||||
|
- Touching ep_kaput.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
Extend the existing `'load'` socket handler in
|
||||||
|
`src/node/hooks/express/adminsettings.ts` to emit one additional
|
||||||
|
field next to the existing `results` blob:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
socket.emit('settings', {
|
||||||
|
results: rawFileString, // unchanged — for textarea + saveSettings
|
||||||
|
resolved: redactedRuntimeObject, // new — parsed object, env vars resolved, secrets redacted
|
||||||
|
flags, // unchanged
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`resolved` is the in-memory `settings` module (which already had
|
||||||
|
`lookupEnvironmentVariables` applied to it at boot) passed through a
|
||||||
|
recursive redactor. Old clients that ignore `resolved` continue to
|
||||||
|
work unchanged. The `saveSettings` handler is not touched, so the
|
||||||
|
file's `${VAR:default}` literals survive save round-trips.
|
||||||
|
|
||||||
|
### Server: redactor
|
||||||
|
|
||||||
|
New module `src/node/utils/AdminSettingsRedact.ts` exporting:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function redactSettings(settings: unknown): unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
A pure function that takes the in-memory settings object, deep-clones
|
||||||
|
it (Node's built-in `structuredClone`, available since Node 17),
|
||||||
|
walks the clone, and replaces values at known sensitive JSON paths
|
||||||
|
with the sentinel string `"[REDACTED]"`. The original object is not
|
||||||
|
mutated. Functions on the live `settings` module — `coerceValue`,
|
||||||
|
`reloadSettings`, etc. — are dropped during the clone walk by
|
||||||
|
filtering them out before recursion (structured clone rejects
|
||||||
|
functions); the resolved payload contains only data.
|
||||||
|
|
||||||
|
Allow-list (paths use `*` for any object key and `[*]` for any array
|
||||||
|
index):
|
||||||
|
|
||||||
|
| Path | Reason |
|
||||||
|
|---|---|
|
||||||
|
| `users.*.password` | Plaintext basic-auth password |
|
||||||
|
| `users.*.passwordHash` | Bcrypt hash — credential material |
|
||||||
|
| `users.*.hash` | Older spelling used by some configs |
|
||||||
|
| `dbSettings.password` | DB password (mysql/postgres/redis) |
|
||||||
|
| `dbSettings.user` | Credential half — redact for symmetry |
|
||||||
|
| `sso.clients[*].client_secret` | OIDC client secret |
|
||||||
|
| `sso.clients[*].secret` | ep_openid_connect older spelling |
|
||||||
|
| `sso.issuer` | Only if URL contains `user:pass@` userinfo |
|
||||||
|
| `loadTest.*.password` | ep_load_test creds |
|
||||||
|
| `sessionKey` | Used to sign session cookies |
|
||||||
|
|
||||||
|
Behaviour:
|
||||||
|
- Redact to the literal string `"[REDACTED]"` regardless of original
|
||||||
|
type, so the SPA only ever has to check one sentinel.
|
||||||
|
- A redacted leaf is redacted only at the leaf — siblings stay
|
||||||
|
visible (e.g. `sso.clients[0].client_id` is shown,
|
||||||
|
`sso.clients[0].client_secret` is redacted).
|
||||||
|
- If the underlying value was `null` (env var unset, no default),
|
||||||
|
still emit `"[REDACTED]"` to avoid leaking "this secret is unset"
|
||||||
|
via a visible `null`.
|
||||||
|
- `dbSettings.filename` is NOT redacted — operators need to verify
|
||||||
|
their volume mount.
|
||||||
|
|
||||||
|
### Server: emit site
|
||||||
|
|
||||||
|
In `adminsettings.ts`, in `socket.on('load')`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {redactSettings} from '../../utils/AdminSettingsRedact';
|
||||||
|
// ...
|
||||||
|
const resolved = redactSettings(settings);
|
||||||
|
socket.emit('settings', {results: rawFileString, resolved, flags});
|
||||||
|
```
|
||||||
|
|
||||||
|
If `showSettingsInAdminPage === false`, omit `resolved` too (don't
|
||||||
|
emit a redacted runtime when the file blob is gated).
|
||||||
|
|
||||||
|
### Client: store
|
||||||
|
|
||||||
|
`admin/src/store/store.ts`:
|
||||||
|
- Add `resolved: unknown | null` to the store, defaulting to `null`.
|
||||||
|
- In the `'settings'` socket listener, store `payload.resolved ?? null`.
|
||||||
|
- Old servers that don't send `resolved` leave it `null` and the UI
|
||||||
|
degrades to current behaviour.
|
||||||
|
|
||||||
|
`admin/src/utils/resolveByPath.ts` (new file — single-purpose helper):
|
||||||
|
- Export `resolveByPath(obj: unknown, path: JSONPath): unknown` —
|
||||||
|
walks a plain JS object by a `jsonc-parser` JSONPath
|
||||||
|
(`(string | number)[]`), returns `undefined` on miss. Pure,
|
||||||
|
unit-tested.
|
||||||
|
|
||||||
|
`admin/src/store/store.ts`:
|
||||||
|
- Add a `useResolvedAt(path: JSONPath)` selector hook that returns
|
||||||
|
`resolveByPath(state.resolved, path)`.
|
||||||
|
|
||||||
|
### Client: env pill widget
|
||||||
|
|
||||||
|
`admin/src/components/settings/widgets/EnvPill.tsx`:
|
||||||
|
- New optional prop `resolvedValue?: unknown`.
|
||||||
|
- When defined and not `'[REDACTED]'`: render a read-only chip after
|
||||||
|
the editable default input, e.g. `→ sqlite`.
|
||||||
|
- When `'[REDACTED]'`: render `→ ••••••` with an i18n tooltip
|
||||||
|
explaining the value is redacted.
|
||||||
|
- When `undefined` (old server or missing path): no chip; render
|
||||||
|
exactly as today.
|
||||||
|
|
||||||
|
New i18n key `admin_settings.env_pill.runtime_label` and
|
||||||
|
`admin_settings.env_pill.redacted_tooltip`. No hardcoded English.
|
||||||
|
|
||||||
|
### Client: jsonc tree
|
||||||
|
|
||||||
|
`admin/src/components/settings/JsoncNode.tsx`:
|
||||||
|
- Where `matchEnvPlaceholder(raw)` is currently called (line ~42),
|
||||||
|
also call `useResolvedAt(path)` and pass the result as
|
||||||
|
`resolvedValue` to `<EnvPill>`.
|
||||||
|
|
||||||
|
### Client: form view
|
||||||
|
|
||||||
|
`admin/src/components/settings/FormView.tsx`:
|
||||||
|
- FormView already has access to the parsed JSONC tree through
|
||||||
|
`rawText = useStore(s => s.settings)` plus jsonc-parser. For each
|
||||||
|
control, after detecting that the raw value at its path is an env
|
||||||
|
placeholder (`matchEnvPlaceholder` on the raw slice), the
|
||||||
|
*selected* dropdown option / displayed input value is derived from
|
||||||
|
`useResolvedAt(path)` rather than from the literal placeholder
|
||||||
|
string. Plain (non-placeholder) values keep using the raw JSONC
|
||||||
|
value as today.
|
||||||
|
- The env pill above the control still shows and is still editable
|
||||||
|
(the editable input mutates the `default` portion of the
|
||||||
|
placeholder in the raw JSONC, exactly as today).
|
||||||
|
- This is the fix for the original "dropdown shows `dirty` when DB is
|
||||||
|
sqlite" complaint.
|
||||||
|
|
||||||
|
## Data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
boot
|
||||||
|
└─ Settings.ts:reloadSettings()
|
||||||
|
└─ lookupEnvironmentVariables(parsedSettings) -- ${VAR:default} → real value
|
||||||
|
└─ writes into the exported `settings` module
|
||||||
|
|
||||||
|
admin loads /admin/settings
|
||||||
|
└─ socket 'load'
|
||||||
|
├─ fsp.readFile(settings.settingsFilename) -- raw, env vars unresolved
|
||||||
|
└─ redactSettings(settings) -- live module, env vars resolved, secrets redacted
|
||||||
|
socket.emit('settings', {results: raw, resolved, flags})
|
||||||
|
|
||||||
|
admin SPA
|
||||||
|
├─ raw textarea ← results -- preserves ${VAR:default}
|
||||||
|
├─ env pill chip ← resolveByPath(resolved, path) -- shows "→ sqlite"
|
||||||
|
└─ form view dropdown selection ← resolveByPath(resolved, path)
|
||||||
|
|
||||||
|
admin clicks Save
|
||||||
|
└─ saveSettings emits the raw textarea blob (unchanged behaviour)
|
||||||
|
└─ server writes verbatim to settings.json — template intact
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- `redactSettings` is a pure function over a structured clone; no I/O,
|
||||||
|
no rejection path. If it throws on a malformed live `settings`
|
||||||
|
module (shouldn't happen — that module is the source of truth at
|
||||||
|
runtime) we let the error propagate; the `socket.on('load')`
|
||||||
|
handler already has no try/catch around the emit and any throw will
|
||||||
|
surface in logs.
|
||||||
|
- On the client, `resolveByPath` returns `undefined` for missing
|
||||||
|
paths. Consumers treat `undefined` as "no resolved value
|
||||||
|
available" and fall back to current behaviour.
|
||||||
|
- Old client + new server: client ignores `resolved` — degrades to
|
||||||
|
today's misleading UI, no regression.
|
||||||
|
- New client + old server: `resolved` is `null` — `useResolvedAt`
|
||||||
|
returns `undefined` everywhere, env pill skips the chip, dropdowns
|
||||||
|
fall back to current behaviour.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Per the `feedback_always_run_backend_tests` and
|
||||||
|
`feedback_test_localized_strings` memories.
|
||||||
|
|
||||||
|
**Backend vitest** (`src/tests/backend/specs/`):
|
||||||
|
- `AdminSettingsRedact.spec.ts` — fixture per allow-list path, plus
|
||||||
|
a no-op control case, plus a nested-secret-inside-an-array case.
|
||||||
|
- `adminsettings.spec.ts` — mock socket, set `process.env.DB_TYPE=sqlite`,
|
||||||
|
call `reloadSettings()`, emit `load`, assert `resolved.dbType === 'sqlite'`
|
||||||
|
and `resolved.dbSettings.password === '[REDACTED]'`.
|
||||||
|
|
||||||
|
**Frontend vitest** (`admin/src/`):
|
||||||
|
- `utils/resolveByPath.spec.ts` — nested objects, arrays, missing keys.
|
||||||
|
- `widgets/EnvPill.spec.tsx` — renders chip when value set, renders
|
||||||
|
redacted chip when sentinel, omits chip when undefined.
|
||||||
|
|
||||||
|
**Playwright e2e** (`src/tests/frontend/specs/` or `src/tests/admin/`):
|
||||||
|
- Boot Etherpad with `DB_TYPE=sqlite` env on port 9003 (per
|
||||||
|
`feedback_test_port_9003`).
|
||||||
|
- Open `/admin/settings`, switch to form view.
|
||||||
|
- Assert DB Type dropdown reflects `sqlite`, not `dirty`.
|
||||||
|
- Switch to raw view, assert env pill chip shows `→ sqlite`.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
Per `feedback_include_docs_updates`:
|
||||||
|
- Identify the existing admin-settings doc path during implementation
|
||||||
|
(likely `doc/admin/admin-settings.md` or under `doc/api/`) — add a
|
||||||
|
short section on the resolved-value chip and the `[REDACTED]`
|
||||||
|
sentinel. If no such doc exists yet, no new doc is created in this
|
||||||
|
PR (avoid the "don't create docs unless asked" rule); instead, a
|
||||||
|
pointer in the PR description.
|
||||||
|
- Follow-up PR to `ether/home-assistant-addon-etherpad/etherpad/DOCS.md`
|
||||||
|
to remove the "admin settings page is cosmetic" caveat once this
|
||||||
|
ships; reference from the etherpad PR description.
|
||||||
|
|
||||||
|
## Future work
|
||||||
|
|
||||||
|
- Form-view save path that lets an admin edit a value without
|
||||||
|
touching its env-var binding. Today FormView writes back to raw
|
||||||
|
JSONC; the env-pill default input is the only way to edit an
|
||||||
|
env-bound value, which is awkward for non-string values.
|
||||||
|
- An audit log of redacted-vs-visible keys so plugins can declare
|
||||||
|
additional secret paths via a hook.
|
||||||
|
|
||||||
|
## Implementation footprint
|
||||||
|
|
||||||
|
- New file `src/node/utils/AdminSettingsRedact.ts` (~80 lines + JSDoc).
|
||||||
|
- `src/node/hooks/express/adminsettings.ts` — ~4 lines changed.
|
||||||
|
- New file `admin/src/utils/resolveByPath.ts` (~15 lines).
|
||||||
|
- `admin/src/store/store.ts` — ~10 lines changed (store field,
|
||||||
|
selector hook).
|
||||||
|
- `admin/src/components/settings/widgets/EnvPill.tsx` — ~15 lines
|
||||||
|
added (prop, chip render).
|
||||||
|
- `admin/src/components/settings/JsoncNode.tsx` — ~3 lines changed.
|
||||||
|
- `admin/src/components/settings/FormView.tsx` — modest changes per
|
||||||
|
control type; estimate ~30 lines.
|
||||||
|
- New i18n keys in `src/locales/en.json` (English source).
|
||||||
|
- Tests: ~4 spec files, ~200 lines.
|
||||||
|
- Docs: ~30 lines.
|
||||||
|
|
||||||
|
Total: ~400 lines including tests and docs.
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
# URL base-path support (X-Forwarded-Prefix / X-Ingress-Path) — Design
|
||||||
|
|
||||||
|
GitHub issue: https://github.com/ether/etherpad/issues/7802
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Etherpad assumes it is served at `/`. When a reverse proxy adds a path prefix —
|
||||||
|
Home Assistant ingress (`/api/hassio_ingress/<random-token>/`), Nginx
|
||||||
|
`location /etherpad/`, Cloudflare Worker routes — three categories of URLs
|
||||||
|
break:
|
||||||
|
|
||||||
|
1. Hard-coded leading-slash hrefs/srcs in server-rendered HTML (`/static/...`,
|
||||||
|
`/admin/...`, `/manifest.json`, `/favicon.ico`).
|
||||||
|
2. Plugin assets injected via the inner ace iframe and via plugin DOM hooks
|
||||||
|
that emit leading-slash URLs.
|
||||||
|
3. From-request absolute URLs in social-meta tags (`og:url`, `og:image`,
|
||||||
|
`twitter:image`) — they pick up the bound listen address (e.g.
|
||||||
|
`http://0.0.0.0:9001/...`) instead of the public origin.
|
||||||
|
|
||||||
|
Failures are partial and confusing: the pad loads, the toolbar renders, the
|
||||||
|
pad text round-trips through the server, but plugin CSS 404s and admin pages
|
||||||
|
are unreachable from inside the proxied iframe.
|
||||||
|
|
||||||
|
The `publicURL` setting can't fix this: HA ingress prefixes are per-session
|
||||||
|
random tokens, not stable values. Etherpad has to learn the prefix from each
|
||||||
|
request.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- When `settings.trustProxy === true` and a proxied request carries an
|
||||||
|
`X-Forwarded-Prefix` or `X-Ingress-Path` header, Etherpad emits every
|
||||||
|
asset URL, admin link, manifest reference, and socket.io endpoint under
|
||||||
|
that prefix.
|
||||||
|
- The HTML page also carries `<base href="${prefix}/">` so plugin-injected
|
||||||
|
leading-slash URLs route through the prefix without each plugin opting in.
|
||||||
|
- Behavior with no proxy header (or with `trustProxy === false`) is byte-for-
|
||||||
|
byte identical to today.
|
||||||
|
- No new `settings.json` field.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Static `settings.basePath` configuration. Rejected because HA ingress is
|
||||||
|
per-session, and proxies that want a stable prefix can already set the
|
||||||
|
header. (Confirmed during brainstorming.)
|
||||||
|
- Deprecating `publicURL`. It remains the canonical-origin setting for
|
||||||
|
Open Graph / Twitter Card absolute URLs; basePath is orthogonal.
|
||||||
|
- Express mount-path rewiring. Proxies strip the prefix before forwarding;
|
||||||
|
Etherpad still routes against `/p/...`.
|
||||||
|
- "No link to /admin from index" UX symptom mentioned in the issue. Out of
|
||||||
|
scope; can be filed separately as an admin discoverability ticket.
|
||||||
|
|
||||||
|
## Header handling
|
||||||
|
|
||||||
|
Honored only when `settings.trustProxy === true`. Otherwise the parser
|
||||||
|
returns `''`.
|
||||||
|
|
||||||
|
Headers checked in this order; first non-empty wins:
|
||||||
|
|
||||||
|
1. `X-Forwarded-Prefix` (HAProxy / Traefik convention)
|
||||||
|
2. `X-Ingress-Path` (Home Assistant convention)
|
||||||
|
|
||||||
|
Sanitization, in order:
|
||||||
|
|
||||||
|
1. Trim whitespace.
|
||||||
|
2. If non-empty and not starting with `/`, prepend `/`.
|
||||||
|
3. Strip trailing slashes.
|
||||||
|
4. Reject (return `''`) on any of: `..`, `://`, `\`, control characters,
|
||||||
|
or any character outside `[A-Za-z0-9_\-./~%]`.
|
||||||
|
5. Reject if length > 1024 chars.
|
||||||
|
|
||||||
|
Result is a plain string: `''` (no prefix) or `/some/prefix` (no trailing
|
||||||
|
slash, starts with `/`).
|
||||||
|
|
||||||
|
`''` is the sentinel for "no prefix" everywhere downstream.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Single source of truth — `req.basePath` — set once per request, propagated
|
||||||
|
to three sinks:
|
||||||
|
|
||||||
|
```
|
||||||
|
X-Forwarded-Prefix / X-Ingress-Path
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
parseBasePath() in middleware
|
||||||
|
│
|
||||||
|
req.basePath = "/foo"
|
||||||
|
│
|
||||||
|
┌──────────────────────────────┼──────────────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
EJS / res.locals.basePath clientVars.basePath <base href="${basePath}/">
|
||||||
|
│ │ │
|
||||||
|
templates emit pad.ts sets baseURL plugin-injected
|
||||||
|
<%= basePath %>/... socket.io path follows leading-slash URLs
|
||||||
|
for every leading socialMeta builds resolve through prefix
|
||||||
|
slash href/src from-request URLs with (belt-and-braces)
|
||||||
|
prefix
|
||||||
|
```
|
||||||
|
|
||||||
|
Backwards compatible by construction: no header → `req.basePath = ''` →
|
||||||
|
templates emit `/static/...` (today's exact output) → `<base href="/">` is a
|
||||||
|
no-op.
|
||||||
|
|
||||||
|
## Pre-existing infrastructure (discovered during plan write-up)
|
||||||
|
|
||||||
|
The codebase already has substantial proxy-path support that wasn't visible
|
||||||
|
from the issue text. The plan extends rather than replaces:
|
||||||
|
|
||||||
|
- `src/node/utils/sanitizeProxyPath.ts` — already reads `x-proxy-path`,
|
||||||
|
sanitizes the value, returns `''` or `/...`. Same shape as the spec's
|
||||||
|
proposed `parseBasePath`. Plan: extend the header list to also check
|
||||||
|
`X-Ingress-Path` and `X-Forwarded-Prefix`, gated on `trustProxy`.
|
||||||
|
- `src/templates/padBootstrap.js` and `timeSliderBootstrap.js` — already
|
||||||
|
compute `basePath` from `window.location` and set `pad.baseURL`,
|
||||||
|
`window.plugins.baseURL`, `timeSlider.baseURL`. socket.io path
|
||||||
|
(`socketio.connect(exports.baseURL, ...)`) and the `fetch` call in
|
||||||
|
`pad.ts:1040` already follow the prefix without any change.
|
||||||
|
- `src/node/hooks/express/admin.ts` — already substitutes `/admin` and
|
||||||
|
`/socket.io` in the admin SPA HTML/JS/CSS using `sanitizeProxyPath`.
|
||||||
|
No admin Vite rebuild needed.
|
||||||
|
- `src/templates/pad.html`, `timeslider.html` — already use relative
|
||||||
|
paths (`../static/...`, `../favicon.ico`, `../../manifest.json`), so
|
||||||
|
they pick up the prefix naturally when served behind one.
|
||||||
|
- `src/node/hooks/express/specialpages.ts` — already calls
|
||||||
|
`sanitizeProxyPath(req)` and threads `proxyPath` into the rendered
|
||||||
|
`entrypoint` URL.
|
||||||
|
|
||||||
|
What is NOT covered today, and forms the plan's actual scope:
|
||||||
|
|
||||||
|
1. **Header source list** — HA Ingress sends `X-Ingress-Path`; nginx
|
||||||
|
subpath users typically rely on `X-Forwarded-Prefix`. Etherpad only
|
||||||
|
reads `x-proxy-path`. Mismatched header → no prefix → all symptoms.
|
||||||
|
2. **`/manifest.json` icons** (`src/node/hooks/express/pwa.ts`) emit
|
||||||
|
hard-coded `/favicon.ico` and `/static/skins/...` paths.
|
||||||
|
3. **`socialMeta` from-request URLs** (`src/node/utils/socialMeta.ts`)
|
||||||
|
don't honor `proxyPath` when building the from-request fallback,
|
||||||
|
producing wrong `og:url` / `og:image` under a prefix.
|
||||||
|
4. **Leading-slash URLs in `index.html`, `timeslider.html`, `pad.html`,
|
||||||
|
`export_html.html`** — manifest link, jslicense link, reconnect
|
||||||
|
form action. Each can be made relative or `proxyPath`-prefixed.
|
||||||
|
5. **Plugin DOM injection** — plugins that emit `<link href="/static/...">`
|
||||||
|
at runtime aren't covered by any existing rewrite. A `<base href>`
|
||||||
|
tag was considered as a belt-and-braces fix but rejected: path-absolute
|
||||||
|
URLs (`/foo`) deliberately ignore the path component of `<base href>` and
|
||||||
|
resolve against the origin, so `<base href="/sub/">` + `<link href="/static/foo">`
|
||||||
|
still resolves to `/static/foo`. And `<base href>` would change the
|
||||||
|
resolution base for existing relative URLs in `pad.html` /
|
||||||
|
`timeslider.html` (e.g. `../static/css/pad.css`), breaking them. Plugin
|
||||||
|
authors emitting leading-slash URLs need to use `clientVars`-derived or
|
||||||
|
relative paths — documented separately as a plugin guidance issue, not
|
||||||
|
resolved here.
|
||||||
|
6. **Settings documentation** — `settings.json.template` and `Settings.ts`
|
||||||
|
doc comment for `trustProxy` need to mention the new header sources.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Reflects the discovery above — extends existing helpers; smaller surface than the original draft.
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/node/utils/sanitizeProxyPath.ts` | Extend header source list to also read `x-forwarded-prefix` and `x-ingress-path`. Standard headers (everything other than the existing `x-proxy-path`) gated on `settings.trustProxy === true`. First non-empty wins, after sanitization. |
|
||||||
|
| `src/node/hooks/express/pwa.ts` | `/manifest.json` handler reads `sanitizeProxyPath(req)` and emits `${proxyPath}/favicon.ico`, `${proxyPath}/static/skins/...`, `${proxyPath}/` for `start_url`. Mark response `Vary: x-proxy-path, x-ingress-path, x-forwarded-prefix` + `Cache-Control: private, no-store` when proxyPath is non-empty (mirrors the admin handler's pattern). |
|
||||||
|
| `src/node/utils/socialMeta.ts` | `buildAbsoluteUrl` honors `proxyPath` when falling back to from-request origin: `${origin}${proxyPath}${pathname}`. `publicURL` still wins when set. |
|
||||||
|
| `src/templates/index.html` | Replace `<link rel="manifest" href="/manifest.json">` and the jslicense `<a href="/javascript">` with `proxyPath`-prefixed values. Route handler in `specialpages.ts` passes `proxyPath` as an explicit template variable. |
|
||||||
|
| `src/templates/timeslider.html`, `pad.html` | jslicense `<a href>` and `<form action="/ep/pad/reconnect">` use `proxyPath`. Fix pre-existing `..`-count bug in the manifest `<link>` (`../../manifest.json` in pad.html resolves under a prefix as `/manifest.json` instead of `/sub/manifest.json`; ditto `../../../manifest.json` in timeslider). Reduce by one to make the path correct in both root-mount and prefix-mount cases. |
|
||||||
|
| `src/templates/export_html.html` | `<link rel="manifest">` uses proxyPath via the export route's render context. |
|
||||||
|
| `src/node/hooks/express/specialpages.ts` + export route | Pass `proxyPath` into every `eejs.require` call that renders the affected templates. |
|
||||||
|
| `settings.json.template` + `Settings.ts` doc comment | Document the three honored header names and the trustProxy gate. No new field. |
|
||||||
|
|
||||||
|
Out: no new `basePath.ts`, no Express middleware, no EJS-context-wide helper, no admin Vite rebuild, no `clientVars.basePath`, no edits to `pad.ts` / `timeslider.ts` / `padBootstrap.js`. Pre-existing code already covers those surfaces (`pad.baseURL` and `window.plugins.baseURL` are derived client-side from `window.location` in `padBootstrap.js` / `timeSliderBootstrap.js`).
|
||||||
|
|
||||||
|
## Data flow — concrete example
|
||||||
|
|
||||||
|
Home Assistant ingress request:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /p/scratch HTTP/1.1
|
||||||
|
Host: 0.0.0.0:9001
|
||||||
|
X-Forwarded-Proto: https
|
||||||
|
X-Forwarded-Host: ha.example
|
||||||
|
X-Forwarded-Prefix: /api/hassio_ingress/abc123
|
||||||
|
X-Ingress-Path: /api/hassio_ingress/abc123
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `app.enable('trust proxy')` → `req.protocol === 'https'`, `req.hostname === 'ha.example'`.
|
||||||
|
2. `sanitizeProxyPath(req)` → `'/api/hassio_ingress/abc123'` (read from `X-Ingress-Path` because trustProxy is on; would also accept `X-Forwarded-Prefix`).
|
||||||
|
3. `specialpages.ts` route handler for `/p/:pad`: renders `pad.html` with `proxyPath` in the template context. Output includes:
|
||||||
|
- jslicense `<a href>` and reconnect form `action` prefixed via the EJS variable.
|
||||||
|
- `<link rel="manifest" href="../manifest.json">` (fixed from `../../manifest.json` — see Risks): resolves to `/api/hassio_ingress/abc123/manifest.json`.
|
||||||
|
- `<link href="../static/css/pad.css...">` is already relative — resolves under the prefix to `/api/hassio_ingress/abc123/static/css/pad.css`.
|
||||||
|
4. Browser fetches `/api/hassio_ingress/abc123/manifest.json` → `pwa.ts` route emits manifest with all icon `src` values prefixed; `start_url` prefixed.
|
||||||
|
5. Browser establishes socket.io: client-side `padBootstrap.js` computes `basePath = new URL('..', window.location).pathname` → `/api/hassio_ingress/abc123/` → `pad.baseURL` set → `socketio.connect('/api/hassio_ingress/abc123/', ...)` → socket.io path is `/api/hassio_ingress/abc123/socket.io/`. No code change here — pre-existing logic.
|
||||||
|
6. `socialMeta`: `og:url = https://ha.example/api/hassio_ingress/abc123/p/scratch`, `og:image = https://ha.example/api/hassio_ingress/abc123/favicon.ico`.
|
||||||
|
7. Inner ace iframe loads `../static/empty.html` (relative) → resolves to `/api/hassio_ingress/abc123/static/empty.html` naturally. No code change in the inner iframe; plugin CSS injected via `aceEditorCSS` is also relative-prefixed (`../static/plugins/...`).
|
||||||
|
|
||||||
|
Direct (non-proxied) request — same code path:
|
||||||
|
|
||||||
|
1. No `x-proxy-path`, no `X-Forwarded-Prefix`, no `X-Ingress-Path` → `sanitizeProxyPath(req) === ''`.
|
||||||
|
2. Templates render today's output. The reduced-`..`-count manifest link still resolves to `/manifest.json` from a root-mount pad URL (`/p/test`), so no observable change for non-proxied users.
|
||||||
|
3. `pwa.ts` returns today's manifest (icon srcs `'/favicon.ico'` etc.) when `proxyPath === ''`.
|
||||||
|
|
||||||
|
## Backwards compatibility
|
||||||
|
|
||||||
|
- Existing deployments: no header → behavior unchanged. `<base href="/">` is
|
||||||
|
benign (HTML5 valid, no-op for absolute URLs).
|
||||||
|
- `publicURL` semantics unchanged. When set, `socialMeta` still uses it for
|
||||||
|
the origin in OG tags; basePath only affects request-derived fallbacks.
|
||||||
|
- Plugins using `clientVars.padId`, etc. continue to work. Plugins that build
|
||||||
|
asset URLs by hardcoding `/static/...` now route through the prefix via the
|
||||||
|
`<base href>` belt-and-braces, even if they don't read `clientVars.basePath`.
|
||||||
|
- No new dependency, no new setting, no migration step.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Plugin templates and plugin-rendered HTML** outside `src/templates/` may
|
||||||
|
contain leading-slash URLs. We cannot auto-rewrite them. `<base href>`
|
||||||
|
was considered as a runtime catch-all and rejected (see "Plugin DOM
|
||||||
|
injection" above). Plugins emitting absolute URLs should prefer relative
|
||||||
|
or `clientVars`-derived paths; documenting that recommendation is a
|
||||||
|
separate follow-up.
|
||||||
|
- **Manifest `..`-count fix is a strict improvement.** Today's
|
||||||
|
`../../manifest.json` in `pad.html` resolves to `/manifest.json` from a
|
||||||
|
root-mount pad URL — a happy accident: the relative path has one `..` too
|
||||||
|
many but the browser silently caps `..` at the path root. After the fix
|
||||||
|
to `../manifest.json`, the result is `/manifest.json` from root and
|
||||||
|
`/<prefix>/manifest.json` under a prefix. No regression possible at root.
|
||||||
|
Same logic for `timeslider.html`'s `../../../manifest.json`.
|
||||||
|
- **Malicious header injection** when `trustProxy === false` is irrelevant
|
||||||
|
(we ignore the headers). When `trustProxy === true` the operator has
|
||||||
|
already declared the proxy trusted; sanitization (rejects `..`, scheme,
|
||||||
|
control chars, etc.) prevents XSS via `<base href>` and prevents path
|
||||||
|
escape. We do NOT trust headers in non-proxy mode.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Unit** — `src/tests/backend/specs/basePath-unit.ts`:
|
||||||
|
- `parseBasePath` truth table: trustProxy off → `''`; no headers → `''`;
|
||||||
|
`X-Forwarded-Prefix: /foo` → `/foo`; `X-Forwarded-Prefix: foo` →
|
||||||
|
`/foo`; `X-Forwarded-Prefix: /foo/` → `/foo`; `X-Forwarded-Prefix:
|
||||||
|
/foo//` → `/foo`; `X-Forwarded-Prefix: /a/../b` → `''`; `X-Forwarded-Prefix:
|
||||||
|
https://evil.example/foo` → `''`; `X-Forwarded-Prefix: ` (whitespace)
|
||||||
|
→ `''`; `X-Forwarded-Prefix` empty + `X-Ingress-Path: /bar` → `/bar`;
|
||||||
|
`X-Forwarded-Prefix: /a` + `X-Ingress-Path: /b` → `/a` (first wins);
|
||||||
|
long-string > 1024 chars → `''`; non-ASCII → `''`.
|
||||||
|
- **Backend integration** — `src/tests/backend/specs/basePath-integration.ts`:
|
||||||
|
- supertest GET `/` with `X-Forwarded-Prefix: /api/foo` (and trustProxy
|
||||||
|
on): rendered HTML contains `<base href="/api/foo/">`, contains
|
||||||
|
`href="/api/foo/static/...`, contains `href="/api/foo/manifest.json"`.
|
||||||
|
- GET `/p/test` same expectations.
|
||||||
|
- GET `/admin/`: assert prefix is present in `<link>`/`<script>` paths and `<base href>` present. The exact mechanism (Vite `base: './'` rebuild vs. server-side templating of the admin HTML) is chosen during implementation; the test only asserts the post-render output.
|
||||||
|
- GET `/` with same headers but trustProxy OFF: no prefix anywhere, output
|
||||||
|
matches the trustProxy-on-no-headers output.
|
||||||
|
- GET `/` with `X-Forwarded-Prefix: /a/../b`: no prefix (rejected),
|
||||||
|
output identical to no-headers.
|
||||||
|
- **socialMeta** — extend existing `src/tests/backend/specs/socialMeta-unit.ts`:
|
||||||
|
- With `req.basePath = '/api/foo'` and no `publicURL`: `og:url` and
|
||||||
|
`og:image` carry the prefix.
|
||||||
|
- With `publicURL` set: `publicURL` still wins (existing test); basePath
|
||||||
|
not applied (publicURL is assumed to encode the full canonical origin
|
||||||
|
including any path component).
|
||||||
|
- **No new E2E.** Existing Playwright suite covers normal URLs. Adding a
|
||||||
|
reverse-proxy harness for E2E is high-effort for low marginal coverage;
|
||||||
|
manual verification through the HA addon during the release candidate
|
||||||
|
phase is sufficient.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
None at design time. Implementation plan will resolve:
|
||||||
|
|
||||||
|
- Exact mechanism to thread `basePath` from HTTP request to socket.io
|
||||||
|
handshake (existing socket handshake already attaches the original
|
||||||
|
request; need to confirm the access pattern in `PadMessageHandler`).
|
||||||
|
- Whether `admin/vite.config.ts` rebuild produces a stable hash filename
|
||||||
|
or whether we need to also adjust the admin route handler to scan the
|
||||||
|
built `dist/` directory at startup.
|
||||||
|
|
@ -0,0 +1,278 @@
|
||||||
|
# Outdated-version notice redesign
|
||||||
|
|
||||||
|
**Issue:** [ether/etherpad#7799](https://github.com/ether/etherpad/issues/7799)
|
||||||
|
**Date:** 2026-05-18
|
||||||
|
**Status:** Design
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The pad-side "Etherpad on this server is severely outdated. Tell your admin." banner is shown to every visitor of every pad, persistently, whenever the running server is at least one major version behind the latest published release. The reporter (a server admin) says:
|
||||||
|
|
||||||
|
> "it's inappropriate to inform users of a site about maintenance tasks that they don't understand or have context to resolve. It wastes users' time by having them try and contact me, and it wastes my time by having to respond."
|
||||||
|
|
||||||
|
In addition to the social problem, the current implementation triggers on develop checkouts and on minor-only deltas in some upstream-version states, and has been observed intercepting chat-icon clicks (z-index 9999, bottom-right) in plugin test matrices pinned to older cores.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. The notice is shown **only to the pad's first author** (the author whose ID occupies position 0 in the pad's attribute pool — i.e. whoever made the first edit).
|
||||||
|
2. The notice is **non-persistent**: a dismissable `$.gritter` toast, auto-fading after 8s, rather than an always-visible badge.
|
||||||
|
3. The notice fires **only on minor-or-more behind** (e.g. 3.1.0 → 3.2.0, 2.7.3 → 3.0.0). Patch-only deltas (3.0.1 → 3.0.2) never fire.
|
||||||
|
4. The notice never fires when `current >= latest` (covers the develop-after-bump case).
|
||||||
|
5. The `vulnerable-below` UI is **dropped entirely**, along with the directive parser and state field. The vulnerable enum is gone from the API.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- No new settings flag. `updates.tier = 'off'` remains the kill-switch.
|
||||||
|
- No translations of new strings in this PR. A `TODO(i18n)` placeholder is carried forward — strings are hard-coded English, mirroring the current state of the badge code. A follow-up adds `pad.outdatedNotice.*` keys once the html10n key set is set up to be shared with the pad-side bundle.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (pad load, after CLIENT_VARS) Server
|
||||||
|
───────────────────────────────────── ──────
|
||||||
|
pad.ts → maybeShowOutdatedNotice()
|
||||||
|
│
|
||||||
|
├─ GET /api/version-status?padId=<id> (cookies: express_sid)
|
||||||
|
│
|
||||||
|
│ loadState(stateFilePath())
|
||||||
|
│ │
|
||||||
|
│ ├─ no latest → {outdated:null,isFirstAuthor:false}
|
||||||
|
│ ├─ current >= latest → {outdated:null,isFirstAuthor:false}
|
||||||
|
│ ├─ same major + minor differs → next step
|
||||||
|
│ ├─ major differs → next step
|
||||||
|
│ └─ patch-only behind → {outdated:null,isFirstAuthor:false}
|
||||||
|
│ next step:
|
||||||
|
│ resolve req-author via express_sid
|
||||||
|
│ load pad → firstAuthor = pool position 0
|
||||||
|
│ if req-author === firstAuthor
|
||||||
|
│ return {outdated:'minor',isFirstAuthor:true}
|
||||||
|
│ else
|
||||||
|
│ return {outdated:null,isFirstAuthor:false}
|
||||||
|
│
|
||||||
|
├─ outdated:'minor' && isFirstAuthor
|
||||||
|
│ → $.gritter.add({class_name:'outdated-notice', position:'bottom',
|
||||||
|
│ sticky:false, time:8000, title, text})
|
||||||
|
└─ else
|
||||||
|
→ no-op
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint shape collapses to a single enum (`'minor' | null`) plus a per-request `isFirstAuthor` boolean. The server never returns a positive `outdated` value to a non-first-author requester — there is no client-side "the answer is minor, but show it conditionally" path. Operational signal does not leak to ordinary pad visitors.
|
||||||
|
|
||||||
|
## Server changes
|
||||||
|
|
||||||
|
### `src/node/updater/versionCompare.ts`
|
||||||
|
|
||||||
|
- **Add** `isMinorOrMoreBehind(current: string, latest: string): boolean` — `true` iff `parseSemver(current).major < parseSemver(latest).major`, or majors equal and `current.minor < latest.minor`. Patch-only delta returns `false`. Returns `false` on parse failure of either side.
|
||||||
|
- **Delete** `isMajorBehind`, `isVulnerable`, `parseVulnerableBelow`, the `VULN_RE` regex, and the `VulnerableBelowDirective` import.
|
||||||
|
|
||||||
|
### `src/node/updater/types.ts`
|
||||||
|
|
||||||
|
- **Delete** `VulnerableBelowDirective`.
|
||||||
|
- **Delete** `UpdaterState.vulnerableBelow` field.
|
||||||
|
|
||||||
|
### `src/node/updater/state.ts`
|
||||||
|
|
||||||
|
- Stop reading and stop writing `vulnerableBelow`. Existing state files with the field still parse — the loader ignores unknown keys. No migration needed; the field naturally drops on next write.
|
||||||
|
|
||||||
|
### `src/node/updater/VersionChecker.ts`
|
||||||
|
|
||||||
|
- Remove the release-notes scraping that called `parseVulnerableBelow`. The rest of the check (current vs latest tag) is unchanged.
|
||||||
|
|
||||||
|
### `src/node/hooks/express/updateStatus.ts` (load-bearing change)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface OutdatedResponse {
|
||||||
|
outdated: 'minor' | null;
|
||||||
|
isFirstAuthor: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY: OutdatedResponse = {outdated: null, isFirstAuthor: false};
|
||||||
|
|
||||||
|
const cache = new LRU<string, {value: OutdatedResponse; at: number}>(1000);
|
||||||
|
const inFlight = new Map<string, Promise<OutdatedResponse>>();
|
||||||
|
const TTL_MS = 60 * 1000;
|
||||||
|
|
||||||
|
const firstAuthorOf = (pad: Pad): string | null => {
|
||||||
|
const num2attrib = pad.pool.numToAttrib;
|
||||||
|
const keys = Object.keys(num2attrib).map(Number).sort((a, b) => a - b);
|
||||||
|
for (const k of keys) {
|
||||||
|
const a = num2attrib[k];
|
||||||
|
if (a && a[0] === 'author' && typeof a[1] === 'string' && a[1] !== '') return a[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const computeOutdated = async (padId: string | null, authorId: string | null): Promise<OutdatedResponse> => {
|
||||||
|
const state = await loadState(stateFilePath());
|
||||||
|
if (!state.latest) return EMPTY;
|
||||||
|
const current = getEpVersion();
|
||||||
|
if (!isMinorOrMoreBehind(current, state.latest.version)) return EMPTY;
|
||||||
|
if (!padId || !authorId) return EMPTY;
|
||||||
|
if (!(await padManager.doesPadExist(padId))) return EMPTY;
|
||||||
|
const pad = await padManager.getPad(padId, null);
|
||||||
|
if (firstAuthorOf(pad) !== authorId) return EMPTY;
|
||||||
|
return {outdated: 'minor', isFirstAuthor: true};
|
||||||
|
};
|
||||||
|
|
||||||
|
app.get('/api/version-status', wrapAsync(async (req, res) => {
|
||||||
|
const padId = typeof req.query.padId === 'string' ? req.query.padId : null;
|
||||||
|
const authorId = await resolveRequestAuthor(req); // express_sid → session → author, null on miss
|
||||||
|
const key = `${padId ?? ''}|${authorId ?? ''}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit && now - hit.at <= TTL_MS) {
|
||||||
|
res.json(hit.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let flight = inFlight.get(key);
|
||||||
|
if (!flight) {
|
||||||
|
flight = computeOutdated(padId, authorId).finally(() => inFlight.delete(key));
|
||||||
|
inFlight.set(key, flight);
|
||||||
|
}
|
||||||
|
const value = await flight;
|
||||||
|
cache.set(key, {value, at: now});
|
||||||
|
res.json(value);
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
- `resolveRequestAuthor(req)` is a small helper that reads `req.cookies.express_sid`, calls `sessionStore.get(sid)` (the same store used by the express-session middleware), and returns `session?.user?.author ?? null`. On any failure path it returns `null` — the request is then treated as anonymous and gets `EMPTY`.
|
||||||
|
- `padId` is validated through `padutils.validateRequest({padID: padId})` before being passed to `padManager`. Validation failures map to `EMPTY`, not 400 — keeping the endpoint quiet about whether the pad exists.
|
||||||
|
- LRU cap of 1000 entries bounds memory on busy servers; entries expire by TTL anyway.
|
||||||
|
- Single-flight per cache key collapses bursts at expiry into one disk read.
|
||||||
|
- `_resetBadgeCacheForTests()` clears both `cache` and `inFlight`.
|
||||||
|
|
||||||
|
### `src/node/hooks/express/openapi-admin.ts`
|
||||||
|
|
||||||
|
- Update the OpenAPI doc for `/api/version-status`:
|
||||||
|
- Add `padId` query parameter (string, optional, must match Etherpad's pad-id format).
|
||||||
|
- Update response schema: `{outdated: 'minor' | null, isFirstAuthor: boolean}`.
|
||||||
|
- Drop the `severe` and `vulnerable` enum values.
|
||||||
|
|
||||||
|
## Client changes
|
||||||
|
|
||||||
|
### `src/templates/pad.html`
|
||||||
|
|
||||||
|
- Delete line 648 (`<div id="version-badge" role="status" aria-live="polite" style="display:none"></div>`).
|
||||||
|
|
||||||
|
### `src/static/css/pad.css`
|
||||||
|
|
||||||
|
- Delete the `#version-badge { … }` rule block (lines ~119–131). Gritter's stock styling carries the notice; no new CSS is added — matches `.privacy-notice` precedent.
|
||||||
|
|
||||||
|
### `src/static/js/pad_version_badge.ts` → renamed to `pad_outdated_notice.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
interface OutdatedResponse {
|
||||||
|
outdated: 'minor' | null;
|
||||||
|
isFirstAuthor: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiBasePath = (): string => {
|
||||||
|
if (typeof window === 'undefined') return '/';
|
||||||
|
return new URL('..', window.location.href).pathname;
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentPadId = (): string | null => {
|
||||||
|
const id = (window as any).clientVars?.padId;
|
||||||
|
return typeof id === 'string' && id.length > 0 ? id : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const maybeShowOutdatedNotice = async (): Promise<void> => {
|
||||||
|
const padId = currentPadId();
|
||||||
|
if (!padId) return;
|
||||||
|
const $ = (window as any).$;
|
||||||
|
if (!$ || !$.gritter || typeof $.gritter.add !== 'function') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${apiBasePath()}api/version-status?padId=${encodeURIComponent(padId)}`;
|
||||||
|
const res = await fetch(url, {credentials: 'same-origin'});
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as OutdatedResponse;
|
||||||
|
if (data.outdated !== 'minor' || !data.isFirstAuthor) return;
|
||||||
|
|
||||||
|
// TODO(i18n): switch to html10n once `pad.outdatedNotice.*` keys land.
|
||||||
|
$.gritter.add({
|
||||||
|
title: 'Etherpad update available',
|
||||||
|
text: 'A newer version of Etherpad has been released. Consider updating this server.',
|
||||||
|
sticky: false,
|
||||||
|
position: 'bottom',
|
||||||
|
class_name: 'outdated-notice',
|
||||||
|
time: 8000,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* never block pad load */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- Module no longer self-bootstraps on `DOMContentLoaded`; it needs `clientVars.padId`, which is only present after `CLIENT_VARS` arrives.
|
||||||
|
- Invocation site: `src/static/js/pad.ts`, in the same post-`handleClientVars` block where `showPrivacyBannerIfEnabled` is called.
|
||||||
|
- No `localStorage` write — dismissal is per-session (gritter X-click clears DOM; reload re-fetches and re-shows if still outdated).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### Backend — `src/tests/backend/specs/api/updateStatus.spec.ts` (rewrite affected blocks)
|
||||||
|
|
||||||
|
- Drop `describe('vulnerable …')` cases entirely.
|
||||||
|
- Replace `describe('severe / isMajorBehind …')` with `describe('isMinorOrMoreBehind …')` covering:
|
||||||
|
- patch-only delta returns `false` (2.7.3 vs 2.7.4)
|
||||||
|
- minor delta returns `true` (2.7.3 vs 2.8.0)
|
||||||
|
- major delta returns `true` (2.7.3 vs 3.0.0)
|
||||||
|
- equal versions return `false`
|
||||||
|
- current newer than latest returns `false` (develop-on-bumped-package.json case)
|
||||||
|
- unparseable input on either side returns `false`
|
||||||
|
- New `describe('GET /api/version-status')` cases:
|
||||||
|
- no `state.latest` → `{outdated:null,isFirstAuthor:false}`
|
||||||
|
- current ≥ latest, with valid padId+author → `EMPTY`
|
||||||
|
- padId omitted → `EMPTY` (no leak)
|
||||||
|
- authorId resolves but isn't pool position 0 → `EMPTY`
|
||||||
|
- current is minor-behind AND requester is pool position 0 → `{outdated:'minor',isFirstAuthor:true}`
|
||||||
|
- current is patch-behind, requester IS pool position 0 → `EMPTY`
|
||||||
|
- cache hit within 60s for same `padId|authorId` does NOT re-call `loadState` (spy assertion)
|
||||||
|
- two different `padId|authorId` pairs are cached independently
|
||||||
|
- with the LRU cap forced low (test-only setter), the oldest entry is evicted first
|
||||||
|
|
||||||
|
Each case calls `_resetBadgeCacheForTests()` in `beforeEach`.
|
||||||
|
|
||||||
|
### Backend — `firstAuthorOf` unit test (new file next to the helper)
|
||||||
|
|
||||||
|
- empty pad → `null`
|
||||||
|
- single-author pad → that author
|
||||||
|
- A edited first then B → A
|
||||||
|
- pool with non-author attribs interleaved at low numeric keys → still returns the lowest `['author', X]`
|
||||||
|
- pool with `['author', '']` placeholder → skipped; returns the next real author
|
||||||
|
|
||||||
|
### Frontend — `src/tests/frontend-new/specs/outdated_notice.spec.ts` (new, mirrors `privacy_banner.spec.ts`)
|
||||||
|
|
||||||
|
- stub `/api/version-status` to `{outdated:null,…}` → no `.gritter-item.outdated-notice` after pad load
|
||||||
|
- stub to `{outdated:'minor', isFirstAuthor:false}` → no gritter (client belt-and-braces guard)
|
||||||
|
- stub to `{outdated:'minor', isFirstAuthor:true}` → `.gritter-item.outdated-notice` appears, body text matches, dismisses on X-click
|
||||||
|
- stub returning 500 → no DOM injection, no user-visible console error
|
||||||
|
- after ~9s with positive stub → gritter auto-faded (asserts `sticky:false` + `time:8000` wiring)
|
||||||
|
|
||||||
|
### Files removed entirely
|
||||||
|
|
||||||
|
- Any standalone `versionBadge.spec.ts` fixture file (merged into `updateStatus.spec.ts`).
|
||||||
|
- Any fixture referencing `vulnerableBelow`.
|
||||||
|
|
||||||
|
### Verification gates (mandatory before claiming done)
|
||||||
|
|
||||||
|
- `pnpm --filter ep_etherpad-lite test:vitest` clean (backend).
|
||||||
|
- `pnpm exec playwright test outdated_notice` clean under `xvfb-run` (frontend).
|
||||||
|
- Manual: load a pad on the dev server (`http://localhost.lan:9003/p/test`) with `var/update.state.json` pinned to a higher `latest.version` — gritter appears once for first-author in incognito-A, absent in incognito-B (second visitor).
|
||||||
|
|
||||||
|
## Docs / settings / build
|
||||||
|
|
||||||
|
- `doc/api/http_api.md` (and `.adoc` if present) — update `/api/version-status` entry: new shape, new `padId` query param, note that positive results are scoped to first-author.
|
||||||
|
- `doc/api/updater.md` (or the relevant `updates.tier` section in `doc/settings.md`) — drop the paragraph(s) on the vulnerable-below directive and the persistent banner UI.
|
||||||
|
- `CHANGELOG.md` (Unreleased) — one entry: "Outdated-version notice redesigned per #7799 — transient gritter, first-author only, minor-or-major behind only. The persistent banner, `severe` enum, and `vulnerable-below` directive scraping are removed."
|
||||||
|
- No settings-schema changes. `updates.tier = 'off'` remains the full kill-switch.
|
||||||
|
- `vite.config.ts` (and any other bundle config) — rename `pad_version_badge` entries to `pad_outdated_notice`. Grep to confirm no admin-bundle reference exists (shouldn't; pad-only).
|
||||||
|
|
||||||
|
## Risk / open questions
|
||||||
|
|
||||||
|
- **Develop-on-stale-package.json.** Today develop's `package.json` reads `2.7.3` while the latest npm release is newer. Under this design, the notice still triggers on develop because `current < latest`. The expected operational practice is for the post-release bump of develop's `package.json` to a higher pre-release identifier to short-circuit this naturally. Documented in the CHANGELOG entry. If maintainers want belt-and-braces, a follow-up can add a `.git`-presence short-circuit, but that is explicitly out-of-scope here per the design decision.
|
||||||
|
- **First-author churn on imported pads.** If a pad was created via `setText`/API by an admin script using a service-account author, the first-author signal points at that service account. Operationally fine — the notice just won't fire for anyone. Acceptable.
|
||||||
|
- **Anonymous browsers without express_sid.** First load of a pad with no prior session has no `express_sid` cookie until `socket.io` connects. The version-status request fires after `CLIENT_VARS`, which is after the socket handshake, so by then the cookie exists. If for any reason it doesn't, `resolveRequestAuthor` returns `null` and the response is `EMPTY` — fail-quiet.
|
||||||
|
|
@ -0,0 +1,154 @@
|
||||||
|
# Downstream Client Compatibility Tests — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Status:** Approved (brainstorming complete)
|
||||||
|
**Target repo:** `ether/etherpad` (core), branch `develop`
|
||||||
|
**Downstream repos in scope:** `etherpad-pad` (Rust terminal editor + `etherpad-client` crate), `etherpad-cli-client` (Node/TS CLI), `etherpad-desktop` (Electron desktop + Capacitor mobile)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The three downstream clients live in **separate repos** and consume core's wire
|
||||||
|
protocols rather than importing core as a library:
|
||||||
|
|
||||||
|
- **etherpad-cli-client** ships its *own copies* of `Changeset.ts` + `AttributePool.ts`
|
||||||
|
and talks the socket.io `message` protocol. No test script today.
|
||||||
|
- **etherpad-pad** (Rust) hand-rolls engine.io v4 / socket.io v4 + changeset
|
||||||
|
decoding in its `etherpad-client` crate. Has CI + a `mock-socket` test feature.
|
||||||
|
- **etherpad-desktop** wraps / points at a core server URL. Has vitest + Playwright e2e + CI.
|
||||||
|
|
||||||
|
A core PR can change the **HTTP API**, the **socket.io handshake / `message`
|
||||||
|
sequence**, or the **changeset / attribpool wire format** and break these clients
|
||||||
|
**silently**, because their CI never runs against the new core. The goal: a PR
|
||||||
|
against core `develop` (and friends) must detect downstream breakage before merge.
|
||||||
|
|
||||||
|
## Decisions (locked during brainstorming)
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Detection strategy | **Hybrid**: fast contract tests in core (every PR) + downstream smoke (every PR) |
|
||||||
|
| Smoke cadence | **Every PR** (with strong flakiness mitigations) |
|
||||||
|
| How CI gets clients | **Git clone at pinned refs**, recorded in a manifest in core |
|
||||||
|
| Contract depth | **Shared golden vectors** — core generates canonical fixtures, each client decodes the same fixtures with its own decoder |
|
||||||
|
| Sequencing | **Phase by layer** — Phase 1 all core-side; Phase 2 one client repo at a time |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Two-layer compatibility gate on every core PR:
|
||||||
|
|
||||||
|
```
|
||||||
|
core PR ──┬─► Layer A: Contract tests (hermetic, fast, no network/clients)
|
||||||
|
│ • golden-vector assertions (changeset/attribpool roundtrip)
|
||||||
|
│ • socket.io message-sequence test (CLIENT_READY → CLIENT_VARS,
|
||||||
|
│ USER_CHANGES → ACCEPT_COMMIT / NEW_CHANGES)
|
||||||
|
│ • HTTP API shape snapshots
|
||||||
|
│
|
||||||
|
└─► Layer B: Downstream smoke (boots a real server, runs real clients)
|
||||||
|
• build + boot Etherpad from the PR on :9003 with a known API key
|
||||||
|
• healthcheck-poll until ready
|
||||||
|
• matrix over manifest: clone client @ pinned ref, set up toolchain,
|
||||||
|
inject core's freshly-generated vectors, run client `test:vectors`,
|
||||||
|
run client smoke: connect → create/open pad → write text →
|
||||||
|
read back via HTTP API → assert equality
|
||||||
|
• tear down server by PID
|
||||||
|
```
|
||||||
|
|
||||||
|
## Layer A — Contract tests (Phase 1, core)
|
||||||
|
|
||||||
|
### Golden vectors
|
||||||
|
- Generator script: `src/tests/downstream/generate-vectors.ts` (run via a package
|
||||||
|
script, e.g. `pnpm run vectors:gen`).
|
||||||
|
- Output fixture: `src/tests/fixtures/wire-vectors.json`. Each record:
|
||||||
|
`{ name, initialAText, changeset, pool, resultAText }` covering the operation
|
||||||
|
classes clients must decode: plain insert, delete, format/attrib op,
|
||||||
|
multi-line insert (char_bank ending in `\n`), attribpool reuse across ops.
|
||||||
|
- Core test `src/tests/backend/specs/wire-vectors.ts`: regenerate in-memory and
|
||||||
|
assert it matches the committed fixture exactly (drift requires a deliberate
|
||||||
|
commit, which is the signal a wire change happened).
|
||||||
|
|
||||||
|
### Socket message-sequence test
|
||||||
|
- `src/tests/backend/specs/wire-socket-sequence.ts`: drive a socket.io client
|
||||||
|
against the in-process server, assert the handshake message sequence and the
|
||||||
|
shape of `CLIENT_VARS`, `USER_CHANGES` → `ACCEPT_COMMIT`, and broadcast
|
||||||
|
`NEW_CHANGES`. Reuses the existing backend socket test helpers.
|
||||||
|
|
||||||
|
### HTTP API shape snapshots
|
||||||
|
- `src/tests/backend/specs/wire-http-api.ts`: snapshot the response *shapes*
|
||||||
|
(keys / types, not volatile values) of the API endpoints clients call
|
||||||
|
(`createPad`, `setText`, `getText`, `getRevisionsCount`, session/auth as needed).
|
||||||
|
|
||||||
|
These join the existing `backend-tests.yml` run — no new per-PR job, no new infra.
|
||||||
|
|
||||||
|
## Layer B — Downstream smoke (Phase 1 scaffold, Phase 2 per client)
|
||||||
|
|
||||||
|
### Manifest
|
||||||
|
`src/tests/downstream/clients.json`:
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "name": "etherpad-pad", "repo": "https://github.com/ether/pad.git", "ref": "<sha on main>", "kind": "rust", "smokeCmd": "..." },
|
||||||
|
{ "name": "etherpad-cli-client", "repo": "https://github.com/ether/etherpad-cli-client.git", "ref": "<sha on main>", "kind": "node", "smokeCmd": "..." },
|
||||||
|
{ "name": "etherpad-desktop", "repo": "https://github.com/ether/etherpad-desktop.git", "ref": "<sha on main>", "kind": "desktop", "smokeCmd": "..." }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
Refs are pinned to a specific commit SHA (not `main`) so a client's own pushes
|
||||||
|
cannot redden core CI; bumping a ref is a deliberate PR. Current `main` HEADs at
|
||||||
|
authoring time: pad `31176d6`, cli-client `edbe0bb`, desktop `ad273c1`.
|
||||||
|
Pinned refs mean a client's *own* breakage never randomly reddens core; picking up
|
||||||
|
a client fix is a deliberate ref-bump PR. Clients are added to the manifest as
|
||||||
|
their Phase-2 smoke lands — the workflow only runs what's registered.
|
||||||
|
|
||||||
|
### Workflow
|
||||||
|
`.github/workflows/downstream-smoke.yml` (triggers: `pull_request` + nightly
|
||||||
|
`schedule` against `develop`):
|
||||||
|
1. Build core from the PR, install deps.
|
||||||
|
2. Boot Etherpad on **:9003** with a known `APIKEY` in the background; record PID.
|
||||||
|
3. Healthcheck-poll the server (bounded timeout) before proceeding.
|
||||||
|
4. Matrix over manifest entries: clone @ pinned ref → set up toolchain
|
||||||
|
(node+pnpm / rust / electron+xvfb) → copy core's freshly-generated
|
||||||
|
`wire-vectors.json` into the client → run `test:vectors` → run smoke.
|
||||||
|
5. Tear down: kill the recorded **PID** (never `pkill -f`).
|
||||||
|
|
||||||
|
### Per-client smoke (Phase 2)
|
||||||
|
Minimal roundtrip exercising the real protocol end-to-end:
|
||||||
|
- **etherpad-pad** (`rust`): integration test gated by `ETHERPAD_SMOKE_URL`, using
|
||||||
|
the real tungstenite socket — connect, open pad, send a changeset, read back
|
||||||
|
via HTTP `getText`, assert. Plus `cargo test` vector consumer reading the
|
||||||
|
injected fixture.
|
||||||
|
- **etherpad-cli-client** (`node`): add a minimal test runner (none today —
|
||||||
|
`node:test` or vitest), a `test:vectors` decoding the fixture, and a smoke
|
||||||
|
using the client lib: connect → write → verify via HTTP `getText`.
|
||||||
|
- **etherpad-desktop** (`desktop`): **headless-light** vitest smoke that points
|
||||||
|
the shell/webview at the booted URL and roundtrips. The full Electron e2e stays
|
||||||
|
in desktop's own CI — it is **not** in the core gate. If Electron is
|
||||||
|
unavoidable here, run under `xvfb-run`.
|
||||||
|
|
||||||
|
## Flakiness mitigations (because smoke runs on every PR)
|
||||||
|
|
||||||
|
- Healthcheck-poll-with-timeout before any client runs.
|
||||||
|
- Bounded timeout + 1 retry per client smoke.
|
||||||
|
- Desktop kept headless-light; heavy Electron e2e excluded from the gate.
|
||||||
|
- PID-based teardown, never `pkill -f` (would kill the developer's other servers).
|
||||||
|
- Pinned manifest refs isolate core CI from clients' own breakage.
|
||||||
|
- Tests that bind a port use **:9003** (9001 is reserved for ad-hoc local use).
|
||||||
|
|
||||||
|
## Phasing
|
||||||
|
|
||||||
|
- **Phase 1 (core only, lands first, immediately useful):** vector generator +
|
||||||
|
`wire-vectors.json` + three contract specs + `downstream-smoke.yml` +
|
||||||
|
`clients.json` manifest. Harness proven with **one** reference client wired in
|
||||||
|
(the Rust `etherpad-pad`, which already has test infra).
|
||||||
|
- **Phase 2 (one client repo at a time):** order `etherpad-pad` →
|
||||||
|
`etherpad-cli-client` → `etherpad-desktop`. Each PR adds that client's
|
||||||
|
`test:vectors` + smoke and registers it in the core manifest.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Replacing clients' existing full e2e suites (they stay in their own repos).
|
||||||
|
- ep_kaput (excluded from all sweeps per standing instruction).
|
||||||
|
- Changing the wire protocol itself — this work only *observes* it.
|
||||||
|
|
||||||
|
## Success criteria
|
||||||
|
|
||||||
|
- A core PR that alters changeset serialization, the socket message sequence, or a
|
||||||
|
client-facing API shape fails Layer A (contract) and/or Layer B (smoke) before merge.
|
||||||
|
- Phase 1 lands green on core `develop` with the Rust client wired into the smoke matrix.
|
||||||
|
- Bumping a client's pinned ref is the only way a client's own changes affect core CI.
|
||||||
|
|
@ -45,13 +45,13 @@
|
||||||
"node": ">=24.0.0",
|
"node": ">=24.0.0",
|
||||||
"pnpm": ">=11.1.2"
|
"pnpm": ">=11.1.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.1.2",
|
"packageManager": "pnpm@11.10.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/ether/etherpad.git"
|
"url": "https://github.com/ether/etherpad.git"
|
||||||
},
|
},
|
||||||
"engineStrict": true,
|
"engineStrict": true,
|
||||||
"version": "3.1.0",
|
"version": "3.3.2",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|
|
||||||
4698
pnpm-lock.yaml
generated
4698
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -21,20 +21,38 @@ strictDepBuilds: false
|
||||||
# As of pnpm 11, overrides must live here (root package.json's pnpm.overrides
|
# 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.
|
# is no longer read). Force-bump transitive deps with known CVEs.
|
||||||
overrides:
|
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'
|
brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3'
|
||||||
diff@>=6.0.0 <8.0.3: '>=8.0.3'
|
diff@>=6.0.0 <8.0.3: '>=8.0.3'
|
||||||
|
esbuild@<0.28.1: '>=0.28.1'
|
||||||
flatted: '>=3.4.2'
|
flatted: '>=3.4.2'
|
||||||
follow-redirects: '>=1.16.0'
|
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'
|
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'
|
lodash: '>=4.18.0'
|
||||||
minimatch@>=9.0.0 <9.0.7: '>=9.0.7'
|
minimatch@>=9.0.0 <9.0.7: '>=9.0.7'
|
||||||
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
||||||
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
|
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'
|
serialize-javascript@<7.0.5: '>=7.0.5'
|
||||||
socket.io-parser@>=4.0.0 <4.2.6: '>=4.2.6'
|
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'
|
uuid@<14.0.0: '>=14.0.0'
|
||||||
vite@>=7.0.0 <7.3.2: '>=7.3.2'
|
vite@>=7.0.0 <7.3.2: '>=7.3.2'
|
||||||
|
ws@>=8.0.0 <8.21.0: '>=8.21.0'
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- '@radix-ui/primitive@1.1.5'
|
||||||
|
- '@radix-ui/react-collection@1.1.12'
|
||||||
|
- '@radix-ui/react-context@1.2.0'
|
||||||
|
- '@radix-ui/react-dialog@1.1.19'
|
||||||
|
- '@radix-ui/react-dismissable-layer@1.1.15'
|
||||||
|
- '@radix-ui/react-focus-scope@1.1.12'
|
||||||
|
- '@radix-ui/react-presence@1.1.7'
|
||||||
|
- '@radix-ui/react-toast@1.2.19'
|
||||||
|
- mysql2@3.22.6
|
||||||
|
- oidc-provider@9.9.0
|
||||||
|
- sql-escaper@1.4.0
|
||||||
|
|
|
||||||
|
|
@ -210,15 +210,17 @@
|
||||||
* tier: "off" | "notify" | "manual" | "auto" | "autonomous"
|
* tier: "off" | "notify" | "manual" | "auto" | "autonomous"
|
||||||
* Default "notify" shows a banner when an update is available.
|
* Default "notify" shows a banner when an update is available.
|
||||||
* Docker installs are read-only — tiers above "notify" are not applied even if requested.
|
* 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": {
|
"updates": {
|
||||||
"tier": "notify",
|
"tier": "${UPDATES_TIER:notify}",
|
||||||
"source": "github",
|
"source": "${UPDATES_SOURCE:github}",
|
||||||
"channel": "stable",
|
"channel": "${UPDATES_CHANNEL:stable}",
|
||||||
"installMethod": "docker",
|
"installMethod": "docker",
|
||||||
"checkIntervalHours": 6,
|
"checkIntervalHours": "${UPDATES_CHECK_INTERVAL_HOURS:6}",
|
||||||
"githubRepo": "ether/etherpad",
|
"githubRepo": "${UPDATES_GITHUB_REPO:ether/etherpad}",
|
||||||
"requireAdminForStatus": false,
|
"requireAdminForStatus": "${UPDATES_REQUIRE_ADMIN_FOR_STATUS:false}",
|
||||||
"preApplyGraceMinutes": 0,
|
"preApplyGraceMinutes": 0,
|
||||||
"drainSeconds": 60,
|
"drainSeconds": 60,
|
||||||
"rollbackHealthCheckSeconds": 60,
|
"rollbackHealthCheckSeconds": 60,
|
||||||
|
|
@ -321,7 +323,19 @@
|
||||||
* https://etherpad.org/ep_infos
|
* 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.
|
* The type of the database.
|
||||||
|
|
|
||||||
|
|
@ -216,7 +216,7 @@
|
||||||
* Default "notify" shows a banner when an update is available. "off" disables the version check.
|
* Default "notify" shows a banner when an update is available. "off" disables the version check.
|
||||||
*/
|
*/
|
||||||
"updates": {
|
"updates": {
|
||||||
"tier": "notify",
|
"tier": "${UPDATES_TIER:notify}",
|
||||||
"source": "github",
|
"source": "github",
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"installMethod": "auto",
|
"installMethod": "auto",
|
||||||
|
|
@ -443,17 +443,19 @@
|
||||||
* https://etherpad.org/ep_infos
|
* 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.
|
* Outbound network calls. See PRIVACY.md for what each one sends.
|
||||||
* - updateCheck=false : disables hourly version check (UpdateCheck.ts)
|
* - updateCheck=false : disables hourly version check (UpdateCheck.ts)
|
||||||
* - pluginCatalog=false: disables admin plugin browser
|
* - pluginCatalog=false: disables admin plugin browser
|
||||||
* (manual install-by-name via CLI still works)
|
* (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": {
|
"privacy": {
|
||||||
"updateCheck": true,
|
"updateCheck": "${PRIVACY_UPDATE_CHECK:true}",
|
||||||
"pluginCatalog": true
|
"pluginCatalog": "${PRIVACY_PLUGIN_CATALOG:true}"
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -535,6 +537,15 @@
|
||||||
*
|
*
|
||||||
* The other effect will be that the logs will contain the real client's IP,
|
* The other effect will be that the logs will contain the real client's IP,
|
||||||
* instead of the reverse proxy's IP.
|
* instead of the reverse proxy's IP.
|
||||||
|
*
|
||||||
|
* Setting this to `true` also makes Etherpad honor two standard URL-path-
|
||||||
|
* prefix headers from upstream proxies:
|
||||||
|
* - `X-Forwarded-Prefix` (HAProxy / Traefik convention)
|
||||||
|
* - `X-Ingress-Path` (Home Assistant supervisor ingress)
|
||||||
|
*
|
||||||
|
* Both are sanitised before use. Etherpad's own `x-proxy-path` header is
|
||||||
|
* honored regardless of this setting; the operator is presumed to have
|
||||||
|
* configured their proxy intentionally when sending the custom header.
|
||||||
*/
|
*/
|
||||||
"trustProxy": false,
|
"trustProxy": false,
|
||||||
|
|
||||||
|
|
@ -625,6 +636,12 @@
|
||||||
/*
|
/*
|
||||||
* Whether to periodically clean up expired and stale sessions from the
|
* Whether to periodically clean up expired and stale sessions from the
|
||||||
* database. Set to false to disable. Default: true.
|
* database. Set to false to disable. Default: true.
|
||||||
|
*
|
||||||
|
* Cleanup runs hourly and walks the sessionstorage:* keyspace in 500-key
|
||||||
|
* pages so very large session tables don't pin the keys in memory all at
|
||||||
|
* once (see https://github.com/ether/etherpad/issues/7830). A single run
|
||||||
|
* is capped at 10 minutes; if your DB is so large the budget is reached,
|
||||||
|
* a warning is logged and the next scheduled run continues.
|
||||||
*/
|
*/
|
||||||
"sessionCleanup": true
|
"sessionCleanup": true
|
||||||
},
|
},
|
||||||
|
|
@ -838,11 +855,11 @@
|
||||||
* accepting pad-wide values under plugin-namespaced keys matching
|
* accepting pad-wide values under plugin-namespaced keys matching
|
||||||
* /^ep_[a-z0-9_]+$/ (e.g. ep_table_of_contents). Values are validated
|
* /^ep_[a-z0-9_]+$/ (e.g. ep_table_of_contents). Values are validated
|
||||||
* (JSON-safe, 64 KB per key, 256 KB total) and broadcast to every
|
* (JSON-safe, 64 KB per key, 256 KB total) and broadcast to every
|
||||||
* connected client just like native pad-wide toggles. Disabled by
|
* connected client just like native pad-wide toggles. Enabled by
|
||||||
* default; flip to true once your plugins (e.g. ep_plugin_helpers'
|
* default; set to false to lock plugins out of pad-wide state. See
|
||||||
* padToggle) require it. See doc/plugins.md.
|
* doc/plugins.md.
|
||||||
**/
|
**/
|
||||||
"enablePluginPadOptions": "${ENABLE_PLUGIN_PAD_OPTIONS:false}",
|
"enablePluginPadOptions": "${ENABLE_PLUGIN_PAD_OPTIONS:true}",
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Optional privacy banner shown once the pad loads. Disabled by default.
|
* Optional privacy banner shown once the pad loads. Disabled by default.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"Haytham morsy",
|
"Haytham morsy",
|
||||||
"Meno25",
|
"Meno25",
|
||||||
"Mido",
|
"Mido",
|
||||||
|
"Mohammed Qays",
|
||||||
"Shbib Al-Subaie",
|
"Shbib Al-Subaie",
|
||||||
"Tala Ali",
|
"Tala Ali",
|
||||||
"Test Create account",
|
"Test Create account",
|
||||||
|
|
@ -57,7 +58,7 @@
|
||||||
"index.transferSessionNow": "نقل الجلسة الآن",
|
"index.transferSessionNow": "نقل الجلسة الآن",
|
||||||
"index.copyLink": "2. نسخ الرابط",
|
"index.copyLink": "2. نسخ الرابط",
|
||||||
"index.copyLinkDescription": "انقر على الزر أدناه لنسخ الرابط إلى الحافظة الخاصة بك.",
|
"index.copyLinkDescription": "انقر على الزر أدناه لنسخ الرابط إلى الحافظة الخاصة بك.",
|
||||||
"index.copyLinkButton": "نسخ الرابط إلى الحافظة",
|
"index.copyLinkButton": "نُسخت الوصلة إلى الحافظة",
|
||||||
"index.transferToSystem": "3. نسخ الجلسة إلى النظام الجديد",
|
"index.transferToSystem": "3. نسخ الجلسة إلى النظام الجديد",
|
||||||
"index.transferToSystemDescription": "افتح الرابط المنسوخ في المتصفح أو الجهاز المستهدف لنقل جلستك.",
|
"index.transferToSystemDescription": "افتح الرابط المنسوخ في المتصفح أو الجهاز المستهدف لنقل جلستك.",
|
||||||
"index.transferSessionDescription": "انقل جلستك الحالية إلى المتصفح أو الجهاز بالنقر على الزر أدناه. سيؤدي هذا إلى نسخ رابط لصفحة ستنقل جلستك عند فتحها في المتصفح أو الجهاز المستهدف.",
|
"index.transferSessionDescription": "انقل جلستك الحالية إلى المتصفح أو الجهاز بالنقر على الزر أدناه. سيؤدي هذا إلى نسخ رابط لصفحة ستنقل جلستك عند فتحها في المتصفح أو الجهاز المستهدف.",
|
||||||
|
|
@ -123,7 +124,7 @@
|
||||||
"pad.importExport.exportword": "مايكروسوفت وورد",
|
"pad.importExport.exportword": "مايكروسوفت وورد",
|
||||||
"pad.importExport.exportpdf": "صيغة المستندات المحمولة",
|
"pad.importExport.exportpdf": "صيغة المستندات المحمولة",
|
||||||
"pad.importExport.exportopen": "ODF (نسق المستند المفتوح)",
|
"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.connected": "متصل.",
|
||||||
"pad.modals.reconnecting": "إعادة الاتصال ببادك..",
|
"pad.modals.reconnecting": "إعادة الاتصال ببادك..",
|
||||||
"pad.modals.forcereconnect": "فرض إعادة الاتصال",
|
"pad.modals.forcereconnect": "فرض إعادة الاتصال",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"admin.page-title": "Адміністрацыйная панэль — Etherpad",
|
"admin.page-title": "Адміністрацыйная панэль — Etherpad",
|
||||||
|
"admin.loading": "Ладаваньне...",
|
||||||
"admin_plugins": "Кіраўнік плагінаў",
|
"admin_plugins": "Кіраўнік плагінаў",
|
||||||
"admin_plugins.available": "Даступныя плагіны",
|
"admin_plugins.available": "Даступныя плагіны",
|
||||||
"admin_plugins.available_not-found": "Плагіны ня знойдзеныя.",
|
"admin_plugins.available_not-found": "Плагіны ня знойдзеныя.",
|
||||||
|
|
@ -115,7 +116,7 @@
|
||||||
"pad.modals.looping.explanation": "Праблемы далучэньня да сэрвэра сынхранізацыі.",
|
"pad.modals.looping.explanation": "Праблемы далучэньня да сэрвэра сынхранізацыі.",
|
||||||
"pad.modals.looping.cause": "Магчыма, вы падключыліся празь несумяшчальны брандмаўэр або проксі.",
|
"pad.modals.looping.cause": "Магчыма, вы падключыліся празь несумяшчальны брандмаўэр або проксі.",
|
||||||
"pad.modals.initsocketfail": "Сэрвэр недаступны.",
|
"pad.modals.initsocketfail": "Сэрвэр недаступны.",
|
||||||
"pad.modals.initsocketfail.explanation": "Не атрымалася падлучыцца да сэрвэра сынхранізацыі.",
|
"pad.modals.initsocketfail.explanation": "Не ўдалося падлучыцца да сэрвэра сынхранізацыі.",
|
||||||
"pad.modals.initsocketfail.cause": "Імаверна, гэта зьвязана з праблемамі з вашым браўзэрам або інтэрнэт-злучэньнем.",
|
"pad.modals.initsocketfail.cause": "Імаверна, гэта зьвязана з праблемамі з вашым браўзэрам або інтэрнэт-злучэньнем.",
|
||||||
"pad.modals.slowcommit.explanation": "Сэрвэр не адказвае.",
|
"pad.modals.slowcommit.explanation": "Сэрвэр не адказвае.",
|
||||||
"pad.modals.slowcommit.cause": "Гэта можа быць выклікана праблемамі зь сеткавым падлучэньнем.",
|
"pad.modals.slowcommit.cause": "Гэта можа быць выклікана праблемамі зь сеткавым падлучэньнем.",
|
||||||
|
|
@ -177,9 +178,9 @@
|
||||||
"pad.impexp.importbutton": "Імпартаваць зараз",
|
"pad.impexp.importbutton": "Імпартаваць зараз",
|
||||||
"pad.impexp.importing": "Імпартаваньне…",
|
"pad.impexp.importing": "Імпартаваньне…",
|
||||||
"pad.impexp.confirmimport": "Імпарт файла перазапіша цяперашні тэкст дакумэнту. Вы ўпэўненыя, што хочаце працягваць?",
|
"pad.impexp.confirmimport": "Імпарт файла перазапіша цяперашні тэкст дакумэнту. Вы ўпэўненыя, што хочаце працягваць?",
|
||||||
"pad.impexp.convertFailed": "Не атрымалася імпартаваць гэты файл. Калі ласка, выкарыстайце іншы фармат дакумэнту або скапіюйце ўручную.",
|
"pad.impexp.convertFailed": "Не ўдалося імпартаваць гэты файл. Калі ласка, выкарыстайце іншы фармат дакумэнту або скапіюйце рукамі.",
|
||||||
"pad.impexp.padHasData": "Мы не змаглі імпартаваць гэты файл, бо дакумэнт ужо мае зьмены, калі ласка, імпартуйце ў новы дакумэнт",
|
"pad.impexp.padHasData": "Мы не змаглі імпартаваць гэты файл, бо дакумэнт ужо мае зьмены, калі ласка, імпартуйце ў новы дакумэнт",
|
||||||
"pad.impexp.uploadFailed": "Загрузка не атрымалася, калі ласка, паспрабуйце яшчэ раз",
|
"pad.impexp.uploadFailed": "Загрузка не ўдалася, калі ласка, паспрабуйце яшчэ раз",
|
||||||
"pad.impexp.importfailed": "Памылка імпарту",
|
"pad.impexp.importfailed": "Памылка імпарту",
|
||||||
"pad.impexp.copypaste": "Калі ласка, скапіюйце і ўстаўце",
|
"pad.impexp.copypaste": "Калі ласка, скапіюйце і ўстаўце",
|
||||||
"pad.impexp.exportdisabled": "Экспарт у фармаце {{type}} адключаны. Калі ласка, зьвярніцеся да вашага сыстэмнага адміністратара па падрабязнасьці.",
|
"pad.impexp.exportdisabled": "Экспарт у фармаце {{type}} адключаны. Калі ласка, зьвярніцеся да вашага сыстэмнага адміністратара па падрабязнасьці.",
|
||||||
|
|
|
||||||
|
|
@ -16,13 +16,80 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"admin.page-title": "Ovládací panel Správce - Etherpad",
|
"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": "Správce zásuvných moodulů",
|
||||||
"admin_plugins.available": "Dostupné zásuvné moduly",
|
"admin_plugins.available": "Dostupné zásuvné moduly",
|
||||||
"admin_plugins.available_not-found": "Nejsou žádné zásuvné moduly",
|
"admin_plugins.available_not-found": "Nejsou žádné zásuvné moduly",
|
||||||
"admin_plugins.available_fetching": "Načítání...",
|
"admin_plugins.available_fetching": "Načítání...",
|
||||||
"admin_plugins.available_install.value": "Instalovat",
|
"admin_plugins.available_install.value": "Instalovat",
|
||||||
"admin_plugins.available_search.placeholder": "Vyhledat zásuvné moduly k instalaci",
|
"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.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": "Nainstalované zásuvné moduly",
|
||||||
"admin_plugins.installed_fetching": "Načítání instalovaných zásuvných modulů...",
|
"admin_plugins.installed_fetching": "Načítání instalovaných zásuvných modulů...",
|
||||||
"admin_plugins.installed_nothing": "Dosud jste nenainstalovali žádné zásuvné moduly.",
|
"admin_plugins.installed_nothing": "Dosud jste nenainstalovali žádné zásuvné moduly.",
|
||||||
|
|
@ -30,23 +97,61 @@
|
||||||
"admin_plugins.last-update": "Poslední aktualizace",
|
"admin_plugins.last-update": "Poslední aktualizace",
|
||||||
"admin_plugins.name": "Název",
|
"admin_plugins.name": "Název",
|
||||||
"admin_plugins.page-title": "Správce zásuvných modulů - Etherpad",
|
"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.version": "Verze",
|
||||||
"admin_plugins_info": "Informace o řešení problému",
|
"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": "Instalované hooks",
|
||||||
"admin_plugins_info.hooks_client": "hooks na straně klienta",
|
"admin_plugins_info.hooks_client": "hooks na straně klienta",
|
||||||
"admin_plugins_info.hooks_server": "hooks na straně serveru",
|
"admin_plugins_info.hooks_server": "hooks na straně serveru",
|
||||||
"admin_plugins_info.parts": "Nainstalované součásti",
|
"admin_plugins_info.parts": "Nainstalované součásti",
|
||||||
"admin_plugins_info.plugins": "Nainstalované zásuvné moduly",
|
"admin_plugins_info.plugins": "Nainstalované zásuvné moduly",
|
||||||
"admin_plugins_info.page-title": "Informace o zásuvných modulech - Etherpad",
|
"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": "Verze Etherpad",
|
||||||
"admin_plugins_info.version_latest": "Poslední dostupná verze",
|
"admin_plugins_info.version_latest": "Poslední dostupná verze",
|
||||||
"admin_plugins_info.version_number": "Číslo verze",
|
"admin_plugins_info.version_number": "Číslo verze",
|
||||||
"admin_settings": "Nastavení",
|
"admin_settings": "Nastavení",
|
||||||
|
"admin_settings.create_pad": "Vytvořit pad",
|
||||||
"admin_settings.current": "Aktuální konfugurace",
|
"admin_settings.current": "Aktuální konfugurace",
|
||||||
"admin_settings.current_example-devel": "Příklad ukázkové vývojové šablony",
|
"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_example-prod": "Příklad šablony nastavení výroby",
|
||||||
"admin_settings.current_restart.value": "Restartovat Etherpad",
|
"admin_settings.current_restart.value": "Restartovat Etherpad",
|
||||||
"admin_settings.current_save.value": "Uložit nastavení",
|
"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",
|
"admin_settings.page-title": "Nastavení - Etherpad",
|
||||||
"index.newPad": "Založ nový Pad",
|
"index.newPad": "Založ nový Pad",
|
||||||
"index.settings": "Nastavení",
|
"index.settings": "Nastavení",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"Metalhead64",
|
"Metalhead64",
|
||||||
"Mklehr",
|
"Mklehr",
|
||||||
"Mukeber",
|
"Mukeber",
|
||||||
|
"Nbux",
|
||||||
"Nipsky",
|
"Nipsky",
|
||||||
"Predatorix",
|
"Predatorix",
|
||||||
"SamTV",
|
"SamTV",
|
||||||
|
|
@ -22,13 +23,80 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"admin.page-title": "Admin Dashboard - Etherpad",
|
"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": "Pluginverwaltung",
|
||||||
"admin_plugins.available": "Verfügbare Plugins",
|
"admin_plugins.available": "Verfügbare Plugins",
|
||||||
"admin_plugins.available_not-found": "Keine Plugins gefunden.",
|
"admin_plugins.available_not-found": "Keine Plugins gefunden.",
|
||||||
"admin_plugins.available_fetching": "Wird abgerufen...",
|
"admin_plugins.available_fetching": "Wird abgerufen...",
|
||||||
"admin_plugins.available_install.value": "Installieren",
|
"admin_plugins.available_install.value": "Installieren",
|
||||||
"admin_plugins.available_search.placeholder": "Suche nach Plugins zum 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.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": "Installierte Plugins",
|
||||||
"admin_plugins.installed_fetching": "Rufe installierte Plugins ab...",
|
"admin_plugins.installed_fetching": "Rufe installierte Plugins ab...",
|
||||||
"admin_plugins.installed_nothing": "Du hast bisher noch keine Plugins installiert.",
|
"admin_plugins.installed_nothing": "Du hast bisher noch keine Plugins installiert.",
|
||||||
|
|
@ -36,23 +104,67 @@
|
||||||
"admin_plugins.last-update": "Letze Aktualisierung",
|
"admin_plugins.last-update": "Letze Aktualisierung",
|
||||||
"admin_plugins.name": "Name",
|
"admin_plugins.name": "Name",
|
||||||
"admin_plugins.page-title": "Plugin Manager - Etherpad",
|
"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.version": "Version",
|
||||||
"admin_plugins_info": "Hilfestellung",
|
"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": "Installierte Hooks",
|
||||||
"admin_plugins_info.hooks_client": "Client-seitige Hooks",
|
"admin_plugins_info.hooks_client": "Client-seitige Hooks",
|
||||||
"admin_plugins_info.hooks_server": "Server-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.parts": "Installierte Teile",
|
||||||
"admin_plugins_info.plugins": "Installierte Plugins",
|
"admin_plugins_info.plugins": "Installierte Plugins",
|
||||||
"admin_plugins_info.page-title": "Plugin Informationen - Etherpad",
|
"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": "Etherpad Version",
|
||||||
"admin_plugins_info.version_latest": "Neueste verfügbare Version",
|
"admin_plugins_info.version_latest": "Neueste verfügbare Version",
|
||||||
"admin_plugins_info.version_number": "Versionsnummer",
|
"admin_plugins_info.version_number": "Versionsnummer",
|
||||||
"admin_settings": "Einstellungen",
|
"admin_settings": "Einstellungen",
|
||||||
|
"admin_settings.create_pad": "Pad erstellen",
|
||||||
"admin_settings.current": "Derzeitige Konfiguration",
|
"admin_settings.current": "Derzeitige Konfiguration",
|
||||||
"admin_settings.current_example-devel": "Beispielhafte Entwicklungseinstellungs-Templates",
|
"admin_settings.current_example-devel": "Beispielhafte Entwicklungseinstellungs-Templates",
|
||||||
"admin_settings.current_example-prod": "Beispiel eines produktiven Templates",
|
"admin_settings.current_example-prod": "Beispiel eines produktiven Templates",
|
||||||
"admin_settings.current_restart.value": "Etherpad neustarten",
|
"admin_settings.current_restart.value": "Etherpad neustarten",
|
||||||
"admin_settings.current_save.value": "Einstellungen speichern",
|
"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",
|
"admin_settings.page-title": "Einstellungen - Etherpad",
|
||||||
"index.newPad": "Neues Pad",
|
"index.newPad": "Neues Pad",
|
||||||
"index.settings": "Einstellungen",
|
"index.settings": "Einstellungen",
|
||||||
|
|
@ -75,7 +187,7 @@
|
||||||
"index.labelPad": "Padname (optional)",
|
"index.labelPad": "Padname (optional)",
|
||||||
"index.placeholderPadEnter": "Gib den Namen des Pads ein...",
|
"index.placeholderPadEnter": "Gib den Namen des Pads ein...",
|
||||||
"index.createAndShareDocuments": "Erstelle und teile Dokumente in Echtzeit",
|
"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.bold.title": "Fett (Strg-B)",
|
||||||
"pad.toolbar.italic.title": "Kursiv (Strg-I)",
|
"pad.toolbar.italic.title": "Kursiv (Strg-I)",
|
||||||
"pad.toolbar.underline.title": "Unterstrichen (Strg-U)",
|
"pad.toolbar.underline.title": "Unterstrichen (Strg-U)",
|
||||||
|
|
@ -99,18 +211,31 @@
|
||||||
"pad.loading": "Laden …",
|
"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.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.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.myView": "Eigene Ansicht",
|
||||||
|
"pad.settings.disablechat": "Chat deaktivieren",
|
||||||
|
"pad.settings.darkMode": "Dunkler Modus",
|
||||||
"pad.settings.stickychat": "Chat immer anzeigen",
|
"pad.settings.stickychat": "Chat immer anzeigen",
|
||||||
"pad.settings.chatandusers": "Chat und Benutzer anzeigen",
|
"pad.settings.chatandusers": "Chat und Benutzer anzeigen",
|
||||||
"pad.settings.colorcheck": "Autorenfarben anzeigen",
|
"pad.settings.colorcheck": "Autorenfarben anzeigen",
|
||||||
|
"pad.settings.fadeInactiveAuthorColors": "Farben inaktiver Autoren ausblenden",
|
||||||
"pad.settings.linenocheck": "Zeilennummern",
|
"pad.settings.linenocheck": "Zeilennummern",
|
||||||
"pad.settings.rtlcheck": "Inhalt von rechts nach links lesen?",
|
"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": "Schriftart:",
|
||||||
"pad.settings.fontType.normal": "Normal",
|
"pad.settings.fontType.normal": "Normal",
|
||||||
"pad.settings.language": "Sprache:",
|
"pad.settings.language": "Sprache:",
|
||||||
"pad.settings.deletePad": "Pad löschen",
|
"pad.settings.deletePad": "Pad löschen",
|
||||||
"pad.delete.confirm": "Möchtest du dieses Pad wirklich 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.about": "Über",
|
||||||
"pad.settings.poweredBy": "Betrieben von",
|
"pad.settings.poweredBy": "Betrieben von",
|
||||||
"pad.importExport.import_export": "Import/Export",
|
"pad.importExport.import_export": "Import/Export",
|
||||||
|
|
@ -123,7 +248,13 @@
|
||||||
"pad.importExport.exportword": "Microsoft Word",
|
"pad.importExport.exportword": "Microsoft Word",
|
||||||
"pad.importExport.exportpdf": "PDF",
|
"pad.importExport.exportpdf": "PDF",
|
||||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
"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.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.connected": "Verbunden.",
|
||||||
"pad.modals.reconnecting": "Dein Pad wird neu verbunden...",
|
"pad.modals.reconnecting": "Dein Pad wird neu verbunden...",
|
||||||
"pad.modals.forcereconnect": "Erneutes Verbinden erzwingen",
|
"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.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.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.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.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.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": "Dieses Pad teilen",
|
||||||
"pad.share.readonly": "Eingeschränkter Nur-Lese-Zugriff",
|
"pad.share.readonly": "Eingeschränkter Nur-Lese-Zugriff",
|
||||||
"pad.share.link": "Verknüpfung",
|
"pad.share.link": "Verknüpfung",
|
||||||
|
|
@ -166,12 +299,29 @@
|
||||||
"timeslider.followContents": "Aktualisierungen des Pad-Inhalts verfolgen",
|
"timeslider.followContents": "Aktualisierungen des Pad-Inhalts verfolgen",
|
||||||
"timeslider.pageTitle": "{{appTitle}} Bearbeitungsverlauf",
|
"timeslider.pageTitle": "{{appTitle}} Bearbeitungsverlauf",
|
||||||
"timeslider.toolbar.returnbutton": "Zurück zum Pad",
|
"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.authors": "Autoren:",
|
||||||
"timeslider.toolbar.authorsList": "Keine Autoren",
|
"timeslider.toolbar.authorsList": "Keine Autoren",
|
||||||
"timeslider.toolbar.exportlink.title": "Diese Version exportieren",
|
"timeslider.toolbar.exportlink.title": "Diese Version exportieren",
|
||||||
"timeslider.exportCurrent": "Exportiere diese Version als:",
|
"timeslider.exportCurrent": "Exportiere diese Version als:",
|
||||||
"timeslider.version": "Version {{version}}",
|
"timeslider.version": "Version {{version}}",
|
||||||
"timeslider.saved": "Gespeichert am {{day}}. {{month}} {{year}}",
|
"timeslider.saved": "Gespeichert am {{day}}. {{month}} {{year}}",
|
||||||
|
"timeslider.settings.playbackSpeed": "Wiedergabegeschwindigkeit:",
|
||||||
"timeslider.playPause": "Padbearbeitung abspielen/pausieren",
|
"timeslider.playPause": "Padbearbeitung abspielen/pausieren",
|
||||||
"timeslider.backRevision": "Eine Version in diesem Pad zurückgehen",
|
"timeslider.backRevision": "Eine Version in diesem Pad zurückgehen",
|
||||||
"timeslider.forwardRevision": "Eine Version in diesem Pad vorwärtsgehen",
|
"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.savedrevs.timeslider": "Du kannst gespeicherte Versionen durch den Aufruf des Bearbeitungsverlaufs ansehen.",
|
||||||
"pad.userlist.entername": "Dein Name?",
|
"pad.userlist.entername": "Dein Name?",
|
||||||
"pad.userlist.unnamed": "unbenannt",
|
"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.editbar.clearcolors": "Autorenfarben im gesamten Dokument zurücksetzen? Dies kann nicht rückgängig gemacht werden",
|
||||||
"pad.impexp.importbutton": "Jetzt importieren",
|
"pad.impexp.importbutton": "Jetzt importieren",
|
||||||
"pad.impexp.importing": "Importiere …",
|
"pad.impexp.importing": "Importiere …",
|
||||||
|
|
@ -203,5 +354,6 @@
|
||||||
"pad.impexp.importfailed": "Import fehlgeschlagen",
|
"pad.impexp.importfailed": "Import fehlgeschlagen",
|
||||||
"pad.impexp.copypaste": "Bitte kopieren und einfügen",
|
"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.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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"admin.page-title": "Panoyê İdarekari - Etherpad",
|
"admin.page-title": "Panoyê İdarekari - Etherpad",
|
||||||
|
"admin_pads.error_dialog_description": "Yew xeta biye.",
|
||||||
"admin_plugins": "Gıredayışê raverberi",
|
"admin_plugins": "Gıredayışê raverberi",
|
||||||
"admin_plugins.available": "Mewcud Dekerdeki",
|
"admin_plugins.available": "Mewcud Dekerdeki",
|
||||||
"admin_plugins.available_not-found": "Dekerdek nevineya",
|
"admin_plugins.available_not-found": "Dekerdek nevineya",
|
||||||
|
|
@ -45,6 +46,7 @@
|
||||||
"admin_settings.current_save.value": "Eyaran qeyd ke",
|
"admin_settings.current_save.value": "Eyaran qeyd ke",
|
||||||
"admin_settings.page-title": "Eyari - Etherpad",
|
"admin_settings.page-title": "Eyari - Etherpad",
|
||||||
"index.newPad": "Bloknoto newe",
|
"index.newPad": "Bloknoto newe",
|
||||||
|
"index.code": "Kod",
|
||||||
"index.createOpenPad": "ya zi be nê nameyi ra yew bloknot vıraze/ake:",
|
"index.createOpenPad": "ya zi be nê nameyi ra yew bloknot vıraze/ake:",
|
||||||
"index.openPad": "yew Padê biyayeyi be nê nameyi ra ake:",
|
"index.openPad": "yew Padê biyayeyi be nê nameyi ra ake:",
|
||||||
"pad.toolbar.bold.title": "Qalınd (Ctrl-B)",
|
"pad.toolbar.bold.title": "Qalınd (Ctrl-B)",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
{
|
{
|
||||||
"admin.page-title": "Admin Dashboard - Etherpad",
|
"admin.page-title": "Admin Dashboard - Etherpad",
|
||||||
"admin.loading": "Loading…",
|
"admin.loading": "Loading…",
|
||||||
|
"admin.loading_description": "Please wait while the page is loading.",
|
||||||
"admin.toggle_sidebar": "Toggle sidebar",
|
"admin.toggle_sidebar": "Toggle sidebar",
|
||||||
"admin.shout": "Communication",
|
"admin.shout": "Communication",
|
||||||
"admin_shout.online_one": "There is currently {{count}} user online",
|
"admin_shout.online_one": "There is currently {{count}} user online",
|
||||||
|
|
@ -20,7 +21,11 @@
|
||||||
"admin_pads.col.revisions": "Revisions",
|
"admin_pads.col.revisions": "Revisions",
|
||||||
"admin_pads.col.users": "Users",
|
"admin_pads.col.users": "Users",
|
||||||
"admin_pads.confirm_button": "OK",
|
"admin_pads.confirm_button": "OK",
|
||||||
|
"admin_pads.create_pad_dialog_description": "Choose a name for the new pad.",
|
||||||
|
"admin_pads.delete_pad_dialog_description": "Confirm or cancel pad deletion.",
|
||||||
|
"admin_pads.delete_pad_dialog_title": "Delete pad",
|
||||||
"admin_pads.empty_never_edited": "empty · never edited",
|
"admin_pads.empty_never_edited": "empty · never edited",
|
||||||
|
"admin_pads.error_dialog_description": "An error has occurred.",
|
||||||
"admin_pads.error_prefix": "Error",
|
"admin_pads.error_prefix": "Error",
|
||||||
"admin_pads.filter.active": "Active",
|
"admin_pads.filter.active": "Active",
|
||||||
"admin_pads.filter.all": "All",
|
"admin_pads.filter.all": "All",
|
||||||
|
|
@ -132,13 +137,21 @@
|
||||||
"admin_settings.prettify_confirm": "Prettifying will remove all comments. Continue?",
|
"admin_settings.prettify_confirm": "Prettifying will remove all comments. Continue?",
|
||||||
"admin_settings.mode.form": "Form",
|
"admin_settings.mode.form": "Form",
|
||||||
"admin_settings.mode.raw": "Raw",
|
"admin_settings.mode.raw": "Raw",
|
||||||
|
"admin_settings.mode.effective": "Effective",
|
||||||
|
"admin_settings.mode.effective_tooltip": "Read-only view of the values Etherpad is actually using right now, after environment-variable substitution. Secrets are redacted.",
|
||||||
"admin_settings.mode.aria_label": "Editor mode",
|
"admin_settings.mode.aria_label": "Editor mode",
|
||||||
|
"admin_settings.envvar_banner.title": "This file is a template, not the live config.",
|
||||||
|
"admin_settings.envvar_banner.body": "Placeholders like ${VAR:default} are substituted into memory at startup; they are never written back to this file. Edit env vars in your environment (Docker compose, systemd, .env) to change the resolved value, or replace the placeholder here with a literal. Switch to the Effective tab to see what Etherpad is using right now.",
|
||||||
|
"admin_settings.toast.auth_error": "You are not authenticated as admin. Please log in again.",
|
||||||
"admin_settings.section.general": "General",
|
"admin_settings.section.general": "General",
|
||||||
"admin_settings.parse_error.title": "Cannot parse settings.json",
|
"admin_settings.parse_error.title": "Cannot parse settings.json",
|
||||||
"admin_settings.parse_error.cta": "Switch to raw to edit",
|
"admin_settings.parse_error.cta": "Switch to raw to edit",
|
||||||
"admin_settings.env_pill.tooltip": "Reads from the {{variable}} environment variable. The value below is used when {{variable}} is unset.",
|
"admin_settings.env_pill.tooltip": "Reads from the {{variable}} environment variable. The value below is used when {{variable}} is unset.",
|
||||||
"admin_settings.env_pill.default_label": "default",
|
"admin_settings.env_pill.default_label": "default",
|
||||||
"admin_settings.env_pill.input_aria": "Default value for {{variable}}",
|
"admin_settings.env_pill.input_aria": "Default value for {{variable}}",
|
||||||
|
"admin_settings.env_pill.runtime_label": "active value",
|
||||||
|
"admin_settings.env_pill.runtime_tooltip": "Etherpad is currently using this value, resolved from {{variable}} or its default.",
|
||||||
|
"admin_settings.env_pill.redacted_tooltip": "Etherpad is using a value for {{variable}}, but it is hidden because it is a secret.",
|
||||||
"admin_settings.page-title": "Settings - Etherpad",
|
"admin_settings.page-title": "Settings - Etherpad",
|
||||||
"admin_settings.save_error": "Error saving settings",
|
"admin_settings.save_error": "Error saving settings",
|
||||||
"admin_settings.saved_success": "Successfully saved settings",
|
"admin_settings.saved_success": "Successfully saved settings",
|
||||||
|
|
@ -214,6 +227,7 @@
|
||||||
"index.copyLinkButton": "Copy link to clipboard",
|
"index.copyLinkButton": "Copy link to clipboard",
|
||||||
"index.transferToSystem": "3. Copy session to new system",
|
"index.transferToSystem": "3. Copy session to new system",
|
||||||
"index.transferToSystemDescription": "Open the copied link in the target browser or device to transfer your session.",
|
"index.transferToSystemDescription": "Open the copied link in the target browser or device to transfer your session.",
|
||||||
|
"index.code": "Code",
|
||||||
"index.transferSessionDescription": "Transfer your current session to browser or device by clicking the button below. This will copy a link to a page that will transfer your session when opened in the target browser or device.",
|
"index.transferSessionDescription": "Transfer your current session to browser or device by clicking the button below. This will copy a link to a page that will transfer your session when opened in the target browser or device.",
|
||||||
"index.createOpenPad": "Open pad by name",
|
"index.createOpenPad": "Open pad by name",
|
||||||
"index.openPad": "open an existing Pad with the name:",
|
"index.openPad": "open an existing Pad with the name:",
|
||||||
|
|
@ -300,7 +314,7 @@
|
||||||
"pad.importExport.exportworda.title": "Export as Microsoft Word",
|
"pad.importExport.exportworda.title": "Export as Microsoft Word",
|
||||||
"pad.importExport.exportpdfa.title": "Export as PDF",
|
"pad.importExport.exportpdfa.title": "Export as PDF",
|
||||||
"pad.importExport.exportopena.title": "Export as ODF (Open Document Format)",
|
"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.connected": "Connected.",
|
||||||
"pad.modals.reconnecting": "Reconnecting to your pad…",
|
"pad.modals.reconnecting": "Reconnecting to your pad…",
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,27 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"admin.page-title": "Ylläpitäjän kojelauta - Etherpad",
|
"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": "Lisäosien hallinta",
|
||||||
"admin_plugins.available": "Saatavilla olevat liitännäiset",
|
"admin_plugins.available": "Saatavilla olevat liitännäiset",
|
||||||
"admin_plugins.available_not-found": "Lisäosia ei löytynyt.",
|
"admin_plugins.available_not-found": "Lisäosia ei löytynyt.",
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,17 @@
|
||||||
"Chpol",
|
"Chpol",
|
||||||
"Cquoi",
|
"Cquoi",
|
||||||
"Crochet.david",
|
"Crochet.david",
|
||||||
|
"Crowwhailord",
|
||||||
"Derugon",
|
"Derugon",
|
||||||
"Envlh",
|
"Envlh",
|
||||||
"Framafan",
|
"Framafan",
|
||||||
|
"Framasky",
|
||||||
"Fylip22",
|
"Fylip22",
|
||||||
"Gomoko",
|
"Gomoko",
|
||||||
"Goofy",
|
"Goofy",
|
||||||
"Goofy-bz",
|
"Goofy-bz",
|
||||||
"Jean-Frédéric",
|
"Jean-Frédéric",
|
||||||
|
"Jérémy-Günther-Heinz Jähnick",
|
||||||
"Leviathan",
|
"Leviathan",
|
||||||
"Macofe",
|
"Macofe",
|
||||||
"Mahabarata",
|
"Mahabarata",
|
||||||
|
|
@ -36,13 +39,73 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"admin.page-title": "Tableau de bord administrateur — Etherpad",
|
"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": "Gestionnaire de greffons",
|
||||||
"admin_plugins.available": "Greffons disponibles",
|
"admin_plugins.available": "Greffons disponibles",
|
||||||
"admin_plugins.available_not-found": "Aucun greffon trouvé.",
|
"admin_plugins.available_not-found": "Aucun greffon trouvé.",
|
||||||
"admin_plugins.available_fetching": "Récupération en cours...",
|
"admin_plugins.available_fetching": "Récupération en cours...",
|
||||||
"admin_plugins.available_install.value": "Installer",
|
"admin_plugins.available_install.value": "Installer",
|
||||||
"admin_plugins.available_search.placeholder": "Rechercher des greffons à 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.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": "Greffons installés",
|
||||||
"admin_plugins.installed_fetching": "Récupération des greffons installés en cours...",
|
"admin_plugins.installed_fetching": "Récupération des greffons installés en cours...",
|
||||||
"admin_plugins.installed_nothing": "Vous n’avez encore installé aucun greffon.",
|
"admin_plugins.installed_nothing": "Vous n’avez encore installé aucun greffon.",
|
||||||
|
|
@ -50,8 +113,25 @@
|
||||||
"admin_plugins.last-update": "Dernière mise à jour",
|
"admin_plugins.last-update": "Dernière mise à jour",
|
||||||
"admin_plugins.name": "Nom",
|
"admin_plugins.name": "Nom",
|
||||||
"admin_plugins.page-title": "Gestionnaire de greffons — Etherpad",
|
"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.version": "Version",
|
||||||
"admin_plugins_info": "Informations de résolution de problème",
|
"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": "Crochets installés",
|
||||||
"admin_plugins_info.hooks_client": "Crochets côté client",
|
"admin_plugins_info.hooks_client": "Crochets côté client",
|
||||||
"admin_plugins_info.hooks_server": "Crochets côté serveur",
|
"admin_plugins_info.hooks_server": "Crochets côté serveur",
|
||||||
|
|
@ -67,6 +147,7 @@
|
||||||
"admin_settings.current_example-prod": "Exemple de modèle de paramètres de production",
|
"admin_settings.current_example-prod": "Exemple de modèle de paramètres de production",
|
||||||
"admin_settings.current_restart.value": "Redémarrer Etherpad",
|
"admin_settings.current_restart.value": "Redémarrer Etherpad",
|
||||||
"admin_settings.current_save.value": "Enregistrer les paramètres",
|
"admin_settings.current_save.value": "Enregistrer les paramètres",
|
||||||
|
"admin_settings.mode.aria_label": "Mode éditeur",
|
||||||
"admin_settings.page-title": "Paramètres — Etherpad",
|
"admin_settings.page-title": "Paramètres — Etherpad",
|
||||||
"update.page.apply": "Appliquer la mise à jour",
|
"update.page.apply": "Appliquer la mise à jour",
|
||||||
"update.page.cancel": "Annuler",
|
"update.page.cancel": "Annuler",
|
||||||
|
|
@ -154,6 +235,16 @@
|
||||||
"pad.settings.language": "Langue :",
|
"pad.settings.language": "Langue :",
|
||||||
"pad.settings.deletePad": "Supprimer le bloc-notes",
|
"pad.settings.deletePad": "Supprimer le bloc-notes",
|
||||||
"pad.delete.confirm": "Voulez-vous vraiment supprimer ce 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.about": "À propos",
|
||||||
"pad.settings.poweredBy": "Propulsé par",
|
"pad.settings.poweredBy": "Propulsé par",
|
||||||
"pad.importExport.import_export": "Importer/Exporter",
|
"pad.importExport.import_export": "Importer/Exporter",
|
||||||
|
|
@ -166,7 +257,10 @@
|
||||||
"pad.importExport.exportword": "Microsoft Word",
|
"pad.importExport.exportword": "Microsoft Word",
|
||||||
"pad.importExport.exportpdf": "PDF",
|
"pad.importExport.exportpdf": "PDF",
|
||||||
"pad.importExport.exportopen": "ODF (Open Document Format)",
|
"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.connected": "Connecté.",
|
||||||
"pad.modals.reconnecting": "Reconnexion à votre bloc-notes en cours...",
|
"pad.modals.reconnecting": "Reconnexion à votre bloc-notes en cours...",
|
||||||
"pad.modals.forcereconnect": "Forcer la reconnexion",
|
"pad.modals.forcereconnect": "Forcer la reconnexion",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
},
|
},
|
||||||
"admin.page-title": "Painéal Riaracháin - Etherpad",
|
"admin.page-title": "Painéal Riaracháin - Etherpad",
|
||||||
"admin.loading": "Ag lódáil…",
|
"admin.loading": "Ag lódáil…",
|
||||||
|
"admin.loading_description": "Fan go fóill fad atá an leathanach á lódáil.",
|
||||||
"admin.toggle_sidebar": "Scoránaigh an taobhbharra",
|
"admin.toggle_sidebar": "Scoránaigh an taobhbharra",
|
||||||
"admin.shout": "Cumarsáid",
|
"admin.shout": "Cumarsáid",
|
||||||
"admin_shout.online_one": "Tá {{count}} úsáideoir ar líne faoi láthair",
|
"admin_shout.online_one": "Tá {{count}} úsáideoir ar líne faoi láthair",
|
||||||
|
|
@ -25,7 +26,11 @@
|
||||||
"admin_pads.col.revisions": "Athbhreithnithe",
|
"admin_pads.col.revisions": "Athbhreithnithe",
|
||||||
"admin_pads.col.users": "Úsáideoirí",
|
"admin_pads.col.users": "Úsáideoirí",
|
||||||
"admin_pads.confirm_button": "Ceart go leor",
|
"admin_pads.confirm_button": "Ceart go leor",
|
||||||
|
"admin_pads.create_pad_dialog_description": "Roghnaigh ainm don phainéal nua.",
|
||||||
|
"admin_pads.delete_pad_dialog_description": "Deimhnigh nó cealaigh scriosadh an eochaircheap.",
|
||||||
|
"admin_pads.delete_pad_dialog_title": "Scrios an eochaircheap",
|
||||||
"admin_pads.empty_never_edited": "folamh · níor cuireadh in eagar riamh",
|
"admin_pads.empty_never_edited": "folamh · níor cuireadh in eagar riamh",
|
||||||
|
"admin_pads.error_dialog_description": "Tharla earráid.",
|
||||||
"admin_pads.error_prefix": "Earráid",
|
"admin_pads.error_prefix": "Earráid",
|
||||||
"admin_pads.filter.active": "Gníomhach",
|
"admin_pads.filter.active": "Gníomhach",
|
||||||
"admin_pads.filter.all": "Gach",
|
"admin_pads.filter.all": "Gach",
|
||||||
|
|
@ -66,11 +71,14 @@
|
||||||
"admin_plugins.available_search.placeholder": "Cuardaigh breiseáin le suiteáil",
|
"admin_plugins.available_search.placeholder": "Cuardaigh breiseáin le suiteáil",
|
||||||
"admin_plugins.check_updates": "Seiceáil le haghaidh nuashonruithe",
|
"admin_plugins.check_updates": "Seiceáil le haghaidh nuashonruithe",
|
||||||
"admin_plugins.core_count": "{{count}} croíleacán",
|
"admin_plugins.core_count": "{{count}} croíleacán",
|
||||||
|
"admin_plugins.catalog_disabled": "Tá catalóg breiseán díchumasaithe ag d'oibreoir (privacy.pluginCatalog=false). Chun breiseán a shuiteáil, rith `pnpm run plugins i ep_<ainm>` ón bhfreastalaí.",
|
||||||
"admin_plugins.crumbs": "Breiseáin",
|
"admin_plugins.crumbs": "Breiseáin",
|
||||||
"admin_plugins.description": "Cur síos",
|
"admin_plugins.description": "Cur síos",
|
||||||
"admin_plugins.disables.label": "Díchumasaíonn:",
|
"admin_plugins.disables.label": "Díchumasaíonn:",
|
||||||
"admin_plugins.disables.warning_title": "Baintear na gnéithe Etherpad atá liostaithe d'aon ghnó leis an mbreiseán seo.",
|
"admin_plugins.disables.warning_title": "Baintear na gnéithe Etherpad atá liostaithe d'aon ghnó leis an mbreiseán seo.",
|
||||||
"admin_plugins.error_retrieving": "Earráid ag aisghabháil breiseán",
|
"admin_plugins.error_retrieving": "Earráid ag aisghabháil breiseán",
|
||||||
|
"admin_plugins.install_error": "Theip ar {{plugin}} a shuiteáil: {{error}}",
|
||||||
|
"admin_plugins.install_error_requires_newer_etherpad": "Ní féidir {{plugin}} a shuiteáil: teastaíonn leagan níos nuaí de Etherpad. Uasghrádaigh Etherpad agus déan iarracht arís.",
|
||||||
"admin_plugins.installed": "Breiseáin suiteáilte",
|
"admin_plugins.installed": "Breiseáin suiteáilte",
|
||||||
"admin_plugins.installed_fetching": "Ag fáil breiseáin suiteáilte…",
|
"admin_plugins.installed_fetching": "Ag fáil breiseáin suiteáilte…",
|
||||||
"admin_plugins.installed_nothing": "Níl aon bhreiseáin suiteáilte agat fós.",
|
"admin_plugins.installed_nothing": "Níl aon bhreiseáin suiteáilte agat fós.",
|
||||||
|
|
@ -122,6 +130,33 @@
|
||||||
"admin_settings.current_restart.value": "Atosaigh Etherpad",
|
"admin_settings.current_restart.value": "Atosaigh Etherpad",
|
||||||
"admin_settings.current_save.value": "Sábháil Socruithe",
|
"admin_settings.current_save.value": "Sábháil Socruithe",
|
||||||
"admin_settings.invalid_json": "JSON neamhbhailí",
|
"admin_settings.invalid_json": "JSON neamhbhailí",
|
||||||
|
"admin_settings.current_test.value": "Bailíochtú JSON",
|
||||||
|
"admin_settings.current_prettify.value": "Áilleachtaigh JSON",
|
||||||
|
"admin_settings.toast.saved": "Socruithe sábháilte go rathúil.",
|
||||||
|
"admin_settings.toast.save_failed": "Theip ar shábháil: níorbh fhéidir settings.json a scríobh.",
|
||||||
|
"admin_settings.toast.json_invalid": "Earráid chomhréire: seiceáil camóga, lúibíní, agus comharthaí athfhriotail.",
|
||||||
|
"admin_settings.toast.disconnected": "Ní féidir sábháil: níl sé ceangailte leis an bhfreastalaí.",
|
||||||
|
"admin_settings.toast.validation_ok": "Tá JSON bailí.",
|
||||||
|
"admin_settings.toast.validation_failed": "Tá JSON neamhbhailí: ceartaigh earráidí comhréire le do thoil.",
|
||||||
|
"admin_settings.toast.prettify_failed": "Ní féidir áilleacht a dhéanamh: ceartaigh earráidí comhréire ar dtús le do thoil.",
|
||||||
|
"admin_settings.prettify_confirm": "Bainfear na tráchtanna go léir má dhéantar áilleachtú. Leanfaidh tú ar aghaidh?",
|
||||||
|
"admin_settings.mode.form": "Foirm",
|
||||||
|
"admin_settings.mode.raw": "Amh",
|
||||||
|
"admin_settings.mode.effective": "Éifeachtach",
|
||||||
|
"admin_settings.mode.effective_tooltip": "Radharc inléite amháin ar na luachanna atá á n-úsáid ag Etherpad faoi láthair, tar éis athsholáthar athróg timpeallachta. Tá rúin curtha in eagar.",
|
||||||
|
"admin_settings.mode.aria_label": "Mód eagarthóireachta",
|
||||||
|
"admin_settings.envvar_banner.title": "Is teimpléad an comhad seo, ní an chumraíocht bheo.",
|
||||||
|
"admin_settings.envvar_banner.body": "Cuirtear áitchoimeádaithe cosúil le ${VAR:default} isteach sa chuimhne ag an am tosaithe; ní scríobhtar ar ais chuig an gcomhad seo iad choíche. Cuir na cineálacha timpeallachta in eagar i do thimpeallacht (Docker compose, systemd, .env) chun an luach réitithe a athrú, nó cuir litriúil in ionad an áitchoimeádaitheora anseo. Téigh go dtí an cluaisín Éifeachtach chun a fheiceáil cad atá á úsáid ag Etherpad faoi láthair.",
|
||||||
|
"admin_settings.toast.auth_error": "Níl tú fíordheimhnithe mar riarthóir. Logáil isteach arís le do thoil.",
|
||||||
|
"admin_settings.section.general": "Ginearálta",
|
||||||
|
"admin_settings.parse_error.title": "Ní féidir settings.json a pharsáil",
|
||||||
|
"admin_settings.parse_error.cta": "Athraigh go amh le heagarthóireacht",
|
||||||
|
"admin_settings.env_pill.tooltip": "Léann sé ón athróg timpeallachta {{variable}}. Úsáidtear an luach thíos nuair nach bhfuil {{variable}} socraithe.",
|
||||||
|
"admin_settings.env_pill.default_label": "réamhshocraithe",
|
||||||
|
"admin_settings.env_pill.input_aria": "Luach réamhshocraithe do {{variable}}",
|
||||||
|
"admin_settings.env_pill.runtime_label": "luach gníomhach",
|
||||||
|
"admin_settings.env_pill.runtime_tooltip": "Tá an luach seo á úsáid ag Etherpad faoi láthair, réitithe ó {{variable}} nó a réamhshocrú.",
|
||||||
|
"admin_settings.env_pill.redacted_tooltip": "Tá luach á úsáid ag Etherpad do {{variable}}, ach tá sé i bhfolach mar is rún é.",
|
||||||
"admin_settings.page-title": "Socruithe - Etherpad",
|
"admin_settings.page-title": "Socruithe - Etherpad",
|
||||||
"admin_settings.save_error": "Earráid ag sábháil socruithe",
|
"admin_settings.save_error": "Earráid ag sábháil socruithe",
|
||||||
"admin_settings.saved_success": "Socruithe sábháilte go rathúil",
|
"admin_settings.saved_success": "Socruithe sábháilte go rathúil",
|
||||||
|
|
@ -150,12 +185,15 @@
|
||||||
"update.page.policy.rollback-failed-terminal": "Theip ar nuashonrú roimhe seo agus níorbh fhéidir é a chur ar ais. Brúigh Admhaigh tar éis don tsuiteáil a bheith sláintiúil chun an glas a ghlanadh.",
|
"update.page.policy.rollback-failed-terminal": "Theip ar nuashonrú roimhe seo agus níorbh fhéidir é a chur ar ais. Brúigh Admhaigh tar éis don tsuiteáil a bheith sláintiúil chun an glas a ghlanadh.",
|
||||||
"update.page.policy.up-to-date": "Tá an leagan is déanaí á rith agat.",
|
"update.page.policy.up-to-date": "Tá an leagan is déanaí á rith agat.",
|
||||||
"update.page.policy.tier-off": "Tá nuashonruithe díchumasaithe (updates.tier = \"off\").",
|
"update.page.policy.tier-off": "Tá nuashonruithe díchumasaithe (updates.tier = \"off\").",
|
||||||
|
"update.page.policy.maintenance-window-missing": "Éilíonn Sraith 4 (uathrialach) fuinneog chothabhála. Socraigh updates.maintenanceWindow i settings.json chun nuashonruithe uathrialacha a chumasú.",
|
||||||
|
"update.page.policy.maintenance-window-invalid": "Tá Sraith 4 (uathrialach) díchumasaithe mar gheall ar mhífhoirmiú updates.maintenanceWindow. Bhíothas ag súil le {start, end, tz} le hamanna HH:MM agus tz de \"local\" nó \"utc\".",
|
||||||
"update.page.last_result.verified": "Nuashonrú deireanach ar {{tag}} fíoraithe.",
|
"update.page.last_result.verified": "Nuashonrú deireanach ar {{tag}} fíoraithe.",
|
||||||
"update.page.last_result.rolled-back": "Cuireadh an iarracht dheireanach ar {{tag}} a nuashonrú ar ceal: {{reason}}.",
|
"update.page.last_result.rolled-back": "Cuireadh an iarracht dheireanach ar {{tag}} a nuashonrú ar ceal: {{reason}}.",
|
||||||
"update.page.last_result.rollback-failed": "Theip ar an iarracht nuashonraithe dheireanach AGUS theip ar an rolladh ar ais: {{reason}}. Idirghabháil láimhe ag teastáil.",
|
"update.page.last_result.rollback-failed": "Theip ar an iarracht nuashonraithe dheireanach AGUS theip ar an rolladh ar ais: {{reason}}. Idirghabháil láimhe ag teastáil.",
|
||||||
"update.page.last_result.preflight-failed": "Theip ar an iarracht dheireanach ar {{tag}} a nuashonrú roimh ré: {{reason}}.",
|
"update.page.last_result.preflight-failed": "Theip ar an iarracht dheireanach ar {{tag}} a nuashonrú roimh ré: {{reason}}.",
|
||||||
"update.page.last_result.cancelled": "Cealaíodh an iarracht dheireanach ar {{tag}} a nuashonrú ag an riarthóir.",
|
"update.page.last_result.cancelled": "Cealaíodh an iarracht dheireanach ar {{tag}} a nuashonrú ag an riarthóir.",
|
||||||
"update.execution.idle": "Díomhaoin",
|
"update.execution.idle": "Díomhaoin",
|
||||||
|
"update.execution.scheduled": "Nuashonrú sceidealaithe",
|
||||||
"update.execution.preflight": "Seiceálacha réamh-eitilte",
|
"update.execution.preflight": "Seiceálacha réamh-eitilte",
|
||||||
"update.execution.preflight-failed": "Theip ar an réamh-eitilt",
|
"update.execution.preflight-failed": "Theip ar an réamh-eitilt",
|
||||||
"update.execution.draining": "Seisiúin draenála",
|
"update.execution.draining": "Seisiúin draenála",
|
||||||
|
|
@ -166,6 +204,17 @@
|
||||||
"update.execution.rolled-back": "Rolladh siar",
|
"update.execution.rolled-back": "Rolladh siar",
|
||||||
"update.execution.rollback-failed": "Theip ar an rolladh siar",
|
"update.execution.rollback-failed": "Theip ar an rolladh siar",
|
||||||
"update.banner.terminal.rollback-failed": "Theip ar iarracht nuashonraithe agus níorbh fhéidir é a chur ar ais. Idirghabháil láimhe ag teastáil.",
|
"update.banner.terminal.rollback-failed": "Theip ar iarracht nuashonraithe agus níorbh fhéidir é a chur ar ais. Idirghabháil láimhe ag teastáil.",
|
||||||
|
"update.banner.scheduled": "Nuashonrú uathoibríoch chuig {{tag}} sceidealaithe — baineann sé le {{remaining}}.",
|
||||||
|
"update.banner.maintenance-window-missing": "Bíonn nuashonruithe uathrialacha díchumasaithe go dtí go gcumraítear fuinneog chothabhála.",
|
||||||
|
"update.banner.maintenance-window-invalid": "Tá nuashonruithe uathrialacha díchumasaithe mar go bhfuil an fhuinneog chothabhála mífhoirmithe.",
|
||||||
|
"update.page.scheduled.title": "Nuashonrú sceidealaithe",
|
||||||
|
"update.page.scheduled.countdown": "Tosóidh Etherpad ag nuashonrú go {{tag}} i {{remaining}}.",
|
||||||
|
"update.page.scheduled.deferred_until": "Lasmuigh den fhuinneog chothabhála. Tosóidh an nuashonrú nuair a osclaítear an fhuinneog ag {{at}}.",
|
||||||
|
"update.page.scheduled.apply_now": "Cuir isteach anois",
|
||||||
|
"update.window.title": "Fuinneog chothabhála",
|
||||||
|
"update.window.summary": "{{start}}–{{end}} ({{tz}})",
|
||||||
|
"update.window.unset": "Gan cumrú.",
|
||||||
|
"update.window.next_opens_at": "Osclaítear an chéad fhuinneog eile ag {{at}}.",
|
||||||
"update.drain.t60": "Atosóidh Etherpad i gceann 60 soicind chun nuashonrú a chur i bhfeidhm.",
|
"update.drain.t60": "Atosóidh Etherpad i gceann 60 soicind chun nuashonrú a chur i bhfeidhm.",
|
||||||
"update.drain.t30": "Atosóidh Etherpad i gceann 30 soicind chun nuashonrú a chur i bhfeidhm.",
|
"update.drain.t30": "Atosóidh Etherpad i gceann 30 soicind chun nuashonrú a chur i bhfeidhm.",
|
||||||
"update.drain.t10": "Atosóidh Etherpad i gceann 10 soicind chun nuashonrú a chur i bhfeidhm.",
|
"update.drain.t10": "Atosóidh Etherpad i gceann 10 soicind chun nuashonrú a chur i bhfeidhm.",
|
||||||
|
|
@ -181,6 +230,7 @@
|
||||||
"index.copyLinkButton": "Cóipeáil nasc chuig an ghearrthaisce",
|
"index.copyLinkButton": "Cóipeáil nasc chuig an ghearrthaisce",
|
||||||
"index.transferToSystem": "3. Cóipeáil an seisiún chuig an gcóras nua",
|
"index.transferToSystem": "3. Cóipeáil an seisiún chuig an gcóras nua",
|
||||||
"index.transferToSystemDescription": "Oscail an nasc cóipeáilte sa bhrabhsálaí nó ar an ngléas sprice chun do sheisiún a aistriú.",
|
"index.transferToSystemDescription": "Oscail an nasc cóipeáilte sa bhrabhsálaí nó ar an ngléas sprice chun do sheisiún a aistriú.",
|
||||||
|
"index.code": "Cód",
|
||||||
"index.transferSessionDescription": "Aistrigh do sheisiún reatha chuig brabhsálaí nó gléas trí chliceáil ar an gcnaipe thíos. Cóipeálfaidh sé seo nasc chuig leathanach a aistreoidh do sheisiún nuair a osclófar é sa bhrabhsálaí nó sa ghléas sprice.",
|
"index.transferSessionDescription": "Aistrigh do sheisiún reatha chuig brabhsálaí nó gléas trí chliceáil ar an gcnaipe thíos. Cóipeálfaidh sé seo nasc chuig leathanach a aistreoidh do sheisiún nuair a osclófar é sa bhrabhsálaí nó sa ghléas sprice.",
|
||||||
"index.createOpenPad": "Oscail ceap de réir ainm",
|
"index.createOpenPad": "Oscail ceap de réir ainm",
|
||||||
"index.openPad": "oscail Pad atá ann cheana féin leis an ainm:",
|
"index.openPad": "oscail Pad atá ann cheana féin leis an ainm:",
|
||||||
|
|
@ -318,6 +368,11 @@
|
||||||
"pad.historyMode.settings.playbackSpeed": "Luas athsheinm:",
|
"pad.historyMode.settings.playbackSpeed": "Luas athsheinm:",
|
||||||
"pad.historyMode.chat.replayHeader": "Comhrá ó {{time}}",
|
"pad.historyMode.chat.replayHeader": "Comhrá ó {{time}}",
|
||||||
"pad.historyMode.users.authorsHeader": "Údair ag an athbhreithniú seo",
|
"pad.historyMode.users.authorsHeader": "Údair ag an athbhreithniú seo",
|
||||||
|
"pad.editor.skipToContent": "Léim go dtí an t-eagarthóir",
|
||||||
|
"pad.editor.keyboardHint": "Brúigh Escape chun an eagarthóir a fhágáil. Brúigh Alt+F9 chun an barra uirlisí a rochtain.",
|
||||||
|
"pad.editor.toolbar.formatting": "Barra uirlisí formáidithe",
|
||||||
|
"pad.editor.toolbar.actions": "Barra uirlisí gníomhartha ceap",
|
||||||
|
"pad.editor.toolbar.showMore": "Taispeáin níos mó cnaipí barra uirlisí",
|
||||||
"timeslider.toolbar.authors": "Údair:",
|
"timeslider.toolbar.authors": "Údair:",
|
||||||
"timeslider.toolbar.authorsList": "Gan Údair",
|
"timeslider.toolbar.authorsList": "Gan Údair",
|
||||||
"timeslider.toolbar.exportlink.title": "Easpórtáil",
|
"timeslider.toolbar.exportlink.title": "Easpórtáil",
|
||||||
|
|
@ -351,6 +406,7 @@
|
||||||
"pad.savedrevs.timeslider": "Is féidir leat athbhreithnithe sábháilte a fheiceáil trí chuairt a thabhairt ar an sleamhnán ama",
|
"pad.savedrevs.timeslider": "Is féidir leat athbhreithnithe sábháilte a fheiceáil trí chuairt a thabhairt ar an sleamhnán ama",
|
||||||
"pad.userlist.entername": "Cuir isteach d'ainm",
|
"pad.userlist.entername": "Cuir isteach d'ainm",
|
||||||
"pad.userlist.unnamed": "gan ainm",
|
"pad.userlist.unnamed": "gan ainm",
|
||||||
|
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} úsáideoir ceangailte, other: {{count}} úsáideoirí ceangailte ]}",
|
||||||
"pad.editbar.clearcolors": "Glan dathanna údair ar an doiciméad ar fad? Ní féidir é seo a chealú",
|
"pad.editbar.clearcolors": "Glan dathanna údair ar an doiciméad ar fad? Ní féidir é seo a chealú",
|
||||||
"pad.impexp.importbutton": "Iompórtáil Anois",
|
"pad.impexp.importbutton": "Iompórtáil Anois",
|
||||||
"pad.impexp.importing": "Ag allmhairiú...",
|
"pad.impexp.importing": "Ag allmhairiú...",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
},
|
},
|
||||||
"admin.page-title": "Panel de administración - Etherpad",
|
"admin.page-title": "Panel de administración - Etherpad",
|
||||||
"admin.loading": "Cargando…",
|
"admin.loading": "Cargando…",
|
||||||
|
"admin.loading_description": "Agarda mentres se carga a páxina.",
|
||||||
"admin.toggle_sidebar": "Activar ou desactivar a barra lateral",
|
"admin.toggle_sidebar": "Activar ou desactivar a barra lateral",
|
||||||
"admin.shout": "Comunicación",
|
"admin.shout": "Comunicación",
|
||||||
"admin_shout.online_one": "Nestes intres hai {{count}} usuario en liña",
|
"admin_shout.online_one": "Nestes intres hai {{count}} usuario en liña",
|
||||||
|
|
@ -27,7 +28,11 @@
|
||||||
"admin_pads.col.revisions": "Revisións",
|
"admin_pads.col.revisions": "Revisións",
|
||||||
"admin_pads.col.users": "Usuarios",
|
"admin_pads.col.users": "Usuarios",
|
||||||
"admin_pads.confirm_button": "Aceptar",
|
"admin_pads.confirm_button": "Aceptar",
|
||||||
|
"admin_pads.create_pad_dialog_description": "Escolle un nome para o documento novo.",
|
||||||
|
"admin_pads.delete_pad_dialog_description": "Confirmar ou cancelar a eliminación do documento.",
|
||||||
|
"admin_pads.delete_pad_dialog_title": "Borrar o documento",
|
||||||
"admin_pads.empty_never_edited": "baleiro · non se editou nunca",
|
"admin_pads.empty_never_edited": "baleiro · non se editou nunca",
|
||||||
|
"admin_pads.error_dialog_description": "Houbo un erro.",
|
||||||
"admin_pads.error_prefix": "Erro",
|
"admin_pads.error_prefix": "Erro",
|
||||||
"admin_pads.filter.active": "Activos",
|
"admin_pads.filter.active": "Activos",
|
||||||
"admin_pads.filter.all": "Todos",
|
"admin_pads.filter.all": "Todos",
|
||||||
|
|
@ -68,11 +73,14 @@
|
||||||
"admin_plugins.available_search.placeholder": "Buscar complementos para instalar",
|
"admin_plugins.available_search.placeholder": "Buscar complementos para instalar",
|
||||||
"admin_plugins.check_updates": "Comprobar se hai actualizacións",
|
"admin_plugins.check_updates": "Comprobar se hai actualizacións",
|
||||||
"admin_plugins.core_count": "{{count}} principais",
|
"admin_plugins.core_count": "{{count}} principais",
|
||||||
|
"admin_plugins.catalog_disabled": "O catálogo de complementos está desactivado polo operador (privacy.pluginCatalog=false). Para instalar un complemento, executa `pnpm run plugins i ep_<nome>` no servidor.",
|
||||||
"admin_plugins.crumbs": "Complementos",
|
"admin_plugins.crumbs": "Complementos",
|
||||||
"admin_plugins.description": "Descrición",
|
"admin_plugins.description": "Descrición",
|
||||||
"admin_plugins.disables.label": "Desactiva:",
|
"admin_plugins.disables.label": "Desactiva:",
|
||||||
"admin_plugins.disables.warning_title": "Este complemento elimina intencionadamente as funcionalidades de Etherpad listadas.",
|
"admin_plugins.disables.warning_title": "Este complemento elimina intencionadamente as funcionalidades de Etherpad listadas.",
|
||||||
"admin_plugins.error_retrieving": "Erro ao recuperar os complementos",
|
"admin_plugins.error_retrieving": "Erro ao recuperar os complementos",
|
||||||
|
"admin_plugins.install_error": "Erro ao instalar «{{plugin}}»: {{error}}",
|
||||||
|
"admin_plugins.install_error_requires_newer_etherpad": "Non se pode instalar «{{plugin}}»: cómpre unha versión máis recente de Etherpad. Actualiza Etherpad e inténtao de novo.",
|
||||||
"admin_plugins.installed": "Complementos instalados",
|
"admin_plugins.installed": "Complementos instalados",
|
||||||
"admin_plugins.installed_fetching": "Obtendo os complementos instalados...",
|
"admin_plugins.installed_fetching": "Obtendo os complementos instalados...",
|
||||||
"admin_plugins.installed_nothing": "Aínda non instalaches ningún complemento.",
|
"admin_plugins.installed_nothing": "Aínda non instalaches ningún complemento.",
|
||||||
|
|
@ -124,6 +132,33 @@
|
||||||
"admin_settings.current_restart.value": "Reiniciar Etherpad",
|
"admin_settings.current_restart.value": "Reiniciar Etherpad",
|
||||||
"admin_settings.current_save.value": "Gardar axustes",
|
"admin_settings.current_save.value": "Gardar axustes",
|
||||||
"admin_settings.invalid_json": "JSON non válido",
|
"admin_settings.invalid_json": "JSON non válido",
|
||||||
|
"admin_settings.current_test.value": "Validar o JSON",
|
||||||
|
"admin_settings.current_prettify.value": "Aquelar o JSON",
|
||||||
|
"admin_settings.toast.saved": "Os axustes gardáronse correctamente.",
|
||||||
|
"admin_settings.toast.save_failed": "Fallou o gardado: non se puido escribir no ficheiro «settings.json».",
|
||||||
|
"admin_settings.toast.json_invalid": "Erro de sintaxe: comproba as comas, as chaves e as comiñas.",
|
||||||
|
"admin_settings.toast.disconnected": "Non se pode gardar: non tes conexión co servidor.",
|
||||||
|
"admin_settings.toast.validation_ok": "O JSON é válido.",
|
||||||
|
"admin_settings.toast.validation_failed": "O JSON non é válido: corrixe os erros de sintaxe.",
|
||||||
|
"admin_settings.toast.prettify_failed": "Non se pode aquelar: primeiro corrixe os erros de sintaxe.",
|
||||||
|
"admin_settings.prettify_confirm": "Ao aquelar, eliminaranse todos os comentarios. Queres continuar?",
|
||||||
|
"admin_settings.mode.form": "Formulario",
|
||||||
|
"admin_settings.mode.raw": "En bruto",
|
||||||
|
"admin_settings.mode.effective": "Efectivo",
|
||||||
|
"admin_settings.mode.effective_tooltip": "Vista de só lectura dos valores que Etherpad está a usar agora mesmo, despois da substitución das variables de contorno. Os segredos están agochados.",
|
||||||
|
"admin_settings.mode.aria_label": "Modo de edición",
|
||||||
|
"admin_settings.envvar_banner.title": "Este ficheiro é un modelo, non a configuración real.",
|
||||||
|
"admin_settings.envvar_banner.body": "Os marcadores de posición como «${VAR:default}» substitúense na memoria durante o inicio da aplicación; nunca se escribir neste ficheiro. Edita as variables no teu contorno (Docker compose, systemd, .env) para cambiar o valor resolto ou substitúe o marcador de posición aquí por unha cadea de texto literal. Cambia á lapela «Efectivo» para ver o que está a usar Etherpad agora mesmo.",
|
||||||
|
"admin_settings.toast.auth_error": "Non te conectaches cunha conta administrativa. Inicia sesión de novo.",
|
||||||
|
"admin_settings.section.general": "Xeral",
|
||||||
|
"admin_settings.parse_error.title": "Non se pode analizar o ficheiro «settings.json»",
|
||||||
|
"admin_settings.parse_error.cta": "Cambiar ao editor en bruto para editar",
|
||||||
|
"admin_settings.env_pill.tooltip": "Le a variable de contorno «{{variable}}». O valor que aparece a continuación úsase cando a variable «{{variable}}» non está definida.",
|
||||||
|
"admin_settings.env_pill.default_label": "predeterminado",
|
||||||
|
"admin_settings.env_pill.input_aria": "Valor predeterminado para «{{variable}}»",
|
||||||
|
"admin_settings.env_pill.runtime_label": "valor activo",
|
||||||
|
"admin_settings.env_pill.runtime_tooltip": "Etherpad está a usar este valor actualmente, resolto a partir de «{{variable}}» ou o seu valor predeterminado.",
|
||||||
|
"admin_settings.env_pill.redacted_tooltip": "Etherpad está a usar un valor para «{{variable}}», pero está oculto porque é un segredo.",
|
||||||
"admin_settings.page-title": "Axustes - Etherpad",
|
"admin_settings.page-title": "Axustes - Etherpad",
|
||||||
"admin_settings.save_error": "Produciuse un erro ao gardar os axustes",
|
"admin_settings.save_error": "Produciuse un erro ao gardar os axustes",
|
||||||
"admin_settings.saved_success": "Os axustes gardáronse correctamente",
|
"admin_settings.saved_success": "Os axustes gardáronse correctamente",
|
||||||
|
|
@ -152,6 +187,8 @@
|
||||||
"update.page.policy.rollback-failed-terminal": "Fallou unha actualización anterior e non se puido reverter. Preme en «Son consciente» despois de que a instalación estea correcta para levantar o bloqueo.",
|
"update.page.policy.rollback-failed-terminal": "Fallou unha actualización anterior e non se puido reverter. Preme en «Son consciente» despois de que a instalación estea correcta para levantar o bloqueo.",
|
||||||
"update.page.policy.up-to-date": "Estás a executar a última versión.",
|
"update.page.policy.up-to-date": "Estás a executar a última versión.",
|
||||||
"update.page.policy.tier-off": "As actualizacións están desactivadas (updates.tier = \"off\").",
|
"update.page.policy.tier-off": "As actualizacións están desactivadas (updates.tier = \"off\").",
|
||||||
|
"update.page.policy.maintenance-window-missing": "O nivel 4 (autónomo) necesita un período de mantemento. Define «updates.maintenanceWindow» en «settings.json» para activar as actualizacións autónomas.",
|
||||||
|
"update.page.policy.maintenance-window-invalid": "O nivel 4 (autónomo) está desactivado porque «updates.maintenanceWindow» ten un formato incorrecto. Esperábase «{inicio, fin, zona horaria}», con horas HH:MM e zona horaria «local» ou «utc».",
|
||||||
"update.page.last_result.verified": "Verificouse a última actualización a {{tag}}.",
|
"update.page.last_result.verified": "Verificouse a última actualización a {{tag}}.",
|
||||||
"update.page.last_result.rolled-back": "Reverteuse o último intento de actualización a {{tag}}: {{reason}}.",
|
"update.page.last_result.rolled-back": "Reverteuse o último intento de actualización a {{tag}}: {{reason}}.",
|
||||||
"update.page.last_result.rollback-failed": "Fallou o último intento de actualización E fallou a reversión: {{reason}}. Cómpre unha intervención manual.",
|
"update.page.last_result.rollback-failed": "Fallou o último intento de actualización E fallou a reversión: {{reason}}. Cómpre unha intervención manual.",
|
||||||
|
|
@ -170,9 +207,16 @@
|
||||||
"update.execution.rollback-failed": "Fallou a reversión",
|
"update.execution.rollback-failed": "Fallou a reversión",
|
||||||
"update.banner.terminal.rollback-failed": "Fallou un intento de actualización e non se puido reverter. Cómpre unha intervención manual.",
|
"update.banner.terminal.rollback-failed": "Fallou un intento de actualización e non se puido reverter. Cómpre unha intervención manual.",
|
||||||
"update.banner.scheduled": "Actualización automática programada a {{tag}}: aplicarase en {{remaining}}.",
|
"update.banner.scheduled": "Actualización automática programada a {{tag}}: aplicarase en {{remaining}}.",
|
||||||
|
"update.banner.maintenance-window-missing": "As actualizacións autónomas están desactivadas ata que se configure un período de mantemento.",
|
||||||
|
"update.banner.maintenance-window-invalid": "As actualizacións autónomas están desactivadas porque o período de mantemento ten un formato incorrecto.",
|
||||||
"update.page.scheduled.title": "Actualización programada",
|
"update.page.scheduled.title": "Actualización programada",
|
||||||
"update.page.scheduled.countdown": "Etherpad comezará a actualizarse a {{tag}} en {{remaining}}.",
|
"update.page.scheduled.countdown": "Etherpad comezará a actualizarse a {{tag}} en {{remaining}}.",
|
||||||
|
"update.page.scheduled.deferred_until": "Fóra do período de mantemento. A actualización comezará cando se inicie o período ás {{at}}.",
|
||||||
"update.page.scheduled.apply_now": "Aplicar agora",
|
"update.page.scheduled.apply_now": "Aplicar agora",
|
||||||
|
"update.window.title": "Período de mantemento",
|
||||||
|
"update.window.summary": "{{start}}–{{end}} ({{tz}})",
|
||||||
|
"update.window.unset": "Sen configurar.",
|
||||||
|
"update.window.next_opens_at": "O seguinte período comeza ás {{at}}.",
|
||||||
"update.drain.t60": "Etherpad reiniciarase en 60 segundos para aplicar unha actualización.",
|
"update.drain.t60": "Etherpad reiniciarase en 60 segundos para aplicar unha actualización.",
|
||||||
"update.drain.t30": "Etherpad reiniciarase en 30 segundos para aplicar unha actualización.",
|
"update.drain.t30": "Etherpad reiniciarase en 30 segundos para aplicar unha actualización.",
|
||||||
"update.drain.t10": "Etherpad reiniciarase en 10 segundos para aplicar unha actualización.",
|
"update.drain.t10": "Etherpad reiniciarase en 10 segundos para aplicar unha actualización.",
|
||||||
|
|
@ -188,6 +232,7 @@
|
||||||
"index.copyLinkButton": "Copiar a ligazón no portapapeis",
|
"index.copyLinkButton": "Copiar a ligazón no portapapeis",
|
||||||
"index.transferToSystem": "3. Copia a sesión no novo sistema",
|
"index.transferToSystem": "3. Copia a sesión no novo sistema",
|
||||||
"index.transferToSystemDescription": "Abre a ligazón copiada no navegador ou dispositivo de destino para transferir a túa sesión.",
|
"index.transferToSystemDescription": "Abre a ligazón copiada no navegador ou dispositivo de destino para transferir a túa sesión.",
|
||||||
|
"index.code": "Código",
|
||||||
"index.transferSessionDescription": "Transfire a túa sesión actual ao navegador ou dispositivo facendo clic no botón de embaixo. Isto copiará unha ligazón cara a unha páxina que transferirá a túa sesión cando se abra no navegador ou dispositivo de destino.",
|
"index.transferSessionDescription": "Transfire a túa sesión actual ao navegador ou dispositivo facendo clic no botón de embaixo. Isto copiará unha ligazón cara a unha páxina que transferirá a túa sesión cando se abra no navegador ou dispositivo de destino.",
|
||||||
"index.createOpenPad": "Abrir un documento por nome",
|
"index.createOpenPad": "Abrir un documento por nome",
|
||||||
"index.openPad": "abrir un documento existente co nome:",
|
"index.openPad": "abrir un documento existente co nome:",
|
||||||
|
|
@ -278,27 +323,27 @@
|
||||||
"pad.modals.userdup.explanation": "Semella que este documento está aberto en varias ventás do navegador neste ordenador.",
|
"pad.modals.userdup.explanation": "Semella que este documento está aberto en varias ventás do navegador neste ordenador.",
|
||||||
"pad.modals.userdup.advice": "Reconectar para usar esta ventá.",
|
"pad.modals.userdup.advice": "Reconectar para usar esta ventá.",
|
||||||
"pad.modals.unauth": "Non autorizado",
|
"pad.modals.unauth": "Non autorizado",
|
||||||
"pad.modals.unauth.explanation": "Os seus permisos cambiaron mentres estaba nesta páxina. Intente a reconexión.",
|
"pad.modals.unauth.explanation": "Os geus permisos cambiaron mentres estabas nesta páxina. Intenta reconectarte.",
|
||||||
"pad.modals.looping.explanation": "Hai un problema de comunicación co servidor de sincronización.",
|
"pad.modals.looping.explanation": "Hai un problema de comunicación co servidor de sincronización.",
|
||||||
"pad.modals.looping.cause": "Seica a súa conexión pasa a través dun firewall ou proxy incompatible.",
|
"pad.modals.looping.cause": "Seica a túa conexión pasa a través dun firewall ou proxy incompatible.",
|
||||||
"pad.modals.initsocketfail": "Non se pode alcanzar o servidor.",
|
"pad.modals.initsocketfail": "Non se pode alcanzar o servidor.",
|
||||||
"pad.modals.initsocketfail.explanation": "Non se pode conectar co servidor de sincronización.",
|
"pad.modals.initsocketfail.explanation": "Non se pode conectar co servidor de sincronización.",
|
||||||
"pad.modals.initsocketfail.cause": "Isto acontece probablemente debido a un problema co navegador ou coa conexión á internet.",
|
"pad.modals.initsocketfail.cause": "Isto acontece probablemente debido a un problema co navegador ou coa conexión a Internet.",
|
||||||
"pad.modals.slowcommit.explanation": "O servidor non responde.",
|
"pad.modals.slowcommit.explanation": "O servidor non responde.",
|
||||||
"pad.modals.slowcommit.cause": "Isto pode deberse a un problema de conexión á rede.",
|
"pad.modals.slowcommit.cause": "Isto pode deberse a un problema de conexión á rede.",
|
||||||
"pad.modals.badChangeset.explanation": "O servidor de sincronización clasificou como ilegal unha das súas edicións.",
|
"pad.modals.badChangeset.explanation": "O servidor de sincronización clasificou como ilegal unha das túas edicións.",
|
||||||
"pad.modals.badChangeset.cause": "Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Póñase en contacto co administrador do servizo, se pensa que isto é un erro. Intente reconectar para continuar editando.",
|
"pad.modals.badChangeset.cause": "Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Ponte en contacto coa administración do servizo se pensas que isto é un erro. Intenta reconectar para continuar editando.",
|
||||||
"pad.modals.corruptPad.explanation": "O documento ao que intenta acceder está corrompido.",
|
"pad.modals.corruptPad.explanation": "O documento ao que intentas acceder está corrompido.",
|
||||||
"pad.modals.corruptPad.cause": "Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Póñase en contacto co administrador do servizo.",
|
"pad.modals.corruptPad.cause": "Isto pode deberse a unha cofiguración errónea do servidor ou algún outro comportamento inesperado. Ponte en contacto coa administración do servizo.",
|
||||||
"pad.modals.deleted": "Borrado.",
|
"pad.modals.deleted": "Borrado.",
|
||||||
"pad.modals.deleted.explanation": "Este documento foi eliminado.",
|
"pad.modals.deleted.explanation": "Este documento foi eliminado.",
|
||||||
"pad.modals.rateLimited": "Taxa limitada.",
|
"pad.modals.rateLimited": "Taxa limitada.",
|
||||||
"pad.modals.rateLimited.explanation": "Enviaches demasiadas mensaxes a este documento polo que te desconectamos.",
|
"pad.modals.rateLimited.explanation": "Enviaches demasiadas mensaxes a este documento polo que te desconectamos.",
|
||||||
"pad.modals.rejected.explanation": "O servidor rexeitou unha mensaxe que o teu navegador enviou.",
|
"pad.modals.rejected.explanation": "O servidor rexeitou unha mensaxe que o teu navegador enviou.",
|
||||||
"pad.modals.rejected.cause": "O servidor podería ter sido actualizado mentras ollabas o documento, ou pode que sexa un fallo de Etherpad. Intenta recargar a páxina.",
|
"pad.modals.rejected.cause": "O servidor podería ter sido actualizado mentras ollabas o documento, ou pode que sexa un fallo de Etherpad. Intenta recargar a páxina.",
|
||||||
"pad.modals.disconnected": "Foi desconectado.",
|
"pad.modals.disconnected": "Desconectácheste.",
|
||||||
"pad.modals.disconnected.explanation": "Perdeuse a conexión co servidor",
|
"pad.modals.disconnected.explanation": "Perdeuse a conexión co servidor",
|
||||||
"pad.modals.disconnected.cause": "O servidor non está dispoñible. Póñase en contacto co administrador do servizo se o problema continúa.",
|
"pad.modals.disconnected.cause": "O servidor non está dispoñible. Ponte en contacto coa administración do servizo se o problema continúa.",
|
||||||
"pad.gritter.unacceptedCommit.title": "Edición sen gardar",
|
"pad.gritter.unacceptedCommit.title": "Edición sen gardar",
|
||||||
"pad.gritter.unacceptedCommit.text": "A túa edición recente aínda non está gardada. Conéctate e inténtao de novo.",
|
"pad.gritter.unacceptedCommit.text": "A túa edición recente aínda non está gardada. Conéctate e inténtao de novo.",
|
||||||
"pad.share": "Compartir este documento",
|
"pad.share": "Compartir este documento",
|
||||||
|
|
@ -326,8 +371,13 @@
|
||||||
"pad.historyMode.settings.playbackSpeed": "Velocidade de reprodución:",
|
"pad.historyMode.settings.playbackSpeed": "Velocidade de reprodución:",
|
||||||
"pad.historyMode.chat.replayHeader": "Conversa o {{time}}",
|
"pad.historyMode.chat.replayHeader": "Conversa o {{time}}",
|
||||||
"pad.historyMode.users.authorsHeader": "Autores nesta revisión",
|
"pad.historyMode.users.authorsHeader": "Autores nesta revisión",
|
||||||
|
"pad.editor.skipToContent": "Ir ata o editor",
|
||||||
|
"pad.editor.keyboardHint": "Preme a tecla Esc para saír do editor. Preme Alt+F9 para acceder á barra de ferramentas.",
|
||||||
|
"pad.editor.toolbar.formatting": "Barras de ferramentas de formato",
|
||||||
|
"pad.editor.toolbar.actions": "Barras de ferramentas de acción",
|
||||||
|
"pad.editor.toolbar.showMore": "Mostrar máis botóns da barra de ferramentas",
|
||||||
"timeslider.toolbar.authors": "Editoras:",
|
"timeslider.toolbar.authors": "Editoras:",
|
||||||
"timeslider.toolbar.authorsList": "Sen Editoras",
|
"timeslider.toolbar.authorsList": "Sen editoras",
|
||||||
"timeslider.toolbar.exportlink.title": "Exportar",
|
"timeslider.toolbar.exportlink.title": "Exportar",
|
||||||
"timeslider.exportCurrent": "Exportar a versión actual en formato:",
|
"timeslider.exportCurrent": "Exportar a versión actual en formato:",
|
||||||
"timeslider.version": "Versión {{version}}",
|
"timeslider.version": "Versión {{version}}",
|
||||||
|
|
@ -356,19 +406,20 @@
|
||||||
"timeslider.month.december": "decembro",
|
"timeslider.month.december": "decembro",
|
||||||
"timeslider.unnamedauthors": "{{num}} {[plural(num) one: editora anónima, other: editora anónima ]}",
|
"timeslider.unnamedauthors": "{{num}} {[plural(num) one: editora anónima, other: editora anónima ]}",
|
||||||
"pad.savedrevs.marked": "Esta revisión está agora marcada como revisión gardada",
|
"pad.savedrevs.marked": "Esta revisión está agora marcada como revisión gardada",
|
||||||
"pad.savedrevs.timeslider": "Pode consultar as revisións gardadas visitando a cronoloxía",
|
"pad.savedrevs.timeslider": "Podes consultar as revisións gardadas visitando a cronoloxía",
|
||||||
"pad.userlist.entername": "Insira o seu nome",
|
"pad.userlist.entername": "Insire o teu nome",
|
||||||
"pad.userlist.unnamed": "anónimo",
|
"pad.userlist.unnamed": "anónimo",
|
||||||
"pad.editbar.clearcolors": "Eliminar as cores relativas ás participantes en todo o documento? Non se poderán recuperar",
|
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} usuario conectado, other: {{count}} usuarios conectados ]}",
|
||||||
|
"pad.editbar.clearcolors": "Eliminar as cores relativas ás participantes en todo o documento? Non se poderán recuperar.",
|
||||||
"pad.impexp.importbutton": "Importar agora",
|
"pad.impexp.importbutton": "Importar agora",
|
||||||
"pad.impexp.importing": "Importando...",
|
"pad.impexp.importing": "Importando...",
|
||||||
"pad.impexp.confirmimport": "A importación dun ficheiro ha sobrescribir o texto actual do documento. Está seguro de querer continuar?",
|
"pad.impexp.confirmimport": "A importación dun ficheiro ha sobrescribir o texto actual do documento. Queres continuar?",
|
||||||
"pad.impexp.convertFailed": "Non somos capaces de importar o ficheiro. Utilice un formato de documento diferente ou copie e pegue manualmente",
|
"pad.impexp.convertFailed": "Non somos capaces de importar o ficheiro. Utiliza un formato de documento diferente ou copia e pega manualmente.",
|
||||||
"pad.impexp.padHasData": "Non puidemos importar este ficheiro porque este documento xa sufriu cambios; importe a un novo documento.",
|
"pad.impexp.padHasData": "Non puidemos importar este ficheiro porque este documento xa sufriu cambios; importa a un novo documento.",
|
||||||
"pad.impexp.uploadFailed": "Houbo un erro ao cargar o ficheiro; inténteo de novo",
|
"pad.impexp.uploadFailed": "Houbo un erro ao subir o ficheiro; inténtao de novo",
|
||||||
"pad.impexp.importfailed": "Fallou a importación",
|
"pad.impexp.importfailed": "Fallou a importación",
|
||||||
"pad.impexp.copypaste": "Copie e pegue",
|
"pad.impexp.copypaste": "Copia e pega",
|
||||||
"pad.impexp.exportdisabled": "A exportación en formato {{type}} está desactivada. Póñase en contacto co administrador do sistema se quere máis detalles.",
|
"pad.impexp.exportdisabled": "A exportación en formato {{type}} está desactivada. Ponte en contacto coa administración do sistema se queres máis detalles.",
|
||||||
"pad.impexp.maxFileSize": "Ficheiro demasiado granda. Contacta coa administración para aumentar o tamaño permitido para importacións",
|
"pad.impexp.maxFileSize": "O ficheiro é demasiado grande. Contacta coa administración para aumentar o tamaño permitido para as importacións.",
|
||||||
"pad.social.description": "Un documento colaborativo que calquera pode editar en tempo real."
|
"pad.social.description": "Un documento colaborativo que calquera pode editar en tempo real."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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