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

1426 lines
48 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.
*/
/* global exports, process, require */
2012-02-28 21:19:10 +01:00
2020-11-23 19:24:19 +01:00
const padManager = require('../db/PadManager');
const Changeset = require('ep_etherpad-lite/static/js/Changeset');
const AttributePool = require('ep_etherpad-lite/static/js/AttributePool');
const AttributeManager = require('ep_etherpad-lite/static/js/AttributeManager');
const authorManager = require('../db/AuthorManager');
const readOnlyManager = require('../db/ReadOnlyManager');
const settings = require('../utils/Settings');
const securityManager = require('../db/SecurityManager');
const plugins = require('ep_etherpad-lite/static/js/pluginfw/plugin_defs.js');
const log4js = require('log4js');
const messageLogger = log4js.getLogger('message');
const accessLogger = log4js.getLogger('access');
const _ = require('underscore');
const hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks.js');
const channels = require('channels');
const stats = require('../stats');
const assert = require('assert').strict;
2020-11-23 19:24:19 +01:00
const nodeify = require('nodeify');
const {RateLimiterMemory} = require('rate-limiter-flexible');
const webaccess = require('../hooks/express/webaccess');
const rateLimiter = new RateLimiterMemory({
points: settings.commitRateLimiting.points,
2020-11-23 19:24:19 +01:00
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 ID used for this session
2011-03-26 22:29:33 +01:00
*/
2020-11-23 19:24:19 +01:00
const 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
2020-11-23 19:24:19 +01:00
stats.gauge('totalUsers', () => Object.keys(socketio.sockets.sockets).length);
2013-10-27 17:42:55 +01:00
/**
* A changeset queue per pad that is processed by handleUserChanges()
*/
2020-11-23 19:24:19 +01:00
const padChannels = new channels.channels(({socket, message}, callback) => nodeify(handleUserChanges(socket, message), 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
*/
2020-11-23 19:24:19 +01:00
exports.setSocketIO = function (socket_io) {
socketio = socket_io;
};
2011-03-26 14:10:41 +01:00
2011-03-26 22:29:33 +01:00
/**
* Handles the connection of a new user
* @param socket the socket.io Socket object for the new connection from the client
2011-03-26 22:29:33 +01:00
*/
exports.handleConnect = (socket) => {
2013-10-27 17:42:55 +01:00
stats.meter('connects').mark();
// Initalize sessioninfos for this new session
sessioninfos[socket.id] = {};
};
2011-03-26 14:10:41 +01:00
2011-08-16 21:02:30 +02:00
/**
* Kicks all sessions from a pad
*/
2020-11-23 19:24:19 +01:00
exports.kickSessionsFromPad = function (padID) {
if (typeof socketio.sockets.clients !== 'function') return;
2014-11-22 16:39:42 +01:00
// skip if there is nobody on this pad
2020-11-23 19:24:19 +01:00
if (_getRoomSockets(padID).length === 0) return;
2011-08-16 21:02:30 +02:00
// disconnect everyone from this pad
2020-11-23 19:24:19 +01:00
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 socket the socket.io Socket object for the client
2011-03-26 22:29:33 +01:00
*/
exports.handleDisconnect = async (socket) => {
2013-10-27 17:42:55 +01:00
stats.meter('disconnects').mark();
// save the padname of this session
const session = sessioninfos[socket.id];
// if this connection was already etablished with a handshake, send a disconnect message to the others
if (session && session.author) {
const {session: {user} = {}} = socket.client.request;
2020-11-23 19:24:19 +01:00
accessLogger.info(`${'[LEAVE]' +
` pad:${session.padId}` +
` socket:${socket.id}` +
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
2020-11-23 19:24:19 +01:00
` authorID:${session.author}`}${
(user && user.username) ? ` username:${user.username}` : ''}`);
2013-10-27 17:42:55 +01:00
// get the author color out of the db
2020-11-23 19:24:19 +01:00
const color = await authorManager.getAuthorColorId(session.author);
// prepare the notification for the other users on the pad, that this user left
2020-11-23 19:24:19 +01:00
const messageToTheOtherUsers = {
type: 'COLLABROOM',
data: {
type: 'USER_LEAVE',
userInfo: {
2020-11-23 19:24:19 +01:00
ip: '127.0.0.1',
colorId: color,
userAgent: 'Anonymous',
userId: session.author,
},
},
};
// Go through all user that are still on the pad, and send them the USER_LEAVE message
socket.broadcast.to(session.padId).json.send(messageToTheOtherUsers);
// Allow plugins to hook into users leaving the pad
2020-11-23 19:24:19 +01:00
hooks.callAll('userLeave', session);
2011-05-23 21:11:57 +02:00
}
// Delete the sessioninfos entrys of this session
delete sessioninfos[socket.id];
};
2011-03-26 14:10:41 +01:00
2011-03-26 22:29:33 +01:00
/**
* Handles a message from a user
* @param socket the socket.io Socket object for the client
2011-03-26 22:29:33 +01:00
* @param message the message from the client
*/
exports.handleMessage = async (socket, message) => {
2020-11-23 19:24:19 +01:00
const env = process.env.NODE_ENV || 'development';
if (env === 'production') {
try {
await rateLimiter.consume(socket.request.ip); // consume 1 point per event from IP
} catch (e) {
console.warn(`Rate limited: ${socket.request.ip} to reduce the amount of rate limiting ` +
'that happens edit the rateLimit values in settings.json');
stats.meter('rateLimited').mark();
socket.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;
}
const thisSession = sessioninfos[socket.id];
if (!thisSession) {
2020-11-23 19:24:19 +01:00
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
}
2020-11-23 19:24:19 +01:00
if (message.type === 'CLIENT_READY') {
// client tried to auth for the first time (first msg from the client)
createSessionInfoAuth(thisSession, message);
}
const auth = thisSession.auth;
if (!auth) {
2020-11-23 19:24:19 +01:00
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;
}
// check if pad is requested via readOnly
let padId = auth.padID;
2020-11-23 19:24:19 +01:00
if (padId.indexOf('r.') === 0) {
// Pad is readOnly, first get the real Pad ID
padId = await readOnlyManager.getPadId(padId);
}
const {session: {user} = {}} = socket.client.request;
const {accessStatus, authorID} =
await securityManager.checkAccess(padId, auth.sessionID, auth.token, user);
if (accessStatus !== 'grant') {
// Access denied. Send the reason to the user.
socket.json.send({accessStatus});
return;
}
if (thisSession.author != null && thisSession.author !== authorID) {
messageLogger.warn(
2020-11-23 19:24:19 +01:00
`${'Rejecting message from client because the author ID changed mid-session.' +
' Bad or missing token or sessionID?' +
` socket:${socket.id}` +
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
` originalAuthorID:${thisSession.author}` +
2020-11-23 19:24:19 +01:00
` newAuthorID:${authorID}`}${
(user && user.username) ? ` username:${user.username}` : ''
} message:${message}`);
socket.json.send({disconnect: 'rejected'});
return;
}
thisSession.author = authorID;
PadMessageHandler: Move handleMessage hooks after access check Move the handleMessageSecurity and handleMessage hooks after the call to securityManager.checkAccess. Benefits: * A handleMessage plugin can safely assume the message will be handled unless the plugin itself drops the message, so it doesn't need to repeat the access checks done by the `handleMessage` function. * This paves the way for a future enhancement: pass the author ID to the hooks. Note: The handleMessageSecurity hook is broken in several ways: * The hook result is ignored for `CLIENT_READY` and `SWITCH_TO_PAD` messages because the `handleClientReady` function overwrites the hook result. This causes the client to receive client vars with `readonly` set to true, which causes the client to display an immutable pad even though the pad is technically writable. * The formatting toolbar buttons are removed for read-only pads before the handleMessageSecurity hook even runs. * It is awkwardly named: Without reading the documentation, how is one supposed to know that "handle message security" actually means "grant one-time write access to a read-only pad"? * It is called for every message even though calls after a `CLIENT_READY` or `SWITCH_TO_PAD` are mostly pointless. * Why would anyone want to grant write access when the user visits a read-only pad URL? The user should just visit the writable pad URL instead. * Why would anyone want to grant write access that only lasts for a single socket.io connection? * There are better ways to temporarily grant write access (e.g., the authorize hook). * This hook is inviting bugs because it breaks a core assumption about `/p/r.*` URLs. I think the hook should be deprecated and eventually removed.
2020-09-10 19:27:59 +02:00
// Allow plugins to bypass the readonly message blocker
const context = {message, socket, client: socket}; // `client` for backwards compatibility.
if ((await hooks.aCallAll('handleMessageSecurity', context)).some((w) => w === true)) {
PadMessageHandler: Move handleMessage hooks after access check Move the handleMessageSecurity and handleMessage hooks after the call to securityManager.checkAccess. Benefits: * A handleMessage plugin can safely assume the message will be handled unless the plugin itself drops the message, so it doesn't need to repeat the access checks done by the `handleMessage` function. * This paves the way for a future enhancement: pass the author ID to the hooks. Note: The handleMessageSecurity hook is broken in several ways: * The hook result is ignored for `CLIENT_READY` and `SWITCH_TO_PAD` messages because the `handleClientReady` function overwrites the hook result. This causes the client to receive client vars with `readonly` set to true, which causes the client to display an immutable pad even though the pad is technically writable. * The formatting toolbar buttons are removed for read-only pads before the handleMessageSecurity hook even runs. * It is awkwardly named: Without reading the documentation, how is one supposed to know that "handle message security" actually means "grant one-time write access to a read-only pad"? * It is called for every message even though calls after a `CLIENT_READY` or `SWITCH_TO_PAD` are mostly pointless. * Why would anyone want to grant write access when the user visits a read-only pad URL? The user should just visit the writable pad URL instead. * Why would anyone want to grant write access that only lasts for a single socket.io connection? * There are better ways to temporarily grant write access (e.g., the authorize hook). * This hook is inviting bugs because it breaks a core assumption about `/p/r.*` URLs. I think the hook should be deprecated and eventually removed.
2020-09-10 19:27:59 +02:00
thisSession.readonly = false;
}
// Call handleMessage hook. If a plugin returns null, the message will be dropped.
if ((await hooks.aCallAll('handleMessage', context)).some((m) => m === null)) {
PadMessageHandler: Move handleMessage hooks after access check Move the handleMessageSecurity and handleMessage hooks after the call to securityManager.checkAccess. Benefits: * A handleMessage plugin can safely assume the message will be handled unless the plugin itself drops the message, so it doesn't need to repeat the access checks done by the `handleMessage` function. * This paves the way for a future enhancement: pass the author ID to the hooks. Note: The handleMessageSecurity hook is broken in several ways: * The hook result is ignored for `CLIENT_READY` and `SWITCH_TO_PAD` messages because the `handleClientReady` function overwrites the hook result. This causes the client to receive client vars with `readonly` set to true, which causes the client to display an immutable pad even though the pad is technically writable. * The formatting toolbar buttons are removed for read-only pads before the handleMessageSecurity hook even runs. * It is awkwardly named: Without reading the documentation, how is one supposed to know that "handle message security" actually means "grant one-time write access to a read-only pad"? * It is called for every message even though calls after a `CLIENT_READY` or `SWITCH_TO_PAD` are mostly pointless. * Why would anyone want to grant write access when the user visits a read-only pad URL? The user should just visit the writable pad URL instead. * Why would anyone want to grant write access that only lasts for a single socket.io connection? * There are better ways to temporarily grant write access (e.g., the authorize hook). * This hook is inviting bugs because it breaks a core assumption about `/p/r.*` URLs. I think the hook should be deprecated and eventually removed.
2020-09-10 19:27:59 +02:00
return;
}
// Drop the message if the client disconnected during the above processing.
if (sessioninfos[socket.id] !== thisSession) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropping message from a connection that has gone away.');
PadMessageHandler: Move handleMessage hooks after access check Move the handleMessageSecurity and handleMessage hooks after the call to securityManager.checkAccess. Benefits: * A handleMessage plugin can safely assume the message will be handled unless the plugin itself drops the message, so it doesn't need to repeat the access checks done by the `handleMessage` function. * This paves the way for a future enhancement: pass the author ID to the hooks. Note: The handleMessageSecurity hook is broken in several ways: * The hook result is ignored for `CLIENT_READY` and `SWITCH_TO_PAD` messages because the `handleClientReady` function overwrites the hook result. This causes the client to receive client vars with `readonly` set to true, which causes the client to display an immutable pad even though the pad is technically writable. * The formatting toolbar buttons are removed for read-only pads before the handleMessageSecurity hook even runs. * It is awkwardly named: Without reading the documentation, how is one supposed to know that "handle message security" actually means "grant one-time write access to a read-only pad"? * It is called for every message even though calls after a `CLIENT_READY` or `SWITCH_TO_PAD` are mostly pointless. * Why would anyone want to grant write access when the user visits a read-only pad URL? The user should just visit the writable pad URL instead. * Why would anyone want to grant write access that only lasts for a single socket.io connection? * There are better ways to temporarily grant write access (e.g., the authorize hook). * This hook is inviting bugs because it breaks a core assumption about `/p/r.*` URLs. I think the hook should be deprecated and eventually removed.
2020-09-10 19:27:59 +02:00
return;
}
// Check what type of message we get and delegate to the other methods
2020-11-23 19:24:19 +01:00
if (message.type === 'CLIENT_READY') {
await handleClientReady(socket, message, authorID);
2020-11-23 19:24:19 +01:00
} else if (message.type === 'CHANGESET_REQ') {
await handleChangesetRequest(socket, message);
2020-11-23 19:24:19 +01:00
} else if (message.type === 'COLLABROOM') {
if (thisSession.readonly) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, COLLABROOM for readonly pad');
} else if (message.data.type === 'USER_CHANGES') {
stats.counter('pendingEdits').inc();
padChannels.emit(message.padId, {socket, message}); // add to pad queue
2020-11-23 19:24:19 +01:00
} else if (message.data.type === 'USERINFO_UPDATE') {
await handleUserInfoUpdate(socket, message);
2020-11-23 19:24:19 +01:00
} else if (message.data.type === 'CHAT_MESSAGE') {
await handleChatMessage(socket, message);
2020-11-23 19:24:19 +01:00
} else if (message.data.type === 'GET_CHAT_MESSAGES') {
await handleGetChatMessages(socket, message);
2020-11-23 19:24:19 +01:00
} else if (message.data.type === 'SAVE_REVISION') {
await handleSaveRevisionMessage(socket, message);
2020-11-23 19:24:19 +01:00
} else if (message.data.type === 'CLIENT_MESSAGE' &&
message.data.payload != null &&
2020-11-23 19:24:19 +01:00
message.data.payload.type === 'suggestUserName') {
handleSuggestUserName(socket, message);
} else {
2020-11-23 19:24:19 +01:00
messageLogger.warn(`Dropped message, unknown COLLABROOM Data Type ${message.data.type}`);
}
2020-11-23 19:24:19 +01:00
} else if (message.type === 'SWITCH_TO_PAD') {
await handleSwitchToPad(socket, message, authorID);
} else {
2020-11-23 19:24:19 +01:00
messageLogger.warn(`Dropped message, unknown Message Type ${message.type}`);
}
2020-11-23 19:24:19 +01:00
};
2011-03-26 14:10:41 +01:00
2012-02-29 20:40:14 +01:00
/**
* Handles a save revision message
* @param socket the socket.io Socket object for the client
2012-02-29 20:40:14 +01:00
* @param message the message from the client
*/
async function handleSaveRevisionMessage(socket, message) {
const {padId, author: authorId} = sessioninfos[socket.id];
const pad = await padManager.getPad(padId);
await pad.addSavedRevision(pad.head, authorId);
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
*/
2020-11-23 19:24:19 +01:00
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);
}
}
2020-11-23 19:24:19 +01:00
};
/**
* 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
*/
2020-11-23 19:24:19 +01:00
exports.handleCustomMessage = function (padID, msgString) {
const time = Date.now();
const msg = {
type: 'COLLABROOM',
data: {
type: msgString,
2020-11-23 19:24:19 +01:00
time,
},
};
socketio.sockets.in(padID).json.send(msg);
2020-11-23 19:24:19 +01:00
};
2011-07-14 17:15:38 +02:00
/**
* Handles a Chat Message
* @param socket the socket.io Socket object for the client
2011-07-14 17:15:38 +02:00
* @param message the message from the client
*/
async function handleChatMessage(socket, message) {
2020-11-23 19:24:19 +01:00
const time = Date.now();
const text = message.data.text;
const {padId, author: authorId} = sessioninfos[socket.id];
await exports.sendChatMessageToPadClients(time, authorId, 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
*/
2020-11-23 19:24:19 +01:00
exports.sendChatMessageToPadClients = async function (time, userId, text, padId) {
// get the pad
2020-11-23 19:24:19 +01:00
const pad = await padManager.getPad(padId);
// get the author
2020-11-23 19:24:19 +01:00
const userName = await authorManager.getAuthorName(userId);
// save the chat message
const promise = pad.appendChatMessage(text, userId, time);
2020-11-23 19:24:19 +01:00
const 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);
await promise;
2020-11-23 19:24:19 +01:00
};
2011-07-14 17:15:38 +02:00
/**
* Handles the clients request for more chat-messages
* @param socket the socket.io Socket object for the client
* @param message the message from the client
*/
async function handleGetChatMessages(socket, message) {
if (message.data.start == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, GetChatMessages Message has no start!');
return;
}
if (message.data.end == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, GetChatMessages Message has no start!');
return;
}
2020-11-23 19:24:19 +01:00
const start = message.data.start;
const end = message.data.end;
const count = end - start;
if (count < 0 || count > 100) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, GetChatMessages Message, client requested invalid amount of messages!');
return;
}
const padId = sessioninfos[socket.id].padId;
2020-11-23 19:24:19 +01:00
const pad = await padManager.getPad(padId);
2020-11-23 19:24:19 +01:00
const chatMessages = await pad.getChatMessages(start, end);
const infoMsg = {
type: 'COLLABROOM',
data: {
2020-11-23 19:24:19 +01:00
type: 'CHAT_MESSAGES',
messages: chatMessages,
},
};
// send the messages back to the client
socket.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 socket the socket.io Socket object for the client
2011-06-30 15:19:30 +02:00
* @param message the message from the client
*/
function handleSuggestUserName(socket, message) {
// check if all ok
if (message.data.payload.newName == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, suggestUserName Message has no newName!');
return;
2011-06-30 15:19:30 +02:00
}
if (message.data.payload.unnamedId == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, suggestUserName Message has no unnamedId!');
return;
2011-06-30 15:19:30 +02:00
}
const padId = sessioninfos[socket.id].padId;
// search the author and send him this message
_getRoomSockets(padId).forEach((socket) => {
const session = sessioninfos[socket.id];
if (session && session.author === message.data.payload.unnamedId) {
socket.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 socket the socket.io Socket object for the client
2011-03-26 22:29:33 +01:00
* @param message the message from the client
*/
async function handleUserInfoUpdate(socket, message) {
// check if all ok
if (message.data.userInfo == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, USERINFO_UPDATE Message has no userInfo!');
2013-01-30 15:21:25 +01:00
return;
}
if (message.data.userInfo.colorId == null) {
2020-11-23 19:24:19 +01:00
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.
const session = sessioninfos[socket.id];
if (!session || !session.author || !session.padId) {
2020-11-23 19:24:19 +01:00
messageLogger.warn(`Dropped message, USERINFO_UPDATE Session not ready.${message.data}`);
return;
}
// Find out the author name of this session
2020-11-23 19:24:19 +01:00
const author = session.author;
2015-11-26 16:55:26 +01:00
// Check colorId is a Hex color
2020-11-23 19:24:19 +01:00
const isColor = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(message.data.userInfo.colorId); // for #f00 (Thanks Smamatti)
if (!isColor) {
2020-11-23 19:24:19 +01:00
messageLogger.warn(`Dropped message, USERINFO_UPDATE Color is malformed.${message.data}`);
2015-11-26 16:55:26 +01:00
return;
}
// Tell the authorManager about the new attributes
const p = Promise.all([
authorManager.setAuthorColorId(author, message.data.userInfo.colorId),
authorManager.setAuthorName(author, message.data.userInfo.name),
]);
2020-11-23 19:24:19 +01:00
const padId = session.padId;
2020-11-23 19:24:19 +01:00
const infoMsg = {
type: 'COLLABROOM',
data: {
// The Client doesn't know about USERINFO_UPDATE, use USER_NEWINFO
2020-11-23 19:24:19 +01:00
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,
2020-11-23 19:24:19 +01:00
userAgent: 'Anonymous',
ip: '127.0.0.1',
},
},
};
// Send the other clients on the pad the update message
socket.broadcast.to(padId).json.send(infoMsg);
// Block until the authorManager has stored the new attributes.
await p;
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()
*
* @param socket the socket.io Socket object for the client
2011-03-26 22:29:33 +01:00
* @param message the message from the client
*/
async function handleUserChanges(socket, message) {
// This one's no longer pending, as we're gonna process it now
2020-11-23 19:24:19 +01:00
stats.counter('pendingEdits').dec();
// Make sure all required fields are present
if (message.data.baseRev == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, USER_CHANGES Message has no baseRev!');
return;
2011-03-26 14:10:41 +01:00
}
if (message.data.apool == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, USER_CHANGES Message has no apool!');
return;
2011-03-26 14:10:41 +01:00
}
if (message.data.changeset == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, USER_CHANGES Message has no changeset!');
return;
2011-03-26 14:10:41 +01:00
}
// The client might disconnect between our callbacks. We should still
// finish processing the changeset, so keep a reference to the session.
const thisSession = sessioninfos[socket.id];
// 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 (!thisSession) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, disconnect happened in the mean time');
return;
2011-03-26 14:10:41 +01:00
}
// get all Vars we need
2020-11-23 19:24:19 +01:00
const baseRev = message.data.baseRev;
const wireApool = (new AttributePool()).fromJsonable(message.data.apool);
let changeset = message.data.changeset;
// Measure time to process edit
2020-11-23 19:24:19 +01:00
const stopWatch = stats.timer('edits').start();
// get the pad
2020-11-23 19:24:19 +01:00
const 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.
2020-11-23 19:24:19 +01:00
Changeset.eachAttribNumber(changeset, (n) => {
if (!wireApool.getAttrib(n)) {
2020-11-23 19:24:19 +01:00
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
2020-11-23 19:24:19 +01:00
const iterator = Changeset.opIterator(Changeset.unpack(changeset).ops);
let op;
while (iterator.hasNext()) {
2020-11-23 19:24:19 +01:00
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
2020-11-23 19:24:19 +01:00
op.attribs.split('*').forEach((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(`Author ${thisSession.author} tried to submit changes as author ${attr[1]} 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);
2020-11-23 19:24:19 +01:00
} catch (e) {
// There is an error in this changeset, so just refuse it
socket.json.send({disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
throw new Error(`Can't apply USER_CHANGES from Socket ${socket.id} because: ${e.message}`);
}
// ex. applyUserChanges
2020-11-23 19:24:19 +01:00
const 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++;
2020-11-23 19:24:19 +01:00
const 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) {
socket.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);
2020-11-23 19:24:19 +01:00
} catch (e) {
socket.json.send({disconnect: 'badChangeset'});
2014-12-04 16:05:02 +01:00
stats.meter('failedChangesets').mark();
2020-11-23 19:24:19 +01:00
throw new Error(`Can't apply USER_CHANGES, because ${e.message}`);
2014-12-04 16:05:02 +01:00
}
}
2020-11-23 19:24:19 +01:00
const prevText = pad.text();
if (Changeset.oldLen(changeset) !== prevText.length) {
socket.json.send({disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
2020-11-23 19:24:19 +01:00
throw new Error(`Can't apply USER_CHANGES ${changeset} with oldLen ${Changeset.oldLen(changeset)} to document of length ${prevText.length}`);
}
try {
await pad.appendRevision(changeset, thisSession.author);
2020-11-23 19:24:19 +01:00
} catch (e) {
socket.json.send({disconnect: 'badChangeset'});
stats.meter('failedChangesets').mark();
throw e;
2011-07-21 21:13:58 +02:00
}
2020-11-23 19:24:19 +01:00
const correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool);
if (correctionChangeset) {
await pad.appendRevision(correctionChangeset);
}
// Make sure the pad always ends with an empty line.
2020-11-23 19:24:19 +01:00
if (pad.text().lastIndexOf('\n') !== pad.text().length - 1) {
const nlChangeset = Changeset.makeSplice(pad.text(), pad.text().length - 1, 0, '\n');
await pad.appendRevision(nlChangeset);
}
await exports.updatePadClients(pad);
} catch (err) {
console.warn(err.stack || err);
}
stopWatch.end();
2011-07-21 21:13:58 +02:00
}
2020-11-23 19:24:19 +01:00
exports.updatePadClients = async function (pad) {
// skip this if no-one is on this pad
const roomSockets = _getRoomSockets(pad.id);
if (roomSockets.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
2020-11-23 19:24:19 +01:00
const revCache = {};
// go through all sessions on this pad
for (const socket of roomSockets) {
const sid = socket.id;
// send them all new changesets
while (sessioninfos[sid] && sessioninfos[sid].rev < pad.getHeadRevisionNumber()) {
2020-11-23 19:24:19 +01:00
const r = sessioninfos[sid].rev + 1;
let revision = revCache[r];
if (!revision) {
revision = await pad.getRevision(r);
revCache[r] = revision;
}
2020-11-23 19:24:19 +01:00
const author = revision.meta.author;
const revChangeset = revision.changeset;
const currentTime = revision.meta.timestamp;
// next if session has not been deleted
if (sessioninfos[sid] == null) {
continue;
}
if (author === sessioninfos[sid].author) {
socket.json.send({type: 'COLLABROOM', data: {type: 'ACCEPT_COMMIT', newRev: r}});
} else {
2020-11-23 19:24:19 +01:00
const forWire = Changeset.prepareForWire(revChangeset, pad.pool);
const wireMsg = {type: 'COLLABROOM',
data: {type: 'NEW_CHANGES',
newRev: r,
changeset: forWire.translated,
apool: forWire.pool,
author,
currentTime,
timeDelta: currentTime - sessioninfos[sid].time}};
socket.json.send(wireMsg);
}
if (sessioninfos[sid]) {
sessioninfos[sid].time = currentTime;
sessioninfos[sid].rev = r;
}
}
}
2020-11-23 19:24:19 +01:00
};
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) {
2020-11-23 19:24:19 +01:00
const text = atext.text;
2011-03-26 14:10:41 +01:00
// collect char positions of line markers (e.g. bullets) in new atext
// that aren't at the start of a line
2020-11-23 19:24:19 +01:00
const badMarkers = [];
const iter = Changeset.opIterator(atext.attribs);
let offset = 0;
2011-03-26 14:10:41 +01:00
while (iter.hasNext()) {
var op = iter.next();
2020-11-23 19:24:19 +01:00
const hasMarker = _.find(AttributeManager.lineAttributes, (attribute) => Changeset.opAttributeValue(op, attribute, apool)) !== undefined;
if (hasMarker) {
2020-11-23 19:24:19 +01:00
for (let i = 0; i < op.chars; i++) {
if (offset > 0 && text.charAt(offset - 1) !== '\n') {
2011-03-26 14:10:41 +01:00
badMarkers.push(offset);
}
offset++;
}
} else {
2011-03-26 14:10:41 +01:00
offset += op.chars;
}
}
if (badMarkers.length === 0) {
2011-03-26 14:10:41 +01:00
return null;
}
// create changeset that removes these bad markers
offset = 0;
2020-11-23 19:24:19 +01:00
const builder = Changeset.builder(text.length);
2020-11-23 19:24:19 +01:00
badMarkers.forEach((pos) => {
2011-03-26 14:10:41 +01:00
builder.keepText(text.substring(offset, pos));
builder.remove(1);
2020-11-23 19:24:19 +01:00
offset = pos + 1;
2011-03-26 14:10:41 +01:00
});
2011-03-26 14:10:41 +01:00
return builder.toString();
}
async function handleSwitchToPad(socket, message, _authorID) {
const currentSessionInfo = sessioninfos[socket.id];
const padId = currentSessionInfo.padId;
// Check permissions for the new pad.
const newPadIds = await readOnlyManager.getIds(message.padId);
const {session: {user} = {}} = socket.client.request;
const {accessStatus, authorID} = await securityManager.checkAccess(
newPadIds.padId, message.sessionID, message.token, user);
if (accessStatus !== 'grant') {
// Access denied. Send the reason to the user.
socket.json.send({accessStatus});
return;
}
// The same token and session ID were passed to checkAccess in handleMessage, so this second call
// to checkAccess should return the same author ID.
assert(authorID === _authorID);
assert(authorID === currentSessionInfo.author);
// Check if the connection dropped during the access check.
if (sessioninfos[socket.id] !== currentSessionInfo) return;
// clear the session and leave the room
_getRoomSockets(padId).forEach((socket) => {
const sinfo = sessioninfos[socket.id];
if (sinfo && sinfo.author === currentSessionInfo.author) {
// fix user's counter, works on page refresh or if user closes browser window and then rejoins
sessioninfos[socket.id] = {};
socket.leave(padId);
}
2016-04-26 18:55:58 +02:00
});
// start up the new pad
const newSessionInfo = sessioninfos[socket.id];
createSessionInfoAuth(newSessionInfo, message);
await handleClientReady(socket, message, authorID);
}
// Creates/replaces the auth object in the given session info.
function createSessionInfoAuth(sessionInfo, 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.
sessionInfo.auth = {
sessionID: message.sessionID,
padID: message.padId,
token: message.token,
};
}
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 socket the socket.io Socket object for the client
2011-03-26 22:29:33 +01:00
* @param message the message from the client
*/
async function handleClientReady(socket, message, authorID) {
// check if all ok
if (!message.token) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, CLIENT_READY Message has no token!');
return;
2011-03-26 14:10:41 +01:00
}
if (!message.padId) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, CLIENT_READY Message has no padId!');
return;
2011-03-26 14:10:41 +01:00
}
if (!message.protocolVersion) {
2020-11-23 19:24:19 +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) {
2020-11-23 19:24:19 +01:00
messageLogger.warn(`Dropped message, CLIENT_READY Message has a unknown protocolVersion '${message.protocolVersion}'!`);
return;
2011-03-26 14:10:41 +01:00
}
2020-11-23 19:24:19 +01:00
hooks.callAll('clientReady', message);
// Get ro/rw id:s
2020-11-23 19:24:19 +01:00
const padIds = await readOnlyManager.getIds(message.padId);
// get all authordata of this new user
assert(authorID);
2020-11-23 19:24:19 +01:00
const value = await authorManager.getAuthor(authorID);
const authorColorId = value.colorId;
const authorName = value.name;
// load the pad-object from the database
2020-11-23 19:24:19 +01:00
const pad = await padManager.getPad(padIds.padId);
// these db requests all need the pad object (timestamp of latest revision, author data)
2020-11-23 19:24:19 +01:00
const authors = pad.getAllAuthors();
// get timestamp of latest revision needed for timeslider
2020-11-23 19:24:19 +01:00
const currentTime = await pad.getRevisionDate(pad.getHeadRevisionNumber());
// get all author data out of the database (in parallel)
2020-11-23 19:24:19 +01:00
const historicalAuthorData = {};
await Promise.all(authors.map((authorId) => 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)
}
})));
// 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.
const sessionInfo = sessioninfos[socket.id];
if (sessionInfo == null) return;
// Check if this author is already on the pad, if yes, kick the other sessions!
const roomSockets = _getRoomSockets(pad.id);
for (const socket of roomSockets) {
const sinfo = sessioninfos[socket.id];
if (sinfo && sinfo.author === authorID) {
// fix user's counter, works on page refresh or if user closes browser window and then rejoins
sessioninfos[socket.id] = {};
socket.leave(padIds.padId);
socket.json.send({disconnect: 'userdup'});
}
}
// Save in sessioninfos that this session belonges to this pad
sessionInfo.padId = padIds.padId;
sessionInfo.readOnlyPadId = padIds.readOnlyPadId;
sessionInfo.readonly =
padIds.readonly || !webaccess.userCanModify(message.padId, socket.client.request);
const {session: {user} = {}} = socket.client.request;
2020-11-23 19:24:19 +01:00
accessLogger.info(`${`[${pad.head > 0 ? 'ENTER' : 'CREATE'}]` +
` pad:${padIds.padId}` +
` socket:${socket.id}` +
` IP:${settings.disableIPlogging ? 'ANONYMOUS' : socket.request.ip}` +
2020-11-23 19:24:19 +01:00
` authorID:${authorID}`}${
(user && user.username) ? ` username:${user.username}` : ''}`);
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
socket.join(padIds.padId);
// Save the revision in sessioninfos, we take the revision from the info the client send to us
sessionInfo.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
2020-11-23 19:24:19 +01:00
const revisionsNeeded = [];
const changesets = {};
2020-11-23 19:24:19 +01:00
let startNum = message.client_rev + 1;
let endNum = pad.getHeadRevisionNumber() + 1;
2020-11-23 19:24:19 +01:00
const 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)
2020-11-23 19:24:19 +01:00
const promises = [];
for (const revNum of revisionsNeeded) {
const 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
2020-11-23 19:24:19 +01:00
for (const r of revisionsNeeded) {
const forWire = Changeset.prepareForWire(changesets[r].changeset, pad.pool);
const 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}};
socket.json.send(wireMsg);
}
2018-02-10 18:00:22 +01:00
if (startNum === endNum) {
2020-11-23 19:24:19 +01:00
const Msg = {type: 'COLLABROOM',
data: {type: 'CLIENT_RECONNECT',
noChanges: true,
newRev: pad.getHeadRevisionNumber()}};
socket.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);
2020-11-23 19:24:19 +01:00
const attribsForWire = Changeset.prepareForWire(atext.attribs, pad.pool);
var apool = attribsForWire.pool.toJsonable();
atext.attribs = attribsForWire.translated;
2020-11-23 19:24:19 +01:00
} catch (e) {
console.error(e.stack || e);
socket.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...
2020-11-23 19:24:19 +01:00
const clientVars = {
skinName: settings.skinName,
skinVariants: settings.skinVariants,
randomVersionString: settings.randomVersionString,
accountPrivs: {
maxRevisions: 100,
},
2020-11-23 19:24:19 +01:00
automaticReconnectionTimeout: settings.automaticReconnectionTimeout,
initialRevisionList: [],
initialOptions: {
guestPolicy: 'deny',
},
2020-11-23 19:24:19 +01:00
savedRevisions: pad.getSavedRevisions(),
collab_client_vars: {
initialAttributedText: atext,
clientIp: '127.0.0.1',
padId: message.padId,
historicalAuthorData,
apool,
rev: pad.getHeadRevisionNumber(),
time: currentTime,
},
2020-11-23 19:24:19 +01:00
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)
2020-11-23 19:24:19 +01:00
chatHead: pad.chatHead,
numConnectedUsers: roomSockets.length,
readOnlyId: padIds.readOnlyPadId,
readonly: sessionInfo.readonly,
serverTimestamp: Date.now(),
userId: authorID,
abiwordAvailable: settings.abiwordAvailable(),
sofficeAvailable: settings.sofficeAvailable(),
exportAvailable: settings.exportAvailable(),
plugins: {
plugins: plugins.plugins,
parts: plugins.parts,
},
2020-11-23 19:24:19 +01:00
indentationOnNewLine: settings.indentationOnNewLine,
scrollWhenFocusLineIsOutOfViewport: {
percentage: {
editionAboveViewport: settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionAboveViewport,
editionBelowViewport: settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionBelowViewport,
},
2020-11-23 19:24:19 +01:00
duration: settings.scrollWhenFocusLineIsOutOfViewport.duration,
scrollWhenCaretIsInTheLastLineOfViewport: settings.scrollWhenFocusLineIsOutOfViewport.scrollWhenCaretIsInTheLastLineOfViewport,
percentageToScrollWhenUserPressesArrowUp: settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp,
},
2020-11-23 19:24:19 +01:00
initialChangesets: [], // FIXME: REMOVE THIS SHIT
};
// 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
const messages = await hooks.aCallAll('clientVars', {clientVars, pad, socket});
// combine our old object with the new attributes from the hook
2020-11-23 19:24:19 +01:00
for (const msg of messages) {
Object.assign(clientVars, msg);
}
// Join the pad and start receiving updates
socket.join(padIds.padId);
// Send the clientVars to the Client
socket.json.send({type: 'CLIENT_VARS', data: clientVars});
// Save the current revision in sessioninfos, should be the same as in clientVars
sessionInfo.rev = pad.getHeadRevisionNumber();
// prepare the notification for the other users on the pad, that this user joined
2020-11-23 19:24:19 +01:00
const messageToTheOtherUsers = {
type: 'COLLABROOM',
data: {
type: 'USER_NEWINFO',
userInfo: {
2020-11-23 19:24:19 +01:00
ip: '127.0.0.1',
colorId: authorColorId,
userAgent: 'Anonymous',
userId: authorID,
},
},
};
// Add the authorname of this new User, if avaiable
if (authorName != null) {
messageToTheOtherUsers.data.userInfo.name = authorName;
}
// notify all existing users about new user
socket.broadcast.to(padIds.padId).json.send(messageToTheOtherUsers);
// Get sessions for this pad and update them (in parallel)
await Promise.all(_getRoomSockets(pad.id).map(async (roomSocket) => {
// Jump over, if this session is the connection session
if (roomSocket.id === socket.id) {
return;
}
// Since sessioninfos might change while being enumerated, check if the
// sessionID is still assigned to a valid session
const sessionInfo = sessioninfos[roomSocket.id];
if (sessionInfo == null) return;
// get the authorname & colorId
2020-11-23 19:24:19 +01:00
const author = sessionInfo.author;
const cached = historicalAuthorData[author];
// reuse previously created cache of author's data
let authorInfo = cached ? cached : (await authorManager.getAuthor(author));
// default fallback color to use if authorInfo.colorId is null
2020-11-23 19:24:19 +01:00
const defaultColor = '#daf0b2';
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
if (!authorInfo) {
2020-11-23 19:24:19 +01: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');
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
// For some reason sometimes name isn't set
// Catch this issue here and use a fixed name.
if (!authorInfo.name) {
2020-11-23 19:24:19 +01:00
console.warn('handleClientReady(): client submitted no author name. Using "Anonymous". See: issue #3612');
authorInfo.name = 'Anonymous';
}
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
// 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;
}
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
// Send the new User a Notification about this other user
2020-11-23 19:24:19 +01:00
const msg = {
type: 'COLLABROOM',
data: {
type: 'USER_NEWINFO',
userInfo: {
2020-11-23 19:24:19 +01:00
ip: '127.0.0.1',
colorId: authorInfo.colorId,
name: authorInfo.name,
userAgent: 'Anonymous',
userId: author,
},
},
};
socket.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(socket, message) {
// check if all ok
if (message.data == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, changeset request has no data!');
return;
}
if (message.padId == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, changeset request has no padId!');
return;
}
if (message.data.granularity == null) {
2020-11-23 19:24:19 +01:00
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) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, changeset request granularity is not an integer!');
return;
}
if (message.data.start == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, changeset request has no start!');
return;
}
if (message.data.requestID == null) {
2020-11-23 19:24:19 +01:00
messageLogger.warn('Dropped message, changeset request has no requestID!');
return;
}
2020-11-23 19:24:19 +01:00
const granularity = message.data.granularity;
const start = message.data.start;
const end = start + (100 * granularity);
2012-07-05 19:33:20 +02:00
2020-11-23 19:24:19 +01:00
const padIds = await readOnlyManager.getIds(message.padId);
2012-07-05 19:33:20 +02:00
// build the requested rough changesets and send them back
try {
2020-11-23 19:24:19 +01:00
const data = await getChangesetInfo(padIds.padId, start, end, granularity);
data.requestID = message.data.requestID;
socket.json.send({type: 'CHANGESET_REQ', data});
} catch (err) {
2020-11-23 19:24:19 +01:00
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) {
2020-11-23 19:24:19 +01:00
const pad = await padManager.getPad(padId);
const head_revision = pad.getHeadRevisionNumber();
// calculate the last full endnum
if (endNum > head_revision + 1) {
endNum = head_revision + 1;
}
endNum = Math.floor(endNum / granularity) * granularity;
2020-11-23 19:24:19 +01:00
const compositesChangesetNeeded = [];
const revTimesNeeded = [];
// figure out which composite Changeset and revTimes we need, to load them in bulk
for (let start = startNum; start < endNum; start += granularity) {
2020-11-23 19:24:19 +01:00
const end = start + granularity;
// add the composite Changeset we needed
2020-11-23 19:24:19 +01:00
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
2020-11-23 19:24:19 +01:00
const composedChangesets = {};
const p1 = Promise.all(compositesChangesetNeeded.map((item) => composePadChangesets(padId, item.start, item.end).then((changeset) => {
composedChangesets[`${item.start}/${item.end}`] = changeset;
})));
// get all needed revision Dates
2020-11-23 19:24:19 +01:00
const revisionDate = [];
const p2 = Promise.all(revTimesNeeded.map((revNum) => pad.getRevisionDate(revNum).then((revDate) => {
revisionDate[revNum] = Math.floor(revDate / 1000);
})));
// get the lines
let lines;
2020-11-23 19:24:19 +01:00
const 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 :/
2020-11-23 19:24:19 +01:00
const timeDeltas = [];
const forwardsChangesets = [];
const backwardsChangesets = [];
const apool = new AttributePool();
for (let compositeStart = startNum; compositeStart < endNum; compositeStart += granularity) {
2020-11-23 19:24:19 +01:00
const compositeEnd = compositeStart + granularity;
if (compositeEnd > endNum || compositeEnd > head_revision + 1) {
break;
}
2020-11-23 19:24:19 +01:00
const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`];
const backwards = Changeset.inverse(forwards, lines.textlines, lines.alines, pad.apool());
Changeset.mutateAttributionLines(forwards, lines.alines, pad.apool());
Changeset.mutateTextLines(forwards, lines.textlines);
2020-11-23 19:24:19 +01:00
const forwards2 = Changeset.moveOpsToNewPool(forwards, pad.apool(), apool);
const backwards2 = Changeset.moveOpsToNewPool(backwards, pad.apool(), apool);
2020-11-23 19:24:19 +01:00
const t1 = (compositeStart === 0) ? revisionDate[0] : revisionDate[compositeStart - 1];
const t2 = revisionDate[compositeEnd - 1];
timeDeltas.push(t2 - t1);
forwardsChangesets.push(forwards2);
backwardsChangesets.push(backwards2);
}
2020-11-23 19:24:19 +01:00
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) {
2020-11-23 19:24:19 +01:00
const pad = await padManager.getPad(padId);
// get the atext
let atext;
if (revNum >= 0) {
atext = await pad.getInternalRevisionAText(revNum);
} else {
2020-11-23 19:24:19 +01:00
atext = Changeset.makeAText('\n');
}
return {
textlines: Changeset.splitTextLines(atext.text),
2020-11-23 19:24:19 +01:00
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
*/
2020-11-23 19:24:19 +01:00
async function composePadChangesets(padId, startNum, endNum) {
const pad = await padManager.getPad(padId);
// fetch all changesets we need
2020-11-23 19:24:19 +01:00
const 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
2020-11-23 19:24:19 +01:00
const changesetsNeeded = [];
for (let r = startNum; r < endNum; r++) {
changesetsNeeded.push(r);
}
// get all changesets
2020-11-23 19:24:19 +01:00
const changesets = {};
await Promise.all(changesetsNeeded.map((revNum) => pad.getRevisionChangeset(revNum).then((changeset) => changesets[revNum] = changeset)));
// compose Changesets
let r;
try {
let changeset = changesets[startNum];
2020-11-23 19:24:19 +01:00
const pool = pad.apool();
for (r = startNum + 1; r < endNum; r++) {
2020-11-23 19:24:19 +01:00
const 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
2020-11-23 19:24:19 +01:00
console.warn('failed to compose cs in pad:', padId, ' startrev:', startNum, ' current rev:', r);
throw e;
}
}
function _getRoomSockets(padID) {
const roomSockets = [];
2020-11-23 19:24:19 +01:00
const room = socketio.sockets.adapter.rooms[padID];
2014-11-23 23:33:56 +01:00
if (room) {
2020-11-23 19:24:19 +01:00
for (const id in room.sockets) {
roomSockets.push(socketio.sockets.sockets[id]);
2014-11-23 23:33:56 +01:00
}
}
return roomSockets;
2016-04-26 18:55:58 +02:00
}
2014-11-23 23:33:56 +01:00
2016-04-26 18:55:58 +02:00
/**
* Get the number of users in a pad
*/
2020-11-23 19:24:19 +01:00
exports.padUsersCount = function (padID) {
return {
2020-11-23 19:24:19 +01:00
padUsersCount: _getRoomSockets(padID).length,
};
};
/**
* Get the list of users in a pad
*/
2020-11-23 19:24:19 +01:00
exports.padUsers = async function (padID) {
const padUsers = [];
2014-11-23 23:33:56 +01:00
// iterate over all clients (in parallel)
await Promise.all(_getRoomSockets(padID).map(async (roomSocket) => {
const s = sessioninfos[roomSocket.id];
if (s) {
2020-11-23 19:24:19 +01:00
return authorManager.getAuthor(s.author).then((author) => {
// Fixes: https://github.com/ether/etherpad-lite/issues/4120
// On restart author might not be populated?
2020-11-23 19:24:19 +01:00
if (author) {
author.id = s.author;
padUsers.push(author);
}
});
}
}));
2020-11-23 19:24:19 +01:00
return {padUsers};
};
exports.sessioninfos = sessioninfos;