mirror of
https://github.com/overleaf/overleaf.git
synced 2024-09-16 02:52:31 -04:00
6765d03339
Then, don't send new chat messages and new comments to those restricted clients. We do this because we don't want to leak private information (email addresses and names) to "restricted" users, those who have read-only access via a shared token.
235 lines
11 KiB
CoffeeScript
235 lines
11 KiB
CoffeeScript
logger = require "logger-sharelatex"
|
|
metrics = require "metrics-sharelatex"
|
|
WebApiManager = require "./WebApiManager"
|
|
AuthorizationManager = require "./AuthorizationManager"
|
|
DocumentUpdaterManager = require "./DocumentUpdaterManager"
|
|
ConnectedUsersManager = require "./ConnectedUsersManager"
|
|
WebsocketLoadBalancer = require "./WebsocketLoadBalancer"
|
|
RoomManager = require "./RoomManager"
|
|
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, (error, project, privilegeLevel, isRestrictedUser) ->
|
|
return callback(error) if error?
|
|
|
|
if !privilegeLevel or privilegeLevel == ""
|
|
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.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)
|
|
client.set("is_restricted_user", !!(isRestrictedUser))
|
|
|
|
RoomManager.joinProject client, project_id, (err) ->
|
|
logger.log {user_id, project_id, client_id: client.id}, "user joined project"
|
|
callback null, project, privilegeLevel, WebsocketController.PROTOCOL_VERSION
|
|
|
|
# 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?
|
|
|
|
logger.log {project_id, user_id, client_id: client.id}, "client leaving project"
|
|
WebsocketLoadBalancer.emitToRoom project_id, "clientTracking.clientDisconnected", client.id
|
|
|
|
# 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()
|
|
|
|
# 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"
|
|
|
|
RoomManager.leaveProjectAndDocs(client)
|
|
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?
|
|
# 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) ->
|
|
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
|
|
logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joined doc"
|
|
callback null, escapedLines, version, ops, ranges
|
|
|
|
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"
|
|
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
|
|
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?
|
|
# Don't store anonymous users in redis to avoid influx
|
|
if !user_id or user_id == 'anonymous-user'
|
|
cursorData.name = ""
|
|
callback()
|
|
else
|
|
cursorData.name = if first_name && last_name
|
|
"#{first_name} #{last_name}"
|
|
else if first_name
|
|
first_name
|
|
else if last_name
|
|
last_name
|
|
else
|
|
""
|
|
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)
|
|
WebsocketLoadBalancer.emitToRoom(project_id, "clientTracking.clientUpdated", cursorData)
|
|
|
|
CLIENT_REFRESH_DELAY: 1000
|
|
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?
|
|
WebsocketLoadBalancer.emitToRoom project_id, 'clientTracking.refresh'
|
|
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
|
|
|
|
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.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)
|
|
update.meta ||= {}
|
|
update.meta.source = client.id
|
|
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"
|
|
|
|
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
|