Create joinDoc socket.io end point

This commit is contained in:
James Allen 2014-11-12 15:54:55 +00:00
parent 919b192e16
commit eb8ccc0298
13 changed files with 516 additions and 56 deletions

View file

@ -0,0 +1,12 @@
module.exports = AuthorizationManager =
assertClientCanViewProject: (client, callback = (error) ->) ->
AuthorizationManager._assertClientHasPrivilegeLevel client, ["readOnly", "readAndWrite", "owner"], callback
_assertClientHasPrivilegeLevel: (client, allowedLevels, callback = (error) ->) ->
client.get "privilege_level", (error, privilegeLevel) ->
return callback(error) if error?
allowed = (privilegeLevel in allowedLevels)
if allowed
callback null
else
callback new Error("not authorized")

View file

@ -0,0 +1,26 @@
request = require "request"
logger = require "logger-sharelatex"
settings = require "settings-sharelatex"
module.exports = DocumentUpdaterManager =
getDocument: (project_id, doc_id, fromVersion, callback = (error, exists, doclines, version) ->) ->
#timer = new metrics.Timer("get-document")
url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}?fromVersion=#{fromVersion}"
logger.log {project_id, doc_id, fromVersion}, "getting doc from document updater"
request.get url, (err, res, body) ->
#timer.done()
if err?
logger.error {err, url, project_id, doc_id}, "error getting doc from doc updater"
return callback(err)
if 200 <= res.statusCode < 300
logger.log {project_id, doc_id}, "got doc from document document updater"
try
body = JSON.parse(body)
catch error
return callback(error)
callback null, body?.lines, body?.version, body?.ops
else
err = new Error("doc updater returned a non-success status code: #{res.statusCode}")
err.statusCode = res.statusCode
logger.error {err, project_id, doc_id, url}, "doc updater returned a non-success status code: #{res.statusCode}"
callback err

View file

@ -3,6 +3,23 @@ logger = require "logger-sharelatex"
WebsocketController = require "./WebsocketController"
module.exports = Router =
# We don't want to send raw errors back to the client, in case they
# contain sensitive data. Instead we log them out, and send a generic
# JSON object which can be serialized over socket.io
_createCallbackWithErrorFilter: (client, method, callback) ->
return (err, args...) ->
if err?
err = {message: "Something went wrong"}
callback err, args...
# Used in error reporting
_getClientData: (client, callback = (error, data) ->) ->
client.get "user_id", (error, user_id) ->
client.get "project_id", (error, project_id) ->
client.get "doc_id", (error, doc_id) ->
callback null, { id: client.id, user_id, project_id, doc_id }
configure: (app, io, session) ->
session.on 'connection', (error, client, session) ->
if error?
@ -12,7 +29,7 @@ module.exports = Router =
Metrics.inc('socket-io.connection')
logger.log session: session, "got session"
logger.log session: session, client_id: client.id, "client connected"
user = session.user
if !user? or !user._id?
@ -21,5 +38,28 @@ module.exports = Router =
return
client.on "joinProject", (data = {}, callback) ->
WebsocketController.joinProject(client, user, data?.project_id, callback)
WebsocketController.joinProject client, user, data.project_id, (err, args...) ->
if err?
Router._getClientData client, (_, client) ->
logger.error {err, client, project_id: data.project_id}, "server side error in joinProject"
# Don't return raw error to prevent leaking server side info
return callback {message: "Something went wrong"}
else
callback(null, args...)
client.on "joinDoc", (doc_id, fromVersion, callback) ->
# fromVersion is optional
if typeof fromVersion == "function"
callback = fromVersion
fromVersion = -1
WebsocketController.joinDoc client, doc_id, fromVersion, (err, args...) ->
if err?
Router._getClientData client, (_, client) ->
logger.error {err, client, doc_id, fromVersion}, "server side error in joinDoc"
# Don't return raw error to prevent leaking server side info
return callback {message: "Something went wrong"}
else
callback(null, args...)

