WebsocketController.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. const OError = require('@overleaf/o-error')
  2. const logger = require('@overleaf/logger')
  3. const metrics = require('@overleaf/metrics')
  4. const WebApiManager = require('./WebApiManager')
  5. const AuthorizationManager = require('./AuthorizationManager')
  6. const DocumentUpdaterManager = require('./DocumentUpdaterManager')
  7. const ConnectedUsersManager = require('./ConnectedUsersManager')
  8. const WebsocketLoadBalancer = require('./WebsocketLoadBalancer')
  9. const RoomManager = require('./RoomManager')
  10. const {
  11. JoinLeaveEpochMismatchError,
  12. NotAuthorizedError,
  13. NotJoinedError,
  14. ClientRequestedMissingOpsError,
  15. } = require('./Errors')
  16. const JOIN_DOC_CATCH_UP_LENGTH_BUCKETS = [
  17. 0, 5, 10, 25, 50, 100, 150, 200, 250, 500, 1000,
  18. ]
  19. const JOIN_DOC_CATCH_UP_AGE = [
  20. 0,
  21. 1,
  22. 2,
  23. 5,
  24. 10,
  25. 20,
  26. 30,
  27. 60,
  28. 120,
  29. 240,
  30. 600,
  31. 60 * 60,
  32. 24 * 60 * 60,
  33. ].map(x => x * 1000)
  34. let WebsocketController
  35. module.exports = WebsocketController = {
  36. // If the protocol version changes when the client reconnects,
  37. // it will force a full refresh of the page. Useful for non-backwards
  38. // compatible protocol changes. Use only in extreme need.
  39. PROTOCOL_VERSION: 2,
  40. joinProject(client, user, projectId, callback) {
  41. if (client.disconnected) {
  42. metrics.inc('editor.join-project.disconnected', 1, {
  43. status: 'immediately',
  44. })
  45. return callback()
  46. }
  47. const userId = user._id
  48. logger.info(
  49. {
  50. userId,
  51. projectId,
  52. clientId: client.id,
  53. remoteIp: client.remoteIp,
  54. userAgent: client.userAgent,
  55. },
  56. 'user joining project'
  57. )
  58. metrics.inc('editor.join-project', 1, { status: client.transport })
  59. WebApiManager.joinProject(
  60. projectId,
  61. user,
  62. function (error, project, privilegeLevel, userMetadata) {
  63. if (error) {
  64. return callback(error)
  65. }
  66. if (client.disconnected) {
  67. logger.info(
  68. { userId, projectId, clientId: client.id },
  69. 'client disconnected before joining project'
  70. )
  71. metrics.inc('editor.join-project.disconnected', 1, {
  72. status: 'after-web-api-call',
  73. })
  74. return callback()
  75. }
  76. if (!privilegeLevel) {
  77. return callback(new NotAuthorizedError())
  78. }
  79. client.ol_context = {}
  80. client.ol_context.privilege_level = privilegeLevel
  81. client.ol_context.user_id = userId
  82. client.ol_context.project_id = projectId
  83. client.ol_context.owner_id = project.owner && project.owner._id
  84. client.ol_context.first_name = user.first_name
  85. client.ol_context.last_name = user.last_name
  86. client.ol_context.email = user.email
  87. client.ol_context.connected_time = new Date()
  88. client.ol_context.signup_date = user.signUpDate
  89. client.ol_context.login_count = user.loginCount
  90. client.ol_context.is_restricted_user = !!userMetadata.isRestrictedUser
  91. client.ol_context.is_token_member = !!userMetadata.isTokenMember
  92. client.ol_context.is_invited_member = !!userMetadata.isInvitedMember
  93. RoomManager.joinProject(client, projectId, function (err) {
  94. if (err) {
  95. return callback(err)
  96. }
  97. logger.debug(
  98. {
  99. userId,
  100. projectId,
  101. clientId: client.id,
  102. privilegeLevel,
  103. userMetadata,
  104. },
  105. 'user joined project'
  106. )
  107. callback(
  108. null,
  109. project,
  110. privilegeLevel,
  111. WebsocketController.PROTOCOL_VERSION
  112. )
  113. })
  114. // No need to block for setting the user as connected in the cursor tracking
  115. ConnectedUsersManager.updateUserPosition(
  116. projectId,
  117. client.publicId,
  118. user,
  119. null,
  120. function (err) {
  121. if (err) {
  122. logger.warn(
  123. { err, projectId, userId, clientId: client.id },
  124. 'background cursor update failed'
  125. )
  126. }
  127. }
  128. )
  129. }
  130. )
  131. },
  132. // We want to flush a project if there are no more (local) connected clients
  133. // but we need to wait for the triggering client to disconnect. How long we wait
  134. // is determined by FLUSH_IF_EMPTY_DELAY.
  135. FLUSH_IF_EMPTY_DELAY: 500, // ms
  136. leaveProject(io, client, callback) {
  137. const { project_id: projectId, user_id: userId } = client.ol_context
  138. if (!projectId) {
  139. return callback()
  140. } // client did not join project
  141. metrics.inc('editor.leave-project', 1, { status: client.transport })
  142. logger.info(
  143. { projectId, userId, clientId: client.id },
  144. 'client leaving project'
  145. )
  146. WebsocketLoadBalancer.emitToRoom(
  147. projectId,
  148. 'clientTracking.clientDisconnected',
  149. client.publicId
  150. )
  151. // We can do this in the background
  152. ConnectedUsersManager.markUserAsDisconnected(
  153. projectId,
  154. client.publicId,
  155. function (err) {
  156. if (err) {
  157. logger.error(
  158. { err, projectId, userId, clientId: client.id },
  159. 'error marking client as disconnected'
  160. )
  161. }
  162. }
  163. )
  164. RoomManager.leaveProjectAndDocs(client)
  165. setTimeout(function () {
  166. const remainingClients = io.sockets.clients(projectId)
  167. if (remainingClients.length === 0) {
  168. // Flush project in the background
  169. DocumentUpdaterManager.flushProjectToMongoAndDelete(
  170. projectId,
  171. function (err) {
  172. if (err) {
  173. logger.error(
  174. { err, projectId, userId, clientId: client.id },
  175. 'error flushing to doc updater after leaving project'
  176. )
  177. }
  178. }
  179. )
  180. }
  181. callback()
  182. }, WebsocketController.FLUSH_IF_EMPTY_DELAY)
  183. },
  184. joinDoc(client, docId, fromVersion, options, callback) {
  185. if (client.disconnected) {
  186. metrics.inc('editor.join-doc.disconnected', 1, { status: 'immediately' })
  187. return callback()
  188. }
  189. const joinLeaveEpoch = ++client.joinLeaveEpoch
  190. metrics.inc('editor.join-doc', 1, { status: client.transport })
  191. const {
  192. project_id: projectId,
  193. user_id: userId,
  194. is_restricted_user: isRestrictedUser,
  195. } = client.ol_context
  196. if (!projectId) {
  197. return callback(new NotJoinedError())
  198. }
  199. logger.debug(
  200. { userId, projectId, docId, fromVersion, clientId: client.id },
  201. 'client joining doc'
  202. )
  203. const emitJoinDocCatchUpMetrics = (
  204. status,
  205. { firstVersionInRedis, version, ttlInS }
  206. ) => {
  207. if (fromVersion === -1) return // full joinDoc call
  208. if (typeof options.age !== 'number') return // old frontend
  209. if (!ttlInS) return // old document-updater pod
  210. const isStale = options.age > ttlInS * 1000
  211. const method = isStale ? 'stale' : 'recent'
  212. metrics.histogram(
  213. 'join-doc-catch-up-length',
  214. version - fromVersion,
  215. JOIN_DOC_CATCH_UP_LENGTH_BUCKETS,
  216. { status, method, path: client.transport }
  217. )
  218. if (firstVersionInRedis) {
  219. metrics.histogram(
  220. 'join-doc-catch-up-length-extra-needed',
  221. firstVersionInRedis - fromVersion,
  222. JOIN_DOC_CATCH_UP_LENGTH_BUCKETS,
  223. { status, method, path: client.transport }
  224. )
  225. }
  226. metrics.histogram(
  227. 'join-doc-catch-up-age',
  228. options.age,
  229. JOIN_DOC_CATCH_UP_AGE,
  230. { status, path: client.transport }
  231. )
  232. }
  233. WebsocketController._assertClientAuthorization(
  234. client,
  235. docId,
  236. function (error) {
  237. if (error) {
  238. return callback(error)
  239. }
  240. if (client.disconnected) {
  241. metrics.inc('editor.join-doc.disconnected', 1, {
  242. status: 'after-client-auth-check',
  243. })
  244. // the client will not read the response anyways
  245. return callback()
  246. }
  247. if (joinLeaveEpoch !== client.joinLeaveEpoch) {
  248. // another joinDoc or leaveDoc rpc overtook us
  249. return callback(new JoinLeaveEpochMismatchError())
  250. }
  251. // ensure the per-doc applied-ops channel is subscribed before sending the
  252. // doc to the client, so that no events are missed.
  253. RoomManager.joinDoc(client, docId, function (error) {
  254. if (error) {
  255. return callback(error)
  256. }
  257. if (client.disconnected) {
  258. metrics.inc('editor.join-doc.disconnected', 1, {
  259. status: 'after-joining-room',
  260. })
  261. // the client will not read the response anyways
  262. return callback()
  263. }
  264. DocumentUpdaterManager.getDocument(
  265. projectId,
  266. docId,
  267. fromVersion,
  268. function (error, lines, version, ranges, ops, ttlInS) {
  269. if (error) {
  270. if (error instanceof ClientRequestedMissingOpsError) {
  271. emitJoinDocCatchUpMetrics('missing', error.info)
  272. }
  273. return callback(error)
  274. }
  275. emitJoinDocCatchUpMetrics('success', { version, ttlInS })
  276. if (client.disconnected) {
  277. metrics.inc('editor.join-doc.disconnected', 1, {
  278. status: 'after-doc-updater-call',
  279. })
  280. // the client will not read the response anyways
  281. return callback()
  282. }
  283. if (isRestrictedUser && ranges && ranges.comments) {
  284. ranges.comments = []
  285. }
  286. // Encode any binary bits of data so it can go via WebSockets
  287. // See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
  288. const encodeForWebsockets = text =>
  289. unescape(encodeURIComponent(text))
  290. const escapedLines = []
  291. for (let line of lines) {
  292. try {
  293. line = encodeForWebsockets(line)
  294. } catch (err) {
  295. OError.tag(err, 'error encoding line uri component', { line })
  296. return callback(err)
  297. }
  298. escapedLines.push(line)
  299. }
  300. if (options.encodeRanges) {
  301. try {
  302. for (const comment of (ranges && ranges.comments) || []) {
  303. if (comment.op.c) {
  304. comment.op.c = encodeForWebsockets(comment.op.c)
  305. }
  306. }
  307. for (const change of (ranges && ranges.changes) || []) {
  308. if (change.op.i) {
  309. change.op.i = encodeForWebsockets(change.op.i)
  310. }
  311. if (change.op.d) {
  312. change.op.d = encodeForWebsockets(change.op.d)
  313. }
  314. }
  315. } catch (err) {
  316. OError.tag(err, 'error encoding range uri component', {
  317. ranges,
  318. })
  319. return callback(err)
  320. }
  321. }
  322. AuthorizationManager.addAccessToDoc(client, docId, () => {})
  323. logger.debug(
  324. {
  325. userId,
  326. projectId,
  327. docId,
  328. fromVersion,
  329. clientId: client.id,
  330. },
  331. 'client joined doc'
  332. )
  333. callback(null, escapedLines, version, ops, ranges)
  334. }
  335. )
  336. })
  337. }
  338. )
  339. },
  340. _assertClientAuthorization(client, docId, callback) {
  341. // Check for project-level access first
  342. AuthorizationManager.assertClientCanViewProject(client, function (error) {
  343. if (error) {
  344. return callback(error)
  345. }
  346. // Check for doc-level access next
  347. AuthorizationManager.assertClientCanViewProjectAndDoc(
  348. client,
  349. docId,
  350. function (error) {
  351. if (error) {
  352. // No cached access, check docupdater
  353. const { project_id: projectId } = client.ol_context
  354. DocumentUpdaterManager.checkDocument(
  355. projectId,
  356. docId,
  357. function (error) {
  358. if (error) {
  359. return callback(error)
  360. } else {
  361. // Success
  362. AuthorizationManager.addAccessToDoc(client, docId, callback)
  363. }
  364. }
  365. )
  366. } else {
  367. // Access already cached
  368. callback()
  369. }
  370. }
  371. )
  372. })
  373. },
  374. leaveDoc(client, docId, callback) {
  375. // client may have disconnected, but we have to cleanup internal state.
  376. client.joinLeaveEpoch++
  377. metrics.inc('editor.leave-doc', 1, { status: client.transport })
  378. const { project_id: projectId, user_id: userId } = client.ol_context
  379. logger.debug(
  380. { userId, projectId, docId, clientId: client.id },
  381. 'client leaving doc'
  382. )
  383. RoomManager.leaveDoc(client, docId)
  384. // we could remove permission when user leaves a doc, but because
  385. // the connection is per-project, we continue to allow access
  386. // after the initial joinDoc since we know they are already authorised.
  387. // # AuthorizationManager.removeAccessToDoc client, doc_id
  388. callback()
  389. },
  390. updateClientPosition(client, cursorData, callback) {
  391. if (client.disconnected) {
  392. // do not create a ghost entry in redis
  393. return callback()
  394. }
  395. metrics.inc('editor.update-client-position', 0.1, {
  396. status: client.transport,
  397. })
  398. const {
  399. project_id: projectId,
  400. first_name: firstName,
  401. last_name: lastName,
  402. email,
  403. user_id: userId,
  404. } = client.ol_context
  405. logger.debug(
  406. { userId, projectId, clientId: client.id, cursorData },
  407. 'updating client position'
  408. )
  409. AuthorizationManager.assertClientCanViewProjectAndDoc(
  410. client,
  411. cursorData.doc_id,
  412. function (error) {
  413. if (error) {
  414. logger.debug(
  415. { err: error, clientId: client.id, projectId, userId },
  416. "silently ignoring unauthorized updateClientPosition. Client likely hasn't called joinProject yet."
  417. )
  418. return callback()
  419. }
  420. cursorData.id = client.publicId
  421. if (userId) {
  422. cursorData.user_id = userId
  423. }
  424. if (email) {
  425. cursorData.email = email
  426. }
  427. // Don't store anonymous users in redis to avoid influx
  428. if (!userId || userId === 'anonymous-user') {
  429. cursorData.name = ''
  430. // consistent async behaviour
  431. setTimeout(callback)
  432. } else {
  433. cursorData.name =
  434. firstName && lastName
  435. ? `${firstName} ${lastName}`
  436. : firstName || lastName || ''
  437. ConnectedUsersManager.updateUserPosition(
  438. projectId,
  439. client.publicId,
  440. {
  441. first_name: firstName,
  442. last_name: lastName,
  443. email,
  444. _id: userId,
  445. },
  446. {
  447. row: cursorData.row,
  448. column: cursorData.column,
  449. doc_id: cursorData.doc_id,
  450. },
  451. callback
  452. )
  453. }
  454. WebsocketLoadBalancer.emitToRoom(
  455. projectId,
  456. 'clientTracking.clientUpdated',
  457. cursorData
  458. )
  459. }
  460. )
  461. },
  462. CLIENT_REFRESH_DELAY: 1000,
  463. getConnectedUsers(client, callback) {
  464. if (client.disconnected) {
  465. // they are not interested anymore, skip the redis lookups
  466. return callback()
  467. }
  468. metrics.inc('editor.get-connected-users', { status: client.transport })
  469. const {
  470. project_id: projectId,
  471. user_id: userId,
  472. is_restricted_user: isRestrictedUser,
  473. } = client.ol_context
  474. if (isRestrictedUser) {
  475. return callback(null, [])
  476. }
  477. if (!projectId) {
  478. return callback(new NotJoinedError())
  479. }
  480. logger.debug(
  481. { userId, projectId, clientId: client.id },
  482. 'getting connected users'
  483. )
  484. AuthorizationManager.assertClientCanViewProject(client, function (error) {
  485. if (error) {
  486. return callback(error)
  487. }
  488. WebsocketLoadBalancer.emitToRoom(projectId, 'clientTracking.refresh')
  489. setTimeout(
  490. () =>
  491. ConnectedUsersManager.getConnectedUsers(
  492. projectId,
  493. function (error, users) {
  494. if (error) {
  495. return callback(error)
  496. }
  497. logger.debug(
  498. { userId, projectId, clientId: client.id },
  499. 'got connected users'
  500. )
  501. callback(null, users)
  502. }
  503. ),
  504. WebsocketController.CLIENT_REFRESH_DELAY
  505. )
  506. })
  507. },
  508. applyOtUpdate(client, docId, update, callback) {
  509. // client may have disconnected, but we can submit their update to doc-updater anyways.
  510. const { user_id: userId, project_id: projectId } = client.ol_context
  511. if (!projectId) {
  512. return callback(new NotJoinedError())
  513. }
  514. WebsocketController._assertClientCanApplyUpdate(
  515. client,
  516. docId,
  517. update,
  518. function (error) {
  519. if (error) {
  520. setTimeout(
  521. () =>
  522. // Disconnect, but give the client the chance to receive the error
  523. client.disconnect(),
  524. 100
  525. )
  526. return callback(error)
  527. }
  528. if (!update.meta) {
  529. update.meta = {}
  530. }
  531. update.meta.source = client.publicId
  532. update.meta.user_id = userId
  533. update.meta.tsRT = performance.now()
  534. metrics.inc('editor.doc-update', 0.3, { status: client.transport })
  535. logger.debug(
  536. {
  537. userId,
  538. docId,
  539. projectId,
  540. clientId: client.id,
  541. version: update.v,
  542. },
  543. 'sending update to doc updater'
  544. )
  545. DocumentUpdaterManager.queueChange(
  546. projectId,
  547. docId,
  548. update,
  549. function (error) {
  550. if ((error && error.message) === 'update is too large') {
  551. metrics.inc('update_too_large')
  552. const { updateSize } = error.info
  553. logger.warn(
  554. { userId, projectId, docId, updateSize },
  555. 'update is too large'
  556. )
  557. // mark the update as received -- the client should not send it again!
  558. callback()
  559. // trigger an out-of-sync error
  560. const message = {
  561. project_id: projectId,
  562. doc_id: docId,
  563. error: 'update is too large',
  564. }
  565. setTimeout(function () {
  566. if (client.disconnected) {
  567. // skip the message broadcast, the client has moved on
  568. return metrics.inc('editor.doc-update.disconnected', 1, {
  569. status: 'at-otUpdateError',
  570. })
  571. }
  572. client.emit('otUpdateError', message.error, message)
  573. client.disconnect()
  574. }, 100)
  575. return
  576. }
  577. if (error) {
  578. OError.tag(error, 'document was not available for update', {
  579. version: update.v,
  580. })
  581. client.disconnect()
  582. }
  583. callback(error)
  584. }
  585. )
  586. }
  587. )
  588. },
  589. _assertClientCanApplyUpdate(client, docId, update, callback) {
  590. AuthorizationManager.assertClientCanEditProjectAndDoc(
  591. client,
  592. docId,
  593. function (error) {
  594. if (
  595. error &&
  596. error.message === 'not authorized' &&
  597. WebsocketController._isCommentUpdate(update)
  598. ) {
  599. // This might be a comment op, which we only need read-only priveleges for
  600. AuthorizationManager.assertClientCanViewProjectAndDoc(
  601. client,
  602. docId,
  603. callback
  604. )
  605. return
  606. }
  607. callback(error)
  608. }
  609. )
  610. },
  611. _isCommentUpdate(update) {
  612. if (!(update && update.op instanceof Array)) {
  613. return false
  614. }
  615. for (const op of update.op) {
  616. if (!op.c) {
  617. return false
  618. }
  619. }
  620. return true
  621. },
  622. }