WebsocketController.coffee 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. logger = require "logger-sharelatex"
  2. metrics = require "metrics-sharelatex"
  3. WebApiManager = require "./WebApiManager"
  4. AuthorizationManager = require "./AuthorizationManager"
  5. DocumentUpdaterManager = require "./DocumentUpdaterManager"
  6. ConnectedUsersManager = require "./ConnectedUsersManager"
  7. WebsocketLoadBalancer = require "./WebsocketLoadBalancer"
  8. Utils = require "./Utils"
  9. module.exports = WebsocketController =
  10. # If the protocol version changes when the client reconnects,
  11. # it will force a full refresh of the page. Useful for non-backwards
  12. # compatible protocol changes. Use only in extreme need.
  13. PROTOCOL_VERSION: 2
  14. joinProject: (client, user, project_id, callback = (error, project, privilegeLevel, protocolVersion) ->) ->
  15. user_id = user?._id
  16. logger.log {user_id, project_id, client_id: client.id}, "user joining project"
  17. metrics.inc "editor.join-project"
  18. WebApiManager.joinProject project_id, user_id, (error, project, privilegeLevel) ->
  19. return callback(error) if error?
  20. if !privilegeLevel or privilegeLevel == ""
  21. err = new Error("not authorized")
  22. logger.error {err, project_id, user_id, client_id: client.id}, "user is not authorized to join project"
  23. return callback(err)
  24. client.join project_id
  25. client.set("privilege_level", privilegeLevel)
  26. client.set("user_id", user_id)
  27. client.set("project_id", project_id)
  28. client.set("owner_id", project?.owner?._id)
  29. client.set("first_name", user?.first_name)
  30. client.set("last_name", user?.last_name)
  31. client.set("email", user?.email)
  32. client.set("connected_time", new Date())
  33. client.set("signup_date", user?.signUpDate)
  34. client.set("login_count", user?.loginCount)
  35. callback null, project, privilegeLevel, WebsocketController.PROTOCOL_VERSION
  36. logger.log {user_id, project_id, client_id: client.id}, "user joined project"
  37. # No need to block for setting the user as connected in the cursor tracking
  38. ConnectedUsersManager.updateUserPosition project_id, client.id, user, null, () ->
  39. # We want to flush a project if there are no more (local) connected clients
  40. # but we need to wait for the triggering client to disconnect. How long we wait
  41. # is determined by FLUSH_IF_EMPTY_DELAY.
  42. FLUSH_IF_EMPTY_DELAY: 500 #ms
  43. leaveProject: (io, client, callback = (error) ->) ->
  44. metrics.inc "editor.leave-project"
  45. Utils.getClientAttributes client, ["project_id", "user_id"], (error, {project_id, user_id}) ->
  46. return callback(error) if error?
  47. logger.log {project_id, user_id, client_id: client.id}, "client leaving project"
  48. WebsocketLoadBalancer.emitToRoom project_id, "clientTracking.clientDisconnected", client.id
  49. # We can do this in the background
  50. ConnectedUsersManager.markUserAsDisconnected project_id, client.id, (err) ->
  51. if err?
  52. logger.error {err, project_id, user_id, client_id: client.id}, "error marking client as disconnected"
  53. setTimeout () ->
  54. remainingClients = io.sockets.clients(project_id)
  55. if remainingClients.length == 0
  56. # Flush project in the background
  57. DocumentUpdaterManager.flushProjectToMongoAndDelete project_id, (err) ->
  58. if err?
  59. logger.error {err, project_id, user_id, client_id: client.id}, "error flushing to doc updater after leaving project"
  60. callback()
  61. , WebsocketController.FLUSH_IF_EMPTY_DELAY
  62. joinDoc: (client, doc_id, fromVersion = -1, callback = (error, doclines, version, ops) ->) ->
  63. metrics.inc "editor.join-doc"
  64. Utils.getClientAttributes client, ["project_id", "user_id"], (error, {project_id, user_id}) ->
  65. return callback(error) if error?
  66. return callback(new Error("no project_id found on client")) if !project_id?
  67. logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joining doc"
  68. AuthorizationManager.assertClientCanViewProject client, (error) ->
  69. return callback(error) if error?
  70. DocumentUpdaterManager.getDocument project_id, doc_id, fromVersion, (error, lines, version, ops) ->
  71. return callback(error) if error?
  72. # Encode any binary bits of data so it can go via WebSockets
  73. # See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
  74. escapedLines = []
  75. for line in lines
  76. try
  77. line = unescape(encodeURIComponent(line))
  78. catch err
  79. logger.err {err, project_id, doc_id, fromVersion, line, client_id: client.id}, "error encoding line uri component"
  80. return callback(err)
  81. escapedLines.push line
  82. AuthorizationManager.addAccessToDoc client, doc_id
  83. client.join(doc_id)
  84. callback null, escapedLines, version, ops
  85. logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joined doc"
  86. leaveDoc: (client, doc_id, callback = (error) ->) ->
  87. metrics.inc "editor.leave-doc"
  88. Utils.getClientAttributes client, ["project_id", "user_id"], (error, {project_id, user_id}) ->
  89. logger.log {user_id, project_id, doc_id, client_id: client.id}, "client leaving doc"
  90. client.leave doc_id
  91. # we could remove permission when user leaves a doc, but because
  92. # the connection is per-project, we continue to allow access
  93. # after the initial joinDoc since we know they are already authorised.
  94. ## AuthorizationManager.removeAccessToDoc client, doc_id
  95. callback()
  96. updateClientPosition: (client, cursorData, callback = (error) ->) ->
  97. metrics.inc "editor.update-client-position", 0.1
  98. Utils.getClientAttributes client, [
  99. "project_id", "first_name", "last_name", "email", "user_id"
  100. ], (error, {project_id, first_name, last_name, email, user_id}) ->
  101. return callback(error) if error?
  102. logger.log {user_id, project_id, client_id: client.id, cursorData: cursorData}, "updating client position"
  103. AuthorizationManager.assertClientCanViewProjectAndDoc client, cursorData.doc_id, (error) ->
  104. if error?
  105. logger.warn {client_id: client.id, project_id, user_id}, "silently ignoring unauthorized updateClientPosition. Client likely hasn't called joinProject yet."
  106. return callback()
  107. cursorData.id = client.id
  108. cursorData.user_id = user_id if user_id?
  109. cursorData.email = email if email?
  110. if first_name? and last_name?
  111. cursorData.name = first_name + " " + last_name
  112. ConnectedUsersManager.updateUserPosition(project_id, client.id, {
  113. first_name: first_name,
  114. last_name: last_name,
  115. email: email,
  116. _id: user_id
  117. }, {
  118. row: cursorData.row,
  119. column: cursorData.column,
  120. doc_id: cursorData.doc_id
  121. }, callback)
  122. else
  123. cursorData.name = "Anonymous"
  124. callback()
  125. WebsocketLoadBalancer.emitToRoom(project_id, "clientTracking.clientUpdated", cursorData)
  126. getConnectedUsers: (client, callback = (error, users) ->) ->
  127. metrics.inc "editor.get-connected-users"
  128. Utils.getClientAttributes client, ["project_id", "user_id"], (error, {project_id, user_id}) ->
  129. return callback(error) if error?
  130. return callback(new Error("no project_id found on client")) if !project_id?
  131. logger.log {user_id, project_id, client_id: client.id}, "getting connected users"
  132. AuthorizationManager.assertClientCanViewProject client, (error) ->
  133. return callback(error) if error?
  134. ConnectedUsersManager.getConnectedUsers project_id, (error, users) ->
  135. return callback(error) if error?
  136. callback null, users
  137. logger.log {user_id, project_id, client_id: client.id}, "got connected users"
  138. applyOtUpdate: (client, doc_id, update, callback = (error) ->) ->
  139. cbc_0 = 0 # Callback counter
  140. cbc_1 = 0
  141. Utils.getClientAttributes client, ["user_id", "project_id"], (error, {user_id, project_id}) ->
  142. cbc_0++
  143. return callback(error) if error?
  144. return callback(new Error("no project_id found on client")) if !project_id?
  145. # Omit this logging for now since it's likely too noisey
  146. #logger.log {user_id, project_id, doc_id, client_id: client.id, update: update}, "applying update"
  147. AuthorizationManager.assertClientCanEditProjectAndDoc client, doc_id, (error) ->
  148. cbc_1++
  149. if error?
  150. logger.error {err: error, doc_id, client_id: client.id, version: update.v}, "client is not authorized to make update"
  151. setTimeout () ->
  152. # Disconnect, but give the client the chance to receive the error
  153. client.disconnect()
  154. , 100
  155. return callback(error)
  156. update.meta ||= {}
  157. update.meta.source = client.id
  158. update.meta.user_id = user_id
  159. metrics.inc "editor.doc-update", 0.3
  160. metrics.set "editor.active-projects", project_id, 0.3
  161. metrics.set "editor.active-users", user_id, 0.3
  162. logger.log {user_id, doc_id, project_id, client_id: client.id, version: update.v, cbc_0, cbc_1}, "sending update to doc updater"
  163. DocumentUpdaterManager.queueChange project_id, doc_id, update, (error) ->
  164. if error?
  165. logger.error {err: error, project_id, doc_id, client_id: client.id, version: update.v}, "document was not available for update"
  166. client.disconnect()
  167. callback(error)
  168. #logger.log {user_id, project_id, doc_id, client_id: client.id}, "applied update"