View file

@ -1,5 +1,7 @@
logger = require "logger-sharelatex"
WebApiManager = require "./WebApiManager"
AuthorizationManager = require "./AuthorizationManager"
DocumentUpdaterManager = require "./DocumentUpdaterManager"
module.exports = WebsocketController =
# If the protocol version changes when the client reconnects,
@ -9,17 +11,16 @@ module.exports = WebsocketController =
joinProject: (client, user, project_id, callback = (error, project, privilegeLevel, protocolVersion) ->) ->
user_id = user?._id
logger.log {user_id, project_id}, "user joining project"
logger.log {user_id, project_id, client_id: client.id}, "user joining project"
WebApiManager.joinProject project_id, user_id, (error, project, privilegeLevel) ->
return callback(error) if error?
if !privilegeLevel or privilegeLevel == ""
err = new Error("not authorized")
logger.error {err, project_id, user_id}, "user is not authorized to join project"
# Don't send an error object since socket.io can apparently
# only serialize JSON.
return callback({message: err.message})
logger.error {err, project_id, user_id, client_id: client.id}, "user is not authorized to join project"
return callback(err)
client.set("privilege_level", privilegeLevel)
client.set("user_id", user_id)
client.set("project_id", project_id)
client.set("owner_id", project?.owner?._id)
@ -31,3 +32,29 @@ module.exports = WebsocketController =
client.set("login_count", user?.loginCount)
callback null, project, privilegeLevel, WebsocketController.PROTOCOL_VERSION
joinDoc: (client, doc_id, fromVersion = -1, callback = (error, doclines, version, ops) ->) ->
client.get "user_id", (error, user_id) ->
client.get "project_id", (error, project_id) ->
logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joining doc"
AuthorizationManager.assertClientCanViewProject client, (error) ->
return callback(error) if error?
client.get "project_id", (error, project_id) ->
return callback(error) if error?
return callback(new Error("no project_id found on client")) if !project_id?
DocumentUpdaterManager.getDocument project_id, doc_id, fromVersion, (error, lines, version, ops) ->
return callback(error) if error?
# Encode any binary bits of data so it can go via WebSockets
# See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
escapedLines = []
for line in lines
try
line = unescape(encodeURIComponent(line))
catch err
logger.err {err, project_id, doc_id, fromVersion, line, client_id: client.id}, "error encoding line uri component"
return callback(err)
escapedLines.push line
client.join(doc_id)
callback null, escapedLines, version, ops

View file

@ -13,6 +13,8 @@ module.exports =
apis:
web:
url: "http://localhost:3000"
documentupdater:
url: "http://localhost:3003"
security:
sessionSecret: "secret-please-change"

View file

