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>
204 lines
9.0 KiB
JavaScript
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 };
|