diff --git a/api.js b/api.js index 7cd2a6fc..e8990fa2 100644 --- a/api.js +++ b/api.js @@ -10,6 +10,8 @@ const MessageHandler = require('./lib/message-handler'); const ImapNotifier = require('./lib/imap-notifier'); const db = require('./lib/db'); const certs = require('./lib/certs'); +const ObjectID = require('mongodb').ObjectID; +const rootUser = new ObjectID('0'.repeat(24)); const usersRoutes = require('./lib/api/users'); const addressesRoutes = require('./lib/api/addresses'); @@ -79,21 +81,64 @@ server.use( ); server.use((req, res, next) => { - if (config.api.accessToken && ![req.query.accessToken, req.headers['x-access-token']].includes(config.api.accessToken)) { - res.status(403); - res.charSet('utf-8'); - return res.json({ - error: 'Invalid accessToken value' - }); - } + let accessToken = req.query.accessToken || req.headers['x-access-token'] || false; if (req.query.accessToken) { delete req.query.accessToken; } + + let tokenRequired = false; + + let fail = () => { + res.status(403); + res.charSet('utf-8'); + return res.json({ + error: 'Invalid accessToken value', + code: 'InvalidToken' + }); + }; + + req.validate = permission => { + if (!permission.granted) { + let err = new Error('Not enough privileges'); + err.responseCode = 403; + err.code = 'MissingPrivileges'; + throw err; + } + }; + + // hard coded master token + if (config.api.accessToken) { + tokenRequired = true; + if (config.api.accessToken === accessToken) { + req.role = 'root'; + req.user = rootUser; + return next(); + } + } + + // TODO: dynamically allocated tokens + + if (tokenRequired) { + // no valid token found + return fail(); + } + + // allow all + req.role = 'root'; + req.user = rootUser; next(); }); +logger.token('user', req => (req.user && req.user.toString()) || '?'.repeat(24)); +logger.token('url', req => { + if (/\baccessToken=/.test(req.url)) { + return req.url.replace(/\baccessToken=[^&]+/g, 'accessToken=' + 'x'.repeat(6)); + } + return req.url; +}); + server.use( - logger(':method :url :status :time-spent :append', { + logger(':remote-addr :user :method :url :status :time-spent :append', { stream: { write: message => { message = (message || '').toString(); diff --git a/config/api.toml b/config/api.toml index 474b9c40..89d55b7b 100644 --- a/config/api.toml +++ b/config/api.toml @@ -19,6 +19,9 @@ enabled=false #cipher="aes192" #secret="a secret cat" +[roles] +# @include "roles.json" + [tls] # If certificate path is not defined, use global or built-in self-signed certs #key="/path/to/server/key.pem" diff --git a/config/roles.json b/config/roles.json new file mode 100644 index 00000000..8dc9e647 --- /dev/null +++ b/config/roles.json @@ -0,0 +1,10 @@ +{ + "root": { + "addresses": { + "create:any": ["*"], + "read:any": ["*"], + "update:any": ["*"], + "delete:any": ["*"] + } + } +} diff --git a/lib/api/addresses.js b/lib/api/addresses.js index 8e6b5207..20afd80f 100644 --- a/lib/api/addresses.js +++ b/lib/api/addresses.js @@ -6,6 +6,7 @@ const MongoPaging = require('mongo-cursor-pagination'); const ObjectID = require('mongodb').ObjectID; const tools = require('../tools'); const consts = require('../consts'); +const roles = require('../roles'); module.exports = (db, server) => { /** @@ -74,6 +75,8 @@ module.exports = (db, server) => { server.get( { name: 'addresses', path: '/addresses' }, tools.asyncifyJson(async (req, res, next) => { + req.validate(roles.can(req.role).readAny('addresses')); + res.charSet('utf-8'); const schema = Joi.object().keys({ @@ -282,208 +285,216 @@ module.exports = (db, server) => { * "error": "This user does not exist" * } */ - server.post('/users/:user/addresses', (req, res, next) => { - res.charSet('utf-8'); + server.post( + '/users/:user/addresses', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string() - .hex() - .lowercase() - .length(24) - .required(), - address: [ - Joi.string() - .email() + const schema = Joi.object().keys({ + user: Joi.string() + .hex() + .lowercase() + .length(24) .required(), - Joi.string().regex(/^\w+@\*$/, 'special address') - ], - name: Joi.string() - .empty('') - .trim() - .max(128), - main: Joi.boolean() - .truthy(['Y', 'true', 'yes', 'on', 1]) - .falsy(['N', 'false', 'no', 'off', 0, '']), - allowWildcard: Joi.boolean() - .truthy(['Y', 'true', 'yes', 'on', 1]) - .falsy(['N', 'false', 'no', 'off', 0, '']), - tags: Joi.array().items( - Joi.string() + address: [ + Joi.string() + .email() + .required(), + Joi.string().regex(/^\w+@\*$/, 'special address') + ], + name: Joi.string() + .empty('') .trim() - .max(128) - ) - }); - - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true - }); - - if (result.error) { - res.json({ - error: result.error.message, - code: 'InputValidationError' + .max(128), + main: Joi.boolean() + .truthy(['Y', 'true', 'yes', 'on', 1]) + .falsy(['N', 'false', 'no', 'off', 0, '']), + allowWildcard: Joi.boolean() + .truthy(['Y', 'true', 'yes', 'on', 1]) + .falsy(['N', 'false', 'no', 'off', 0, '']), + tags: Joi.array().items( + Joi.string() + .trim() + .max(128) + ) }); - return next(); - } - let user = new ObjectID(result.value.user); - let main = result.value.main; - let name = result.value.name; - let address = tools.normalizeAddress(result.value.address); - - if (address.indexOf('+') >= 0) { - res.json({ - error: 'Address can not contain +' + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true }); - return next(); - } - let wcpos = address.indexOf('*'); - - if (wcpos >= 0) { - if (!result.value.allowWildcard) { + if (result.error) { res.json({ - error: 'Address can not contain *' + error: result.error.message, + code: 'InputValidationError' }); return next(); } - if (/[^@]\*|\*[^@]/.test(result.value) || wcpos !== address.lastIndexOf('*')) { + let user = new ObjectID(result.value.user); + + // permissions check + if (req.user && req.user.equals(user)) { + req.validate(roles.can(req.role).createOwn('addresses')); + } else { + req.validate(roles.can(req.role).createAny('addresses')); + } + + let main = result.value.main; + let name = result.value.name; + let address = tools.normalizeAddress(result.value.address); + + if (address.indexOf('+') >= 0) { res.json({ - error: 'Invalid wildcard address, use "*@domain" or "user@*"' + error: 'Address can not contain +' }); return next(); } - if (main) { - res.json({ - error: 'Main address can not contain *' - }); - return next(); - } - } + let wcpos = address.indexOf('*'); - if (result.value.tags) { - let tagSeen = new Set(); - let tags = result.value.tags - .map(tag => tag.trim()) - .filter(tag => { - if (tag && !tagSeen.has(tag.toLowerCase())) { - tagSeen.add(tag.toLowerCase()); - return true; - } - return false; - }) - .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - - result.value.tags = tags; - result.value.tagsview = tags.map(tag => tag.toLowerCase()); - } - - db.users.collection('users').findOne( - { - _id: user - }, - { - projection: { - address: true - } - }, - (err, userData) => { - if (err) { + if (wcpos >= 0) { + if (!result.value.allowWildcard) { res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - if (!userData) { - res.json({ - error: 'This user does not exist', - code: 'UserNotFound' + error: 'Address can not contain *' }); return next(); } - db.users.collection('addresses').findOne( + if (/[^@]\*|\*[^@]/.test(result.value) || wcpos !== address.lastIndexOf('*')) { + res.json({ + error: 'Invalid wildcard address, use "*@domain" or "user@*"' + }); + return next(); + } + + if (main) { + res.json({ + error: 'Main address can not contain *' + }); + return next(); + } + } + + if (result.value.tags) { + let tagSeen = new Set(); + let tags = result.value.tags + .map(tag => tag.trim()) + .filter(tag => { + if (tag && !tagSeen.has(tag.toLowerCase())) { + tagSeen.add(tag.toLowerCase()); + return true; + } + return false; + }) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + result.value.tags = tags; + result.value.tagsview = tags.map(tag => tag.toLowerCase()); + } + + let userData; + try { + userData = await db.users.collection('users').findOne( { - addrview: tools.uview(address) + _id: user }, - (err, addressData) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); + { + projection: { + address: true } - - if (addressData) { - res.json({ - error: 'This email address already exists', - code: 'AddressExists' - }); - return next(); - } - - addressData = { - user, - name, - address, - addrview: tools.uview(address), - created: new Date() - }; - - if (result.value.tags) { - addressData.tags = result.value.tags; - addressData.tagsview = result.value.tags; - } - - // insert alias address to email address registry - db.users.collection('addresses').insertOne(addressData, (err, r) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - - let insertId = r.insertedId; - - let done = () => { - // ignore potential user update error - res.json({ - success: !!insertId, - id: insertId - }); - return next(); - }; - - if (!userData.address || main) { - // register this address as the default address for that user - return db.users.collection('users').findOneAndUpdate( - { - _id: user - }, - { - $set: { - address - } - }, - {}, - done - ); - } - - done(); - }); } ); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); } - ); - }); + + if (!userData) { + res.json({ + error: 'This user does not exist', + code: 'UserNotFound' + }); + return next(); + } + + let addressData; + try { + addressData = await db.users.collection('addresses').findOne({ + addrview: tools.uview(address) + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + if (addressData) { + res.json({ + error: 'This email address already exists', + code: 'AddressExists' + }); + return next(); + } + + addressData = { + user, + name, + address, + addrview: tools.uview(address), + created: new Date() + }; + + if (result.value.tags) { + addressData.tags = result.value.tags; + addressData.tagsview = result.value.tags; + } + + let r; + // insert alias address to email address registry + try { + r = await db.users.collection('addresses').insertOne(addressData); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + let insertId = r.insertedId; + + if (!userData.address || main) { + // register this address as the default address for that user + try { + await db.users.collection('users').findOneAndUpdate( + { + _id: user + }, + { + $set: { + address + } + } + ); + } catch (err) { + // ignore + } + } + + res.json({ + success: !!insertId, + id: insertId + }); + return next(); + }) + ); /** * @api {get} /users/:user/addresses List registered Addresses for an User @@ -535,59 +546,74 @@ module.exports = (db, server) => { * "error": "This user does not exist" * } */ - server.get('/users/:user/addresses', (req, res, next) => { - res.charSet('utf-8'); + server.get( + '/users/:user/addresses', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string() - .hex() - .lowercase() - .length(24) - .required() - }); - - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true - }); - - if (result.error) { - res.json({ - error: result.error.message, - code: 'InputValidationError' + const schema = Joi.object().keys({ + user: Joi.string() + .hex() + .lowercase() + .length(24) + .required() }); - return next(); - } - let user = new ObjectID(result.value.user); + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true + }); - db.users.collection('users').findOne( - { - _id: user - }, - { - projection: { - name: true, - address: true - } - }, - (err, userData) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - if (!userData) { - res.json({ - error: 'This user does not exist', - code: 'UserNotFound' - }); - return next(); - } + if (result.error) { + res.json({ + error: result.error.message, + code: 'InputValidationError' + }); + return next(); + } - db.users + let user = new ObjectID(result.value.user); + + // permissions check + if (req.user && req.user.equals(user)) { + req.validate(roles.can(req.role).readOwn('addresses')); + } else { + req.validate(roles.can(req.role).readAny('addresses')); + } + + let userData; + try { + userData = await db.users.collection('users').findOne( + { + _id: user + }, + { + projection: { + name: true, + address: true + } + } + ); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + if (!userData) { + res.json({ + error: 'This user does not exist', + code: 'UserNotFound' + }); + return next(); + } + + let addresses; + + try { + addresses = await db.users .collection('addresses') .find({ user @@ -595,37 +621,35 @@ module.exports = (db, server) => { .sort({ addrview: 1 }) - .toArray((err, addresses) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - - if (!addresses) { - addresses = []; - } - - res.json({ - success: true, - - results: addresses.map(address => ({ - id: address._id, - name: address.name || false, - address: address.address, - main: address.address === userData.address, - tags: address.tags || [], - created: address.created - })) - }); - - return next(); - }); + .toArray(); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); } - ); - }); + + if (!addresses) { + addresses = []; + } + + res.json({ + success: true, + + results: addresses.map(address => ({ + id: address._id, + name: address.name || false, + address: address.address, + main: address.address === userData.address, + tags: address.tags || [], + created: address.created + })) + }); + + return next(); + }) + ); /** * @api {get} /users/:user/addresses/:address Request Addresses information @@ -668,101 +692,111 @@ module.exports = (db, server) => { * "error": "This user does not exist" * } */ - server.get('/users/:user/addresses/:address', (req, res, next) => { - res.charSet('utf-8'); + server.get( + '/users/:user/addresses/:address', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string() - .hex() - .lowercase() - .length(24) - .required(), - address: Joi.string() - .hex() - .lowercase() - .length(24) - .required() - }); - - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true - }); - - if (result.error) { - res.json({ - error: result.error.message, - code: 'InputValidationError' + const schema = Joi.object().keys({ + user: Joi.string() + .hex() + .lowercase() + .length(24) + .required(), + address: Joi.string() + .hex() + .lowercase() + .length(24) + .required() }); - return next(); - } - let user = new ObjectID(result.value.user); - let address = new ObjectID(result.value.address); + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true + }); - db.users.collection('users').findOne( - { - _id: user - }, - { - projection: { - name: true, - address: true - } - }, - (err, userData) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - if (!userData) { - res.json({ - error: 'This user does not exist', - code: 'UserNotFound' - }); - return next(); - } + if (result.error) { + res.json({ + error: result.error.message, + code: 'InputValidationError' + }); + return next(); + } - db.users.collection('addresses').findOne( + let user = new ObjectID(result.value.user); + + // permissions check + if (req.user && req.user.equals(user)) { + req.validate(roles.can(req.role).readOwn('addresses')); + } else { + req.validate(roles.can(req.role).readAny('addresses')); + } + + let address = new ObjectID(result.value.address); + + let userData; + try { + userData = await db.users.collection('users').findOne( { - _id: address, - user + _id: user }, - (err, addressData) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); + { + projection: { + name: true, + address: true } - if (!addressData) { - res.status(404); - res.json({ - error: 'Invalid or unknown address', - code: 'AddressNotFound' - }); - return next(); - } - - res.json({ - success: true, - id: addressData._id, - name: addressData.name || false, - address: addressData.address, - main: addressData.address === userData.address, - created: addressData.created - }); - - return next(); } ); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); } - ); - }); + + if (!userData) { + res.json({ + error: 'This user does not exist', + code: 'UserNotFound' + }); + return next(); + } + + let addressData; + try { + addressData = await db.users.collection('addresses').findOne({ + _id: address, + user + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + if (!addressData) { + res.status(404); + res.json({ + error: 'Invalid or unknown address', + code: 'AddressNotFound' + }); + return next(); + } + + res.json({ + success: true, + id: addressData._id, + name: addressData.name || false, + address: addressData.address, + main: addressData.address === userData.address, + created: addressData.created + }); + + return next(); + }) + ); /** * @api {put} /users/:user/addresses/:address Update Address information @@ -805,242 +839,240 @@ module.exports = (db, server) => { * "error": "This user does not exist" * } */ - server.put('/users/:user/addresses/:id', (req, res, next) => { - res.charSet('utf-8'); + server.put( + '/users/:user/addresses/:id', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string() - .hex() - .lowercase() - .length(24) - .required(), - id: Joi.string() - .hex() - .lowercase() - .length(24) - .required(), - name: Joi.string() - .empty('') - .trim() - .max(128), - address: Joi.string().email(), - main: Joi.boolean() - .truthy(['Y', 'true', 'yes', 'on', 1]) - .falsy(['N', 'false', 'no', 'off', 0, '']), - tags: Joi.array().items( - Joi.string() + const schema = Joi.object().keys({ + user: Joi.string() + .hex() + .lowercase() + .length(24) + .required(), + id: Joi.string() + .hex() + .lowercase() + .length(24) + .required(), + name: Joi.string() + .empty('') .trim() - .max(128) - ) - }); - - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true - }); - - if (result.error) { - res.json({ - error: result.error.message, - code: 'InputValidationError' + .max(128), + address: Joi.string().email(), + main: Joi.boolean() + .truthy(['Y', 'true', 'yes', 'on', 1]) + .falsy(['N', 'false', 'no', 'off', 0, '']), + tags: Joi.array().items( + Joi.string() + .trim() + .max(128) + ) }); - return next(); - } - let user = new ObjectID(result.value.user); - let id = new ObjectID(result.value.id); - let main = result.value.main; - - if (main === false) { - res.json({ - error: 'Cannot unset main status' + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true }); - return next(); - } - let updates = {}; + if (result.error) { + res.json({ + error: result.error.message, + code: 'InputValidationError' + }); + return next(); + } - if (result.value.address) { - let address = tools.normalizeAddress(result.value.address); - let addrview = tools.uview(address); + let user = new ObjectID(result.value.user); - updates.address = address; - updates.addrview = addrview; - } + // permissions check + if (req.user && req.user.equals(user)) { + req.validate(roles.can(req.role).updateOwn('addresses')); + } else { + req.validate(roles.can(req.role).updateAny('addresses')); + } - if (result.value.name) { - updates.name = result.value.name; - } + let id = new ObjectID(result.value.id); + let main = result.value.main; - if (result.value.tags) { - let tagSeen = new Set(); - let tags = result.value.tags - .map(tag => tag.trim()) - .filter(tag => { - if (tag && !tagSeen.has(tag.toLowerCase())) { - tagSeen.add(tag.toLowerCase()); - return true; - } - return false; - }) - .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + if (main === false) { + res.json({ + error: 'Cannot unset main status' + }); + return next(); + } - updates.tags = tags; - updates.tagsview = tags.map(tag => tag.toLowerCase()); - } + let updates = {}; - db.users.collection('users').findOne( - { - _id: user - }, - { - projection: { - address: true - } - }, - (err, userData) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - if (!userData) { - res.json({ - error: 'This user does not exist', - code: 'UserNotFound' - }); - return next(); - } + if (result.value.address) { + let address = tools.normalizeAddress(result.value.address); + let addrview = tools.uview(address); - db.users.collection('addresses').findOne( + updates.address = address; + updates.addrview = addrview; + } + + if (result.value.name) { + updates.name = result.value.name; + } + + if (result.value.tags) { + let tagSeen = new Set(); + let tags = result.value.tags + .map(tag => tag.trim()) + .filter(tag => { + if (tag && !tagSeen.has(tag.toLowerCase())) { + tagSeen.add(tag.toLowerCase()); + return true; + } + return false; + }) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + updates.tags = tags; + updates.tagsview = tags.map(tag => tag.toLowerCase()); + } + + let userData; + try { + userData = await db.users.collection('users').findOne( { - _id: id + _id: user }, - (err, addressData) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); + { + projection: { + address: true } - - if (!addressData || !addressData.user || addressData.user.toString() !== user.toString()) { - res.status(404); - res.json({ - error: 'Invalid or unknown email address identifier', - code: 'AddressNotFound' - }); - return next(); - } - - if (addressData.address.indexOf('*') >= 0 && result.value.address && result.value.address !== addressData.address) { - res.json({ - error: 'Can not change special address', - code: 'ChangeNotAllowed' - }); - return next(); - } - - if (result.value.address && result.value.address.indexOf('*') >= 0 && result.value.address !== addressData.address) { - res.json({ - error: 'Can not change special address', - code: 'ChangeNotAllowed' - }); - return next(); - } - - if ((result.value.address || addressData.address).indexOf('*') >= 0 && main) { - res.json({ - error: 'Can not set wildcard address as default' - }); - return next(); - } - - if (result.value.address && addressData.address === userData.address && result.value.address !== addressData.address) { - // main address was changed, update user data as well - main = true; - addressData.address = result.value.address; - } - - let updateAddressData = done => { - if (!Object.keys(updates).length) { - return done(); - } - db.users.collection('addresses').findOneAndUpdate( - { - _id: addressData._id - }, - { - $set: updates - }, - { - returnOriginal: false - }, - err => { - if (err) { - if (err.code === 11000) { - res.json({ - error: 'Address already exists', - code: 'AddressExistsError' - }); - } else { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - } - return next(); - } - return done(); - } - ); - }; - - updateAddressData(() => { - if (!main) { - // nothing to do anymore - res.json({ - success: true - }); - return next(); - } - - db.users.collection('users').findOneAndUpdate( - { - _id: user - }, - { - $set: { - address: addressData.address - } - }, - { - returnOriginal: false - }, - (err, r) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - - res.json({ - success: !!r.value - }); - return next(); - } - ); - }); } ); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); } - ); - }); + + if (!userData) { + res.json({ + error: 'This user does not exist', + code: 'UserNotFound' + }); + return next(); + } + + let addressData; + try { + addressData = await db.users.collection('addresses').findOne({ + _id: id + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + if (!addressData || !addressData.user || addressData.user.toString() !== user.toString()) { + res.status(404); + res.json({ + error: 'Invalid or unknown email address identifier', + code: 'AddressNotFound' + }); + return next(); + } + + if (addressData.address.indexOf('*') >= 0 && result.value.address && result.value.address !== addressData.address) { + res.json({ + error: 'Can not change special address', + code: 'ChangeNotAllowed' + }); + return next(); + } + + if (result.value.address && result.value.address.indexOf('*') >= 0 && result.value.address !== addressData.address) { + res.json({ + error: 'Can not change special address', + code: 'ChangeNotAllowed' + }); + return next(); + } + + if ((result.value.address || addressData.address).indexOf('*') >= 0 && main) { + res.json({ + error: 'Can not set wildcard address as default', + code: 'WildcardNotPermitted' + }); + return next(); + } + + if (result.value.address && addressData.address === userData.address && result.value.address !== addressData.address) { + // main address was changed, update user data as well + main = true; + addressData.address = result.value.address; + } + + if (Object.keys(updates).length) { + try { + await db.users.collection('addresses').updateOne( + { + _id: addressData._id + }, + { + $set: updates + } + ); + } catch (err) { + if (err.code === 11000) { + res.json({ + error: 'Address already exists', + code: 'AddressExistsError' + }); + } else { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + } + return next(); + } + } + + if (!main) { + // nothing to do anymore + res.json({ + success: true + }); + return next(); + } + + let r; + try { + r = await db.users.collection('users').updateOne( + { + _id: user + }, + { + $set: { + address: addressData.address + } + } + ); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + res.json({ + success: !!r.matchedCount + }); + return next(); + }) + ); /** * @api {delete} /users/:user/addresses/:address Delete an Address @@ -1074,117 +1106,125 @@ module.exports = (db, server) => { * "error": "Trying to delete main address. Set a new main address first" * } */ - server.del('/users/:user/addresses/:address', (req, res, next) => { - res.charSet('utf-8'); + server.del( + '/users/:user/addresses/:address', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string() - .hex() - .lowercase() - .length(24) - .required(), - address: Joi.string() - .hex() - .lowercase() - .length(24) - .required() - }); - - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true - }); - - if (result.error) { - res.json({ - error: result.error.message, - code: 'InputValidationError' + const schema = Joi.object().keys({ + user: Joi.string() + .hex() + .lowercase() + .length(24) + .required(), + address: Joi.string() + .hex() + .lowercase() + .length(24) + .required() }); - return next(); - } - let user = new ObjectID(result.value.user); - let address = new ObjectID(result.value.address); + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true + }); - db.users.collection('users').findOne( - { - _id: user - }, - { - projection: { - address: true - } - }, - (err, userData) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - if (!userData) { - res.json({ - error: 'This user does not exist', - code: 'UserNotFound' - }); - return next(); - } + if (result.error) { + res.json({ + error: result.error.message, + code: 'InputValidationError' + }); + return next(); + } - db.users.collection('addresses').findOne( + let user = new ObjectID(result.value.user); + + // permissions check + if (req.user && req.user.equals(user)) { + req.validate(roles.can(req.role).deleteOwn('addresses')); + } else { + req.validate(roles.can(req.role).deleteAny('addresses')); + } + + let address = new ObjectID(result.value.address); + + let userData; + try { + userData = await db.users.collection('users').findOne( { - _id: address + _id: user }, - (err, addressData) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); + { + projection: { + address: true } - - if (!addressData || addressData.user.toString() !== user.toString()) { - res.status(404); - res.json({ - error: 'Invalid or unknown email address identifier', - code: 'AddressNotFound' - }); - return next(); - } - - if (addressData.address === userData.address) { - res.json({ - error: 'Trying to delete main address. Set a new main address first' - }); - return next(); - } - - // delete address from email address registry - db.users.collection('addresses').deleteOne( - { - _id: address - }, - (err, r) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - - res.json({ - success: !!r.deletedCount - }); - return next(); - } - ); } ); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); } - ); - }); + + if (!userData) { + res.json({ + error: 'This user does not exist', + code: 'UserNotFound' + }); + return next(); + } + + let addressData; + try { + addressData = await db.users.collection('addresses').findOne({ + _id: address + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + if (!addressData || addressData.user.toString() !== user.toString()) { + res.status(404); + res.json({ + error: 'Invalid or unknown email address identifier', + code: 'AddressNotFound' + }); + return next(); + } + + if (addressData.address === userData.address) { + res.json({ + error: 'Trying to delete main address. Set a new main address first' + }); + return next(); + } + + // delete address from email address registry + let r; + try { + r = await db.users.collection('addresses').deleteOne({ + _id: address + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + res.json({ + success: !!r.deletedCount + }); + return next(); + }) + ); /** * @api {post} /addresses/forwarded Create new forwarded Address @@ -1245,294 +1285,294 @@ module.exports = (db, server) => { * "error": "This email address already exists" * } */ - server.post('/addresses/forwarded', (req, res, next) => { - res.charSet('utf-8'); + server.post( + '/addresses/forwarded', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - address: [ - Joi.string() - .email() - .required(), - Joi.string().regex(/^\w+@\*$/, 'special address') - ], - name: Joi.string() - .empty('') - .trim() - .max(128), - targets: Joi.array().items( - Joi.string().email(), - Joi.string().uri({ - scheme: [/smtps?/, /https?/], - allowRelative: false, - relativeOnly: false - }) - ), - forwards: Joi.number() - .min(0) - .default(0), - allowWildcard: Joi.boolean() - .truthy(['Y', 'true', 'yes', 'on', 1]) - .falsy(['N', 'false', 'no', 'off', 0, '']), - autoreply: Joi.object().keys({ - status: Joi.boolean() - .truthy(['Y', 'true', 'yes', 'on', 1]) - .falsy(['N', 'false', 'no', 'off', 0, '']) - .default(true), - start: Joi.date() - .empty('') - .allow(false), - end: Joi.date() - .empty('') - .allow(false), + const schema = Joi.object().keys({ + address: [ + Joi.string() + .email() + .required(), + Joi.string().regex(/^\w+@\*$/, 'special address') + ], name: Joi.string() .empty('') .trim() .max(128), - subject: Joi.string() - .empty('') - .trim() - .max(128), - text: Joi.string() - .empty('') - .trim() - .max(128 * 1024), - html: Joi.string() - .empty('') - .trim() - .max(128 * 1024) - }), - tags: Joi.array().items( - Joi.string() - .trim() - .max(128) - ) - }); - - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true - }); - - if (result.error) { - res.json({ - error: result.error.message, - code: 'InputValidationError' + targets: Joi.array().items( + Joi.string().email(), + Joi.string().uri({ + scheme: [/smtps?/, /https?/], + allowRelative: false, + relativeOnly: false + }) + ), + forwards: Joi.number() + .min(0) + .default(0), + allowWildcard: Joi.boolean() + .truthy(['Y', 'true', 'yes', 'on', 1]) + .falsy(['N', 'false', 'no', 'off', 0, '']), + autoreply: Joi.object().keys({ + status: Joi.boolean() + .truthy(['Y', 'true', 'yes', 'on', 1]) + .falsy(['N', 'false', 'no', 'off', 0, '']) + .default(true), + start: Joi.date() + .empty('') + .allow(false), + end: Joi.date() + .empty('') + .allow(false), + name: Joi.string() + .empty('') + .trim() + .max(128), + subject: Joi.string() + .empty('') + .trim() + .max(128), + text: Joi.string() + .empty('') + .trim() + .max(128 * 1024), + html: Joi.string() + .empty('') + .trim() + .max(128 * 1024) + }), + tags: Joi.array().items( + Joi.string() + .trim() + .max(128) + ) }); - return next(); - } - let address = tools.normalizeAddress(result.value.address); - let addrview = tools.uview(address); - let name = result.value.name; + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true + }); - let targets = result.value.targets || []; - let forwards = result.value.forwards; - - if (result.value.autoreply) { - if (!result.value.autoreply.name && 'name' in req.params.autoreply) { - result.value.autoreply.name = ''; - } - - if (!result.value.autoreply.subject && 'subject' in req.params.autoreply) { - result.value.autoreply.subject = ''; - } - - if (!result.value.autoreply.text && 'text' in req.params.autoreply) { - result.value.autoreply.text = ''; - if (!result.value.autoreply.html) { - // make sure we also update html part - result.value.autoreply.html = ''; - } - } - - if (!result.value.autoreply.html && 'html' in req.params.autoreply) { - result.value.autoreply.html = ''; - if (!result.value.autoreply.text) { - // make sure we also update plaintext part - result.value.autoreply.text = ''; - } - } - } else { - result.value.autoreply = { - status: false - }; - } - - if (result.value.tags) { - let tagSeen = new Set(); - let tags = result.value.tags - .map(tag => tag.trim()) - .filter(tag => { - if (tag && !tagSeen.has(tag.toLowerCase())) { - tagSeen.add(tag.toLowerCase()); - return true; - } - return false; - }) - .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - - result.value.tags = tags; - result.value.tagsview = tags.map(tag => tag.toLowerCase()); - } - - // needed to resolve users for addresses - let addrlist = []; - let cachedAddrviews = new WeakMap(); - - for (let i = 0, len = targets.length; i < len; i++) { - let target = targets[i]; - if (!/^smtps?:/i.test(target) && !/^https?:/i.test(target) && target.indexOf('@') >= 0) { - // email - let addr = tools.normalizeAddress(target); - let addrv = addr.substr(0, addr.indexOf('@')).replace(/\./g, '') + addr.substr(addr.indexOf('@')); - if (addrv === addrview) { - res.json({ - error: 'Can not forward to self "' + target + '"', - code: 'InputValidationError' - }); - return next(); - } - targets[i] = { - id: new ObjectID(), - type: 'mail', - value: target - }; - cachedAddrviews.set(targets[i], addrv); - addrlist.push(addrv); - } else if (/^smtps?:/i.test(target)) { - targets[i] = { - id: new ObjectID(), - type: 'relay', - value: target - }; - } else if (/^https?:/i.test(target)) { - targets[i] = { - id: new ObjectID(), - type: 'http', - value: target - }; - } else { + if (result.error) { res.json({ - error: 'Unknown target type "' + target + '"', + error: result.error.message, code: 'InputValidationError' }); return next(); } - } - if (address.indexOf('+') >= 0) { - res.json({ - error: 'Address can not contain +' - }); - return next(); - } + // permissions check + req.validate(roles.can(req.role).createAny('addresses')); - let wcpos = address.indexOf('*'); + let address = tools.normalizeAddress(result.value.address); + let addrview = tools.uview(address); + let name = result.value.name; - if (wcpos >= 0) { - if (!result.value.allowWildcard) { - res.json({ - error: 'Address can not contain *' - }); - return next(); + let targets = result.value.targets || []; + let forwards = result.value.forwards; + + if (result.value.autoreply) { + if (!result.value.autoreply.name && 'name' in req.params.autoreply) { + result.value.autoreply.name = ''; + } + + if (!result.value.autoreply.subject && 'subject' in req.params.autoreply) { + result.value.autoreply.subject = ''; + } + + if (!result.value.autoreply.text && 'text' in req.params.autoreply) { + result.value.autoreply.text = ''; + if (!result.value.autoreply.html) { + // make sure we also update html part + result.value.autoreply.html = ''; + } + } + + if (!result.value.autoreply.html && 'html' in req.params.autoreply) { + result.value.autoreply.html = ''; + if (!result.value.autoreply.text) { + // make sure we also update plaintext part + result.value.autoreply.text = ''; + } + } + } else { + result.value.autoreply = { + status: false + }; } - if (/[^@]\*|\*[^@]/.test(result.value) || wcpos !== address.lastIndexOf('*')) { - res.json({ - error: 'Invalid wildcard address, use "*@domain" or "user@*"' - }); - return next(); - } - } + if (result.value.tags) { + let tagSeen = new Set(); + let tags = result.value.tags + .map(tag => tag.trim()) + .filter(tag => { + if (tag && !tagSeen.has(tag.toLowerCase())) { + tagSeen.add(tag.toLowerCase()); + return true; + } + return false; + }) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - let resolveUsers = done => { - if (!addrlist.length) { - return done(); + result.value.tags = tags; + result.value.tagsview = tags.map(tag => tag.toLowerCase()); } - db.users - .collection('addresses') - .find({ - addrview: { $in: addrlist } - }) - .toArray((err, addressList) => { - if (err) { + + // needed to resolve users for addresses + let addrlist = []; + let cachedAddrviews = new WeakMap(); + + for (let i = 0, len = targets.length; i < len; i++) { + let target = targets[i]; + if (!/^smtps?:/i.test(target) && !/^https?:/i.test(target) && target.indexOf('@') >= 0) { + // email + let addr = tools.normalizeAddress(target); + let addrv = addr.substr(0, addr.indexOf('@')).replace(/\./g, '') + addr.substr(addr.indexOf('@')); + if (addrv === addrview) { res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' + error: 'Can not forward to self "' + target + '"', + code: 'InputValidationError' }); return next(); } - let map = new Map(addressList.filter(addr => addr.user).map(addr => [addr.addrview, addr.user])); - targets.forEach(target => { - let addrv = cachedAddrviews.get(target); - if (addrv && map.has(addrv)) { - target.user = map.get(addrv); - } + targets[i] = { + id: new ObjectID(), + type: 'mail', + value: target + }; + cachedAddrviews.set(targets[i], addrv); + addrlist.push(addrv); + } else if (/^smtps?:/i.test(target)) { + targets[i] = { + id: new ObjectID(), + type: 'relay', + value: target + }; + } else if (/^https?:/i.test(target)) { + targets[i] = { + id: new ObjectID(), + type: 'http', + value: target + }; + } else { + res.json({ + error: 'Unknown target type "' + target + '"', + code: 'InputValidationError' }); - done(); - }); - }; + return next(); + } + } - db.users.collection('addresses').findOne( - { - addrview - }, - (err, addressData) => { - if (err) { + if (address.indexOf('+') >= 0) { + res.json({ + error: 'Address can not contain +' + }); + return next(); + } + + let wcpos = address.indexOf('*'); + + if (wcpos >= 0) { + if (!result.value.allowWildcard) { + res.json({ + error: 'Address can not contain *' + }); + return next(); + } + + if (/[^@]\*|\*[^@]/.test(result.value) || wcpos !== address.lastIndexOf('*')) { + res.json({ + error: 'Invalid wildcard address, use "*@domain" or "user@*"' + }); + return next(); + } + } + + let addressData; + try { + addressData = await db.users.collection('addresses').findOne({ + addrview + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + if (addressData) { + res.json({ + error: 'This email address already exists', + code: 'AddressExists' + }); + return next(); + } + + if (addrlist.length) { + let addressList; + try { + addressList = await db.users + .collection('addresses') + .find({ + addrview: { $in: addrlist } + }) + .toArray(); + } catch (err) { res.json({ error: 'MongoDB Error: ' + err.message, code: 'InternalDatabaseError' }); return next(); } - - if (addressData) { - res.json({ - error: 'This email address already exists', - code: 'AddressExists' - }); - return next(); - } - - resolveUsers(() => { - // insert alias address to email address registry - - let addressData = { - name, - address, - addrview: tools.uview(address), - targets, - forwards, - autoreply: result.value.autoreply, - created: new Date() - }; - - if (result.value.tags) { - addressData.tags = result.value.tags; - addressData.tagsview = result.value.tags; + let map = new Map(addressList.filter(addr => addr.user).map(addr => [addr.addrview, addr.user])); + targets.forEach(target => { + let addrv = cachedAddrviews.get(target); + if (addrv && map.has(addrv)) { + target.user = map.get(addrv); } - - db.users.collection('addresses').insertOne(addressData, (err, r) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - - let insertId = r.insertedId; - - res.json({ - success: !!insertId, - id: insertId - }); - return next(); - }); }); } - ); - }); + + // insert alias address to email address registry + addressData = { + name, + address, + addrview: tools.uview(address), + targets, + forwards, + autoreply: result.value.autoreply, + created: new Date() + }; + + if (result.value.tags) { + addressData.tags = result.value.tags; + addressData.tagsview = result.value.tags; + } + + let r; + + try { + r = await db.users.collection('addresses').insertOne(addressData); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + let insertId = r.insertedId; + + res.json({ + success: !!insertId, + id: insertId + }); + return next(); + }) + ); /** * @api {put} /addresses/forwarded/:address Update forwarded Address information @@ -1584,296 +1624,293 @@ module.exports = (db, server) => { * "error": "This address does not exist" * } */ - server.put('/addresses/forwarded/:id', (req, res, next) => { - res.charSet('utf-8'); + server.put( + '/addresses/forwarded/:id', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - id: Joi.string() - .hex() - .lowercase() - .length(24) - .required(), - address: Joi.string().email(), - name: Joi.string() - .empty('') - .trim() - .max(128), - targets: Joi.array().items( - Joi.string().email(), - Joi.string().uri({ - scheme: [/smtps?/, /https?/], - allowRelative: false, - relativeOnly: false - }) - ), - forwards: Joi.number().min(0), - autoreply: Joi.object().keys({ - status: Joi.boolean() - .truthy(['Y', 'true', 'yes', 'on', 1]) - .falsy(['N', 'false', 'no', 'off', 0, '']), - start: Joi.date() - .empty('') - .allow(false), - end: Joi.date() - .empty('') - .allow(false), + const schema = Joi.object().keys({ + id: Joi.string() + .hex() + .lowercase() + .length(24) + .required(), + address: Joi.string().email(), name: Joi.string() .empty('') .trim() .max(128), - subject: Joi.string() - .empty('') - .trim() - .max(128), - text: Joi.string() - .empty('') - .trim() - .max(128 * 1024), - html: Joi.string() - .empty('') - .trim() - .max(128 * 1024) - }), - tags: Joi.array().items( - Joi.string() - .trim() - .max(128) - ) - }); - - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true - }); - - if (result.error) { - res.json({ - error: result.error.message, - code: 'InputValidationError' + targets: Joi.array().items( + Joi.string().email(), + Joi.string().uri({ + scheme: [/smtps?/, /https?/], + allowRelative: false, + relativeOnly: false + }) + ), + forwards: Joi.number().min(0), + autoreply: Joi.object().keys({ + status: Joi.boolean() + .truthy(['Y', 'true', 'yes', 'on', 1]) + .falsy(['N', 'false', 'no', 'off', 0, '']), + start: Joi.date() + .empty('') + .allow(false), + end: Joi.date() + .empty('') + .allow(false), + name: Joi.string() + .empty('') + .trim() + .max(128), + subject: Joi.string() + .empty('') + .trim() + .max(128), + text: Joi.string() + .empty('') + .trim() + .max(128 * 1024), + html: Joi.string() + .empty('') + .trim() + .max(128 * 1024) + }), + tags: Joi.array().items( + Joi.string() + .trim() + .max(128) + ) }); - return next(); - } - let id = new ObjectID(result.value.id); - let updates = {}; - if (result.value.address) { - let address = tools.normalizeAddress(result.value.address); - let addrview = tools.uview(address); + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true + }); - updates.address = address; - updates.addrview = addrview; - } - - if (result.value.forwards) { - updates.forwards = result.value.forwards; - } - - if (result.value.name) { - updates.name = result.value.name; - } - - if (result.value.autoreply) { - if (!result.value.autoreply.name && 'name' in req.params.autoreply) { - result.value.autoreply.name = ''; + if (result.error) { + res.json({ + error: result.error.message, + code: 'InputValidationError' + }); + return next(); } - if (!result.value.autoreply.subject && 'subject' in req.params.autoreply) { - result.value.autoreply.subject = ''; + // permissions check + req.validate(roles.can(req.role).updateAny('addresses')); + + let id = new ObjectID(result.value.id); + let updates = {}; + if (result.value.address) { + let address = tools.normalizeAddress(result.value.address); + let addrview = tools.uview(address); + + updates.address = address; + updates.addrview = addrview; } - if (!result.value.autoreply.text && 'text' in req.params.autoreply) { - result.value.autoreply.text = ''; - if (!result.value.autoreply.html) { - // make sure we also update html part - result.value.autoreply.html = ''; + if (result.value.forwards) { + updates.forwards = result.value.forwards; + } + + if (result.value.name) { + updates.name = result.value.name; + } + + if (result.value.autoreply) { + if (!result.value.autoreply.name && 'name' in req.params.autoreply) { + result.value.autoreply.name = ''; } - } - if (!result.value.autoreply.html && 'html' in req.params.autoreply) { - result.value.autoreply.html = ''; - if (!result.value.autoreply.text) { - // make sure we also update plaintext part + if (!result.value.autoreply.subject && 'subject' in req.params.autoreply) { + result.value.autoreply.subject = ''; + } + + if (!result.value.autoreply.text && 'text' in req.params.autoreply) { result.value.autoreply.text = ''; + if (!result.value.autoreply.html) { + // make sure we also update html part + result.value.autoreply.html = ''; + } } + + if (!result.value.autoreply.html && 'html' in req.params.autoreply) { + result.value.autoreply.html = ''; + if (!result.value.autoreply.text) { + // make sure we also update plaintext part + result.value.autoreply.text = ''; + } + } + + Object.keys(result.value.autoreply).forEach(key => { + updates['autoreply.' + key] = result.value.autoreply[key]; + }); } - Object.keys(result.value.autoreply).forEach(key => { - updates['autoreply.' + key] = result.value.autoreply[key]; - }); - } + if (result.value.tags) { + let tagSeen = new Set(); + let tags = result.value.tags + .map(tag => tag.trim()) + .filter(tag => { + if (tag && !tagSeen.has(tag.toLowerCase())) { + tagSeen.add(tag.toLowerCase()); + return true; + } + return false; + }) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - if (result.value.tags) { - let tagSeen = new Set(); - let tags = result.value.tags - .map(tag => tag.trim()) - .filter(tag => { - if (tag && !tagSeen.has(tag.toLowerCase())) { - tagSeen.add(tag.toLowerCase()); - return true; + updates.tags = tags; + updates.tagsview = tags.map(tag => tag.toLowerCase()); + } + + let addressData; + + try { + addressData = await db.users.collection('addresses').findOne({ + _id: id + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + if (!addressData || !addressData.targets || addressData.user) { + res.status(404); + res.json({ + error: 'Invalid or unknown email address identifier', + code: 'AddressNotFound' + }); + return next(); + } + + if (addressData.address.indexOf('*') >= 0 && result.value.address && result.value.address !== addressData.address) { + res.json({ + error: 'Can not change special address', + code: 'ChangeNotAllowed' + }); + return next(); + } + + if (result.value.address && result.value.address.indexOf('*') >= 0 && result.value.address !== addressData.address) { + res.json({ + error: 'Can not change special address', + code: 'ChangeNotAllowed' + }); + return next(); + } + + let targets = result.value.targets; + let addrlist = []; + let cachedAddrviews = new WeakMap(); + + if (targets) { + // needed to resolve users for addresses + + for (let i = 0, len = targets.length; i < len; i++) { + let target = targets[i]; + if (!/^smtps?:/i.test(target) && !/^https?:/i.test(target) && target.indexOf('@') >= 0) { + // email + let addr = tools.normalizeAddress(target); + let addrv = addr.substr(0, addr.indexOf('@')).replace(/\./g, '') + addr.substr(addr.indexOf('@')); + if (addrv === addressData.addrview) { + res.json({ + error: 'Can not forward to self "' + target + '"', + code: 'InputValidationError' + }); + return next(); + } + targets[i] = { + id: new ObjectID(), + type: 'mail', + value: target + }; + cachedAddrviews.set(targets[i], addrv); + addrlist.push(addrv); + } else if (/^smtps?:/i.test(target)) { + targets[i] = { + id: new ObjectID(), + type: 'relay', + value: target + }; + } else if (/^https?:/i.test(target)) { + targets[i] = { + id: new ObjectID(), + type: 'http', + value: target + }; + } else { + res.json({ + error: 'Unknown target type "' + target + '"', + code: 'InputValidationError' + }); + return next(); } - return false; - }) - .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + } - updates.tags = tags; - updates.tagsview = tags.map(tag => tag.toLowerCase()); - } + updates.targets = targets; + } - db.users.collection('addresses').findOne( - { - _id: id - }, - (err, addressData) => { - if (err) { + if (targets && addrlist.length) { + let addressList; + try { + addressList = await db.users + .collection('addresses') + .find({ + addrview: { $in: addrlist } + }) + .toArray(); + } catch (err) { res.json({ error: 'MongoDB Error: ' + err.message, code: 'InternalDatabaseError' }); return next(); } - - if (!addressData || !addressData.targets || addressData.user) { - res.status(404); - res.json({ - error: 'Invalid or unknown email address identifier', - code: 'AddressNotFound' - }); - return next(); - } - - if (addressData.address.indexOf('*') >= 0 && result.value.address && result.value.address !== addressData.address) { - res.json({ - error: 'Can not change special address', - code: 'ChangeNotAllowed' - }); - return next(); - } - - if (result.value.address && result.value.address.indexOf('*') >= 0 && result.value.address !== addressData.address) { - res.json({ - error: 'Can not change special address', - code: 'ChangeNotAllowed' - }); - return next(); - } - - let targets = result.value.targets; - let addrlist = []; - let cachedAddrviews = new WeakMap(); - - if (targets) { - // needed to resolve users for addresses - - for (let i = 0, len = targets.length; i < len; i++) { - let target = targets[i]; - if (!/^smtps?:/i.test(target) && !/^https?:/i.test(target) && target.indexOf('@') >= 0) { - // email - let addr = tools.normalizeAddress(target); - let addrv = addr.substr(0, addr.indexOf('@')).replace(/\./g, '') + addr.substr(addr.indexOf('@')); - if (addrv === addressData.addrview) { - res.json({ - error: 'Can not forward to self "' + target + '"', - code: 'InputValidationError' - }); - return next(); - } - targets[i] = { - id: new ObjectID(), - type: 'mail', - value: target - }; - cachedAddrviews.set(targets[i], addrv); - addrlist.push(addrv); - } else if (/^smtps?:/i.test(target)) { - targets[i] = { - id: new ObjectID(), - type: 'relay', - value: target - }; - } else if (/^https?:/i.test(target)) { - targets[i] = { - id: new ObjectID(), - type: 'http', - value: target - }; - } else { - res.json({ - error: 'Unknown target type "' + target + '"', - code: 'InputValidationError' - }); - return next(); - } + let map = new Map(addressList.filter(addr => addr.user).map(addr => [addr.addrview, addr.user])); + targets.forEach(target => { + let addrv = cachedAddrviews.get(target); + if (addrv && map.has(addrv)) { + target.user = map.get(addrv); } - - updates.targets = targets; - } - - let resolveUsers = done => { - if (!targets || !addrlist.length) { - return done(); - } - db.users - .collection('addresses') - .find({ - addrview: { $in: addrlist } - }) - .toArray((err, addressList) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - let map = new Map(addressList.filter(addr => addr.user).map(addr => [addr.addrview, addr.user])); - targets.forEach(target => { - let addrv = cachedAddrviews.get(target); - if (addrv && map.has(addrv)) { - target.user = map.get(addrv); - } - }); - done(); - }); - }; - - resolveUsers(() => { - // insert alias address to email address registry - db.users.collection('addresses').findOneAndUpdate( - { - _id: addressData._id - }, - { - $set: updates - }, - { - returnOriginal: false - }, - (err, r) => { - if (err) { - if (err.code === 11000) { - res.json({ - error: 'Address already exists', - code: 'AddressExistsError' - }); - } else { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - } - return next(); - } - - res.json({ - success: !!r.value - }); - return next(); - } - ); }); } - ); - }); + + // insert alias address to email address registry + let r; + try { + r = await db.users.collection('addresses').updateOne( + { + _id: addressData._id + }, + { + $set: updates + } + ); + } catch (err) { + if (err.code === 11000) { + res.json({ + error: 'Address already exists', + code: 'AddressExistsError' + }); + } else { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + } + return next(); + } + + res.json({ + success: !!r.matchedCount + }); + return next(); + }) + ); /** * @api {delete} /addresses/forwarded/:address Delete a forwarded Address @@ -1906,77 +1943,79 @@ module.exports = (db, server) => { * "error": "This address does not exist" * } */ - server.del('/addresses/forwarded/:address', (req, res, next) => { - res.charSet('utf-8'); + server.del( + '/addresses/forwarded/:address', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - address: Joi.string() - .hex() - .lowercase() - .length(24) - .required() - }); + const schema = Joi.object().keys({ + address: Joi.string() + .hex() + .lowercase() + .length(24) + .required() + }); - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true - }); + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true + }); + + if (result.error) { + res.json({ + error: result.error.message, + code: 'InputValidationError' + }); + return next(); + } + + // permissions check + req.validate(roles.can(req.role).deleteAny('addresses')); + + let address = new ObjectID(result.value.address); + + let addressData; + try { + addressData = await db.users.collection('addresses').findOne({ + _id: address + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + if (!addressData || !addressData.targets || addressData.user) { + res.status(404); + res.json({ + error: 'Invalid or unknown email address identifier', + code: 'AddressNotFound' + }); + return next(); + } + + // delete address from email address registry + let r; + try { + r = await db.users.collection('addresses').deleteOne({ + _id: address + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } - if (result.error) { res.json({ - error: result.error.message, - code: 'InputValidationError' + success: !!r.deletedCount }); return next(); - } - - let address = new ObjectID(result.value.address); - - db.users.collection('addresses').findOne( - { - _id: address - }, - (err, addressData) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - - if (!addressData || !addressData.targets || addressData.user) { - res.status(404); - res.json({ - error: 'Invalid or unknown email address identifier', - code: 'AddressNotFound' - }); - return next(); - } - - // delete address from email address registry - db.users.collection('addresses').deleteOne( - { - _id: address - }, - (err, r) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - - res.json({ - success: !!r.deletedCount - }); - return next(); - } - ); - } - ); - }); + }) + ); /** * @api {get} /addresses/forwarded/:address Request forwarded Addresses information @@ -2039,91 +2078,96 @@ module.exports = (db, server) => { * "error": "This address does not exist" * } */ - server.get('/addresses/forwarded/:address', (req, res, next) => { - res.charSet('utf-8'); + server.get( + '/addresses/forwarded/:address', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - address: Joi.string() - .hex() - .lowercase() - .length(24) - .required() - }); - - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true - }); - - if (result.error) { - res.json({ - error: result.error.message, - code: 'InputValidationError' + const schema = Joi.object().keys({ + address: Joi.string() + .hex() + .lowercase() + .length(24) + .required() }); - return next(); - } - let address = new ObjectID(result.value.address); + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true + }); - db.users.collection('addresses').findOne( - { - _id: address - }, - (err, addressData) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - if (!addressData || !addressData.targets || addressData.user) { - res.status(404); - res.json({ - error: 'Invalid or unknown address', - code: 'AddressNotFound' - }); - return next(); - } + if (result.error) { + res.json({ + error: result.error.message, + code: 'InputValidationError' + }); + return next(); + } - db.redis + // permissions check + req.validate(roles.can(req.role).readAny('addresses')); + + let address = new ObjectID(result.value.address); + + let addressData; + try { + addressData = await db.users.collection('addresses').findOne({ + _id: address + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + if (!addressData || !addressData.targets || addressData.user) { + res.status(404); + res.json({ + error: 'Invalid or unknown address', + code: 'AddressNotFound' + }); + return next(); + } + + let response; + try { + response = await db.redis .multi() // sending counters are stored in Redis .get('wdf:' + addressData._id.toString()) .ttl('wdf:' + addressData._id.toString()) - .exec((err, result) => { - if (err) { - // ignore - } - - let forwards = Number(addressData.forwards) || config.maxForwards || consts.MAX_FORWARDS; - - let forwardsSent = Number(result && result[0] && result[0][1]) || 0; - let forwardsTtl = Number(result && result[1] && result[1][1]) || 0; - - res.json({ - success: true, - id: addressData._id, - name: addressData.name || false, - address: addressData.address, - targets: addressData.targets && addressData.targets.map(t => t.value), - limits: { - forwards: { - allowed: forwards, - used: forwardsSent, - ttl: forwardsTtl >= 0 ? forwardsTtl : false - } - }, - autoreply: addressData.autoreply || { status: false }, - tags: addressData.tags || [], - created: addressData.created - }); - - return next(); - }); + .exec(); + } catch (err) { + // ignore } - ); - }); + + let forwards = Number(addressData.forwards) || config.maxForwards || consts.MAX_FORWARDS; + + let forwardsSent = Number(response && response[0] && response[0][1]) || 0; + let forwardsTtl = Number(response && response[1] && response[1][1]) || 0; + + res.json({ + success: true, + id: addressData._id, + name: addressData.name || false, + address: addressData.address, + targets: addressData.targets && addressData.targets.map(t => t.value), + limits: { + forwards: { + allowed: forwards, + used: forwardsSent, + ttl: forwardsTtl >= 0 ? forwardsTtl : false + } + }, + autoreply: addressData.autoreply || { status: false }, + tags: addressData.tags || [], + created: addressData.created + }); + + return next(); + }) + ); /** * @api {get} /addresses/resolve/:address Get Address info @@ -2200,54 +2244,62 @@ module.exports = (db, server) => { * "error": "This address does not exist" * } */ - server.get('/addresses/resolve/:address', (req, res, next) => { - res.charSet('utf-8'); + server.get( + '/addresses/resolve/:address', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - address: [ - Joi.string() - .hex() - .lowercase() - .length(24) - .required(), - Joi.string().email() - ] - }); - - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true - }); - - if (result.error) { - res.json({ - error: result.error.message, - code: 'InputValidationError' + const schema = Joi.object().keys({ + address: [ + Joi.string() + .hex() + .lowercase() + .length(24) + .required(), + Joi.string().email() + ] }); - return next(); - } - let query = {}; - if (result.value.address.indexOf('@') >= 0) { - let address = tools.normalizeAddress(result.value.address); - query = { - addrview: tools.uview(address) - }; - } else { - let address = new ObjectID(result.value.address); - query = { - _id: address - }; - } + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true + }); - db.users.collection('addresses').findOne(query, (err, addressData) => { - if (err) { + if (result.error) { + res.json({ + error: result.error.message, + code: 'InputValidationError' + }); + return next(); + } + + // permissions check + req.validate(roles.can(req.role).readAny('addresses')); + + let query = {}; + if (result.value.address.indexOf('@') >= 0) { + let address = tools.normalizeAddress(result.value.address); + query = { + addrview: tools.uview(address) + }; + } else { + let address = new ObjectID(result.value.address); + query = { + _id: address + }; + } + + let addressData; + try { + addressData = await db.users.collection('addresses').findOne(query); + } catch (err) { res.json({ error: 'MongoDB Error: ' + err.message, code: 'InternalDatabaseError' }); return next(); } + if (!addressData) { res.status(404); res.json({ @@ -2269,43 +2321,44 @@ module.exports = (db, server) => { return next(); } - db.redis - .multi() - // sending counters are stored in Redis - .get('wdf:' + addressData._id.toString()) - .ttl('wdf:' + addressData._id.toString()) - .exec((err, result) => { - if (err) { - // ignore + let response; + try { + response = await db.redis + .multi() + // sending counters are stored in Redis + .get('wdf:' + addressData._id.toString()) + .ttl('wdf:' + addressData._id.toString()) + .exec(); + } catch (err) { + // ignore + } + + let forwards = Number(addressData.forwards) || config.maxForwards || consts.MAX_FORWARDS; + + let forwardsSent = Number(response && response[0] && response[0][1]) || 0; + let forwardsTtl = Number(response && response[1] && response[1][1]) || 0; + + res.json({ + success: true, + id: addressData._id, + name: addressData.name || '', + address: addressData.address, + targets: addressData.targets && addressData.targets.map(t => t.value), + limits: { + forwards: { + allowed: forwards, + used: forwardsSent, + ttl: forwardsTtl >= 0 ? forwardsTtl : false } + }, + autoreply: addressData.autoreply || { status: false }, + tags: addressData.tags || [], + created: addressData.created + }); - let forwards = Number(addressData.forwards) || config.maxForwards || consts.MAX_FORWARDS; - - let forwardsSent = Number(result && result[0] && result[0][1]) || 0; - let forwardsTtl = Number(result && result[1] && result[1][1]) || 0; - - res.json({ - success: true, - id: addressData._id, - name: addressData.name || '', - address: addressData.address, - targets: addressData.targets && addressData.targets.map(t => t.value), - limits: { - forwards: { - allowed: forwards, - used: forwardsSent, - ttl: forwardsTtl >= 0 ? forwardsTtl : false - } - }, - autoreply: addressData.autoreply || { status: false }, - tags: addressData.tags || [], - created: addressData.created - }); - - return next(); - }); - }); - }); + return next(); + }) + ); /** * @api {put} /addresses/renameDomain Rename domain in addresses @@ -2344,80 +2397,47 @@ module.exports = (db, server) => { * "error": "Failed to rename domain" * } */ - server.put('/addresses/renameDomain', (req, res, next) => { - res.charSet('utf-8'); + server.put( + '/addresses/renameDomain', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - oldDomain: Joi.string().required(), - newDomain: Joi.string().required() - }); - - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true - }); - - if (result.error) { - res.json({ - error: result.error.message, - code: 'InputValidationError' + const schema = Joi.object().keys({ + oldDomain: Joi.string().required(), + newDomain: Joi.string().required() }); - return next(); - } - let oldDomain = tools.normalizeDomain(result.value.oldDomain); - let newDomain = tools.normalizeDomain(result.value.newDomain); + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true + }); - let updateAddresses = []; - let updateUsers = []; + if (result.error) { + res.json({ + error: result.error.message, + code: 'InputValidationError' + }); + return next(); + } + + // permissions check + req.validate(roles.can(req.role).updateAny('addresses')); + + let oldDomain = tools.normalizeDomain(result.value.oldDomain); + let newDomain = tools.normalizeDomain(result.value.newDomain); + + let updateAddresses = []; + let updateUsers = []; - let renameDomain = callback => { let cursor = db.users.collection('addresses').find({ addrview: { $regex: '@' + tools.escapeRegexStr(oldDomain) + '$' } }); - let processNext = () => { - cursor.next((err, addressData) => { - if (err) { - return callback(err); - } - - if (!addressData) { - return cursor.close(() => { - if (!updateAddresses.length) { - return callback(null, false); - } - - db.users.collection('addresses').bulkWrite( - updateAddresses, - { - ordered: false, - w: 1 - }, - err => { - if (err) { - return callback(err); - } - db.users.collection('users').bulkWrite( - updateUsers, - { - ordered: false, - w: 1 - }, - err => { - if (err) { - return callback(err); - } - return callback(null, true); - } - ); - } - ); - }); - } - + let addressData; + try { + while ((addressData = await cursor.next())) { updateAddresses.push({ updateOne: { filter: { @@ -2445,25 +2465,48 @@ module.exports = (db, server) => { } } }); + } - return setImmediate(processNext); - }); - }; - - processNext(); - }; - - renameDomain(err => { - if (err) { + await cursor.close(); + } catch (err) { res.json({ error: 'MongoDB Error: ' + err.message, code: 'InternalDatabaseError' }); return next(); } + + if (updateAddresses.length) { + try { + await db.users.collection('addresses').bulkWrite(updateAddresses, { + ordered: false, + w: 1 + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + + try { + await db.users.collection('users').bulkWrite(updateUsers, { + ordered: false, + w: 1 + }); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); + } + } + res.json({ success: true }); - }); - }); + }) + ); }; diff --git a/lib/roles.js b/lib/roles.js new file mode 100644 index 00000000..dba157c3 --- /dev/null +++ b/lib/roles.js @@ -0,0 +1,13 @@ +'use strict'; + +const config = require('wild-config'); +const AccessControl = require('accesscontrol'); +const ac = new AccessControl(); + +ac.setGrants(config.api.roles); + +config.on('reload', () => { + ac.setGrants(config.api.roles); +}); + +module.exports.can = role => ac.can(role); diff --git a/lib/tools.js b/lib/tools.js index e2c781fe..1be098d4 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -488,9 +488,20 @@ module.exports = { try { await middleware(req, res, next); } catch (err) { - res.json({ + let data = { error: err.message - }); + }; + + if (err.responseCode) { + res.status(err.responseCode); + } + + if (err.code) { + data.code = err.code; + } + + res.charSet('utf-8'); + res.json(data); return next(); } } diff --git a/package.json b/package.json index 54f6ff3b..98a7a8f9 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "request": "2.88.0" }, "dependencies": { + "accesscontrol": "^2.2.1", "base32.js": "0.1.0", "bcryptjs": "2.4.3", "bugsnag": "2.4.3",