overleaf/services/real-time/app/js/Router.js
Jakob Ackermann 78c1687f3b Merge pull request #16003 from overleaf/jpa-real-time-remove-join-project
[real-time] remove legacy joinProject rpc and require auto-join-project

GitOrigin-RevId: 2b610c83a34e516e0da0fa8a17858ec5c98c5bdd
2023-11-30 09:03:58 +00:00

453 lines
14 KiB
JavaScript

const metrics = require('@overleaf/metrics')
const logger = require('@overleaf/logger')
const settings = require('@overleaf/settings')
const WebsocketController = require('./WebsocketController')
const HttpController = require('./HttpController')
const HttpApiController = require('./HttpApiController')
const WebsocketAddressManager = require('./WebsocketAddressManager')
const bodyParser = require('body-parser')
const base64id = require('base64id')
const { UnexpectedArgumentsError } = require('./Errors')
const Joi = require('@hapi/joi')
const HOSTNAME = require('os').hostname()
const JOI_OBJECT_ID = Joi.string()
.required()
.regex(/^[0-9a-f]{24}$/)
.message('invalid id')
let Router
module.exports = Router = {
_handleError(callback, error, client, method, attrs) {
attrs = attrs || {}
for (const key of ['project_id', 'user_id']) {
attrs[key] = attrs[key] || client.ol_context[key]
}
attrs.client_id = client.id
attrs.err = error
attrs.method = method
if (Joi.isError(error)) {
logger.info(attrs, 'validation error')
let message = 'invalid'
try {
message = error.details[0].message
} catch (e) {
// ignore unexpected errors
logger.warn({ error, e }, 'unexpected validation error')
}
const serializedError = { message }
metrics.inc('validation-error', 1, {
status: method,
})
callback(serializedError)
} else if (error.name === 'CodedError') {
logger.warn(attrs, error.message)
const serializedError = { message: error.message, code: error.info.code }
callback(serializedError)
} else if (error.message === 'unexpected arguments') {
// the payload might be very large; put it on level debug
logger.debug(attrs, 'unexpected arguments')
metrics.inc('unexpected-arguments', 1, { status: method })
const serializedError = { message: error.message }
callback(serializedError)
} else if (error.message === 'no project_id found on client') {
logger.debug(attrs, error.message)
const serializedError = { message: error.message }
callback(serializedError)
} else if (
[
'not authorized',
'joinLeaveEpoch mismatch',
'doc updater could not load requested ops',
'no project_id found on client',
'cannot join multiple projects',
].includes(error.message)
) {
logger.warn(attrs, error.message)
const serializedError = { message: error.message }
callback(serializedError)
} else {
logger.error(attrs, `server side error in ${method}`)
// Don't return raw error to prevent leaking server side info
const serializedError = {
message: 'Something went wrong in real-time service',
}
callback(serializedError)
}
if (attrs.disconnect) {
setTimeout(function () {
client.disconnect()
}, 100)
}
},
_handleInvalidArguments(client, method, args) {
const error = new UnexpectedArgumentsError()
let callback = args[args.length - 1]
if (typeof callback !== 'function') {
callback = function () {}
}
const attrs = { arguments: args }
Router._handleError(callback, error, client, method, attrs)
},
configure(app, io, session) {
app.set('io', io)
if (settings.behindProxy) {
app.set('trust proxy', settings.trustedProxyIps)
}
const websocketAddressManager = new WebsocketAddressManager(
settings.behindProxy,
settings.trustedProxyIps
)
app.get('/clients', HttpController.getConnectedClients)
app.get('/clients/:client_id', HttpController.getConnectedClient)
app.post(
'/project/:project_id/message/:message',
bodyParser.json({ limit: '5mb' }),
HttpApiController.sendMessage
)
app.post('/drain', HttpApiController.startDrain)
app.post(
'/client/:client_id/disconnect',
HttpApiController.disconnectClient
)
session.on('connection', function (error, client, session) {
// init client context, we may access it in Router._handleError before
// setting any values
client.ol_context = {}
// bail out from joinDoc when a parallel joinDoc or leaveDoc is running
client.joinLeaveEpoch = 0
if (client) {
client.on('error', function (err) {
logger.err({ clientErr: err }, 'socket.io client error')
if (client.connected) {
client.emit('reconnectGracefully')
client.disconnect()
}
})
}
if (settings.shutDownInProgress) {
client.emit('connectionRejected', { message: 'retry' })
client.disconnect()
return
}
if (
client &&
error &&
error.message.match(/could not look up session by key/)
) {
logger.warn(
{ err: error, client: !!client, session: !!session },
'invalid session'
)
// tell the client to reauthenticate if it has an invalid session key
client.emit('connectionRejected', { message: 'invalid session' })
client.disconnect()
return
}
if (error) {
logger.err(
{ err: error, client: !!client, session: !!session },
'error when client connected'
)
if (client) {
client.emit('connectionRejected', { message: 'error' })
}
if (client) {
client.disconnect()
}
return
}
const projectId = client.handshake?.query?.projectId
try {
Joi.assert(projectId, JOI_OBJECT_ID)
} catch (error) {
metrics.inc('socket-io.connection', 1, {
status: client.transport,
method: projectId ? 'bad-project-id' : 'missing-project-id',
})
client.emit('connectionRejected', {
message: 'missing/bad ?projectId=... query flag on handshake',
})
client.disconnect()
return
}
// The client.id is security sensitive. Generate a publicId for sending to other clients.
client.publicId = 'P.' + base64id.generateId()
client.remoteIp = websocketAddressManager.getRemoteIp(client.handshake)
const headers = client.handshake && client.handshake.headers
client.userAgent = headers && headers['user-agent']
metrics.inc('socket-io.connection', 1, {
status: client.transport,
method: 'auto-join-project',
})
metrics.gauge('socket-io.clients', io.sockets.clients().length)
logger.debug({ session, clientId: client.id }, 'client connected')
let user
if (session && session.passport && session.passport.user) {
;({ user } = session.passport)
} else if (session && session.user) {
;({ user } = session)
} else {
const anonymousAccessToken = session?.anonTokenAccess?.[projectId]
user = { _id: 'anonymous-user', anonymousAccessToken }
}
if (settings.exposeHostname) {
client.on('debug.getHostname', function (callback) {
if (typeof callback !== 'function') {
return Router._handleInvalidArguments(
client,
'debug.getHostname',
arguments
)
}
callback(HOSTNAME)
})
}
const joinProject = function (callback) {
WebsocketController.joinProject(
client,
user,
projectId,
function (err, ...args) {
if (err) {
Router._handleError(callback, err, client, 'joinProject', {
project_id: projectId,
user_id: user._id,
})
} else {
callback(null, ...args)
}
}
)
}
client.on('disconnect', function () {
metrics.inc('socket-io.disconnect', 1, { status: client.transport })
metrics.gauge('socket-io.clients', io.sockets.clients().length)
WebsocketController.leaveProject(io, client, function (err) {
if (err) {
Router._handleError(function () {}, err, client, 'leaveProject')
}
})
})
// Variadic. The possible arguments:
// doc_id, callback
// doc_id, fromVersion, callback
// doc_id, options, callback
// doc_id, fromVersion, options, callback
client.on('joinDoc', function (docId, fromVersion, options, callback) {
if (typeof fromVersion === 'function' && !options) {
callback = fromVersion
fromVersion = -1
options = {}
} else if (
typeof fromVersion === 'number' &&
typeof options === 'function'
) {
callback = options
options = {}
} else if (
typeof fromVersion === 'object' &&
typeof options === 'function'
) {
callback = options
options = fromVersion
fromVersion = -1
} else if (
typeof fromVersion === 'number' &&
typeof options === 'object' &&
typeof callback === 'function'
) {
// Called with 4 args, things are as expected
} else {
return Router._handleInvalidArguments(client, 'joinDoc', arguments)
}
try {
Joi.assert(
{ doc_id: docId, fromVersion, options },
Joi.object({
doc_id: JOI_OBJECT_ID,
fromVersion: Joi.number().integer(),
options: Joi.object().required(),
})
)
} catch (error) {
return Router._handleError(callback, error, client, 'joinDoc', {
disconnect: 1,
})
}
WebsocketController.joinDoc(
client,
docId,
fromVersion,
options,
function (err, ...args) {
if (err) {
Router._handleError(callback, err, client, 'joinDoc', {
doc_id: docId,
fromVersion,
})
} else {
callback(null, ...args)
}
}
)
})
client.on('leaveDoc', function (docId, callback) {
if (typeof callback !== 'function') {
return Router._handleInvalidArguments(client, 'leaveDoc', arguments)
}
try {
Joi.assert(docId, JOI_OBJECT_ID)
} catch (error) {
return Router._handleError(callback, error, client, 'joinDoc', {
disconnect: 1,
})
}
WebsocketController.leaveDoc(client, docId, function (err, ...args) {
if (err) {
Router._handleError(callback, err, client, 'leaveDoc', {
doc_id: docId,
})
} else {
callback(null, ...args)
}
})
})
client.on('clientTracking.getConnectedUsers', function (callback) {
if (typeof callback !== 'function') {
return Router._handleInvalidArguments(
client,
'clientTracking.getConnectedUsers',
arguments
)
}
WebsocketController.getConnectedUsers(client, function (err, users) {
if (err) {
Router._handleError(
callback,
err,
client,
'clientTracking.getConnectedUsers'
)
} else {
callback(null, users)
}
})
})
client.on(
'clientTracking.updatePosition',
function (cursorData, callback) {
if (!callback) {
callback = function () {
// NOTE: The frontend does not pass any callback to socket.io.
// Any error is already logged via Router._handleError.
}
}
if (typeof callback !== 'function') {
return Router._handleInvalidArguments(
client,
'clientTracking.updatePosition',
arguments
)
}
WebsocketController.updateClientPosition(
client,
cursorData,
function (err) {
if (err) {
Router._handleError(
callback,
err,
client,
'clientTracking.updatePosition'
)
} else {
callback()
}
}
)
}
)
client.on('applyOtUpdate', function (docId, update, callback) {
if (typeof callback !== 'function') {
return Router._handleInvalidArguments(
client,
'applyOtUpdate',
arguments
)
}
try {
Joi.assert(
{ doc_id: docId, update },
Joi.object({
doc_id: JOI_OBJECT_ID,
update: Joi.object().required(),
})
)
} catch (error) {
return Router._handleError(callback, error, client, 'applyOtUpdate', {
disconnect: 1,
})
}
WebsocketController.applyOtUpdate(
client,
docId,
update,
function (err) {
if (err) {
Router._handleError(callback, err, client, 'applyOtUpdate', {
doc_id: docId,
update,
})
} else {
callback()
}
}
)
})
joinProject((err, project, permissionsLevel, protocolVersion) => {
if (err) {
client.emit('connectionRejected', err)
client.disconnect()
return
}
client.emit('joinProjectResponse', {
publicId: client.publicId,
project,
permissionsLevel,
protocolVersion,
})
})
})
},
}