wildduck/lib/api/updates.js

361 lines
11 KiB
JavaScript
Raw Normal View History

2017-07-26 16:52:55 +08:00
'use strict';
const crypto = require('crypto');
const Joi = require('joi');
const ObjectID = require('mongodb').ObjectID;
const tools = require('../tools');
2018-08-30 17:24:21 +08:00
const roles = require('../roles');
2017-11-22 22:22:36 +08:00
const base32 = require('base32.js');
2017-07-26 16:52:55 +08:00
module.exports = (db, server, notifier) => {
2017-11-28 17:51:45 +08:00
/**
* @api {get} /users/:id/updates Open change stream
* @apiName GetUpdates
* @apiGroup Users
* @apiDescription This api call returns an EventSource response. Listen on this stream to get
* notifications about changes in messages and mailboxes. Returned events are JSON encoded strings
*
* @apiHeader {String} X-Access-Token Optional access token if authentication is enabled
* @apiHeaderExample {json} Header-Example:
* {
* "X-Access-Token": "59fc66a03e54454869460e45"
* }
*
* @apiParam {String} id Users unique ID.
*
* @apiSuccess {String} command Indicates data event type
*
* @apiError error Description of the error
*
* @apiExample {javascript} Example usage:
* var stream = new EventSource('/users/59fc66a03e54454869460e45/updates');
* stream.onmessage = function(e) {
* console.log(JSON.parse(e.data));
* };
*
* @apiSuccessExample {text} Success-Response:
* HTTP/1.1 200 OK
* Content-Type: text/event-stream
*
* data: {
* data: "command": "CREATE",
* data: "mailbox": "5a1d3061153888cdcd62a719",
* data: "path": "First Level/Second 😎 Level/Folder Name"
* data: }
*
* @apiErrorExample {json} Error-Response:
* HTTP/1.1 200 OK
* {
* "error": "This user does not exist"
* }
*/
2018-08-30 17:24:21 +08:00
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),
sess: Joi.string().max(255),
ip: Joi.string().ip({
version: ['ipv4', 'ipv6'],
cidr: 'forbidden'
})
2018-08-30 17:24:21 +08:00
});
2017-07-26 16:52:55 +08:00
2018-08-30 17:24:21 +08:00
if (req.header('Last-Event-ID')) {
req.params['Last-Event-ID'] = req.header('Last-Event-ID');
}
2017-07-26 16:52:55 +08:00
2018-08-30 17:24:21 +08:00
const result = Joi.validate(req.params, schema, {
abortEarly: false,
convert: true
2017-07-26 16:52:55 +08:00
});
2018-08-30 17:24:21 +08:00
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
2017-07-26 16:52:55 +08:00
}
2018-08-30 17:24:21 +08:00
};
let closed = false;
let idleTimer = false;
let idleCounter = 0;
let sendIdleComment = () => {
clearTimeout(idleTimer);
if (closed) {
return;
2017-07-26 16:52:55 +08:00
}
2018-08-30 17:24:21 +08:00
res.write(': idling ' + ++idleCounter + '\n\n');
idleTimer = setTimeout(sendIdleComment, 15 * 1000);
};
2017-07-26 16:52:55 +08:00
2018-08-30 17:24:21 +08:00
let resetIdleComment = () => {
clearTimeout(idleTimer);
if (closed) {
return;
}
idleTimer = setTimeout(sendIdleComment, 15 * 1000);
};
2017-07-26 16:52:55 +08:00
2018-08-30 17:24:21 +08:00
let journalReading = false;
let journalReader = message => {
if (journalReading || closed) {
return;
}
2017-07-26 16:52:55 +08:00
2018-08-30 17:24:21 +08:00
if (message) {
return res.write(formatJournalData(message));
}
2017-07-26 16:52:55 +08:00
2018-08-30 17:24:21 +08:00
journalReading = true;
loadJournalStream(db, req, res, user, lastEventId, (err, info) => {
if (err) {
// ignore?
2017-07-26 16:52:55 +08:00
}
2018-08-30 17:24:21 +08:00
lastEventId = info && info.lastEventId;
journalReading = false;
if (info && info.processed) {
resetIdleComment();
2017-11-22 22:22:36 +08:00
}
2018-08-30 17:24:21 +08:00
});
};
2018-08-30 17:24:21 +08:00
let close = () => {
closed = true;
clearTimeout(idleTimer);
notifier.removeListener(session, journalReader);
};
2018-08-30 17:24:21 +08:00
let setup = () => {
notifier.addListener(session, journalReader);
2017-07-26 16:52:55 +08:00
2018-08-30 17:24:21 +08:00
let finished = false;
let done = () => {
if (finished) {
return;
}
finished = true;
close();
return next();
2017-11-22 22:22:36 +08:00
};
2017-07-26 16:52:55 +08:00
2018-08-30 17:24:21 +08:00
// 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);
};
2017-11-22 22:22:36 +08:00
2018-08-30 17:24:21 +08:00
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
2017-11-22 22:22:36 +08:00
2018-08-30 17:24:21 +08:00
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 {
2017-07-26 16:52:55 +08:00
sendIdleComment();
2018-08-30 17:24:21 +08:00
}
});
} else {
let latest;
try {
latest = await db.database.collection('journal').findOne({ user }, { sort: { _id: -1 } });
} catch (err) {
// ignore
}
if (latest) {
lastEventId = latest._id;
2017-11-22 22:22:36 +08:00
}
2018-08-30 17:24:21 +08:00
setup();
sendIdleComment();
2017-07-26 16:52:55 +08:00
}
2018-08-30 17:24:21 +08:00
})
);
2017-07-26 16:52:55 +08:00
};
function formatJournalData(e) {
let data = {};
Object.keys(e).forEach(key => {
if (!['_id', 'ignore', 'user', 'modseq', 'unseenChange', 'created'].includes(key)) {
if (e.command !== 'COUNTERS' && key === 'unseen') {
return;
}
data[key] = e[key];
}
});
let response = [];
2017-11-22 22:22:36 +08:00
response.push(
'data: ' +
JSON.stringify(data, false, 2)
.split('\n')
.join('\ndata: ')
);
2017-07-26 16:52:55 +08:00
if (e._id) {
response.push('id: ' + e._id.toString());
}
return response.join('\n') + '\n\n';
}
function loadJournalStream(db, req, res, user, lastEventId, done) {
let query = { user };
if (lastEventId) {
query._id = { $gt: lastEventId };
}
let mailboxes = new Set();
2017-11-22 22:22:36 +08:00
let cursor = db.database
.collection('journal')
.find(query)
.sort({ _id: 1 });
2017-07-26 16:52:55 +08:00
let processed = 0;
let processNext = () => {
cursor.next((err, e) => {
if (err) {
return done(err);
}
if (!e) {
return cursor.close(() => {
if (!mailboxes.size) {
return done(null, {
lastEventId,
processed
});
}
mailboxes = Array.from(mailboxes);
let mailboxPos = 0;
let emitCounters = () => {
if (mailboxPos >= mailboxes.length) {
return done(null, {
lastEventId,
processed
});
}
let mailbox = new ObjectID(mailboxes[mailboxPos++]);
tools.getMailboxCounter(db, mailbox, false, (err, total) => {
if (err) {
// ignore
}
tools.getMailboxCounter(db, mailbox, 'unseen', (err, unseen) => {
if (err) {
// ignore
}
res.write(
formatJournalData({
command: 'COUNTERS',
_id: lastEventId,
mailbox,
total,
unseen
})
);
setImmediate(emitCounters);
});
});
};
emitCounters();
});
}
lastEventId = e._id;
if (!e || !e.command) {
// skip
return processNext();
}
switch (e.command) {
case 'EXISTS':
case 'EXPUNGE':
if (e.mailbox) {
mailboxes.add(e.mailbox.toString());
}
break;
case 'FETCH':
if (e.mailbox && (e.unseen || e.unseenChange)) {
mailboxes.add(e.mailbox.toString());
}
break;
}
res.write(formatJournalData(e));
processed++;
processNext();
});
};
processNext();
}