mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-18 00:57:55 +00:00
Two issues raised on the first push: 1. **Rev advanced before send (Bug · Correctness).** The previous diff advanced sessioninfo.rev/time inside the collect loop, before any emit ran. A concurrent updatePadClients() could then see the bumped rev and skip those revisions, and if the emit threw later, the skipped revs were lost forever. The client enforces strict newRev===rev+1 and silently stops applying on mismatch — net effect was a possible pad desync under concurrent fan-outs. Fix: snapshot startRev/startTime once, claim the (startRev, headRev] range by setting sessioninfo.rev = headRev immediately (so a concurrent run skips it), build the pending list against the local startTime, then emit. If the emit throws, roll sessioninfo.rev back to startRev so the next fan-out retries. Time is only committed after a successful send. 2. **Test re-implemented the decision (Rule violation · Reliability).** The original test re-implemented the NEW_CHANGES vs NEW_CHANGES_BATCH switch locally instead of exercising the production code. Removing the production logic would have left the test green. Fix: extract the pure wire-format decision into src/node/handler/NewChangesPacker.ts (no DB / pad dependency, so the test can import it directly under vitest), and rewrite the test to assert against the exported `buildNewChangesEmits` function from that module. PadMessageHandler now calls the same function; deleting it would fail the test. 9/9 tests across new-changes-batch + prom-instruments. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1661 lines
67 KiB
TypeScript
1661 lines
67 KiB
TypeScript
'use strict';
|
|
/**
|
|
* The MessageHandler handles all Messages that comes from Socket.IO and controls the sessions
|
|
*/
|
|
|
|
/*
|
|
* Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd)
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
import {MapArrayType} from "../types/MapType";
|
|
|
|
import AttributeMap from '../../static/js/AttributeMap';
|
|
const padManager = require('../db/PadManager');
|
|
const padDeletionManager = require('../db/PadDeletionManager');
|
|
import {checkRep, cloneAText, compose, deserializeOps, follow, identity, inverse, makeAText, makeSplice, moveOpsToNewPool, mutateAttributionLines, mutateTextLines, oldLen, prepareForWire, splitAttributionLines, splitTextLines, unpack} from '../../static/js/Changeset';
|
|
import ChatMessage from '../../static/js/ChatMessage';
|
|
import AttributePool from '../../static/js/AttributePool';
|
|
const AttributeManager = require('../../static/js/AttributeManager');
|
|
const authorManager = require('../db/AuthorManager');
|
|
import padutils from '../../static/js/pad_utils';
|
|
import readOnlyManager from '../db/ReadOnlyManager';
|
|
import settings, {
|
|
exportAvailable,
|
|
getPublicPrivacyBanner,
|
|
sofficeAvailable
|
|
} from '../utils/Settings';
|
|
import {anonymizeIp} from '../utils/anonymizeIp';
|
|
import {isAcceptingConnections} from '../updater/SessionDrainer';
|
|
const logIp = (ip: string | null | undefined) => anonymizeIp(ip, settings.ipLogging);
|
|
const securityManager = require('../db/SecurityManager');
|
|
const plugins = require('../../static/js/pluginfw/plugin_defs');
|
|
import log4js from 'log4js';
|
|
const messageLogger = log4js.getLogger('message');
|
|
const accessLogger = log4js.getLogger('access');
|
|
const hooks = require('../../static/js/pluginfw/hooks');
|
|
const stats = require('../stats')
|
|
const assert = require('assert').strict;
|
|
import {recordChangesetApply, recordSocketEmit} from '../prom-instruments';
|
|
import {buildNewChangesEmits, type NewChangesItem} from './NewChangesPacker';
|
|
import {RateLimiterMemory} from 'rate-limiter-flexible';
|
|
import {ChangesetRequest, PadUserInfo, SocketClientRequest} from "../types/SocketClientRequest";
|
|
import {APool, AText, PadAuthor, PadType} from "../types/PadType";
|
|
import {ChangeSet} from "../types/ChangeSet";
|
|
import {ChatMessageMessage, ClientReadyMessage, ClientSaveRevisionMessage, ClientSuggestUserName, ClientUserChangesMessage, ClientVarMessage, CustomMessage, PadDeleteMessage, PadOptionsMessage, UserNewInfoMessage} from "../../static/js/types/SocketIOMessage";
|
|
import {Builder} from "../../static/js/Builder";
|
|
const webaccess = require('../hooks/express/webaccess');
|
|
const { checkValidRev } = require('../utils/checkValidRev');
|
|
|
|
let rateLimiter:any;
|
|
let socketio: any = null;
|
|
|
|
hooks.deprecationNotices.clientReady = 'use the userJoin hook instead';
|
|
|
|
const addContextToError = (err:any, pfx:string) => {
|
|
const newErr = new Error(`${pfx}${err.message}`, {cause: err});
|
|
if (Error.captureStackTrace) Error.captureStackTrace(newErr, addContextToError);
|
|
// Check for https://github.com/tc39/proposal-error-cause support, available in Node.js >= v16.10.
|
|
if (newErr.cause === err) return newErr;
|
|
err.message = `${pfx}${err.message}`;
|
|
return err;
|
|
};
|
|
|
|
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);
|
|
};
|
|
|
|
/**
|
|
* 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: The value returned from the createSession() HTTP API, normally set as
|
|
* the `sessionID` cookie by the integrator. Read from the socket.io handshake's
|
|
* Cookie header (so the cookie can be HttpOnly — issue #7045) and falls back to a
|
|
* deprecated `sessionID` field on the CLIENT_READY message for legacy clients.
|
|
* This will be null/undefined if createSession() isn't used or the integrator
|
|
* 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.
|
|
*/
|
|
const sessioninfos:MapArrayType<any> = {};
|
|
exports.sessioninfos = sessioninfos;
|
|
|
|
function getTotalActiveUsers() {
|
|
return socketio ? socketio.engine.clientsCount : 0;
|
|
}
|
|
|
|
exports.getTotalActiveUsers = getTotalActiveUsers;
|
|
|
|
function getActivePadCountFromSessionInfos() {
|
|
const padIds = new Set();
|
|
for (const {padId} of Object.values(sessioninfos)) {
|
|
if (!padId) continue;
|
|
padIds.add(padId);
|
|
}
|
|
return padIds.size;
|
|
}
|
|
exports.getActivePadCountFromSessionInfos = getActivePadCountFromSessionInfos;
|
|
|
|
// Per-pad user counts derived on demand from sessioninfos. Used by
|
|
// prometheus.ts to populate `etherpad_pad_users{padId}` so the #7756
|
|
// scaling-dive harness can confirm the pad it's pointing at actually
|
|
// has the expected concurrency.
|
|
function getPadUsersMap(): Map<string, number> {
|
|
const out = new Map<string, number>();
|
|
for (const {padId} of Object.values(sessioninfos)) {
|
|
if (!padId) continue;
|
|
out.set(padId, (out.get(padId) ?? 0) + 1);
|
|
}
|
|
return out;
|
|
}
|
|
exports.getPadUsersMap = getPadUsersMap;
|
|
|
|
/**
|
|
* Build a sanitized copy of the plugins registry suitable for sending to the
|
|
* client as part of clientVars. The shape is preserved but each plugin's
|
|
* `package` field is reduced to `{name, version}` so internal paths (realPath,
|
|
* path, location) are not leaked to the browser.
|
|
*
|
|
* CRITICAL: this function MUST NOT mutate the shared server-side registry.
|
|
* Other components — notably `src/node/utils/Minify.ts` — read
|
|
* `plugins.plugins[x].package.realPath` on every static asset request to
|
|
* resolve `/static/plugins/ep_<name>/...` URLs to disk. Mutating the shared object
|
|
* in place would clobber `realPath` and cause every such request to 500 with
|
|
* `ERR_INVALID_ARG_TYPE: The "path" argument must be of type string`.
|
|
*/
|
|
const sanitizePluginsForWire = (
|
|
pluginsRegistry: MapArrayType<any>,
|
|
): MapArrayType<any> => {
|
|
const out: MapArrayType<any> = {};
|
|
for (const [name, plugin] of Object.entries(pluginsRegistry)) {
|
|
const p: any = plugin.package;
|
|
out[name] = {
|
|
...plugin,
|
|
package: {name: p.name, version: p.version},
|
|
};
|
|
}
|
|
return out;
|
|
};
|
|
exports.sanitizePluginsForWire = sanitizePluginsForWire;
|
|
|
|
stats.gauge('totalUsers', () => getTotalActiveUsers());
|
|
stats.gauge('activePads', () => {
|
|
return getActivePadCountFromSessionInfos();
|
|
});
|
|
|
|
/**
|
|
* Processes one task at a time per channel.
|
|
*/
|
|
class Channels {
|
|
private readonly _exec: (ch:any, task:any) => any;
|
|
private _promiseChains: Map<any, Promise<any>>;
|
|
/**
|
|
* @param {(ch, task) => any} [exec] - Task executor. If omitted, tasks are assumed to be
|
|
* functions that will be executed with the channel as the only argument.
|
|
*/
|
|
constructor(exec = (ch: string, task:any) => task(ch)) {
|
|
this._exec = exec;
|
|
this._promiseChains = new Map();
|
|
}
|
|
|
|
/**
|
|
* Schedules a task for execution. The task will be executed once all previously enqueued tasks
|
|
* for the named channel have completed.
|
|
*
|
|
* @param {any} ch - Identifies the channel.
|
|
* @param {any} task - The task to give to the executor.
|
|
* @returns {Promise<any>} The value returned by the executor.
|
|
*/
|
|
async enqueue(ch:any, task:any): Promise<any> {
|
|
const p = (this._promiseChains.get(ch) || Promise.resolve()).then(() => this._exec(ch, task));
|
|
const pc = p
|
|
.catch(() => {}) // Prevent rejections from halting the queue.
|
|
.then(() => {
|
|
// Clean up this._promiseChains if there are no more tasks for the channel.
|
|
if (this._promiseChains.get(ch) === pc) this._promiseChains.delete(ch);
|
|
});
|
|
this._promiseChains.set(ch, pc);
|
|
return await p;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A changeset queue per pad that is processed by handleUserChanges()
|
|
*/
|
|
const padChannels = new Channels((ch, {socket, message}) => handleUserChanges(socket, message));
|
|
|
|
/**
|
|
* This Method is called by server.ts to tell the message handler on which socket it should send
|
|
* @param socket_io The Socket
|
|
*/
|
|
exports.setSocketIO = (socket_io:any) => {
|
|
socketio = socket_io;
|
|
};
|
|
|
|
/**
|
|
* Handles the connection of a new user
|
|
* @param socket the socket.io Socket object for the new connection from the client
|
|
*/
|
|
exports.handleConnect = (socket:any) => {
|
|
stats.meter('connects').mark();
|
|
|
|
// Initialize sessioninfos for this new session
|
|
sessioninfos[socket.id] = {};
|
|
};
|
|
|
|
/**
|
|
* Kicks all sessions from a pad
|
|
*/
|
|
exports.kickSessionsFromPad = (padID: string) => {
|
|
|
|
if(socketio.sockets == null) return;
|
|
|
|
// skip if there is nobody on this pad
|
|
if (_getRoomSockets(padID).length === 0) return;
|
|
|
|
// disconnect everyone from this pad
|
|
socketio.in(padID).emit('message', {disconnect: 'deleted'});
|
|
};
|
|
|
|
/**
|
|
* Handles the disconnection of a user
|
|
* @param socket the socket.io Socket object for the client
|
|
*/
|
|
exports.handleDisconnect = async (socket:any) => {
|
|
stats.meter('disconnects').mark();
|
|
const session = sessioninfos[socket.id];
|
|
delete sessioninfos[socket.id];
|
|
// session.padId can be nullish if the user disconnects before sending CLIENT_READY.
|
|
if (!session || !session.author || !session.padId) return;
|
|
const {session: {user} = {}} = socket.client.request as SocketClientRequest;
|
|
/* eslint-disable prefer-template -- it doesn't support breaking across multiple lines */
|
|
accessLogger.info('[LEAVE]' +
|
|
` pad:${session.padId}` +
|
|
` socket:${socket.id}` +
|
|
` IP:${logIp(socket.request.ip)}` +
|
|
` authorID:${session.author}` +
|
|
(user && user.username ? ` username:${user.username}` : ''));
|
|
/* eslint-enable prefer-template */
|
|
// Client presence is keyed by authorID. With the #7656 fix, multiple sockets
|
|
// can share an authorID (same authenticated identity across windows/devices),
|
|
// so emitting USER_LEAVE on every socket disconnect would drop the author
|
|
// from presence even when another socket of theirs is still connected. Only
|
|
// broadcast — and only run the userLeave hook — when the *last* socket for
|
|
// this author leaves the pad.
|
|
const isLastSocketForAuthor = !_getRoomSockets(session.padId).some(
|
|
(s: any) => sessioninfos[s.id]?.author === session.author);
|
|
if (isLastSocketForAuthor) {
|
|
socket.broadcast.to(session.padId).emit('message', {
|
|
type: 'COLLABROOM',
|
|
data: {
|
|
type: 'USER_LEAVE',
|
|
userInfo: {
|
|
colorId: await authorManager.getAuthorColorId(session.author),
|
|
userId: session.author,
|
|
},
|
|
},
|
|
});
|
|
await hooks.aCallAll('userLeave', {
|
|
...session, // For backwards compatibility.
|
|
authorId: session.author,
|
|
readOnly: session.readonly,
|
|
socket,
|
|
});
|
|
}
|
|
};
|
|
|
|
|
|
const handlePadDelete = async (socket: any, padDeleteMessage: PadDeleteMessage) => {
|
|
const session = sessioninfos[socket.id];
|
|
if (!session || !session.author || !session.padId) throw new Error('session not ready');
|
|
const padId = padDeleteMessage.data.padId;
|
|
if (session.padId !== padId) throw new Error('refusing cross-pad delete');
|
|
if (!await padManager.doesPadExist(padId)) return;
|
|
|
|
const retrievedPad = await padManager.getPad(padId);
|
|
const firstContributor = await retrievedPad.getRevisionAuthor(0);
|
|
const isCreator = session.author === firstContributor;
|
|
const suppliedToken = padDeleteMessage.data.deletionToken;
|
|
const tokenSupplied = typeof suppliedToken === 'string' && suppliedToken !== '';
|
|
const tokenOk = tokenSupplied &&
|
|
await padDeletionManager.isValidDeletionToken(padId, suppliedToken);
|
|
// When a token is supplied it must validate. We deliberately do NOT fall
|
|
// back to the creator-cookie path, otherwise a creator pasting a wrong
|
|
// recovery token into the disclosure field would still succeed — masking a
|
|
// typo and contradicting the UI.
|
|
const creatorOk = !tokenSupplied && isCreator;
|
|
const flagOk = !tokenSupplied && !isCreator && settings.allowPadDeletionByAllUsers;
|
|
|
|
if (creatorOk || tokenOk || flagOk) {
|
|
await retrievedPad.remove();
|
|
return;
|
|
}
|
|
|
|
// tokenSupplied-but-invalid is a different user-facing message from
|
|
// not-the-creator. The client localizes via the l10n key.
|
|
const messageKey = tokenSupplied
|
|
? 'pad.deletionToken.invalid'
|
|
: 'pad.deletionToken.notCreator';
|
|
socket.emit('shout', {
|
|
type: 'COLLABROOM',
|
|
data: {
|
|
type: 'shoutMessage',
|
|
payload: {
|
|
message: {
|
|
messageKey,
|
|
sticky: false,
|
|
},
|
|
timestamp: Date.now(),
|
|
},
|
|
},
|
|
});
|
|
};
|
|
|
|
const isPadCreator = async (pad: any, authorId: string) => authorId === await pad.getRevisionAuthor(0);
|
|
|
|
const handlePadOptionsMessage = async (
|
|
socket: any, message: PadOptionsMessage & {data: {payload: PadOptionsMessage}}) => {
|
|
const session = sessioninfos[socket.id];
|
|
if (!session || !session.author || !session.padId) throw new Error('session not ready');
|
|
if (!settings.enablePadWideSettings) return;
|
|
if (!await padManager.doesPadExist(session.padId)) {
|
|
messageLogger.warn(`Ignoring padoptions for missing pad ${session.padId}`);
|
|
return;
|
|
}
|
|
const pad = await padManager.getPad(session.padId, null, session.author);
|
|
if (!await isPadCreator(pad, session.author)) {
|
|
socket.emit('shout', {
|
|
type: 'COLLABROOM',
|
|
data: {
|
|
type: 'shoutMessage',
|
|
payload: {
|
|
message: {
|
|
message: 'Only the pad creator can change pad settings',
|
|
sticky: false,
|
|
},
|
|
timestamp: Date.now(),
|
|
},
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
pad.setPadSettings(message.data.payload.options);
|
|
await pad.saveToDatabase();
|
|
_getRoomSockets(session.padId).forEach((socket) => {
|
|
socket.emit('message', message);
|
|
});
|
|
};
|
|
|
|
|
|
/**
|
|
* Handles a message from a user
|
|
* @param socket the socket.io Socket object for the client
|
|
* @param message the message from the client
|
|
*/
|
|
exports.handleMessage = async (socket:any, message: ClientVarMessage) => {
|
|
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 (err) {
|
|
messageLogger.warn(`Rate limited IP ${logIp(socket.request.ip)}. To reduce the amount of rate ` +
|
|
'limiting that happens edit the rateLimit values in settings.json');
|
|
stats.meter('rateLimited').mark();
|
|
socket.emit('message', {disconnect: 'rateLimited'});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
if (message == null) throw new Error('message is null');
|
|
if (!message.type) throw new Error('message type missing');
|
|
|
|
const thisSession = sessioninfos[socket.id];
|
|
if (!thisSession) throw new Error('message from an unknown connection');
|
|
|
|
if (message.type === 'CLIENT_READY') {
|
|
// Refuse new joiners while the updater drainer is running. Existing sockets
|
|
// are unaffected — only the initial CLIENT_READY handshake is gated. The
|
|
// pad UI will show the drain announcement separately via shoutMessage.
|
|
// Use socket.emit('message', ...) for consistency with the other disconnect
|
|
// paths in this file (see line ~221, 569). socket.json.send is a socket.io
|
|
// v2/v3-era API that may not exist on v4 Socket objects.
|
|
if (!isAcceptingConnections()) {
|
|
socket.emit('message', {disconnect: 'updateInProgress'});
|
|
socket.disconnect(true);
|
|
return;
|
|
}
|
|
// Prefer the HttpOnly author-token cookie over the in-message token (GDPR
|
|
// PR3). Legacy clients (pre-PR3 browsers or API consumers) still send
|
|
// `token` in the CLIENT_READY payload — honour it one more release, warn
|
|
// once so the migration is visible in logs. The socket.io handshake does
|
|
// not run cookie-parser, so pull the cookie directly from the Cookie
|
|
// header.
|
|
//
|
|
// The same applies to the integrator-set `sessionID` cookie (issue #7045):
|
|
// historically the client read it from `document.cookie`, which forced the
|
|
// cookie to be non-HttpOnly and exposed it to XSS. Now we read it from the
|
|
// handshake Cookie header so integrators can set it `HttpOnly`.
|
|
const cookiePrefix = settings.cookie?.prefix || '';
|
|
const cookieHeader: string = socket.request?.headers?.cookie || '';
|
|
const readCookie = (name: string): string | null => {
|
|
const match = cookieHeader.split(/;\s*/).find(
|
|
(c) => c.split('=')[0] === name);
|
|
if (!match) return null;
|
|
const raw = match.split('=').slice(1).join('=');
|
|
// A malformed value (e.g. `name=%ZZ`) makes decodeURIComponent throw
|
|
// URIError. Without this guard a single bad cookie aborts CLIENT_READY,
|
|
// letting an unauthenticated peer spam server error logs and block
|
|
// itself from joining (flagged by Qodo on #7755). Treat undecodable
|
|
// values as absent.
|
|
try {
|
|
return decodeURIComponent(raw);
|
|
} catch (err) {
|
|
if (err instanceof URIError) return null;
|
|
throw err;
|
|
}
|
|
};
|
|
const cookieToken = readCookie(`${cookiePrefix}token`);
|
|
const legacyToken = typeof message.token === 'string' ? message.token : null;
|
|
const resolvedToken = cookieToken || legacyToken;
|
|
if (!cookieToken && legacyToken && !thisSession.legacyTokenWarned) {
|
|
messageLogger.warn(
|
|
'client sent author token via CLIENT_READY message; cookie migration ' +
|
|
'will take effect on next HTTP response. ' +
|
|
'See docs/superpowers/specs/2026-04-19-gdpr-pr3-anon-identity-design.md');
|
|
thisSession.legacyTokenWarned = true;
|
|
}
|
|
const cookieSessionID =
|
|
readCookie(`${cookiePrefix}sessionID`) || readCookie('sessionID');
|
|
const legacySessionID =
|
|
typeof message.sessionID === 'string' ? message.sessionID : null;
|
|
const resolvedSessionID = cookieSessionID || legacySessionID;
|
|
if (!cookieSessionID && legacySessionID && !thisSession.legacySessionIdWarned) {
|
|
messageLogger.warn(
|
|
'client sent sessionID via CLIENT_READY message; integrators should ' +
|
|
'set the sessionID cookie as HttpOnly (issue #7045). The in-message ' +
|
|
'field is deprecated and will be removed in a future release.');
|
|
thisSession.legacySessionIdWarned = true;
|
|
}
|
|
// 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.
|
|
thisSession.auth = {
|
|
sessionID: resolvedSessionID,
|
|
padID: message.padId,
|
|
token: resolvedToken,
|
|
};
|
|
// Issue #7659: connections from the in-place history iframe must not
|
|
// trigger the duplicate-author kick — they share the parent's author
|
|
// by design, and kicking the parent on iframe load would tear down
|
|
// the live editor mid-session. The iframe sets `embed=1` in its
|
|
// socket.io handshake query.
|
|
thisSession.embed = socket.handshake?.query?.embed === '1';
|
|
|
|
// Pad does not exist, so we need to sanitize the id
|
|
if (!(await padManager.doesPadExist(thisSession.auth.padID))) {
|
|
thisSession.auth.padID = await padManager.sanitizePadId(thisSession.auth.padID);
|
|
}
|
|
const padIds = await readOnlyManager.getIds(thisSession.auth.padID);
|
|
thisSession.padId = padIds.padId;
|
|
thisSession.readOnlyPadId = padIds.readOnlyPadId;
|
|
thisSession.readonly =
|
|
padIds.readonly || !webaccess.userCanModify(thisSession.auth.padID, socket.client.request);
|
|
}
|
|
// Outside of the checks done by this function, message.padId must not be accessed because it is
|
|
// too easy to introduce a security vulnerability that allows malicious users to read or modify
|
|
// pads that they should not be able to access. Code should instead use
|
|
// sessioninfos[socket.id].padId if the real pad ID is needed or
|
|
// sessioninfos[socket.id].auth.padID if the original user-supplied pad ID is needed.
|
|
Object.defineProperty(message, 'padId', {get: () => {
|
|
throw new Error('message.padId must not be accessed (for security reasons)');
|
|
}});
|
|
|
|
const auth = thisSession.auth;
|
|
if (!auth) {
|
|
const ip = logIp(socket.request.ip);
|
|
const msg = JSON.stringify(message, null, 2);
|
|
throw new Error(`pre-CLIENT_READY message from IP ${ip}: ${msg}`);
|
|
}
|
|
|
|
const {session: {user} = {}} = socket.client.request as SocketClientRequest;
|
|
const {accessStatus, authorID} =
|
|
await securityManager.checkAccess(auth.padID, auth.sessionID, auth.token, user);
|
|
if (accessStatus !== 'grant') {
|
|
socket.emit('message', {accessStatus});
|
|
throw new Error('access denied');
|
|
}
|
|
if (thisSession.author != null && thisSession.author !== authorID) {
|
|
socket.emit('message', {disconnect: 'rejected'});
|
|
throw new Error([
|
|
'Author ID changed mid-session. Bad or missing token or sessionID?',
|
|
`socket:${socket.id}`,
|
|
`IP:${logIp(socket.request.ip)}`,
|
|
`originalAuthorID:${thisSession.author}`,
|
|
`newAuthorID:${authorID}`,
|
|
...(user && user.username) ? [`username:${user.username}`] : [],
|
|
`message:${message}`,
|
|
].join(' '));
|
|
}
|
|
thisSession.author = authorID;
|
|
|
|
// Allow plugins to bypass the readonly message blocker
|
|
let readOnly = thisSession.readonly;
|
|
const context = {
|
|
message,
|
|
sessionInfo: {
|
|
authorId: thisSession.author,
|
|
padId: thisSession.padId,
|
|
readOnly: thisSession.readonly,
|
|
},
|
|
socket,
|
|
get client() {
|
|
padutils.warnDeprecated(
|
|
'the `client` context property for the handleMessageSecurity and handleMessage hooks ' +
|
|
'is deprecated; use the `socket` property instead');
|
|
return this.socket;
|
|
},
|
|
};
|
|
for (const res of await hooks.aCallAll('handleMessageSecurity', context)) {
|
|
switch (res) {
|
|
case true:
|
|
padutils.warnDeprecated(
|
|
'returning `true` from a `handleMessageSecurity` hook function is deprecated; ' +
|
|
'return "permitOnce" instead');
|
|
thisSession.readonly = false;
|
|
// Fall through:
|
|
case 'permitOnce':
|
|
readOnly = false;
|
|
break;
|
|
default:
|
|
messageLogger.warn(
|
|
'Ignoring unsupported return value from handleMessageSecurity hook function:', res);
|
|
}
|
|
}
|
|
|
|
// Call handleMessage hook. If a plugin returns null, the message will be dropped.
|
|
if ((await hooks.aCallAll('handleMessage', context)).some((m: null|string) => m == null)) {
|
|
return;
|
|
}
|
|
|
|
// Drop the message if the client disconnected during the above processing.
|
|
if (sessioninfos[socket.id] !== thisSession) throw new Error('client disconnected');
|
|
|
|
const {type} = message;
|
|
try {
|
|
switch (type) {
|
|
case 'CLIENT_READY': await handleClientReady(socket, message); break;
|
|
case 'CHANGESET_REQ': await handleChangesetRequest(socket, message); break;
|
|
case 'COLLABROOM': {
|
|
if (readOnly) throw new Error('write attempt on read-only pad');
|
|
const {type} = message.data;
|
|
try {
|
|
switch (type) {
|
|
case 'USER_CHANGES':
|
|
stats.counter('pendingEdits').inc();
|
|
await padChannels.enqueue(thisSession.padId, {socket, message});
|
|
break;
|
|
case 'PAD_DELETE': await handlePadDelete(socket, message.data as unknown as PadDeleteMessage); break;
|
|
case 'USERINFO_UPDATE': await handleUserInfoUpdate(socket, message as unknown as UserNewInfoMessage); break;
|
|
case 'CHAT_MESSAGE': await handleChatMessage(socket, message as unknown as ChatMessageMessage); break;
|
|
case 'GET_CHAT_MESSAGES': await handleGetChatMessages(socket, message); break;
|
|
case 'SAVE_REVISION': await handleSaveRevisionMessage(socket, message as unknown as ClientSaveRevisionMessage); break;
|
|
case 'CLIENT_MESSAGE': {
|
|
const {type} = message.data.payload;
|
|
try {
|
|
switch (type) {
|
|
case 'suggestUserName': handleSuggestUserName(socket, message as unknown as ClientSuggestUserName); break;
|
|
case 'padoptions':
|
|
await handlePadOptionsMessage(
|
|
socket,
|
|
message as unknown as PadOptionsMessage & {data: {payload: PadOptionsMessage}});
|
|
break;
|
|
default: throw new Error('unknown message type');
|
|
}
|
|
} catch (err) {
|
|
throw addContextToError(err, `${type}: `);
|
|
}
|
|
break;
|
|
}
|
|
default: throw new Error('unknown message type');
|
|
}
|
|
} catch (err) {
|
|
throw addContextToError(err, `${type}: `);
|
|
}
|
|
break;
|
|
}
|
|
default: throw new Error('unknown message type');
|
|
}
|
|
} catch (err) {
|
|
throw addContextToError(err, `${type}: `);
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* Handles a save revision message
|
|
* @param socket the socket.io Socket object for the client
|
|
* @param message the message from the client
|
|
*/
|
|
const handleSaveRevisionMessage = async (socket:any, message: ClientSaveRevisionMessage) => {
|
|
const {padId, author: authorId} = sessioninfos[socket.id];
|
|
const pad = await padManager.getPad(padId, null, authorId);
|
|
await pad.addSavedRevision(pad.head, authorId);
|
|
};
|
|
|
|
/**
|
|
* 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: CustomMessage, sessionID: string) => {
|
|
if (msg.data.type === 'CUSTOM') {
|
|
if (sessionID) {
|
|
// a sessionID is targeted: directly to this sessionID
|
|
socketio.sockets.socket(sessionID).emit('message', msg);
|
|
} else {
|
|
// broadcast to all clients on this pad
|
|
socketio.sockets.in(msg.data.payload.padId).emit('message', msg);
|
|
recordSocketEmit(msg.data.type);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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: string, msgString:string) => {
|
|
const time = Date.now();
|
|
const msg = {
|
|
type: 'COLLABROOM',
|
|
data: {
|
|
type: msgString,
|
|
time,
|
|
},
|
|
};
|
|
socketio.sockets.in(padID).emit('message', msg);
|
|
recordSocketEmit(msg.data.type);
|
|
};
|
|
|
|
/**
|
|
* Handles a Chat Message
|
|
* @param socket the socket.io Socket object for the client
|
|
* @param message the message from the client
|
|
*/
|
|
const handleChatMessage = async (socket:any, message: ChatMessageMessage) => {
|
|
const chatMessage = ChatMessage.fromObject(message.data.message);
|
|
const {padId, author: authorId} = sessioninfos[socket.id];
|
|
// Don't trust the user-supplied values.
|
|
chatMessage.time = Date.now();
|
|
chatMessage.authorId = authorId;
|
|
await exports.sendChatMessageToPadClients(chatMessage, padId);
|
|
};
|
|
|
|
/**
|
|
* Adds a new chat message to a pad and sends it to connected clients.
|
|
*
|
|
* @param {(ChatMessage|number)} mt - Either a chat message object (recommended) or the timestamp of
|
|
* the chat message in ms since epoch (deprecated).
|
|
* @param {string} puId - If `mt` is a chat message object, this is the destination pad ID.
|
|
* Otherwise, this is the user's author ID (deprecated).
|
|
* @param {string} [text] - The text of the chat message. Deprecated; use `mt.text` instead.
|
|
* @param {string} [padId] - The destination pad ID. Deprecated; pass a chat message
|
|
* object as the first argument and the destination pad ID as the second argument instead.
|
|
*/
|
|
exports.sendChatMessageToPadClients = async (mt: ChatMessage|number, puId: string, text:string|null = null, padId:string|null = null) => {
|
|
const message = mt instanceof ChatMessage ? mt : new ChatMessage(text, puId, mt);
|
|
padId = mt instanceof ChatMessage ? puId : padId;
|
|
const pad = await padManager.getPad(padId, null, message.authorId);
|
|
await hooks.aCallAll('chatNewMessage', {message, pad, padId});
|
|
// pad.appendChatMessage() ignores the displayName property so we don't need to wait for
|
|
// authorManager.getAuthorName() to resolve before saving the message to the database.
|
|
const promise = pad.appendChatMessage(message);
|
|
message.displayName = await authorManager.getAuthorName(message.authorId);
|
|
socketio.sockets.in(padId).emit('message', {
|
|
type: 'COLLABROOM',
|
|
data: {type: 'CHAT_MESSAGE', message},
|
|
});
|
|
recordSocketEmit('CHAT_MESSAGE');
|
|
await promise;
|
|
};
|
|
|
|
/**
|
|
* 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:any, {data: {start, end}}:any) => {
|
|
if (!Number.isInteger(start)) throw new Error(`missing or invalid start: ${start}`);
|
|
if (!Number.isInteger(end)) throw new Error(`missing or invalid end: ${end}`);
|
|
const count = end - start;
|
|
if (count < 0 || count > 100) throw new Error(`invalid number of messages: ${count}`);
|
|
const {padId, author: authorId} = sessioninfos[socket.id];
|
|
const pad = await padManager.getPad(padId, null, authorId);
|
|
|
|
const chatMessages = await pad.getChatMessages(start, end);
|
|
const infoMsg = {
|
|
type: 'COLLABROOM',
|
|
data: {
|
|
type: 'CHAT_MESSAGES',
|
|
messages: chatMessages,
|
|
},
|
|
};
|
|
|
|
// send the messages back to the client
|
|
socket.emit('message', infoMsg);
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
* @param message the message from the client
|
|
*/
|
|
const handleSuggestUserName = (socket:any, message: ClientSuggestUserName) => {
|
|
const {newName, unnamedId} = message.data.payload;
|
|
if (newName == null) throw new Error('missing newName');
|
|
if (unnamedId == null) throw new Error('missing unnamedId');
|
|
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 === unnamedId) {
|
|
socket.emit('message', message);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
* @param message the message from the client
|
|
*/
|
|
const handleUserInfoUpdate = async (socket:any, {data: {userInfo: {name, colorId}}}: UserNewInfoMessage) => {
|
|
if (colorId == null) throw new Error('missing colorId');
|
|
if (!name) name = null;
|
|
const session = sessioninfos[socket.id];
|
|
if (!session || !session.author || !session.padId) throw new Error('session not ready');
|
|
const author = session.author;
|
|
if (!/(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(colorId)) {
|
|
throw new Error(`malformed color: ${colorId}`);
|
|
}
|
|
|
|
// Tell the authorManager about the new attributes
|
|
const p = Promise.all([
|
|
authorManager.setAuthorColorId(author, colorId),
|
|
authorManager.setAuthorName(author, name),
|
|
]);
|
|
|
|
const padId = session.padId;
|
|
|
|
const infoMsg = {
|
|
type: 'COLLABROOM',
|
|
data: {
|
|
// The Client doesn't know about USERINFO_UPDATE, use USER_NEWINFO
|
|
type: 'USER_NEWINFO',
|
|
userInfo: {userId: author, name, colorId},
|
|
},
|
|
};
|
|
|
|
// Send the other clients on the pad the update message
|
|
socket.broadcast.to(padId).emit('message',infoMsg);
|
|
|
|
// Block until the authorManager has stored the new attributes.
|
|
await p;
|
|
};
|
|
|
|
/**
|
|
* 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
|
|
* @param message the message from the client
|
|
*/
|
|
const handleUserChanges = async (socket:any, message: {
|
|
data: ClientUserChangesMessage
|
|
}) => {
|
|
// This one's no longer pending, as we're gonna process it now
|
|
stats.counter('pendingEdits').dec();
|
|
|
|
// 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) throw new Error('client disconnected');
|
|
|
|
// Measure time to process edit. stats.timer('edits') spans the full handler
|
|
// (apply + fan-out) for backwards-compat; the new Prometheus histogram below
|
|
// wraps only the apply path so the scaling-dive harness can distinguish
|
|
// "apply is slow" from "fan-out is slow". Failed applies do not call the
|
|
// stopper — leaving the timer un-observed keeps the success-path
|
|
// distribution clean.
|
|
const stopWatch = stats.timer('edits').start();
|
|
const stopApplyHistogram = recordChangesetApply();
|
|
try {
|
|
const {data: {baseRev, apool, changeset}} = message;
|
|
if (baseRev == null) throw new Error('missing baseRev');
|
|
if (apool == null) throw new Error('missing apool');
|
|
if (changeset == null) throw new Error('missing changeset');
|
|
const wireApool = (new AttributePool()).fromJsonable(apool);
|
|
const pad = await padManager.getPad(thisSession.padId, null, thisSession.author);
|
|
|
|
// Verify that the changeset has valid syntax and is in canonical form
|
|
checkRep(changeset);
|
|
|
|
// Validate all added 'author' attribs to be the same value as the current user.
|
|
// Exception: '=' ops (attribute changes on existing text) are allowed to restore other authors'
|
|
// IDs, but only if that author already exists in the pad's pool (i.e., they genuinely
|
|
// contributed to this pad). This is necessary for undoing "clear authorship colors", which
|
|
// re-applies the original author attributes for all authors.
|
|
// See https://github.com/ether/etherpad-lite/issues/2802
|
|
for (const op of deserializeOps(unpack(changeset).ops)) {
|
|
// + 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
|
|
|
|
// Besides verifying the author attribute, this serves a second purpose:
|
|
// AttributeMap.fromString() ensures that all attribute numbers are valid (it will throw if
|
|
// an attribute number isn't in the pool).
|
|
const opAuthorId = AttributeMap.fromString(op.attribs, wireApool).get('author');
|
|
if (opAuthorId && opAuthorId !== thisSession.author) {
|
|
if (op.opcode === '=') {
|
|
// Allow restoring author attributes on existing text (undo of clear authorship),
|
|
// but only if the author ID is already known to this pad. This prevents a user
|
|
// from attributing text to a fabricated author who never contributed to the pad.
|
|
const knownAuthor = pad.pool.putAttrib(['author', opAuthorId], true) !== -1;
|
|
if (!knownAuthor) {
|
|
throw new Error(`Author ${thisSession.author} tried to set unknown author ` +
|
|
`${opAuthorId} on existing text in changeset ${changeset}`);
|
|
}
|
|
} else {
|
|
// Reject '+' ops (inserting new text as another author) and '-' ops (deleting
|
|
// with another author's attribs). While '-' attribs are discarded from the
|
|
// document, they are added to the pad's attribute pool by moveOpsToNewPool,
|
|
// which could be exploited to inject fabricated author IDs into the pool and
|
|
// bypass the '=' op pool check above.
|
|
throw new Error(`Author ${thisSession.author} tried to submit changes as author ` +
|
|
`${opAuthorId} in changeset ${changeset}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ex. adoptChangesetAttribs
|
|
|
|
// Afaik, it copies the new attributes from the changeset, to the global Attribute Pool
|
|
let rebasedChangeset = moveOpsToNewPool(changeset, wireApool, pad.pool);
|
|
|
|
// ex. applyUserChanges
|
|
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++;
|
|
const {changeset: c, meta: {author: authorId}} = await pad.getRevision(r);
|
|
if (changeset === c && thisSession.author === authorId) {
|
|
// Assume this is a retransmission of an already applied changeset.
|
|
rebasedChangeset = identity(unpack(changeset).oldLen);
|
|
}
|
|
// 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".
|
|
rebasedChangeset = follow(c, rebasedChangeset, false, pad.pool);
|
|
}
|
|
|
|
const prevText = pad.text();
|
|
|
|
if (oldLen(rebasedChangeset) !== prevText.length) {
|
|
throw new Error(
|
|
`Can't apply changeset ${rebasedChangeset} with oldLen ` +
|
|
`${oldLen(rebasedChangeset)} to document of length ${prevText.length}`);
|
|
}
|
|
|
|
const newRev = await pad.appendRevision(rebasedChangeset, thisSession.author);
|
|
// The head revision will either stay the same or increase by 1 depending on whether the
|
|
// changeset has a net effect.
|
|
assert([r, r + 1].includes(newRev));
|
|
|
|
const correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool);
|
|
if (correctionChangeset) {
|
|
await pad.appendRevision(correctionChangeset, thisSession.author);
|
|
}
|
|
|
|
// Make sure the pad always ends with an empty line.
|
|
if (pad.text().lastIndexOf('\n') !== pad.text().length - 1) {
|
|
const nlChangeset = makeSplice(pad.text(), pad.text().length - 1, 0, '\n');
|
|
await pad.appendRevision(nlChangeset, thisSession.author);
|
|
}
|
|
|
|
// The client assumes that ACCEPT_COMMIT and NEW_CHANGES messages arrive in order. Make sure we
|
|
// have already sent any previous ACCEPT_COMMIT and NEW_CHANGES messages.
|
|
assert.equal(thisSession.rev, r);
|
|
// End of the apply path. The Prometheus histogram observes here so that
|
|
// fan-out (socket emit + updatePadClients) does NOT inflate the apply
|
|
// duration. Failed applies are deliberately not recorded.
|
|
stopApplyHistogram();
|
|
socket.emit('message', {type: 'COLLABROOM', data: {type: 'ACCEPT_COMMIT', newRev}});
|
|
thisSession.rev = newRev;
|
|
if (newRev !== r) thisSession.time = await pad.getRevisionDate(newRev);
|
|
await exports.updatePadClients(pad);
|
|
} catch (err:any) {
|
|
socket.emit('message', {disconnect: 'badChangeset'});
|
|
stats.meter('failedChangesets').mark();
|
|
messageLogger.warn(`Failed to apply USER_CHANGES from author ${thisSession.author} ` +
|
|
`(socket ${socket.id}) on pad ${thisSession.padId}: ${err.stack || err}`);
|
|
} finally {
|
|
stopWatch.end();
|
|
}
|
|
};
|
|
|
|
exports.updatePadClients = async (pad: PadType) => {
|
|
// 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
|
|
const revCache:MapArrayType<any> = {};
|
|
|
|
// When `settings.newChangesBatch` is true and a recipient is more than one
|
|
// revision behind, pack the queued revisions into a single NEW_CHANGES_BATCH
|
|
// emit per recipient. The engine.io WebSocket transport sends one frame per
|
|
// packet (the polling transport already batches at the HTTP-response layer),
|
|
// so reducing the packet count translates directly into fewer system calls
|
|
// on the server and fewer onmessage callbacks on the client.
|
|
const batchEnabled = settings.newChangesBatch === true;
|
|
|
|
await Promise.all(roomSockets.map(async (socket) => {
|
|
const sessioninfo = sessioninfos[socket.id];
|
|
// The user might have disconnected since _getRoomSockets() was called.
|
|
if (sessioninfo == null) return;
|
|
|
|
// Snapshot the local state so a concurrent updatePadClients() can't make
|
|
// us double-emit. We hold the "I'm responsible for revs (startRev,
|
|
// headRev]" claim by reading sessioninfo.rev once and overwriting it
|
|
// before any await. A second invocation arriving mid-loop will see the
|
|
// bumped rev and skip those revisions; if our emit fails the catch
|
|
// below rolls sessioninfo.rev back so they aren't lost.
|
|
const startRev = sessioninfo.rev;
|
|
const headRev = pad.getHeadRevisionNumber();
|
|
if (startRev >= headRev) return;
|
|
const startTime = sessioninfo.time;
|
|
// Claim the range immediately so concurrent runs skip it.
|
|
sessioninfo.rev = headRev;
|
|
|
|
// Collect all queued revisions for this socket.
|
|
const pending: Array<{
|
|
newRev: number;
|
|
changeset: string;
|
|
apool: unknown;
|
|
author: string;
|
|
currentTime: number;
|
|
timeDelta: number;
|
|
}> = [];
|
|
|
|
try {
|
|
let previousTime = startTime;
|
|
for (let r = startRev + 1; r <= headRev; r++) {
|
|
let revision = revCache[r];
|
|
if (!revision) {
|
|
revision = await pad.getRevision(r);
|
|
revCache[r] = revision;
|
|
}
|
|
const author = revision.meta.author;
|
|
const revChangeset = revision.changeset;
|
|
const currentTime = revision.meta.timestamp;
|
|
const forWire = prepareForWire(revChangeset, pad.pool);
|
|
pending.push({
|
|
newRev: r,
|
|
changeset: forWire.translated,
|
|
apool: forWire.pool,
|
|
author,
|
|
currentTime,
|
|
timeDelta: currentTime - previousTime,
|
|
});
|
|
previousTime = currentTime;
|
|
}
|
|
|
|
for (const emit of buildNewChangesEmits(pending, batchEnabled)) {
|
|
socket.emit('message', emit);
|
|
recordSocketEmit(emit.data.type);
|
|
}
|
|
// Only after the wire send succeeds do we commit the new time.
|
|
sessioninfo.time = previousTime;
|
|
} catch (err: any) {
|
|
// Roll back the claim so the next updatePadClients retries these revs.
|
|
// Only set rev back if no one else has advanced past us in the meantime.
|
|
if (sessioninfo.rev === headRev) sessioninfo.rev = startRev;
|
|
messageLogger.error(`Failed to notify user of new revision: ${err.stack || err}`);
|
|
}
|
|
}));
|
|
};
|
|
|
|
/**
|
|
* Copied from the Etherpad Source Code. Don't know what this method does excatly...
|
|
*/
|
|
const _correctMarkersInPad = (atext: AText, apool: AttributePool) => {
|
|
const text = atext.text;
|
|
|
|
// collect char positions of line markers (e.g. bullets) in new atext
|
|
// that aren't at the start of a line
|
|
const badMarkers = [];
|
|
let offset = 0;
|
|
for (const op of deserializeOps(atext.attribs)) {
|
|
const attribs = AttributeMap.fromString(op.attribs, apool);
|
|
const hasMarker = AttributeManager.lineAttributes.some((a: string) => attribs.has(a));
|
|
if (hasMarker) {
|
|
for (let i = 0; i < op.chars; i++) {
|
|
if (offset > 0 && text.charAt(offset - 1) !== '\n') {
|
|
badMarkers.push(offset);
|
|
}
|
|
offset++;
|
|
}
|
|
} else {
|
|
offset += op.chars;
|
|
}
|
|
}
|
|
|
|
if (badMarkers.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
// create changeset that removes these bad markers
|
|
offset = 0;
|
|
|
|
const builder = new Builder(text.length);
|
|
|
|
badMarkers.forEach((pos) => {
|
|
builder.keepText(text.substring(offset, pos));
|
|
builder.remove(1);
|
|
offset = pos + 1;
|
|
});
|
|
|
|
return builder.toString();
|
|
};
|
|
|
|
/**
|
|
* Handles a CLIENT_READY. A CLIENT_READY is the first message from the client
|
|
* to the server. The Client sends his token
|
|
* and the pad it wants to enter. The Server answers with the inital values (clientVars) of the pad
|
|
* @param socket the socket.io Socket object for the client
|
|
* @param message the message from the client
|
|
*/
|
|
const handleClientReady = async (socket:any, message: ClientReadyMessage) => {
|
|
const sessionInfo = sessioninfos[socket.id];
|
|
if (sessionInfo == null) throw new Error('client disconnected');
|
|
assert(sessionInfo.author);
|
|
|
|
await hooks.aCallAll('clientReady', message); // Deprecated due to awkward context.
|
|
|
|
let {colorId: authorColorId, name: authorName} = message.userInfo || {};
|
|
if (authorColorId && !/^#(?:[0-9A-F]{3}){1,2}$/i.test(authorColorId as string)) {
|
|
messageLogger.warn(`Ignoring invalid colorId in CLIENT_READY message: ${authorColorId}`);
|
|
// @ts-ignore
|
|
authorColorId = null;
|
|
}
|
|
await Promise.all([
|
|
authorName && authorManager.setAuthorName(sessionInfo.author, authorName),
|
|
authorColorId && authorManager.setAuthorColorId(sessionInfo.author, authorColorId),
|
|
]);
|
|
({colorId: authorColorId, name: authorName} = await authorManager.getAuthor(sessionInfo.author));
|
|
|
|
const padExisted = await padManager.doesPadExist(sessionInfo.padId);
|
|
// load the pad-object from the database
|
|
const pad = await padManager.getPad(sessionInfo.padId, null, sessionInfo.author);
|
|
if (settings.enablePadWideSettings && !padExisted && message.padSettingsDefaults) {
|
|
pad.setPadSettings(message.padSettingsDefaults);
|
|
await pad.saveToDatabase();
|
|
}
|
|
|
|
// these db requests all need the pad object (timestamp of latest revision, author data)
|
|
const authors = pad.getAllAuthors();
|
|
|
|
// get timestamp of latest revision needed for timeslider
|
|
const currentTime = await pad.getRevisionDate(pad.getHeadRevisionNumber());
|
|
|
|
// get all author data out of the database (in parallel)
|
|
const historicalAuthorData:MapArrayType<{
|
|
name: string;
|
|
colorId: string;
|
|
}> = {};
|
|
await Promise.all(authors.map(async (authorId: string) => {
|
|
const author = await authorManager.getAuthor(authorId);
|
|
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 {
|
|
// Filter author attribs (e.g. don't send author's pads to all clients)
|
|
historicalAuthorData[authorId] = {name: author.name, colorId: author.colorId};
|
|
}
|
|
}));
|
|
|
|
// glue the clientVars together, send them and tell the other clients that a new one is there
|
|
|
|
// Check if the user has disconnected during any of the above awaits.
|
|
if (sessionInfo !== sessioninfos[socket.id]) throw new Error('client disconnected');
|
|
|
|
const {session: {user} = {}} = socket.client.request as SocketClientRequest;
|
|
|
|
// The duplicate-author kick exists because cookie-derived authorIDs are
|
|
// per-browser, so "same authorID, same pad" historically meant "stale tab in
|
|
// the same browser" — see #7656. Authenticated sessions (req.session.user
|
|
// set, e.g. via basic auth, SSO, or a getAuthorId plugin hook) carry a
|
|
// stable identity across windows and devices, so concurrent same-author
|
|
// sessions are legitimate and must not be kicked.
|
|
const roomSockets = _getRoomSockets(pad.id);
|
|
if (user == null && !sessionInfo.embed) {
|
|
for (const otherSocket of roomSockets) {
|
|
// The user shouldn't have joined the room yet, but check anyway just in case.
|
|
if (otherSocket.id === socket.id) continue;
|
|
const sinfo = sessioninfos[otherSocket.id];
|
|
// Embedded sessions (issue #7659 — in-place history iframe) share
|
|
// the parent's author by design, so they neither kick same-author
|
|
// sockets nor get kicked by them. Only non-embedded same-author
|
|
// duplicates (real stale tabs) hit the kick path.
|
|
if (sinfo && sinfo.author === sessionInfo.author && !sinfo.embed) {
|
|
// fix user's counter, works on page refresh or if user closes browser window and then rejoins
|
|
sessioninfos[otherSocket.id] = {};
|
|
otherSocket.leave(sessionInfo.padId);
|
|
otherSocket.emit('message', {disconnect: 'userdup'});
|
|
}
|
|
}
|
|
}
|
|
|
|
/* eslint-disable prefer-template -- it doesn't support breaking across multiple lines */
|
|
accessLogger.info(`[${pad.head > 0 ? 'ENTER' : 'CREATE'}]` +
|
|
` pad:${sessionInfo.padId}` +
|
|
` socket:${socket.id}` +
|
|
` IP:${logIp(socket.request.ip)}` +
|
|
` authorID:${sessionInfo.author}` +
|
|
(user && user.username ? ` username:${user.username}` : ''));
|
|
/* eslint-enable prefer-template */
|
|
|
|
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(sessionInfo.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
|
|
const revisionsNeeded = [];
|
|
const changesets:MapArrayType<any> = {};
|
|
|
|
let startNum = message.client_rev! + 1;
|
|
let endNum = pad.getHeadRevisionNumber() + 1;
|
|
|
|
const headNum = pad.getHeadRevisionNumber();
|
|
|
|
if (endNum > headNum + 1) {
|
|
endNum = headNum + 1;
|
|
}
|
|
|
|
if (startNum < 0) {
|
|
startNum = 0;
|
|
}
|
|
|
|
for (let r = startNum; r < endNum; r++) {
|
|
revisionsNeeded.push(r);
|
|
changesets[r] = {};
|
|
}
|
|
|
|
await Promise.all(revisionsNeeded.map(async (revNum) => {
|
|
const cs = changesets[revNum];
|
|
[cs.changeset, cs.author, cs.timestamp] = await Promise.all([
|
|
pad.getRevisionChangeset(revNum),
|
|
pad.getRevisionAuthor(revNum),
|
|
pad.getRevisionDate(revNum),
|
|
]);
|
|
}));
|
|
|
|
// return pending changesets
|
|
for (const r of revisionsNeeded) {
|
|
const forWire = 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.emit('message', wireMsg);
|
|
}
|
|
|
|
if (startNum === endNum) {
|
|
const Msg = {type: 'COLLABROOM',
|
|
data: {type: 'CLIENT_RECONNECT',
|
|
noChanges: true,
|
|
newRev: pad.getHeadRevisionNumber()}};
|
|
socket.emit('message', Msg);
|
|
}
|
|
} else {
|
|
// This is a normal first connect
|
|
let atext;
|
|
let apool;
|
|
let headRev: number;
|
|
// prepare all values for the wire, there's a chance that this throws, if the pad is corrupted
|
|
try {
|
|
// Capture atext AND head revision atomically to prevent a race condition where
|
|
// concurrent edits advance the revision between these two reads. If the client
|
|
// receives initialAttributedText from rev N but rev=N+3, the first NEW_CHANGES
|
|
// changeset will fail with "mismatched apply" because it expects rev N+3 text.
|
|
// See https://github.com/ether/etherpad-lite/issues/4040
|
|
headRev = pad.getHeadRevisionNumber();
|
|
atext = cloneAText(pad.atext);
|
|
const attribsForWire = prepareForWire(atext.attribs, pad.pool);
|
|
apool = attribsForWire.pool.toJsonable();
|
|
atext.attribs = attribsForWire.translated;
|
|
} catch (e:any) {
|
|
messageLogger.error(e.stack || e);
|
|
socket.emit('message', {disconnect: 'corruptPad'}); // pull the brakes
|
|
throw new Error('corrupt pad');
|
|
}
|
|
|
|
const pluginsSanitized = sanitizePluginsForWire(plugins.plugins);
|
|
|
|
// Only the original creator of the pad (revision 0 author) receives the
|
|
// deletion token, and only on their first arrival — subsequent visits get
|
|
// null because createDeletionTokenIfAbsent() only emits a plaintext token
|
|
// once. Readonly sessions never see it.
|
|
const isCreator =
|
|
!sessionInfo.readonly && sessionInfo.author === await pad.getRevisionAuthor(0);
|
|
// Skip token issuance when requireAuthentication is on: every creator has a
|
|
// stable identity so the cookie/identity path is sufficient.
|
|
const padDeletionToken = isCreator && !settings.requireAuthentication
|
|
? await padDeletionManager.createDeletionTokenIfAbsent(sessionInfo.padId)
|
|
: null;
|
|
|
|
// Warning: never ever send sessionInfo.padId to the client. If the client is read only you
|
|
// would open a security hole 1 swedish mile wide...
|
|
const canEditPadSettings = settings.enablePadWideSettings &&
|
|
!sessionInfo.readonly && await isPadCreator(pad, sessionInfo.author);
|
|
const clientVars:MapArrayType<any> = {
|
|
skinName: settings.skinName,
|
|
skinVariants: settings.skinVariants,
|
|
randomVersionString: settings.randomVersionString,
|
|
accountPrivs: {
|
|
maxRevisions: 100,
|
|
},
|
|
enableDarkMode: settings.enableDarkMode,
|
|
enablePadWideSettings: settings.enablePadWideSettings,
|
|
enablePluginPadOptions: settings.enablePluginPadOptions,
|
|
padDeletionToken,
|
|
// Allow-listed copy — settings.privacyBanner could carry extra nested
|
|
// keys from a hand-edited settings.json; sending those by reference
|
|
// would leak them to every browser. See getPublicPrivacyBanner().
|
|
privacyBanner: getPublicPrivacyBanner(),
|
|
automaticReconnectionTimeout: settings.automaticReconnectionTimeout,
|
|
initialRevisionList: [],
|
|
initialOptions: pad.getPadSettings(),
|
|
savedRevisions: pad.getSavedRevisions(),
|
|
collab_client_vars: {
|
|
initialAttributedText: atext,
|
|
padId: sessionInfo.auth.padID,
|
|
historicalAuthorData,
|
|
apool,
|
|
rev: headRev,
|
|
time: currentTime,
|
|
},
|
|
colorPalette: authorManager.getColorPalette(),
|
|
userColor: authorColorId,
|
|
padId: sessionInfo.auth.padID,
|
|
padOptions: settings.padOptions,
|
|
padShortcutEnabled: settings.padShortcutEnabled,
|
|
initialTitle: `Pad: ${sessionInfo.auth.padID}`,
|
|
opts: {},
|
|
// tell the client the number of the latest chat-message, which will be
|
|
// used to request the latest 100 chat-messages later (GET_CHAT_MESSAGES)
|
|
chatHead: pad.chatHead,
|
|
numConnectedUsers: roomSockets.length + 1, // +1 for this user (not yet in room)
|
|
readOnlyId: sessionInfo.readOnlyPadId,
|
|
readonly: sessionInfo.readonly,
|
|
canEditPadSettings,
|
|
serverTimestamp: Date.now(),
|
|
sessionRefreshInterval: settings.cookie.sessionRefreshInterval,
|
|
userId: sessionInfo.author,
|
|
sofficeAvailable: sofficeAvailable(),
|
|
exportAvailable: exportAvailable(),
|
|
docxExport: settings.docxExport,
|
|
plugins: {
|
|
plugins: pluginsSanitized,
|
|
parts: plugins.parts,
|
|
},
|
|
indentationOnNewLine: settings.indentationOnNewLine,
|
|
scrollWhenFocusLineIsOutOfViewport: {
|
|
percentage: {
|
|
editionAboveViewport:
|
|
settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionAboveViewport,
|
|
editionBelowViewport:
|
|
settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionBelowViewport,
|
|
},
|
|
duration: settings.scrollWhenFocusLineIsOutOfViewport.duration,
|
|
scrollWhenCaretIsInTheLastLineOfViewport:
|
|
settings.scrollWhenFocusLineIsOutOfViewport.scrollWhenCaretIsInTheLastLineOfViewport,
|
|
percentageToScrollWhenUserPressesArrowUp:
|
|
settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp,
|
|
},
|
|
initialChangesets: [], // FIXME: REMOVE THIS SHIT,
|
|
cookiePrefix: settings.cookie.prefix,
|
|
mode: process.env.NODE_ENV
|
|
};
|
|
|
|
// 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
|
|
for (const msg of messages) {
|
|
Object.assign(clientVars, msg);
|
|
}
|
|
|
|
// Join the pad and start receiving updates
|
|
socket.join(sessionInfo.padId);
|
|
|
|
// Send the clientVars to the Client
|
|
socket.emit('message', {type: 'CLIENT_VARS', data: clientVars});
|
|
|
|
// Save the revision in sessioninfos — must match what was sent in clientVars
|
|
sessionInfo.rev = headRev;
|
|
|
|
// Initialize sessionInfo.time to the timestamp of the snapshot revision so
|
|
// that subsequent NEW_CHANGES timeDelta calculations are valid. Without
|
|
// this, the catch-up updatePadClients() call below would emit timeDelta=NaN
|
|
// which breaks the client's broadcast/timeslider time tracking.
|
|
try {
|
|
sessionInfo.time = await pad.getRevisionDate(headRev);
|
|
} catch (err) {
|
|
// Fallback: if we can't read the revision timestamp, use now.
|
|
sessionInfo.time = Date.now();
|
|
}
|
|
|
|
// Flush any revisions that may have been appended while we were awaiting the
|
|
// clientVars hook (before socket.join). Those revisions were broadcast to
|
|
// existing room members but this socket hadn't joined yet so it missed them.
|
|
await exports.updatePadClients(pad);
|
|
}
|
|
|
|
// Notify other users about this new user.
|
|
socket.broadcast.to(sessionInfo.padId).emit('message', {
|
|
type: 'COLLABROOM',
|
|
data: {
|
|
type: 'USER_NEWINFO',
|
|
userInfo: {
|
|
colorId: authorColorId,
|
|
name: authorName,
|
|
userId: sessionInfo.author,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Notify this new user about other users.
|
|
await Promise.all(_getRoomSockets(pad.id).map(async (roomSocket) => {
|
|
if (roomSocket.id === socket.id) return;
|
|
|
|
// sessioninfos might change while enumerating, so 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;
|
|
}
|
|
|
|
const msg = {
|
|
type: 'COLLABROOM',
|
|
data: {
|
|
type: 'USER_NEWINFO',
|
|
userInfo: {
|
|
colorId: authorInfo.colorId,
|
|
name: authorInfo.name,
|
|
userId: authorId,
|
|
},
|
|
},
|
|
};
|
|
|
|
socket.emit('message', msg);
|
|
}));
|
|
|
|
await hooks.aCallAll('userJoin', {
|
|
authorId: sessionInfo.author,
|
|
displayName: authorName,
|
|
padId: sessionInfo.padId,
|
|
readOnly: sessionInfo.readonly,
|
|
readOnlyPadId: sessionInfo.readOnlyPadId,
|
|
socket,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Handles a request for a rough changeset, the timeslider client needs it
|
|
*/
|
|
const handleChangesetRequest = async (socket:any, {data: {granularity, start, requestID}}: ChangesetRequest) => {
|
|
if (granularity == null) throw new Error('missing granularity');
|
|
if (!Number.isInteger(granularity)) throw new Error('granularity is not an integer');
|
|
if (start == null) throw new Error('missing start');
|
|
start = checkValidRev(start);
|
|
if (requestID == null) throw new Error('mising requestID');
|
|
const end = start + (100 * granularity);
|
|
const {padId, author: authorId} = sessioninfos[socket.id];
|
|
const pad = await padManager.getPad(padId, null, authorId);
|
|
const headRev = pad.getHeadRevisionNumber();
|
|
if (start > headRev)
|
|
start = headRev;
|
|
const data:MapArrayType<any> = await getChangesetInfo(pad, start, end, granularity);
|
|
data.requestID = requestID;
|
|
socket.emit('message', {type: 'CHANGESET_REQ', 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 (pad: PadType, startNum: number, endNum:number, granularity: number) => {
|
|
const headRevision = pad.getHeadRevisionNumber();
|
|
|
|
// calculate the last full endnum
|
|
if (endNum > headRevision + 1) endNum = headRevision + 1;
|
|
endNum = Math.floor(endNum / granularity) * granularity;
|
|
|
|
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) {
|
|
const end = start + granularity;
|
|
|
|
// add the composite Changeset we needed
|
|
compositesChangesetNeeded.push({start, end});
|
|
|
|
// add the t1 time we need
|
|
revTimesNeeded.push(start === 0 ? 0 : start - 1);
|
|
|
|
// add the t2 time we need
|
|
revTimesNeeded.push(end - 1);
|
|
}
|
|
|
|
// Get all needed db values in parallel.
|
|
const composedChangesets:MapArrayType<any> = {};
|
|
const revisionDate:number[] = [];
|
|
const [lines] = await Promise.all([
|
|
getPadLines(pad, startNum - 1),
|
|
// Get all needed composite Changesets.
|
|
...compositesChangesetNeeded.map(async (item) => {
|
|
const changeset = await exports.composePadChangesets(pad, item.start, item.end);
|
|
composedChangesets[`${item.start}/${item.end}`] = changeset;
|
|
}),
|
|
// Get all needed revision Dates.
|
|
...revTimesNeeded.map(async (revNum) => {
|
|
const revDate = await pad.getRevisionDate(revNum);
|
|
revisionDate[revNum] = revDate;
|
|
}),
|
|
]);
|
|
|
|
// doesn't know what happens here exactly :/
|
|
const timeDeltas = [];
|
|
const forwardsChangesets = [];
|
|
const backwardsChangesets = [];
|
|
const apool = new AttributePool();
|
|
|
|
for (let compositeStart = startNum; compositeStart < endNum; compositeStart += granularity) {
|
|
const compositeEnd = compositeStart + granularity;
|
|
if (compositeEnd > endNum || compositeEnd > headRevision + 1) break;
|
|
|
|
const forwards = composedChangesets[`${compositeStart}/${compositeEnd}`];
|
|
const backwards = inverse(forwards, lines.textlines, lines.alines, pad.apool());
|
|
|
|
mutateAttributionLines(forwards, lines.alines, pad.apool());
|
|
mutateTextLines(forwards, lines.textlines);
|
|
|
|
const forwards2 = moveOpsToNewPool(forwards, pad.apool(), apool);
|
|
const backwards2 = moveOpsToNewPool(backwards, pad.apool(), apool);
|
|
|
|
const t1 = (compositeStart === 0) ? revisionDate[0] : revisionDate[compositeStart - 1];
|
|
const t2 = revisionDate[compositeEnd - 1];
|
|
|
|
timeDeltas.push(t2 - t1);
|
|
forwardsChangesets.push(forwards2);
|
|
backwardsChangesets.push(backwards2);
|
|
}
|
|
|
|
return {forwardsChangesets, backwardsChangesets,
|
|
apool: apool.toJsonable(), actualEndNum: endNum,
|
|
timeDeltas, start: startNum, granularity};
|
|
};
|
|
|
|
/**
|
|
* Tries to rebuild the getPadLines function of the original Etherpad
|
|
* https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L263
|
|
*/
|
|
const getPadLines = async (pad: PadType, revNum: number) => {
|
|
// get the atext
|
|
let atext;
|
|
|
|
if (revNum >= 0) {
|
|
atext = await pad.getInternalRevisionAText(revNum);
|
|
} else {
|
|
atext = makeAText('\n');
|
|
}
|
|
|
|
return {
|
|
textlines: splitTextLines(atext.text),
|
|
alines: 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
|
|
*/
|
|
exports.composePadChangesets = async (pad: PadType, startNum: number, endNum: number) => {
|
|
// fetch all changesets we need
|
|
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
|
|
const changesetsNeeded = [];
|
|
for (let r = startNum; r < endNum; r++) {
|
|
changesetsNeeded.push(r);
|
|
}
|
|
|
|
// get all changesets
|
|
const changesets:MapArrayType<ChangeSet> = {};
|
|
await Promise.all(changesetsNeeded.map(
|
|
(revNum) => pad.getRevisionChangeset(revNum)
|
|
.then((changeset) => changesets[revNum] = changeset)));
|
|
|
|
// compose Changesets
|
|
let r;
|
|
try {
|
|
let changeset = changesets[startNum];
|
|
const pool = pad.apool();
|
|
|
|
for (r = startNum + 1; r < endNum; r++) {
|
|
const cs = changesets[r];
|
|
changeset = compose(changeset as string, cs as string, pool);
|
|
}
|
|
return changeset;
|
|
} catch (e) {
|
|
// r-1 indicates the rev that was build starting with startNum, applying startNum+1, +2, +3
|
|
messageLogger.warn(
|
|
`failed to compose cs in pad: ${pad.id} startrev: ${startNum} current rev: ${r}`);
|
|
throw e;
|
|
}
|
|
};
|
|
|
|
const _getRoomSockets = (padID: string) => {
|
|
const ns = socketio.sockets; // Default namespace.
|
|
// 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 = ns.adapter.rooms?.get(padID);
|
|
|
|
if (!room) return [];
|
|
|
|
return Array.from(room)
|
|
.map(socketId => ns.sockets.get(socketId))
|
|
.filter(socket => socket);
|
|
};
|
|
|
|
/**
|
|
* Get the number of users in a pad
|
|
*/
|
|
exports.padUsersCount = (padID:string) => ({
|
|
padUsersCount: _getRoomSockets(padID).length,
|
|
});
|
|
|
|
/**
|
|
* Get the list of users in a pad
|
|
*/
|
|
exports.padUsers = async (padID: string) => {
|
|
const padUsers:PadAuthor[] = [];
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}));
|
|
|
|
return {padUsers};
|
|
};
|
|
|
|
exports.sessioninfos = sessioninfos;
|