├── .dockerignore ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── dependabot.yml ├── .gitignore ├── .mocharc.json ├── .nvmrc ├── .prettierrc ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── app.js ├── app └── js │ ├── AuthorizationManager.js │ ├── ChannelManager.js │ ├── ConnectedUsersManager.js │ ├── DeploymentManager.js │ ├── DocumentUpdaterController.js │ ├── DocumentUpdaterManager.js │ ├── DrainManager.js │ ├── Errors.js │ ├── EventLogger.js │ ├── HealthCheckManager.js │ ├── HttpApiController.js │ ├── HttpController.js │ ├── RedisClientManager.js │ ├── RoomManager.js │ ├── Router.js │ ├── SafeJsonParse.js │ ├── SessionSockets.js │ ├── WebApiManager.js │ ├── WebsocketController.js │ └── WebsocketLoadBalancer.js ├── buildscript.txt ├── config ├── settings.defaults.js └── settings.test.js ├── docker-compose.ci.yml ├── docker-compose.yml ├── nodemon.json ├── package-lock.json ├── package.json └── test ├── acceptance ├── js │ ├── ApplyUpdateTests.js │ ├── ClientTrackingTests.js │ ├── DrainManagerTests.js │ ├── EarlyDisconnect.js │ ├── HttpControllerTests.js │ ├── JoinDocTests.js │ ├── JoinProjectTests.js │ ├── LeaveDocTests.js │ ├── LeaveProjectTests.js │ ├── MatrixTests.js │ ├── PubSubRace.js │ ├── ReceiveUpdateTests.js │ ├── RouterTests.js │ ├── SessionSocketsTests.js │ ├── SessionTests.js │ └── helpers │ │ ├── FixturesManager.js │ │ ├── MockDocUpdaterServer.js │ │ ├── MockWebServer.js │ │ ├── RealTimeClient.js │ │ └── RealtimeServer.js ├── libs │ └── XMLHttpRequest.js └── scripts │ └── full-test.sh ├── setup.js └── unit └── js ├── AuthorizationManagerTests.js ├── ChannelManagerTests.js ├── ConnectedUsersManagerTests.js ├── DocumentUpdaterControllerTests.js ├── DocumentUpdaterManagerTests.js ├── DrainManagerTests.js ├── EventLoggerTests.js ├── RoomManagerTests.js ├── SafeJsonParseTest.js ├── SessionSocketsTests.js ├── WebApiManagerTests.js ├── WebsocketControllerTests.js ├── WebsocketLoadBalancerTests.js └── helpers └── MockClient.js /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | gitrev 3 | .git 4 | .gitignore 5 | .npm 6 | .nvmrc 7 | nodemon.json 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | // this file was auto-generated, do not edit it directly. 2 | // instead run bin/update_build_scripts from 3 | // https://github.com/sharelatex/sharelatex-dev-environment 4 | { 5 | "extends": [ 6 | "eslint:recommended", 7 | "standard", 8 | "prettier" 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": 2018 12 | }, 13 | "plugins": [ 14 | "mocha", 15 | "chai-expect", 16 | "chai-friendly" 17 | ], 18 | "env": { 19 | "node": true, 20 | "mocha": true 21 | }, 22 | "rules": { 23 | // TODO(das7pad): remove overrides after fixing all the violations manually (https://github.com/overleaf/issues/issues/3882#issuecomment-878999671) 24 | // START of temporary overrides 25 | "array-callback-return": "off", 26 | "no-dupe-else-if": "off", 27 | "no-var": "off", 28 | "no-empty": "off", 29 | "node/handle-callback-err": "off", 30 | "no-loss-of-precision": "off", 31 | "node/no-callback-literal": "off", 32 | "node/no-path-concat": "off", 33 | "prefer-regex-literals": "off", 34 | // END of temporary overrides 35 | 36 | // Swap the no-unused-expressions rule with a more chai-friendly one 37 | "no-unused-expressions": 0, 38 | "chai-friendly/no-unused-expressions": "error", 39 | 40 | // Do not allow importing of implicit dependencies. 41 | "import/no-extraneous-dependencies": "error" 42 | }, 43 | "overrides": [ 44 | { 45 | // Test specific rules 46 | "files": ["test/**/*.js"], 47 | "globals": { 48 | "expect": true 49 | }, 50 | "rules": { 51 | // mocha-specific rules 52 | "mocha/handle-done-callback": "error", 53 | "mocha/no-exclusive-tests": "error", 54 | "mocha/no-global-tests": "error", 55 | "mocha/no-identical-title": "error", 56 | "mocha/no-nested-tests": "error", 57 | "mocha/no-pending-tests": "error", 58 | "mocha/no-skipped-tests": "error", 59 | "mocha/no-mocha-arrows": "error", 60 | 61 | // chai-specific rules 62 | "chai-expect/missing-assertion": "error", 63 | "chai-expect/terminating-properties": "error", 64 | 65 | // prefer-arrow-callback applies to all callbacks, not just ones in mocha tests. 66 | // we don't enforce this at the top-level - just in tests to manage `this` scope 67 | // based on mocha's context mechanism 68 | "mocha/prefer-arrow-callback": "error" 69 | } 70 | }, 71 | { 72 | // Backend specific rules 73 | "files": ["app/**/*.js", "app.js", "index.js"], 74 | "rules": { 75 | // don't allow console.log in backend code 76 | "no-console": "error", 77 | 78 | // Do not allow importing of implicit dependencies. 79 | "import/no-extraneous-dependencies": ["error", { 80 | // Do not allow importing of devDependencies. 81 | "devDependencies": false 82 | }] 83 | } 84 | } 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Steps to Reproduce 4 | 5 | 6 | 7 | 1. 8 | 2. 9 | 3. 10 | 11 | ## Expected Behaviour 12 | 13 | 14 | ## Observed Behaviour 15 | 16 | 17 | 18 | ## Context 19 | 20 | 21 | ## Technical Info 22 | 23 | 24 | * URL: 25 | * Browser Name and version: 26 | * Operating System and version (desktop or mobile): 27 | * Signed in as: 28 | * Project and/or file: 29 | 30 | ## Analysis 31 | 32 | 33 | ## Who Needs to Know? 34 | 35 | 36 | 37 | - 38 | - 39 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ### Description 7 | 8 | 9 | 10 | #### Screenshots 11 | 12 | 13 | 14 | #### Related Issues / PRs 15 | 16 | 17 | 18 | ### Review 19 | 20 | 21 | 22 | #### Potential Impact 23 | 24 | 25 | 26 | #### Manual Testing Performed 27 | 28 | - [ ] 29 | - [ ] 30 | 31 | #### Accessibility 32 | 33 | 34 | 35 | ### Deployment 36 | 37 | 38 | 39 | #### Deployment Checklist 40 | 41 | - [ ] Update documentation not included in the PR (if any) 42 | - [ ] 43 | 44 | #### Metrics and Monitoring 45 | 46 | 47 | 48 | #### Who Needs to Know? 49 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | 8 | pull-request-branch-name: 9 | # Separate sections of the branch name with a hyphen 10 | # Docker images use the branch name and do not support slashes in tags 11 | # https://github.com/overleaf/google-ops/issues/822 12 | # https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates#pull-request-branch-nameseparator 13 | separator: "-" 14 | 15 | # Block informal upgrades -- security upgrades use a separate queue. 16 | # https://docs.github.com/en/github/administering-a-repository/configuration-options-for-dependency-updates#open-pull-requests-limit 17 | open-pull-requests-limit: 0 18 | 19 | # currently assign team-magma to all dependabot PRs - this may change in 20 | # future if we reorganise teams 21 | labels: 22 | - "dependencies" 23 | - "type:maintenance" 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | forever 3 | 4 | # managed by dev-environment$ bin/update_build_scripts 5 | .npmrc 6 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": "test/setup.js" 3 | } 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 12.22.3 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | # This file was auto-generated, do not edit it directly. 2 | # Instead run bin/update_build_scripts from 3 | # https://github.com/sharelatex/sharelatex-dev-environment 4 | { 5 | "arrowParens": "avoid", 6 | "semi": false, 7 | "singleQuote": true, 8 | "trailingComma": "es5", 9 | "tabWidth": 2, 10 | "useTabs": false 11 | } 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # This file was auto-generated, do not edit it directly. 2 | # Instead run bin/update_build_scripts from 3 | # https://github.com/sharelatex/sharelatex-dev-environment 4 | 5 | FROM node:12.22.3 as base 6 | 7 | WORKDIR /app 8 | 9 | FROM base as app 10 | 11 | #wildcard as some files may not be in all repos 12 | COPY package*.json npm-shrink*.json /app/ 13 | 14 | RUN npm ci --quiet 15 | 16 | COPY . /app 17 | 18 | FROM base 19 | 20 | COPY --from=app /app /app 21 | USER node 22 | 23 | CMD ["node", "--expose-gc", "app.js"] 24 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # This file was auto-generated, do not edit it directly. 2 | # Instead run bin/update_build_scripts from 3 | # https://github.com/sharelatex/sharelatex-dev-environment 4 | 5 | BUILD_NUMBER ?= local 6 | BRANCH_NAME ?= $(shell git rev-parse --abbrev-ref HEAD) 7 | PROJECT_NAME = real-time 8 | BUILD_DIR_NAME = $(shell pwd | xargs basename | tr -cd '[a-zA-Z0-9_.\-]') 9 | 10 | DOCKER_COMPOSE_FLAGS ?= -f docker-compose.yml 11 | DOCKER_COMPOSE := BUILD_NUMBER=$(BUILD_NUMBER) \ 12 | BRANCH_NAME=$(BRANCH_NAME) \ 13 | PROJECT_NAME=$(PROJECT_NAME) \ 14 | MOCHA_GREP=${MOCHA_GREP} \ 15 | docker-compose ${DOCKER_COMPOSE_FLAGS} 16 | 17 | DOCKER_COMPOSE_TEST_ACCEPTANCE = \ 18 | COMPOSE_PROJECT_NAME=test_acceptance_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) 19 | 20 | DOCKER_COMPOSE_TEST_UNIT = \ 21 | COMPOSE_PROJECT_NAME=test_unit_$(BUILD_DIR_NAME) $(DOCKER_COMPOSE) 22 | 23 | clean: 24 | -docker rmi ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) 25 | -docker rmi gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) 26 | -$(DOCKER_COMPOSE_TEST_UNIT) down --rmi local 27 | -$(DOCKER_COMPOSE_TEST_ACCEPTANCE) down --rmi local 28 | 29 | format: 30 | $(DOCKER_COMPOSE) run --rm test_unit npm run --silent format 31 | 32 | format_fix: 33 | $(DOCKER_COMPOSE) run --rm test_unit npm run --silent format:fix 34 | 35 | lint: 36 | $(DOCKER_COMPOSE) run --rm test_unit npm run --silent lint 37 | 38 | test: format lint test_unit test_acceptance 39 | 40 | test_unit: 41 | ifneq (,$(wildcard test/unit)) 42 | $(DOCKER_COMPOSE_TEST_UNIT) run --rm test_unit 43 | $(MAKE) test_unit_clean 44 | endif 45 | 46 | test_clean: test_unit_clean 47 | test_unit_clean: 48 | ifneq (,$(wildcard test/unit)) 49 | $(DOCKER_COMPOSE_TEST_UNIT) down -v -t 0 50 | endif 51 | 52 | test_acceptance: test_acceptance_clean test_acceptance_pre_run test_acceptance_run 53 | $(MAKE) test_acceptance_clean 54 | 55 | test_acceptance_debug: test_acceptance_clean test_acceptance_pre_run test_acceptance_run_debug 56 | $(MAKE) test_acceptance_clean 57 | 58 | test_acceptance_run: 59 | ifneq (,$(wildcard test/acceptance)) 60 | $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance 61 | endif 62 | 63 | test_acceptance_run_debug: 64 | ifneq (,$(wildcard test/acceptance)) 65 | $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run -p 127.0.0.9:19999:19999 --rm test_acceptance npm run test:acceptance -- --inspect=0.0.0.0:19999 --inspect-brk 66 | endif 67 | 68 | test_clean: test_acceptance_clean 69 | test_acceptance_clean: 70 | $(DOCKER_COMPOSE_TEST_ACCEPTANCE) down -v -t 0 71 | 72 | test_acceptance_pre_run: 73 | ifneq (,$(wildcard test/acceptance/js/scripts/pre-run)) 74 | $(DOCKER_COMPOSE_TEST_ACCEPTANCE) run --rm test_acceptance test/acceptance/js/scripts/pre-run 75 | endif 76 | 77 | build: 78 | docker build --pull --tag ci/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ 79 | --tag gcr.io/overleaf-ops/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) \ 80 | . 81 | 82 | tar: 83 | $(DOCKER_COMPOSE) up tar 84 | 85 | publish: 86 | 87 | docker push $(DOCKER_REPO)/$(PROJECT_NAME):$(BRANCH_NAME)-$(BUILD_NUMBER) 88 | 89 | 90 | .PHONY: clean test test_unit test_acceptance test_clean build publish 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ⚠️ This repository has been migrated into [`overleaf/overleaf`](https://github.com/overleaf/overleaf). See the [monorepo announcement](https://github.com/overleaf/overleaf/issues/923) for more info. ⚠️ 2 | 3 | --- 4 | 5 | overleaf/real-time 6 | ================== 7 | 8 | The socket.io layer of Overleaf for real-time editor interactions. 9 | 10 | License 11 | ------- 12 | 13 | The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the `LICENSE` file. 14 | 15 | Copyright (c) Overleaf, 2014-2021. 16 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const Metrics = require('@overleaf/metrics') 2 | const Settings = require('@overleaf/settings') 3 | Metrics.initialize(Settings.appName || 'real-time') 4 | const async = require('async') 5 | 6 | const logger = require('logger-sharelatex') 7 | logger.initialize('real-time') 8 | Metrics.event_loop.monitor(logger) 9 | 10 | const express = require('express') 11 | const session = require('express-session') 12 | const redis = require('@overleaf/redis-wrapper') 13 | if (Settings.sentry && Settings.sentry.dsn) { 14 | logger.initializeErrorReporting(Settings.sentry.dsn) 15 | } 16 | 17 | const sessionRedisClient = redis.createClient(Settings.redis.websessions) 18 | 19 | const RedisStore = require('connect-redis')(session) 20 | const SessionSockets = require('./app/js/SessionSockets') 21 | const CookieParser = require('cookie-parser') 22 | 23 | const DrainManager = require('./app/js/DrainManager') 24 | const HealthCheckManager = require('./app/js/HealthCheckManager') 25 | const DeploymentManager = require('./app/js/DeploymentManager') 26 | 27 | // NOTE: debug is invoked for every blob that is put on the wire 28 | const socketIoLogger = { 29 | error(...message) { 30 | logger.info({ fromSocketIo: true, originalLevel: 'error' }, ...message) 31 | }, 32 | warn(...message) { 33 | logger.info({ fromSocketIo: true, originalLevel: 'warn' }, ...message) 34 | }, 35 | info() {}, 36 | debug() {}, 37 | log() {}, 38 | } 39 | 40 | // monitor status file to take dark deployments out of the load-balancer 41 | DeploymentManager.initialise() 42 | 43 | // Set up socket.io server 44 | const app = express() 45 | 46 | const server = require('http').createServer(app) 47 | const io = require('socket.io').listen(server, { 48 | logger: socketIoLogger, 49 | }) 50 | 51 | // Bind to sessions 52 | const sessionStore = new RedisStore({ client: sessionRedisClient }) 53 | const cookieParser = CookieParser(Settings.security.sessionSecret) 54 | 55 | const sessionSockets = new SessionSockets( 56 | io, 57 | sessionStore, 58 | cookieParser, 59 | Settings.cookieName 60 | ) 61 | 62 | Metrics.injectMetricsRoute(app) 63 | app.use(Metrics.http.monitor(logger)) 64 | 65 | io.configure(function () { 66 | io.enable('browser client minification') 67 | io.enable('browser client etag') 68 | 69 | // Fix for Safari 5 error of "Error during WebSocket handshake: location mismatch" 70 | // See http://answers.dotcloud.com/question/578/problem-with-websocket-over-ssl-in-safari-with 71 | io.set('match origin protocol', true) 72 | 73 | // gzip uses a Node 0.8.x method of calling the gzip program which 74 | // doesn't work with 0.6.x 75 | // io.enable('browser client gzip') 76 | io.set('transports', [ 77 | 'websocket', 78 | 'flashsocket', 79 | 'htmlfile', 80 | 'xhr-polling', 81 | 'jsonp-polling', 82 | ]) 83 | }) 84 | 85 | // a 200 response on '/' is required for load balancer health checks 86 | // these operate separately from kubernetes readiness checks 87 | app.get('/', function (req, res) { 88 | if (Settings.shutDownInProgress || DeploymentManager.deploymentIsClosed()) { 89 | res.sendStatus(503) // Service unavailable 90 | } else { 91 | res.send('real-time is open') 92 | } 93 | }) 94 | 95 | app.get('/status', function (req, res) { 96 | if (Settings.shutDownInProgress) { 97 | res.sendStatus(503) // Service unavailable 98 | } else { 99 | res.send('real-time is alive') 100 | } 101 | }) 102 | 103 | app.get('/debug/events', function (req, res) { 104 | Settings.debugEvents = parseInt(req.query.count, 10) || 20 105 | logger.log({ count: Settings.debugEvents }, 'starting debug mode') 106 | res.send(`debug mode will log next ${Settings.debugEvents} events`) 107 | }) 108 | 109 | const rclient = require('@overleaf/redis-wrapper').createClient( 110 | Settings.redis.realtime 111 | ) 112 | 113 | function healthCheck(req, res) { 114 | rclient.healthCheck(function (error) { 115 | if (error) { 116 | logger.err({ err: error }, 'failed redis health check') 117 | res.sendStatus(500) 118 | } else if (HealthCheckManager.isFailing()) { 119 | const status = HealthCheckManager.status() 120 | logger.err({ pubSubErrors: status }, 'failed pubsub health check') 121 | res.sendStatus(500) 122 | } else { 123 | res.sendStatus(200) 124 | } 125 | }) 126 | } 127 | app.get( 128 | '/health_check', 129 | (req, res, next) => { 130 | if (Settings.shutDownComplete) { 131 | return res.sendStatus(503) 132 | } 133 | next() 134 | }, 135 | healthCheck 136 | ) 137 | 138 | app.get('/health_check/redis', healthCheck) 139 | 140 | const Router = require('./app/js/Router') 141 | Router.configure(app, io, sessionSockets) 142 | 143 | const WebsocketLoadBalancer = require('./app/js/WebsocketLoadBalancer') 144 | WebsocketLoadBalancer.listenForEditorEvents(io) 145 | 146 | const DocumentUpdaterController = require('./app/js/DocumentUpdaterController') 147 | DocumentUpdaterController.listenForUpdatesFromDocumentUpdater(io) 148 | 149 | const { port } = Settings.internal.realTime 150 | const { host } = Settings.internal.realTime 151 | 152 | server.listen(port, host, function (error) { 153 | if (error) { 154 | throw error 155 | } 156 | logger.info(`realtime starting up, listening on ${host}:${port}`) 157 | }) 158 | 159 | // Stop huge stack traces in logs from all the socket.io parsing steps. 160 | Error.stackTraceLimit = 10 161 | 162 | function shutdownCleanly(signal) { 163 | const connectedClients = io.sockets.clients().length 164 | if (connectedClients === 0) { 165 | logger.warn('no clients connected, exiting') 166 | process.exit() 167 | } else { 168 | logger.warn( 169 | { connectedClients }, 170 | 'clients still connected, not shutting down yet' 171 | ) 172 | setTimeout(() => shutdownCleanly(signal), 30 * 1000) 173 | } 174 | } 175 | 176 | function drainAndShutdown(signal) { 177 | if (Settings.shutDownInProgress) { 178 | logger.warn({ signal }, 'shutdown already in progress, ignoring signal') 179 | } else { 180 | Settings.shutDownInProgress = true 181 | const { statusCheckInterval } = Settings 182 | if (statusCheckInterval) { 183 | logger.warn( 184 | { signal }, 185 | `received interrupt, delay drain by ${statusCheckInterval}ms` 186 | ) 187 | } 188 | setTimeout(function () { 189 | logger.warn( 190 | { signal }, 191 | `received interrupt, starting drain over ${shutdownDrainTimeWindow} mins` 192 | ) 193 | DrainManager.startDrainTimeWindow(io, shutdownDrainTimeWindow, () => { 194 | setTimeout(() => { 195 | const staleClients = io.sockets.clients() 196 | if (staleClients.length !== 0) { 197 | logger.warn( 198 | { staleClients: staleClients.map(client => client.id) }, 199 | 'forcefully disconnecting stale clients' 200 | ) 201 | staleClients.forEach(client => { 202 | client.disconnect() 203 | }) 204 | } 205 | // Mark the node as unhealthy. 206 | Settings.shutDownComplete = true 207 | }, Settings.gracefulReconnectTimeoutMs) 208 | }) 209 | shutdownCleanly(signal) 210 | }, statusCheckInterval) 211 | } 212 | } 213 | 214 | Settings.shutDownInProgress = false 215 | const shutdownDrainTimeWindow = parseInt(Settings.shutdownDrainTimeWindow, 10) 216 | if (Settings.shutdownDrainTimeWindow) { 217 | logger.log({ shutdownDrainTimeWindow }, 'shutdownDrainTimeWindow enabled') 218 | for (const signal of [ 219 | 'SIGINT', 220 | 'SIGHUP', 221 | 'SIGQUIT', 222 | 'SIGUSR1', 223 | 'SIGUSR2', 224 | 'SIGTERM', 225 | 'SIGABRT', 226 | ]) { 227 | process.on(signal, drainAndShutdown) 228 | } // signal is passed as argument to event handler 229 | 230 | // global exception handler 231 | if (Settings.errors && Settings.errors.catchUncaughtErrors) { 232 | process.removeAllListeners('uncaughtException') 233 | process.on('uncaughtException', function (error) { 234 | if ( 235 | [ 236 | 'ETIMEDOUT', 237 | 'EHOSTUNREACH', 238 | 'EPIPE', 239 | 'ECONNRESET', 240 | 'ERR_STREAM_WRITE_AFTER_END', 241 | ].includes(error.code) 242 | ) { 243 | Metrics.inc('disconnected_write', 1, { status: error.code }) 244 | return logger.warn( 245 | { err: error }, 246 | 'attempted to write to disconnected client' 247 | ) 248 | } 249 | logger.error({ err: error }, 'uncaught exception') 250 | if (Settings.errors && Settings.errors.shutdownOnUncaughtError) { 251 | drainAndShutdown('SIGABRT') 252 | } 253 | }) 254 | } 255 | } 256 | 257 | if (Settings.continualPubsubTraffic) { 258 | logger.warn('continualPubsubTraffic enabled') 259 | 260 | const pubsubClient = redis.createClient(Settings.redis.pubsub) 261 | const clusterClient = redis.createClient(Settings.redis.websessions) 262 | 263 | const publishJob = function (channel, callback) { 264 | const checker = new HealthCheckManager(channel) 265 | logger.debug({ channel }, 'sending pub to keep connection alive') 266 | const json = JSON.stringify({ 267 | health_check: true, 268 | key: checker.id, 269 | date: new Date().toString(), 270 | }) 271 | Metrics.summary(`redis.publish.${channel}`, json.length) 272 | pubsubClient.publish(channel, json, function (err) { 273 | if (err) { 274 | logger.err({ err, channel }, 'error publishing pubsub traffic to redis') 275 | } 276 | const blob = JSON.stringify({ keep: 'alive' }) 277 | Metrics.summary('redis.publish.cluster-continual-traffic', blob.length) 278 | clusterClient.publish('cluster-continual-traffic', blob, callback) 279 | }) 280 | } 281 | 282 | const runPubSubTraffic = () => 283 | async.map(['applied-ops', 'editor-events'], publishJob, () => 284 | setTimeout(runPubSubTraffic, 1000 * 20) 285 | ) 286 | 287 | runPubSubTraffic() 288 | } 289 | -------------------------------------------------------------------------------- /app/js/AuthorizationManager.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | const { NotAuthorizedError } = require('./Errors') 5 | 6 | let AuthorizationManager 7 | module.exports = AuthorizationManager = { 8 | assertClientCanViewProject(client, callback) { 9 | AuthorizationManager._assertClientHasPrivilegeLevel( 10 | client, 11 | ['readOnly', 'readAndWrite', 'owner'], 12 | callback 13 | ) 14 | }, 15 | 16 | assertClientCanEditProject(client, callback) { 17 | AuthorizationManager._assertClientHasPrivilegeLevel( 18 | client, 19 | ['readAndWrite', 'owner'], 20 | callback 21 | ) 22 | }, 23 | 24 | _assertClientHasPrivilegeLevel(client, allowedLevels, callback) { 25 | if (allowedLevels.includes(client.ol_context.privilege_level)) { 26 | callback(null) 27 | } else { 28 | callback(new NotAuthorizedError()) 29 | } 30 | }, 31 | 32 | assertClientCanViewProjectAndDoc(client, doc_id, callback) { 33 | AuthorizationManager.assertClientCanViewProject(client, function (error) { 34 | if (error) { 35 | return callback(error) 36 | } 37 | AuthorizationManager._assertClientCanAccessDoc(client, doc_id, callback) 38 | }) 39 | }, 40 | 41 | assertClientCanEditProjectAndDoc(client, doc_id, callback) { 42 | AuthorizationManager.assertClientCanEditProject(client, function (error) { 43 | if (error) { 44 | return callback(error) 45 | } 46 | AuthorizationManager._assertClientCanAccessDoc(client, doc_id, callback) 47 | }) 48 | }, 49 | 50 | _assertClientCanAccessDoc(client, doc_id, callback) { 51 | if (client.ol_context[`doc:${doc_id}`] === 'allowed') { 52 | callback(null) 53 | } else { 54 | callback(new NotAuthorizedError()) 55 | } 56 | }, 57 | 58 | addAccessToDoc(client, doc_id, callback) { 59 | client.ol_context[`doc:${doc_id}`] = 'allowed' 60 | callback(null) 61 | }, 62 | 63 | removeAccessToDoc(client, doc_id, callback) { 64 | delete client.ol_context[`doc:${doc_id}`] 65 | callback(null) 66 | }, 67 | } 68 | -------------------------------------------------------------------------------- /app/js/ChannelManager.js: -------------------------------------------------------------------------------- 1 | const logger = require('logger-sharelatex') 2 | const metrics = require('@overleaf/metrics') 3 | const settings = require('@overleaf/settings') 4 | const OError = require('@overleaf/o-error') 5 | 6 | const ClientMap = new Map() // for each redis client, store a Map of subscribed channels (channelname -> subscribe promise) 7 | 8 | // Manage redis pubsub subscriptions for individual projects and docs, ensuring 9 | // that we never subscribe to a channel multiple times. The socket.io side is 10 | // handled by RoomManager. 11 | 12 | module.exports = { 13 | getClientMapEntry(rclient) { 14 | // return the per-client channel map if it exists, otherwise create and 15 | // return an empty map for the client. 16 | return ( 17 | ClientMap.get(rclient) || ClientMap.set(rclient, new Map()).get(rclient) 18 | ) 19 | }, 20 | 21 | subscribe(rclient, baseChannel, id) { 22 | const clientChannelMap = this.getClientMapEntry(rclient) 23 | const channel = `${baseChannel}:${id}` 24 | const actualSubscribe = function () { 25 | // subscribe is happening in the foreground and it should reject 26 | return rclient 27 | .subscribe(channel) 28 | .finally(function () { 29 | if (clientChannelMap.get(channel) === subscribePromise) { 30 | clientChannelMap.delete(channel) 31 | } 32 | }) 33 | .then(function () { 34 | logger.log({ channel }, 'subscribed to channel') 35 | metrics.inc(`subscribe.${baseChannel}`) 36 | }) 37 | .catch(function (err) { 38 | logger.error({ channel, err }, 'failed to subscribe to channel') 39 | metrics.inc(`subscribe.failed.${baseChannel}`) 40 | // add context for the stack-trace at the call-site 41 | throw new OError('failed to subscribe to channel', { 42 | channel, 43 | }).withCause(err) 44 | }) 45 | } 46 | 47 | const pendingActions = clientChannelMap.get(channel) || Promise.resolve() 48 | const subscribePromise = pendingActions.then( 49 | actualSubscribe, 50 | actualSubscribe 51 | ) 52 | clientChannelMap.set(channel, subscribePromise) 53 | logger.log({ channel }, 'planned to subscribe to channel') 54 | return subscribePromise 55 | }, 56 | 57 | unsubscribe(rclient, baseChannel, id) { 58 | const clientChannelMap = this.getClientMapEntry(rclient) 59 | const channel = `${baseChannel}:${id}` 60 | const actualUnsubscribe = function () { 61 | // unsubscribe is happening in the background, it should not reject 62 | return rclient 63 | .unsubscribe(channel) 64 | .finally(function () { 65 | if (clientChannelMap.get(channel) === unsubscribePromise) { 66 | clientChannelMap.delete(channel) 67 | } 68 | }) 69 | .then(function () { 70 | logger.log({ channel }, 'unsubscribed from channel') 71 | metrics.inc(`unsubscribe.${baseChannel}`) 72 | }) 73 | .catch(function (err) { 74 | logger.error({ channel, err }, 'unsubscribed from channel') 75 | metrics.inc(`unsubscribe.failed.${baseChannel}`) 76 | }) 77 | } 78 | 79 | const pendingActions = clientChannelMap.get(channel) || Promise.resolve() 80 | const unsubscribePromise = pendingActions.then( 81 | actualUnsubscribe, 82 | actualUnsubscribe 83 | ) 84 | clientChannelMap.set(channel, unsubscribePromise) 85 | logger.log({ channel }, 'planned to unsubscribe from channel') 86 | return unsubscribePromise 87 | }, 88 | 89 | publish(rclient, baseChannel, id, data) { 90 | let channel 91 | metrics.summary(`redis.publish.${baseChannel}`, data.length) 92 | if (id === 'all' || !settings.publishOnIndividualChannels) { 93 | channel = baseChannel 94 | } else { 95 | channel = `${baseChannel}:${id}` 96 | } 97 | // we publish on a different client to the subscribe, so we can't 98 | // check for the channel existing here 99 | rclient.publish(channel, data) 100 | }, 101 | } 102 | -------------------------------------------------------------------------------- /app/js/ConnectedUsersManager.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | const async = require('async') 5 | const Settings = require('@overleaf/settings') 6 | const logger = require('logger-sharelatex') 7 | const redis = require('@overleaf/redis-wrapper') 8 | const OError = require('@overleaf/o-error') 9 | const rclient = redis.createClient(Settings.redis.realtime) 10 | const Keys = Settings.redis.realtime.key_schema 11 | 12 | const ONE_HOUR_IN_S = 60 * 60 13 | const ONE_DAY_IN_S = ONE_HOUR_IN_S * 24 14 | const FOUR_DAYS_IN_S = ONE_DAY_IN_S * 4 15 | 16 | const USER_TIMEOUT_IN_S = ONE_HOUR_IN_S / 4 17 | const REFRESH_TIMEOUT_IN_S = 10 // only show clients which have responded to a refresh request in the last 10 seconds 18 | 19 | module.exports = { 20 | // Use the same method for when a user connects, and when a user sends a cursor 21 | // update. This way we don't care if the connected_user key has expired when 22 | // we receive a cursor update. 23 | updateUserPosition(project_id, client_id, user, cursorData, callback) { 24 | logger.log({ project_id, client_id }, 'marking user as joined or connected') 25 | 26 | const multi = rclient.multi() 27 | 28 | multi.sadd(Keys.clientsInProject({ project_id }), client_id) 29 | multi.expire(Keys.clientsInProject({ project_id }), FOUR_DAYS_IN_S) 30 | 31 | multi.hset( 32 | Keys.connectedUser({ project_id, client_id }), 33 | 'last_updated_at', 34 | Date.now() 35 | ) 36 | multi.hset( 37 | Keys.connectedUser({ project_id, client_id }), 38 | 'user_id', 39 | user._id 40 | ) 41 | multi.hset( 42 | Keys.connectedUser({ project_id, client_id }), 43 | 'first_name', 44 | user.first_name || '' 45 | ) 46 | multi.hset( 47 | Keys.connectedUser({ project_id, client_id }), 48 | 'last_name', 49 | user.last_name || '' 50 | ) 51 | multi.hset( 52 | Keys.connectedUser({ project_id, client_id }), 53 | 'email', 54 | user.email || '' 55 | ) 56 | 57 | if (cursorData) { 58 | multi.hset( 59 | Keys.connectedUser({ project_id, client_id }), 60 | 'cursorData', 61 | JSON.stringify(cursorData) 62 | ) 63 | } 64 | multi.expire( 65 | Keys.connectedUser({ project_id, client_id }), 66 | USER_TIMEOUT_IN_S 67 | ) 68 | 69 | multi.exec(function (err) { 70 | if (err) { 71 | err = new OError('problem marking user as connected').withCause(err) 72 | } 73 | callback(err) 74 | }) 75 | }, 76 | 77 | refreshClient(project_id, client_id) { 78 | logger.log({ project_id, client_id }, 'refreshing connected client') 79 | const multi = rclient.multi() 80 | multi.hset( 81 | Keys.connectedUser({ project_id, client_id }), 82 | 'last_updated_at', 83 | Date.now() 84 | ) 85 | multi.expire( 86 | Keys.connectedUser({ project_id, client_id }), 87 | USER_TIMEOUT_IN_S 88 | ) 89 | multi.exec(function (err) { 90 | if (err) { 91 | logger.err( 92 | { err, project_id, client_id }, 93 | 'problem refreshing connected client' 94 | ) 95 | } 96 | }) 97 | }, 98 | 99 | markUserAsDisconnected(project_id, client_id, callback) { 100 | logger.log({ project_id, client_id }, 'marking user as disconnected') 101 | const multi = rclient.multi() 102 | multi.srem(Keys.clientsInProject({ project_id }), client_id) 103 | multi.expire(Keys.clientsInProject({ project_id }), FOUR_DAYS_IN_S) 104 | multi.del(Keys.connectedUser({ project_id, client_id })) 105 | multi.exec(function (err) { 106 | if (err) { 107 | err = new OError('problem marking user as disconnected').withCause(err) 108 | } 109 | callback(err) 110 | }) 111 | }, 112 | 113 | _getConnectedUser(project_id, client_id, callback) { 114 | rclient.hgetall( 115 | Keys.connectedUser({ project_id, client_id }), 116 | function (err, result) { 117 | if (err) { 118 | err = new OError('problem fetching connected user details', { 119 | other_client_id: client_id, 120 | }).withCause(err) 121 | return callback(err) 122 | } 123 | if (!(result && result.user_id)) { 124 | result = { 125 | connected: false, 126 | client_id, 127 | } 128 | } else { 129 | result.connected = true 130 | result.client_id = client_id 131 | result.client_age = 132 | (Date.now() - parseInt(result.last_updated_at, 10)) / 1000 133 | if (result.cursorData) { 134 | try { 135 | result.cursorData = JSON.parse(result.cursorData) 136 | } catch (e) { 137 | OError.tag(e, 'error parsing cursorData JSON', { 138 | other_client_id: client_id, 139 | cursorData: result.cursorData, 140 | }) 141 | return callback(e) 142 | } 143 | } 144 | } 145 | callback(err, result) 146 | } 147 | ) 148 | }, 149 | 150 | getConnectedUsers(project_id, callback) { 151 | const self = this 152 | rclient.smembers( 153 | Keys.clientsInProject({ project_id }), 154 | function (err, results) { 155 | if (err) { 156 | err = new OError('problem getting clients in project').withCause(err) 157 | return callback(err) 158 | } 159 | const jobs = results.map( 160 | client_id => cb => self._getConnectedUser(project_id, client_id, cb) 161 | ) 162 | async.series(jobs, function (err, users) { 163 | if (err) { 164 | OError.tag(err, 'problem getting connected users') 165 | return callback(err) 166 | } 167 | users = users.filter( 168 | user => 169 | user && user.connected && user.client_age < REFRESH_TIMEOUT_IN_S 170 | ) 171 | callback(null, users) 172 | }) 173 | } 174 | ) 175 | }, 176 | } 177 | -------------------------------------------------------------------------------- /app/js/DeploymentManager.js: -------------------------------------------------------------------------------- 1 | const logger = require('logger-sharelatex') 2 | const settings = require('@overleaf/settings') 3 | const fs = require('fs') 4 | 5 | // Monitor a status file (e.g. /etc/real_time_status) periodically and close the 6 | // service if the file contents don't contain the matching deployment colour. 7 | 8 | const FILE_CHECK_INTERVAL = 5000 9 | const statusFile = settings.deploymentFile 10 | const deploymentColour = settings.deploymentColour 11 | 12 | let serviceCloseTime 13 | 14 | function updateDeploymentStatus(fileContent) { 15 | const closed = fileContent && !fileContent.includes(deploymentColour) 16 | if (closed && !settings.serviceIsClosed) { 17 | settings.serviceIsClosed = true 18 | serviceCloseTime = Date.now() + 60 * 1000 // delay closing by 1 minute 19 | logger.warn({ fileContent }, 'closing service') 20 | } else if (!closed && settings.serviceIsClosed) { 21 | settings.serviceIsClosed = false 22 | logger.warn({ fileContent }, 'opening service') 23 | } 24 | } 25 | 26 | function pollStatusFile() { 27 | fs.readFile(statusFile, { encoding: 'utf8' }, (err, fileContent) => { 28 | if (err) { 29 | logger.error( 30 | { file: statusFile, fsErr: err }, 31 | 'error reading service status file' 32 | ) 33 | return 34 | } 35 | updateDeploymentStatus(fileContent) 36 | }) 37 | } 38 | 39 | function checkStatusFileSync() { 40 | // crash on start up if file does not exist 41 | const content = fs.readFileSync(statusFile, { encoding: 'utf8' }) 42 | updateDeploymentStatus(content) 43 | } 44 | 45 | module.exports = { 46 | initialise() { 47 | if (statusFile && deploymentColour) { 48 | logger.log( 49 | { statusFile, deploymentColour, interval: FILE_CHECK_INTERVAL }, 50 | 'monitoring deployment status file' 51 | ) 52 | checkStatusFileSync() // perform an initial synchronous check at start up 53 | setInterval(pollStatusFile, FILE_CHECK_INTERVAL) // continue checking periodically 54 | } 55 | }, 56 | deploymentIsClosed() { 57 | return settings.serviceIsClosed && Date.now() > serviceCloseTime 58 | }, 59 | } 60 | -------------------------------------------------------------------------------- /app/js/DocumentUpdaterController.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | const logger = require('logger-sharelatex') 5 | const settings = require('@overleaf/settings') 6 | const RedisClientManager = require('./RedisClientManager') 7 | const SafeJsonParse = require('./SafeJsonParse') 8 | const EventLogger = require('./EventLogger') 9 | const HealthCheckManager = require('./HealthCheckManager') 10 | const RoomManager = require('./RoomManager') 11 | const ChannelManager = require('./ChannelManager') 12 | const metrics = require('@overleaf/metrics') 13 | 14 | let DocumentUpdaterController 15 | module.exports = DocumentUpdaterController = { 16 | // DocumentUpdaterController is responsible for updates that come via Redis 17 | // Pub/Sub from the document updater. 18 | rclientList: RedisClientManager.createClientList(settings.redis.pubsub), 19 | 20 | listenForUpdatesFromDocumentUpdater(io) { 21 | logger.log( 22 | { rclients: this.rclientList.length }, 23 | 'listening for applied-ops events' 24 | ) 25 | for (const rclient of this.rclientList) { 26 | rclient.subscribe('applied-ops') 27 | rclient.on('message', function (channel, message) { 28 | metrics.inc('rclient', 0.001) // global event rate metric 29 | if (settings.debugEvents > 0) { 30 | EventLogger.debugEvent(channel, message) 31 | } 32 | DocumentUpdaterController._processMessageFromDocumentUpdater( 33 | io, 34 | channel, 35 | message 36 | ) 37 | }) 38 | } 39 | // create metrics for each redis instance only when we have multiple redis clients 40 | if (this.rclientList.length > 1) { 41 | this.rclientList.forEach((rclient, i) => { 42 | // per client event rate metric 43 | const metricName = `rclient-${i}` 44 | rclient.on('message', () => metrics.inc(metricName, 0.001)) 45 | }) 46 | } 47 | this.handleRoomUpdates(this.rclientList) 48 | }, 49 | 50 | handleRoomUpdates(rclientSubList) { 51 | const roomEvents = RoomManager.eventSource() 52 | roomEvents.on('doc-active', function (doc_id) { 53 | const subscribePromises = rclientSubList.map(rclient => 54 | ChannelManager.subscribe(rclient, 'applied-ops', doc_id) 55 | ) 56 | RoomManager.emitOnCompletion( 57 | subscribePromises, 58 | `doc-subscribed-${doc_id}` 59 | ) 60 | }) 61 | roomEvents.on('doc-empty', doc_id => 62 | rclientSubList.map(rclient => 63 | ChannelManager.unsubscribe(rclient, 'applied-ops', doc_id) 64 | ) 65 | ) 66 | }, 67 | 68 | _processMessageFromDocumentUpdater(io, channel, message) { 69 | SafeJsonParse.parse(message, function (error, message) { 70 | if (error) { 71 | logger.error({ err: error, channel }, 'error parsing JSON') 72 | return 73 | } 74 | if (message.op) { 75 | if (message._id && settings.checkEventOrder) { 76 | const status = EventLogger.checkEventOrder( 77 | 'applied-ops', 78 | message._id, 79 | message 80 | ) 81 | if (status === 'duplicate') { 82 | return // skip duplicate events 83 | } 84 | } 85 | DocumentUpdaterController._applyUpdateFromDocumentUpdater( 86 | io, 87 | message.doc_id, 88 | message.op 89 | ) 90 | } else if (message.error) { 91 | DocumentUpdaterController._processErrorFromDocumentUpdater( 92 | io, 93 | message.doc_id, 94 | message.error, 95 | message 96 | ) 97 | } else if (message.health_check) { 98 | logger.debug( 99 | { message }, 100 | 'got health check message in applied ops channel' 101 | ) 102 | HealthCheckManager.check(channel, message.key) 103 | } 104 | }) 105 | }, 106 | 107 | _applyUpdateFromDocumentUpdater(io, doc_id, update) { 108 | let client 109 | const clientList = io.sockets.clients(doc_id) 110 | // avoid unnecessary work if no clients are connected 111 | if (clientList.length === 0) { 112 | return 113 | } 114 | // send updates to clients 115 | logger.log( 116 | { 117 | doc_id, 118 | version: update.v, 119 | source: update.meta && update.meta.source, 120 | socketIoClients: clientList.map(client => client.id), 121 | }, 122 | 'distributing updates to clients' 123 | ) 124 | const seen = {} 125 | // send messages only to unique clients (due to duplicate entries in io.sockets.clients) 126 | for (client of clientList) { 127 | if (!seen[client.id]) { 128 | seen[client.id] = true 129 | if (client.publicId === update.meta.source) { 130 | logger.log( 131 | { 132 | doc_id, 133 | version: update.v, 134 | source: update.meta.source, 135 | }, 136 | 'distributing update to sender' 137 | ) 138 | client.emit('otUpdateApplied', { v: update.v, doc: update.doc }) 139 | } else if (!update.dup) { 140 | // Duplicate ops should just be sent back to sending client for acknowledgement 141 | logger.log( 142 | { 143 | doc_id, 144 | version: update.v, 145 | source: update.meta.source, 146 | client_id: client.id, 147 | }, 148 | 'distributing update to collaborator' 149 | ) 150 | client.emit('otUpdateApplied', update) 151 | } 152 | } 153 | } 154 | if (Object.keys(seen).length < clientList.length) { 155 | metrics.inc('socket-io.duplicate-clients', 0.1) 156 | logger.log( 157 | { 158 | doc_id, 159 | socketIoClients: clientList.map(client => client.id), 160 | }, 161 | 'discarded duplicate clients' 162 | ) 163 | } 164 | }, 165 | 166 | _processErrorFromDocumentUpdater(io, doc_id, error, message) { 167 | for (const client of io.sockets.clients(doc_id)) { 168 | logger.warn( 169 | { err: error, doc_id, client_id: client.id }, 170 | 'error from document updater, disconnecting client' 171 | ) 172 | client.emit('otUpdateError', error, message) 173 | client.disconnect() 174 | } 175 | }, 176 | } 177 | -------------------------------------------------------------------------------- /app/js/DocumentUpdaterManager.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | const request = require('request') 5 | const _ = require('underscore') 6 | const OError = require('@overleaf/o-error') 7 | const logger = require('logger-sharelatex') 8 | const settings = require('@overleaf/settings') 9 | const metrics = require('@overleaf/metrics') 10 | const { 11 | ClientRequestedMissingOpsError, 12 | DocumentUpdaterRequestFailedError, 13 | NullBytesInOpError, 14 | UpdateTooLargeError, 15 | } = require('./Errors') 16 | 17 | const rclient = require('@overleaf/redis-wrapper').createClient( 18 | settings.redis.documentupdater 19 | ) 20 | const Keys = settings.redis.documentupdater.key_schema 21 | 22 | const DocumentUpdaterManager = { 23 | getDocument(project_id, doc_id, fromVersion, callback) { 24 | const timer = new metrics.Timer('get-document') 25 | const url = `${settings.apis.documentupdater.url}/project/${project_id}/doc/${doc_id}?fromVersion=${fromVersion}` 26 | logger.log( 27 | { project_id, doc_id, fromVersion }, 28 | 'getting doc from document updater' 29 | ) 30 | request.get(url, function (err, res, body) { 31 | timer.done() 32 | if (err) { 33 | OError.tag(err, 'error getting doc from doc updater') 34 | return callback(err) 35 | } 36 | if (res.statusCode >= 200 && res.statusCode < 300) { 37 | logger.log( 38 | { project_id, doc_id }, 39 | 'got doc from document document updater' 40 | ) 41 | try { 42 | body = JSON.parse(body) 43 | } catch (error) { 44 | OError.tag(error, 'error parsing doc updater response') 45 | return callback(error) 46 | } 47 | body = body || {} 48 | callback(null, body.lines, body.version, body.ranges, body.ops) 49 | } else if ([404, 422].includes(res.statusCode)) { 50 | callback(new ClientRequestedMissingOpsError(res.statusCode)) 51 | } else { 52 | callback( 53 | new DocumentUpdaterRequestFailedError('getDocument', res.statusCode) 54 | ) 55 | } 56 | }) 57 | }, 58 | 59 | checkDocument(project_id, doc_id, callback) { 60 | // in this call fromVersion = -1 means get document without docOps 61 | DocumentUpdaterManager.getDocument(project_id, doc_id, -1, callback) 62 | }, 63 | 64 | flushProjectToMongoAndDelete(project_id, callback) { 65 | // this method is called when the last connected user leaves the project 66 | logger.log({ project_id }, 'deleting project from document updater') 67 | const timer = new metrics.Timer('delete.mongo.project') 68 | // flush the project in the background when all users have left 69 | const url = 70 | `${settings.apis.documentupdater.url}/project/${project_id}?background=true` + 71 | (settings.shutDownInProgress ? '&shutdown=true' : '') 72 | request.del(url, function (err, res) { 73 | timer.done() 74 | if (err) { 75 | OError.tag(err, 'error deleting project from document updater') 76 | callback(err) 77 | } else if (res.statusCode >= 200 && res.statusCode < 300) { 78 | logger.log({ project_id }, 'deleted project from document updater') 79 | callback(null) 80 | } else { 81 | callback( 82 | new DocumentUpdaterRequestFailedError( 83 | 'flushProjectToMongoAndDelete', 84 | res.statusCode 85 | ) 86 | ) 87 | } 88 | }) 89 | }, 90 | 91 | _getPendingUpdateListKey() { 92 | const shard = _.random(0, settings.pendingUpdateListShardCount - 1) 93 | if (shard === 0) { 94 | return 'pending-updates-list' 95 | } else { 96 | return `pending-updates-list-${shard}` 97 | } 98 | }, 99 | 100 | queueChange(project_id, doc_id, change, callback) { 101 | const allowedKeys = [ 102 | 'doc', 103 | 'op', 104 | 'v', 105 | 'dupIfSource', 106 | 'meta', 107 | 'lastV', 108 | 'hash', 109 | ] 110 | change = _.pick(change, allowedKeys) 111 | const jsonChange = JSON.stringify(change) 112 | if (jsonChange.indexOf('\u0000') !== -1) { 113 | // memory corruption check 114 | return callback(new NullBytesInOpError(jsonChange)) 115 | } 116 | 117 | const updateSize = jsonChange.length 118 | if (updateSize > settings.maxUpdateSize) { 119 | return callback(new UpdateTooLargeError(updateSize)) 120 | } 121 | 122 | // record metric for each update added to queue 123 | metrics.summary('redis.pendingUpdates', updateSize, { status: 'push' }) 124 | 125 | const doc_key = `${project_id}:${doc_id}` 126 | // Push onto pendingUpdates for doc_id first, because once the doc updater 127 | // gets an entry on pending-updates-list, it starts processing. 128 | rclient.rpush( 129 | Keys.pendingUpdates({ doc_id }), 130 | jsonChange, 131 | function (error) { 132 | if (error) { 133 | error = new OError('error pushing update into redis').withCause(error) 134 | return callback(error) 135 | } 136 | const queueKey = DocumentUpdaterManager._getPendingUpdateListKey() 137 | rclient.rpush(queueKey, doc_key, function (error) { 138 | if (error) { 139 | error = new OError('error pushing doc_id into redis') 140 | .withInfo({ queueKey }) 141 | .withCause(error) 142 | } 143 | callback(error) 144 | }) 145 | } 146 | ) 147 | }, 148 | } 149 | 150 | module.exports = DocumentUpdaterManager 151 | -------------------------------------------------------------------------------- /app/js/DrainManager.js: -------------------------------------------------------------------------------- 1 | const logger = require('logger-sharelatex') 2 | 3 | module.exports = { 4 | startDrainTimeWindow(io, minsToDrain, callback) { 5 | const drainPerMin = io.sockets.clients().length / minsToDrain 6 | // enforce minimum drain rate 7 | this.startDrain(io, Math.max(drainPerMin / 60, 4), callback) 8 | }, 9 | 10 | startDrain(io, rate, callback) { 11 | // Clear out any old interval 12 | clearInterval(this.interval) 13 | logger.log({ rate }, 'starting drain') 14 | if (rate === 0) { 15 | return 16 | } 17 | let pollingInterval 18 | if (rate < 1) { 19 | // allow lower drain rates 20 | // e.g. rate=0.1 will drain one client every 10 seconds 21 | pollingInterval = 1000 / rate 22 | rate = 1 23 | } else { 24 | pollingInterval = 1000 25 | } 26 | this.interval = setInterval(() => { 27 | const requestedAllClientsToReconnect = this.reconnectNClients(io, rate) 28 | if (requestedAllClientsToReconnect && callback) { 29 | callback() 30 | callback = undefined 31 | } 32 | }, pollingInterval) 33 | }, 34 | 35 | RECONNECTED_CLIENTS: {}, 36 | reconnectNClients(io, N) { 37 | let drainedCount = 0 38 | for (const client of io.sockets.clients()) { 39 | if (!this.RECONNECTED_CLIENTS[client.id]) { 40 | this.RECONNECTED_CLIENTS[client.id] = true 41 | logger.log( 42 | { client_id: client.id }, 43 | 'Asking client to reconnect gracefully' 44 | ) 45 | client.emit('reconnectGracefully') 46 | drainedCount++ 47 | } 48 | const haveDrainedNClients = drainedCount === N 49 | if (haveDrainedNClients) { 50 | break 51 | } 52 | } 53 | if (drainedCount < N) { 54 | logger.log('All clients have been told to reconnectGracefully') 55 | return true 56 | } 57 | return false 58 | }, 59 | } 60 | -------------------------------------------------------------------------------- /app/js/Errors.js: -------------------------------------------------------------------------------- 1 | const OError = require('@overleaf/o-error') 2 | 3 | class ClientRequestedMissingOpsError extends OError { 4 | constructor(statusCode) { 5 | super('doc updater could not load requested ops', { 6 | statusCode, 7 | }) 8 | } 9 | } 10 | 11 | class CodedError extends OError { 12 | constructor(message, code) { 13 | super(message, { code }) 14 | } 15 | } 16 | 17 | class CorruptedJoinProjectResponseError extends OError { 18 | constructor() { 19 | super('no data returned from joinProject request') 20 | } 21 | } 22 | 23 | class DataTooLargeToParseError extends OError { 24 | constructor(data) { 25 | super('data too large to parse', { 26 | head: data.slice(0, 1024), 27 | length: data.length, 28 | }) 29 | } 30 | } 31 | 32 | class DocumentUpdaterRequestFailedError extends OError { 33 | constructor(action, statusCode) { 34 | super('doc updater returned a non-success status code', { 35 | action, 36 | statusCode, 37 | }) 38 | } 39 | } 40 | 41 | class JoinLeaveEpochMismatchError extends OError { 42 | constructor() { 43 | super('joinLeaveEpoch mismatch') 44 | } 45 | } 46 | 47 | class MissingSessionError extends OError { 48 | constructor() { 49 | super('could not look up session by key') 50 | } 51 | } 52 | 53 | class NotAuthorizedError extends OError { 54 | constructor() { 55 | super('not authorized') 56 | } 57 | } 58 | 59 | class NotJoinedError extends OError { 60 | constructor() { 61 | super('no project_id found on client') 62 | } 63 | } 64 | 65 | class NullBytesInOpError extends OError { 66 | constructor(jsonChange) { 67 | super('null bytes found in op', { jsonChange }) 68 | } 69 | } 70 | 71 | class UnexpectedArgumentsError extends OError { 72 | constructor() { 73 | super('unexpected arguments') 74 | } 75 | } 76 | 77 | class UpdateTooLargeError extends OError { 78 | constructor(updateSize) { 79 | super('update is too large', { updateSize }) 80 | } 81 | } 82 | 83 | class WebApiRequestFailedError extends OError { 84 | constructor(statusCode) { 85 | super('non-success status code from web', { statusCode }) 86 | } 87 | } 88 | 89 | module.exports = { 90 | CodedError, 91 | CorruptedJoinProjectResponseError, 92 | ClientRequestedMissingOpsError, 93 | DataTooLargeToParseError, 94 | DocumentUpdaterRequestFailedError, 95 | JoinLeaveEpochMismatchError, 96 | MissingSessionError, 97 | NotAuthorizedError, 98 | NotJoinedError, 99 | NullBytesInOpError, 100 | UnexpectedArgumentsError, 101 | UpdateTooLargeError, 102 | WebApiRequestFailedError, 103 | } 104 | -------------------------------------------------------------------------------- /app/js/EventLogger.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | let EventLogger 5 | const logger = require('logger-sharelatex') 6 | const metrics = require('@overleaf/metrics') 7 | const settings = require('@overleaf/settings') 8 | 9 | // keep track of message counters to detect duplicate and out of order events 10 | // messsage ids have the format "UNIQUEHOSTKEY-COUNTER" 11 | 12 | const EVENT_LOG_COUNTER = {} 13 | const EVENT_LOG_TIMESTAMP = {} 14 | let EVENT_LAST_CLEAN_TIMESTAMP = 0 15 | 16 | // counter for debug logs 17 | let COUNTER = 0 18 | 19 | module.exports = EventLogger = { 20 | MAX_STALE_TIME_IN_MS: 3600 * 1000, 21 | 22 | debugEvent(channel, message) { 23 | if (settings.debugEvents > 0) { 24 | logger.log({ channel, message, counter: COUNTER++ }, 'logging event') 25 | settings.debugEvents-- 26 | } 27 | }, 28 | 29 | checkEventOrder(channel, message_id) { 30 | if (typeof message_id !== 'string') { 31 | return 32 | } 33 | let result 34 | if (!(result = message_id.match(/^(.*)-(\d+)$/))) { 35 | return 36 | } 37 | const key = result[1] 38 | const count = parseInt(result[2], 0) 39 | if (!(count >= 0)) { 40 | // ignore checks if counter is not present 41 | return 42 | } 43 | // store the last count in a hash for each host 44 | const previous = EventLogger._storeEventCount(key, count) 45 | if (!previous || count === previous + 1) { 46 | metrics.inc(`event.${channel}.valid`) 47 | return // order is ok 48 | } 49 | if (count === previous) { 50 | metrics.inc(`event.${channel}.duplicate`) 51 | logger.warn({ channel, message_id }, 'duplicate event') 52 | return 'duplicate' 53 | } else { 54 | metrics.inc(`event.${channel}.out-of-order`) 55 | logger.warn( 56 | { channel, message_id, key, previous, count }, 57 | 'out of order event' 58 | ) 59 | return 'out-of-order' 60 | } 61 | }, 62 | 63 | _storeEventCount(key, count) { 64 | const previous = EVENT_LOG_COUNTER[key] 65 | const now = Date.now() 66 | EVENT_LOG_COUNTER[key] = count 67 | EVENT_LOG_TIMESTAMP[key] = now 68 | // periodically remove old counts 69 | if (now - EVENT_LAST_CLEAN_TIMESTAMP > EventLogger.MAX_STALE_TIME_IN_MS) { 70 | EventLogger._cleanEventStream(now) 71 | EVENT_LAST_CLEAN_TIMESTAMP = now 72 | } 73 | return previous 74 | }, 75 | 76 | _cleanEventStream(now) { 77 | Object.entries(EVENT_LOG_TIMESTAMP).forEach(([key, timestamp]) => { 78 | if (now - timestamp > EventLogger.MAX_STALE_TIME_IN_MS) { 79 | delete EVENT_LOG_COUNTER[key] 80 | delete EVENT_LOG_TIMESTAMP[key] 81 | } 82 | }) 83 | }, 84 | } 85 | -------------------------------------------------------------------------------- /app/js/HealthCheckManager.js: -------------------------------------------------------------------------------- 1 | const metrics = require('@overleaf/metrics') 2 | const logger = require('logger-sharelatex') 3 | 4 | const os = require('os') 5 | const HOST = os.hostname() 6 | const PID = process.pid 7 | let COUNT = 0 8 | 9 | const CHANNEL_MANAGER = {} // hash of event checkers by channel name 10 | const CHANNEL_ERROR = {} // error status by channel name 11 | 12 | module.exports = class HealthCheckManager { 13 | // create an instance of this class which checks that an event with a unique 14 | // id is received only once within a timeout 15 | constructor(channel, timeout) { 16 | // unique event string 17 | this.channel = channel 18 | this.id = `host=${HOST}:pid=${PID}:count=${COUNT++}` 19 | // count of number of times the event is received 20 | this.count = 0 21 | // after a timeout check the status of the count 22 | this.handler = setTimeout(() => { 23 | this.setStatus() 24 | }, timeout || 1000) 25 | // use a timer to record the latency of the channel 26 | this.timer = new metrics.Timer(`event.${this.channel}.latency`) 27 | // keep a record of these objects to dispatch on 28 | CHANNEL_MANAGER[this.channel] = this 29 | } 30 | 31 | processEvent(id) { 32 | // if this is our event record it 33 | if (id === this.id) { 34 | this.count++ 35 | if (this.timer) { 36 | this.timer.done() 37 | } 38 | this.timer = undefined // only time the latency of the first event 39 | } 40 | } 41 | 42 | setStatus() { 43 | // if we saw the event anything other than a single time that is an error 44 | const isFailing = this.count !== 1 45 | if (isFailing) { 46 | logger.err( 47 | { channel: this.channel, count: this.count, id: this.id }, 48 | 'redis channel health check error' 49 | ) 50 | } 51 | CHANNEL_ERROR[this.channel] = isFailing 52 | } 53 | 54 | // class methods 55 | static check(channel, id) { 56 | // dispatch event to manager for channel 57 | if (CHANNEL_MANAGER[channel]) { 58 | CHANNEL_MANAGER[channel].processEvent(id) 59 | } 60 | } 61 | 62 | static status() { 63 | // return status of all channels for logging 64 | return CHANNEL_ERROR 65 | } 66 | 67 | static isFailing() { 68 | // check if any channel status is bad 69 | for (const channel in CHANNEL_ERROR) { 70 | const error = CHANNEL_ERROR[channel] 71 | if (error === true) { 72 | return true 73 | } 74 | } 75 | return false 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/js/HttpApiController.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | const WebsocketLoadBalancer = require('./WebsocketLoadBalancer') 5 | const DrainManager = require('./DrainManager') 6 | const logger = require('logger-sharelatex') 7 | 8 | module.exports = { 9 | sendMessage(req, res) { 10 | logger.log({ message: req.params.message }, 'sending message') 11 | if (Array.isArray(req.body)) { 12 | for (const payload of req.body) { 13 | WebsocketLoadBalancer.emitToRoom( 14 | req.params.project_id, 15 | req.params.message, 16 | payload 17 | ) 18 | } 19 | } else { 20 | WebsocketLoadBalancer.emitToRoom( 21 | req.params.project_id, 22 | req.params.message, 23 | req.body 24 | ) 25 | } 26 | res.sendStatus(204) 27 | }, 28 | 29 | startDrain(req, res) { 30 | const io = req.app.get('io') 31 | let rate = req.query.rate || '4' 32 | rate = parseFloat(rate) || 0 33 | logger.log({ rate }, 'setting client drain rate') 34 | DrainManager.startDrain(io, rate) 35 | res.sendStatus(204) 36 | }, 37 | 38 | disconnectClient(req, res, next) { 39 | const io = req.app.get('io') 40 | const { client_id } = req.params 41 | const client = io.sockets.sockets[client_id] 42 | 43 | if (!client) { 44 | logger.info({ client_id }, 'api: client already disconnected') 45 | res.sendStatus(404) 46 | return 47 | } 48 | logger.warn({ client_id }, 'api: requesting client disconnect') 49 | client.on('disconnect', () => res.sendStatus(204)) 50 | client.disconnect() 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /app/js/HttpController.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | 5 | let HttpController 6 | module.exports = HttpController = { 7 | // The code in this controller is hard to unit test because of a lot of 8 | // dependencies on internal socket.io methods. It is not critical to the running 9 | // of ShareLaTeX, and is only used for getting stats about connected clients, 10 | // and for checking internal state in acceptance tests. The acceptances tests 11 | // should provide appropriate coverage. 12 | _getConnectedClientView(ioClient) { 13 | const client_id = ioClient.id 14 | const { 15 | project_id, 16 | user_id, 17 | first_name, 18 | last_name, 19 | email, 20 | connected_time, 21 | } = ioClient.ol_context 22 | const client = { 23 | client_id, 24 | project_id, 25 | user_id, 26 | first_name, 27 | last_name, 28 | email, 29 | connected_time, 30 | } 31 | client.rooms = Object.keys(ioClient.manager.roomClients[client_id] || {}) 32 | // drop the namespace 33 | .filter(room => room !== '') 34 | // room names are composed as '/' and the default 35 | // namespace is empty (see comments in RoomManager), just drop the '/' 36 | .map(fullRoomPath => fullRoomPath.slice(1)) 37 | return client 38 | }, 39 | 40 | getConnectedClients(req, res) { 41 | const io = req.app.get('io') 42 | const ioClients = io.sockets.clients() 43 | 44 | res.json(ioClients.map(HttpController._getConnectedClientView)) 45 | }, 46 | 47 | getConnectedClient(req, res) { 48 | const { client_id } = req.params 49 | const io = req.app.get('io') 50 | const ioClient = io.sockets.sockets[client_id] 51 | if (!ioClient) { 52 | res.sendStatus(404) 53 | return 54 | } 55 | res.json(HttpController._getConnectedClientView(ioClient)) 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /app/js/RedisClientManager.js: -------------------------------------------------------------------------------- 1 | const redis = require('@overleaf/redis-wrapper') 2 | const logger = require('logger-sharelatex') 3 | 4 | module.exports = { 5 | createClientList(...configs) { 6 | // create a dynamic list of redis clients, excluding any configurations which are not defined 7 | return configs.filter(Boolean).map(x => { 8 | const redisType = x.cluster 9 | ? 'cluster' 10 | : x.sentinels 11 | ? 'sentinel' 12 | : x.host 13 | ? 'single' 14 | : 'unknown' 15 | logger.log({ redis: redisType }, 'creating redis client') 16 | return redis.createClient(x) 17 | }) 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /app/js/RoomManager.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | const logger = require('logger-sharelatex') 5 | const metrics = require('@overleaf/metrics') 6 | const { EventEmitter } = require('events') 7 | const OError = require('@overleaf/o-error') 8 | 9 | const IdMap = new Map() // keep track of whether ids are from projects or docs 10 | const RoomEvents = new EventEmitter() // emits {project,doc}-active and {project,doc}-empty events 11 | 12 | // Manage socket.io rooms for individual projects and docs 13 | // 14 | // The first time someone joins a project or doc we emit a 'project-active' or 15 | // 'doc-active' event. 16 | // 17 | // When the last person leaves a project or doc, we emit 'project-empty' or 18 | // 'doc-empty' event. 19 | // 20 | // The pubsub side is handled by ChannelManager 21 | 22 | module.exports = { 23 | joinProject(client, project_id, callback) { 24 | this.joinEntity(client, 'project', project_id, callback) 25 | }, 26 | 27 | joinDoc(client, doc_id, callback) { 28 | this.joinEntity(client, 'doc', doc_id, callback) 29 | }, 30 | 31 | leaveDoc(client, doc_id) { 32 | this.leaveEntity(client, 'doc', doc_id) 33 | }, 34 | 35 | leaveProjectAndDocs(client) { 36 | // what rooms is this client in? we need to leave them all. socket.io 37 | // will cause us to leave the rooms, so we only need to manage our 38 | // channel subscriptions... but it will be safer if we leave them 39 | // explicitly, and then socket.io will just regard this as a client that 40 | // has not joined any rooms and do a final disconnection. 41 | const roomsToLeave = this._roomsClientIsIn(client) 42 | logger.log({ client: client.id, roomsToLeave }, 'client leaving project') 43 | for (const id of roomsToLeave) { 44 | const entity = IdMap.get(id) 45 | this.leaveEntity(client, entity, id) 46 | } 47 | }, 48 | 49 | emitOnCompletion(promiseList, eventName) { 50 | Promise.all(promiseList) 51 | .then(() => RoomEvents.emit(eventName)) 52 | .catch(err => RoomEvents.emit(eventName, err)) 53 | }, 54 | 55 | eventSource() { 56 | return RoomEvents 57 | }, 58 | 59 | joinEntity(client, entity, id, callback) { 60 | const beforeCount = this._clientsInRoom(client, id) 61 | // client joins room immediately but joinDoc request does not complete 62 | // until room is subscribed 63 | client.join(id) 64 | // is this a new room? if so, subscribe 65 | if (beforeCount === 0) { 66 | logger.log({ entity, id }, 'room is now active') 67 | RoomEvents.once(`${entity}-subscribed-${id}`, function (err) { 68 | // only allow the client to join when all the relevant channels have subscribed 69 | if (err) { 70 | OError.tag(err, 'error joining', { entity, id }) 71 | return callback(err) 72 | } 73 | logger.log( 74 | { client: client.id, entity, id, beforeCount }, 75 | 'client joined new room and subscribed to channel' 76 | ) 77 | callback(err) 78 | }) 79 | RoomEvents.emit(`${entity}-active`, id) 80 | IdMap.set(id, entity) 81 | // keep track of the number of listeners 82 | metrics.gauge('room-listeners', RoomEvents.eventNames().length) 83 | } else { 84 | logger.log( 85 | { client: client.id, entity, id, beforeCount }, 86 | 'client joined existing room' 87 | ) 88 | callback() 89 | } 90 | }, 91 | 92 | leaveEntity(client, entity, id) { 93 | // Ignore any requests to leave when the client is not actually in the 94 | // room. This can happen if the client sends spurious leaveDoc requests 95 | // for old docs after a reconnection. 96 | // This can now happen all the time, as we skip the join for clients that 97 | // disconnect before joinProject/joinDoc completed. 98 | if (!this._clientAlreadyInRoom(client, id)) { 99 | logger.log( 100 | { client: client.id, entity, id }, 101 | 'ignoring request from client to leave room it is not in' 102 | ) 103 | return 104 | } 105 | client.leave(id) 106 | const afterCount = this._clientsInRoom(client, id) 107 | logger.log( 108 | { client: client.id, entity, id, afterCount }, 109 | 'client left room' 110 | ) 111 | // is the room now empty? if so, unsubscribe 112 | if (!entity) { 113 | logger.error({ entity: id }, 'unknown entity when leaving with id') 114 | return 115 | } 116 | if (afterCount === 0) { 117 | logger.log({ entity, id }, 'room is now empty') 118 | RoomEvents.emit(`${entity}-empty`, id) 119 | IdMap.delete(id) 120 | metrics.gauge('room-listeners', RoomEvents.eventNames().length) 121 | } 122 | }, 123 | 124 | // internal functions below, these access socket.io rooms data directly and 125 | // will need updating for socket.io v2 126 | 127 | // The below code makes some assumptions that are always true for v0 128 | // - we are using the base namespace '', so room names are '/' 129 | // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L62 130 | // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L1018 131 | // - client.namespace is a Namespace 132 | // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/namespace.js#L204 133 | // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/socket.js#L40 134 | // - client.manager is a Manager 135 | // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/namespace.js#L204 136 | // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/socket.js#L41 137 | // - a Manager has 138 | // - `.rooms={'NAMESPACE/ENTITY': []}` and 139 | // - `.roomClients={'CLIENT_ID': {'...': true}}` 140 | // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L287-L288 141 | // https://github.com/socketio/socket.io/blob/e4d61b1be65ac3313a85da111a46777aa8d4aae3/lib/manager.js#L444-L455 142 | 143 | _clientsInRoom(client, room) { 144 | const clients = client.manager.rooms['/' + room] || [] 145 | return clients.length 146 | }, 147 | 148 | _roomsClientIsIn(client) { 149 | const rooms = client.manager.roomClients[client.id] || {} 150 | return ( 151 | Object.keys(rooms) 152 | // drop the namespace 153 | .filter(room => room !== '') 154 | // room names are composed as '/' and the default 155 | // namespace is empty (see comments above), just drop the '/' 156 | .map(fullRoomPath => fullRoomPath.slice(1)) 157 | ) 158 | }, 159 | 160 | _clientAlreadyInRoom(client, room) { 161 | const rooms = client.manager.roomClients[client.id] || {} 162 | return !!rooms['/' + room] 163 | }, 164 | } 165 | -------------------------------------------------------------------------------- /app/js/SafeJsonParse.js: -------------------------------------------------------------------------------- 1 | const Settings = require('@overleaf/settings') 2 | const { DataTooLargeToParseError } = require('./Errors') 3 | 4 | module.exports = { 5 | parse(data, callback) { 6 | if (data.length > Settings.maxUpdateSize) { 7 | return callback(new DataTooLargeToParseError(data)) 8 | } 9 | let parsed 10 | try { 11 | parsed = JSON.parse(data) 12 | } catch (e) { 13 | return callback(e) 14 | } 15 | callback(null, parsed) 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /app/js/SessionSockets.js: -------------------------------------------------------------------------------- 1 | const OError = require('@overleaf/o-error') 2 | const { EventEmitter } = require('events') 3 | const { MissingSessionError } = require('./Errors') 4 | 5 | module.exports = function (io, sessionStore, cookieParser, cookieName) { 6 | const missingSessionError = new MissingSessionError() 7 | 8 | const sessionSockets = new EventEmitter() 9 | function next(error, socket, session) { 10 | sessionSockets.emit('connection', error, socket, session) 11 | } 12 | 13 | io.on('connection', function (socket) { 14 | const req = socket.handshake 15 | cookieParser(req, {}, function () { 16 | const sessionId = req.signedCookies && req.signedCookies[cookieName] 17 | if (!sessionId) { 18 | return next(missingSessionError, socket) 19 | } 20 | sessionStore.get(sessionId, function (error, session) { 21 | if (error) { 22 | OError.tag(error, 'error getting session from sessionStore', { 23 | sessionId, 24 | }) 25 | return next(error, socket) 26 | } 27 | if (!session) { 28 | return next(missingSessionError, socket) 29 | } 30 | next(null, socket, session) 31 | }) 32 | }) 33 | }) 34 | 35 | return sessionSockets 36 | } 37 | -------------------------------------------------------------------------------- /app/js/WebApiManager.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | const request = require('request') 5 | const OError = require('@overleaf/o-error') 6 | const settings = require('@overleaf/settings') 7 | const logger = require('logger-sharelatex') 8 | const { 9 | CodedError, 10 | CorruptedJoinProjectResponseError, 11 | NotAuthorizedError, 12 | WebApiRequestFailedError, 13 | } = require('./Errors') 14 | 15 | module.exports = { 16 | joinProject(project_id, user, callback) { 17 | const user_id = user._id 18 | logger.log({ project_id, user_id }, 'sending join project request to web') 19 | const url = `${settings.apis.web.url}/project/${project_id}/join` 20 | const headers = {} 21 | if (user.anonymousAccessToken) { 22 | headers['x-sl-anonymous-access-token'] = user.anonymousAccessToken 23 | } 24 | request.post( 25 | { 26 | url, 27 | qs: { user_id }, 28 | auth: { 29 | user: settings.apis.web.user, 30 | pass: settings.apis.web.pass, 31 | sendImmediately: true, 32 | }, 33 | json: true, 34 | jar: false, 35 | headers, 36 | }, 37 | function (error, response, data) { 38 | if (error) { 39 | OError.tag(error, 'join project request failed') 40 | return callback(error) 41 | } 42 | if (response.statusCode >= 200 && response.statusCode < 300) { 43 | if (!(data && data.project)) { 44 | return callback(new CorruptedJoinProjectResponseError()) 45 | } 46 | callback( 47 | null, 48 | data.project, 49 | data.privilegeLevel, 50 | data.isRestrictedUser 51 | ) 52 | } else if (response.statusCode === 429) { 53 | callback( 54 | new CodedError( 55 | 'rate-limit hit when joining project', 56 | 'TooManyRequests' 57 | ) 58 | ) 59 | } else if (response.statusCode === 403) { 60 | callback(new NotAuthorizedError()) 61 | } else if (response.statusCode === 404) { 62 | callback(new CodedError('project not found', 'ProjectNotFound')) 63 | } else { 64 | callback(new WebApiRequestFailedError(response.statusCode)) 65 | } 66 | } 67 | ) 68 | }, 69 | } 70 | -------------------------------------------------------------------------------- /app/js/WebsocketLoadBalancer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | const Settings = require('@overleaf/settings') 5 | const logger = require('logger-sharelatex') 6 | const RedisClientManager = require('./RedisClientManager') 7 | const SafeJsonParse = require('./SafeJsonParse') 8 | const EventLogger = require('./EventLogger') 9 | const HealthCheckManager = require('./HealthCheckManager') 10 | const RoomManager = require('./RoomManager') 11 | const ChannelManager = require('./ChannelManager') 12 | const ConnectedUsersManager = require('./ConnectedUsersManager') 13 | 14 | const RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST = [ 15 | 'connectionAccepted', 16 | 'otUpdateApplied', 17 | 'otUpdateError', 18 | 'joinDoc', 19 | 'reciveNewDoc', 20 | 'reciveNewFile', 21 | 'reciveNewFolder', 22 | 'removeEntity', 23 | ] 24 | 25 | let WebsocketLoadBalancer 26 | module.exports = WebsocketLoadBalancer = { 27 | rclientPubList: RedisClientManager.createClientList(Settings.redis.pubsub), 28 | rclientSubList: RedisClientManager.createClientList(Settings.redis.pubsub), 29 | 30 | emitToRoom(room_id, message, ...payload) { 31 | if (!room_id) { 32 | logger.warn( 33 | { message, payload }, 34 | 'no room_id provided, ignoring emitToRoom' 35 | ) 36 | return 37 | } 38 | const data = JSON.stringify({ 39 | room_id, 40 | message, 41 | payload, 42 | }) 43 | logger.log( 44 | { room_id, message, payload, length: data.length }, 45 | 'emitting to room' 46 | ) 47 | 48 | this.rclientPubList.map(rclientPub => 49 | ChannelManager.publish(rclientPub, 'editor-events', room_id, data) 50 | ) 51 | }, 52 | 53 | emitToAll(message, ...payload) { 54 | this.emitToRoom('all', message, ...payload) 55 | }, 56 | 57 | listenForEditorEvents(io) { 58 | logger.log( 59 | { rclients: this.rclientSubList.length }, 60 | 'listening for editor events' 61 | ) 62 | for (const rclientSub of this.rclientSubList) { 63 | rclientSub.subscribe('editor-events') 64 | rclientSub.on('message', function (channel, message) { 65 | if (Settings.debugEvents > 0) { 66 | EventLogger.debugEvent(channel, message) 67 | } 68 | WebsocketLoadBalancer._processEditorEvent(io, channel, message) 69 | }) 70 | } 71 | this.handleRoomUpdates(this.rclientSubList) 72 | }, 73 | 74 | handleRoomUpdates(rclientSubList) { 75 | const roomEvents = RoomManager.eventSource() 76 | roomEvents.on('project-active', function (project_id) { 77 | const subscribePromises = rclientSubList.map(rclient => 78 | ChannelManager.subscribe(rclient, 'editor-events', project_id) 79 | ) 80 | RoomManager.emitOnCompletion( 81 | subscribePromises, 82 | `project-subscribed-${project_id}` 83 | ) 84 | }) 85 | roomEvents.on('project-empty', project_id => 86 | rclientSubList.map(rclient => 87 | ChannelManager.unsubscribe(rclient, 'editor-events', project_id) 88 | ) 89 | ) 90 | }, 91 | 92 | _processEditorEvent(io, channel, message) { 93 | SafeJsonParse.parse(message, function (error, message) { 94 | if (error) { 95 | logger.error({ err: error, channel }, 'error parsing JSON') 96 | return 97 | } 98 | if (message.room_id === 'all') { 99 | io.sockets.emit(message.message, ...message.payload) 100 | } else if ( 101 | message.message === 'clientTracking.refresh' && 102 | message.room_id 103 | ) { 104 | const clientList = io.sockets.clients(message.room_id) 105 | logger.log( 106 | { 107 | channel, 108 | message: message.message, 109 | room_id: message.room_id, 110 | message_id: message._id, 111 | socketIoClients: clientList.map(client => client.id), 112 | }, 113 | 'refreshing client list' 114 | ) 115 | for (const client of clientList) { 116 | ConnectedUsersManager.refreshClient(message.room_id, client.publicId) 117 | } 118 | } else if (message.room_id) { 119 | if (message._id && Settings.checkEventOrder) { 120 | const status = EventLogger.checkEventOrder( 121 | 'editor-events', 122 | message._id, 123 | message 124 | ) 125 | if (status === 'duplicate') { 126 | return // skip duplicate events 127 | } 128 | } 129 | 130 | const is_restricted_message = 131 | !RESTRICTED_USER_MESSAGE_TYPE_PASS_LIST.includes(message.message) 132 | 133 | // send messages only to unique clients (due to duplicate entries in io.sockets.clients) 134 | const clientList = io.sockets 135 | .clients(message.room_id) 136 | .filter( 137 | client => 138 | !(is_restricted_message && client.ol_context.is_restricted_user) 139 | ) 140 | 141 | // avoid unnecessary work if no clients are connected 142 | if (clientList.length === 0) { 143 | return 144 | } 145 | logger.log( 146 | { 147 | channel, 148 | message: message.message, 149 | room_id: message.room_id, 150 | message_id: message._id, 151 | socketIoClients: clientList.map(client => client.id), 152 | }, 153 | 'distributing event to clients' 154 | ) 155 | const seen = new Map() 156 | for (const client of clientList) { 157 | if (!seen.has(client.id)) { 158 | seen.set(client.id, true) 159 | client.emit(message.message, ...message.payload) 160 | } 161 | } 162 | } else if (message.health_check) { 163 | logger.debug( 164 | { message }, 165 | 'got health check message in editor events channel' 166 | ) 167 | HealthCheckManager.check(channel, message.key) 168 | } 169 | }) 170 | }, 171 | } 172 | -------------------------------------------------------------------------------- /buildscript.txt: -------------------------------------------------------------------------------- 1 | real-time 2 | --dependencies=redis 3 | --docker-repos=gcr.io/overleaf-ops 4 | --env-add= 5 | --env-pass-through= 6 | --node-version=12.22.3 7 | --public-repo=True 8 | --script-version=3.11.0 9 | -------------------------------------------------------------------------------- /config/settings.defaults.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | 3 | const settings = { 4 | redis: { 5 | pubsub: { 6 | host: 7 | process.env.PUBSUB_REDIS_HOST || process.env.REDIS_HOST || 'localhost', 8 | port: process.env.PUBSUB_REDIS_PORT || process.env.REDIS_PORT || '6379', 9 | password: 10 | process.env.PUBSUB_REDIS_PASSWORD || process.env.REDIS_PASSWORD || '', 11 | maxRetriesPerRequest: parseInt( 12 | process.env.PUBSUB_REDIS_MAX_RETRIES_PER_REQUEST || 13 | process.env.REDIS_MAX_RETRIES_PER_REQUEST || 14 | '20' 15 | ), 16 | }, 17 | 18 | realtime: { 19 | host: 20 | process.env.REAL_TIME_REDIS_HOST || 21 | process.env.REDIS_HOST || 22 | 'localhost', 23 | port: 24 | process.env.REAL_TIME_REDIS_PORT || process.env.REDIS_PORT || '6379', 25 | password: 26 | process.env.REAL_TIME_REDIS_PASSWORD || 27 | process.env.REDIS_PASSWORD || 28 | '', 29 | key_schema: { 30 | clientsInProject({ project_id }) { 31 | return `clients_in_project:{${project_id}}` 32 | }, 33 | connectedUser({ project_id, client_id }) { 34 | return `connected_user:{${project_id}}:${client_id}` 35 | }, 36 | }, 37 | maxRetriesPerRequest: parseInt( 38 | process.env.REAL_TIME_REDIS_MAX_RETRIES_PER_REQUEST || 39 | process.env.REDIS_MAX_RETRIES_PER_REQUEST || 40 | '20' 41 | ), 42 | }, 43 | 44 | documentupdater: { 45 | host: 46 | process.env.DOC_UPDATER_REDIS_HOST || 47 | process.env.REDIS_HOST || 48 | 'localhost', 49 | port: 50 | process.env.DOC_UPDATER_REDIS_PORT || process.env.REDIS_PORT || '6379', 51 | password: 52 | process.env.DOC_UPDATER_REDIS_PASSWORD || 53 | process.env.REDIS_PASSWORD || 54 | '', 55 | key_schema: { 56 | pendingUpdates({ doc_id }) { 57 | return `PendingUpdates:{${doc_id}}` 58 | }, 59 | }, 60 | maxRetriesPerRequest: parseInt( 61 | process.env.DOC_UPDATER_REDIS_MAX_RETRIES_PER_REQUEST || 62 | process.env.REDIS_MAX_RETRIES_PER_REQUEST || 63 | '20' 64 | ), 65 | }, 66 | 67 | websessions: { 68 | host: process.env.WEB_REDIS_HOST || process.env.REDIS_HOST || 'localhost', 69 | port: process.env.WEB_REDIS_PORT || process.env.REDIS_PORT || '6379', 70 | password: 71 | process.env.WEB_REDIS_PASSWORD || process.env.REDIS_PASSWORD || '', 72 | maxRetriesPerRequest: parseInt( 73 | process.env.WEB_REDIS_MAX_RETRIES_PER_REQUEST || 74 | process.env.REDIS_MAX_RETRIES_PER_REQUEST || 75 | '20' 76 | ), 77 | }, 78 | }, 79 | 80 | internal: { 81 | realTime: { 82 | port: 3026, 83 | host: process.env.LISTEN_ADDRESS || 'localhost', 84 | user: 'sharelatex', 85 | pass: 'password', 86 | }, 87 | }, 88 | 89 | apis: { 90 | web: { 91 | url: `http://${ 92 | process.env.WEB_API_HOST || process.env.WEB_HOST || 'localhost' 93 | }:${process.env.WEB_API_PORT || process.env.WEB_PORT || 3000}`, 94 | user: process.env.WEB_API_USER || 'sharelatex', 95 | pass: process.env.WEB_API_PASSWORD || 'password', 96 | }, 97 | documentupdater: { 98 | url: `http://${ 99 | process.env.DOCUMENT_UPDATER_HOST || 100 | process.env.DOCUPDATER_HOST || 101 | 'localhost' 102 | }:3003`, 103 | }, 104 | }, 105 | 106 | security: { 107 | sessionSecret: process.env.SESSION_SECRET || 'secret-please-change', 108 | }, 109 | 110 | cookieName: process.env.COOKIE_NAME || 'sharelatex.sid', 111 | 112 | // Expose the hostname in the `debug.getHostname` rpc 113 | exposeHostname: process.env.EXPOSE_HOSTNAME === 'true', 114 | 115 | max_doc_length: 2 * 1024 * 1024, // 2mb 116 | 117 | // should be set to the same same as dispatcherCount in document updater 118 | pendingUpdateListShardCount: parseInt( 119 | process.env.PENDING_UPDATE_LIST_SHARD_COUNT || 10, 120 | 10 121 | ), 122 | 123 | // combine 124 | // max_doc_length (2mb see above) * 2 (delete + insert) 125 | // max_ranges_size (3mb see MAX_RANGES_SIZE in document-updater) 126 | // overhead for JSON serialization 127 | maxUpdateSize: 128 | parseInt(process.env.MAX_UPDATE_SIZE) || 7 * 1024 * 1024 + 64 * 1024, 129 | 130 | shutdownDrainTimeWindow: process.env.SHUTDOWN_DRAIN_TIME_WINDOW || 9, 131 | 132 | // The shutdown procedure asks clients to reconnect gracefully. 133 | // 3rd-party/buggy clients may not act upon receiving the message and keep 134 | // stale connections alive. We forcefully disconnect them after X ms: 135 | gracefulReconnectTimeoutMs: 136 | parseInt(process.env.GRACEFUL_RECONNECT_TIMEOUT_MS, 10) || 137 | // The frontend allows actively editing users to keep the connection open 138 | // for up-to ConnectionManager.MAX_RECONNECT_GRACEFULLY_INTERVAL=45s 139 | // Permit an extra delay to account for slow/flaky connections. 140 | (45 + 30) * 1000, 141 | 142 | continualPubsubTraffic: process.env.CONTINUAL_PUBSUB_TRAFFIC || false, 143 | 144 | checkEventOrder: process.env.CHECK_EVENT_ORDER || false, 145 | 146 | publishOnIndividualChannels: 147 | process.env.PUBLISH_ON_INDIVIDUAL_CHANNELS || false, 148 | 149 | statusCheckInterval: parseInt(process.env.STATUS_CHECK_INTERVAL || '0'), 150 | 151 | // The deployment colour for this app (if any). Used for blue green deploys. 152 | deploymentColour: process.env.DEPLOYMENT_COLOUR, 153 | // Load balancer health checks will return 200 only when this file contains 154 | // the deployment colour for this app. 155 | deploymentFile: process.env.DEPLOYMENT_FILE, 156 | 157 | sentry: { 158 | dsn: process.env.SENTRY_DSN, 159 | }, 160 | 161 | errors: { 162 | catchUncaughtErrors: true, 163 | shutdownOnUncaughtError: true, 164 | }, 165 | } 166 | 167 | // console.log settings.redis 168 | module.exports = settings 169 | -------------------------------------------------------------------------------- /config/settings.test.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | errors: { 3 | catchUncaughtErrors: false, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /docker-compose.ci.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated, do not edit it directly. 2 | # Instead run bin/update_build_scripts from 3 | # https://github.com/sharelatex/sharelatex-dev-environment 4 | 5 | version: "2.3" 6 | 7 | services: 8 | test_unit: 9 | image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER 10 | user: node 11 | command: npm run test:unit:_run 12 | environment: 13 | NODE_ENV: test 14 | NODE_OPTIONS: "--unhandled-rejections=strict" 15 | 16 | 17 | test_acceptance: 18 | build: . 19 | image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER 20 | environment: 21 | ELASTIC_SEARCH_DSN: es:9200 22 | REDIS_HOST: redis 23 | QUEUES_REDIS_HOST: redis 24 | MONGO_HOST: mongo 25 | POSTGRES_HOST: postgres 26 | MOCHA_GREP: ${MOCHA_GREP} 27 | NODE_ENV: test 28 | NODE_OPTIONS: "--unhandled-rejections=strict" 29 | depends_on: 30 | redis: 31 | condition: service_healthy 32 | user: node 33 | command: npm run test:acceptance:_run 34 | 35 | 36 | tar: 37 | build: . 38 | image: ci/$PROJECT_NAME:$BRANCH_NAME-$BUILD_NUMBER 39 | volumes: 40 | - ./:/tmp/build/ 41 | command: tar -czf /tmp/build/build.tar.gz --exclude=build.tar.gz --exclude-vcs . 42 | user: root 43 | redis: 44 | image: redis 45 | healthcheck: 46 | test: ping="$$(redis-cli ping)" && [ "$$ping" = 'PONG' ] 47 | interval: 1s 48 | retries: 20 49 | 50 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated, do not edit it directly. 2 | # Instead run bin/update_build_scripts from 3 | # https://github.com/sharelatex/sharelatex-dev-environment 4 | 5 | version: "2.3" 6 | 7 | services: 8 | test_unit: 9 | image: node:12.22.3 10 | volumes: 11 | - .:/app 12 | working_dir: /app 13 | environment: 14 | MOCHA_GREP: ${MOCHA_GREP} 15 | NODE_ENV: test 16 | NODE_OPTIONS: "--unhandled-rejections=strict" 17 | command: npm run --silent test:unit 18 | user: node 19 | 20 | test_acceptance: 21 | image: node:12.22.3 22 | volumes: 23 | - .:/app 24 | working_dir: /app 25 | environment: 26 | ELASTIC_SEARCH_DSN: es:9200 27 | REDIS_HOST: redis 28 | QUEUES_REDIS_HOST: redis 29 | MONGO_HOST: mongo 30 | POSTGRES_HOST: postgres 31 | MOCHA_GREP: ${MOCHA_GREP} 32 | LOG_LEVEL: ERROR 33 | NODE_ENV: test 34 | NODE_OPTIONS: "--unhandled-rejections=strict" 35 | user: node 36 | depends_on: 37 | redis: 38 | condition: service_healthy 39 | command: npm run --silent test:acceptance 40 | 41 | redis: 42 | image: redis 43 | healthcheck: 44 | test: ping=$$(redis-cli ping) && [ "$$ping" = 'PONG' ] 45 | interval: 1s 46 | retries: 20 47 | 48 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": [ 3 | ".git", 4 | "node_modules/" 5 | ], 6 | "verbose": true, 7 | "legacyWatch": true, 8 | "execMap": { 9 | "js": "npm run start" 10 | }, 11 | "watch": [ 12 | "app/js/", 13 | "app.js", 14 | "config/" 15 | ], 16 | "ext": "js" 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "real-time-sharelatex", 3 | "version": "0.1.4", 4 | "description": "The socket.io layer of ShareLaTeX for real-time editor interactions", 5 | "author": "ShareLaTeX ", 6 | "license": "AGPL-3.0-only", 7 | "private": true, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/sharelatex/real-time-sharelatex.git" 11 | }, 12 | "scripts": { 13 | "start": "node $NODE_APP_OPTIONS app.js", 14 | "test:acceptance:_run": "mocha --recursive --reporter spec --timeout 15000 --exit $@ test/acceptance/js", 15 | "test:acceptance": "npm run test:acceptance:_run -- --grep=$MOCHA_GREP", 16 | "test:unit:_run": "mocha --recursive --reporter spec $@ test/unit/js", 17 | "test:unit": "npm run test:unit:_run -- --grep=$MOCHA_GREP", 18 | "nodemon": "nodemon --config nodemon.json", 19 | "lint": "eslint --max-warnings 0 --format unix .", 20 | "format": "prettier --list-different $PWD/'**/*.js'", 21 | "format:fix": "prettier --write $PWD/'**/*.js'", 22 | "lint:fix": "eslint --fix ." 23 | }, 24 | "dependencies": { 25 | "@overleaf/metrics": "^3.5.1", 26 | "@overleaf/o-error": "^3.1.0", 27 | "@overleaf/redis-wrapper": "^2.0.0", 28 | "@overleaf/settings": "^2.1.1", 29 | "async": "^0.9.0", 30 | "base64id": "0.1.0", 31 | "basic-auth-connect": "^1.0.0", 32 | "body-parser": "^1.19.0", 33 | "bunyan": "^1.8.15", 34 | "connect-redis": "^2.1.0", 35 | "cookie-parser": "^1.4.5", 36 | "express": "^4.17.1", 37 | "express-session": "^1.17.1", 38 | "logger-sharelatex": "^2.2.0", 39 | "request": "^2.88.2", 40 | "socket.io": "https://github.com/overleaf/socket.io/archive/0.9.19-overleaf-5.tar.gz", 41 | "socket.io-client": "https://github.com/overleaf/socket.io-client/archive/0.9.17-overleaf-3.tar.gz", 42 | "underscore": "1.13.1" 43 | }, 44 | "devDependencies": { 45 | "chai": "^4.2.0", 46 | "chai-as-promised": "^7.1.1", 47 | "cookie-signature": "^1.1.0", 48 | "eslint": "^7.21.0", 49 | "eslint-config-prettier": "^8.1.0", 50 | "eslint-config-standard": "^16.0.2", 51 | "eslint-plugin-chai-expect": "^2.2.0", 52 | "eslint-plugin-chai-friendly": "^0.6.0", 53 | "eslint-plugin-import": "^2.22.1", 54 | "eslint-plugin-mocha": "^8.0.0", 55 | "eslint-plugin-node": "^11.1.0", 56 | "eslint-plugin-prettier": "^3.1.2", 57 | "eslint-plugin-promise": "^4.2.1", 58 | "mocha": "^8.3.2", 59 | "prettier": "^2.2.1", 60 | "sandboxed-module": "~0.3.0", 61 | "sinon": "^9.2.4", 62 | "timekeeper": "0.0.4", 63 | "uid-safe": "^2.1.5" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/acceptance/js/ClientTrackingTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | handle-callback-err, 4 | no-unused-vars, 5 | */ 6 | // TODO: This file was created by bulk-decaffeinate. 7 | // Fix any style issues and re-enable lint. 8 | /* 9 | * decaffeinate suggestions: 10 | * DS101: Remove unnecessary use of Array.from 11 | * DS102: Remove unnecessary code created because of implicit returns 12 | * DS207: Consider shorter variations of null checks 13 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 14 | */ 15 | const { expect } = require('chai') 16 | 17 | const RealTimeClient = require('./helpers/RealTimeClient') 18 | const MockWebServer = require('./helpers/MockWebServer') 19 | const FixturesManager = require('./helpers/FixturesManager') 20 | 21 | const async = require('async') 22 | 23 | describe('clientTracking', function () { 24 | describe('when a client updates its cursor location', function () { 25 | before(function (done) { 26 | return async.series( 27 | [ 28 | cb => { 29 | return FixturesManager.setUpProject( 30 | { 31 | privilegeLevel: 'owner', 32 | project: { name: 'Test Project' }, 33 | }, 34 | (error, { user_id, project_id }) => { 35 | this.user_id = user_id 36 | this.project_id = project_id 37 | return cb() 38 | } 39 | ) 40 | }, 41 | 42 | cb => { 43 | return FixturesManager.setUpDoc( 44 | this.project_id, 45 | { lines: this.lines, version: this.version, ops: this.ops }, 46 | (e, { doc_id }) => { 47 | this.doc_id = doc_id 48 | return cb(e) 49 | } 50 | ) 51 | }, 52 | 53 | cb => { 54 | this.clientA = RealTimeClient.connect() 55 | return this.clientA.on('connectionAccepted', cb) 56 | }, 57 | 58 | cb => { 59 | this.clientB = RealTimeClient.connect() 60 | return this.clientB.on('connectionAccepted', cb) 61 | }, 62 | 63 | cb => { 64 | return this.clientA.emit( 65 | 'joinProject', 66 | { 67 | project_id: this.project_id, 68 | }, 69 | cb 70 | ) 71 | }, 72 | 73 | cb => { 74 | return this.clientA.emit('joinDoc', this.doc_id, cb) 75 | }, 76 | 77 | cb => { 78 | return this.clientB.emit( 79 | 'joinProject', 80 | { 81 | project_id: this.project_id, 82 | }, 83 | cb 84 | ) 85 | }, 86 | 87 | cb => { 88 | this.updates = [] 89 | this.clientB.on('clientTracking.clientUpdated', data => { 90 | return this.updates.push(data) 91 | }) 92 | 93 | return this.clientA.emit( 94 | 'clientTracking.updatePosition', 95 | { 96 | row: (this.row = 42), 97 | column: (this.column = 36), 98 | doc_id: this.doc_id, 99 | }, 100 | error => { 101 | if (error != null) { 102 | throw error 103 | } 104 | return setTimeout(cb, 300) 105 | } 106 | ) 107 | }, // Give the message a chance to reach client B. 108 | ], 109 | done 110 | ) 111 | }) 112 | 113 | it('should tell other clients about the update', function () { 114 | return this.updates.should.deep.equal([ 115 | { 116 | row: this.row, 117 | column: this.column, 118 | doc_id: this.doc_id, 119 | id: this.clientA.publicId, 120 | user_id: this.user_id, 121 | name: 'Joe Bloggs', 122 | }, 123 | ]) 124 | }) 125 | 126 | return it('should record the update in getConnectedUsers', function (done) { 127 | return this.clientB.emit( 128 | 'clientTracking.getConnectedUsers', 129 | (error, users) => { 130 | for (const user of Array.from(users)) { 131 | if (user.client_id === this.clientA.publicId) { 132 | expect(user.cursorData).to.deep.equal({ 133 | row: this.row, 134 | column: this.column, 135 | doc_id: this.doc_id, 136 | }) 137 | return done() 138 | } 139 | } 140 | throw new Error('user was never found') 141 | } 142 | ) 143 | }) 144 | }) 145 | 146 | return describe('when an anonymous client updates its cursor location', function () { 147 | before(function (done) { 148 | return async.series( 149 | [ 150 | cb => { 151 | return FixturesManager.setUpProject( 152 | { 153 | privilegeLevel: 'owner', 154 | project: { name: 'Test Project' }, 155 | publicAccess: 'readAndWrite', 156 | }, 157 | (error, { user_id, project_id }) => { 158 | this.user_id = user_id 159 | this.project_id = project_id 160 | return cb() 161 | } 162 | ) 163 | }, 164 | 165 | cb => { 166 | return FixturesManager.setUpDoc( 167 | this.project_id, 168 | { lines: this.lines, version: this.version, ops: this.ops }, 169 | (e, { doc_id }) => { 170 | this.doc_id = doc_id 171 | return cb(e) 172 | } 173 | ) 174 | }, 175 | 176 | cb => { 177 | this.clientA = RealTimeClient.connect() 178 | return this.clientA.on('connectionAccepted', cb) 179 | }, 180 | 181 | cb => { 182 | return this.clientA.emit( 183 | 'joinProject', 184 | { 185 | project_id: this.project_id, 186 | }, 187 | cb 188 | ) 189 | }, 190 | 191 | cb => { 192 | return RealTimeClient.setSession({}, cb) 193 | }, 194 | 195 | cb => { 196 | this.anonymous = RealTimeClient.connect() 197 | return this.anonymous.on('connectionAccepted', cb) 198 | }, 199 | 200 | cb => { 201 | return this.anonymous.emit( 202 | 'joinProject', 203 | { 204 | project_id: this.project_id, 205 | }, 206 | cb 207 | ) 208 | }, 209 | 210 | cb => { 211 | return this.anonymous.emit('joinDoc', this.doc_id, cb) 212 | }, 213 | 214 | cb => { 215 | this.updates = [] 216 | this.clientA.on('clientTracking.clientUpdated', data => { 217 | return this.updates.push(data) 218 | }) 219 | 220 | return this.anonymous.emit( 221 | 'clientTracking.updatePosition', 222 | { 223 | row: (this.row = 42), 224 | column: (this.column = 36), 225 | doc_id: this.doc_id, 226 | }, 227 | error => { 228 | if (error != null) { 229 | throw error 230 | } 231 | return setTimeout(cb, 300) 232 | } 233 | ) 234 | }, // Give the message a chance to reach client B. 235 | ], 236 | done 237 | ) 238 | }) 239 | 240 | return it('should tell other clients about the update', function () { 241 | return this.updates.should.deep.equal([ 242 | { 243 | row: this.row, 244 | column: this.column, 245 | doc_id: this.doc_id, 246 | id: this.anonymous.publicId, 247 | user_id: 'anonymous-user', 248 | name: '', 249 | }, 250 | ]) 251 | }) 252 | }) 253 | }) 254 | -------------------------------------------------------------------------------- /test/acceptance/js/DrainManagerTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | // TODO: This file was created by bulk-decaffeinate. 5 | // Fix any style issues and re-enable lint. 6 | /* 7 | * decaffeinate suggestions: 8 | * DS102: Remove unnecessary code created because of implicit returns 9 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 10 | */ 11 | const RealTimeClient = require('./helpers/RealTimeClient') 12 | const FixturesManager = require('./helpers/FixturesManager') 13 | 14 | const { expect } = require('chai') 15 | 16 | const async = require('async') 17 | const request = require('request') 18 | 19 | const Settings = require('@overleaf/settings') 20 | 21 | const drain = function (rate, callback) { 22 | request.post( 23 | { 24 | url: `http://localhost:3026/drain?rate=${rate}`, 25 | auth: { 26 | user: Settings.internal.realTime.user, 27 | pass: Settings.internal.realTime.pass, 28 | }, 29 | }, 30 | (error, response, data) => callback(error, data) 31 | ) 32 | return null 33 | } 34 | 35 | describe('DrainManagerTests', function () { 36 | before(function (done) { 37 | FixturesManager.setUpProject( 38 | { 39 | privilegeLevel: 'owner', 40 | project: { 41 | name: 'Test Project', 42 | }, 43 | }, 44 | (e, { project_id, user_id }) => { 45 | this.project_id = project_id 46 | this.user_id = user_id 47 | return done() 48 | } 49 | ) 50 | return null 51 | }) 52 | 53 | before(function (done) { 54 | // cleanup to speedup reconnecting 55 | this.timeout(10000) 56 | return RealTimeClient.disconnectAllClients(done) 57 | }) 58 | 59 | // trigger and check cleanup 60 | it('should have disconnected all previous clients', function (done) { 61 | return RealTimeClient.getConnectedClients((error, data) => { 62 | if (error) { 63 | return done(error) 64 | } 65 | expect(data.length).to.equal(0) 66 | return done() 67 | }) 68 | }) 69 | 70 | return describe('with two clients in the project', function () { 71 | beforeEach(function (done) { 72 | return async.series( 73 | [ 74 | cb => { 75 | this.clientA = RealTimeClient.connect() 76 | return this.clientA.on('connectionAccepted', cb) 77 | }, 78 | 79 | cb => { 80 | this.clientB = RealTimeClient.connect() 81 | return this.clientB.on('connectionAccepted', cb) 82 | }, 83 | 84 | cb => { 85 | return this.clientA.emit( 86 | 'joinProject', 87 | { project_id: this.project_id }, 88 | cb 89 | ) 90 | }, 91 | 92 | cb => { 93 | return this.clientB.emit( 94 | 'joinProject', 95 | { project_id: this.project_id }, 96 | cb 97 | ) 98 | }, 99 | ], 100 | done 101 | ) 102 | }) 103 | 104 | return describe('starting to drain', function () { 105 | beforeEach(function (done) { 106 | return async.parallel( 107 | [ 108 | cb => { 109 | return this.clientA.on('reconnectGracefully', cb) 110 | }, 111 | cb => { 112 | return this.clientB.on('reconnectGracefully', cb) 113 | }, 114 | 115 | cb => drain(2, cb), 116 | ], 117 | done 118 | ) 119 | }) 120 | 121 | afterEach(function (done) { 122 | return drain(0, done) 123 | }) // reset drain 124 | 125 | it('should not timeout', function () { 126 | return expect(true).to.equal(true) 127 | }) 128 | 129 | return it('should not have disconnected', function () { 130 | expect(this.clientA.socket.connected).to.equal(true) 131 | return expect(this.clientB.socket.connected).to.equal(true) 132 | }) 133 | }) 134 | }) 135 | }) 136 | -------------------------------------------------------------------------------- /test/acceptance/js/EarlyDisconnect.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | no-return-assign, 4 | */ 5 | // TODO: This file was created by bulk-decaffeinate. 6 | // Fix any style issues and re-enable lint. 7 | /* 8 | * decaffeinate suggestions: 9 | * DS101: Remove unnecessary use of Array.from 10 | * DS102: Remove unnecessary code created because of implicit returns 11 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 12 | */ 13 | const async = require('async') 14 | const { expect } = require('chai') 15 | 16 | const RealTimeClient = require('./helpers/RealTimeClient') 17 | const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') 18 | const MockWebServer = require('./helpers/MockWebServer') 19 | const FixturesManager = require('./helpers/FixturesManager') 20 | 21 | const settings = require('@overleaf/settings') 22 | const redis = require('@overleaf/redis-wrapper') 23 | const rclient = redis.createClient(settings.redis.pubsub) 24 | const rclientRT = redis.createClient(settings.redis.realtime) 25 | const KeysRT = settings.redis.realtime.key_schema 26 | 27 | describe('EarlyDisconnect', function () { 28 | before(function (done) { 29 | return MockDocUpdaterServer.run(done) 30 | }) 31 | 32 | describe('when the client disconnects before joinProject completes', function () { 33 | before(function () { 34 | // slow down web-api requests to force the race condition 35 | let joinProject 36 | this.actualWebAPIjoinProject = joinProject = MockWebServer.joinProject 37 | return (MockWebServer.joinProject = (project_id, user_id, cb) => 38 | setTimeout(() => joinProject(project_id, user_id, cb), 300)) 39 | }) 40 | 41 | after(function () { 42 | return (MockWebServer.joinProject = this.actualWebAPIjoinProject) 43 | }) 44 | 45 | beforeEach(function (done) { 46 | return async.series( 47 | [ 48 | cb => { 49 | return FixturesManager.setUpProject( 50 | { 51 | privilegeLevel: 'owner', 52 | project: { 53 | name: 'Test Project', 54 | }, 55 | }, 56 | (e, { project_id, user_id }) => { 57 | this.project_id = project_id 58 | this.user_id = user_id 59 | return cb() 60 | } 61 | ) 62 | }, 63 | 64 | cb => { 65 | this.clientA = RealTimeClient.connect() 66 | return this.clientA.on('connectionAccepted', cb) 67 | }, 68 | 69 | cb => { 70 | this.clientA.emit( 71 | 'joinProject', 72 | { project_id: this.project_id }, 73 | () => {} 74 | ) 75 | // disconnect before joinProject completes 76 | this.clientA.on('disconnect', () => cb()) 77 | return this.clientA.disconnect() 78 | }, 79 | 80 | cb => { 81 | // wait for joinDoc and subscribe 82 | return setTimeout(cb, 500) 83 | }, 84 | ], 85 | done 86 | ) 87 | }) 88 | 89 | // we can force the race condition, there is no need to repeat too often 90 | return Array.from(Array.from({ length: 5 }).map((_, i) => i + 1)).map( 91 | attempt => 92 | it(`should not subscribe to the pub/sub channel anymore (race ${attempt})`, function (done) { 93 | rclient.pubsub('CHANNELS', (err, resp) => { 94 | if (err) { 95 | return done(err) 96 | } 97 | expect(resp).to.not.include(`editor-events:${this.project_id}`) 98 | return done() 99 | }) 100 | return null 101 | }) 102 | ) 103 | }) 104 | 105 | describe('when the client disconnects before joinDoc completes', function () { 106 | beforeEach(function (done) { 107 | return async.series( 108 | [ 109 | cb => { 110 | return FixturesManager.setUpProject( 111 | { 112 | privilegeLevel: 'owner', 113 | project: { 114 | name: 'Test Project', 115 | }, 116 | }, 117 | (e, { project_id, user_id }) => { 118 | this.project_id = project_id 119 | this.user_id = user_id 120 | return cb() 121 | } 122 | ) 123 | }, 124 | 125 | cb => { 126 | this.clientA = RealTimeClient.connect() 127 | return this.clientA.on('connectionAccepted', cb) 128 | }, 129 | 130 | cb => { 131 | return this.clientA.emit( 132 | 'joinProject', 133 | { project_id: this.project_id }, 134 | (error, project, privilegeLevel, protocolVersion) => { 135 | this.project = project 136 | this.privilegeLevel = privilegeLevel 137 | this.protocolVersion = protocolVersion 138 | return cb(error) 139 | } 140 | ) 141 | }, 142 | 143 | cb => { 144 | return FixturesManager.setUpDoc( 145 | this.project_id, 146 | { lines: this.lines, version: this.version, ops: this.ops }, 147 | (e, { doc_id }) => { 148 | this.doc_id = doc_id 149 | return cb(e) 150 | } 151 | ) 152 | }, 153 | 154 | cb => { 155 | this.clientA.emit('joinDoc', this.doc_id, () => {}) 156 | // disconnect before joinDoc completes 157 | this.clientA.on('disconnect', () => cb()) 158 | return this.clientA.disconnect() 159 | }, 160 | 161 | cb => { 162 | // wait for subscribe and unsubscribe 163 | return setTimeout(cb, 100) 164 | }, 165 | ], 166 | done 167 | ) 168 | }) 169 | 170 | // we can not force the race condition, so we have to try many times 171 | return Array.from(Array.from({ length: 20 }).map((_, i) => i + 1)).map( 172 | attempt => 173 | it(`should not subscribe to the pub/sub channels anymore (race ${attempt})`, function (done) { 174 | rclient.pubsub('CHANNELS', (err, resp) => { 175 | if (err) { 176 | return done(err) 177 | } 178 | expect(resp).to.not.include(`editor-events:${this.project_id}`) 179 | 180 | return rclient.pubsub('CHANNELS', (err, resp) => { 181 | if (err) { 182 | return done(err) 183 | } 184 | expect(resp).to.not.include(`applied-ops:${this.doc_id}`) 185 | return done() 186 | }) 187 | }) 188 | return null 189 | }) 190 | ) 191 | }) 192 | 193 | return describe('when the client disconnects before clientTracking.updatePosition starts', function () { 194 | beforeEach(function (done) { 195 | return async.series( 196 | [ 197 | cb => { 198 | return FixturesManager.setUpProject( 199 | { 200 | privilegeLevel: 'owner', 201 | project: { 202 | name: 'Test Project', 203 | }, 204 | }, 205 | (e, { project_id, user_id }) => { 206 | this.project_id = project_id 207 | this.user_id = user_id 208 | return cb() 209 | } 210 | ) 211 | }, 212 | 213 | cb => { 214 | this.clientA = RealTimeClient.connect() 215 | return this.clientA.on('connectionAccepted', cb) 216 | }, 217 | 218 | cb => { 219 | return this.clientA.emit( 220 | 'joinProject', 221 | { project_id: this.project_id }, 222 | (error, project, privilegeLevel, protocolVersion) => { 223 | this.project = project 224 | this.privilegeLevel = privilegeLevel 225 | this.protocolVersion = protocolVersion 226 | return cb(error) 227 | } 228 | ) 229 | }, 230 | 231 | cb => { 232 | return FixturesManager.setUpDoc( 233 | this.project_id, 234 | { lines: this.lines, version: this.version, ops: this.ops }, 235 | (e, { doc_id }) => { 236 | this.doc_id = doc_id 237 | return cb(e) 238 | } 239 | ) 240 | }, 241 | 242 | cb => { 243 | return this.clientA.emit('joinDoc', this.doc_id, cb) 244 | }, 245 | 246 | cb => { 247 | this.clientA.emit( 248 | 'clientTracking.updatePosition', 249 | { 250 | row: 42, 251 | column: 36, 252 | doc_id: this.doc_id, 253 | }, 254 | () => {} 255 | ) 256 | // disconnect before updateClientPosition completes 257 | this.clientA.on('disconnect', () => cb()) 258 | return this.clientA.disconnect() 259 | }, 260 | 261 | cb => { 262 | // wait for updateClientPosition 263 | return setTimeout(cb, 100) 264 | }, 265 | ], 266 | done 267 | ) 268 | }) 269 | 270 | // we can not force the race condition, so we have to try many times 271 | return Array.from(Array.from({ length: 20 }).map((_, i) => i + 1)).map( 272 | attempt => 273 | it(`should not show the client as connected (race ${attempt})`, function (done) { 274 | rclientRT.smembers( 275 | KeysRT.clientsInProject({ project_id: this.project_id }), 276 | (err, results) => { 277 | if (err) { 278 | return done(err) 279 | } 280 | expect(results).to.deep.equal([]) 281 | return done() 282 | } 283 | ) 284 | return null 285 | }) 286 | ) 287 | }) 288 | }) 289 | -------------------------------------------------------------------------------- /test/acceptance/js/HttpControllerTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | // TODO: This file was created by bulk-decaffeinate. 5 | // Fix any style issues and re-enable lint. 6 | /* 7 | * decaffeinate suggestions: 8 | * DS102: Remove unnecessary code created because of implicit returns 9 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 10 | */ 11 | const async = require('async') 12 | const { expect } = require('chai') 13 | const request = require('request').defaults({ 14 | baseUrl: 'http://localhost:3026', 15 | }) 16 | 17 | const RealTimeClient = require('./helpers/RealTimeClient') 18 | const FixturesManager = require('./helpers/FixturesManager') 19 | 20 | describe('HttpControllerTests', function () { 21 | describe('without a user', function () { 22 | return it('should return 404 for the client view', function (done) { 23 | const client_id = 'not-existing' 24 | return request.get( 25 | { 26 | url: `/clients/${client_id}`, 27 | json: true, 28 | }, 29 | (error, response, data) => { 30 | if (error) { 31 | return done(error) 32 | } 33 | expect(response.statusCode).to.equal(404) 34 | return done() 35 | } 36 | ) 37 | }) 38 | }) 39 | 40 | return describe('with a user and after joining a project', function () { 41 | before(function (done) { 42 | return async.series( 43 | [ 44 | cb => { 45 | return FixturesManager.setUpProject( 46 | { 47 | privilegeLevel: 'owner', 48 | }, 49 | (error, { project_id, user_id }) => { 50 | this.project_id = project_id 51 | this.user_id = user_id 52 | return cb(error) 53 | } 54 | ) 55 | }, 56 | 57 | cb => { 58 | return FixturesManager.setUpDoc( 59 | this.project_id, 60 | {}, 61 | (error, { doc_id }) => { 62 | this.doc_id = doc_id 63 | return cb(error) 64 | } 65 | ) 66 | }, 67 | 68 | cb => { 69 | this.client = RealTimeClient.connect() 70 | return this.client.on('connectionAccepted', cb) 71 | }, 72 | 73 | cb => { 74 | return this.client.emit( 75 | 'joinProject', 76 | { project_id: this.project_id }, 77 | cb 78 | ) 79 | }, 80 | 81 | cb => { 82 | return this.client.emit('joinDoc', this.doc_id, cb) 83 | }, 84 | ], 85 | done 86 | ) 87 | }) 88 | 89 | return it('should send a client view', function (done) { 90 | return request.get( 91 | { 92 | url: `/clients/${this.client.socket.sessionid}`, 93 | json: true, 94 | }, 95 | (error, response, data) => { 96 | if (error) { 97 | return done(error) 98 | } 99 | expect(response.statusCode).to.equal(200) 100 | expect(data.connected_time).to.exist 101 | delete data.connected_time 102 | // .email is not set in the session 103 | delete data.email 104 | expect(data).to.deep.equal({ 105 | client_id: this.client.socket.sessionid, 106 | first_name: 'Joe', 107 | last_name: 'Bloggs', 108 | project_id: this.project_id, 109 | user_id: this.user_id, 110 | rooms: [this.project_id, this.doc_id], 111 | }) 112 | return done() 113 | } 114 | ) 115 | }) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /test/acceptance/js/JoinProjectTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | handle-callback-err, 4 | */ 5 | // TODO: This file was created by bulk-decaffeinate. 6 | // Fix any style issues and re-enable lint. 7 | /* 8 | * decaffeinate suggestions: 9 | * DS101: Remove unnecessary use of Array.from 10 | * DS102: Remove unnecessary code created because of implicit returns 11 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 12 | */ 13 | const { expect } = require('chai') 14 | 15 | const RealTimeClient = require('./helpers/RealTimeClient') 16 | const MockWebServer = require('./helpers/MockWebServer') 17 | const FixturesManager = require('./helpers/FixturesManager') 18 | 19 | const async = require('async') 20 | 21 | describe('joinProject', function () { 22 | describe('when authorized', function () { 23 | before(function (done) { 24 | return async.series( 25 | [ 26 | cb => { 27 | return FixturesManager.setUpProject( 28 | { 29 | privilegeLevel: 'owner', 30 | project: { 31 | name: 'Test Project', 32 | }, 33 | }, 34 | (e, { project_id, user_id }) => { 35 | this.project_id = project_id 36 | this.user_id = user_id 37 | return cb(e) 38 | } 39 | ) 40 | }, 41 | 42 | cb => { 43 | this.client = RealTimeClient.connect() 44 | return this.client.on('connectionAccepted', cb) 45 | }, 46 | 47 | cb => { 48 | return this.client.emit( 49 | 'joinProject', 50 | { project_id: this.project_id }, 51 | (error, project, privilegeLevel, protocolVersion) => { 52 | this.project = project 53 | this.privilegeLevel = privilegeLevel 54 | this.protocolVersion = protocolVersion 55 | return cb(error) 56 | } 57 | ) 58 | }, 59 | ], 60 | done 61 | ) 62 | }) 63 | 64 | it('should get the project from web', function () { 65 | return MockWebServer.joinProject 66 | .calledWith(this.project_id, this.user_id) 67 | .should.equal(true) 68 | }) 69 | 70 | it('should return the project', function () { 71 | return this.project.should.deep.equal({ 72 | name: 'Test Project', 73 | }) 74 | }) 75 | 76 | it('should return the privilege level', function () { 77 | return this.privilegeLevel.should.equal('owner') 78 | }) 79 | 80 | it('should return the protocolVersion', function () { 81 | return this.protocolVersion.should.equal(2) 82 | }) 83 | 84 | it('should have joined the project room', function (done) { 85 | return RealTimeClient.getConnectedClient( 86 | this.client.socket.sessionid, 87 | (error, client) => { 88 | expect(Array.from(client.rooms).includes(this.project_id)).to.equal( 89 | true 90 | ) 91 | return done() 92 | } 93 | ) 94 | }) 95 | 96 | return it('should have marked the user as connected', function (done) { 97 | return this.client.emit( 98 | 'clientTracking.getConnectedUsers', 99 | (error, users) => { 100 | let connected = false 101 | for (const user of Array.from(users)) { 102 | if ( 103 | user.client_id === this.client.publicId && 104 | user.user_id === this.user_id 105 | ) { 106 | connected = true 107 | break 108 | } 109 | } 110 | expect(connected).to.equal(true) 111 | return done() 112 | } 113 | ) 114 | }) 115 | }) 116 | 117 | describe('when not authorized', function () { 118 | before(function (done) { 119 | return async.series( 120 | [ 121 | cb => { 122 | return FixturesManager.setUpProject( 123 | { 124 | privilegeLevel: null, 125 | project: { 126 | name: 'Test Project', 127 | }, 128 | }, 129 | (e, { project_id, user_id }) => { 130 | this.project_id = project_id 131 | this.user_id = user_id 132 | return cb(e) 133 | } 134 | ) 135 | }, 136 | 137 | cb => { 138 | this.client = RealTimeClient.connect() 139 | return this.client.on('connectionAccepted', cb) 140 | }, 141 | 142 | cb => { 143 | return this.client.emit( 144 | 'joinProject', 145 | { project_id: this.project_id }, 146 | (error, project, privilegeLevel, protocolVersion) => { 147 | this.error = error 148 | this.project = project 149 | this.privilegeLevel = privilegeLevel 150 | this.protocolVersion = protocolVersion 151 | return cb() 152 | } 153 | ) 154 | }, 155 | ], 156 | done 157 | ) 158 | }) 159 | 160 | it('should return an error', function () { 161 | return this.error.message.should.equal('not authorized') 162 | }) 163 | 164 | return it('should not have joined the project room', function (done) { 165 | return RealTimeClient.getConnectedClient( 166 | this.client.socket.sessionid, 167 | (error, client) => { 168 | expect(Array.from(client.rooms).includes(this.project_id)).to.equal( 169 | false 170 | ) 171 | return done() 172 | } 173 | ) 174 | }) 175 | }) 176 | 177 | describe('when not authorized and web replies with a 403', function () { 178 | before(function (done) { 179 | return async.series( 180 | [ 181 | cb => { 182 | return FixturesManager.setUpProject( 183 | { 184 | project_id: 'forbidden', 185 | privilegeLevel: 'owner', 186 | project: { 187 | name: 'Test Project', 188 | }, 189 | }, 190 | (e, { project_id, user_id }) => { 191 | this.project_id = project_id 192 | this.user_id = user_id 193 | cb(e) 194 | } 195 | ) 196 | }, 197 | 198 | cb => { 199 | this.client = RealTimeClient.connect() 200 | this.client.on('connectionAccepted', cb) 201 | }, 202 | 203 | cb => { 204 | this.client.emit( 205 | 'joinProject', 206 | { project_id: this.project_id }, 207 | (error, project, privilegeLevel, protocolVersion) => { 208 | this.error = error 209 | this.project = project 210 | this.privilegeLevel = privilegeLevel 211 | this.protocolVersion = protocolVersion 212 | cb() 213 | } 214 | ) 215 | }, 216 | ], 217 | done 218 | ) 219 | }) 220 | 221 | it('should return an error', function () { 222 | this.error.message.should.equal('not authorized') 223 | }) 224 | 225 | it('should not have joined the project room', function (done) { 226 | RealTimeClient.getConnectedClient( 227 | this.client.socket.sessionid, 228 | (error, client) => { 229 | expect(Array.from(client.rooms).includes(this.project_id)).to.equal( 230 | false 231 | ) 232 | done() 233 | } 234 | ) 235 | }) 236 | }) 237 | 238 | describe('when deleted and web replies with a 404', function () { 239 | before(function (done) { 240 | return async.series( 241 | [ 242 | cb => { 243 | return FixturesManager.setUpProject( 244 | { 245 | project_id: 'not-found', 246 | privilegeLevel: 'owner', 247 | project: { 248 | name: 'Test Project', 249 | }, 250 | }, 251 | (e, { project_id, user_id }) => { 252 | this.project_id = project_id 253 | this.user_id = user_id 254 | cb(e) 255 | } 256 | ) 257 | }, 258 | 259 | cb => { 260 | this.client = RealTimeClient.connect() 261 | this.client.on('connectionAccepted', cb) 262 | }, 263 | 264 | cb => { 265 | this.client.emit( 266 | 'joinProject', 267 | { project_id: this.project_id }, 268 | (error, project, privilegeLevel, protocolVersion) => { 269 | this.error = error 270 | this.project = project 271 | this.privilegeLevel = privilegeLevel 272 | this.protocolVersion = protocolVersion 273 | cb() 274 | } 275 | ) 276 | }, 277 | ], 278 | done 279 | ) 280 | }) 281 | 282 | it('should return an error', function () { 283 | this.error.code.should.equal('ProjectNotFound') 284 | }) 285 | 286 | it('should not have joined the project room', function (done) { 287 | RealTimeClient.getConnectedClient( 288 | this.client.socket.sessionid, 289 | (error, client) => { 290 | expect(Array.from(client.rooms).includes(this.project_id)).to.equal( 291 | false 292 | ) 293 | done() 294 | } 295 | ) 296 | }) 297 | }) 298 | 299 | return describe('when over rate limit', function () { 300 | before(function (done) { 301 | return async.series( 302 | [ 303 | cb => { 304 | this.client = RealTimeClient.connect() 305 | return this.client.on('connectionAccepted', cb) 306 | }, 307 | 308 | cb => { 309 | return this.client.emit( 310 | 'joinProject', 311 | { project_id: 'rate-limited' }, 312 | error => { 313 | this.error = error 314 | return cb() 315 | } 316 | ) 317 | }, 318 | ], 319 | done 320 | ) 321 | }) 322 | 323 | return it('should return a TooManyRequests error code', function () { 324 | this.error.message.should.equal('rate-limit hit when joining project') 325 | return this.error.code.should.equal('TooManyRequests') 326 | }) 327 | }) 328 | }) 329 | -------------------------------------------------------------------------------- /test/acceptance/js/LeaveDocTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | handle-callback-err, 4 | no-return-assign, 5 | no-unused-vars, 6 | */ 7 | // TODO: This file was created by bulk-decaffeinate. 8 | // Fix any style issues and re-enable lint. 9 | /* 10 | * decaffeinate suggestions: 11 | * DS101: Remove unnecessary use of Array.from 12 | * DS102: Remove unnecessary code created because of implicit returns 13 | * DS207: Consider shorter variations of null checks 14 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 15 | */ 16 | const { expect } = require('chai') 17 | const sinon = require('sinon') 18 | 19 | const RealTimeClient = require('./helpers/RealTimeClient') 20 | const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') 21 | const FixturesManager = require('./helpers/FixturesManager') 22 | const logger = require('logger-sharelatex') 23 | 24 | const async = require('async') 25 | 26 | describe('leaveDoc', function () { 27 | before(function () { 28 | this.lines = ['test', 'doc', 'lines'] 29 | this.version = 42 30 | this.ops = ['mock', 'doc', 'ops'] 31 | sinon.spy(logger, 'error') 32 | sinon.spy(logger, 'warn') 33 | sinon.spy(logger, 'log') 34 | return (this.other_doc_id = FixturesManager.getRandomId()) 35 | }) 36 | 37 | after(function () { 38 | logger.error.restore() // remove the spy 39 | logger.warn.restore() 40 | return logger.log.restore() 41 | }) 42 | 43 | return describe('when joined to a doc', function () { 44 | beforeEach(function (done) { 45 | return async.series( 46 | [ 47 | cb => { 48 | return FixturesManager.setUpProject( 49 | { 50 | privilegeLevel: 'readAndWrite', 51 | }, 52 | (e, { project_id, user_id }) => { 53 | this.project_id = project_id 54 | this.user_id = user_id 55 | return cb(e) 56 | } 57 | ) 58 | }, 59 | 60 | cb => { 61 | return FixturesManager.setUpDoc( 62 | this.project_id, 63 | { lines: this.lines, version: this.version, ops: this.ops }, 64 | (e, { doc_id }) => { 65 | this.doc_id = doc_id 66 | return cb(e) 67 | } 68 | ) 69 | }, 70 | 71 | cb => { 72 | this.client = RealTimeClient.connect() 73 | return this.client.on('connectionAccepted', cb) 74 | }, 75 | 76 | cb => { 77 | return this.client.emit( 78 | 'joinProject', 79 | { project_id: this.project_id }, 80 | cb 81 | ) 82 | }, 83 | 84 | cb => { 85 | return this.client.emit( 86 | 'joinDoc', 87 | this.doc_id, 88 | (error, ...rest) => { 89 | ;[...this.returnedArgs] = Array.from(rest) 90 | return cb(error) 91 | } 92 | ) 93 | }, 94 | ], 95 | done 96 | ) 97 | }) 98 | 99 | describe('then leaving the doc', function () { 100 | beforeEach(function (done) { 101 | return this.client.emit('leaveDoc', this.doc_id, error => { 102 | if (error != null) { 103 | throw error 104 | } 105 | return done() 106 | }) 107 | }) 108 | 109 | return it('should have left the doc room', function (done) { 110 | return RealTimeClient.getConnectedClient( 111 | this.client.socket.sessionid, 112 | (error, client) => { 113 | expect(Array.from(client.rooms).includes(this.doc_id)).to.equal( 114 | false 115 | ) 116 | return done() 117 | } 118 | ) 119 | }) 120 | }) 121 | 122 | describe('when sending a leaveDoc request before the previous joinDoc request has completed', function () { 123 | beforeEach(function (done) { 124 | this.client.emit('leaveDoc', this.doc_id, () => {}) 125 | this.client.emit('joinDoc', this.doc_id, () => {}) 126 | return this.client.emit('leaveDoc', this.doc_id, error => { 127 | if (error != null) { 128 | throw error 129 | } 130 | return done() 131 | }) 132 | }) 133 | 134 | it('should not trigger an error', function () { 135 | return sinon.assert.neverCalledWith( 136 | logger.error, 137 | sinon.match.any, 138 | "not subscribed - shouldn't happen" 139 | ) 140 | }) 141 | 142 | return it('should have left the doc room', function (done) { 143 | return RealTimeClient.getConnectedClient( 144 | this.client.socket.sessionid, 145 | (error, client) => { 146 | expect(Array.from(client.rooms).includes(this.doc_id)).to.equal( 147 | false 148 | ) 149 | return done() 150 | } 151 | ) 152 | }) 153 | }) 154 | 155 | return describe('when sending a leaveDoc for a room the client has not joined ', function () { 156 | beforeEach(function (done) { 157 | return this.client.emit('leaveDoc', this.other_doc_id, error => { 158 | if (error != null) { 159 | throw error 160 | } 161 | return done() 162 | }) 163 | }) 164 | 165 | return it('should trigger a low level message only', function () { 166 | return sinon.assert.calledWith( 167 | logger.log, 168 | sinon.match.any, 169 | 'ignoring request from client to leave room it is not in' 170 | ) 171 | }) 172 | }) 173 | }) 174 | }) 175 | -------------------------------------------------------------------------------- /test/acceptance/js/LeaveProjectTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | handle-callback-err, 4 | no-throw-literal, 5 | */ 6 | // TODO: This file was created by bulk-decaffeinate. 7 | // Fix any style issues and re-enable lint. 8 | /* 9 | * decaffeinate suggestions: 10 | * DS101: Remove unnecessary use of Array.from 11 | * DS102: Remove unnecessary code created because of implicit returns 12 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 13 | */ 14 | const RealTimeClient = require('./helpers/RealTimeClient') 15 | const MockDocUpdaterServer = require('./helpers/MockDocUpdaterServer') 16 | const FixturesManager = require('./helpers/FixturesManager') 17 | 18 | const async = require('async') 19 | 20 | const settings = require('@overleaf/settings') 21 | const redis = require('@overleaf/redis-wrapper') 22 | const rclient = redis.createClient(settings.redis.pubsub) 23 | 24 | describe('leaveProject', function () { 25 | before(function (done) { 26 | return MockDocUpdaterServer.run(done) 27 | }) 28 | 29 | describe('with other clients in the project', function () { 30 | before(function (done) { 31 | return async.series( 32 | [ 33 | cb => { 34 | return FixturesManager.setUpProject( 35 | { 36 | privilegeLevel: 'owner', 37 | project: { 38 | name: 'Test Project', 39 | }, 40 | }, 41 | (e, { project_id, user_id }) => { 42 | this.project_id = project_id 43 | this.user_id = user_id 44 | return cb() 45 | } 46 | ) 47 | }, 48 | 49 | cb => { 50 | this.clientA = RealTimeClient.connect() 51 | return this.clientA.on('connectionAccepted', cb) 52 | }, 53 | 54 | cb => { 55 | this.clientB = RealTimeClient.connect() 56 | this.clientB.on('connectionAccepted', cb) 57 | 58 | this.clientBDisconnectMessages = [] 59 | return this.clientB.on( 60 | 'clientTracking.clientDisconnected', 61 | data => { 62 | return this.clientBDisconnectMessages.push(data) 63 | } 64 | ) 65 | }, 66 | 67 | cb => { 68 | return this.clientA.emit( 69 | 'joinProject', 70 | { project_id: this.project_id }, 71 | (error, project, privilegeLevel, protocolVersion) => { 72 | this.project = project 73 | this.privilegeLevel = privilegeLevel 74 | this.protocolVersion = protocolVersion 75 | return cb(error) 76 | } 77 | ) 78 | }, 79 | 80 | cb => { 81 | return this.clientB.emit( 82 | 'joinProject', 83 | { project_id: this.project_id }, 84 | (error, project, privilegeLevel, protocolVersion) => { 85 | this.project = project 86 | this.privilegeLevel = privilegeLevel 87 | this.protocolVersion = protocolVersion 88 | return cb(error) 89 | } 90 | ) 91 | }, 92 | 93 | cb => { 94 | return FixturesManager.setUpDoc( 95 | this.project_id, 96 | { lines: this.lines, version: this.version, ops: this.ops }, 97 | (e, { doc_id }) => { 98 | this.doc_id = doc_id 99 | return cb(e) 100 | } 101 | ) 102 | }, 103 | 104 | cb => { 105 | return this.clientA.emit('joinDoc', this.doc_id, cb) 106 | }, 107 | cb => { 108 | return this.clientB.emit('joinDoc', this.doc_id, cb) 109 | }, 110 | 111 | cb => { 112 | // leaveProject is called when the client disconnects 113 | this.clientA.on('disconnect', () => cb()) 114 | return this.clientA.disconnect() 115 | }, 116 | 117 | cb => { 118 | // The API waits a little while before flushing changes 119 | return setTimeout(done, 1000) 120 | }, 121 | ], 122 | done 123 | ) 124 | }) 125 | 126 | it('should emit a disconnect message to the room', function () { 127 | return this.clientBDisconnectMessages.should.deep.equal([ 128 | this.clientA.publicId, 129 | ]) 130 | }) 131 | 132 | it('should no longer list the client in connected users', function (done) { 133 | return this.clientB.emit( 134 | 'clientTracking.getConnectedUsers', 135 | (error, users) => { 136 | for (const user of Array.from(users)) { 137 | if (user.client_id === this.clientA.publicId) { 138 | throw 'Expected clientA to not be listed in connected users' 139 | } 140 | } 141 | return done() 142 | } 143 | ) 144 | }) 145 | 146 | it('should not flush the project to the document updater', function () { 147 | return MockDocUpdaterServer.deleteProject 148 | .calledWith(this.project_id) 149 | .should.equal(false) 150 | }) 151 | 152 | it('should remain subscribed to the editor-events channels', function (done) { 153 | rclient.pubsub('CHANNELS', (err, resp) => { 154 | if (err) { 155 | return done(err) 156 | } 157 | resp.should.include(`editor-events:${this.project_id}`) 158 | return done() 159 | }) 160 | return null 161 | }) 162 | 163 | return it('should remain subscribed to the applied-ops channels', function (done) { 164 | rclient.pubsub('CHANNELS', (err, resp) => { 165 | if (err) { 166 | return done(err) 167 | } 168 | resp.should.include(`applied-ops:${this.doc_id}`) 169 | return done() 170 | }) 171 | return null 172 | }) 173 | }) 174 | 175 | return describe('with no other clients in the project', function () { 176 | before(function (done) { 177 | return async.series( 178 | [ 179 | cb => { 180 | return FixturesManager.setUpProject( 181 | { 182 | privilegeLevel: 'owner', 183 | project: { 184 | name: 'Test Project', 185 | }, 186 | }, 187 | (e, { project_id, user_id }) => { 188 | this.project_id = project_id 189 | this.user_id = user_id 190 | return cb() 191 | } 192 | ) 193 | }, 194 | 195 | cb => { 196 | this.clientA = RealTimeClient.connect() 197 | return this.clientA.on('connect', cb) 198 | }, 199 | 200 | cb => { 201 | return this.clientA.emit( 202 | 'joinProject', 203 | { project_id: this.project_id }, 204 | (error, project, privilegeLevel, protocolVersion) => { 205 | this.project = project 206 | this.privilegeLevel = privilegeLevel 207 | this.protocolVersion = protocolVersion 208 | return cb(error) 209 | } 210 | ) 211 | }, 212 | 213 | cb => { 214 | return FixturesManager.setUpDoc( 215 | this.project_id, 216 | { lines: this.lines, version: this.version, ops: this.ops }, 217 | (e, { doc_id }) => { 218 | this.doc_id = doc_id 219 | return cb(e) 220 | } 221 | ) 222 | }, 223 | cb => { 224 | return this.clientA.emit('joinDoc', this.doc_id, cb) 225 | }, 226 | 227 | cb => { 228 | // leaveProject is called when the client disconnects 229 | this.clientA.on('disconnect', () => cb()) 230 | return this.clientA.disconnect() 231 | }, 232 | 233 | cb => { 234 | // The API waits a little while before flushing changes 235 | return setTimeout(done, 1000) 236 | }, 237 | ], 238 | done 239 | ) 240 | }) 241 | 242 | it('should flush the project to the document updater', function () { 243 | return MockDocUpdaterServer.deleteProject 244 | .calledWith(this.project_id) 245 | .should.equal(true) 246 | }) 247 | 248 | it('should not subscribe to the editor-events channels anymore', function (done) { 249 | rclient.pubsub('CHANNELS', (err, resp) => { 250 | if (err) { 251 | return done(err) 252 | } 253 | resp.should.not.include(`editor-events:${this.project_id}`) 254 | return done() 255 | }) 256 | return null 257 | }) 258 | 259 | return it('should not subscribe to the applied-ops channels anymore', function (done) { 260 | rclient.pubsub('CHANNELS', (err, resp) => { 261 | if (err) { 262 | return done(err) 263 | } 264 | resp.should.not.include(`applied-ops:${this.doc_id}`) 265 | return done() 266 | }) 267 | return null 268 | }) 269 | }) 270 | }) 271 | -------------------------------------------------------------------------------- /test/acceptance/js/RouterTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | */ 4 | // TODO: This file was created by bulk-decaffeinate. 5 | // Fix any style issues and re-enable lint. 6 | /* 7 | * decaffeinate suggestions: 8 | * DS102: Remove unnecessary code created because of implicit returns 9 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 10 | */ 11 | const async = require('async') 12 | const { expect } = require('chai') 13 | 14 | const RealTimeClient = require('./helpers/RealTimeClient') 15 | const FixturesManager = require('./helpers/FixturesManager') 16 | 17 | describe('Router', function () { 18 | return describe('joinProject', function () { 19 | describe('when there is no callback provided', function () { 20 | after(function () { 21 | return process.removeListener('unhandledRejection', this.onUnhandled) 22 | }) 23 | 24 | before(function (done) { 25 | this.onUnhandled = error => done(error) 26 | process.on('unhandledRejection', this.onUnhandled) 27 | return async.series( 28 | [ 29 | cb => { 30 | return FixturesManager.setUpProject( 31 | { 32 | privilegeLevel: 'owner', 33 | project: { 34 | name: 'Test Project', 35 | }, 36 | }, 37 | (e, { project_id, user_id }) => { 38 | this.project_id = project_id 39 | this.user_id = user_id 40 | return cb(e) 41 | } 42 | ) 43 | }, 44 | 45 | cb => { 46 | this.client = RealTimeClient.connect() 47 | return this.client.on('connectionAccepted', cb) 48 | }, 49 | 50 | cb => { 51 | this.client = RealTimeClient.connect() 52 | return this.client.on('connectionAccepted', cb) 53 | }, 54 | 55 | cb => { 56 | this.client.emit('joinProject', { project_id: this.project_id }) 57 | return setTimeout(cb, 100) 58 | }, 59 | ], 60 | done 61 | ) 62 | }) 63 | 64 | return it('should keep on going', function () { 65 | return expect('still running').to.exist 66 | }) 67 | }) 68 | 69 | return describe('when there are too many arguments', function () { 70 | after(function () { 71 | return process.removeListener('unhandledRejection', this.onUnhandled) 72 | }) 73 | 74 | before(function (done) { 75 | this.onUnhandled = error => done(error) 76 | process.on('unhandledRejection', this.onUnhandled) 77 | return async.series( 78 | [ 79 | cb => { 80 | return FixturesManager.setUpProject( 81 | { 82 | privilegeLevel: 'owner', 83 | project: { 84 | name: 'Test Project', 85 | }, 86 | }, 87 | (e, { project_id, user_id }) => { 88 | this.project_id = project_id 89 | this.user_id = user_id 90 | return cb(e) 91 | } 92 | ) 93 | }, 94 | 95 | cb => { 96 | this.client = RealTimeClient.connect() 97 | return this.client.on('connectionAccepted', cb) 98 | }, 99 | 100 | cb => { 101 | this.client = RealTimeClient.connect() 102 | return this.client.on('connectionAccepted', cb) 103 | }, 104 | 105 | cb => { 106 | return this.client.emit('joinProject', 1, 2, 3, 4, 5, error => { 107 | this.error = error 108 | return cb() 109 | }) 110 | }, 111 | ], 112 | done 113 | ) 114 | }) 115 | 116 | return it('should return an error message', function () { 117 | return expect(this.error.message).to.equal('unexpected arguments') 118 | }) 119 | }) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /test/acceptance/js/SessionSocketsTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | no-return-assign, 3 | */ 4 | // TODO: This file was created by bulk-decaffeinate. 5 | // Fix any style issues and re-enable lint. 6 | /* 7 | * decaffeinate suggestions: 8 | * DS102: Remove unnecessary code created because of implicit returns 9 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 10 | */ 11 | const RealTimeClient = require('./helpers/RealTimeClient') 12 | const Settings = require('@overleaf/settings') 13 | const { expect } = require('chai') 14 | 15 | describe('SessionSockets', function () { 16 | before(function () { 17 | return (this.checkSocket = function (fn) { 18 | const client = RealTimeClient.connect() 19 | client.on('connectionAccepted', fn) 20 | client.on('connectionRejected', fn) 21 | return null 22 | }) 23 | }) 24 | 25 | describe('without cookies', function () { 26 | before(function () { 27 | return (RealTimeClient.cookie = null) 28 | }) 29 | 30 | return it('should return a lookup error', function (done) { 31 | return this.checkSocket(error => { 32 | expect(error).to.exist 33 | expect(error.message).to.equal('invalid session') 34 | return done() 35 | }) 36 | }) 37 | }) 38 | 39 | describe('with a different cookie', function () { 40 | before(function () { 41 | return (RealTimeClient.cookie = 'some.key=someValue') 42 | }) 43 | 44 | return it('should return a lookup error', function (done) { 45 | return this.checkSocket(error => { 46 | expect(error).to.exist 47 | expect(error.message).to.equal('invalid session') 48 | return done() 49 | }) 50 | }) 51 | }) 52 | 53 | describe('with an invalid cookie', function () { 54 | before(function (done) { 55 | RealTimeClient.setSession({}, error => { 56 | if (error) { 57 | return done(error) 58 | } 59 | RealTimeClient.cookie = `${ 60 | Settings.cookieName 61 | }=${RealTimeClient.cookie.slice(17, 49)}` 62 | return done() 63 | }) 64 | return null 65 | }) 66 | 67 | return it('should return a lookup error', function (done) { 68 | return this.checkSocket(error => { 69 | expect(error).to.exist 70 | expect(error.message).to.equal('invalid session') 71 | return done() 72 | }) 73 | }) 74 | }) 75 | 76 | describe('with a valid cookie and no matching session', function () { 77 | before(function () { 78 | return (RealTimeClient.cookie = `${Settings.cookieName}=unknownId`) 79 | }) 80 | 81 | return it('should return a lookup error', function (done) { 82 | return this.checkSocket(error => { 83 | expect(error).to.exist 84 | expect(error.message).to.equal('invalid session') 85 | return done() 86 | }) 87 | }) 88 | }) 89 | 90 | return describe('with a valid cookie and a matching session', function () { 91 | before(function (done) { 92 | RealTimeClient.setSession({}, done) 93 | return null 94 | }) 95 | 96 | return it('should not return an error', function (done) { 97 | return this.checkSocket(error => { 98 | expect(error).to.not.exist 99 | return done() 100 | }) 101 | }) 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /test/acceptance/js/SessionTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | handle-callback-err, 3 | no-return-assign, 4 | */ 5 | // TODO: This file was created by bulk-decaffeinate. 6 | // Fix any style issues and re-enable lint. 7 | /* 8 | * decaffeinate suggestions: 9 | * DS101: Remove unnecessary use of Array.from 10 | * DS102: Remove unnecessary code created because of implicit returns 11 | * DS207: Consider shorter variations of null checks 12 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 13 | */ 14 | const { expect } = require('chai') 15 | 16 | const RealTimeClient = require('./helpers/RealTimeClient') 17 | 18 | describe('Session', function () { 19 | return describe('with an established session', function () { 20 | before(function (done) { 21 | this.user_id = 'mock-user-id' 22 | RealTimeClient.setSession( 23 | { 24 | user: { _id: this.user_id }, 25 | }, 26 | error => { 27 | if (error != null) { 28 | throw error 29 | } 30 | this.client = RealTimeClient.connect() 31 | return done() 32 | } 33 | ) 34 | return null 35 | }) 36 | 37 | it('should not get disconnected', function (done) { 38 | let disconnected = false 39 | this.client.on('disconnect', () => (disconnected = true)) 40 | return setTimeout(() => { 41 | expect(disconnected).to.equal(false) 42 | return done() 43 | }, 500) 44 | }) 45 | 46 | return it('should appear in the list of connected clients', function (done) { 47 | return RealTimeClient.getConnectedClients((error, clients) => { 48 | let included = false 49 | for (const client of Array.from(clients)) { 50 | if (client.client_id === this.client.socket.sessionid) { 51 | included = true 52 | break 53 | } 54 | } 55 | expect(included).to.equal(true) 56 | return done() 57 | }) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /test/acceptance/js/helpers/FixturesManager.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | handle-callback-err, 4 | */ 5 | // TODO: This file was created by bulk-decaffeinate. 6 | // Fix any style issues and re-enable lint. 7 | /* 8 | * decaffeinate suggestions: 9 | * DS102: Remove unnecessary code created because of implicit returns 10 | * DS207: Consider shorter variations of null checks 11 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 12 | */ 13 | let FixturesManager 14 | const RealTimeClient = require('./RealTimeClient') 15 | const MockWebServer = require('./MockWebServer') 16 | const MockDocUpdaterServer = require('./MockDocUpdaterServer') 17 | 18 | module.exports = FixturesManager = { 19 | setUpProject(options, callback) { 20 | if (options == null) { 21 | options = {} 22 | } 23 | if (callback == null) { 24 | callback = function (error, data) {} 25 | } 26 | if (!options.user_id) { 27 | options.user_id = FixturesManager.getRandomId() 28 | } 29 | if (!options.project_id) { 30 | options.project_id = FixturesManager.getRandomId() 31 | } 32 | if (!options.project) { 33 | options.project = { name: 'Test Project' } 34 | } 35 | const { project_id, user_id, privilegeLevel, project, publicAccess } = 36 | options 37 | 38 | const privileges = {} 39 | privileges[user_id] = privilegeLevel 40 | if (publicAccess) { 41 | privileges['anonymous-user'] = publicAccess 42 | } 43 | 44 | MockWebServer.createMockProject(project_id, privileges, project) 45 | return MockWebServer.run(error => { 46 | if (error != null) { 47 | throw error 48 | } 49 | return RealTimeClient.setSession( 50 | { 51 | user: { 52 | _id: user_id, 53 | first_name: 'Joe', 54 | last_name: 'Bloggs', 55 | }, 56 | }, 57 | error => { 58 | if (error != null) { 59 | throw error 60 | } 61 | return callback(null, { 62 | project_id, 63 | user_id, 64 | privilegeLevel, 65 | project, 66 | }) 67 | } 68 | ) 69 | }) 70 | }, 71 | 72 | setUpDoc(project_id, options, callback) { 73 | if (options == null) { 74 | options = {} 75 | } 76 | if (callback == null) { 77 | callback = function (error, data) {} 78 | } 79 | if (!options.doc_id) { 80 | options.doc_id = FixturesManager.getRandomId() 81 | } 82 | if (!options.lines) { 83 | options.lines = ['doc', 'lines'] 84 | } 85 | if (!options.version) { 86 | options.version = 42 87 | } 88 | if (!options.ops) { 89 | options.ops = ['mock', 'ops'] 90 | } 91 | const { doc_id, lines, version, ops, ranges } = options 92 | 93 | MockDocUpdaterServer.createMockDoc(project_id, doc_id, { 94 | lines, 95 | version, 96 | ops, 97 | ranges, 98 | }) 99 | return MockDocUpdaterServer.run(error => { 100 | if (error != null) { 101 | throw error 102 | } 103 | return callback(null, { project_id, doc_id, lines, version, ops }) 104 | }) 105 | }, 106 | 107 | setUpEditorSession(options, callback) { 108 | FixturesManager.setUpProject(options, (err, detailsProject) => { 109 | if (err) return callback(err) 110 | 111 | FixturesManager.setUpDoc( 112 | detailsProject.project_id, 113 | options, 114 | (err, detailsDoc) => { 115 | if (err) return callback(err) 116 | 117 | callback(null, Object.assign({}, detailsProject, detailsDoc)) 118 | } 119 | ) 120 | }) 121 | }, 122 | 123 | getRandomId() { 124 | return require('crypto') 125 | .createHash('sha1') 126 | .update(Math.random().toString()) 127 | .digest('hex') 128 | .slice(0, 24) 129 | }, 130 | } 131 | -------------------------------------------------------------------------------- /test/acceptance/js/helpers/MockDocUpdaterServer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | handle-callback-err, 4 | no-return-assign, 5 | */ 6 | // TODO: This file was created by bulk-decaffeinate. 7 | // Fix any style issues and re-enable lint. 8 | /* 9 | * decaffeinate suggestions: 10 | * DS102: Remove unnecessary code created because of implicit returns 11 | * DS207: Consider shorter variations of null checks 12 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 13 | */ 14 | let MockDocUpdaterServer 15 | const sinon = require('sinon') 16 | const express = require('express') 17 | 18 | module.exports = MockDocUpdaterServer = { 19 | docs: {}, 20 | 21 | createMockDoc(project_id, doc_id, data) { 22 | return (MockDocUpdaterServer.docs[`${project_id}:${doc_id}`] = data) 23 | }, 24 | 25 | getDocument(project_id, doc_id, fromVersion, callback) { 26 | if (callback == null) { 27 | callback = function (error, data) {} 28 | } 29 | return callback(null, MockDocUpdaterServer.docs[`${project_id}:${doc_id}`]) 30 | }, 31 | 32 | deleteProject: sinon.stub().callsArg(1), 33 | 34 | getDocumentRequest(req, res, next) { 35 | const { project_id, doc_id } = req.params 36 | let { fromVersion } = req.query 37 | fromVersion = parseInt(fromVersion, 10) 38 | return MockDocUpdaterServer.getDocument( 39 | project_id, 40 | doc_id, 41 | fromVersion, 42 | (error, data) => { 43 | if (error != null) { 44 | return next(error) 45 | } 46 | if (!data) { 47 | return res.sendStatus(404) 48 | } 49 | return res.json(data) 50 | } 51 | ) 52 | }, 53 | 54 | deleteProjectRequest(req, res, next) { 55 | const { project_id } = req.params 56 | return MockDocUpdaterServer.deleteProject(project_id, error => { 57 | if (error != null) { 58 | return next(error) 59 | } 60 | return res.sendStatus(204) 61 | }) 62 | }, 63 | 64 | running: false, 65 | run(callback) { 66 | if (callback == null) { 67 | callback = function (error) {} 68 | } 69 | if (MockDocUpdaterServer.running) { 70 | return callback() 71 | } 72 | const app = express() 73 | app.get( 74 | '/project/:project_id/doc/:doc_id', 75 | MockDocUpdaterServer.getDocumentRequest 76 | ) 77 | app.delete( 78 | '/project/:project_id', 79 | MockDocUpdaterServer.deleteProjectRequest 80 | ) 81 | return app 82 | .listen(3003, error => { 83 | MockDocUpdaterServer.running = true 84 | return callback(error) 85 | }) 86 | .on('error', error => { 87 | console.error('error starting MockDocUpdaterServer:', error.message) 88 | return process.exit(1) 89 | }) 90 | }, 91 | } 92 | 93 | sinon.spy(MockDocUpdaterServer, 'getDocument') 94 | -------------------------------------------------------------------------------- /test/acceptance/js/helpers/MockWebServer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | handle-callback-err, 4 | no-return-assign, 5 | */ 6 | // TODO: This file was created by bulk-decaffeinate. 7 | // Fix any style issues and re-enable lint. 8 | /* 9 | * decaffeinate suggestions: 10 | * DS102: Remove unnecessary code created because of implicit returns 11 | * DS207: Consider shorter variations of null checks 12 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 13 | */ 14 | let MockWebServer 15 | const sinon = require('sinon') 16 | const express = require('express') 17 | 18 | module.exports = MockWebServer = { 19 | projects: {}, 20 | privileges: {}, 21 | 22 | createMockProject(project_id, privileges, project) { 23 | MockWebServer.privileges[project_id] = privileges 24 | return (MockWebServer.projects[project_id] = project) 25 | }, 26 | 27 | joinProject(project_id, user_id, callback) { 28 | if (callback == null) { 29 | callback = function (error, project, privilegeLevel) {} 30 | } 31 | return callback( 32 | null, 33 | MockWebServer.projects[project_id], 34 | MockWebServer.privileges[project_id][user_id] || 35 | MockWebServer.privileges[project_id]['anonymous-user'] 36 | ) 37 | }, 38 | 39 | joinProjectRequest(req, res, next) { 40 | const { project_id } = req.params 41 | const { user_id } = req.query 42 | if (project_id === 'not-found') { 43 | return res.status(404).send() 44 | } 45 | if (project_id === 'forbidden') { 46 | return res.status(403).send() 47 | } 48 | if (project_id === 'rate-limited') { 49 | return res.status(429).send() 50 | } else { 51 | return MockWebServer.joinProject( 52 | project_id, 53 | user_id, 54 | (error, project, privilegeLevel) => { 55 | if (error != null) { 56 | return next(error) 57 | } 58 | return res.json({ 59 | project, 60 | privilegeLevel, 61 | }) 62 | } 63 | ) 64 | } 65 | }, 66 | 67 | running: false, 68 | run(callback) { 69 | if (callback == null) { 70 | callback = function (error) {} 71 | } 72 | if (MockWebServer.running) { 73 | return callback() 74 | } 75 | const app = express() 76 | app.post('/project/:project_id/join', MockWebServer.joinProjectRequest) 77 | return app 78 | .listen(3000, error => { 79 | MockWebServer.running = true 80 | return callback(error) 81 | }) 82 | .on('error', error => { 83 | console.error('error starting MockWebServer:', error.message) 84 | return process.exit(1) 85 | }) 86 | }, 87 | } 88 | 89 | sinon.spy(MockWebServer, 'joinProject') 90 | -------------------------------------------------------------------------------- /test/acceptance/js/helpers/RealTimeClient.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | handle-callback-err, 4 | no-return-assign, 5 | */ 6 | // TODO: This file was created by bulk-decaffeinate. 7 | // Fix any style issues and re-enable lint. 8 | /* 9 | * decaffeinate suggestions: 10 | * DS102: Remove unnecessary code created because of implicit returns 11 | * DS207: Consider shorter variations of null checks 12 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 13 | */ 14 | let Client 15 | const { XMLHttpRequest } = require('../../libs/XMLHttpRequest') 16 | const io = require('socket.io-client') 17 | const async = require('async') 18 | 19 | const request = require('request') 20 | const Settings = require('@overleaf/settings') 21 | const redis = require('@overleaf/redis-wrapper') 22 | const rclient = redis.createClient(Settings.redis.websessions) 23 | 24 | const uid = require('uid-safe').sync 25 | const signature = require('cookie-signature') 26 | 27 | io.util.request = function () { 28 | const xhr = new XMLHttpRequest() 29 | const _open = xhr.open 30 | xhr.open = function () { 31 | _open.apply(xhr, arguments) 32 | if (Client.cookie != null) { 33 | return xhr.setRequestHeader('Cookie', Client.cookie) 34 | } 35 | } 36 | return xhr 37 | } 38 | 39 | module.exports = Client = { 40 | cookie: null, 41 | 42 | setSession(session, callback) { 43 | if (callback == null) { 44 | callback = function (error) {} 45 | } 46 | const sessionId = uid(24) 47 | session.cookie = {} 48 | return rclient.set('sess:' + sessionId, JSON.stringify(session), error => { 49 | if (error != null) { 50 | return callback(error) 51 | } 52 | const secret = Settings.security.sessionSecret 53 | const cookieKey = 's:' + signature.sign(sessionId, secret) 54 | Client.cookie = `${Settings.cookieName}=${cookieKey}` 55 | return callback() 56 | }) 57 | }, 58 | 59 | unsetSession(callback) { 60 | if (callback == null) { 61 | callback = function (error) {} 62 | } 63 | Client.cookie = null 64 | return callback() 65 | }, 66 | 67 | connect(cookie) { 68 | const client = io.connect('http://localhost:3026', { 69 | 'force new connection': true, 70 | }) 71 | client.on( 72 | 'connectionAccepted', 73 | (_, publicId) => (client.publicId = publicId) 74 | ) 75 | return client 76 | }, 77 | 78 | getConnectedClients(callback) { 79 | if (callback == null) { 80 | callback = function (error, clients) {} 81 | } 82 | return request.get( 83 | { 84 | url: 'http://localhost:3026/clients', 85 | json: true, 86 | }, 87 | (error, response, data) => callback(error, data) 88 | ) 89 | }, 90 | 91 | getConnectedClient(client_id, callback) { 92 | if (callback == null) { 93 | callback = function (error, clients) {} 94 | } 95 | return request.get( 96 | { 97 | url: `http://localhost:3026/clients/${client_id}`, 98 | json: true, 99 | }, 100 | (error, response, data) => callback(error, data) 101 | ) 102 | }, 103 | 104 | disconnectClient(client_id, callback) { 105 | request.post( 106 | { 107 | url: `http://localhost:3026/client/${client_id}/disconnect`, 108 | auth: { 109 | user: Settings.internal.realTime.user, 110 | pass: Settings.internal.realTime.pass, 111 | }, 112 | }, 113 | (error, response, data) => callback(error, data) 114 | ) 115 | return null 116 | }, 117 | 118 | disconnectAllClients(callback) { 119 | return Client.getConnectedClients((error, clients) => 120 | async.each( 121 | clients, 122 | (clientView, cb) => Client.disconnectClient(clientView.client_id, cb), 123 | callback 124 | ) 125 | ) 126 | }, 127 | } 128 | -------------------------------------------------------------------------------- /test/acceptance/js/helpers/RealtimeServer.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | handle-callback-err, 3 | */ 4 | // TODO: This file was created by bulk-decaffeinate. 5 | // Fix any style issues and re-enable lint. 6 | /* 7 | * decaffeinate suggestions: 8 | * DS101: Remove unnecessary use of Array.from 9 | * DS102: Remove unnecessary code created because of implicit returns 10 | * DS103: Rewrite code to no longer use __guard__ 11 | * DS205: Consider reworking code to avoid use of IIFEs 12 | * DS207: Consider shorter variations of null checks 13 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 14 | */ 15 | const app = require('../../../../app') 16 | const logger = require('logger-sharelatex') 17 | const Settings = require('@overleaf/settings') 18 | 19 | module.exports = { 20 | running: false, 21 | initing: false, 22 | callbacks: [], 23 | ensureRunning(callback) { 24 | if (callback == null) { 25 | callback = function (error) {} 26 | } 27 | if (this.running) { 28 | return callback() 29 | } else if (this.initing) { 30 | return this.callbacks.push(callback) 31 | } else { 32 | this.initing = true 33 | this.callbacks.push(callback) 34 | return app.listen( 35 | __guard__( 36 | Settings.internal != null ? Settings.internal.realtime : undefined, 37 | x => x.port 38 | ), 39 | 'localhost', 40 | error => { 41 | if (error != null) { 42 | throw error 43 | } 44 | this.running = true 45 | logger.log('clsi running in dev mode') 46 | 47 | return (() => { 48 | const result = [] 49 | for (callback of Array.from(this.callbacks)) { 50 | result.push(callback()) 51 | } 52 | return result 53 | })() 54 | } 55 | ) 56 | } 57 | }, 58 | } 59 | 60 | function __guard__(value, transform) { 61 | return typeof value !== 'undefined' && value !== null 62 | ? transform(value) 63 | : undefined 64 | } 65 | -------------------------------------------------------------------------------- /test/acceptance/scripts/full-test.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env bash 2 | 3 | # npm rebuild 4 | 5 | echo ">> Starting server..." 6 | 7 | grunt --no-color forever:app:start 8 | 9 | echo ">> Server started" 10 | 11 | sleep 5 12 | 13 | echo ">> Running acceptance tests..." 14 | grunt --no-color mochaTest:acceptance 15 | _test_exit_code=$? 16 | 17 | echo ">> Killing server" 18 | 19 | grunt --no-color forever:app:stop 20 | 21 | echo ">> Done" 22 | 23 | exit $_test_exit_code 24 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai') 2 | const SandboxedModule = require('sandboxed-module') 3 | const sinon = require('sinon') 4 | 5 | // Chai configuration 6 | chai.should() 7 | 8 | // Global stubs 9 | const sandbox = sinon.createSandbox() 10 | const stubs = { 11 | logger: { 12 | debug: sandbox.stub(), 13 | log: sandbox.stub(), 14 | info: sandbox.stub(), 15 | warn: sandbox.stub(), 16 | err: sandbox.stub(), 17 | error: sandbox.stub(), 18 | }, 19 | } 20 | 21 | // SandboxedModule configuration 22 | SandboxedModule.configure({ 23 | requires: { 24 | 'logger-sharelatex': stubs.logger, 25 | }, 26 | globals: { Buffer, JSON, console, process }, 27 | }) 28 | 29 | // Mocha hooks 30 | exports.mochaHooks = { 31 | beforeEach() { 32 | this.logger = stubs.logger 33 | }, 34 | 35 | afterEach() { 36 | sandbox.reset() 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /test/unit/js/AuthorizationManagerTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | no-return-assign, 3 | no-unused-vars, 4 | */ 5 | // TODO: This file was created by bulk-decaffeinate. 6 | // Fix any style issues and re-enable lint. 7 | /* 8 | * decaffeinate suggestions: 9 | * DS102: Remove unnecessary code created because of implicit returns 10 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 11 | */ 12 | const { expect } = require('chai') 13 | const sinon = require('sinon') 14 | const SandboxedModule = require('sandboxed-module') 15 | const path = require('path') 16 | const modulePath = '../../../app/js/AuthorizationManager' 17 | 18 | describe('AuthorizationManager', function () { 19 | beforeEach(function () { 20 | this.client = { ol_context: {} } 21 | 22 | return (this.AuthorizationManager = SandboxedModule.require(modulePath, { 23 | requires: {}, 24 | })) 25 | }) 26 | 27 | describe('assertClientCanViewProject', function () { 28 | it('should allow the readOnly privilegeLevel', function (done) { 29 | this.client.ol_context.privilege_level = 'readOnly' 30 | return this.AuthorizationManager.assertClientCanViewProject( 31 | this.client, 32 | error => { 33 | expect(error).to.be.null 34 | return done() 35 | } 36 | ) 37 | }) 38 | 39 | it('should allow the readAndWrite privilegeLevel', function (done) { 40 | this.client.ol_context.privilege_level = 'readAndWrite' 41 | return this.AuthorizationManager.assertClientCanViewProject( 42 | this.client, 43 | error => { 44 | expect(error).to.be.null 45 | return done() 46 | } 47 | ) 48 | }) 49 | 50 | it('should allow the owner privilegeLevel', function (done) { 51 | this.client.ol_context.privilege_level = 'owner' 52 | return this.AuthorizationManager.assertClientCanViewProject( 53 | this.client, 54 | error => { 55 | expect(error).to.be.null 56 | return done() 57 | } 58 | ) 59 | }) 60 | 61 | return it('should return an error with any other privilegeLevel', function (done) { 62 | this.client.ol_context.privilege_level = 'unknown' 63 | return this.AuthorizationManager.assertClientCanViewProject( 64 | this.client, 65 | error => { 66 | error.message.should.equal('not authorized') 67 | return done() 68 | } 69 | ) 70 | }) 71 | }) 72 | 73 | describe('assertClientCanEditProject', function () { 74 | it('should not allow the readOnly privilegeLevel', function (done) { 75 | this.client.ol_context.privilege_level = 'readOnly' 76 | return this.AuthorizationManager.assertClientCanEditProject( 77 | this.client, 78 | error => { 79 | error.message.should.equal('not authorized') 80 | return done() 81 | } 82 | ) 83 | }) 84 | 85 | it('should allow the readAndWrite privilegeLevel', function (done) { 86 | this.client.ol_context.privilege_level = 'readAndWrite' 87 | return this.AuthorizationManager.assertClientCanEditProject( 88 | this.client, 89 | error => { 90 | expect(error).to.be.null 91 | return done() 92 | } 93 | ) 94 | }) 95 | 96 | it('should allow the owner privilegeLevel', function (done) { 97 | this.client.ol_context.privilege_level = 'owner' 98 | return this.AuthorizationManager.assertClientCanEditProject( 99 | this.client, 100 | error => { 101 | expect(error).to.be.null 102 | return done() 103 | } 104 | ) 105 | }) 106 | 107 | return it('should return an error with any other privilegeLevel', function (done) { 108 | this.client.ol_context.privilege_level = 'unknown' 109 | return this.AuthorizationManager.assertClientCanEditProject( 110 | this.client, 111 | error => { 112 | error.message.should.equal('not authorized') 113 | return done() 114 | } 115 | ) 116 | }) 117 | }) 118 | 119 | // check doc access for project 120 | 121 | describe('assertClientCanViewProjectAndDoc', function () { 122 | beforeEach(function () { 123 | this.doc_id = '12345' 124 | this.callback = sinon.stub() 125 | return (this.client.ol_context = {}) 126 | }) 127 | 128 | describe('when not authorised at the project level', function () { 129 | beforeEach(function () { 130 | return (this.client.ol_context.privilege_level = 'unknown') 131 | }) 132 | 133 | it('should not allow access', function () { 134 | return this.AuthorizationManager.assertClientCanViewProjectAndDoc( 135 | this.client, 136 | this.doc_id, 137 | err => err.message.should.equal('not authorized') 138 | ) 139 | }) 140 | 141 | return describe('even when authorised at the doc level', function () { 142 | beforeEach(function (done) { 143 | return this.AuthorizationManager.addAccessToDoc( 144 | this.client, 145 | this.doc_id, 146 | done 147 | ) 148 | }) 149 | 150 | return it('should not allow access', function () { 151 | return this.AuthorizationManager.assertClientCanViewProjectAndDoc( 152 | this.client, 153 | this.doc_id, 154 | err => err.message.should.equal('not authorized') 155 | ) 156 | }) 157 | }) 158 | }) 159 | 160 | return describe('when authorised at the project level', function () { 161 | beforeEach(function () { 162 | return (this.client.ol_context.privilege_level = 'readOnly') 163 | }) 164 | 165 | describe('and not authorised at the document level', function () { 166 | return it('should not allow access', function () { 167 | return this.AuthorizationManager.assertClientCanViewProjectAndDoc( 168 | this.client, 169 | this.doc_id, 170 | err => err.message.should.equal('not authorized') 171 | ) 172 | }) 173 | }) 174 | 175 | describe('and authorised at the document level', function () { 176 | beforeEach(function (done) { 177 | return this.AuthorizationManager.addAccessToDoc( 178 | this.client, 179 | this.doc_id, 180 | done 181 | ) 182 | }) 183 | 184 | return it('should allow access', function () { 185 | this.AuthorizationManager.assertClientCanViewProjectAndDoc( 186 | this.client, 187 | this.doc_id, 188 | this.callback 189 | ) 190 | return this.callback.calledWith(null).should.equal(true) 191 | }) 192 | }) 193 | 194 | return describe('when document authorisation is added and then removed', function () { 195 | beforeEach(function (done) { 196 | return this.AuthorizationManager.addAccessToDoc( 197 | this.client, 198 | this.doc_id, 199 | () => { 200 | return this.AuthorizationManager.removeAccessToDoc( 201 | this.client, 202 | this.doc_id, 203 | done 204 | ) 205 | } 206 | ) 207 | }) 208 | 209 | return it('should deny access', function () { 210 | return this.AuthorizationManager.assertClientCanViewProjectAndDoc( 211 | this.client, 212 | this.doc_id, 213 | err => err.message.should.equal('not authorized') 214 | ) 215 | }) 216 | }) 217 | }) 218 | }) 219 | 220 | return describe('assertClientCanEditProjectAndDoc', function () { 221 | beforeEach(function () { 222 | this.doc_id = '12345' 223 | this.callback = sinon.stub() 224 | return (this.client.ol_context = {}) 225 | }) 226 | 227 | describe('when not authorised at the project level', function () { 228 | beforeEach(function () { 229 | return (this.client.ol_context.privilege_level = 'readOnly') 230 | }) 231 | 232 | it('should not allow access', function () { 233 | return this.AuthorizationManager.assertClientCanEditProjectAndDoc( 234 | this.client, 235 | this.doc_id, 236 | err => err.message.should.equal('not authorized') 237 | ) 238 | }) 239 | 240 | return describe('even when authorised at the doc level', function () { 241 | beforeEach(function (done) { 242 | return this.AuthorizationManager.addAccessToDoc( 243 | this.client, 244 | this.doc_id, 245 | done 246 | ) 247 | }) 248 | 249 | return it('should not allow access', function () { 250 | return this.AuthorizationManager.assertClientCanEditProjectAndDoc( 251 | this.client, 252 | this.doc_id, 253 | err => err.message.should.equal('not authorized') 254 | ) 255 | }) 256 | }) 257 | }) 258 | 259 | return describe('when authorised at the project level', function () { 260 | beforeEach(function () { 261 | return (this.client.ol_context.privilege_level = 'readAndWrite') 262 | }) 263 | 264 | describe('and not authorised at the document level', function () { 265 | return it('should not allow access', function () { 266 | return this.AuthorizationManager.assertClientCanEditProjectAndDoc( 267 | this.client, 268 | this.doc_id, 269 | err => err.message.should.equal('not authorized') 270 | ) 271 | }) 272 | }) 273 | 274 | describe('and authorised at the document level', function () { 275 | beforeEach(function (done) { 276 | return this.AuthorizationManager.addAccessToDoc( 277 | this.client, 278 | this.doc_id, 279 | done 280 | ) 281 | }) 282 | 283 | return it('should allow access', function () { 284 | this.AuthorizationManager.assertClientCanEditProjectAndDoc( 285 | this.client, 286 | this.doc_id, 287 | this.callback 288 | ) 289 | return this.callback.calledWith(null).should.equal(true) 290 | }) 291 | }) 292 | 293 | return describe('when document authorisation is added and then removed', function () { 294 | beforeEach(function (done) { 295 | return this.AuthorizationManager.addAccessToDoc( 296 | this.client, 297 | this.doc_id, 298 | () => { 299 | return this.AuthorizationManager.removeAccessToDoc( 300 | this.client, 301 | this.doc_id, 302 | done 303 | ) 304 | } 305 | ) 306 | }) 307 | 308 | return it('should deny access', function () { 309 | return this.AuthorizationManager.assertClientCanEditProjectAndDoc( 310 | this.client, 311 | this.doc_id, 312 | err => err.message.should.equal('not authorized') 313 | ) 314 | }) 315 | }) 316 | }) 317 | }) 318 | }) 319 | -------------------------------------------------------------------------------- /test/unit/js/DocumentUpdaterControllerTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | no-return-assign, 4 | */ 5 | // TODO: This file was created by bulk-decaffeinate. 6 | // Fix any style issues and re-enable lint. 7 | /* 8 | * decaffeinate suggestions: 9 | * DS101: Remove unnecessary use of Array.from 10 | * DS102: Remove unnecessary code created because of implicit returns 11 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 12 | */ 13 | const SandboxedModule = require('sandboxed-module') 14 | const sinon = require('sinon') 15 | const modulePath = require('path').join( 16 | __dirname, 17 | '../../../app/js/DocumentUpdaterController' 18 | ) 19 | const MockClient = require('./helpers/MockClient') 20 | 21 | describe('DocumentUpdaterController', function () { 22 | beforeEach(function () { 23 | this.project_id = 'project-id-123' 24 | this.doc_id = 'doc-id-123' 25 | this.callback = sinon.stub() 26 | this.io = { mock: 'socket.io' } 27 | this.rclient = [] 28 | this.RoomEvents = { on: sinon.stub() } 29 | this.EditorUpdatesController = SandboxedModule.require(modulePath, { 30 | requires: { 31 | '@overleaf/settings': (this.settings = { 32 | redis: { 33 | documentupdater: { 34 | key_schema: { 35 | pendingUpdates({ doc_id }) { 36 | return `PendingUpdates:${doc_id}` 37 | }, 38 | }, 39 | }, 40 | pubsub: null, 41 | }, 42 | }), 43 | './RedisClientManager': { 44 | createClientList: () => { 45 | this.redis = { 46 | createClient: name => { 47 | let rclientStub 48 | this.rclient.push((rclientStub = { name })) 49 | return rclientStub 50 | }, 51 | } 52 | }, 53 | }, 54 | './SafeJsonParse': (this.SafeJsonParse = { 55 | parse: (data, cb) => cb(null, JSON.parse(data)), 56 | }), 57 | './EventLogger': (this.EventLogger = { checkEventOrder: sinon.stub() }), 58 | './HealthCheckManager': { check: sinon.stub() }, 59 | '@overleaf/metrics': (this.metrics = { inc: sinon.stub() }), 60 | './RoomManager': (this.RoomManager = { 61 | eventSource: sinon.stub().returns(this.RoomEvents), 62 | }), 63 | './ChannelManager': (this.ChannelManager = {}), 64 | }, 65 | }) 66 | }) 67 | 68 | describe('listenForUpdatesFromDocumentUpdater', function () { 69 | beforeEach(function () { 70 | this.rclient.length = 0 // clear any existing clients 71 | this.EditorUpdatesController.rclientList = [ 72 | this.redis.createClient('first'), 73 | this.redis.createClient('second'), 74 | ] 75 | this.rclient[0].subscribe = sinon.stub() 76 | this.rclient[0].on = sinon.stub() 77 | this.rclient[1].subscribe = sinon.stub() 78 | this.rclient[1].on = sinon.stub() 79 | this.EditorUpdatesController.listenForUpdatesFromDocumentUpdater() 80 | }) 81 | 82 | it('should subscribe to the doc-updater stream', function () { 83 | this.rclient[0].subscribe.calledWith('applied-ops').should.equal(true) 84 | }) 85 | 86 | it('should register a callback to handle updates', function () { 87 | this.rclient[0].on.calledWith('message').should.equal(true) 88 | }) 89 | 90 | it('should subscribe to any additional doc-updater stream', function () { 91 | this.rclient[1].subscribe.calledWith('applied-ops').should.equal(true) 92 | this.rclient[1].on.calledWith('message').should.equal(true) 93 | }) 94 | }) 95 | 96 | describe('_processMessageFromDocumentUpdater', function () { 97 | describe('with bad JSON', function () { 98 | beforeEach(function () { 99 | this.SafeJsonParse.parse = sinon 100 | .stub() 101 | .callsArgWith(1, new Error('oops')) 102 | return this.EditorUpdatesController._processMessageFromDocumentUpdater( 103 | this.io, 104 | 'applied-ops', 105 | 'blah' 106 | ) 107 | }) 108 | 109 | it('should log an error', function () { 110 | return this.logger.error.called.should.equal(true) 111 | }) 112 | }) 113 | 114 | describe('with update', function () { 115 | beforeEach(function () { 116 | this.message = { 117 | doc_id: this.doc_id, 118 | op: { t: 'foo', p: 12 }, 119 | } 120 | this.EditorUpdatesController._applyUpdateFromDocumentUpdater = 121 | sinon.stub() 122 | return this.EditorUpdatesController._processMessageFromDocumentUpdater( 123 | this.io, 124 | 'applied-ops', 125 | JSON.stringify(this.message) 126 | ) 127 | }) 128 | 129 | it('should apply the update', function () { 130 | return this.EditorUpdatesController._applyUpdateFromDocumentUpdater 131 | .calledWith(this.io, this.doc_id, this.message.op) 132 | .should.equal(true) 133 | }) 134 | }) 135 | 136 | describe('with error', function () { 137 | beforeEach(function () { 138 | this.message = { 139 | doc_id: this.doc_id, 140 | error: 'Something went wrong', 141 | } 142 | this.EditorUpdatesController._processErrorFromDocumentUpdater = 143 | sinon.stub() 144 | return this.EditorUpdatesController._processMessageFromDocumentUpdater( 145 | this.io, 146 | 'applied-ops', 147 | JSON.stringify(this.message) 148 | ) 149 | }) 150 | 151 | return it('should process the error', function () { 152 | return this.EditorUpdatesController._processErrorFromDocumentUpdater 153 | .calledWith(this.io, this.doc_id, this.message.error) 154 | .should.equal(true) 155 | }) 156 | }) 157 | }) 158 | 159 | describe('_applyUpdateFromDocumentUpdater', function () { 160 | beforeEach(function () { 161 | this.sourceClient = new MockClient() 162 | this.otherClients = [new MockClient(), new MockClient()] 163 | this.update = { 164 | op: [{ t: 'foo', p: 12 }], 165 | meta: { source: this.sourceClient.publicId }, 166 | v: (this.version = 42), 167 | doc: this.doc_id, 168 | } 169 | return (this.io.sockets = { 170 | clients: sinon 171 | .stub() 172 | .returns([ 173 | this.sourceClient, 174 | ...Array.from(this.otherClients), 175 | this.sourceClient, 176 | ]), 177 | }) 178 | }) // include a duplicate client 179 | 180 | describe('normally', function () { 181 | beforeEach(function () { 182 | return this.EditorUpdatesController._applyUpdateFromDocumentUpdater( 183 | this.io, 184 | this.doc_id, 185 | this.update 186 | ) 187 | }) 188 | 189 | it('should send a version bump to the source client', function () { 190 | this.sourceClient.emit 191 | .calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id }) 192 | .should.equal(true) 193 | return this.sourceClient.emit.calledOnce.should.equal(true) 194 | }) 195 | 196 | it('should get the clients connected to the document', function () { 197 | return this.io.sockets.clients 198 | .calledWith(this.doc_id) 199 | .should.equal(true) 200 | }) 201 | 202 | return it('should send the full update to the other clients', function () { 203 | return Array.from(this.otherClients).map(client => 204 | client.emit 205 | .calledWith('otUpdateApplied', this.update) 206 | .should.equal(true) 207 | ) 208 | }) 209 | }) 210 | 211 | return describe('with a duplicate op', function () { 212 | beforeEach(function () { 213 | this.update.dup = true 214 | return this.EditorUpdatesController._applyUpdateFromDocumentUpdater( 215 | this.io, 216 | this.doc_id, 217 | this.update 218 | ) 219 | }) 220 | 221 | it('should send a version bump to the source client as usual', function () { 222 | return this.sourceClient.emit 223 | .calledWith('otUpdateApplied', { v: this.version, doc: this.doc_id }) 224 | .should.equal(true) 225 | }) 226 | 227 | return it("should not send anything to the other clients (they've already had the op)", function () { 228 | return Array.from(this.otherClients).map(client => 229 | client.emit.calledWith('otUpdateApplied').should.equal(false) 230 | ) 231 | }) 232 | }) 233 | }) 234 | 235 | return describe('_processErrorFromDocumentUpdater', function () { 236 | beforeEach(function () { 237 | this.clients = [new MockClient(), new MockClient()] 238 | this.io.sockets = { clients: sinon.stub().returns(this.clients) } 239 | return this.EditorUpdatesController._processErrorFromDocumentUpdater( 240 | this.io, 241 | this.doc_id, 242 | 'Something went wrong' 243 | ) 244 | }) 245 | 246 | it('should log a warning', function () { 247 | return this.logger.warn.called.should.equal(true) 248 | }) 249 | 250 | return it('should disconnect all clients in that document', function () { 251 | this.io.sockets.clients.calledWith(this.doc_id).should.equal(true) 252 | return Array.from(this.clients).map(client => 253 | client.disconnect.called.should.equal(true) 254 | ) 255 | }) 256 | }) 257 | }) 258 | -------------------------------------------------------------------------------- /test/unit/js/DrainManagerTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | no-return-assign, 3 | no-unused-vars, 4 | */ 5 | // TODO: This file was created by bulk-decaffeinate. 6 | // Fix any style issues and re-enable lint. 7 | /* 8 | * decaffeinate suggestions: 9 | * DS102: Remove unnecessary code created because of implicit returns 10 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 11 | */ 12 | const sinon = require('sinon') 13 | const SandboxedModule = require('sandboxed-module') 14 | const path = require('path') 15 | const modulePath = path.join(__dirname, '../../../app/js/DrainManager') 16 | 17 | describe('DrainManager', function () { 18 | beforeEach(function () { 19 | this.DrainManager = SandboxedModule.require(modulePath, {}) 20 | return (this.io = { 21 | sockets: { 22 | clients: sinon.stub(), 23 | }, 24 | }) 25 | }) 26 | 27 | describe('startDrainTimeWindow', function () { 28 | beforeEach(function () { 29 | this.clients = [] 30 | for (let i = 0; i <= 5399; i++) { 31 | this.clients[i] = { 32 | id: i, 33 | emit: sinon.stub(), 34 | } 35 | } 36 | this.io.sockets.clients.returns(this.clients) 37 | return (this.DrainManager.startDrain = sinon.stub()) 38 | }) 39 | 40 | return it('should set a drain rate fast enough', function (done) { 41 | this.DrainManager.startDrainTimeWindow(this.io, 9) 42 | this.DrainManager.startDrain.calledWith(this.io, 10).should.equal(true) 43 | return done() 44 | }) 45 | }) 46 | 47 | return describe('reconnectNClients', function () { 48 | beforeEach(function () { 49 | this.clients = [] 50 | for (let i = 0; i <= 9; i++) { 51 | this.clients[i] = { 52 | id: i, 53 | emit: sinon.stub(), 54 | } 55 | } 56 | return this.io.sockets.clients.returns(this.clients) 57 | }) 58 | 59 | return describe('after first pass', function () { 60 | beforeEach(function () { 61 | return this.DrainManager.reconnectNClients(this.io, 3) 62 | }) 63 | 64 | it('should reconnect the first 3 clients', function () { 65 | return [0, 1, 2].map(i => 66 | this.clients[i].emit 67 | .calledWith('reconnectGracefully') 68 | .should.equal(true) 69 | ) 70 | }) 71 | 72 | it('should not reconnect any more clients', function () { 73 | return [3, 4, 5, 6, 7, 8, 9].map(i => 74 | this.clients[i].emit 75 | .calledWith('reconnectGracefully') 76 | .should.equal(false) 77 | ) 78 | }) 79 | 80 | return describe('after second pass', function () { 81 | beforeEach(function () { 82 | return this.DrainManager.reconnectNClients(this.io, 3) 83 | }) 84 | 85 | it('should reconnect the next 3 clients', function () { 86 | return [3, 4, 5].map(i => 87 | this.clients[i].emit 88 | .calledWith('reconnectGracefully') 89 | .should.equal(true) 90 | ) 91 | }) 92 | 93 | it('should not reconnect any more clients', function () { 94 | return [6, 7, 8, 9].map(i => 95 | this.clients[i].emit 96 | .calledWith('reconnectGracefully') 97 | .should.equal(false) 98 | ) 99 | }) 100 | 101 | it('should not reconnect the first 3 clients again', function () { 102 | return [0, 1, 2].map(i => 103 | this.clients[i].emit.calledOnce.should.equal(true) 104 | ) 105 | }) 106 | 107 | return describe('after final pass', function () { 108 | beforeEach(function () { 109 | return this.DrainManager.reconnectNClients(this.io, 100) 110 | }) 111 | 112 | it('should not reconnect the first 6 clients again', function () { 113 | return [0, 1, 2, 3, 4, 5].map(i => 114 | this.clients[i].emit.calledOnce.should.equal(true) 115 | ) 116 | }) 117 | 118 | return it('should log out that it reached the end', function () { 119 | return this.logger.log 120 | .calledWith('All clients have been told to reconnectGracefully') 121 | .should.equal(true) 122 | }) 123 | }) 124 | }) 125 | }) 126 | }) 127 | }) 128 | -------------------------------------------------------------------------------- /test/unit/js/EventLoggerTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | no-return-assign, 3 | */ 4 | // TODO: This file was created by bulk-decaffeinate. 5 | // Fix any style issues and re-enable lint. 6 | /* 7 | * decaffeinate suggestions: 8 | * DS102: Remove unnecessary code created because of implicit returns 9 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 10 | */ 11 | const { expect } = require('chai') 12 | const SandboxedModule = require('sandboxed-module') 13 | const modulePath = '../../../app/js/EventLogger' 14 | const sinon = require('sinon') 15 | const tk = require('timekeeper') 16 | 17 | describe('EventLogger', function () { 18 | beforeEach(function () { 19 | this.start = Date.now() 20 | tk.freeze(new Date(this.start)) 21 | this.EventLogger = SandboxedModule.require(modulePath, { 22 | requires: { 23 | '@overleaf/metrics': (this.metrics = { inc: sinon.stub() }), 24 | }, 25 | }) 26 | this.channel = 'applied-ops' 27 | this.id_1 = 'random-hostname:abc-1' 28 | this.message_1 = 'message-1' 29 | this.id_2 = 'random-hostname:abc-2' 30 | return (this.message_2 = 'message-2') 31 | }) 32 | 33 | afterEach(function () { 34 | return tk.reset() 35 | }) 36 | 37 | return describe('checkEventOrder', function () { 38 | describe('when the events are in order', function () { 39 | beforeEach(function () { 40 | this.EventLogger.checkEventOrder( 41 | this.channel, 42 | this.id_1, 43 | this.message_1 44 | ) 45 | return (this.status = this.EventLogger.checkEventOrder( 46 | this.channel, 47 | this.id_2, 48 | this.message_2 49 | )) 50 | }) 51 | 52 | it('should accept events in order', function () { 53 | return expect(this.status).to.be.undefined 54 | }) 55 | 56 | return it('should increment the valid event metric', function () { 57 | return this.metrics.inc 58 | .calledWith(`event.${this.channel}.valid`) 59 | .should.equals(true) 60 | }) 61 | }) 62 | 63 | describe('when there is a duplicate events', function () { 64 | beforeEach(function () { 65 | this.EventLogger.checkEventOrder( 66 | this.channel, 67 | this.id_1, 68 | this.message_1 69 | ) 70 | return (this.status = this.EventLogger.checkEventOrder( 71 | this.channel, 72 | this.id_1, 73 | this.message_1 74 | )) 75 | }) 76 | 77 | it('should return "duplicate" for the same event', function () { 78 | return expect(this.status).to.equal('duplicate') 79 | }) 80 | 81 | return it('should increment the duplicate event metric', function () { 82 | return this.metrics.inc 83 | .calledWith(`event.${this.channel}.duplicate`) 84 | .should.equals(true) 85 | }) 86 | }) 87 | 88 | describe('when there are out of order events', function () { 89 | beforeEach(function () { 90 | this.EventLogger.checkEventOrder( 91 | this.channel, 92 | this.id_1, 93 | this.message_1 94 | ) 95 | this.EventLogger.checkEventOrder( 96 | this.channel, 97 | this.id_2, 98 | this.message_2 99 | ) 100 | return (this.status = this.EventLogger.checkEventOrder( 101 | this.channel, 102 | this.id_1, 103 | this.message_1 104 | )) 105 | }) 106 | 107 | it('should return "out-of-order" for the event', function () { 108 | return expect(this.status).to.equal('out-of-order') 109 | }) 110 | 111 | return it('should increment the out-of-order event metric', function () { 112 | return this.metrics.inc 113 | .calledWith(`event.${this.channel}.out-of-order`) 114 | .should.equals(true) 115 | }) 116 | }) 117 | 118 | return describe('after MAX_STALE_TIME_IN_MS', function () { 119 | return it('should flush old entries', function () { 120 | let status 121 | this.EventLogger.MAX_EVENTS_BEFORE_CLEAN = 10 122 | this.EventLogger.checkEventOrder( 123 | this.channel, 124 | this.id_1, 125 | this.message_1 126 | ) 127 | for (let i = 1; i <= 8; i++) { 128 | status = this.EventLogger.checkEventOrder( 129 | this.channel, 130 | this.id_1, 131 | this.message_1 132 | ) 133 | expect(status).to.equal('duplicate') 134 | } 135 | // the next event should flush the old entries aboce 136 | this.EventLogger.MAX_STALE_TIME_IN_MS = 1000 137 | tk.freeze(new Date(this.start + 5 * 1000)) 138 | // because we flushed the entries this should not be a duplicate 139 | this.EventLogger.checkEventOrder( 140 | this.channel, 141 | 'other-1', 142 | this.message_2 143 | ) 144 | status = this.EventLogger.checkEventOrder( 145 | this.channel, 146 | this.id_1, 147 | this.message_1 148 | ) 149 | return expect(status).to.be.undefined 150 | }) 151 | }) 152 | }) 153 | }) 154 | -------------------------------------------------------------------------------- /test/unit/js/SafeJsonParseTest.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | camelcase, 3 | handle-callback-err, 4 | no-return-assign, 5 | no-useless-escape, 6 | */ 7 | // TODO: This file was created by bulk-decaffeinate. 8 | // Fix any style issues and re-enable lint. 9 | /* 10 | * decaffeinate suggestions: 11 | * DS102: Remove unnecessary code created because of implicit returns 12 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 13 | */ 14 | const { expect } = require('chai') 15 | const SandboxedModule = require('sandboxed-module') 16 | const modulePath = '../../../app/js/SafeJsonParse' 17 | 18 | describe('SafeJsonParse', function () { 19 | beforeEach(function () { 20 | return (this.SafeJsonParse = SandboxedModule.require(modulePath, { 21 | requires: { 22 | '@overleaf/settings': (this.Settings = { 23 | maxUpdateSize: 16 * 1024, 24 | }), 25 | }, 26 | })) 27 | }) 28 | 29 | return describe('parse', function () { 30 | it('should parse documents correctly', function (done) { 31 | return this.SafeJsonParse.parse('{"foo": "bar"}', (error, parsed) => { 32 | expect(parsed).to.deep.equal({ foo: 'bar' }) 33 | return done() 34 | }) 35 | }) 36 | 37 | it('should return an error on bad data', function (done) { 38 | return this.SafeJsonParse.parse('blah', (error, parsed) => { 39 | expect(error).to.exist 40 | return done() 41 | }) 42 | }) 43 | 44 | return it('should return an error on oversized data', function (done) { 45 | // we have a 2k overhead on top of max size 46 | const big_blob = Array(16 * 1024).join('A') 47 | const data = `{\"foo\": \"${big_blob}\"}` 48 | this.Settings.maxUpdateSize = 2 * 1024 49 | return this.SafeJsonParse.parse(data, (error, parsed) => { 50 | this.logger.error.called.should.equal(false) 51 | expect(error).to.exist 52 | return done() 53 | }) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /test/unit/js/SessionSocketsTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | handle-callback-err, 3 | no-return-assign, 4 | */ 5 | // TODO: This file was created by bulk-decaffeinate. 6 | // Fix any style issues and re-enable lint. 7 | /* 8 | * decaffeinate suggestions: 9 | * DS102: Remove unnecessary code created because of implicit returns 10 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 11 | */ 12 | const { EventEmitter } = require('events') 13 | const { expect } = require('chai') 14 | const SandboxedModule = require('sandboxed-module') 15 | const modulePath = '../../../app/js/SessionSockets' 16 | const sinon = require('sinon') 17 | 18 | describe('SessionSockets', function () { 19 | before(function () { 20 | this.SessionSocketsModule = SandboxedModule.require(modulePath) 21 | this.io = new EventEmitter() 22 | this.id1 = Math.random().toString() 23 | this.id2 = Math.random().toString() 24 | const redisResponses = { 25 | error: [new Error('Redis: something went wrong'), null], 26 | unknownId: [null, null], 27 | } 28 | redisResponses[this.id1] = [null, { user: { _id: '123' } }] 29 | redisResponses[this.id2] = [null, { user: { _id: 'abc' } }] 30 | 31 | this.sessionStore = { 32 | get: sinon 33 | .stub() 34 | .callsFake((id, fn) => fn.apply(null, redisResponses[id])), 35 | } 36 | this.cookieParser = function (req, res, next) { 37 | req.signedCookies = req._signedCookies 38 | return next() 39 | } 40 | this.SessionSockets = this.SessionSocketsModule( 41 | this.io, 42 | this.sessionStore, 43 | this.cookieParser, 44 | 'ol.sid' 45 | ) 46 | return (this.checkSocket = (socket, fn) => { 47 | this.SessionSockets.once('connection', fn) 48 | return this.io.emit('connection', socket) 49 | }) 50 | }) 51 | 52 | describe('without cookies', function () { 53 | before(function () { 54 | return (this.socket = { handshake: {} }) 55 | }) 56 | 57 | it('should return a lookup error', function (done) { 58 | return this.checkSocket(this.socket, error => { 59 | expect(error).to.exist 60 | expect(error.message).to.equal('could not look up session by key') 61 | return done() 62 | }) 63 | }) 64 | 65 | return it('should not query redis', function (done) { 66 | return this.checkSocket(this.socket, () => { 67 | expect(this.sessionStore.get.called).to.equal(false) 68 | return done() 69 | }) 70 | }) 71 | }) 72 | 73 | describe('with a different cookie', function () { 74 | before(function () { 75 | return (this.socket = { handshake: { _signedCookies: { other: 1 } } }) 76 | }) 77 | 78 | it('should return a lookup error', function (done) { 79 | return this.checkSocket(this.socket, error => { 80 | expect(error).to.exist 81 | expect(error.message).to.equal('could not look up session by key') 82 | return done() 83 | }) 84 | }) 85 | 86 | return it('should not query redis', function (done) { 87 | return this.checkSocket(this.socket, () => { 88 | expect(this.sessionStore.get.called).to.equal(false) 89 | return done() 90 | }) 91 | }) 92 | }) 93 | 94 | describe('with a valid cookie and a failing session lookup', function () { 95 | before(function () { 96 | return (this.socket = { 97 | handshake: { _signedCookies: { 'ol.sid': 'error' } }, 98 | }) 99 | }) 100 | 101 | it('should query redis', function (done) { 102 | return this.checkSocket(this.socket, () => { 103 | expect(this.sessionStore.get.called).to.equal(true) 104 | return done() 105 | }) 106 | }) 107 | 108 | return it('should return a redis error', function (done) { 109 | return this.checkSocket(this.socket, error => { 110 | expect(error).to.exist 111 | expect(error.message).to.equal('Redis: something went wrong') 112 | return done() 113 | }) 114 | }) 115 | }) 116 | 117 | describe('with a valid cookie and no matching session', function () { 118 | before(function () { 119 | return (this.socket = { 120 | handshake: { _signedCookies: { 'ol.sid': 'unknownId' } }, 121 | }) 122 | }) 123 | 124 | it('should query redis', function (done) { 125 | return this.checkSocket(this.socket, () => { 126 | expect(this.sessionStore.get.called).to.equal(true) 127 | return done() 128 | }) 129 | }) 130 | 131 | return it('should return a lookup error', function (done) { 132 | return this.checkSocket(this.socket, error => { 133 | expect(error).to.exist 134 | expect(error.message).to.equal('could not look up session by key') 135 | return done() 136 | }) 137 | }) 138 | }) 139 | 140 | describe('with a valid cookie and a matching session', function () { 141 | before(function () { 142 | return (this.socket = { 143 | handshake: { _signedCookies: { 'ol.sid': this.id1 } }, 144 | }) 145 | }) 146 | 147 | it('should query redis', function (done) { 148 | return this.checkSocket(this.socket, () => { 149 | expect(this.sessionStore.get.called).to.equal(true) 150 | return done() 151 | }) 152 | }) 153 | 154 | it('should not return an error', function (done) { 155 | return this.checkSocket(this.socket, error => { 156 | expect(error).to.not.exist 157 | return done() 158 | }) 159 | }) 160 | 161 | return it('should return the session', function (done) { 162 | return this.checkSocket(this.socket, (error, s, session) => { 163 | expect(session).to.deep.equal({ user: { _id: '123' } }) 164 | return done() 165 | }) 166 | }) 167 | }) 168 | 169 | return describe('with a different valid cookie and matching session', function () { 170 | before(function () { 171 | return (this.socket = { 172 | handshake: { _signedCookies: { 'ol.sid': this.id2 } }, 173 | }) 174 | }) 175 | 176 | it('should query redis', function (done) { 177 | return this.checkSocket(this.socket, () => { 178 | expect(this.sessionStore.get.called).to.equal(true) 179 | return done() 180 | }) 181 | }) 182 | 183 | it('should not return an error', function (done) { 184 | return this.checkSocket(this.socket, error => { 185 | expect(error).to.not.exist 186 | return done() 187 | }) 188 | }) 189 | 190 | return it('should return the other session', function (done) { 191 | return this.checkSocket(this.socket, (error, s, session) => { 192 | expect(session).to.deep.equal({ user: { _id: 'abc' } }) 193 | return done() 194 | }) 195 | }) 196 | }) 197 | }) 198 | -------------------------------------------------------------------------------- /test/unit/js/WebApiManagerTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | no-return-assign, 3 | no-unused-vars, 4 | */ 5 | // TODO: This file was created by bulk-decaffeinate. 6 | // Fix any style issues and re-enable lint. 7 | /* 8 | * decaffeinate suggestions: 9 | * DS102: Remove unnecessary code created because of implicit returns 10 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 11 | */ 12 | const sinon = require('sinon') 13 | const modulePath = '../../../app/js/WebApiManager.js' 14 | const SandboxedModule = require('sandboxed-module') 15 | const { CodedError } = require('../../../app/js/Errors') 16 | 17 | describe('WebApiManager', function () { 18 | beforeEach(function () { 19 | this.project_id = 'project-id-123' 20 | this.user_id = 'user-id-123' 21 | this.user = { _id: this.user_id } 22 | this.callback = sinon.stub() 23 | return (this.WebApiManager = SandboxedModule.require(modulePath, { 24 | requires: { 25 | request: (this.request = {}), 26 | '@overleaf/settings': (this.settings = { 27 | apis: { 28 | web: { 29 | url: 'http://web.example.com', 30 | user: 'username', 31 | pass: 'password', 32 | }, 33 | }, 34 | }), 35 | }, 36 | })) 37 | }) 38 | 39 | return describe('joinProject', function () { 40 | describe('successfully', function () { 41 | beforeEach(function () { 42 | this.response = { 43 | project: { name: 'Test project' }, 44 | privilegeLevel: 'owner', 45 | isRestrictedUser: true, 46 | } 47 | this.request.post = sinon 48 | .stub() 49 | .callsArgWith(1, null, { statusCode: 200 }, this.response) 50 | return this.WebApiManager.joinProject( 51 | this.project_id, 52 | this.user, 53 | this.callback 54 | ) 55 | }) 56 | 57 | it('should send a request to web to join the project', function () { 58 | return this.request.post 59 | .calledWith({ 60 | url: `${this.settings.apis.web.url}/project/${this.project_id}/join`, 61 | qs: { 62 | user_id: this.user_id, 63 | }, 64 | auth: { 65 | user: this.settings.apis.web.user, 66 | pass: this.settings.apis.web.pass, 67 | sendImmediately: true, 68 | }, 69 | json: true, 70 | jar: false, 71 | headers: {}, 72 | }) 73 | .should.equal(true) 74 | }) 75 | 76 | return it('should return the project, privilegeLevel, and restricted flag', function () { 77 | return this.callback 78 | .calledWith( 79 | null, 80 | this.response.project, 81 | this.response.privilegeLevel, 82 | this.response.isRestrictedUser 83 | ) 84 | .should.equal(true) 85 | }) 86 | }) 87 | 88 | describe('when web replies with a 403', function () { 89 | beforeEach(function () { 90 | this.request.post = sinon 91 | .stub() 92 | .callsArgWith(1, null, { statusCode: 403 }, null) 93 | this.WebApiManager.joinProject( 94 | this.project_id, 95 | this.user_id, 96 | this.callback 97 | ) 98 | }) 99 | 100 | it('should call the callback with an error', function () { 101 | this.callback 102 | .calledWith( 103 | sinon.match({ 104 | message: 'not authorized', 105 | }) 106 | ) 107 | .should.equal(true) 108 | }) 109 | }) 110 | 111 | describe('when web replies with a 404', function () { 112 | beforeEach(function () { 113 | this.request.post = sinon 114 | .stub() 115 | .callsArgWith(1, null, { statusCode: 404 }, null) 116 | this.WebApiManager.joinProject( 117 | this.project_id, 118 | this.user_id, 119 | this.callback 120 | ) 121 | }) 122 | 123 | it('should call the callback with an error', function () { 124 | this.callback 125 | .calledWith( 126 | sinon.match({ 127 | message: 'project not found', 128 | info: { code: 'ProjectNotFound' }, 129 | }) 130 | ) 131 | .should.equal(true) 132 | }) 133 | }) 134 | 135 | describe('with an error from web', function () { 136 | beforeEach(function () { 137 | this.request.post = sinon 138 | .stub() 139 | .callsArgWith(1, null, { statusCode: 500 }, null) 140 | return this.WebApiManager.joinProject( 141 | this.project_id, 142 | this.user_id, 143 | this.callback 144 | ) 145 | }) 146 | 147 | return it('should call the callback with an error', function () { 148 | return this.callback 149 | .calledWith( 150 | sinon.match({ 151 | message: 'non-success status code from web', 152 | info: { statusCode: 500 }, 153 | }) 154 | ) 155 | .should.equal(true) 156 | }) 157 | }) 158 | 159 | describe('with no data from web', function () { 160 | beforeEach(function () { 161 | this.request.post = sinon 162 | .stub() 163 | .callsArgWith(1, null, { statusCode: 200 }, null) 164 | return this.WebApiManager.joinProject( 165 | this.project_id, 166 | this.user_id, 167 | this.callback 168 | ) 169 | }) 170 | 171 | return it('should call the callback with an error', function () { 172 | return this.callback 173 | .calledWith( 174 | sinon.match({ 175 | message: 'no data returned from joinProject request', 176 | }) 177 | ) 178 | .should.equal(true) 179 | }) 180 | }) 181 | 182 | return describe('when the project is over its rate limit', function () { 183 | beforeEach(function () { 184 | this.request.post = sinon 185 | .stub() 186 | .callsArgWith(1, null, { statusCode: 429 }, null) 187 | return this.WebApiManager.joinProject( 188 | this.project_id, 189 | this.user_id, 190 | this.callback 191 | ) 192 | }) 193 | 194 | return it('should call the callback with a TooManyRequests error code', function () { 195 | return this.callback 196 | .calledWith( 197 | sinon.match({ 198 | message: 'rate-limit hit when joining project', 199 | info: { 200 | code: 'TooManyRequests', 201 | }, 202 | }) 203 | ) 204 | .should.equal(true) 205 | }) 206 | }) 207 | }) 208 | }) 209 | -------------------------------------------------------------------------------- /test/unit/js/WebsocketLoadBalancerTests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | no-return-assign, 3 | */ 4 | // TODO: This file was created by bulk-decaffeinate. 5 | // Fix any style issues and re-enable lint. 6 | /* 7 | * decaffeinate suggestions: 8 | * DS101: Remove unnecessary use of Array.from 9 | * DS102: Remove unnecessary code created because of implicit returns 10 | * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md 11 | */ 12 | const SandboxedModule = require('sandboxed-module') 13 | const sinon = require('sinon') 14 | const modulePath = require('path').join( 15 | __dirname, 16 | '../../../app/js/WebsocketLoadBalancer' 17 | ) 18 | 19 | describe('WebsocketLoadBalancer', function () { 20 | beforeEach(function () { 21 | this.rclient = {} 22 | this.RoomEvents = { on: sinon.stub() } 23 | this.WebsocketLoadBalancer = SandboxedModule.require(modulePath, { 24 | requires: { 25 | './RedisClientManager': { 26 | createClientList: () => [], 27 | }, 28 | './SafeJsonParse': (this.SafeJsonParse = { 29 | parse: (data, cb) => cb(null, JSON.parse(data)), 30 | }), 31 | './EventLogger': { checkEventOrder: sinon.stub() }, 32 | './HealthCheckManager': { check: sinon.stub() }, 33 | './RoomManager': (this.RoomManager = { 34 | eventSource: sinon.stub().returns(this.RoomEvents), 35 | }), 36 | './ChannelManager': (this.ChannelManager = { publish: sinon.stub() }), 37 | './ConnectedUsersManager': (this.ConnectedUsersManager = { 38 | refreshClient: sinon.stub(), 39 | }), 40 | }, 41 | }) 42 | this.io = {} 43 | this.WebsocketLoadBalancer.rclientPubList = [{ publish: sinon.stub() }] 44 | this.WebsocketLoadBalancer.rclientSubList = [ 45 | { 46 | subscribe: sinon.stub(), 47 | on: sinon.stub(), 48 | }, 49 | ] 50 | 51 | this.room_id = 'room-id' 52 | this.message = 'otUpdateApplied' 53 | return (this.payload = ['argument one', 42]) 54 | }) 55 | 56 | describe('emitToRoom', function () { 57 | beforeEach(function () { 58 | return this.WebsocketLoadBalancer.emitToRoom( 59 | this.room_id, 60 | this.message, 61 | ...Array.from(this.payload) 62 | ) 63 | }) 64 | 65 | return it('should publish the message to redis', function () { 66 | return this.ChannelManager.publish 67 | .calledWith( 68 | this.WebsocketLoadBalancer.rclientPubList[0], 69 | 'editor-events', 70 | this.room_id, 71 | JSON.stringify({ 72 | room_id: this.room_id, 73 | message: this.message, 74 | payload: this.payload, 75 | }) 76 | ) 77 | .should.equal(true) 78 | }) 79 | }) 80 | 81 | describe('emitToAll', function () { 82 | beforeEach(function () { 83 | this.WebsocketLoadBalancer.emitToRoom = sinon.stub() 84 | return this.WebsocketLoadBalancer.emitToAll( 85 | this.message, 86 | ...Array.from(this.payload) 87 | ) 88 | }) 89 | 90 | return it("should emit to the room 'all'", function () { 91 | return this.WebsocketLoadBalancer.emitToRoom 92 | .calledWith('all', this.message, ...Array.from(this.payload)) 93 | .should.equal(true) 94 | }) 95 | }) 96 | 97 | describe('listenForEditorEvents', function () { 98 | beforeEach(function () { 99 | this.WebsocketLoadBalancer._processEditorEvent = sinon.stub() 100 | return this.WebsocketLoadBalancer.listenForEditorEvents() 101 | }) 102 | 103 | it('should subscribe to the editor-events channel', function () { 104 | return this.WebsocketLoadBalancer.rclientSubList[0].subscribe 105 | .calledWith('editor-events') 106 | .should.equal(true) 107 | }) 108 | 109 | return it('should process the events with _processEditorEvent', function () { 110 | return this.WebsocketLoadBalancer.rclientSubList[0].on 111 | .calledWith('message', sinon.match.func) 112 | .should.equal(true) 113 | }) 114 | }) 115 | 116 | return describe('_processEditorEvent', function () { 117 | describe('with bad JSON', function () { 118 | beforeEach(function () { 119 | this.isRestrictedUser = false 120 | this.SafeJsonParse.parse = sinon 121 | .stub() 122 | .callsArgWith(1, new Error('oops')) 123 | return this.WebsocketLoadBalancer._processEditorEvent( 124 | this.io, 125 | 'editor-events', 126 | 'blah' 127 | ) 128 | }) 129 | 130 | return it('should log an error', function () { 131 | return this.logger.error.called.should.equal(true) 132 | }) 133 | }) 134 | 135 | describe('with a designated room', function () { 136 | beforeEach(function () { 137 | this.io.sockets = { 138 | clients: sinon.stub().returns([ 139 | { 140 | id: 'client-id-1', 141 | emit: (this.emit1 = sinon.stub()), 142 | ol_context: {}, 143 | }, 144 | { 145 | id: 'client-id-2', 146 | emit: (this.emit2 = sinon.stub()), 147 | ol_context: {}, 148 | }, 149 | { 150 | id: 'client-id-1', 151 | emit: (this.emit3 = sinon.stub()), 152 | ol_context: {}, 153 | }, // duplicate client 154 | ]), 155 | } 156 | const data = JSON.stringify({ 157 | room_id: this.room_id, 158 | message: this.message, 159 | payload: this.payload, 160 | }) 161 | return this.WebsocketLoadBalancer._processEditorEvent( 162 | this.io, 163 | 'editor-events', 164 | data 165 | ) 166 | }) 167 | 168 | return it('should send the message to all (unique) clients in the room', function () { 169 | this.io.sockets.clients.calledWith(this.room_id).should.equal(true) 170 | this.emit1 171 | .calledWith(this.message, ...Array.from(this.payload)) 172 | .should.equal(true) 173 | this.emit2 174 | .calledWith(this.message, ...Array.from(this.payload)) 175 | .should.equal(true) 176 | return this.emit3.called.should.equal(false) 177 | }) 178 | }) // duplicate client should be ignored 179 | 180 | describe('with a designated room, and restricted clients, not restricted message', function () { 181 | beforeEach(function () { 182 | this.io.sockets = { 183 | clients: sinon.stub().returns([ 184 | { 185 | id: 'client-id-1', 186 | emit: (this.emit1 = sinon.stub()), 187 | ol_context: {}, 188 | }, 189 | { 190 | id: 'client-id-2', 191 | emit: (this.emit2 = sinon.stub()), 192 | ol_context: {}, 193 | }, 194 | { 195 | id: 'client-id-1', 196 | emit: (this.emit3 = sinon.stub()), 197 | ol_context: {}, 198 | }, // duplicate client 199 | { 200 | id: 'client-id-4', 201 | emit: (this.emit4 = sinon.stub()), 202 | ol_context: { is_restricted_user: true }, 203 | }, 204 | ]), 205 | } 206 | const data = JSON.stringify({ 207 | room_id: this.room_id, 208 | message: this.message, 209 | payload: this.payload, 210 | }) 211 | return this.WebsocketLoadBalancer._processEditorEvent( 212 | this.io, 213 | 'editor-events', 214 | data 215 | ) 216 | }) 217 | 218 | return it('should send the message to all (unique) clients in the room', function () { 219 | this.io.sockets.clients.calledWith(this.room_id).should.equal(true) 220 | this.emit1 221 | .calledWith(this.message, ...Array.from(this.payload)) 222 | .should.equal(true) 223 | this.emit2 224 | .calledWith(this.message, ...Array.from(this.payload)) 225 | .should.equal(true) 226 | this.emit3.called.should.equal(false) // duplicate client should be ignored 227 | return this.emit4.called.should.equal(true) 228 | }) 229 | }) // restricted client, but should be called 230 | 231 | describe('with a designated room, and restricted clients, restricted message', function () { 232 | beforeEach(function () { 233 | this.io.sockets = { 234 | clients: sinon.stub().returns([ 235 | { 236 | id: 'client-id-1', 237 | emit: (this.emit1 = sinon.stub()), 238 | ol_context: {}, 239 | }, 240 | { 241 | id: 'client-id-2', 242 | emit: (this.emit2 = sinon.stub()), 243 | ol_context: {}, 244 | }, 245 | { 246 | id: 'client-id-1', 247 | emit: (this.emit3 = sinon.stub()), 248 | ol_context: {}, 249 | }, // duplicate client 250 | { 251 | id: 'client-id-4', 252 | emit: (this.emit4 = sinon.stub()), 253 | ol_context: { is_restricted_user: true }, 254 | }, 255 | ]), 256 | } 257 | const data = JSON.stringify({ 258 | room_id: this.room_id, 259 | message: (this.restrictedMessage = 'new-comment'), 260 | payload: this.payload, 261 | }) 262 | return this.WebsocketLoadBalancer._processEditorEvent( 263 | this.io, 264 | 'editor-events', 265 | data 266 | ) 267 | }) 268 | 269 | return it('should send the message to all (unique) clients in the room, who are not restricted', function () { 270 | this.io.sockets.clients.calledWith(this.room_id).should.equal(true) 271 | this.emit1 272 | .calledWith(this.restrictedMessage, ...Array.from(this.payload)) 273 | .should.equal(true) 274 | this.emit2 275 | .calledWith(this.restrictedMessage, ...Array.from(this.payload)) 276 | .should.equal(true) 277 | this.emit3.called.should.equal(false) // duplicate client should be ignored 278 | return this.emit4.called.should.equal(false) 279 | }) 280 | }) // restricted client, should not be called 281 | 282 | return describe('when emitting to all', function () { 283 | beforeEach(function () { 284 | this.io.sockets = { emit: (this.emit = sinon.stub()) } 285 | const data = JSON.stringify({ 286 | room_id: 'all', 287 | message: this.message, 288 | payload: this.payload, 289 | }) 290 | return this.WebsocketLoadBalancer._processEditorEvent( 291 | this.io, 292 | 'editor-events', 293 | data 294 | ) 295 | }) 296 | 297 | return it('should send the message to all clients', function () { 298 | return this.emit 299 | .calledWith(this.message, ...Array.from(this.payload)) 300 | .should.equal(true) 301 | }) 302 | }) 303 | }) 304 | }) 305 | -------------------------------------------------------------------------------- /test/unit/js/helpers/MockClient.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable 2 | no-unused-vars, 3 | */ 4 | // TODO: This file was created by bulk-decaffeinate. 5 | // Fix any style issues and re-enable lint. 6 | let MockClient 7 | const sinon = require('sinon') 8 | 9 | let idCounter = 0 10 | 11 | module.exports = MockClient = class MockClient { 12 | constructor() { 13 | this.ol_context = {} 14 | this.join = sinon.stub() 15 | this.emit = sinon.stub() 16 | this.disconnect = sinon.stub() 17 | this.id = idCounter++ 18 | this.publicId = idCounter++ 19 | this.joinLeaveEpoch = 0 20 | } 21 | 22 | disconnect() {} 23 | } 24 | --------------------------------------------------------------------------------