etherpad-lite/src/node/hooks/express/updateStatus.ts
John McLear 29dac6bfcc
fix(pad): redesign outdated-version notice (#7799) (#7804)
* docs: design spec for #7799 outdated-notice redesign

Per-pad first-author gating, dismissable gritter, minor-or-more rule, drop vulnerable UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: implementation plan for #7799 outdated-notice redesign

12 bite-sized tasks, TDD-first where applicable; closes the spec end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): add isMinorOrMoreBehind, drop major/vulnerable helpers

Adds isMinorOrMoreBehind(current, latest) which returns true only when
the latest release is at least one minor version ahead (patch-only deltas
return false). Removes isMajorBehind, parseVulnerableBelow, and
isVulnerable from versionCompare.ts — callers in updateStatus.ts,
VersionChecker.ts, and index.ts will be updated in subsequent tasks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(updater): drop vulnerable-below directive and state field

Remove VulnerableBelowDirective type, UpdateState.vulnerableBelow field, and
all related scraping/checking logic (parseVulnerableBelow, isVulnerable imports).
Clean up Notifier, OpenAPI schema, and all test fixtures to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(updater): drop residual EmailSendLog vulnerable fields

Remove `vulnerableAt` and `vulnerableNewReleaseTag` from the
`EmailSendLog` interface, `EMPTY_STATE`, and the `isValidEmail`
validator — these backed the removed `vulnerable`/`vulnerable-new-release`
email kinds and are now dead code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): add firstAuthorOf helper

Export firstAuthorOf() from updateStatus.ts — finds the lowest-numbered
author attrib in a pad's pool, skipping empty-string placeholders.
Covered by 6 vitest cases in tests/backend-new/specs/hooks/express/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): add resolveRequestAuthor helper for HTTP GET

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): pad-aware /api/version-status with first-author gating

Replace global badge cache with a per-(padId, authorId) LRU cache. The
new response shape is {outdated: 'minor' | null, isFirstAuthor: boolean};
the old 'severe'/'vulnerable' enum is dropped entirely. computeOutdated
now resolves the pad's first author and compares it against the session
author before returning outdated:'minor', so the notice is only shown to
the person who created the pad.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): switch isSevere signal from major-only to minor-or-more behind

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(updater): end-to-end coverage for /api/version-status

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(openapi): /api/version-status pad-aware shape and gating

Add the /api/version-status GET operation to the admin OpenAPI spec with
the new pad-aware response shape: outdated enum reduced to [minor]|null,
isFirstAuthor boolean, and an optional padId query param.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(pad): remove unused #version-badge template and CSS

* feat(pad): replace persistent badge with first-author outdated gritter

Renames pad_version_badge.ts → pad_outdated_notice.ts and rewrites it
as a fire-and-forget gritter notice that only shows when the API reports
outdated=minor AND the current user is the pad's first author.  Wires
the new maybeShowOutdatedNotice() call into pad.ts immediately after
showPrivacyBannerIfEnabled().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(pad): playwright coverage for outdated notice gritter

Six Playwright specs exercise maybeShowOutdatedNotice: null response,
isFirstAuthor:false guard, positive appearance + text, X-dismiss,
500 server error tolerance, and 8 s auto-fade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(pad): outdated-notice redesign + drop vulnerable-below docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(test): remove stale specs for deleted #version-badge surface

Delete the GET /api/version-status describe block from the legacy mocha
spec (asserted outdated:null and outdated:'severe' — both no longer match
the new response shape). The new vitest spec at
tests/backend-new/specs/hooks/express/updateStatus.test.ts covers this
surface comprehensively.

Delete src/tests/frontend-new/specs/pad-version-badge.spec.ts entirely:
all three tests reference the #version-badge DOM element removed in Task 8
and stub 'severe'/'vulnerable' enum values that no longer exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore: clean stale references to vulnerable/severe in types, emails, docs

- Remove OutdatedLevel type (null|'severe') from types.ts — no consumers
  remain after the badge redesign removed the severe tier.
