Merge branch 'develop'

This commit is contained in:
Etherpad Release Bot 2026-05-22 17:44:57 +00:00
commit da00ad7057
101 changed files with 8424 additions and 1730 deletions

View file

@ -30,7 +30,8 @@ If applicable, add screenshots to help explain your problem.
- OS: [e.g., Ubuntu 20.04]
- Node.js version (`node --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):**
- OS: [e.g. iOS]

View file

@ -146,7 +146,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
ep_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
@ -289,7 +289,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
ep_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript

View file

@ -66,7 +66,13 @@ jobs:
name: Test
working-directory: etherpad
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
docker logs -f test &
while true; do
@ -79,6 +85,34 @@ jobs:
esac
done
(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
git clean -dxf .
-

View file

@ -219,7 +219,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
ep_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript
@ -308,7 +308,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
ep_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript

View file

@ -93,7 +93,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
ep_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript

View file

@ -76,7 +76,7 @@ jobs:
ep_hash_auth
ep_headings2
ep_markdown
ep_readonly_guest
ep_guest
ep_set_title_on_pad
ep_spellcheck
ep_subscript_and_superscript

View file

@ -1,9 +1,42 @@
# 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 012 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 012 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 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
- **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).
- **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'`.
@ -14,6 +47,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 — 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

View file

@ -64,3 +64,25 @@ const SettingsPanel = () => {
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
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

View file

@ -1,7 +1,7 @@
{
"name": "admin",
"private": true,
"version": "3.1.0",
"version": "3.2.0",
"type": "module",
"scripts": {
"dev": "pnpm gen:api && vite",
@ -11,12 +11,12 @@
"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",
"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": {
"@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10",
"@tanstack/react-query": "^5.100.11",
"@tanstack/react-query-devtools": "^5.100.11",
"jsonc-parser": "^3.3.1",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4"
@ -24,10 +24,10 @@
"devDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-toast": "^1.2.15",
"@types/react": "^19.2.14",
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.3",
"@typescript-eslint/parser": "^8.59.3",
"@typescript-eslint/eslint-plugin": "^8.59.4",
"@typescript-eslint/parser": "^8.59.4",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"eslint": "^10.4.0",
@ -39,14 +39,14 @@
"openapi-typescript": "^7.13.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.75.0",
"react-hook-form": "^7.76.0",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.15.1",
"socket.io-client": "^4.8.3",
"tsx": "^4.22.0",
"tsx": "^4.22.3",
"typescript": "^6.0.3",
"vite": "^8.0.13",
"vite-plugin-babel": "^1.7.1",
"vite": "^8.0.14",
"vite-plugin-babel": "^1.7.3",
"zustand": "^5.0.13"
}
}

View file

@ -233,6 +233,32 @@ textarea.settings:focus {
outline: 2px solid var(--accent, #2b8a3e);
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) */
.settings-widget-boolean {

View file

@ -56,10 +56,14 @@ export const App = () => {
}
if (settings.results === 'NOT_ALLOWED') {
console.log('Not allowed to view settings.json')
useStore.getState().setResolved(null);
return;
}
if (isJSONClean(settings.results)) setSettings(settings.results);
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);
});

View file

@ -9,6 +9,7 @@ import { NumberInput } from './widgets/NumberInput';
import { BooleanToggle } from './widgets/BooleanToggle';
import { NullChip } from './widgets/NullChip';
import { EnvPill } from './widgets/EnvPill';
import { useResolvedAt } from '../../store/store';
type Props = {
/** The value node (not the property node). */
@ -36,6 +37,7 @@ const renderLeaf = (
path: JSONPath,
text: string,
onEdit: (path: JSONPath, value: unknown) => void,
resolvedValue: unknown,
) => {
if (node.type === 'string') {
const raw = text.slice(node.offset, node.offset + node.length);
@ -46,6 +48,7 @@ const renderLeaf = (
placeholder={env}
path={path}
onChange={(d) => onEdit(path, `\${${env.variable}:${d}}`)}
resolvedValue={resolvedValue}
/>
);
}
@ -84,6 +87,11 @@ const renderLeaf = (
export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: Props) => {
const path = getNodePath(node);
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 fileComments = extractAdjacentComments(text, anchor.offset, node.offset, node.length);
@ -163,7 +171,7 @@ export const JsoncNode = ({ node, property, text, onEdit, suppressOwnHeader }: P
{label}
</label>
<div className="settings-row-control">
{renderLeaf(node, path, text, onEdit)}
{renderLeaf(node, path, text, onEdit, resolvedValue)}
</div>
{help && (
<p className="settings-row-help" id={helpId}>{help}</p>

View file

@ -3,22 +3,29 @@ import { useTranslation } from 'react-i18next';
import type { JSONPath } from 'jsonc-parser';
import type { EnvPlaceholder } from '../envPill';
const REDACTED = '[REDACTED]';
type Props = {
placeholder: EnvPlaceholder;
path: JSONPath;
onChange: (newDefault: string) => void;
resolvedValue?: unknown;
};
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 initial = placeholder.defaultValue ?? '';
const [draft, setDraft] = useState(initial);
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(() => {
if (!focused.current) setDraft(initial);
}, [initial]);
@ -26,6 +33,13 @@ export const EnvPill = ({ placeholder, path, onChange }: Props) => {
const id = `field-${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 (
<span
className="settings-widget settings-widget-env"
@ -52,6 +66,31 @@ export const EnvPill = ({ placeholder, path, onChange }: Props) => {
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>
);
};

View file

@ -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}`);
});

View file

@ -1,7 +1,7 @@
import {Trans, useTranslation} from "react-i18next";
import {useEffect, useMemo, useState} from "react";
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 * as Dialog from "@radix-ui/react-dialog";
import {ChevronLeft, ChevronRight, Eye, Trash2, FileStack, PlusIcon, Search, X, RefreshCw, History} from "lucide-react";
@ -9,12 +9,8 @@ import {useForm} from "react-hook-form";
import type {TFunction} from "i18next";
type PadCreateProps = { padName: string }
type FilterId = 'all' | 'active' | 'recent' | 'empty' | 'stale'
const PAD_FILTER_IDS: FilterId[] = ['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
const PAD_FILTER_IDS: PadFilter[] = ['all', 'active', 'recent', 'empty', 'stale']
function relativeTime(t: TFunction, ts: number): string {
const d = (Date.now() - ts) / 1000
@ -58,12 +54,23 @@ function sanitizeLocale(lng?: string): string {
export const PadPage = () => {
const settingsSocket = useStore(state => state.settingsSocket)
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 locale = sanitizeLocale(i18n.resolvedLanguage ?? i18n.language)
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 pads = useStore(state => state.pads)
const [currentPage, setCurrentPage] = useState(0)
@ -78,28 +85,23 @@ export const PadPage = () => {
[pads, searchParams.limit]
)
const filteredResults = useMemo(() => {
const r = pads?.results ?? []
if (filter === 'active') return r.filter(p => p.userCount > 0)
if (filter === 'recent') return r.filter(p => isRecent(p.lastEdited))
if (filter === 'empty') return r.filter(p => p.revisionNumber === 0)
if (filter === 'stale') return r.filter(p => isStale(p.lastEdited))
return r
}, [pads, filter])
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])
// The server applies `filter` before paginating; the page payload is
// already the filtered slice. The stats cards still reflect the
// current page (pre-existing behaviour) — making them global would
// require a separate aggregate query.
const visibleResults = pads?.results ?? []
const totalUsers = useMemo(() => visibleResults.reduce((s, p) => s + p.userCount, 0), [pads])
const activeCount = useMemo(() => visibleResults.filter(p => p.userCount > 0).length, [pads])
const emptyCount = useMemo(() => visibleResults.filter(p => p.revisionNumber === 0).length, [pads])
const lastActivity = useMemo(() => {
const r = pads?.results ?? []
return r.length ? Math.max(...r.map(p => p.lastEdited)) : null
return visibleResults.length ? Math.max(...visibleResults.map(p => p.lastEdited)) : null
}, [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 s = new Set(selected)
if (allSelected) filteredResults.forEach(p => s.delete(p.padName))
else filteredResults.forEach(p => s.add(p.padName))
if (allSelected) visibleResults.forEach(p => s.delete(p.padName))
else visibleResults.forEach(p => s.add(p.padName))
setSelected(s)
}
const toggleOne = (name: string) => {
@ -109,7 +111,10 @@ export const PadPage = () => {
}
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])
useEffect(() => {
@ -250,7 +255,7 @@ export const PadPage = () => {
<section className="pm-section">
<div className="pm-section-header">
<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-toolbar">
<div className="pm-search">
@ -268,12 +273,12 @@ export const PadPage = () => {
<select
className="pm-select"
value={searchParams.sortBy}
onChange={e => setSearchParams({
...searchParams,
onChange={e => setSearchParams((sp) => ({
...sp,
sortBy: e.target.value,
// Keep current direction when only the column changes; the
// ↑/↓ button below is the sole control for direction.
})}
}))}
>
<option value="lastEdited">{t('ep_admin_pads:ep_adminpads2_last-edited')}</option>
<option value="padName">{t('admin_pads.sort.name')}</option>
@ -282,10 +287,10 @@ export const PadPage = () => {
</select>
<button
className="pm-sort-dir"
onClick={() => setSearchParams({
...searchParams,
ascending: !searchParams.ascending,
})}
onClick={() => setSearchParams((sp) => ({
...sp,
ascending: !sp.ascending,
}))}
title={t(searchParams.ascending
? 'admin_plugins.sort_ascending'
: 'admin_plugins.sort_descending')}
@ -330,7 +335,7 @@ export const PadPage = () => {
</div>
)}
{filteredResults.length > 0 ? (
{visibleResults.length > 0 ? (
<div className="pm-table-wrap">
<table>
<thead>
@ -348,7 +353,7 @@ export const PadPage = () => {
</tr>
</thead>
<tbody>
{filteredResults.map(pad => {
{visibleResults.map(pad => {
const isEmpty = pad.revisionNumber === 0
const isSel = selected.has(pad.padName)
return (
@ -432,7 +437,7 @@ export const PadPage = () => {
onClick={() => {
const p = currentPage - 1
setCurrentPage(p)
setSearchParams({...searchParams, offset: p * searchParams.limit})
setSearchParams((sp) => ({...sp, offset: p * sp.limit}))
}}
>
<ChevronLeft size={14}/> <Trans i18nKey="admin_pads.pagination.previous"/>
@ -444,7 +449,7 @@ export const PadPage = () => {
onClick={() => {
const p = currentPage + 1
setCurrentPage(p)
setSearchParams({...searchParams, offset: p * searchParams.limit})
setSearchParams((sp) => ({...sp, offset: p * sp.limit}))
}}
>
<Trans i18nKey="admin_pads.pagination.next"/> <ChevronRight size={14}/>

View file

@ -1,8 +1,10 @@
import {create} from "zustand";
import {Socket} from "socket.io-client";
import type {JSONPath} from "jsonc-parser";
import {PadSearchResult} from "../utils/PadSearch.ts";
import {AuthorSearchResult} from "../utils/AuthorSearch.ts";
import {InstalledPlugin} from "../pages/Plugin.ts";
import {resolveByPath} from "../utils/resolveByPath.ts";
export type Execution =
| {status: 'idle'}
@ -45,7 +47,6 @@ export interface UpdateStatusPayload {
installMethod: string;
tier: string;
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
vulnerableBelow: Array<{announcedBy: string; threshold: string}>;
// Tier 2 additions:
execution: Execution;
lastResult: LastResult;
@ -66,6 +67,11 @@ type ToastState = {
type StoreState = {
settings: string|undefined,
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,
setSettingsSocket: (socket: Socket) => void,
showLoading: boolean,
@ -92,6 +98,8 @@ type StoreState = {
export const useStore = create<StoreState>()((set) => ({
settings: undefined,
setSettings: (settings: string) => set({settings}),
resolved: null,
setResolved: (resolved) => set({resolved}),
settingsSocket: undefined,
setSettingsSocket: (socket: Socket) => set({settingsSocket: socket}),
showLoading: false,
@ -118,3 +126,6 @@ export const useStore = create<StoreState>()((set) => ({
gdprAuthorErasureEnabled: false,
setGdprAuthorErasureEnabled: (gdprAuthorErasureEnabled)=>set({gdprAuthorErasureEnabled}),
}));
export const useResolvedAt = (path: JSONPath): unknown =>
useStore(s => resolveByPath(s.resolved, path));

View file

@ -1,9 +1,12 @@
export type PadFilter = 'all' | 'active' | 'recent' | 'empty' | 'stale';
export type PadSearchQuery = {
pattern: string;
offset: number;
limit: number;
ascending: boolean;
sortBy: string;
filter?: PadFilter;
}

View 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);
});

View 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;
};

View file

@ -1,6 +1,6 @@
{
"name": "bin",
"version": "3.1.0",
"version": "3.2.0",
"description": "",
"main": "checkAllPads.js",
"directories": {
@ -9,12 +9,12 @@
"dependencies": {
"ep_etherpad-lite": "workspace:../src",
"log4js": "^6.9.1",
"semver": "^7.8.0",
"tsx": "^4.22.0",
"ueberdb2": "^6.0.3"
"semver": "^7.8.1",
"tsx": "^4.22.3",
"ueberdb2": "^6.1.2"
},
"devDependencies": {
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/semver": "^7.7.1",
"typescript": "^6.0.3"
},

View file

@ -2,7 +2,7 @@
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 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.
@ -52,30 +52,27 @@ In `settings.json`:
## What "outdated" means
- **`severe`** — running at least one major version behind the latest release.
- **`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.
- **`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.
## Email cadence (when `adminEmail` is set)
| Trigger | First send | Repeat |
| --- | --- | --- |
| Vulnerable status detected | Immediate | Weekly while still vulnerable |
| New release announced while still vulnerable | Immediate | n/a (one event per tag change) |
| Severely outdated detected | Immediate | Monthly while still severely outdated |
| Outdated (minor or more behind) detected | Immediate | Monthly while still outdated |
| 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.
If `adminEmail` is unset, the updater never sends mail. The admin UI banner and the pad-side notice still work without it.
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.
## Pad-side badge
## Pad-side notice
Pad users see no version information by default. A small badge appears in the bottom-right corner only when:
Pad users see no version information by default. A dismissable gritter notification appears only when:
- The instance is `severe` (one or more major versions behind), or
- The instance is `vulnerable` (running below an announced threshold).
- 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 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.
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

View file

@ -712,3 +712,31 @@ get stats of the etherpad instance
_Example returns_:
* `{"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'`.

View file

@ -764,3 +764,24 @@ get stats of the etherpad instance
{"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'`.

View file

@ -1,6 +1,6 @@
{
"devDependencies": {
"oxc-minify": "^0.131.0",
"oxc-minify": "^0.132.0",
"vitepress": "^2.0.0-alpha.17"
},
"scripts": {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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.

View file

@ -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.

View file

@ -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 ~119131). 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.

View file

@ -51,7 +51,7 @@
"url": "https://github.com/ether/etherpad.git"
},
"engineStrict": true,
"version": "3.1.0",
"version": "3.2.0",
"license": "Apache-2.0",
"pnpm": {
"onlyBuiltDependencies": [

1612
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -535,6 +535,15 @@
*
* The other effect will be that the logs will contain the real client'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,
@ -625,6 +634,12 @@
/*
* Whether to periodically clean up expired and stale sessions from the
* 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
},

View file

@ -9,6 +9,7 @@
]
},
"admin.page-title": "Адміністрацыйная панэль — Etherpad",
"admin.loading": "Ладаваньне...",
"admin_plugins": "Кіраўнік плагінаў",
"admin_plugins.available": "Даступныя плагіны",
"admin_plugins.available_not-found": "Плагіны ня знойдзеныя.",

View file

@ -139,6 +139,9 @@
"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.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.save_error": "Error saving settings",
"admin_settings.saved_success": "Successfully saved settings",

View file

@ -15,6 +15,7 @@
"Goofy",
"Goofy-bz",
"Jean-Frédéric",
"Jérémy-Günther-Heinz Jähnick",
"Leviathan",
"Macofe",
"Mahabarata",
@ -67,6 +68,7 @@
"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_save.value": "Enregistrer les paramètres",
"admin_settings.mode.aria_label": "Mode éditeur",
"admin_settings.page-title": "Paramètres — Etherpad",
"update.page.apply": "Appliquer la mise à jour",
"update.page.cancel": "Annuler",

View file

@ -66,11 +66,14 @@
"admin_plugins.available_search.placeholder": "Cuardaigh breiseáin le suiteáil",
"admin_plugins.check_updates": "Seiceáil le haghaidh nuashonruithe",
"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.description": "Cur síos",
"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.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_fetching": "Ag fáil breiseáin suiteáilte…",
"admin_plugins.installed_nothing": "Níl aon bhreiseáin suiteáilte agat fós.",
@ -122,6 +125,28 @@
"admin_settings.current_restart.value": "Atosaigh Etherpad",
"admin_settings.current_save.value": "Sábháil Socruithe",
"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.aria_label": "Mód eagarthóireachta",
"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.save_error": "Earráid ag sábháil socruithe",
"admin_settings.saved_success": "Socruithe sábháilte go rathúil",
@ -150,12 +175,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.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.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.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.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.execution.idle": "Díomhaoin",
"update.execution.scheduled": "Nuashonrú sceidealaithe",
"update.execution.preflight": "Seiceálacha réamh-eitilte",
"update.execution.preflight-failed": "Theip ar an réamh-eitilt",
"update.execution.draining": "Seisiúin draenála",
@ -166,6 +194,17 @@
"update.execution.rolled-back": "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.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.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.",
@ -318,6 +357,11 @@
"pad.historyMode.settings.playbackSpeed": "Luas athsheinm:",
"pad.historyMode.chat.replayHeader": "Comhrá ó {{time}}",
"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.authorsList": "Gan Údair",
"timeslider.toolbar.exportlink.title": "Easpórtáil",
@ -351,6 +395,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.userlist.entername": "Cuir isteach d'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.impexp.importbutton": "Iompórtáil Anois",
"pad.impexp.importing": "Ag allmhairiú...",

View file

@ -68,11 +68,14 @@
"admin_plugins.available_search.placeholder": "Buscar complementos para instalar",
"admin_plugins.check_updates": "Comprobar se hai actualizacións",
"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.description": "Descrición",
"admin_plugins.disables.label": "Desactiva:",
"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.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_fetching": "Obtendo os complementos instalados...",
"admin_plugins.installed_nothing": "Aínda non instalaches ningún complemento.",
@ -124,6 +127,28 @@
"admin_settings.current_restart.value": "Reiniciar Etherpad",
"admin_settings.current_save.value": "Gardar axustes",
"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.aria_label": "Modo de edición",
"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.save_error": "Produciuse un erro ao gardar os axustes",
"admin_settings.saved_success": "Os axustes gardáronse correctamente",
@ -152,6 +177,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.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.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.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.",
@ -170,9 +197,16 @@
"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.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.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.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.t30": "Etherpad reiniciarase en 30 segundos para aplicar unha actualización.",
"update.drain.t10": "Etherpad reiniciarase en 10 segundos para aplicar unha actualización.",
@ -278,27 +312,27 @@
"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.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.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.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.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.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.corruptPad.explanation": "O documento ao que intenta 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.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. 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 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. Ponte en contacto coa administración do servizo.",
"pad.modals.deleted": "Borrado.",
"pad.modals.deleted.explanation": "Este documento foi eliminado.",
"pad.modals.rateLimited": "Taxa limitada.",
"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.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.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.text": "A túa edición recente aínda non está gardada. Conéctate e inténtao de novo.",
"pad.share": "Compartir este documento",
@ -326,8 +360,13 @@
"pad.historyMode.settings.playbackSpeed": "Velocidade de reprodución:",
"pad.historyMode.chat.replayHeader": "Conversa o {{time}}",
"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.authorsList": "Sen Editoras",
"timeslider.toolbar.authorsList": "Sen editoras",
"timeslider.toolbar.exportlink.title": "Exportar",
"timeslider.exportCurrent": "Exportar a versión actual en formato:",
"timeslider.version": "Versión {{version}}",
@ -356,19 +395,20 @@
"timeslider.month.december": "decembro",
"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.timeslider": "Pode consultar as revisións gardadas visitando a cronoloxía",
"pad.userlist.entername": "Insira o seu nome",
"pad.savedrevs.timeslider": "Podes consultar as revisións gardadas visitando a cronoloxía",
"pad.userlist.entername": "Insire o teu nome",
"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.importing": "Importando...",
"pad.impexp.confirmimport": "A importación dun ficheiro ha sobrescribir o texto actual do documento. Está seguro de querer continuar?",
"pad.impexp.convertFailed": "Non somos capaces de importar o ficheiro. Utilice un formato de documento diferente ou copie e pegue manualmente",
"pad.impexp.padHasData": "Non puidemos importar este ficheiro porque este documento xa sufriu cambios; importe a un novo documento.",
"pad.impexp.uploadFailed": "Houbo un erro ao cargar o ficheiro; inténteo de novo",
"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. 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; importa a un novo documento.",
"pad.impexp.uploadFailed": "Houbo un erro ao subir o ficheiro; inténtao de novo",
"pad.impexp.importfailed": "Fallou a importación",
"pad.impexp.copypaste": "Copie e pegue",
"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.maxFileSize": "Ficheiro demasiado granda. Contacta coa administración para aumentar o tamaño permitido para importacións",
"pad.impexp.copypaste": "Copia e pega",
"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": "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."
}

View file

@ -50,6 +50,12 @@
"admin_settings.current_example-prod": "예시 운영용 설정 틀",
"admin_settings.current_restart.value": "이더패드 다시 시작",
"admin_settings.current_save.value": "설정 저장",
"admin_settings.toast.saved": "설정을 성공적으로 저장했습니다.",
"admin_settings.mode.form": "형태",
"admin_settings.mode.raw": "원본",
"admin_settings.section.general": "일반",
"admin_settings.parse_error.cta": "원본 편집으로 전환",
"admin_settings.env_pill.default_label": "기본값",
"admin_settings.page-title": "설정 - 이더패드",
"update.banner.title": "업데이트 사용 가능",
"update.banner.cta": "업데이트 보기",

View file

@ -40,6 +40,7 @@
"admin_plugins.available_install.value": "Installéieren",
"admin_plugins.check_updates": "No Aktualiséierunge sichen",
"admin_plugins.description": "Beschreiwung",
"admin_plugins.install_error": "{{plugin}} konnt net installéiert ginn: {{error}}",
"admin_plugins.installed_uninstall.value": "Desinstalléieren",
"admin_plugins.last-update": "Lescht Aktualiséierung",
"admin_plugins.name": "Numm",
@ -56,10 +57,12 @@
"admin_settings": "Astellungen",
"admin_settings.current_save.value": "Astellunge späicheren",
"admin_settings.invalid_json": "Ongültegen JSON",
"admin_settings.env_pill.runtime_label": "aktive Wäert",
"admin_settings.page-title": "Astellungen - Etherpad",
"admin_settings.save_error": "Feeler beim Späichere vun den Astellungen",
"update.page.cancel": "Ofbriechen",
"update.page.execution": "Status",
"update.window.unset": "Net konfiguréiert.",
"index.newPad": "Neie Pad",
"index.settings": "Astellungen",
"index.copyLink": "2. Link kopéieren",

View file

@ -7,15 +7,75 @@
]
},
"admin.page-title": "Администраторска управувачница — Etherpad",
"admin.loading": "Вчитувам…",
"admin.toggle_sidebar": "Префрли страничник",
"admin.shout": "Општење",
"admin_shout.online_one": "Моментално има {{count}} корисник на линија",
"admin_shout.online_other": "Моментално има {{count}} корисници на линија",
"admin_shout.sticky_toggle": "Смени леплива порака",
"admin_login.title": "Etherpad",
"admin_login.username": "Корисничко име",
"admin_login.password": "Лозинка",
"admin_login.submit": "Најава",
"admin_login.failed": "Најавата не успеа",
"admin_pads.all_pads": "Сите тетратки",
"admin_pads.bulk.cleanup_history": "Исчисти историја",
"admin_pads.bulk.clear_selection": "Исчисти го изборот",
"admin_pads.bulk.delete": "Избриши",
"admin_pads.cancel": "Откажи",
"admin_pads.col.pad": "Тетратка",
"admin_pads.col.revisions": "Преработки",
"admin_pads.col.users": "Корисници",
"admin_pads.confirm_button": "ОК",
"admin_pads.empty_never_edited": "празно · никогаш неуредено",
"admin_pads.error_prefix": "Грешка",
"admin_pads.filter.active": "Активно",
"admin_pads.filter.all": "Сите",
"admin_pads.filter.empty": "Празно",
"admin_pads.filter.recent": "Оваа седмица",
"admin_pads.filter.stale": "Застарено (>1 г.)",
"admin_pads.open": "Отвори",
"admin_pads.pagination.next": "Следно",
"admin_pads.pagination.previous": "Претходно",
"admin_pads.refresh": "Превчитај",
"admin_pads.relative.days": "пред {{count}} ден.",
"admin_pads.relative.hours": "пред {{count}} час.",
"admin_pads.relative.just_now": "штотуку",
"admin_pads.relative.minutes": "пред {{count}} мес.",
"admin_pads.relative.months": "пред {{count}} мес.",
"admin_pads.relative.weeks": "пред {{count}} нед.",
"admin_pads.relative.years": "пред {{count}} год.",
"admin_pads.revisions_count": "{{count}} преработки",
"admin_pads.selected_count": "{{count}} избрани",
"admin_pads.show": "Прикажи",
"admin_pads.sort.name": "Име (А-Ш)",
"admin_pads.sort.revision_number": "Преработки",
"admin_pads.sort.user_count": "Корисници",
"admin_pads.stats.across_pads": "за сите тетратки",
"admin_pads.stats.active_users": "Активни корисници",
"admin_pads.stats.empty_pads": "Празни тетратки",
"admin_pads.stats.last_activity": "Последна активност",
"admin_pads.stats.no_active_users": "Нема активни корисници",
"admin_pads.stats.revisions_zero": "0 преработки",
"admin_pads.stats.total": "Вкупно тетратки",
"admin_pads.stats.users_active": "{{count}} тековно активни",
"admin_pads.subtitle": "Преглед на сите тетратки на овој примерок на Etherpad. Пребарајте, исчистете, отворете.",
"admin_plugins": "Раководител со приклучоци",
"admin_plugins.available": "Приклучоци на располагање",
"admin_plugins.available_not-found": "Не пронајдов ниеден приклучок.",
"admin_plugins.available_fetching": "Земам...",
"admin_plugins.available_install.value": "Воспостави",
"admin_plugins.available_search.placeholder": "Пребарај приклучоци за воспоставка",
"admin_plugins.check_updates": "Провери поднови",
"admin_plugins.core_count": "{{count}} основни",
"admin_plugins.catalog_disabled": "Каталогот на приклучокот е оневозможен од вашиот оператор (privacy.pluginCatalog=false). За да воспоставите приклучок, извршете ја наредбата „pnpm run plugins i ep_<name>“ од опслужувачот.",
"admin_plugins.crumbs": "Приклучоци",
"admin_plugins.description": "Опис",
"admin_plugins.disables.label": "Оневозможува:",
"admin_plugins.disables.warning_title": "Овој приклучок намерно ги отстранува наведените функции на Etherpad.",
"admin_plugins.error_retrieving": "Грешка при добивањето на приклучоци",
"admin_plugins.install_error": "Не успеав да го воспоставам {{plugin}}: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad": "Не успеав да го воспоставам {{plugin}}: бара понова верзија на Etherpad. Наградете го Etherpad и обидете се пак.",
"admin_plugins.installed": "Воспоставени приклучоци",
"admin_plugins.installed_fetching": "Ги земам воспоставените приклучоци…",
"admin_plugins.installed_nothing": "Засега немате воспоставено ниеден приклучок.",
@ -23,14 +83,34 @@
"admin_plugins.last-update": "Последна поднова",
"admin_plugins.name": "Назив",
"admin_plugins.page-title": "Раководител со приклучоци — Etherpad",
"admin_plugins.reload_catalog": "Превчитај каталог",
"admin_plugins.search_npm": "Пребарај на npm",
"admin_plugins.sort_ascending": "Подреди нагорно",
"admin_plugins.sort_descending": "Подреди надолно",
"admin_plugins.sort.last_updated": "Последна поднова",
"admin_plugins.sort.name": "Име (А–Ш)",
"admin_plugins.sort.version": "Верзија",
"admin_plugins.source": "Извор на приклучокот",
"admin_plugins.subtitle": "Воспоставете, подновете и отстранете приклучоци за Etherpad. Промените бараат превклучување на опслужувачот.",
"admin_plugins.tag_core": "Основно",
"admin_plugins.update_tooltip": "Поднови",
"admin_plugins.updates_available": "Има поднови на располагање",
"admin_plugins.update_now": "Поднови",
"admin_plugins.version": "Верзија",
"admin_plugins_info": "Инфрмации за решавање проблеми",
"admin_plugins_info.bindings_label": "{{count}} подврзоци",
"admin_plugins_info.copy_diagnostics": "Копирај дијагностика",
"admin_plugins_info.copy_value": "Копирај {{label}}",
"admin_plugins_info.git_sha": "Git SHA",
"admin_plugins_info.hook_bindings": "Подврзоци на пресретници",
"admin_plugins_info.hooks": "Воспоставени пресретници",
"admin_plugins_info.hooks_client": "Пресретници од клиентска страна",
"admin_plugins_info.hooks_server": "Пресретници од опслужувачка страна",
"admin_plugins_info.no_hooks": "Не најдов пресретници",
"admin_plugins_info.parts": "Воспоставени делови",
"admin_plugins_info.plugins": "Воспоставени приклучоци",
"admin_plugins_info.page-title": "Информации за приклучоци — Etherpad",
"admin_plugins_info.search_placeholder": "Пребарај пресретник или дел…",
"admin_plugins_info.version": "Верзија на Etherpad",
"admin_plugins_info.version_latest": "Најнова достапна верзија",
"admin_plugins_info.version_number": "Број на верзијата",

View file

@ -80,11 +80,14 @@
"admin_plugins.available_search.placeholder": "Zoeken naar plug-ins om te installeren",
"admin_plugins.check_updates": "Controleren op updates",
"admin_plugins.core_count": "{{count}} core",
"admin_plugins.catalog_disabled": "De plug-incatalogus is uitgeschakeld door uw beheerder (privacy.pluginCatalog=false). Om een plug-in te installeren, voert u het volgende commando uit op de server: `pnpm run plugins i ep_<naam>`",
"admin_plugins.crumbs": "Plug-ins",
"admin_plugins.description": "Beschrijving",
"admin_plugins.disables.label": "Maakt onbruikbaar:",
"admin_plugins.disables.warning_title": "Deze plugin verwijdert opzettelijk de vermelde Etherpad-functies.",
"admin_plugins.error_retrieving": "Fout bij het ophalen van plug-ins",
"admin_plugins.install_error": "Installatie van {{plugin}} mislukt: {{error}}",
"admin_plugins.install_error_requires_newer_etherpad": "Kan {{plugin}} niet installeren: er is een nieuwere versie van Etherpad vereist. Werk Etherpad bij en probeer het opnieuw.",
"admin_plugins.installed": "Geïnstalleerde plug-ins",
"admin_plugins.installed_fetching": "Geïnstalleerd plug-ins worden opgehaald…",
"admin_plugins.installed_nothing": "U hebt nog geen plug-ins geïnstalleerd.",
@ -136,6 +139,28 @@
"admin_settings.current_restart.value": "Etherpad herstarten",
"admin_settings.current_save.value": "Instellingen opslaan",
"admin_settings.invalid_json": "Onjuiste JSON",
"admin_settings.current_test.value": "JSON valideren",
"admin_settings.current_prettify.value": "JSON opmaken",
"admin_settings.toast.saved": "Instellingen opgeslagen.",
"admin_settings.toast.save_failed": "Opslaan mislukt: settings.json kon niet worden geschreven.",
"admin_settings.toast.json_invalid": "Syntaxisfout: controleer komma's, accolades en aanhalingstekens.",
"admin_settings.toast.disconnected": "Opslaan mislukt: geen verbinding met de server.",
"admin_settings.toast.validation_ok": "JSON is correct.",
"admin_settings.toast.validation_failed": "De JSON-code is incorrect: corrigeer de syntaxisfouten.",
"admin_settings.toast.prettify_failed": "Kan niet opmaken: corrigeer eerst de syntaxisfouten.",
"admin_settings.prettify_confirm": "Met opmaken worden alle opmerkingen verwijderd. Doorgaan?",
"admin_settings.mode.form": "Formulier",
"admin_settings.mode.raw": "Ruw",
"admin_settings.mode.aria_label": "Bewerkingsmodus",
"admin_settings.section.general": "Algemeen",
"admin_settings.parse_error.title": "Kan settings.json niet verwerken",
"admin_settings.parse_error.cta": "Overschakelen naar ruw bewerken",
"admin_settings.env_pill.tooltip": "Leest de waarde uit de omgevingsvariabele {{variable}}. De onderstaande waarde wordt gebruikt wanneer {{variable}} niet is ingesteld.",
"admin_settings.env_pill.default_label": "standaard",
"admin_settings.env_pill.input_aria": "Standaardwaarde voor {{variable}}",
"admin_settings.env_pill.runtime_label": "actieve waarde",
"admin_settings.env_pill.runtime_tooltip": "Etherpad gebruikt deze waarde op het moment, afgeleid van {{variable}} of de standaardwaarde.",
"admin_settings.env_pill.redacted_tooltip": "Etherpad gebruikt een waarde voor {{variable}}, maar die is verborgen omdat het een geheim is.",
"admin_settings.page-title": "Instellingen - Etherpad",
"admin_settings.save_error": "Fout bij het opslaan van de instellingen",
"admin_settings.saved_success": "Instellingen opgeslagen",
@ -164,6 +189,8 @@
"update.page.policy.rollback-failed-terminal": "Een eerdere update is mislukt en kon niet ongedaan worden gemaakt. Druk op 'Bevestigen' nadat de installatie succesvol is voltooid om de vergrendeling op te heffen.",
"update.page.policy.up-to-date": "U gebruikt de nieuwste versie.",
"update.page.policy.tier-off": "Updates zijn uitgeschakeld (updates.tier = \"off\").",
"update.page.policy.maintenance-window-missing": "Niveau 4 (autonoom) vereist een onderhoudsvenster. Stel updates.maintenanceWindow in settings.json in om autonoom bijwerken in te schakelen.",
"update.page.policy.maintenance-window-invalid": "Niveau 4 (autonoom) is uitgeschakeld omdat updates.maintenanceWindow onjuist is ingesteld. Verwacht wordt {start, end, tz} met HH:MM-tijden en een tijdzone van \"local\" of \"utc\".",
"update.page.last_result.verified": "Laatste update naar {{tag}} geverifieerd.",
"update.page.last_result.rolled-back": "De laatste poging om {{tag}} bij te werken is teruggedraaid: {{reason}}.",
"update.page.last_result.rollback-failed": "Laatste updatepoging is mislukt EN terugdraaien is mislukt: {{reason}}. Handmatige tussenkomst is vereist.",
@ -182,9 +209,16 @@
"update.execution.rollback-failed": "Terugdraaien mislukt",
"update.banner.terminal.rollback-failed": "Een updatepoging is mislukt en kon niet ongedaan gemaakt worden. Handmatige tussenkomst is vereist.",
"update.banner.scheduled": "Automatische bijwerken naar {{tag}} gepland — van toepassing over {{remaining}}.",
"update.banner.maintenance-window-missing": "Autonoom bijwerken is uitgeschakeld totdat een onderhoudsvenster is ingesteld.",
"update.banner.maintenance-window-invalid": "Autonome updates zijn uitgeschakeld omdat het onderhoudsvenster verkeerd is ingesteld.",
"update.page.scheduled.title": "Bijwerken gepland",
"update.page.scheduled.countdown": "Etherpad begint over {{remaining}} met bijwerken naar {{tag}}.",
"update.page.scheduled.deferred_until": "Buiten het onderhoudsvenster. Bijwerken begint wanneer het venster opent om {{at}}.",
"update.page.scheduled.apply_now": "Nu toepassen",
"update.window.title": "Onderhoudsvenster",
"update.window.summary": "{{start}}{{end}} ({{tz}})",
"update.window.unset": "Niet ingesteld.",
"update.window.next_opens_at": "Het volgende venster wordt geopend om {{at}}.",
"update.drain.t60": "Etherpad start over 60 seconden opnieuw op om een update toe te passen.",
"update.drain.t30": "Etherpad start over 30 seconden opnieuw op om een update toe te passen.",
"update.drain.t10": "Etherpad start over 10 seconden opnieuw op om een update toe te passen.",
@ -338,6 +372,11 @@
"pad.historyMode.settings.playbackSpeed": "Afspeelsnelheid:",
"pad.historyMode.chat.replayHeader": "Chat vanaf {{time}}",
"pad.historyMode.users.authorsHeader": "Auteurs tot deze versie",
"pad.editor.skipToContent": "Direct naar de tekstverwerker",
"pad.editor.keyboardHint": "Druk op Escape om de tekstverwerker te verlaten. Druk op Alt+F9 om de werkbalk te openen.",
"pad.editor.toolbar.formatting": "Opmaakgereedschappen",
"pad.editor.toolbar.actions": "Notitiegereedschappen",
"pad.editor.toolbar.showMore": "Meer gereedschapknoppen weergeven",
"timeslider.toolbar.authors": "Auteurs:",
"timeslider.toolbar.authorsList": "Geen auteurs",
"timeslider.toolbar.exportlink.title": "Exporteren",
@ -371,6 +410,7 @@
"pad.savedrevs.timeslider": "U kunt opgeslagen versies bekijken via de tijdlijn",
"pad.userlist.entername": "Geef uw naam op",
"pad.userlist.unnamed": "zonder naam",
"pad.userlist.onlineCount": "{[ plural(count) one: {{count}} verbonden gebruiker, other: {{count}} verbonden gebruikers ]}",
"pad.editbar.clearcolors": "Auteurskleuren voor het hele document wissen? Dit kan niet ongedaan worden gemaakt.",
"pad.impexp.importbutton": "Nu importeren",
"pad.impexp.importing": "Bezig met importeren…",

View file

@ -6,6 +6,8 @@
]
},
"admin.page-title": "Cruscòt d'aministrator - Etherpad",
"admin.loading": "Antramentre ch'as caria…",
"admin.shout": "Comunicassion",
"admin_plugins": "Mansé dj'anstalassion",
"admin_plugins.available": "Anstalassion disponìbij",
"admin_plugins.available_not-found": "Gnun-e anstalassion trovà.",

View file

@ -25,7 +25,7 @@
"pad.toolbar.settings.title": "ပိူင်ႁႅၼ်း",
"pad.toolbar.embed.title": "ၽႄပၼ်ၽႅတ်ႉဢၼ်ၼႆႉသေ ၽိူမ်ႉပၼ်",
"pad.toolbar.showusers.title": "ၼႄပၼ်ၵေႃႉၸႂ်ႉ တီႈၼိူဝ်ၽႅတ်ႉၼႆႉ",
"pad.colorpicker.save": "ၵဵပ်းသိမ်း",
"pad.colorpicker.save": "သိမ်း",
"pad.colorpicker.cancel": "ယႃႉၶိုၼ်း",
"pad.loading": "တိုၵ်ႉလူတ်ႇ",
"pad.noCookie": "ၶုၵ်းၶီး ဢမ်ႇႁၼ်လႆႈ။ ၶႅၼ်းတေႃႈ ၶႂၢင်းပၼ် ၶုၵ်းၶီး တီႈၼႂ်း ပရၢဝ်ႇသႃႇၸဝ်ႈၵဝ်ႇ",

View file

@ -4,6 +4,13 @@
"Saraiki"
]
},
"admin_login.title": "ایتھرپیڈ",
"admin_pads.bulk.delete": "مٹاؤ",
"admin_pads.cancel": "منسوخ",
"admin_pads.filter.active": "فعال",
"admin_pads.filter.empty": "خالی",
"admin_pads.open": "کھولو",
"admin_pads.pagination.previous": "پچھلا",
"admin_plugins": "پلگ ان منیجر",
"admin_plugins.available": "دستیاب پلگ ان",
"admin_plugins.available_not-found": "کوئی پلگ ان کائنی لبھے۔",
@ -12,6 +19,7 @@
"admin_plugins.installed_uninstall.value": "ان انسٹال",
"admin_plugins.last-update": "چھیکڑی تبدیلی",
"admin_plugins.name": "ناں",
"admin_plugins.update_tooltip": "اپ ڈیٹ",
"admin_plugins.version": "ورژن",
"admin_plugins_info.parts": "انسٹال تھئے حصے",
"admin_plugins_info.plugins": "انسٹال تھئے پلگ ان",

View file

@ -47,8 +47,13 @@ exports.init = async () => {
stats.gauge(`ueberdb_${metric}`, () => exports.db.metrics[metric]);
}
}
for (const fn of ['get', 'set', 'findKeys', 'getSub', 'setSub', 'remove']) {
for (const fn of ['get', 'set', 'findKeys', 'findKeysPaged', 'getSub', 'setSub', 'remove']) {
const f = exports.db[fn];
if (typeof f !== 'function') {
throw new Error(
`ueberdb2 ${exports.db.constructor.name} is missing required method ${fn}; ` +
'check that ueberdb2 is at the minimum version pinned in package.json');
}
exports[fn] = async (...args:string[]) => await f.call(exports.db, ...args);
Object.setPrototypeOf(exports[fn], Object.getPrototypeOf(f));
Object.defineProperties(exports[fn], Object.getOwnPropertyDescriptors(f));

View file

@ -12,6 +12,19 @@ const logger = log4js.getLogger('SessionStore');
// How often to run the cleanup of expired/stale sessions.
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
// Maximum number of session keys fetched from the database per cleanup
// iteration. Bounded so that instances with very large session keyspaces (see
// https://github.com/ether/etherpad/issues/7830) don't load every key into
// memory at once. Tuned for ~50 KB per page assuming ~100-char keys.
const CLEANUP_PAGE_SIZE = 500;
// Upper bound on a single cleanup run. Under sustained session creation the
// keyspace can grow faster than cleanup processes it; without a budget the
// loop would never reach an empty page and the next scheduled run would never
// fire. When the budget hits, the next scheduled run picks up where this one
// left off (the database state advances each iteration regardless).
const CLEANUP_MAX_RUNTIME_MS = 10 * 60 * 1000; // 10 minutes
class SessionStore extends expressSession.Store {
/**
* @param {?number} [refresh] - How often (in milliseconds) `touch()` will update a session's
@ -74,37 +87,71 @@ class SessionStore extends expressSession.Store {
* - Sessions with no expiry that contain no data beyond the default cookie are removed.
* These are the empty sessions that accumulate indefinitely (bug #5010) they have
* `{cookie: {path: "/", _expires: null, ...}}` and nothing else.
*
* Iterates the keyspace in fixed-size pages (CLEANUP_PAGE_SIZE) so a large
* sessionstorage table (#7830) doesn't load every key into memory at once.
*/
async _cleanup() {
const keys = await DB.findKeys('sessionstorage:*', null);
if (!keys || keys.length === 0) return;
const now = Date.now();
const startMs = Date.now();
let removed = 0;
for (const key of keys) {
const sess = await DB.get(key);
if (!sess) {
await DB.remove(key);
removed++;
continue;
let scanned = 0;
let after: string | undefined;
let budgetExhausted = false;
// eslint-disable-next-line no-constant-condition
while (true) {
const page = await DB.findKeysPaged('sessionstorage:*', null, {
limit: CLEANUP_PAGE_SIZE,
...(after != null ? {after} : {}),
});
if (!page || page.length === 0) break;
// Defensive: a buggy backend that returns the cursor key would loop
// forever. `after` is exclusive, so the first key of the next page must
// be strictly greater than the previous cursor. Log so an operator can
// notice partial cleanup caused by a pagination regression.
if (after != null && page[0] <= after) {
logger.error(
`Session cleanup: paged cursor did not advance (after=${after}, ` +
`page[0]=${page[0]}); aborting this run to prevent an infinite loop`);
break;
}
const expires = sess.cookie?.expires;
if (expires) {
// Session has an expiry — remove if expired.
if (new Date(expires).getTime() <= now) {
for (const key of page) {
scanned++;
const sess = await DB.get(key);
if (!sess) {
await DB.remove(key);
removed++;
continue;
}
} else {
// Session has no expiry and no user data beyond the cookie — remove as empty/stale.
const hasData = Object.keys(sess).some((k) => k !== 'cookie');
if (!hasData) {
await DB.remove(key);
removed++;
const expires = sess.cookie?.expires;
if (expires) {
if (new Date(expires).getTime() <= now) {
await DB.remove(key);
removed++;
}
} else {
const hasData = Object.keys(sess).some((k) => k !== 'cookie');
if (!hasData) {
await DB.remove(key);
removed++;
}
}
}
after = page[page.length - 1];
if (Date.now() - startMs > CLEANUP_MAX_RUNTIME_MS) {
budgetExhausted = true;
break;
}
// Yield to the event loop between pages so request handlers can run and
// the DB driver can release the previous page's buffered rows.
await new Promise((resolve) => setImmediate(resolve));
}
if (removed > 0) {
logger.info(`Session cleanup: removed ${removed} expired/stale sessions out of ${keys.length}`);
if (budgetExhausted) {
logger.warn(
`Session cleanup: hit ${CLEANUP_MAX_RUNTIME_MS}ms budget after scanning ` +
`${scanned} keys (${removed} removed); next scheduled run will continue`);
} else if (removed > 0) {
logger.info(`Session cleanup: removed ${removed} expired/stale sessions out of ${scanned}`);
}
}

View file

@ -1,7 +1,7 @@
'use strict';
import {PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery";
import {PAD_FILTERS, PadFilter, PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery";
import log4js from 'log4js';
const fsp = require('fs').promises;
@ -9,14 +9,42 @@ const hooks = require('../../../static/js/pluginfw/hooks');
const plugins = require('../../../static/js/pluginfw/plugins');
import settings, {getEpVersion, getGitCommit, reloadSettings} from '../../utils/Settings';
import {getLatestVersion} from '../../utils/UpdateCheck';
import {redactSettings} from '../../utils/AdminSettingsRedact';
const padManager = require('../../db/PadManager');
const api = require('../../db/API');
import {deleteRevisions} from '../../utils/Cleanup';
const queryPadLimit = 12;
// Cap on concurrent `padManager.getPad()` calls while hydrating the pad
// universe for filter chip / non-name sort. The old per-sortBy handlers
// awaited each getPad sequentially (concurrency = 1); the unified
// pipeline used to issue Promise.all over the full candidate set, which
// can fan out to thousands of in-flight DB reads on busy deployments.
// 16 is empirically enough to saturate a single ueberDB driver without
// pushing the event loop into back-pressure.
const PAD_HYDRATE_CONCURRENCY = 16;
const logger = log4js.getLogger('adminSettings');
// Concurrency-limited Promise.all replacement. Preserves the input
// order in the returned array (caller slices later). Used by padLoad
// to bound DB reads during hydration.
async function mapWithConcurrency<T, R>(
items: T[], limit: number, fn: (t: T) => Promise<R>): Promise<R[]> {
const out: R[] = new Array(items.length);
let next = 0;
const worker = async () => {
while (true) {
const i = next++;
if (i >= items.length) return;
out[i] = await fn(items[i]);
}
};
const workers = Array.from({length: Math.min(limit, items.length)}, worker);
await Promise.all(workers);
return out;
}
exports.socketio = (hookName: string, {io}: any) => {
io.of('/settings').on('connection', (socket: any) => {
@ -38,7 +66,8 @@ exports.socketio = (hookName: string, {io}: any) => {
if (settings.showSettingsInAdminPage === false) {
socket.emit('settings', {results: 'NOT_ALLOWED', flags});
} else {
socket.emit('settings', {results: data, flags});
const resolved = redactSettings(settings);
socket.emit('settings', {results: data, resolved, flags});
}
});
@ -110,137 +139,112 @@ exports.socketio = (hookName: string, {io}: any) => {
socket.on('padLoad', async (query: PadSearchQuery) => {
const {padIDs} = await padManager.listAllPads();
const data: {
total: number,
results?: PadQueryResult[]
} = {
total: padIDs.length,
};
let result: string[] = padIDs;
let maxResult;
// Filter out matches
// ── 1. Pattern filter (cheap, by name only) ─────────────────────
let candidateNames: string[] = padIDs;
if (query.pattern) {
result = result.filter((padName: string) => padName.includes(query.pattern));
candidateNames = candidateNames.filter(
(padName: string) => padName.includes(query.pattern));
}
data.total = result.length;
// ── 2. Resolve filter chip ──────────────────────────────────────
// PadPage sends a chip id; "all" (default) means no additional
// filtering. We accept missing values from older clients gracefully.
const filter: PadFilter =
(query.filter && PAD_FILTERS.includes(query.filter)) ? query.filter : 'all';
maxResult = result.length - 1;
if (maxResult < 0) {
maxResult = 0;
// ── 3. Decide whether we need full metadata for every candidate ──
// The fast path — name-sort with no filter chip — only needs to
// hydrate metadata for the 12-row page slice. Any other path
// (filter chip OR non-name sort) requires every candidate's revs
// / users / lastEdited up front so we can sort and slice against
// the right universe. The expensive call is `padManager.getPad`;
// user counts come from an in-memory map.
const needsFullScan = filter !== 'all' || query.sortBy !== 'padName';
const loadMeta = async (padName: string): Promise<PadQueryResult> => {
const pad = await padManager.getPad(padName);
return {
padName,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(padName).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber(),
};
};
// Lazily lifted so we don't load every pad twice on the fast path.
let hydrated: PadQueryResult[] | null = null;
const hydrateAll = async () => {
if (hydrated == null) {
hydrated = await mapWithConcurrency(
candidateNames, PAD_HYDRATE_CONCURRENCY, loadMeta);
}
return hydrated;
};
// ── 4. Filter chip — applied to hydrated metadata ────────────────
// Bucket boundaries match the client chips in PadPage.tsx so the
// counts on the stats cards keep meaning the same thing. Compute
// `now` once per request so a pad doesn't slip between buckets
// mid-loop.
const now = Date.now();
const isRecent = (lastEdited: number) => now - lastEdited < 86_400_000 * 7;
const isStale = (lastEdited: number) => now - lastEdited > 86_400_000 * 365;
const matchesFilter = (m: PadQueryResult) => {
switch (filter) {
case 'active': return m.userCount > 0;
case 'recent': return isRecent(Number(m.lastEdited));
case 'empty': return m.revisionNumber === 0;
case 'stale': return isStale(Number(m.lastEdited));
default: return true;
}
};
// ── 5. Total — i.e. the count the pagination footer reflects ────
// For the fast path this is just the pattern-filtered name list;
// for full-scan we report the post-chip total.
let totalNames: string[] | null = needsFullScan ? null : candidateNames;
let postFilterMetas: PadQueryResult[] | null = null;
if (needsFullScan) {
postFilterMetas = (await hydrateAll()).filter(matchesFilter);
}
const total = needsFullScan ? postFilterMetas!.length : totalNames!.length;
// Reset to default values if out of bounds
// ── 6. Clamp offset/limit ──────────────────────────────────────
const maxOffset = Math.max(total - 1, 0);
if (query.offset && query.offset < 0) {
query.offset = 0;
} else if (query.offset > maxResult) {
query.offset = maxResult;
} else if (query.offset > maxOffset) {
query.offset = maxOffset;
}
if (query.limit && query.limit < 0) {
// Too small
query.limit = 0;
} else if (query.limit > queryPadLimit) {
// Too big
query.limit = queryPadLimit;
}
// ── 7. Sort + slice ────────────────────────────────────────────
const dir = query.ascending ? 1 : -1;
const cmpStr = (a: string, b: string) => a < b ? -dir : a > b ? dir : 0;
const cmpNum = (a: number, b: number) => a < b ? -dir : a > b ? dir : 0;
if (query.sortBy === 'padName') {
result = result.sort((a, b) => {
if (a < b) return query.ascending ? -1 : 1;
if (a > b) return query.ascending ? 1 : -1;
return 0;
}).slice(query.offset, query.offset + query.limit);
data.results = await Promise.all(result.map(async (padName: string) => {
const pad = await padManager.getPad(padName);
const revisionNumber = pad.getHeadRevisionNumber()
const userCount = api.padUsersCount(padName).padUsersCount;
const lastEdited = await pad.getLastEdit();
return {
padName,
lastEdited,
userCount,
revisionNumber
let results: PadQueryResult[];
if (needsFullScan) {
const sorted = postFilterMetas!.sort((a, b) => {
switch (query.sortBy) {
case 'padName': return cmpStr(a.padName, b.padName);
case 'revisionNumber': return cmpNum(a.revisionNumber, b.revisionNumber);
case 'userCount': return cmpNum(a.userCount, b.userCount);
case 'lastEdited': return cmpStr(String(a.lastEdited), String(b.lastEdited));
default: return 0;
}
}));
} else if (query.sortBy === "revisionNumber") {
const currentWinners: PadQueryResult[] = []
const padMapping = [] as {padId: string, revisionNumber: number}[]
for (let res of result) {
const pad = await padManager.getPad(res);
const revisionNumber = pad.getHeadRevisionNumber()
padMapping.push({padId: res, revisionNumber})
}
padMapping.sort((a, b) => {
if (a.revisionNumber < b.revisionNumber) return query.ascending ? -1 : 1;
if (a.revisionNumber > b.revisionNumber) return query.ascending ? 1 : -1;
return 0;
})
for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) {
let pad = await padManager.getPad(padRetrieval.padId);
currentWinners.push({
padName: padRetrieval.padId,
lastEdited: await pad.getLastEdit(),
userCount: api.padUsersCount(pad.padName).padUsersCount,
revisionNumber: padRetrieval.revisionNumber
})
}
data.results = currentWinners;
} else if (query.sortBy === "userCount") {
const currentWinners: PadQueryResult[] = []
const padMapping = [] as {padId: string, userCount: number}[]
for (let res of result) {
const userCount = api.padUsersCount(res).padUsersCount
padMapping.push({padId: res, userCount})
}
padMapping.sort((a, b) => {
if (a.userCount < b.userCount) return query.ascending ? -1 : 1;
if (a.userCount > b.userCount) return query.ascending ? 1 : -1;
return 0;
})
for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) {
let pad = await padManager.getPad(padRetrieval.padId);
currentWinners.push({
padName: padRetrieval.padId,
lastEdited: await pad.getLastEdit(),
userCount: padRetrieval.userCount,
revisionNumber: pad.getHeadRevisionNumber()
})
}
data.results = currentWinners;
} else if (query.sortBy === "lastEdited") {
const currentWinners: PadQueryResult[] = []
const padMapping = [] as {padId: string, lastEdited: string}[]
for (let res of result) {
const pad = await padManager.getPad(res);
const lastEdited = await pad.getLastEdit();
padMapping.push({padId: res, lastEdited})
}
padMapping.sort((a, b) => {
if (a.lastEdited < b.lastEdited) return query.ascending ? -1 : 1;
if (a.lastEdited > b.lastEdited) return query.ascending ? 1 : -1;
return 0;
})
for (const padRetrieval of padMapping.slice(query.offset, query.offset + query.limit)) {
let pad = await padManager.getPad(padRetrieval.padId);
currentWinners.push({
padName: padRetrieval.padId,
lastEdited: padRetrieval.lastEdited,
userCount: api.padUsersCount(pad.padName).padUsersCount,
revisionNumber: pad.getHeadRevisionNumber()
})
}
data.results = currentWinners;
});
results = sorted.slice(query.offset, query.offset + query.limit);
} else {
const sliceNames = totalNames!.sort(cmpStr).slice(query.offset, query.offset + query.limit);
results = await Promise.all(sliceNames.map(loadMeta));
}
const data: {total: number, results?: PadQueryResult[]} = {total, results};
socket.emit('results:padLoad', data);
})

View file

@ -20,7 +20,10 @@ export const generateAdminDefinition = (): any => ({
title: 'Etherpad Admin API',
description:
'Authenticated administrative endpoints consumed by the Etherpad admin UI. ' +
'Distinct from the public /api/{version}/* surface served by /api/openapi.json.',
'Distinct from the public /api/{version}/* surface served by /api/openapi.json. ' +
'For completeness this document also includes non-admin endpoints that are ' +
'consumed by the pad UI itself (e.g. /api/version-status) since they share the ' +
'same internal route registration.',
version: getEpVersion(),
},
paths: {
@ -45,13 +48,51 @@ export const generateAdminDefinition = (): any => ({
},
},
},
'/api/version-status': {
get: {
operationId: 'getVersionStatus',
summary: 'Outdated-version notice signal for the pad UI',
description:
'Returns a non-null `outdated` value only to the first author of the supplied pad, ' +
'and only when the running server is at least one minor version behind the latest ' +
'published release. Result is cached per (padId, authorId) for 60 s.',
parameters: [
{
name: 'padId',
in: 'query',
required: false,
schema: {type: 'string'},
description:
'Pad whose first-author membership is being checked. ' +
'Omitted padId always yields a null result.',
},
],
responses: {
'200': {
description: 'Outdated-notice signal.',
content: {
'application/json': {
schema: {
type: 'object',
required: ['outdated', 'isFirstAuthor'],
properties: {
outdated: {type: 'string', enum: ['minor'], nullable: true},
isFirstAuthor: {type: 'boolean'},
},
},
},
},
},
},
},
},
'/admin/update/status': {
get: {
operationId: 'getUpdateStatus',
summary: 'Fetch updater status for the admin UI banner and update page',
description:
'Returns the cached update state (current version, latest known release, ' +
'install method, tier, policy verdict, and vulnerability directives). ' +
'install method, tier, policy verdict, execution state, lastResult, and lockHeld). ' +
'Open by default; gated to authenticated admin sessions when ' +
'updates.requireAdminForStatus=true in settings.',
security: [
@ -102,17 +143,9 @@ export const generateAdminDefinition = (): any => ({
reason: {type: 'string'},
},
},
VulnerableBelowDirective: {
type: 'object',
required: ['announcedBy', 'threshold'],
properties: {
announcedBy: {type: 'string'},
threshold: {type: 'string'},
},
},
UpdateStatus: {
type: 'object',
required: ['currentVersion', 'installMethod', 'tier', 'vulnerableBelow'],
required: ['currentVersion', 'installMethod', 'tier'],
properties: {
currentVersion: {type: 'string'},
latest: {
@ -132,10 +165,6 @@ export const generateAdminDefinition = (): any => ({
allOf: [{$ref: '#/components/schemas/PolicyResult'}],
nullable: true,
},
vulnerableBelow: {
type: 'array',
items: {$ref: '#/components/schemas/VulnerableBelowDirective'},
},
},
},
},

View file

@ -1,32 +1,38 @@
import {ArgsExpressType} from "../../types/ArgsExpressType";
import settings from '../../utils/Settings';
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath';
const pwa = {
const buildManifest = (proxyPath: string) => ({
name: settings.title || "Etherpad",
short_name: settings.title,
description: "A collaborative online editor",
icons: [
{
"src": "/static/skins/colibris/images/fond.jpg",
"src": `${proxyPath}/static/skins/colibris/images/fond.jpg`,
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
},
{
"src": "/favicon.ico",
"src": `${proxyPath}/favicon.ico`,
"sizes": "64x64 32x32 24x24 16x16",
type: "image/png"
}
type: "image/png",
},
],
start_url: "/",
start_url: `${proxyPath}/`,
display: "fullscreen",
theme_color: "#0f775b",
background_color: "#0f775b"
}
background_color: "#0f775b",
});
exports.expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Function) => {
args.app.get('/manifest.json', (req:any, res:any) => {
res.json(pwa);
const proxyPath = sanitizeProxyPath(req);
if (proxyPath) {
res.setHeader('Vary', 'x-proxy-path, x-forwarded-prefix, x-ingress-path');
res.setHeader('Cache-Control', 'private, no-store');
}
res.json(buildManifest(proxyPath));
});
return cb();
}
};

View file

@ -175,8 +175,9 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
proxyPath,
});
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings, socialMetaHtml}));
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, entrypoint: proxyPath + '/watch/index?hash=' + hash, settings, socialMetaHtml, proxyPath}));
})
})
@ -203,6 +204,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
proxyPath,
});
const content = eejs.require('ep_etherpad-lite/templates/pad.html', {
req,
@ -211,6 +213,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
entrypoint: proxyPath + '/watch/pad?hash=' + hash,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
})
res.send(content);
})
@ -245,6 +248,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
proxyPath,
});
const content = eejs.require('ep_etherpad-lite/templates/timeslider.html', {
req,
@ -254,6 +258,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
entrypoint: proxyPath + '/watch/timeslider?hash=' + hash,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
})
res.send(content);
})
@ -363,10 +368,12 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
// serve index.html under /
args.app.get('/', (req: any, res: any) => {
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'home',
proxyPath,
});
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml}));
res.send(eejs.require('ep_etherpad-lite/templates/index.html', {req, settings, entrypoint: "./"+fileNameIndex, socialMetaHtml, proxyPath}));
});
@ -381,8 +388,10 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
isReadOnly
});
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'pad', padName: req.params.pad,
proxyPath,
});
const content = eejs.require('ep_etherpad-lite/templates/pad.html', {
req,
@ -391,6 +400,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
entrypoint: "../"+fileNamePad,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
})
res.send(content);
});
@ -414,8 +424,10 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
toolbar,
});
const proxyPath = sanitizeProxyPath(req);
const socialMetaHtml = renderSocialMeta({
req, settings, availableLangs: i18n.availableLangs, locales: i18n.locales, kind: 'timeslider', padName: req.params.pad,
proxyPath,
});
res.send(eejs.require('ep_etherpad-lite/templates/timeslider.html', {
req,
@ -424,6 +436,7 @@ exports.expressCreateServer = async (_hookName: string, args: ArgsExpressType, c
entrypoint: "../../"+fileNameTimeSlider,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
}));
});
} else {

View file

@ -1,36 +1,103 @@
'use strict';
import path from 'node:path';
import {LRUCache} from 'lru-cache';
import {ArgsExpressType} from '../../types/ArgsExpressType';
import settings, {getEpVersion} from '../../utils/Settings';
import {getDetectedInstallMethod, stateFilePath} from '../../updater';
import {evaluatePolicy} from '../../updater/UpdatePolicy';
import {compareSemver, isMajorBehind, isVulnerable} from '../../updater/versionCompare';
import {isMinorOrMoreBehind} from '../../updater/versionCompare';
import {loadState} from '../../updater/state';
import {isHeld} from '../../updater/lock';
import {nextWindowStart, parseWindow} from '../../updater/MaintenanceWindow';
let badgeCache: {value: 'severe' | 'vulnerable' | null; at: number} = {value: null, at: 0};
// Coalesce concurrent computeOutdated() calls during a cache-miss so a burst of
// requests at expiry doesn't fan out into N redundant disk reads.
let badgeInFlight: Promise<'severe' | 'vulnerable' | null> | null = null;
const BADGE_CACHE_MS = 60 * 1000;
const computeOutdated = async (): Promise<'severe' | 'vulnerable' | null> => {
const state = await loadState(stateFilePath());
if (!state.latest) return null;
const current = getEpVersion();
if (compareSemver(current, state.latest.version) >= 0) return null;
if (isVulnerable(current, state.vulnerableBelow)) return 'vulnerable';
if (isMajorBehind(current, state.latest.version)) return 'severe';
/**
* Returns the authorID of whoever first contributed to the pad i.e. the
* `['author', X]` entry at the lowest numeric key in the pool, with empty-X
* placeholders skipped. Returns null for a pad with no real author attribs yet.
*/
export const firstAuthorOf = (pad: {pool?: {numToAttrib?: Record<number | string, unknown>}}): string | null => {
const num2attrib = pad?.pool?.numToAttrib;
if (!num2attrib) return null;
const keys = Object.keys(num2attrib).map(Number).sort((a, b) => a - b);
for (const k of keys) {
const a = num2attrib[k];
if (Array.isArray(a) && a[0] === 'author' && typeof a[1] === 'string' && a[1] !== '') {
return a[1];
}
}
return null;
};
/**
* Resolve the pad-visitor's authorID from the HttpOnly token cookie. This
* mirrors how Etherpad's own socket.io handshake resolves pad-visitor identity:
* the browser sends the `token` cookie (or `<prefix>token`) with every
* same-origin request, and `authorManager.getAuthorId(token, user)` maps the
* token to a stable authorID via the `token2author` DB key.
*
* We do NOT use `req.session.user.author` because Etherpad does not populate
* an authorID into the express-session `user` object for pad visitors, so that
* field is always undefined in production.
*
* On any failure path we return null and the caller treats the request as
* anonymous, resulting in an EMPTY (no-badge) response.
*/
export const resolveRequestAuthor = async (req: any): Promise<string | null> => {
try {
const cookiePrefix = (settings as any).cookie?.prefix ?? '';
const token = req?.cookies?.[`${cookiePrefix}token`];
if (typeof token !== 'string' || token === '') return null;
const authorManagerMod: any = await import('../../db/AuthorManager');
const authorManager = authorManagerMod.default ?? authorManagerMod;
if (typeof authorManager.getAuthorId !== 'function') return null;
const authorId = await authorManager.getAuthorId(token, req?.session?.user);
return typeof authorId === 'string' && authorId !== '' ? authorId : null;
} catch {
return null;
}
};
interface OutdatedResponse {
outdated: 'minor' | null;
isFirstAuthor: boolean;
}
const EMPTY: OutdatedResponse = {outdated: null, isFirstAuthor: false};
const TTL_MS = 60 * 1000;
let cache = new LRUCache<string, {value: OutdatedResponse; at: number}>({max: 1000});
const inFlight = new Map<string, Promise<OutdatedResponse>>();
/** Test-only setter: rebuild the LRU with a smaller cap so eviction can be asserted. */
export const _setBadgeCacheCapForTests = (max: number): void => {
cache = new LRUCache<string, {value: OutdatedResponse; at: number}>({max});
};
/** Test-only: clear the in-memory badge cache so integration tests see fresh state. */
export const _resetBadgeCacheForTests = (): void => {
badgeCache = {value: null, at: 0};
badgeInFlight = null;
cache.clear();
inFlight.clear();
};
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;
// padManager is loaded via dynamic import to avoid circular-init w/ updater.
const padManagerMod: any = await import('../../db/PadManager');
const padManager = padManagerMod.default ?? padManagerMod;
if (typeof padManager.isValidPadId !== 'function' || !padManager.isValidPadId(padId)) return EMPTY;
if (!(await padManager.doesPadExist(padId))) return EMPTY;
const pad = await padManager.getPad(padId);
if (firstAuthorOf(pad) !== authorId) return EMPTY;
return {outdated: 'minor', isFirstAuthor: true};
};
// Wrap an async Express handler so a rejected promise becomes next(err) rather than
@ -65,22 +132,27 @@ export const expressCreateServer = (
// Tier "off" disables the entire updater feature, including its HTTP surface.
if (settings.updates.tier === 'off') return cb();
// Public endpoint. Cached for 60s. Returns only an enum — no version string.
app.get('/api/version-status', wrapAsync(async (_req, res) => {
// Public endpoint. Cached for 60s per (padId, authorId) key.
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);
const key = `${padId ?? ''}|${authorId ?? ''}`;
const now = Date.now();
if (now - badgeCache.at > BADGE_CACHE_MS) {
// Single-flight: if another request is already computing, await its
// promise instead of starting a second one. The first to land seeds
// the cache; the rest read it.
if (!badgeInFlight) {
badgeInFlight = computeOutdated().finally(() => { badgeInFlight = null; });
}
const value = await badgeInFlight;
// Only the request that observed the original miss writes the cache;
// followers may race on the assignment but write the same value.
badgeCache = {value, at: now};
const hit = cache.get(key);
if (hit && now - hit.at <= TTL_MS) {
res.json(hit.value);
return;
}
res.json({outdated: badgeCache.value});
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);
}));
// Admin UI status endpoint. By default this is open: the running version is already
@ -138,7 +210,6 @@ export const expressCreateServer = (
installMethod,
tier: settings.updates.tier,
policy,
vulnerableBelow: state.vulnerableBelow,
// PR 2 additions:
execution,
lastResult,

View file

@ -1,9 +1,18 @@
export type PadFilter = "all" | "active" | "recent" | "empty" | "stale";
export const PAD_FILTERS: PadFilter[] = ["all", "active", "recent", "empty", "stale"];
export type PadSearchQuery = {
pattern: string;
offset: number;
limit: number;
ascending: boolean;
sortBy: "padName" | "lastEdited" | "userCount" | "revisionNumber";
// Filter chip. Defaults to "all". Applied server-side so pagination
// reflects the filtered universe — without this, the chip filters only
// the current page slice and "0 empty pads" can appear on page 1 while
// page 2 has nothing but empties.
filter?: PadFilter;
}

View file

@ -1,13 +1,10 @@
import {EmailSendLog} from './types';
// TODO(future): surface the threshold version in email bodies so admins know which version
// clears the vulnerability. Requires extending NotifierInput with the relevant directive(s).
export interface NotifierInput {
adminEmail: string | null;
current: string;
latest: string;
latestTag: string;
isVulnerable: boolean;
isSevere: boolean;
state: EmailSendLog;
now: Date;
@ -15,8 +12,6 @@ export interface NotifierInput {
export type EmailKind =
| 'severe'
| 'vulnerable'
| 'vulnerable-new-release'
| 'grace-start'
| 'update-preflight-failed'
| 'update-rolled-back'
@ -35,7 +30,6 @@ export interface NotifierResult {
const DAY = 24 * 60 * 60 * 1000;
const SEVERE_INTERVAL = 30 * DAY;
const VULNERABLE_INTERVAL = 7 * DAY;
const sinceMs = (iso: string | null, now: Date): number =>
iso ? now.getTime() - new Date(iso).getTime() : Infinity;
@ -44,48 +38,23 @@ const sinceMs = (iso: string | null, now: Date): number =>
* Decide which emails to send and what the new dedupe-log state should be.
* Pure function: returns plans + new state, does not actually send.
*
* Cadence: vulnerable beats severe; vulnerable repeats every 7 days; severe every 30.
* If vulnerable AND the release tag changed since last send, fire `vulnerable-new-release`
* even within the 7-day window so admins learn of the fixed release.
* Cadence: severe repeats every 30 days.
*/
export const decideEmails = (input: NotifierInput): NotifierResult => {
const {adminEmail, current, latest, latestTag, isVulnerable, isSevere, state, now} = input;
const {adminEmail, current, latest, isSevere, state, now} = input;
if (!adminEmail) return {toSend: [], newState: state};
const toSend: PlannedEmail[] = [];
const newState: EmailSendLog = {...state};
if (isVulnerable) {
const sinceVuln = sinceMs(state.vulnerableAt, now);
const tagChanged = state.vulnerableNewReleaseTag !== null && state.vulnerableNewReleaseTag !== latestTag;
if (tagChanged) {
// A new release shipped while the instance is still vulnerable. Fire regardless
// of the 7-day cadence: the admin needs to know a fix exists.
toSend.push({
kind: 'vulnerable-new-release',
subject: `[Etherpad] New release available — ${latest} (your version is vulnerable)`,
body: `A new Etherpad release (${latestTag}) is available. Your version (${current}) is flagged as vulnerable. Please update.`,
});
newState.vulnerableNewReleaseTag = latestTag;
// Also reset the periodic clock so we don't immediately re-nag on next tick.
newState.vulnerableAt = now.toISOString();
} else if (sinceVuln >= VULNERABLE_INTERVAL) {
toSend.push({
kind: 'vulnerable',
subject: `[Etherpad] Your instance is running a vulnerable version (${current})`,
body: `Your Etherpad version (${current}) is below the security threshold. Latest is ${latest}.`,
});
newState.vulnerableAt = now.toISOString();
newState.vulnerableNewReleaseTag = latestTag;
}
} else if (isSevere) {
if (isSevere) {
const sinceSevere = sinceMs(state.severeAt, now);
if (sinceSevere >= SEVERE_INTERVAL) {
toSend.push({
kind: 'severe',
subject: `[Etherpad] Your instance is severely outdated (${current})`,
body: `Your Etherpad version (${current}) is more than one major release behind ${latest}.`,
subject: `[Etherpad] Your instance is outdated (${current})`,
body: `Your Etherpad version (${current}) is at least one minor release behind the latest published version (${latest}). Consider scheduling an upgrade.`,
});
newState.severeAt = now.toISOString();
}

View file

@ -1,5 +1,4 @@
import {ReleaseInfo, VulnerableBelowDirective} from './types';
import {parseVulnerableBelow} from './versionCompare';
import {ReleaseInfo} from './types';
import {isValidTag} from './refSafety';
export interface FetchResult {
@ -14,7 +13,7 @@ export type Fetcher = (url: string, etag: string | null) => Promise<FetchResult>
/** Discriminated union of every outcome the checker can return. */
export type CheckResult =
| {kind: 'updated'; release: ReleaseInfo; etag: string | null; vulnerableBelow: VulnerableBelowDirective[]}
| {kind: 'updated'; release: ReleaseInfo; etag: string | null}
| {kind: 'notmodified'}
| {kind: 'ratelimited'}
| {kind: 'skipped-prerelease'; etag: string | null}
@ -72,12 +71,7 @@ export const checkLatestRelease = async (
htmlUrl: j.html_url,
};
const directiveThreshold = parseVulnerableBelow(body);
const vulnerableBelow: VulnerableBelowDirective[] = directiveThreshold
? [{announcedBy: tag, threshold: directiveThreshold}]
: [];
return {kind: 'updated', release, etag: res.etag, vulnerableBelow};
return {kind: 'updated', release, etag: res.etag};
};
/** Production fetcher built on Node 18+ native fetch. Honors If-None-Match for cheap polling. */

View file

@ -6,7 +6,7 @@ import settings, {getEpVersion} from '../utils/Settings';
import {detectInstallMethod} from './InstallMethodDetector';
import {checkLatestRelease, realFetcher} from './VersionChecker';
import {loadState, saveState} from './state';
import {isMajorBehind, isVulnerable} from './versionCompare';
import {isMinorOrMoreBehind} from './versionCompare';
import {evaluatePolicy} from './UpdatePolicy';
import {decideEmails, decideOutcomeEmail, FailureOutcome} from './Notifier';
import {checkPendingVerification, CheckResult, RollbackDeps, performRollback} from './RollbackHandler';
@ -132,11 +132,6 @@ const performCheck = async (): Promise<void> => {
if (result.kind === 'updated') {
state.latest = result.release;
state.lastEtag = result.etag;
// Union new directives with existing — same announcedBy is a no-op.
const existingTags = new Set(state.vulnerableBelow.map((v) => v.announcedBy));
for (const v of result.vulnerableBelow) {
if (!existingTags.has(v.announcedBy)) state.vulnerableBelow.push(v);
}
} else if (result.kind === 'skipped-prerelease') {
// Preserve ETag so we don't re-fetch an unchanged prerelease body next tick.
state.lastEtag = result.etag;
@ -164,8 +159,7 @@ const performCheck = async (): Promise<void> => {
current,
latest: state.latest.version,
latestTag: state.latest.tag,
isVulnerable: isVulnerable(current, state.vulnerableBelow),
isSevere: isMajorBehind(current, state.latest.version),
isSevere: isMinorOrMoreBehind(current, state.latest.version),
state: state.email,
now,
});

View file

@ -78,14 +78,6 @@ const isValidLatest = (v: unknown): boolean => {
&& typeof v.prerelease === 'boolean';
};
const isValidVulnerableBelow = (v: unknown): boolean => {
if (!Array.isArray(v)) return false;
return v.every((entry) =>
isPlainObject(entry)
&& typeof entry.announcedBy === 'string'
&& typeof entry.threshold === 'string');
};
const isValidEmail = (v: unknown): boolean => {
if (!isPlainObject(v)) return false;
// graceStartTag (Tier 3) and lastFailureKey (Tier 4) are both optional for
@ -94,8 +86,6 @@ const isValidEmail = (v: unknown): boolean => {
const graceOk = v.graceStartTag === undefined || isStringOrNull(v.graceStartTag);
const failOk = v.lastFailureKey === undefined || isStringOrNull(v.lastFailureKey);
return isStringOrNull(v.severeAt)
&& isStringOrNull(v.vulnerableAt)
&& isStringOrNull(v.vulnerableNewReleaseTag)
&& graceOk
&& failOk;
};
@ -114,7 +104,6 @@ const isValid = (raw: unknown): raw is Partial<UpdateState> & object => {
if (!isStringOrNull(raw.lastCheckAt)) return false;
if (!isStringOrNull(raw.lastEtag)) return false;
if (!isValidLatest(raw.latest)) return false;
if (!isValidVulnerableBelow(raw.vulnerableBelow)) return false;
if (!isValidEmail(raw.email)) return false;
if (raw.execution !== undefined && !isValidExecution(raw.execution)) return false;
if (raw.bootCount !== undefined && typeof raw.bootCount !== 'number') return false;

View file

@ -13,9 +13,6 @@ export interface MaintenanceWindow {
tz: 'local' | 'utc';
}
/** null = up-to-date (or not yet checked); 'severe' = at least one major version behind; 'vulnerable' = matched a vulnerable-below directive. */
export type OutdatedLevel = null | 'severe' | 'vulnerable';
export interface ReleaseInfo {
/** semver string without leading 'v', e.g. "2.7.2". */
version: string;
@ -31,13 +28,6 @@ export interface ReleaseInfo {
htmlUrl: string;
}
export interface VulnerableBelowDirective {
/** The release that *announced* the vulnerability (latest release wins on conflict). */
announcedBy: string;
/** Versions strictly below this string are considered vulnerable. */
threshold: string;
}
export interface PolicyResult {
canNotify: boolean;
canManual: boolean;
@ -50,10 +40,6 @@ export interface PolicyResult {
export interface EmailSendLog {
/** Last time we emailed about being severely-outdated, ISO-8601. */
severeAt: string | null;
/** Last time we emailed about being vulnerable, ISO-8601. */
vulnerableAt: string | null;
/** Tag of the release the last "new release while vulnerable" email referenced. */
vulnerableNewReleaseTag: string | null;
/** Tag of the most recent release for which we sent a Tier 3 `grace-start` email. */
graceStartTag: string | null;
/**
@ -114,8 +100,6 @@ export interface UpdateState {
lastEtag: string | null;
/** Cached release info, or null if we've never successfully fetched. */
latest: ReleaseInfo | null;
/** Vulnerable-below directives parsed from the most recent N releases. */
vulnerableBelow: VulnerableBelowDirective[];
/** Email send dedupe state. */
email: EmailSendLog;
/** Current in-flight execution state. Persisted so a restart mid-update reaches RollbackHandler. */
@ -135,11 +119,8 @@ export const EMPTY_STATE: UpdateState = {
lastCheckAt: null,
lastEtag: null,
latest: null,
vulnerableBelow: [],
email: {
severeAt: null,
vulnerableAt: null,
vulnerableNewReleaseTag: null,
graceStartTag: null,
lastFailureKey: null,
},

View file

@ -1,5 +1,3 @@
import type {VulnerableBelowDirective} from './types';
export interface ParsedSemver {
major: number;
minor: number;
@ -26,28 +24,14 @@ export const compareSemver = (a: string, b: string): -1 | 0 | 1 => {
return 0;
};
export const isMajorBehind = (current: string, latest: string): boolean => {
// True iff `current` is at least one minor version behind `latest`.
// Equivalent to: latest.major > current.major, OR same major and
// latest.minor > current.minor. Patch-only deltas return false, equal
// versions return false, current newer than latest returns false.
export const isMinorOrMoreBehind = (current: string, latest: string): boolean => {
const c = parseSemver(current);
const l = parseSemver(latest);
if (!c || !l) return false;
return l.major - c.major >= 1;
};
const VULN_RE = /<!--\s*updater\s*:\s*vulnerable-below\s+([^\s-][^\s]*)\s*-->/i;
export const parseVulnerableBelow = (body: string): string | null => {
const m = VULN_RE.exec(body);
if (!m) return null;
if (!parseSemver(m[1])) return null;
return m[1];
};
export const isVulnerable = (
current: string,
directives: readonly VulnerableBelowDirective[],
): boolean => {
for (const d of directives) {
if (compareSemver(current, d.threshold) < 0) return true;
}
return false;
if (l.major !== c.major) return l.major > c.major;
return l.minor > c.minor;
};

View file

@ -0,0 +1,50 @@
// Produce a clone of the in-memory settings object suitable for emitting
// to the admin SPA. Secrets are replaced with the sentinel "[REDACTED]"
// so the runtime values surface in the UI without leaking credentials.
const SENTINEL = '[REDACTED]';
// Path patterns. '*' matches any object key OR array index.
// A leaf matches if its full path equals one of these patterns.
const REDACT_PATHS: ReadonlyArray<ReadonlyArray<string>> = [
['users', '*', 'password'],
['users', '*', 'passwordHash'],
['users', '*', 'hash'],
['dbSettings', 'password'],
['dbSettings', 'user'],
['sso', 'clients', '*', 'client_secret'],
['sso', 'clients', '*', 'secret'],
['sessionKey'],
];
const pathMatches = (path: ReadonlyArray<string>): boolean => {
for (const pattern of REDACT_PATHS) {
if (pattern.length !== path.length) continue;
let ok = true;
for (let i = 0; i < pattern.length; i++) {
if (pattern[i] !== '*' && pattern[i] !== path[i]) { ok = false; break; }
}
if (ok) return true;
}
return false;
};
const walk = (value: unknown, path: string[]): unknown => {
if (pathMatches(path)) return SENTINEL;
if (value === null || value === undefined) return value;
if (typeof value === 'function') return undefined;
if (Array.isArray(value)) {
return value.map((v, i) => walk(v, [...path, String(i)]));
}
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const child = walk(v, [...path, k]);
if (child !== undefined) out[k] = child;
}
return out;
}
return value;
};
export const redactSettings = (settings: unknown): unknown => walk(settings, []);

View file

@ -532,6 +532,7 @@ exports.getPadHTMLDocument = async (padId: string, revNum: string, readOnlyId: n
body: html,
padId: Security.escapeHTML(readOnlyId || padId),
extraCSS: stylesForExportCSS,
proxyPath: '',
});
};

View file

@ -721,9 +721,19 @@ const settings: SettingsType = {
* Deprecated cookie signing key.
*/
sessionKey: null,
/*
* Trust Proxy, whether or not trust the x-forwarded-for header.
*/
/**
* Trust Proxy, whether or not trust the x-forwarded-for header.
*
* 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 (see src/node/utils/sanitizeProxyPath.ts).
* 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,
/*
* Settings controlling the session cookie issued by Etherpad.

View file

@ -1,48 +1,65 @@
import settings from './Settings';
/**
* Sanitize the `x-proxy-path` request header.
* Sanitize the URL-path prefix Etherpad is being served under.
*
* Etherpad lets operators run behind a reverse proxy that prefixes every
* route under a subpath (e.g. `/pad/etherpad/...`). The proxy is expected
* to set `x-proxy-path` so that server-rendered links and redirects know
* about the prefix. The header value is then woven into HTML, JS, CSS,
* and HTTP Location headers so it must be treated as untrusted input
* even if the deployment intends to set it from a trusted proxy.
* Headers checked in order; first non-empty (after sanitization) wins:
* 1. `x-proxy-path` Etherpad's own convention; always honored because
* the operator must explicitly configure their proxy to send it.
* 2. `x-forwarded-prefix` HAProxy / Traefik standard.
* 3. `x-ingress-path` Home Assistant supervisor ingress.
*
* Semantics:
* - Returns an empty string when the header is absent or unparseable.
* The two standard headers (everything other than x-proxy-path) are honored
* ONLY when `settings.trustProxy === true`, because they can otherwise be
* forged by any internet client when Etherpad runs on a public IP.
*
* The header value is woven into HTML, JS, CSS and HTTP Location headers,
* so the same value is also treated as untrusted input even when read from
* a trusted header. Sanitization rules:
* - Strips every character outside `[a-zA-Z0-9\-_\/\.]`.
* - Collapses a leading `//+` to a single `/` so the value can never
* be interpreted as a protocol-relative URL.
* - Prepends `/` if the (non-empty) result doesn't already start
* with one, so callers can always concatenate the value as an
* absolute path prefix.
* - Collapses a leading `//+` to a single `/` so the value can never be
* interpreted as a protocol-relative URL.
* - Prepends `/` if the (non-empty) result doesn't already start with one,
* so callers can always concatenate the value as an absolute path prefix.
* - Rejects values containing `..` segments.
*
* The output is always either the empty string or a string that starts
* with exactly one `/` and contains only `[A-Za-z0-9\-_./]`.
*/
export const sanitizeProxyPath = (req: {header: (n: string) => string|undefined} | string | undefined): string => {
const raw = typeof req === 'string'
? req
: req && typeof req.header === 'function'
? (req.header('x-proxy-path') || '')
: '';
const HEADER_NAMES = [
// [headerName, requiresTrustProxy]
['x-proxy-path', false] as const,
['x-forwarded-prefix', true] as const,
['x-ingress-path', true] as const,
];
const cleanOne = (raw: string): string => {
let cleaned = raw.replace(/[^a-zA-Z0-9\-_\/\.]/g, '');
if (!cleaned) return '';
// Collapse leading "//+" to a single "/" so the value can never be
// interpreted as a protocol-relative URL when concatenated into an
// href / Location / iframe src.
cleaned = cleaned.replace(/^\/{2,}/, '/');
// Ensure the value starts with exactly one "/". Several callers
// concatenate this as a URL-path prefix (e.g. `${proxyPath}/p/...`
// for redirects, `${proxyPath}/watch/...` for entrypoint URLs) and
// assume the value is either empty or absolute. A header value like
// `pad/etherpad` would otherwise become a relative redirect /
// entrypoint and break the page.
if (cleaned[0] !== '/') cleaned = '/' + cleaned;
// Refuse "/.." / "../" segments — path-traversal shapes that some
// downstream URL joiners would still honour even after the character
// filter above.
if (/(?:^|\/)\.\.(?:\/|$)/.test(cleaned)) return '';
return cleaned;
};
type ReqLike = {header: (n: string) => string|undefined};
export const sanitizeProxyPath = (
req: ReqLike | string | undefined,
opts: {trustProxy?: boolean} = {},
): string => {
// String form preserves the original behaviour for callers that pre-extracted
// the value themselves (e.g. tests). It's treated as a raw value with no
// header-gating: the caller has already decided to use it.
if (typeof req === 'string') return cleanOne(req);
if (!req || typeof req.header !== 'function') return '';
const trustProxy = opts.trustProxy ?? !!settings.trustProxy;
for (const [name, requiresTrust] of HEADER_NAMES) {
if (requiresTrust && !trustProxy) continue;
const raw = req.header(name) || '';
const cleaned = cleanOne(raw);
if (cleaned) return cleaned;
}
return '';
};

View file

@ -139,21 +139,27 @@ const sanitizePublicURL = (raw: string | null | undefined): string | null => {
// Builds an absolute URL. Prefers settings.publicURL when configured (operator-
// trusted); otherwise falls back to the request's protocol+Host with strict
// host validation so a crafted Host header can't appear in og:url / og:image.
// proxyPath is prepended to pathname in the fallback path — it recovers the
// public URL prefix that the reverse proxy stripped before forwarding the
// request. Ignored when publicURL is set (publicURL encodes the canonical
// origin and any path component the operator wants).
const buildAbsoluteUrl = (
req: Request, pathname: string, publicURL: string | null | undefined,
proxyPath: string,
): string => {
const trusted = sanitizePublicURL(publicURL);
if (trusted) return `${trusted}${pathname}`;
const proto = req.protocol === 'https' ? 'https' : 'http';
const host = sanitizeHost(req.get && req.get('host')) || 'localhost';
return `${proto}://${host}${pathname}`;
return `${proto}://${host}${proxyPath}${pathname}`;
};
const resolveImageUrl = (
req: Request, faviconSetting: string | null | undefined, publicURL: string | null | undefined,
proxyPath: string,
): string => {
if (faviconSetting && /^https?:\/\//i.test(faviconSetting)) return faviconSetting;
return buildAbsoluteUrl(req, '/favicon.ico', publicURL);
return buildAbsoluteUrl(req, '/favicon.ico', publicURL, proxyPath);
};
export type RenderOpts = {
@ -163,6 +169,11 @@ export type RenderOpts = {
locales: {[lang: string]: {[key: string]: string}},
kind: 'pad' | 'timeslider' | 'home',
padName?: string,
// URL-path prefix Etherpad is being served under (`''` when running at root).
// When set, used as a path prefix for from-request fallback URLs. Ignored
// when settings.publicURL is configured (publicURL encodes the canonical
// origin and any path component the operator wants).
proxyPath?: string,
};
// Operator override wins when set. Settings.ts coerces env-var strings to
@ -189,7 +200,8 @@ export const renderSocialMeta = (o: RenderOpts): string => {
const description = resolveDescriptionWithOverride(
o.settings.socialMeta && o.settings.socialMeta.description,
o.locales, renderLang);
const imageUrl = resolveImageUrl(o.req, o.settings.favicon, o.settings.publicURL);
const proxyPath = o.proxyPath || '';
const imageUrl = resolveImageUrl(o.req, o.settings.favicon, o.settings.publicURL, proxyPath);
const imageAlt = `${siteName} logo`;
let title = siteName;
@ -203,7 +215,7 @@ export const renderSocialMeta = (o: RenderOpts): string => {
if (qIdx >= 0) pathname = pathname.slice(0, qIdx);
return buildSocialMetaHtml({
url: buildAbsoluteUrl(o.req, pathname, o.settings.publicURL),
url: buildAbsoluteUrl(o.req, pathname, o.settings.publicURL, proxyPath),
siteName,
title,
description,

View file

@ -30,7 +30,7 @@
}
],
"dependencies": {
"@elastic/elasticsearch": "^9.4.0",
"@elastic/elasticsearch": "^9.4.1",
"async": "^3.2.6",
"cassandra-driver": "^4.8.0",
"cookie-parser": "^1.4.7",
@ -48,7 +48,7 @@
"htmlparser2": "^12.0.0",
"http-errors": "^2.0.1",
"jose": "^6.2.3",
"js-cookie": "^3.0.6",
"js-cookie": "^3.0.7",
"jsdom": "^29.1.1",
"jsonminify": "0.4.2",
"jsonwebtoken": "^9.0.3",
@ -57,7 +57,7 @@
"live-plugin-manager": "^1.1.0",
"lodash.clonedeep": "4.5.0",
"log4js": "^6.9.1",
"lru-cache": "^11.3.6",
"lru-cache": "^11.5.0",
"mammoth": "^1.12.0",
"measured-core": "^2.0.0",
"mime-types": "^3.0.2",
@ -67,9 +67,9 @@
"nano": "^11.0.5",
"nodemailer": "^8.0.7",
"oidc-provider": "9.8.3",
"openapi-backend": "^5.16.1",
"openapi-backend": "^5.17.0",
"pdfkit": "^0.18.0",
"pg": "^8.20.0",
"pg": "^8.21.0",
"prom-client": "^15.1.3",
"proxy-addr": "^2.0.7",
"rate-limiter-flexible": "^11.1.0",
@ -80,14 +80,14 @@
"rethinkdb": "^2.4.2",
"rusty-store-kv": "^1.3.1",
"security": "1.0.0",
"semver": "^7.8.0",
"semver": "^7.8.1",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"superagent": "10.3.0",
"surrealdb": "^2.0.3",
"tinycon": "0.6.8",
"tsx": "4.22.0",
"ueberdb2": "^6.0.3",
"tsx": "4.22.3",
"ueberdb2": "^6.1.2",
"underscore": "1.13.8",
"undici": "^8.3.0",
"unorm": "1.6.0",
@ -114,7 +114,7 @@
"@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^3.0.1",
"@types/mocha": "^10.0.9",
"@types/node": "^25.8.0",
"@types/node": "^25.9.1",
"@types/nodemailer": "^8.0.0",
"@types/oidc-provider": "^9.5.0",
"@types/pdfkit": "^0.17.6",
@ -127,7 +127,7 @@
"eslint": "^10.4.0",
"eslint-config-etherpad": "^4.0.5",
"etherpad-cli-client": "^4.0.3",
"mocha": "^11.7.5",
"mocha": "^11.7.6",
"mocha-froth": "^0.2.10",
"nodeify": "^1.0.1",
"openapi-schema-validation": "^0.4.2",
@ -136,7 +136,7 @@
"split-grid": "^1.0.11",
"supertest": "^7.2.2",
"typescript": "^6.0.3",
"vitest": "^4.1.6"
"vitest": "^4.1.7"
},
"engines": {
"node": ">=24.0.0",
@ -151,7 +151,7 @@
"lint": "eslint .",
"test": "cross-env NODE_ENV=production mocha --import=tsx --require ./tests/backend/diagnostics.ts --timeout 120000 --extension ts --recursive tests/backend/specs ../node_modules/ep_*/static/tests/backend/specs",
"test-utils": "cross-env NODE_ENV=production mocha --import=tsx --timeout 5000 --recursive tests/backend/specs/*utils.ts",
"test-container": "mocha --import=tsx --timeout 5000 tests/container/specs/api",
"test-container": "mocha --import=tsx --timeout 30000 --extension ts,js tests/container/specs/api",
"dev": "cross-env NODE_ENV=development node --require tsx/cjs node/server.ts",
"prod": "cross-env NODE_ENV=production node --require tsx/cjs node/server.ts",
"ts-check": "tsc --noEmit",
@ -163,6 +163,6 @@
"debug:socketio": "cross-env DEBUG=socket.io* node --require tsx/cjs node/server.ts",
"test:vitest": "vitest"
},
"version": "3.1.0",
"version": "3.2.0",
"license": "Apache-2.0"
}

View file

@ -115,20 +115,6 @@ input {
margin-right:auto;
}
/* Auto-update version badge — only visible when /api/version-status reports severe or vulnerable. */
#version-badge {
position: fixed;
bottom: 8px;
right: 8px;
padding: 6px 10px;
font-size: 12px;
border-radius: 4px;
z-index: 9999;
pointer-events: auto;
max-width: 320px;
}
#version-badge[data-level="severe"] { background: #fff3cd; color: #664d03; border: 1px solid #ffe69c; }
#version-badge[data-level="vulnerable"] { background: #f8d7da; color: #58151c; border: 1px solid #f1aeb5; }
/* ----------------------------------------------------------------------- */
/* History mode (issue #7659): timeslider rendered in-place inside the */

View file

@ -55,8 +55,7 @@ const socketio = require('./socketio');
const hooks = require('./pluginfw/hooks');
import {showPrivacyBannerIfEnabled} from './privacy_banner';
import './pad_version_badge';
import {maybeShowOutdatedNotice} from './pad_outdated_notice';
// This array represents all GET-parameters which can be used to change a setting.
// name: the parameter-name, eg `?noColors=true` => `noColors`
@ -749,6 +748,7 @@ const pad = {
showDeletionTokenModalIfPresent();
showPrivacyBannerIfEnabled((clientVars as any).privacyBanner);
void maybeShowOutdatedNotice();
hooks.aCallAll('postAceInit', {ace: padeditor.ace, clientVars, pad});
};

View file

@ -0,0 +1,43 @@
'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 */
}
};

View file

@ -1,46 +0,0 @@
'use strict';
interface BadgeResponse { outdated: 'severe' | 'vulnerable' | null }
// TODO(i18n): switch to html10n once a `pad.update.badge.*` key set is added there.
// (Strings are deliberately not pulled from /locales/en.json yet — that file is
// consumed by the admin UI's i18next, not the pad's html10n. Cross-wiring is
// a separate piece of work.)
const TEXT_BY_LEVEL: Record<'severe' | 'vulnerable', string> = {
severe: 'Etherpad on this server is severely outdated. Tell your admin.',
vulnerable: 'Etherpad on this server is running a version with known security issues. Tell your admin.',
};
// padBootstrap.js derives basePath from window.location ('..' relative to the
// pad URL) so deployments hosted under a subpath route requests through the
// same prefix. We replicate that here rather than importing pad.ts (which
// would reintroduce the badge↔pad circular initialisation).
const apiBasePath = (): string => {
if (typeof window === 'undefined') return '/';
return new URL('..', window.location.href).pathname;
};
export const renderVersionBadge = async (): Promise<void> => {
const el = document.getElementById('version-badge');
if (!el) return;
try {
const res = await fetch(`${apiBasePath()}api/version-status`, {credentials: 'same-origin'});
if (!res.ok) return;
const data = (await res.json()) as BadgeResponse;
if (!data.outdated) { el.style.display = 'none'; return; }
el.textContent = TEXT_BY_LEVEL[data.outdated];
el.dataset.level = data.outdated;
el.style.display = '';
} catch {
// Quiet failure — never block the pad load.
}
};
// Auto-render once DOM is ready.
if (typeof window !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => { void renderVersionBadge(); });
} else {
void renderVersionBadge();
}
}

View file

@ -680,6 +680,16 @@ export class Html10n {
// @ts-ignore
node[prop] = str.str!
populateAriaLabel()
} else if (node.tagName === 'SELECT' || node.tagName === 'INPUT' ||
node.tagName === 'TEXTAREA') {
// Form-controllable elements carry their accessible name on aria-label
// rather than as text content — a <select>'s text is its <option>
// labels, not its own name. Plugins that put `data-l10n-id` on a
// <select> (ep_headings2, ep_align, ep_font_size, …) used to trigger a
// spurious "could not translate element content" warning here because
// the text-node hunt below finds nothing to write. Short-circuit and
// just localize aria-label. See ether/ep_align#182 review.
populateAriaLabel()
} else {
let children = node.childNodes,
found = false
@ -697,17 +707,6 @@ export class Html10n {
if (!found) {
console.warn('Unexpected error: could not translate element content for key '+str.id, node)
}
// Form-controllable elements (<select>, <input>, <textarea>) carry their
// accessible name on aria-label rather than as text content (a <select>'s
// text is its <option> labels, not its own name). The textContent branch
// above doesn't fall through to populateAriaLabel(), so plugins that put
// data-l10n-id on a <select> and rely on the auto-population (introduced
// in #7584) end up with no accessible name. Populate aria-label here so
// those controls stay localized too. See ether/ep_align#182 review.
const tag = node.tagName;
if (tag === 'SELECT' || tag === 'INPUT' || tag === 'TEXTAREA') {
populateAriaLabel()
}
}
}

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<title><%- padId %></title>
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/manifest.json" />
<meta name="generator" content="Etherpad"/>
<meta name="author" content="Etherpad"/>
<meta name="changedby" content="Etherpad"/>

View file

@ -10,7 +10,7 @@
<title><%=settings.title%></title>
<%- typeof socialMetaHtml !== 'undefined' ? socialMetaHtml : '' %>
<meta charset="utf-8">
<link rel="manifest" href="/manifest.json" />
<link rel="manifest" href="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/manifest.json" />
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
<link rel="shortcut icon" href="favicon.ico">
@ -248,5 +248,5 @@
<% e.begin_block("indexCustomScripts"); %>
<script src="static/skins/<%=encodeURI(settings.skinName)%>/index.js?v=<%=settings.randomVersionString%>"></script>
<% e.end_block(); %>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
<div style="display:none"><a href="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/javascript" data-jslicense="1">JavaScript license information</a></div>
</html>

View file

@ -20,7 +20,7 @@
<% e.end_block(); %>
<title><%=settings.title%></title>
<%- typeof socialMetaHtml !== 'undefined' ? socialMetaHtml : '' %>
<link rel="manifest" href="../../manifest.json" />
<link rel="manifest" href="../manifest.json" />
<script>
/*
|@licstart The following is the entire license notice for the
@ -515,7 +515,7 @@
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
<% e.end_block(); %>
</div>
<form id="reconnectform" method="post" action="/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;">
<form id="reconnectform" method="post" action="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;">
<input type="hidden" class="padId" name="padId">
<input type="hidden" class="diagnosticInfo" name="diagnosticInfo">
<input type="hidden" class="missedChanges" name="missedChanges">
@ -645,7 +645,6 @@
<% e.end_block(); %>
<div id="version-badge" role="status" aria-live="polite" style="display:none"></div>
</div> <!-- End of #editorcontainerbox -->
<% e.end_block(); %>
@ -662,7 +661,7 @@
<% e.begin_block("customScripts"); %>
<script type="text/javascript" src="../static/skins/<%=encodeURI(settings.skinName)%>/pad.js?v=<%=settings.randomVersionString%>"></script>
<% e.end_block(); %>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
<div style="display:none"><a href="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/javascript" data-jslicense="1">JavaScript license information</a></div>
<% e.end_block(); %>
</body>
</html>

View file

@ -35,7 +35,7 @@
*/
</script>
<meta charset="utf-8">
<link rel="manifest" href="../../../manifest.json" />
<link rel="manifest" href="../../manifest.json" />
<meta name="robots" content="noindex, nofollow">
<meta name="referrer" content="no-referrer">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
@ -220,7 +220,7 @@
<button id="forcereconnect" class="btn btn-primary" data-l10n-id="pad.modals.forcereconnect"></button>
<% e.end_block(); %>
</div>
<form id="reconnectform" method="post" action="/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;">
<form id="reconnectform" method="post" action="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/ep/pad/reconnect" accept-charset="UTF-8" style="display: none;">
<input type="hidden" class="padId" name="padId">
<input type="hidden" class="diagnosticInfo" name="diagnosticInfo">
<input type="hidden" class="missedChanges" name="missedChanges">
@ -280,5 +280,5 @@
<!-- Bootstrap -->
<script src="<%=entrypoint%>"></script>
<% e.end_block(); %>
<div style="display:none"><a href="/javascript" data-jslicense="1">JavaScript license information</a></div>
<div style="display:none"><a href="<%= typeof proxyPath !== 'undefined' ? proxyPath : '' %>/javascript" data-jslicense="1">JavaScript license information</a></div>
</html>

View file

@ -0,0 +1,42 @@
import {describe, expect, it} from 'vitest';
import {firstAuthorOf} from '../../../../../node/hooks/express/updateStatus';
const makePad = (entries: Record<number, [string, string]>): any => ({
pool: {numToAttrib: entries},
});
describe('firstAuthorOf', () => {
it('returns null for a pad with no attribs', () => {
expect(firstAuthorOf(makePad({}))).toBeNull();
});
it('returns null when no author attribs exist', () => {
expect(firstAuthorOf(makePad({0: ['bold', 'true'], 1: ['italic', 'true']}))).toBeNull();
});
it('returns the only author when there is one', () => {
expect(firstAuthorOf(makePad({0: ['author', 'a.alice']}))).toBe('a.alice');
});
it('returns the lowest-numbered author when there are several', () => {
expect(firstAuthorOf(makePad({
0: ['bold', 'true'],
1: ['author', 'a.alice'],
2: ['author', 'a.bob'],
}))).toBe('a.alice');
});
it('skips empty-string author placeholders', () => {
expect(firstAuthorOf(makePad({
0: ['author', ''],
1: ['author', 'a.alice'],
}))).toBe('a.alice');
});
it('walks keys in numeric order, not string order', () => {
expect(firstAuthorOf(makePad({
10: ['author', 'a.bob'],
2: ['author', 'a.alice'],
}))).toBe('a.alice');
});
});

View file

@ -0,0 +1,332 @@
/**
* End-to-end vitest coverage for the /api/version-status route.
*
* Harness: minimal Express app built by calling `expressCreateServer` directly
* (same as production), then exercised via supertest. `loadState` is mocked so
* tests control the "latest" version without touching the filesystem.
* `PadManager` is mocked so pad-creation doesn't require a running database.
* `AuthorManager` is mocked so tokenauthorID resolution doesn't require a DB.
*
* Author injection: `resolveRequestAuthor` reads the `token` cookie and calls
* `authorManager.getAuthorId(token, user)`. Tests set `sessionAuthor` to
* control which authorID the mock returns for the test token `t.alice`.
* `null` means "anonymous" (getAuthorId returns null / empty for the token).
*/
import {describe, it, expect, vi, beforeAll, beforeEach, afterEach} from 'vitest';
import express from 'express';
import supertest from 'supertest';
import type {Express} from 'express';
import type {UpdateState} from '../../../../../node/updater/types';
import {EMPTY_STATE} from '../../../../../node/updater/types';
// ---------------------------------------------------------------------------
// Module mocks — must appear before any import that transitively imports them.
// vi.mock() is hoisted by vitest ahead of all imports, so these factories run
// before updateStatus.ts is loaded and its own `import {loadState}` runs.
// ---------------------------------------------------------------------------
vi.mock('../../../../../node/updater/state', () => ({
loadState: vi.fn(),
saveState: vi.fn(),
}));
// The updater index is imported by updateStatus.ts for stateFilePath() and
// getDetectedInstallMethod(). Provide stubs so we don't boot the full updater.
vi.mock('../../../../../node/updater', () => ({
stateFilePath: () => '/tmp/test-update-state.json',
getDetectedInstallMethod: () => 'git',
}));
// AuthorManager is dynamically imported inside resolveRequestAuthor(). Stubbing
// it here lets tests control token→authorID resolution without a DB.
vi.mock('../../../../../node/db/AuthorManager', () => ({
default: {
getAuthorId: vi.fn(),
},
}));
// PadManager is dynamically imported inside computeOutdated(). Stubbing it
// here lets us control pad existence and author-pool contents without a DB.
vi.mock('../../../../../node/db/PadManager', () => {
const pads = new Map<string, any>();
return {
default: {
isValidPadId: (id: string) => /^[^$]{1,50}$/.test(id),
doesPadExist: async (id: string) => pads.has(id),
getPad: async (id: string) => pads.get(id),
},
// Also expose the map for test setup via the named export __pads__.
__pads__: pads,
};
});
// ---------------------------------------------------------------------------
// Import the SUT *after* vi.mock declarations so the mocks take effect.
// ---------------------------------------------------------------------------
import * as stateModule from '../../../../../node/updater/state';
import * as authorManagerModule from '../../../../../node/db/AuthorManager';
import {
expressCreateServer,
_resetBadgeCacheForTests,
_setBadgeCacheCapForTests,
} from '../../../../../node/hooks/express/updateStatus';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build the mocked state with optional `latest` field. */
const makeState = (latest: UpdateState['latest']): UpdateState => ({
...EMPTY_STATE,
latest,
});
/** A fake ReleaseInfo for a version that is a minor-version ahead of 3.1.0. */
const MINOR_AHEAD: UpdateState['latest'] = {
version: '3.2.0',
tag: 'v3.2.0',
body: '',
publishedAt: '2026-05-01T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.2.0',
};
/** A fake ReleaseInfo that is only a patch ahead of 3.1.0 (no minor/major delta). */
const PATCH_AHEAD: UpdateState['latest'] = {
version: '3.1.1',
tag: 'v3.1.1',
body: '',
publishedAt: '2026-05-01T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.1.1',
};
/** A fake ReleaseInfo that is behind or equal to 3.1.0 (current >= latest). */
const SAME_OR_BEHIND: UpdateState['latest'] = {
version: '3.0.0',
tag: 'v3.0.0',
body: '',
publishedAt: '2026-01-01T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v3.0.0',
};
// ---------------------------------------------------------------------------
// Test app setup
// ---------------------------------------------------------------------------
let app: Express;
let request: ReturnType<typeof supertest>;
/**
* The author that `authorManager.getAuthorId` will return for the test token
* `t.alice`. Tests set this before making a request. `null` means "anonymous"
* (getAuthorId returns null/empty, so resolveRequestAuthor returns null).
*/
let sessionAuthor: string | null = null;
/** Fixed test token used in every request. The cookie name has no prefix in tests. */
const TEST_TOKEN = 't.alice';
beforeAll(() => {
app = express();
// Inject the test token cookie so resolveRequestAuthor() sees it.
// The real cookie-parser middleware is not needed: we set req.cookies directly.
app.use((req: any, _res, next) => {
req.cookies = {token: TEST_TOKEN};
next();
});
// Register the route under test. The hook signature is (hookName, {app, ...}, cb).
expressCreateServer('expressCreateServer', {app, io: null, server: null, settings: null as any}, () => {});
request = supertest(app);
});
beforeEach(() => {
// Reset LRU cache and in-flight map so every test sees a cold cache.
_resetBadgeCacheForTests();
// Reset the session author to "anonymous" by default.
sessionAuthor = null;
// Reset the loadState spy so each test controls its own return value.
vi.mocked(stateModule.loadState).mockReset();
// Wire up the AuthorManager mock: return sessionAuthor (or null) for our test token.
vi.mocked((authorManagerModule as any).default.getAuthorId).mockImplementation(
async (token: string) => {
if (token === TEST_TOKEN && sessionAuthor !== null) return sessionAuthor;
return null;
},
);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Helper to get the pad map from the mocked PadManager.
// ---------------------------------------------------------------------------
const getPadMap = async (): Promise<Map<string, any>> => {
// Dynamic import returns the mock factory's return value.
const mod: any = await import('../../../../../node/db/PadManager');
return mod.__pads__ as Map<string, any>;
};
/** Create a minimal fake pad object with the given author at pool position 0. */
const makePad = (firstAuthorId: string, secondAuthorId?: string) => {
const numToAttrib: Record<number, [string, string]> = {
0: ['author', firstAuthorId],
};
if (secondAuthorId) {
numToAttrib[1] = ['author', secondAuthorId];
}
return {pool: {numToAttrib}};
};
// ---------------------------------------------------------------------------
// Test cases
// ---------------------------------------------------------------------------
describe('/api/version-status', () => {
// Case 1: loadState returns no `latest` → EMPTY response regardless of author/padId.
it('case 1: returns {outdated:null, isFirstAuthor:false} when state has no latest', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(null));
const res = await request.get('/api/version-status').query({padId: 'testpad1'});
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: null, isFirstAuthor: false});
});
// Case 2: current >= latest → no banner.
it('case 2: returns {outdated:null, isFirstAuthor:false} when current >= latest', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(SAME_OR_BEHIND));
const res = await request.get('/api/version-status').query({padId: 'testpad2'});
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: null, isFirstAuthor: false});
});
// Case 3: delta is patch-only → isMinorOrMoreBehind returns false → no banner.
it('case 3: returns {outdated:null, isFirstAuthor:false} for patch-only delta', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(PATCH_AHEAD));
const res = await request.get('/api/version-status').query({padId: 'testpad3'});
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: null, isFirstAuthor: false});
});
// Case 4: padId omitted → even if behind, route returns EMPTY because padId is null.
it('case 4: returns {outdated:null, isFirstAuthor:false} when padId is omitted', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
const res = await request.get('/api/version-status');
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: null, isFirstAuthor: false});
});
// Case 5: pad exists, request author is NOT pool position 0 → EMPTY.
it('case 5: returns {outdated:null, isFirstAuthor:false} when requester is not first author', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
const padMap = await getPadMap();
padMap.set('mypad5', makePad('a.alice', 'a.bob'));
// Request is made by a.bob (position 1), not a.alice (position 0).
sessionAuthor = 'a.bob';
const res = await request.get('/api/version-status').query({padId: 'mypad5'});
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: null, isFirstAuthor: false});
padMap.delete('mypad5');
});
// Case 6: request author IS pool position 0 AND latest is minor-behind → full badge.
it('case 6: returns {outdated:"minor", isFirstAuthor:true} when requester is first author and minor behind', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
const padMap = await getPadMap();
padMap.set('mypad6', makePad('a.alice'));
sessionAuthor = 'a.alice';
const res = await request.get('/api/version-status').query({padId: 'mypad6'});
expect(res.status).toBe(200);
expect(res.body).toEqual({outdated: 'minor', isFirstAuthor: true});
padMap.delete('mypad6');
});
// Case 7: two requests to the same (padId, authorId) → loadState called exactly once (cache hit).
it('case 7: cache hit — loadState called exactly once across two identical requests', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
const padMap = await getPadMap();
padMap.set('mypad7', makePad('a.alice'));
sessionAuthor = 'a.alice';
await request.get('/api/version-status').query({padId: 'mypad7'});
await request.get('/api/version-status').query({padId: 'mypad7'});
expect(vi.mocked(stateModule.loadState)).toHaveBeenCalledTimes(1);
padMap.delete('mypad7');
});
// Case 8: different (padId, authorId) pairs → cache entries are independent.
it('case 8: cache isolation — different keys result in separate loadState calls', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
const padMap = await getPadMap();
padMap.set('mypad8a', makePad('a.alice'));
padMap.set('mypad8b', makePad('a.bob'));
sessionAuthor = 'a.alice';
await request.get('/api/version-status').query({padId: 'mypad8a'});
sessionAuthor = 'a.bob';
await request.get('/api/version-status').query({padId: 'mypad8b'});
// Two distinct cache keys → two separate computeOutdated() calls → two loadState calls.
expect(vi.mocked(stateModule.loadState)).toHaveBeenCalledTimes(2);
padMap.delete('mypad8a');
padMap.delete('mypad8b');
});
// Case 9: LRU eviction — cap at 2, insert 3 entries, then re-hit key 1 → 4 total loadState calls.
it('case 9: LRU eviction causes re-computation after capacity exceeded', async () => {
vi.mocked(stateModule.loadState).mockResolvedValue(makeState(MINOR_AHEAD));
_setBadgeCacheCapForTests(2);
const padMap = await getPadMap();
padMap.set('mypad9a', makePad('a.alice'));
padMap.set('mypad9b', makePad('a.bob'));
padMap.set('mypad9c', makePad('a.carol'));
// First three distinct keys: key1, key2, key3.
// With cap=2, after inserting key3 the LRU evicts the least-recently-used
// (key1, since key2 was accessed after key1).
sessionAuthor = 'a.alice';
await request.get('/api/version-status').query({padId: 'mypad9a'}); // key1, miss → call 1
sessionAuthor = 'a.bob';
await request.get('/api/version-status').query({padId: 'mypad9b'}); // key2, miss → call 2
sessionAuthor = 'a.carol';
await request.get('/api/version-status').query({padId: 'mypad9c'}); // key3, miss → call 3, evicts key1
// Re-hit key1 → it was evicted, so another miss → call 4.
sessionAuthor = 'a.alice';
await request.get('/api/version-status').query({padId: 'mypad9a'}); // key1, miss → call 4
expect(vi.mocked(stateModule.loadState)).toHaveBeenCalledTimes(4);
padMap.delete('mypad9a');
padMap.delete('mypad9b');
padMap.delete('mypad9c');
});
});

View file

@ -121,4 +121,88 @@ describe('sanitizeProxyPath', () => {
expect(sanitizeProxyPath('//pad')).toBe('/pad');
});
});
describe('X-Forwarded-Prefix and X-Ingress-Path', () => {
const mockReqMulti = (headers: Record<string, string|undefined>) => ({
header: (name: string) => headers[name.toLowerCase()],
});
it('reads X-Forwarded-Prefix when trustProxy is true', () => {
expect(sanitizeProxyPath(
mockReqMulti({'x-forwarded-prefix': '/foo'}),
{trustProxy: true})).toBe('/foo');
});
it('reads X-Ingress-Path when trustProxy is true', () => {
expect(sanitizeProxyPath(
mockReqMulti({'x-ingress-path': '/api/hassio_ingress/abc'}),
{trustProxy: true})).toBe('/api/hassio_ingress/abc');
});
it('ignores X-Forwarded-Prefix when trustProxy is false', () => {
expect(sanitizeProxyPath(
mockReqMulti({'x-forwarded-prefix': '/foo'}),
{trustProxy: false})).toBe('');
});
it('ignores X-Ingress-Path when trustProxy is false', () => {
expect(sanitizeProxyPath(
mockReqMulti({'x-ingress-path': '/foo'}),
{trustProxy: false})).toBe('');
});
it('x-proxy-path still works without trustProxy (legacy Etherpad convention)', () => {
expect(sanitizeProxyPath(
mockReqMulti({'x-proxy-path': '/legacy'}),
{trustProxy: false})).toBe('/legacy');
});
it('x-proxy-path wins over standard headers when all are present', () => {
expect(sanitizeProxyPath(
mockReqMulti({
'x-proxy-path': '/legacy',
'x-forwarded-prefix': '/forwarded',
'x-ingress-path': '/ingress',
}),
{trustProxy: true})).toBe('/legacy');
});
it('x-forwarded-prefix beats x-ingress-path when both are present', () => {
expect(sanitizeProxyPath(
mockReqMulti({
'x-forwarded-prefix': '/forwarded',
'x-ingress-path': '/ingress',
}),
{trustProxy: true})).toBe('/forwarded');
});
it('sanitises standard headers the same as x-proxy-path', () => {
expect(sanitizeProxyPath(
mockReqMulti({'x-forwarded-prefix': '//evil.example/pwn'}),
{trustProxy: true})).toBe('/evil.example/pwn');
expect(sanitizeProxyPath(
mockReqMulti({'x-ingress-path': '/a/../b'}),
{trustProxy: true})).toBe('');
expect(sanitizeProxyPath(
mockReqMulti({'x-forwarded-prefix': 'pad'}),
{trustProxy: true})).toBe('/pad');
});
it('defaults trustProxy from settings when opts not provided', async () => {
const settings = (await import('../../../node/utils/Settings')).default;
const original = settings.trustProxy;
try {
settings.trustProxy = true;
expect(sanitizeProxyPath(
mockReqMulti({'x-forwarded-prefix': '/x'})))
.toBe('/x');
settings.trustProxy = false;
expect(sanitizeProxyPath(
mockReqMulti({'x-forwarded-prefix': '/x'})))
.toBe('');
} finally {
settings.trustProxy = original;
}
});
});
});

View file

@ -7,7 +7,6 @@ const base: NotifierInput = {
current: '2.0.0',
latest: '2.7.2',
latestTag: 'v2.7.2',
isVulnerable: false,
isSevere: false,
state: EMPTY_STATE.email,
now: new Date('2026-04-25T12:00:00Z'),
@ -43,55 +42,10 @@ describe('decideEmails', () => {
expect(r.toSend.map(e => e.kind)).toEqual(['severe']);
});
it('emits vulnerable email on first detection', () => {
const r = decideEmails({...base, isVulnerable: true});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
expect(r.newState.vulnerableAt).toBe('2026-04-25T12:00:00.000Z');
});
it('does not re-emit vulnerable within 7 days', () => {
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-22T12:00:00.000Z'},
});
it('emits no email when neither severe nor vulnerable', () => {
const r = decideEmails({...base});
expect(r.toSend).toEqual([]);
});
it('re-emits vulnerable after 7 days', () => {
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-15T12:00:00.000Z'},
});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
});
it('emits new-release-while-vulnerable when latest tag changes', () => {
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-25T11:59:00.000Z', vulnerableNewReleaseTag: 'v2.7.1'},
});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable-new-release']);
});
it('vulnerable wins over severe in the same tick', () => {
const r = decideEmails({...base, isSevere: true, isVulnerable: true});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
});
it('emits new-release-while-vulnerable even after the 7-day window has passed', () => {
// Regression: tagChanged should fire regardless of cadence; admin must learn of the fix.
const r = decideEmails({
...base,
isVulnerable: true,
state: {...base.state, vulnerableAt: '2026-04-01T12:00:00.000Z', vulnerableNewReleaseTag: 'v2.7.1'},
});
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable-new-release']);
expect(r.newState.vulnerableNewReleaseTag).toBe('v2.7.2');
expect(r.newState.vulnerableAt).toBe('2026-04-25T12:00:00.000Z');
});
});
describe('decideOutcomeEmail', () => {

View file

@ -4,7 +4,7 @@ import {ReleaseInfo} from '../../../../node/updater/types';
const ghBody = (overrides: Partial<{tag_name: string; body: string; prerelease: boolean; html_url: string; published_at: string}> = {}) => ({
tag_name: 'v2.7.2',
body: 'Some changes.\n<!-- updater: vulnerable-below 2.6.4 -->',
body: 'Some changes.',
prerelease: false,
html_url: 'https://github.com/ether/etherpad/releases/tag/v2.7.2',
published_at: '2026-04-25T00:00:00Z',
@ -24,14 +24,13 @@ describe('checkLatestRelease', () => {
const expected: ReleaseInfo = {
version: '2.7.2',
tag: 'v2.7.2',
body: 'Some changes.\n<!-- updater: vulnerable-below 2.6.4 -->',
body: 'Some changes.',
publishedAt: '2026-04-25T00:00:00Z',
prerelease: false,
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v2.7.2',
};
expect(r.release).toEqual(expected);
expect(r.etag).toBe('abc');
expect(r.vulnerableBelow).toEqual([{announcedBy: 'v2.7.2', threshold: '2.6.4'}]);
});
it('returns notmodified on 304', async () => {

View file

@ -62,22 +62,8 @@ describe('loadState', () => {
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when vulnerableBelow entries miss threshold', async () => {
const broken = {...EMPTY_STATE, vulnerableBelow: [{announcedBy: 'v1.0.0'}]};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when vulnerableBelow.threshold is non-string', async () => {
const broken = {...EMPTY_STATE, vulnerableBelow: [{announcedBy: 'v1', threshold: 123}]};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);
});
it('returns empty state when email subfield is wrong type', async () => {
const broken = {...EMPTY_STATE, email: {severeAt: 0, vulnerableAt: null, vulnerableNewReleaseTag: null}};
const broken = {...EMPTY_STATE, email: {severeAt: 0}};
await fs.writeFile(statePath(), JSON.stringify(broken));
const s = await loadState(statePath());
expect(s).toEqual(EMPTY_STATE);

View file

@ -1,13 +1,13 @@
import {describe, it, expect} from 'vitest';
import {
parseSemver,
compareSemver,
isMajorBehind,
parseVulnerableBelow,
isVulnerable,
} from '../../../../node/updater/versionCompare';
import {describe, expect, it} from 'vitest';
import {compareSemver, isMinorOrMoreBehind, parseSemver} from '../../../../node/updater/versionCompare';
describe('parseSemver', () => {
it('parses standard semver', () => {
expect(parseSemver('1.2.3')).toEqual({major: 1, minor: 2, patch: 3});
});
it('accepts v-prefix and pre-release', () => {
expect(parseSemver('v2.7.3-rc.1')).toEqual({major: 2, minor: 7, patch: 3});
});
it('parses a plain version', () => {
expect(parseSemver('2.7.1')).toEqual({major: 2, minor: 7, patch: 1});
});
@ -19,6 +19,11 @@ describe('parseSemver', () => {
expect(parseSemver('')).toBeNull();
expect(parseSemver('2.7')).toBeNull();
});
it('rejects garbage', () => {
expect(parseSemver('not-a-version')).toBeNull();
expect(parseSemver('1.2')).toBeNull();
expect(parseSemver('2.7.1.4')).toBeNull();
});
it('strips prerelease suffix', () => {
expect(parseSemver('2.7.1-rc.1')).toEqual({major: 2, minor: 7, patch: 1});
expect(parseSemver('v2.7.1-beta')).toEqual({major: 2, minor: 7, patch: 1});
@ -33,6 +38,11 @@ describe('parseSemver', () => {
});
describe('compareSemver', () => {
it('returns -1, 0, 1', () => {
expect(compareSemver('1.2.3', '1.2.4')).toBe(-1);
expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
expect(compareSemver('1.2.4', '1.2.3')).toBe(1);
});
it('orders correctly', () => {
expect(compareSemver('2.7.1', '2.7.2')).toBe(-1);
expect(compareSemver('2.7.2', '2.7.1')).toBe(1);
@ -44,49 +54,26 @@ describe('compareSemver', () => {
});
});
describe('isMajorBehind', () => {
it('true when at least one major behind', () => {
expect(isMajorBehind('2.7.1', '3.0.0')).toBe(true);
expect(isMajorBehind('2.7.1', '4.0.0')).toBe(true);
describe('isMinorOrMoreBehind', () => {
it('returns false for equal versions', () => {
expect(isMinorOrMoreBehind('3.0.0', '3.0.0')).toBe(false);
});
it('false otherwise', () => {
expect(isMajorBehind('2.7.1', '2.99.99')).toBe(false);
expect(isMajorBehind('3.0.0', '3.0.0')).toBe(false);
expect(isMajorBehind('3.0.0', '2.7.1')).toBe(false);
});
});
describe('parseVulnerableBelow', () => {
it('extracts directive from release body', () => {
const body = 'Fixes a few things.\n<!-- updater: vulnerable-below 2.6.4 -->\nMore notes.';
expect(parseVulnerableBelow(body)).toBe('2.6.4');
});
it('tolerates whitespace and casing', () => {
expect(parseVulnerableBelow('<!--updater:vulnerable-below 1.0.0-->')).toBe('1.0.0');
expect(parseVulnerableBelow('<!-- UPDATER: VULNERABLE-BELOW 1.0.0 -->')).toBe('1.0.0');
});
it('returns null when absent or malformed', () => {
expect(parseVulnerableBelow('no directive here')).toBeNull();
expect(parseVulnerableBelow('<!-- updater: vulnerable-below garbage -->')).toBeNull();
});
});
describe('isVulnerable', () => {
it('true if current strictly below any directive threshold', () => {
expect(isVulnerable('2.6.3', [
{announcedBy: 'v2.7.0', threshold: '2.6.4'},
])).toBe(true);
});
it('false at or above all thresholds', () => {
expect(isVulnerable('2.6.4', [
{announcedBy: 'v2.7.0', threshold: '2.6.4'},
])).toBe(false);
expect(isVulnerable('2.7.0', [])).toBe(false);
});
it('handles multiple directives', () => {
expect(isVulnerable('1.5.0', [
{announcedBy: 'v2.0.0', threshold: '2.0.0'},
{announcedBy: 'v3.0.0', threshold: '1.9.0'},
])).toBe(true);
it('returns false for current ahead of latest', () => {
expect(isMinorOrMoreBehind('3.1.0', '3.0.5')).toBe(false);
});
it('returns false for patch-only delta', () => {
expect(isMinorOrMoreBehind('2.7.3', '2.7.4')).toBe(false);
expect(isMinorOrMoreBehind('3.0.1', '3.0.9')).toBe(false);
});
it('returns true for minor delta', () => {
expect(isMinorOrMoreBehind('3.1.0', '3.2.0')).toBe(true);
expect(isMinorOrMoreBehind('3.1.5', '3.2.0')).toBe(true);
});
it('returns true for major delta', () => {
expect(isMinorOrMoreBehind('2.7.3', '3.0.0')).toBe(true);
});
it('returns false on unparseable input on either side', () => {
expect(isMinorOrMoreBehind('garbage', '3.0.0')).toBe(false);
expect(isMinorOrMoreBehind('3.0.0', 'garbage')).toBe(false);
});
});

View file

@ -309,5 +309,62 @@ describe(__filename, function () {
// After shutdown, the timer should be cleared.
assert(ss!._cleanupTimer == null);
});
// Regression for https://github.com/ether/etherpad/issues/7830 — cleanup
// used to load every sessionstorage key into a single array; on huge DBs
// this OOMed. Verifies the paged iteration still hits every key when the
// count exceeds CLEANUP_PAGE_SIZE — we seed a few-row spread and force a
// small page size to keep the test fast.
it('pages across a large sessionstorage keyspace', async function () {
// Tag rows so the assertion ignores anything other tests left behind.
const tag = common.randomString();
const expiredSids: string[] = [];
const validSids: string[] = [];
// Seed 25 expired + 25 valid rows. The default CLEANUP_PAGE_SIZE (500)
// would cover this in one call, so we monkey-patch the constant for
// this test by stubbing DB.findKeysPaged to enforce a small page.
const real = db.findKeysPaged;
let pageCalls = 0;
db.findKeysPaged = async (key: string, notKey: any, opts: any) => {
pageCalls++;
return await real.call(db, key, notKey, {...opts, limit: 4});
};
try {
for (let i = 0; i < 25; i++) {
const sid = `cleanup_paged_exp_${tag}_${String(i).padStart(2, '0')}`;
expiredSids.push(sid);
await db.set(`sessionstorage:${sid}`, {
cookie: {path: '/', expires: new Date(1).toJSON(), httpOnly: true},
});
}
for (let i = 0; i < 25; i++) {
const sid = `cleanup_paged_val_${tag}_${String(i).padStart(2, '0')}`;
validSids.push(sid);
await db.set(`sessionstorage:${sid}`, {
cookie: {
path: '/', expires: new Date(Date.now() + 60000).toJSON(), httpOnly: true,
},
});
}
await ss!._cleanup();
for (const sid of expiredSids) {
assert(await db.get(`sessionstorage:${sid}`) == null, `expired ${sid} not removed`);
}
for (const sid of validSids) {
assert(await db.get(`sessionstorage:${sid}`) != null, `valid ${sid} was wrongly removed`);
}
// page size 4 over 50 rows -> at least 12 paged calls (final page may
// be short). Confirms we actually iterated.
assert(pageCalls >= 12, `expected paged iteration (got ${pageCalls} calls)`);
} finally {
db.findKeysPaged = real;
// Symmetric cleanup — if an assertion threw earlier, expiredSids may
// still be present in the DB. Remove both groups so the test leaves
// no rows behind even on failure.
for (const sid of [...expiredSids, ...validSids]) {
await db.remove(`sessionstorage:${sid}`);
}
}
});
});
});

View file

@ -0,0 +1,101 @@
'use strict';
import {strict as assert} from 'assert';
import {redactSettings} from '../../../../node/utils/AdminSettingsRedact';
describe('AdminSettingsRedact', function () {
it('returns a deep clone, never mutates input', function () {
const input = {dbSettings: {password: 'secret'}};
const out = redactSettings(input) as any;
assert.equal(input.dbSettings.password, 'secret');
assert.equal(out.dbSettings.password, '[REDACTED]');
assert.notEqual(out.dbSettings, input.dbSettings);
});
it('redacts users.*.password and users.*.passwordHash', function () {
const out = redactSettings({
users: {
admin: {password: 'p1', is_admin: true},
bob: {passwordHash: 'bcrypt$...'},
},
}) as any;
assert.equal(out.users.admin.password, '[REDACTED]');
assert.equal(out.users.admin.is_admin, true);
assert.equal(out.users.bob.passwordHash, '[REDACTED]');
});
it('redacts users.*.hash (older spelling)', function () {
const out = redactSettings({users: {alice: {hash: 'old$...'}}}) as any;
assert.equal(out.users.alice.hash, '[REDACTED]');
});
it('redacts dbSettings.password and dbSettings.user', function () {
const out = redactSettings({
dbSettings: {
host: 'localhost',
user: 'etherpad',
password: 'secret',
filename: '/data/etherpad.db',
},
}) as any;
assert.equal(out.dbSettings.password, '[REDACTED]');
assert.equal(out.dbSettings.user, '[REDACTED]');
assert.equal(out.dbSettings.host, 'localhost');
assert.equal(out.dbSettings.filename, '/data/etherpad.db');
});
it('redacts sso.clients[*].client_secret and .secret', function () {
const out = redactSettings({
sso: {
clients: [
{client_id: 'app1', client_secret: 'shhh'},
{client_id: 'app2', secret: 'older-style'},
],
},
}) as any;
assert.equal(out.sso.clients[0].client_secret, '[REDACTED]');
assert.equal(out.sso.clients[0].client_id, 'app1');
assert.equal(out.sso.clients[1].secret, '[REDACTED]');
assert.equal(out.sso.clients[1].client_id, 'app2');
});
it('redacts top-level sessionKey', function () {
const out = redactSettings({sessionKey: 'sign-me'}) as any;
assert.equal(out.sessionKey, '[REDACTED]');
});
it('emits [REDACTED] sentinel for null secret values', function () {
const out = redactSettings({dbSettings: {password: null}}) as any;
assert.equal(out.dbSettings.password, '[REDACTED]');
});
it('drops functions and other non-serialisable values', function () {
const out = redactSettings({
port: 9001,
reloadSettings: () => {},
dbSettings: {password: 'x'},
}) as any;
assert.equal(out.port, 9001);
assert.equal(out.reloadSettings, undefined);
assert.equal(out.dbSettings.password, '[REDACTED]');
});
it('leaves non-sensitive keys untouched', function () {
const input = {
port: 9001,
ip: '0.0.0.0',
loglevel: 'INFO',
trustProxy: false,
defaultPadText: 'Welcome!',
};
const out = redactSettings(input) as any;
assert.deepEqual(out, input);
});
it('only matches the exact JSON path, not deeper matches', function () {
const out = redactSettings({
sso: {clients: [{nested: {client_secret: 'nope'}}]},
}) as any;
assert.equal(out.sso.clients[0].nested.client_secret, 'nope');
});
});

View file

@ -0,0 +1,181 @@
'use strict';
import {strict as assert} from 'assert';
import setCookieParser from 'set-cookie-parser';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const io = require('socket.io-client');
const common = require('../../common');
const settings = require('../../../../node/utils/Settings');
const adminSocket = async () => {
settings.users = settings.users || {};
settings.users['test-admin'] = {password: 'test-admin-password', is_admin: true};
const savedRequireAuthentication = settings.requireAuthentication;
settings.requireAuthentication = true;
let res: any;
try {
res = await (common.agent as any)
.get('/admin/')
.auth('test-admin', 'test-admin-password');
} finally {
settings.requireAuthentication = savedRequireAuthentication;
}
const resCookies = setCookieParser.parse(res, {map: true});
const reqCookieHdr = Object.entries(resCookies)
.map(([name, cookie]: [string, any]) =>
`${name}=${encodeURIComponent(cookie.value)}`)
.join('; ');
const socket = io(`${common.baseUrl}/settings`, {
forceNew: true,
query: {cookie: reqCookieHdr},
});
await new Promise<void>((res, rej) => {
const onErr = (err: any) => { socket.off('connect', onConn); rej(err); };
const onConn = () => { socket.off('connect_error', onErr); res(); };
socket.once('connect', onConn);
socket.once('connect_error', onErr);
});
return socket;
};
// Probe modeled on anonymizeAuthorSocket.ts — when an authenticate-hook
// plugin (e.g. ep_hash_auth) rejects plain-text test creds, the /settings
// connection handler never registers listeners and every emit hangs.
// `load` is the simplest event with a matching reply on this namespace.
const PROBE_BUDGET_MS = 15000;
const adminSocketWithProbe = async (budgetMs: number): Promise<{
ok: true; socket: any;
} | {ok: false; reason: string;}> => {
const deadline = Date.now() + budgetMs;
let socket: any;
try {
socket = await Promise.race([
adminSocket(),
new Promise<never>((_, rej) =>
setTimeout(() => rej(new Error('adminSocket connect timed out')),
Math.max(0, deadline - Date.now()))),
]);
} catch (err: any) {
return {ok: false, reason: String(err && err.message || err)};
}
const remaining = Math.max(0, deadline - Date.now());
const replied = new Promise<true>((res) => socket.once('settings', () => res(true)));
socket.emit('load', null);
const probed = await Promise.race([
replied,
new Promise<false>((res) => setTimeout(() => res(false), remaining)),
]);
if (!probed) {
socket.disconnect();
return {ok: false, reason: `no \`settings\` reply within ${budgetMs}ms`};
}
return {ok: true, socket};
};
const ask = (socket: any, evt: string, payload: any, replyEvt: string) =>
new Promise<any>((res) => {
socket.once(replyEvt, res);
socket.emit(evt, payload);
});
describe(__filename, function () {
let socket: any;
let savedUsers: any;
let savedRequireAuthentication: boolean;
let savedDbPwd: any;
let savedTrustProxy: any;
let savedSessionKey: any;
let savedShow: any;
let savedSettingsFilename: any;
let tmpSettingsPath: string | null = null;
let setupCompleted = false;
before(async function () {
this.timeout(60000);
await common.init();
// The load handler bails with logger.error + early return if the
// file is missing, so make sure something is on disk for it to read.
savedSettingsFilename = settings.settingsFilename;
tmpSettingsPath = path.join(os.tmpdir(),
`etherpad-7803-settings-${process.pid}.json`);
fs.writeFileSync(tmpSettingsPath,
'{\n "_comment": "stub settings.json for adminSettingsResolved.ts"\n}\n');
settings.settingsFilename = tmpSettingsPath;
savedUsers = settings.users;
savedRequireAuthentication = settings.requireAuthentication;
settings.dbSettings = settings.dbSettings || {};
savedDbPwd = settings.dbSettings.password;
savedTrustProxy = settings.trustProxy;
savedSessionKey = settings.sessionKey;
savedShow = settings.showSettingsInAdminPage;
// Mutate the in-memory module so we can prove `resolved` reflects
// the runtime, not the file on disk.
settings.dbSettings.password = 'live-db-password';
settings.trustProxy = true;
settings.sessionKey = 'live-session-key';
setupCompleted = true;
const probe = await adminSocketWithProbe(PROBE_BUDGET_MS);
if (!probe.ok) {
console.warn(
`[adminSettingsResolved] admin socket probe failed (${probe.reason}); ` +
'skipping suite — likely an authenticate-hook plugin rejecting test creds.');
this.skip();
return;
}
socket = probe.socket;
});
after(function () {
if (socket) socket.disconnect();
if (!setupCompleted) return;
if (savedDbPwd === undefined) delete settings.dbSettings.password;
else settings.dbSettings.password = savedDbPwd;
settings.trustProxy = savedTrustProxy;
settings.sessionKey = savedSessionKey;
settings.showSettingsInAdminPage = savedShow;
settings.settingsFilename = savedSettingsFilename;
if (tmpSettingsPath) {
try { fs.unlinkSync(tmpSettingsPath); } catch { /* best effort */ }
}
if (settings.users) delete settings.users['test-admin'];
settings.users = savedUsers;
settings.requireAuthentication = savedRequireAuthentication;
});
it('emits {results, resolved, flags}', async function () {
const reply: any = await ask(socket, 'load', null, 'settings');
assert.ok(reply, 'reply present');
assert.equal(typeof reply.results, 'string', 'raw file string');
assert.equal(typeof reply.resolved, 'object', 'resolved object');
assert.ok(reply.flags, 'flags present');
});
it('resolved reflects live in-memory values, not the file on disk', async function () {
const reply: any = await ask(socket, 'load', null, 'settings');
assert.equal(reply.resolved.trustProxy, true,
'resolved should show the in-memory trustProxy');
});
it('resolved redacts secrets', async function () {
const reply: any = await ask(socket, 'load', null, 'settings');
assert.equal(reply.resolved.dbSettings.password, '[REDACTED]');
assert.equal(reply.resolved.sessionKey, '[REDACTED]');
});
it('resolved is omitted when showSettingsInAdminPage is false', async function () {
settings.showSettingsInAdminPage = false;
try {
const reply: any = await ask(socket, 'load', null, 'settings');
assert.equal(reply.results, 'NOT_ALLOWED');
assert.equal(reply.resolved, undefined);
} finally {
settings.showSettingsInAdminPage = savedShow;
}
});
});

View file

@ -0,0 +1,220 @@
'use strict';
import {strict as assert} from 'assert';
import setCookieParser from 'set-cookie-parser';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const io = require('socket.io-client');
const common = require('../../common');
const settings = require('../../../../node/utils/Settings');
// Mirrors the adminSocket helper in adminSettingsResolved.ts. Lifted here
// because the suite owns its own settings.settingsFilename stub and we
// don't want either suite's setup/teardown to step on the other.
const adminSocket = async () => {
settings.users = settings.users || {};
settings.users['test-admin'] = {password: 'test-admin-password', is_admin: true};
const savedRequireAuthentication = settings.requireAuthentication;
settings.requireAuthentication = true;
let res: any;
try {
res = await (common.agent as any)
.get('/admin/')
.auth('test-admin', 'test-admin-password');
} finally {
settings.requireAuthentication = savedRequireAuthentication;
}
const resCookies = setCookieParser.parse(res, {map: true});
const reqCookieHdr = Object.entries(resCookies)
.map(([name, cookie]: [string, any]) =>
`${name}=${encodeURIComponent(cookie.value)}`)
.join('; ');
const socket = io(`${common.baseUrl}/settings`, {
forceNew: true,
query: {cookie: reqCookieHdr},
});
await new Promise<void>((res, rej) => {
const onErr = (err: any) => { socket.off('connect', onConn); rej(err); };
const onConn = () => { socket.off('connect_error', onErr); res(); };
socket.once('connect', onConn);
socket.once('connect_error', onErr);
});
return socket;
};
const PROBE_BUDGET_MS = 15000;
const adminSocketWithProbe = async (budgetMs: number): Promise<{
ok: true; socket: any;
} | {ok: false; reason: string;}> => {
const deadline = Date.now() + budgetMs;
let socket: any;
try {
socket = await Promise.race([
adminSocket(),
new Promise<never>((_, rej) =>
setTimeout(() => rej(new Error('adminSocket connect timed out')),
Math.max(0, deadline - Date.now()))),
]);
} catch (err: any) {
return {ok: false, reason: String(err && err.message || err)};
}
const remaining = Math.max(0, deadline - Date.now());
const replied = new Promise<true>((res) => socket.once('settings', () => res(true)));
socket.emit('load', null);
const probed = await Promise.race([
replied,
new Promise<false>((res) => setTimeout(() => res(false), remaining)),
]);
if (!probed) {
socket.disconnect();
return {ok: false, reason: `no \`settings\` reply within ${budgetMs}ms`};
}
return {ok: true, socket};
};
const ask = (socket: any, evt: string, payload: any, replyEvt: string) =>
new Promise<any>((res) => {
socket.once(replyEvt, res);
socket.emit(evt, payload);
});
const save = (socket: any, payload: string) =>
new Promise<{status: string; detail?: any}>((res, rej) => {
const timeout = setTimeout(
() => rej(new Error('saveSettings: no saveprogress within 5s')), 5000);
socket.once('saveprogress', (status: string, detail: any) => {
clearTimeout(timeout);
res({status, detail});
});
socket.emit('saveSettings', payload);
});
// Regression coverage for issue #7819 / the broader observation that the
// admin saveSettings socket has zero backend coverage. The goal here is
// narrow: prove that whatever raw string the admin SPA emits ends up on
// disk byte-for-byte at settings.settingsFilename, and the subsequent
// `load` reply reflects the new file contents. We do NOT exercise
// runtime reload — that's reloadSettings()' job and is covered elsewhere.
describe(__filename, function () {
let socket: any;
let savedUsers: any;
let savedRequireAuthentication: boolean;
let savedSettingsFilename: any;
let tmpSettingsPath: string | null = null;
let baselineContents: string;
let setupCompleted = false;
before(async function () {
this.timeout(60000);
await common.init();
savedSettingsFilename = settings.settingsFilename;
tmpSettingsPath = path.join(os.tmpdir(),
`etherpad-7819-settings-${process.pid}.json`);
// Realistic baseline: keys you'd find in a stock settings.json.
// Saved with two-space indent so we can later assert formatting is
// preserved through the write path.
baselineContents = JSON.stringify({
title: 'Etherpad',
ip: '0.0.0.0',
port: 9001,
users: {admin: {password: 'changeme1', is_admin: true}},
}, null, 2) + '\n';
fs.writeFileSync(tmpSettingsPath, baselineContents);
settings.settingsFilename = tmpSettingsPath;
savedUsers = settings.users;
savedRequireAuthentication = settings.requireAuthentication;
setupCompleted = true;
const probe = await adminSocketWithProbe(PROBE_BUDGET_MS);
if (!probe.ok) {
console.warn(
`[adminSettingsSave] admin socket probe failed (${probe.reason}); ` +
'skipping suite — likely an authenticate-hook plugin rejecting test creds.');
this.skip();
return;
}
socket = probe.socket;
});
after(function () {
if (socket) socket.disconnect();
if (!setupCompleted) return;
settings.settingsFilename = savedSettingsFilename;
if (tmpSettingsPath) {
try { fs.unlinkSync(tmpSettingsPath); } catch { /* best effort */ }
}
if (settings.users) delete settings.users['test-admin'];
settings.users = savedUsers;
settings.requireAuthentication = savedRequireAuthentication;
});
// Reset to baseline between tests so each it() is independent — earlier
// suites in the same mocha run can leave behind state via shared sockets.
beforeEach(function () {
if (!tmpSettingsPath) this.skip();
fs.writeFileSync(tmpSettingsPath!, baselineContents);
});
it('saveSettings writes the payload byte-for-byte to settings.settingsFilename',
async function () {
const payload = JSON.stringify({title: 'EtherpadWrittenViaSocket'}, null, 2);
const ack = await save(socket, payload);
assert.equal(ack.status, 'saved', 'saveprogress should be "saved"');
const onDisk = fs.readFileSync(tmpSettingsPath!, 'utf8');
assert.equal(onDisk, payload,
'on-disk contents must equal the raw payload (no transform)');
});
// The shape that triggered #7819: take an existing settings.json and add
// one new top-level block (a plugin config). The block must persist on
// disk verbatim and reappear in the next `load` reply.
it('augmenting existing JSON with a new top-level plugin block round-trips',
async function () {
const augmented = JSON.stringify({
title: 'Etherpad',
ip: '0.0.0.0',
port: 9001,
ep_oauth: {
clientID: 'Iv1.testclient',
clientSecret: 'testsecret',
callbackURL: 'https://etherpad.example.com/auth/callback',
},
users: {admin: {password: 'changeme1', is_admin: true}},
}, null, 2);
const ack = await save(socket, augmented);
assert.equal(ack.status, 'saved');
const onDisk = fs.readFileSync(tmpSettingsPath!, 'utf8');
assert.equal(onDisk, augmented,
'augmented JSON must be on disk verbatim');
// load() now reads the file we just wrote — `results` is the raw
// string, so it must contain the plugin block we added.
const reply: any = await ask(socket, 'load', null, 'settings');
assert.equal(reply.results, augmented,
'load.results must equal the file we just saved');
assert.ok(reply.results.includes('"ep_oauth"'),
'plugin block must be present in subsequent load');
});
// /* */ comments are legal in the admin editor (jsonc-parser tolerates
// them; the SPA's isJSONClean strips them before validation). The save
// path must not normalize or strip them — the SPA test
// 'preserves /* */ comments after save round-trip' covers the UI side;
// this one covers the socket-level guarantee.
it('preserves /* */ comments in the written file', async function () {
const withComment =
'/* persisted-marker-7819 */\n' +
JSON.stringify({title: 'Etherpad'}, null, 2);
const ack = await save(socket, withComment);
assert.equal(ack.status, 'saved');
const onDisk = fs.readFileSync(tmpSettingsPath!, 'utf8');
assert.ok(onDisk.includes('persisted-marker-7819'),
'comment must survive the write path');
});
});

View file

@ -62,17 +62,21 @@ const ask = (socket: any, evt: string, payload: any, replyEvt: string) =>
});
// adminSocket() depends on Etherpad's default plain-text password check for
// settings.users[name].password. Plugins like ep_hash_auth replace the
// authenticate hook to expect hashed credentials, so the basic-auth probe
// returns no admin session, /settings's connection handler returns without
// settings.users[name].password. Any authenticate-hook plugin that claims
// the request before the built-in basic-auth fallback can block this:
// the historical offender was ep_readonly_guest, whose authenticate hook
// sorts itself first and silently swaps req.session.user with a guest
// (#7795); ep_hash_auth-style plugins that expect hashed credentials
// would do the same. When that happens the basic-auth probe returns no
// admin session, /settings's connection handler returns without
// registering listeners (see src/node/hooks/express/adminsettings.ts:25),
// and every socket.emit() afterwards waits forever for a reply that
// nothing will ever send. The socket itself still connects when admin
// session is missing, so the probe has to run at the application layer:
// emit a known `/settings` event (`load`) and wait for the matching reply
// (`settings`). If it doesn't arrive within the budget, skip — much
// cheaper than letting mocha's 120s per-test timeout absorb 7 stalled
// tests. Tracked in #7795.
// emit a known `/settings` event (`authorLoad`) and wait for the matching
// reply (`results:authorLoad`). If it doesn't arrive within the budget,
// skip — much cheaper than letting mocha's 120s per-test timeout absorb
// 7 stalled tests.
const PROBE_BUDGET_MS = 15000;
const adminSocketWithProbe = async (budgetMs: number): Promise<{
ok: true; socket: any;
@ -135,8 +139,9 @@ describe(__filename, function () {
if (!probe.ok) {
console.warn(
`[anonymizeAuthorSocket] admin socket probe failed (${probe.reason}); ` +
'skipping suite — likely an authenticate-hook plugin (e.g. ep_hash_auth) ' +
'rejecting the test\'s plain-text admin credentials. Tracked in #7795.');
'skipping suite — an authenticate-hook plugin (e.g. ep_readonly_guest, ' +
'or an ep_hash_auth-style plugin requiring hashed credentials) is ' +
'rejecting the test\'s plain-text admin credentials.');
this.skip();
return;
}

View file

@ -0,0 +1,205 @@
'use strict';
// Regression test for the admin /settings socket's `padLoad` filter chip.
// Before commit fb…, `filter` (active|empty|recent|stale) lived only on
// the client and ran AFTER pagination, so clicking "empty pads" on a
// server with thousands of pads showed 012 results from page 1 even
// though hundreds of empty pads existed deeper in the list. The filter
// now rides PadSearchQuery and is applied server-side before the
// offset/limit slice, so `total` reflects the filtered universe.
import {strict as assert} from 'assert';
import setCookieParser from 'set-cookie-parser';
const io = require('socket.io-client');
const common = require('../../common');
const settings = require('../../../../node/utils/Settings');
const padManager = require('../../../../node/db/PadManager');
const adminSocket = async () => {
settings.users = settings.users || {};
settings.users['test-admin'] = {password: 'test-admin-password', is_admin: true};
const savedRequireAuthentication = settings.requireAuthentication;
settings.requireAuthentication = true;
let res: any;
try {
res = await (common.agent as any)
.get('/admin/')
.auth('test-admin', 'test-admin-password');
} finally {
settings.requireAuthentication = savedRequireAuthentication;
}
const resCookies = setCookieParser.parse(res, {map: true});
const reqCookieHdr = Object.entries(resCookies)
.map(([name, cookie]: [string, any]) =>
`${name}=${encodeURIComponent(cookie.value)}`)
.join('; ');
const socket = io(`${common.baseUrl}/settings`, {
forceNew: true,
query: {cookie: reqCookieHdr},
});
await new Promise<void>((res, rej) => {
const onErr = (err: any) => { socket.off('connect', onConn); rej(err); };
const onConn = () => { socket.off('connect_error', onErr); res(); };
socket.once('connect', onConn);
socket.once('connect_error', onErr);
});
return socket;
};
const ask = (socket: any, evt: string, payload: any, replyEvt: string) =>
new Promise<any>((res) => {
socket.once(replyEvt, res);
socket.emit(evt, payload);
});
const PROBE_BUDGET_MS = 15000;
const adminSocketWithProbe = async (budgetMs: number): Promise<{
ok: true; socket: any;
} | {ok: false; reason: string;}> => {
const deadline = Date.now() + budgetMs;
let socket: any;
try {
socket = await Promise.race([
adminSocket(),
new Promise<never>((_, rej) =>
setTimeout(() => rej(new Error('adminSocket connect timed out')),
Math.max(0, deadline - Date.now()))),
]);
} catch (err: any) {
return {ok: false, reason: String(err && err.message || err)};
}
const remaining = Math.max(0, deadline - Date.now());
const replied = new Promise<true>((res) => socket.once('results:padLoad', () => res(true)));
socket.emit('padLoad', {
pattern: '__padLoadFilter-probe__', offset: 0, limit: 1,
sortBy: 'padName', ascending: true,
});
const probed = await Promise.race([
replied,
new Promise<false>((res) => setTimeout(() => res(false), remaining)),
]);
if (!probed) {
socket.disconnect();
return {ok: false, reason: `no \`results:padLoad\` reply within ${budgetMs}ms`};
}
return {ok: true, socket};
};
describe(__filename, function () {
let socket: any;
let savedUsers: any;
let savedRequireAuthentication: boolean;
let setupCompleted = false;
// Distinct per-suite tag so concurrent test runs / leftover pads from
// earlier suites don't pollute the filter assertions.
const tag = `padLoadFilter-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
const emptyPadIds: string[] = [];
const editedPadIds: string[] = [];
before(async function () {
this.timeout(120000);
await common.init();
savedUsers = settings.users;
savedRequireAuthentication = settings.requireAuthentication;
setupCompleted = true;
const probe = await adminSocketWithProbe(PROBE_BUDGET_MS);
if (!probe.ok) {
console.warn(
`[padLoadFilter] admin socket probe failed (${probe.reason}); ` +
"skipping suite — likely an authenticate-hook plugin rejecting the test's " +
'admin credentials.');
this.skip();
return;
}
socket = probe.socket;
// 5 empty pads, 3 edited (head rev > 0).
for (let i = 0; i < 5; i++) {
const id = `${tag}-empty-${i}`;
await padManager.getPad(id, '');
emptyPadIds.push(id);
}
for (let i = 0; i < 3; i++) {
const id = `${tag}-edited-${i}`;
const pad = await padManager.getPad(id, '');
// setText bumps head past 0; padLoad reports the post-edit
// revisionNumber, which is what filter:"empty" excludes.
await pad.setText(`seed-${i}\n`, `m-${tag}-${i}`);
editedPadIds.push(id);
}
});
after(async function () {
if (socket) socket.disconnect();
if (!setupCompleted) return;
// `savedUsers` may point at the same object that adminSocket mutated,
// so reassigning the reference is a no-op; explicitly delete the
// injected key so subsequent backend specs don't see a stale
// test-admin user.
if (settings.users) delete settings.users['test-admin'];
settings.users = savedUsers;
settings.requireAuthentication = savedRequireAuthentication;
for (const id of [...emptyPadIds, ...editedPadIds]) {
try {
const pad = await padManager.getPad(id, '');
await pad.remove();
} catch { /* already gone */ }
}
});
it('filter:"empty" returns only revisionNumber===0 pads from the full set', async function () {
const res = await ask(socket, 'padLoad', {
pattern: tag, offset: 0, limit: 12, sortBy: 'padName',
ascending: true, filter: 'empty',
}, 'results:padLoad');
assert.equal(res.total, 5, `expected total=5, got ${JSON.stringify(res)}`);
for (const r of res.results) {
assert.equal(r.revisionNumber, 0,
`non-empty pad leaked through filter: ${JSON.stringify(r)}`);
}
});
it('filter:"empty" with limit=2 still reports the correct total (regression: thm)', async function () {
// The bug thm hit: clicking "empty" showed at most `limit` empties
// because filtering happened on the client AFTER pagination. The
// server now applies filter first, so total reflects the filtered
// universe and pagination spans it correctly.
const res = await ask(socket, 'padLoad', {
pattern: tag, offset: 0, limit: 2, sortBy: 'padName',
ascending: true, filter: 'empty',
}, 'results:padLoad');
assert.equal(res.total, 5, `expected total=5 (all empties), got total=${res.total}`);
assert.equal(res.results.length, 2, `expected limit=2 page, got ${res.results.length} rows`);
});
it('filter omitted (older client) falls back to "all"', async function () {
const res = await ask(socket, 'padLoad', {
pattern: tag, offset: 0, limit: 12, sortBy: 'padName',
ascending: true,
}, 'results:padLoad');
assert.equal(res.total, 8,
`expected total=8 (5 empty + 3 edited), got ${JSON.stringify(res)}`);
});
it('filter:"all" matches the no-filter behaviour', async function () {
const res = await ask(socket, 'padLoad', {
pattern: tag, offset: 0, limit: 12, sortBy: 'padName',
ascending: true, filter: 'all',
}, 'results:padLoad');
assert.equal(res.total, 8);
});
it('filter:"active" excludes pads with no active users', async function () {
// No connected clients in this test, so every test pad has
// userCount === 0 → filter:"active" must return zero.
const res = await ask(socket, 'padLoad', {
pattern: tag, offset: 0, limit: 12, sortBy: 'padName',
ascending: true, filter: 'active',
}, 'results:padLoad');
assert.equal(res.total, 0);
assert.equal(res.results.length, 0);
});
});

View file

@ -90,7 +90,6 @@ describe('admin OpenAPI document', function () {
'latest',
'policy',
'tier',
'vulnerableBelow',
]);
});
@ -104,10 +103,9 @@ describe('admin OpenAPI document', function () {
assert.deepEqual(enums.slice().sort(), ['auto', 'autonomous', 'manual', 'notify', 'off']);
});
it('declares ReleaseInfo, PolicyResult, VulnerableBelowDirective sub-schemas', function () {
it('declares ReleaseInfo and PolicyResult sub-schemas', function () {
assert.ok(doc.components.schemas.ReleaseInfo);
assert.ok(doc.components.schemas.PolicyResult);
assert.ok(doc.components.schemas.VulnerableBelowDirective);
});
it('ReleaseInfo properties mirror updater/types.ts', function () {
@ -124,10 +122,6 @@ describe('admin OpenAPI document', function () {
]);
});
it('VulnerableBelowDirective properties mirror updater/types.ts', function () {
const props = Object.keys(doc.components.schemas.VulnerableBelowDirective.properties).sort();
assert.deepEqual(props, ['announcedBy', 'threshold']);
});
});
describe('cross-collision with public spec', function () {

View file

@ -0,0 +1,97 @@
'use strict';
/**
* Coverage for /manifest.json prefix-awareness.
*
* Without a proxy header the manifest should emit today's values
* (leading-slash absolute paths). With a sanitised `x-proxy-path`,
* `x-forwarded-prefix` (requires trustProxy) or `x-ingress-path`
* (requires trustProxy), the manifest should emit prefixed paths so
* the PWA renders icons and start_url correctly when Etherpad is
* proxied under a subpath.
*/
const common = require('../common');
import settings from '../../../node/utils/Settings';
let agent: any;
describe(__filename, function () {
before(async function () { agent = await common.init(); });
describe('/manifest.json without proxy headers', function () {
it('emits leading-slash icon srcs and start_url=/', async function () {
const res = await agent.get('/manifest.json').expect(200);
const m = res.body;
if (m.start_url !== '/') {
throw new Error(`expected start_url "/", got ${JSON.stringify(m.start_url)}`);
}
const srcs = (m.icons || []).map((i: any) => i.src);
for (const s of srcs) {
if (!s.startsWith('/')) {
throw new Error(`expected leading-slash icon src, got ${s}`);
}
}
});
});
describe('/manifest.json with x-proxy-path', function () {
it('prefixes every icon src and start_url', async function () {
const res = await agent.get('/manifest.json')
.set('x-proxy-path', '/sub')
.expect(200);
const m = res.body;
if (m.start_url !== '/sub/') {
throw new Error(`expected start_url "/sub/", got ${JSON.stringify(m.start_url)}`);
}
const srcs = (m.icons || []).map((i: any) => i.src);
for (const s of srcs) {
if (!s.startsWith('/sub/')) {
throw new Error(`expected /sub/-prefixed icon src, got ${s}`);
}
}
});
it('sets Vary so caches don\'t collapse responses across prefixes', async function () {
const res = await agent.get('/manifest.json')
.set('x-proxy-path', '/sub')
.expect(200);
const vary = (res.headers.vary || '').toLowerCase();
if (!vary.includes('x-proxy-path')) {
throw new Error(`expected Vary to include x-proxy-path, got ${vary}`);
}
});
});
describe('/manifest.json with x-ingress-path (HA)', function () {
it('ignores the header when trustProxy is off', async function () {
const original = settings.trustProxy;
settings.trustProxy = false;
try {
const res = await agent.get('/manifest.json')
.set('x-ingress-path', '/api/hassio_ingress/abc')
.expect(200);
if (res.body.start_url !== '/') {
throw new Error(`expected start_url "/" when trustProxy=false, got ${res.body.start_url}`);
}
} finally {
settings.trustProxy = original;
}
});
it('honors the header when trustProxy is on', async function () {
const original = settings.trustProxy;
settings.trustProxy = true;
try {
const res = await agent.get('/manifest.json')
.set('x-ingress-path', '/api/hassio_ingress/abc')
.expect(200);
if (res.body.start_url !== '/api/hassio_ingress/abc/') {
throw new Error(`expected prefixed start_url, got ${res.body.start_url}`);
}
} finally {
settings.trustProxy = original;
}
});
});
});

View file

@ -427,4 +427,81 @@ describe(__filename, function () {
assert.ok(html.includes('&quot;'), 'quote escaped');
});
});
describe('renderSocialMeta — proxyPath fallback (no publicURL)', function () {
const mkReq = (overrides: Record<string, any> = {}) => ({
protocol: 'https',
get: (n: string) => n.toLowerCase() === 'host' ? 'pad.example' : undefined,
acceptsLanguages: () => 'en',
originalUrl: '/p/scratch',
...overrides,
});
it('prefixes og:url with proxyPath when publicURL is null', function () {
const out = renderSocialMeta({
req: mkReq() as any,
settings: {title: 'Etherpad', favicon: null, publicURL: null},
availableLangs: {en: {}},
locales: {en: {}},
kind: 'pad',
padName: 'scratch',
proxyPath: '/api/hassio_ingress/abc',
});
if (!out.includes('content="https://pad.example/api/hassio_ingress/abc/p/scratch"')) {
throw new Error(`og:url missing proxyPath prefix:\n${out}`);
}
});
it('prefixes og:image with proxyPath when publicURL is null and favicon is not an absolute URL', function () {
const out = renderSocialMeta({
req: mkReq() as any,
settings: {title: 'Etherpad', favicon: null, publicURL: null},
availableLangs: {en: {}},
locales: {en: {}},
kind: 'pad',
padName: 'scratch',
proxyPath: '/sub',
});
if (!out.includes('content="https://pad.example/sub/favicon.ico"')) {
throw new Error(`og:image missing proxyPath prefix:\n${out}`);
}
});
it('publicURL still wins over proxyPath when both are set', function () {
const out = renderSocialMeta({
req: mkReq() as any,
settings: {
title: 'Etherpad',
favicon: null,
publicURL: 'https://pad.canonical.example',
},
availableLangs: {en: {}},
locales: {en: {}},
kind: 'pad',
padName: 'scratch',
proxyPath: '/sub',
});
if (!out.includes('content="https://pad.canonical.example/p/scratch"')) {
throw new Error(`publicURL should win over proxyPath:\n${out}`);
}
if (out.includes('/sub/')) {
throw new Error(`proxyPath leaked into URL when publicURL was set:\n${out}`);
}
});
it('proxyPath default of "" produces today\'s URL shape', function () {
const out = renderSocialMeta({
req: mkReq() as any,
settings: {title: 'Etherpad', favicon: null, publicURL: null},
availableLangs: {en: {}},
locales: {en: {}},
kind: 'pad',
padName: 'scratch',
// proxyPath omitted
});
if (!out.includes('content="https://pad.example/p/scratch"')) {
throw new Error(`default URL shape regressed:\n${out}`);
}
});
});
});

View file

@ -44,35 +44,6 @@ describe(__filename, function () {
Object.assign(settings, backups.settings);
});
describe('GET /api/version-status', function () {
it('returns null when no state', async function () {
await saveState(statePath(), {...EMPTY_STATE});
const res = await agent.get('/api/version-status').expect(200);
assert.deepEqual(res.body, {outdated: null});
});
it('does not leak the running version', async function () {
const res = await agent.get('/api/version-status').expect(200);
assert.ok(!('version' in res.body), 'response leaks version field');
assert.ok(!('latest' in res.body), 'response leaks latest field');
assert.ok(!('currentVersion' in res.body), 'response leaks currentVersion field');
});
it('returns severe when running > 1 major behind', async function () {
// Force "latest" to be 99.0.0 so our running version is severely outdated.
await saveState(statePath(), {
...EMPTY_STATE,
latest: {
version: '99.0.0', tag: 'v99.0.0', body: '',
publishedAt: '2099-01-01T00:00:00Z', prerelease: false,
htmlUrl: 'https://example/',
},
});
const res = await agent.get('/api/version-status').expect(200);
assert.equal(res.body.outdated, 'severe');
});
});
describe('GET /admin/update/status', function () {
// Auth on this endpoint is intentionally loose: the running version is already
// exposed publicly via /health (releaseId), and latest/changelog come from a
@ -85,7 +56,6 @@ describe(__filename, function () {
assert.ok(typeof res.body.currentVersion === 'string');
assert.equal(res.body.latest, null);
assert.equal(res.body.tier, settings.updates.tier);
assert.ok(Array.isArray(res.body.vulnerableBelow));
});
it('redacts execution.reason / lastResult.reason for unauth callers', async function () {

View file

@ -0,0 +1,129 @@
'use strict';
/**
* End-to-end coverage for X-Forwarded-Prefix / X-Ingress-Path support (#7802).
*
* Verifies that across the public surfaces:
* - /
* - /p/:pad
* - /manifest.json
*
* a single sanitised proxy-path is reflected consistently in the
* rendered HTML and JSON: <link rel="manifest">, og:url, og:image,
* manifest start_url, manifest icon srcs, reconnect form action.
*
* Also verifies the no-header case still produces today's output
* (regression guard).
*/
const common = require('../common');
import settings from 'ep_etherpad-lite/node/utils/Settings';
let agent: any;
const expectHas = (haystack: string, needle: string, label: string) => {
if (!haystack.includes(needle)) {
throw new Error(`expected ${label} to include ${JSON.stringify(needle)}.\n--- got ---\n${haystack.slice(0, 800)}\n...`);
}
};
const expectMisses = (haystack: string, needle: string, label: string) => {
if (haystack.includes(needle)) {
throw new Error(`${label} should not include ${JSON.stringify(needle)}.\n--- got ---\n${haystack.slice(0, 800)}\n...`);
}
};
describe(__filename, function () {
before(async function () { agent = await common.init(); });
describe('no proxy headers - backwards compatibility', function () {
it('/ renders today\'s URLs', async function () {
const res = await agent.get('/').expect(200);
expectHas(res.text, 'href="/manifest.json"', 'index manifest link');
});
it('/p/:pad renders today\'s URLs', async function () {
const res = await agent.get('/p/UrlBasePathTest').expect(200);
expectHas(res.text, 'action="/ep/pad/reconnect"', 'reconnect form action');
expectHas(res.text, 'href="../manifest.json"', 'manifest link (relative form)');
});
it('/manifest.json returns root-relative paths', async function () {
const res = await agent.get('/manifest.json').expect(200);
if (res.body.start_url !== '/') {
throw new Error(`expected "/", got ${res.body.start_url}`);
}
});
});
describe('with x-proxy-path: /sub', function () {
const headers = {'x-proxy-path': '/sub'};
it('/ has /sub-prefixed manifest link', async function () {
const res = await agent.get('/').set(headers).expect(200);
expectHas(res.text, 'href="/sub/manifest.json"', 'index manifest link');
expectMisses(res.text, 'href="/manifest.json"', 'unprefixed manifest link');
});
it('/p/:pad reconnect form action carries the prefix', async function () {
const res = await agent.get('/p/UrlBasePathTest').set(headers).expect(200);
expectHas(res.text, 'action="/sub/ep/pad/reconnect"', 'reconnect form action');
// The manifest <link> stays relative (../manifest.json); browser resolves
// it to /sub/manifest.json based on the request URL - we assert the
// template emits the relative form unchanged.
expectHas(res.text, 'href="../manifest.json"', 'manifest link (relative form)');
});
it('/p/:pad og:url and og:image carry the prefix', async function () {
const res = await agent.get('/p/UrlBasePathTest').set(headers).expect(200);
expectHas(res.text, '/sub/p/UrlBasePathTest', 'og:url path');
expectHas(res.text, '/sub/favicon.ico', 'og:image path');
});
it('/manifest.json has /sub-prefixed start_url and icon srcs', async function () {
const res = await agent.get('/manifest.json').set(headers).expect(200);
if (res.body.start_url !== '/sub/') {
throw new Error(`expected /sub/, got ${res.body.start_url}`);
}
for (const icon of res.body.icons) {
if (!icon.src.startsWith('/sub/')) {
throw new Error(`icon src missing prefix: ${icon.src}`);
}
}
});
});
describe('with x-ingress-path under trustProxy', function () {
const headers = {'x-ingress-path': '/api/hassio_ingress/abc'};
let originalTrust: boolean;
before(function () {
originalTrust = settings.trustProxy;
settings.trustProxy = true;
});
after(function () { settings.trustProxy = originalTrust; });
it('/p/:pad picks up the HA ingress prefix in the reconnect form action', async function () {
const res = await agent.get('/p/UrlBasePathTest').set(headers).expect(200);
expectHas(res.text, 'action="/api/hassio_ingress/abc/ep/pad/reconnect"', 'reconnect form action');
});
it('/manifest.json picks up the HA ingress prefix', async function () {
const res = await agent.get('/manifest.json').set(headers).expect(200);
if (res.body.start_url !== '/api/hassio_ingress/abc/') {
throw new Error(`expected /api/hassio_ingress/abc/, got ${res.body.start_url}`);
}
});
});
describe('with x-ingress-path WITHOUT trustProxy', function () {
const headers = {'x-ingress-path': '/api/hassio_ingress/abc'};
it('header is ignored - output is today\'s', async function () {
// setUp guarantees trustProxy starts at its default (false) - see common.init
const res = await agent.get('/p/UrlBasePathTest').set(headers).expect(200);
expectHas(res.text, 'action="/ep/pad/reconnect"', 'unprefixed reconnect form action');
expectMisses(res.text, '/api/hassio_ingress/', 'leaked ingress prefix');
});
});
});

View file

@ -0,0 +1,157 @@
'use strict';
// Regression coverage for https://github.com/ether/etherpad/issues/7819.
// Drives the admin /settings socket against the running Docker container
// (test-container target) to prove the save flow actually writes a new
// top-level plugin block and the next `load` reads it back.
//
// Requires the container to be started with `-e ADMIN_PASSWORD=changeme1`
// so settings.json.docker provisions the admin user used here. Run via
// `pnpm run test-container` from the docker.yml workflow.
import {strict as assert} from 'assert';
import setCookieParser from 'set-cookie-parser';
const supertest = require('supertest');
const io = require('socket.io-client');
const BASE_URL = 'http://localhost:9001';
const ADMIN_USER = 'admin';
const ADMIN_PASSWORD = 'changeme1';
const MARKER = 'persist-marker-7819';
// /admin-auth/ is the path webaccess.ts always treats as requireAdmin,
// regardless of settings.requireAuthentication. The container runs with
// REQUIRE_AUTHENTICATION=false (default), so GET /admin/ would NOT issue
// a Basic challenge and we'd never get a session cookie. POSTing to
// /admin-auth/ does.
const adminCookieHeader = async (): Promise<string> => {
const res: any = await supertest(BASE_URL)
.post('/admin-auth/')
.auth(ADMIN_USER, ADMIN_PASSWORD);
if (res.status !== 200) {
throw new Error(
`/admin-auth/ POST returned ${res.status} (expected 200) — ` +
'is the container started with ADMIN_PASSWORD=changeme1? ' +
`Body: ${String(res.text).slice(0, 200)}`);
}
const cookies = setCookieParser.parse(res, {map: true}) as Record<string, any>;
if (Object.keys(cookies).length === 0) {
throw new Error('/admin-auth/ returned 200 but set no cookies — session middleware not wired?');
}
return Object.entries(cookies)
.map(([name, cookie]) => `${name}=${encodeURIComponent(cookie.value)}`)
.join('; ');
};
const settingsSocket = async (cookieHdr: string) => {
const socket = io(`${BASE_URL}/settings`, {
forceNew: true,
query: {cookie: cookieHdr},
transports: ['websocket'],
});
await new Promise<void>((res, rej) => {
const onErr = (err: any) => { socket.off('connect', onConn); rej(err); };
const onConn = () => { socket.off('connect_error', onErr); res(); };
socket.once('connect', onConn);
socket.once('connect_error', onErr);
});
return socket;
};
const load = (socket: any): Promise<{results: string; resolved?: any; flags?: any}> =>
new Promise((res, rej) => {
// No reply == handler never registered, which means our session
// wasn't admin. Surface that fast rather than burning the mocha
// timeout — the adminsettings.ts connection handler silently
// returns without binding any listeners when is_admin is false.
const t = setTimeout(
() => rej(new Error(
'load: no `settings` reply within 8s — likely not authenticated as admin')),
8000);
socket.once('settings', (s: any) => { clearTimeout(t); res(s); });
socket.emit('load', null);
});
const save = (socket: any, payload: string): Promise<{status: string; detail?: any}> =>
new Promise((res, rej) => {
const t = setTimeout(
() => rej(new Error('saveSettings: no saveprogress within 8s')), 8000);
socket.once('saveprogress', (status: string, detail: any) => {
clearTimeout(t);
res({status, detail});
});
socket.emit('saveSettings', payload);
});
describe('admin /settings socket (Docker container) — #7819', function () {
this.timeout(20000);
let socket: any;
before(async function () {
const cookieHdr = await adminCookieHeader();
socket = await settingsSocket(cookieHdr);
// Sanity: load works as admin. We don't keep the result — the file
// we're about to save replaces settings.json entirely.
const reply = await load(socket);
assert.equal(typeof reply.results, 'string',
'settings.results must be a string — container started without ADMIN_PASSWORD?');
});
after(function () {
if (socket) socket.disconnect();
// INTENTIONAL: do NOT restore baseline. docker.yml greps for MARKER
// via `docker exec` after this suite, then runs `docker restart`,
// then greps again — that whole chain proves the on-disk file
// survives container restart, which is the actual #7819 ask. The
// container is `docker rm -f`'d at the end of the workflow step, so
// leftover state doesn't poison anything.
});
it('save → load round-trip preserves a top-level plugin block', async function () {
// Hand-built minimal-but-viable settings document. Three reasons we
// don't splice into the original:
// 1. settings.json.docker uses jsonc `/* */` and `//` comments and
// keeps a trailing-comma-before-comment-before-close pattern
// that's annoying to patch correctly from the test side.
// 2. The backend `saveSettings` handler writes bytes verbatim with
// zero validation — so what we save IS what should come back.
// Whether the payload is "realistic" is orthogonal to whether
// the file persists.
// 3. After this save the container will be `docker restart`ed by
// the workflow. Minimal-but-viable means Etherpad starts back
// up: `port` is required by the HTTP server, `users.admin`
// keeps admin auth working post-restart, dbType=dirty keeps DB
// init happy.
const augmented = JSON.stringify({
title: 'Etherpad',
ip: '0.0.0.0',
port: 9001,
dbType: 'dirty',
dbSettings: {filename: 'var/dirty.db'},
showSettingsInAdminPage: true,
enableAdminUITests: true,
users: {admin: {password: ADMIN_PASSWORD, is_admin: true}},
ep_oauth: {
clientID: MARKER,
clientSecret: 'x',
callbackURL: 'http://x/cb',
},
}, null, 2);
const ack = await save(socket, augmented);
assert.equal(ack.status, 'saved',
`saveSettings did not ack 'saved' — got ${JSON.stringify(ack)}`);
// Re-load over the same socket. adminsettings.ts re-reads
// settings.settingsFilename on every `load`, so this reflects the
// actual file on disk — not a client-side echo.
const reply = await load(socket);
assert.equal(reply.results, augmented,
'load.results must equal the bytes we just saved');
assert.ok(reply.results.includes('"ep_oauth"'),
'ep_oauth block missing from next load — file on disk does not match payload');
assert.ok(reply.results.includes(MARKER),
`marker '${MARKER}' missing from next load`);
});
});

View file

@ -124,6 +124,66 @@ test.describe('admin settings',()=> {
await expect(settings).not.toHaveValue('', {timeout: 30000});
});
// Regression for https://github.com/ether/etherpad/issues/7819.
// The pre-existing 'restart works' test only proves the server comes
// back up; it never sets a custom value first, so a deployment that
// resets settings.json on restart (Docker layer wipe, init-container
// template render, snap reseed bug) would pass it. This test mirrors
// the user's actual workflow: open Raw, add a new top-level plugin
// block, save, restart, confirm the block is still there.
test('#7819 custom top-level block survives server restart', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
const raw = page.getByTestId('settings-raw-textarea');
await expect(raw).toBeVisible({timeout: 10000});
const original = await raw.inputValue();
expect(original.length).toBeGreaterThan(0);
// Inject `"ep_oauth": {…},` right after the opening brace. This matches
// what a user adding a plugin config block would type — we keep every
// other key intact, no schema, just a new top-level object.
const augmented = original.replace(
/^(\s*\{)/,
'$1"ep_oauth":{"clientID":"persist-marker-7819","clientSecret":"x",' +
'"callbackURL":"http://x/cb"},',
);
expect(augmented).not.toEqual(original);
expect(augmented).toContain('persist-marker-7819');
await raw.fill(augmented);
await saveSettings(page);
await restartEtherpad(page);
await loginToAdmin(page, 'admin', 'changeme1');
await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await page.getByTestId('mode-toggle-raw').click();
const rawAfter = page.getByTestId('settings-raw-textarea');
await expect(rawAfter).toBeVisible({timeout: 10000});
await expect(rawAfter).not.toHaveValue('', {timeout: 30000});
const afterRestart = await rawAfter.inputValue();
expect(afterRestart).toContain('"ep_oauth"');
expect(afterRestart).toContain('persist-marker-7819');
// The Form view must also surface the new block as its own section,
// since FormView renders every top-level object/array as a Section
// (admin/src/components/settings/FormView.tsx). humanize('ep_oauth')
// becomes 'Ep oauth'.
await page.getByTestId('mode-toggle-form').click();
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});
await expect(page.locator('[data-testid="settings-form-view"]'))
.toContainText(/Ep oauth/i);
// Restore — DO NOT rely on the next test's cleanup; the file is
// shared across the serial run and a custom ep_oauth block could
// poison anything that asserts the exact length of settings.
await page.getByTestId('mode-toggle-raw').click();
await rawAfter.fill(original);
await saveSettings(page);
});
test('form view derives label + help text from key comment', async ({page}) => {
await page.goto('http://localhost:9001/admin/settings');
await page.waitForSelector('[data-testid="settings-form-view"]', {timeout: 30000});

View file

@ -26,7 +26,7 @@ test.describe('admin update page', () => {
installMethod: 'git',
tier: 'notify',
policy: null,
vulnerableBelow: [],
}),
});
});
@ -59,7 +59,7 @@ test.describe('admin update page', () => {
installMethod: 'git',
tier: 'notify',
policy: {canNotify: true, canManual: false, canAuto: false, canAutonomous: false, reason: 'install-method-not-writable'},
vulnerableBelow: [],
}),
});
});

View file

@ -15,7 +15,6 @@ const baseStatus = {
installMethod: 'git',
tier: 'manual',
policy: {canNotify: true, canManual: true, canAuto: false, canAutonomous: false, reason: 'ok'},
vulnerableBelow: [],
execution: {status: 'idle'},
lastResult: null,
lockHeld: false,

View file

@ -15,7 +15,6 @@ const scheduledStatus = (msFromNow: number) => ({
installMethod: 'git',
tier: 'auto',
policy: {canNotify: true, canManual: true, canAuto: true, canAutonomous: false, reason: 'ok'},
vulnerableBelow: [],
execution: {
status: 'scheduled',
targetTag: 'v2.7.2',

View file

@ -59,6 +59,49 @@ test('html10n auto-populates aria-label on <textarea> with data-l10n-id', async
await expect(ta).toHaveAttribute('data-l10n-aria-label', 'true');
});
test('no "could not translate element content" warning for <select data-l10n-id>', async ({page}) => {
// Regression: thm reported the warning on Etherpad 3.1.0 because the
// <select data-l10n-id="ep_headings.style"> in ep_headings2 has only
// <option> element children — the text-node hunt in translateNode finds
// nothing and used to warn before the SELECT/INPUT/TEXTAREA short-circuit
// landed. The accessible name still ends up on aria-label.
//
// To actually exercise the bug path the key must default `prop` to
// textContent (i.e. the suffix after the last `.` must NOT be one of
// title|innerHTML|alt|textContent|value|placeholder, see
// html10n.translateNode). `pad.loading` is a stable pad-bundle key that
// satisfies that.
const warnings: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'warning') warnings.push(msg.text());
});
await page.evaluate(() => new Promise<void>((res) => {
const sel = document.createElement('select');
sel.id = 'html10n-test-no-warn';
sel.setAttribute('data-l10n-id', 'pad.loading');
for (const v of ['a', 'b', 'c']) {
const opt = document.createElement('option');
opt.value = v;
opt.textContent = v.toUpperCase();
sel.appendChild(opt);
}
document.body.appendChild(sel);
// localize completes asynchronously inside a build() callback; wait
// on the `localized` event instead of a fixed timeout so the test
// is deterministic on slow CI runners.
// @ts-ignore window.html10n is exposed by pad.ts
window.html10n.mt.bind('localized', () => res());
// @ts-ignore
window.html10n.localize(['en']);
}));
const offending = warnings.filter((m) =>
m.includes('could not translate element content') &&
m.includes('pad.loading'));
expect(offending).toEqual([]);
});
test('an author-supplied aria-label on a form control is preserved', async ({page}) => {
// Mirror the existing semantics for non-form-control elements: if the
// template author wrote their own aria-label, html10n must not

View file

@ -0,0 +1,124 @@
import {expect, test, Page} from '@playwright/test';
import {randomUUID} from 'node:crypto';
// Gritter items for outdated-notice are rendered into #gritter-container.bottom
// and tagged with class_name:'outdated-notice' so tests can target them
// independently of any other gritter surfaced during the pad session.
const NOTICE = '#gritter-container.bottom .gritter-item.outdated-notice';
const freshPad = async (page: Page) => {
// Suppress the pad-deletion-token modal (same technique as goToNewPad in
// padHelper.ts) so it can't race with postAceInit or steal DOM focus.
await page.addInitScript(() => {
let stored: unknown;
Object.defineProperty(window, 'clientVars', {
configurable: true,
get() { return stored; },
set(v) {
if (v != null && typeof v === 'object') {
(v as {padDeletionToken?: string | null}).padDeletionToken = null;
}
stored = v;
},
});
});
const padId = `FRONTEND_TESTS${randomUUID()}`;
await page.goto(`http://localhost:9001/p/${padId}`);
await page.waitForSelector('iframe[name="ace_outer"]');
await page.waitForSelector('#editorcontainer.initialized');
// Wait for the inner editor to be content-editable so postAceInit has fully
// resolved and the async maybeShowOutdatedNotice fetch has been dispatched.
await page.frameLocator('iframe[name="ace_outer"]')
.frameLocator('iframe[name="ace_inner"]')
.locator('#innerdocbody[contenteditable="true"]')
.waitFor({state: 'attached'});
return padId;
};
test.describe('outdated notice (gritter-based)', () => {
test.beforeEach(async ({context}) => {
await context.clearCookies();
});
test('outdated:null — no outdated-notice gritter is shown', async ({page}) => {
await page.route('**/api/version-status*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: null, isFirstAuthor: true}),
}));
await freshPad(page);
await expect(page.locator(NOTICE)).toHaveCount(0);
});
test('outdated:minor, isFirstAuthor:false — no gritter shown (client guard)',
async ({page}) => {
await page.route('**/api/version-status*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'minor', isFirstAuthor: false}),
}));
await freshPad(page);
await expect(page.locator(NOTICE)).toHaveCount(0);
});
test('outdated:minor, isFirstAuthor:true — gritter appears with correct text',
async ({page}) => {
await page.route('**/api/version-status*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'minor', isFirstAuthor: true}),
}));
await freshPad(page);
const item = page.locator(NOTICE);
await expect(item).toBeVisible();
await expect(item.locator('.gritter-title')).toHaveText('Etherpad update available');
await expect(item).toContainText(
'A newer version of Etherpad has been released');
});
test('user dismisses by clicking X — gritter disappears', async ({page}) => {
await page.route('**/api/version-status*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'minor', isFirstAuthor: true}),
}));
await freshPad(page);
const item = page.locator(NOTICE);
await expect(item).toBeVisible();
await item.locator('.gritter-close').click();
await expect(page.locator(NOTICE)).toHaveCount(0);
});
test('server returns 500 — no gritter and no user-visible error', async ({page}) => {
await page.route('**/api/version-status*', (route) =>
route.fulfill({status: 500, body: 'Internal Server Error'}));
await freshPad(page);
// Allow the async fetch to settle before asserting nothing appeared.
await page.waitForTimeout(500);
await expect(page.locator(NOTICE)).toHaveCount(0);
// No generic JS error dialog should have appeared.
await expect(page.locator('#errorpopup')).toHaveCount(0);
});
test('auto-fade after 8s — gritter gone after 9s', async ({page}) => {
// This test deliberately waits ~9 s for the gritter's time:8000 to elapse.
// Mark as slow so Playwright allocates a larger timeout budget.
test.slow();
await page.route('**/api/version-status*', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'minor', isFirstAuthor: true}),
}));
await freshPad(page);
// Confirm it appeared first.
await expect(page.locator(NOTICE)).toBeVisible();
// Wait for auto-fade (time:8000 ms in the gritter.add call).
await page.waitForTimeout(9000);
await expect(page.locator(NOTICE)).toHaveCount(0);
});
});

