Files
hope-events/backend/src/controllers/hubController.js
T
joshuaandClaude Opus 5.5 dd537a1a16 Fix: update request for an older version than the latest (1.47.4)
After asking for an update, a newer version coming out made the Licence
page claim the newer version was requested, with no way to ask for it.
Now it shows the version asked for, that a newer one is out, and "Ask
for X instead"; the banner shows again. Hub 1.5.0 moves the open request
on (`changed`); with an older Hub the site withdraws and asks again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 12:16:56 +02:00

204 lines
9.0 KiB
JavaScript

const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { getClientIp } = require('../utils/requestUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { licenceStatus } = require('../hub/licence');
const hub = require('../hub/client');
const { APP_VERSION } = require('../hub/report');
function compareVersions(a, b) {
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0);
const pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
return 0;
}
/**
* An update request still waiting: until this site runs the version that was asked for.
* `outdated`: a newer version than the one asked for is out, so the request can be changed to it.
*/
function pendingUpdateRequest(version, requestedAt, requestedVersion, latestVersion = null) {
if (!requestedAt || !requestedVersion || compareVersions(version, requestedVersion) >= 0) return null;
return { requestedAt, version: requestedVersion, outdated: !!latestVersion && compareVersions(latestVersion, requestedVersion) > 0 };
}
const str = (v, max) => String(v ?? '').slice(0, max);
/** What's new in each version newer than this one (from the Hub), newest first. */
function newerReleases(releases, version) {
return (releases || [])
.filter(r => r && /^\d+\.\d+\.\d+$/.test(String(r.version)) && compareVersions(r.version, version) > 0)
.sort((a, b) => compareVersions(b.version, a.version))
.map(r => ({ version: r.version, releasedAt: r.releasedAt || null, urgent: !!r.urgent, notes: str(r.notes, 20000) }));
}
/**
* "Not now" holds until a newer version than the one put off comes out. Urgent updates (security or
* serious bug fixes) can't be put off.
*/
function updateSnoozed({ latestVersion, snoozedVersion, urgent }) {
return !!(latestVersion && snoozedVersion && !urgent && compareVersions(snoozedVersion, latestVersion) >= 0);
}
const NOTICE_LEVELS = new Set(['info', 'maintenance', 'warning']);
/** The provider's notices showing now: started (or no start) and not ended. */
function activeNotices(notices, now = new Date()) {
return (notices || [])
.filter(n => n && n.id && (!n.startsAt || new Date(n.startsAt) <= now) && (!n.endsAt || new Date(n.endsAt) > now))
.map(n => ({
id: str(n.id, 64),
level: NOTICE_LEVELS.has(n.level) ? n.level : 'info',
title: str(n.title, 120),
message: str(n.message, 2000),
startsAt: n.startsAt || null,
endsAt: n.endsAt || null,
updatedAt: n.updatedAt || null,
}));
}
/** What the admin pages show: licence state, Hub connection, version and update. */
async function statusPayload({ fresh = false } = {}) {
const s = await licenceStatus({ fresh });
const releases = newerReleases(s.releases, APP_VERSION);
const updateAvailable = !!(s.latestVersion && compareVersions(APP_VERSION, s.latestVersion) < 0);
const urgentUpdate = updateAvailable && releases.some(r => r.urgent);
return {
state: s.state,
readOnly: s.readOnly,
daysLeft: s.daysLeft,
endsAt: s.endsAt,
graceEndsAt: s.graceEndsAt,
licence: s.claims ? {
customer: s.claims.customer,
plan: s.claims.plan,
status: s.claims.status,
licenceExpiresAt: s.claims.licenceExpiresAt,
graceDays: s.claims.graceDays,
issuedAt: s.claims.issuedAt,
validUntil: s.claims.validUntil,
limits: s.claims.limits || {},
} : null,
connected: s.connected,
keyConfigured: s.keyConfigured,
hubUrl: hub.hubUrl(),
lastHeartbeatAt: s.lastHeartbeatAt,
lastError: s.lastError,
version: APP_VERSION,
latestVersion: s.latestVersion,
updateAvailable,
urgentUpdate,
updateSnoozed: updateAvailable && updateSnoozed({ latestVersion: s.latestVersion, snoozedVersion: s.updateSnoozedVersion, urgent: urgentUpdate }),
releases,
updateRequest: pendingUpdateRequest(APP_VERSION, s.updateRequestedAt, s.updateRequestedVersion, s.latestVersion),
notices: activeNotices(s.notices),
};
}
// @desc Licence and Hub status (for the admin banner and Site settings → Licence)
// @route GET /api/hub/status
// @access Private/Admin
const getStatus = async (req, res) => {
try {
res.json(await statusPayload());
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
// @desc Connect this install to the Hub with a licence key (also the /setup licence step)
// @route POST /api/hub/connect body: { licenceKey }
// @access Private/Admin
const connect = async (req, res) => {
try {
const licenceKey = String(req.body?.licenceKey || '').trim();
if (!licenceKey) { res.status(400); throw new Error('Enter your licence key.'); }
const me = await prisma.user.findUnique({ where: { id: req.user.id }, select: { name: true, email: true } });
const claims = await hub.connect(licenceKey, me);
hub.heartbeatSoon();
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_connected', targetType: 'hub', metadata: { customer: claims.customer, hubUrl: hub.hubUrl() }, ip: getClientIp(req) });
res.json(await statusPayload({ fresh: true }));
} catch (e) {
const status = e.statusCode || (res.statusCode === 200 ? 400 : res.statusCode);
res.status(status >= 500 && e.code ? 502 : status).json({ message: e.message || safeErrorMessage(e), code: e.code });
}
};
// @desc Send a heartbeat now (checks the connection and picks up licence changes)
// @route POST /api/hub/heartbeat
// @access Private/Admin
const heartbeatNow = async (req, res) => {
try {
await hub.heartbeat();
res.json(await statusPayload({ fresh: true }));
} catch (e) {
res.status(502).json({ message: e.message || safeErrorMessage(e), code: e.code, status: await statusPayload({ fresh: true }).catch(() => null) });
}
};
// @desc Ask the Hub for this site to be updated (the provider is told; nothing runs here)
// @route POST /api/hub/update-request
// @access Private/Admin
const requestUpdate = async (req, res) => {
try {
const result = await hub.requestUpdate();
if (!result.alreadyRequested || result.changed) {
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_requested', targetType: 'hub',
metadata: { from: APP_VERSION, to: result.version, ...(result.changed ? { changedFrom: result.previousVersion || null } : {}) }, ip: getClientIp(req),
});
}
res.json(await statusPayload({ fresh: true }));
} catch (e) {
const status = e.statusCode && e.statusCode < 500 && e.code !== 'INSTALL_NOT_FOUND' ? e.statusCode : 502;
res.status(status).json({ message: e.message || safeErrorMessage(e), code: e.code });
}
};
// @desc Withdraw the update request (the provider is told not to update after all)
// @route POST /api/hub/update-request/cancel
// @access Private/Admin
const cancelUpdateRequest = async (req, res) => {
try {
await hub.cancelUpdateRequest();
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'hub_update_request_withdrawn', targetType: 'hub', metadata: { version: APP_VERSION }, ip: getClientIp(req) });
res.json(await statusPayload({ fresh: true }));
} catch (e) {
const status = e.statusCode && e.statusCode < 500 && e.code !== 'INSTALL_NOT_FOUND' ? e.statusCode : 502;
res.status(status).json({ message: e.message || safeErrorMessage(e), code: e.code });
}
};
// @desc "Not now": no update reminders until a newer version is out (snooze: false turns them back on)
// @route POST /api/hub/update-snooze body: { snooze }
// @access Private/Admin
const snoozeUpdate = async (req, res) => {
try {
const snooze = req.body?.snooze !== false;
const current = await statusPayload({ fresh: true });
if (snooze) {
if (!current.updateAvailable) { res.status(400); throw new Error('There is no update to put off.'); }
if (current.urgentUpdate) { res.status(400); throw new Error('This update fixes a security or serious problem, so it can\'t be put off.'); }
}
await hub.snoozeUpdate(snooze ? current.latestVersion : null);
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: snooze ? 'hub_update_snoozed' : 'hub_update_unsnoozed', targetType: 'hub', metadata: { version: current.latestVersion }, ip: getClientIp(req) });
res.json(await statusPayload({ fresh: true }));
} catch (e) {
res.status(res.statusCode === 200 ? 500 : res.statusCode).json({ message: safeErrorMessage(e) });
}
};
// @desc The provider's notices showing now (banner for site admins and supervisors)
// @route GET /api/hub/notices
// @access Private/Supervisor
const getNotices = async (req, res) => {
try {
const s = await licenceStatus();
res.json(activeNotices(s.notices));
} catch (e) {
res.status(500).json({ message: safeErrorMessage(e) });
}
};
module.exports = { getStatus, connect, heartbeatNow, requestUpdate, cancelUpdateRequest, snoozeUpdate, getNotices, statusPayload, compareVersions, pendingUpdateRequest, newerReleases, updateSnoozed, activeNotices };