WebsocketController.js 21 KB

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