WebsocketController.js 18 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', 1, { status: client.transport })
  37. WebApiManager.joinProject(
  38. project_id,
  39. user,
  40. function (error, project, privilegeLevel, isRestrictedUser) {
  41. if (error) {
  42. return callback(error)
  43. }
  44. if (client.disconnected) {
  45. metrics.inc('editor.join-project.disconnected', 1, {
  46. status: 'after-web-api-call',
  47. })
  48. return callback()
  49. }
  50. if (!privilegeLevel) {
  51. return callback(new NotAuthorizedError())
  52. }
  53. client.ol_context = {}
  54. client.ol_context.privilege_level = privilegeLevel
  55. client.ol_context.user_id = user_id
  56. client.ol_context.project_id = project_id
  57. client.ol_context.owner_id = project.owner && project.owner._id
  58. client.ol_context.first_name = user.first_name
  59. client.ol_context.last_name = user.last_name
  60. client.ol_context.email = user.email
  61. client.ol_context.connected_time = new Date()
  62. client.ol_context.signup_date = user.signUpDate
  63. client.ol_context.login_count = user.loginCount
  64. client.ol_context.is_restricted_user = !!isRestrictedUser
  65. RoomManager.joinProject(client, project_id, function (err) {
  66. if (err) {
  67. return callback(err)
  68. }
  69. logger.log(
  70. { user_id, project_id, client_id: client.id },
  71. 'user joined project'
  72. )
  73. callback(
  74. null,
  75. project,
  76. privilegeLevel,
  77. WebsocketController.PROTOCOL_VERSION
  78. )
  79. })
  80. // No need to block for setting the user as connected in the cursor tracking
  81. ConnectedUsersManager.updateUserPosition(
  82. project_id,
  83. client.publicId,
  84. user,
  85. null,
  86. function (err) {
  87. if (err) {
  88. logger.warn(
  89. { err, project_id, user_id, client_id: client.id },
  90. 'background cursor update failed'
  91. )
  92. }
  93. }
  94. )
  95. }
  96. )
  97. },
  98. // We want to flush a project if there are no more (local) connected clients
  99. // but we need to wait for the triggering client to disconnect. How long we wait
  100. // is determined by FLUSH_IF_EMPTY_DELAY.
  101. FLUSH_IF_EMPTY_DELAY: 500, // ms
  102. leaveProject(io, client, callback) {
  103. const { project_id, user_id } = client.ol_context
  104. if (!project_id) {
  105. return callback()
  106. } // client did not join project
  107. metrics.inc('editor.leave-project', 1, { status: client.transport })
  108. logger.log(
  109. { project_id, user_id, client_id: client.id },
  110. 'client leaving project'
  111. )
  112. WebsocketLoadBalancer.emitToRoom(
  113. project_id,
  114. 'clientTracking.clientDisconnected',
  115. client.publicId
  116. )
  117. // We can do this in the background
  118. ConnectedUsersManager.markUserAsDisconnected(
  119. project_id,
  120. client.publicId,
  121. function (err) {
  122. if (err) {
  123. logger.error(
  124. { err, project_id, user_id, client_id: client.id },
  125. 'error marking client as disconnected'
  126. )
  127. }
  128. }
  129. )
  130. RoomManager.leaveProjectAndDocs(client)
  131. setTimeout(function () {
  132. const remainingClients = io.sockets.clients(project_id)
  133. if (remainingClients.length === 0) {
  134. // Flush project in the background
  135. DocumentUpdaterManager.flushProjectToMongoAndDelete(
  136. project_id,
  137. function (err) {
  138. if (err) {
  139. logger.error(
  140. { err, project_id, user_id, client_id: client.id },
  141. 'error flushing to doc updater after leaving project'
  142. )
  143. }
  144. }
  145. )
  146. }
  147. callback()
  148. }, WebsocketController.FLUSH_IF_EMPTY_DELAY)
  149. },
  150. joinDoc(client, doc_id, fromVersion, options, callback) {
  151. if (client.disconnected) {
  152. metrics.inc('editor.join-doc.disconnected', 1, { status: 'immediately' })
  153. return callback()
  154. }
  155. const joinLeaveEpoch = ++client.joinLeaveEpoch
  156. metrics.inc('editor.join-doc', 1, { status: client.transport })
  157. const { project_id, user_id, is_restricted_user } = client.ol_context
  158. if (!project_id) {
  159. return callback(new NotJoinedError())
  160. }
  161. logger.log(
  162. { user_id, project_id, doc_id, fromVersion, client_id: client.id },
  163. 'client joining doc'
  164. )
  165. WebsocketController._assertClientAuthorization(
  166. client,
  167. doc_id,
  168. function (error) {
  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. },
  268. _assertClientAuthorization(client, doc_id, callback) {
  269. // Check for project-level access first
  270. AuthorizationManager.assertClientCanViewProject(client, function (error) {
  271. if (error) {
  272. return callback(error)
  273. }
  274. // Check for doc-level access next
  275. AuthorizationManager.assertClientCanViewProjectAndDoc(
  276. client,
  277. doc_id,
  278. function (error) {
  279. if (error) {
  280. // No cached access, check docupdater
  281. const { project_id } = client.ol_context
  282. DocumentUpdaterManager.checkDocument(
  283. project_id,
  284. doc_id,
  285. function (error) {
  286. if (error) {
  287. return callback(error)
  288. } else {
  289. // Success
  290. AuthorizationManager.addAccessToDoc(client, doc_id, callback)
  291. }
  292. }
  293. )
  294. } else {
  295. // Access already cached
  296. callback()
  297. }
  298. }
  299. )
  300. })
  301. },
  302. leaveDoc(client, doc_id, callback) {
  303. // client may have disconnected, but we have to cleanup internal state.
  304. client.joinLeaveEpoch++
  305. metrics.inc('editor.leave-doc', 1, { status: client.transport })
  306. const { project_id, user_id } = client.ol_context
  307. logger.log(
  308. { user_id, project_id, doc_id, client_id: client.id },
  309. 'client leaving doc'
  310. )
  311. RoomManager.leaveDoc(client, doc_id)
  312. // we could remove permission when user leaves a doc, but because
  313. // the connection is per-project, we continue to allow access
  314. // after the initial joinDoc since we know they are already authorised.
  315. // # AuthorizationManager.removeAccessToDoc client, doc_id
  316. callback()
  317. },
  318. updateClientPosition(client, cursorData, callback) {
  319. if (client.disconnected) {
  320. // do not create a ghost entry in redis
  321. return callback()
  322. }
  323. metrics.inc('editor.update-client-position', 0.1, {
  324. status: client.transport,
  325. })
  326. const { project_id, first_name, last_name, email, 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', { status: client.transport })
  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(
  411. project_id,
  412. function (error, users) {
  413. if (error) {
  414. return callback(error)
  415. }
  416. logger.log(
  417. { user_id, project_id, client_id: client.id },
  418. 'got connected users'
  419. )
  420. callback(null, users)
  421. }
  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, { status: client.transport })
  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. }