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

1451 lines
49 KiB
JavaScript
Raw Normal View History

'use strict';
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.
*/
2020-11-23 19:24:19 +01:00
const padManager = require('../db/PadManager');
const Changeset = require('../../static/js/Changeset');
const AttributePool = require('../../static/js/AttributePool');
const AttributeManager = require('../../static/js/AttributeManager');
2020-11-23 19:24:19 +01:00
const authorManager = require('../db/AuthorManager');
const readOnlyManager = require('../db/ReadOnlyManager');
const settings = require('../utils/Settings');
const securityManager = require('../db/SecurityManager');
const plugins = require('../../static/js/pluginfw/plugin_defs.js');
2020-11-23 19:24:19 +01:00
const log4js = require('log4js');
const messageLogger = log4js.getLogger('message');
const accessLogger = log4js.getLogger('access');
const _ = require('underscore');
const hooks = require('../../static/js/pluginfw/hooks.js');
2020-11-23 19:24:19 +01:00
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');
let rateLimiter;
exports.socketio = () => {
// The rate limiter is created in this hook so that restarting the server resets the limiter. The
// settings.commitRateLimiting object is passed directly to the rate limiter so that the limits
// can be dynamically changed during runtime by modifying its properties.
rateLimiter = new RateLimiterMemory(settings.commitRateLimiting);
};
2011-03-26 14:10:41 +01:00
2011-03-26 22:29:33 +01:00
/**
* Contains information about socket.io connections:
* - key: Socket.io socket ID.
* - value: Object that is initially empty immediately after connect. Once the client's
* CLIENT_READY message is processed, it has the following properties:
* - auth: Object with the following properties copied from the client's CLIENT_READY message:
* - padID: Pad ID requested by the user. Unlike the padId property described below, this
* may be a read-only pad ID.
* - sessionID: Copied from the client's sessionID cookie, which should be the value
* returned from the createSession() HTTP API. This will be null/undefined if
* createSession() isn't used or the portal doesn't set the sessionID cookie.
* - token: User-supplied token.
* - author: The user's author ID.
* - padId: The real (not read-only) ID of the pad.
* - readonlyPadId: The read-only ID of the pad.
* - readonly: Whether the client has read-only access (true) or read/write access (false).
* - rev: The last revision that was sent to the client.
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
2020-11-23 19:24:19 +01:00
stats.gauge('totalUsers', () => Object.keys(socketio.sockets.sockets).length);
stats.gauge('activePads', () => {
const padIds = new Set();
for (const {padId} of Object.values(sessioninfos)) {
if (!padId) continue;
padIds.add(padId);
}
return padIds.size;
});
2013-10-27 17:42:55 +01:00
/**
* A changeset queue per pad that is processed by handleUserChanges()
*/
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
*/
exports.setSocketIO = (socket_io) => {
2020-11-23 19:24:19 +01:00
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();
2021-02-03 00:30:07 +01:00
// Initialize 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
*/
exports.kickSessionsFromPad = (padID) => {
2020-11-23 19:24:19 +01:00
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
colorId: color,
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) {
const ip = settings.disableIPlogging ? 'ANONYMOUS' : (socket.request.ip || '<unknown>');
const msg = JSON.stringify(message, null, 2);
messageLogger.error(`Dropping pre-CLIENT_READY message from IP ${ip}: ${msg}`);
messageLogger.debug(
'If you are using the stress-test tool then restart Etherpad and the Stress test tool.');
return;
}
const {session: {user} = {}} = socket.client.request;
const {accessStatus, authorID} =
await securityManager.checkAccess(auth.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
*/
const handleSaveRevisionMessage = async (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
*/
exports.handleCustomObjectMessage = (msg, sessionID) => {
2020-11-23 19:24:19 +01:00
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
*/
exports.handleCustomMessage = (padID, msgString) => {
2020-11-23 19:24:19 +01:00
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
*/
const handleChatMessage = async (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
*/
exports.sendChatMessageToPadClients = async (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
*/
const handleGetChatMessages = async (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) {
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
*/
const 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
*/
const handleUserInfoUpdate = async (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
// for #f00 (Thanks Smamatti)
const isColor = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(message.data.userInfo.colorId);
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
},
},
};
// 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
*/
const handleUserChanges = async (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, as 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();
2021-02-21 20:53:45 +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
exports.updatePadClients = async (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
*/
const _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()) {
const op = iter.next();
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();
};
2011-03-26 14:10:41 +01:00
const handleSwitchToPad = async (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.
const 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
*/
const handleClientReady = async (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) {
2021-02-21 20:53:45 +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');
2020-11-23 19:24:19 +01:00
} else {
// Filter author attribs (e.g. don't send author's pads to all clients)
historicalAuthorData[authorId] = {name: author.name, colorId: author.colorId};
2020-11-23 19:24:19 +01:00
}
})));
// 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
let atext;
let apool;
// prepare all values for the wire, there's a chance that this throws, if the pad is corrupted
try {
atext = Changeset.cloneAText(pad.atext);
2020-11-23 19:24:19 +01:00
const attribsForWire = Changeset.prepareForWire(atext.attribs, pad.pool);
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: {},
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',
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: {
2021-02-21 20:53:45 +01:00
editionAboveViewport:
settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionAboveViewport,
editionBelowViewport:
settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionBelowViewport,
},
2020-11-23 19:24:19 +01:00
duration: settings.scrollWhenFocusLineIsOutOfViewport.duration,
2021-02-21 20:53:45 +01:00
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
colorId: authorColorId,
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
const authorId = sessionInfo.author;
// The authorId of this other user might be unknown if the other user just connected and has
// not yet sent a CLIENT_READY message.
if (authorId == null) return;
// reuse previously created cache of author's data
const authorInfo = historicalAuthorData[authorId] || await authorManager.getAuthor(authorId);
if (authorInfo == null) {
messageLogger.error(
`Author ${authorId} connected via socket.io session ${roomSocket.id} is missing from ` +
'the global author database. This should never happen because the author ID is ' +
'generated by the same code that adds the author to the database.');
// Don't bother telling the new user about this mystery author.
return;
}
// 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
colorId: authorInfo.colorId,
name: authorInfo.name,
userId: authorId,
2020-11-23 19:24:19 +01:00
},
},
};
socket.json.send(msg);
}));
}
};
/**
* Handles a request for a rough changeset, the timeslider client needs it
*/
const handleChangesetRequest = async (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) {
console.error(`Error while handling a changeset request for ${padIds.padId}`,
2021-01-29 07:14:03 +01:00
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
*/
const getChangesetInfo = async (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
*/
const getPadLines = async (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
*/
const composePadChangesets = async (padId, startNum, endNum) => {
2020-11-23 19:24:19 +01:00
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(
2021-02-21 20:53:45 +01:00
(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;
}
};
const _getRoomSockets = (padID) => {
const ns = socketio.sockets; // Default namespace.
const adapter = ns.adapter;
// We could call adapter.clients(), but that method is unnecessarily asynchronous. Replicate what
// it does here, but synchronously to avoid a race condition. This code will have to change when
// we update to socket.io v3.
const room = adapter.rooms[padID];
if (!room) return [];
return Object.keys(room.sockets).map((id) => ns.connected[id]).filter((s) => s);
};
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 = (padID) => ({
padUsersCount: _getRoomSockets(padID).length,
});
/**
* Get the list of users in a pad
*/
exports.padUsers = async (padID) => {
2020-11-23 19:24:19 +01:00
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) {
const author = await authorManager.getAuthor(s.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);
}
}
}));
2020-11-23 19:24:19 +01:00
return {padUsers};
};
exports.sessioninfos = sessioninfos;