WebsocketController.js 18 KB

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