Merge branch 'develop' into backend-esm-vitest

Conflicts resolved in 10 files. All updater/hook files kept develop's new
logic (LRUCache per-key badge caching, firstAuthorOf/resolveRequestAuthor,
maintenanceWindow tier-4 support, node-engine-mismatch preflight check,
smtpTransportKey/notifyApplyFailure, isMinorOrMoreBehind replacing
isMajorBehind/isVulnerable, removed vulnerableBelow machinery).

Import style: branch ESM shape preserved throughout — .js extensions kept
on all local imports, require() calls in develop converted to ESM import
statements (ImportEtherpad.ts, ImportHtml.ts, pad.ts). pad.ts imports
pad_outdated_notice.js (new) instead of pad_version_badge.js (removed).
All vitest test files taken from develop as-is (no mocha shape introduced).
This commit is contained in:
SamTV12345 2026-05-25 11:56:32 +02:00
commit 5afd466bba
138 changed files with 11527 additions and 1668 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

@ -143,7 +143,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
@ -276,7 +276,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,3 +1,75 @@
# 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'`.
- **Updater — email admin on rollback / preflight-failed (not just `rollback-failed`).** Before this release only the terminal `rollback-failed` state emailed. Auto-recovered failures (`rolled-back-install-failed`, `rolled-back-build-failed`, `rolled-back-health-check`, `rolled-back-crash-loop`) and `preflight-failed` now also fire one email per `<outcome>:<targetTag>` (dedupe key in `EmailSendLog.lastFailureKey`). A 3am autonomous update that rolls back because of, say, a Node engine bump now lands in the admin inbox at 3am instead of staying invisible until the next admin login. Boot-path catch-up covers cases where the failure preceded a clean process exit (timer-fired health-check rollback, crash-loop forced rollback, preflight-failed that didn't get to email before exit).
- **API — `listAuthorsOfPad` filters the synthetic system author.** `Pad.SYSTEM_AUTHOR_ID` (`a.etherpad-system`) is the placeholder Etherpad attributes to when the HTTP API receives a call without an `authorId` (setText, setHTML, appendText, server-side import). It was leaking through `listAuthorsOfPad`, making pads with only API-driven content appear to have one "real" author. The synthetic id is now filtered at that API surface only — `getAllAuthors()` and downstream callers (copy, anonymize, atext verification) still see it. Fixes #7785 / #7790 (#7793).
### Notable fixes
- **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
A bundle of defence-in-depth tightening picked up during an internal audit pass (#7784):
- **HTTP API — OAuth JWT path.** Verify the signature *before* reading any claim off the payload; require `admin: true` strictly (presence is no longer sufficient). The apikey comparison switches to `crypto.timingSafeEqual`.
- **Import/Export temp-file path tokens.** Derived from `crypto.randomBytes(16)` instead of `Math.random()`.
- **Token transfer.** Records now have a 5-minute TTL and are single-use (removed from the store before responding). The author token is no longer in the redemption response body — the `HttpOnly` cookie is the only delivery channel.
- **`x-proxy-path` header sanitiser (new `src/node/utils/sanitizeProxyPath.ts`).** Shared by `admin.ts` and `specialpages.ts`. Strips characters outside `[A-Za-z0-9_./-]`, collapses leading `//+` to a single `/`, rejects `..` traversal. `admin.ts` also emits `Vary: x-proxy-path` and `Cache-Control: private, no-store` so a poisoned response can never be reused for another origin.
- **`Pad.appendRevision` insert-op author invariant.** Centralises the "every insert op carries an `author` attribute" rule the socket handler already enforced, so non-wire callers (`setText`, `setHTML`, `restoreRevision`, plugin paths) get the same check. `Pad.init` and `setPadHTML` substitute `SYSTEM_AUTHOR_ID` when no author is supplied — same pattern `setText` / `spliceText` already used.
- **`setPadRaw` legacy-import rewrite.** Bulk-import bypasses `appendRevision`, so a hand-crafted `.etherpad` file could persist non-conforming records that any subsequent `setText` / `setHTML` would refuse to extend. A pre-pass now walks revs in order, sanitises each changeset's `+` ops against the cumulative pad pool (substituting `SYSTEM_AUTHOR_ID` where needed), and re-applies each changeset to a running atext so the head atext and key-rev `meta.atext` / `meta.pool` snapshots stay in lock-step. Conforming payloads round-trip unchanged.
### Internal / contributor-facing
- **Backend tests — `tests/backend/specs/{api,admin}/*` un-skipped.** The pnpm test script's glob (`tests/backend/specs/**.ts`) only matched depth-1 files. Every spec under `api/` (14 files) and `admin/` (2 files) has been silently skipped by CI. Switched to `--extension ts --recursive` so mocha walks the tree as documented. A new vitest regression check reads the pnpm script, hands mocha the same arguments under `--dry-run --list-files`, and asserts representative specs from both subdirectories appear in the discovered list (#7789).
- **CI — Windows `npx ENOENT` in the glob-discovery regression check.** `execFileSync('npx', ...)` doesn't pick up `npx.cmd` on Windows runners. Resolved by running `mocha`'s JS entry directly via `require.resolve` under the current node process. Path normalisation now goes through `path.relative` + `replace([\\/])` so mixed-separator / drive-letter casing on Windows mocha output still matches the POSIX-relative assertions (#7794).
- **CI — `anonymizeAuthorSocket` suite gated on admin-socket health when `ep_hash_auth` is installed.** Un-hiding the suite in #7789 surfaced a 14-minute stall on every with-plugins matrix run because `ep_hash_auth`'s `handleMessage` hook fires for every socket message regardless of namespace and reads from the deprecated `client` context (undefined for non-pad namespaces). Until the root cause lands (tracked in #7795), the suite skips itself when an application-level probe shows the admin `/settings` namespace isn't responding — keeps the no-plugin matrix covered and stops burning ~14 minutes per with-plugins run (#7796).
### Localisation
- Multiple updates from translatewiki.net.
# 3.0.0
3.0 is a feature-heavy release that closes out the self-update programme (Tiers 2 and 3 land alongside Tier 1 from 2.7.3), removes the last identified upstream telemetry vector, and ships a parsed JSONC settings editor, native DOCX export, in-place pad history scrubbing, and an admin UI for GDPR author erasure. It also completes the backend ESM migration and replaces mocha with vitest as the backend test runner, and marks the start of the broader Etherpad app ecosystem (see *Companion apps* below).
@ -34,7 +106,9 @@ Both clients hit the **stable 3.x API surface**, so server operators don't need
- **Self-update subsystem — Tier 3 (auto with grace window).**
- On a git install, set `updates.tier: "auto"` to have new releases applied automatically after `preApplyGraceMinutes`. During the grace window, `/admin/update` shows a live countdown plus Cancel and Apply now buttons. Schedules are persisted to `var/update-state.json`, so an Etherpad restart during the grace window rehydrates the timer instead of losing the schedule. A new release tag detected mid-grace re-arms the timer; if `adminEmail` is set, a one-shot `grace-start` notification fires per scheduled tag (issue #7607).
- The terminal `rollback-failed` state continues to disable auto/autonomous attempts globally until acknowledged; manual click stays available because an admin click *is* the intervention the terminal state requires.
- Tier 4 (autonomous in a maintenance window) remains designed but unimplemented and will land in a subsequent release.
- **Self-update subsystem — 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 the host's 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 behavior is not silently disabled. Closes #7607.
- **Privacy — drop swagger-ui telemetry, document phone-homes, add opt-outs.**
- Dropped `swagger-ui-express` because upstream injects a Scarf analytics pixel that cannot be disabled at install or runtime (see [swagger-api/swagger-ui#10573](https://github.com/swagger-api/swagger-ui/issues/10573)). `/api-docs` now serves a vendored copy of [Scalar](https://github.com/scalar/scalar) (MIT) configured with `withDefaultFonts: false` and `telemetry: false` so no outbound calls are made.
- New `privacy.updateCheck` (default `true`) — set to `false` to disable the hourly `UpdateCheck.ts` request to `${updateServer}/info.json`.

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

@ -52,6 +52,23 @@ export const UpdateBanner = () => {
);
}
// Tier 4: tier is autonomous but the maintenance window isn't usable.
// Surface that before the generic "update available" banner so the admin
// knows the autonomous behavior is sitting idle.
const policyReason = updateStatus.policy?.reason;
if (updateStatus.tier === 'autonomous'
&& (policyReason === 'maintenance-window-missing'
|| policyReason === 'maintenance-window-invalid')) {
return (
<div className="update-banner update-banner-window" role="status">
<strong>
<Trans i18nKey={`update.banner.${policyReason}`}/>
</strong>{' '}
<Link to="/update">{t('update.banner.cta')}</Link>
</div>
);
}
// Tier 3: scheduled update — show countdown banner instead of the plain
// "update available" one.
if (updateStatus.execution?.status === 'scheduled') {

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

@ -192,6 +192,54 @@ export const UpdatePage = () => {
values={{tag: scheduled.targetTag, remaining: fmtRemaining(remainingMs)}}
/>
</p>
{/* Tier 4: only surface the deferral subtitle when `scheduledFor`
was actually snapped forward to the next window opening. The
backend keeps `scheduledFor = now + grace` whenever that lands
inside the window, so we can't use a fixed time-distance
heuristic (a normal 15-min grace would falsely match). Instead,
compare against `nextWindowOpensAt` with a small tolerance the
two are computed seconds apart at request time, so an exact-ish
match is the only safe signal that the schedule was deferred. */}
{us.tier === 'autonomous' && us.nextWindowOpensAt
&& Math.abs(new Date(scheduled.scheduledFor).getTime()
- new Date(us.nextWindowOpensAt).getTime()) < 60 * 1000 && (
<p className="update-scheduled-deferred">
<Trans
i18nKey="update.page.scheduled.deferred_until"
values={{at: us.nextWindowOpensAt}}
/>
</p>
)}
</section>
)}
{us.tier === 'autonomous' && (
<section className="update-maintenance-window">
<h2><Trans i18nKey="update.window.title"/></h2>
{us.maintenanceWindow ? (
<>
<p>
<Trans
i18nKey="update.window.summary"
values={{
start: us.maintenanceWindow.start,
end: us.maintenanceWindow.end,
tz: us.maintenanceWindow.tz,
}}
/>
</p>
{us.nextWindowOpensAt && (
<p>
<Trans
i18nKey="update.window.next_opens_at"
values={{at: us.nextWindowOpensAt}}
/>
</p>
)}
</>
) : (
<p><Trans i18nKey="update.window.unset"/></p>
)}
</section>
)}

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'}
@ -25,6 +27,12 @@ export type LastResult = null | {
at: string;
};
export interface MaintenanceWindow {
start: string;
end: string;
tz: 'local' | 'utc';
}
export interface UpdateStatusPayload {
currentVersion: string;
latest: null | {
@ -39,11 +47,13 @@ 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;
lockHeld: boolean;
// Tier 4 additions:
maintenanceWindow: MaintenanceWindow | null;
nextWindowOpensAt: string | null;
}
type ToastState = {
@ -57,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,
@ -83,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,
@ -109,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.0.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,10 +2,10 @@
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)**designed, not yet implemented.
- **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.
## Settings
@ -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
@ -192,3 +189,43 @@ The right way to give docker admins an in-product Apply button is to delegate to
- **Deploy webhook.** New setting `updates.dockerWebhook`. When set, the Apply button on a docker install POSTs to the configured URL and trusts the orchestrator (Render / Railway / Fly / Portainer / Coolify / GitHub Actions — they all expose redeploy webhooks) to do the actual pull-and-recreate.
Direct Docker-socket access (mount `/var/run/docker.sock` into the container) is **out of scope** — anyone who escapes the Etherpad process via that socket gets root on the host. Admins who want fully autonomous docker updates should run [Watchtower](https://containrrr.dev/watchtower/) alongside Etherpad rather than bake equivalent privilege into Etherpad itself.
## Tier 4 — autonomous in a maintenance window
Tier 4 layers a wall-clock window on top of Tier 3 so autonomous updates only run while it is safe to drain sessions (typically nightly).
To enable, on a git install:
```jsonc
{
"updates": {
"tier": "autonomous",
"preApplyGraceMinutes": 15,
"maintenanceWindow": { "start": "03:00", "end": "05:00", "tz": "local" }
}
}
```
`start` and `end` are 24-hour `HH:MM` wall-clock times in the configured `tz` (`"local"` or `"utc"`). `end` is exclusive; `end < start` denotes a cross-midnight window (`22:0002:00` runs from 22:00 through 01:59).
### How the window gate works
1. `evaluatePolicy` returns `canAutonomous: true` only when the install is `git`, tier is `"autonomous"`, no terminal `rollback-failed` is set, and `updates.maintenanceWindow` is set and parse-valid. Missing/malformed windows return `canAutonomous: false` with `policy.reason` equal to `maintenance-window-missing` / `maintenance-window-invalid`, and the rest of the policy degrades to Tier 3 (`canAuto: true`). An admin banner surfaces the misconfiguration so the autonomous behavior is never silently disabled.
2. When the scheduler picks up a new release while `canAutonomous: true`, it computes `scheduledFor = now + preApplyGraceMinutes`. If that timestamp falls **outside** the window, it is snapped forward to the **next opening** of the window.
3. When the timer fires, the scheduler re-checks the clock. If the window has already closed (long grace, clock skew, host suspend), the fire is **deferred**: `var/update-state.json` is updated with a new `scheduledFor` pointing at the next opening, the timer is re-armed, and the actual apply runs at the next valid moment.
### DST and timezone notes
- `tz: "utc"` is recommended for hosts running across DST boundaries — the window is interpreted against the same wall clock every day of the year.
- `tz: "local"` follows the host's local time. On DST spring-forward days, a window starting at a non-existent local time (e.g. `02:30` in `America/New_York` on the second Sunday of March) silently lands at the next valid wall-clock minute via the host JS `Date` constructor's normalization. On fall-back days, the first occurrence of the wall-clock start time is used.
- Cross-midnight windows (`end < start`) span at most 24 hours; longer "windows" should be split into two settings, e.g. by running Tier 3 instead.
### Admin UI
`/admin/update` shows a "Maintenance window" section when `updates.tier == "autonomous"`:
- Configured: summary `HH:MMHH:MM (tz)` plus "Next window opens at …".
- Not configured: a clear "Not configured" message and a top-of-page banner that links back to the page.
- During a deferred-grace schedule, the scheduled panel shows both the countdown to `scheduledFor` and an explanatory "Outside maintenance window. Update will start when the window opens at …" line.
Admins edit `updates.maintenanceWindow` via the parsed JSONC settings editor at `/admin/settings`. Saving an invalid shape is caught at boot — the warning is logged via the `updater` log4js category and the policy downgrades to Tier 3.

View file

@ -654,6 +654,8 @@ _Example returns:_
returns an array of authors who contributed to this pad
The synthetic `a.etherpad-system` author (used internally when content is inserted without an explicit `authorId` — HTTP API `setText`/`appendText`/`setHTML` calls without `authorId`, server-side imports, plugins like `ep_post_data`) is omitted from the returned list.
_Example returns:_
* `{code: 0, message:"ok", data: {authorIDs : ["a.s8oes9dhwrvt0zif", "a.akf8finncvomlqva"]}`
@ -710,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

@ -698,6 +698,8 @@ return true of false
returns an array of authors who contributed to this pad
The synthetic `a.etherpad-system` author (used internally when content is inserted without an explicit `authorId` — HTTP API `setText`/`appendText`/`setHTML` calls without `authorId`, server-side imports, plugins like `ep_post_data`) is omitted from the returned list.
*Example returns:*
* `{code: 0, message:"ok", data: {authorIDs : ["a.s8oes9dhwrvt0zif", "a.akf8finncvomlqva"]}`
* `{code: 1, message:"padID does not exist", data: null}`
@ -762,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

@ -29,6 +29,37 @@ Edit `<BASEDIR>/settings.json.docker` at your will. When rebuilding the image, t
**Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. For details, refer to `settings.json.template`.
### How `settings.json` and environment variables interact
This trips people up often enough that it's worth calling out explicitly (see [#7819](https://github.com/ether/etherpad/issues/7819)):
* `settings.json` inside the container is a **template** containing `${VAR:default}` placeholders.
* Environment variable substitution happens at **load time, in memory only** — env vars never overwrite `settings.json` on disk.
* `docker exec <container> cat /opt/etherpad-lite/settings.json` will therefore always show the *templated* file (e.g. `"port": "${PORT:9001}"`), regardless of what `PORT` is set to in your environment. The resolved value is what Etherpad uses at runtime; the file is unchanged.
* The admin /settings page also reads this file directly, so the raw view shows placeholders too. The page now surfaces a banner and an "Effective" tab that displays the in-memory resolved values when placeholders are present.
### Persisting admin /settings edits across container recreates
`settings.json` lives in the container's writable layer by default. That means:
| Operation | Effect on `settings.json` |
|------------------------------------------|------------------------------------------|
| `docker restart` | Preserved (writable layer is reused) |
| `docker compose restart` | Preserved |
| `docker compose down && docker compose up` | **Reset** to the image template |
| `docker compose pull && docker compose up` | **Reset** to the new image template |
| Watchtower / image auto-update | **Reset** to the new image template |
| `docker rm` + `docker run` | **Reset** to the image template |
If you intend to edit `settings.json` through the admin UI (rather than relying solely on env vars), mount the file from the host so edits survive container recreate:
```yaml
volumes:
- ./settings.json:/opt/etherpad-lite/settings.json
```
(Bootstrap by copying `settings.json.docker` to `./settings.json` on the host before the first `up`.) The default compose example below ships this line commented out — uncomment it if you need persistent on-disk edits.
### Rebuilding including some plugins
If you want to install some plugins in your container, it is sufficient to list them in the ETHERPAD_PLUGINS build variable.
The variable value has to be a space separated, double quoted list of plugin names (see examples).
@ -282,6 +313,13 @@ services:
volumes:
- plugins:/opt/etherpad-lite/src/plugin_packages
- etherpad-var:/opt/etherpad-lite/var
# OPTIONAL: persist admin /settings edits across container recreates.
# Without this mount, settings.json lives in the image's writable
# layer — `docker compose restart` preserves it, but `docker compose
# down && up`, `pull`, or watchtower reverts it to the image
# template. Uncomment if you intend to edit settings.json through
# the /admin UI. See https://github.com/ether/etherpad/issues/7819.
# - ./settings.json:/opt/etherpad-lite/settings.json
depends_on:
- postgres
environment:

View file

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

View file

@ -7,6 +7,14 @@ services:
volumes:
- plugins:/opt/etherpad-lite/src/plugin_packages
- etherpad-var:/opt/etherpad-lite/var
# OPTIONAL: persist admin /settings edits across container recreates.
# Without this mount, settings.json lives in the image's writable
# layer — `docker compose restart` preserves it, but `docker compose
# down && up`, `pull`, or watchtower reverts it to the image
# template. Uncomment if you intend to edit settings.json through
# the /admin UI instead of (or in addition to) env vars. See
# https://github.com/ether/etherpad/issues/7819.
# - ./settings.json:/opt/etherpad-lite/settings.json
depends_on:
- postgres
environment:

View file

@ -0,0 +1,224 @@
# Auto-Update PR 4 — Tier 4 (autonomous in maintenance window) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Land Tier 4 of the auto-update subsystem: when a new release is detected and `updates.tier == "autonomous"` on a writable install with a valid `updates.maintenanceWindow`, schedule the update so that the drain only starts while `now()` is inside the window. Outside the window, the schedule is deferred to the next opening. The admin UI gains a window picker (start/end HH:MM, tz local|utc) with validation and a "next window opens at..." preview.
**Architecture:** Add a new pure module `MaintenanceWindow.ts` with `inWindow(now, window)` and `nextWindowStart(now, window)`. Both handle cross-midnight (`end < start`), local- vs utc-tz selection, and DST transitions (compute against the configured wall clock, not UTC offsets that shift). The `Scheduler.decideSchedule()` and `decideTriggerApply()` decisions take a new `maintenanceWindow` input and a `canAutonomous` policy bit; when the tier is `autonomous`, schedules are placed at `max(now + grace, nextWindowStart)` and trigger-apply aborts (back to `scheduled`) if the window has closed by fire time. `UpdatePolicy.canAutonomous` flips on for `git + tier:autonomous + valid window`. Admin UI adds a picker bound to `updates.maintenanceWindow` via the existing settings round-trip; the UpdatePage scheduled panel shows the resolved next-window time.
**Tech Stack:** TypeScript (Node ≥ 25), Express, log4js, vitest (unit), mocha + supertest (HTTP integration), Playwright (admin UI), React + Zustand (admin UI).
---
## File structure
### New files
- `src/node/updater/MaintenanceWindow.ts` — pure `inWindow(now, window)` + `nextWindowStart(now, window)`. No I/O.
- `src/tests/backend-new/specs/updater/MaintenanceWindow.test.ts` — vitest unit. Same-day, cross-midnight, exact boundary, tz=utc vs tz=local, DST spring-forward + fall-back.
- `src/tests/backend/specs/updater-window-integration.ts` — mocha integration. Latest release detected outside window queues for next opening; entering window triggers fire-now (or grace+window); cancel during deferred-grace returns to idle; window closes mid-grace defers to next window without dropping the schedule.
- `admin/src/components/MaintenanceWindowPicker.tsx` — small controlled component: start (HH:MM), end (HH:MM), tz select, validation message, "next window opens at..." preview.
- `src/tests/frontend-new/admin-spec/update-autonomous.spec.ts` — Playwright: window picker round-trips through Settings; scheduled panel renders "next window opens at..." when waiting; cancel works.
### Modified files
- `src/node/updater/types.ts` — add `MaintenanceWindow` type (`{start: string; end: string; tz: 'local' | 'utc'}`), thread `maintenanceWindow: MaintenanceWindow | null` through `PolicyInput`.
- `src/node/updater/UpdatePolicy.ts``canAutonomous` flips on for `git + tier === 'autonomous'` AND a non-null, schema-valid `maintenanceWindow`. Add new policy `reason` value `'maintenance-window-missing'` (denied tier 4 when window not configured) and `'maintenance-window-invalid'` (denied tier 4 when window fails parse).
- `src/node/updater/Scheduler.ts` — extend `DecideScheduleInput` with `maintenanceWindow` + `canAutonomous`; when canAutonomous, `scheduledFor = max(now+grace, nextWindowStart(now+grace, window))`. Extend `decideTriggerApply()` so that when canAutonomous and `inWindow(now, window) === false`, return new action `{action: 'defer'; nextStart: string}`. Extend `SchedulerRunner` to re-arm on defer.
- `src/node/updater/index.ts` — pass `updates.maintenanceWindow` + the autonomous bit into `decideSchedule`/`decideTriggerApply`. On `defer`, persist new `scheduledFor` and re-arm. Log line at `info`: `updater: deferred to next maintenance window at <iso>`.
- `src/node/utils/Settings.ts` — add `maintenanceWindow: MaintenanceWindow | null` to the `updates` settings type; default `null`. Validate shape on boot; on invalid, log a warning and treat as null (do not crash boot).
- `settings.json.template` + `settings.json.docker` — add `"maintenanceWindow": null` line with comment showing example `{"start":"03:00","end":"05:00","tz":"local"}`.
- `src/node/hooks/express/updateStatus.ts` — surface `nextWindowStart` (computed at request time when tier is autonomous + window set) in `GET /admin/update/status` response so the admin UI can show "next window opens at...".
- `src/locales/en.json``update.window.start`, `update.window.end`, `update.window.tz_local`, `update.window.tz_utc`, `update.window.validation.format`, `update.window.validation.equal`, `update.window.next_opens_at`, `update.page.scheduled.deferred_until`, `update.page.policy.autonomous_no_window`, `update.page.policy.autonomous_invalid_window`.
- `admin/src/store/store.ts` — extend `Settings.updates` with `maintenanceWindow`; extend response shape returned by `/admin/update/status` with optional `nextWindowOpensAt: string | null`.
- `admin/src/pages/UpdatePage.tsx` — render `MaintenanceWindowPicker` when `tier === 'autonomous'`. Render "Deferred — next window opens at ..." when `execution.status === 'scheduled'` and `scheduledFor > now`. Show explicit `policy.reason` text for `autonomous_no_window` and `autonomous_invalid_window`.
- `admin/src/components/UpdateBanner.tsx` — add a banner variant when `tier === 'autonomous'` but window is missing/invalid: "Autonomous updates are disabled until a maintenance window is configured." Links to `/admin/update`.
- `doc/admin/updates.md` — flip Tier 4 from "designed, not yet implemented" to current; document `maintenanceWindow` shape, cross-midnight, DST behavior, fallback when window is missing.
- `CHANGELOG.md` — Unreleased section entry under `### Added`.
- `docs/superpowers/specs/2026-04-25-auto-update-runbook.md` — append Tier 4 smoke section: configure window 5 min from now, observe deferral, walk window forward, observe fire.
---
## Task 1: Settings schema for `maintenanceWindow`
**Files:**
- Modify: `src/node/utils/Settings.ts`
- Modify: `settings.json.template`
- Modify: `settings.json.docker`
- Modify: `src/node/updater/types.ts` (export `MaintenanceWindow`)
- Test: extend an existing Settings-load test if one exists for `updates`; otherwise rely on Task 4 unit coverage of the window module + boot-time log.
**Steps:**
- [ ] In `src/node/updater/types.ts` add `export interface MaintenanceWindow { start: string; end: string; tz: 'local' | 'utc' }`.
- [ ] In `src/node/utils/Settings.ts` extend the `updates` type with `maintenanceWindow: MaintenanceWindow | null`. Default to `null` in the literal.
- [ ] Add boot-time validation: regex `/^([01]\d|2[0-3]):[0-5]\d$/` for both `start` and `end`; tz must be `'local' | 'utc'`; `start !== end`. On invalid, log warning via `log4js` category `updater` and set to `null` (do not crash). Validation lives in a small pure helper exported from `MaintenanceWindow.ts` (`parseWindow`) so the policy and the UI can reuse it.
- [ ] Edit `settings.json.template` and `settings.json.docker` to include `"maintenanceWindow": null` immediately below `tier`, with a comment showing the shape.
**Verification:**
- [ ] `pnpm exec tsc --noEmit` clean.
- [ ] Boot the server with a deliberately malformed window (`{"start":"oops"}`) and confirm the warning is logged and tier downgrades to `auto` effectively (canAutonomous=false via the policy reason `'maintenance-window-invalid'`).
---
## Task 2: `MaintenanceWindow.ts` module + unit tests
**Files:**
- Create: `src/node/updater/MaintenanceWindow.ts`
- Create: `src/tests/backend-new/specs/updater/MaintenanceWindow.test.ts`
**Steps:**
- [ ] Export `parseWindow(raw: unknown): MaintenanceWindow | null` (returns `null` if shape/format invalid).
- [ ] Export `inWindow(now: Date, window: MaintenanceWindow): boolean`. Compare against the configured tz's wall clock. For `tz: 'utc'` use `getUTCHours/Minutes`; for `tz: 'local'` use `getHours/Minutes`. Cross-midnight (`end < start`): inside if `now ≥ start || now < end`.
- [ ] Export `nextWindowStart(now: Date, window: MaintenanceWindow): Date`. Returns the next `Date` whose wall-clock time equals `start` in the configured tz and which is ≥ `now`. For `tz: 'local'` this is straightforward; for `tz: 'utc'` build via `Date.UTC`. Document via inline comment that DST spring-forward will be handled by the host's `setTimer`/`setTimeout` and we never schedule "into the gap" because we always compare against wall clock.
**Tests (vitest):**
- [ ] `inWindow` — same-day window 03:00-05:00 (inside at 03:30, outside at 02:59, outside at 05:00 (exclusive end)).
- [ ] `inWindow` — cross-midnight 22:00-02:00 (inside at 23:00 and at 01:00; outside at 02:00 and 21:59).
- [ ] `inWindow` — tz=utc respects UTC clock regardless of host TZ (run with `TZ=America/Los_Angeles`).
- [ ] `nextWindowStart` — when `now` is before today's start, returns today at start.
- [ ] `nextWindowStart` — when `now` is inside the window, returns next day's start (callers gate fire-now via `inWindow`, not `nextWindowStart`).
- [ ] `nextWindowStart` — DST spring forward (America/New_York, 2026-03-08, window 02:30-03:30 local): `nextWindowStart` for `now = 2026-03-08T06:00:00Z` resolves to the next wall-clock 02:30 (which is actually 03:30 local on the DST day; document this in the test).
- [ ] `nextWindowStart` — DST fall back (America/New_York, 2026-11-01, window 01:30-02:30 local): assertion that `nextWindowStart` returns the *first* 01:30 wall-clock occurrence.
- [ ] `parseWindow` — accepts `{start:"03:00",end:"05:00",tz:"local"}`; rejects missing fields, malformed times, `start===end`, unknown tz.
**Verification:**
- [ ] `pnpm exec vitest run src/tests/backend-new/specs/updater/MaintenanceWindow.test.ts` green.
---
## Task 3: Extend `UpdatePolicy` with `canAutonomous` and window args
**Files:**
- Modify: `src/node/updater/UpdatePolicy.ts`
- Modify: `src/node/updater/types.ts` (extend `PolicyInput`)
- Modify: `src/tests/backend-new/specs/updater/UpdatePolicy.test.ts`
**Steps:**
- [ ] Extend `PolicyInput` with `maintenanceWindow: MaintenanceWindow | null` (optional, defaults to null in callers).
- [ ] Modify `evaluatePolicy`: when `tier === 'autonomous'` and writable and not terminal:
- if `maintenanceWindow == null`, `canAutonomous = false`, `reason = 'maintenance-window-missing'`, but keep `canAuto = true`, `canManual = true` (degrade to Tier 3 behavior).
- if `parseWindow(maintenanceWindow) == null`, same as above with `reason = 'maintenance-window-invalid'`.
- otherwise `canAutonomous = true`.
- [ ] Update existing tests that asserted `canAutonomous: true` for `tier: 'autonomous'` without a window — they now expect `canAutonomous: false, reason: 'maintenance-window-missing'`. Add new cases for the three policy outcomes.
**Verification:**
- [ ] `pnpm exec vitest run src/tests/backend-new/specs/updater/UpdatePolicy.test.ts` green.
---
## Task 4: Scheduler — gate scheduling + firing on window
**Files:**
- Modify: `src/node/updater/Scheduler.ts`
- Modify: `src/tests/backend-new/specs/updater/Scheduler.test.ts` (extend; create if absent)
**Steps:**
- [ ] Extend `DecideScheduleInput` with `maintenanceWindow: MaintenanceWindow | null` and use `policy.canAutonomous` to decide whether to apply the window gate.
- [ ] In `decideSchedule`, after the existing grace computation, if `canAutonomous && maintenanceWindow`:
- candidate `scheduledFor = now + grace`.
- if `inWindow(candidate, window) === false`, set `scheduledFor = nextWindowStart(candidate, window)`.
- keep the rest of the email/dedupe machinery untouched (`grace-start` email cadence still fires once per tag).
- [ ] In `decideTriggerApply`, add a parameter for the resolved policy plus the window/now. If `policy.canAutonomous && !inWindow(now, window)`, return new decision `{action: 'defer'; nextStart: string}`. The runner persists `scheduledFor = nextStart` and re-arms.
- [ ] In `SchedulerRunner`, extend the timer-fire callback to call `triggerApply` and, on `defer`, re-arm without firing. (The runner is already idempotent on `arm`.)
**Tests (vitest):**
- [ ] `decideSchedule` — canAutonomous + window 03:00-05:00 + now=10:00 → `scheduledFor` snapped to the next 03:00 (not `now + grace`).
- [ ] `decideSchedule` — canAutonomous + window 03:00-05:00 + now=03:30 with grace=0 → `scheduledFor` is `now` (inside window, no snap).
- [ ] `decideTriggerApply` — canAutonomous + outside window → `{action: 'defer', nextStart: <iso>}`.
- [ ] `decideTriggerApply` — canAutonomous + inside window → `{action: 'fire'}`.
- [ ] Email dedupe: defer does not trigger a new `grace-start` email.
**Verification:**
- [ ] `pnpm exec vitest run src/tests/backend-new/specs/updater/Scheduler.test.ts` green.
---
## Task 5: Wire scheduler runner + status endpoint to surface window state
**Files:**
- Modify: `src/node/updater/index.ts`
- Modify: `src/node/hooks/express/updateStatus.ts`
- Modify: `src/tests/backend/specs/updater-actions.ts` (or the equivalent status test) — extend to assert `nextWindowOpensAt` is present when tier=autonomous + window set.
**Steps:**
- [ ] In the periodic check loop, pass `settings.updates.maintenanceWindow` into `decideSchedule`. Pass policy result into both `decideSchedule` and `decideTriggerApply`.
- [ ] On `{action: 'defer'}`, write `state.execution.scheduledFor = nextStart`, persist, `runner.arm(...)`. Emit a log line at INFO category `updater`.
- [ ] In `updateStatus.ts`, when `tier === 'autonomous'` and `maintenanceWindow` parses, compute `nextWindowOpensAt = nextWindowStart(now, window)` and include in the JSON response (`null` otherwise).
**Verification:**
- [ ] `pnpm exec mocha src/tests/backend/specs/updater-actions.ts` green.
---
## Task 6: Admin UI — `MaintenanceWindowPicker` + scheduled-panel "deferred until"
**Files:**
- Create: `admin/src/components/MaintenanceWindowPicker.tsx`
- Modify: `admin/src/pages/UpdatePage.tsx`
- Modify: `admin/src/components/UpdateBanner.tsx`
- Modify: `admin/src/store/store.ts`
- Modify: `src/locales/en.json`
- Test: `src/tests/frontend-new/admin-spec/update-autonomous.spec.ts`
**Steps:**
- [ ] `MaintenanceWindowPicker.tsx` — controlled component over `value: {start, end, tz} | null`, emits `onChange`. Inline validation message via i18n keys `update.window.validation.format` / `update.window.validation.equal`. Below the picker, render the resolved `nextWindowOpensAt` (passed in via prop) with key `update.window.next_opens_at`.
- [ ] In `UpdatePage.tsx`, when `settings.updates.tier === 'autonomous'`, render the picker. Wiring through the existing settings round-trip (the parsed settings editor PR #7709 lands first; if it's not yet on develop at integration time, fall back to writing through `/admin/settings`).
- [ ] When `execution.status === 'scheduled'` and `policy.canAutonomous` and `scheduledFor > now`, render the scheduled panel with the deferral subtitle (`update.page.scheduled.deferred_until`).
- [ ] In `UpdateBanner.tsx`, render the "configure maintenance window" banner when `policy.reason === 'maintenance-window-missing' | 'maintenance-window-invalid'` and `tier === 'autonomous'`.
- [ ] Add all i18n keys to `en.json`. **Always i18n, never hardcoded** (memory: `feedback_always_i18n`).
**Tests (Playwright):**
- [ ] Window picker saves a value; reload restores it.
- [ ] Invalid input shows the validation message and does not save.
- [ ] When tier=autonomous + window set + outside window, the scheduled panel shows "Next window opens at HH:MM (local)".
- [ ] When tier=autonomous + window missing, the banner renders the link to `/admin/update`.
**Verification:**
- [ ] `pnpm --filter ep_etherpad-lite exec playwright test src/tests/frontend-new/admin-spec/update-autonomous.spec.ts` green (port 9003 per memory `feedback_test_port_9003`).
---
## Task 7: Window-boundary integration test
**Files:**
- Create: `src/tests/backend/specs/updater-window-integration.ts`
**Cases:**
- [ ] Outside window: VersionChecker sees a new release; Scheduler arms `scheduledFor = nextWindowStart`; no drain starts.
- [ ] Enter window: clock advances to inside-window; fire-time `decideTriggerApply` returns `fire`; drain starts.
- [ ] Cancel during deferred-grace: `/admin/update/cancel` returns 200 and `execution.status` returns to `idle`.
- [ ] Window closes mid-grace: clock advances past `end` before fire; `decideTriggerApply` returns `defer`; state persists with new `scheduledFor`; runner re-arms.
**Verification:**
- [ ] `pnpm exec mocha src/tests/backend/specs/updater-window-integration.ts` green.
---
## Task 8: Docs, runbook, CHANGELOG
**Files:**
- Modify: `doc/admin/updates.md`
- Modify: `docs/superpowers/specs/2026-04-25-auto-update-runbook.md`
- Modify: `CHANGELOG.md`
**Steps:**
- [ ] Flip the Tier 4 section in `doc/admin/updates.md` from "designed, not yet implemented" to current. Document `maintenanceWindow` shape, cross-midnight, DST behavior, and the policy fallback when the window is missing or invalid.
- [ ] Append a Tier 4 smoke section to the runbook: configure window 5 min from now, observe deferral, walk window forward, observe fire, observe rollback path inside window still works.
- [ ] Add an `Unreleased` entry to `CHANGELOG.md` under `### Added`.
**Verification:**
- [ ] Manual: `pnpm run dev` on a clean checkout with `tier: "autonomous"` + a near-future 2-minute window and confirm the admin UI matches the documented flow.
---
## Cross-cutting checks before opening the PR
- [ ] `pnpm exec tsc --noEmit` clean (root + admin).
- [ ] `pnpm exec vitest run` green (backend-new).
- [ ] `pnpm exec mocha src/tests/backend/specs/updater-*.ts` green.
- [ ] Playwright admin spec green under `pnpm --filter ep_etherpad-lite exec playwright test src/tests/frontend-new/admin-spec/update-autonomous.spec.ts` on port 9003.
- [ ] `pnpm run build:ui` succeeds.
- [ ] Manual smoke runbook Tier 4 section completed against a disposable VM (canary deferred to merge if the 2-week canary requirement from spec §"Ship gate" is dropped; otherwise gate merge on canary).
- [ ] PR title `feat(updater): tier 4 — autonomous update in maintenance window (#7607)`.
- [ ] PR body links to the spec + this plan, lists settings additions, and links to PRs #7601 / #7704 / #7720.
- [ ] After merge, close issue #7607 with a summary comment linking all four PRs.

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

@ -244,3 +244,49 @@ If any step diverges, capture `var/log/update.log` and stop. Add to the §10 sig
- [ ] Apply now during scheduled runs the Tier 2 pipeline immediately.
- [ ] Restart-in-grace rehydrates the timer.
- [ ] `grace-start` email fires once per tag when `adminEmail` is set.
## 12. Tier 4 — autonomous in a maintenance window
Goal: verify the scheduler defers autonomous applies to the configured window, snaps grace forward to the next opening, and surfaces the configuration state in the admin UI.
### Setup
Continuing from §11. Settings additions in `settings.json`:
```jsonc
{
"updates": {
"tier": "autonomous",
"preApplyGraceMinutes": 1,
"maintenanceWindow": null
}
}
```
Restart Etherpad.
1. **Missing window banner:** visit `/admin/update`. Expect:
- A red/yellow banner at the top: *"Autonomous updates are disabled until a maintenance window is configured."*
- The "Maintenance window" section shows "Not configured."
- `policy.reason` in `GET /admin/update/status` is `maintenance-window-missing`.
2. **Malformed window:** set `"maintenanceWindow": {"start":"oops","end":"05:00","tz":"local"}`. Restart. Expect:
- `journalctl -u etherpad` shows `updater: ignoring malformed updates.maintenanceWindow (...)`.
- The banner now reads *"Autonomous updates are disabled because the maintenance window is malformed."*
- `policy.reason` is `maintenance-window-invalid`.
3. **Outside-window deferral:** set the window to **5 minutes in the future**, e.g. at 14:00 local set `{"start":"14:05","end":"14:10","tz":"local"}`. Restart. Force a new release as in §3. Expect:
- The next version check transitions `execution.status` to `scheduled`.
- `scheduledFor` is at the **window start** (14:05), not at `now + 1m`.
- The scheduled panel shows both the countdown *and* an *"Outside maintenance window. Update will start when the window opens at …"* line.
4. **Fire-at-opening:** wait for the window to open. The scheduler should fire and the regular Tier 2 pipeline (drain → executor → exit 75) runs. State ends at `verified`.
5. **Window-closes-mid-grace:** repeat the setup, but configure a window that **closes** before `now + preApplyGraceMinutes`. For example: at 14:00 local set `{"start":"14:01","end":"14:02","tz":"local"}`, `preApplyGraceMinutes: 5`. Force a release. The scheduler arms for 14:01 but at fire time (after the window has closed) `decideTriggerApply` returns `defer`. Expected:
- `journalctl -u etherpad` shows `updater: scheduler deferred ... to next maintenance window at ...`.
- `var/update-state.json` has a *new* `scheduledFor` ~24h ahead.
- No drain, no exit, no apply.
Add to the §10 sign-off checklist:
- [ ] Tier 4 missing-window banner renders the localised string.
- [ ] Tier 4 malformed-window banner renders the localised string.
- [ ] Outside-window `scheduledFor` snaps to the next window opening.
- [ ] Scheduled panel shows the "deferred until" line when outside the window.
- [ ] Window-closes-mid-grace cleanly defers without applying.

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.0.0",
"version": "3.2.0",
"license": "Apache-2.0",
"pnpm": {
"onlyBuiltDependencies": [

1530
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -224,7 +224,8 @@
"rollbackHealthCheckSeconds": 60,
"diskSpaceMinMB": 500,
"requireSignature": false,
"trustedKeysPath": null
"trustedKeysPath": null,
"maintenanceWindow": null
},
/*
@ -233,6 +234,19 @@
*/
"adminEmail": null,
/*
* SMTP transport. host=null keeps log-only behaviour; set host+from to send
* real mail via nodemailer (lazy-loaded). Pulls from env vars by default —
* leave the template as-is and provide MAIL_HOST / MAIL_FROM at runtime.
*/
"mail": {
"host": "${MAIL_HOST:null}",
"port": "${MAIL_PORT:587}",
"secure": "${MAIL_SECURE:false}",
"from": "${MAIL_FROM:null}",
"auth": null
},
/*
* Settings for cleanup of pads
*/

View file

@ -239,13 +239,18 @@
* - diskSpaceMinMB: pre-flight refuses to start an update without this much free.
* - requireSignature: refuse updates whose tag isn't signed by a trusted key.
* - trustedKeysPath: override the keyring location passed to git verify-tag (GNUPGHOME).
* - maintenanceWindow: tier 4 only — nightly window during which the scheduler
* may fire. Null = tier 4 disabled (with tier="autonomous", the policy
* downgrades to canAuto). Shape: {"start":"HH:MM","end":"HH:MM","tz":"local"|"utc"}.
* `end` is exclusive; `end < start` denotes a cross-midnight window.
*/
"preApplyGraceMinutes": 0,
"drainSeconds": 60,
"rollbackHealthCheckSeconds": 60,
"diskSpaceMinMB": 500,
"requireSignature": false,
"trustedKeysPath": null
"trustedKeysPath": null,
"maintenanceWindow": null
},
/*
@ -267,6 +272,29 @@
*/
"adminEmail": null,
/*
* SMTP transport for outbound admin notifications. host=null keeps the
* legacy log-only behaviour (Notifier still dedupes; nothing leaves the
* box). Set host+from (and optionally auth) to deliver via nodemailer.
* The dependency is lazy-loaded so installs without mail.host pay no
* runtime cost.
*
* "mail": {
* "host": "smtp.example.com",
* "port": 587,
* "secure": false,
* "from": "etherpad@example.com",
* "auth": { "user": "smtp-user", "pass": "smtp-pass" }
* }
*/
"mail": {
"host": null,
"port": 587,
"secure": false,
"from": null,
"auth": null
},
/*
* Settings for cleanup of pads
*/
@ -507,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,
@ -597,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",
@ -168,6 +171,8 @@
"update.page.policy.rollback-failed-terminal": "A previous update failed and could not be rolled back. Press Acknowledge after the install is healthy to clear the lock.",
"update.page.policy.up-to-date": "You are running the latest version.",
"update.page.policy.tier-off": "Updates are disabled (updates.tier = \"off\").",
"update.page.policy.maintenance-window-missing": "Tier 4 (autonomous) requires a maintenance window. Set updates.maintenanceWindow in settings.json to enable autonomous updates.",
"update.page.policy.maintenance-window-invalid": "Tier 4 (autonomous) is disabled because updates.maintenanceWindow is malformed. Expected {start, end, tz} with HH:MM times and tz of \"local\" or \"utc\".",
"update.page.last_result.verified": "Last update to {{tag}} verified.",
"update.page.last_result.rolled-back": "Last attempted update to {{tag}} rolled back: {{reason}}.",
"update.page.last_result.rollback-failed": "Last update attempt failed AND rollback failed: {{reason}}. Manual intervention required.",
@ -186,9 +191,16 @@
"update.execution.rollback-failed": "Rollback failed",
"update.banner.terminal.rollback-failed": "An update attempt failed and could not be rolled back. Manual intervention required.",
"update.banner.scheduled": "Auto-update to {{tag}} scheduled — applies in {{remaining}}.",
"update.banner.maintenance-window-missing": "Autonomous updates are disabled until a maintenance window is configured.",
"update.banner.maintenance-window-invalid": "Autonomous updates are disabled because the maintenance window is malformed.",
"update.page.scheduled.title": "Update scheduled",
"update.page.scheduled.countdown": "Etherpad will start updating to {{tag}} in {{remaining}}.",
"update.page.scheduled.deferred_until": "Outside maintenance window. Update will start when the window opens at {{at}}.",
"update.page.scheduled.apply_now": "Apply now",
"update.window.title": "Maintenance window",
"update.window.summary": "{{start}}{{end}} ({{tz}})",
"update.window.unset": "Not configured.",
"update.window.next_opens_at": "Next window opens at {{at}}.",
"update.drain.t60": "Etherpad will restart in 60 seconds to apply an update.",
"update.drain.t30": "Etherpad will restart in 30 seconds to apply an update.",
"update.drain.t10": "Etherpad will restart in 10 seconds to apply an update.",

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

@ -40,6 +40,11 @@ import { cleanText } from './Pad.js';
import PadDiff from '../utils/padDiff.js';
import { checkValidRev, isInt } from '../utils/checkValidRev.js';
// Mirror of `Pad.SYSTEM_AUTHOR_ID`. Inlined to avoid a circular load
// (API <-> Pad) at module init time. Kept in sync with the constant in
// `./Pad.ts`.
const SYSTEM_AUTHOR_ID = 'a.etherpad-system';
// Lazy require bridge for the optional `Cleanup` helper used by compactPad —
// avoids loading the cleanup subsystem on every API import.
const require = createRequire(import.meta.url);
@ -625,9 +630,28 @@ export const restoreRevision = async (padID: string, rev: number, authorId = '')
// create a new changeset with a helper builder object
const builder = new Builder(oldText.length);
// The author to attribute inserts to. If the caller supplied an
// explicit authorId, that wins; otherwise fall back to the stable
// system author. The replayed atext was built from historical
// revisions that may legitimately have insert ops without an
// author attribute (legacy server-internal flows / .etherpad
// imports); appendRevision now requires every insert to carry
// one, so we merge the marker in below.
const replayAuthorId = authorId || SYSTEM_AUTHOR_ID;
// assemble each line into the builder
eachAttribRun(atext.attribs, (start: number, end: number, attribs:Attribute[]) => {
builder.insert(atext.text.substring(start, end), attribs);
eachAttribRun(atext.attribs, (start: number, end: number, attribs:string) => {
// attribs here is the op.attribs *string* (the eachAttribRun
// callback receives it as the third arg). Use AttributeMap to
// merge in `author` while preserving canonical (sorted) order
// so checkRep doesn't reject the result. The `.set` call is a
// no-op when the existing attribs already contain an `author`
// attribute that matches; when they contain a *different*
// author it preserves the historical attribution (we only
// set author when it's missing).
const map = AttributeMap.fromString(attribs, pad.pool);
if (!map.get('author')) map.set('author', replayAuthorId);
builder.insert(atext.text.substring(start, end), map.toString());
});
const lastNewlinePos = oldText.lastIndexOf('\n');
@ -836,7 +860,12 @@ Example returns:
export const listAuthorsOfPad = async (padID: string) => {
// get the pad
const pad = await getPadSafe(padID, true);
const authorIDs = pad.getAllAuthors();
// SYSTEM_AUTHOR_ID is the synthetic author Etherpad attributes inserts to
// when no authorId is supplied (HTTP API setText/appendText/setHTML without
// authorId, server-side import flows, plugins like ep_post_data). It is an
// implementation detail of changeset bookkeeping, not a real contributor, so
// it should not surface through this public API.
const authorIDs = pad.getAllAuthors().filter((id: string) => id !== SYSTEM_AUTHOR_ID);
return {authorIDs};
};

View file

@ -46,18 +46,23 @@ const dbModule: any = {
});
}
}
for (const fn of ['get', 'set', 'findKeys', 'getSub', 'setSub', 'remove']) {
for (const fn of ['get', 'set', 'findKeys', 'findKeysPaged', 'getSub', 'setSub', 'remove']) {
const f = (dbModule.db as any)[fn];
if (typeof f !== 'function') {
throw new Error(
`ueberdb2 ${dbModule.db!.constructor.name} is missing required method ${fn}; ` +
'check that ueberdb2 is at the minimum version pinned in package.json');
}
dbModule[fn] = async (...args: string[]) => {
// During shutdown, background timers (for example session cleanup) can still
// attempt DB access for a short period. Avoid crashing the process in that
// window if the DB has already been closed.
if (dbModule.db == null) {
if (fn === 'get' || fn === 'getSub') return null;
if (fn === 'findKeys') return [];
if (fn === 'findKeys' || fn === 'findKeysPaged') return [];
return;
}
const f = dbModule.db[fn];
return await f.call(dbModule.db, ...args);
return await (dbModule.db as any)[fn].call(dbModule.db, ...args);
};
}
},

View file

@ -104,6 +104,61 @@ class Pad {
*/
static readonly SYSTEM_AUTHOR_ID = 'a.etherpad-system';
/**
* Validate that every `+` (insert) op in `aChangeset` carries an
* `author` attribute that resolves through `pool`. Callers that have
* already rebased onto pad.pool pass the post-rebase changeset, so
* we accept the pad's own pool here.
*
* Throws an Error if any insert op is missing an author attribute,
* carries an empty author, or references an attribute number that
* is not present in the pool.
*
* Tolerates `=` and `-` ops with empty attribs (those are the
* canonical form for keeps/deletes that don't change attribution).
* Also tolerates pure-newline `+` ops: the client's line assembler
* handles those regardless of attribs, and the API restoreRevision
* path emits them at line boundaries.
*/
private static _assertInsertOpsCarryAuthor(aChangeset: string, pool: AttributePool) {
let unpacked;
try {
unpacked = unpack(aChangeset);
} catch (e: any) {
// unpack already throws a descriptive error; rethrow as-is so the
// caller's failure mode stays the same.
throw e;
}
for (const op of deserializeOps(unpacked.ops)) {
if (op.opcode !== '+') continue;
// Pure-newline inserts (e.g. `|1+1` for a single line break) are
// tolerated — the client's line assembler handles them regardless
// of attribs, and the API restoreRevision path emits them at
// line boundaries.
if (op.lines > 0 && op.chars === op.lines) continue;
if (!op.attribs) {
throw new Error(
'insert op without an author attribute ' +
`(empty attribs): ${aChangeset}`);
}
let authorIdSeen: string | undefined;
try {
authorIdSeen = AttributeMap.fromString(op.attribs, pool).get('author');
} catch (e: any) {
throw new Error(
'insert op references an attribute number ' +
`not present in the pool: ${aChangeset} (${e && e.message || e})`);
}
if (!authorIdSeen) {
throw new Error(
'insert op without an author attribute: ' + aChangeset);
}
}
}
// Branch needs these public for ESM consumers that previously accessed
// them via the legacy `module.exports`/`require(...)` shape. Keep public
// intentionally; do not regress to private from develop.
public db: Database;
public atext: AText;
public pool: AttributePool;
@ -226,6 +281,13 @@ class Pad {
* @return {Promise<number|string>}
*/
async appendRevision(aChangeset:string, authorId = '') {
// Centralised "every insert op carries an author attribute"
// invariant. The socket handler enforces the same rule at the wire
// boundary; checking here covers the non-wire callers (HTTP API
// setHTML/setText/restoreRevision, plugin paths that call
// appendRevision directly).
Pad._assertInsertOpsCarryAuthor(aChangeset, this.pool);
const newAText = applyToAText(aChangeset, this.atext, this.pool);
if (newAText.text === this.atext.text && newAText.attribs === this.atext.attribs &&
this.head !== -1) {
@ -537,9 +599,19 @@ class Pad {
if (context.type !== 'text') throw new Error(`unsupported content type: ${context.type}`);
text = cleanText(context.content);
}
const firstAttribs = authorId ? [['author', authorId] as [string, string]] : undefined;
// When the initial pad text is non-empty but no authorId was
// supplied (internal getPad calls during HTTP API setup,
// padDefaultContent flows, plugin-driven pad creation), fall back
// to the stable system author so the initial changeset's insert
// op carries an `author` attribute. Mirrors the same substitution
// setText/appendText already do via spliceText.
const effectiveAuthorId =
(text.length > 0 && !authorId) ? Pad.SYSTEM_AUTHOR_ID : authorId;
const firstAttribs = effectiveAuthorId
? [['author', effectiveAuthorId] as [string, string]]
: undefined;
const firstChangeset = makeSplice('\n', 0, 0, text, firstAttribs, this.pool);
await this.appendRevision(firstChangeset, authorId);
await this.appendRevision(firstChangeset, effectiveAuthorId);
}
this.padSettings = Pad.normalizePadSettings(this.padSettings);
await hooks.aCallAll('padLoad', {pad: this});
@ -665,9 +737,25 @@ class Pad {
const oldAText = this.atext;
// The author to attribute inserts to when the historical op lacks
// one (legacy server-internal flows / .etherpad imports). Caller-
// supplied authorId wins; otherwise the stable system author.
// appendRevision now requires every insert to carry an author, so
// unattributed ops in the source pad would otherwise throw here.
const replayAuthorId = authorId || Pad.SYSTEM_AUTHOR_ID;
// based on Changeset.makeSplice
const assem = new SmartOpAssembler();
for (const op of opsFromAText(oldAText)) assem.append(op);
for (const op of opsFromAText(oldAText)) {
if (op.opcode === '+') {
const map = AttributeMap.fromString(op.attribs, dstPad.pool);
if (!map.get('author')) {
map.set('author', replayAuthorId);
op.attribs = map.toString();
}
}
assem.append(op);
}
assem.endDocument();
// although we have instantiated the dstPad with '\n', an additional '\n' is
@ -867,6 +955,12 @@ class Pad {
assert(changeset != null);
assert.equal(typeof changeset, 'string');
checkRep(changeset);
// NOTE: pad.check() intentionally does not invoke
// _assertInsertOpsCarryAuthor — it runs against historical
// stored data (including legacy .etherpad files) where some
// server-internal flows did not previously substitute the
// system author. The write-time guard in appendRevision is
// where the invariant is enforced for new content.
const unpacked = unpack(changeset);
let text = atext.text;
for (const op of deserializeOps(unpacked.ops)) {

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

@ -29,6 +29,7 @@ import {Http2ServerRequest} from "node:http2";
import {publicKeyExported} from "../security/OAuth2Provider.js";
import {jwtVerify} from "jose";
import {APIFields, apikey} from './APIKeyHandler.js'
import crypto from 'node:crypto';
// a list of all functions
const version:MapArrayType<any> = {};
@ -179,27 +180,41 @@ export const handle = async function (apiVersion: string, functionName: string,
if (apikey !== null && apikey.trim().length > 0) {
fields.apikey = fields.apikey || fields.api_key || fields.authorization;
// API key is configured, check if it is valid
if (fields.apikey !== apikey!.trim()) {
// Constant-time compare — see crypto.timingSafeEqual docs.
const provided = Buffer.from(String(fields.apikey ?? ''), 'utf8');
const want = Buffer.from(apikey!.trim(), 'utf8');
const ok = provided.length === want.length &&
crypto.timingSafeEqual(provided, want);
if (!ok) {
throw new createHTTPError.Unauthorized('no or wrong API Key');
}
} else {
if(!req.headers.authorization) {
if (!req.headers.authorization) {
throw new createHTTPError.Unauthorized('no or wrong API Key');
}
try {
const clientIds: string[] = settings.sso.clients?.map((client: {client_id: string}) => client.client_id) ?? [];
const jwtToCheck = req.headers.authorization.replace("Bearer ", "")
const payload = jwtDecode(jwtToCheck)
// client_credentials
if (clientIds.includes(<string>payload.sub)) {
await jwtVerify(jwtToCheck, publicKeyExported!, {algorithms: ['RS256']})
} else {
// authorization_code
await jwtVerify(jwtToCheck, publicKeyExported!, {algorithms: ['RS256'],
requiredClaims: ["admin"]})
const clientIds: string[] = settings.sso.clients?.map(
(client: {client_id: string}) => client.client_id) ?? [];
const jwtToCheck = req.headers.authorization.replace('Bearer ', '');
// Verify the JWT signature first, then read claims off the verified
// payload only.
const {payload: verified} = await jwtVerify(
jwtToCheck, publicKeyExported!, {algorithms: ['RS256']});
const isClientCredentials =
clientIds.includes(verified.sub as string);
if (!isClientCredentials) {
// authorization_code branch: require the admin claim to be
// strictly true. Checking only that the claim is present is not
// sufficient — the provider issues it as `admin: is_admin`, so
// non-admin users would have it set to false.
if (verified.admin !== true) {
throw new createHTTPError.Unauthorized(
'admin claim missing or not true');
}
}
} catch (e) {
// Single error string regardless of the underlying failure so we
// don't leak which check rejected the token.
throw new createHTTPError.Unauthorized('no or wrong OAuth token');
}
}

View file

@ -24,6 +24,7 @@ import {createRequire} from 'node:module';
import * as exporthtml from '../utils/ExportHtml.js';
import * as exporttxt from '../utils/ExportTxt.js';
import * as exportEtherpad from '../utils/ExportEtherpad.js';
import crypto from 'node:crypto';
import fs from 'fs';
import settings, {sofficeAvailable} from '../utils/Settings.js';
import * as ExportSanitizeHtml from '../utils/ExportSanitizeHtml.js';
@ -53,6 +54,16 @@ const tempDirectory = os.tmpdir();
* @param {String} type the type to export
*/
export const doExport = async (req: any, res: any, padId: string, readOnlyId: string, type:string) => {
// Validate :rev BEFORE setting Content-Disposition. A bad rev causes
// checkValidRev to throw, which the route handler catches and renders as a
// plain-text 500. If we set the attachment header first, the browser would
// download the error message as a file instead of displaying it.
if (req.params.rev !== undefined) {
// modify req, as we use it in a later call to exportConvert
req.params.rev = checkValidRev(req.params.rev);
}
// avoid naming the read-only file as the original pad's id
let fileName = readOnlyId ? readOnlyId : padId;
@ -67,12 +78,6 @@ export const doExport = async (req: any, res: any, padId: string, readOnlyId: st
// tell the browser that this is a downloadable file
res.attachment(`${fileName}.${type}`);
if (req.params.rev !== undefined) {
// ensure revision is a number
// modify req, as we use it in a later call to exportConvert
req.params.rev = checkValidRev(req.params.rev);
}
// if this is a plain text export, we can do this directly
// We have to over engineer this because tabs are stored as attributes and not plain text
if (type === 'etherpad') {
@ -163,8 +168,9 @@ export const doExport = async (req: any, res: any, padId: string, readOnlyId: st
}
}
// soffice path — write the html export to a file
const randNum = Math.floor(Math.random() * 0xFFFFFFFF);
// soffice path — write the html export to a file. Use CSPRNG output
// for the temp path token (see matching note in ImportHandler.ts).
const randNum = crypto.randomBytes(16).toString('hex');
const srcFile = `${tempDirectory}/etherpad_export_${randNum}.html`;
await fsp_writeFile(srcFile, html);

View file

@ -23,6 +23,7 @@
import * as padManager from '../db/PadManager.js';
import padMessageHandler from './PadMessageHandler.js';
import crypto from 'node:crypto';
import {promises as fs} from 'fs';
import path from 'path';
import settings from '../utils/Settings.js';
@ -86,7 +87,10 @@ const performImport = async (req:any, res:any, padId:string, authorId:string) =>
// pipe to a file
// convert file to html via soffice
// set html in the pad
const randNum = Math.floor(Math.random() * 0xFFFFFFFF);
//
// Use CSPRNG output for the temp path token so the destination path
// can't be predicted by another process on the same host.
const randNum = crypto.randomBytes(16).toString('hex');
// setting flag for whether to use converter or not
let useConverter = (converter != null);

View file

@ -5,6 +5,7 @@ import fs from "fs";
import type {MapArrayType} from "../../types/MapType.js";
import settings from '../../utils/Settings.js';
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath.js';
const ADMIN_PATH = path.join(settings.root, 'src', 'templates');
const PROXY_HEADER = "x-proxy-path"
@ -72,11 +73,19 @@ export const expressCreateServer = (hookName: string, args: ArgsExpressType, cb:
// if the file is found, set Content-type and send data
res.setHeader('Content-type', map[ext] || 'text/plain');
if (ext === ".html" || ext === ".js" || ext === ".css") {
if (req.header(PROXY_HEADER)) {
// The proxy-path header is woven into the response body, so
// it must be sanitised before substitution and downstream
// caches must not collapse responses across different
// header values.
const proxyPath = sanitizeProxyPath(req);
if (proxyPath) {
let string = data.toString()
dataToSend = string.replaceAll("/admin", req.header(PROXY_HEADER) + "/admin")
dataToSend = dataToSend.replaceAll("/socket.io", req.header(PROXY_HEADER) + "/socket.io")
dataToSend = string.replaceAll("/admin", proxyPath + "/admin")
dataToSend = dataToSend.replaceAll(
"/socket.io", proxyPath + "/socket.io")
}
res.setHeader('Vary', 'x-proxy-path');
res.setHeader('Cache-Control', 'private, no-store');
}
res.end(dataToSend);
}

View file

@ -1,7 +1,7 @@
'use strict';
import {PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery.js";
import {PAD_FILTERS, PadFilter, PadQueryResult, PadSearchQuery} from "../../types/PadSearchQuery.js";
import log4js from 'log4js';
import { promises as fsp } from 'fs';
@ -9,6 +9,7 @@ import hooks from '../../../static/js/pluginfw/hooks.js';
import plugins from '../../../static/js/pluginfw/plugins.js';
import settings, {getEpVersion, getGitCommit, reloadSettings} from '../../utils/Settings.js';
import {getLatestVersion} from '../../utils/UpdateCheck.js';
import {redactSettings} from '../../utils/AdminSettingsRedact.js';
import * as padManager from '../../db/PadManager.js';
import * as api from '../../db/API.js';
import * as authorManager from '../../db/AuthorManager.js';
@ -16,8 +17,35 @@ import {deleteRevisions} from '../../utils/Cleanup.js';
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;
}
export const socketio = (hookName: string, {io}: any) => {
io.of('/settings').on('connection', (socket: any) => {
@ -39,7 +67,8 @@ export const 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});
}
});
@ -111,137 +140,112 @@ export const 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|number}[]
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

@ -71,7 +71,24 @@ export const expressCreateServer = (hookName:string, args:ArgsExpressType, cb:Fu
console.log(`Exporting pad "${req.params.pad}" in ${req.params.type} format`);
await exportHandler.doExport(req, res, padId, readOnlyId, req.params.type);
}
})().catch((err) => next(err || new Error(err)));
})().catch((err) => {
// Send a deterministic plain-text body for every export failure.
// checkValidRev throws CustomError('...', 'apierror') for a bad :rev,
// but conversion / fs / soffice errors also reach this handler — and
// without an explicit response, all of them would fall through to
// Express's default HTML error renderer, which is hostile to API
// callers (and would be saved as a file by the browser because of
// the attachment header set in doExport for non-apierror cases).
if (res.headersSent) return next(err || new Error(err));
// Clear the download header so the error body renders inline instead
// of being saved as the requested filename.
res.removeHeader('Content-Disposition');
// Log the full error server-side for operators. apierrors are
// user-facing validation errors and not worth a server-side log line.
if (!err || err.name !== 'apierror') console.error('Export error:', err);
const msg = (err && err.message) || 'Internal Server Error';
return res.status(500).type('text/plain').send(msg);
});
});
// handle import requests

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.js";
import settings from '../../utils/Settings.js';
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath.js';
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",
});
export const 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

@ -21,12 +21,10 @@ import stats from '../../stats.js';
let ioI: { sockets: { sockets: any[]; }; } | null = null
// Sanitize x-proxy-path header to prevent XSS via header injection.
// Only allow path-like characters (letters, digits, hyphens, underscores, slashes, dots).
const sanitizeProxyPath = (req: any): string => {
const raw = req.header('x-proxy-path') || '';
return raw.replace(/[^a-zA-Z0-9\-_\/\.]/g, '');
};
// Shared sanitizer for the `x-proxy-path` header. See the helper for the
// allowed character class and the protocol-relative / traversal rejection
// rules. Reused by admin.ts so both call sites share one definition.
import {sanitizeProxyPath} from '../../utils/sanitizeProxyPath';
export const socketio = (hookName: string, {io}: any) => {
@ -178,8 +176,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}));
})
})
@ -206,6 +205,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,
@ -214,6 +214,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
entrypoint: proxyPath + '/watch/pad?hash=' + hash,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
})
res.send(content);
})
@ -248,6 +249,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,
@ -257,6 +259,7 @@ const handleLiveReload = async (args: ArgsExpressType, padString: string, timeSl
entrypoint: proxyPath + '/watch/timeslider?hash=' + hash,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
})
res.send(content);
})
@ -366,10 +369,12 @@ export const expressCreateServer = async (_hookName: string, args: ArgsExpressTy
// 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}));
});
@ -384,8 +389,10 @@ export const expressCreateServer = async (_hookName: string, args: ArgsExpressTy
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,
@ -394,6 +401,7 @@ export const expressCreateServer = async (_hookName: string, args: ArgsExpressTy
entrypoint: "../"+fileNamePad,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
})
res.send(content);
});
@ -417,8 +425,10 @@ export const expressCreateServer = async (_hookName: string, args: ArgsExpressTy
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,
@ -427,6 +437,7 @@ export const expressCreateServer = async (_hookName: string, args: ArgsExpressTy
entrypoint: "../../"+fileNameTimeSlider,
settings: settings.getPublicSettings(),
socialMetaHtml,
proxyPath,
}));
});
} else {

View file

@ -7,10 +7,20 @@ import settings from '../../utils/Settings.js';
type TokenTransferRequest = {
token: string;
prefsHttp: string,
// Optional because legacy records from older code paths persisted
// without it. The GET handler treats absent/non-numeric createdAt as
// expired (safe fallback); the type reflects that.
createdAt?: number;
}
const tokenTransferKey = "tokenTransfer:";
// Keep the legacy on-the-wire key shape so any in-flight transfers
// created before this change are still redeemable.
const tokenTransferKey = (id: string) => `tokenTransfer::${id}`;
// Transfer records have a hard TTL — the legitimate flow is "scan a QR
// code on another device and click within a few minutes". A stale id
// should not be redeemable indefinitely.
const TRANSFER_TTL_MS = 5 * 60 * 1000;
export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) => {
app.post('/tokenTransfer', async (req: any, res) => {
@ -33,7 +43,7 @@ export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) =>
createdAt: Date.now(),
};
await db.set(`${tokenTransferKey}:${id}`, token);
await db.set(tokenTransferKey(id), token);
res.send({id});
})
@ -43,11 +53,26 @@ export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) =>
return res.status(400).send({error: 'Invalid request'});
}
const tokenData = await db.get(`${tokenTransferKey}:${id}`);
const key = tokenTransferKey(id);
const tokenData: TokenTransferRequest | undefined = await db.get(key);
if (!tokenData) {
return res.status(404).send({error: 'Token not found'});
}
// Single-use: remove the record BEFORE the response is sent, so a
// parallel request that wins the race observes an already-redeemed
// transfer rather than a second usable copy.
await db.remove(key);
// Enforce the TTL. Absent/non-numeric createdAt is treated as
// expired so legacy records that pre-date this code path are
// rejected on the safe side.
const createdAt = typeof tokenData.createdAt === 'number'
? tokenData.createdAt : 0;
if (Date.now() - createdAt > TRANSFER_TTL_MS) {
return res.status(410).send({error: 'Token expired'});
}
const p = settings.cookie.prefix;
// Re-issue the author token on the new device as an HttpOnly cookie to
// match the /p/:pad path (ether/etherpad#6701 PR3). Without this, the
@ -63,6 +88,9 @@ export const expressCreateServer = (hookName:string, {app}:ArgsExpressType) =>
res.cookie(`${p}prefsHttp`, tokenData.prefsHttp, {
path: '/', maxAge: 1000 * 60 * 60 * 24 * 365,
});
res.send(tokenData);
// Body must NOT echo the author token — the HttpOnly cookie above
// is the only channel. Body advertises only the non-secret prefs
// the client needs to wire up locally.
res.send({ok: true, prefsHttp: tokenData.prefsHttp});
})
}

