pad.libre-service.eu-etherpad/src/node/handler/PadMessageHandler.js

1528 lines
50 KiB
JavaScript
Raw Normal View History

2011-03-26 14:10:41 +01:00
/**
* The MessageHandler handles all Messages that comes from Socket.IO and controls the sessions
*/
2011-05-30 16:53:11 +02:00
/*
* Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
2011-03-26 14:10:41 +01:00
*
* 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.
*/
2012-02-28 21:19:10 +01:00
2011-07-27 19:52:23 +02:00
var padManager = require("../db/PadManager");
var Changeset = require("ep_etherpad-lite/static/js/Changeset");
var AttributePool = require("ep_etherpad-lite/static/js/AttributePool");
2012-04-08 21:21:30 +02:00
var AttributeManager = require("ep_etherpad-lite/static/js/AttributeManager");
2011-07-27 19:52:23 +02:00
var authorManager = require("../db/AuthorManager");
var readOnlyManager = require("../db/ReadOnlyManager");
var settings = require('../utils/Settings');
var securityManager = require("../db/SecurityManager");
2012-03-01 19:00:58 +01:00
var plugins = require("ep_etherpad-lite/static/js/pluginfw/plugins.js");
var log4js = require('log4js');
var messageLogger = log4js.getLogger("message");
var accessLogger = log4js.getLogger("access");
var _ = require('underscore');
var hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks.js");
var channels = require("channels");
2013-10-27 17:42:55 +01:00
var stats = require('../stats');
var remoteAddress = require("../utils/RemoteAddress").remoteAddress;
const nodeify = require("nodeify");
const { RateLimiterMemory } = require('rate-limiter-flexible');
const rateLimiter = new RateLimiterMemory({
points: settings.commitRateLimiting.points,
duration: settings.commitRateLimiting.duration
});
2011-03-26 14:10:41 +01:00
2011-03-26 22:29:33 +01:00
/**
2012-04-23 16:18:14 +02:00
* A associative array that saves informations about a session
2011-03-26 22:29:33 +01:00
* key = sessionId
2012-04-23 16:18:14 +02:00
* values = padId, readonlyPadId, readonly, author, rev
* padId = the real padId of the pad
* readonlyPadId = The readonly pad id of the pad
* readonly = Wether the client has only read access (true) or read/write access (false)
2011-03-26 22:29:33 +01:00
* rev = That last revision that was send to this client
* author = the author name of this session
*/
2011-03-26 14:10:41 +01:00
var sessioninfos = {};
exports.sessioninfos = sessioninfos;
2011-03-26 14:10:41 +01:00
2013-10-27 17:42:55 +01:00
// Measure total amount of users
stats.gauge('totalUsers', function() {
return Object.keys(socketio.sockets.sockets).length;
});
2013-10-27 17:42:55 +01:00
/**
* A changeset queue per pad that is processed by handleUserChanges()
*/
var padChannels = new channels.channels(function(data, callback) {
return nodeify(handleUserChanges(data), callback);
});
2011-03-26 22:29:33 +01:00
/**
* Saves the Socket class we need to send and receive data from the client
2011-03-26 22:29:33 +01:00
*/
let socketio;
2011-03-26 14:10:41 +01:00
2011-03-26 22:29:33 +01:00
/**
* This Method is called by server.js to tell the message handler on which socket it should send
* @param socket_io The Socket
*/
2011-03-26 14:10:41 +01:00
exports.setSocketIO = function(socket_io)
{
socketio=socket_io;
}
2011-03-26 22:29:33 +01:00
/**
* Handles the connection of a new user
* @param client the new client
*/
2011-03-26 14:10:41 +01:00
exports.handleConnect = function(client)
2013-10-27 17:42:55 +01:00
{
stats.meter('connects').mark();
// Initalize sessioninfos for this new session
sessioninfos[client.id]={};
2011-03-26 14:10:41 +01:00
}
2011-08-16 21:02:30 +02:00
/**
* Kicks all sessions from a pad
* @param client the new client
*/
exports.kickSessionsFromPad = function(padID)
{
2014-11-22 16:39:42 +01:00
if(typeof socketio.sockets['clients'] !== 'function')
return;
// skip if there is nobody on this pad
2016-04-26 18:55:58 +02:00
if(_getRoomClients(padID).length == 0)
2011-08-16 21:02:30 +02:00
return;
// disconnect everyone from this pad
socketio.sockets.in(padID).json.send({disconnect:"deleted"});
2011-08-16 21:02:30 +02:00
}
2011-03-26 22:29:33 +01:00
/**
* Handles the disconnection of a user
* @param client the client that leaves
*/
exports.handleDisconnect = async function(client)
{
2013-10-27 17:42:55 +01:00
stats.meter('disconnects').mark();
// save the padname of this session
let session = sessioninfos[client.id];
// if this connection was already etablished with a handshake, send a disconnect message to the others
if (session && session.author) {
// Get the IP address from our persistant object
let ip = remoteAddress[client.id];
// Anonymize the IP address if IP logging is disabled
if (settings.disableIPlogging) {
ip = 'ANONYMOUS';
}
accessLogger.info('[LEAVE] Pad "' + session.padId + '": Author "' + session.author + '" on client ' + client.id + ' with IP "' + ip + '" left the pad');
2013-10-27 17:42:55 +01:00
// get the author color out of the db
let color = await authorManager.getAuthorColorId(session.author);
// prepare the notification for the other users on the pad, that this user left
let messageToTheOtherUsers = {
"type": "COLLABROOM",
"data": {
type: "USER_LEAVE",
userInfo: {
"ip": "127.0.0.1",
"colorId": color,
"userAgent": "Anonymous",
"userId": session.author
2011-05-23 21:11:57 +02:00
}
}
};
// Go through all user that are still on the pad, and send them the USER_LEAVE message
client.broadcast.to(session.padId).json.send(messageToTheOtherUsers);
// Allow plugins to hook into users leaving the pad
hooks.callAll("userLeave", session);
2011-05-23 21:11:57 +02:00
}
// Delete the sessioninfos entrys of this session
delete sessioninfos[client.id];
2011-03-26 14:10:41 +01:00
}
2011-03-26 22:29:33 +01:00
/**
* Handles a message from a user
* @param client the client that send this message
* @param message the message from the client
*/
exports.handleMessage = async function(client, message)
{
var env = process.env.NODE_ENV || 'development';
if (env === 'production') {
try {
await rateLimiter.consume(client.handshake.address); // consume 1 point per event from IP
}catch(e){
console.warn("Rate limited: ", client.handshake.address, " to reduce the amount of rate limiting that happens edit the rateLimit values in settings.json");
stats.meter('rateLimited').mark();
client.json.send({disconnect:"rateLimited"});
return;
}
}
if (message == null) {
2011-08-20 19:56:38 +02:00
return;
2011-03-26 14:10:41 +01:00
}
if (!message.type) {
return;
}
let thisSession = sessioninfos[client.id];
if (!thisSession) {
messageLogger.warn("Dropped message from an unknown connection.")
2011-08-20 19:56:38 +02:00
return;
2011-03-26 14:10:41 +01:00
}
async function handleMessageHook() {
// Allow plugins to bypass the readonly message blocker
let messages = await hooks.aCallAll("handleMessageSecurity", { client: client, message: message });
for (let message of messages) {
if (message === true) {
thisSession.readonly = false;
break;
}
}
let dropMessage = false;
// Call handleMessage hook. If a plugin returns null, the message will be dropped. Note that for all messages
2012-07-08 21:06:19 +02:00
// handleMessage will be called, even if the client is not authorized
messages = await hooks.aCallAll("handleMessage", { client: client, message: message });
for (let message of messages) {
if (message === null ) {
dropMessage = true;
break;
}
}
return dropMessage;
2012-07-08 21:06:19 +02:00
}
function finalHandler() {
// Check what type of message we get and delegate to the other methods
if (message.type == "CLIENT_READY") {
handleClientReady(client, message);
} else if (message.type == "CHANGESET_REQ") {
handleChangesetRequest(client, message);
} else if(message.type == "COLLABROOM") {
if (thisSession.readonly) {
messageLogger.warn("Dropped message, COLLABROOM for readonly pad");
} else if (message.data.type == "USER_CHANGES") {
stats.counter('pendingEdits').inc()
padChannels.emit(message.padId, {client: client, message: message}); // add to pad queue
} else if (message.data.type == "USERINFO_UPDATE") {
handleUserInfoUpdate(client, message);
} else if (message.data.type == "CHAT_MESSAGE") {
handleChatMessage(client, message);
} else if (message.data.type == "GET_CHAT_MESSAGES") {
handleGetChatMessages(client, message);
} else if (message.data.type == "SAVE_REVISION") {
handleSaveRevisionMessage(client, message);
} else if (message.data.type == "CLIENT_MESSAGE" &&
message.data.payload != null &&
message.data.payload.type == "suggestUserName") {
handleSuggestUserName(client, message);
} else {
messageLogger.warn("Dropped message, unknown COLLABROOM Data Type " + message.data.type);
}
} else if(message.type == "SWITCH_TO_PAD") {
handleSwitchToPad(client, message);
2012-04-23 16:18:14 +02:00
} else {
messageLogger.warn("Dropped message, unknown Message Type " + message.type);
2012-04-23 16:18:14 +02:00
}
}
let dropMessage = await handleMessageHook();
if (!dropMessage) {
// check permissions
if (message.type == "CLIENT_READY") {
// client tried to auth for the first time (first msg from the client)
createSessionInfo(client, message);
}
// Note: message.sessionID is an entirely different kind of
// session from the sessions we use here! Beware!
// FIXME: Call our "sessions" "connections".
// FIXME: Use a hook instead
// FIXME: Allow to override readwrite access with readonly
// the session may have been dropped during earlier processing
if (!sessioninfos[client.id]) {
messageLogger.warn("Dropping message from a connection that has gone away.")
return;
}
// Simulate using the load testing tool
if (!sessioninfos[client.id].auth) {
console.error("Auth was never applied to a session. If you are using the stress-test tool then restart Etherpad and the Stress test tool.")
return;
}
let auth = sessioninfos[client.id].auth;
// check if pad is requested via readOnly
let padId = auth.padID;
if (padId.indexOf("r.") === 0) {
// Pad is readOnly, first get the real Pad ID
padId = await readOnlyManager.getPadId(padId);
}
let { accessStatus } = await securityManager.checkAccess(padId, auth.sessionID, auth.token, auth.password);
if (accessStatus !== "grant") {
// no access, send the client a message that tells him why
client.json.send({ accessStatus });
return;
}
// access was granted
finalHandler();
}
2011-03-26 14:10:41 +01:00
}
2012-02-29 20:40:14 +01:00
/**
* Handles a save revision message
* @param client the client that send this message
* @param message the message from the client
*/
async function handleSaveRevisionMessage(client, message)
{
2012-04-23 16:18:14 +02:00
var padId = sessioninfos[client.id].padId;
2012-02-29 20:40:14 +01:00
var userId = sessioninfos[client.id].author;
let pad = await padManager.getPad(padId);
pad.addSavedRevision(pad.head, userId);
2012-02-29 20:40:14 +01:00
}
/**
* Handles a custom message, different to the function below as it handles
* objects not strings and you can direct the message to specific sessionID
*
* @param msg {Object} the message we're sending
* @param sessionID {string} the socketIO session to which we're sending this message
*/
exports.handleCustomObjectMessage = function(msg, sessionID) {
if (msg.data.type === "CUSTOM") {
if (sessionID){
// a sessionID is targeted: directly to this sessionID
socketio.sockets.socket(sessionID).json.send(msg);
} else {
// broadcast to all clients on this pad
socketio.sockets.in(msg.data.payload.padId).json.send(msg);
}
}
}
/**
* Handles a custom message (sent via HTTP API request)
*
* @param padID {Pad} the pad to which we're sending this message
* @param msgString {String} the message we're sending
*/
exports.handleCustomMessage = function(padID, msgString) {
let time = Date.now();
let msg = {
type: 'COLLABROOM',
data: {
type: msgString,
time: time
}
};
socketio.sockets.in(padID).json.send(msg);
}
2011-07-14 17:15:38 +02:00
/**
* Handles a Chat Message
* @param client the client that send this message
* @param message the message from the client
*/
function handleChatMessage(client, message)
{
var time = Date.now();
2011-07-14 17:15:38 +02:00
var userId = sessioninfos[client.id].author;
var text = message.data.text;
2012-04-23 16:18:14 +02:00
var padId = sessioninfos[client.id].padId;
exports.sendChatMessageToPadClients(time, userId, text, padId);
}
/**
* Sends a chat message to all clients of this pad
* @param time the timestamp of the chat message
* @param userId the author id of the chat message
* @param text the text of the chat message
* @param padId the padId to send the chat message to
*/
exports.sendChatMessageToPadClients = async function(time, userId, text, padId)
{
// get the pad
let pad = await padManager.getPad(padId);
// get the author
let userName = await authorManager.getAuthorName(userId);
// save the chat message
pad.appendChatMessage(text, userId, time);
let msg = {
type: "COLLABROOM",
data: { type: "CHAT_MESSAGE", userId, userName, time, text }
};
// broadcast the chat message to everyone on the pad
socketio.sockets.in(padId).json.send(msg);
2011-07-14 17:15:38 +02:00
}
/**
* Handles the clients request for more chat-messages
* @param client the client that send this message
* @param message the message from the client
*/
async function handleGetChatMessages(client, message)
{
if (message.data.start == null) {
messageLogger.warn("Dropped message, GetChatMessages Message has no start!");
return;
}
if (message.data.end == null) {
messageLogger.warn("Dropped message, GetChatMessages Message has no start!");
return;
}
let start = message.data.start;
let end = message.data.end;
let count = end - start;
if (count < 0 || count > 100) {
messageLogger.warn("Dropped message, GetChatMessages Message, client requested invalid amount of messages!");
return;
}
let padId = sessioninfos[client.id].padId;
let pad = await padManager.getPad(padId);
let chatMessages = await pad.getChatMessages(start, end);
let infoMsg = {
type: "COLLABROOM",
data: {
type: "CHAT_MESSAGES",
messages: chatMessages
}
};
// send the messages back to the client
client.json.send(infoMsg);
}
2011-07-14 17:15:38 +02:00
2011-06-30 15:19:30 +02:00
/**
* Handles a handleSuggestUserName, that means a user have suggest a userName for a other user
* @param client the client that send this message
* @param message the message from the client
*/
function handleSuggestUserName(client, message)
{
// check if all ok
if (message.data.payload.newName == null) {
messageLogger.warn("Dropped message, suggestUserName Message has no newName!");
return;
2011-06-30 15:19:30 +02:00
}
if (message.data.payload.unnamedId == null) {
messageLogger.warn("Dropped message, suggestUserName Message has no unnamedId!");
return;
2011-06-30 15:19:30 +02:00
}
2014-11-21 01:11:50 +01:00
var padId = sessioninfos[client.id].padId;
2016-04-26 18:55:58 +02:00
var roomClients = _getRoomClients(padId);
// search the author and send him this message
2016-04-26 18:55:58 +02:00
roomClients.forEach(function(client) {
var session = sessioninfos[client.id];
if (session && session.author == message.data.payload.unnamedId) {
2016-04-26 18:55:58 +02:00
client.json.send(message);
2011-06-30 15:19:30 +02:00
}
2016-04-26 18:55:58 +02:00
});
2011-06-30 15:19:30 +02:00
}
2011-03-26 22:29:33 +01:00
/**
* 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
*/
2011-03-26 14:10:41 +01:00
function handleUserInfoUpdate(client, message)
{
// check if all ok
if (message.data.userInfo == null) {
2013-01-30 15:21:25 +01:00
messageLogger.warn("Dropped message, USERINFO_UPDATE Message has no userInfo!");
return;
}
if (message.data.userInfo.colorId == null) {
messageLogger.warn("Dropped message, USERINFO_UPDATE Message has no colorId!");
return;
2011-03-26 14:10:41 +01:00
}
// Check that we have a valid session and author to update.
var session = sessioninfos[client.id];
if (!session || !session.author || !session.padId) {
messageLogger.warn("Dropped message, USERINFO_UPDATE Session not ready." + message.data);
return;
}
// Find out the author name of this session
var author = session.author;
2015-11-26 16:55:26 +01:00
// Check colorId is a Hex color
var isColor = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(message.data.userInfo.colorId) // for #f00 (Thanks Smamatti)
if (!isColor) {
2015-11-26 16:55:26 +01:00
messageLogger.warn("Dropped message, USERINFO_UPDATE Color is malformed." + message.data);
return;
}
// Tell the authorManager about the new attributes
2011-03-26 14:10:41 +01:00
authorManager.setAuthorColorId(author, message.data.userInfo.colorId);
authorManager.setAuthorName(author, message.data.userInfo.name);
var padId = session.padId;
var infoMsg = {
type: "COLLABROOM",
data: {
// The Client doesn't know about USERINFO_UPDATE, use USER_NEWINFO
type: "USER_NEWINFO",
userInfo: {
userId: author,
// set a null name, when there is no name set. cause the client wants it null
name: message.data.userInfo.name || null,
colorId: message.data.userInfo.colorId,
userAgent: "Anonymous",
ip: "127.0.0.1",
}
}
};
// Send the other clients on the pad the update message
client.broadcast.to(padId).json.send(infoMsg);
2011-03-26 14:10:41 +01:00
}
2011-03-26 22:29:33 +01:00
/**
* Handles a USER_CHANGES message, where the client submits its local
* edits as a changeset.
*
* This handler's job is to update the incoming changeset so that it applies
* to the latest revision, then add it to the pad, broadcast the changes
* to all other clients, and send a confirmation to the submitting client.
*
* This function is based on a similar one in the original Etherpad.
* See https://github.com/ether/pad/blob/master/etherpad/src/etherpad/collab/collab_server.js in the function applyUserChanges()
*
2011-03-26 22:29:33 +01:00
* @param client the client that send this message
* @param message the message from the client
*/
async function handleUserChanges(data)
2011-03-26 14:10:41 +01:00
{
var client = data.client
, message = data.message
// This one's no longer pending, as we're gonna process it now
stats.counter('pendingEdits').dec()
// Make sure all required fields are present
if (message.data.baseRev == null) {
messageLogger.warn("Dropped message, USER_CHANGES Message has no baseRev!");
return;
2011-03-26 14:10:41 +01:00
}
if (message.data.apool == null) {
messageLogger.warn("Dropped message, USER_CHANGES Message has no apool!");
return;
2011-03-26 14:10:41 +01:00
}
if (message.data.changeset == null) {
messageLogger.warn("Dropped message, USER_CHANGES Message has no changeset!");
return;
2011-03-26 14:10:41 +01:00
}
// TODO: this might happen with other messages too => find one place to copy the session
// and always use the copy. atm a message will be ignored if the session is gone even
// if the session was valid when the message arrived in the first place
if (!sessioninfos[client.id]) {
messageLogger.warn("Dropped message, disconnect happened in the mean time");
return;
2011-03-26 14:10:41 +01:00
}
// get all Vars we need
2011-03-26 14:10:41 +01:00
var baseRev = message.data.baseRev;
var wireApool = (new AttributePool()).fromJsonable(message.data.apool);
2011-03-26 14:10:41 +01:00
var changeset = message.data.changeset;
// The client might disconnect between our callbacks. We should still
// finish processing the changeset, so keep a reference to the session.
var thisSession = sessioninfos[client.id];
// Measure time to process edit
var stopWatch = stats.timer('edits').start();
// get the pad
let pad = await padManager.getPad(thisSession.padId);
// create the changeset
try {
try {
// Verify that the changeset has valid syntax and is in canonical form
Changeset.checkRep(changeset);
// Verify that the attribute indexes used in the changeset are all
// defined in the accompanying attribute pool.
Changeset.eachAttribNumber(changeset, function(n) {
if (!wireApool.getAttrib(n)) {
throw new Error("Attribute pool is missing attribute " + n + " for changeset " + changeset);
}
});
// Validate all added 'author' attribs to be the same value as the current user
var iterator = Changeset.opIterator(Changeset.unpack(changeset).ops)
, op;
while (iterator.hasNext()) {
op = iterator.next()
2015-03-03 15:20:33 +01:00
// + can add text with attribs
// = can change or add attribs
// - can have attribs, but they are discarded and don't show up in the attribs - but do show up in the pool
2015-03-03 15:20:33 +01:00
op.attribs.split('*').forEach(function(attr) {
if (!attr) return;
attr = wireApool.getAttrib(attr);
if (!attr) return;
// the empty author is used in the clearAuthorship functionality so this should be the only exception
if ('author' == attr[0] && (attr[1] != thisSession.author && attr[1] != '')) {
throw new Error("Trying to submit changes as another author in 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);
} catch(e) {
// There is an error in this changeset, so just refuse it
client.json.send({ disconnect: "badChangeset" });
stats.meter('failedChangesets').mark();
throw new Error("Can't apply USER_CHANGES, because " + e.message);
}
// ex. applyUserChanges
let apool = pad.pool;
let r = baseRev;
// The client's changeset might not be based on the latest revision,
// since other clients are sending changes at the same time.
// Update the changeset so that it can be applied to the latest revision.
while (r < pad.getHeadRevisionNumber()) {
r++;
let c = await pad.getRevisionChangeset(r);
// At this point, both "c" (from the pad) and "changeset" (from the
// client) are relative to revision r - 1. The follow function
// rebases "changeset" so that it is relative to revision r
// and can be applied after "c".
try {
// a changeset can be based on an old revision with the same changes in it
// prevent eplite from accepting it TODO: better send the client a NEW_CHANGES
// of that revision
if (baseRev + 1 == r && c == changeset) {
client.json.send({disconnect:"badChangeset"});
stats.meter('failedChangesets').mark();
throw new Error("Won't apply USER_CHANGES, because it contains an already accepted changeset");
}
changeset = Changeset.follow(c, changeset, false, apool);
} catch(e) {
2014-12-04 16:05:02 +01:00
client.json.send({disconnect:"badChangeset"});
stats.meter('failedChangesets').mark();
throw new Error("Can't apply USER_CHANGES, because " + e.message);
2014-12-04 16:05:02 +01:00
}
}
let prevText = pad.text();
if (Changeset.oldLen(changeset) != prevText.length) {
client.json.send({disconnect:"badChangeset"});
stats.meter('failedChangesets').mark();
throw new Error("Can't apply USER_CHANGES "+changeset+" with oldLen " + Changeset.oldLen(changeset) + " to document of length " + prevText.length);
}
try {
pad.appendRevision(changeset, thisSession.author);
} catch(e) {
client.json.send({ disconnect: "badChangeset" });
stats.meter('failedChangesets').mark();
throw e;
2011-07-21 21:13:58 +02:00
}
let correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool);
if (correctionChangeset) {
pad.appendRevision(correctionChangeset);
}
// Make sure the pad always ends with an empty line.
if (pad.text().lastIndexOf("\n") != pad.text().length-1) {
var nlChangeset = Changeset.makeSplice(pad.text(), pad.text().length - 1, 0, "\n");
pad.appendRevision(nlChangeset);
}
await exports.updatePadClients(pad);
} catch (err) {
console.warn(err.stack || err);
}
stopWatch.end();
2011-07-21 21:13:58 +02:00
}
exports.updatePadClients = async function(pad)
{
// skip this if no-one is on this pad
let roomClients = _getRoomClients(pad.id);
if (roomClients.length == 0) {
return;
}
// since all clients usually get the same set of changesets, store them in local cache
// to remove unnecessary roundtrip to the datalayer
// NB: note below possibly now accommodated via the change to promises/async
// TODO: in REAL world, if we're working without datalayer cache, all requests to revisions will be fired
// BEFORE first result will be landed to our cache object. The solution is to replace parallel processing
// via async.forEach with sequential for() loop. There is no real benefits of running this in parallel,
// but benefit of reusing cached revision object is HUGE
let revCache = {};
// go through all sessions on this pad
for (let client of roomClients) {
let sid = client.id;
// send them all new changesets
while (sessioninfos[sid] && sessioninfos[sid].rev < pad.getHeadRevisionNumber()) {
let r = sessioninfos[sid].rev + 1;
let revision = revCache[r];
if (!revision) {
revision = await pad.getRevision(r);
revCache[r] = revision;
}
let author = revision.meta.author,
revChangeset = revision.changeset,
currentTime = revision.meta.timestamp;
// next if session has not been deleted
if (sessioninfos[sid] == null) {
continue;
}
if (author == sessioninfos[sid].author) {
client.json.send({ "type": "COLLABROOM", "data":{ type: "ACCEPT_COMMIT", newRev: r }});
} else {
let forWire = Changeset.prepareForWire(revChangeset, pad.pool);
let wireMsg = {"type": "COLLABROOM",
"data": { type:"NEW_CHANGES",
newRev:r,
changeset: forWire.translated,
apool: forWire.pool,
author: author,
currentTime: currentTime,
timeDelta: currentTime - sessioninfos[sid].time
}};
client.json.send(wireMsg);
}
if (sessioninfos[sid]) {
sessioninfos[sid].time = currentTime;
sessioninfos[sid].rev = r;
}
}
}
}
2011-03-26 14:10:41 +01:00
2011-03-26 22:29:33 +01:00
/**
2013-10-27 17:42:55 +01:00
* Copied from the Etherpad Source Code. Don't know what this method does excatly...
2011-03-26 22:29:33 +01:00
*/
2011-03-26 14:10:41 +01:00
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 hasMarker = _.find(AttributeManager.lineAttributes, function(attribute) {
return Changeset.opAttributeValue(op, attribute, apool);
}) !== undefined;
if (hasMarker) {
for (var i = 0; i < op.chars; i++) {
2011-03-26 14:10:41 +01:00
if (offset > 0 && text.charAt(offset-1) != '\n') {
badMarkers.push(offset);
}
offset++;
}
} else {
2011-03-26 14:10:41 +01:00
offset += op.chars;
}
}
if (badMarkers.length == 0) {
return null;
}
// create changeset that removes these bad markers
offset = 0;
2011-03-26 14:10:41 +01:00
var builder = Changeset.builder(text.length);
2011-03-26 14:10:41 +01:00
badMarkers.forEach(function(pos) {
builder.keepText(text.substring(offset, pos));
builder.remove(1);
offset = pos+1;
});
2011-03-26 14:10:41 +01:00
return builder.toString();
}
function handleSwitchToPad(client, message)
{
// clear the session and leave the room
let currentSession = sessioninfos[client.id];
let padId = currentSession.padId;
let roomClients = _getRoomClients(padId);
roomClients.forEach(client => {
let sinfo = sessioninfos[client.id];
if (sinfo && sinfo.author == currentSession.author) {
// fix user's counter, works on page refresh or if user closes browser window and then rejoins
2016-04-26 18:55:58 +02:00
sessioninfos[client.id] = {};
client.leave(padId);
}
2016-04-26 18:55:58 +02:00
});
// start up the new pad
createSessionInfo(client, message);
handleClientReady(client, message);
}
function createSessionInfo(client, message)
{
// Remember this information since we won't
// have the cookie in further socket.io messages.
// This information will be used to check if
// the sessionId of this connection is still valid
// since it could have been deleted by the API.
sessioninfos[client.id].auth =
{
sessionID: message.sessionID,
padID: message.padId,
token : message.token,
password: message.password
};
}
2011-03-26 22:29:33 +01:00
/**
* Handles a CLIENT_READY. A CLIENT_READY is the first message from the client to the server. The Client sends his token
2011-03-26 22:29:33 +01:00
* 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
*/
async function handleClientReady(client, message)
2011-03-26 14:10:41 +01:00
{
// check if all ok
if (!message.token) {
2011-11-19 20:21:23 +01:00
messageLogger.warn("Dropped message, CLIENT_READY Message has no token!");
return;
2011-03-26 14:10:41 +01:00
}
if (!message.padId) {
2011-11-19 20:21:23 +01:00
messageLogger.warn("Dropped message, CLIENT_READY Message has no padId!");
return;
2011-03-26 14:10:41 +01:00
}
if (!message.protocolVersion) {
2011-11-19 20:21:23 +01:00
messageLogger.warn("Dropped message, CLIENT_READY Message has no protocolVersion!");
return;
2011-03-26 14:10:41 +01:00
}
if (message.protocolVersion != 2) {
2011-11-19 20:21:23 +01:00
messageLogger.warn("Dropped message, CLIENT_READY Message has a unknown protocolVersion '" + message.protocolVersion + "'!");
return;
2011-03-26 14:10:41 +01:00
}
hooks.callAll("clientReady", message);
// Get ro/rw id:s
let padIds = await readOnlyManager.getIds(message.padId);
// check permissions
// Note: message.sessionID is an entierly different kind of
// session from the sessions we use here! Beware!
// FIXME: Call our "sessions" "connections".
// FIXME: Use a hook instead
// FIXME: Allow to override readwrite access with readonly
let statusObject = await securityManager.checkAccess(padIds.padId, message.sessionID, message.token, message.password);
let accessStatus = statusObject.accessStatus;
// no access, send the client a message that tells him why
if (accessStatus !== "grant") {
client.json.send({ accessStatus });
return;
}
let author = statusObject.authorID;
// get all authordata of this new user
let value = await authorManager.getAuthor(author);
let authorColorId = value.colorId;
let authorName = value.name;
/*
* Here we know authorID, token and session. We should ?always? store it..
* TODO: I fear that this might allow a user to pass a token for an authorID
* meaning that they could in theory "imitate" another author?
* Perhaps the fix to this is check to see if it exists first and if it
* does then abort.. Details: https://github.com/ether/etherpad-lite/issues/4006
*/
await authorManager.setToken2Author(message.token, statusObject.authorID)
// load the pad-object from the database
let pad = await padManager.getPad(padIds.padId);
// these db requests all need the pad object (timestamp of latest revision, author data)
let authors = pad.getAllAuthors();
// get timestamp of latest revision needed for timeslider
let currentTime = await pad.getRevisionDate(pad.getHeadRevisionNumber());
// get all author data out of the database (in parallel)
let historicalAuthorData = {};
await Promise.all(authors.map(authorId => {
return authorManager.getAuthor(authorId).then(author => {
if (!author) {
messageLogger.error("There is no author for authorId: ", authorId, ". This is possibly related to https://github.com/ether/etherpad-lite/issues/2802");
} else {
historicalAuthorData[authorId] = { name: author.name, colorId: author.colorId }; // Filter author attribs (e.g. don't send author's pads to all clients)
}
});
}));
let thisUserHasEditedThisPad = false;
if (historicalAuthorData[statusObject.authorID]) {
/*
* This flag is set to true when a user contributes to a specific pad for
* the first time. It is used for deciding if importing to that pad is
* allowed or not.
*/
thisUserHasEditedThisPad = true;
}
// glue the clientVars together, send them and tell the other clients that a new one is there
// Check that the client is still here. It might have disconnected between callbacks.
if (sessioninfos[client.id] === undefined) {
return;
}
// Check if this author is already on the pad, if yes, kick the other sessions!
let roomClients = _getRoomClients(pad.id);
for (let client of roomClients) {
let sinfo = sessioninfos[client.id];
if (sinfo && sinfo.author == author) {
// fix user's counter, works on page refresh or if user closes browser window and then rejoins
sessioninfos[client.id] = {};
client.leave(padIds.padId);
client.json.send({disconnect:"userdup"});
}
}
// Save in sessioninfos that this session belonges to this pad
sessioninfos[client.id].padId = padIds.padId;
sessioninfos[client.id].readOnlyPadId = padIds.readOnlyPadId;
sessioninfos[client.id].readonly = padIds.readonly;
// Log creation/(re-)entering of a pad
let ip = remoteAddress[client.id];
// Anonymize the IP address if IP logging is disabled
if (settings.disableIPlogging) {
ip = 'ANONYMOUS';
}
if (pad.head > 0) {
accessLogger.info('[ENTER] Pad "' + padIds.padId + '": Client ' + client.id + ' with IP "' + ip + '" entered the pad');
} else if (pad.head == 0) {
accessLogger.info('[CREATE] Pad "' + padIds.padId + '": Client ' + client.id + ' with IP "' + ip + '" created the pad');
}
if (message.reconnect) {
// If this is a reconnect, we don't have to send the client the ClientVars again
// Join the pad and start receiving updates
client.join(padIds.padId);
// Save the revision in sessioninfos, we take the revision from the info the client send to us
sessioninfos[client.id].rev = message.client_rev;
// During the client reconnect, client might miss some revisions from other clients. By using client revision,
// this below code sends all the revisions missed during the client reconnect
var revisionsNeeded = [];
var changesets = {};
var startNum = message.client_rev + 1;
var endNum = pad.getHeadRevisionNumber() + 1;
var headNum = pad.getHeadRevisionNumber();
2014-11-05 00:29:45 +01:00
if (endNum > headNum + 1) {
endNum = headNum + 1;
}
if (startNum < 0) {
startNum = 0;
}
for (let r = startNum; r < endNum; r++) {
revisionsNeeded.push(r);
changesets[r] = {};
}
2018-02-10 18:00:22 +01:00
// get changesets, author and timestamp needed for pending revisions (in parallel)
let promises = [];
for (let revNum of revisionsNeeded) {
let cs = changesets[revNum];
promises.push( pad.getRevisionChangeset(revNum).then(result => cs.changeset = result ));
promises.push( pad.getRevisionAuthor(revNum).then(result => cs.author = result ));
promises.push( pad.getRevisionDate(revNum).then(result => cs.timestamp = result ));
}
await Promise.all(promises);
2018-02-10 18:00:22 +01:00
// return pending changesets
for (let r of revisionsNeeded) {
let forWire = Changeset.prepareForWire(changesets[r]['changeset'], pad.pool);
let wireMsg = {"type":"COLLABROOM",
"data":{type:"CLIENT_RECONNECT",
headRev:pad.getHeadRevisionNumber(),
newRev:r,
changeset:forWire.translated,
apool: forWire.pool,
author: changesets[r]['author'],
currentTime: changesets[r]['timestamp']
}};
client.json.send(wireMsg);
}
2018-02-10 18:00:22 +01:00
if (startNum == endNum) {
var Msg = {"type":"COLLABROOM",
"data":{type:"CLIENT_RECONNECT",
noChanges: true,
newRev: pad.getHeadRevisionNumber()
}};
client.json.send(Msg);
}
} else {
// This is a normal first connect
// prepare all values for the wire, there's a chance that this throws, if the pad is corrupted
try {
var atext = Changeset.cloneAText(pad.atext);
var attribsForWire = Changeset.prepareForWire(atext.attribs, pad.pool);
var apool = attribsForWire.pool.toJsonable();
atext.attribs = attribsForWire.translated;
} catch(e) {
console.error(e.stack || e)
client.json.send({ disconnect:"corruptPad" }); // pull the brakes
2018-04-03 15:21:14 +02:00
return;
}
// Warning: never ever send padIds.padId to the client. If the
// client is read only you would open a security hole 1 swedish
// mile wide...
var clientVars = {
"skinName": settings.skinName,
"skinVariants": settings.skinVariants,
"randomVersionString": settings.randomVersionString,
"accountPrivs": {
"maxRevisions": 100
},
"automaticReconnectionTimeout": settings.automaticReconnectionTimeout,
"initialRevisionList": [],
"initialOptions": {
"guestPolicy": "deny"
},
"savedRevisions": pad.getSavedRevisions(),
"collab_client_vars": {
"initialAttributedText": atext,
"clientIp": "127.0.0.1",
"padId": message.padId,
"historicalAuthorData": historicalAuthorData,
"apool": apool,
"rev": pad.getHeadRevisionNumber(),
"time": currentTime,
},
"colorPalette": authorManager.getColorPalette(),
"clientIp": "127.0.0.1",
"userIsGuest": true,
"userColor": authorColorId,
"padId": message.padId,
"padOptions": settings.padOptions,
"padShortcutEnabled": settings.padShortcutEnabled,
"initialTitle": "Pad: " + message.padId,
"opts": {},
// tell the client the number of the latest chat-message, which will be
// used to request the latest 100 chat-messages later (GET_CHAT_MESSAGES)
"chatHead": pad.chatHead,
"numConnectedUsers": roomClients.length,
"readOnlyId": padIds.readOnlyPadId,
"readonly": padIds.readonly,
"serverTimestamp": Date.now(),
"userId": author,
"abiwordAvailable": settings.abiwordAvailable(),
"sofficeAvailable": settings.sofficeAvailable(),
"exportAvailable": settings.exportAvailable(),
"plugins": {
"plugins": plugins.plugins,
"parts": plugins.parts,
},
"indentationOnNewLine": settings.indentationOnNewLine,
"scrollWhenFocusLineIsOutOfViewport": {
"percentage" : {
"editionAboveViewport": settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionAboveViewport,
"editionBelowViewport": settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionBelowViewport,
},
"duration": settings.scrollWhenFocusLineIsOutOfViewport.duration,
"scrollWhenCaretIsInTheLastLineOfViewport": settings.scrollWhenFocusLineIsOutOfViewport.scrollWhenCaretIsInTheLastLineOfViewport,
"percentageToScrollWhenUserPressesArrowUp": settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp,
},
"initialChangesets": [], // FIXME: REMOVE THIS SHIT
"thisUserHasEditedThisPad": thisUserHasEditedThisPad,
"allowAnyoneToImport": settings.allowAnyoneToImport
}
// Add a username to the clientVars if one avaiable
if (authorName != null) {
clientVars.userName = authorName;
}
// call the clientVars-hook so plugins can modify them before they get sent to the client
let messages = await hooks.aCallAll('clientVars', {clientVars, pad, socket: client});
// combine our old object with the new attributes from the hook
for (let msg of messages) {
Object.assign(clientVars, msg);
}
// Join the pad and start receiving updates
client.join(padIds.padId);
// Send the clientVars to the Client
client.json.send({type: "CLIENT_VARS", data: clientVars});
// Save the current revision in sessioninfos, should be the same as in clientVars
sessioninfos[client.id].rev = pad.getHeadRevisionNumber();
sessioninfos[client.id].author = author;
// prepare the notification for the other users on the pad, that this user joined
let 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;
}
// notify all existing users about new user
client.broadcast.to(padIds.padId).json.send(messageToTheOtherUsers);
// Get sessions for this pad and update them (in parallel)
roomClients = _getRoomClients(pad.id);
await Promise.all(_getRoomClients(pad.id).map(async roomClient => {
// Jump over, if this session is the connection session
if (roomClient.id == client.id) {
return;
}
// Since sessioninfos might change while being enumerated, check if the
// sessionID is still assigned to a valid session
if (sessioninfos[roomClient.id] === undefined) {
return;
}
// get the authorname & colorId
let author = sessioninfos[roomClient.id].author;
let cached = historicalAuthorData[author];
// reuse previously created cache of author's data
let p = cached ? Promise.resolve(cached) : authorManager.getAuthor(author);
return p.then(authorInfo => {
PadMessageHandler: use a predefined color when authorInfo.colorId is not defined For some reason authorInfo is sometimes null, and therefore it is not possible to get colorId from it. This resulted in the following stack trace: [2020-03-16 09:27:17.291] [ERROR] console - (node:1746) UnhandledPromiseRejectionWarning: TypeError: Cannot read property 'colorId' of null at <BASEDIR>/src/node/handler/PadMessageHandler.js:1199:37 at runMicrotasks (<anonymous>) at processTicksAndRejections (internal/process/task_queues.js:97:5) at async Promise.all (index 0) at async handleClientReady (<BASEDIR>/src/node/handler/PadMessageHandler.js:1171:5) [2020-03-16 09:27:17.291] [ERROR] console - (node:1746) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 76) [2020-03-16 09:27:19.034] [WARN] message - Dropped message, USERINFO_UPDATE Session not ready.[object Object] Which is due to a bug in Etherpad that we are not going to solve now. As a workaround, when this happens, let's set the username to "Anonymous" (if it is not already set), and colorId to the fixed value "#daf0b2". Warning messages are written in the logs to signal this condition. This is no definitive solution, but fixes #3612 (via a workaround).
2020-03-16 15:43:05 +01:00
// default fallback color to use if authorInfo.colorId is null
const defaultColor = "#daf0b2";
if (!authorInfo) {
2020-03-31 12:08:35 +02:00
console.warn(`handleClientReady(): no authorInfo parameter was received. Default values are going to be used. See issue #3612. This can be caused by a user clicking undo after clearing all authorship colors see #2802`);
PadMessageHandler: use a predefined color when authorInfo.colorId is not defined For some reason authorInfo is sometimes null, and therefore it is not possible to get colorId from it. This resulted in the following stack trace: [2020-03-16 09:27:17.291] [ERROR] console - (node:1746) UnhandledPromiseRejectionWarning: TypeError: Cannot read property 'colorId' of null at <BASEDIR>/src/node/handler/PadMessageHandler.js:1199:37 at runMicrotasks (<anonymous>) at processTicksAndRejections (internal/process/task_queues.js:97:5) at async Promise.all (index 0) at async handleClientReady (<BASEDIR>/src/node/handler/PadMessageHandler.js:1171:5) [2020-03-16 09:27:17.291] [ERROR] console - (node:1746) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 76) [2020-03-16 09:27:19.034] [WARN] message - Dropped message, USERINFO_UPDATE Session not ready.[object Object] Which is due to a bug in Etherpad that we are not going to solve now. As a workaround, when this happens, let's set the username to "Anonymous" (if it is not already set), and colorId to the fixed value "#daf0b2". Warning messages are written in the logs to signal this condition. This is no definitive solution, but fixes #3612 (via a workaround).
2020-03-16 15:43:05 +01:00
authorInfo = {};
}
// For some reason sometimes name isn't set
// Catch this issue here and use a fixed name.
if (!authorInfo.name) {
console.warn(`handleClientReady(): client submitted no author name. Using "Anonymous". See: issue #3612`);
authorInfo.name = "Anonymous";
}
// For some reason sometimes colorId isn't set
// Catch this issue here and use a fixed color.
if (!authorInfo.colorId) {
console.warn(`handleClientReady(): author "${authorInfo.name}" has no property colorId. Using the default color ${defaultColor}. See issue #3612`);
authorInfo.colorId = defaultColor;
}
// Send the new User a Notification about this other user
let msg = {
"type": "COLLABROOM",
"data": {
type: "USER_NEWINFO",
userInfo: {
"ip": "127.0.0.1",
"colorId": authorInfo.colorId,
"name": authorInfo.name,
"userAgent": "Anonymous",
"userId": author
}
2011-03-26 14:10:41 +01:00
}
};
client.json.send(msg);
});
}));
}
2011-03-26 14:10:41 +01:00
}
/**
* Handles a request for a rough changeset, the timeslider client needs it
*/
async function handleChangesetRequest(client, message)
{
// check if all ok
if (message.data == null) {
messageLogger.warn("Dropped message, changeset request has no data!");
return;
}
if (message.padId == null) {
messageLogger.warn("Dropped message, changeset request has no padId!");
return;
}
if (message.data.granularity == null) {
messageLogger.warn("Dropped message, changeset request has no granularity!");
return;
}
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isInteger#Polyfill
if (Math.floor(message.data.granularity) !== message.data.granularity) {
messageLogger.warn("Dropped message, changeset request granularity is not an integer!");
return;
}
if (message.data.start == null) {
messageLogger.warn("Dropped message, changeset request has no start!");
return;
}
if (message.data.requestID == null) {
messageLogger.warn("Dropped message, changeset request has no requestID!");
return;
}
let granularity = message.data.granularity;
let start = message.data.start;
let end = start + (100 * granularity);
2012-07-05 19:33:20 +02:00
let padIds = await readOnlyManager.getIds(message.padId);
2012-07-05 19:33:20 +02:00
// build the requested rough changesets and send them back
try {
let data = await getChangesetInfo(padIds.padId, start, end, granularity);
data.requestID = message.data.requestID;
client.json.send({ type: "CHANGESET_REQ", data });
} catch (err) {
console.error('Error while handling a changeset request for ' + padIds.padId, err.toString(), message.data);
}
}
/**
* Tries to rebuild the getChangestInfo function of the original Etherpad
* https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L144
*/
async function getChangesetInfo(padId, startNum, endNum, granularity)
{
let pad = await padManager.getPad(padId);
let head_revision = pad.getHeadRevisionNumber();
// calculate the last full endnum
if (endNum > head_revision + 1) {
endNum = head_revision + 1;
}
endNum = Math.floor(endNum / granularity) * granularity;
let compositesChangesetNeeded = [];
let revTimesNeeded = [];
// figure out which composite Changeset and revTimes we need, to load them in bulk
for (let start = startNum; start < endNum; start += granularity) {
let end = start + granularity;
// add the composite Changeset we needed
compositesChangesetNeeded.push({ start, end });
// add the t1 time we need
revTimesNeeded.push(start == 0 ? 0 : start - 1);
// add the t2 time we need
revTimesNeeded.push(end - 1);
}
// get all needed db values parallel - no await here since
// it would make all the lookups run in series
// get all needed composite Changesets
let composedChangesets = {};
let p1 = Promise.all(compositesChangesetNeeded.map(item => {
return composePadChangesets(padId, item.start, item.end).then(changeset => {
composedChangesets[item.start + "/" + item.end] = changeset;
});
}));
// get all needed revision Dates
let revisionDate = [];
let p2 = Promise.all(revTimesNeeded.map(revNum => {
return pad.getRevisionDate(revNum).then(revDate => {
revisionDate[revNum] = Math.floor(revDate / 1000);
});
}));
// get the lines
let lines;
let p3 = getPadLines(padId, startNum - 1).then(_lines => {
lines = _lines;
});
// wait for all of the above to complete
await Promise.all([p1, p2, p3]);
// doesn't know what happens here exactly :/
let timeDeltas = [];
let forwardsChangesets = [];
let backwardsChangesets = [];
let apool = new AttributePool();
for (let compositeStart = startNum; compositeStart < endNum; compositeStart += granularity) {
let compositeEnd = compositeStart + granularity;
if (compositeEnd > endNum || compositeEnd > head_revision + 1) {
break;
}
let forwards = composedChangesets[compositeStart + "/" + compositeEnd];
let backwards = Changeset.inverse(forwards, lines.textlines, lines.alines, pad.apool());
Changeset.mutateAttributionLines(forwards, lines.alines, pad.apool());
Changeset.mutateTextLines(forwards, lines.textlines);
let forwards2 = Changeset.moveOpsToNewPool(forwards, pad.apool(), apool);
let backwards2 = Changeset.moveOpsToNewPool(backwards, pad.apool(), apool);
let t1 = (compositeStart == 0) ? revisionDate[0] : revisionDate[compositeStart - 1];
let t2 = revisionDate[compositeEnd - 1];
timeDeltas.push(t2 - t1);
forwardsChangesets.push(forwards2);
backwardsChangesets.push(backwards2);
}
return { forwardsChangesets, backwardsChangesets,
apool: apool.toJsonable(), actualEndNum: endNum,
timeDeltas, start: startNum, granularity };
}
/**
* Tries to rebuild the getPadLines function of the original Etherpad
* https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L263
*/
async function getPadLines(padId, revNum)
{
2019-03-12 18:10:24 +01:00
let pad = await padManager.getPad(padId);
// get the atext
let atext;
if (revNum >= 0) {
atext = await pad.getInternalRevisionAText(revNum);
} else {
atext = Changeset.makeAText("\n");
}
return {
textlines: Changeset.splitTextLines(atext.text),
alines: Changeset.splitAttributionLines(atext.attribs, atext.text)
};
}
/**
* Tries to rebuild the composePadChangeset function of the original Etherpad
* https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L241
*/
async function composePadChangesets (padId, startNum, endNum)
{
let pad = await padManager.getPad(padId);
// fetch all changesets we need
let headNum = pad.getHeadRevisionNumber();
endNum = Math.min(endNum, headNum + 1);
startNum = Math.max(startNum, 0);
// create an array for all changesets, we will
// replace the values with the changeset later
let changesetsNeeded = [];
for (let r = startNum ; r < endNum; r++) {
changesetsNeeded.push(r);
}
// get all changesets
let changesets = {};
await Promise.all(changesetsNeeded.map(revNum => {
return pad.getRevisionChangeset(revNum).then(changeset => changesets[revNum] = changeset);
}));
// compose Changesets
let r;
try {
let changeset = changesets[startNum];
let pool = pad.apool();
for (r = startNum + 1; r < endNum; r++) {
let cs = changesets[r];
changeset = Changeset.compose(changeset, cs, pool);
}
return changeset;
} catch (e) {
// r-1 indicates the rev that was build starting with startNum, applying startNum+1, +2, +3
console.warn("failed to compose cs in pad:", padId, " startrev:", startNum," current rev:", r);
throw e;
}
}
2016-04-26 18:55:58 +02:00
function _getRoomClients(padID) {
var roomClients = [];
var room = socketio.sockets.adapter.rooms[padID];
2014-11-23 23:33:56 +01:00
if (room) {
2016-04-26 18:55:58 +02:00
for (var id in room.sockets) {
roomClients.push(socketio.sockets.sockets[id]);
2014-11-23 23:33:56 +01:00
}
}
2016-04-26 18:55:58 +02:00
return roomClients;
}
2014-11-23 23:33:56 +01:00
2016-04-26 18:55:58 +02:00
/**
* Get the number of users in a pad
*/
exports.padUsersCount = function(padID) {
return {
2016-04-26 18:55:58 +02:00
padUsersCount: _getRoomClients(padID).length
}
}
/**
* Get the list of users in a pad
*/
exports.padUsers = async function(padID) {
let padUsers = [];
let roomClients = _getRoomClients(padID);
2014-11-23 23:33:56 +01:00
// iterate over all clients (in parallel)
await Promise.all(roomClients.map(async roomClient => {
let s = sessioninfos[roomClient.id];
if (s) {
return authorManager.getAuthor(s.author).then(author => {
// Fixes: https://github.com/ether/etherpad-lite/issues/4120
// On restart author might not be populated?
if(author){
author.id = s.author;
padUsers.push(author);
}
});
}
}));
return { padUsers };
}
exports.sessioninfos = sessioninfos;