etherpad-lite/node/MessageHandler.js

713 lines
21 KiB
JavaScript

/**
* Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
var async = require("async");
var padManager = require("./PadManager");
var Changeset = require("./Changeset");
var AttributePoolFactory = require("./AttributePoolFactory");
var authorManager = require("./AuthorManager");
/**
* A associative array that translates a session to a pad
*/
var session2pad = {};
/**
* A associative array that saves which sessions belong to a pad
*/
var pad2sessions = {};
/**
* A associative array that saves some general informations about a session
* key = sessionId
* values = author, rev
* rev = That last revision that was send to this client
* author = the author name of this session
*/
var sessioninfos = {};
/**
* Saves the Socket class we need to send and recieve data from the client
*/
var socketio;
/**
* This Method is called by server.js to tell the message handler on which socket it should send
* @param socket_io The Socket
*/
exports.setSocketIO = function(socket_io)
{
socketio=socket_io;
}
/**
* Handles the connection of a new user
* @param client the new client
*/
exports.handleConnect = function(client)
{
//check if all ok
throwExceptionIfClientOrIOisInvalid(client);
//Initalize session2pad and sessioninfos for this new session
session2pad[client.sessionId]=null;
sessioninfos[client.sessionId]={};
}
/**
* Handles the disconnection of a user
* @param client the client that leaves
*/
exports.handleDisconnect = function(client)
{
//check if all ok
throwExceptionIfClientOrIOisInvalid(client);
//save the padname of this session
var sessionPad=session2pad[client.sessionId];
//if this connection was already etablished with a handshake, send a disconnect message to the others
if(sessioninfos[client.sessionId].author)
{
var author = sessioninfos[client.sessionId].author;
//get the author color out of the db
authorManager.getAuthorColorId(author, function(err, color)
{
if(err) throw err;
//prepare the notification for the other users on the pad, that this user left
var messageToTheOtherUsers = {
"type": "COLLABROOM",
"data": {
type: "USER_LEAVE",
userInfo: {
"ip": "127.0.0.1",
"colorId": color,
"userAgent": "Anonymous",
"userId": author
}
}
};
//Go trough all user that are still on the pad, and send them the USER_LEAVE message
for(i in pad2sessions[sessionPad])
{
socketio.clients[pad2sessions[sessionPad][i]].send(messageToTheOtherUsers);
}
});
}
//Go trough all sessions of this pad, search and destroy the entry of this client
for(i in pad2sessions[sessionPad])
{
if(pad2sessions[sessionPad][i] == client.sessionId)
{
delete pad2sessions[sessionPad][i];
break;
}
}
//Delete the session2pad and sessioninfos entrys of this session
delete session2pad[client.sessionId];
delete sessioninfos[client.sessionId];
}
/**
* Handles a message from a user
* @param client the client that send this message
* @param message the message from the client
*/
exports.handleMessage = function(client, message)
{
//check if all ok
throwExceptionIfClientOrIOisInvalid(client);
if(message == null)
{
throw "Message is null!";
}
//Etherpad sometimes send JSON and sometimes a JSONstring...
if(typeof message == "string")
{
message = JSON.parse(message);
}
if(!message.type)
{
throw "Message have no type attribute!";
}
//Check what type of message we get and delegate to the other methodes
if(message.type == "CLIENT_READY")
{
handleClientReady(client, message);
}
else if(message.type == "COLLABROOM" &&
message.data.type == "USER_CHANGES")
{
console.error(JSON.stringify(message));
handleUserChanges(client, message);
}
else if(message.type == "COLLABROOM" &&
message.data.type == "USERINFO_UPDATE")
{
console.error(JSON.stringify(message));
handleUserInfoUpdate(client, message);
}
//if the message type is unkown, throw an exception
else
{
console.error(message);
throw "unkown Message Type: '" + message.type + "'";
}
}
/**
* Handles a USERINFO_UPDATE, that means that a user have changed his color or name. Anyway, we get both informations
* @param client the client that send this message
* @param message the message from the client
*/
function handleUserInfoUpdate(client, message)
{
//check if all ok
if(message.data.userInfo.colorId == null)
{
throw "USERINFO_UPDATE Message have no colorId!";
}
//Find out the author name of this session
var author = sessioninfos[client.sessionId].author;
//Tell the authorManager about the new attributes
authorManager.setAuthorColorId(author, message.data.userInfo.colorId);
authorManager.setAuthorName(author, message.data.userInfo.name);
var padId = session2pad[client.sessionId];
//set a null name, when there is no name set. cause the client wants it null
if(message.data.userInfo.name == null)
{
message.data.userInfo.name = null;
}
//The Client don't know about a USERINFO_UPDATE, it can handle only new user_newinfo, so change the message type
message.data.type = "USER_NEWINFO";
//Send the other clients on the pad the update message
for(i in pad2sessions[padId])
{
if(pad2sessions[padId][i] != client.sessionId)
{
socketio.clients[pad2sessions[padId][i]].send(message);
}
}
}
function errlog(name, value)
{
console.error(name+"=" + JSON.stringify(value));
}
/**
* Handles a USERINFO_UPDATE, that means that a user have changed his color or name. Anyway, we get both informations
* This Method is nearly 90% copied out of the Etherpad Source Code. So I can't tell you what happens here exactly
* Look at https://github.com/ether/pad/blob/master/etherpad/src/etherpad/collab/collab_server.js in the function applyUserChanges()
* @param client the client that send this message
* @param message the message from the client
*/
function handleUserChanges(client, message)
{
//check if all ok
if(message.data.baseRev == null)
{
throw "USER_CHANGES Message have no baseRev!";
}
if(message.data.apool == null)
{
throw "USER_CHANGES Message have no apool!";
}
if(message.data.changeset == null)
{
throw "USER_CHANGES Message have no changeset!";
}
//get all Vars we need
var baseRev = message.data.baseRev;
var wireApool = (AttributePoolFactory.createAttributePool()).fromJsonable(message.data.apool);
var changeset = message.data.changeset;
var r, apool, pad;
async.series([
//get the pad
function(callback)
{
padManager.getPad(session2pad[client.sessionId], function(err, value)
{
pad = value;
callback(err);
});
},
//create the changeset
function(callback)
{
//ex. _checkChangesetAndPool
//Copied from Etherpad, don't know what it does exactly
Changeset.checkRep(changeset);
Changeset.eachAttribNumber(changeset, function(n) {
if (! wireApool.getAttrib(n)) {
throw "Attribute pool is missing attribute "+n+" for changeset "+changeset;
}
});
//ex. adoptChangesetAttribs
//Afaik, it copies the new attributes from the changeset, to the global Attribute Pool
changeset = Changeset.moveOpsToNewPool(changeset, wireApool, pad.pool);
//ex. applyUserChanges
apool = pad.pool;
r = baseRev;
//https://github.com/caolan/async#whilst
async.whilst(
function() { return r < pad.getHeadRevisionNumber(); },
function(callback)
{
r++;
pad.getRevisionChangeset(r, function(err, c)
{
if(err)
{
callback(err);
return;
}
else
{
changeset = Changeset.follow(c, changeset, false, apool);
callback(null);
}
});
},
//use the callback of the series function
callback
);
},
//do correction changesets, and send it to all users
function (callback)
{
var prevText = pad.text();
if (Changeset.oldLen(changeset) != prevText.length) {
throw "Can't apply USER_CHANGES "+changeset+" with oldLen "
+ Changeset.oldLen(changeset) + " to document of length " + prevText.length;
}
var thisAuthor = sessioninfos[client.sessionId].author;
pad.appendRevision(changeset, thisAuthor);
var correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool);
if (correctionChangeset) {
pad.appendRevision(correctionChangeset);
}
if (pad.text().lastIndexOf("\n\n") != pad.text().length-2) {
var nlChangeset = Changeset.makeSplice(pad.text(), pad.text().length-1, 0, "\n");
pad.appendRevision(nlChangeset);
}
//ex. updatePadClients
//go trough all sessions on this pad
async.forEach(pad2sessions[pad.id], function(session, callback)
{
var lastRev = sessioninfos[session].rev;
//https://github.com/caolan/async#whilst
//send them all new changesets
async.whilst(
function (){ return lastRev < pad.getHeadRevisionNumber()},
function(callback)
{
var author, revChangeset;
var r = ++lastRev;
async.parallel([
function (callback)
{
pad.getRevisionAuthor(r, function(err, value)
{
author = value;
callback(err);
});
},
function (callback)
{
pad.getRevisionChangeset(r, function(err, value)
{
revChangeset = value;
callback(err);
});
}
], function(err)
{
if(err)
{
callback(err);
return;
}
if(author == sessioninfos[session].author)
{
socketio.clients[session].send({"type":"COLLABROOM","data":{type:"ACCEPT_COMMIT", newRev:r}});
}
else
{
var forWire = Changeset.prepareForWire(revChangeset, pad.pool);
var wireMsg = {"type":"COLLABROOM","data":{type:"NEW_CHANGES", newRev:r,
changeset: forWire.translated,
apool: forWire.pool,
author: author}};
socketio.clients[session].send(wireMsg);
}
callback(null);
});
},
callback
);
sessioninfos[session].rev = pad.getHeadRevisionNumber();
},callback);
}
], function(err)
{
if(err) throw err;
});
}
/**
* Copied from the Etherpad Source Code. Don't know what this methode does excatly...
*/
function _correctMarkersInPad(atext, apool) {
var text = atext.text;
// collect char positions of line markers (e.g. bullets) in new atext
// that aren't at the start of a line
var badMarkers = [];
var iter = Changeset.opIterator(atext.attribs);
var offset = 0;
while (iter.hasNext()) {
var op = iter.next();
var listValue = Changeset.opAttributeValue(op, 'list', apool);
if (listValue) {
for(var i=0;i<op.chars;i++) {
if (offset > 0 && text.charAt(offset-1) != '\n') {
badMarkers.push(offset);
}
offset++;
}
}
else {
offset += op.chars;
}
}
if (badMarkers.length == 0) {
return null;
}
// create changeset that removes these bad markers
offset = 0;
var builder = Changeset.builder(text.length);
badMarkers.forEach(function(pos) {
builder.keepText(text.substring(offset, pos));
builder.remove(1);
offset = pos+1;
});
return builder.toString();
}
/**
* Handles a CLIENT_READY. A CLIENT_READY is the first message from the client to the server. The Client sends his token
* and the pad it wants to enter. The Server answers with the inital values (clientVars) of the pad
* @param client the client that send this message
* @param message the message from the client
*/
function handleClientReady(client, message)
{
//check if all ok
if(!message.token)
{
throw "CLIENT_READY Message have no token!";
}
if(!message.padId)
{
throw "CLIENT_READY Message have no padId!";
}
if(!message.protocolVersion)
{
throw "CLIENT_READY Message have no protocolVersion!";
}
if(message.protocolVersion != 1)
{
throw "CLIENT_READY Message have a unkown protocolVersion '" + protocolVersion + "'!";
}
var author;
var authorName;
var authorColorId;
var pad;
async.series([
//get all authordata of this new user
function(callback)
{
//Ask the author Manager for a author of this token.
authorManager.getAuthor4Token(message.token, function(err,value)
{
author = value;
async.parallel([
//get colorId
function(callback)
{
authorManager.getAuthorColorId(author, function(err, value)
{
authorColorId = value;
callback(err);
});
},
//get author name
function(callback)
{
authorManager.getAuthorName(author, function(err, value)
{
authorName = value;
callback(err);
});
},
function(callback)
{
padManager.getPad(message.padId, function(err, value)
{
pad = value;
callback(err);
});
}
], callback);
});
},
function(callback)
{
//Check if this author is already on the pad, if yes, kick him!
if(pad2sessions[message.padId])
{
for(var i in pad2sessions[message.padId])
{
if(sessioninfos[pad2sessions[message.padId][i]].author == author)
{
client.send({disconnect:"doublelogin"});
return;
}
}
}
//Save in session2pad that this session belonges to this pad
var sessionId=String(client.sessionId);
session2pad[sessionId] = message.padId;
//check if there is already a pad2sessions entry, if not, create one
if(!pad2sessions[message.padId])
{
pad2sessions[message.padId] = [];
}
//Saves in pad2sessions that this session belongs to this pad
pad2sessions[message.padId].push(sessionId);
//prepare all values for the wire
var atext = Changeset.cloneAText(pad.atext);
var attribsForWire = Changeset.prepareForWire(atext.attribs, pad.pool);
var apool = attribsForWire.pool.toJsonable();
atext.attribs = attribsForWire.translated;
var clientVars = {
"accountPrivs": {
"maxRevisions": 100
},
"initialRevisionList": [],
"initialOptions": {
"guestPolicy": "deny"
},
"collab_client_vars": {
"initialAttributedText": atext,
"clientIp": (client.request && client.request.connection) ? client.request.connection.remoteAddress : "127.0.0.1",
//"clientAgent": "Anonymous Agent",
"padId": message.padId,
"historicalAuthorData": {},
"apool": apool,
"rev": pad.getHeadRevisionNumber(),
"globalPadId": message.padId
},
"colorPalette": ["#ffc7c7", "#fff1c7", "#e3ffc7", "#c7ffd5", "#c7ffff", "#c7d5ff", "#e3c7ff", "#ffc7f1", "#ff8f8f", "#ffe38f", "#c7ff8f", "#8fffab", "#8fffff", "#8fabff", "#c78fff", "#ff8fe3", "#d97979", "#d9c179", "#a9d979", "#79d991", "#79d9d9", "#7991d9", "#a979d9", "#d979c1", "#d9a9a9", "#d9cda9", "#c1d9a9", "#a9d9b5", "#a9d9d9", "#a9b5d9", "#c1a9d9", "#d9a9cd"],
"clientIp": (client.request && client.request.connection) ? client.request.connection.remoteAddress : "127.0.0.1",
"userIsGuest": true,
"userColor": authorColorId,
"padId": message.padId,
"initialTitle": "Pad: " + message.padId,
"opts": {},
"chatHistory": {
"start": 0,
"historicalAuthorData": {},
"end": 0,
"lines": []
},
"numConnectedUsers": pad2sessions[message.padId].length,
"isProPad": false,
"serverTimestamp": new Date().getTime(),
"globalPadId": message.padId,
"userId": author,
"cookiePrefsToSet": {
"fullWidth": false,
"hideSidebar": false
},
"hooks": {}
}
//Add a username to the clientVars if one avaiable
if(authorName != null)
{
clientVars.userName = authorName;
}
//Add all authors that worked on this pad, to the historicalAuthorData on clientVars
var allAuthors = pad.getAllAuthors();
for(i in allAuthors)
{
clientVars.collab_client_vars.historicalAuthorData[allAuthors[i]] = {};
if(authorName != null)
clientVars.collab_client_vars.historicalAuthorData[allAuthors[i]].name = authorName;
clientVars.collab_client_vars.historicalAuthorData[allAuthors[i]].colorId = authorColorId;
}
//Send the clientVars to the Client
client.send(clientVars);
//Save the revision and the author id in sessioninfos
sessioninfos[client.sessionId].rev = pad.getHeadRevisionNumber();
sessioninfos[client.sessionId].author = author;
//prepare the notification for the other users on the pad, that this user joined
var messageToTheOtherUsers = {
"type": "COLLABROOM",
"data": {
type: "USER_NEWINFO",
userInfo: {
"ip": "127.0.0.1",
"colorId": authorColorId,
"userAgent": "Anonymous",
"userId": author
}
}
};
//Add the authorname of this new User, if avaiable
if(authorName != null)
{
messageToTheOtherUsers.data.userInfo.name = authorName;
}
//Run trough all sessions of this pad
async.forEach(pad2sessions[message.padId], function(sessionID, callback)
{
var sessionAuthorName, sessionAuthorColorId;
async.series([
//get the authorname & colorId
function(callback)
{
async.parallel([
function(callback)
{
authorManager.getAuthorColorId(sessioninfos[sessionID].author, function(err, value)
{
sessionAuthorColorId = value;
callback(err);
})
},
function(callback)
{
authorManager.getAuthorName(sessioninfos[sessionID].author, function(err, value)
{
sessionAuthorName = value;
callback(err);
})
}
],callback);
},
function (callback)
{
//Jump over, if this session is the connection session
if(sessionID != client.sessionId)
{
//Send this Session the Notification about the new user
socketio.clients[sessionID].send(messageToTheOtherUsers);
//Send the new User a Notification about this other user
var messageToNotifyTheClientAboutTheOthers = {
"type": "COLLABROOM",
"data": {
type: "USER_NEWINFO",
userInfo: {
"ip": "127.0.0.1",
"colorId": sessionAuthorColorId,
"name": sessionAuthorName,
"userAgent": "Anonymous",
"userId": sessioninfos[sessionID].author
}
}
};
client.send(messageToNotifyTheClientAboutTheOthers);
}
}
], callback);
}, callback);
}
],function(err)
{
if(err) throw err;
});
}
/**
* A internal function that simply checks if client or socketio is null and throws a exception if yes
*/
function throwExceptionIfClientOrIOisInvalid(client)
{
if(client == null)
{
throw "Client is null!";
}
if(socketio == null)
{
throw "SocketIO is not set or null! Please use setSocketIO(io) to set it";
}
}