View file

@ -1,47 +0,0 @@
import {expect, test} from '@playwright/test';
const padUrl = (id = `test-${Date.now()}-${Math.floor(Math.random() * 1e6)}`) =>
`http://localhost:9001/p/${id}`;
test.describe('pad version badge', () => {
test('hidden when /api/version-status returns outdated:null', async ({page}) => {
await page.route('**/api/version-status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: null}),
}));
await page.goto(padUrl());
const badge = page.locator('#version-badge');
// The badge is rendered hidden (display:none) and stays hidden.
await expect(badge).toBeHidden({timeout: 30000});
});
test('shows severe text when outdated=severe', async ({page}) => {
await page.route('**/api/version-status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'severe'}),
}));
await page.goto(padUrl());
const badge = page.locator('#version-badge');
await expect(badge).toBeVisible({timeout: 30000});
await expect(badge).toContainText(/severely outdated/i);
await expect(badge).toHaveAttribute('data-level', 'severe');
});
test('shows vulnerable text when outdated=vulnerable', async ({page}) => {
await page.route('**/api/version-status', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({outdated: 'vulnerable'}),
}));
await page.goto(padUrl());
const badge = page.locator('#version-badge');
await expect(badge).toBeVisible({timeout: 30000});
await expect(badge).toContainText(/security issues/i);
await expect(badge).toHaveAttribute('data-level', 'vulnerable');
});
});

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