overleaf/services/real-time/app/coffee/WebsocketController.coffee
Shane Kilkelly 6765d03339 Track the isRestrictedUser flag on clients
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.
2019-10-04 10:30:24 +01:00

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