mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-05-05 04:06:37 +02:00
feat(updater): tier 1 — notify admin and pad users of available updates (#7601)
* docs(updater): add four-tier auto-update design spec
Four-tier opt-in self-update subsystem (off / notify / manual / auto / autonomous).
GitHub Releases as source of truth; install-method auto-detection with admin
override; in-process execution with supervisor restart; 60s drain + announce;
auto-rollback on health-check failure with crash-loop guard. Pad-side severe/
vulnerable badge that does not leak the running version. Top-level adminEmail
with escalating cadence (weekly while vulnerable, monthly while severe).
Refs: docs/superpowers/specs/2026-04-25-auto-update-design.md
* docs(updater): add PR 1 (Tier 1 notify) implementation plan
Bite-sized TDD task breakdown for shipping Tier 1 notify only:
- VersionChecker, InstallMethodDetector, UpdatePolicy, Notifier, state modules
- /admin/update/status (admin-auth) and /api/version-status (public, no version leak)
- Admin UI banner + read-only update page + nav link
- Pad-side severe/vulnerable footer badge
- Settings: updates.* block + top-level adminEmail
- Tests: vitest unit + mocha integration + Playwright admin/pad
- CHANGELOG + doc/admin/updates.md
PRs 2-4 (manual/auto/autonomous) get their own plans after PR 1 lands.
* feat(updater): add shared types for auto-update subsystem
* feat(updater): clarify OutdatedLevel and EMPTY_STATE doc, drop path header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): add semver helpers and vulnerable-below parser
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): tighten semver regex to reject four-part versions
* feat(updater): add state persistence with schema validation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): reject null email and array latest in state validation
typeof null === 'object' meant {email:null} passed the old isValid check,
which would crash downstream Notifier code reading email.severeAt. Likewise,
an array would pass the typeof latest === 'object' branch. Introduce
isPlainObject helper (null-safe, Array.isArray guard) and use it for both
fields. Adds two regression tests covering the exact broken inputs.
* feat(updater): add install-method detector with override
* feat(updater): add policy evaluator
* feat(updater): add GitHub Releases checker with ETag support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): validate release fields and preserve ETag on prerelease
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): add email cadence decider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): tagChanged email fires regardless of cadence; drop unused field
* feat(settings): add updates.* and adminEmail settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): wire boot hook and periodic checker
Register expressCreateServer/shutdown hooks in ep.json and implement
the boot-wiring module that detects install method, starts the polling
interval and runs the notifier dedupe pass each tick.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* feat(updater): add /admin/update/status and /api/version-status endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* i18n(updater): add english strings for update banner, page, and pad badge
* feat(updater): add pad footer badge for severe/vulnerable status
* feat(admin-ui): add update banner, page, and nav link
Add UpdateStatusPayload to the zustand store, a persistent UpdateBanner
rendered in the App layout, a /update page showing version details and
changelog, and a Bell nav link — all wired to the /admin/update/status
endpoint added in Task 10.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(updater): add Playwright specs for admin banner/page and pad badge
* docs(updater): document tier 1 settings, badge, email cadence
* refactor(updater): dedupe helpers, fix misleading log, add banner styling
- Export stateFilePath from index.ts and import it in updateStatus.ts (removes local duplicate)
- Import getEpVersion from Settings.ts in both index.ts and updateStatus.ts (removes two local definitions)
- Fix misleading 'backing off' log message — no backoff is implemented, just retries at next interval
- Remove EMPTY_STATE_FOR_TESTS re-export from state.ts; state.test.ts now imports EMPTY_STATE directly from types.ts
- Add .update-banner and .update-page CSS rules to admin/src/index.css
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(updater): address review feedback — async wrap, tier=off skip, poll race, opt-in admin gate
- Wrap /api/version-status and /admin/update/status with a small async helper
so a rejected promise becomes next(err) instead of an unhandled rejection.
- Short-circuit route registration when updates.tier === 'off' so the heavier
opt-out also removes the HTTP surface (matches pre-PR behavior for that case).
- Add an in-flight guard around performCheck() so overlapping interval ticks
can't race on update-state.json writes or duplicate email decisions; track
the initial setTimeout handle and clear it in shutdown().
- Add updates.requireAdminForStatus (default false) so admins can lock
/admin/update/status to authenticated admin sessions without disabling the
updater. Default false preserves current behavior (the running version is
already exposed publicly via /health). Backend specs cover unauth → 401,
non-admin → 403, admin → 200.
- Bump admin troubleshooting menu count test 5 → 6 to account for the new
Update nav link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(updater): address Qodo round-2 review feedback
Round 2 of Qodo review on #7601. Addressing the action-required items:
#1 Badge bypassed pad baseURL — derive basePath the same way
padBootstrap.js does (`new URL('..', window.location.href).pathname`)
and prefix the fetch with it. Subpath deployments now reach
/<prefix>/api/version-status instead of 404ing.
#2 Updater poller could get stuck — `getCurrentState()` is now inside
the try/finally so a one-time loadState() rejection can't leave
`checkInFlight=true` and permanently silence polling.
#3 Updates off hung admin page — UpdatePage now self-fetches and
renders explicit `disabled` (404), `unauthorized` (401/403), and
`error` states instead of staying on "Loading...". Banner-driven
prefetch is still honoured if it landed first.
#11 NaN polling interval — coerce `checkIntervalHours` to a number,
clamp to [1h, 168h], log a warning and fall back to 6h on
non-finite input. Math.max(1, NaN) === NaN previously meant a
malformed settings.json could turn the poller into a tight loop.
#13 State validation accepted broken subfields — `isValid()` now
inspects `latest.{version,tag,body,publishedAt,htmlUrl,prerelease}`,
`vulnerableBelow[].{announcedBy,threshold}`, and
`email.{severeAt,vulnerableAt,vulnerableNewReleaseTag}`. A
hand-edited file with a number where a string is expected is now
treated as corrupt and reset to EMPTY_STATE rather than crashing
later in semver parsing or email rendering.
#14 Badge cache stampede — wrap `computeOutdated()` in a single-flight
promise so concurrent requests at cache expiry await one shared
computation instead of fanning out into N redundant disk reads.
Plus six new state.test.ts cases covering each new validation guard.
Pushing back on the remaining items:
#4 `updates.tier` defaults to `notify` — intentional. The whole point
of tier 1 is to surface the "you are behind" signal to admins by
default. Opt-in defeats the purpose; the existing failure mode
(admin never hears about a security-relevant release) is exactly
what this PR is fixing.
#5/#8 Admin status endpoint admin-auth — `currentVersion` is already
public via `/health`, so wrapping the route in admin-auth doesn't
reduce the disclosure surface meaningfully. Operators who want it
gated set `updates.requireAdminForStatus=true` (already wired and
covered by the comment on the route handler).
#10 Plain `https://` URLs in planning doc — planning markdown is
viewed in editors and on GitHub where protocol-relative URLs would
either render literally or break entirely. Keeping `https://`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
85f9a5f2f5
commit
e39dbde887
10
CHANGELOG.md
10
CHANGELOG.md
@ -4,6 +4,16 @@
|
||||
|
||||
- **Minimum required Node.js version is now 22.** Node.js 20 is reaching end-of-life (see https://nodejs.org/en/about/previous-releases). The CI matrix now targets Node 22, 24, and 25. Upgrading should be straightforward — install a current Node.js release before updating Etherpad.
|
||||
|
||||
### Notable enhancements
|
||||
|
||||
- New built-in self-update subsystem (Tier 1: notify).
|
||||
- Periodic check against the GitHub Releases API for the configured repo (default `ether/etherpad`). Configurable via the new `updates.*` settings block, default tier `"notify"`. Set `updates.tier` to `"off"` to disable entirely.
|
||||
- The admin UI shows a banner and a dedicated "Etherpad updates" page with the current version, latest version, install method, and changelog.
|
||||
- Pad users see a discreet footer badge **only** when the running version is severely outdated (one or more major versions behind) or flagged as vulnerable in a recent release manifest. The public endpoint that drives this never leaks the version string itself.
|
||||
- New top-level `adminEmail` setting. When set, the updater emails the admin on first detection of severe / vulnerable status, with escalating cadence (weekly while vulnerable, monthly while severely outdated). PR 1 ships the dedupe + cadence logic; real SMTP wiring lands in a follow-up PR.
|
||||
- Tier 1 ships in this release. Tiers 2 (manual click), 3 (auto with grace window) and 4 (autonomous in maintenance window) are designed and will land in subsequent releases.
|
||||
- See `doc/admin/updates.md` for full configuration.
|
||||
|
||||
# 2.7.2
|
||||
|
||||
### Notable enhancements and fixes
|
||||
|
||||
@ -6,7 +6,8 @@ import {NavLink, Outlet, useNavigate} from "react-router-dom";
|
||||
import {useStore} from "./store/store.ts";
|
||||
import {LoadingScreen} from "./utils/LoadingScreen.tsx";
|
||||
import {Trans, useTranslation} from "react-i18next";
|
||||
import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu} from "lucide-react";
|
||||
import {Cable, Construction, Crown, NotepadText, Wrench, PhoneCall, LucideMenu, Bell} from "lucide-react";
|
||||
import {UpdateBanner} from "./components/UpdateBanner";
|
||||
|
||||
const WS_URL = import.meta.env.DEV ? 'http://localhost:9001' : ''
|
||||
export const App = () => {
|
||||
@ -105,6 +106,7 @@ export const App = () => {
|
||||
<li><NavLink to={"/pads"}><NotepadText/><Trans
|
||||
i18nKey="ep_admin_pads:ep_adminpads2_manage-pads"/></NavLink></li>
|
||||
<li><NavLink to={"/shout"}><PhoneCall/>Communication</NavLink></li>
|
||||
<li><NavLink to={"/update"}><Bell/><Trans i18nKey="update.page.title"/></NavLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -112,6 +114,7 @@ export const App = () => {
|
||||
setSidebarOpen(!sidebarOpen)
|
||||
}}><LucideMenu/></button>
|
||||
<div className="innerwrapper">
|
||||
<UpdateBanner/>
|
||||
<Outlet/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
35
admin/src/components/UpdateBanner.tsx
Normal file
35
admin/src/components/UpdateBanner.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import {useEffect} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Trans, useTranslation} from 'react-i18next';
|
||||
import {useStore} from '../store/store';
|
||||
|
||||
export const UpdateBanner = () => {
|
||||
const {t} = useTranslation();
|
||||
const updateStatus = useStore((s) => s.updateStatus);
|
||||
const setUpdateStatus = useStore((s) => s.setUpdateStatus);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/admin/update/status', {credentials: 'same-origin'})
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
.then((data) => { if (data && !cancelled) setUpdateStatus(data); })
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, [setUpdateStatus]);
|
||||
|
||||
if (!updateStatus || !updateStatus.latest) return null;
|
||||
if (updateStatus.currentVersion === updateStatus.latest.version) return null;
|
||||
|
||||
return (
|
||||
<div className="update-banner" role="status">
|
||||
<strong><Trans i18nKey="update.banner.title"/></strong>{' '}
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey="update.banner.body"
|
||||
values={{latest: updateStatus.latest.version, current: updateStatus.currentVersion}}
|
||||
/>
|
||||
</span>{' '}
|
||||
<Link to="/update">{t('update.banner.cta')}</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -895,3 +895,30 @@ input, button, select, optgroup, textarea {
|
||||
.manage-pads-header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Update banner — shown on every admin page when a new version is available. */
|
||||
.update-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
margin: 0 0 12px 0;
|
||||
background: #fff3cd;
|
||||
color: #664d03;
|
||||
border: 1px solid #ffe69c;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.update-banner a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Update page layout. */
|
||||
.update-page { padding: 16px 0; }
|
||||
.update-page h1 { margin-bottom: 16px; }
|
||||
.update-page dl { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; margin: 0 0 24px; }
|
||||
.update-page dt { font-weight: 600; color: #555; }
|
||||
.update-page dd { margin: 0; }
|
||||
.update-page pre { background: #f6f8fa; border: 1px solid #d0d7de; border-radius: 4px; padding: 12px; font-size: 13px; max-height: 400px; overflow: auto; }
|
||||
|
||||
@ -13,6 +13,7 @@ import i18n from "./localization/i18n.ts";
|
||||
import {PadPage} from "./pages/PadPage.tsx";
|
||||
import {ToastDialog} from "./utils/Toast.tsx";
|
||||
import {ShoutPage} from "./pages/ShoutPage.tsx";
|
||||
import {UpdatePage} from "./pages/UpdatePage.tsx";
|
||||
|
||||
const router = createBrowserRouter(createRoutesFromElements(
|
||||
<><Route element={<App/>}>
|
||||
@ -22,6 +23,7 @@ const router = createBrowserRouter(createRoutesFromElements(
|
||||
<Route path="/help" element={<HelpPage/>}/>
|
||||
<Route path="/pads" element={<PadPage/>}/>
|
||||
<Route path="/shout" element={<ShoutPage/>}/>
|
||||
<Route path="/update" element={<UpdatePage/>}/>
|
||||
</Route><Route path="/login">
|
||||
<Route index element={<LoginScreen/>}/>
|
||||
</Route></>
|
||||
|
||||
103
admin/src/pages/UpdatePage.tsx
Normal file
103
admin/src/pages/UpdatePage.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Trans, useTranslation} from 'react-i18next';
|
||||
import {useStore} from '../store/store';
|
||||
|
||||
type FetchState =
|
||||
| {kind: 'loading'}
|
||||
| {kind: 'disabled'}
|
||||
| {kind: 'unauthorized'}
|
||||
| {kind: 'error', status: number}
|
||||
| {kind: 'ok'};
|
||||
|
||||
export const UpdatePage = () => {
|
||||
const {t} = useTranslation();
|
||||
const us = useStore((s) => s.updateStatus);
|
||||
const setUpdateStatus = useStore((s) => s.setUpdateStatus);
|
||||
// Self-fetch so the page renders an explicit state even if UpdateBanner's
|
||||
// best-effort fetch never landed (route returns 404 when tier=off, 401/403
|
||||
// if requireAdminForStatus is set, or a transient network error).
|
||||
const [fetchState, setFetchState] = useState<FetchState>(us ? {kind: 'ok'} : {kind: 'loading'});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/admin/update/status', {credentials: 'same-origin'})
|
||||
.then(async (r) => {
|
||||
if (cancelled) return;
|
||||
if (r.ok) {
|
||||
const data = await r.json();
|
||||
setUpdateStatus(data);
|
||||
setFetchState({kind: 'ok'});
|
||||
} else if (r.status === 404) {
|
||||
setFetchState({kind: 'disabled'});
|
||||
} else if (r.status === 401 || r.status === 403) {
|
||||
setFetchState({kind: 'unauthorized'});
|
||||
} else {
|
||||
setFetchState({kind: 'error', status: r.status});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setFetchState({kind: 'error', status: 0});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [setUpdateStatus]);
|
||||
|
||||
if (fetchState.kind === 'loading') {
|
||||
return <div>{t('admin.loading', {defaultValue: 'Loading...'})}</div>;
|
||||
}
|
||||
if (fetchState.kind === 'disabled') {
|
||||
return (
|
||||
<div className="update-page">
|
||||
<h1><Trans i18nKey="update.page.title"/></h1>
|
||||
<p>{t('update.page.disabled', {defaultValue: 'Update checks are disabled (updates.tier = "off").'})}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (fetchState.kind === 'unauthorized') {
|
||||
return (
|
||||
<div className="update-page">
|
||||
<h1><Trans i18nKey="update.page.title"/></h1>
|
||||
<p>{t('update.page.unauthorized', {defaultValue: 'You are not authorised to view update status.'})}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (fetchState.kind === 'error' || !us) {
|
||||
const status = fetchState.kind === 'error' ? fetchState.status : 0;
|
||||
return (
|
||||
<div className="update-page">
|
||||
<h1><Trans i18nKey="update.page.title"/></h1>
|
||||
<p>{t('update.page.error', {defaultValue: 'Could not load update status (status {{status}}).', status})}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const upToDate = !us.latest || us.currentVersion === us.latest.version;
|
||||
|
||||
return (
|
||||
<div className="update-page">
|
||||
<h1><Trans i18nKey="update.page.title"/></h1>
|
||||
<dl>
|
||||
<dt><Trans i18nKey="update.page.current"/></dt>
|
||||
<dd>{us.currentVersion}</dd>
|
||||
<dt><Trans i18nKey="update.page.latest"/></dt>
|
||||
<dd>{us.latest ? us.latest.version : '—'}</dd>
|
||||
<dt><Trans i18nKey="update.page.last_check"/></dt>
|
||||
<dd>{us.lastCheckAt ?? '—'}</dd>
|
||||
<dt><Trans i18nKey="update.page.install_method"/></dt>
|
||||
<dd>{us.installMethod}</dd>
|
||||
<dt><Trans i18nKey="update.page.tier"/></dt>
|
||||
<dd>{us.tier}</dd>
|
||||
</dl>
|
||||
{upToDate ? (
|
||||
<p><Trans i18nKey="update.page.up_to_date"/></p>
|
||||
) : us.latest ? (
|
||||
<>
|
||||
<h2><Trans i18nKey="update.page.changelog"/></h2>
|
||||
<pre style={{whiteSpace: 'pre-wrap'}}>{us.latest.body}</pre>
|
||||
<p><a href={us.latest.htmlUrl} rel="noreferrer noopener" target="_blank">{us.latest.htmlUrl}</a></p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdatePage;
|
||||
@ -3,6 +3,23 @@ import {Socket} from "socket.io-client";
|
||||
import {PadSearchResult} from "../utils/PadSearch.ts";
|
||||
import {InstalledPlugin} from "../pages/Plugin.ts";
|
||||
|
||||
export interface UpdateStatusPayload {
|
||||
currentVersion: string;
|
||||
latest: null | {
|
||||
version: string;
|
||||
tag: string;
|
||||
body: string;
|
||||
publishedAt: string;
|
||||
prerelease: boolean;
|
||||
htmlUrl: string;
|
||||
};
|
||||
lastCheckAt: string | null;
|
||||
installMethod: string;
|
||||
tier: string;
|
||||
policy: null | {canNotify: boolean; canManual: boolean; canAuto: boolean; canAutonomous: boolean; reason: string};
|
||||
vulnerableBelow: Array<{announcedBy: string; threshold: string}>;
|
||||
}
|
||||
|
||||
type ToastState = {
|
||||
description?:string,
|
||||
title: string,
|
||||
@ -25,7 +42,9 @@ type StoreState = {
|
||||
pads: PadSearchResult|undefined,
|
||||
setPads: (pads: PadSearchResult)=>void,
|
||||
installedPlugins: InstalledPlugin[],
|
||||
setInstalledPlugins: (plugins: InstalledPlugin[])=>void
|
||||
setInstalledPlugins: (plugins: InstalledPlugin[])=>void,
|
||||
updateStatus: UpdateStatusPayload | null,
|
||||
setUpdateStatus: (s: UpdateStatusPayload) => void,
|
||||
}
|
||||
|
||||
|
||||
@ -48,5 +67,7 @@ export const useStore = create<StoreState>()((set) => ({
|
||||
pads: undefined,
|
||||
setPads: (pads)=>set({pads}),
|
||||
installedPlugins: [],
|
||||
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins})
|
||||
setInstalledPlugins: (plugins)=>set({installedPlugins: plugins}),
|
||||
updateStatus: null,
|
||||
setUpdateStatus: (s) => set({updateStatus: s}),
|
||||
}));
|
||||
|
||||
83
doc/admin/updates.md
Normal file
83
doc/admin/updates.md
Normal file
@ -0,0 +1,83 @@
|
||||
# Etherpad updates
|
||||
|
||||
Etherpad ships with a built-in update subsystem. **Tier 1 (notify)** is enabled by 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 automatic execution happens at this tier — admins are simply informed.
|
||||
|
||||
Tiers 2 (manual click), 3 (auto with grace window), and 4 (autonomous in maintenance window) are designed but not yet implemented. They will land in subsequent releases.
|
||||
|
||||
## Settings
|
||||
|
||||
In `settings.json`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"updates": {
|
||||
"tier": "notify",
|
||||
"source": "github",
|
||||
"channel": "stable",
|
||||
"installMethod": "auto",
|
||||
"checkIntervalHours": 6,
|
||||
"githubRepo": "ether/etherpad",
|
||||
"requireAdminForStatus": false
|
||||
},
|
||||
"adminEmail": null
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `updates.tier` | `"notify"` | One of `"off"`, `"notify"`, `"manual"`, `"auto"`, `"autonomous"`. Higher tiers are silently downgraded if the install method does not allow them. PR 1 only honors `"notify"` and `"off"`. |
|
||||
| `updates.source` | `"github"` | Reserved for future alternative sources. Only `"github"` is implemented. |
|
||||
| `updates.channel` | `"stable"` | Reserved. Stable releases only. |
|
||||
| `updates.installMethod` | `"auto"` | One of `"auto"`, `"git"`, `"docker"`, `"npm"`, `"managed"`. Auto-detects via filesystem heuristics. Set explicitly to override. |
|
||||
| `updates.checkIntervalHours` | `6` | How often to poll GitHub Releases. |
|
||||
| `updates.githubRepo` | `"ether/etherpad"` | Override for forks. |
|
||||
| `updates.requireAdminForStatus` | `false` | Lock the `/admin/update/status` endpoint to authenticated admin sessions. Default `false` matches existing Etherpad behavior — `/health` already exposes `releaseId` publicly, and changelog data comes from a public GitHub release. Set `true` to hide the full update payload from non-admins without disabling the updater (`tier: "off"` is the heavier opt-out that removes the endpoints entirely). |
|
||||
| `adminEmail` | `null` | Top-level. Contact for admin notifications. Setting it enables the email nudges below. |
|
||||
|
||||
## 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.
|
||||
|
||||
## 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 |
|
||||
| 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.
|
||||
|
||||
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 users see no version information by default. A small badge appears in the bottom-right corner only when:
|
||||
|
||||
- The instance is `severe` (one or more major versions behind), or
|
||||
- The instance is `vulnerable` (running below an announced threshold).
|
||||
|
||||
The public endpoint `/api/version-status` returns only `{outdated: null|"severe"|"vulnerable"}` — it never leaks the running version, so attackers do not gain a fingerprint vector.
|
||||
|
||||
## Disabling everything
|
||||
|
||||
Set `updates.tier` to `"off"`. No HTTP request will leave the instance and no banner or badge will render.
|
||||
|
||||
## Privacy
|
||||
|
||||
The version check sends no telemetry. Etherpad fetches the public GitHub Releases API (`api.github.com/repos/<repo>/releases/latest`) with `If-None-Match` to be cache-friendly. The only metadata GitHub sees is the same as any other GitHub API client — your IP and a `User-Agent: etherpad-self-update` header. No instance ID, no version, no identifiers travel upstream.
|
||||
|
||||
## How install method is detected
|
||||
|
||||
`updates.installMethod` defaults to `"auto"`, which uses these heuristics in order:
|
||||
|
||||
1. `/.dockerenv` exists → `"docker"`.
|
||||
2. `.git/` directory present and the install root is writable → `"git"`.
|
||||
3. `package-lock.json` present and writable → `"npm"`.
|
||||
4. Otherwise → `"managed"`.
|
||||
|
||||
Set the value explicitly if the heuristics get it wrong (e.g., a docker container that bind-mounts a writable git checkout).
|
||||
|
||||
In PR 1 (notify only) the install method does not change behavior — every install method gets the banner. From PR 2 onward the install method gates whether the manual-click and automatic tiers can run; only `"git"` is initially supported for write tiers.
|
||||
2335
docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md
Normal file
2335
docs/superpowers/plans/2026-04-25-auto-update-pr1-notify.md
Normal file
File diff suppressed because it is too large
Load Diff
350
docs/superpowers/specs/2026-04-25-auto-update-design.md
Normal file
350
docs/superpowers/specs/2026-04-25-auto-update-design.md
Normal file
@ -0,0 +1,350 @@
|
||||
# Etherpad Auto-Update — Design Spec
|
||||
|
||||
**Date:** 2026-04-25
|
||||
**Author:** John McLear (johnmclear)
|
||||
**Status:** Approved for planning
|
||||
**Related:** none yet
|
||||
|
||||
## Problem
|
||||
|
||||
Etherpad has no built-in mechanism to tell an admin a new version exists, no in-product update flow, and no automatic patching. The result: many public Etherpad instances run unpatched versions for months or years, and CVEs land on installs whose admins are not even aware an update shipped.
|
||||
|
||||
## Goal
|
||||
|
||||
Add a four-tier self-update subsystem to Etherpad core. Each tier is opt-in via a single `updates.tier` setting. Higher tiers subsume lower ones.
|
||||
|
||||
| Tier | Setting | Behavior |
|
||||
|---|---|---|
|
||||
| 0 | `off` | No version checks, no banner, no badge. |
|
||||
| 1 | `notify` | Default. Periodic version check, admin banner, severe/vulnerable pad badge. No execution. |
|
||||
| 2 | `manual` | Tier 1 + admin can click "Apply now" to update from the UI. |
|
||||
| 3 | `auto` | Tier 2 + new releases are scheduled automatically after a configurable grace window during which the admin can cancel. |
|
||||
| 4 | `autonomous` | Tier 3 + scheduling is gated to an admin-defined maintenance window. |
|
||||
|
||||
Tiers above what the install method allows are silently downgraded with a logged warning. A docker install will refuse to enable tier 2+ even if the admin sets `tier: "autonomous"`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Updating plugins. The admin already has a plugin manager. The design preserves a `target: 'core' | 'plugins'` seam, but plugin updates are out of scope for this spec.
|
||||
- Updating Etherpad in environments where the filesystem is ephemeral or read-only (Docker, snap, apt/brew). Those installs stay on tier 1.
|
||||
- Telemetry of any kind. The GitHub poll uses no auth, no instance identifiers, no version reporting upstream.
|
||||
- DB schema or `settings.json` schema migration logic. Etherpad's existing on-boot migration runs after restart. If a migration fails, the new version fails its post-update health check and we roll back.
|
||||
|
||||
## Decisions
|
||||
|
||||
These were settled during brainstorming and are load-bearing for the rest of the spec.
|
||||
|
||||
- **Update source:** GitHub Releases API (`api.github.com/repos/ether/etherpad/releases/latest`). Configurable via `updates.githubRepo` for forks.
|
||||
- **Install-method detection:** auto-detect at boot with admin override. Heuristics: `/.dockerenv` → docker; `.git/HEAD` + writable tree → git; writable `node_modules` + lockfile → npm; else `managed`. Override via `updates.installMethod`.
|
||||
- **Execution model:** in-process. Etherpad spawns the update steps (git fetch, git checkout, pnpm install, build:ui) as child processes, then exits with code `75`. Etherpad must be run under a process supervisor (systemd, pm2, docker restart-policy, etc.) — that is best practice anyway.
|
||||
- **Tier 4 scope:** all releases (not just security/patch). Restricted only by maintenance window.
|
||||
- **Rollback:** on every update we snapshot the git SHA and copy `pnpm-lock.yaml` to `var/update-backup/`. After restart, a 60s health-check timer fires; on failure we restore SHA + lockfile, run `pnpm install`, and exit again. A boot-count guard catches crash loops.
|
||||
- **Active sessions:** 60-second drain. We broadcast a system message at T-60, T-30, T-10 to every connected pad, refuse new connections during the drain, then exit at T=0. Best-effort: we do not wait for client acks past T=0.
|
||||
- **Pad-user visibility:** pads see nothing about updates by default. A discreet badge appears only when the running version is `severe` (one or more major versions behind) or `vulnerable` (matched by a `vulnerable-below` directive in a recent release manifest). The badge endpoint never returns the running version string.
|
||||
|
||||
## Architecture
|
||||
|
||||
A new self-update subsystem lives at `src/node/updater/`. Each unit has one purpose, communicates through narrow interfaces, and is independently testable.
|
||||
|
||||
### Components
|
||||
|
||||
- **`VersionChecker`** — periodic poller. Hits `api.github.com/repos/ether/etherpad/releases/latest` with `If-None-Match` ETag. Default interval 6h. Caches latest release in memory and on disk at `var/update-state.json`. Parses `vulnerable-below <semver>` directives from the bodies of the most recent N releases to build a runtime `KNOWN_VULNERABLE` set. On 403/rate-limit responses, backs off exponentially. Exposes `getUpdateStatus()`.
|
||||
- **`InstallMethodDetector`** — runs once at boot. Caches result for the process lifetime.
|
||||
- **`UpdatePolicy`** — pure function over `(installMethod, tier, currentVersion, latestVersion, settings, now)` → `{canNotify, canManual, canAuto, canAutonomous, reason}`. Single source of truth for "what is allowed." No I/O. Easy to unit-test.
|
||||
- **`UpdateExecutor`** — performs the update for `git` installs. Records pre-state, runs the update steps as child processes, streams to `var/log/update.log`, exits 75. Held by `var/update.lock` (PID-based, stale locks reaped on boot).
|
||||
- **`RollbackHandler`** — runs on every boot. Reads `var/update-state.json`. If status is `pending-verification`, arms the health-check timer and increments `bootCount`. If `bootCount > 2`, forces rollback (crash-loop guard). On rollback failure, transitions to terminal `rollback-failed` state which disables auto/autonomous until an admin acknowledges.
|
||||
- **`SessionDrainer`** — coordinates the 60s drain. Hooks `PadMessageHandler` to broadcast at T-60/-30/-10, sets a "no new connections" flag in the express middleware, signals the executor at T=0.
|
||||
- **`Scheduler`** (PR 3+) — listens to `VersionChecker` events, evaluates `UpdatePolicy.canAuto/canAutonomous`, applies pre-apply grace and (tier 4) maintenance-window checks. Persists pending update info so a restart during the grace window doesn't drop the schedule.
|
||||
- **`MaintenanceWindow`** (PR 4) — pure function over `(now, window)`. Handles cross-midnight, DST.
|
||||
|
||||
### API surface
|
||||
|
||||
Three admin endpoints (auth + CSRF identical to existing `/admin/*`):
|
||||
|
||||
- `GET /admin/update/status` — current version, latest, policy result, last update result, in-flight state.
|
||||
- `POST /admin/update/apply` — manual trigger. Refuses if `UpdatePolicy.canManual` is false or if the lock is held. Permitted in `rollback-failed` (an admin clicking "Apply" *is* the intervention that state requires); the call implicitly acknowledges the prior failure.
|
||||
- `POST /admin/update/cancel` — works any time before `UpdateExecutor` starts the `git checkout`. Once filesystem changes have begun, returns 409 (we either complete or rollback).
|
||||
- `POST /admin/update/acknowledge` — clears terminal states (`rollback-failed`, `preflight-failed`, etc.) so future attempts are allowed.
|
||||
- `GET /admin/update/log` — streams the last 200 lines of `var/log/update.log` for the in-progress UI.
|
||||
|
||||
One public endpoint:
|
||||
|
||||
- `GET /api/version-status` — returns `{outdated: null | "severe" | "vulnerable"}`. No version string. Memory-cached, max one underlying state read per minute. Public so pad clients can fetch without auth.
|
||||
|
||||
### Admin UI
|
||||
|
||||
- `admin/src/components/UpdateBanner.tsx` — visible on every admin page when an update exists or last update terminated abnormally.
|
||||
- `admin/src/pages/UpdatePage.tsx` — full status, changelog (rendered from release body), Apply/Cancel/Acknowledge buttons, log stream view, maintenance-window picker (PR 4).
|
||||
- New i18n keys under `updater.*`.
|
||||
|
||||
### Pad UI
|
||||
|
||||
- A small footer badge component fetches `/api/version-status` once on pad load. Renders nothing on `null`, a discreet icon on `severe`, a more prominent indicator on `vulnerable`.
|
||||
|
||||
## Settings
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"updates": {
|
||||
"tier": "notify", // "off" | "notify" | "manual" | "auto" | "autonomous"
|
||||
"source": "github", // future: "manifest"
|
||||
"channel": "stable", // future: "beta" | "lts"
|
||||
"installMethod": "auto", // "auto" | "git" | "docker" | "npm" | "managed"
|
||||
"checkIntervalHours": 6,
|
||||
"maintenanceWindow": null, // {"start":"03:00","end":"05:00","tz":"local"}
|
||||
"preApplyGraceMinutes": 15,
|
||||
"drainSeconds": 60,
|
||||
"rollbackHealthCheckSeconds": 60,
|
||||
"diskSpaceMinMB": 500,
|
||||
"githubRepo": "ether/etherpad",
|
||||
"trustedKeysPath": null // override default trusted-key set for forks
|
||||
},
|
||||
"adminEmail": null // top-level. Contact address for admin notifications
|
||||
// (updates, future security advisories, etc.). Used by
|
||||
// the updater; reusable by other features later.
|
||||
}
|
||||
```
|
||||
|
||||
Shipped defaults:
|
||||
|
||||
- `settings.json.template`: `tier: "notify"`. Fresh installs get the banner with no manual config.
|
||||
- `settings.json.docker`: `tier: "notify"`, `installMethod: "docker"` (explicit, even though detector would catch it — clearer in policy logs).
|
||||
|
||||
The whole `updates` block is optional. Existing installs upgrading to the version that ships PR 1 will start showing the banner with no config change. This is called out in `CHANGELOG.md` and the release notes; admins who want the old behavior set `tier: "off"`.
|
||||
|
||||
### Email notifications (`adminEmail`)
|
||||
|
||||
`adminEmail` is a top-level setting (not under `updates`) so other features — security advisories, plugin alerts, future operational notifications — can reuse it. The updater is the first consumer.
|
||||
|
||||
If `adminEmail` is unset, the updater never sends mail; banners and logs still work. If set, the existing SMTP path Etherpad already uses for invite/notification plugins delivers the message.
|
||||
|
||||
Triggers and cadence (deduped state lives in `var/update-state.json` under `email.lastSentFor`):
|
||||
|
||||
| Trigger | First send | Repeat |
|
||||
|---|---|---|
|
||||
| New release detected while running a `vulnerable` version | immediate | weekly while still `vulnerable` |
|
||||
| Instance enters `severe` (>= 1 major behind) | immediate | monthly while still `severe` |
|
||||
| Tier 3 grace window starts | every grace start | n/a (one event per scheduled update) |
|
||||
| `rollback-failed` terminal state entered | immediate | n/a (one event per entry) |
|
||||
|
||||
Successful updates do not generate email — that is noise. The admin UI banner is sufficient for non-urgent state.
|
||||
|
||||
Cadence is per-status, not per-tick: if a `severe` instance also becomes `vulnerable`, the vulnerable cadence applies until vulnerability clears, then the severe cadence resumes.
|
||||
|
||||
## Data flow
|
||||
|
||||
### Boot sequence (every tier)
|
||||
|
||||
1. `InstallMethodDetector.detect()` — caches result.
|
||||
2. `RollbackHandler.checkPendingVerification()` — if previous boot was an update, arm the 60s health-check timer; on success mark `verified`; on timeout/failure trigger rollback and exit 75. Increment `bootCount`; if it exceeds 2, force rollback regardless of timer.
|
||||
3. `VersionChecker.start()` — immediate first check, then interval.
|
||||
|
||||
### Tier 1 — notify
|
||||
|
||||
`VersionChecker` updates `var/update-state.json`. `GET /admin/update/status` reads it. `GET /api/version-status` reads it. No execution path.
|
||||
|
||||
### Tier 2 — manual click
|
||||
|
||||
```
|
||||
admin click
|
||||
→ POST /admin/update/apply (admin auth + CSRF)
|
||||
→ UpdatePolicy.canManual() — abort if false
|
||||
→ SessionDrainer.start() (broadcast at T-60/-30/-10, refuse new connections)
|
||||
→ UpdateExecutor.run()
|
||||
├─ snapshot SHA + pnpm-lock.yaml to var/update-backup/
|
||||
├─ verify release tag signature
|
||||
├─ git fetch, git checkout <tag>
|
||||
├─ pnpm install --frozen-lockfile
|
||||
├─ pnpm run build:ui
|
||||
├─ write update-state.json: status=pending-verification, from=<sha>, to=<tag>, bootCount=0
|
||||
└─ exit 75
|
||||
→ supervisor restarts → boot sequence runs RollbackHandler
|
||||
```
|
||||
|
||||
### Tier 3 — auto (admin-opted-in)
|
||||
|
||||
Same pipeline as tier 2, but the trigger is `VersionChecker` detecting a new release while `UpdatePolicy.canAuto()` returns true. Before the drain starts, a `preApplyGraceMinutes` window opens during which the admin can cancel via `/admin/update/cancel`. Pending-update info is persisted so a restart during the grace window doesn't lose the schedule. Optional email notification at grace start.
|
||||
|
||||
### Tier 4 — autonomous
|
||||
|
||||
Same as tier 3, but `Scheduler` only schedules when `now()` is inside `maintenanceWindow`. If the window closes while an update is mid-grace, the update is deferred to the next window (drain does not start outside the window).
|
||||
|
||||
## Error handling
|
||||
|
||||
### Pre-flight checks
|
||||
|
||||
Run before `UpdateExecutor` modifies anything. Any failure aborts cleanly.
|
||||
|
||||
- `installMethod` allows execution.
|
||||
- Git working tree clean — admin patches are not silently clobbered.
|
||||
- Git remote `origin` reachable and target tag exists.
|
||||
- Target tag's signature verifies against trusted-key set.
|
||||
- Free disk space ≥ `diskSpaceMinMB`.
|
||||
- `pnpm` resolvable on `PATH`.
|
||||
- `var/update.lock` not held (or stale).
|
||||
- Tier 4 only: currently inside maintenance window.
|
||||
|
||||
On failure: write `update-state.json` with `status: "preflight-failed"`, log to `update.log`, surface in admin UI banner. No rollback needed because nothing changed.
|
||||
|
||||
### Failure modes during execution
|
||||
|
||||
| Stage | Failure | Behavior |
|
||||
|---|---|---|
|
||||
| `git fetch` | network | abort, no state change, status = `preflight-failed` |
|
||||
| `git checkout` | conflict / dirty tree | abort, status = `preflight-failed` |
|
||||
| `pnpm install` | resolver/network/disk | rollback: restore SHA + lockfile, retry `pnpm install`. Status = `rolled-back-install-failed` |
|
||||
| `pnpm run build:ui` | build error | same rollback. Status = `rolled-back-build-failed` |
|
||||
| `exit 75` | — | success path. Status = `pending-verification` |
|
||||
| Boot crash loop | new version crashes repeatedly | RollbackHandler sees `bootCount > 2`, forces rollback. Status = `rolled-back-crash-loop` |
|
||||
| Health check fails in 60s | new version starts but `/health` doesn't 200 | RollbackHandler timer fires, restores prior state. Status = `rolled-back-health-check` |
|
||||
| Rollback itself fails | restore-time `pnpm install` errors | terminal state. Status = `rollback-failed`. Big red banner, refuse further auto/autonomous attempts until admin acknowledges. Email if SMTP configured. |
|
||||
|
||||
### State machine
|
||||
|
||||
```
|
||||
idle
|
||||
│ (admin click / autonomous trigger)
|
||||
▼
|
||||
preflight ──fail──► preflight-failed ──ack──► idle
|
||||
│
|
||||
▼
|
||||
draining ──cancel──► idle
|
||||
│
|
||||
▼
|
||||
executing ──install/build fail──► rolling-back ──► rolled-back-* ──ack──► idle
|
||||
│ │
|
||||
▼ └─fail──► rollback-failed (terminal until ack)
|
||||
pending-verification ──health-check fail──┘
|
||||
│
|
||||
▼ verified by health check
|
||||
verified ──► idle
|
||||
```
|
||||
|
||||
`rollback-failed` is the only state that disables auto/autonomous attempts globally until an admin POSTs `/admin/update/acknowledge`. Manual updates remain allowed because an admin can intervene directly.
|
||||
|
||||
### Logging
|
||||
|
||||
- All updater activity → `var/log/update.log` (rotated, 10MB × 5).
|
||||
- `GET /admin/update/log` streams the last 200 lines for the in-progress UI.
|
||||
- Important state transitions also written to log4js category `updater` at INFO so they appear in normal Etherpad logs.
|
||||
|
||||
## Security
|
||||
|
||||
- Admin endpoints share existing auth path (`webaccess.ts`, basic auth + admin role). State-changing endpoints require CSRF tokens.
|
||||
- Tag signature verification before checkout. Trusted-key set ships in `src/node/updater/trusted-keys.ts`. Forks override via `updates.trustedKeysPath`. Failure → `preflight-failed: signature`.
|
||||
- Update execution runs as Etherpad's OS user. No privilege escalation. Pre-flight permissions probe catches setups where `pnpm install` would need root.
|
||||
- `GET /api/version-status` deliberately does not return the running version. Returning `severe` or `vulnerable` to attackers without confirming exact version makes fingerprinting strictly harder than it is today, where a `/static/js/...` path or response header may already leak it.
|
||||
- Concurrent-update prevention via PID-based `var/update.lock`.
|
||||
- No telemetry. The only outbound traffic is to `api.github.com` (or the configured `updates.githubRepo` host). No instance ID, no version, no identifiers — just the IP-level metadata GitHub already sees.
|
||||
- Public `/api/version-status` rate-limited by an in-memory cache refreshed at most once per minute.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit (`src/tests/backend/specs/updater/`, vitest, no I/O)
|
||||
|
||||
- `UpdatePolicy.test.ts` — full `(installMethod × tier × current/latest)` matrix.
|
||||
- `VersionChecker.test.ts` — mocked `fetch`. ETag, backoff, parsing of `vulnerable-below` directives, `prerelease` filtering.
|
||||
- `InstallMethodDetector.test.ts` — fake fs.
|
||||
- `RollbackHandler.test.ts` — fake state file + clock + spawn. State-machine transitions, crash-loop guard, terminal `rollback-failed`.
|
||||
- `MaintenanceWindow.test.ts` — cross-midnight, DST.
|
||||
|
||||
### Integration (`src/tests/backend/specs/updater-integration.test.ts`)
|
||||
|
||||
- Tmp git repo as the "Etherpad install."
|
||||
- Local HTTP server impersonating GitHub Releases.
|
||||
- Cases: happy path; install-fail rollback; build-fail rollback; health-check timeout rollback; crash-loop rollback (force `bootCount` to 3); `rollback-failed` terminal blocks auto/autonomous but allows manual.
|
||||
|
||||
### API tests
|
||||
|
||||
- `GET /admin/update/status` — auth required, schema, expected fields.
|
||||
- `POST /admin/update/apply` — admin-only, CSRF, refuses on lock/policy denial.
|
||||
- `POST /admin/update/cancel` — works during pre-execute, 409 during execute.
|
||||
- `POST /admin/update/acknowledge` — clears terminal state.
|
||||
- `GET /api/version-status` — public, never leaks version string.
|
||||
|
||||
### Playwright (`src/tests/frontend-new/specs/`, headless)
|
||||
|
||||
- Update banner appears on `/admin` when an update exists.
|
||||
- UpdatePage shows version, changelog, Apply button.
|
||||
- Click triggers `POST /admin/update/apply`, log stream visible.
|
||||
- Banner copy correct for each terminal state.
|
||||
- Maintenance-window picker validates inputs.
|
||||
- Pad footer badge: invisible on `null`, discreet on `severe`, prominent on `vulnerable`.
|
||||
- Drain announcement appears in pad chat at T-60, T-30, T-10.
|
||||
|
||||
### Out of CI (manual smoke)
|
||||
|
||||
- Real-network GitHub calls.
|
||||
- Real process restart with a real supervisor.
|
||||
- Real `pnpm install` of a different version.
|
||||
|
||||
These are covered by:
|
||||
|
||||
- A manual smoke runbook in `docs/superpowers/specs/2026-04-25-auto-update-runbook.md` (created during PR 2 implementation), run before each tier ships, against a disposable VM.
|
||||
- A canary instance running `tier: "auto"` against a beta channel for ≥ 2 weeks before tier 4 ships.
|
||||
|
||||
### Test coverage gates per PR
|
||||
|
||||
- **PR 1:** VersionChecker, InstallMethodDetector, UpdatePolicy, Notifier unit + status endpoint API + banner Playwright + pad badge Playwright.
|
||||
- **PR 2:** + UpdateExecutor + RollbackHandler unit + integration (all rollback paths) + apply/cancel/acknowledge API + UpdatePage Playwright. Runbook smoke completed by a human on a disposable VM.
|
||||
- **PR 3:** + Scheduler unit + grace-window integration + cancel-during-grace test.
|
||||
- **PR 4:** + MaintenanceWindow unit + window-boundary integration. Canary on beta channel for 2 weeks before merge.
|
||||
|
||||
## Phased rollout
|
||||
|
||||
Each PR is independently shippable, independently revertable, and gated by `updates.tier`.
|
||||
|
||||
### PR 1 — Tier 1: Notify
|
||||
|
||||
- `src/node/updater/{VersionChecker,InstallMethodDetector,UpdatePolicy,state}.ts`
|
||||
- `src/node/updater/Notifier.ts` — single entry point for updater emails. Reads top-level `adminEmail`. Implements the cadence table (immediate-then-weekly for vulnerable, immediate-then-monthly for severe). Persists `email.lastSentFor` in `var/update-state.json` to dedupe. No-op if `adminEmail` unset.
|
||||
- `src/node/hooks/express/updateStatus.ts` registering `GET /admin/update/status` and `GET /api/version-status`.
|
||||
- Settings additions in `settings.json.template` and `settings.json.docker`, including new top-level `adminEmail`.
|
||||
- Admin UI: `UpdatePage.tsx` (read-only), `UpdateBanner.tsx`, route entry, i18n.
|
||||
- Pad UI: footer badge.
|
||||
- Tests per PR 1 row above, plus `Notifier.test.ts` (cadence math, dedupe, no-op when `adminEmail` unset).
|
||||
- `CHANGELOG.md` entry.
|
||||
|
||||
**Ship gate:** unit + API + Playwright pass; manual smoke confirms banner appears when version is patched downward.
|
||||
|
||||
### PR 2 — Tier 2: Manual click
|
||||
|
||||
- `src/node/updater/{UpdateExecutor,RollbackHandler,SessionDrainer,lock,trusted-keys}.ts`
|
||||
- Endpoints: `POST /admin/update/apply`, `POST /admin/update/cancel`, `POST /admin/update/acknowledge`, `GET /admin/update/log`.
|
||||
- `UpdatePolicy` flips `canManual` on for `git` install method.
|
||||
- Admin UI: Apply button, log stream, terminal-state banners, Cancel during pre-execute, Acknowledge on terminal.
|
||||
- Drain announcement i18n.
|
||||
- Tests per PR 2 row.
|
||||
- Manual smoke runbook updated and run end-to-end on a disposable VM, including a deliberately broken-lockfile rollback.
|
||||
|
||||
**Ship gate:** integration tests pass for all rollback paths; runbook smoke completed by a human.
|
||||
|
||||
### PR 3 — Tier 3: Auto
|
||||
|
||||
- `src/node/updater/Scheduler.ts` — listens to `VersionChecker` events, applies grace window, persists pending-update info.
|
||||
- `UpdatePolicy.canAuto` flips on for `git` + `tier: "auto"`.
|
||||
- Email notification at grace start (existing SMTP, only if `adminEmail` is set).
|
||||
- Admin UI: countdown + cancel during grace.
|
||||
- Tests per PR 3 row.
|
||||
|
||||
**Ship gate:** scheduler tests pass; canary running `tier: "auto"` against a beta channel for 2 weeks.
|
||||
|
||||
### PR 4 — Tier 4: Autonomous
|
||||
|
||||
- `src/node/updater/MaintenanceWindow.ts`.
|
||||
- `Scheduler` learns to gate on the window. Updates outside the window queue for the next opening.
|
||||
- `UpdatePolicy.canAutonomous` flips on for `git` + `tier: "autonomous"` + valid window.
|
||||
- Admin UI: window picker, validation, "next window opens at..." preview.
|
||||
- Tests per PR 4 row.
|
||||
|
||||
**Ship gate:** window unit tests pass; canary switched to `tier: "autonomous"` on the beta channel for 2 weeks.
|
||||
|
||||
### Cross-cutting
|
||||
|
||||
- **Plugin seam:** `UpdatePolicy` and `VersionChecker` take a `target: 'core' | 'plugins'` parameter from PR 1. Plugin support is not implemented in this spec but the API does not paint us into a corner.
|
||||
- **Telemetry:** none. Stated explicitly here so it is not silently added later.
|
||||
- **Docs:** PR 1 introduces `doc/admin/updates.md`; subsequent PRs extend it.
|
||||
|
||||
## Open questions
|
||||
|
||||
None at spec time. Concrete questions that may surface during implementation are expected to land in PR review, not here.
|
||||
@ -192,6 +192,28 @@
|
||||
*/
|
||||
"enableMetrics": "${ENABLE_METRICS:true}",
|
||||
|
||||
/*
|
||||
* Self-update subsystem.
|
||||
* tier: "off" | "notify" | "manual" | "auto" | "autonomous"
|
||||
* Default "notify" shows a banner when an update is available.
|
||||
* Docker installs are read-only — tiers above "notify" are not applied even if requested.
|
||||
*/
|
||||
"updates": {
|
||||
"tier": "notify",
|
||||
"source": "github",
|
||||
"channel": "stable",
|
||||
"installMethod": "docker",
|
||||
"checkIntervalHours": 6,
|
||||
"githubRepo": "ether/etherpad",
|
||||
"requireAdminForStatus": false
|
||||
},
|
||||
|
||||
/*
|
||||
* Contact address for admin notifications (updates, security advisories, future features).
|
||||
* Set to null to disable outbound mail from the updater.
|
||||
*/
|
||||
"adminEmail": null,
|
||||
|
||||
/*
|
||||
* Settings for cleanup of pads
|
||||
*/
|
||||
|
||||
@ -190,6 +190,32 @@
|
||||
*/
|
||||
"enableMetrics": "${ENABLE_METRICS:true}",
|
||||
|
||||
/*
|
||||
* Self-update subsystem.
|
||||
* tier: "off" | "notify" | "manual" | "auto" | "autonomous"
|
||||
* Default "notify" shows a banner when an update is available. "off" disables the version check.
|
||||
*/
|
||||
"updates": {
|
||||
"tier": "notify",
|
||||
"source": "github",
|
||||
"channel": "stable",
|
||||
"installMethod": "auto",
|
||||
"checkIntervalHours": 6,
|
||||
"githubRepo": "ether/etherpad",
|
||||
/*
|
||||
* Lock /admin/update/status to authenticated admins. Default false keeps the
|
||||
* endpoint open (the version is already public via /health). Set true to hide
|
||||
* full update detail from non-admins without turning the updater off.
|
||||
*/
|
||||
"requireAdminForStatus": false
|
||||
},
|
||||
|
||||
/*
|
||||
* Contact address for admin notifications (updates, security advisories, future features).
|
||||
* Set to null to disable outbound mail from the updater.
|
||||
*/
|
||||
"adminEmail": null,
|
||||
|
||||
/*
|
||||
* Settings for cleanup of pads
|
||||
*/
|
||||
|
||||
14
src/ep.json
14
src/ep.json
@ -102,6 +102,20 @@
|
||||
"socketio": "ep_etherpad-lite/node/handler/PadMessageHandler"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "updater",
|
||||
"hooks": {
|
||||
"expressCreateServer": "ep_etherpad-lite/node/updater/index",
|
||||
"shutdown": "ep_etherpad-lite/node/updater/index"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "updateStatus",
|
||||
"post": ["ep_etherpad-lite/admin"],
|
||||
"hooks": {
|
||||
"expressCreateServer": "ep_etherpad-lite/node/hooks/express/updateStatus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "admin",
|
||||
"hooks": {
|
||||
|
||||
@ -33,6 +33,20 @@
|
||||
"admin_settings.current_save.value": "Save Settings",
|
||||
"admin_settings.page-title": "Settings - Etherpad",
|
||||
|
||||
"update.banner.title": "Update available",
|
||||
"update.banner.body": "Etherpad {{latest}} is available (you are running {{current}}).",
|
||||
"update.banner.cta": "View update",
|
||||
"update.page.title": "Etherpad updates",
|
||||
"update.page.current": "Current version",
|
||||
"update.page.latest": "Latest version",
|
||||
"update.page.last_check": "Last checked",
|
||||
"update.page.install_method": "Install method",
|
||||
"update.page.tier": "Update tier",
|
||||
"update.page.changelog": "Changelog",
|
||||
"update.page.up_to_date": "You are running the latest version.",
|
||||
"update.badge.severe": "Etherpad on this server is severely outdated. Tell your admin.",
|
||||
"update.badge.vulnerable": "Etherpad on this server is running a version with known security issues. Tell your admin.",
|
||||
|
||||
"index.newPad": "New Pad",
|
||||
"index.settings": "Settings",
|
||||
"index.transferSessionTitle": "Transfer session",
|
||||
|
||||
94
src/node/hooks/express/updateStatus.ts
Normal file
94
src/node/hooks/express/updateStatus.ts
Normal file
@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
import {ArgsExpressType} from '../../types/ArgsExpressType';
|
||||
import settings, {getEpVersion} from '../../utils/Settings';
|
||||
import {getDetectedInstallMethod, stateFilePath} from '../../updater';
|
||||
import {evaluatePolicy} from '../../updater/UpdatePolicy';
|
||||
import {compareSemver, isMajorBehind, isVulnerable} from '../../updater/versionCompare';
|
||||
import {loadState} from '../../updater/state';
|
||||
|
||||
|
||||
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';
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 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;
|
||||
};
|
||||
|
||||
// Wrap an async Express handler so a rejected promise becomes next(err) rather than
|
||||
// an unhandled rejection. Mirrors the .catch(next) pattern used elsewhere in the repo.
|
||||
const wrapAsync = (fn: (req: any, res: any, next: Function) => Promise<unknown>) =>
|
||||
(req: any, res: any, next: Function) => {
|
||||
Promise.resolve(fn(req, res, next)).catch((err) => next(err));
|
||||
};
|
||||
|
||||
export const expressCreateServer = (
|
||||
_hookName: string,
|
||||
{app}: ArgsExpressType,
|
||||
cb: Function,
|
||||
): void => {
|
||||
// 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) => {
|
||||
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};
|
||||
}
|
||||
res.json({outdated: badgeCache.value});
|
||||
}));
|
||||
|
||||
// Admin UI status endpoint. By default this is open: the running version is already
|
||||
// exposed publicly via /health, and latest/changelog come from a public GitHub
|
||||
// release. Admins who want the endpoint gated to authenticated admin sessions —
|
||||
// without disabling the updater entirely — set updates.requireAdminForStatus=true.
|
||||
app.get('/admin/update/status', wrapAsync(async (req, res) => {
|
||||
if (settings.updates.requireAdminForStatus) {
|
||||
const user = req.session?.user;
|
||||
if (!user) return res.status(401).send('Authentication required');
|
||||
if (!user.is_admin) return res.status(403).send('Forbidden');
|
||||
}
|
||||
const state = await loadState(stateFilePath());
|
||||
const current = getEpVersion();
|
||||
const installMethod = getDetectedInstallMethod();
|
||||
const policy = state.latest
|
||||
? evaluatePolicy({installMethod, tier: settings.updates.tier, current, latest: state.latest.version})
|
||||
: null;
|
||||
res.json({
|
||||
currentVersion: current,
|
||||
latest: state.latest,
|
||||
lastCheckAt: state.lastCheckAt,
|
||||
installMethod,
|
||||
tier: settings.updates.tier,
|
||||
policy,
|
||||
vulnerableBelow: state.vulnerableBelow,
|
||||
});
|
||||
}));
|
||||
|
||||
cb();
|
||||
};
|
||||
46
src/node/updater/InstallMethodDetector.ts
Normal file
46
src/node/updater/InstallMethodDetector.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import {constants as fsConstants} from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {InstallMethod} from './types';
|
||||
|
||||
export interface DetectOptions {
|
||||
/** Setting from settings.json. "auto" means detect; anything else is forced. */
|
||||
override: InstallMethod;
|
||||
/** Root directory of the Etherpad install. */
|
||||
rootDir: string;
|
||||
/** Path to /.dockerenv (overridable for tests). */
|
||||
dockerEnvPath?: string;
|
||||
}
|
||||
|
||||
const exists = async (p: string): Promise<boolean> => {
|
||||
try { await fs.access(p, fsConstants.F_OK); return true; } catch { return false; }
|
||||
};
|
||||
|
||||
const writable = async (p: string): Promise<boolean> => {
|
||||
try { await fs.access(p, fsConstants.W_OK); return true; } catch { return false; }
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect how Etherpad was installed. Returns 'docker' | 'git' | 'npm' | 'managed'.
|
||||
* - If `opts.override` is anything other than 'auto', that value is returned unchanged.
|
||||
* - 'docker' is checked first via `/.dockerenv` (overridable for tests).
|
||||
* - 'git' requires both a `.git` dir AND a writable rootDir (so we don't try to update read-only checkouts).
|
||||
* - 'npm' requires a writable `package-lock.json`.
|
||||
* - 'managed' is the catch-all for installs we can't safely modify.
|
||||
*/
|
||||
export const detectInstallMethod = async (
|
||||
opts: DetectOptions,
|
||||
): Promise<Exclude<InstallMethod, 'auto'>> => {
|
||||
if (opts.override !== 'auto') return opts.override;
|
||||
|
||||
const dockerEnv = opts.dockerEnvPath ?? '/.dockerenv';
|
||||
if (await exists(dockerEnv)) return 'docker';
|
||||
|
||||
const gitDir = path.join(opts.rootDir, '.git');
|
||||
if (await exists(gitDir) && await writable(opts.rootDir)) return 'git';
|
||||
|
||||
const lockfile = path.join(opts.rootDir, 'package-lock.json');
|
||||
if (await exists(lockfile) && await writable(lockfile)) return 'npm';
|
||||
|
||||
return 'managed';
|
||||
};
|
||||
88
src/node/updater/Notifier.ts
Normal file
88
src/node/updater/Notifier.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import {EmailSendLog} from './types';
|
||||
|
||||
// TODO(future): surface the threshold version in email bodies so admins know which version
|
||||
// clears the vulnerability. Requires extending NotifierInput with the relevant directive(s).
|
||||
export interface NotifierInput {
|
||||
adminEmail: string | null;
|
||||
current: string;
|
||||
latest: string;
|
||||
latestTag: string;
|
||||
isVulnerable: boolean;
|
||||
isSevere: boolean;
|
||||
state: EmailSendLog;
|
||||
now: Date;
|
||||
}
|
||||
|
||||
export type EmailKind = 'severe' | 'vulnerable' | 'vulnerable-new-release';
|
||||
|
||||
export interface PlannedEmail {
|
||||
kind: EmailKind;
|
||||
subject: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface NotifierResult {
|
||||
toSend: PlannedEmail[];
|
||||
newState: EmailSendLog;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const decideEmails = (input: NotifierInput): NotifierResult => {
|
||||
const {adminEmail, current, latest, latestTag, isVulnerable, 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) {
|
||||
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}.`,
|
||||
});
|
||||
newState.severeAt = now.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
return {toSend, newState};
|
||||
};
|
||||
42
src/node/updater/UpdatePolicy.ts
Normal file
42
src/node/updater/UpdatePolicy.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import {compareSemver} from './versionCompare';
|
||||
import {InstallMethod, PolicyResult, Tier} from './types';
|
||||
|
||||
// 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.
|
||||
const WRITABLE_METHODS: ReadonlySet<Exclude<InstallMethod, 'auto'>> = new Set(['git']);
|
||||
|
||||
export interface PolicyInput {
|
||||
installMethod: Exclude<InstallMethod, 'auto'>;
|
||||
tier: Tier;
|
||||
current: string;
|
||||
latest: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which update tiers are allowed under the given (installMethod, tier, current, latest).
|
||||
* 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' | 'ok'.
|
||||
*/
|
||||
export const evaluatePolicy = ({installMethod, tier, current, latest}: PolicyInput): PolicyResult => {
|
||||
if (tier === 'off') {
|
||||
return {canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'tier-off'};
|
||||
}
|
||||
if (compareSemver(current, latest) >= 0) {
|
||||
return {canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'up-to-date'};
|
||||
}
|
||||
|
||||
const canNotify = true;
|
||||
const writable = WRITABLE_METHODS.has(installMethod);
|
||||
|
||||
if (!writable) {
|
||||
return {canNotify, canManual: false, canAuto: false, canAutonomous: false, reason: 'install-method-not-writable'};
|
||||
}
|
||||
|
||||
return {
|
||||
canNotify,
|
||||
canManual: tier === 'manual' || tier === 'auto' || tier === 'autonomous',
|
||||
canAuto: tier === 'auto' || tier === 'autonomous',
|
||||
canAutonomous: tier === 'autonomous',
|
||||
reason: 'ok',
|
||||
};
|
||||
};
|
||||
87
src/node/updater/VersionChecker.ts
Normal file
87
src/node/updater/VersionChecker.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import {ReleaseInfo, VulnerableBelowDirective} from './types';
|
||||
import {parseVulnerableBelow} from './versionCompare';
|
||||
|
||||
export interface FetchResult {
|
||||
status: number;
|
||||
etag: string | null;
|
||||
/** Parsed JSON body on 200, otherwise null. */
|
||||
json: any;
|
||||
}
|
||||
|
||||
/** Adapter so tests can stub the network. Maps URL+ETag to a FetchResult. */
|
||||
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: 'notmodified'}
|
||||
| {kind: 'ratelimited'}
|
||||
| {kind: 'skipped-prerelease'; etag: string | null}
|
||||
| {kind: 'error'; status: number};
|
||||
|
||||
export interface CheckOptions {
|
||||
fetcher: Fetcher;
|
||||
prevEtag: string | null;
|
||||
/** GitHub repo as `owner/name`, e.g. `ether/etherpad`. */
|
||||
repo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hit `/repos/{repo}/releases/latest` on GitHub. Pass the previous ETag for `If-None-Match`.
|
||||
* Returns one of: 'updated' | 'notmodified' | 'ratelimited' | 'skipped-prerelease' | 'error'.
|
||||
*/
|
||||
export const checkLatestRelease = async (
|
||||
{fetcher, prevEtag, repo}: CheckOptions,
|
||||
): Promise<CheckResult> => {
|
||||
const url = `https://api.github.com/repos/${repo}/releases/latest`;
|
||||
const res = await fetcher(url, prevEtag);
|
||||
|
||||
if (res.status === 304) return {kind: 'notmodified'};
|
||||
if (res.status === 403 || res.status === 429) return {kind: 'ratelimited'};
|
||||
if (res.status !== 200 || !res.json) return {kind: 'error', status: res.status};
|
||||
|
||||
const j = res.json;
|
||||
if (j.prerelease) return {kind: 'skipped-prerelease', etag: res.etag};
|
||||
|
||||
if (typeof j.tag_name !== 'string' ||
|
||||
typeof j.html_url !== 'string' ||
|
||||
typeof j.published_at !== 'string') {
|
||||
return {kind: 'error', status: 200};
|
||||
}
|
||||
|
||||
const tag = j.tag_name;
|
||||
const version = tag.replace(/^v/, '');
|
||||
const body: string = typeof j.body === 'string' ? j.body : '';
|
||||
|
||||
const release: ReleaseInfo = {
|
||||
version,
|
||||
tag,
|
||||
body,
|
||||
publishedAt: j.published_at,
|
||||
prerelease: false,
|
||||
htmlUrl: j.html_url,
|
||||
};
|
||||
|
||||
const directiveThreshold = parseVulnerableBelow(body);
|
||||
const vulnerableBelow: VulnerableBelowDirective[] = directiveThreshold
|
||||
? [{announcedBy: tag, threshold: directiveThreshold}]
|
||||
: [];
|
||||
|
||||
return {kind: 'updated', release, etag: res.etag, vulnerableBelow};
|
||||
};
|
||||
|
||||
/** Production fetcher built on Node 18+ native fetch. Honors If-None-Match for cheap polling. */
|
||||
export const realFetcher: Fetcher = async (url, etag) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': 'etherpad-self-update',
|
||||
};
|
||||
if (etag) headers['If-None-Match'] = etag;
|
||||
const r = await fetch(url, {headers});
|
||||
const newEtag = r.headers.get('etag');
|
||||
let json: any = null;
|
||||
if (r.status === 200) {
|
||||
try { json = await r.json(); } catch { json = null; }
|
||||
}
|
||||
return {status: r.status, etag: newEtag, json};
|
||||
};
|
||||
149
src/node/updater/index.ts
Normal file
149
src/node/updater/index.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import path from 'node:path';
|
||||
import log4js from 'log4js';
|
||||
import settings, {getEpVersion} from '../utils/Settings';
|
||||
import {detectInstallMethod} from './InstallMethodDetector';
|
||||
import {checkLatestRelease, realFetcher} from './VersionChecker';
|
||||
import {loadState, saveState} from './state';
|
||||
import {isMajorBehind, isVulnerable} from './versionCompare';
|
||||
import {evaluatePolicy} from './UpdatePolicy';
|
||||
import {decideEmails} from './Notifier';
|
||||
import {InstallMethod, UpdateState} from './types';
|
||||
|
||||
const logger = log4js.getLogger('updater');
|
||||
|
||||
let detectedMethod: Exclude<InstallMethod, 'auto'> = 'managed';
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let initialTimer: NodeJS.Timeout | null = null;
|
||||
let checkInFlight = false;
|
||||
let inMemoryState: UpdateState | null = null;
|
||||
|
||||
export const stateFilePath = () => path.join(settings.root, 'var', 'update-state.json');
|
||||
|
||||
/** Returns the current state from memory; loads on first call. */
|
||||
export const getCurrentState = async (): Promise<UpdateState> => {
|
||||
if (inMemoryState) return inMemoryState;
|
||||
inMemoryState = await loadState(stateFilePath());
|
||||
return inMemoryState;
|
||||
};
|
||||
|
||||
export const getDetectedInstallMethod = () => detectedMethod;
|
||||
|
||||
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 performCheck = async (): Promise<void> => {
|
||||
if (settings.updates.tier === 'off') return;
|
||||
// Coalesce overlapping ticks. performCheck mutates shared in-memory state and writes
|
||||
// it to disk; concurrent runs would race on saveState() and could double-send emails.
|
||||
if (checkInFlight) return;
|
||||
checkInFlight = true;
|
||||
try {
|
||||
// getCurrentState() can throw on a non-ENOENT fs error from loadState();
|
||||
// it must run inside the try/finally so checkInFlight is always cleared,
|
||||
// otherwise a one-time permission error permanently disables polling.
|
||||
const state = await getCurrentState();
|
||||
const result = await checkLatestRelease({
|
||||
fetcher: realFetcher,
|
||||
prevEtag: state.lastEtag,
|
||||
repo: settings.updates.githubRepo,
|
||||
});
|
||||
const now = new Date();
|
||||
state.lastCheckAt = now.toISOString();
|
||||
|
||||
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;
|
||||
} else if (result.kind === 'notmodified') {
|
||||
// 304 — no state change.
|
||||
} else if (result.kind === 'ratelimited') {
|
||||
logger.warn('GitHub rate-limited; will retry at next interval');
|
||||
} else if (result.kind === 'error') {
|
||||
logger.warn(`GitHub fetch error status=${result.status}`);
|
||||
}
|
||||
|
||||
// Notifier pass: only when we have a known latest, an admin email, and the policy allows notify.
|
||||
if (state.latest && settings.adminEmail) {
|
||||
const current = getEpVersion();
|
||||
const policy = evaluatePolicy({
|
||||
installMethod: detectedMethod,
|
||||
tier: settings.updates.tier,
|
||||
current,
|
||||
latest: state.latest.version,
|
||||
});
|
||||
if (policy.canNotify) {
|
||||
const decision = decideEmails({
|
||||
adminEmail: settings.adminEmail,
|
||||
current,
|
||||
latest: state.latest.version,
|
||||
latestTag: state.latest.tag,
|
||||
isVulnerable: isVulnerable(current, state.vulnerableBelow),
|
||||
isSevere: isMajorBehind(current, state.latest.version),
|
||||
state: state.email,
|
||||
now,
|
||||
});
|
||||
for (const email of decision.toSend) {
|
||||
await sendEmailViaSmtp(settings.adminEmail, email.subject, email.body);
|
||||
}
|
||||
state.email = decision.newState;
|
||||
}
|
||||
}
|
||||
|
||||
await saveState(stateFilePath(), state);
|
||||
} catch (err) {
|
||||
logger.warn(`Updater check failed: ${(err as Error).message}`);
|
||||
} finally {
|
||||
checkInFlight = false;
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = (): void => {
|
||||
// Coerce in case settings.json carries a non-number (Math.max(1, NaN) === NaN,
|
||||
// which becomes a tight setInterval loop). Clamp to a sane window: at least 1h
|
||||
// (don't hammer GitHub) and at most a week (don't silently stop checking).
|
||||
const rawHours = Number(settings.updates.checkIntervalHours);
|
||||
const safeHours = Number.isFinite(rawHours) ? Math.min(168, Math.max(1, rawHours)) : 6;
|
||||
if (safeHours !== rawHours) {
|
||||
logger.warn(`updates.checkIntervalHours invalid (${settings.updates.checkIntervalHours}); using ${safeHours}h`);
|
||||
}
|
||||
const intervalMs = safeHours * 60 * 60 * 1000;
|
||||
if (timer) clearInterval(timer);
|
||||
if (initialTimer) clearTimeout(initialTimer);
|
||||
timer = setInterval(() => { void performCheck(); }, intervalMs);
|
||||
// Run an immediate first check, but don't block boot. Track the handle so shutdown()
|
||||
// can cancel it before it fires.
|
||||
initialTimer = setTimeout(() => { initialTimer = null; void performCheck(); }, 5000);
|
||||
};
|
||||
|
||||
/** Hook entry point — called by ep.json on createServer. */
|
||||
export const expressCreateServer = async (): Promise<void> => {
|
||||
detectedMethod = await detectInstallMethod({
|
||||
override: settings.updates.installMethod,
|
||||
rootDir: settings.root,
|
||||
});
|
||||
logger.info(`updater: install method = ${detectedMethod}, tier = ${settings.updates.tier}`);
|
||||
if (settings.updates.tier !== 'off') startPolling();
|
||||
};
|
||||
|
||||
/** Shutdown hook. */
|
||||
export const shutdown = async (): Promise<void> => {
|
||||
if (timer) { clearInterval(timer); timer = null; }
|
||||
if (initialTimer) { clearTimeout(initialTimer); initialTimer = null; }
|
||||
};
|
||||
|
||||
/** Exposed for tests / route handlers. */
|
||||
export const _internal = {
|
||||
performCheck,
|
||||
stateFilePath,
|
||||
};
|
||||
77
src/node/updater/state.ts
Normal file
77
src/node/updater/state.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {EMPTY_STATE, UpdateState} from './types';
|
||||
|
||||
const isPlainObject = (v: unknown): v is Record<string, unknown> =>
|
||||
v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
|
||||
const isStringOrNull = (v: unknown): v is string | null =>
|
||||
v === null || typeof v === 'string';
|
||||
|
||||
const isValidLatest = (v: unknown): boolean => {
|
||||
if (v === null) return true;
|
||||
if (!isPlainObject(v)) return false;
|
||||
// Subfields are read into semver parsing and email rendering; if any is
|
||||
// missing or wrong-type the file is treated as corrupt and reset.
|
||||
return typeof v.version === 'string'
|
||||
&& typeof v.tag === 'string'
|
||||
&& typeof v.body === 'string'
|
||||
&& typeof v.publishedAt === 'string'
|
||||
&& typeof v.htmlUrl === 'string'
|
||||
&& 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;
|
||||
return isStringOrNull(v.severeAt)
|
||||
&& isStringOrNull(v.vulnerableAt)
|
||||
&& isStringOrNull(v.vulnerableNewReleaseTag);
|
||||
};
|
||||
|
||||
// Validate the full shape so loadState() actually delivers on its "safely
|
||||
// reset on malformed input" contract. Downstream code calls .trim() / semver
|
||||
// parsing on these subfields and would crash on a hand-edited file otherwise.
|
||||
const isValid = (raw: unknown): raw is UpdateState => {
|
||||
if (!isPlainObject(raw)) return false;
|
||||
return raw.schemaVersion === 1
|
||||
&& isStringOrNull(raw.lastCheckAt)
|
||||
&& isStringOrNull(raw.lastEtag)
|
||||
&& isValidLatest(raw.latest)
|
||||
&& isValidVulnerableBelow(raw.vulnerableBelow)
|
||||
&& isValidEmail(raw.email);
|
||||
};
|
||||
|
||||
/** Reads the on-disk state. Returns a fresh empty-state clone when the file is missing, malformed, or has an unknown schemaVersion. Never throws on parse errors. */
|
||||
export const loadState = async (filePath: string): Promise<UpdateState> => {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf8');
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') return structuredClone(EMPTY_STATE);
|
||||
throw err;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return structuredClone(EMPTY_STATE);
|
||||
}
|
||||
if (!isValid(parsed)) return structuredClone(EMPTY_STATE);
|
||||
return parsed;
|
||||
};
|
||||
|
||||
/** Atomic write via tmp-then-rename. Creates parent directories as needed. */
|
||||
export const saveState = async (filePath: string, state: UpdateState): Promise<void> => {
|
||||
await fs.mkdir(path.dirname(filePath), {recursive: true});
|
||||
const tmp = `${filePath}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(state, null, 2));
|
||||
await fs.rename(tmp, filePath);
|
||||
};
|
||||
75
src/node/updater/types.ts
Normal file
75
src/node/updater/types.ts
Normal file
@ -0,0 +1,75 @@
|
||||
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';
|
||||
|
||||
export interface ReleaseInfo {
|
||||
/** semver string without leading 'v', e.g. "2.7.2". */
|
||||
version: string;
|
||||
/** Original GitHub `tag_name`, e.g. "v2.7.2". */
|
||||
tag: string;
|
||||
/** Markdown body of the release. */
|
||||
body: string;
|
||||
/** ISO-8601 timestamp from GitHub. */
|
||||
publishedAt: string;
|
||||
/** True if GitHub flagged it as a prerelease. */
|
||||
prerelease: boolean;
|
||||
/** GitHub HTML URL for the release page. */
|
||||
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;
|
||||
canAuto: boolean;
|
||||
canAutonomous: boolean;
|
||||
/** Human-readable string explaining the most-restrictive denial, or "ok". */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface UpdateState {
|
||||
/** Schema version of this file. Increment when fields change. */
|
||||
schemaVersion: 1;
|
||||
/** Last time VersionChecker successfully fetched, ISO-8601. */
|
||||
lastCheckAt: string | null;
|
||||
/** Last ETag returned by GitHub, used for If-None-Match. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Zero-value initial state. Treat as immutable — spread before mutating: `{...EMPTY_STATE, lastCheckAt: x}`. */
|
||||
export const EMPTY_STATE: UpdateState = {
|
||||
schemaVersion: 1,
|
||||
lastCheckAt: null,
|
||||
lastEtag: null,
|
||||
latest: null,
|
||||
vulnerableBelow: [],
|
||||
email: {
|
||||
severeAt: null,
|
||||
vulnerableAt: null,
|
||||
vulnerableNewReleaseTag: null,
|
||||
},
|
||||
};
|
||||
53
src/node/updater/versionCompare.ts
Normal file
53
src/node/updater/versionCompare.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type {VulnerableBelowDirective} from './types';
|
||||
|
||||
export interface ParsedSemver {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
// Accepts optional prerelease (e.g. -rc.1) and build-metadata (e.g. +build.123).
|
||||
// Four-part versions like 2.7.1.4 are rejected — use standard semver only.
|
||||
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
|
||||
|
||||
export const parseSemver = (s: string): ParsedSemver | null => {
|
||||
const m = SEMVER_RE.exec(s.trim());
|
||||
if (!m) return null;
|
||||
return {major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3])};
|
||||
};
|
||||
|
||||
export const compareSemver = (a: string, b: string): -1 | 0 | 1 => {
|
||||
const pa = parseSemver(a);
|
||||
const pb = parseSemver(b);
|
||||
if (!pa || !pb) return 0;
|
||||
for (const k of ['major', 'minor', 'patch'] as const) {
|
||||
if (pa[k] !== pb[k]) return pa[k] < pb[k] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const isMajorBehind = (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;
|
||||
};
|
||||
@ -299,6 +299,16 @@ export type SettingsType = {
|
||||
lowerCasePadIds: boolean,
|
||||
randomVersionString: string,
|
||||
gitVersion: string
|
||||
updates: {
|
||||
tier: 'off' | 'notify' | 'manual' | 'auto' | 'autonomous',
|
||||
source: 'github',
|
||||
channel: 'stable',
|
||||
installMethod: 'auto' | 'git' | 'docker' | 'npm' | 'managed',
|
||||
checkIntervalHours: number,
|
||||
githubRepo: string,
|
||||
requireAdminForStatus: boolean,
|
||||
},
|
||||
adminEmail: string | null,
|
||||
getPublicSettings: () => Pick<SettingsType, "title" | "skinVariants"|"randomVersionString"|"skinName"|"toolbar"| "exposeVersion"| "gitVersion" | "enablePadWideSettings">,
|
||||
}
|
||||
|
||||
@ -431,6 +441,28 @@ const settings: SettingsType = {
|
||||
* Wether to enable the /stats endpoint. The functionality in the admin menu is untouched for this.
|
||||
*/
|
||||
enableMetrics: true,
|
||||
/**
|
||||
* Self-update subsystem (PR 1: tier 1 only).
|
||||
* Tier "off" disables the version check entirely. Default "notify" shows a banner when behind.
|
||||
*/
|
||||
updates: {
|
||||
tier: 'notify',
|
||||
source: 'github',
|
||||
channel: 'stable',
|
||||
installMethod: 'auto',
|
||||
checkIntervalHours: 6,
|
||||
githubRepo: 'ether/etherpad',
|
||||
// The /admin/update/status endpoint returns full info including currentVersion.
|
||||
// Default false matches existing behavior: the version is already exposed via /health.
|
||||
// Set true to require an authenticated admin session for the endpoint without
|
||||
// disabling the updater itself.
|
||||
requireAdminForStatus: false,
|
||||
},
|
||||
/**
|
||||
* Contact address for admin notifications (updates, future security advisories).
|
||||
* Null disables outbound mail from the updater.
|
||||
*/
|
||||
adminEmail: null,
|
||||
/**
|
||||
* Whether certain shortcut keys are enabled for a user in the pad
|
||||
*/
|
||||
|
||||
@ -92,3 +92,18 @@ input {
|
||||
margin-left:auto;
|
||||
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; }
|
||||
|
||||
@ -54,6 +54,8 @@ const socketio = require('./socketio');
|
||||
|
||||
const hooks = require('./pluginfw/hooks');
|
||||
|
||||
import './pad_version_badge';
|
||||
|
||||
// This array represents all GET-parameters which can be used to change a setting.
|
||||
// name: the parameter-name, eg `?noColors=true` => `noColors`
|
||||
// checkVal: the callback is only executed when
|
||||
|
||||
46
src/static/js/pad_version_badge.ts
Normal file
46
src/static/js/pad_version_badge.ts
Normal file
@ -0,0 +1,46 @@
|
||||
'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();
|
||||
}
|
||||
}
|
||||
@ -516,6 +516,7 @@
|
||||
|
||||
<% e.end_block(); %>
|
||||
|
||||
<div id="version-badge" role="status" aria-live="polite" style="display:none"></div>
|
||||
</div> <!-- End of #editorcontainerbox -->
|
||||
|
||||
<% e.end_block(); %>
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {detectInstallMethod} from '../../../../node/updater/InstallMethodDetector';
|
||||
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'detector-'));
|
||||
});
|
||||
|
||||
const opts = (override?: 'auto' | 'git' | 'docker' | 'npm' | 'managed') => ({
|
||||
override: override ?? 'auto',
|
||||
rootDir: dir,
|
||||
dockerEnvPath: path.join(dir, '.dockerenv'),
|
||||
});
|
||||
|
||||
describe('detectInstallMethod', () => {
|
||||
it('honors a non-auto override', async () => {
|
||||
expect(await detectInstallMethod(opts('git'))).toBe('git');
|
||||
expect(await detectInstallMethod(opts('docker'))).toBe('docker');
|
||||
expect(await detectInstallMethod(opts('managed'))).toBe('managed');
|
||||
});
|
||||
|
||||
it('returns docker when /.dockerenv exists', async () => {
|
||||
await fs.writeFile(opts().dockerEnvPath, '');
|
||||
expect(await detectInstallMethod(opts())).toBe('docker');
|
||||
});
|
||||
|
||||
it('returns git when .git is present and root is writable', async () => {
|
||||
await fs.mkdir(path.join(dir, '.git'));
|
||||
expect(await detectInstallMethod(opts())).toBe('git');
|
||||
});
|
||||
|
||||
it('returns npm when package-lock.json is present and writable', async () => {
|
||||
await fs.writeFile(path.join(dir, 'package-lock.json'), '{}');
|
||||
expect(await detectInstallMethod(opts())).toBe('npm');
|
||||
});
|
||||
|
||||
it('returns managed when nothing matches', async () => {
|
||||
expect(await detectInstallMethod(opts())).toBe('managed');
|
||||
});
|
||||
|
||||
it('docker takes precedence over git', async () => {
|
||||
await fs.writeFile(opts().dockerEnvPath, '');
|
||||
await fs.mkdir(path.join(dir, '.git'));
|
||||
expect(await detectInstallMethod(opts())).toBe('docker');
|
||||
});
|
||||
});
|
||||
95
src/tests/backend-new/specs/updater/Notifier.test.ts
Normal file
95
src/tests/backend-new/specs/updater/Notifier.test.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import {describe, it, expect} from 'vitest';
|
||||
import {decideEmails, NotifierInput} from '../../../../node/updater/Notifier';
|
||||
import {EMPTY_STATE} from '../../../../node/updater/types';
|
||||
|
||||
const base: NotifierInput = {
|
||||
adminEmail: 'admin@example.com',
|
||||
current: '2.0.0',
|
||||
latest: '2.7.2',
|
||||
latestTag: 'v2.7.2',
|
||||
isVulnerable: false,
|
||||
isSevere: false,
|
||||
state: EMPTY_STATE.email,
|
||||
now: new Date('2026-04-25T12:00:00Z'),
|
||||
};
|
||||
|
||||
describe('decideEmails', () => {
|
||||
it('emits no email if adminEmail is unset', () => {
|
||||
const r = decideEmails({...base, adminEmail: null, isSevere: true});
|
||||
expect(r.toSend).toEqual([]);
|
||||
});
|
||||
|
||||
it('emits severe email on first detection', () => {
|
||||
const r = decideEmails({...base, isSevere: true});
|
||||
expect(r.toSend.map(e => e.kind)).toEqual(['severe']);
|
||||
expect(r.newState.severeAt).toBe('2026-04-25T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('does not re-emit severe within 30 days', () => {
|
||||
const r = decideEmails({
|
||||
...base,
|
||||
isSevere: true,
|
||||
state: {...base.state, severeAt: '2026-04-10T12:00:00.000Z'},
|
||||
});
|
||||
expect(r.toSend).toEqual([]);
|
||||
});
|
||||
|
||||
it('re-emits severe after 30 days', () => {
|
||||
const r = decideEmails({
|
||||
...base,
|
||||
isSevere: true,
|
||||
state: {...base.state, severeAt: '2026-03-20T12:00:00.000Z'},
|
||||
});
|
||||
expect(r.toSend.map(e => e.kind)).toEqual(['severe']);
|
||||
});
|
||||
|
||||
it('emits vulnerable email on first detection', () => {
|
||||
const r = decideEmails({...base, isVulnerable: true});
|
||||
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
|
||||
expect(r.newState.vulnerableAt).toBe('2026-04-25T12:00:00.000Z');
|
||||
});
|
||||
|
||||
it('does not re-emit vulnerable within 7 days', () => {
|
||||
const r = decideEmails({
|
||||
...base,
|
||||
isVulnerable: true,
|
||||
state: {...base.state, vulnerableAt: '2026-04-22T12:00:00.000Z'},
|
||||
});
|
||||
expect(r.toSend).toEqual([]);
|
||||
});
|
||||
|
||||
it('re-emits vulnerable after 7 days', () => {
|
||||
const r = decideEmails({
|
||||
...base,
|
||||
isVulnerable: true,
|
||||
state: {...base.state, vulnerableAt: '2026-04-15T12:00:00.000Z'},
|
||||
});
|
||||
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
|
||||
});
|
||||
|
||||
it('emits new-release-while-vulnerable when latest tag changes', () => {
|
||||
const r = decideEmails({
|
||||
...base,
|
||||
isVulnerable: true,
|
||||
state: {...base.state, vulnerableAt: '2026-04-25T11:59:00.000Z', vulnerableNewReleaseTag: 'v2.7.1'},
|
||||
});
|
||||
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable-new-release']);
|
||||
});
|
||||
|
||||
it('vulnerable wins over severe in the same tick', () => {
|
||||
const r = decideEmails({...base, isSevere: true, isVulnerable: true});
|
||||
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable']);
|
||||
});
|
||||
|
||||
it('emits new-release-while-vulnerable even after the 7-day window has passed', () => {
|
||||
// Regression: tagChanged should fire regardless of cadence; admin must learn of the fix.
|
||||
const r = decideEmails({
|
||||
...base,
|
||||
isVulnerable: true,
|
||||
state: {...base.state, vulnerableAt: '2026-04-01T12:00:00.000Z', vulnerableNewReleaseTag: 'v2.7.1'},
|
||||
});
|
||||
expect(r.toSend.map(e => e.kind)).toEqual(['vulnerable-new-release']);
|
||||
expect(r.newState.vulnerableNewReleaseTag).toBe('v2.7.2');
|
||||
expect(r.newState.vulnerableAt).toBe('2026-04-25T12:00:00.000Z');
|
||||
});
|
||||
});
|
||||
64
src/tests/backend-new/specs/updater/UpdatePolicy.test.ts
Normal file
64
src/tests/backend-new/specs/updater/UpdatePolicy.test.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import {describe, it, expect} from 'vitest';
|
||||
import {evaluatePolicy} from '../../../../node/updater/UpdatePolicy';
|
||||
import {InstallMethod, Tier} from '../../../../node/updater/types';
|
||||
|
||||
const baseInput = {
|
||||
installMethod: 'git' as Exclude<InstallMethod, 'auto'>,
|
||||
tier: 'manual' as Tier,
|
||||
current: '2.7.1',
|
||||
latest: '2.7.2',
|
||||
};
|
||||
|
||||
describe('evaluatePolicy', () => {
|
||||
it('off tier denies everything', () => {
|
||||
const r = evaluatePolicy({...baseInput, tier: 'off'});
|
||||
expect(r).toEqual({canNotify: false, canManual: false, canAuto: false, canAutonomous: false, reason: 'tier-off'});
|
||||
});
|
||||
|
||||
it('notify tier allows only notify', () => {
|
||||
const r = evaluatePolicy({...baseInput, tier: 'notify'});
|
||||
expect(r.canNotify).toBe(true);
|
||||
expect(r.canManual).toBe(false);
|
||||
expect(r.canAuto).toBe(false);
|
||||
expect(r.canAutonomous).toBe(false);
|
||||
});
|
||||
|
||||
it('manual tier allows notify+manual on git', () => {
|
||||
const r = evaluatePolicy({...baseInput, tier: 'manual'});
|
||||
expect(r.canManual).toBe(true);
|
||||
expect(r.canAuto).toBe(false);
|
||||
});
|
||||
|
||||
it('manual tier denies manual on docker', () => {
|
||||
const r = evaluatePolicy({...baseInput, tier: 'manual', installMethod: 'docker'});
|
||||
expect(r.canNotify).toBe(true);
|
||||
expect(r.canManual).toBe(false);
|
||||
expect(r.reason).toBe('install-method-not-writable');
|
||||
});
|
||||
|
||||
it('autonomous tier allows everything on git', () => {
|
||||
const r = evaluatePolicy({...baseInput, tier: 'autonomous'});
|
||||
expect(r).toEqual({canNotify: true, canManual: true, canAuto: true, canAutonomous: true, reason: 'ok'});
|
||||
});
|
||||
|
||||
it('autonomous on managed install denies write tiers', () => {
|
||||
const r = evaluatePolicy({...baseInput, tier: 'autonomous', installMethod: 'managed'});
|
||||
expect(r.canNotify).toBe(true);
|
||||
expect(r.canManual).toBe(false);
|
||||
expect(r.canAuto).toBe(false);
|
||||
expect(r.canAutonomous).toBe(false);
|
||||
});
|
||||
|
||||
it('current === latest denies all (nothing to do)', () => {
|
||||
const r = evaluatePolicy({...baseInput, tier: 'autonomous', current: '2.7.2', latest: '2.7.2'});
|
||||
expect(r.canNotify).toBe(false);
|
||||
expect(r.canManual).toBe(false);
|
||||
expect(r.reason).toBe('up-to-date');
|
||||
});
|
||||
|
||||
it('current > latest (dev build) denies all', () => {
|
||||
const r = evaluatePolicy({...baseInput, tier: 'autonomous', current: '3.0.0', latest: '2.7.2'});
|
||||
expect(r.canNotify).toBe(false);
|
||||
expect(r.reason).toBe('up-to-date');
|
||||
});
|
||||
});
|
||||
96
src/tests/backend-new/specs/updater/VersionChecker.test.ts
Normal file
96
src/tests/backend-new/specs/updater/VersionChecker.test.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import {describe, it, expect} from 'vitest';
|
||||
import {checkLatestRelease, FetchResult} from '../../../../node/updater/VersionChecker';
|
||||
import {ReleaseInfo} from '../../../../node/updater/types';
|
||||
|
||||
const ghBody = (overrides: Partial<{tag_name: string; body: string; prerelease: boolean; html_url: string; published_at: string}> = {}) => ({
|
||||
tag_name: 'v2.7.2',
|
||||
body: 'Some changes.\n<!-- updater: vulnerable-below 2.6.4 -->',
|
||||
prerelease: false,
|
||||
html_url: 'https://github.com/ether/etherpad/releases/tag/v2.7.2',
|
||||
published_at: '2026-04-25T00:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('checkLatestRelease', () => {
|
||||
it('returns parsed release on 200', async () => {
|
||||
const fetcher = async () => ({
|
||||
status: 200,
|
||||
etag: 'abc',
|
||||
json: ghBody(),
|
||||
} as FetchResult);
|
||||
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
|
||||
expect(r.kind).toBe('updated');
|
||||
if (r.kind !== 'updated') return;
|
||||
const expected: ReleaseInfo = {
|
||||
version: '2.7.2',
|
||||
tag: 'v2.7.2',
|
||||
body: 'Some changes.\n<!-- updater: vulnerable-below 2.6.4 -->',
|
||||
publishedAt: '2026-04-25T00:00:00Z',
|
||||
prerelease: false,
|
||||
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v2.7.2',
|
||||
};
|
||||
expect(r.release).toEqual(expected);
|
||||
expect(r.etag).toBe('abc');
|
||||
expect(r.vulnerableBelow).toEqual([{announcedBy: 'v2.7.2', threshold: '2.6.4'}]);
|
||||
});
|
||||
|
||||
it('returns notmodified on 304', async () => {
|
||||
const fetcher = async () => ({status: 304, etag: 'abc', json: null} as FetchResult);
|
||||
const r = await checkLatestRelease({fetcher, prevEtag: 'abc', repo: 'ether/etherpad'});
|
||||
expect(r.kind).toBe('notmodified');
|
||||
});
|
||||
|
||||
it('returns ratelimited on 403', async () => {
|
||||
const fetcher = async () => ({status: 403, etag: null, json: null} as FetchResult);
|
||||
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
|
||||
expect(r.kind).toBe('ratelimited');
|
||||
});
|
||||
|
||||
it('skips prereleases but preserves ETag', async () => {
|
||||
const fetcher = async () => ({
|
||||
status: 200, etag: 'pre-etag', json: ghBody({prerelease: true}),
|
||||
} as FetchResult);
|
||||
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
|
||||
expect(r.kind).toBe('skipped-prerelease');
|
||||
if (r.kind !== 'skipped-prerelease') return;
|
||||
expect(r.etag).toBe('pre-etag');
|
||||
});
|
||||
|
||||
it('returns error on unexpected status', async () => {
|
||||
const fetcher = async () => ({status: 500, etag: null, json: null} as FetchResult);
|
||||
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
|
||||
expect(r.kind).toBe('error');
|
||||
});
|
||||
|
||||
it('passes prevEtag to fetcher', async () => {
|
||||
let observed: string | null = '';
|
||||
const fetcher = async (_url: string, etag: string | null) => {
|
||||
observed = etag;
|
||||
return {status: 304, etag: 'abc', json: null} as FetchResult;
|
||||
};
|
||||
await checkLatestRelease({fetcher, prevEtag: 'old', repo: 'ether/etherpad'});
|
||||
expect(observed).toBe('old');
|
||||
});
|
||||
|
||||
it('returns error when required fields are missing', async () => {
|
||||
const fetcher = async () => ({
|
||||
status: 200,
|
||||
etag: 'abc',
|
||||
json: {body: 'no tag_name here'},
|
||||
} as FetchResult);
|
||||
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
|
||||
expect(r.kind).toBe('error');
|
||||
if (r.kind !== 'error') return;
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
it('returns error when tag_name is null', async () => {
|
||||
const fetcher = async () => ({
|
||||
status: 200,
|
||||
etag: 'abc',
|
||||
json: {tag_name: null, html_url: 'x', published_at: 'y'},
|
||||
} as FetchResult);
|
||||
const r = await checkLatestRelease({fetcher, prevEtag: null, repo: 'ether/etherpad'});
|
||||
expect(r.kind).toBe('error');
|
||||
});
|
||||
});
|
||||
119
src/tests/backend-new/specs/updater/state.test.ts
Normal file
119
src/tests/backend-new/specs/updater/state.test.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import {describe, it, expect, beforeEach} from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import {loadState, saveState} from '../../../../node/updater/state';
|
||||
import {EMPTY_STATE} from '../../../../node/updater/types';
|
||||
|
||||
let dir: string;
|
||||
const statePath = () => path.join(dir, 'update-state.json');
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'updater-state-'));
|
||||
});
|
||||
|
||||
describe('loadState', () => {
|
||||
it('returns empty state when file does not exist', async () => {
|
||||
const s = await loadState(statePath());
|
||||
expect(s).toEqual(EMPTY_STATE);
|
||||
});
|
||||
|
||||
it('round-trips a saved state', async () => {
|
||||
const s = {...EMPTY_STATE, lastCheckAt: '2026-04-25T00:00:00Z'};
|
||||
await saveState(statePath(), s);
|
||||
const loaded = await loadState(statePath());
|
||||
expect(loaded.lastCheckAt).toBe('2026-04-25T00:00:00Z');
|
||||
});
|
||||
|
||||
it('returns empty state when file is corrupt', async () => {
|
||||
await fs.writeFile(statePath(), 'not json');
|
||||
const s = await loadState(statePath());
|
||||
expect(s).toEqual(EMPTY_STATE);
|
||||
});
|
||||
|
||||
it('returns empty state when schemaVersion is unknown', async () => {
|
||||
await fs.writeFile(statePath(), JSON.stringify({schemaVersion: 999}));
|
||||
const s = await loadState(statePath());
|
||||
expect(s).toEqual(EMPTY_STATE);
|
||||
});
|
||||
|
||||
it('returns empty state when email is null', async () => {
|
||||
// Regression: typeof null === 'object', so a hand-edited file with email:null
|
||||
// would have passed an earlier shape check and crashed downstream consumers.
|
||||
const broken = {...EMPTY_STATE, email: null};
|
||||
await fs.writeFile(statePath(), JSON.stringify(broken));
|
||||
const s = await loadState(statePath());
|
||||
expect(s).toEqual(EMPTY_STATE);
|
||||
});
|
||||
|
||||
it('returns empty state when latest is an array', async () => {
|
||||
const broken = {...EMPTY_STATE, latest: []};
|
||||
await fs.writeFile(statePath(), JSON.stringify(broken));
|
||||
const s = await loadState(statePath());
|
||||
expect(s).toEqual(EMPTY_STATE);
|
||||
});
|
||||
|
||||
it('returns empty state when latest is missing required subfields', async () => {
|
||||
// Regression: top-level shape passed earlier validation but downstream code
|
||||
// calls .trim() / semver parsing on latest.version → crash on bad input.
|
||||
const broken = {...EMPTY_STATE, latest: {version: 1}};
|
||||
await fs.writeFile(statePath(), JSON.stringify(broken));
|
||||
const s = await loadState(statePath());
|
||||
expect(s).toEqual(EMPTY_STATE);
|
||||
});
|
||||
|
||||
it('returns empty state when vulnerableBelow entries miss threshold', async () => {
|
||||
const broken = {...EMPTY_STATE, vulnerableBelow: [{announcedBy: 'v1.0.0'}]};
|
||||
await fs.writeFile(statePath(), JSON.stringify(broken));
|
||||
const s = await loadState(statePath());
|
||||
expect(s).toEqual(EMPTY_STATE);
|
||||
});
|
||||
|
||||
it('returns empty state when vulnerableBelow.threshold is non-string', async () => {
|
||||
const broken = {...EMPTY_STATE, vulnerableBelow: [{announcedBy: 'v1', threshold: 123}]};
|
||||
await fs.writeFile(statePath(), JSON.stringify(broken));
|
||||
const s = await loadState(statePath());
|
||||
expect(s).toEqual(EMPTY_STATE);
|
||||
});
|
||||
|
||||
it('returns empty state when email subfield is wrong type', async () => {
|
||||
const broken = {...EMPTY_STATE, email: {severeAt: 0, vulnerableAt: null, vulnerableNewReleaseTag: null}};
|
||||
await fs.writeFile(statePath(), JSON.stringify(broken));
|
||||
const s = await loadState(statePath());
|
||||
expect(s).toEqual(EMPTY_STATE);
|
||||
});
|
||||
|
||||
it('accepts a fully-typed latest payload', async () => {
|
||||
const good = {
|
||||
...EMPTY_STATE,
|
||||
latest: {
|
||||
version: '2.7.2',
|
||||
tag: 'v2.7.2',
|
||||
body: 'release notes',
|
||||
publishedAt: '2026-04-25T00:00:00Z',
|
||||
htmlUrl: 'https://example.invalid/r/v2.7.2',
|
||||
prerelease: false,
|
||||
},
|
||||
};
|
||||
await fs.writeFile(statePath(), JSON.stringify(good));
|
||||
const s = await loadState(statePath());
|
||||
expect(s.latest?.version).toBe('2.7.2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveState', () => {
|
||||
it('writes atomically (no partial file on crash simulation)', async () => {
|
||||
// We cannot easily simulate a crash, but we can verify the write went via a tmp file
|
||||
// by checking only one file ends up in the dir.
|
||||
await saveState(statePath(), EMPTY_STATE);
|
||||
const entries = await fs.readdir(dir);
|
||||
expect(entries).toEqual(['update-state.json']);
|
||||
});
|
||||
|
||||
it('creates the directory if missing', async () => {
|
||||
const nested = path.join(dir, 'nested', 'deep', 'update-state.json');
|
||||
await saveState(nested, EMPTY_STATE);
|
||||
const data = JSON.parse(await fs.readFile(nested, 'utf8'));
|
||||
expect(data.schemaVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
92
src/tests/backend-new/specs/updater/versionCompare.test.ts
Normal file
92
src/tests/backend-new/specs/updater/versionCompare.test.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import {describe, it, expect} from 'vitest';
|
||||
import {
|
||||
parseSemver,
|
||||
compareSemver,
|
||||
isMajorBehind,
|
||||
parseVulnerableBelow,
|
||||
isVulnerable,
|
||||
} from '../../../../node/updater/versionCompare';
|
||||
|
||||
describe('parseSemver', () => {
|
||||
it('parses a plain version', () => {
|
||||
expect(parseSemver('2.7.1')).toEqual({major: 2, minor: 7, patch: 1});
|
||||
});
|
||||
it('strips leading v', () => {
|
||||
expect(parseSemver('v2.7.1')).toEqual({major: 2, minor: 7, patch: 1});
|
||||
});
|
||||
it('returns null for garbage', () => {
|
||||
expect(parseSemver('garbage')).toBeNull();
|
||||
expect(parseSemver('')).toBeNull();
|
||||
expect(parseSemver('2.7')).toBeNull();
|
||||
});
|
||||
it('strips prerelease suffix', () => {
|
||||
expect(parseSemver('2.7.1-rc.1')).toEqual({major: 2, minor: 7, patch: 1});
|
||||
expect(parseSemver('v2.7.1-beta')).toEqual({major: 2, minor: 7, patch: 1});
|
||||
});
|
||||
it('strips build metadata', () => {
|
||||
expect(parseSemver('2.7.1+build.123')).toEqual({major: 2, minor: 7, patch: 1});
|
||||
});
|
||||
it('rejects four-part versions', () => {
|
||||
expect(parseSemver('2.7.1.4')).toBeNull();
|
||||
expect(parseSemver('2.7.1.foo')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareSemver', () => {
|
||||
it('orders correctly', () => {
|
||||
expect(compareSemver('2.7.1', '2.7.2')).toBe(-1);
|
||||
expect(compareSemver('2.7.2', '2.7.1')).toBe(1);
|
||||
expect(compareSemver('2.7.2', '2.7.2')).toBe(0);
|
||||
expect(compareSemver('3.0.0', '2.99.99')).toBe(1);
|
||||
});
|
||||
it('returns 0 if either is unparsable', () => {
|
||||
expect(compareSemver('garbage', '2.7.1')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMajorBehind', () => {
|
||||
it('true when at least one major behind', () => {
|
||||
expect(isMajorBehind('2.7.1', '3.0.0')).toBe(true);
|
||||
expect(isMajorBehind('2.7.1', '4.0.0')).toBe(true);
|
||||
});
|
||||
it('false otherwise', () => {
|
||||
expect(isMajorBehind('2.7.1', '2.99.99')).toBe(false);
|
||||
expect(isMajorBehind('3.0.0', '3.0.0')).toBe(false);
|
||||
expect(isMajorBehind('3.0.0', '2.7.1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseVulnerableBelow', () => {
|
||||
it('extracts directive from release body', () => {
|
||||
const body = 'Fixes a few things.\n<!-- updater: vulnerable-below 2.6.4 -->\nMore notes.';
|
||||
expect(parseVulnerableBelow(body)).toBe('2.6.4');
|
||||
});
|
||||
it('tolerates whitespace and casing', () => {
|
||||
expect(parseVulnerableBelow('<!--updater:vulnerable-below 1.0.0-->')).toBe('1.0.0');
|
||||
expect(parseVulnerableBelow('<!-- UPDATER: VULNERABLE-BELOW 1.0.0 -->')).toBe('1.0.0');
|
||||
});
|
||||
it('returns null when absent or malformed', () => {
|
||||
expect(parseVulnerableBelow('no directive here')).toBeNull();
|
||||
expect(parseVulnerableBelow('<!-- updater: vulnerable-below garbage -->')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVulnerable', () => {
|
||||
it('true if current strictly below any directive threshold', () => {
|
||||
expect(isVulnerable('2.6.3', [
|
||||
{announcedBy: 'v2.7.0', threshold: '2.6.4'},
|
||||
])).toBe(true);
|
||||
});
|
||||
it('false at or above all thresholds', () => {
|
||||
expect(isVulnerable('2.6.4', [
|
||||
{announcedBy: 'v2.7.0', threshold: '2.6.4'},
|
||||
])).toBe(false);
|
||||
expect(isVulnerable('2.7.0', [])).toBe(false);
|
||||
});
|
||||
it('handles multiple directives', () => {
|
||||
expect(isVulnerable('1.5.0', [
|
||||
{announcedBy: 'v2.0.0', threshold: '2.0.0'},
|
||||
{announcedBy: 'v3.0.0', threshold: '1.9.0'},
|
||||
])).toBe(true);
|
||||
});
|
||||
});
|
||||
144
src/tests/backend/specs/updateStatus.ts
Normal file
144
src/tests/backend/specs/updateStatus.ts
Normal file
@ -0,0 +1,144 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert').strict;
|
||||
const common = require('../common');
|
||||
const plugins = require('../../../static/js/pluginfw/plugin_defs');
|
||||
import settings from '../../../node/utils/Settings';
|
||||
import {saveState} from '../../../node/updater/state';
|
||||
import {EMPTY_STATE} from '../../../node/updater/types';
|
||||
import path from 'node:path';
|
||||
|
||||
const statePath = () => path.join(settings.root, 'var', 'update-state.json');
|
||||
|
||||
// Hook names that plugins can register to influence auth decisions.
|
||||
const authHookNames = ['preAuthorize', 'authenticate', 'authorize'];
|
||||
const failHookNames = ['preAuthzFailure', 'authnFailure', 'authzFailure', 'authFailure'];
|
||||
|
||||
describe(__filename, function () {
|
||||
let agent: any;
|
||||
const backups: Record<string, any> = {};
|
||||
|
||||
before(async function () {
|
||||
agent = await common.init();
|
||||
});
|
||||
|
||||
beforeEach(async function () {
|
||||
// Reset the route module's badge cache so each test sees fresh state.
|
||||
const mod = require('../../../node/hooks/express/updateStatus');
|
||||
if (typeof mod._resetBadgeCacheForTests === 'function') {
|
||||
mod._resetBadgeCacheForTests();
|
||||
}
|
||||
// Save auth settings and hooks so we can restore after each test.
|
||||
backups.hooks = {};
|
||||
for (const hookName of authHookNames.concat(failHookNames)) {
|
||||
backups.hooks[hookName] = plugins.hooks[hookName];
|
||||
}
|
||||
backups.settings = {};
|
||||
for (const key of ['requireAuthentication', 'requireAuthorization', 'users']) {
|
||||
backups.settings[key] = (settings as any)[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
Object.assign(plugins.hooks, backups.hooks);
|
||||
Object.assign(settings, backups.settings);
|
||||
});
|
||||
|
||||
describe('GET /api/version-status', function () {
|
||||
it('returns null when no state', async function () {
|
||||
await saveState(statePath(), {...EMPTY_STATE});
|
||||
const res = await agent.get('/api/version-status').expect(200);
|
||||
assert.deepEqual(res.body, {outdated: null});
|
||||
});
|
||||
|
||||
it('does not leak the running version', async function () {
|
||||
const res = await agent.get('/api/version-status').expect(200);
|
||||
assert.ok(!('version' in res.body), 'response leaks version field');
|
||||
assert.ok(!('latest' in res.body), 'response leaks latest field');
|
||||
assert.ok(!('currentVersion' in res.body), 'response leaks currentVersion field');
|
||||
});
|
||||
|
||||
it('returns severe when running > 1 major behind', async function () {
|
||||
// Force "latest" to be 99.0.0 so our running version is severely outdated.
|
||||
await saveState(statePath(), {
|
||||
...EMPTY_STATE,
|
||||
latest: {
|
||||
version: '99.0.0', tag: 'v99.0.0', body: '',
|
||||
publishedAt: '2099-01-01T00:00:00Z', prerelease: false,
|
||||
htmlUrl: 'https://example/',
|
||||
},
|
||||
});
|
||||
const res = await agent.get('/api/version-status').expect(200);
|
||||
assert.equal(res.body.outdated, 'severe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /admin/update/status', function () {
|
||||
// Auth on this endpoint is intentionally loose: the running version is already
|
||||
// exposed publicly via /health (releaseId), and latest/changelog come from a
|
||||
// public GitHub release. Admins who want the endpoint gone set updates.tier=off,
|
||||
// which removes route registration entirely (covered by the unit test for the
|
||||
// hook). Here we just assert the basic shape.
|
||||
it('returns the expected shape', async function () {
|
||||
await saveState(statePath(), {...EMPTY_STATE});
|
||||
const res = await agent.get('/admin/update/status').expect(200);
|
||||
assert.ok(typeof res.body.currentVersion === 'string');
|
||||
assert.equal(res.body.latest, null);
|
||||
assert.equal(res.body.tier, settings.updates.tier);
|
||||
assert.ok(Array.isArray(res.body.vulnerableBelow));
|
||||
});
|
||||
|
||||
describe('when updates.requireAdminForStatus = true', function () {
|
||||
const restore: Record<string, any> = {};
|
||||
beforeEach(function () {
|
||||
restore.requireAdminForStatus = settings.updates.requireAdminForStatus;
|
||||
settings.updates.requireAdminForStatus = true;
|
||||
});
|
||||
afterEach(function () {
|
||||
settings.updates.requireAdminForStatus = restore.requireAdminForStatus;
|
||||
});
|
||||
|
||||
it('rejects unauthenticated requests with 401', async function () {
|
||||
await agent.get('/admin/update/status').expect(401);
|
||||
});
|
||||
|
||||
it('rejects authenticated non-admin sessions with 403', async function () {
|
||||
// Inject a session via authenticate hook: any request becomes user "guest" (not admin).
|
||||
for (const hookName of authHookNames.concat(failHookNames)) {
|
||||
plugins.hooks[hookName] = [];
|
||||
}
|
||||
plugins.hooks.authenticate = [{
|
||||
hook_fn: (_hookName: string, ctx: any, cb: Function) => {
|
||||
ctx.req.session.user = {is_admin: false};
|
||||
cb([true]);
|
||||
},
|
||||
}];
|
||||
(settings as any).requireAuthentication = true;
|
||||
(settings as any).requireAuthorization = false;
|
||||
(settings as any).users = {guest: {password: 'guest-password'}};
|
||||
await agent.get('/admin/update/status')
|
||||
.auth('guest', 'guest-password')
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('admits authenticated admin sessions', async function () {
|
||||
for (const hookName of authHookNames.concat(failHookNames)) {
|
||||
plugins.hooks[hookName] = [];
|
||||
}
|
||||
plugins.hooks.authenticate = [{
|
||||
hook_fn: (_hookName: string, ctx: any, cb: Function) => {
|
||||
ctx.req.session.user = {is_admin: true};
|
||||
cb([true]);
|
||||
},
|
||||
}];
|
||||
(settings as any).requireAuthentication = true;
|
||||
(settings as any).requireAuthorization = false;
|
||||
(settings as any).users = {admin: {password: 'admin-password', is_admin: true}};
|
||||
await saveState(statePath(), {...EMPTY_STATE});
|
||||
await agent.get('/admin/update/status')
|
||||
.auth('admin', 'admin-password')
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -14,7 +14,8 @@ test('Shows troubleshooting page manager', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/help')
|
||||
await page.waitForSelector('.menu')
|
||||
const menu = page.locator('.menu');
|
||||
await expect(menu.locator('li')).toHaveCount(5);
|
||||
// Sidebar nav: plugins, settings, help, pads, shout, update.
|
||||
await expect(menu.locator('li')).toHaveCount(6);
|
||||
})
|
||||
|
||||
test('Shows a version number', async function ({page}) {
|
||||
|
||||
72
src/tests/frontend-new/admin-spec/update-banner.spec.ts
Normal file
72
src/tests/frontend-new/admin-spec/update-banner.spec.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import {expect, test} from "@playwright/test";
|
||||
import {loginToAdmin} from "../helper/adminhelper";
|
||||
|
||||
test.describe('admin update page', () => {
|
||||
test.beforeEach(async ({page}) => {
|
||||
await loginToAdmin(page, 'admin', 'changeme1');
|
||||
});
|
||||
|
||||
test('exposes the update nav link', async ({page}) => {
|
||||
await page.goto('http://localhost:9001/admin/');
|
||||
// Bell-icon link with i18nKey "update.page.title" → label "Etherpad updates".
|
||||
const link = page.getByRole('link', {name: /etherpad updates/i});
|
||||
await expect(link).toBeVisible({timeout: 30000});
|
||||
});
|
||||
|
||||
test('update page renders current version when status fetch returns valid payload', async ({page}) => {
|
||||
// Stub the status endpoint so the test does not depend on real GitHub state.
|
||||
await page.route('**/admin/update/status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
currentVersion: '2.7.1',
|
||||
latest: null,
|
||||
lastCheckAt: null,
|
||||
installMethod: 'git',
|
||||
tier: 'notify',
|
||||
policy: null,
|
||||
vulnerableBelow: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:9001/admin/update');
|
||||
// h1 from <Trans i18nKey="update.page.title"/> → "Etherpad updates"
|
||||
await expect(page.getByRole('heading', {name: /etherpad updates/i})).toBeVisible({timeout: 30000});
|
||||
// Current-version <dd> shows 2.7.1
|
||||
await expect(page.getByText('2.7.1').first()).toBeVisible();
|
||||
// up-to-date message because latest is null
|
||||
await expect(page.getByText(/running the latest version/i)).toBeVisible();
|
||||
});
|
||||
|
||||
test('banner appears when latest > current', async ({page}) => {
|
||||
await page.route('**/admin/update/status', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
currentVersion: '2.7.1',
|
||||
latest: {
|
||||
version: '2.7.2',
|
||||
tag: 'v2.7.2',
|
||||
body: 'Some changes.',
|
||||
publishedAt: '2026-04-25T00:00:00Z',
|
||||
prerelease: false,
|
||||
htmlUrl: 'https://github.com/ether/etherpad/releases/tag/v2.7.2',
|
||||
},
|
||||
lastCheckAt: '2026-04-25T00:00:00Z',
|
||||
installMethod: 'git',
|
||||
tier: 'notify',
|
||||
policy: {canNotify: true, canManual: false, canAuto: false, canAutonomous: false, reason: 'install-method-not-writable'},
|
||||
vulnerableBelow: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('http://localhost:9001/admin/');
|
||||
// Banner copy: "Update available" + "Etherpad 2.7.2 is available (you are running 2.7.1)."
|
||||
await expect(page.getByText(/update available/i).first()).toBeVisible({timeout: 30000});
|
||||
await expect(page.getByText(/2\.7\.2/).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
47
src/tests/frontend-new/specs/pad-version-badge.spec.ts
Normal file
47
src/tests/frontend-new/specs/pad-version-badge.spec.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
|
||||
const padUrl = (id = `test-${Date.now()}-${Math.floor(Math.random() * 1e6)}`) =>
|
||||
`http://localhost:9001/p/${id}`;
|
||||
|
||||
test.describe('pad version badge', () => {
|
||||
test('hidden when /api/version-status returns outdated:null', async ({page}) => {
|
||||
await page.route('**/api/version-status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({outdated: null}),
|
||||
}));
|
||||
await page.goto(padUrl());
|
||||
const badge = page.locator('#version-badge');
|
||||
// The badge is rendered hidden (display:none) and stays hidden.
|
||||
await expect(badge).toBeHidden({timeout: 30000});
|
||||
});
|
||||
|
||||
test('shows severe text when outdated=severe', async ({page}) => {
|
||||
await page.route('**/api/version-status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({outdated: 'severe'}),
|
||||
}));
|
||||
await page.goto(padUrl());
|
||||
const badge = page.locator('#version-badge');
|
||||
await expect(badge).toBeVisible({timeout: 30000});
|
||||
await expect(badge).toContainText(/severely outdated/i);
|
||||
await expect(badge).toHaveAttribute('data-level', 'severe');
|
||||
});
|
||||
|
||||
test('shows vulnerable text when outdated=vulnerable', async ({page}) => {
|
||||
await page.route('**/api/version-status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({outdated: 'vulnerable'}),
|
||||
}));
|
||||
await page.goto(padUrl());
|
||||
const badge = page.locator('#version-badge');
|
||||
await expect(badge).toBeVisible({timeout: 30000});
|
||||
await expect(badge).toContainText(/security issues/i);
|
||||
await expect(badge).toHaveAttribute('data-level', 'vulnerable');
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user