mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-18 00:57:55 +00:00
* 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>
223 lines
8.8 KiB
TypeScript
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();
|
|
};
|