acl for mailbox listing

This commit is contained in:
Andris Reinman 2018-08-30 12:24:21 +03:00
parent 3b645d39f3
commit fabbc597b9
4 changed files with 560 additions and 465 deletions

View file

@ -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": ["*"]
}
},

View file

@ -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

View file

@ -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();
});
});
})
);
};

View file

@ -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) {