diff --git a/services/real-time/app/js/Router.js b/services/real-time/app/js/Router.js index 495fb751d1..056305dc67 100644 --- a/services/real-time/app/js/Router.js +++ b/services/real-time/app/js/Router.js @@ -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, + }) + } + ) + } }) }, } diff --git a/services/real-time/test/acceptance/js/JoinProjectTests.js b/services/real-time/test/acceptance/js/JoinProjectTests.js index 17d9c996e1..c831507f4c 100644 --- a/services/real-time/test/acceptance/js/JoinProjectTests.js +++ b/services/real-time/test/acceptance/js/JoinProjectTests.js @@ -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') + }) + }) + }) }) diff --git a/services/real-time/test/acceptance/js/helpers/RealTimeClient.js b/services/real-time/test/acceptance/js/helpers/RealTimeClient.js index d18db8542e..b50ac01cdf 100644 --- a/services/real-time/test/acceptance/js/helpers/RealTimeClient.js +++ b/services/real-time/test/acceptance/js/helpers/RealTimeClient.js @@ -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) + } + } ) }, diff --git a/services/web/frontend/js/ide/connection/ConnectionManager.js b/services/web/frontend/js/ide/connection/ConnectionManager.js index 9568196cce..74ac7acf8d 100644 --- a/services/web/frontend/js/ide/connection/ConnectionManager.js +++ b/services/web/frontend/js/ide/connection/ConnectionManager.js @@ -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()