diff --git a/config.default.js b/config.default.js index 075450b..9a9d6e8 100644 --- a/config.default.js +++ b/config.default.js @@ -2,6 +2,10 @@ module.exports = { port: 3369, publicUrl: 'https://your.domain.name/', authToken: '', + allowedIps: { + post: ['127.0.0.1'], + manage: null + }, dataFilename: './data.json', salt: '', @@ -10,7 +14,8 @@ module.exports = { enabled: true, interval: { days: 1 }, title: 'Reminder', - message: 'One or more notifications are still disabled' + message: 'Notifications for \'{title}\' are still disabled. Enable the notifications or disable the reminders.', + sound: 'vibrate' }, contacts: [ diff --git a/frontend/src/locale/en.ts b/frontend/src/locale/en.ts index 4b08550..2fd3d80 100644 --- a/frontend/src/locale/en.ts +++ b/frontend/src/locale/en.ts @@ -1,7 +1,7 @@ export default { notification: { loading: 'Loading, please wait...', - tokenInvalid: 'Invalid token', + tokenInvalid: 'Invalid token or access denied', listHeader: 'Disabled notifications', latchTime: 'Sent on {latchTime}', diff --git a/frontend/src/locale/nl.ts b/frontend/src/locale/nl.ts index 5348cc1..cfa0b81 100644 --- a/frontend/src/locale/nl.ts +++ b/frontend/src/locale/nl.ts @@ -1,7 +1,7 @@ export default { notification: { loading: 'Bezig met laden, een ogenblik geduld a.u.b....', - tokenInvalid: 'Ongeldig token', + tokenInvalid: 'Ongeldig token of geen toegang', listHeader: 'Uitgeschakelde meldingen', latchTime: 'Verzonden op {latchTime}', diff --git a/index.js b/index.js index 0a9eabd..6f8b8f7 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,7 @@ const NotificationRepository = require('./src/notification/repository'); const NotificationFacade = require('./src/notification/facade'); const TransportProvider = require('./src/transport/provider'); const SubjectParser = require('./src/subjectparser'); +const ReminderScheduler = require('./src/reminderscheduler'); const logger = winston.createLogger({ @@ -17,7 +18,7 @@ const logger = winston.createLogger({ logger.add(new winston.transports.Console({ format: winston.format.simple() -})) +})); const container = new Container(); @@ -29,6 +30,7 @@ container.registerType('NotificationRepository', NotificationRepository); container.registerType('NotificationFacade', NotificationFacade); container.registerType('TransportProvider', TransportProvider); container.registerType('SubjectParser', SubjectParser); +container.registerType('ReminderScheduler', ReminderScheduler); async function asyncMain() @@ -51,6 +53,7 @@ async function asyncMain() await container.NotificationRepository.init(); + container.ReminderScheduler.start(); const app = express(); @@ -60,7 +63,7 @@ async function asyncMain() app.use('/api', container.ApiRoutes.createRouter(express)); app.use('/', express.static('frontend/dist')); - app.listen(config.port, () => + app.listen(config.port, '0.0.0.0', () => { logger.info(`NotificationLatch listening at http://localhost:${config.port}`); }); diff --git a/src/notification/facade.js b/src/notification/facade.js index 4fb245f..4fa5ae2 100644 --- a/src/notification/facade.js +++ b/src/notification/facade.js @@ -78,6 +78,12 @@ class NotificationFacade } + async sendReminders(interval, title, message, sound) + { + await this.notificationRepository.processReminderNotifications(interval, this._sendReminder.bind(this, title, message, sound)); + } + + _sendNotification(notification) { this.contacts.forEach(contact => @@ -88,6 +94,25 @@ class NotificationFacade } + _sendReminder(title, message, sound, token, notification) + { + const reminderNotification = { + id: notification.id, + token: token, + title: title, + message: message.replace('{title}', notification.title), + priority: 0, + timestamp: this.dateTimeProvider.unixTimestamp(), + url: new URL('/#/n/' + token, this.publicUrl).href + }; + + if (sound) + reminderNotification.sound = sound; + + this._sendNotification(reminderNotification); + } + + _delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); diff --git a/src/notification/repository.js b/src/notification/repository.js index 489a24a..1d7cd13 100644 --- a/src/notification/repository.js +++ b/src/notification/repository.js @@ -133,6 +133,32 @@ class NotificationRepository } + async processReminderNotifications(interval, callback) + { + this._checkInitialized(); + + const now = this.dateTimeProvider.unixTimestamp(); + let shouldFlush = false; + + Object.keys(this.notifications).forEach(token => + { + const notification = this.notifications[token]; + + if (!notification.latched || !notification.reminders || (notification.remindTime !== null && (now - notification.remindTime) < interval)) + return; + + this.logger.info(`Sending reminder for notification with id '${notification.id}' and token '${token}'`) + + callback(token, notification); + notification.remindTime = now; + shouldFlush = true; + }); + + if (shouldFlush) + await this._flush(); + } + + async setReminders(token, enabled) { this._checkInitialized(); diff --git a/src/reminderscheduler.js b/src/reminderscheduler.js new file mode 100644 index 0000000..45cda3b --- /dev/null +++ b/src/reminderscheduler.js @@ -0,0 +1,58 @@ +const { Duration } = require('luxon'); + + +class ReminderScheduler +{ + static create = container => new this(container.Config, container.Logger, container.NotificationFacade); + + constructor(config, logger, notificationFacade) + { + this.reminders = config.reminders; + this.logger = logger; + this.notificationFacade = notificationFacade; + + this.timerInterval = 60000; + } + + + start() + { + if (!this.reminders.enabled) + { + this.logger.info('Reminders are disabled'); + return; + } + + this.interval = Duration.fromObject(this.reminders.interval).shiftTo('seconds').seconds; + if (this.interval <= 0) + { + this.logger.warn(`Invalid reminder interval: ${this.interval} seconds, reminders will NOT be sent`); + return; + } + + this.logger.info(`Checking for reminders every minute, interval is ${this.interval} seconds`); + setTimeout(this._onTimer.bind(this), this.timerInterval); + } + + + async _onTimer() + { + try + { + this.logger.verbose('Checking for reminders'); + await this.notificationFacade.sendReminders(this.interval, this.reminders.title, this.reminders.message, this.reminders.sound); + } + catch (err) + { + this.logger.error(`Error while sending reminders: ${err}`); + this.logger.verbose(err.stack); + } + finally + { + setTimeout(this._onTimer.bind(this), this.timerInterval); + } + } +} + + +module.exports = ReminderScheduler; \ No newline at end of file diff --git a/src/routes/api.js b/src/routes/api.js index e948553..d51a519 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -8,6 +8,7 @@ class ApiRoutes this.logger = logger; this.notificationFacade = notificationFacade; this.authToken = config.authToken; + this.allowedIps = config.allowedIps; } @@ -20,6 +21,7 @@ class ApiRoutes router.post('/notification/:token/reset', this._wrapAsyncHandler(this._handleResetNotification)); router.post('/notification/:token/reminders/enable', this._wrapAsyncHandler(this._handleEnableReminders)); router.post('/notification/:token/reminders/disable', this._wrapAsyncHandler(this._handleDisableReminders)); + router.get('/ip', this._handleIp.bind(this)); return router; } @@ -27,6 +29,13 @@ class ApiRoutes async _handlePostNotification(req, res) { + if (!this._isIpAllowed(req, this.allowedIps.post)) + { + this._logRequestWarning(req, 'IP address not in allowedIps.post'); + res.sendStatus(401); + return; + } + if (req.headers.authorization !== 'Bearer ' + this.authToken) { this._logRequestWarning(req, 'Missing or invalid authorization header'); @@ -48,6 +57,13 @@ class ApiRoutes async _handleResetNotification(req, res) { + if (!this._isIpAllowed(req, this.allowedIps.manage)) + { + this._logRequestWarning(req, 'IP address not in allowedIps.manage'); + res.sendStatus(401); + return; + } + await this.notificationFacade.resetNotification(req.params.token); res.sendStatus(200); } @@ -55,6 +71,13 @@ class ApiRoutes async _handleEnableReminders(req, res) { + if (!this._isIpAllowed(req, this.allowedIps.manage)) + { + this._logRequestWarning(req, 'IP address not in allowedIps.manage'); + res.sendStatus(401); + return; + } + await this.notificationFacade.setReminders(req.params.token, true); res.sendStatus(200); } @@ -62,6 +85,13 @@ class ApiRoutes async _handleDisableReminders(req, res) { + if (!this._isIpAllowed(req, this.allowedIps.manage)) + { + this._logRequestWarning(req, 'IP address not in allowedIps.manage'); + res.sendStatus(401); + return; + } + await this.notificationFacade.setReminders(req.params.token, false); res.sendStatus(200); } @@ -69,6 +99,13 @@ class ApiRoutes async _handleLatchedNotifications(req, res) { + if (!this._isIpAllowed(req, this.allowedIps.manage)) + { + this._logRequestWarning(req, 'IP address not in allowedIps.manage'); + res.sendStatus(401); + return; + } + if (!req.headers.authorization || !req.headers.authorization.startsWith('Bearer ')) { this._logRequestWarning(req, 'Missing or invalid authorization header'); @@ -90,6 +127,12 @@ class ApiRoutes } + _handleIp(req, res) + { + res.send(this._getIp(req)); + } + + _wrapAsyncHandler(handler) { const boundHandler = handler.bind(this); @@ -113,7 +156,19 @@ class ApiRoutes _logRequestWarning(req, message) { this.logger.warn(`[${req.ip}] ${message}`); - } + } + + + _isIpAllowed(req, whitelist) + { + return whitelist === null || whitelist.includes(this._getIp(req)); + } + + + _getIp(req) + { + return req.headers['x-forwarded-for'] || req.ip; + } }