overleaf/services/real-time/app/js/WebsocketController.js
Jakob Ackermann a77222470b [misc] WebsocketControllerTests: always pass cb to updateClientPosition
...and fix the inconsistent async behaviour for logged-in vs anonymous
 users in the app.
2020-06-29 18:11:33 +01:00

609 lines
18 KiB
JavaScript

/* 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 = ''
// consistent async behaviour
setTimeout(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
}