| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221 |
- logger = require "logger-sharelatex"
- metrics = require "metrics-sharelatex"
- WebApiManager = require "./WebApiManager"
- AuthorizationManager = require "./AuthorizationManager"
- DocumentUpdaterManager = require "./DocumentUpdaterManager"
- ConnectedUsersManager = require "./ConnectedUsersManager"
- WebsocketLoadBalancer = require "./WebsocketLoadBalancer"
- Utils = require "./Utils"
- 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 = (error, project, privilegeLevel, protocolVersion) ->) ->
- user_id = user?._id
- logger.log {user_id, project_id, client_id: client.id}, "user joining project"
- metrics.inc "editor.join-project"
- WebApiManager.joinProject project_id, user_id, (error, project, privilegeLevel) ->
- return callback(error) if error?
- if !privilegeLevel or privilegeLevel == ""
- err = new Error("not authorized")
- logger.error {err, project_id, user_id, client_id: client.id}, "user is not authorized to join project"
- return callback(err)
-
- client.join project_id
- client.set("privilege_level", privilegeLevel)
- client.set("user_id", user_id)
- client.set("project_id", project_id)
- client.set("owner_id", project?.owner?._id)
- client.set("first_name", user?.first_name)
- client.set("last_name", user?.last_name)
- client.set("email", user?.email)
- client.set("connected_time", new Date())
- client.set("signup_date", user?.signUpDate)
- client.set("login_count", user?.loginCount)
-
- callback null, project, privilegeLevel, WebsocketController.PROTOCOL_VERSION
- logger.log {user_id, project_id, client_id: client.id}, "user joined project"
-
- # No need to block for setting the user as connected in the cursor tracking
- ConnectedUsersManager.updateUserPosition project_id, client.id, user, null, () ->
-
- # 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 = (error) ->) ->
- metrics.inc "editor.leave-project"
- Utils.getClientAttributes client, ["project_id", "user_id"], (error, {project_id, user_id}) ->
- return callback(error) if error?
- # bail out if the client had not managed to authenticate or join
- # the project. Prevents downstream errors in docupdater from
- # flushProjectToMongoAndDelete with null project_id.
- if not user_id?
- logger.log {client_id: client.id}, "client leaving, unknown user"
- return callback()
- if not project_id?
- logger.log {user_id: user_id, client_id: client.id}, "client leaving, not in project"
- return callback()
- logger.log {project_id, user_id, client_id: client.id}, "client leaving project"
- WebsocketLoadBalancer.emitToRoom project_id, "clientTracking.clientDisconnected", client.id
-
- # We can do this in the background
- ConnectedUsersManager.markUserAsDisconnected project_id, client.id, (err) ->
- if err?
- logger.error {err, project_id, user_id, client_id: client.id}, "error marking client as disconnected"
-
- setTimeout () ->
- remainingClients = io.sockets.clients(project_id)
- if remainingClients.length == 0
- # Flush project in the background
- DocumentUpdaterManager.flushProjectToMongoAndDelete project_id, (err) ->
- if err?
- logger.error {err, project_id, user_id, client_id: client.id}, "error flushing to doc updater after leaving project"
- callback()
- , WebsocketController.FLUSH_IF_EMPTY_DELAY
-
- joinDoc: (client, doc_id, fromVersion = -1, options, callback = (error, doclines, version, ops, ranges) ->) ->
- metrics.inc "editor.join-doc"
- Utils.getClientAttributes client, ["project_id", "user_id"], (error, {project_id, user_id}) ->
- return callback(error) if error?
- return callback(new Error("no project_id found on client")) if !project_id?
- logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joining doc"
-
- AuthorizationManager.assertClientCanViewProject client, (error) ->
- return callback(error) if error?
- DocumentUpdaterManager.getDocument project_id, doc_id, fromVersion, (error, lines, version, ranges, ops) ->
- return callback(error) if error?
- # 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
- encodeForWebsockets = (text) -> unescape(encodeURIComponent(text))
- escapedLines = []
- for line in lines
- try
- line = encodeForWebsockets(line)
- catch err
- 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 comment in ranges?.comments or []
- comment.op.c = encodeForWebsockets(comment.op.c) if comment.op.c?
- for change in ranges?.changes or []
- change.op.i = encodeForWebsockets(change.op.i) if change.op.i?
- change.op.d = encodeForWebsockets(change.op.d) if change.op.d?
- catch err
- 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
- client.join(doc_id)
- callback null, escapedLines, version, ops, ranges
- logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joined doc"
-
- leaveDoc: (client, doc_id, callback = (error) ->) ->
- metrics.inc "editor.leave-doc"
- Utils.getClientAttributes client, ["project_id", "user_id"], (error, {project_id, user_id}) ->
- logger.log {user_id, project_id, doc_id, client_id: client.id}, "client leaving doc"
- client.leave 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
- callback()
-
- updateClientPosition: (client, cursorData, callback = (error) ->) ->
- metrics.inc "editor.update-client-position", 0.1
- Utils.getClientAttributes client, [
- "project_id", "first_name", "last_name", "email", "user_id"
- ], (error, {project_id, first_name, last_name, email, user_id}) ->
- return callback(error) if error?
- logger.log {user_id, project_id, client_id: client.id, cursorData: cursorData}, "updating client position"
-
- AuthorizationManager.assertClientCanViewProjectAndDoc client, cursorData.doc_id, (error) ->
- if error?
- 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.id
- cursorData.user_id = user_id if user_id?
- cursorData.email = email if email?
- if first_name? and last_name?
- cursorData.name = first_name + " " + last_name
- ConnectedUsersManager.updateUserPosition(project_id, client.id, {
- first_name: first_name,
- last_name: last_name,
- email: email,
- _id: user_id
- }, {
- row: cursorData.row,
- column: cursorData.column,
- doc_id: cursorData.doc_id
- }, callback)
- else
- cursorData.name = "Anonymous"
- callback()
- WebsocketLoadBalancer.emitToRoom(project_id, "clientTracking.clientUpdated", cursorData)
- getConnectedUsers: (client, callback = (error, users) ->) ->
- metrics.inc "editor.get-connected-users"
- Utils.getClientAttributes client, ["project_id", "user_id"], (error, {project_id, user_id}) ->
- return callback(error) if error?
- return callback(new Error("no project_id found on client")) if !project_id?
- logger.log {user_id, project_id, client_id: client.id}, "getting connected users"
- AuthorizationManager.assertClientCanViewProject client, (error) ->
- return callback(error) if error?
- ConnectedUsersManager.getConnectedUsers project_id, (error, users) ->
- return callback(error) if error?
- callback null, users
- logger.log {user_id, project_id, client_id: client.id}, "got connected users"
-
- applyOtUpdate: (client, doc_id, update, callback = (error) ->) ->
- Utils.getClientAttributes client, ["user_id", "project_id"], (error, {user_id, project_id}) ->
- return callback(error) if error?
- return callback(new Error("no project_id found on client")) if !project_id?
- WebsocketController._assertClientCanApplyUpdate client, doc_id, update, (error) ->
- if error?
- logger.error {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)
- update.meta ||= {}
- update.meta.source = client.id
- update.meta.user_id = user_id
- metrics.inc "editor.doc-update", 0.3
- metrics.set "editor.active-projects", project_id, 0.3
- metrics.set "editor.active-users", user_id, 0.3
- logger.log {user_id, doc_id, project_id, client_id: client.id, version: update.v}, "sending update to doc updater"
- DocumentUpdaterManager.queueChange project_id, doc_id, update, (error) ->
- if error?
- logger.error {err: error, project_id, doc_id, client_id: client.id, version: update.v}, "document was not available for update"
- client.disconnect()
- callback(error)
- _assertClientCanApplyUpdate: (client, doc_id, update, callback) ->
- AuthorizationManager.assertClientCanEditProjectAndDoc client, doc_id, (error) ->
- if error?
- if error.message == "not authorized" and WebsocketController._isCommentUpdate(update)
- # This might be a comment op, which we only need read-only priveleges for
- AuthorizationManager.assertClientCanViewProjectAndDoc client, doc_id, callback
- else
- return callback(error)
- else
- return callback(null)
-
- _isCommentUpdate: (update) ->
- for op in update.op
- if !op.c?
- return false
- return true
|