diff --git a/.gitignore b/.gitignore index a9e5b9e..90dd4f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ deploy.sh package-lock.json launch.json firebase.json +pnpm-lock.yaml diff --git a/index.js b/index.js index bbb7aad..7084cbf 100644 --- a/index.js +++ b/index.js @@ -14,10 +14,10 @@ const express = require('express'), https = require('https'), httpsServer = ((!srv_config.DEBUG && srv_config.CHAIN_PATH && srv_config.PRIVATE_KEY_PATH && srv_config.CERTIFICATE_PATH) ? https.createServer({ - ca: fs.readFileSync(srv_config.CHAIN_PATH, 'utf-8'), - key: fs.readFileSync(srv_config.PRIVATE_KEY_PATH, 'utf-8'), - cert: fs.readFileSync(srv_config.CERTIFICATE_PATH, 'utf-8') - }, app) : false), + ca: fs.readFileSync(srv_config.CHAIN_PATH, 'utf-8'), + key: fs.readFileSync(srv_config.PRIVATE_KEY_PATH, 'utf-8'), + cert: fs.readFileSync(srv_config.CERTIFICATE_PATH, 'utf-8') + }, app) : false), Rollbar = require('rollbar'), rollbar = ((srv_config.ROLLBAR_TOKEN) ? new Rollbar({ accessToken: srv_config.ROLLBAR_TOKEN, @@ -32,7 +32,7 @@ const express = require('express'), getApiVersion: () => '2', skip: req => { const path = req.path.toLowerCase(); - + return path === '/location' || path === '/debug' || path === '/soc' || path === '/extended' } }) : false), @@ -105,7 +105,7 @@ app.use((req, res, next) => { // set default headers app.use((req, res, next) => { - res.contentType('application/json'); + //res.contentType('application/json'); res.setHeader('Access-Control-Allow-Origin', ((!req.get('origin') || req.get('origin') === 'null') ? '*' : req.get('origin'))); res.setHeader('Access-Control-Allow-Credentials', 'true'); next(); @@ -118,6 +118,8 @@ app.post('/login', account.login); app.post('/changepw', account.changePW); app.get('/settings', settings.getSettings); app.put('/settings', settings.setSettings); +app.use('/verify/:id', express.static('static/verify')) +app.post('/verify/:id', settings.verifyMail); app.post('/soc', sync.postSoC); app.get('/soc', sync.getSoC); app.post('/extended', sync.postExtended); @@ -148,7 +150,7 @@ app.post('/debug', (req, res) => { req.body.data, req.body.akey, ((parseInt(req.body.timestamp)) ? req.body.timestamp : parseInt(new Date() / 1000)) ], (err, dbRes) => { if (!err && dbRes) { - res.json({status: true}); + res.json({ status: true }); } else { res.status(422).json({ error: srv_errors.UNPROCESSABLE_ENTITY, diff --git a/modules/db/db_template.sql b/modules/db/db_template.sql index d0337ae..ee6ed36 100644 --- a/modules/db/db_template.sql +++ b/modules/db/db_template.sql @@ -1,3 +1,10 @@ +CREATE TABLE IF NOT EXISTS `system` ( + `key` VARCHAR(20) NOT NULL PRIMARY KEY, + `value` VARCHAR(20) NOT NULL +); + +INSERT INTO `system` VALUES("version", 1) ON DUPLICATE KEY UPDATE `value`=VALUES(`value`); + -- accounts table structure CREATE TABLE IF NOT EXISTS `accounts` ( `akey` VARCHAR(6) NOT NULL PRIMARY KEY, @@ -11,7 +18,6 @@ CREATE TABLE IF NOT EXISTS `settings` ( `user` VARCHAR(6) NOT NULL, `akey` VARCHAR(6) NOT NULL, `webhook` VARCHAR(100) DEFAULT NULL, - `email` VARCHAR(1000) DEFAULT NULL, `telegram` INT(100) DEFAULT 0, `abrp` VARCHAR(36) DEFAULT NULL, `summary` TINYINT(1) DEFAULT 0, @@ -25,6 +31,23 @@ CREATE TABLE IF NOT EXISTS `settings` ( FOREIGN KEY (`akey`) REFERENCES `accounts`(`akey`) ); +CREATE TABLE IF NOT EXISTS `notificationMail` ( + `akey` VARCHAR(6) NOT NULL, + `mail` VARCHAR(1000) NOT NULL, + `verified` BOOLEAN NOT NULL DEFAULT FALSE, + `identifier` BINARY(16) NOT NULL, + PRIMARY KEY (`akey`), + UNIQUE KEY (`identifier`), + FOREIGN KEY (`akey`) REFERENCES `accounts`(`akey`) +); + +CREATE TABLE IF NOT EXISTS `mailLock` ( + `hash` BINARY(32) NOT NULL, + `time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `weight` INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (`hash`) +); + -- sync table structure CREATE TABLE IF NOT EXISTS `sync` ( `user` VARCHAR(6) NOT NULL, diff --git a/modules/db/index.js b/modules/db/index.js index d6e32fb..3184e32 100644 --- a/modules/db/index.js +++ b/modules/db/index.js @@ -26,12 +26,18 @@ module.exports = { if (typeof params === 'function') callback = params; if (!Array.isArray(params)) params = []; if (typeof sql === 'string') { - if (params.length>0 && Array.isArray(params[0])) { + if (params.length > 0 && Array.isArray(params[0])) { return db.query(sql, params, callback); - }else { + } else { return db.query(mysql.format(sql, params), callback); } } else if (typeof callback === 'function') callback(srv_errors.INVALID_PARAMETERS); }, + /** + * Gets a database connection from the pool. Requested connections have to be released manually! + */ + getConnection: (callback) => { + db.getConnection(callback); + }, close: callback => db.end(callback), }; \ No newline at end of file diff --git a/modules/db/upgrades/1.sql b/modules/db/upgrades/1.sql new file mode 100644 index 0000000..3cb7d91 --- /dev/null +++ b/modules/db/upgrades/1.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS `system` ( + `key` VARCHAR(20) NOT NULL PRIMARY KEY, + `value` VARCHAR(20) NOT NULL +); + +CREATE TABLE IF NOT EXISTS `notificationMail` ( + `akey` VARCHAR(6) NOT NULL PRIMARY KEY, + `mail` VARCHAR(1000) NOT NULL, + `verified` BOOLEAN NOT NULL DEFAULT FALSE, + `identifier` BINARY(16) NOT NULL, + UNIQUE KEY (`identifier`), + FOREIGN KEY (`akey`) REFERENCES `accounts`(`akey`) +); + +CREATE TABLE IF NOT EXISTS `mailLock` ( + `hash` BINARY(32) NOT NULL PRIMARY KEY, + `time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `weight` INTEGER NOT NULL DEFAULT 1 +); + +INSERT INTO `notificationMail` SELECT `akey`,`email`,TRUE,UNHEX(MD5(RAND())) FROM `settings` WHERE email IS NOT NULL; + +ALTER TABLE `settings`DROP COLUMN `email`; + +INSERT INTO `system` VALUES("version", 1); \ No newline at end of file diff --git a/modules/db/upgrades/upgradeSql.js b/modules/db/upgrades/upgradeSql.js new file mode 100644 index 0000000..a3b8288 --- /dev/null +++ b/modules/db/upgrades/upgradeSql.js @@ -0,0 +1,43 @@ +const db = require('..'); +const path = require('path'); +const fs = require('fs') + +fs.readdir(".", function (err, files) { + if (err) { + return console.err('Unable to scan directory: ' + err); + } + var max = files.reduce((prev, file) => { + var part = file.split(".")[0]; + if (!isNaN(part)) { + return Math.max(prev, parseInt(part)); + } + return prev; + }, 0); + if (!max) return console.error('did not find any upgrade files in current directory'); + + db.getConnection((err, connection) => { + if (err) { + connection.release(); + console.error('unable to get connection', err); + return; + } + connection.doQuery = require('util').promisify(connection.query); + connection.doBeginTransaction = require('util').promisify(connection.beginTransaction); + connection.doBeginTransaction() + .then(() => connection.doQuery('CREATE TABLE IF NOT EXISTS `system` (`key` VARCHAR(20) NOT NULL PRIMARY KEY, `value` VARCHAR(20) NOT NULL)')) + .then(() => connection.doQuery('SELECT value FROM system WHERE "key"="version"')) + .then(res => res.length == 0 ? 0 : res.value) + .then(currentVersion => [...Array(max - currentVersion)].reduce((p, _, i) => p.then(() => { + const content = fs.readFileSync((1 + i + currentVersion) + ".sql", "utf-8"); + var queries = content.replace(/[\r\n]+/g, ' ').split(";"); + return queries.reduce((p, q) => p.then(() => connection.doQuery(q)), Promise.resolve()); + }), Promise.resolve())) + .then(connection.commit()) + .catch(err => { + connection.rollback(); + console.error(err); + }) + .then(() => connection.release()) + .then(() => db.close()); + }); +}); diff --git a/modules/notification/index.js b/modules/notification/index.js index 401b54b..4eaf889 100644 --- a/modules/notification/index.js +++ b/modules/notification/index.js @@ -26,8 +26,8 @@ const send = (req, res) => { }); } // retrieve required information - db.query('SELECT accounts.akey, token, car, email, telegram, push, lng, soc_display, soc_bms, consumption, last_notification FROM accounts \ - INNER JOIN sync ON accounts.akey=sync.akey INNER JOIN settings ON settings.akey=accounts.akey WHERE accounts.akey=?', [ + db.query('SELECT accounts.akey, token, car, mail as email, telegram, push, lng, soc_display, soc_bms, consumption, last_notification FROM accounts \ + INNER JOIN sync ON accounts.akey=sync.akey INNER JOIN settings ON settings.akey=accounts.akey LEFT JOIN notificationMail ON notificationMail.akey=accounts.akey AND verified=TRUE WHERE accounts.akey=?', [ req.body.akey ], (err, dbRes) => { if (!err && dbRes && (userObj = dbRes[0]) != null) { diff --git a/modules/notification/mail/index.js b/modules/notification/mail/index.js index 91d3ade..d8b3e5e 100644 --- a/modules/notification/mail/index.js +++ b/modules/notification/mail/index.js @@ -4,14 +4,18 @@ * @description Mail notification module */ const nodemailer = require('nodemailer'), + db = require('./../../db'), srv_config = require('./../../../srv_config.json'), srv_errors = require('./../../../srv_errors.json'), encryption = require('./../../encryption'), helper = require('./../../helper'), translation = require('./../../translation'); -const transporter = ((!srv_config.MAIL_SERVICE || !srv_config.MAIL_HOST || !srv_config.MAIL_PORT || !srv_config.MAIL_USER || - !srv_config.MAIL_PASSWORD || !srv_config.MAIL_ADDRESS) ? null : +const doQuery = require('util').promisify(db.query); +const getRandomBytes = require('util').promisify(require('crypto').randomBytes); + +const transporter = (((!srv_config.MAIL_SERVICE && (!srv_config.MAIL_HOST || !srv_config.MAIL_PORT)) || !srv_config.MAIL_USER || + !srv_config.MAIL_PASSWORD || !srv_config.MAIL_ADDRESS) ? null : nodemailer.createTransport({ host: srv_config.MAIL_HOST, port: srv_config.MAIL_PORT, @@ -46,14 +50,14 @@ const sendMail = (userObj, abort) => { SOC: (( userObj.soc_display == null) ? SOC_BMS : (( userObj.soc_bms == null) ? - SOC_DISPLAY : SOC_DISPLAY)) + SOC_DISPLAY : SOC_DISPLAY)) }, // use only defined values for text textObj = { SOC: (( userObj.soc_display == null) ? '' + SOC_BMS + ' (BMS)' : (( userObj.soc_bms == null) ? - '' + SOC_DISPLAY + ' (Display)' : '' + SOC_DISPLAY + ' (Display) / ' + SOC_BMS + ' (BMS)')), + '' + SOC_DISPLAY + ' (Display)' : '' + SOC_DISPLAY + ' (Display) / ' + SOC_BMS + ' (BMS)')), RANGE: helper.calculateRange(userObj.car, userObj.soc_display || userObj.soc_bms, userObj.consumption) + 'km' }; @@ -108,9 +112,86 @@ const simpleSend = (mail, subject, html, attachments, callback) => { }); }; +const setMail = (userObj, mail, callback) => { + const akey = userObj.akey; + if (!mail) { + return doQuery('DELETE FROM notificationMail WHERE akey=?', [akey]).then(() => callback(false)).catch(callback); + } + if (!validateMail(mail)) { + if (typeof callback === 'function') callback(srv_errors.INVALID_PARAMETERS); + return; + } + doQuery('SELECT mail FROM notificationMail WHERE akey=? AND verified=TRUE', [akey]) + .then(result => { + if (result.length > 0 && encryption.decrypt(result[0].mail) === mail) return Promise.reject(srv_errors.CURRENT_MAIL); + return true; + }) + .then(() => module.exports.checkMailUnlocked(mail)) + .then(() => getRandomBytes(16)) + .then(id => new Promise((res, rej) => { + module.exports.simpleSend(mail, translation.translate('MAIL_SUBJECT_VERIFY', userObj.lng, true), + translation.translateWithData('MAIL_TEXT_VERIFY', userObj.lng, { BASE_URL: srv_config.BASE_URL, ID: id.toString('hex') }, true), null, (err) => { + if (err) { + if (err.responseCode === 550) return rej(srv_errors.INVALID_MAIL) + return rej(err); + } + return res(id); + }); + })) + .then(id => doQuery('INSERT INTO notificationMail(akey,mail,verified,identifier) VALUES(?,?,false,?) ON DUPLICATE KEY UPDATE mail=VALUES(mail), verified=false, identifier=VALUES(identifier)', [akey, encryption.encrypt(mail), id])) + .then(() => module.exports.updateMailLock(mail)) + .then(() => callback(false)).catch(callback); +}; + +const verifyMail = (identifier, callback) => { + doQuery('UPDATE notificationMail SET verified=TRUE WHERE identifier=UNHEX(?)', [identifier]).then(queryResult => { + if (queryResult.affectedRows !== 1) return Promise.reject(srv_errors.NOT_FOUND); + if (queryResult.changedRows !== 1) return Promise.reject(srv_errors.CONFLICT); + return true; + }).then(() => callback(false)).catch(callback); +} + +const checkMailUnlocked = mail => { + return doQuery('SELECT time FROM mailLock WHERE hash=UNHEX(SHA2(?,256))', [mail]).then(dbRes => { + if (dbRes.length === 0) return mail; + if (dbRes[0].time.getTime() <= new Date().getTime()) return mail; + throw new Error('Recipient is currently locked') + }); +} + +const updateQuery = `INSERT INTO + mailLock + SELECT + hash, + TIMESTAMPADD(MINUTE, POWER(weight + 1, 2), CURRENT_TIMESTAMP()) as time, + weight + 1 as weight + FROM + mailLock + WHERE + hash = UNHEX(SHA2(?, 256)) + AND time > TIMESTAMPADD(MINUTE, - POWER(weight + 1, 3), CURRENT_TIMESTAMP()) + UNION + SELECT + UNHEX(SHA2(?, 256)), + CURRENT_TIMESTAMP() as time, + 1 as weight + LIMIT + 1 + ON DUPLICATE KEY UPDATE + time = VALUES(time), + weight = values(weight) + ` +const updateMailLock = mail => { + return doQuery(updateQuery, [mail, mail]); +} + module.exports = { validateMail, sendMail, sendQRMail, - simpleSend + simpleSend, + checkMailUnlocked, + updateMailLock, + setMail, + verifyMail, }; \ No newline at end of file diff --git a/modules/settings/index.js b/modules/settings/index.js index 94eb9ad..53229e7 100644 --- a/modules/settings/index.js +++ b/modules/settings/index.js @@ -7,7 +7,8 @@ const srv_config = require('./../../srv_config.json'), srv_errors = require('./../../srv_errors.json'), db = require('./../db'), token = require('./../token'), - encryption = require('./../encryption'); + encryption = require('./../encryption'), + mail = require('./../notification/mail'); /** * Retrieves settings from database for given akey @@ -15,7 +16,7 @@ const srv_config = require('./../../srv_config.json'), * @param {Function} callback callback function */ const getSettings = (akey, callback) => { - db.query('SELECT email, telegram, abrp, push, soc, consumption, car, device, lng, summary FROM settings WHERE akey=?', [ + db.query('SELECT verified as emailVerified, mail as email, telegram, abrp, push, soc, consumption, car, device, lng, summary FROM settings LEFT JOIN notificationMail ON notificationMail.akey=settings.akey WHERE settings.akey=?', [ akey ], (err, dbRes) => { if (!err && dbRes && dbRes[0] && dbRes[0].email) dbRes[0].email = encryption.decrypt(dbRes[0].email); @@ -30,8 +31,7 @@ const getSettings = (akey, callback) => { * @param {Function} callback callback function */ const setSettings = (akey, settings, callback) => { - db.query('UPDATE settings SET email=?, telegram=?, push=?, soc=?, consumption=?, car=?, device=?, lng=?, summary=? WHERE akey=?', [ - ((settings.email) ? encryption.encrypt(settings.email) : ''), + db.query('UPDATE settings SET telegram=?, push=?, soc=?, consumption=?, car=?, device=?, lng=?, summary=? WHERE akey=?', [ settings.telegram, settings.push, settings.soc, @@ -41,7 +41,13 @@ const setSettings = (akey, settings, callback) => { settings.lng, settings.summary, akey - ], (err, dbRes) => callback(err, ((!err && dbRes && dbRes[0]) ? dbRes[0] : null))); + ], (err) => { + if (err) callback(err); + mail.setMail({ akey: akey, lng: settings.lng }, settings.email, (err) => { + if (err && err.code !== 1102) return callback(err); + callback(false); + }); + }); }; module.exports = { @@ -111,10 +117,19 @@ module.exports = { settings: req.body.settings }); } else { - res.status(422).json({ - error: srv_errors.UNPROCESSABLE_ENTITY, - debug: ((srv_config.DEBUG) ? err : null) - }); + if (err.code && err.message && typeof err.code === "number") { + if (!err.status) { + err.status = err.code < 600 ? err.code : 400; + } + var debug = ((srv_config.DEBUG) ? err.debug : null) + delete err.debug; + return res.status(err.status).json({ error: err, debug }); + } else { + res.status(500).json({ + error: srv_errors.INTERNAL_SERVER_ERROR, + debug: ((srv_config.DEBUG) ? err : null) + }); + } } }); } else { @@ -130,5 +145,27 @@ module.exports = { }); } }); + }, + + verifyMail: (req, res) => { + if (!req.params.id) { + return res.status(400).json({ + error: srv_errors.INVALID_PARAMETERS + }); + } + mail.verifyMail(req.params.id, (err) => { + if (err) { + if (err.code && err.message) { + var debug = ((srv_config.DEBUG) ? err.debug : null) + err.debug = null; + return res.status(err.code).json({ error: err, debug }); + } + return res.status(500).json({ + error: srv_errors.INTERNAL_SERVER_ERROR, + debug: ((srv_config.DEBUG) ? err : null) + }); + } + res.status(204).send(); + }); } }; diff --git a/modules/translation/lng/de.json b/modules/translation/lng/de.json index 746fb5b..9403918 100644 --- a/modules/translation/lng/de.json +++ b/modules/translation/lng/de.json @@ -29,5 +29,7 @@ "TELEGRAM_NOTIFICATION_MESSAGE": "Hallo! Ich wollte Dir nur kurz Bescheid geben, dass Dein Elektroauto den gewünschten Ladezustand von {SOC} erreicht hat! Damit wirst Du ungefähr {RANGE} weit fahren können.", "TELEGRAM_NOTIFICATION_ABORT_MESSAGE": "Hallo! Es scheint so, als sei der Ladevorgang bei {SOC} abgebrochen worden oder es gab einen Fehler bei der Kommunikation mit dem Auto. Am besten mal nachschauen!", "MAIL_SUBJECT_QR": "QRNotify: Jemand möchte laden", - "MAIL_TEXT_QR": "Dein QR Code von QRNotify, welcher in EVNotify generiert wurde, wurde soeben eingescannt. Jemand möchte laden. Wenn möglich, kannst Du die Ladesäule frei machen." -} + "MAIL_TEXT_QR": "Dein QR Code von QRNotify, welcher in EVNotify generiert wurde, wurde soeben eingescannt. Jemand möchte laden. Wenn möglich, kannst Du die Ladesäule frei machen.", + "MAIL_SUBJECT_VERIFY": "Verifizierung der E-Mail-Adresse für EVNotify", + "MAIL_TEXT_VERIFY": "Hallo,
Um Benachrichtigungen von EVNotify zu erhalten, muss zuerst die E-Mail-Adresse verifiziert werden. Klicke hier oder öffne den folgenden Link um diesen Prozess abzuschließen: {BASE_URL}/verify/{ID}/de.html" +} \ No newline at end of file diff --git a/modules/translation/lng/en.json b/modules/translation/lng/en.json index 8c35543..a042547 100644 --- a/modules/translation/lng/en.json +++ b/modules/translation/lng/en.json @@ -29,5 +29,7 @@ "TELEGRAM_NOTIFICATION_MESSAGE": "Hello! I just wanted to notify you that your electric vehicle now has reached the desired charging status of {SOC}%! With that, you can drive about {RANGE}.", "TELEGRAM_NOTIFICATION_ABORT_MESSAGE": "Hello! It seems as if the charging process had been interrupted or there was an error within the communication with the car. Better have a look!", "MAIL_SUBJECT_QR": "QRNotify: Someone wants to charge", - "MAIL_TEXT_QR": "Your QR code of QRNotify generated in EVNotify has just been scanned. Someone wants to charge. If possible, you can clear the charging station." + "MAIL_TEXT_QR": "Your QR code of QRNotify generated in EVNotify has just been scanned. Someone wants to charge. If possible, you can clear the charging station.", + "MAIL_SUBJECT_VERIFY": "Please verify your email address for EVNotify", + "MAIL_TEXT_VERIFY": "Hello,
In order to receive notifications from EVNotify, we need you to verify your email address. Klick here or open the following link to complete this process: {BASE_URL}/verify/{ID}/en.html" } \ No newline at end of file diff --git a/package.json b/package.json index dc5e9bb..d5c3c7b 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,14 @@ "rollbar": "2.8.1", "stringinject": "2.1.1" }, - "devDependencies": {}, + "devDependencies": { + "chai-as-promised": "^7.1.1", + "chai-datetime": "^1.5.0", + "sinon": "^7.3.2" + }, "scripts": { "pretest": "npm run createEnv", - "test": "mocha tests/*", + "test": "mocha tests --recursive", "start": "node server.js", "createEnv": "/bin/bash createEnv.sh" }, diff --git a/srv_errors.json b/srv_errors.json index d52af80..a245c94 100644 --- a/srv_errors.json +++ b/srv_errors.json @@ -1,5 +1,6 @@ { "BAD_REQUEST": { + "status": 400, "code": 400, "message": "Bad request. Your request could not be processed. This has been automatically reported." }, @@ -11,6 +12,10 @@ "code": 404, "message": "Requested source not found." }, + "CONFLICT": { + "code": 409, + "message": "Request conflicts with the current state" + }, "UNPROCESSABLE_ENTITY": { "code": 422, "message": "Your request could not be processed. This is either a client side error or a server error." @@ -24,34 +29,52 @@ "message": "Internal server error occured. It has been automatically reported." }, "UNKNOWN_ROUTE": { + "status": 404, "code": 1000, "message": "Requested route does not exist. Unable to handle request." }, "INVALID_PARAMETERS": { + "status": 422, "code": 1100, "message": "Missing or invalid parameters." }, + "INVALID_MAIL": { + "status": 422, + "code": 1101, + "message": "Missing or invalid target mail address." + }, + "CURRENT_MAIL": { + "status": 409, + "code": 1102, + "message": "The target mail address is already the current mail address for this user." + }, "DB_QUERY": { + "status": 500, "code": 1200, "message": "Error on database query." }, "MALFORMED_AKEY": { + "status": 422, "code": 1300, "message": "AKey format is not valid. Must be a string with 6 characters." }, "MALFORMED_PASSWORD": { + "status": 422, "code": 1400, "message": "Password must be a string with at least 6 characters and less than 72 characters." }, "ALREADY_REGISTERED": { + "status": 409, "code": 1500, "message": "Requested AKey already registered." }, "HASH_FAILED": { + "status": 500, "code": 1600, "message": "Generation of password hash failed." }, "USER_NOT_EXISTING": { + "status": 404, "code": 1700, "message": "Requested user does not exist." }, @@ -64,6 +87,7 @@ "message": "Provided token is invalid or no longer valid." }, "MAIL_ALREADY_REGISTERED": { + "status": 409, "code": 2000, "message": "Requested Mail already registered." }, diff --git a/static/verify/de.html b/static/verify/de.html new file mode 100644 index 0000000..1eb0f2d --- /dev/null +++ b/static/verify/de.html @@ -0,0 +1,17 @@ + + + + + + + +
+ +
+ + + + + + + \ No newline at end of file diff --git a/static/verify/en.html b/static/verify/en.html new file mode 100644 index 0000000..b529659 --- /dev/null +++ b/static/verify/en.html @@ -0,0 +1,17 @@ + + + + + + + +
+ +
+ + + + + + + \ No newline at end of file diff --git a/static/verify/request.js b/static/verify/request.js new file mode 100644 index 0000000..84d4ca2 --- /dev/null +++ b/static/verify/request.js @@ -0,0 +1,21 @@ +function doRequest() { + var request = new XMLHttpRequest(); + request.onreadystatechange = function () { + if (this.readyState == 4) { + if (this.status >= 200 && this.status < 300) { + document.getElementById("success").style.display = "block"; + } else { + var errorDiv = document.getElementById("error" + this.status); + if (errorDiv) { + errorDiv.style.display = "block"; + } else { + document.getElementById("error").appendChild(document.createTextNode(request.responseText)); + document.getElementById("error").style.display = "block"; + } + } + } + } + request.open("post", ".", true); + request.send(); + return false; +} \ No newline at end of file diff --git a/tests/mochaHooks.js b/tests/mochaHooks.js new file mode 100644 index 0000000..08bf7c4 --- /dev/null +++ b/tests/mochaHooks.js @@ -0,0 +1 @@ +after(require('../modules/db').close); \ No newline at end of file diff --git a/tests/notification/mail/mailNotificationTest.js b/tests/notification/mail/mailNotificationTest.js new file mode 100644 index 0000000..d823102 --- /dev/null +++ b/tests/notification/mail/mailNotificationTest.js @@ -0,0 +1,193 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const sandbox = sinon.createSandbox(); + +const db = require('../../../modules/db'); +const uut = require('../../../modules/notification/mail'); +const encryption = require('./../../../modules/encryption') + +const srv_errors = require('./../../../srv_errors.json'); + +const doQuery = require('util').promisify(db.query); + +chai.use(require('chai-as-promised')); +chai.use(require('chai-datetime')); +chai.should(); + +const clearAll = () => { + return Promise.all(['sync', 'statistics', 'settings', 'qr', 'notificationMail', 'mailLock', 'logs', 'login', 'devices', 'debug'].map(e => doQuery('DELETE FROM ' + e))).then(() => doQuery('DELETE FROM accounts')); +} + +const realSend = uut.simpleSend; + +describe('mail', () => { + beforeEach('clear db', clearAll); + + describe('checkMailUnlocked', () => { + it('successful when table is empty', () => { + return uut.checkMailUnlocked('test@example.com') + }); + + it('successful on unknown mail', () => { + return doQuery('INSERT INTO mailLock(hash,time) VALUES(UNHEX(SHA2("someOtherMail@example.com",256)),TIMESTAMPADD(DAY, 1, CURRENT_TIMESTAMP()))').then(() => uut.checkMailUnlocked('test@example.com')); + }); + + it('successful on expired lock', () => { + return doQuery('INSERT INTO mailLock(hash,time) VALUES(UNHEX(SHA2("test@example.com",256)),TIMESTAMPADD(DAY, -1, CURRENT_TIMESTAMP()))').then(() => uut.checkMailUnlocked('test@example.com')); + }); + + it('fail on active lock', () => { + return doQuery('INSERT INTO mailLock(hash,time) VALUES(UNHEX(SHA2("test@example.com",256)),TIMESTAMPADD(DAY, 1, CURRENT_TIMESTAMP()))').then(() => uut.checkMailUnlocked('test@example.com')).should.eventually.be.rejectedWith(Error, /Recipient is currently locked/); + }); + + }); + + describe('updateMailLock', () => { + it('successful when table is empty', () => { + return uut.updateMailLock('test@example.com').then(() => doQuery('SELECT HEX(hash),time,weight FROM mailLock ORDER BY hash')).then(queryRes => { + const dateRange = createDates(0); + queryRes.should.have.lengthOf(1); + queryRes[0].should.have.property('weight', 1); + queryRes[0].should.have.property('time').withinTime(dateRange[0], dateRange[1]) + }) + }); + + it('successful on unknown mail', () => { + return doQuery('INSERT INTO mailLock(hash,time) VALUES(UNHEX(SHA2("someOtherMail@example.com",256)),TIMESTAMPADD(DAY, 1, CURRENT_TIMESTAMP()))').then(() => uut.updateMailLock('test@example.com')).then(() => doQuery('SELECT HEX(hash) as hash,time,weight FROM mailLock ORDER BY hash')).then(queryRes => { + const dateRangeOld = createDates(1440); + const dateRangeNew = createDates(0); + queryRes.should.have.lengthOf(2); + + queryRes[0].should.have.property('hash', '973DFE463EC85785F5F95AF5BA3906EEDB2D931C24E69824A89EA65DBA4E813B'); + queryRes[0].should.have.property('weight', 1); + queryRes[0].should.have.property('time').withinTime(dateRangeNew[0], dateRangeNew[1]) + + queryRes[1].should.have.property('hash', 'A88AC22920CAC00341069D6BD52EAA778187E3AE9109AB2FB1B593024B48F19A'); + queryRes[1].should.have.property('weight', 1); + queryRes[1].should.have.property('time').withinTime(dateRangeOld[0], dateRangeOld[1]) + }) + }); + + it('successful on known mail', () => { + return doQuery('INSERT INTO mailLock(hash,time) VALUES(UNHEX(SHA2("test@example.com",256)), CURRENT_TIMESTAMP())').then(() => uut.updateMailLock('test@example.com')).then(() => doQuery('SELECT HEX(hash) as hash,time,weight FROM mailLock ORDER BY hash')).then(queryRes => { + const dateRangeNew = createDates(4); + queryRes.should.have.lengthOf(1); + + queryRes[0].should.have.property('hash', '973DFE463EC85785F5F95AF5BA3906EEDB2D931C24E69824A89EA65DBA4E813B'); + queryRes[0].should.have.property('weight', 2); + queryRes[0].should.have.property('time').withinTime(dateRangeNew[0], dateRangeNew[1]) + }) + }); + + it('successful on known heavy mail', () => { + return doQuery('INSERT INTO mailLock(hash,time,weight) VALUES(UNHEX(SHA2("test@example.com",256)), TIMESTAMPADD(DAY, 1, CURRENT_TIMESTAMP()), 19)').then(() => uut.updateMailLock('test@example.com')).then(() => doQuery('SELECT HEX(hash) as hash,time,weight FROM mailLock ORDER BY hash')).then(queryRes => { + const dateRangeNew = createDates(400); + queryRes.should.have.lengthOf(1); + + queryRes[0].should.have.property('hash', '973DFE463EC85785F5F95AF5BA3906EEDB2D931C24E69824A89EA65DBA4E813B'); + queryRes[0].should.have.property('weight', 20); + queryRes[0].should.have.property('time').withinTime(dateRangeNew[0], dateRangeNew[1]) + }) + }); + }); + + describe('set mail', () => { + promisedSetMail = require('util').promisify(uut.setMail); + beforeEach(() => doQuery('INSERT INTO accounts VALUES(123456,1,2,3),(234567,4,5,6)')); + beforeEach(() => sandbox.spy(uut, 'updateMailLock')); + + it('fails if new mail is same as current mail', () => { + sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(false)); + return doQuery('INSERT INTO notificationMail(akey, mail, verified, identifier) VALUES(123456,?, TRUE, UNHEX("1234567890abcdef1234567890abcdef"))', [encryption.encrypt('test@example.com')]) + .then(() => promisedSetMail({ akey: '123456', lng: 'en' }, 'test@example.com').should.eventually.be.rejected.and.equal(srv_errors.CURRENT_MAIL)); + }); + + it('new mail added successfully', () => { + sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(false)); + return promisedSetMail({ akey: '123456', lng: 'en' }, 'test@example.com') + .then(() => doQuery('SELECT akey,mail,verified FROM notificationMail')) + .then(queryRes => { + queryRes.should.have.lengthOf(1); + + queryRes[0].should.have.property('akey', '123456'); + queryRes[0].should.have.property('mail'); + encryption.decrypt(queryRes[0].mail).should.equal('test@example.com'); + queryRes[0].should.have.property('verified', 0); + + sinon.assert.calledWith(uut.updateMailLock, 'test@example.com'); + }); + }); + + it('mail replaced successfully', () => { + sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(false)); + return doQuery('INSERT INTO notificationMail(akey,mail,verified,identifier) VALUES(?,?,false,?)', [123456, encryption.encrypt('oldtest@example.com'), 'asd']) + .then(() => promisedSetMail({ akey: '123456', lng: 'en' }, 'test@example.com')) + .then(() => doQuery('SELECT akey,mail,verified FROM notificationMail')) + .then(queryRes => { + queryRes.should.have.lengthOf(1); + + queryRes[0].should.have.property('akey', '123456'); + queryRes[0].should.have.property('mail'); + encryption.decrypt(queryRes[0].mail).should.equal('test@example.com'); + queryRes[0].should.have.property('verified', 0); + + sinon.assert.calledWith(uut.updateMailLock, 'test@example.com'); + }); + }); + + it('fails if invalid mail', () => { + sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(false)); + return promisedSetMail({ akey: '123456', lng: 'en' }, 'test@@@example.com').should.eventually.be.rejected.and.equal(srv_errors.INVALID_PARAMETERS); + }); + + it('fails if sending of mail failed', () => { + sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(new Error('some error...'))); + return promisedSetMail({ akey: '123456', lng: 'en' }, 'test@example.com').should.eventually.be.rejectedWith(Error, /some error.../); + }); + + it('fails if mail currently locked', () => { + sandbox.stub(uut, 'simpleSend').callsFake((mail, subject, html, attachments, callback) => callback(false)); + sandbox.stub(uut, 'checkMailUnlocked').callsFake(() => Promise.reject(new Error('Recipient is currently locked'))) + return promisedSetMail({ akey: '123456', lng: 'en' }, 'test@example.com').should.eventually.be.rejectedWith(Error, /Recipient is currently locked/) + }); + + it('removes mail if not specified', () => { + return doQuery('INSERT INTO notificationMail(akey,mail,verified,identifier) VALUES(?,?,false,?)', [123456, encryption.encrypt('test@example.com'), 'asd']) + .then(() => promisedSetMail({ akey: '123456', lng: 'en' }, null)) + .then(() => doQuery('SELECT akey,mail,verified FROM notificationMail')) + .then(queryRes => { + queryRes.should.have.lengthOf(0); + }); + }); + + afterEach(() => sandbox.restore()) + }); + + describe('verify mail', () => { + beforeEach(() => doQuery('INSERT INTO accounts VALUES(123456,1,2,3),(234567,4,5,6)') + .then(() => doQuery('INSERT INTO notificationMail(akey, mail, verified, identifier) VALUES(123456,?, FALSE, UNHEX("1234567890abcdef1234567890abcdef")),(234567,?, TRUE, UNHEX("fedcba0987654321fedcba0987654321"))', ['test@example.com', 'test2@example.com']))); + var promisedMailVerification = require('util').promisify(uut.verifyMail); + + it('verification of unverified mail successful', () => { + return promisedMailVerification('1234567890abcdef1234567890abcdef'); + }) + + it('verification of verified mail fails', () => { + return promisedMailVerification('fedcba0987654321fedcba0987654321').should.eventually.be.rejected.and.equal(srv_errors.CONFLICT); + }) + + it('verification of unknown mail fails', () => { + return promisedMailVerification('eeeeee0987654321fedcba0987654321').should.eventually.be.rejected.and.equal(srv_errors.NOT_FOUND); + }) + }) + + after(() => uut.simpleSend = realSend); +}); + +function createDates(minutesToAdd) { + const endDateMin = new Date(); + endDateMin.setMinutes(endDateMin.getMinutes() + minutesToAdd - 1); + const endDateMax = new Date(); + endDateMax.setMinutes(endDateMax.getMinutes() + minutesToAdd + 1); + return [endDateMin, endDateMax]; +} \ No newline at end of file diff --git a/tests/notification/notificationTest.js b/tests/notification/notificationTest.js new file mode 100644 index 0000000..6d5f1e8 --- /dev/null +++ b/tests/notification/notificationTest.js @@ -0,0 +1,78 @@ +require('chai'); +const sinon = require('sinon'); +const sandbox = sinon.createSandbox(); + +const db = require('../../modules/db'); +const uut = require('../../modules/notification'); +const encryption = require('../../modules/encryption') +const webhook = require('../../modules/webhook'); +const mail = require('../../modules/notification/mail'); +const telegram = require('../../modules/notification/telegram'); +const push = require('../../modules/notification/push') + +const doQuery = require('util').promisify(db.query); +const clearAll = () => { + return Promise.all(['sync', 'statistics', 'settings', 'qr', 'notificationMail', 'mailLock', 'logs', 'login', 'devices', 'debug'].map(e => doQuery('DELETE FROM ' + e))).then(() => doQuery('DELETE FROM accounts')); +} +var encryptedTestMail = encryption.encrypt('test@example.com') + +var reqA = { body: { akey: '123456', token: '3', abort: false } }; +// var reqB = { body: { akey: '234567', token: '6', abort: false } }; +var req, res; + +describe('test notification sending', () => { + beforeEach('clear db', clearAll); + beforeEach('create accounts', () => doQuery('INSERT INTO accounts VALUES(123456,1,2,3),(234567,4,5,6)')); + beforeEach('create empty syncs', () => doQuery('INSERT INTO sync(user,akey) VALUES(123456,123456),(234567,234567)')); + beforeEach('create empty settings', () => doQuery('INSERT INTO settings(user,akey) VALUES(123456,123456),(234567,234567)')); + beforeEach('create stubs', () => { + sandbox.stub(mail, 'sendMail').returns(null); + sandbox.stub(telegram, 'sendMessage'); + sandbox.stub(push, 'sendPush'); + sandbox.stub(webhook, 'emit'); + req = {}; + res = () => { }; + }) + + describe('mail notifications', () => { + it('sends mail if present and verified', done => { + req = reqA; + res = { + json: () => { + sinon.assert.calledWith(mail.sendMail, sinon.match.has("email", encryptedTestMail), false); + done(); + } + }; + + doQuery('INSERT INTO notificationMail(akey, mail, verified, identifier) VALUES(123456,?, TRUE, UNHEX("1234567890abcdef1234567890abcdef"))', [encryptedTestMail]) + .then(() => uut.send(req, res)); + }); + + it('does not send if not verified', done => { + req = reqA; + res = { + json: () => { + sinon.assert.notCalled(mail.sendMail); + done(); + } + }; + + doQuery('INSERT INTO notificationMail(akey, mail, verified, identifier) VALUES(123456,?, FALSE, UNHEX("1234567890abcdef1234567890abcdef"))', [encryptedTestMail]) + .then(() => uut.send(req, res)); + }); + + it('does not send if no address', done => { + req = reqA; + res = { + json: () => { + sinon.assert.notCalled(mail.sendMail); + done(); + } + }; + + uut.send(req, res); + }); + }); + + afterEach("restore sandbox", () => sandbox.restore()); +}); \ No newline at end of file