WebsocketController.js 18 KB

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