diff --git a/config.default.js b/config.default.js index 70f5e58..075450b 100644 --- a/config.default.js +++ b/config.default.js @@ -19,7 +19,9 @@ module.exports = { description: 'Pushover Example', user: '', token: '', - urlTitle: 'Manage notifications' + urlTitle: 'Manage notifications', + maxAttempts: 5, + retryIntervalSeconds: 5 } ] } \ No newline at end of file diff --git a/index.js b/index.js index 310dcfe..2d63899 100644 --- a/index.js +++ b/index.js @@ -1,32 +1,75 @@ -const fshelpers = require('./lib/fshelpers'); +const Container = require('./src/container'); +const winston = require('winston'); const express = require('express'); -const repository = require('./lib/repository'); -const facade = require('./lib/facade'); + +const AsyncFs = require('./src/asyncfs'); +const DateTimeProvider = require('./src/datetimeprovider'); +const ApiRoutes = require('./src/routes/api'); +const NotificationRepository = require('./src/notification/repository'); +const NotificationFacade = require('./src/notification/facade'); +const TransportProvider = require('./src/transport/provider'); +const SubjectParser = require('./src/subjectparser'); + + +const logger = winston.createLogger({ + level: 'verbose', +}); + +logger.add(new winston.transports.Console({ + format: winston.format.simple() +})) + + +const container = new Container(); +container.register('Logger', c => logger); +container.register('AsyncFs', c => new AsyncFs()); +container.register('DateTimeProvider', c => new DateTimeProvider()); +container.register('ApiRoutes', c => new ApiRoutes(c.Logger, c.NotificationFacade, c.Config)); +container.register('NotificationRepository', c => new NotificationRepository(c.Logger, c.DateTimeProvider, c.AsyncFs, c.Config)); +container.register('NotificationFacade', c => new NotificationFacade(c.Logger, c.DateTimeProvider, c.TransportProvider, c.SubjectParser, c.NotificationRepository, c.Config)); +container.register('TransportProvider', c => new TransportProvider()); +container.register('SubjectParser', c => new SubjectParser()); async function asyncMain() { - if (await fshelpers.exists('config.js')) - global.config = require('./config.js'); - else - global.config = require('./config.default.js'); - + let config; - facade.init(global.config.contacts); - await repository.init(global.config.dataFilename, global.config.salt); + if (await container.AsyncFs.exists('config.js')) + { + logger.verbose('Using config.js'); + config = require('./config.js'); + } + else + { + logger.verbose('No config.js found, using config.default.js'); + config = require('./config.default.js'); + } + + + container.register('Config', c => config); + + + await container.NotificationRepository.init(); const app = express(); app.use(express.json()); app.use(express.urlencoded({ extended: false })); - app.use('/api', require('./routes/api')); + app.use('/api', container.ApiRoutes.create(express)); app.use('/', express.static('frontend/dist')); - app.listen(global.config.port, () => + app.listen(config.port, () => { - console.log(`NotificationLatch listening at http://localhost:${global.config.port}`); + logger.info(`NotificationLatch listening at http://localhost:${config.port}`); }); } -asyncMain(); \ No newline at end of file + +asyncMain() + .catch(err => + { + logger.error(`Unhandled exception: ${err}`); + logger.verbose(err.stack); + }); \ No newline at end of file diff --git a/lib/facade.js b/lib/facade.js deleted file mode 100644 index 820074f..0000000 --- a/lib/facade.js +++ /dev/null @@ -1,117 +0,0 @@ -const repository = require('./repository'); -const subjectParser = require('./subjectparser'); -const { DateTime } = require('luxon'); - - -function init(contacts, maxAttempts, retryInterval) -{ - if (global.facadeData) - throw new Error("Facade is already initialized"); - - - const transportMap = { - "pushover": require('./transport/pushover') - } - - - global.facadeData = { - maxAttempts: maxAttempts, - retryInterval: retryInterval, - - contacts: contacts.filter(c => - { - if (!transportMap.hasOwnProperty(c.type)) - { - console.error(`Unknown transport type '${c.type}', skipping contact`) - return false; - } - - return true; - }), - - transportMap: transportMap - } -} - - -async function postNotification(subject, message, priority) -{ - var parsedSubject = subjectParser.parse(subject); - console.log(parsedSubject); - - const token = await repository.storeNotification(parsedSubject.id, parsedSubject.title); - if (token === null) - return; - - sendNotification({ - id: parsedSubject.id, - token: token, - title: parsedSubject.title, - message: message, - priority: priority, - sound: parsedSubject.sound, - timestamp: Math.floor(DateTime.now().toSeconds()), - url: 'https://www.hierhaduwurlkunnenstaan.nl/' - }); -} - - -async function resetNotification(token) -{ - await repository.resetNotification(token); -} - - - -function sendNotification(notification) -{ - if (!global.facadeData) - throw new Error('Facade not initialized, call init() first'); - - global.facadeData.contacts.forEach(contact => - { - const transport = global.facadeData.transportMap[contact.type]; - retryableSend(transport, contact, notification); - }); -} - - -const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); - - -async function retryableSend(transport, contact, notification) -{ - let attempt = 1; - - while (true) - { - try - { - console.info(`Sending notification '${notification.id}' with token '${notification.token}' to '${contact.description}' (attempt ${attempt})`); - await transport.send(contact, notification); - - console.info(`Notification '${notification.id}' succesfully sent to '${contact.description}'`); - return; - } - catch (err) - { - if (attempt >= global.facadeData.maxAttempts) - { - console.info(`Error while sending notification '${notification.id}' to '${contact.description}', max attempts reached: ${err}`); - return; - } - - console.info(`Error while sending notification '${notification.id}' to '${contact.description}', retrying in ${global.facadeData.retryInterval} seconds: ${err}`); - await delay(global.facadeData.retryInterval * 1000); - - attempt++; - } - } -} - - -module.exports = { - init, - postNotification, - resetNotification -} \ No newline at end of file diff --git a/lib/fshelpers.js b/lib/fshelpers.js deleted file mode 100644 index 12bf239..0000000 --- a/lib/fshelpers.js +++ /dev/null @@ -1,20 +0,0 @@ -const fs = require('fs').promises; - -async function exists(path) -{ - try - { - await fs.access(path); - return true; - } - catch - { - return false; - } -} - - -module.exports = -{ - exists -} \ No newline at end of file diff --git a/lib/repository.js b/lib/repository.js deleted file mode 100644 index 45a5ceb..0000000 --- a/lib/repository.js +++ /dev/null @@ -1,130 +0,0 @@ -const fs = require('fs').promises; -const fshelpers = require('./fshelpers'); -const crypto = require('crypto'); -const { DateTime } = require('luxon'); - -function checkInitialized() -{ - if (!global.repositoryData) - throw new Error('Repository not initialized, call init() first'); -} - - -async function init(filename, salt) -{ - if (global.repositoryData) - throw new Error('Repository is already initialized'); - - global.repositoryData = { - filename: filename, - salt: salt, - notifications: {} - }; - - if (!(await fshelpers.exists(filename))) - return; - - const contents = await fs.readFile(filename, 'utf8'); - global.repositoryData.notifications = JSON.parse(contents.toString()); -} - - -async function flush() -{ - const contents = JSON.stringify(global.repositoryData.notifications, null, 2); - await fs.writeFile(global.repositoryData.filename, contents, 'utf8'); -} - - -function getNotificationToken(id) -{ - const hasher = crypto.createHmac("sha256", global.repositoryData.salt); - return hasher.update(id).digest("hex"); -} - - -function getUnixTimestamp() -{ - return Math.floor(DateTime.now().toSeconds()); -} - - - -// Returns the notification token if it should be sent out or null if the notification is latched -async function storeNotification(id, title) -{ - checkInitialized(); - - const now = getUnixTimestamp(); - const token = getNotificationToken(id); - - if (!global.repositoryData.notifications.hasOwnProperty(token)) - { - console.info(`New notification with id '${id}' and token '${token}', send permitted`); - - global.repositoryData.notifications[token] = { - id: id, - title: title, - latched: true, - latchTime: now, - resetTime: null, - reminders: true, - remindTime: null - }; - - await flush(); - return token; - } - - const notification = global.repositoryData.notifications[token]; - - if (notification.latched) - { - console.info(`Notification with id ${id} and token '${token}' is already latched, send blocked`); - return null; - } - - console.info(`Latching notification with id ${id} and token '${token}', send permitted`); - notification.latched = true; - notification.latchTime = now; - - await flush(); - return token; -} - - -async function resetNotification(token) -{ - checkInitialized(); - - const now = getUnixTimestamp(); - - if (!global.repositoryData.notifications.hasOwnProperty(token)) - { - console.info(`Notification token '${token}' does not exist, reset unneccesary`); - return; - } - - const notification = global.repositoryData.notifications[token]; - - if (!notification.latched) - { - console.info(`Notification with id '${notification.id}' and token '${token}' is not latched, reset unneccesary`); - return; - } - - console.info(`Resetting notification with id '${notification.id}' and token '${token}'`); - notification.latched = false; - notification.resetTime = now; - notification.reminders = true; - notification.remindTime = null; - - await flush(); -} - - -module.exports = { - init, - storeNotification, - resetNotification -} \ No newline at end of file diff --git a/lib/subjectparser.js b/lib/subjectparser.js deleted file mode 100644 index 0a8520b..0000000 --- a/lib/subjectparser.js +++ /dev/null @@ -1,27 +0,0 @@ -function parse(subject) -{ - // Possible formats: - // Title - // |id| Title - // |id,sound| Title - // |,sound| Title - const match = subject.match(/^(?:\|([^,]*?)(?:,(.+?)){0,1}\|){0,1}\s*(.+?)$/m); - - if (match == null) - return { - id: subject, - sound: null, - title: subject - }; - - return { - id: match[1] || match[3], - sound: match[2] || null, - title: match[3] - }; -}; - - -module.exports = { - parse -} \ No newline at end of file diff --git a/lib/transport/pushover.js b/lib/transport/pushover.js deleted file mode 100644 index 92d1c28..0000000 --- a/lib/transport/pushover.js +++ /dev/null @@ -1,33 +0,0 @@ -const axios = require('axios'); - - -async function send(contact, notification) -{ - try - { - const data = { - token: contact.token, - user: contact.user, - message: notification.message, - priority: notification.priority, - timestamp: notification.timestamp, - url: notification.url, - url_title: contact.urlTitle || 'Manage notifications' - }; - - if (notification.sound) - data.sound = notification.sound; - - const response = await axios.post("https://api.pushover.net/1/messages.json", data); - return response.statu; - } - catch - { - return false; - } -} - - -module.exports = { - send -} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index aa5a19c..ed69c01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,16 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@dabh/diagnostics": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.2.tgz", + "integrity": "sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q==", + "requires": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, "@sindresorhus/is": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", @@ -86,6 +96,11 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, + "async": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.1.tgz", + "integrity": "sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg==" + }, "axios": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz", @@ -265,6 +280,30 @@ "mimic-response": "^1.0.0" } }, + "color": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/color/-/color-3.0.0.tgz", + "integrity": "sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==", + "requires": { + "color-convert": "^1.9.1", + "color-string": "^1.5.2" + }, + "dependencies": { + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" + } + } + }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -277,8 +316,30 @@ "color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "color-string": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.6.0.tgz", + "integrity": "sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==", + "requires": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" + }, + "colorspace": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.2.tgz", + "integrity": "sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ==", + "requires": { + "color": "3.0.x", + "text-hex": "1.0.x" + } }, "concat-map": { "version": "0.0.1", @@ -323,6 +384,11 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw=" }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, "crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", @@ -394,6 +460,11 @@ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", "dev": true }, + "enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + }, "encodeurl": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", @@ -461,6 +532,16 @@ "vary": "~1.1.2" } }, + "fast-safe-stringify": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.0.8.tgz", + "integrity": "sha512-lXatBjf3WPjmWD6DpIZxkeSsCOwqI0maYMpgDlx8g4U2qi4lbjA9oH/HD2a87G+KfsUmo5WbJFmqBZlPxtptag==" + }, + "fecha": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.1.tgz", + "integrity": "sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q==" + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -484,6 +565,11 @@ "unpipe": "~1.0.0" } }, + "fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" + }, "follow-redirects": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.1.tgz", @@ -630,6 +716,11 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, + "is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" + }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -703,6 +794,11 @@ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==" + }, "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -715,6 +811,11 @@ "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==", "dev": true }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, "json-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", @@ -730,6 +831,11 @@ "json-buffer": "3.0.0" } }, + "kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + }, "latest-version": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", @@ -739,6 +845,25 @@ "package-json": "^6.3.0" } }, + "logform": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/logform/-/logform-2.2.0.tgz", + "integrity": "sha512-N0qPlqfypFx7UHNn4B3lzS/b0uLqt2hmuoa+PpuXNYgozdJYAyauF5Ky0BWVjrxDlMWiT3qN4zPq3vVAfZy7Yg==", + "requires": { + "colors": "^1.2.1", + "fast-safe-stringify": "^2.0.4", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "triple-beam": "^1.3.0" + }, + "dependencies": { + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, "lowercase-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", @@ -904,6 +1029,14 @@ "wrappy": "1" } }, + "one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "requires": { + "fn.name": "1.x.x" + } + }, "p-cancelable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", @@ -952,6 +1085,11 @@ "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", "dev": true }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1019,6 +1157,16 @@ "strip-json-comments": "~2.0.1" } }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, "readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1137,6 +1285,19 @@ "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==", "dev": true }, + "simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=", + "requires": { + "is-arrayish": "^0.3.1" + } + }, + "stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=" + }, "statuses": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", @@ -1182,6 +1343,21 @@ } } }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + } + } + }, "strip-ansi": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", @@ -1212,6 +1388,11 @@ "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==", "dev": true }, + "text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + }, "to-readable-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", @@ -1241,6 +1422,11 @@ "nopt": "~1.0.10" } }, + "triple-beam": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.3.0.tgz", + "integrity": "sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==" + }, "type-fest": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", @@ -1318,6 +1504,11 @@ "prepend-http": "^2.0.0" } }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, "utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -1337,6 +1528,55 @@ "string-width": "^4.0.0" } }, + "winston": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.3.3.tgz", + "integrity": "sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw==", + "requires": { + "@dabh/diagnostics": "^2.0.2", + "async": "^3.1.0", + "is-stream": "^2.0.0", + "logform": "^2.2.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.4.0" + } + }, + "winston-transport": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.4.0.tgz", + "integrity": "sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw==", + "requires": { + "readable-stream": "^2.3.7", + "triple-beam": "^1.2.0" + }, + "dependencies": { + "readable-stream": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", + "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index cba6325..6850483 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "dependencies": { "axios": "^0.21.1", "express": "^4.17.1", - "luxon": "^2.0.2" + "luxon": "^2.0.2", + "winston": "^3.3.3" }, "devDependencies": { "nodemon": "^2.0.12" diff --git a/routes/api.js b/routes/api.js deleted file mode 100644 index e7710f8..0000000 --- a/routes/api.js +++ /dev/null @@ -1,46 +0,0 @@ -const express = require('express'); -const router = express.Router(); - -const facade = require('../lib/facade'); - -function logRequestWarning(req, message) -{ - console.warn(`[${req.ip}] ${message}`); -} - - -router.post('/notification', async (req, res) => -{ - if (req.headers.authorization !== 'Bearer ' + global.config.authToken) - { - logRequestWarning(req, 'Missing or invalid authorization header'); - res.sendStatus(401); - return; - } - - if (!req.body || !req.body.subject || !req.body.message) - { - logRequestWarning(req, 'Missing body, subject and/or message parameters'); - res.sendStatus(400); - return; - } - - await facade.postNotification(req.body.subject, req.body.message, req.body.priority || 0); - res.sendStatus(200); -}); - - -router.post('/notification/reset', async (req, res) => -{ - if (!req.body || !req.body.token) - { - logRequestWarning(req, 'Missing token'); - res.sendStatus(400); - return; - } - - await facade.resetNotification(req.body.token); - res.sendStatus(200); -}); - -module.exports = router; \ No newline at end of file diff --git a/src/asyncfs.js b/src/asyncfs.js new file mode 100644 index 0000000..ef26e46 --- /dev/null +++ b/src/asyncfs.js @@ -0,0 +1,21 @@ +const fs = require('fs').promises; + + +class AsyncFs +{ + async exists(path) + { + try + { + await fs.access(path); + return true; + } + catch + { + return false; + } + } +} + + +module.exports = AsyncFs; \ No newline at end of file diff --git a/src/container.js b/src/container.js new file mode 100644 index 0000000..155fd30 --- /dev/null +++ b/src/container.js @@ -0,0 +1,31 @@ +/* + * Original concept and code by Magnus Tovslid: + * https://medium.com/@magnusjt/ioc-container-in-nodejs-e7aea8a89600 + */ +class Container +{ + constructor() + { + this.services = {}; + } + + register(name, factory) + { + Object.defineProperty(this, name, { + get: () => + { + if(!this.services.hasOwnProperty(name)) + this.services[name] = factory(this); + + return this.services[name]; + }, + configurable: true, + enumerable: true + }); + + return this; + } +} + + +module.exports = Container; \ No newline at end of file diff --git a/src/datetimeprovider.js b/src/datetimeprovider.js new file mode 100644 index 0000000..1c3d95f --- /dev/null +++ b/src/datetimeprovider.js @@ -0,0 +1,13 @@ +const { DateTime } = require('luxon'); + + +class DateTimeProvider +{ + unixTimestamp() + { + return Math.floor(DateTime.now().toSeconds()); + } +} + + +module.exports = DateTimeProvider; \ No newline at end of file diff --git a/src/notification/facade.js b/src/notification/facade.js new file mode 100644 index 0000000..d18bbfb --- /dev/null +++ b/src/notification/facade.js @@ -0,0 +1,102 @@ +class NotificationFacade +{ + constructor(logger, dateTimeProvider, transportProvider, subjectParser, notificationRepository, config) + { + this.logger = logger; + this.dateTimeProvider = dateTimeProvider; + this.transportProvider = transportProvider; + this.subjectParser = subjectParser; + this.notificationRepository = notificationRepository; + + this.contacts = config.contacts.filter(contact => + { + if (transportProvider.byType(contact.type) === null) + { + logger.error(`Unknown transport type '${contact.type}' for contact '${contact.description}', skipping contact`) + return false; + } + + return true; + }); + } + + + async postNotification(subject, message, priority) + { + var parsedSubject = this.subjectParser.parse(subject); + + const token = await this.notificationRepository.storeNotification(parsedSubject.id, parsedSubject.title); + if (token === null) + return; + + this._sendNotification({ + id: parsedSubject.id, + token: token, + title: parsedSubject.title, + message: message, + priority: priority, + sound: parsedSubject.sound, + timestamp: this.dateTimeProvider.unixTimestamp(), + url: 'https://www.hierhaduwurlkunnenstaan.nl/' + }); + } + + + async resetNotification(token) + { + await this.notificationRepository.resetNotification(token); + } + + + + _sendNotification(notification) + { + this.contacts.forEach(contact => + { + const transport = this.transportProvider.byType(contact.type); + this._retryableSend(transport, contact, notification); + }); + } + + + _delay(ms) + { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + + async _retryableSend(transport, contact, notification) + { + let attempt = 1; + const retryInterval = contact.retryIntervalSeconds || 0; + const maxAttempts = contact.maxAttempts || 1; + + while (true) + { + try + { + this.logger.info(`Sending notification '${notification.id}' with token '${notification.token}' to '${contact.description}' (attempt ${attempt})`); + await transport.send(contact, notification); + + this.logger.info(`Notification '${notification.id}' succesfully sent to '${contact.description}'`); + return; + } + catch (err) + { + if (attempt >= maxAttempts) + { + this.logger.info(`Error while sending notification '${notification.id}' to '${contact.description}', max attempts reached: ${err}`); + return; + } + + this.logger.info(`Error while sending notification '${notification.id}' to '${contact.description}', retrying in ${retryInterval} seconds: ${err}`); + await this._delay(retryInterval * 1000); + + attempt++; + } + } + } +} + + +module.exports = NotificationFacade; \ No newline at end of file diff --git a/src/notification/repository.js b/src/notification/repository.js new file mode 100644 index 0000000..ac570ee --- /dev/null +++ b/src/notification/repository.js @@ -0,0 +1,130 @@ +const fs = require('fs').promises; +const crypto = require('crypto'); + + +class NotificationRepository +{ + constructor(logger, dateTimeProvider, asyncFs, config) + { + this.logger = logger; + this.dateTimeProvider = dateTimeProvider; + this.asyncFs = asyncFs; + + this.filename = config.dataFilename; + this.salt = config.salt; + this.notifications = null; + } + + + async init() + { + if (this.notifications !== null) + throw new Error('Repository is already initialized'); + + this.notifications = {}; + + if (!(await this.asyncFs.exists(this.filename))) + return; + + const contents = await fs.readFile(this.filename, 'utf8'); + this.notifications = JSON.parse(contents.toString()); + } + + + // Returns the notification token if it should be sent out or null if the notification is latched + async storeNotification(id, title) + { + this._checkInitialized(); + + const now = this.dateTimeProvider.unixTimestamp(); + const token = this._getNotificationToken(id); + + if (!this.notifications.hasOwnProperty(token)) + { + this.logger.info(`New notification with id '${id}' and token '${token}', send permitted`); + + this.notifications[token] = { + id: id, + title: title, + latched: true, + latchTime: now, + resetTime: null, + reminders: true, + remindTime: null + }; + + await this._flush(); + return token; + } + + const notification = this.notifications[token]; + + if (notification.latched) + { + this.logger.info(`Notification with id ${id} and token '${token}' is already latched, send blocked`); + return null; + } + + this.logger.info(`Latching notification with id ${id} and token '${token}', send permitted`); + notification.latched = true; + notification.latchTime = now; + + await this._flush(); + return token; + } + + + async resetNotification(token) + { + this._checkInitialized(); + + const now = this.dateTimeProvider.unixTimestamp(); + + if (!this.notifications.hasOwnProperty(token)) + { + this.logger.info(`Notification token '${token}' does not exist, reset unneccesary`); + return; + } + + const notification = this.notifications[token]; + + if (!notification.latched) + { + this.logger.info(`Notification with id '${notification.id}' and token '${token}' is not latched, reset unneccesary`); + return; + } + + this.logger.info(`Resetting notification with id '${notification.id}' and token '${token}'`); + notification.latched = false; + notification.resetTime = now; + notification.reminders = true; + notification.remindTime = null; + + await this._flush(); + } + + + _getNotificationToken(id) + { + const hasher = crypto.createHmac("sha256", this.salt); + return hasher.update(id).digest("hex"); + } + + + async _flush() + { + const contents = JSON.stringify(this.notifications, null, 2); + await fs.writeFile(this.filename, contents, 'utf8'); + } + + + _checkInitialized() + { + if (this.notifications === null) + throw new Error('Repository not initialized, call init() first'); + } + +} + + +module.exports = NotificationRepository; \ No newline at end of file diff --git a/src/routes/api.js b/src/routes/api.js new file mode 100644 index 0000000..2e41adf --- /dev/null +++ b/src/routes/api.js @@ -0,0 +1,79 @@ +class ApiRoutes +{ + constructor(logger, notificationFacade, config) + { + this.logger = logger; + this.notificationFacade = notificationFacade; + this.authToken = config.authToken; + } + + + create(express) + { + const router = express.Router(); + + router.post('/notification', (req, res) => this._wrapAsyncHandler(req, res, this._handlePostNotification.bind(this))); + router.post('/notification/reset', (req, res) => this._wrapAsyncHandler(req, res, this._handleResetNotification.bind(this))); + + return router; + } + + + async _handlePostNotification(req, res) + { + if (req.headers.authorization !== 'Bearer ' + this.authToken) + { + this._logRequestWarning(req, 'Missing or invalid authorization header'); + res.sendStatus(401); + return; + } + + if (!req.body || !req.body.subject || !req.body.message) + { + this._logRequestWarning(req, 'Missing body, subject and/or message parameters'); + res.sendStatus(400); + return; + } + + await this.notificationFacade.postNotification(req.body.subject, req.body.message, req.body.priority || 0); + res.sendStatus(200); + } + + + async _handleResetNotification(req, res) + { + if (!req.body || !req.body.token) + { + this._logRequestWarning(req, 'Missing token'); + res.sendStatus(400); + return; + } + + await this.notificationFacade.resetNotification(req.body.token); + res.sendStatus(200); + } + + + async _wrapAsyncHandler(req, res, handler) + { + try + { + await handler(req, res); + } + catch (err) + { + this.logger.error(`Unhandled exception in request handler: ${err}`); + this.logger.verbose(err.stack); + res.sendStatus(500); + } + } + + + _logRequestWarning(req, message) + { + this.logger.warn(`[${req.ip}] ${message}`); + } +} + + +module.exports = ApiRoutes; \ No newline at end of file diff --git a/src/subjectparser.js b/src/subjectparser.js new file mode 100644 index 0000000..028f270 --- /dev/null +++ b/src/subjectparser.js @@ -0,0 +1,28 @@ +class SubjectParser +{ + parse(subject) + { + // Possible formats: + // Title + // |id| Title + // |id,sound| Title + // |,sound| Title + const match = subject.match(/^(?:\|([^,]*?)(?:,(.+?)){0,1}\|){0,1}\s*(.+?)$/m); + + if (match == null) + return { + id: subject, + sound: null, + title: subject + }; + + return { + id: match[1] || match[3], + sound: match[2] || null, + title: match[3] + }; + }; +} + + +module.exports = SubjectParser; \ No newline at end of file diff --git a/src/transport/provider.js b/src/transport/provider.js new file mode 100644 index 0000000..b7f5d2d --- /dev/null +++ b/src/transport/provider.js @@ -0,0 +1,17 @@ +const PushoverTransport = require('./pushover'); + +const transportMap = { + 'pushover': new PushoverTransport() +} + + +class TransportProvider +{ + byType(type) + { + return transportMap.hasOwnProperty(type) ? transportMap[type] : null; + } +} + + +module.exports = TransportProvider; \ No newline at end of file diff --git a/src/transport/pushover.js b/src/transport/pushover.js new file mode 100644 index 0000000..bc5a3a5 --- /dev/null +++ b/src/transport/pushover.js @@ -0,0 +1,34 @@ +const axios = require('axios'); + + +class PushoverTransport +{ + async send(contact, notification) + { + try + { + const data = { + token: contact.token, + user: contact.user, + message: notification.message, + priority: notification.priority, + timestamp: notification.timestamp, + url: notification.url, + url_title: contact.urlTitle || 'Manage notifications' + }; + + if (notification.sound) + data.sound = notification.sound; + + const response = await axios.post("https://api.pushover.net/1/messages.json", data); + return response.statu; + } + catch + { + return false; + } + } +} + + +module.exports = PushoverTransport; \ No newline at end of file