View file

@ -6,7 +6,10 @@ import {spawn} from 'node:child_process';
import log4js from 'log4js';
import {ArgsExpressType} from '../../types/ArgsExpressType.js';
import settings, {getEpVersion} from '../../utils/Settings.js';
import {getDetectedInstallMethod, stateFilePath, getRollbackDeps} from '../../updater/index.js';
import {
getDetectedInstallMethod, stateFilePath, getRollbackDeps,
notifyApplyFailure, cancelScheduler,
} from '../../updater/index.js';
import {evaluatePolicy} from '../../updater/UpdatePolicy.js';
import {loadState, saveState} from '../../updater/state.js';
import {acquireLock, releaseLock} from '../../updater/lock.js';
@ -19,7 +22,6 @@ import {performRollback} from '../../updater/RollbackHandler.js';
import {UpdateState} from '../../updater/types.js';
import {isValidTag} from '../../updater/refSafety.js';
import {applyUpdate} from '../../updater/applyPipeline.js';
import {cancelScheduler} from '../../updater/index.js';
import {getIo} from './socketio.js';
const logger = log4js.getLogger('updater');
@ -104,6 +106,20 @@ const buildPreflightDeps = (installMethod: ReturnType<typeof getDetectedInstallM
requireSignature: settings.updates.requireSignature,
trustedKeysPath: settings.updates.trustedKeysPath,
}),
readTargetEnginesNode: (tag: string) => new Promise<string | null>((resolve) => {
const c = spawn('git', ['show', `${tag}:package.json`],
{cwd: settings.root, stdio: ['ignore', 'pipe', 'ignore']});
let out = '';
c.stdout.on('data', (b) => { out += b.toString(); });
c.on('close', () => {
try {
const pkg = JSON.parse(out);
const range = pkg?.engines?.node;
resolve(typeof range === 'string' && range.trim().length > 0 ? range : null);
} catch { resolve(null); }
});
c.on('error', () => resolve(null));
}),
});
/**
@ -193,6 +209,7 @@ export const expressCreateServer = (
diskSpaceMinMB: Number(settings.updates.diskSpaceMinMB) || 500,
requireSignature: settings.updates.requireSignature,
trustedKeysPath: settings.updates.trustedKeysPath,
currentNodeVersion: process.versions.node,
},
{
...baseDeps,
@ -256,6 +273,20 @@ export const expressCreateServer = (
});
drainer = null;
// Fire the failure-notification email path for outcomes the admin needs
// to know about even on manual apply (an admin might click Apply and
// walk away; rolling back silently isn't enough). Errors here are
// swallowed by notifyApplyFailure — they must not block the response.
if (result.outcome === 'preflight-failed') {
void notifyApplyFailure({
outcome: 'preflight-failed', targetTag, reason: result.reason,
});
} else if (result.outcome === 'rolled-back') {
void notifyApplyFailure({
outcome: 'rolled-back', targetTag, reason: 'rolled-back',
});
}
if (responded) return; // already 202'd in onAccepted; nothing more to send.
switch (result.outcome) {

View file

@ -1,35 +1,103 @@
'use strict';
import path from 'node:path';
import {LRUCache} from 'lru-cache';
import {ArgsExpressType} from '../../types/ArgsExpressType.js';
import settings, {getEpVersion} from '../../utils/Settings.js';
import {getDetectedInstallMethod, stateFilePath} from '../../updater/index.js';
import {evaluatePolicy} from '../../updater/UpdatePolicy.js';
import {compareSemver, isMajorBehind, isVulnerable} from '../../updater/versionCompare.js';
import {isMinorOrMoreBehind} from '../../updater/versionCompare.js';
import {loadState} from '../../updater/state.js';
import {isHeld} from '../../updater/lock.js';
import {nextWindowStart, parseWindow} from '../../updater/MaintenanceWindow.js';
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
@ -64,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
@ -103,9 +176,19 @@ export const expressCreateServer = (
current,
latest: state.latest.version,
executionStatus: state.execution.status,
maintenanceWindow: settings.updates.maintenanceWindow,
})
: null;
const lockHeld = await isHeld(path.join(settings.root, 'var', 'update.lock'));
// Tier 4: surface the configured window + the next opening so the admin UI
// can render the picker and the "deferred until..." subtitle on the
// scheduled panel. Non-admin requests get null for both fields (the parsed
// window is operational config, not a public datum).
const parsedWindow = parseWindow(settings.updates.maintenanceWindow);
const maintenanceWindow = isAdmin ? parsedWindow : null;
const nextWindowOpensAt = isAdmin && parsedWindow && settings.updates.tier === 'autonomous'
? nextWindowStart(new Date(), parsedWindow).toISOString()
: null;
// The Tier 2 fields (execution, lastResult) carry diagnostic strings
// built from git/pnpm stderr — environment-specific paths, error
@ -127,11 +210,13 @@ export const expressCreateServer = (
installMethod,
tier: settings.updates.tier,
policy,
vulnerableBelow: state.vulnerableBelow,
// PR 2 additions:
execution,
lastResult,
lockHeld,
// PR 4 additions:
maintenanceWindow,
nextWindowOpensAt,
});
}));

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

@ -0,0 +1,105 @@
/**
* Maintenance-window math for Tier 4 (autonomous updates).
*
* Pure no I/O, no log4js, no globals beyond `Date`. Imported by:
* - `UpdatePolicy.ts` (canAutonomous gate)
* - `Scheduler.ts` (snap scheduledFor to the next window opening, defer fires)
* - `index.ts` (compute nextWindowOpensAt for /admin/update/status)
* - admin UI picker (validation)
*
* Time semantics
* --------------
* A window is a pair of HH:MM wall-clock times plus a `tz` selector. For
* `tz: 'utc'`, comparisons use `getUTCHours/Minutes` and `Date.UTC(...)`. For
* `tz: 'local'`, they use the host's local wall clock via the standard `Date`
* constructor. `nextWindowStart` therefore returns a `Date` whose wall-clock
* components in the configured tz equal `window.start` DST transitions are
* absorbed by the JS Date constructor's normalization (a 02:30 window-start on
* a spring-forward day silently lands at 03:30 local because 02:30 does not
* exist; documented behavior, not a bug).
*
* Cross-midnight windows are supported (`end < start` means "wraps past
* 00:00"). The `end` minute is exclusive in both same-day and cross-midnight
* cases a `22:00-02:00` window matches `[22:00, 24:00) [00:00, 02:00)`.
*/
export interface MaintenanceWindow {
/** Wall-clock start in `HH:MM` (24h). */
start: string;
/** Wall-clock end in `HH:MM` (24h). Exclusive. */
end: string;
/** Whether `start`/`end` are read against UTC or the host's local clock. */
tz: 'local' | 'utc';
}
const HHMM = /^([01]\d|2[0-3]):([0-5]\d)$/;
const toMinutes = (hhmm: string): number | null => {
const m = HHMM.exec(hhmm);
if (!m) return null;
return Number(m[1]) * 60 + Number(m[2]);
};
/**
* Parse and validate a raw value (typically from `settings.json`) into a
* `MaintenanceWindow`. Returns `null` for any structural or format failure
* callers should treat that as "tier 4 disabled, fall back to tier 3".
*/
export const parseWindow = (raw: unknown): MaintenanceWindow | null => {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
if (typeof r.start !== 'string' || typeof r.end !== 'string') return null;
if (r.tz !== 'local' && r.tz !== 'utc') return null;
const s = toMinutes(r.start);
const e = toMinutes(r.end);
if (s == null || e == null) return null;
if (s === e) return null;
return {start: r.start, end: r.end, tz: r.tz};
};
const wallMinutes = (now: Date, tz: MaintenanceWindow['tz']): number => (
tz === 'utc'
? now.getUTCHours() * 60 + now.getUTCMinutes()
: now.getHours() * 60 + now.getMinutes()
);
/**
* `true` iff `now`'s wall-clock minute is within `[start, end)` in the window's
* tz. Cross-midnight windows wrap at 24:00 see file header for the exact set.
*/
export const inWindow = (now: Date, window: MaintenanceWindow): boolean => {
const s = toMinutes(window.start);
const e = toMinutes(window.end);
if (s == null || e == null || s === e) return false;
const m = wallMinutes(now, window.tz);
return s < e ? (m >= s && m < e) : (m >= s || m < e);
};
const buildAt = (year: number, month: number, day: number, mins: number,
tz: MaintenanceWindow['tz']): Date => {
const h = Math.floor(mins / 60);
const mm = mins % 60;
return tz === 'utc'
? new Date(Date.UTC(year, month, day, h, mm, 0, 0))
: new Date(year, month, day, h, mm, 0, 0);
};
/**
* Smallest `Date` `t` such that `t >= now` and `t`'s wall-clock equals
* `window.start` in the window's tz. Used by Scheduler to snap a scheduledFor
* that lands outside the window forward to the next opening.
*
* If `now` is *inside* the window, the next opening is tomorrow we don't
* collapse to `now`. Fire-now is gated by `inWindow`, not this function.
*/
export const nextWindowStart = (now: Date, window: MaintenanceWindow): Date => {
const s = toMinutes(window.start);
if (s == null) return now;
const isUtc = window.tz === 'utc';
const year = isUtc ? now.getUTCFullYear() : now.getFullYear();
const month = isUtc ? now.getUTCMonth() : now.getMonth();
const day = isUtc ? now.getUTCDate() : now.getDate();
const todayStart = buildAt(year, month, day, s, window.tz);
if (todayStart.getTime() > now.getTime()) return todayStart;
return buildAt(year, month, day + 1, s, window.tz);
};

