UNPKG

webgme-engine

Version:

WebGME server and Client API without a GUI

1,077 lines (952 loc) 54.7 kB
/*globals requireJS*/ /*eslint-env node*/ /** * @module Server:WebSockets * @author pmeijer / https://github.com/pmeijer */ 'use strict'; var io = require('socket.io'), redis = require('socket.io-redis'), Q = require('q'), UTIL = require('../../utils'), DocumentServer = require('./documentserver'), URL = requireJS('common/util/url'), CONSTANTS = requireJS('common/storage/constants'), PACKAGE_JSON; PACKAGE_JSON = UTIL.getPackageJsonSync(); function WebSocket(storage, mainLogger, gmeConfig, gmeAuth, workerManager) { var logger = mainLogger.fork('WebSocket'), metadataStorage = gmeAuth.metadataStorage, authorizer = gmeAuth.authorizer, projectAuthParams = { entityType: authorizer.ENTITY_TYPES.PROJECT }, documents = { //<docId> : { otServer: DocumentServer, users: {}, disconnectedUsers: {} } }, // TODO: This is a single state on one server! webSocket = io(); const socketRouters = {}; logger.debug('ctor'); function getErrorHandler(callback) { return function (err) { if (err.message.includes('Not authorized to') || err.message.includes('Project does not exist')) { logger.warn(err.message); } else { logger.error(err.stack, '\n', (new Error('Caught by')).stack); } callback && callback(err.message); }; } function parseCookiesFromHandshake(socket) { let cookies = {}; if (socket.handshake && socket.handshake.headers && socket.handshake.headers.cookie) { // We try to dig it from the cookie. cookies = URL.parseCookie(socket.handshake.headers.cookie); } return cookies; } function getUserIdFromToken(socket, token, callback) { var deferred = Q.defer(); if (gmeConfig.authentication.enable === true) { gmeAuth.verifyJWToken(token) .then(function (result) { // Check if token is about to expire if (result.renew === true) { logger.debug('JWT_ABOUT_TO_EXPIRE for user', result.content.userId, socket.id); socket.emit(CONSTANTS.JWT_ABOUT_TO_EXPIRE, { exp: result.content.exp, iat: result.content.iat }); } deferred.resolve(result.content.userId); }) .catch(function (err) { if (err.name === 'TokenExpiredError') { logger.debug('JWT_EXPIRED for socket', socket.id); if (!socket[CONSTANTS.JWT_EXPIRED]) { socket[CONSTANTS.JWT_EXPIRED] = true; socket.emit(CONSTANTS.JWT_EXPIRED, {}); } deferred.reject(new Error('TokenExpired')); } else { deferred.reject(err); } }); } else { deferred.resolve(gmeConfig.authentication.guestAccount); } return deferred.promise.nodeify(callback); } function projectAccess(socket, token, projectId, callback) { return getUserIdFromToken(socket, token) .then(function (userId) { return authorizer.getAccessRights(userId, projectId, projectAuthParams); }) .nodeify(callback); } function getEmitter(data) { var emitter; delete data.webgmeToken; if (data.socket) { logger.debug('socket provided - will broadcast from ', data.socket.id); emitter = data.socket.broadcast; delete data.socket; } else { // Changes from the server itself needs to emit to all sockets. logger.debug('socket NOT provided - will emit to everybody.'); emitter = webSocket; } return emitter; } function joinBranchRoom(socket, token, projectId, branchName) { var deferred = Q.defer(), roomName = projectId + CONSTANTS.ROOM_DIVIDER + branchName, userId = socket.userId, notificationData = { projectId: projectId, branchName: branchName, userId: userId, socketId: socket.id, join: true }; if (Object.hasOwn(socket.rooms, roomName) === true) { // Socket is already in given room - no need to account for it. logger.debug('socket already in room', socket.id, roomName); deferred.resolve(); } else { Q.ninvoke(socket, 'join', roomName) .then(function () { var eventData = { projectId: projectId, branchName: branchName, userId: userId, socketId: socket.id, webgmeToken: token }; logger.debug('socket joined room', socket.id, notificationData.userId, roomName); notificationData.type = CONSTANTS.BRANCH_ROOM_SOCKETS; socket.broadcast.to(roomName).emit(CONSTANTS.NOTIFICATION, notificationData); storage.dispatchEvent(CONSTANTS.BRANCH_JOINED, eventData); deferred.resolve(); }) .catch(function (err) { deferred.reject(err instanceof Error ? err : new Error(err)); }); } return deferred.promise; } function leaveBranchRoom(socket, projectId, branchName/*, disconnected*/) { var deferred = Q.defer(), roomName = projectId + CONSTANTS.ROOM_DIVIDER + branchName, userId = socket.userId, notificationData = { projectId: projectId, branchName: branchName, userId: userId, socketId: socket.id }; if (Object.hasOwn(socket.rooms, roomName) === false) { // Socket was never in or had already left given room - no need to account for it. logger.debug('socket already left room', socket.id, roomName); deferred.resolve(); } else { notificationData.type = CONSTANTS.BRANCH_ROOM_SOCKETS; socket.broadcast.to(roomName).emit(CONSTANTS.NOTIFICATION, notificationData); Q.ninvoke(socket, 'leave', roomName) .then(function () { logger.debug('socket left room', socket.id, notificationData.userId, roomName); storage.dispatchEvent(CONSTANTS.BRANCH_LEFT, notificationData); deferred.resolve(); }) .catch(function (err) { deferred.reject(err instanceof Error ? err : new Error(err)); }); } return deferred.promise; } function triggerDocumentRemoval(docId) { if (Object.keys(documents[docId].users).length === 0) { logger.debug('No more connected sockets in document ...'); if (Object.keys(documents[docId].disconnectedUsers).length === 0) { logger.debug('... no disconnectedUsers either will close the document.'); delete documents[docId]; } else { logger.debug('.. there are disconnected users - setting timeout to close doc', gmeConfig.documentEditing.disconnectTimeout); documents[docId].timeoutId = setTimeout(function () { delete documents[docId]; }, gmeConfig.documentEditing.disconnectTimeout); } } } storage.addEventListener(CONSTANTS.PROJECT_DELETED, function (_s, data) { getEmitter(data).to(CONSTANTS.DATABASE_ROOM).emit(CONSTANTS.PROJECT_DELETED, data); }); storage.addEventListener(CONSTANTS.PROJECT_CREATED, function (_s, data) { getEmitter(data).to(CONSTANTS.DATABASE_ROOM).emit(CONSTANTS.PROJECT_CREATED, data); }); storage.addEventListener(CONSTANTS.BRANCH_DELETED, function (_s, data) { getEmitter(data).to(data.projectId).emit(CONSTANTS.BRANCH_DELETED, data); }); storage.addEventListener(CONSTANTS.BRANCH_CREATED, function (_s, data) { getEmitter(data).to(data.projectId).emit(CONSTANTS.BRANCH_CREATED, data); }); storage.addEventListener(CONSTANTS.BRANCH_HASH_UPDATED, function (_s, data) { getEmitter(data).to(data.projectId).emit(CONSTANTS.BRANCH_HASH_UPDATED, data); }); storage.addEventListener(CONSTANTS.COMMIT, function (_s, data) { getEmitter(data).to(data.projectId).emit(CONSTANTS.COMMIT, data); }); storage.addEventListener(CONSTANTS.TAG_CREATED, function (_s, data) { getEmitter(data).to(data.projectId).emit(CONSTANTS.TAG_CREATED, data); }); storage.addEventListener(CONSTANTS.TAG_DELETED, function (_s, data) { getEmitter(data).to(data.projectId).emit(CONSTANTS.TAG_DELETED, data); }); storage.addEventListener(CONSTANTS.BRANCH_UPDATED, function (_s, data) { getEmitter(data) .to(data.projectId + CONSTANTS.ROOM_DIVIDER + data.branchName) .emit(CONSTANTS.BRANCH_UPDATED, data); }); this.start = function (server) { logger.debug('start'); // webSocket = io.listen(server || gmeConfig.server.port, gmeConfig.socketIO.serverOptions); webSocket.listen(server || gmeConfig.server.port, gmeConfig.socketIO.serverOptions); if (gmeConfig.socketIO.adapter.type.toLowerCase() === 'redis') { logger.info('redis adapter:', JSON.stringify(gmeConfig.socketIO.adapter.options)); webSocket.adapter(redis(gmeConfig.socketIO.adapter.options.uri)); } logger.debug('listening'); webSocket.use(function (socket, next) { getUserIdFromToken(socket, parseCookiesFromHandshake(socket)[gmeConfig.authentication.jwt.cookieId]) .then(function (userId) { logger.debug('User connected and authenticated', userId); socket.userId = userId; next(); }) .catch(next); }); webSocket.on('connection', function (socket) { logger.debug('New socket connected', socket.id); // Inject into socket.onclose in order to see which rooms socket was in. var originalOnClose = socket.onclose; socket.onclose = function () { var i, roomIds, projectIdBranchName, document, roomDividerCnt; if (webSocket) { roomIds = Object.keys(socket.rooms); for (i = 0; i < roomIds.length; i += 1) { // This is not the prettiest, but we know that branchRooms // are divided by one ROOM_DIVIDER (projectId % branchName), // whereas document rooms are divided by 3 ROOM_DIVIDER // (projectId % branchName % nodeId % attrName). roomDividerCnt = roomIds[i].split(CONSTANTS.ROOM_DIVIDER).length; if (roomDividerCnt === 2) { logger.debug('Disconnected was in branchRoom', roomIds[i]); projectIdBranchName = roomIds[i].split(CONSTANTS.ROOM_DIVIDER); // We cannot wait for this since socket.onclose is synchronous. leaveBranchRoom(socket, projectIdBranchName[0], projectIdBranchName[1]) .fail(function (err) { logger.error(err); }); } else if (roomDividerCnt === 4) { logger.debug('Disconnected socket was in document room', roomIds[i]); if (Object.hasOwn(documents, roomIds[i])) { document = documents[roomIds[i]]; socket.broadcast.to(roomIds[i]).emit(CONSTANTS.DOCUMENT_SELECTION, { docId: roomIds[i], socketId: socket.id, userId: socket.userId, selection: null }); document.disconnectedUsers[document.users[socket.id].sessionId] = document.users[socket.id].watchers; delete document.users[socket.id]; socket.leave(roomIds[i]); logger.debug('socket left document room.'); triggerDocumentRemoval(roomIds[i]); } else { logger.error('No document server object for active room'); } } } } originalOnClose.apply(socket, arguments); }; socket.on('disconnect', function () { // When this event is triggered, the disconnect socket has already left all rooms. logger.debug('disconnect socket is in rooms: ', socket.id, Object.keys(socket.rooms)); }); socket.on('getConnectionInfo', function (data, callback) { var info = { userId: null, serverVersion: PACKAGE_JSON.version }; getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { info.userId = userId; callback(null, info); }) .catch(getErrorHandler(callback)); }); // Watchers socket.on('watchDatabase', function (data, callback) { logger.debug('watchDatabase', {metadata: data}); if (data && data.join) { socket.join(CONSTANTS.DATABASE_ROOM); } else { socket.leave(CONSTANTS.DATABASE_ROOM); } callback(); }); socket.on('watchProject', function (data, callback) { logger.debug('watchProject', {metadata: data}); data = data || {}; projectAccess(socket, data.webgmeToken, data.projectId) .then(function (access) { if (data.join) { if (access.read) { socket.join(data.projectId); logger.debug('socket joined room', data.projectId); callback(); } else { logger.warn('socket not authorized to join room', data.projectId); callback('No read access for ' + data.projectId); } } else { socket.leave(data.projectId); logger.debug('socket left room', data.projectId); callback(); } }) .catch(getErrorHandler(callback)); }); socket.on('watchBranch', function (data, callback) { // This is emitted from clients that got disconnected while having branches open. logger.debug('watchBranch', {metadata: data}); data = data || {}; projectAccess(socket, data.webgmeToken, data.projectId) .then(function (access) { if (data.join) { if (access.read) { joinBranchRoom(socket, data.webgmeToken, data.projectId, data.branchName) .fail(function (err) { logger.error(err); }); } else { logger.warn('socket not authorized to join room', data.projectId); throw new Error('No read access for ' + data.projectId); } } else { leaveBranchRoom(socket, data.projectId, data.branchName) .fail(function (err) { logger.error(err); }); } }) .then(function () { callback(null); }) .catch(getErrorHandler(callback)); }); // Model editing socket.on('openProject', function (data, callback) { var branches, access; logger.debug('openProject', {metadata: data}); getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.getBranches(data); }) .then(function (branches_) { branches = branches_; return projectAccess(socket, data.webgmeToken, data.projectId); }) .then(function (access_) { var username = data.username || this.gmeConfig.authentication.guestAccount; access = access_; return metadataStorage.updateProjectInfo(data.projectId, { viewedAt: (new Date()).toISOString(), viewer: username }); }) .then(function () { callback(null, branches, access); }) .catch(getErrorHandler(callback)); }); socket.on('closeProject', function (data, callback) { logger.debug('closeProject', {metadata: data}); getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { return metadataStorage.updateProjectInfo(data.projectId, { viewedAt: (new Date()).toISOString(), viewer: userId || this.gmeConfig.authentication.guestAccount }); }) .then(function () { callback(); }) .catch(getErrorHandler(callback)); }); socket.on('openBranch', function (data, callback) { var latestCommitData; logger.debug('openBranch', {metadata: data}); getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; // This ensures read access. return storage.getLatestCommitData(data); }) .then(function (commitData) { latestCommitData = commitData; joinBranchRoom(socket, data.webgmeToken, data.projectId, data.branchName) .fail(function (err) { logger.error(err); }); }) .then(function () { callback(null, latestCommitData); }) .catch(getErrorHandler(callback)); }); socket.on('closeBranch', function (data, callback) { logger.debug('closeBranch', {metadata: data}); data = data || {}; leaveBranchRoom(socket, data.projectId, data.branchName) .fail(function (err) { logger.error(err); }); callback(null); }); socket.on('makeCommit', function (data, callback) { var commitStatus; getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { var roomName; if (data.branchName) { roomName = data.projectId + CONSTANTS.ROOM_DIVIDER + data.branchName; if (Object.hasOwn(socket.rooms, roomName)) { logger.debug('Committer is in the branch-room', userId, roomName); data.socket = socket; } } data.username = userId; return storage.makeCommit(data); }) .then(function (status) { var now = (new Date()).toISOString(); commitStatus = status; return metadataStorage.updateProjectInfo(data.projectId, { modifiedAt: now, viewedAt: now, viewer: data.username, modifier: data.username }); }) .then(function () { var tokenPromise; if (commitStatus.status === CONSTANTS.FORKED && gmeConfig.storage.autoMerge.enable) { // Commit was forked and auto-merge is enabled. First get a new token for the worker. if (gmeConfig.authentication.enable === true) { tokenPromise = gmeAuth.regenerateJWToken(data.webgmeToken); } else { tokenPromise = Q(); } tokenPromise .then(function (token) { var workerParameters = { command: 'autoMerge', projectId: data.projectId, mine: commitStatus.hash, theirs: data.branchName, webgmeToken: token }; workerManager.request(workerParameters, function (err, result) { if (err) { logger.error('Merging failed', err); } else if (result.conflict && result.conflict.items.length > 0) { logger.info('Merge resulted in conflict', commitStatus); } else if (result.updatedBranch) { logger.info('Merge successful', commitStatus); callback(null, { status: CONSTANTS.MERGED, hash: commitStatus.hash, theirHash: result.theirCommitHash, mergeHash: result.finalCommitHash }); return; } else { logger.error('No conflict nor an updateBranch, this should not happen.'); } // In the cases where the merged failed or resulted in conflicts we just return // the original FORKED commit-status. callback(null, commitStatus); }); }) .catch(getErrorHandler(callback)); } else { callback(null, commitStatus); } }) .catch(getErrorHandler(callback)); }); socket.on('loadObjects', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.loadObjects(data); }) .then(function (loadedObjects) { callback(null, loadedObjects); //Single load-fails are reported in this object. }) .catch(getErrorHandler(callback)); }); socket.on('loadPaths', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.loadPaths(data); }) .then(function (hashDictionary) { callback(null, hashDictionary); }) .catch(getErrorHandler(callback)); }); socket.on('setBranchHash', function (data, callback) { var status; getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { if (Object.hasOwn(socket.rooms, data.projectId)) { data.socket = socket; } data.username = userId; return storage.setBranchHash(data); }) .then(function (result) { var now = (new Date()).toISOString(), username = data.username || this.gmeConfig.authentication.guestAccount; status = result; return metadataStorage.updateProjectInfo(data.projectId, { modifiedAt: now, viewedAt: now, viewer: username, modifier: username }); }) .then(function () { callback(null, status); }) .catch(getErrorHandler(callback)); }); socket.on('getBranchHash', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.getBranchHash(data); }) .then(function (result) { callback(null, result); }) .catch(getErrorHandler(callback)); }); // Project operations and getters socket.on('getProjects', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.getProjects(data); }) .then(function (projects) { callback(null, projects); }) .catch(getErrorHandler(callback)); }); socket.on('deleteProject', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { if (Object.hasOwn(socket.rooms, CONSTANTS.DATABASE_ROOM)) { data.socket = socket; } data.username = userId; return storage.deleteProject(data); }) .then(function (didExist) { callback(null, didExist); }) .catch(getErrorHandler(callback)); }); socket.on('createProject', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { if (Object.hasOwn(socket.rooms, CONSTANTS.DATABASE_ROOM)) { data.socket = socket; } data.username = userId; return storage.createProject(data); }) .then(function (project) { callback(null, project.projectId); }) .catch(getErrorHandler(callback)); }); socket.on('transferProject', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { if (Object.hasOwn(socket.rooms, CONSTANTS.DATABASE_ROOM)) { data.socket = socket; } data.username = userId; return storage.transferProject(data); }) .then(function (newProjectId) { callback(null, newProjectId); }) .catch(getErrorHandler(callback)); }); socket.on('duplicateProject', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { if (Object.hasOwn(socket.rooms, CONSTANTS.DATABASE_ROOM)) { data.socket = socket; } data.username = userId; return storage.duplicateProject(data); }) .then(function (newProject) { callback(null, newProject.projectId); }) .catch(getErrorHandler(callback)); }); // Tags, commits and branches socket.on('getBranches', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.getBranches(data); }) .then(function (branches) { callback(null, branches); }) .catch(getErrorHandler(callback)); }); socket.on('createTag', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.createTag(data); }) .then(function () { callback(null); }) .catch(getErrorHandler(callback)); }); socket.on('deleteTag', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.deleteTag(data); }) .then(function () { callback(null); }) .catch(getErrorHandler(callback)); }); socket.on('getTags', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.getTags(data); }) .then(function (tags) { callback(null, tags); }) .catch(getErrorHandler(callback)); }); socket.on('getCommits', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.getCommits(data); }) .then(function (commits) { callback(null, commits); }) .catch(getErrorHandler(callback)); }); socket.on('getHistory', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.getHistory(data); }) .then(function (commits) { callback(null, commits); }) .catch(getErrorHandler(callback)); }); socket.on('getLatestCommitData', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.getLatestCommitData(data); }) .then(function (commitData) { callback(null, commitData); }) .catch(getErrorHandler(callback)); }); socket.on('getCommonAncestorCommit', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.getCommonAncestorCommit(data); }) .then(function (commonCommitHash) { callback(null, commonCommitHash); }) .catch(getErrorHandler(callback)); }); socket.on('squashCommits', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.username = userId; return storage.squashCommits(data); }) .then(function (commitResult) { callback(null, commitResult); }) .catch(getErrorHandler(callback)); }); // Worker commands socket.on('simpleRequest', function (data, callback) { const cookies = parseCookiesFromHandshake(this); getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { data.userId = userId; data.socketId = socket.id; if (gmeConfig.authentication.enable === true) { return gmeAuth.regenerateJWToken(data.webgmeToken); } }) .then(function (newToken) { data.webgmeToken = newToken; //TODO this should probably come from authenticator and not the request!!! if (gmeConfig.authentication.enable === true && gmeConfig.authentication.azureActiveDirectory.enable === true && gmeConfig.authentication.azureActiveDirectory.accessScope) { data.aadToken = cookies[gmeConfig.authentication.azureActiveDirectory.cookieId] || null; } workerManager.request(data, function (err, result) { if (err) { callback(err.message, result); } else { callback(null, result); } }); }) .catch(getErrorHandler(callback)); }); socket.on('simpleQuery', function (workerId, data, callback) { callback('simpleQuery is not implemented!'); }); // Notification handling socket.on('notification', function (data, callback) { getUserIdFromToken(socket, data && data.webgmeToken) .then(function (userId) { logger.debug('Incoming notification from', userId, {metadata: data}); data.userId = userId; data.socketId = socket.id; delete data.webgmeToken; if (data.type === CONSTANTS.PLUGIN_NOTIFICATION) { if (data.notification.toBranch && typeof data.projectId === 'string' && typeof data.branchName === 'string') { webSocket.to(data.projectId + CONSTANTS.ROOM_DIVIDER + data.branchName) .emit(CONSTANTS.NOTIFICATION, data); } else if (data.originalSocketId) { webSocket.to(data.originalSocketId).emit(CONSTANTS.NOTIFICATION, data); } else { throw new Error('PLUGIN_NOTIFICATION requires provided originalSocketId to emit to.'); } } else if (data.type === CONSTANTS.ADD_ON_NOTIFICATION) { socket.broadcast.to(data.projectId + CONSTANTS.ROOM_DIVIDER + data.branchName) .emit(CONSTANTS.NOTIFICATION, data); } else if (data.type === CONSTANTS.CLIENT_STATE_NOTIFICATION) { socket.broadcast.to(data.projectId + CONSTANTS.ROOM_DIVIDER + data.branchName) .emit(CONSTANTS.NOTIFICATION, data); } else { throw new Error('Unknown notification type: "' + data.type + '"'); } }) .then(function () { callback(null); }) .catch(getErrorHandler(callback)); }); // OT handling socket.on('watchDocument', function (data, callback) { projectAccess(socket, data && data.webgmeToken, data && data.projectId) .then(function (access) { var docId = data.join ? [data.projectId, data.branchName, data.nodeId, data.attrName] .join(CONSTANTS.ROOM_DIVIDER) : data.docId, eventData; if (!gmeConfig.documentEditing.enable) { throw new Error('Document editing is disabled from gmeConfig!'); } if (typeof data.watcherId !== 'string') { throw new Error('data.watcherId was not provided!'); } logger.debug('watchDocument', docId, 'join?', data.join, 'rejoin?', data.rejoin); if (data.join === true) { if (!access.read) { logger.warn('socket not authorized to join document room', docId); throw new Error('No read access for ' + data.projectId); } if (Object.hasOwn(documents, docId) === false) { logger.debug('First user joining document, will create it..'); documents[docId] = { otServer: new DocumentServer(logger, data.attrValue, docId, gmeConfig), users: {}, disconnectedUsers: {}, timeoutId: null }; } else { clearTimeout(documents[docId].timeoutId); } if (Object.hasOwn(documents[docId].users, socket.id) === false) { documents[docId].users[socket.id] = { socketId: socket.id, sessionId: data.sessionId, userId: socket.userId, access: access, watchers: [data.watcherId] }; socket.join(docId); } else { logger.info('socket joining the same document again', docId); documents[docId].users[socket.id].watchers.push(data.watcherId); } callback(null, { docId: docId, watcherId: data.watcherId, document: documents[docId].otServer.document, revision: documents[docId].otServer.operations.length, clients: documents[docId].users }); } else if (data.rejoin === true) { if (!access.read) { logger.warn('socket not authorized to join document room', docId); throw new Error('No longer has read access to ' + data.projectId); } // The document will be removed after config.documentEditing.disconnectTimeout // (default is 20 seconds) when a socket left without leaving the room. // To prohibit disconnected users to overwrite changes while they were // disconnected - at reconnect they are rejected (the UI should clearly notify // that edits made while disconnected could be lost). if (Object.hasOwn(documents, docId) === false) { throw new Error('Document room was closed ' + docId); } else if (Object.hasOwn(documents[docId].disconnectedUsers, data.sessionId) === false) { throw new Error('Document room was closed ' + docId + ' and then reopened.'); } else if (documents[docId].disconnectedUsers[data.sessionId].indexOf( data.watcherId) === -1) { throw new Error('Document room to rejoin ' + docId + ' did not have current watcher.'); } documents[docId].otServer.getOperationsSince(data.revision); documents[docId].disconnectedUsers[data.sessionId].splice( documents[docId].disconnectedUsers[data.sessionId].indexOf(data.watcherId), 1); clearTimeout(documents[docId].timeoutId); if (documents[docId].disconnectedUsers[data.sessionId].length === 0) { delete documents[docId].disconnectedUsers[data.sessionId]; } if (Object.hasOwn(documents[docId].users, socket.id) === false) { documents[docId].users[socket.id] = { socketId: socket.id, userId: socket.userId, sessionId: data.sessionId, access: access, watchers: [data.watcherId] }; socket.join(docId); } else { documents[docId].users[socket.id].watchers.push(data.watcherId); } callback(null, { docId: docId, watcherId: data.watcherId, str: documents[docId].otServer.document, revision: documents[docId].otServer.operations.length, operations: documents[docId].otServer.getOperationsSince(data.revision), clients: documents[docId].users }); } else { if (Object.hasOwn(documents, docId) && Object.hasOwn(documents[docId].users, socket.id) && documents[docId].users[socket.id].watchers.indexOf(data.watcherId) > -1) { eventData = { docId: data.docId, socketId: socket.id, userId: socket.userId, watcherId: data.watcherId, selection: null }; if (documents[docId].users[socket.id].watchers.length > 1) { webSocket.to(data.docId).emit(CONSTANTS.DOCUMENT_SELECTION, eventData); } else { socket.broadcast.to(data.docId).emit(CONSTANTS.DOCUMENT_SELECTION, eventData); } // Remove the watcherId documents[docId].users[socket.id].watchers.splice( documents[docId].users[socket.id].watchers.indexOf(data.watcherId), 1); if (documents[docId].users[socket.id].watchers.length === 0) { // Last watcher from this socket - leave the room and clean-up. delete documents[docId].users[socket.id]; socket.leave(docId); logger.debug('Client left document', docId); triggerDocumentRemoval(docId); } callback(); } else { logger.warn('Client was never watching document', docId); callback(); } } }) .catch(getErrorHandler(callback)); }); socket.on(CONSTANTS.DOCUMENT_OPERATION, function (data, callback) { var wrappedOperation, eventData; try { if (typeof data.watcherId !== 'string') { throw new Error('data.watcherId not provided!'); } if (Object.hasOwn(documents, data.docId) === false || Object.hasOwn(documents[data.docId].users, socket.id) === false) { throw new Error('Client not watching document - cannot send operation!'); } if (documents[data.docId].users[socket.id].access.write !== true) { throw new Error('Not Authorized to open document. Does not have write access to document'); } data.userId = documents[data.docId].users[socket.id].userId; wrappedOperation = documents[data.docId].otServer.onOperation(data); // Acknowledge, callback(); eventData = { docId: data.docId, watcherId: data.watcherId, socketId: socket.id, userId: socket.userId, operation: wrappedOperation.wrapped.toJSON(), selection: wrappedOperation.selection }; // and then broadcast or emit the operation. if (documents[data.docId].users[socket.id].watchers.length > 1) { webSocket.to(data.docId).emit(CONSTANTS.DOCUMENT_OPERATION, eventData); } else { socket.broadcast.to(data.docId).emit(CONSTANTS.DOCUMENT_OPERATION, eventData); } } catch (err) {