WebsocketController.js 17 KB

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