diff --git a/config/roles.json b/config/roles.json index 433940f4..17e6168a 100644 --- a/config/roles.json +++ b/config/roles.json @@ -17,6 +17,52 @@ "read:any": ["*"], "update:any": ["*"], "delete:any": ["*"] + }, + + "messages": { + "create:any": ["*"], + "read:any": ["*"], + "update:any": ["*"], + "delete:any": ["*"] + }, + + "mailboxes": { + "create:any": ["*"], + "read:any": ["*"], + "update:any": ["*"], + "delete:any": ["*"] + } + }, + + "user": { + "addresses": { + "create:own": ["*"], + "read:own": ["*"], + "update:own": ["*"], + "delete:own": ["*"] + }, + + "authentication": { + "read:own": ["*"] + }, + + "users": { + "read:own": ["*"], + "update:own": ["*"] + }, + + "messages": { + "create:own": ["*"], + "read:own": ["*"], + "update:own": ["*"], + "delete:own": ["*"] + }, + + "mailboxes": { + "create:own": ["*"], + "read:own": ["*"], + "update:own": ["*"], + "delete:own": ["*"] } }, diff --git a/lib/api/mailboxes.js b/lib/api/mailboxes.js index 77561632..73259153 100644 --- a/lib/api/mailboxes.js +++ b/lib/api/mailboxes.js @@ -4,8 +4,12 @@ const Joi = require('joi'); const ObjectID = require('mongodb').ObjectID; const imapTools = require('../../imap-core/lib/imap-tools'); const tools = require('../tools'); +const roles = require('../roles'); +const util = require('util'); module.exports = (db, server, mailboxHandler) => { + const getMailboxCounter = util.promisify(tools.getMailboxCounter); + /** * @api {get} /users/:user/mailboxes List Mailboxes for an User * @apiName GetMailboxes @@ -69,160 +73,169 @@ module.exports = (db, server, mailboxHandler) => { * "error": "This mailbox does not exist" * } */ - server.get('/users/:user/mailboxes', (req, res, next) => { - res.charSet('utf-8'); + server.get( + '/users/:user/mailboxes', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string() - .hex() - .lowercase() - .length(24) - .required(), - counters: Joi.boolean() - .truthy(['Y', 'true', 'yes', 'on', 1]) - .falsy(['N', 'false', 'no', 'off', 0, '']) - .default(false) - }); - - if (req.query.counters) { - req.params.counters = req.query.counters; - } - - 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(), + counters: Joi.boolean() + .truthy(['Y', 'true', 'yes', 'on', 1]) + .falsy(['N', 'false', 'no', 'off', 0, '']) + .default(false) }); - return next(); - } - let user = new ObjectID(result.value.user); - let counters = result.value.counters; + if (req.query.counters) { + req.params.counters = req.query.counters; + } - 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(); - } + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true + }); - db.database + if (result.error) { + res.json({ + error: result.error.message, + code: 'InputValidationError' + }); + return next(); + } + + // permissions check + if (req.user && req.user === result.value.user) { + req.validate(roles.can(req.role).readOwn('mailboxes')); + } else { + req.validate(roles.can(req.role).readAny('mailboxes')); + } + + let user = new ObjectID(result.value.user); + let counters = result.value.counters; + + let userData; + try { + userData = await db.users.collection('users').findOne( + { + _id: user + }, + { + projection: { + 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 mailboxes; + try { + mailboxes = await db.database .collection('mailboxes') .find({ user }) - .toArray((err, mailboxes) => { - if (err) { - res.json({ - error: 'MongoDB Error: ' + err.message, - code: 'InternalDatabaseError' - }); - return next(); - } - - if (!mailboxes) { - mailboxes = []; - } - - let list = new Map(); - - mailboxes = mailboxes - .map(mailbox => { - list.set(mailbox.path, mailbox); - return mailbox; - }) - .sort((a, b) => { - if (a.path === 'INBOX') { - return -1; - } - if (b.path === 'INBOX') { - return 1; - } - if (a.path.indexOf('INBOX/') === 0 && b.path.indexOf('INBOX/') !== 0) { - return -1; - } - if (a.path.indexOf('INBOX/') !== 0 && b.path.indexOf('INBOX/') === 0) { - return 1; - } - if (a.subscribed !== b.subscribed) { - return (a.subscribed ? 0 : 1) - (b.subscribed ? 0 : 1); - } - return a.path.localeCompare(b.path); - }); - - let responses = []; - let position = 0; - let checkMailboxes = () => { - if (position >= mailboxes.length) { - res.json({ - success: true, - results: responses - }); - - return next(); - } - - let mailbox = mailboxes[position++]; - let path = mailbox.path.split('/'); - let name = path.pop(); - - let response = { - id: mailbox._id, - name, - path: mailbox.path, - specialUse: mailbox.specialUse, - modifyIndex: mailbox.modifyIndex, - subscribed: mailbox.subscribed - }; - - if (!counters) { - responses.push(response); - return setImmediate(checkMailboxes); - } - - tools.getMailboxCounter(db, mailbox._id, false, (err, total) => { - if (err) { - // ignore - } - tools.getMailboxCounter(db, mailbox._id, 'unseen', (err, unseen) => { - if (err) { - // ignore - } - response.total = total; - response.unseen = unseen; - responses.push(response); - return setImmediate(checkMailboxes); - }); - }); - }; - checkMailboxes(); - }); + .toArray(); + } catch (err) { + res.json({ + error: 'MongoDB Error: ' + err.message, + code: 'InternalDatabaseError' + }); + return next(); } - ); - }); + + if (!mailboxes) { + mailboxes = []; + } + + let list = new Map(); + + mailboxes = mailboxes + .map(mailbox => { + list.set(mailbox.path, mailbox); + return mailbox; + }) + .sort((a, b) => { + if (a.path === 'INBOX') { + return -1; + } + if (b.path === 'INBOX') { + return 1; + } + if (a.path.indexOf('INBOX/') === 0 && b.path.indexOf('INBOX/') !== 0) { + return -1; + } + if (a.path.indexOf('INBOX/') !== 0 && b.path.indexOf('INBOX/') === 0) { + return 1; + } + if (a.subscribed !== b.subscribed) { + return (a.subscribed ? 0 : 1) - (b.subscribed ? 0 : 1); + } + return a.path.localeCompare(b.path); + }); + + let responses = []; + + for (let mailboxData of mailboxes) { + let path = mailboxData.path.split('/'); + let name = path.pop(); + + let response = { + id: mailboxData._id, + name, + path: mailboxData.path, + specialUse: mailboxData.specialUse, + modifyIndex: mailboxData.modifyIndex, + subscribed: mailboxData.subscribed + }; + + if (!counters) { + responses.push(response); + continue; + } + + let total, unseen; + + try { + total = await getMailboxCounter(db, mailboxData._id, false); + } catch (err) { + // ignore + } + + try { + unseen = await tools.getMailboxCounter(db, mailboxData._id, 'unseen'); + } catch (err) { + // ignore + } + + response.total = total; + response.unseen = unseen; + + responses.push(response); + } + + res.json({ + success: true, + results: responses + }); + }) + ); /** * @api {post} /users/:user/mailboxes Create new Mailbox diff --git a/lib/api/submit.js b/lib/api/submit.js index 4d2c97a6..9913c36e 100644 --- a/lib/api/submit.js +++ b/lib/api/submit.js @@ -3,12 +3,14 @@ const config = require('wild-config'); const log = require('npmlog'); const libmime = require('libmime'); +const util = require('util'); const MailComposer = require('nodemailer/lib/mail-composer'); const htmlToText = require('html-to-text'); const Joi = require('../joi'); const ObjectID = require('mongodb').ObjectID; const tools = require('../tools'); const maildrop = require('../maildrop'); +const roles = require('../roles'); const Transform = require('stream').Transform; class StreamCollect extends Transform { @@ -539,6 +541,8 @@ module.exports = (db, server, messageHandler, userHandler) => { ); } + const submitMessageWrapper = util.promisify(submitMessage); + /** * @api {post} /users/:user/submit Submit a Message for Delivery * @apiName PostSubmit @@ -658,59 +662,81 @@ module.exports = (db, server, messageHandler, userHandler) => { * "code": "ERRDISABLEDUSER" * } */ - server.post({ name: 'send', path: '/users/:user/submit' }, (req, res, next) => { - res.charSet('utf-8'); + server.post( + { name: 'send', path: '/users/:user/submit' }, + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string() - .hex() - .lowercase() - .length(24) - .required(), - - mailbox: Joi.string() - .hex() - .lowercase() - .length(24), - - reference: Joi.object().keys({ - mailbox: Joi.string() + const schema = Joi.object().keys({ + user: Joi.string() .hex() .lowercase() .length(24) .required(), - id: Joi.number().required(), - action: Joi.string() - .valid('reply', 'replyAll', 'forward') - .required() - }), - // if true then treat this message as a draft - isDraft: Joi.boolean() - .empty('') - .truthy(['Y', 'true', 'yes', 'on', 1]) - .falsy(['N', 'false', 'no', 'off', 0, '']) - .default(false), - - // if set then this message is based on a draft that should be deleted after processing - draft: Joi.object().keys({ mailbox: Joi.string() .hex() .lowercase() - .length(24) - .required(), - id: Joi.number().required() - }), + .length(24), - uploadOnly: Joi.boolean() - .empty('') - .truthy(['Y', 'true', 'yes', 'on', 1]) - .falsy(['N', 'false', 'no', 'off', 0, '']) - .default(false), + reference: Joi.object().keys({ + mailbox: Joi.string() + .hex() + .lowercase() + .length(24) + .required(), + id: Joi.number().required(), + action: Joi.string() + .valid('reply', 'replyAll', 'forward') + .required() + }), - sendTime: Joi.date(), + // if true then treat this message as a draft + isDraft: Joi.boolean() + .empty('') + .truthy(['Y', 'true', 'yes', 'on', 1]) + .falsy(['N', 'false', 'no', 'off', 0, '']) + .default(false), + + // if set then this message is based on a draft that should be deleted after processing + draft: Joi.object().keys({ + mailbox: Joi.string() + .hex() + .lowercase() + .length(24) + .required(), + id: Joi.number().required() + }), + + uploadOnly: Joi.boolean() + .empty('') + .truthy(['Y', 'true', 'yes', 'on', 1]) + .falsy(['N', 'false', 'no', 'off', 0, '']) + .default(false), + + sendTime: Joi.date(), + + envelope: Joi.object().keys({ + from: Joi.object().keys({ + name: Joi.string() + .empty('') + .max(255), + address: Joi.string() + .email() + .required() + }), + to: Joi.array().items( + Joi.object().keys({ + name: Joi.string() + .empty('') + .max(255), + address: Joi.string() + .email() + .required() + }) + ) + }), - envelope: Joi.object().keys({ from: Joi.object().keys({ name: Joi.string() .empty('') @@ -719,6 +745,16 @@ module.exports = (db, server, messageHandler, userHandler) => { .email() .required() }), + + replyTo: Joi.object().keys({ + name: Joi.string() + .empty('') + .max(255), + address: Joi.string() + .email() + .required() + }), + to: Joi.array().items( Joi.object().keys({ name: Joi.string() @@ -728,139 +764,120 @@ module.exports = (db, server, messageHandler, userHandler) => { .email() .required() }) - ) - }), + ), - from: Joi.object().keys({ - name: Joi.string() + cc: Joi.array().items( + Joi.object().keys({ + name: Joi.string() + .empty('') + .max(255), + address: Joi.string() + .email() + .required() + }) + ), + + bcc: Joi.array().items( + Joi.object().keys({ + name: Joi.string() + .empty('') + .max(255), + address: Joi.string() + .email() + .required() + }) + ), + + headers: Joi.array().items( + Joi.object().keys({ + key: Joi.string() + .empty('') + .max(255), + value: Joi.string() + .empty('') + .max(100 * 1024) + }) + ), + + subject: Joi.string() .empty('') .max(255), - address: Joi.string() - .email() - .required() - }), - - replyTo: Joi.object().keys({ - name: Joi.string() + text: Joi.string() .empty('') - .max(255), - address: Joi.string() - .email() - .required() - }), + .max(1024 * 1024), + html: Joi.string() + .empty('') + .max(1024 * 1024), - to: Joi.array().items( - Joi.object().keys({ - name: Joi.string() - .empty('') - .max(255), - address: Joi.string() - .email() - .required() + attachments: Joi.array().items( + Joi.object().keys({ + filename: Joi.string() + .empty('') + .max(255), + contentType: Joi.string() + .empty('') + .max(255), + encoding: Joi.string() + .empty('') + .default('base64'), + content: Joi.string().required(), + cid: Joi.string() + .empty('') + .max(255) + }) + ), + meta: Joi.object().unknown(true), + sess: Joi.string().max(255), + ip: Joi.string().ip({ + version: ['ipv4', 'ipv6'], + cidr: 'forbidden' }) - ), - - cc: Joi.array().items( - Joi.object().keys({ - name: Joi.string() - .empty('') - .max(255), - address: Joi.string() - .email() - .required() - }) - ), - - bcc: Joi.array().items( - Joi.object().keys({ - name: Joi.string() - .empty('') - .max(255), - address: Joi.string() - .email() - .required() - }) - ), - - headers: Joi.array().items( - Joi.object().keys({ - key: Joi.string() - .empty('') - .max(255), - value: Joi.string() - .empty('') - .max(100 * 1024) - }) - ), - - subject: Joi.string() - .empty('') - .max(255), - text: Joi.string() - .empty('') - .max(1024 * 1024), - html: Joi.string() - .empty('') - .max(1024 * 1024), - - attachments: Joi.array().items( - Joi.object().keys({ - filename: Joi.string() - .empty('') - .max(255), - contentType: Joi.string() - .empty('') - .max(255), - encoding: Joi.string() - .empty('') - .default('base64'), - content: Joi.string().required(), - cid: Joi.string() - .empty('') - .max(255) - }) - ), - meta: Joi.object().unknown(true), - sess: Joi.string().max(255), - ip: Joi.string().ip({ - version: ['ipv4', 'ipv6'], - cidr: 'forbidden' - }) - }); - - const result = Joi.validate(req.params, schema, { - abortEarly: false, - convert: true, - allowUnknown: true - }); - - if (result.error) { - res.json({ - error: result.error.message, - code: 'InputValidationError' }); - return next(); - } - result.value.user = new ObjectID(result.value.user); - if (result.value.reference && result.value.reference.mailbox) { - result.value.reference.mailbox = new ObjectID(result.value.reference.mailbox); - } + const result = Joi.validate(req.params, schema, { + abortEarly: false, + convert: true, + allowUnknown: true + }); - submitMessage(result.value, (err, info) => { - if (err) { + if (result.error) { + res.json({ + error: result.error.message, + code: 'InputValidationError' + }); + return next(); + } + + // permissions check + if (req.user && req.user === result.value.user) { + req.validate(roles.can(req.role).createOwn('messages')); + } else { + req.validate(roles.can(req.role).createAny('messages')); + } + + result.value.user = new ObjectID(result.value.user); + if (result.value.reference && result.value.reference.mailbox) { + result.value.reference.mailbox = new ObjectID(result.value.reference.mailbox); + } + + let info; + try { + info = await submitMessageWrapper(result.value); + } catch (err) { log.error('API', 'SUBMIT error=%s', err.message); res.json({ error: err.message, code: err.code }); - } else { - res.json({ - success: true, - message: info - }); + return next(); } + + res.json({ + success: true, + message: info + }); + next(); - }); - }); + }) + ); }; diff --git a/lib/api/updates.js b/lib/api/updates.js index 48776e0d..ebd77ca7 100644 --- a/lib/api/updates.js +++ b/lib/api/updates.js @@ -4,6 +4,7 @@ const crypto = require('crypto'); const Joi = require('joi'); const ObjectID = require('mongodb').ObjectID; const tools = require('../tools'); +const roles = require('../roles'); const base32 = require('base32.js'); module.exports = (db, server, notifier) => { @@ -48,171 +49,189 @@ module.exports = (db, server, notifier) => { * "error": "This user does not exist" * } */ - server.get('/users/:user/updates', (req, res, next) => { - res.charSet('utf-8'); + server.get( + '/users/:user/updates', + tools.asyncifyJson(async (req, res, next) => { + res.charSet('utf-8'); - const schema = Joi.object().keys({ - user: Joi.string() - .hex() - .lowercase() - .length(24) - .required(), - 'Last-Event-ID': Joi.string() - .hex() - .lowercase() - .length(24) - }); - - if (req.header('Last-Event-ID')) { - req.params['Last-Event-ID'] = req.header('Last-Event-ID'); - } - - 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(), + 'Last-Event-ID': Joi.string() + .hex() + .lowercase() + .length(24) }); - return next(); - } - let user = new ObjectID(result.value.user); - let lastEventId = result.value['Last-Event-ID'] ? new ObjectID(result.value['Last-Event-ID']) : false; - - db.users.collection('users').findOne( - { - _id: user - }, - { - projection: { - username: 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(); - } - - let session = { - id: 'api.' + base32.encode(crypto.randomBytes(10)).toLowerCase(), - user: { - id: userData._id, - username: userData.username - } - }; - - let closed = false; - let idleTimer = false; - let idleCounter = 0; - - let sendIdleComment = () => { - clearTimeout(idleTimer); - if (closed) { - return; - } - res.write(': idling ' + ++idleCounter + '\n\n'); - idleTimer = setTimeout(sendIdleComment, 15 * 1000); - }; - - let resetIdleComment = () => { - clearTimeout(idleTimer); - if (closed) { - return; - } - idleTimer = setTimeout(sendIdleComment, 15 * 1000); - }; - - let journalReading = false; - let journalReader = message => { - if (journalReading || closed) { - return; - } - - if (message) { - return res.write(formatJournalData(message)); - } - - journalReading = true; - loadJournalStream(db, req, res, user, lastEventId, (err, info) => { - if (err) { - // ignore? - } - lastEventId = info && info.lastEventId; - journalReading = false; - if (info && info.processed) { - resetIdleComment(); - } - }); - }; - - let close = () => { - closed = true; - clearTimeout(idleTimer); - notifier.removeListener(session, journalReader); - }; - - let setup = () => { - notifier.addListener(session, journalReader); - - let finished = false; - let done = () => { - if (finished) { - return; - } - finished = true; - close(); - return next(); - }; - - req.connection.setTimeout(30 * 60 * 1000, done); - req.connection.on('end', done); - req.connection.on('close', done); - req.connection.on('error', done); - }; - - res.writeHead(200, { 'Content-Type': 'text/event-stream' }); - - if (lastEventId) { - loadJournalStream(db, req, res, user, lastEventId, (err, info) => { - if (err) { - res.write('event: error\ndata: ' + err.message.split('\n').join('\ndata: ') + '\n\n'); - // ignore - } - setup(); - if (info && info.processed) { - resetIdleComment(); - } else { - sendIdleComment(); - } - }); - } else { - db.database.collection('journal').findOne({ user }, { sort: { _id: -1 } }, (err, latest) => { - if (!err && latest) { - lastEventId = latest._id; - } - setup(); - sendIdleComment(); - }); - } + if (req.header('Last-Event-ID')) { + req.params['Last-Event-ID'] = req.header('Last-Event-ID'); } - ); - }); + + 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 + // should the resource be something else than 'users'? + if (req.user && req.user === result.value.user) { + req.validate(roles.can(req.role).readOwn('users')); + } else { + req.validate(roles.can(req.role).readAny('users')); + } + + let user = new ObjectID(result.value.user); + let lastEventId = result.value['Last-Event-ID'] ? new ObjectID(result.value['Last-Event-ID']) : false; + + let userData; + + try { + userData = await db.users.collection('users').findOne( + { + _id: user + }, + { + projection: { + username: 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 session = { + id: 'api.' + base32.encode(crypto.randomBytes(10)).toLowerCase(), + user: { + id: userData._id, + username: userData.username + } + }; + + let closed = false; + let idleTimer = false; + let idleCounter = 0; + + let sendIdleComment = () => { + clearTimeout(idleTimer); + if (closed) { + return; + } + res.write(': idling ' + ++idleCounter + '\n\n'); + idleTimer = setTimeout(sendIdleComment, 15 * 1000); + }; + + let resetIdleComment = () => { + clearTimeout(idleTimer); + if (closed) { + return; + } + idleTimer = setTimeout(sendIdleComment, 15 * 1000); + }; + + let journalReading = false; + let journalReader = message => { + if (journalReading || closed) { + return; + } + + if (message) { + return res.write(formatJournalData(message)); + } + + journalReading = true; + loadJournalStream(db, req, res, user, lastEventId, (err, info) => { + if (err) { + // ignore? + } + lastEventId = info && info.lastEventId; + journalReading = false; + if (info && info.processed) { + resetIdleComment(); + } + }); + }; + + let close = () => { + closed = true; + clearTimeout(idleTimer); + notifier.removeListener(session, journalReader); + }; + + let setup = () => { + notifier.addListener(session, journalReader); + + let finished = false; + let done = () => { + if (finished) { + return; + } + finished = true; + close(); + return next(); + }; + + // force close after 30 min, otherwise we might end with connections that never close + req.connection.setTimeout(30 * 60 * 1000, done); + req.connection.on('end', done); + req.connection.on('close', done); + req.connection.on('error', done); + }; + + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + + if (lastEventId) { + loadJournalStream(db, req, res, user, lastEventId, (err, info) => { + if (err) { + res.write('event: error\ndata: ' + err.message.split('\n').join('\ndata: ') + '\n\n'); + // ignore + } + setup(); + if (info && info.processed) { + resetIdleComment(); + } else { + sendIdleComment(); + } + }); + } else { + let latest; + try { + latest = await db.database.collection('journal').findOne({ user }, { sort: { _id: -1 } }); + } catch (err) { + // ignore + } + if (latest) { + lastEventId = latest._id; + } + + setup(); + sendIdleComment(); + } + }) + ); }; function formatJournalData(e) {