Merge pull request #13463 from overleaf/jpa-bootstrap-ws

[misc] automatically call joinProject as part of connecting to real-time

GitOrigin-RevId: 2466168e9cebb62dec07481273050efcd0478114
This commit is contained in:
Jakob Ackermann 2023-06-14 10:16:27 +01:00 committed by Copybot
parent bffe76ff26
commit 55c5330108
4 changed files with 719 additions and 64 deletions

View file

@ -119,6 +119,8 @@ module.exports = Router = {
)
session.on('connection', function (error, client, session) {
const joinProjectAutomatically = !!client.handshake.query.projectId
// init client context, we may access it in Router._handleError before
// setting any values
client.ol_context = {}
@ -172,13 +174,18 @@ module.exports = Router = {
// send positive confirmation that the client has a valid connection
client.publicId = 'P.' + base64id.generateId()
client.emit('connectionAccepted', null, client.publicId)
if (!joinProjectAutomatically) {
client.emit('connectionAccepted', null, client.publicId)
}
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 })
metrics.inc('socket-io.connection', 1, {
status: client.transport,
method: joinProjectAutomatically ? 'auto-join-project' : undefined,
})
metrics.gauge('socket-io.clients', io.sockets.clients().length)
logger.debug({ session, clientId: client.id }, 'client connected')
@ -205,7 +212,7 @@ module.exports = Router = {
})
}
client.on('joinProject', function (data, callback) {
const joinProject = function (data, callback) {
data = data || {}
if (typeof callback !== 'function') {
return Router._handleInvalidArguments(
@ -260,7 +267,8 @@ module.exports = Router = {
}
}
)
})
}
client.on('joinProject', joinProject)
client.on('disconnect', function () {
metrics.inc('socket-io.disconnect', 1, { status: client.transport })
@ -455,6 +463,27 @@ module.exports = Router = {
}
)
})
if (joinProjectAutomatically) {
const { projectId } = client.handshake.query
const anonymousAccessToken = session?.anonTokenAccess?.[projectId]
joinProject(
{ project_id: projectId, anonymousAccessToken },
(err, project, permissionsLevel, protocolVersion) => {
if (err) {
client.emit('connectionRejected', err)
client.disconnect()
return
}
client.emit('joinProjectResponse', {
publicId: client.publicId,
project,
permissionsLevel,
protocolVersion,
})
}
)
}
})
},
}

View file

