'use strict'; exports = module.exports = { get, update, list, del, onEvent, // these are notification types, keep in sync with client.js TYPE_CLOUDRON_INSTALLED: 'cloudronInstalled', TYPE_CLOUDRON_UPDATED: 'cloudronUpdated', TYPE_CLOUDRON_UPDATE_FAILED: 'cloudronUpdateFailed', TYPE_CERTIFICATE_RENEWAL_FAILED: 'certificateRenewalFailed', TYPE_BACKUP_CONFIG: 'backupConfig', TYPE_APP_OOM: 'appOutOfMemory', TYPE_APP_UPDATED: 'appUpdated', TYPE_BACKUP_FAILED: 'backupFailed', TYPE_APP_DOWN: 'appDown', TYPE_APP_UP: 'appUp', // these are singleton types allowed in pin() and unpin() TYPE_DISK_SPACE: 'diskSpace', TYPE_MAIL_STATUS: 'mailStatus', TYPE_REBOOT: 'reboot', TYPE_UPDATE_UBUNTU: 'ubuntuUpdate', TYPE_BOX_UPDATE: 'boxUpdate', TYPE_MANUAL_APP_UPDATE_NEEDED: 'manualAppUpdate', TYPE_DOMAIN_CONFIG_CHECK_FAILED: 'domainConfigCheckFailed', // these work off singleton types pin, unpin, // exported for testing _add: add }; const assert = require('node: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'), debug = require('debug')('box:notifications'), eventlog = require('./eventlog.js'), mailer = require('./mailer.js'), safe = require('safetydance'), users = require('./users.js'); const NOTIFICATION_FIELDS = [ 'id', 'eventId', 'type', 'title', 'message', 'creationTime', 'acknowledged', 'context' ]; 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'); debug(`add: ${type} ${title}`); const query = 'INSERT INTO notifications (type, title, message, acknowledged, eventId, context) VALUES (?, ?, ?, ?, ?, ?)'; const args = [ type, title, message, false, data?.eventId || null, data.context || '' ]; 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 getByType(type, context) { assert.strictEqual(typeof type, 'string'); assert.strictEqual(typeof context, 'string'); const results = await database.query(`SELECT ${NOTIFICATION_FIELDS} from notifications WHERE type = ? AND context = ? ORDER BY creationTime LIMIT 1`, [ type, context || '']); 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 acknowledged ASC, 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.TYPE_APP_OOM, title, message, { eventId }); const admins = await users.getAdmins(); for (const admin of admins) { if (admin.notificationConfig.includes(exports.TYPE_APP_OOM)) { await safe(mailer.oomEvent(admin.email, containerId, app, addonName, event), { debug }); } } } async function appUp(eventId, app) { assert.strictEqual(typeof eventId, 'string'); assert.strictEqual(typeof app, 'object'); const admins = await users.getAdmins(); for (const admin of admins) { if (admin.notificationConfig.includes(exports.TYPE_APP_UP)) { await safe(mailer.appUp(admin.email, app), { debug }); } } } async function appDown(eventId, app) { assert.strictEqual(typeof eventId, 'string'); assert.strictEqual(typeof app, 'object'); const admins = await users.getAdmins(); for (const admin of admins) { if (admin.notificationConfig.includes(exports.TYPE_APP_DOWN)) { await safe(mailer.appDown(admin.email, app), { debug }); } } } 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>/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.TYPE_APP_UPDATED, title, `The application installed at https://${app.fqdn} was updated.\n\nChangelog:\n${toManifest.changelog}\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.TYPE_CLOUDRON_UPDATED, `Cloudron updated to v${newVersion}`, `Cloudron was updated from v${oldVersion} to v${newVersion}.\n\nChangelog:\n${changelogMarkdown}\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.TYPE_CLOUDRON_INSTALLED, `Cloudron v${version} installed`, `Cloudron v${version} was installed.\n\nChangelog:\n${changelogMarkdown}\n`, { eventId }); } async function boxUpdateError(eventId, errorMessage) { assert.strictEqual(typeof eventId, 'string'); assert.strictEqual(typeof errorMessage, 'string'); await add(exports.TYPE_CLOUDRON_UPDATE_FAILED, 'Cloudron update failed', `Failed to update Cloudron: ${errorMessage}.`, { eventId }); const admins = await users.getAdmins(); for (const admin of admins) { if (admin.notificationConfig.includes(exports.TYPE_CLOUDRON_UPDATE_FAILED)) { await safe(mailer.boxUpdateError(admin.email, errorMessage), { debug }); } } } async function certificateRenewalError(eventId, fqdn, errorMessage) { assert.strictEqual(typeof eventId, 'string'); assert.strictEqual(typeof fqdn, 'string'); assert.strictEqual(typeof errorMessage, 'string'); await add(exports.TYPE_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) { if (admin.notificationConfig.includes(exports.TYPE_CERTIFICATE_RENEWAL_FAILED)) { await safe(mailer.certificateRenewalError(admin.email, fqdn, errorMessage), { debug }); } } } async function backupFailed(eventId, taskId, errorMessage) { assert.strictEqual(typeof eventId, 'string'); assert.strictEqual(typeof taskId, 'string'); assert.strictEqual(typeof errorMessage, 'string'); await add(exports.TYPE_BACKUP_FAILED, 'Backup failed', `Backup failed: ${errorMessage}. Logs are available [here](/logs.html?taskId=${taskId}).`, { eventId }); const { fqdn:dashboardFqdn } = await dashboard.getLocation(); const superadmins = await users.getSuperadmins(); for (const superadmin of superadmins) { if (superadmin.notificationConfig.includes(exports.TYPE_BACKUP_FAILED)) { await safe(mailer.backupFailed(superadmin.email, errorMessage, `https://${dashboardFqdn}/logs.html?taskId=${taskId}`), { debug }); } } } async function rebootRequired() { const admins = await users.getAdmins(); for (const admin of admins) { if (admin.notificationConfig.includes(exports.TYPE_REBOOT)) { await safe(mailer.rebootRequired(admin.email), { debug }); } } } async function lowDiskSpace(message) { assert.strictEqual(typeof message, 'string'); const admins = await users.getAdmins(); for (const admin of admins) { if (admin.notificationConfig.includes(exports.TYPE_DISK_SPACE)) { await safe(mailer.lowDiskSpace(admin.email, message), { debug }); } } } async function onPin(type, message) { assert.strictEqual(typeof type, 'string'); // TYPE_ assert.strictEqual(typeof message, 'string'); switch (type) { case exports.TYPE_REBOOT: return await rebootRequired(); case exports.TYPE_DISK_SPACE: return await lowDiskSpace(message); default: break; } } async function pin(type, title, message, options) { assert.strictEqual(typeof type, 'string'); // TYPE_ assert.strictEqual(typeof title, 'string'); assert.strictEqual(typeof message, 'string'); assert.strictEqual(typeof options, 'object'); const result = await getByType(type, options.context || ''); if (!result) { await onPin(type, message); return await add(type, title, message, { eventId: null, context: options.context || '' }); } // do not reset the ack state if user has already seen the update notification const isUpdateType = type === exports.TYPE_BOX_UPDATE || type === exports.TYPE_MANUAL_APP_UPDATE_NEEDED; const acknowledged = (isUpdateType && result.message === message) ? result.acknowledged : false; if (result.acknowledged && !acknowledged) await onPin(type, message); await update(result, { title, message, acknowledged, creationTime: new Date() }); return result.id; } async function unpin(type, options) { assert.strictEqual(typeof type, 'string'); // TYPE_ assert.strictEqual(typeof options, 'object'); await database.query('UPDATE notifications SET acknowledged=1 WHERE type = ? AND context = ?', [ type, options.context || '' ]); } async function onEvent(eventId, action, source, data) { assert.strictEqual(typeof eventId, '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(eventId, data.containerId, data.app, data.addonName, data.event); case eventlog.ACTION_APP_UP: return await appUp(eventId, data.app); case eventlog.ACTION_APP_DOWN: return await appDown(eventId, data.app); 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(eventId, 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(eventId, 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(eventId, data.taskId, data.errorMessage); // only notify for automated backups or timedout case eventlog.ACTION_INSTALL_FINISH: return await boxInstalled(eventId, data.version); case eventlog.ACTION_UPDATE_FINISH: if (!data.errorMessage) return await boxUpdated(eventId, data.oldVersion, data.newVersion); if (data.timedOut) return await boxUpdateError(eventId, data.errorMessage); return; default: return; } }