WebsocketController.js 18 KB

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