2014-11-10 06:27:08 -05:00
|
|
|
logger = require "logger-sharelatex"
|
2014-11-17 08:12:49 -05:00
|
|
|
metrics = require "metrics-sharelatex"
|
2020-03-24 04:12:12 -04:00
|
|
|
settings = require "settings-sharelatex"
|
2014-11-10 06:27:08 -05:00
|
|
|
WebApiManager = require "./WebApiManager"
|
2014-11-12 10:54:55 -05:00
|
|
|
AuthorizationManager = require "./AuthorizationManager"
|
|
|
|
DocumentUpdaterManager = require "./DocumentUpdaterManager"
|
2014-11-13 08:05:49 -05:00
|
|
|
ConnectedUsersManager = require "./ConnectedUsersManager"
|
2014-11-13 11:03:37 -05:00
|
|
|
WebsocketLoadBalancer = require "./WebsocketLoadBalancer"
|
2019-07-18 06:25:10 -04:00
|
|
|
RoomManager = require "./RoomManager"
|
2014-11-13 07:03:43 -05:00
|
|
|
Utils = require "./Utils"
|
2014-11-10 06:27:08 -05:00
|
|
|
|
|
|
|
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
|
2019-10-30 09:52:36 -04:00
|
|
|
|
2014-11-10 06:27:08 -05:00
|
|
|
joinProject: (client, user, project_id, callback = (error, project, privilegeLevel, protocolVersion) ->) ->
|
2020-04-28 12:03:38 -04:00
|
|
|
if client.disconnected
|
2020-05-21 05:08:23 -04:00
|
|
|
metrics.inc('editor.join-project.disconnected', 1, {status: 'immediately'})
|
2020-04-28 12:03:38 -04:00
|
|
|
return callback()
|
|
|
|
|
2014-11-10 06:27:08 -05:00
|
|
|
user_id = user?._id
|
2014-11-12 10:54:55 -05:00
|
|
|
logger.log {user_id, project_id, client_id: client.id}, "user joining project"
|
2014-11-17 08:12:49 -05:00
|
|
|
metrics.inc "editor.join-project"
|
2019-10-30 09:52:36 -04:00
|
|
|
WebApiManager.joinProject project_id, user, (error, project, privilegeLevel, isRestrictedUser) ->
|
2014-11-10 06:27:08 -05:00
|
|
|
return callback(error) if error?
|
2020-04-28 12:03:38 -04:00
|
|
|
if client.disconnected
|
2020-05-21 05:08:23 -04:00
|
|
|
metrics.inc('editor.join-project.disconnected', 1, {status: 'after-web-api-call'})
|
2020-04-28 12:03:38 -04:00
|
|
|
return callback()
|
2014-11-10 06:27:08 -05:00
|
|
|
|
|
|
|
if !privilegeLevel or privilegeLevel == ""
|
|
|
|
err = new Error("not authorized")
|
2019-06-21 01:30:12 -04:00
|
|
|
logger.warn {err, project_id, user_id, client_id: client.id}, "user is not authorized to join project"
|
2014-11-12 10:54:55 -05:00
|
|
|
return callback(err)
|
2019-07-23 12:02:09 -04:00
|
|
|
|
2014-11-12 10:54:55 -05:00
|
|
|
client.set("privilege_level", privilegeLevel)
|
2014-11-10 06:27:08 -05:00
|
|
|
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)
|
2019-10-30 09:52:36 -04:00
|
|
|
client.set("is_restricted_user", !!(isRestrictedUser))
|
|
|
|
|
2019-07-23 12:02:09 -04:00
|
|
|
RoomManager.joinProject client, project_id, (err) ->
|
2020-05-12 06:45:46 -04:00
|
|
|
return callback(err) if err
|
2019-07-23 12:02:09 -04:00
|
|
|
logger.log {user_id, project_id, client_id: client.id}, "user joined project"
|
|
|
|
callback null, project, privilegeLevel, WebsocketController.PROTOCOL_VERSION
|
2019-10-30 09:52:36 -04:00
|
|
|
|
2014-11-13 08:05:49 -05:00
|
|
|
# No need to block for setting the user as connected in the cursor tracking
|
2020-06-04 10:52:13 -04:00
|
|
|
ConnectedUsersManager.updateUserPosition project_id, client.publicId, user, null, () ->
|
2019-10-30 09:52:36 -04:00
|
|
|
|
2014-11-14 11:51:55 -05:00
|
|
|
# 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.
|
2019-10-30 09:52:36 -04:00
|
|
|
FLUSH_IF_EMPTY_DELAY: 500 #ms
|
2014-11-14 11:51:55 -05:00
|
|
|
leaveProject: (io, client, callback = (error) ->) ->
|
|
|
|
Utils.getClientAttributes client, ["project_id", "user_id"], (error, {project_id, user_id}) ->
|
|
|
|
return callback(error) if error?
|
2020-05-12 06:55:04 -04:00
|
|
|
return callback() unless project_id # client did not join project
|
2019-02-12 09:00:47 -05:00
|
|
|
|
2020-05-12 06:55:04 -04:00
|
|
|
metrics.inc "editor.leave-project"
|
2019-02-12 09:00:47 -05:00
|
|
|
logger.log {project_id, user_id, client_id: client.id}, "client leaving project"
|
2020-06-04 10:52:13 -04:00
|
|
|
WebsocketLoadBalancer.emitToRoom project_id, "clientTracking.clientDisconnected", client.publicId
|
2019-10-30 09:52:36 -04:00
|
|
|
|
2014-11-14 11:51:55 -05:00
|
|
|
# We can do this in the background
|
2020-06-04 10:52:13 -04:00
|
|
|
ConnectedUsersManager.markUserAsDisconnected project_id, client.publicId, (err) ->
|
2014-11-14 11:51:55 -05:00
|
|
|
if err?
|
|
|
|
logger.error {err, project_id, user_id, client_id: client.id}, "error marking client as disconnected"
|
2019-10-30 09:52:36 -04:00
|
|
|
|
2019-07-18 06:25:10 -04:00
|
|
|
RoomManager.leaveProjectAndDocs(client)
|
2014-11-14 11:51:55 -05:00
|
|
|
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
|
2019-10-30 09:52:36 -04:00
|
|
|
|
2017-09-21 11:56:09 -04:00
|
|
|
joinDoc: (client, doc_id, fromVersion = -1, options, callback = (error, doclines, version, ops, ranges) ->) ->
|
2020-04-28 12:03:38 -04:00
|
|
|
if client.disconnected
|
2020-05-21 05:08:23 -04:00
|
|
|
metrics.inc('editor.join-doc.disconnected', 1, {status: 'immediately'})
|
2020-04-28 12:03:38 -04:00
|
|
|
return callback()
|
|
|
|
|
2014-11-17 08:12:49 -05:00
|
|
|
metrics.inc "editor.join-doc"
|
2019-10-30 06:15:20 -04:00
|
|
|
Utils.getClientAttributes client, ["project_id", "user_id", "is_restricted_user"], (error, {project_id, user_id, is_restricted_user}) ->
|
2014-11-17 07:46:27 -05:00
|
|
|
return callback(error) if error?
|
|
|
|
return callback(new Error("no project_id found on client")) if !project_id?
|
2014-11-13 07:03:43 -05:00
|
|
|
logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joining doc"
|
2019-10-30 09:52:36 -04:00
|
|
|
|
2014-11-17 07:46:27 -05:00
|
|
|
AuthorizationManager.assertClientCanViewProject client, (error) ->
|
2014-11-12 10:54:55 -05:00
|
|
|
return callback(error) if error?
|
2019-07-24 10:43:48 -04:00
|
|
|
# ensure the per-doc applied-ops channel is subscribed before sending the
|
|
|
|
# doc to the client, so that no events are missed.
|
|
|
|
RoomManager.joinDoc client, doc_id, (error) ->
|
2014-11-12 10:54:55 -05:00
|
|
|
return callback(error) if error?
|
2020-04-28 12:03:38 -04:00
|
|
|
if client.disconnected
|
2020-05-21 05:08:23 -04:00
|
|
|
metrics.inc('editor.join-doc.disconnected', 1, {status: 'after-joining-room'})
|
2020-04-28 12:03:38 -04:00
|
|
|
# the client will not read the response anyways
|
|
|
|
return callback()
|
|
|
|
|
2019-07-24 10:43:48 -04:00
|
|
|
DocumentUpdaterManager.getDocument project_id, doc_id, fromVersion, (error, lines, version, ranges, ops) ->
|
|
|
|
return callback(error) if error?
|
2020-04-28 12:03:38 -04:00
|
|
|
if client.disconnected
|
2020-05-21 05:08:23 -04:00
|
|
|
metrics.inc('editor.join-doc.disconnected', 1, {status: 'after-doc-updater-call'})
|
2020-04-28 12:03:38 -04:00
|
|
|
# the client will not read the response anyways
|
|
|
|
return callback()
|
2017-09-21 10:07:15 -04:00
|
|
|
|
2019-10-30 06:15:20 -04:00
|
|
|
if is_restricted_user and ranges?.comments?
|
|
|
|
ranges.comments = []
|
|
|
|
|
2019-07-24 10:43:48 -04:00
|
|
|
# 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)
|
2017-09-21 08:25:55 -04:00
|
|
|
|
2019-07-24 10:43:48 -04:00
|
|
|
AuthorizationManager.addAccessToDoc client, doc_id
|
2019-07-23 12:02:09 -04:00
|
|
|
logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joined doc"
|
|
|
|
callback null, escapedLines, version, ops, ranges
|
2019-10-30 09:52:36 -04:00
|
|
|
|
2014-11-12 11:51:48 -05:00
|
|
|
leaveDoc: (client, doc_id, callback = (error) ->) ->
|
2020-04-28 12:03:38 -04:00
|
|
|
# client may have disconnected, but we have to cleanup internal state.
|
2014-11-17 08:12:49 -05:00
|
|
|
metrics.inc "editor.leave-doc"
|
2014-11-13 07:03:43 -05:00
|
|
|
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"
|
2019-07-18 06:25:10 -04:00
|
|
|
RoomManager.leaveDoc(client, doc_id)
|
2016-09-05 07:35:49 -04:00
|
|
|
# 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
|
2016-09-02 11:35:00 -04:00
|
|
|
callback()
|
2014-11-13 10:27:18 -05:00
|
|
|
updateClientPosition: (client, cursorData, callback = (error) ->) ->
|
2020-04-28 12:03:38 -04:00
|
|
|
if client.disconnected
|
|
|
|
# do not create a ghost entry in redis
|
|
|
|
return callback()
|
|
|
|
|
2014-11-17 08:12:49 -05:00
|
|
|
metrics.inc "editor.update-client-position", 0.1
|
2014-11-13 10:27:18 -05:00
|
|
|
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"
|
2019-02-12 09:06:59 -05:00
|
|
|
|
2016-09-02 11:35:00 -04:00
|
|
|
AuthorizationManager.assertClientCanViewProjectAndDoc client, cursorData.doc_id, (error) ->
|
2014-11-24 10:42:26 -05:00
|
|
|
if error?
|
2016-12-08 06:12:07 -05:00
|
|
|
logger.warn {err: error, client_id: client.id, project_id, user_id}, "silently ignoring unauthorized updateClientPosition. Client likely hasn't called joinProject yet."
|
2016-09-02 11:34:14 -04:00
|
|
|
return callback()
|
2020-06-04 10:52:13 -04:00
|
|
|
cursorData.id = client.publicId
|
2014-11-24 07:05:05 -05:00
|
|
|
cursorData.user_id = user_id if user_id?
|
|
|
|
cursorData.email = email if email?
|
2019-02-12 09:06:59 -05:00
|
|
|
# Don't store anonymous users in redis to avoid influx
|
2019-02-12 09:00:47 -05:00
|
|
|
if !user_id or user_id == 'anonymous-user'
|
|
|
|
cursorData.name = ""
|
|
|
|
callback()
|
2019-02-12 09:06:59 -05:00
|
|
|
else
|
2017-12-13 05:28:35 -05:00
|
|
|
cursorData.name = if first_name && last_name
|
2017-12-12 10:27:50 -05:00
|
|
|
"#{first_name} #{last_name}"
|
2017-12-13 05:28:35 -05:00
|
|
|
else if first_name
|
|
|
|
first_name
|
|
|
|
else if last_name
|
|
|
|
last_name
|
2019-02-12 09:00:47 -05:00
|
|
|
else
|
|
|
|
""
|
2020-06-04 10:52:13 -04:00
|
|
|
ConnectedUsersManager.updateUserPosition(project_id, client.publicId, {
|
2014-11-24 07:05:05 -05:00
|
|
|
first_name: first_name,
|
|
|
|
last_name: last_name,
|
|
|
|
email: email,
|
2015-02-05 08:41:31 -05:00
|
|
|
_id: user_id
|
2014-11-24 07:05:05 -05:00
|
|
|
}, {
|
|
|
|
row: cursorData.row,
|
|
|
|
column: cursorData.column,
|
|
|
|
doc_id: cursorData.doc_id
|
|
|
|
}, callback)
|
|
|
|
WebsocketLoadBalancer.emitToRoom(project_id, "clientTracking.clientUpdated", cursorData)
|
2016-09-02 11:35:00 -04:00
|
|
|
|
2019-08-13 05:39:52 -04:00
|
|
|
CLIENT_REFRESH_DELAY: 1000
|
2014-11-13 08:05:49 -05:00
|
|
|
getConnectedUsers: (client, callback = (error, users) ->) ->
|
2020-04-28 12:03:38 -04:00
|
|
|
if client.disconnected
|
|
|
|
# they are not interested anymore, skip the redis lookups
|
|
|
|
return callback()
|
|
|
|
|
2014-11-17 08:12:49 -05:00
|
|
|
metrics.inc "editor.get-connected-users"
|
2019-10-30 09:52:36 -04:00
|
|
|
Utils.getClientAttributes client, ["project_id", "user_id", "is_restricted_user"], (error, clientAttributes) ->
|
2014-11-13 08:05:49 -05:00
|
|
|
return callback(error) if error?
|
2019-10-30 09:52:36 -04:00
|
|
|
{project_id, user_id, is_restricted_user} = clientAttributes
|
|
|
|
if is_restricted_user
|
|
|
|
return callback(null, [])
|
2014-11-17 07:46:27 -05:00
|
|
|
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) ->
|
2014-11-13 08:05:49 -05:00
|
|
|
return callback(error) if error?
|
2019-08-14 08:03:06 -04:00
|
|
|
WebsocketLoadBalancer.emitToRoom project_id, 'clientTracking.refresh'
|
2019-08-13 05:39:52 -04:00
|
|
|
setTimeout () ->
|
|
|
|
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"
|
|
|
|
, WebsocketController.CLIENT_REFRESH_DELAY
|
2014-11-13 12:07:05 -05:00
|
|
|
|
2014-11-14 05:12:35 -05:00
|
|
|
applyOtUpdate: (client, doc_id, update, callback = (error) ->) ->
|
2020-04-28 12:03:38 -04:00
|
|
|
# client may have disconnected, but we can submit their update to doc-updater anyways.
|
2014-11-17 07:46:27 -05:00
|
|
|
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?
|
2020-03-24 04:12:12 -04:00
|
|
|
|
2017-03-15 11:45:52 -04:00
|
|
|
WebsocketController._assertClientCanApplyUpdate client, doc_id, update, (error) ->
|
2014-11-17 07:46:27 -05:00
|
|
|
if error?
|
2017-11-10 10:01:23 -05:00
|
|
|
logger.warn {err: error, doc_id, client_id: client.id, version: update.v}, "client is not authorized to make update"
|
2014-11-17 07:46:27 -05:00
|
|
|
setTimeout () ->
|
|
|
|
# Disconnect, but give the client the chance to receive the error
|
|
|
|
client.disconnect()
|
|
|
|
, 100
|
|
|
|
return callback(error)
|
2014-11-13 12:07:05 -05:00
|
|
|
update.meta ||= {}
|
2020-06-04 10:52:13 -04:00
|
|
|
update.meta.source = client.publicId
|
2014-11-13 12:07:05 -05:00
|
|
|
update.meta.user_id = user_id
|
2014-11-17 08:12:49 -05:00
|
|
|
metrics.inc "editor.doc-update", 0.3
|
2014-11-13 12:07:05 -05:00
|
|
|
|
2017-03-15 11:45:18 -04:00
|
|
|
logger.log {user_id, doc_id, project_id, client_id: client.id, version: update.v}, "sending update to doc updater"
|
2014-11-13 12:07:05 -05:00
|
|
|
|
|
|
|
DocumentUpdaterManager.queueChange project_id, doc_id, update, (error) ->
|
2020-03-24 06:22:28 -04:00
|
|
|
if error?.message == "update is too large"
|
|
|
|
metrics.inc "update_too_large"
|
|
|
|
updateSize = error.updateSize
|
|
|
|
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
|
|
|
|
message = {project_id, doc_id, error: "update is too large"}
|
|
|
|
setTimeout () ->
|
2020-04-28 12:03:38 -04:00
|
|
|
if client.disconnected
|
|
|
|
# skip the message broadcast, the client has moved on
|
2020-05-21 05:08:23 -04:00
|
|
|
return metrics.inc('editor.doc-update.disconnected', 1, {status:'at-otUpdateError'})
|
2020-03-24 06:22:28 -04:00
|
|
|
client.emit "otUpdateError", message.error, message
|
|
|
|
client.disconnect()
|
|
|
|
, 100
|
|
|
|
return
|
|
|
|
|
2014-11-13 12:07:05 -05:00
|
|
|
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)
|
2017-03-15 11:45:52 -04:00
|
|
|
|
|
|
|
_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)
|
2019-10-30 09:52:36 -04:00
|
|
|
|
2017-03-15 11:45:52 -04:00
|
|
|
_isCommentUpdate: (update) ->
|
|
|
|
for op in update.op
|
|
|
|
if !op.c?
|
|
|
|
return false
|
2019-10-30 09:52:36 -04:00
|
|
|
return true
|