/* eslint-disable camelcase, handle-callback-err, no-unused-vars, */ // TODO: This file was created by bulk-decaffeinate. // Fix any style issues and re-enable lint. /* * decaffeinate suggestions: * DS101: Remove unnecessary use of Array.from * DS102: Remove unnecessary code created because of implicit returns * DS103: Rewrite code to no longer use __guard__ * DS207: Consider shorter variations of null checks * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md */ let WebsocketController const logger = require('logger-sharelatex') const metrics = require('metrics-sharelatex') const settings = require('settings-sharelatex') const WebApiManager = require('./WebApiManager') const AuthorizationManager = require('./AuthorizationManager') const DocumentUpdaterManager = require('./DocumentUpdaterManager') const ConnectedUsersManager = require('./ConnectedUsersManager') const WebsocketLoadBalancer = require('./WebsocketLoadBalancer') const RoomManager = require('./RoomManager') module.exports = WebsocketController = { // If the protocol version changes when the client reconnects, // it will force a full refresh of the page. Useful for non-backwards // compatible protocol changes. Use only in extreme need. PROTOCOL_VERSION: 2, joinProject(client, user, project_id, callback) { if (callback == null) { callback = function (error, project, privilegeLevel, protocolVersion) {} } if (client.disconnected) { metrics.inc('editor.join-project.disconnected', 1, { status: 'immediately' }) return callback() } const user_id = user != null ? user._id : undefined logger.log( { user_id, project_id, client_id: client.id }, 'user joining project' ) metrics.inc('editor.join-project') return WebApiManager.joinProject(project_id, user, function ( error, project, privilegeLevel, isRestrictedUser ) { if (error != null) { return callback(error) } if (client.disconnected) { metrics.inc('editor.join-project.disconnected', 1, { status: 'after-web-api-call' }) return callback() } if (!privilegeLevel || privilegeLevel === '') { const err = new Error('not authorized') logger.warn( { err, project_id, user_id, client_id: client.id }, 'user is not authorized to join project' ) return callback(err) } client.ol_context = {} client.ol_context.privilege_level = privilegeLevel client.ol_context.user_id = user_id client.ol_context.project_id = project_id client.ol_context.owner_id = __guard__( project != null ? project.owner : undefined, (x) => x._id ) client.ol_context.first_name = user != null ? user.first_name : undefined client.ol_context.last_name = user != null ? user.last_name : undefined client.ol_context.email = user != null ? user.email : undefined client.ol_context.connected_time = new Date() client.ol_context.signup_date = user != null ? user.signUpDate : undefined client.ol_context.login_count = user != null ? user.loginCount : undefined client.ol_context.is_restricted_user = !!isRestrictedUser RoomManager.joinProject(client, project_id, function (err) { if (err) { return callback(err) } logger.log( { user_id, project_id, client_id: client.id }, 'user joined project' ) return callback( null, project, privilegeLevel, WebsocketController.PROTOCOL_VERSION ) }) // No need to block for setting the user as connected in the cursor tracking return ConnectedUsersManager.updateUserPosition( project_id, client.publicId, user, null, function () {} ) }) }, // We want to flush a project if there are no more (local) connected clients // but we need to wait for the triggering client to disconnect. How long we wait // is determined by FLUSH_IF_EMPTY_DELAY. FLUSH_IF_EMPTY_DELAY: 500, // ms leaveProject(io, client, callback) { if (callback == null) { callback = function (error) {} } const { project_id, user_id } = client.ol_context if (!project_id) { return callback() } // client did not join project metrics.inc('editor.leave-project') logger.log( { project_id, user_id, client_id: client.id }, 'client leaving project' ) WebsocketLoadBalancer.emitToRoom( project_id, 'clientTracking.clientDisconnected', client.publicId ) // We can do this in the background ConnectedUsersManager.markUserAsDisconnected( project_id, client.publicId, function (err) { if (err != null) { return logger.error( { err, project_id, user_id, client_id: client.id }, 'error marking client as disconnected' ) } } ) RoomManager.leaveProjectAndDocs(client) return setTimeout(function () { const remainingClients = io.sockets.clients(project_id) if (remainingClients.length === 0) { // Flush project in the background DocumentUpdaterManager.flushProjectToMongoAndDelete( project_id, function (err) { if (err != null) { return logger.error( { err, project_id, user_id, client_id: client.id }, 'error flushing to doc updater after leaving project' ) } } ) } return callback() }, WebsocketController.FLUSH_IF_EMPTY_DELAY) }, joinDoc(client, doc_id, fromVersion, options, callback) { if (fromVersion == null) { fromVersion = -1 } if (callback == null) { callback = function (error, doclines, version, ops, ranges) {} } if (client.disconnected) { metrics.inc('editor.join-doc.disconnected', 1, { status: 'immediately' }) return callback() } metrics.inc('editor.join-doc') const { project_id, user_id, is_restricted_user } = client.ol_context if (project_id == null) { return callback(new Error('no project_id found on client')) } logger.log( { user_id, project_id, doc_id, fromVersion, client_id: client.id }, 'client joining doc' ) return AuthorizationManager.assertClientCanViewProject(client, function ( error ) { if (error != null) { return callback(error) } // ensure the per-doc applied-ops channel is subscribed before sending the // doc to the client, so that no events are missed. return RoomManager.joinDoc(client, doc_id, function (error) { if (error != null) { return callback(error) } if (client.disconnected) { metrics.inc('editor.join-doc.disconnected', 1, { status: 'after-joining-room' }) // the client will not read the response anyways return callback() } return DocumentUpdaterManager.getDocument( project_id, doc_id, fromVersion, function (error, lines, version, ranges, ops) { let err if (error != null) { return callback(error) } if (client.disconnected) { metrics.inc('editor.join-doc.disconnected', 1, { status: 'after-doc-updater-call' }) // the client will not read the response anyways return callback() } if ( is_restricted_user && (ranges != null ? ranges.comments : undefined) != null ) { ranges.comments = [] } // Encode any binary bits of data so it can go via WebSockets // See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html const encodeForWebsockets = (text) => unescape(encodeURIComponent(text)) const escapedLines = [] for (let line of Array.from(lines)) { try { line = encodeForWebsockets(line) } catch (error1) { err = error1 logger.err( { err, project_id, doc_id, fromVersion, line, client_id: client.id }, 'error encoding line uri component' ) return callback(err) } escapedLines.push(line) } if (options.encodeRanges) { try { for (const comment of Array.from( (ranges != null ? ranges.comments : undefined) || [] )) { if (comment.op.c != null) { comment.op.c = encodeForWebsockets(comment.op.c) } } for (const change of Array.from( (ranges != null ? ranges.changes : undefined) || [] )) { if (change.op.i != null) { change.op.i = encodeForWebsockets(change.op.i) } if (change.op.d != null) { change.op.d = encodeForWebsockets(change.op.d) } } } catch (error2) { err = error2 logger.err( { err, project_id, doc_id, fromVersion, ranges, client_id: client.id }, 'error encoding range uri component' ) return callback(err) } } AuthorizationManager.addAccessToDoc(client, doc_id) logger.log( { user_id, project_id, doc_id, fromVersion, client_id: client.id }, 'client joined doc' ) return callback(null, escapedLines, version, ops, ranges) } ) }) }) }, leaveDoc(client, doc_id, callback) { // client may have disconnected, but we have to cleanup internal state. if (callback == null) { callback = function (error) {} } metrics.inc('editor.leave-doc') const { project_id, user_id } = client.ol_context logger.log( { user_id, project_id, doc_id, client_id: client.id }, 'client leaving doc' ) RoomManager.leaveDoc(client, doc_id) // we could remove permission when user leaves a doc, but because // the connection is per-project, we continue to allow access // after the initial joinDoc since we know they are already authorised. // # AuthorizationManager.removeAccessToDoc client, doc_id return callback() }, updateClientPosition(client, cursorData, callback) { if (callback == null) { callback = function (error) {} } if (client.disconnected) { // do not create a ghost entry in redis return callback() } metrics.inc('editor.update-client-position', 0.1) const { project_id, first_name, last_name, email, user_id } = client.ol_context logger.log( { user_id, project_id, client_id: client.id, cursorData }, 'updating client position' ) return AuthorizationManager.assertClientCanViewProjectAndDoc( client, cursorData.doc_id, function (error) { if (error != null) { logger.warn( { err: error, client_id: client.id, project_id, user_id }, "silently ignoring unauthorized updateClientPosition. Client likely hasn't called joinProject yet." ) return callback() } cursorData.id = client.publicId if (user_id != null) { cursorData.user_id = user_id } if (email != null) { cursorData.email = email } // Don't store anonymous users in redis to avoid influx if (!user_id || user_id === 'anonymous-user') { cursorData.name = '' callback() } else { cursorData.name = first_name && last_name ? `${first_name} ${last_name}` : first_name || last_name || '' ConnectedUsersManager.updateUserPosition( project_id, client.publicId, { first_name, last_name, email, _id: user_id }, { row: cursorData.row, column: cursorData.column, doc_id: cursorData.doc_id }, callback ) } return WebsocketLoadBalancer.emitToRoom( project_id, 'clientTracking.clientUpdated', cursorData ) } ) }, CLIENT_REFRESH_DELAY: 1000, getConnectedUsers(client, callback) { if (callback == null) { callback = function (error, users) {} } if (client.disconnected) { // they are not interested anymore, skip the redis lookups return callback() } metrics.inc('editor.get-connected-users') const { project_id, user_id, is_restricted_user } = client.ol_context if (is_restricted_user) { return callback(null, []) } if (project_id == null) { return callback(new Error('no project_id found on client')) } logger.log( { user_id, project_id, client_id: client.id }, 'getting connected users' ) return AuthorizationManager.assertClientCanViewProject(client, function ( error ) { if (error != null) { return callback(error) } WebsocketLoadBalancer.emitToRoom(project_id, 'clientTracking.refresh') return setTimeout( () => ConnectedUsersManager.getConnectedUsers(project_id, function ( error, users ) { if (error != null) { return callback(error) } callback(null, users) return logger.log( { user_id, project_id, client_id: client.id }, 'got connected users' ) }), WebsocketController.CLIENT_REFRESH_DELAY ) }) }, applyOtUpdate(client, doc_id, update, callback) { // client may have disconnected, but we can submit their update to doc-updater anyways. if (callback == null) { callback = function (error) {} } const { user_id, project_id } = client.ol_context if (project_id == null) { return callback(new Error('no project_id found on client')) } return WebsocketController._assertClientCanApplyUpdate( client, doc_id, update, function (error) { if (error != null) { logger.warn( { err: error, doc_id, client_id: client.id, version: update.v }, 'client is not authorized to make update' ) setTimeout( () => // Disconnect, but give the client the chance to receive the error client.disconnect(), 100 ) return callback(error) } if (!update.meta) { update.meta = {} } update.meta.source = client.publicId update.meta.user_id = user_id metrics.inc('editor.doc-update', 0.3) logger.log( { user_id, doc_id, project_id, client_id: client.id, version: update.v }, 'sending update to doc updater' ) return DocumentUpdaterManager.queueChange( project_id, doc_id, update, function (error) { if ( (error != null ? error.message : undefined) === 'update is too large' ) { metrics.inc('update_too_large') const { updateSize } = error logger.warn( { user_id, project_id, doc_id, updateSize }, 'update is too large' ) // mark the update as received -- the client should not send it again! callback() // trigger an out-of-sync error const message = { project_id, doc_id, error: 'update is too large' } setTimeout(function () { if (client.disconnected) { // skip the message broadcast, the client has moved on return metrics.inc('editor.doc-update.disconnected', 1, { status: 'at-otUpdateError' }) } client.emit('otUpdateError', message.error, message) return client.disconnect() }, 100) return } if (error != null) { logger.error( { err: error, project_id, doc_id, client_id: client.id, version: update.v }, 'document was not available for update' ) client.disconnect() } return callback(error) } ) } ) }, _assertClientCanApplyUpdate(client, doc_id, update, callback) { return AuthorizationManager.assertClientCanEditProjectAndDoc( client, doc_id, function (error) { if (error != null) { if ( error.message === 'not authorized' && WebsocketController._isCommentUpdate(update) ) { // This might be a comment op, which we only need read-only priveleges for return AuthorizationManager.assertClientCanViewProjectAndDoc( client, doc_id, callback ) } else { return callback(error) } } else { return callback(null) } } ) }, _isCommentUpdate(update) { for (const op of Array.from(update.op)) { if (op.c == null) { return false } } return true } } function __guard__(value, transform) { return typeof value !== 'undefined' && value !== null ? transform(value) : undefined }