WebsocketController.js 18 KB

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