Files
cloudron-box/src/notifications.js
2023-09-26 14:14:09 +02:00

309 lines
12 KiB
JavaScript

'use strict';
exports = module.exports = {
get,
update,
list,
del,
onEvent,
// these are notification types, keep in sync with client.js
ALERT_CLOUDRON_INSTALLED: 'cloudronInstalled',
ALERT_CLOUDRON_UPDATED: 'cloudronUpdated',
ALERT_CLOUDRON_UPDATE_FAILED: 'cloudronUpdateFailed',
ALERT_CERTIFICATE_RENEWAL_FAILED: 'certificateRenewalFailed',
ALERT_BACKUP_CONFIG: 'backupConfig',
ALERT_DISK_SPACE: 'diskSpace',
ALERT_MAIL_STATUS: 'mailStatus',
ALERT_REBOOT: 'reboot',
ALERT_BOX_UPDATE: 'boxUpdate',
ALERT_UPDATE_UBUNTU: 'ubuntuUpdate',
ALERT_MANUAL_APP_UPDATE: 'manualAppUpdate',
ALERT_APP_OOM: 'appOutOfMemory',
ALERT_APP_UPDATED: 'appUpdated',
ALERT_BACKUP_FAILED: 'backupFailed',
alert,
clearAlert,
// exported for testing
_add: add
};
const assert = require('assert'),
AuditSource = require('./auditsource.js'),
BoxError = require('./boxerror.js'),
changelog = require('./changelog.js'),
constants = require('./constants.js'),
dashboard = require('./dashboard.js'),
database = require('./database.js'),
eventlog = require('./eventlog.js'),
mailer = require('./mailer.js'),
users = require('./users.js');
const NOTIFICATION_FIELDS = [ 'id', 'eventId', 'type', 'title', 'message', 'creationTime', 'acknowledged' ];
function postProcess(result) {
assert.strictEqual(typeof result, 'object');
result.id = String(result.id);
result.acknowledged = !!result.acknowledged; // convert to boolean
return result;
}
async function add(type, title, message, data) {
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof title, 'string');
assert.strictEqual(typeof message, 'string');
assert.strictEqual(typeof data, 'object');
const query = 'INSERT INTO notifications (type, title, message, acknowledged, eventId) VALUES (?, ?, ?, ?, ?)';
const args = [ type, title, message, false, data?.eventId || null ];
const result = await database.query(query, args);
return String(result.insertId);
}
async function get(id) {
assert.strictEqual(typeof id, 'string');
const result = await database.query(`SELECT ${NOTIFICATION_FIELDS} FROM notifications WHERE id = ?`, [ id ]);
if (result.length === 0) return null;
return postProcess(result[0]);
}
async function getByTitle(title) {
assert.strictEqual(typeof title, 'string');
const results = await database.query(`SELECT ${NOTIFICATION_FIELDS} from notifications WHERE title = ? ORDER BY creationTime LIMIT 1`, [ title ]);
if (results.length === 0) return null;
return postProcess(results[0]);
}
async function update(notification, data) {
assert.strictEqual(typeof notification, 'object');
assert.strictEqual(typeof data, 'object');
const args = [];
const fields = [];
for (const k in data) {
fields.push(k + ' = ?');
args.push(data[k]);
}
args.push(notification.id);
const result = await database.query('UPDATE notifications SET ' + fields.join(', ') + ' WHERE id = ?', args);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Notification not found');
}
async function del(id) {
assert.strictEqual(typeof id, 'string');
const result = await database.query('DELETE FROM notifications WHERE id = ?', [ id ]);
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Notification not found');
}
async function list(filters, page, perPage) {
assert.strictEqual(typeof filters, 'object');
assert.strictEqual(typeof page, 'number');
assert.strictEqual(typeof perPage, 'number');
const args = [];
const where = [];
if ('acknowledged' in filters) {
where.push('acknowledged=?');
args.push(filters.acknowledged);
}
let query = `SELECT ${NOTIFICATION_FIELDS} FROM notifications`;
if (where.length) query += ' WHERE ' + where.join(' AND ');
query += ' ORDER BY creationTime DESC LIMIT ?,?';
args.push((page-1)*perPage);
args.push(perPage);
const results = await database.query(query, args);
results.forEach(postProcess);
return results;
}
async function oomEvent(eventId, containerId, app, addonName /*, event*/) {
assert.strictEqual(typeof eventId, 'string');
assert.strictEqual(typeof containerId, 'string');
assert.strictEqual(typeof app, 'object');
assert(addonName === null || typeof addonName === 'string');
assert(app || addonName);
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
let title, message;
if (addonName) {
if (app) {
title = `The ${addonName} service of the app at ${app.fqdn} ran out of memory`;
} else {
title = `The ${addonName} service ran out of memory`;
}
message = `The service has been restarted automatically. If you see this notification often, consider increasing the [memory limit](https://${dashboardFqdn}/#/services)`;
} else if (app) {
title = `The app at ${app.fqdn} ran out of memory.`;
message = `The app has been restarted automatically. If you see this notification often, consider increasing the [memory limit](https://${dashboardFqdn}/#/app/${app.id}/resources)`;
}
await add(exports.ALERT_APP_OOM, title, message, { eventId });
}
async function appUpdated(eventId, app, fromManifest, toManifest) {
assert.strictEqual(typeof eventId, 'string');
assert.strictEqual(typeof app, 'object');
assert.strictEqual(typeof fromManifest, 'object');
assert.strictEqual(typeof toManifest, 'object');
if (!app.appStoreId) return; // skip notification of dev apps
const tmp = toManifest.description.match(/<upstream>(.*)<\/upstream>/i);
const upstreamVersion = (tmp && tmp[1]) ? tmp[1] : '';
const title = upstreamVersion ? `${toManifest.title} at ${app.fqdn} updated to ${upstreamVersion} (package version ${toManifest.version})`
: `${toManifest.title} at ${app.fqdn} updated to package version ${toManifest.version}`;
await add(exports.ALERT_APP_UPDATED, title, `The application installed at https://${app.fqdn} was updated.\n\nChangelog:\n${toManifest.changelog}\n`, { eventId });
}
async function boxInstalled(eventId, version) {
assert.strictEqual(typeof eventId, 'string');
assert.strictEqual(typeof version, 'string');
const changes = changelog.getChanges(version.replace(/\.([^.]*)$/, '.0')); // last .0 release
const changelogMarkdown = changes.map((m) => `* ${m}\n`).join('');
await add(exports.ALERT_CLOUDRON_INSTALLED, `Cloudron v${version} installed`, `Cloudron v${version} was installed.\n\nPlease join our community at ${constants.FORUM_URL} .\n\nChangelog:\n${changelogMarkdown}\n`, { eventId });
}
async function boxUpdated(eventId, oldVersion, newVersion) {
assert.strictEqual(typeof eventId, 'string');
assert.strictEqual(typeof oldVersion, 'string');
assert.strictEqual(typeof newVersion, 'string');
const changes = changelog.getChanges(newVersion);
const changelogMarkdown = changes.map((m) => `* ${m}\n`).join('');
await add(exports.ALERT_CLOUDRON_UPDATED, `Cloudron updated to v${newVersion}`, `Cloudron was updated from v${oldVersion} to v${newVersion}.\n\nChangelog:\n${changelogMarkdown}\n`, { eventId });
}
async function boxUpdateError(eventId, errorMessage) {
assert.strictEqual(typeof eventId, 'string');
assert.strictEqual(typeof errorMessage, 'string');
await add(exports.ALERT_CLOUDRON_UPDATE_FAILED, 'Cloudron update failed', `Failed to update Cloudron: ${errorMessage}.`, { eventId });
}
async function certificateRenewalError(eventId, fqdn, errorMessage) {
assert.strictEqual(typeof eventId, 'string');
assert.strictEqual(typeof fqdn, 'string');
assert.strictEqual(typeof errorMessage, 'string');
await add(exports.ALERT_CERTIFICATE_RENEWAL_FAILED, `Certificate renewal of ${fqdn} failed`, `Failed to renew certs of ${fqdn}: ${errorMessage}. Renewal will be retried in 12 hours.`, { eventId });
const admins = await users.getAdmins();
for (const admin of admins) {
await mailer.certificateRenewalError(admin.email, fqdn, errorMessage);
}
}
async function backupFailed(eventId, taskId, errorMessage) {
assert.strictEqual(typeof eventId, 'string');
assert.strictEqual(typeof taskId, 'string');
assert.strictEqual(typeof errorMessage, 'string');
await add(exports.ALERT_BACKUP_FAILED, 'Backup failed', `Backup failed: ${errorMessage}. Logs are available [here](/frontend/logs.html?taskId=${taskId}).`, { eventId });
// only send mail if the past 3 automated backups failed
const backupEvents = await eventlog.listPaged([eventlog.ACTION_BACKUP_FINISH], null /* search */, 1, 20);
let count = 0;
for (const event of backupEvents) {
if (!event.data.errorMessage) return; // successful backup (manual or cron)
if (event.source.username === AuditSource.CRON.username && ++count === 3) break; // last 3 consecutive crons have failed
}
if (count !== 3) return; // less than 3 failures
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
const superadmins = await users.getSuperadmins();
for (const superadmin of superadmins) {
await mailer.backupFailed(superadmin.email, errorMessage, `https://${dashboardFqdn}/frontend/logs.html?taskId=${taskId}`);
}
}
// type must be one of ALERT_*
async function alert(type, title, message, options) {
assert.strictEqual(typeof type, 'string');
assert.strictEqual(typeof title, 'string');
assert.strictEqual(typeof message, 'string');
assert.strictEqual(typeof options, 'object');
const result = await getByTitle(title);
if (!result) return await add(type, title, message, { eventId: null });
if (!options.persist) return result.id;
await update(result, {
id: result.id,
eventId: null,
type: type,
title,
message,
acknowledged: false,
creationTime: new Date()
});
return result.id;
}
// id is unused but nice to search code
async function clearAlert(type) {
assert.strictEqual(typeof type, 'string');
await database.query('DELETE FROM notifications WHERE type = ?', [ type ]);
return null;
}
async function onEvent(id, action, source, data) {
assert.strictEqual(typeof id, 'string');
assert.strictEqual(typeof action, 'string');
assert.strictEqual(typeof source, 'object');
assert.strictEqual(typeof data, 'object');
switch (action) {
case eventlog.ACTION_APP_OOM:
return await oomEvent(id, data.containerId, data.app, data.addonName, data.event);
case eventlog.ACTION_APP_UPDATE_FINISH:
if (source.username !== AuditSource.CRON.username) return; // updated by user
if (data.errorMessage) return; // the update indicator will still appear, so no need to notify user
return await appUpdated(id, data.app, data.fromManifest, data.toManifest);
case eventlog.ACTION_CERTIFICATE_RENEWAL:
case eventlog.ACTION_CERTIFICATE_NEW:
if (!data.errorMessage) return;
if (!data.notAfter || (data.notAfter - new Date() >= (10 * 24 * 60 * 60 * 1000))) return; // more than 10 days left to expire
return await certificateRenewalError(id, data.domain, data.errorMessage);
case eventlog.ACTION_BACKUP_FINISH:
if (!data.errorMessage) return;
if (source.username !== AuditSource.CRON.username && !data.timedOut) return; // manual stop by user
return await backupFailed(id, data.taskId, data.errorMessage); // only notify for automated backups or timedout
case eventlog.ACTION_INSTALL_FINISH:
return await boxInstalled(id, data.version);
case eventlog.ACTION_UPDATE_FINISH:
if (!data.errorMessage) return await boxUpdated(id, data.oldVersion, data.newVersion);
if (data.timedOut) return await boxUpdateError(id, data.errorMessage);
return;
default:
return;
}
}