WebsocketController.js 18 KB

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