View file

@ -1,19 +1,21 @@
import {EmailSendLog} from './types.js';
// 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;
}
export type EmailKind = 'severe' | 'vulnerable' | 'vulnerable-new-release' | 'grace-start';
export type EmailKind =
| 'severe'
| 'grace-start'
| 'update-preflight-failed'
| 'update-rolled-back'
| 'update-rollback-failed';
export interface PlannedEmail {
kind: EmailKind;
@ -28,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;
@ -37,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();
}
@ -86,3 +62,72 @@ export const decideEmails = (input: NotifierInput): NotifierResult => {
return {toSend, newState};
};
export type FailureOutcome =
| 'preflight-failed'
| 'rolled-back'
| 'rollback-failed';
export interface OutcomeEmailInput {
adminEmail: string | null;
outcome: FailureOutcome;
/** Free-text reason string from `ApplyResult.reason` (or RollbackHandler). */
reason: string;
/** Tag the failed apply was targeting. */
targetTag: string;
/** Currently-running Etherpad version (so the admin sees what's live now). */
currentVersion: string;
/** Email-state slice from UpdateState. */
state: EmailSendLog;
}
/**
* Decide whether to email about a non-success apply outcome. Pure returns
* the planned email + new dedupe state; does not send.
*
* Dedupe key: `<outcome>:<targetTag>`. Same outcome on the same tag (e.g.
* a retry loop that keeps failing `pnpm install` for v2.7.6) emits one
* email. A different outcome OR a different tag resets the dedupe key and
* fires a new email.
*
* `rollback-failed` always fires (overrides dedupe) it's the terminal
* state that needs human intervention and the admin must learn about it
* even if a previous transient failure happened to share its key.
*/
export const decideOutcomeEmail = (input: OutcomeEmailInput): NotifierResult => {
const {adminEmail, outcome, reason, targetTag, currentVersion, state} = input;
if (!adminEmail) return {toSend: [], newState: state};
const key = `${outcome}:${targetTag}`;
const isTerminal = outcome === 'rollback-failed';
if (!isTerminal && state.lastFailureKey === key) {
return {toSend: [], newState: state};
}
const kind: EmailKind =
outcome === 'preflight-failed' ? 'update-preflight-failed'
: outcome === 'rolled-back' ? 'update-rolled-back'
: 'update-rollback-failed';
const titleByKind: Record<typeof kind, string> = {
'update-preflight-failed':
`[Etherpad] Auto-update to ${targetTag} blocked at preflight`,
'update-rolled-back':
`[Etherpad] Auto-update to ${targetTag} rolled back`,
'update-rollback-failed':
`[Etherpad] Auto-update FAILED and could not be rolled back — manual intervention required`,
};
const bodyTail = isTerminal
? ' Visit /admin/update and POST /admin/update/acknowledge after restoring a working install.'
: ' Visit /admin/update for details.';
const body =
`Etherpad attempted to auto-update to ${targetTag} but failed: ${reason}.\n` +
`The running version is ${currentVersion}.${bodyTail}`;
return {
toSend: [{kind, subject: titleByKind[kind], body}],
newState: {...state, lastFailureKey: key},
};
};

