'use strict'; exports = module.exports = { get, update, list, del, onEvent, ALERT_BACKUP_CONFIG: 'backupConfig', ALERT_DISK_SPACE: 'diskSpace', ALERT_MAIL_STATUS: 'mailStatus', ALERT_REBOOT: 'reboot', ALERT_BOX_UPDATE: 'boxUpdate', ALERT_UPDATE_UBUNTU: 'ubuntuUpdate', alert, // exported for testing _add: add }; const assert = require('assert'), auditSource = require('./auditsource.js'), BoxError = require('./boxerror.js'), changelog = require('./changelog.js'), database = require('./database.js'), eventlog = require('./eventlog.js'), mailer = require('./mailer.js'), settings = require('./settings.js'), users = require('./users.js'), util = require('util'); const NOTIFICATION_FIELDS = [ 'id', 'eventId', 'title', 'message', 'creationTime', 'acknowledged' ]; function postProcess(result) { assert.strictEqual(typeof result, 'object'); result.id = String(result.id); // convert to boolean result.acknowledged = !!result.acknowledged; return result; } async function add(eventId, title, message) { assert(typeof eventId === 'string' || eventId === null); assert.strictEqual(typeof title, 'string'); assert.strictEqual(typeof message, 'string'); const query = 'INSERT INTO notifications (eventId, title, message, acknowledged) VALUES (?, ?, ?, ?)'; const args = [ eventId, title, message, false ]; 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'); let args = [ ]; let fields = [ ]; for (let 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'); let args = []; let 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, app, addon, containerId /*, event*/) { assert.strictEqual(typeof eventId, 'string'); assert.strictEqual(typeof app, 'object'); assert.strictEqual(typeof addon, 'object'); assert.strictEqual(typeof containerId, 'string'); assert(app || addon); let title, message; if (app) { title = `The application at ${app.fqdn} ran out of memory.`; message = `The application has been restarted automatically. If you see this notification often, consider increasing the [memory limit](${settings.dashboardOrigin()}/#/app/${app.id}/resources)`; } else if (addon) { title = `The ${addon.name} service ran out of memory`; message = `The service has been restarted automatically. If you see this notification often, consider increasing the [memory limit](${settings.dashboardOrigin()}/#/services)`; } await add(eventId, title, message); } async function appUpdated(eventId, app) { assert.strictEqual(typeof eventId, 'string'); assert.strictEqual(typeof app, 'object'); if (!app.appStoreId) return; // skip notification of dev apps const tmp = app.manifest.description.match(/(.*)<\/upstream>/i); const upstreamVersion = (tmp && tmp[1]) ? tmp[1] : ''; const title = upstreamVersion ? `${app.manifest.title} at ${app.fqdn} updated to ${upstreamVersion} (package version ${app.manifest.version})` : `${app.manifest.title} at ${app.fqdn} updated to package version ${app.manifest.version}`; await add(eventId, title, `The application installed at https://${app.fqdn} was updated.\n\nChangelog:\n${app.manifest.changelog}\n`); } 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(eventId, `Cloudron updated to v${newVersion}`, `Cloudron was updated from v${oldVersion} to v${newVersion}.\n\nChangelog:\n${changelogMarkdown}\n`); } async function boxUpdateError(eventId, errorMessage) { assert.strictEqual(typeof eventId, 'string'); assert.strictEqual(typeof errorMessage, 'string'); await add(eventId, 'Cloudron update failed', `Failed to update Cloudron: ${errorMessage}.`); } async function certificateRenewalError(eventId, vhost, errorMessage) { assert.strictEqual(typeof eventId, 'string'); assert.strictEqual(typeof vhost, 'string'); assert.strictEqual(typeof errorMessage, 'string'); const getAdmins = util.callbackify(users.getAdmins); const admins = await getAdmins(); for (const admin of admins) { mailer.certificateRenewalError(admin.email, vhost, errorMessage); } await add(eventId, `Certificate renewal of ${vhost} failed`, `Failed to renew certs of ${vhost}: ${errorMessage}. Renewal will be retried in 12 hours`); } async function backupFailed(eventId, taskId, errorMessage) { assert.strictEqual(typeof eventId, 'string'); assert.strictEqual(typeof taskId, 'string'); assert.strictEqual(typeof errorMessage, 'string'); const getSuperAdmins = util.callbackify(users.getSuperAdmins); const superAdmins = await getSuperAdmins(); for (const superAdmin of superAdmins) { mailer.backupFailed(superAdmin.email, errorMessage, `${settings.dashboardOrigin()}/logs.html?taskId=${taskId}`); } await add(eventId, 'Backup failed', `Backup failed: ${errorMessage}. Logs are available [here](/logs.html?taskId=${taskId}).`); } async function alert(id, title, message) { assert.strictEqual(typeof id, 'string'); assert.strictEqual(typeof title, 'string'); assert.strictEqual(typeof message, 'string'); const acknowledged = !message; const result = await getByTitle(title); if (!result && acknowledged) return; // do not add acked alerts if (!result) { await add(null /* eventId */, title, message); } else { await update(result, { eventId: null, title, message, acknowledged, creationTime: new Date() }); } } 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'); // external ldap syncer does not generate notifications - FIXME username might be an issue here if (source.username === auditSource.EXTERNAL_LDAP_TASK.username) return; switch (action) { case eventlog.ACTION_APP_OOM: return await oomEvent(id, data.app, data.addon, data.containerId, data.event); case eventlog.ACTION_APP_UPDATE_FINISH: return await appUpdated(id, data.app); case eventlog.ACTION_CERTIFICATE_RENEWAL: case eventlog.ACTION_CERTIFICATE_NEW: if (!data.errorMessage) return; if (!data.notAfter || (data.notAfter - new Date() >= (60 * 60 * 24 * 10 * 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_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; } }