mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-20 16:54:17 +00: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
38 changed files with 4636 additions and 4 deletions
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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue