WebsocketController.coffee 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. logger = require "logger-sharelatex"
  2. metrics = require "metrics-sharelatex"
  3. settings = require "settings-sharelatex"
  4. WebApiManager = require "./WebApiManager"
  5. AuthorizationManager = require "./AuthorizationManager"
  6. DocumentUpdaterManager = require "./DocumentUpdaterManager"
  7. ConnectedUsersManager = require "./ConnectedUsersManager"
  8. WebsocketLoadBalancer = require "./WebsocketLoadBalancer"
  9. RoomManager = require "./RoomManager"
  10. Utils = require "./Utils"
  11. module.exports = WebsocketController =
  12. # If the protocol version changes when the client reconnects,
  13. # it will force a full refresh of the page. Useful for non-backwards
  14. # compatible protocol changes. Use only in extreme need.
  15. PROTOCOL_VERSION: 2
  16. joinProject: (client, user, project_id, callback = (error, project, privilegeLevel, protocolVersion) ->) ->
  17. user_id = user?._id
  18. logger.log {user_id, project_id, client_id: client.id}, "user joining project"
  19. metrics.inc "editor.join-project"
  20. WebApiManager.joinProject project_id, user, (error, project, privilegeLevel, isRestrictedUser) ->
  21. return callback(error) if error?
  22. if !privilegeLevel or privilegeLevel == ""
  23. err = new Error("not authorized")
  24. logger.warn {err, project_id, user_id, client_id: client.id}, "user is not authorized to join project"
  25. return callback(err)
  26. client.set("privilege_level", privilegeLevel)
  27. client.set("user_id", user_id)
  28. client.set("project_id", project_id)
  29. client.set("owner_id", project?.owner?._id)
  30. client.set("first_name", user?.first_name)
  31. client.set("last_name", user?.last_name)
  32. client.set("email", user?.email)
  33. client.set("connected_time", new Date())
  34. client.set("signup_date", user?.signUpDate)
  35. client.set("login_count", user?.loginCount)
  36. client.set("is_restricted_user", !!(isRestrictedUser))
  37. RoomManager.joinProject client, project_id, (err) ->
  38. logger.log {user_id, project_id, client_id: client.id}, "user joined project"
  39. callback null, project, privilegeLevel, WebsocketController.PROTOCOL_VERSION
  40. # No need to block for setting the user as connected in the cursor tracking
  41. ConnectedUsersManager.updateUserPosition project_id, client.id, user, null, () ->
  42. # We want to flush a project if there are no more (local) connected clients
  43. # but we need to wait for the triggering client to disconnect. How long we wait
  44. # is determined by FLUSH_IF_EMPTY_DELAY.
  45. FLUSH_IF_EMPTY_DELAY: 500 #ms
  46. leaveProject: (io, client, callback = (error) ->) ->
  47. metrics.inc "editor.leave-project"
  48. Utils.getClientAttributes client, ["project_id", "user_id"], (error, {project_id, user_id}) ->
  49. return callback(error) if error?
  50. logger.log {project_id, user_id, client_id: client.id}, "client leaving project"
  51. WebsocketLoadBalancer.emitToRoom project_id, "clientTracking.clientDisconnected", client.id
  52. # bail out if the client had not managed to authenticate or join
  53. # the project. Prevents downstream errors in docupdater from
  54. # flushProjectToMongoAndDelete with null project_id.
  55. if not user_id?
  56. logger.log {client_id: client.id}, "client leaving, unknown user"
  57. return callback()
  58. if not project_id?
  59. logger.log {user_id: user_id, client_id: client.id}, "client leaving, not in project"
  60. return callback()
  61. # We can do this in the background
  62. ConnectedUsersManager.markUserAsDisconnected project_id, client.id, (err) ->
  63. if err?
  64. logger.error {err, project_id, user_id, client_id: client.id}, "error marking client as disconnected"
  65. RoomManager.leaveProjectAndDocs(client)
  66. setTimeout () ->
  67. remainingClients = io.sockets.clients(project_id)
  68. if remainingClients.length == 0
  69. # Flush project in the background
  70. DocumentUpdaterManager.flushProjectToMongoAndDelete project_id, (err) ->
  71. if err?
  72. logger.error {err, project_id, user_id, client_id: client.id}, "error flushing to doc updater after leaving project"
  73. callback()
  74. , WebsocketController.FLUSH_IF_EMPTY_DELAY
  75. joinDoc: (client, doc_id, fromVersion = -1, options, callback = (error, doclines, version, ops, ranges) ->) ->
  76. metrics.inc "editor.join-doc"
  77. Utils.getClientAttributes client, ["project_id", "user_id", "is_restricted_user"], (error, {project_id, user_id, is_restricted_user}) ->
  78. return callback(error) if error?
  79. return callback(new Error("no project_id found on client")) if !project_id?
  80. logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joining doc"
  81. AuthorizationManager.assertClientCanViewProject client, (error) ->
  82. return callback(error) if error?
  83. # ensure the per-doc applied-ops channel is subscribed before sending the
  84. # doc to the client, so that no events are missed.
  85. RoomManager.joinDoc client, doc_id, (error) ->
  86. return callback(error) if error?
  87. DocumentUpdaterManager.getDocument project_id, doc_id, fromVersion, (error, lines, version, ranges, ops) ->
  88. return callback(error) if error?
  89. if is_restricted_user and ranges?.comments?
  90. ranges.comments = []
  91. # Encode any binary bits of data so it can go via WebSockets
  92. # See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
  93. encodeForWebsockets = (text) -> unescape(encodeURIComponent(text))
  94. escapedLines = []
  95. for line in lines
  96. try
  97. line = encodeForWebsockets(line)
  98. catch err
  99. logger.err {err, project_id, doc_id, fromVersion, line, client_id: client.id}, "error encoding line uri component"
  100. return callback(err)
  101. escapedLines.push line
  102. if options.encodeRanges
  103. try
  104. for comment in ranges?.comments or []
  105. comment.op.c = encodeForWebsockets(comment.op.c) if comment.op.c?
  106. for change in ranges?.changes or []
  107. change.op.i = encodeForWebsockets(change.op.i) if change.op.i?
  108. change.op.d = encodeForWebsockets(change.op.d) if change.op.d?
  109. catch err
  110. logger.err {err, project_id, doc_id, fromVersion, ranges, client_id: client.id}, "error encoding range uri component"
  111. return callback(err)
  112. AuthorizationManager.addAccessToDoc client, doc_id
  113. logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joined doc"
  114. callback null, escapedLines, version, ops, ranges
  115. leaveDoc: (client, doc_id, callback = (error) ->) ->
  116. metrics.inc "editor.leave-doc"
  117. Utils.getClientAttributes client, ["project_id", "user_id"], (error, {project_id, user_id}) ->
  118. logger.log {user_id, project_id, doc_id, client_id: client.id}, "client leaving doc"
  119. RoomManager.leaveDoc(client, doc_id)
  120. # we could remove permission when user leaves a doc, but because
  121. # the connection is per-project, we continue to allow access
  122. # after the initial joinDoc since we know they are already authorised.
  123. ## AuthorizationManager.removeAccessToDoc client, doc_id
  124. callback()
  125. updateClientPosition: (client, cursorData, callback = (error) ->) ->
  126. metrics.inc "editor.update-client-position", 0.1
  127. Utils.getClientAttributes client, [
  128. "project_id", "first_name", "last_name", "email", "user_id"
  129. ], (error, {project_id, first_name, last_name, email, user_id}) ->
  130. return callback(error) if error?
  131. logger.log {user_id, project_id, client_id: client.id, cursorData: cursorData}, "updating client position"
  132. AuthorizationManager.assertClientCanViewProjectAndDoc client, cursorData.doc_id, (error) ->
  133. if error?
  134. logger.warn {err: error, client_id: client.id, project_id, user_id}, "silently ignoring unauthorized updateClientPosition. Client likely hasn't called joinProject yet."
  135. return callback()
  136. cursorData.id = client.id
  137. cursorData.user_id = user_id if user_id?
  138. cursorData.email = email if email?
  139. # Don't store anonymous users in redis to avoid influx
  140. if !user_id or user_id == 'anonymous-user'
  141. cursorData.name = ""
  142. callback()
  143. else
  144. cursorData.name = if first_name && last_name
  145. "#{first_name} #{last_name}"
  146. else if first_name
  147. first_name
  148. else if last_name
  149. last_name
  150. else
  151. ""
  152. ConnectedUsersManager.updateUserPosition(project_id, client.id, {
  153. first_name: first_name,
  154. last_name: last_name,
  155. email: email,
  156. _id: user_id
  157. }, {
  158. row: cursorData.row,
  159. column: cursorData.column,
  160. doc_id: cursorData.doc_id
  161. }, callback)
  162. WebsocketLoadBalancer.emitToRoom(project_id, "clientTracking.clientUpdated", cursorData)
  163. CLIENT_REFRESH_DELAY: 1000
  164. getConnectedUsers: (client, callback = (error, users) ->) ->
  165. metrics.inc "editor.get-connected-users"
  166. Utils.getClientAttributes client, ["project_id", "user_id", "is_restricted_user"], (error, clientAttributes) ->
  167. return callback(error) if error?
  168. {project_id, user_id, is_restricted_user} = clientAttributes
  169. if is_restricted_user
  170. return callback(null, [])
  171. return callback(new Error("no project_id found on client")) if !project_id?
  172. logger.log {user_id, project_id, client_id: client.id}, "getting connected users"
  173. AuthorizationManager.assertClientCanViewProject client, (error) ->
  174. return callback(error) if error?
  175. WebsocketLoadBalancer.emitToRoom project_id, 'clientTracking.refresh'
  176. setTimeout () ->
  177. ConnectedUsersManager.getConnectedUsers project_id, (error, users) ->
  178. return callback(error) if error?
  179. callback null, users
  180. logger.log {user_id, project_id, client_id: client.id}, "got connected users"
  181. , WebsocketController.CLIENT_REFRESH_DELAY
  182. applyOtUpdate: (client, doc_id, update, callback = (error) ->) ->
  183. Utils.getClientAttributes client, ["user_id", "project_id"], (error, {user_id, project_id}) ->
  184. return callback(error) if error?
  185. return callback(new Error("no project_id found on client")) if !project_id?
  186. WebsocketController._assertClientCanApplyUpdate client, doc_id, update, (error) ->
  187. if error?
  188. logger.warn {err: error, doc_id, client_id: client.id, version: update.v}, "client is not authorized to make update"
  189. setTimeout () ->
  190. # Disconnect, but give the client the chance to receive the error
  191. client.disconnect()
  192. , 100
  193. return callback(error)
  194. update.meta ||= {}
  195. update.meta.source = client.id
  196. update.meta.user_id = user_id
  197. metrics.inc "editor.doc-update", 0.3
  198. logger.log {user_id, doc_id, project_id, client_id: client.id, version: update.v}, "sending update to doc updater"
  199. DocumentUpdaterManager.queueChange project_id, doc_id, update, (error) ->
  200. if error?.message == "update is too large"
  201. metrics.inc "update_too_large"
  202. updateSize = error.updateSize
  203. logger.warn({user_id, project_id, doc_id, updateSize}, "update is too large")
  204. # mark the update as received -- the client should not send it again!
  205. callback()
  206. # trigger an out-of-sync error
  207. message = {project_id, doc_id, error: "update is too large"}
  208. setTimeout () ->
  209. client.emit "otUpdateError", message.error, message
  210. client.disconnect()
  211. , 100
  212. return
  213. if error?
  214. logger.error {err: error, project_id, doc_id, client_id: client.id, version: update.v}, "document was not available for update"
  215. client.disconnect()
  216. callback(error)
  217. _assertClientCanApplyUpdate: (client, doc_id, update, callback) ->
  218. AuthorizationManager.assertClientCanEditProjectAndDoc client, doc_id, (error) ->
  219. if error?
  220. if error.message == "not authorized" and WebsocketController._isCommentUpdate(update)
  221. # This might be a comment op, which we only need read-only priveleges for
  222. AuthorizationManager.assertClientCanViewProjectAndDoc client, doc_id, callback
  223. else
  224. return callback(error)
  225. else
  226. return callback(null)
  227. _isCommentUpdate: (update) ->
  228. for op in update.op
  229. if !op.c?
  230. return false
  231. return true