WebsocketController.js 21 KB

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