@ -0,0 +1,118 @@
chai = require("chai")
expect = chai.expect
chai.should()
RealTimeClient = require "./helpers/RealTimeClient"
MockDocUpdaterServer = require "./helpers/MockDocUpdaterServer"
FixturesManager = require "./helpers/FixturesManager"
describe "joinDoc", ->
before ->
@lines = ["test", "doc", "lines"]
@version = 42
@ops = ["mock", "doc", "ops"]
describe "when authorised readAndWrite", ->
before (done) ->
FixturesManager.setUpProject {
privilegeLevel: "readAndWrite"
}, (error, data) =>
throw error if error?
{@project_id, @user_id} = data
FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (error, data) =>
throw error if error?
{@doc_id} = data
@client = RealTimeClient.connect()
@client.emit "joinProject", project_id: @project_id, (error) =>
throw error if error?
@client.emit "joinDoc", @doc_id, (error, @returnedArgs...) =>
throw error if error?
done()
it "should get the doc from the doc updater", ->
MockDocUpdaterServer.getDocument
.calledWith(@project_id, @doc_id, -1)
.should.equal true
it "should return the doc lines, version and ops", ->
@returnedArgs.should.deep.equal [@lines, @version, @ops]
describe "when authorised readOnly", ->
before (done) ->
FixturesManager.setUpProject {
privilegeLevel: "readOnly"
}, (error, data) =>
throw error if error?
{@project_id, @user_id} = data
FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (error, data) =>
throw error if error?
{@doc_id} = data
@client = RealTimeClient.connect()
@client.emit "joinProject", project_id: @project_id, (error) =>
throw error if error?
@client.emit "joinDoc", @doc_id, (error, @returnedArgs...) =>
throw error if error?
done()
it "should get the doc from the doc updater", ->
MockDocUpdaterServer.getDocument
.calledWith(@project_id, @doc_id, -1)
.should.equal true
it "should return the doc lines, version and ops", ->
@returnedArgs.should.deep.equal [@lines, @version, @ops]
describe "when authorised as owner", ->
before (done) ->
FixturesManager.setUpProject {
privilegeLevel: "owner"
}, (error, data) =>
throw error if error?
{@project_id, @user_id} = data
FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (error, data) =>
throw error if error?
{@doc_id} = data
@client = RealTimeClient.connect()
@client.emit "joinProject", project_id: @project_id, (error) =>
throw error if error?
@client.emit "joinDoc", @doc_id, (error, @returnedArgs...) =>
throw error if error?
done()
it "should get the doc from the doc updater", ->
MockDocUpdaterServer.getDocument
.calledWith(@project_id, @doc_id, -1)
.should.equal true
it "should return the doc lines, version and ops", ->
@returnedArgs.should.deep.equal [@lines, @version, @ops]
# It is impossible to write an acceptance test to test joining an unauthorized
# project, since joinProject already catches that. If you can join a project,
# then you can join a doc in that project.
describe "with a fromVersion", ->
before (done) ->
@fromVersion = 36
FixturesManager.setUpProject {
privilegeLevel: "readAndWrite"
}, (error, data) =>
throw error if error?
{@project_id, @user_id} = data
FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (error, data) =>
throw error if error?
{@doc_id} = data
@client = RealTimeClient.connect()
@client.emit "joinProject", project_id: @project_id, (error) =>
throw error if error?
@client.emit "joinDoc", @doc_id, @fromVersion, (error, @returnedArgs...) =>
throw error if error?
done()
it "should get the doc from the doc updater with the fromVersion", ->
MockDocUpdaterServer.getDocument
.calledWith(@project_id, @doc_id, @fromVersion)
.should.equal true
it "should return the doc lines, version and ops", ->
@returnedArgs.should.deep.equal [@lines, @version, @ops]

View file