- Fix Notifier severe-email body: was "more than one major release behind"
  but isSevere now fires on minor-or-more, so update to "at least one
  minor release behind the latest published version".
- Drop "vulnerability directives" from the /admin/update/status OpenAPI
  description; replace with the actual response fields.
- Remove stale vulnerableBelow field from UpdateStatusPayload in
  admin/src/store/store.ts — server no longer sends it.
- Fix docs/admin/updates.md: "pad-side badge" → "pad-side notice".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 12:23:40 +01:00

223 lines
8.8 KiB
TypeScript

'use strict';
import path from 'node:path';
import {LRUCache} from 'lru-cache';
import {ArgsExpressType} from '../../types/ArgsExpressType';
import settings, {getEpVersion} from '../../utils/Settings';
import {getDetectedInstallMethod, stateFilePath} from '../../updater';
import {evaluatePolicy} from '../../updater/UpdatePolicy';
import {isMinorOrMoreBehind} from '../../updater/versionCompare';
import {loadState} from '../../updater/state';
import {isHeld} from '../../updater/lock';
import {nextWindowStart, parseWindow} from '../../updater/MaintenanceWindow';
/**
* Returns the authorID of whoever first contributed to the pad — i.e. the
* `['author', X]` entry at the lowest numeric key in the pool, with empty-X
* placeholders skipped. Returns null for a pad with no real author attribs yet.
*/
export const firstAuthorOf = (pad: {pool?: {numToAttrib?: Record<number | string, unknown>}}): string | null => {
const num2attrib = pad?.pool?.numToAttrib;
if (!num2attrib) return null;
const keys = Object.keys(num2attrib).map(Number).sort((a, b) => a - b);
for (const k of keys) {
const a = num2attrib[k];
if (Array.isArray(a) && a[0] === 'author' && typeof a[1] === 'string' && a[1] !== '') {
return a[1];
}
}
return null;
};
/**
* Resolve the express-session author for a plain HTTP GET. The pad-side fetch
* is `credentials: 'same-origin'`, so the `express_sid` cookie is sent
* automatically. The global express-session middleware should have populated
* `req.session` already — but if not (e.g. test harness without middleware),
* we re-invoke it ourselves. On any failure path we return null and the
* caller treats the request as anonymous.
*/
export const resolveRequestAuthor = async (req: any): Promise<string | null> => {
const readAuthor = (): string | null => {
const a = req?.session?.user?.author;
return typeof a === 'string' && a !== '' ? a : null;
};
const fromSession = readAuthor();
if (fromSession !== null) return fromSession;
try {
const expressModule = await import('../express');
const mw = (expressModule as any).sessionMiddleware;
if (typeof mw !== 'function') return null;
await new Promise<void>((resolve, reject) => {
mw(req, {} as any, (err?: unknown) => (err ? reject(err) : resolve()));
});
} catch {
return null;
}
return readAuthor();
};
interface OutdatedResponse {
outdated: 'minor' | null;
isFirstAuthor: boolean;
}
const EMPTY: OutdatedResponse = {outdated: null, isFirstAuthor: false};
const TTL_MS = 60 * 1000;
let cache = new LRUCache<string, {value: OutdatedResponse; at: number}>({max: 1000});
const inFlight = new Map<string, Promise<OutdatedResponse>>();
/** Test-only setter: rebuild the LRU with a smaller cap so eviction can be asserted. */
export const _setBadgeCacheCapForTests = (max: number): void => {
cache = new LRUCache<string, {value: OutdatedResponse; at: number}>({max});
};
/** Test-only: clear the in-memory badge cache so integration tests see fresh state. */
export const _resetBadgeCacheForTests = (): void => {
cache.clear();
inFlight.clear();
};
const computeOutdated = async (
padId: string | null,
authorId: string | null,
): Promise<OutdatedResponse> => {
const state = await loadState(stateFilePath());
if (!state.latest) return EMPTY;
const current = getEpVersion();
if (!isMinorOrMoreBehind(current, state.latest.version)) return EMPTY;
if (!padId || !authorId) return EMPTY;
// padManager is loaded via dynamic import to avoid circular-init w/ updater.
const padManagerMod: any = await import('../../db/PadManager');
const padManager = padManagerMod.default ?? padManagerMod;
if (typeof padManager.isValidPadId !== 'function' || !padManager.isValidPadId(padId)) return EMPTY;
if (!(await padManager.doesPadExist(padId))) return EMPTY;
const pad = await padManager.getPad(padId);
if (firstAuthorOf(pad) !== authorId) return EMPTY;
return {outdated: 'minor', isFirstAuthor: true};
};
// Wrap an async Express handler so a rejected promise becomes next(err) rather than
// 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));
};
/**
* Strip diagnostic strings (reason, fromSha, targetTag, build/install paths)
* from execution before exposing to unauthenticated callers. Status enum is
* preserved so the admin banner / pad-side badge can still render the right UI.
*/
const sanitizeExecution = (e: any): any => {
if (!e || typeof e !== 'object' || typeof e.status !== 'string') return {status: 'idle'};
return {status: e.status};
};
const sanitizeLastResult = (r: any): any => {
if (r === null) return null;
if (!r || typeof r !== 'object' || typeof r.outcome !== 'string') return null;
// outcome enum + at timestamp are non-sensitive. reason / fromSha / targetTag are dropped.
return {outcome: r.outcome, at: typeof r.at === 'string' ? r.at : null};
};
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 per (padId, authorId) key.
app.get('/api/version-status', wrapAsync(async (req, res) => {
const padId = typeof req.query.padId === 'string' ? req.query.padId : null;
const authorId = await resolveRequestAuthor(req);
const key = `${padId ?? ''}|${authorId ?? ''}`;
const now = Date.now();
const hit = cache.get(key);
if (hit && now - hit.at <= TTL_MS) {
res.json(hit.value);
return;
}
let flight = inFlight.get(key);
if (!flight) {
flight = computeOutdated(padId, authorId).finally(() => { inFlight.delete(key); });
inFlight.set(key, flight);
}
const value = await flight;
cache.set(key, {value, at: now});
res.json(value);
}));
// 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) => {
const isAdmin = !!req.session?.user?.is_admin;
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,
executionStatus: state.execution.status,
maintenanceWindow: settings.updates.maintenanceWindow,
})
: null;
const lockHeld = await isHeld(path.join(settings.root, 'var', 'update.lock'));
// Tier 4: surface the configured window + the next opening so the admin UI
// can render the picker and the "deferred until..." subtitle on the
// scheduled panel. Non-admin requests get null for both fields (the parsed
// window is operational config, not a public datum).
const parsedWindow = parseWindow(settings.updates.maintenanceWindow);
const maintenanceWindow = isAdmin ? parsedWindow : null;
const nextWindowOpensAt = isAdmin && parsedWindow && settings.updates.tier === 'autonomous'
? nextWindowStart(new Date(), parsedWindow).toISOString()
: null;
// The Tier 2 fields (execution, lastResult) carry diagnostic strings
// built from git/pnpm stderr — environment-specific paths, error
// messages, etc. Endpoint defaults to unauthenticated; only authed
// admin sessions see the full diagnostic payload. Everyone else sees
// just the status enum + outcome enum so the pad-side / public banners
// can still render correctly without leaking operational detail.
const execution = isAdmin
? state.execution
: sanitizeExecution(state.execution);
const lastResult = isAdmin
? state.lastResult
: sanitizeLastResult(state.lastResult);
res.json({
currentVersion: current,
latest: state.latest,
lastCheckAt: state.lastCheckAt,
installMethod,
tier: settings.updates.tier,
policy,
// PR 2 additions:
execution,
lastResult,
lockHeld,
// PR 4 additions:
maintenanceWindow,
nextWindowOpensAt,
});
}));
cb();
};