WebsocketController.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  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, result) {
  271. if (error) {
  272. if (error instanceof ClientRequestedMissingOpsError) {
  273. emitJoinDocCatchUpMetrics('missing', error.info)
  274. }
  275. return callback(error)
  276. }
  277. const { lines, version, ranges, ops, ttlInS, type } = result
  278. emitJoinDocCatchUpMetrics('success', { version, ttlInS })
  279. if (client.disconnected) {
  280. metrics.inc('editor.join-doc.disconnected', 1, {
  281. status: 'after-doc-updater-call',
  282. })
  283. // the client will not read the response anyways
  284. return callback()
  285. }
  286. if (isRestrictedUser && ranges && ranges.comments) {
  287. ranges.comments = []
  288. }
  289. // Encode any binary bits of data so it can go via WebSockets
  290. // See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
  291. const encodeForWebsockets = text =>
  292. unescape(encodeURIComponent(text))
  293. metrics.inc('client_supports_history_v1_ot', 1, {
  294. status: options.supportsHistoryOT ? 'success' : 'failure',
  295. })
  296. let escapedLines
  297. if (type === 'history-ot') {
  298. if (!options.supportsHistoryOT) {
  299. RoomManager.leaveDoc(client, docId)
  300. // TODO(24596): ask the user to reload the editor page (via out-of-sync modal when there are pending ops).
  301. return callback(
  302. new CodedError('client does not support history-ot')
  303. )
  304. }
  305. escapedLines = lines
  306. } else {
  307. escapedLines = []
  308. for (let line of lines) {
  309. try {
  310. line = encodeForWebsockets(line)
  311. } catch (err) {
  312. OError.tag(err, 'error encoding line uri component', {
  313. line,
  314. })
  315. return callback(err)
  316. }
  317. escapedLines.push(line)
  318. }
  319. if (options.encodeRanges) {
  320. try {
  321. for (const comment of (ranges && ranges.comments) || []) {
  322. if (comment.op.c) {
  323. comment.op.c = encodeForWebsockets(comment.op.c)
  324. }
  325. }
  326. for (const change of (ranges && ranges.changes) || []) {
  327. if (change.op.i) {
  328. change.op.i = encodeForWebsockets(change.op.i)
  329. }
  330. if (change.op.d) {
  331. change.op.d = encodeForWebsockets(change.op.d)
  332. }
  333. }
  334. } catch (err) {
  335. OError.tag(err, 'error encoding range uri component', {
  336. ranges,
  337. })
  338. return callback(err)
  339. }
  340. }
  341. }
  342. AuthorizationManager.addAccessToDoc(client, docId, () => {})
  343. logger.debug(
  344. {
  345. userId,
  346. projectId,
  347. docId,
  348. fromVersion,
  349. clientId: client.id,
  350. },
  351. 'client joined doc'
  352. )
  353. callback(null, escapedLines, version, ops, ranges, type)
  354. }
  355. )
  356. })
  357. }
  358. )
  359. },
  360. _assertClientAuthorization(client, docId, callback) {
  361. // Check for project-level access first
  362. AuthorizationManager.assertClientCanViewProject(client, function (error) {
  363. if (error) {
  364. return callback(error)
  365. }
  366. // Check for doc-level access next
  367. AuthorizationManager.assertClientCanViewProjectAndDoc(
  368. client,
  369. docId,
  370. function (error) {
  371. if (error) {
  372. // No cached access, check docupdater
  373. const { project_id: projectId } = client.ol_context
  374. DocumentUpdaterManager.checkDocument(
  375. projectId,
  376. docId,
  377. function (error) {
  378. if (error) {
  379. return callback(error)
  380. } else {
  381. // Success
  382. AuthorizationManager.addAccessToDoc(client, docId, callback)
  383. }
  384. }
  385. )
  386. } else {
  387. // Access already cached
  388. callback()
  389. }
  390. }
  391. )
  392. })
  393. },
  394. leaveDoc(client, docId, callback) {
  395. // client may have disconnected, but we have to cleanup internal state.
  396. client.joinLeaveEpoch++
  397. metrics.inc('editor.leave-doc', 1, { status: client.transport })
  398. const { project_id: projectId, user_id: userId } = client.ol_context
  399. logger.debug(
  400. { userId, projectId, docId, clientId: client.id },
  401. 'client leaving doc'
  402. )
  403. RoomManager.leaveDoc(client, docId)
  404. // we could remove permission when user leaves a doc, but because
  405. // the connection is per-project, we continue to allow access
  406. // after the initial joinDoc since we know they are already authorised.
  407. // # AuthorizationManager.removeAccessToDoc client, doc_id
  408. callback()
  409. },
  410. updateClientPosition(client, cursorData, callback) {
  411. if (client.disconnected) {
  412. // do not create a ghost entry in redis
  413. return callback()
  414. }
  415. metrics.inc('editor.update-client-position', 0.1, {
  416. status: client.transport,
  417. })
  418. const {
  419. project_id: projectId,
  420. first_name: firstName,
  421. last_name: lastName,
  422. email,
  423. user_id: userId,
  424. } = client.ol_context
  425. logger.debug(
  426. { userId, projectId, clientId: client.id, cursorData },
  427. 'updating client position'
  428. )
  429. AuthorizationManager.assertClientCanViewProjectAndDoc(
  430. client,
  431. cursorData.doc_id,
  432. function (error) {
  433. if (error) {
  434. logger.debug(
  435. { err: error, clientId: client.id, projectId, userId },
  436. "silently ignoring unauthorized updateClientPosition. Client likely hasn't called joinProject yet."
  437. )
  438. return callback()
  439. }
  440. cursorData.id = client.publicId
  441. if (userId) {
  442. cursorData.user_id = userId
  443. }
  444. if (email) {
  445. cursorData.email = email
  446. }
  447. // Don't store anonymous users in redis to avoid influx
  448. if (!userId || userId === 'anonymous-user') {
  449. cursorData.name = ''
  450. // consistent async behaviour
  451. setTimeout(callback)
  452. } else {
  453. cursorData.name =
  454. firstName && lastName
  455. ? `${firstName} ${lastName}`
  456. : firstName || lastName || ''
  457. ConnectedUsersManager.updateUserPosition(
  458. projectId,
  459. client.publicId,
  460. {
  461. first_name: firstName,
  462. last_name: lastName,
  463. email,
  464. _id: userId,
  465. },
  466. {
  467. row: cursorData.row,
  468. column: cursorData.column,
  469. doc_id: cursorData.doc_id,
  470. },
  471. callback
  472. )
  473. }
  474. WebsocketLoadBalancer.emitToRoom(
  475. projectId,
  476. 'clientTracking.clientUpdated',
  477. cursorData
  478. )
  479. }
  480. )
  481. },
  482. CLIENT_REFRESH_DELAY: 1000,
  483. getConnectedUsers(client, callback) {
  484. if (client.disconnected) {
  485. // they are not interested anymore, skip the redis lookups
  486. return callback()
  487. }
  488. metrics.inc('editor.get-connected-users', { status: client.transport })
  489. const {
  490. project_id: projectId,
  491. user_id: userId,
  492. is_restricted_user: isRestrictedUser,
  493. } = client.ol_context
  494. if (isRestrictedUser) {
  495. return callback(null, [])
  496. }
  497. if (!projectId) {
  498. return callback(new NotJoinedError())
  499. }
  500. logger.debug(
  501. { userId, projectId, clientId: client.id },
  502. 'getting connected users'
  503. )
  504. AuthorizationManager.assertClientCanViewProject(client, function (error) {
  505. if (error) {
  506. return callback(error)
  507. }
  508. WebsocketLoadBalancer.emitToRoom(projectId, 'clientTracking.refresh')
  509. setTimeout(
  510. () =>
  511. ConnectedUsersManager.getConnectedUsers(
  512. projectId,
  513. function (error, users) {
  514. if (error) {
  515. return callback(error)
  516. }
  517. logger.debug(
  518. { userId, projectId, clientId: client.id },
  519. 'got connected users'
  520. )
  521. callback(null, users)
  522. }
  523. ),
  524. WebsocketController.CLIENT_REFRESH_DELAY
  525. )
  526. })
  527. },
  528. applyOtUpdate(client, docId, update, callback) {
  529. // client may have disconnected, but we can submit their update to doc-updater anyways.
  530. const { user_id: userId, project_id: projectId } = client.ol_context
  531. if (!projectId) {
  532. return callback(new NotJoinedError())
  533. }
  534. WebsocketController._assertClientCanApplyUpdate(
  535. client,
  536. docId,
  537. update,
  538. function (error) {
  539. if (error) {
  540. setTimeout(
  541. () =>
  542. // Disconnect, but give the client the chance to receive the error
  543. client.disconnect(),
  544. 100
  545. )
  546. return callback(error)
  547. }
  548. if (update.doc && update.doc !== docId) {
  549. return callback(
  550. new Errors.CodedError(
  551. 'update.doc must be identical to docId parameter in applyOtUpdate(docId, update)'
  552. )
  553. )
  554. }
  555. update.doc = docId
  556. if (!update.meta) {
  557. update.meta = {}
  558. }
  559. update.meta.source = client.publicId
  560. update.meta.user_id = userId
  561. update.meta.tsRT = performance.now()
  562. metrics.inc('editor.doc-update', 0.3, { status: client.transport })
  563. logger.debug(
  564. {
  565. userId,
  566. docId,
  567. projectId,
  568. clientId: client.id,
  569. version: update.v,
  570. },
  571. 'sending update to doc updater'
  572. )
  573. DocumentUpdaterManager.queueChange(
  574. projectId,
  575. docId,
  576. update,
  577. function (error) {
  578. if ((error && error.message) === 'update is too large') {
  579. metrics.inc('update_too_large')
  580. const { updateSize } = error.info
  581. logger.warn(
  582. { userId, projectId, docId, updateSize },
  583. 'update is too large'
  584. )
  585. // mark the update as received -- the client should not send it again!
  586. callback()
  587. // trigger an out-of-sync error
  588. const message = {
  589. project_id: projectId,
  590. doc_id: docId,
  591. error: 'update is too large',
  592. }
  593. setTimeout(function () {
  594. if (client.disconnected) {
  595. // skip the message broadcast, the client has moved on
  596. return metrics.inc('editor.doc-update.disconnected', 1, {
  597. status: 'at-otUpdateError',
  598. })
  599. }
  600. client.emit('otUpdateError', message.error, message)
  601. client.disconnect()
  602. }, 100)
  603. return
  604. }
  605. if (error) {
  606. OError.tag(error, 'document was not available for update', {
  607. version: update.v,
  608. })
  609. client.disconnect()
  610. }
  611. callback(error)
  612. }
  613. )
  614. }
  615. )
  616. },
  617. _assertClientCanApplyUpdate(client, docId, update, callback) {
  618. if (WebsocketController._isCommentUpdate(update)) {
  619. return AuthorizationManager.assertClientCanViewProjectAndDoc(
  620. client,
  621. docId,
  622. callback
  623. )
  624. } else if (update.meta?.tc) {
  625. return AuthorizationManager.assertClientCanReviewProjectAndDoc(
  626. client,
  627. docId,
  628. callback
  629. )
  630. } else {
  631. return AuthorizationManager.assertClientCanEditProjectAndDoc(
  632. client,
  633. docId,
  634. callback
  635. )
  636. }
  637. },
  638. _isCommentUpdate(update) {
  639. if (!(update && update.op instanceof Array)) {
  640. return false
  641. }
  642. for (const op of update.op) {
  643. if (!op.c) {
  644. return false
  645. }
  646. }
  647. return true
  648. },
  649. }