@ -3,24 +3,21 @@ expect = chai.expect
chai.should()
RealTimeClient = require "./helpers/RealTimeClient"
MockWebClient = require "./helpers/MockWebClient"
MockWebServer = require "./helpers/MockWebServer"
FixturesManager = require "./helpers/FixturesManager"
describe "joinProject", ->
describe "when authorized", ->
before (done) ->
@user_id = "mock-user-id"
@project_id = "mock-project-id"
privileges = {}
privileges[@user_id] = "owner"
MockWebClient.createMockProject(@project_id, privileges, {
FixturesManager.setUpProject {
privilegeLevel: "owner"
project: {
name: "Test Project"
})
MockWebClient.run (error) =>
throw error if error?
RealTimeClient.setSession {
user: { _id: @user_id }
}, (error) =>
}
}, (error, data) =>
throw error if error?
{@user_id, @project_id} = data
@client = RealTimeClient.connect()
@client.emit "joinProject", {
project_id: @project_id
@ -29,7 +26,7 @@ describe "joinProject", ->
done()
it "should get the project from web", ->
MockWebClient.joinProject
MockWebServer.joinProject
.calledWith(@project_id, @user_id)
.should.equal true
@ -46,18 +43,14 @@ describe "joinProject", ->
describe "when not authorized", ->
before (done) ->
@user_id = "mock-user-id-2"
@project_id = "mock-project-id-2"
privileges = {}
MockWebClient.createMockProject(@project_id, privileges, {
FixturesManager.setUpProject {
privilegeLevel: null
project: {
name: "Test Project"
})
MockWebClient.run (error) =>
throw error if error?
RealTimeClient.setSession {
user: { _id: @user_id }
}, (error) =>
}
}, (error, data) =>
throw error if error?
{@user_id, @project_id} = data
@client = RealTimeClient.connect()
@client.emit "joinProject", {
project_id: @project_id
@ -65,4 +58,5 @@ describe "joinProject", ->
done()
it "should return an error", ->
@error.message.should.equal "not authorized"
# We don't return specific errors
@error.message.should.equal "Something went wrong"

View file

@ -0,0 +1,42 @@
RealTimeClient = require "./RealTimeClient"
MockWebServer = require "./MockWebServer"
MockDocUpdaterServer = require "./MockDocUpdaterServer"
module.exports = FixturesManager =
setUpProject: (options = {}, callback = (error, data) ->) ->
options.user_id ||= FixturesManager.getRandomId()
options.project_id ||= FixturesManager.getRandomId()
options.project ||= { name: "Test Project" }
{project_id, user_id, privilegeLevel, project} = options
privileges = {}
privileges[user_id] = privilegeLevel
MockWebServer.createMockProject(project_id, privileges, project)
MockWebServer.run (error) =>
throw error if error?
RealTimeClient.setSession {
user: { _id: user_id }
}, (error) =>
throw error if error?
callback null, {project_id, user_id, privilegeLevel, project}
setUpDoc: (project_id, options = {}, callback = (error, data) ->) ->
options.doc_id ||= FixturesManager.getRandomId()
options.lines ||= ["doc", "lines"]
options.version ||= 42
options.ops ||= ["mock", "ops"]
{doc_id, lines, version, ops} = options
MockDocUpdaterServer.createMockDoc project_id, doc_id, {lines, version, ops}
MockDocUpdaterServer.run (error) =>
throw error if error?
callback null, {project_id, doc_id, lines, version, ops}
getRandomId: () ->
return require("crypto")
.createHash("sha1")
.update(Math.random().toString())
.digest("hex")
.slice(0,24)

View file

@ -0,0 +1,33 @@
sinon = require "sinon"
express = require "express"
module.exports = MockDocUpdaterServer =
docs: {}
createMockDoc: (project_id, doc_id, data) ->
MockDocUpdaterServer.docs["#{project_id}:#{doc_id}"] = data
getDocument: (project_id, doc_id, fromVersion, callback = (error, data) ->) ->
callback(
null, MockDocUpdaterServer.docs["#{project_id}:#{doc_id}"]
)
getDocumentRequest: (req, res, next) ->
{project_id, doc_id} = req.params
{fromVersion} = req.query
fromVersion = parseInt(fromVersion, 10)
MockDocUpdaterServer.getDocument project_id, doc_id, fromVersion, (error, data) ->
return next(error) if error?
res.json data
running: false
run: (callback = (error) ->) ->
if MockDocUpdaterServer.running
return callback()
app = express()
app.get "/project/:project_id/doc/:doc_id", MockDocUpdaterServer.getDocumentRequest
app.listen 3003, (error) ->
MockDocUpdaterServer.running = true
callback(error)
sinon.spy MockDocUpdaterServer, "getDocument"

View file

@ -1,25 +1,25 @@
sinon = require "sinon"
express = require "express"
module.exports = MockWebClient =
module.exports = MockWebServer =
projects: {}
privileges: {}
createMockProject: (project_id, privileges, project) ->
MockWebClient.privileges[project_id] = privileges
MockWebClient.projects[project_id] = project
MockWebServer.privileges[project_id] = privileges
MockWebServer.projects[project_id] = project
joinProject: (project_id, user_id, callback = (error, project, privilegeLevel) ->) ->
callback(
null,
MockWebClient.projects[project_id],
MockWebClient.privileges[project_id][user_id]
MockWebServer.projects[project_id],
MockWebServer.privileges[project_id][user_id]
)
joinProjectRequest: (req, res, next) ->
{project_id} = req.params
{user_id} = req.query
MockWebClient.joinProject project_id, user_id, (error, project, privilegeLevel) ->
MockWebServer.joinProject project_id, user_id, (error, project, privilegeLevel) ->
return next(error) if error?
res.json {
project: project
@ -28,12 +28,12 @@ module.exports = MockWebClient =
running: false
run: (callback = (error) ->) ->
if MockWebClient.running
if MockWebServer.running
return callback()
app = express()
app.post "/project/:project_id/join", MockWebClient.joinProjectRequest
app.post "/project/:project_id/join", MockWebServer.joinProjectRequest
app.listen 3000, (error) ->
MockWebClient.running = true
MockWebServer.running = true
callback(error)
sinon.spy MockWebClient, "joinProject"
sinon.spy MockWebServer, "joinProject"

View file

@ -0,0 +1,39 @@
chai = require "chai"
chai.should()
expect = chai.expect
sinon = require("sinon")
SandboxedModule = require('sandboxed-module')
path = require "path"
modulePath = '../../../app/js/AuthorizationManager'
describe 'AuthorizationManager', ->
beforeEach ->
@client =
params: {}
get: (param, cb) -> cb null, @params[param]
@AuthorizationManager = SandboxedModule.require modulePath, requires: {}
describe "assertClientCanViewProject", ->
it "should allow the readOnly privilegeLevel", (done) ->
@client.params.privilege_level = "readOnly"
@AuthorizationManager.assertClientCanViewProject @client, (error) ->
expect(error).to.be.null
done()
it "should allow the readAndWrite privilegeLevel", (done) ->
@client.params.privilege_level = "readAndWrite"
@AuthorizationManager.assertClientCanViewProject @client, (error) ->
expect(error).to.be.null
done()
it "should allow the owner privilegeLevel", (done) ->
@client.params.privilege_level = "owner"
@AuthorizationManager.assertClientCanViewProject @client, (error) ->
expect(error).to.be.null
done()
it "should return an error with any other privilegeLevel", (done) ->
@client.params.privilege_level = "unknown"
@AuthorizationManager.assertClientCanViewProject @client, (error) ->
error.message.should.equal "not authorized"
done()

View file

@ -0,0 +1,60 @@
require('chai').should()
sinon = require("sinon")
SandboxedModule = require('sandboxed-module')
path = require "path"
modulePath = '../../../app/js/DocumentUpdaterManager'
describe 'DocumentUpdaterManager', ->
beforeEach ->
@project_id = "project-id-923"
@doc_id = "doc-id-394"
@lines = ["one", "two", "three"]
@version = 42
@settings =
apis: documentupdater: url: "http://doc-updater.example.com"
@DocumentUpdaterManager = SandboxedModule.require modulePath, requires:
'settings-sharelatex':@settings
'logger-sharelatex': @logger = {log: sinon.stub(), error: sinon.stub()}
'request': @request = {}
describe "getDocument", ->
beforeEach ->
@callback = sinon.stub()
describe "successfully", ->
beforeEach ->
@body = JSON.stringify
lines: @lines
version: @version
ops: @ops = ["mock-op-1", "mock-op-2"]
@fromVersion = 2
@request.get = sinon.stub().callsArgWith(1, null, {statusCode: 200}, @body)
@DocumentUpdaterManager.getDocument @project_id, @doc_id, @fromVersion, @callback
it 'should get the document from the document updater', ->
url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}/doc/#{@doc_id}?fromVersion=#{@fromVersion}"
@request.get.calledWith(url).should.equal true
it "should call the callback with the lines and version", ->
@callback.calledWith(null, @lines, @version, @ops).should.equal true
describe "when the document updater API returns an error", ->
beforeEach ->
@request.get = sinon.stub().callsArgWith(1, @error = new Error("something went wrong"), null, null)
@DocumentUpdaterManager.getDocument @project_id, @doc_id, @fromVersion, @callback
it "should return an error to the callback", ->
@callback.calledWith(@error).should.equal true
describe "when the document updater returns a failure error code", ->
beforeEach ->
@request.get = sinon.stub().callsArgWith(1, null, { statusCode: 500 }, "")
@DocumentUpdaterManager.getDocument @project_id, @doc_id, @fromVersion, @callback
it "should return the callback with an error", ->
err = new Error("doc updater returned failure status code: 500")
err.statusCode = 500
@callback
.calledWith(err)
.should.equal true

View file

@ -19,10 +19,14 @@ describe 'WebsocketController', ->
}
@callback = sinon.stub()
@client =
params: {}
set: sinon.stub()
get: (param, cb) -> cb null, @params[param]
join: sinon.stub()
@WebsocketController = SandboxedModule.require modulePath, requires:
"./WebApiManager": @WebApiManager = {}
"./AuthorizationManager": @AuthorizationManager = {}
"./DocumentUpdaterManager": @DocumentUpdaterManager = {}
"logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() }
afterEach ->
@ -46,6 +50,9 @@ describe 'WebsocketController', ->
.calledWith(@project_id, @user._id)
.should.equal true
it "should set the privilege level on the client", ->
@client.set.calledWith("privilege_level", @privilegeLevel).should.equal true
it "should set the user's id on the client", ->
@client.set.calledWith("user_id", @user._id).should.equal true
@ -85,5 +92,65 @@ describe 'WebsocketController', ->
it "should return an error", ->
@callback
.calledWith({message: "not authorized"})
.calledWith(new Error("not authorized"))
.should.equal true
describe "joinDoc", ->
beforeEach ->
@doc_id = "doc-id-123"
@doc_lines = ["doc", "lines"]
@version = 42
@ops = ["mock", "ops"]
@client.params.project_id = @project_id
@AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, null)
@DocumentUpdaterManager.getDocument = sinon.stub().callsArgWith(3, null, @doc_lines, @version, @ops)
describe "with a fromVersion", ->
beforeEach ->
@fromVersion = 40
@WebsocketController.joinDoc @client, @doc_id, @fromVersion, @callback
it "should check that the client is authorized to view the project", ->
@AuthorizationManager.assertClientCanViewProject
.calledWith(@client)
.should.equal true
it "should get the document from the DocumentUpdaterManager", ->
@DocumentUpdaterManager.getDocument
.calledWith(@project_id, @doc_id, @fromVersion)
.should.equal true
it "should join the client to room for the doc_id", ->
@client.join
.calledWith(@doc_id)
.should.equal true
it "should call the callback with the lines, version and ops", ->
@callback
.calledWith(null, @doc_lines, @version, @ops)
.should.equal true
describe "with doclines that need escaping", ->
beforeEach ->
@doc_lines.push ["räksmörgås"]
@WebsocketController.joinDoc @client, @doc_id, -1, @callback
it "should call the callback with the escaped lines", ->
escaped_lines = @callback.args[0][1]
escaped_word = escaped_lines.pop()
escaped_word.should.equal 'räksmörgås'
# Check that unescaping works
decodeURIComponent(escape(escaped_word)).should.equal "räksmörgås"
describe "when not authorized", ->
beforeEach ->
@AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, @err = new Error("not authorized"))
@WebsocketController.joinDoc @client, @doc_id, -1, @callback
it "should call the callback with an error", ->
@callback.calledWith(@err).should.equal true
it "should not call the DocumentUpdaterManager", ->
@DocumentUpdaterManager.getDocument.called.should.equal false