WebsocketController.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  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', 1, { status: client.transport })
  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', 1, { status: client.transport })
  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', 1, { status: client.transport })
  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', 1, { status: client.transport })
  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. status: client.transport
  322. })
  323. const {
  324. project_id,
  325. first_name,
  326. last_name,
  327. email,
  328. user_id
  329. } = client.ol_context
  330. logger.log(
  331. { user_id, project_id, client_id: client.id, cursorData },
  332. 'updating client position'
  333. )
  334. AuthorizationManager.assertClientCanViewProjectAndDoc(
  335. client,
  336. cursorData.doc_id,
  337. function (error) {
  338. if (error) {
  339. logger.info(
  340. { err: error, client_id: client.id, project_id, user_id },
  341. "silently ignoring unauthorized updateClientPosition. Client likely hasn't called joinProject yet."
  342. )
  343. return callback()
  344. }
  345. cursorData.id = client.publicId
  346. if (user_id) {
  347. cursorData.user_id = user_id
  348. }
  349. if (email) {
  350. cursorData.email = email
  351. }
  352. // Don't store anonymous users in redis to avoid influx
  353. if (!user_id || user_id === 'anonymous-user') {
  354. cursorData.name = ''
  355. // consistent async behaviour
  356. setTimeout(callback)
  357. } else {
  358. cursorData.name =
  359. first_name && last_name
  360. ? `${first_name} ${last_name}`
  361. : first_name || last_name || ''
  362. ConnectedUsersManager.updateUserPosition(
  363. project_id,
  364. client.publicId,
  365. {
  366. first_name,
  367. last_name,
  368. email,
  369. _id: user_id
  370. },
  371. {
  372. row: cursorData.row,
  373. column: cursorData.column,
  374. doc_id: cursorData.doc_id
  375. },
  376. callback
  377. )
  378. }
  379. WebsocketLoadBalancer.emitToRoom(
  380. project_id,
  381. 'clientTracking.clientUpdated',
  382. cursorData
  383. )
  384. }
  385. )
  386. },
  387. CLIENT_REFRESH_DELAY: 1000,
  388. getConnectedUsers(client, callback) {
  389. if (client.disconnected) {
  390. // they are not interested anymore, skip the redis lookups
  391. return callback()
  392. }
  393. metrics.inc('editor.get-connected-users', { status: client.transport })
  394. const { project_id, user_id, is_restricted_user } = client.ol_context
  395. if (is_restricted_user) {
  396. return callback(null, [])
  397. }
  398. if (!project_id) {
  399. return callback(new NotJoinedError())
  400. }
  401. logger.log(
  402. { user_id, project_id, client_id: client.id },
  403. 'getting connected users'
  404. )
  405. AuthorizationManager.assertClientCanViewProject(client, function (error) {
  406. if (error) {
  407. return callback(error)
  408. }
  409. WebsocketLoadBalancer.emitToRoom(project_id, 'clientTracking.refresh')
  410. setTimeout(
  411. () =>
  412. ConnectedUsersManager.getConnectedUsers(project_id, function (
  413. error,
  414. users
  415. ) {
  416. if (error) {
  417. return callback(error)
  418. }
  419. logger.log(
  420. { user_id, project_id, client_id: client.id },
  421. 'got connected users'
  422. )
  423. callback(null, users)
  424. }),
  425. WebsocketController.CLIENT_REFRESH_DELAY
  426. )
  427. })
  428. },
  429. applyOtUpdate(client, doc_id, update, callback) {
  430. // client may have disconnected, but we can submit their update to doc-updater anyways.
  431. const { user_id, project_id } = client.ol_context
  432. if (!project_id) {
  433. return callback(new NotJoinedError())
  434. }
  435. WebsocketController._assertClientCanApplyUpdate(
  436. client,
  437. doc_id,
  438. update,
  439. function (error) {
  440. if (error) {
  441. setTimeout(
  442. () =>
  443. // Disconnect, but give the client the chance to receive the error
  444. client.disconnect(),
  445. 100
  446. )
  447. return callback(error)
  448. }
  449. if (!update.meta) {
  450. update.meta = {}
  451. }
  452. update.meta.source = client.publicId
  453. update.meta.user_id = user_id
  454. metrics.inc('editor.doc-update', 0.3, { status: client.transport })
  455. logger.log(
  456. {
  457. user_id,
  458. doc_id,
  459. project_id,
  460. client_id: client.id,
  461. version: update.v
  462. },
  463. 'sending update to doc updater'
  464. )
  465. DocumentUpdaterManager.queueChange(
  466. project_id,
  467. doc_id,
  468. update,
  469. function (error) {
  470. if ((error && error.message) === 'update is too large') {
  471. metrics.inc('update_too_large')
  472. const { updateSize } = error.info
  473. logger.warn(
  474. { user_id, project_id, doc_id, updateSize },
  475. 'update is too large'
  476. )
  477. // mark the update as received -- the client should not send it again!
  478. callback()
  479. // trigger an out-of-sync error
  480. const message = {
  481. project_id,
  482. doc_id,
  483. error: 'update is too large'
  484. }
  485. setTimeout(function () {
  486. if (client.disconnected) {
  487. // skip the message broadcast, the client has moved on
  488. return metrics.inc('editor.doc-update.disconnected', 1, {
  489. status: 'at-otUpdateError'
  490. })
  491. }
  492. client.emit('otUpdateError', message.error, message)
  493. client.disconnect()
  494. }, 100)
  495. return
  496. }
  497. if (error) {
  498. OError.tag(error, 'document was not available for update', {
  499. version: update.v
  500. })
  501. client.disconnect()
  502. }
  503. callback(error)
  504. }
  505. )
  506. }
  507. )
  508. },
  509. _assertClientCanApplyUpdate(client, doc_id, update, callback) {
  510. AuthorizationManager.assertClientCanEditProjectAndDoc(
  511. client,
  512. doc_id,
  513. function (error) {
  514. if (
  515. error &&
  516. error.message === 'not authorized' &&
  517. WebsocketController._isCommentUpdate(update)
  518. ) {
  519. // This might be a comment op, which we only need read-only priveleges for
  520. AuthorizationManager.assertClientCanViewProjectAndDoc(
  521. client,
  522. doc_id,
  523. callback
  524. )
  525. return
  526. }
  527. callback(error)
  528. }
  529. )
  530. },
  531. _isCommentUpdate(update) {
  532. if (!(update && update.op instanceof Array)) {
  533. return false
  534. }
  535. for (const op of update.op) {
  536. if (!op.c) {
  537. return false
  538. }
  539. }
  540. return true
  541. }
  542. }