@ -519,7 +519,7 @@ describe('joinProject', function () {
})
})
return describe('when over rate limit', function () {
describe('when over rate limit', function () {
before(function (done) {
return async.series(
[
@ -548,4 +548,560 @@ describe('joinProject', function () {
return this.error.code.should.equal('TooManyRequests')
})
})
describe('when automatically joining the project', function () {
describe('when authorized', function () {
let connectionAcceptedReceived = false
before(function (done) {
async.series(
[
cb => {
FixturesManager.setUpProject(
{
privilegeLevel: 'owner',
project: {
name: 'Test Project',
},
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(
`projectId=${this.project_id}`
)
this.client.on('connectionAccepted', () => {
connectionAcceptedReceived = true
})
this.client.on('connectionRejected', cb)
this.client.on(
'joinProjectResponse',
({ project, permissionsLevel, protocolVersion }) => {
this.project = project
this.permissionsLevel = permissionsLevel
this.protocolVersion = protocolVersion
cb()
}
)
},
],
done
)
})
it('should not emit connectionAccepted', function () {
expect(connectionAcceptedReceived).to.equal(false)
})
it('should get the project from web', function () {
MockWebServer.joinProject
.calledWith(this.project_id, this.user_id)
.should.equal(true)
})
it('should return the project', function () {
this.project.should.deep.equal({
name: 'Test Project',
owner: { _id: this.user_id },
})
})
it('should return the privilege level', function () {
this.permissionsLevel.should.equal('owner')
})
it('should return the protocolVersion', function () {
this.protocolVersion.should.equal(2)
})
it('should have joined the project room', function (done) {
RealTimeClient.getConnectedClient(
this.client.socket.sessionid,
(error, client) => {
if (error) return done(error)
expect(Array.from(client.rooms).includes(this.project_id)).to.equal(
true
)
done()
}
)
})
it('should have marked the user as connected', function (done) {
this.client.emit('clientTracking.getConnectedUsers', (error, users) => {
if (error) return done(error)
let connected = false
for (const user of Array.from(users)) {
if (
user.client_id === this.client.publicId &&
user.user_id === this.user_id
) {
connected = true
break
}
}
expect(connected).to.equal(true)
done()
})
})
})
describe('when authorized with token', function () {
let connectionAcceptedReceived = false
before(function (done) {
async.series(
[
cb => {
FixturesManager.setUpProject(
{
privilegeLevel: 'owner',
publicAccess: 'readOnly',
project: {
name: 'Test Project',
},
},
(
e,
{
user_id: ownerId,
project_id: projectId,
anonymousAccessToken,
}
) => {
this.ownerId = ownerId
this.project_id = projectId
this.anonymousAccessToken = anonymousAccessToken
cb(e)
}
)
},
cb => {
RealTimeClient.setSession(
{
anonTokenAccess: {
[this.project_id]: this.anonymousAccessToken,
},
},
cb
)
},
cb => {
this.client = RealTimeClient.connect(
`projectId=${this.project_id}`
)
this.client.on('connectionAccepted', () => {
connectionAcceptedReceived = true
})
this.client.on('connectionRejected', cb)
this.client.on(
'joinProjectResponse',
({ project, permissionsLevel, protocolVersion }) => {
this.project = project
this.permissionsLevel = permissionsLevel
this.protocolVersion = protocolVersion
cb()
}
)
},
],
done
)
})
it('should not emit connectionAccepted', function () {
expect(connectionAcceptedReceived).to.equal(false)
})
it('should get the project from web', function () {
MockWebServer.joinProject
.calledWith(
this.project_id,
'anonymous-user',
this.anonymousAccessToken
)
.should.equal(true)
})
it('should return the project', function () {
this.project.should.deep.equal({
name: 'Test Project',
owner: { _id: this.ownerId },
})
})
it('should return the privilege level', function () {
this.permissionsLevel.should.equal('readOnly')
})
it('should return the protocolVersion', function () {
this.protocolVersion.should.equal(2)
})
it('should have joined the project room', function (done) {
RealTimeClient.getConnectedClient(
this.client.socket.sessionid,
(error, client) => {
if (error) return done(error)
expect(Array.from(client.rooms).includes(this.project_id)).to.equal(
true
)
done()
}
)
})
it('should have marked the user as connected', function (done) {
this.client.emit('clientTracking.getConnectedUsers', (error, users) => {
if (error) return done(error)
let connected = false
for (const user of Array.from(users)) {
if (user.client_id === this.client.publicId) {
connected = true
break
}
}
expect(connected).to.equal(true)
done()
})
})
})
describe('when not authorized', function () {
let joinProjectResponseReceived = false
before(function (done) {
async.series(
[
cb => {
FixturesManager.setUpProject(
{
privilegeLevel: null,
project: {
name: 'Test Project',
},
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(
`projectId=${this.project_id}`
)
this.client.on('connectionRejected', err => {
this.error = err
cb()
})
this.client.on('joinProjectResponse', () => {
joinProjectResponseReceived = true
cb()
})
},
],
done
)
})
it('should not emit joinProjectResponse', function () {
expect(joinProjectResponseReceived).to.equal(false)
})
it('should have disconnected the client', function () {
expect(this.client.socket.connected).to.equal(false)
})
it('should return an error', function () {
this.error.message.should.equal('not authorized')
})
it('should not have joined the project room', function (done) {
RealTimeClient.getConnectedClient(
this.client.socket.sessionid,
error => {
expect(error.message).to.equal('not found')
done()
}
)
})
})
describe('when not authorized and web replies with a 403', function () {
let joinProjectResponseReceived = false
before(function (done) {
async.series(
[
cb => {
FixturesManager.setUpProject(
{
project_id: '403403403403403403403403', // forbidden
privilegeLevel: 'owner',
project: {
name: 'Test Project',
},
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(
`projectId=${this.project_id}`
)
this.client.on('connectionRejected', err => {
this.error = err
cb()
})
this.client.on('joinProjectResponse', () => {
joinProjectResponseReceived = true
cb()
})
},
],
done
)
})
it('should not emit joinProjectResponse', function () {
expect(joinProjectResponseReceived).to.equal(false)
})
it('should have disconnected the client', function () {
expect(this.client.socket.connected).to.equal(false)
})
it('should return an error', function () {
this.error.message.should.equal('not authorized')
})
it('should not have joined the project room', function (done) {
RealTimeClient.getConnectedClient(
this.client.socket.sessionid,
error => {
expect(error.message).to.equal('not found')
done()
}
)
})
})
describe('when deleted and web replies with a 404', function () {
let joinProjectResponseReceived = false
before(function (done) {
async.series(
[
cb => {
FixturesManager.setUpProject(
{
project_id: '404404404404404404404404', // not-found
privilegeLevel: 'owner',
project: {
name: 'Test Project',
},
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(
`projectId=${this.project_id}`
)
this.client.on('connectionRejected', err => {
this.error = err
cb()
})
this.client.on('joinProjectResponse', () => {
joinProjectResponseReceived = true
cb()
})
},
],
done
)
})
it('should not emit joinProjectResponse', function () {
expect(joinProjectResponseReceived).to.equal(false)
})
it('should have disconnected the client', function () {
expect(this.client.socket.connected).to.equal(false)
})
it('should return an error', function () {
this.error.code.should.equal('ProjectNotFound')
})
it('should not have joined the project room', function (done) {
RealTimeClient.getConnectedClient(
this.client.socket.sessionid,
error => {
expect(error.message).to.equal('not found')
done()
}
)
})
})
describe('when invalid', function () {
let joinProjectResponseReceived = false
before(function (done) {
MockWebServer.joinProject.resetHistory()
async.series(
[
cb => {
this.client = RealTimeClient.connect('projectId=invalid-id')
this.client.on('connectionRejected', err => {
this.error = err
cb()
})
this.client.on('joinProjectResponse', () => {
joinProjectResponseReceived = true
cb()
})
},
],
done
)
})
it('should not emit joinProjectResponse', function () {
expect(joinProjectResponseReceived).to.equal(false)
})
it('should have disconnected the client', function () {
expect(this.client.socket.connected).to.equal(false)
})
it('should return an invalid id error', function () {
this.error.message.should.equal('invalid id')
})
it('should not call to web', function () {
MockWebServer.joinProject.called.should.equal(false)
})
})
describe('when joining more than one project', function () {
before(function (done) {
async.series(
[
cb => {
FixturesManager.setUpProject(
{
privilegeLevel: 'owner',
project: {
name: 'Other Project',
},
},
(e, { project_id: projectId, user_id: userId }) => {
this.other_project_id = projectId
this.other_user_id = userId
cb(e)
}
)
},
cb => {
FixturesManager.setUpProject(
{
user_id: this.other_user_id,
privilegeLevel: 'owner',
project: {
name: 'Test Project',
},
},
(e, { project_id: projectId, user_id: userId }) => {
this.project_id = projectId
this.user_id = userId
cb(e)
}
)
},
cb => {
this.client = RealTimeClient.connect(
`projectId=${this.project_id}`
)
this.client.on('connectionRejected', cb)
this.client.on('joinProjectResponse', () => {
cb()
})
},
cb => {
this.client.emit(
'joinProject',
{ project_id: this.other_project_id },
error => {
this.error = error
cb()
}
)
},
],
done
)
})
it('should return an error', function () {
this.error.message.should.equal('cannot join multiple projects')
})
})
describe('when over rate limit', function () {
let joinProjectResponseReceived = false
before(function (done) {
async.series(
[
cb => {
this.client = RealTimeClient.connect(
'projectId=429429429429429429429429'
)
this.client.on('connectionRejected', err => {
this.error = err
cb()
})
this.client.on('joinProjectResponse', () => {
joinProjectResponseReceived = true
cb()
})
},
],
done
)
})
it('should not emit joinProjectResponse', function () {
expect(joinProjectResponseReceived).to.equal(false)
})
it('should have disconnected the client', function () {
expect(this.client.socket.connected).to.equal(false)
})
it('should return a TooManyRequests error code', function () {
this.error.message.should.equal('rate-limit hit when joining project')
this.error.code.should.equal('TooManyRequests')
})
})
})
})

View file

@ -62,14 +62,19 @@ module.exports = Client = {
return callback()
},
connect(cookie) {
connect(query) {
const client = io.connect('http://localhost:3026', {
'force new connection': true,
query,
})
client.on(
'connectionAccepted',
(_, publicId) => (client.publicId = publicId)
)
client.on(
'joinProjectResponse',
({ publicId }) => (client.publicId = publicId)
)
return client
},
@ -95,7 +100,13 @@ module.exports = Client = {
url: `http://localhost:3026/clients/${clientId}`,
json: true,
},
(error, response, data) => callback(error, data)
(error, response, data) => {
if (response?.statusCode === 404) {
callback(new Error('not found'))
} else {
callback(error, data)
}
}
)
},

View file

@ -11,6 +11,7 @@
/* global io */
import SocketIoShim from './SocketIoShim'
import getMeta from '../../utils/meta'
let ConnectionManager
const ONEHOUR = 1000 * 60 * 60
@ -130,11 +131,15 @@ export default ConnectionManager = (function () {
pathname: this.wsUrl || '/socket.io',
}
}
const query = new URLSearchParams({
projectId: getMeta('ol-project_id'),
}).toString()
this.ide.socket = SocketIoShim.connect(parsedURL.origin, {
resource: parsedURL.pathname.slice(1),
reconnect: false,
'connect timeout': 30 * 1000,
'force new connection': true,
query,
})
// handle network-level websocket errors (e.g. failed dns lookups)
@ -176,9 +181,10 @@ export default ConnectionManager = (function () {
})
// The next event we should get is an authentication response
// from the server, either "connectionAccepted" or
// from the server, either "connectionAccepted" or "bootstrap" or
// "connectionRejected".
// Handle real-time without bootstrap capability.
this.ide.socket.on('connectionAccepted', (_, publicId) => {
this.ide.socket.publicId = publicId || this.ide.socket.socket.sessionid
// state should be 'authenticating'...
@ -201,15 +207,54 @@ export default ConnectionManager = (function () {
}, 100)
})
this.ide.socket.on(
'joinProjectResponse',
({ publicId, project, permissionsLevel, protocolVersion }) => {
this.ide.socket.publicId = publicId
sl_console.log('[socket.io bootstrap] ready for joinDoc')
this.connected = true
this.gracefullyReconnecting = false
this.ide.pushEvent('connected')
this.ide.pushEvent('joinProjectResponse')
this.updateConnectionManagerState('joining')
this.$scope.$apply(() => {
if (this.$scope.state.loading) {
this.$scope.state.load_progress = 70
}
})
const connectionJobId = this.$scope.connection.jobId
this.handleJoinProjectResponse({
connectionJobId,
project,
permissionsLevel,
protocolVersion,
})
}
)
this.ide.socket.on('connectionRejected', err => {
// state should be 'authenticating'...
sl_console.log(
'[socket.io connectionRejected] session not valid or other connection error'
)
// real time sends a 'retry' message if the process was shutting down
if (err && err.message === 'retry') {
// real-time sends a 'retry' message if the process was shutting down
// real-time sends TooManyRequests if joinProject was rate-limited.
if (err?.message === 'retry' || err?.code === 'TooManyRequests') {
return this.tryReconnectWithRateLimit()
}
if (err?.code === 'ProjectNotFound') {
// A stale browser tab tried to join a deleted project.
// Reloading the page will render a 404.
this.ide
.showGenericMessageModal(
'Project has been deleted',
'This project has been deleted by the owner.'
)
.result.then(() => location.reload(true))
return
}
// we have failed authentication, usually due to an invalid session cookie
return this.reportConnectionError(err)
})
@ -396,65 +441,79 @@ Something went wrong connecting to your project. Please refresh if this continue
'joinProject',
data,
(err, project, permissionsLevel, protocolVersion) => {
if (err != null || project == null) {
err = err || {}
if (err.code === 'ProjectNotFound') {
// A stale browser tab tried to join a deleted project.
// Reloading the page will render a 404.
this.ide
.showGenericMessageModal(
'Project has been deleted',
'This project has been deleted by the owner.'
)
.result.then(() => location.reload(true))
return
}
if (err.code === 'TooManyRequests') {
sl_console.log(
`[joinProject ${connectionId}] retrying: ${err.message}`
)
setTimeout(
() => this.joinProject(connectionId),
this.joinProjectRetryInterval
)
if (
this.joinProjectRetryInterval <
this.JOIN_PROJECT_MAX_RETRY_INTERVAL
) {
this.joinProjectRetryInterval +=
this.JOIN_PROJECT_RETRY_INTERVAL
}
return
} else {
return this.reportConnectionError(err)
}
}
this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
if (
this.$scope.protocolVersion != null &&
this.$scope.protocolVersion !== protocolVersion
) {
location.reload(true)
}
this.$scope.$apply(() => {
this.updateConnectionManagerState('ready')
this.$scope.protocolVersion = protocolVersion
const defaultProjectAttributes = { rootDoc_id: null }
this.$scope.project = { ...defaultProjectAttributes, ...project }
this.$scope.permissionsLevel = permissionsLevel
this.ide.loadingManager.socketLoaded()
window.dispatchEvent(
new CustomEvent('project:joined', { detail: this.$scope.project })
)
this.$scope.$broadcast('project:joined')
this.handleJoinProjectResponse({
connectionId,
err,
project,
permissionsLevel,
protocolVersion,
})
}
)
}
handleJoinProjectResponse({
connectionId,
err,
project,
permissionsLevel,
protocolVersion,
}) {
if (err != null || project == null) {
err = err || {}
if (err.code === 'ProjectNotFound') {
// A stale browser tab tried to join a deleted project.
// Reloading the page will render a 404.
this.ide
.showGenericMessageModal(
'Project has been deleted',
'This project has been deleted by the owner.'
)
.result.then(() => location.reload(true))
return
}
if (err.code === 'TooManyRequests') {
sl_console.log(
`[joinProject ${connectionId}] retrying: ${err.message}`
)
setTimeout(
() => this.joinProject(connectionId),
this.joinProjectRetryInterval
)
if (
this.joinProjectRetryInterval < this.JOIN_PROJECT_MAX_RETRY_INTERVAL
) {
this.joinProjectRetryInterval += this.JOIN_PROJECT_RETRY_INTERVAL
}
return
} else {
return this.reportConnectionError(err)
}
}
this.joinProjectRetryInterval = this.JOIN_PROJECT_RETRY_INTERVAL
if (
this.$scope.protocolVersion != null &&
this.$scope.protocolVersion !== protocolVersion
) {
location.reload(true)
}
this.$scope.$apply(() => {
this.updateConnectionManagerState('ready')
this.$scope.protocolVersion = protocolVersion
const defaultProjectAttributes = { rootDoc_id: null }
this.$scope.project = { ...defaultProjectAttributes, ...project }
this.$scope.permissionsLevel = permissionsLevel
this.ide.loadingManager.socketLoaded()
window.dispatchEvent(
new CustomEvent('project:joined', { detail: this.$scope.project })
)
this.$scope.$broadcast('project:joined')
})
}
reconnectImmediately() {
this.disconnect()
return this.tryReconnect()