View file

@ -1,5 +1,6 @@
import {EmailSendLog, ExecutionStatus, PolicyResult, ReleaseInfo, UpdateState} from './types.js';
import {EmailSendLog, ExecutionStatus, MaintenanceWindow, PolicyResult, ReleaseInfo, UpdateState} from './types.js';
import {PlannedEmail} from './Notifier.js';
import {inWindow, nextWindowStart} from './MaintenanceWindow.js';
export interface DecideScheduleInput {
state: UpdateState;
@ -9,6 +10,13 @@ export interface DecideScheduleInput {
current: string;
preApplyGraceMinutes: number;
adminEmail: string | null;
/**
* Tier 4 only when `policy.canAutonomous` is true, the scheduler snaps
* `scheduledFor` forward to the next window opening (if it would otherwise
* land outside the window) and `decideTriggerApply` defers fires that the
* window has closed for. Ignored when `canAutonomous === false`.
*/
maintenanceWindow?: MaintenanceWindow | null;
}
export type SchedulerDecision =
@ -48,7 +56,10 @@ const clampGrace = (m: number): number => {
* email when `adminEmail` is set and `email.graceStartTag !== latest.tag`.
*/
export const decideSchedule = (input: DecideScheduleInput): SchedulerDecision => {
const {state, now, policy, latest, current, preApplyGraceMinutes, adminEmail} = input;
const {
state, now, policy, latest, current, preApplyGraceMinutes, adminEmail,
maintenanceWindow,
} = input;
const status = state.execution.status;
if (!latest) return {action: 'nothing'};
@ -66,7 +77,14 @@ export const decideSchedule = (input: DecideScheduleInput): SchedulerDecision =>
}
const graceMs = clampGrace(preApplyGraceMinutes) * 60 * 1000;
const scheduledFor = new Date(now.getTime() + graceMs).toISOString();
let scheduledForDate = new Date(now.getTime() + graceMs);
// Tier 4: snap forward to the next opening if grace lands outside the window.
if (policy.canAutonomous && maintenanceWindow) {
if (!inWindow(scheduledForDate, maintenanceWindow)) {
scheduledForDate = nextWindowStart(scheduledForDate, maintenanceWindow);
}
}
const scheduledFor = scheduledForDate.toISOString();
const newExecution = {
status: 'scheduled' as const,
targetTag: latest.tag,
@ -92,7 +110,8 @@ export const decideSchedule = (input: DecideScheduleInput): SchedulerDecision =>
export type TriggerApplyDecision =
| {action: 'fire'}
| {action: 'abort'; reason: string}
| {action: 'clear-schedule'; reason: string};
| {action: 'clear-schedule'; reason: string}
| {action: 'defer'; nextStart: string; reason: 'outside-maintenance-window'};
/**
* Decide whether the scheduler's timer-fire callback should actually run the
@ -100,10 +119,20 @@ export type TriggerApplyDecision =
* arming-to-firing has a long delay (the grace window) during which the
* admin can cancel, click Apply now, or flip the tier. SchedulerRunnerDeps
* documents this contract; this helper is the canonical implementation.
*
* Tier 4: when `policy.canAutonomous` is true and `now` is outside the
* configured `maintenanceWindow`, returns `{action: 'defer'}` so the runner
* persists a new `scheduledFor = nextStart` and re-arms.
*/
export const decideTriggerApply = ({
state, targetTag, policy,
}: {state: UpdateState; targetTag: string; policy: PolicyResult}): TriggerApplyDecision => {
state, targetTag, policy, now, maintenanceWindow,
}: {
state: UpdateState;
targetTag: string;
policy: PolicyResult;
now?: Date;
maintenanceWindow?: MaintenanceWindow | null;
}): TriggerApplyDecision => {
if (state.execution.status !== 'scheduled') {
return {action: 'abort', reason: `state=${state.execution.status}`};
}
@ -112,6 +141,13 @@ export const decideTriggerApply = ({
}
if (!state.latest) return {action: 'abort', reason: 'no-latest'};
if (!policy.canAuto) return {action: 'clear-schedule', reason: policy.reason || 'policy-denied'};
if (policy.canAutonomous && maintenanceWindow && now && !inWindow(now, maintenanceWindow)) {
return {
action: 'defer',
nextStart: nextWindowStart(now, maintenanceWindow).toISOString(),
reason: 'outside-maintenance-window',
};
}
return {action: 'fire'};
};

View file

@ -1,5 +1,6 @@
import {compareSemver} from './versionCompare.js';
import {InstallMethod, PolicyResult, Tier} from './types.js';
import {parseWindow} from './MaintenanceWindow.js';
import {InstallMethod, MaintenanceWindow, PolicyResult, Tier} from './types.js';
// For PR 1 (notify only) the writable list contains only 'git'.
// PR 2+ may add 'npm' here as the executor learns to handle that path.
@ -17,19 +18,29 @@ export interface PolicyInput {
* intervention the terminal state requires.
*/
executionStatus?: string;
/**
* Configured maintenance window from `updates.maintenanceWindow`. Tier 4
* requires a non-null, parse-valid window. When null or malformed,
* canAutonomous degrades to false with a reason of
* `maintenance-window-missing` / `maintenance-window-invalid`; the other
* permissions still resolve as if tier were `auto`.
*/
maintenanceWindow?: MaintenanceWindow | unknown | null;
}
/**
* Decide which update tiers are allowed under the given (installMethod, tier,
* current, latest, executionStatus). Pure function no I/O. The single source
* of truth for "what's allowed in this environment."
* current, latest, executionStatus, maintenanceWindow). Pure function no I/O.
* The single source of truth for "what's allowed in this environment."
*
* `reason` is one of:
* 'tier-off' | 'up-to-date' | 'install-method-not-writable'
* | 'rollback-failed-terminal' | 'ok'.
* | 'rollback-failed-terminal'
* | 'maintenance-window-missing' | 'maintenance-window-invalid'
* | 'ok'.
*/
export const evaluatePolicy = ({
installMethod, tier, current, latest, executionStatus,
installMethod, tier, current, latest, executionStatus, maintenanceWindow,
}: PolicyInput): PolicyResult => {
if (tier === 'off') {
return {canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'tier-off'};
@ -46,11 +57,24 @@ export const evaluatePolicy = ({
}
const terminal = executionStatus === 'rollback-failed';
return {
canNotify,
canManual: tier === 'manual' || tier === 'auto' || tier === 'autonomous',
canAuto: !terminal && (tier === 'auto' || tier === 'autonomous'),
canAutonomous: !terminal && tier === 'autonomous',
reason: terminal ? 'rollback-failed-terminal' : 'ok',
};
const canManual = tier === 'manual' || tier === 'auto' || tier === 'autonomous';
const canAuto = !terminal && (tier === 'auto' || tier === 'autonomous');
let canAutonomous = false;
let windowReason: string | null = null;
if (!terminal && tier === 'autonomous') {
if (maintenanceWindow == null) {
windowReason = 'maintenance-window-missing';
} else if (parseWindow(maintenanceWindow) == null) {
windowReason = 'maintenance-window-invalid';
} else {
canAutonomous = true;
}
}
const reason = terminal
? 'rollback-failed-terminal'
: (windowReason ?? 'ok');
return {canNotify, canManual, canAuto, canAutonomous, reason};
};

View file

@ -1,5 +1,4 @@
import {ReleaseInfo, VulnerableBelowDirective} from './types.js';
import {parseVulnerableBelow} from './versionCompare.js';
import {ReleaseInfo} from './types.js';
import {isValidTag} from './refSafety.js';
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

@ -1,11 +1,11 @@
import {UpdateState} from './types.js';
import {PreflightResult, PreflightReason} from './preflight.js';
import {PreflightResult} from './preflight.js';
import {ExecutorResult} from './UpdateExecutor.js';
import {Drainer, DrainBroadcastKey} from './SessionDrainer.js';
export type ApplyOutcome =
| {outcome: 'pending-verification'}
| {outcome: 'preflight-failed'; reason: PreflightReason}
| {outcome: 'preflight-failed'; reason: string}
| {outcome: 'cancelled'}
| {outcome: 'lock-held'}
| {outcome: 'busy'; status: string}
@ -89,13 +89,17 @@ export const applyUpdate = async (
const pf = await deps.runPreflight(targetTag);
if (!pf.ok) {
const at = deps.now().toISOString();
// Append the optional `detail` (e.g. "target requires Node >=26.0.0,
// running 25.0.0" for node-engine-mismatch) so the admin UI shows a
// version-specific message without requiring a separate API field.
const reasonStr = pf.detail ? `${pf.reason}: ${pf.detail}` : pf.reason;
await deps.saveState({
...preState,
execution: {status: 'preflight-failed', targetTag, reason: pf.reason, at},
lastResult: {targetTag, fromSha: '', outcome: 'preflight-failed', reason: pf.reason, at},
execution: {status: 'preflight-failed', targetTag, reason: reasonStr, at},
lastResult: {targetTag, fromSha: '', outcome: 'preflight-failed', reason: reasonStr, at},
});
deps.appendLog(`[${at}] PREFLIGHT_FAILED ${pf.reason}`);
return {outcome: 'preflight-failed', reason: pf.reason};
deps.appendLog(`[${at}] PREFLIGHT_FAILED ${reasonStr}`);
return {outcome: 'preflight-failed', reason: reasonStr};
}
// Re-load state after preflight: the cancel endpoint can flip execution

View file

@ -6,12 +6,13 @@ import settings, {getEpVersion} from '../utils/Settings.js';
import {detectInstallMethod} from './InstallMethodDetector.js';
import {checkLatestRelease, realFetcher} from './VersionChecker.js';
import {loadState, saveState} from './state.js';
import {isMajorBehind, isVulnerable} from './versionCompare.js';
import {isMinorOrMoreBehind} from './versionCompare.js';
import {evaluatePolicy} from './UpdatePolicy.js';
import {decideEmails} from './Notifier.js';
import {decideEmails, decideOutcomeEmail, FailureOutcome} from './Notifier.js';
import {checkPendingVerification, CheckResult, RollbackDeps, performRollback} from './RollbackHandler.js';
import {executeUpdate, SpawnFn} from './UpdateExecutor.js';
import {createSchedulerRunner, decideSchedule, decideTriggerApply, SchedulerRunner} from './Scheduler.js';
import {parseWindow} from './MaintenanceWindow.js';
import {applyUpdate, ApplyPipelineDeps} from './applyPipeline.js';
import {acquireLock, releaseLock} from './lock.js';
import {runPreflight} from './preflight.js';
@ -42,11 +43,71 @@ export const getCurrentState = async (): Promise<UpdateState> => {
export const getDetectedInstallMethod = () => detectedMethod;
/**
* Cached nodemailer transport. Built on first use when `settings.mail.host` is
* set; never imported when mail is disabled (keeps boot costs predictable for
* installs that don't care about outbound mail).
*
* The cache is keyed on the full set of SMTP options that `buildTransport`
* consumes (host, port, secure, auth). `reloadSettings()` can mutate any of
* these at runtime, so a host-only key would silently keep using a stale
* transport when an operator rotates credentials or moves to a different
* port without changing host.
*/
let transportCache: {key: string; transporter: {sendMail: (m: any) => Promise<any>}} | null = null;
/**
* Stable string key derived from the SMTP options `buildTransport` consumes.
* Exported as `_internal` so tests can verify that runtime mutations to
* `port`/`secure`/`auth` (without a host change) actually invalidate the
* cache a regression caught by Qodo on PR #7753.
*/
export const smtpTransportKey = (mail: {
host?: string | null;
port?: number | string | null;
secure?: boolean | null;
auth?: unknown;
}): string => JSON.stringify({
host: mail.host ?? null,
port: Number(mail.port) || 587,
secure: !!mail.secure,
auth: mail.auth ?? null,
});
const buildTransport = async (host: string) => {
const {default: nodemailer} = await import('nodemailer');
return nodemailer.createTransport({
host,
port: Number(settings.mail.port) || 587,
secure: !!settings.mail.secure,
auth: settings.mail.auth ?? undefined,
});
};
const sendEmailViaSmtp = async (to: string, subject: string, body: string): Promise<void> => {
// Etherpad core has no built-in SMTP. PR 1 ships the dedupe machinery without an actual sender;
// subsequent PRs can wire nodemailer or rely on a notification plugin.
logger.info(`(would send email) to=${to} subject="${subject}"`);
void body;
const host = settings.mail.host;
if (!host || !settings.mail.from) {
// Mail not configured. Log so operators running the runbook can confirm
// the Notifier fired even without delivery, and the dedupe state still
// advances so we don't re-evaluate the same trigger every tick.
logger.info(`(would send email) to=${to} subject="${subject}"`);
return;
}
const key = smtpTransportKey(settings.mail);
if (!transportCache || transportCache.key !== key) {
transportCache = {key, transporter: await buildTransport(host)};
}
try {
await transportCache.transporter.sendMail({
from: settings.mail.from, to, subject, text: body,
});
logger.info(`email sent to=${to} subject="${subject}"`);
} catch (err) {
// Never throw out of the sender — a transient SMTP failure must not
// poison the surrounding updater state machine. The admin UI banner
// is still the source of truth for the underlying condition.
logger.warn(`email send failed: ${(err as Error).message}`);
}
};
const performCheck = async (): Promise<void> => {
@ -71,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;
@ -95,6 +151,7 @@ const performCheck = async (): Promise<void> => {
tier: settings.updates.tier,
current,
latest: state.latest.version,
maintenanceWindow: settings.updates.maintenanceWindow,
});
if (policy.canNotify) {
const decision = decideEmails({
@ -102,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,
});
@ -115,6 +171,7 @@ const performCheck = async (): Promise<void> => {
}
// Tier 3 scheduler pass: decide whether to schedule, reschedule, or cancel.
// Tier 4 snap-forward to next maintenance window is layered in here too.
if (state.latest && scheduler) {
const current = getEpVersion();
const policy = evaluatePolicy({
@ -123,12 +180,14 @@ const performCheck = async (): Promise<void> => {
current,
latest: state.latest.version,
executionStatus: state.execution.status,
maintenanceWindow: settings.updates.maintenanceWindow,
});
const decision = decideSchedule({
state, now, policy,
latest: state.latest, current,
preApplyGraceMinutes: Number(settings.updates.preApplyGraceMinutes) || 0,
adminEmail: settings.adminEmail,
maintenanceWindow: policy.canAutonomous ? parseWindow(settings.updates.maintenanceWindow) : null,
});
if (decision.action === 'schedule') {
state.execution = decision.newExecution;
@ -217,6 +276,7 @@ const buildSchedulerApplyDeps = (): ApplyPipelineDeps => ({
diskSpaceMinMB: Number(settings.updates.diskSpaceMinMB) || 500,
requireSignature: settings.updates.requireSignature,
trustedKeysPath: settings.updates.trustedKeysPath,
currentNodeVersion: process.versions.node,
},
{
installMethod: detectedMethod,
@ -256,6 +316,26 @@ const buildSchedulerApplyDeps = (): ApplyPipelineDeps => ({
requireSignature: settings.updates.requireSignature,
trustedKeysPath: settings.updates.trustedKeysPath,
}),
readTargetEnginesNode: (tagName: string) => new Promise<string | null>((resolve) => {
// Read the target tag's package.json *without* mutating the working
// tree: `git show <tag>:package.json` writes to stdout only. Treat
// any failure (missing tag, missing file, malformed JSON, missing
// engines.node) as "no constraint" — preflight already covers
// missing-tag separately; we don't want to gate updates on a
// package.json shape that older releases predate.
const c = spawn('git', ['show', `${tagName}:package.json`],
{cwd: settings.root, stdio: ['ignore', 'pipe', 'ignore']});
let out = '';
c.stdout.on('data', (b) => { out += b.toString(); });
c.on('close', () => {
try {
const pkg = JSON.parse(out);
const range = pkg?.engines?.node;
resolve(typeof range === 'string' && range.trim().length > 0 ? range : null);
} catch { resolve(null); }
});
c.on('error', () => resolve(null));
}),
},
),
createDrainer: (opts) => createDrainer(opts),
@ -299,6 +379,57 @@ const buildSchedulerApplyDeps = (): ApplyPipelineDeps => ({
/** Allow the cancel handler to drop the pending scheduler timer. */
export const cancelScheduler = (): void => { scheduler?.cancel(); };
/**
* Map an `applyUpdate` outcome to a `FailureOutcome` for the Notifier, or
* `null` when the outcome doesn't warrant an admin email. We deliberately
* do NOT email on `cancelled` (the admin did it themselves), `busy` (UI
* already surfaced the in-flight state), `lock-held`, `invalid-tag`, or
* `no-known-latest` (all transient operational conditions surfaced via the
* banner already). The terminal `rollback-failed` is emitted separately
* from RollbackHandler's own path — applyUpdate's `rolled-back` covers the
* auto-recovered case.
*/
const failureOutcomeFromApplyResult = (
outcome: string,
): FailureOutcome | null => {
if (outcome === 'preflight-failed') return 'preflight-failed';
if (outcome === 'rolled-back') return 'rolled-back';
return null;
};
/**
* Load state, run Notifier.decideOutcomeEmail for the given failure, send
* the planned mail (best-effort), and persist the updated dedupe key. Never
* throws a transient SMTP issue must not poison the surrounding apply
* flow's bookkeeping.
*/
export const notifyApplyFailure = async (params: {
outcome: FailureOutcome;
reason: string;
targetTag: string;
}): Promise<void> => {
try {
const state = await loadState(stateFilePath());
const decision = decideOutcomeEmail({
adminEmail: settings.adminEmail,
outcome: params.outcome,
reason: params.reason,
targetTag: params.targetTag,
currentVersion: getEpVersion(),
state: state.email,
});
if (decision.toSend.length === 0) return;
for (const e of decision.toSend) {
if (settings.adminEmail) {
await sendEmailViaSmtp(settings.adminEmail, e.subject, e.body);
}
}
await saveState(stateFilePath(), {...state, email: decision.newState});
} catch (err) {
logger.warn(`notifyApplyFailure: ${(err as Error).message}`);
}
};
/**
* Timer-fire callback. Re-reads persisted state and re-evaluates policy
* *before* invoking applyUpdate so a last-moment cancel, a manual Apply now
@ -318,9 +449,14 @@ const schedulerTriggerApply = async (targetTag: string): Promise<void> => {
current: getEpVersion(),
latest: state.latest.version,
executionStatus: state.execution.status,
maintenanceWindow: settings.updates.maintenanceWindow,
})
: {canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'no-latest'};
const decision = decideTriggerApply({state, targetTag, policy});
const window = policy.canAutonomous ? parseWindow(settings.updates.maintenanceWindow) : null;
const decision = decideTriggerApply({
state, targetTag, policy,
now: new Date(), maintenanceWindow: window,
});
if (decision.action === 'abort') {
logger.info(`scheduler fired for ${targetTag} but aborting (${decision.reason})`);
return;
@ -332,8 +468,27 @@ const schedulerTriggerApply = async (targetTag: string): Promise<void> => {
await saveState(stateFilePath(), {...state, execution: {status: 'idle'}});
return;
}
if (decision.action === 'defer') {
// Tier 4: fire-time was outside the window. Re-arm for the next opening
// and persist the new scheduledFor so a restart in the gap rehydrates.
logger.info(`scheduler deferred ${targetTag} to next maintenance window at ${decision.nextStart}`);
const sched = state.execution.status === 'scheduled' ? state.execution : null;
if (sched) {
await saveState(stateFilePath(), {
...state,
execution: {...sched, scheduledFor: decision.nextStart},
});
scheduler?.arm({targetTag, scheduledFor: decision.nextStart});
}
return;
}
const result = await applyUpdate({targetTag, deps: buildSchedulerApplyDeps()});
logger.info(`scheduler apply finished: ${result.outcome}`);
const failureKind = failureOutcomeFromApplyResult(result.outcome);
if (failureKind) {
const reason = (result as {reason?: string}).reason ?? failureKind;
await notifyApplyFailure({outcome: failureKind, reason, targetTag});
}
} catch (err) {
logger.warn(`scheduler apply failed: ${(err as Error).message}`);
}
@ -354,6 +509,26 @@ export const expressCreateServer = async (): Promise<void> => {
const state = await getCurrentState();
pendingVerification = checkPendingVerification(state, getRollbackDeps());
// Boot-time failure notification. If a previous run produced a failure
// outcome whose admin email we haven't already sent (lastFailureKey
// dedupe), fire it now. Covers:
// - health-check timeout rollback on the previous boot
// - crash-loop forced rollback (detected on a later boot)
// - preflight-failed where we never got to send (e.g. process kill)
// - rollback-failed terminal that the operator hasn't acknowledged
// Fire-and-forget — the rest of boot must proceed regardless.
const failureOutcome = state.lastResult?.outcome === 'rolled-back' ? 'rolled-back'
: state.lastResult?.outcome === 'rollback-failed' ? 'rollback-failed'
: state.lastResult?.outcome === 'preflight-failed' ? 'preflight-failed'
: null;
if (failureOutcome && state.lastResult) {
void notifyApplyFailure({
outcome: failureOutcome,
targetTag: state.lastResult.targetTag,
reason: state.lastResult.reason ?? failureOutcome,
});
}
// Tier 3: instantiate the scheduler unless updates are entirely disabled.
// The runner is purely in-memory — the persisted state file is the source
// of truth for "is something scheduled." On `tier: "off"` we explicitly

View file

@ -1,3 +1,4 @@
import semver from 'semver';
import {InstallMethod} from './types.js';
import type {VerifyResult} from './trustedKeys.js';
@ -8,13 +9,20 @@ export type PreflightReason =
| 'pnpm-not-found'
| 'lock-held'
| 'remote-tag-missing'
| 'signature-verification-failed';
| 'signature-verification-failed'
| 'node-engine-mismatch';
export interface PreflightInput {
targetTag: string;
diskSpaceMinMB: number;
requireSignature: boolean;
trustedKeysPath: string | null;
/**
* Running Node version (typically `process.versions.node`). Threaded
* through `input` rather than read from globals so the function stays
* fully testable without process mocking.
*/
currentNodeVersion: string;
}
export interface PreflightDeps {
@ -25,9 +33,16 @@ export interface PreflightDeps {
lockHeld: () => Promise<boolean>;
remoteHasTag: (tag: string) => Promise<boolean>;
verifyTag: () => Promise<VerifyResult>;
/**
* Returns the `engines.node` field from the target tag's `package.json`
* without mutating the working tree. The implementation typically runs
* `git show <tag>:package.json` and parses the JSON. Returns `null` if
* the field is absent that's treated as "no constraint, pass".
*/
readTargetEnginesNode: (tag: string) => Promise<string | null>;
}
export type PreflightResult = {ok: true} | {ok: false; reason: PreflightReason};
export type PreflightResult = {ok: true} | {ok: false; reason: PreflightReason; detail?: string};
const WRITABLE_METHODS: ReadonlySet<Exclude<InstallMethod, 'auto'>> = new Set(['git']);
@ -35,6 +50,11 @@ const WRITABLE_METHODS: ReadonlySet<Exclude<InstallMethod, 'auto'>> = new Set(['
* Sequenced preflight: each check is fast and reads the world. Order matters
* cheap, definitive failures (install method) run before slow ones (network
* tag lookup, gpg). The first failure short-circuits.
*
* The Node-engine check runs *after* signature verification: we want the
* range to come from a trusted tag. It runs *before* anything mutates the
* working tree (the executor does the first `git checkout` after we return
* ok), so a failure leaves the system exactly as it was no rollback needed.
*/
export const runPreflight = async (
input: PreflightInput,
@ -50,5 +70,15 @@ export const runPreflight = async (
if (!await deps.remoteHasTag(input.targetTag)) return {ok: false, reason: 'remote-tag-missing'};
const sig = await deps.verifyTag();
if (!sig.ok) return {ok: false, reason: 'signature-verification-failed'};
const range = await deps.readTargetEnginesNode(input.targetTag);
if (range && !semver.satisfies(input.currentNodeVersion, range, {includePrerelease: true})) {
return {
ok: false,
reason: 'node-engine-mismatch',
detail: `target requires Node ${range}, running ${input.currentNodeVersion}`,
};
}
return {ok: true};
};

View file

@ -78,24 +78,16 @@ 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 was added in Tier 3. Treat as optional for backwards
// compatibility with state files written by Tier 1/2 installs; loadState
// backfills the missing field to null. If present, must be string|null.
// graceStartTag (Tier 3) and lastFailureKey (Tier 4) are both optional for
// backwards compatibility with state files written by earlier installs;
// loadState backfills missing fields to null. If present, must be string|null.
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;
&& graceOk
&& failOk;
};
// Validate the full shape so loadState() actually delivers on its "safely
@ -112,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;
@ -145,7 +136,11 @@ export const loadState = async (filePath: string): Promise<UpdateState> => {
return {
...structuredClone(EMPTY_STATE),
...partial,
email: {...email, graceStartTag: email.graceStartTag ?? null},
email: {
...email,
graceStartTag: email.graceStartTag ?? null,
lastFailureKey: email.lastFailureKey ?? null,
},
execution: partial.execution ?? structuredClone(EMPTY_STATE.execution),
bootCount: partial.bootCount ?? 0,
lastResult: partial.lastResult ?? null,

View file

@ -2,8 +2,16 @@ export type InstallMethod = 'auto' | 'git' | 'docker' | 'npm' | 'managed';
export type Tier = 'off' | 'notify' | 'manual' | 'auto' | 'autonomous';
/** 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';
/**
* Tier 4 (autonomous) maintenance window. `start`/`end` are HH:MM (24h) in the
* configured `tz`. `end` is exclusive; `end < start` denotes a cross-midnight
* window. See `MaintenanceWindow.ts` for the parser/predicate implementation.
*/
export interface MaintenanceWindow {
start: string;
end: string;
tz: 'local' | 'utc';
}
export interface ReleaseInfo {
/** semver string without leading 'v', e.g. "2.7.2". */
@ -20,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;
@ -39,12 +40,15 @@ 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;
/**
* Dedupe key for `update-rolled-back` / `update-preflight-failed` emails.
* Stores the `<tag>:<outcome>` of the last failure we emailed about so a
* retry-loop (e.g. repeated `pnpm install` failures on the same release)
* doesn't fire one email per attempt. Cleared when the next outcome differs.
*/
lastFailureKey: string | null;
}
/**
@ -96,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. */
@ -117,12 +119,10 @@ export const EMPTY_STATE: UpdateState = {
lastCheckAt: null,
lastEtag: null,
latest: null,
vulnerableBelow: [],
email: {
severeAt: null,
vulnerableAt: null,
vulnerableNewReleaseTag: null,
graceStartTag: null,
lastFailureKey: null,
},
execution: {status: 'idle'},
bootCount: 0,

View file

@ -1,5 +1,3 @@
import type {VulnerableBelowDirective} from './types.js';
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

@ -469,7 +469,10 @@ const getHTMLFromAtext = async (pad:PadType, atext: AText, authorColors?: string
// preserve counters so numbering can continue after interruptions.
// Use 0 as sentinel (not delete) so the ol-opening logic knows this
// level was explicitly reset and won't fall back to line.start.
if (diff + 1 > actualNextLevel) {
// Only reset when closing an ordered list — closing an unordered list
// at the same level must not poison the ol counter for a future
// unrelated ol at this level (which would still want line.start).
if (line.listTypeName === 'number' && diff + 1 > actualNextLevel) {
olItemCounts[diff + 1] = 0;
}
@ -528,6 +531,7 @@ export const getPadHTMLDocument = async (padId: string, revNum: string|number|un
body: html,
padId: Security.escapeHTML(readOnlyId || padId),
extraCSS: stylesForExportCSS,
proxyPath: '',
});
};

View file

@ -18,7 +18,10 @@ import {APool} from "../types/PadType.js";
* limitations under the License.
*/
import AttributeMap from '../../static/js/AttributeMap.js';
import AttributePool from '../../static/js/AttributePool.js';
import {applyToAText, cloneAText, deserializeOps, makeAText, pack, unpack} from '../../static/js/Changeset.js';
import {SmartOpAssembler} from '../../static/js/SmartOpAssembler.js';
import { Pad } from '../db/Pad.js';
import Stream from './Stream.js';
import * as authorManager from '../db/AuthorManager.js';
@ -30,9 +33,184 @@ import {Database} from 'ueberdb2';
const logger = log4js.getLogger('ImportEtherpad');
// Mirror of `Pad.SYSTEM_AUTHOR_ID`. Inlined to avoid a circular import
// (ImportEtherpad -> Pad -> ImportEtherpad via padManager) at module
// init time.
const SYSTEM_AUTHOR_ID = 'a.etherpad-system';
// A `+` op is "pure newline" (and therefore exempt from the author
// requirement) iff every character in the op is a newline. The wire-
// boundary guard in Pad._assertInsertOpsCarryAuthor whitelists the
// same shape; mirror it here so the sanitiser doesn't touch ops the
// downstream guard would have accepted anyway.
const isPureNewlineInsert = (op: {lines: number, chars: number}) =>
op.lines > 0 && op.chars === op.lines;
// Walk a serialized ops string (changeset ops *or* an atext.attribs
// stream — both use the same encoding), inject the `author` attribute
// on any `+` content op that lacks one, and return the rebuilt ops
// string plus the number of ops that were rewritten.
//
// `pool` is the AttributePool that the ops reference, and is mutated
// in-place to register the system author when needed. The caller is
// responsible for persisting the (possibly mutated) pool back to the
// record alongside the rewritten ops string.
const sanitiseOpsString = (
opsStr: string, pool: AttributePool): {ops: string, rewrites: number} => {
const assem = new SmartOpAssembler();
let rewrites = 0;
let touched = false;
for (const op of deserializeOps(opsStr)) {
if (op.opcode === '+' && !isPureNewlineInsert(op)) {
const map = AttributeMap.fromString(op.attribs, pool);
if (!map.get('author')) {
map.set('author', SYSTEM_AUTHOR_ID);
op.attribs = map.toString();
rewrites++;
touched = true;
}
}
assem.append(op);
}
assem.endDocument();
// Even when nothing was rewritten, re-serializing through the
// assembler is safe (it produces canonical form). But to keep the
// diff minimal on clean inputs, return the original string when
// nothing actually changed.
if (!touched) return {ops: opsStr, rewrites: 0};
return {ops: assem.toString(), rewrites};
};
// Sanitise an entire changeset: unpack -> rewrite ops -> repack.
// oldLen / newLen / charBank are preserved as-is because adding
// author markers doesn't change op.chars or the character stream.
const sanitiseChangeset = (
cs: string, pool: AttributePool): {cs: string, rewrites: number} => {
let unpacked;
try {
unpacked = unpack(cs);
} catch {
// Not a parseable changeset — leave it alone and let the
// downstream consumer surface the original error.
return {cs, rewrites: 0};
}
const {ops, rewrites} = sanitiseOpsString(unpacked.ops, pool);
if (rewrites === 0) return {cs, rewrites: 0};
return {cs: pack(unpacked.oldLen, unpacked.newLen, ops, unpacked.charBank), rewrites};
};
// Top-level pre-pass: walks the imported `records` dict, sanitises any
// `+` content op (across all revisions) that lacks an `author`
// attribute, and re-derives the cumulative head atext and any
// key-revision meta.atext / meta.pool snapshots so they stay
// consistent with the rewritten revs. Without re-derivation, the
// `Pad.check()` deep-equal that runs at the end of `setPadRaw` would
// see a sanitised head atext (or sanitised key-rev snapshot) whose
// attribute numbers don't agree with the sanitised running atext
// computed from the (separately-sanitised) revs.
//
// Returns the number of ops rewritten across the whole pad (0 means
// the import was already conforming and nothing was touched).
//
// Mutates `records` in place. The caller passes the original-padId-
// keyed records dict (i.e. the post-JSON.parse state, BEFORE the
// destination padId rewrite happens in processRecord).
const sanitiseImportedRecords = (
records: Record<string, any>, srcPadId: string): number => {
const padKey = `pad:${srcPadId}`;
const padRec = records[padKey];
if (!padRec || !padRec.pool) return 0;
// Collect rev records in numeric order. We process them
// sequentially so we can re-apply each (post-sanitisation)
// changeset to a running atext and refresh key-rev snapshots
// along the way.
const escPadId = srcPadId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const revKeyRe = new RegExp(`^pad:${escPadId}:revs:(\\d+)$`);
const revs: Array<{n: number, rec: any}> = [];
for (const [k, v] of Object.entries(records)) {
const m = k.match(revKeyRe);
if (m && v) revs.push({n: Number(m[1]), rec: v});
}
revs.sort((a, b) => a.n - b.n);
if (revs.length === 0) return 0;
// Start the running atext at the canonical empty pad and the
// cumulative pool at whatever the imported padRec.pool was — the
// latter already contains every attribute that the rev changesets
// reference, so deserialising rev ops against it always resolves.
// The pool grows in place when sanitiseOpsString needs to register
// SYSTEM_AUTHOR_ID; that's exactly what we want the final
// padRec.pool to look like.
const cumulativePool = new AttributePool().fromJsonable(padRec.pool);
let runningAText = makeAText('\n');
let totalRewrites = 0;
for (const {rec} of revs) {
if (typeof rec.changeset !== 'string') continue;
const {cs, rewrites} = sanitiseChangeset(rec.changeset, cumulativePool);
if (rewrites > 0) rec.changeset = cs;
totalRewrites += rewrites;
// Walk the (possibly rewritten) changeset against the running
// atext to keep it in lock-step. applyToAText also serves as
// an in-pass sanity check — if a sanitised changeset doesn't
// apply cleanly the import dies here instead of silently
// corrupting state.
runningAText = applyToAText(rec.changeset, runningAText, cumulativePool);
// If the imported rev carried a key-rev snapshot (meta.atext /
// meta.pool), replace it with the post-sanitisation running
// state. We *always* refresh when totalRewrites > 0 for this
// pad — and we always refresh the snapshot of *this* rev when
// the snapshot was present in the import (cheaper than figuring
// out exactly which key-revs were affected by the rewrite).
if (rec.meta && (rec.meta.pool || rec.meta.atext)) {
rec.meta.pool = cumulativePool.toJsonable();
rec.meta.atext = cloneAText(runningAText);
}
}
// Refresh the head atext and pad pool. Same rationale as the
// key-rev refresh above.
if (totalRewrites > 0) {
padRec.atext = cloneAText(runningAText);
padRec.pool = cumulativePool.toJsonable();
}
return totalRewrites;
};
export const setPadRaw = async (padId: string, r: string, authorId = '') => {
const records = JSON.parse(r);
// Sanitiser pre-pass: legacy .etherpad files (and exports from older
// server-internal flows that didn't substitute SYSTEM_AUTHOR_ID)
// can contain `+` content ops without an `author` attribute. The
// wire boundary and Pad.appendRevision now reject that shape, so a
// post-import setText/setHTML/restoreRevision against an imported
// pad would throw. Rewrite the imported records up-front to inject
// the system author marker on any unattributed insert, mutating the
// pad pool (and any per-key-rev snapshot pool) to register the
// attribute. Discover the source pad id by scanning record keys:
// pre-rewrite they still use the original padId.
let srcPadId: string | null = null;
for (const k of Object.keys(records)) {
const parts = k.split(':');
if (parts[0] === 'pad' && parts.length >= 2) {
srcPadId = parts[1];
break;
}
}
if (srcPadId != null) {
const rewritten = sanitiseImportedRecords(records, srcPadId);
if (rewritten > 0) {
logger.warn(
`(pad ${padId}) import contained ${rewritten} unattributed insert ` +
`op(s); rewriting them with the system author to satisfy the ` +
`appendRevision invariant. Source pad id: ${srcPadId}.`);
}
}
// get supported block Elements from plugins, we will use this later.
hooks.callAll('ccRegisterBlockElements').forEach((element:any) => {
supportedElems.add(element);

View file

@ -16,12 +16,17 @@
*/
import log4js from 'log4js';
import AttributeMap from '../../static/js/AttributeMap.js';
import {deserializeOps} from '../../static/js/Changeset.js';
import * as contentcollector from '../../static/js/contentcollector.js';
import jsdom from 'jsdom';
import {PadType} from "../types/PadType.js";
import {Builder} from "../../static/js/Builder.js";
// Mirror of `Pad.SYSTEM_AUTHOR_ID`. Imported as a literal to avoid a
// circular require between Pad and ImportHtml during module init.
const SYSTEM_AUTHOR_ID = 'a.etherpad-system';
const apiLogger = log4js.getLogger('ImportHtml');
let processor:any;
@ -72,6 +77,15 @@ export const setPadHTML = async (pad: PadType, html:string|null|undefined, autho
// create a new changeset with a helper builder object
const builder = new Builder(1);
// Every insert op needs an `author` attribute (the appendRevision
// precondition). The contentcollector tags ops with style
// attributes (bold, italic, etc.) but doesn't add an author; for
// server-side imports the author is implicit in the caller, so
// substitute the system author when no explicit one was supplied —
// same pattern setText/spliceText already use.
const effectiveAuthorId =
(newText.length > 0 && !authorId) ? SYSTEM_AUTHOR_ID : authorId;
// assemble each line into the builder
let textIndex = 0;
const newTextStart = 0;
@ -81,7 +95,16 @@ export const setPadHTML = async (pad: PadType, html:string|null|undefined, autho
if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) {
const start = Math.max(newTextStart, textIndex);
const end = Math.min(newTextEnd, nextIndex);
builder.insert(newText.substring(start, end), op.attribs);
// Merge via AttributeMap so the result is in canonical order
// (sorted by pool index) — a raw `*N` prefix could violate
// checkRep's canonical-form assertion.
let mergedAttribs = op.attribs;
if (effectiveAuthorId) {
mergedAttribs = AttributeMap.fromString(op.attribs, pad.pool)
.set('author', effectiveAuthorId)
.toString();
}
builder.insert(newText.substring(start, end), mergedAttribs);
}
textIndex = nextIndex;
}
@ -90,6 +113,10 @@ export const setPadHTML = async (pad: PadType, html:string|null|undefined, autho
const theChangeset = builder.toString();
apiLogger.debug(`The changeset: ${theChangeset}`);
await pad.setText('\n', authorId);
await pad.appendRevision(theChangeset, authorId);
// Pass effectiveAuthorId here too so meta.author on the stored
// revision matches the author attribute we merged into the op
// attribs above — and so the padCreate / padUpdate hooks and
// authorManager.addPad link the same author identity.
await pad.setText('\n', effectiveAuthorId);
await pad.appendRevision(theChangeset, effectiveAuthorId);
};

View file

@ -348,11 +348,30 @@ export type SettingsType = {
requireSignature: boolean,
/** Override the OS keyring location (passed to git verify-tag via $GNUPGHOME). */
trustedKeysPath: string | null,
/**
* Tier 4: nightly window during which the scheduler is allowed to fire.
* Null = tier 4 disabled (canAutonomous is denied with reason
* `maintenance-window-missing`). Shape validated at boot by `parseWindow`.
*/
maintenanceWindow: {start: string; end: string; tz: 'local' | 'utc'} | null,
},
adminOpenAPI: {
enabled: boolean,
},
adminEmail: string | null,
/**
* SMTP transport for outbound admin notifications (updater + future
* features). Null `host` disables outbound mail the Notifier still runs
* and dedupe state is updated, but messages only log `(would send email)`.
* `auth` is optional; omit for unauthenticated relays.
*/
mail: {
host: string | null;
port: number;
secure: boolean;
from: string | null;
auth: {user: string; pass: string} | null;
},
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings" | "enablePluginPadOptions" | "privacyBanner">,
}
@ -550,6 +569,9 @@ const settings: SettingsType = {
diskSpaceMinMB: 500,
requireSignature: false,
trustedKeysPath: null,
// Tier 4: night-window during which the scheduler may fire. Null disables tier 4 only.
// Example: { start: "03:00", end: "05:00", tz: "local" } or tz: "utc".
maintenanceWindow: null,
},
/**
* Admin OpenAPI document endpoint at /admin/openapi.json.
@ -568,6 +590,19 @@ const settings: SettingsType = {
* Null disables outbound mail from the updater.
*/
adminEmail: null,
/**
* SMTP transport for outbound admin notifications. Null `host` keeps the
* legacy log-only behaviour. Set `host`+`from` (and optionally `auth`) to
* deliver via nodemailer. The dependency is lazy-loaded installs without
* a mail.host pay no runtime cost.
*/
mail: {
host: null,
port: 587,
secure: false,
from: null,
auth: null,
},
/**
* Whether certain shortcut keys are enabled for a user in the pad
*/
@ -689,9 +724,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

@ -0,0 +1,65 @@
import settings from './Settings';
/**
* Sanitize the URL-path prefix Etherpad is being served under.
*
* 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.
*
* 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.
* - 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\-_./]`.
*/
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 '';
cleaned = cleaned.replace(/^\/{2,}/, '/');
if (cleaned[0] !== '/') cleaned = '/' + cleaned;
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

@ -31,7 +31,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",
@ -49,7 +49,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",
@ -58,18 +58,19 @@
"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",
"mongodb": "^7.1.1",
"mssql": "^12.5.3",
"mssql": "^12.5.4",
"mysql2": "^3.22.3",
"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 +81,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",
@ -113,7 +114,8 @@
"@types/jsonminify": "^0.4.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^3.0.1",
"@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",
"@types/semver": "^7.7.1",
@ -133,7 +135,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",
@ -160,6 +162,6 @@
"debug:socketio": "cross-env DEBUG=socket.io* node --import tsx node/server.ts",
"test:watch": "cross-env NODE_ENV=production vitest"
},
"version": "3.0.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

@ -59,8 +59,7 @@ import socketio from './socketio.js';
import hooks from './pluginfw/hooks.js';
import {showPrivacyBannerIfEnabled} from './privacy_banner.js';
import './pad_version_badge.js';
import {maybeShowOutdatedNotice} from './pad_outdated_notice.js';
// This array represents all GET-parameters which can be used to change a setting.
// name: the parameter-name, eg `?noColors=true` => `noColors`
@ -751,6 +750,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,64 @@
'use strict';
// Regression check for the `pnpm test` glob. The previous spec script
// `tests/backend/specs/**.ts` only matched files at depth 1 under
// tests/backend/specs/, silently skipping every spec under api/ and
// admin/ — including the failures filed in #7785#7788, #7790. This
// test asserts that mocha (running the exact arguments from
// src/package.json's "test" script) still discovers a representative
// file in each of those subdirectories.
//
// If the glob is ever narrowed again, this test fails loudly instead
// of letting the affected specs slip out of CI.
import {execFileSync} from 'child_process';
import {readFileSync} from 'fs';
import {isAbsolute, join, relative} from 'path';
import {describe, it, expect} from 'vitest';
const srcRoot = join(__dirname, '..', '..', '..');
const pkg = JSON.parse(readFileSync(join(srcRoot, 'package.json'), 'utf8'));
// Strip `cross-env NAME=value` prefixes and the leading binary name so we
// invoke mocha directly with the rest of the script's arguments.
const tokens = String(pkg.scripts.test).split(/\s+/);
while (tokens[0] && /^[A-Z_][A-Z0-9_]*=/.test(tokens[0])) tokens.shift();
if (tokens[0] === 'cross-env') {
tokens.shift();
while (tokens[0] && /^[A-Z_][A-Z0-9_]*=/.test(tokens[0])) tokens.shift();
}
if (tokens[0] === 'mocha') tokens.shift();
const REQUIRED = [
'tests/backend/specs/api/pad.ts',
'tests/backend/specs/api/importexportGetPost.ts',
'tests/backend/specs/admin/authorSearch.ts',
];
describe('backend test glob', () => {
it('discovers nested specs under tests/backend/specs/{api,admin}/', () => {
// Resolve mocha's JS entry directly and run it under the current node.
// Going through `npx` (or even via the package.json bin shim) breaks on
// Windows runners where the resolver doesn't auto-pick `.cmd`/`.bat`.
const mochaBin = require.resolve('mocha/bin/mocha.js');
const out = execFileSync(
process.execPath, [mochaBin, '--dry-run', '--list-files', ...tokens],
{cwd: srcRoot, encoding: 'utf8', env: {...process.env, NODE_ENV: 'production'}},
);
// mocha --list-files prints absolute paths with platform separators.
// Normalise to repo-relative POSIX paths so the assertions match on
// both Linux and Windows runners. path.relative handles drive-letter
// casing and mixed separators consistently; absolute lines that fall
// outside srcRoot (shouldn't happen with --recursive on srcRoot, but
// be defensive) are passed through untouched and would fail the
// toContain() check loudly rather than silently.
const seen = out.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean)
.map((l) => (isAbsolute(l) ? relative(srcRoot, l) : l))
.map((l) => l.split(/[\\/]/).join('/'));
for (const required of REQUIRED) {
expect(seen, `mocha test glob missed ${required}`).toContain(required);
}
}, 60000);
});

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,341 @@
/**
* 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';
import {getEpVersion} from '../../../../../node/utils/Settings';
import {parseSemver} from '../../../../../node/updater/versionCompare';
// ---------------------------------------------------------------------------
// 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,
});
// Build fixtures relative to the actual current version so the test stays
// stable across release bumps. Hard-coded versions break every time the
// codebase rolls forward past the fixture (e.g. once 3.2.0 shipped, a
// MINOR_AHEAD pinned at "3.2.0" no longer counted as minor-behind).
const CUR = parseSemver(getEpVersion())!;
const v = (major: number, minor: number, patch: number) => `${major}.${minor}.${patch}`;
/** A fake ReleaseInfo for a version that is one minor ahead of current. */
const MINOR_AHEAD: UpdateState['latest'] = {
version: v(CUR.major, CUR.minor + 1, 0),
tag: `v${v(CUR.major, CUR.minor + 1, 0)}`,
body: '',
publishedAt: '2026-05-01T00:00:00Z',
prerelease: false,
htmlUrl: `https://github.com/ether/etherpad/releases/tag/v${v(CUR.major, CUR.minor + 1, 0)}`,
};
/** A fake ReleaseInfo that is only a patch ahead of current (no minor/major delta). */
const PATCH_AHEAD: UpdateState['latest'] = {
version: v(CUR.major, CUR.minor, CUR.patch + 1),
tag: `v${v(CUR.major, CUR.minor, CUR.patch + 1)}`,
body: '',
publishedAt: '2026-05-01T00:00:00Z',
prerelease: false,
htmlUrl: `https://github.com/ether/etherpad/releases/tag/v${v(CUR.major, CUR.minor, CUR.patch + 1)}`,
};
/** A fake ReleaseInfo that is behind current (current >= latest). */
const SAME_OR_BEHIND: UpdateState['latest'] = {
version: v(Math.max(0, CUR.major - 1), 0, 0),
tag: `v${v(Math.max(0, CUR.major - 1), 0, 0)}`,
body: '',
publishedAt: '2026-01-01T00:00:00Z',
prerelease: false,
htmlUrl: `https://github.com/ether/etherpad/releases/tag/v${v(Math.max(0, CUR.major - 1), 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');
});
});

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