etherpad-lite/src/node/hooks/express/updateStatus.ts
SamTV12345 ca4b015fcf fix(vitest): alias ep_etherpad-lite/* to source to avoid double-loading
When internal code imported via the exports map (ep_etherpad-lite/node/x)
AND via a relative path (../../node/x), vite-node resolved two distinct
module instances. Prom-client top-level Counter() calls ran twice and
threw "metric already registered", cascading ~35 test failures. Fix adds a
resolve.alias in vitest.config.ts that rewrites ep_etherpad-lite/<subpath>
to the .ts source, so the two spellings collapse to one module instance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 13:23:34 +02:00

224 lines
9.1 KiB
TypeScript

'use strict';
import path from 'node:path';
import {LRUCache} from 'lru-cache';
import {ArgsExpressType} from '../../types/ArgsExpressType.js';
import settings, {getEpVersion} from '../../utils/Settings.js';
import {getDetectedInstallMethod, stateFilePath} from '../../updater/index.js';
import {evaluatePolicy} from '../../updater/UpdatePolicy.js';
import {isMinorOrMoreBehind} from '../../updater/versionCompare.js';
import {loadState} from '../../updater/state.js';
import {isHeld} from '../../updater/lock.js';
import {nextWindowStart, parseWindow} from '../../updater/MaintenanceWindow.js';
/**
* Returns the authorID of whoever first contributed to the pad — i.e. the
* `['author', X]` entry at the lowest numeric key in the pool, with empty-X
* placeholders skipped. Returns null for a pad with no real author attribs yet.
*/
export const firstAuthorOf = (pad: {pool?: {numToAttrib?: Record<number | string, unknown>}}): string | null => {
const num2attrib = pad?.pool?.numToAttrib;
if (!num2attrib) return null;
const keys = Object.keys(num2attrib).map(Number).sort((a, b) => a - b);
for (const k of keys) {
const a = num2attrib[k];
if (Array.isArray(a) && a[0] === 'author' && typeof a[1] === 'string' && a[1] !== '') {
return a[1];
}
}
return null;
};
/**
* Resolve the pad-visitor's authorID from the HttpOnly token cookie. This
* mirrors how Etherpad's own socket.io handshake resolves pad-visitor identity:
* the browser sends the `token` cookie (or `<prefix>token`) with every
* same-origin request, and `authorManager.getAuthorId(token, user)` maps the
* token to a stable authorID via the `token2author` DB key.
*
* We do NOT use `req.session.user.author` because Etherpad does not populate
* an authorID into the express-session `user` object for pad visitors, so that
* field is always undefined in production.
*
* On any failure path we return null and the caller treats the request as
* anonymous, resulting in an EMPTY (no-badge) response.
*/
export const resolveRequestAuthor = async (req: any): Promise<string | null> => {
try {
const cookiePrefix = (settings as any).cookie?.prefix ?? '';
const token = req?.cookies?.[`${cookiePrefix}token`];
if (typeof token !== 'string' || token === '') return null;
const authorManagerMod: any = await import('../../db/AuthorManager.js');
const authorManager = authorManagerMod.default ?? authorManagerMod;
if (typeof authorManager.getAuthorId !== 'function') return null;
const authorId = await authorManager.getAuthorId(token, req?.session?.user);
return typeof authorId === 'string' && authorId !== '' ? authorId : null;
} catch {
return null;
}
};
interface OutdatedResponse {
outdated: 'minor' | null;
isFirstAuthor: boolean;
}
const EMPTY: OutdatedResponse = {outdated: null, isFirstAuthor: false};
const TTL_MS = 60 * 1000;
let cache = new LRUCache<string, {value: OutdatedResponse; at: number}>({max: 1000});
const inFlight = new Map<string, Promise<OutdatedResponse>>();
/** Test-only setter: rebuild the LRU with a smaller cap so eviction can be asserted. */
export const _setBadgeCacheCapForTests = (max: number): void => {
cache = new LRUCache<string, {value: OutdatedResponse; at: number}>({max});
};
/** Test-only: clear the in-memory badge cache so integration tests see fresh state. */
export const _resetBadgeCacheForTests = (): void => {
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.js');
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();
};