Initial Open Source commit

This commit is contained in:
James Allen 2014-08-15 10:50:36 +01:00
commit 8cb71e8fad
52 changed files with 41511 additions and 0 deletions

11
services/chat/.gitignore vendored Normal file
View file

@ -0,0 +1,11 @@
**.swp
app.js
app/js/
test/unit/js/
public/build/
node_modules/
/public/js/chat.js
plato/

View file

@ -0,0 +1,124 @@
module.exports = (grunt) ->
# Project configuration.
grunt.initConfig
coffee:
client:
expand: true,
flatten: false,
cwd: 'public/coffee',
src: ['**/*.coffee'],
dest: 'public/build/',
ext: '.js'
server:
expand: true,
flatten: false,
cwd: 'app/coffee',
src: ['**/*.coffee'],
dest: 'app/js/',
ext: '.js'
app_server:
expand: true,
flatten: false,
src: ['app.coffee'],
dest: './',
ext: '.js'
server_tests:
expand: true,
flatten: false,
cwd: 'test/unit/coffee',
src: ['**/*.coffee'],
dest: 'test/unit/js/',
ext: '.js'
watch:
server_coffee:
files: ['app/**/*.coffee', 'test/unit/**/*.coffee']
tasks: ['compile:server', 'compile:server_tests', 'mochaTest']
client_coffee:
files: ['public/**/*.coffee']
tasks: ['compile']
less:
files: ['public/less/*.less']
tasks: ['compile']
jade:
files: ['public/jade/*.jade']
tasks: ['compile']
less:
production:
files:
"public/build/css/chat.css": "public/less/chat.less"
jade:
compile:
files:
"public/build/html/templates.html": ["public/jade/templates.jade"]
requirejs:
compile:
options:
mainConfigFile: 'public/app.build.js',
uglify:
my_target:
files:
'public/build/chat.js': ['public/build/chat.js']
copy:
main:
expand: true
cwd: 'public/js'
src: '**'
dest: 'public/build/'
clean: ["public/build", "app/js", "test/unit/js"]
nodemon:
dev:
options:
file: 'app.js'
concurrent:
dev:
tasks: ['nodemon', 'watch']
options:
logConcurrentOutput: true
mochaTest:
unit:
options:
reporter: process.env.MOCHA_RUNNER || "spec"
grep: grunt.option("grep")
src: ['test/**/*.js']
plato:
your_task:
files: 'plato': ['app/js/**/*.js'],
grunt.loadNpmTasks 'grunt-contrib-coffee'
grunt.loadNpmTasks 'grunt-contrib-watch'
grunt.loadNpmTasks 'grunt-contrib-copy'
grunt.loadNpmTasks 'grunt-contrib-less'
grunt.loadNpmTasks 'grunt-contrib-jade'
grunt.loadNpmTasks 'grunt-contrib-requirejs'
grunt.loadNpmTasks 'grunt-contrib-uglify'
grunt.loadNpmTasks 'grunt-nodemon'
grunt.loadNpmTasks 'grunt-contrib-clean'
grunt.loadNpmTasks 'grunt-concurrent'
grunt.loadNpmTasks 'grunt-mocha-test'
grunt.loadNpmTasks 'grunt-plato'
grunt.registerTask 'compile', ['clean', 'copy', 'coffee', 'less', 'jade', 'requirejs']
grunt.registerTask 'install', ['compile']
grunt.registerTask 'compileAndCompress', ['compile', 'uglify']
grunt.registerTask 'default', ['compile', 'concurrent']
grunt.registerTask 'test:unit', ['compile', 'mochaTest:unit']

5
services/chat/app.coffee Normal file
View file

@ -0,0 +1,5 @@
logger = require 'logger-sharelatex'
Server = require "./app/js/server"
Server.server.listen(3010, "localhost")
logger.log "chat sharelatex listening on port 3010"

View file

@ -0,0 +1,23 @@
async = require "async"
logger = require "logger-sharelatex"
WebApiManager = require("../WebApi/WebApiManager")
UserFormatter = require("../Users/UserFormatter")
module.exports = AuthenticationController =
authClient: (client, data, callback = (error) ->) ->
logger.log auth_token: data.auth_token, "authenticating user"
WebApiManager.getUserDetailsFromAuthToken data.auth_token, (error, user) =>
if error?
logger.error data: data, client_id: client.id, err: error, "error authenticating user"
return callback("something went wrong")
logger.log user: user, auth_token: data.auth_token, "authenticated user"
user = UserFormatter.formatUserForClientSide user
jobs = []
for key, value of user
do (key, value) ->
jobs.push (callback) -> client.set key, value, callback
jobs.push (callback) -> client.set "auth_token", data.auth_token, callback
async.series jobs, (error, results) =>
callback(error, user)

View file

@ -0,0 +1,24 @@
WebApiManager = require "../WebApi/WebApiManager"
SocketManager = require "../Sockets/SocketManager"
module.exports = AuthorizationManager =
canClientJoinProjectRoom: (client, project_id, callback = (error, authorized) ->) ->
client.get "auth_token", (error, auth_token) ->
return callback(error) if error?
WebApiManager.getProjectCollaborators project_id, auth_token, (error, collaborators) ->
return callback(error) if error?
client.get "id", (error, user_id) ->
return callback(error) if error?
authorized = false
for collaborator in collaborators
if collaborator.id == user_id
authorized = true
break
callback null, authorized
canClientSendMessageToRoom: (client, room_id, callback = (error, authorized) ->) ->
SocketManager.isClientInRoom(client, room_id, callback)
canClientReadMessagesInRoom: (client, room_id, callback = (error, authorized) ->) ->
SocketManager.isClientInRoom(client, room_id, callback)

View file

@ -0,0 +1,84 @@
logger = require "logger-sharelatex"
metrics = require "../../metrics"
MessageManager = require "./MessageManager"
MessageFormatter = require "./MessageFormatter"
SocketManager = require "../Sockets/SocketManager"
AuthorizationManager = require "../Authorization/AuthorizationManager"
module.exports = MessageController =
DEFAULT_MESSAGE_LIMIT: 50
sendMessage: (client, data, callback = (error) ->) ->
content = data?.message?.content
room_id = data?.room?.id
return callback("malformed message") if not (content? and room_id?)
client.get "id", (error, user_id) ->
logger.log user_id: user_id, room_id: room_id, "sending message"
AuthorizationManager.canClientSendMessageToRoom client, room_id, (error, authorized) ->
if error?
logger.err err:error, user_id:user_id, "something went wrong checking if canClientSendMessageToRoom"
return callback("something went wrong")
if authorized
SocketManager.getClientAttributes client, ["id"], (error, values) ->
if error?
logger.err err:error, user_id:user_id, "something went wrong getClientAttributes"
return callback("something went wrong")
newMessageOpts =
content: content
room_id: room_id
user_id: values[0]
timestamp: Date.now()
MessageManager.createMessage newMessageOpts, (error, message) ->
if error?
logger.err err:error, user_id:user_id, "something went wrong createMessage"
return callback("something went wrong")
MessageManager.populateMessagesWithUsers [message], (error, messages) ->
if error?
logger.err err:error, user_id:user_id, "something went wrong populateMessagesWithUsers"
return callback("something went wrong")
message = MessageFormatter.formatMessageForClientSide(messages[0])
message.room =
id: room_id
SocketManager.emitToRoom data.room.id, "messageReceived", message:message
metrics.inc "editor.instant-message"
logger.log user_id: user_id, room_id: room_id, "sent message"
callback()
else
logger.log user_id: user_id, room_id: room_id, "unauthorized attempt to send message"
callback("unknown room")
getMessages: (client, data, callback = (error, messages) ->) ->
room_id = data?.room?.id
return callback("malformed message") if not room_id?
client.get "id", (error, user_id) ->
logger.log user_id: user_id, room_id: room_id, "getting messages"
AuthorizationManager.canClientReadMessagesInRoom client, room_id, (error, authorized) ->
if error?
logger.err err:error, user_id:user_id, "something went canClientReadMessagesInRoom"
return callback("something went wrong")
if authorized
query = room_id: room_id
if data.before?
query.timestamp = $lt: data.before
options =
order_by: "timestamp"
sort_order: -1
limit: data.limit || MessageController.DEFAULT_MESSAGE_LIMIT
MessageManager.getMessages query, options, (error, messages) ->
if error?
logger.err err:error, user_id:user_id, "something went getMessages"
return callback("something went wrong")
MessageManager.populateMessagesWithUsers messages, (error, messages) ->
if error?
logger.err err:error, user_id:user_id, "something went populateMessagesWithUsers"
return callback("something went wrong")
messages = MessageFormatter.formatMessagesForClientSide messages
logger.log user_id: user_id, room_id: room_id, "got messages"
callback null, messages
else
logger.log user_id: user_id, room_id: room_id, "unauthorized attempt to get messages"
callback("unknown room")

View file

@ -0,0 +1,16 @@
UserFormatter = require "../Users/UserFormatter"
module.exports = MessageFormatter =
formatMessageForClientSide: (message) ->
if message._id?
message.id = message._id.toString()
delete message._id
formattedMessage =
id: message.id
content: message.content
timestamp: message.timestamp
user: UserFormatter.formatUserForClientSide(message.user)
return formattedMessage
formatMessagesForClientSide: (messages) ->
(@formatMessageForClientSide(message) for message in messages)

View file

@ -0,0 +1,62 @@
logger = require "logger-sharelatex"
metrics = require "../../metrics"
MessageManager = require "./MessageManager"
MessageFormatter = require "./MessageFormatter"
RoomManager = require "../Rooms/RoomManager"
module.exports = MessageHttpController =
DEFAULT_MESSAGE_LIMIT: 50
sendMessage: (req, res, next) ->
{user_id, content} = req?.body
{project_id} = req.params
logger.log user_id: user_id, content: content, "new message recived"
RoomManager.findOrCreateRoom project_id: project_id, (error, room) ->
return next(error) if error?
newMessageOpts =
content: content
room_id: room._id
user_id: user_id
timestamp: Date.now()
MessageManager.createMessage newMessageOpts, (error, message) ->
if err?
logger.err err:error, user_id:user_id, "something went wrong with create message"
return next(err)
MessageManager.populateMessagesWithUsers [message], (error, messages) ->
if error?
logger.err err:error, user_id:user_id, "something went wrong populateMessagesWithUsers"
return next("something went wrong")
message = MessageFormatter.formatMessageForClientSide(messages[0])
message.room =
id: project_id
res.send(201, message)
getMessages: (req, res, next) ->
{project_id} = req.params
query = {}
if req.query?.before?
query.timestamp = $lt: parseInt(req.query.before, 10)
if req.query?.limit?
limit = parseInt(req.query.limit, 10)
else
limit = MessageHttpController.DEFAULT_MESSAGE_LIMIT
options =
order_by: "timestamp"
sort_order: -1
limit: limit
logger.log options:options, "get message request recived"
RoomManager.findOrCreateRoom project_id: project_id, (error, room) ->
return next(error) if error?
query.room_id = room._id
MessageManager.getMessages query, options, (error, messages) ->
if error?
logger.err err:error, "something went getMessages"
return next("something went wrong")
MessageManager.populateMessagesWithUsers messages, (error, messages) ->
if error?
logger.err err:error, "something went populateMessagesWithUsers"
return next("something went wrong")
messages = MessageFormatter.formatMessagesForClientSide messages
logger.log project_id: project_id, "got messages"
res.send 200, messages

View file

@ -0,0 +1,52 @@
mongojs = require "../../mongojs"
db = mongojs.db
ObjectId = mongojs.ObjectId
WebApiManager = require "../WebApi/WebApiManager"
async = require "async"
module.exports = MessageManager =
createMessage: (message, callback = (error, message) ->) ->
message = @_ensureIdsAreObjectIds(message)
db.messages.save message, callback
getMessages: (query, options, callback = (error, messages) ->) ->
query = @_ensureIdsAreObjectIds(query)
cursor = db.messages.find(query)
if options.order_by?
options.sort_order ||= 1
sortQuery = {}
sortQuery[options.order_by] = options.sort_order
cursor = cursor.sort(sortQuery)
if options.limit?
cursor = cursor.limit(options.limit)
cursor.toArray callback
populateMessagesWithUsers: (messages, callback = (error, messages) ->) ->
jobs = new Array()
userCache = {}
getUserDetails = (user_id, callback = (error, user) ->) ->
return callback(null, userCache[user_id]) if userCache[user_id]?
WebApiManager.getUserDetails user_id, (error, user) ->
return callback(error) if error?
userCache[user_id] = user
callback null, user
for message in messages
do (message) ->
jobs.push (callback) ->
getUserDetails message.user_id.toString(), (error, user) ->
return callback(error) if error?
delete message.user_id
message.user = user
callback(null, message)
async.series jobs, callback
_ensureIdsAreObjectIds: (query) ->
if query.user_id? and query.user_id not instanceof ObjectId
query.user_id = ObjectId(query.user_id)
if query.room_id? and query.room_id not instanceof ObjectId
query.room_id = ObjectId(query.room_id)
return query

View file

@ -0,0 +1,101 @@
async = require "async"
logger = require "logger-sharelatex"
AuthorizationManager = require "../Authorization/AuthorizationManager"
RoomManager = require "../Rooms/RoomManager"
SocketManager = require "../Sockets/SocketManager"
module.exports = RoomController =
joinRoom: (client, data, callback = (error) ->) ->
if !data.room?.project_id?
return callback("unknown room")
project_id = data.room.project_id
client.get "id", (error, id) ->
logger.log user_id: id, project_id: project_id, "joining room"
AuthorizationManager.canClientJoinProjectRoom client, project_id, (error, authorized) ->
return callback("something went wrong") if error?
if authorized
RoomManager.findOrCreateRoom project_id: project_id, (error, room) ->
return callback("something went wrong") if error?
room_id = room._id.toString()
RoomController._addClientToRoom client, room_id, (error) ->
return callback("something went wrong") if error?
RoomController._getClientsInRoom room_id, (error, clients) ->
return callback("something went wrong") if error?
logger.log user_id: id, project_id: project_id, room_id: room_id, "joined room"
roomDetails =
room:
id: room_id
connectedUsers: clients
callback null, roomDetails
else
logger.log user_id: id, project_id: project_id, "unauthorized attempt to join room"
callback("unknown room")
leaveAllRooms: (client, callback = (error) ->) ->
client.get "id", (error, id) ->
logger.log user_id: id, "leaving all rooms"
SocketManager.getRoomIdsClientHasJoined client, (error, room_ids) ->
return callback("something went wrong") if error?
jobs = []
for room_id in room_ids
do (room_id) ->
jobs.push (callback) ->
RoomController.leaveRoom client, room_id, callback
async.series jobs, (error)-> callback(error)
leaveRoom: (client, room_id, callback = (error) ->) ->
client.get "id", (error, id) ->
logger.log user_id: id, room_id: room_id, "leaving room"
RoomController._getClientAttributes client, (error, attributes) ->
return callback("something went wrong") if error?
SocketManager.removeClientFromRoom client, room_id, (error) ->
return callback("something went wrong") if error?
leftRoomUpdate =
room:
id: room_id
user: attributes
SocketManager.emitToRoom room_id, "userLeft", leftRoomUpdate
logger.log user_id: id, room_id: room_id, "left room"
callback()
_addClientToRoom: (client, room_id, callback = (error) ->) ->
RoomController._getClientAttributes client, (error, attributes) ->
return callback(error) if error?
update =
room:
id: room_id
user: attributes
SocketManager.emitToRoom room_id, "userJoined", update
SocketManager.addClientToRoom client, room_id, callback
_getClientsInRoom: (room_id, callback = (error, clients) ->) ->
SocketManager.getClientsInRoom room_id, (error, clients) ->
return callback(error) if error?
formattedClients = []
jobs = []
for client in clients
do (client) ->
jobs.push (callback) ->
RoomController._getClientAttributes client, (error, attributes) ->
return callback(error) if error?
formattedClients.push attributes
callback()
async.series jobs, (error) ->
return callback(error) if error?
callback null, formattedClients
_getClientAttributes: (client, callback = (error, attributes) ->) ->
SocketManager.getClientAttributes client, ["id", "first_name", "last_name", "email", "gravatar_url"], (error, attributes) ->
return callback(error) if error?
[id, first_name, last_name, email, gravatar_url] = attributes
clientAttributes =
id : id
first_name : first_name
last_name : last_name
email : email
gravatar_url : gravatar_url
callback null, clientAttributes

View file

@ -0,0 +1,18 @@
mongojs = require("../../mongojs")
db = mongojs.db
ObjectId = mongojs.ObjectId
module.exports = RoomManager =
findOrCreateRoom: (query, callback = (error, room) ->) ->
if query.project_id? and query.project_id not instanceof ObjectId
query.project_id = ObjectId(query.project_id)
db.rooms.findOne query, (error, room) ->
return callback(error) if error?
if room?
callback null, room
else
db.rooms.save query, (error, room) ->
return callback(error) if error?
callback null, room

View file

@ -0,0 +1,25 @@
settings = require 'settings-sharelatex'
rclientPub = require("redis").createClient(settings.redis.web.port, settings.redis.web.host)
rclientPub.auth(settings.redis.web.password)
rclientSub = require("redis").createClient(settings.redis.web.port, settings.redis.web.host)
rclientSub.auth(settings.redis.web.password)
module.exports = RealTimeEventManager =
rclientPub:rclientPub
rclientSub:rclientSub
emitToRoom: (room_id, message, payload...) ->
RealTimeEventManager.rclientPub.publish "chat-events", JSON.stringify
room_id: room_id
message: message
payload: payload
listenForChatEvents: () ->
@rclientSub.subscribe "chat-events"
@rclientSub.on "message", @_processEditorEvent.bind(@)
_processEditorEvent: (channel, message) ->
io = require('../../server').io
message = JSON.parse(message)
io.sockets.in(message.room_id).emit(message.message, message.payload...)

View file

@ -0,0 +1,40 @@
async = require "async"
RealTimeEventManager = require("./RealTimeEventManager")
module.exports = SocketManager =
addClientToRoom: (client, room_id, callback = (error) ->) ->
client.join(room_id)
callback()
removeClientFromRoom: (client, room_id, callback = (error) ->) ->
client.leave(room_id)
callback()
getClientAttributes: (client, attributes, callback = (error, values) ->) ->
jobs = []
for attribute in attributes
do (attribute) ->
jobs.push (cb) -> client.get attribute, cb
async.series jobs, callback
emitToRoom: RealTimeEventManager.emitToRoom
isClientInRoom: (targetClient, room_id, callback = (error, inRoom) ->) ->
io = require("../../server").io
for client in io.sockets.clients(room_id)
if client.id == targetClient.id
return callback null, true
callback null, false
getClientsInRoom: (room_id, callback = (error, clients) ->) ->
io = require("../../server").io
callback null, io.sockets.clients(room_id)
getRoomIdsClientHasJoined: (client, callback = (error, room_ids) ->) ->
io = require("../../server").io
room_ids = []
for room_id, value of io.sockets.manager.roomClients[client.id]
if room_id[0] == "/"
room_ids.push room_id.slice(1)
callback null, room_ids

View file

@ -0,0 +1,18 @@
crypto = require "crypto"
module.exports = UserFormatter =
formatUserForClientSide: (user) ->
if user._id?
user.id = user._id.toString()
delete user._id
return {
id: user.id
first_name: user.first_name
last_name: user.last_name
email: user.email
gravatar_url: @_getGravatarUrlForEmail(user.email)
}
_getGravatarUrlForEmail: (email) ->
hash = crypto.createHash("md5").update(email.toLowerCase()).digest("hex")
return "//www.gravatar.com/avatar/#{hash}"

View file

@ -0,0 +1,32 @@
request = require('request').defaults(jar: false)
Settings = require("settings-sharelatex")
module.exports = WebApiManager =
apiRequest: (url, method, options = {}, callback = (error, result) ->) ->
if typeof options == "function"
callback = options
options = {}
url = "#{Settings.apis.web.url}#{url}"
options.url = url
options.method = method
request options, (error, response, body) ->
return callback(error) if error?
try
result = JSON.parse(body)
catch e
return callback(e)
return callback null, result
getUserDetailsFromAuthToken: (auth_token, callback = (error, details) ->) ->
@apiRequest "/user/personal_info?auth_token=#{auth_token}", "get", callback
getUserDetails: (user_id, callback = (error, details) ->) ->
@apiRequest "/user/#{user_id}/personal_info", "get", {
auth:
user: Settings.apis.web.user
pass: Settings.apis.web.pass
sendImmediately: true
}, callback
getProjectCollaborators: (project_id, auth_token, callback = (error, collaborators) ->) ->
@apiRequest "/project/#{project_id}/collaborators?auth_token=#{auth_token}", "get", callback

View file

@ -0,0 +1,22 @@
StatsD = require('lynx')
statsd = new StatsD('localhost', 8125, {on_error:->})
buildKey = (key)-> "chat.#{process.env.NODE_ENV}.#{key}"
module.exports =
set : (key, value, sampleRate = 1)->
statsd.set buildKey(key), value, sampleRate
inc : (key, sampleRate = 1)->
statsd.increment buildKey(key), sampleRate
Timer : class
constructor :(key, sampleRate = 1)->
this.start = new Date()
this.key = buildKey(key)
done:->
timeSpan = new Date - this.start
statsd.timing(this.key, timeSpan, this.sampleRate)
gauge : (key, value, sampleRate = 1)->
statsd.gauge key, value, sampleRate

View file

@ -0,0 +1,6 @@
Settings = require("settings-sharelatex")
mongojs = require "mongojs"
db = mongojs.connect(Settings.mongo.url, ["rooms", "messages"])
module.exports =
db: db
ObjectId: mongojs.ObjectId

View file

@ -0,0 +1,31 @@
AuthenticationController = require("./Features/Authentication/AuthenticationController")
MessageController = require("./Features/Messages/MessageController")
RoomController = require("./Features/Rooms/RoomController")
MessageHttpController = require('./Features/Messages/MessageHttpController')
module.exports = Router =
route: (app, io) ->
app.get "/room/:project_id/messages", MessageHttpController.getMessages
app.post "/room/:project_id/messages", MessageHttpController.sendMessage
app.get "/status", (req, res, next) ->
res.send("chat is alive")
io.sockets.on "connection", (client) ->
client.on "disconnect", () ->
RoomController.leaveAllRooms(client)
client.on "auth", (data, callback = (error) ->) ->
AuthenticationController.authClient(client, data, callback)
client.on "joinRoom", (data, callback = (error) ->) ->
RoomController.joinRoom(client, data, callback)
client.on "sendMessage", (data, callback = (error) ->) ->
MessageController.sendMessage(client, data, callback)
client.on "getMessages", (data, callback = (error) ->) ->
MessageController.getMessages(client, data, callback)

View file

@ -0,0 +1,52 @@
logger = require 'logger-sharelatex'
logger.initialize("chat-sharelatex")
metrics = require("metrics-sharelatex")
metrics.initialize("chat")
Path = require("path")
express = require("express")
app = express()
server = require("http").createServer(app)
io = require("socket.io").listen(server)
io.set("resource", "/chat/socket.io")
io.set("log level", 1)
Router = require "./router"
metrics.mongodb.monitor(Path.resolve(__dirname + "/../../node_modules/mongojs/node_modules/mongodb"), logger)
app.configure ()->
app.use express.bodyParser()
app.use metrics.http.monitor(logger)
Router.route(app, io)
app.configure 'development', ->
console.log "Development Enviroment"
app.use express.errorHandler({ dumpExceptions: true, showStack: true })
app.configure 'production', ->
console.log "Production Enviroment"
app.use express.logger()
app.use express.errorHandler()
mountPoint = "/chat"
app.use (req, res, next) ->
if req.url.slice(0, mountPoint.length) == mountPoint
req.url = req.url.slice(mountPoint.length)
next()
else
res.send(404)
app.use(express.static(__dirname + "/../../public/build"))
module.exports = {
server: server
io: io
app: app
}
require("./Features/Sockets/RealTimeEventManager").listenForChatEvents()

View file

@ -0,0 +1,14 @@
module.exports =
apis:
web:
url: "http://localhost:3000"
user: "sharelatex"
pass: "password"
mongo:
url : 'mongodb://127.0.0.1/sharelatex'
redis:
web:
host: "localhost"
port: "6379"
password: ""

View file

@ -0,0 +1,39 @@
{
"name": "chat-sharelatex",
"version": "0.0.1",
"dependencies": {
"async": "0.2.9",
"express": "3.3.1",
"lynx": "0.0.11",
"request": "2.21.0",
"socket.io": "0.9.14",
"settings": "git+ssh://git@bitbucket.org:sharelatex/settings-sharelatex.git#master",
"metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#master",
"logger": "git+ssh://git@bitbucket.org:sharelatex/logger-sharelatex.git#bunyan",
"grunt-requirejs": "~0.4.0",
"grunt-mocha-test": "~0.8.0",
"mongojs": "0.9.11",
"redis": "~0.10.1",
"coffee-script": "~1.7.1",
"timekeeper": "0.0.4"
},
"devDependencies": {
"chai": "",
"sandboxed-module": "",
"sinon": "",
"timekeeper": "",
"grunt": "~0.4.1",
"grunt-contrib-requirejs": "~0.4.1",
"grunt-contrib-coffee": "~0.7.0",
"grunt-contrib-watch": "~0.5.3",
"grunt-contrib-copy": "~0.4.1",
"grunt-contrib-less": "~0.8.2",
"grunt-contrib-jade": "~0.8.0",
"grunt-contrib-uglify": "~0.2.7",
"grunt-nodemon": "~0.1.2",
"grunt-contrib-clean": "~0.5.0",
"grunt-concurrent": "~0.4.2",
"grunt-plato": "~0.2.1",
"grunt-notify": "~0.2.16"
}
}

View file

@ -0,0 +1,25 @@
requirejs.config({
baseUrl: "./build",
out: "./build/chat.js",
inlineText:true,
preserveLicenseComments:false,
shim: {
"libs/underscore": {
init: function() {
return _.noConflict();
}
},
"libs/backbone": {
deps: ["libs/underscore"],
init: function() {
return Backbone.noConflict();
}
}
},
paths: {
"moment": "libs/moment",
},
name:"chat",
optimize: 'none',
skipDirOptimize: true
})

View file

@ -0,0 +1,109 @@
define [
"utils/staticLoader"
"libs/underscore"
"libs/backbone"
"libs/jquery.storage"
"models/room"
"models/user"
"views/chatWindowView"
], (staticLoader, _, Backbone, jqueryStorage, Room, User, ChatWindowView) ->
staticLoader.appendAssets()
_.templateSettings = escape : /\{\{(.+?)\}\}/g
class GlobalNotificationManager
constructor: (@chat) ->
@focussed = true
$(window).on "focus", () =>
@clearNewMessageNotification()
@focussed = true
$(window).on "blur", () => @focussed = false
@chat.on "joinedRoom", (room) =>
notifyIfAppropriate = (message) =>
if message.get("user") != @chat.user and !message.get("preloaded")
@notifyAboutNewMessage()
room.get("messages").on "add", notifyIfAppropriate
room.on "disconnect", () ->
room.get("messages").off "add", notifyIfAppropriate
notifyAboutNewMessage: () ->
if !@focussed and !@newMessageNotificationTimeout?
@originalTitle ||= window.document.title
do changeTitle = () =>
if window.document.title == @originalTitle
window.document.title = "New Message"
else
window.document.title = @originalTitle
@newMessageNotificationTimeout = setTimeout changeTitle, 800
clearNewMessageNotification: () ->
clearTimeout @newMessageNotificationTimeout
delete @newMessageNotificationTimeout
if @originalTitle?
window.document.title = @originalTitle
class Chat
constructor: (options) ->
_.extend(@, Backbone.Events)
window.chat = @
@rooms = {}
project_id = window.location.pathname.split( '/' )[2]
@socket = socket = io.connect options.url, {
resource: "chat/socket.io",
"force new connection": true
query:"project_id=#{project_id}"
}
@socket.on "connect", () =>
@connected = true
@getAuthToken (error, auth_token) =>
return @handleError(error) if error?
@socket.emit "auth", {auth_token: auth_token}, (error, user_info) =>
return @handleError(error) if error?
@user = User.findOrCreate(user_info)
@joinProjectRoom(options.room.project_id)
@trigger "authed"
@socket.on "disconnect", () =>
@connected = false
@trigger "disconnected"
@socket.on "messageReceived", (data) =>
@getRoom(data.message.room.id)?.onMessageReceived(data)
@socket.on "userJoined", (data) =>
@getRoom(data.room.id).addConnectedUser(data.user)
@socket.on "userLeft", (data) =>
@getRoom(data.room.id)?.removeConnectedUser(data.user)
@globalNotificationManager = new GlobalNotificationManager(@)
getRoom: (room_id) ->
@rooms[room_id]
joinProjectRoom: (project_id) ->
if !@room?
@room = new Room(
project_id: project_id
chat: @
)
@window = new ChatWindowView({
room: @room
chat: @
})
@room.on "joined", => @trigger("joinedRoom", @room)
getAuthToken: (callback = (error, auth_token) ->) ->
$.ajax "/user/auth_token", {
success: (data, status, xhr) ->
callback null, data
error: (xhr, status, error) ->
callback error
}
handleError: (error) ->
console.error error

View file

@ -0,0 +1,9 @@
define [
"libs/backbone"
"models/user"
], (Backbone, User) ->
ConnectedUsers = Backbone.Collection.extend
model: User
initialize: (models, options) ->
{@chat, @room} = options

View file

@ -0,0 +1,45 @@
define [
"libs/backbone"
"models/message"
"models/user"
], (Backbone, Message, User) ->
Messages = Backbone.Collection.extend
model: Message
initialize: (models, options) ->
{@chat, @room} = options
fetchMoreMessages: (options = { preloading: false }, callback = (error) ->) ->
limit = Messages.DEFAULT_MESSAGE_LIMIT
@room.fetchMessages @_buildMessagesQuery(limit), (error, messages) =>
if error?
callback(error)
return @chat.handleError(error)
if messages.length < limit
@trigger "noMoreMessages"
@_parseAndAddMessages(messages, options)
callback()
_parseAndAddMessages: (messages, options) ->
for message in messages
user = User.findOrCreate message.user
@add new Message(
content : message.content
timestamp : message.timestamp
user : user
preloaded : !!options.preloading
), at: 0
_buildMessagesQuery: (limit) ->
query =
limit: limit
firstMessage = @at(0)
if firstMessage?
query.before = firstMessage.get("timestamp")
return query
Messages.DEFAULT_MESSAGE_LIMIT = 50
return Messages

View file

@ -0,0 +1,5 @@
define [
"libs/backbone"
], (Backbone) ->
Message = Backbone.Model.extend {}

View file

@ -0,0 +1,85 @@
define [
"libs/underscore"
"libs/backbone"
"collections/messages"
"collections/connectedUsers"
"models/user"
"models/message"
], (_, Backbone, Messages, ConnectedUsers, User, Message) ->
Room = Backbone.Model.extend
initialize: () ->
@chat = @get("chat")
@set "messages", new Messages([], chat: @chat, room: @)
@set "connectedUsers", new ConnectedUsers([], chat: @chat, room: @)
@get("connectedUsers").on "change", () ->
@get("connectedUsers").on "add", () ->
@get("connectedUsers").on "remove", () ->
@connected = false
@chat.on "authed", () => @join()
@chat.on "disconnected", () => @_onDisconnect()
join: () ->
@chat.socket.emit "joinRoom", room: project_id: @get("project_id"), (error, data) =>
return @chat.handleError(error) if error?
room = data.room
@set("id", room.id)
@chat.rooms[room.id] = @
@addConnectedUsers(room.connectedUsers)
@_onJoin()
_onJoin: () ->
@trigger "joined"
@connected = true
if @get("messages").models.length == 0
@get("messages").fetchMoreMessages preloading: true, () =>
@trigger("afterMessagesPreloaded")
_onDisconnect: () ->
@trigger "disconnected"
@connected = false
addConnectedUsers: (users) ->
for user in users
@addConnectedUser(user)
addConnectedUser: (user) ->
if user not instanceof User
user = User.findOrCreate(user)
@get("connectedUsers").add user
removeConnectedUser: (user) ->
if user not instanceof User
user = User.findOrCreate(user)
@get("connectedUsers").remove user
sendMessage: (content, callback = (error) ->) ->
if !@connected
return callback(new Error("Not connected"))
@chat.socket.emit "sendMessage", {
message:
content: content
room:
id: @get("id")
}
fetchMessages: (query, callback = (error, messages) ->) ->
if !@connected
return callback(new Error("Not connected"))
query.room = id: @get("id")
@chat.socket.emit "getMessages", query, callback
onMessageReceived: (data) ->
message = data.message
user = User.findOrCreate message.user
message = new Message(
content : data.message.content
timestamp : data.message.timestamp
user : user
)
@get("messages").add message

View file

@ -0,0 +1,13 @@
define [
"libs/backbone"
], (Backbone, room) ->
User = Backbone.Model.extend {},
findOrCreate: (attributes) ->
User.cache ||= {}
if User.cache[attributes.id]?
return User.cache[attributes.id]
else
user = new User(attributes)
User.cache[attributes.id] = user
return user

View file

@ -0,0 +1,10 @@
define [
"text!html/templates.html"
"text!css/chat.css"
], (templates, css)->
appendAssets : ->
$(document.body).append($(templates))
style = $("<style/>")
style.html(css)
$(document.body).append(style)

View file

@ -0,0 +1,219 @@
define [
"libs/underscore"
"libs/backbone"
"views/userMessageBlockView"
"views/timeMessageBlockView"
], (_, Backbone, UserMessageBlockView, TimeMessageBlockView) ->
FIVE_MINS = 5 * 60 * 1000
ONE_HOUR = 60 * 60 * 1000
TWELVE_HOURS = ONE_HOUR * 12
ONE_DAY = ONE_HOUR * 24
ChatWindowView = Backbone.View.extend
events:
"keydown textarea" : "_onTextAreaKeyDown"
"click .js-load-older-messages" : "_loadOlderMessages"
"click .js-minimize-toggle" : "_toggleMinimizeState"
"click h3" : "_toggleMinimizeState"
"click .js-new-message-alert" : "_toggleMinimizeState"
"click" : "_removeNotification"
initialize: () ->
@template = $("#chatWindowTemplate").html()
@chat = @options.chat
@room = @options.room
@listenTo @room.get("messages"), "add", (model, collection) -> @_onNewMessage(model, collection)
@listenTo @room.get("messages"), "noMoreMessages", () -> @$(".js-loading").hide()
@listenTo @room.get("connectedUsers"), "add", (user, collection) -> @_renderConnectedUsers()
@listenTo @room.get("connectedUsers"), "remove", (user, collection) -> @_renderConnectedUsers()
@listenTo @room.get("connectedUsers"), "change", (user, collection) -> @_renderConnectedUsers()
@listenTo @room, "joined", -> @_onJoin()
@listenTo @room, "disconnected", -> @_onDisconnect()
@listenTo @room, "afterMessagesPreloaded", -> @_scrollToBottomOfMessages()
render: () ->
@setElement($(@template))
$(document.body).append(@$el)
@_renderConnectedUsers()
@_initializeMinimizedState()
_onJoin: () ->
if !@rendered?
@render()
@rendered = true
@$el.removeClass("disconnected")
@$("textarea").removeAttr("disabled")
_onDisconnect: () ->
@$el.addClass("disconnected")
@$("textarea").attr("disabled", "disabled")
_onNewMessage: (message, collection) ->
@_renderMessage(message, collection)
@_notifyAboutNewMessage(message)
_renderMessage: (message, collection) ->
@messageBlocks ||= []
scrollEl = @$(".sent-message-area")
isOldestMessage = (message, collection)->
collection.indexOf(message) == 0
ismessageFromNewUser = (messageView, message)->
!messageView? or messageView.user != message.get("user")
isTimeForNewBlockBackwards = (message, previousUserMessageBlockView)->
if !message? or !previousUserMessageBlockView?
return true
timeSinceMessageWasSent = new Date().getTime() - message.get("timestamp")
if timeSinceMessageWasSent < ONE_HOUR
timeBlockSize = FIVE_MINS
else if timeSinceMessageWasSent > ONE_HOUR and timeSinceMessageWasSent < (ONE_DAY + TWELVE_HOURS)
timeBlockSize = ONE_HOUR
else
timeBlockSize = ONE_DAY
timeSinceLastPrinted = previousUserMessageBlockView.getTime() - message.get("timestamp")
if timeSinceLastPrinted > timeBlockSize
return true
else
return false
isTimeForNewBlock = (message, previousUserMessageBlockView)->
(message.get("timestamp") - previousUserMessageBlockView.getTime()) > FIVE_MINS
if isOldestMessage(message, collection)
oldScrollTopFromBottom = scrollEl[0].scrollHeight - scrollEl.scrollTop()
userMessageBlockView = @messageBlocks[0]
if ismessageFromNewUser(userMessageBlockView, message) or isTimeForNewBlockBackwards(message, userMessageBlockView)
userMessageBlockView = new UserMessageBlockView(user: message.get("user"))
@$(".sent-messages").prepend(userMessageBlockView.$el)
@messageBlocks.unshift userMessageBlockView
userMessageBlockView.prependMessage(message)
scrollEl.scrollTop(scrollEl[0].scrollHeight - oldScrollTopFromBottom)
else
oldScrollBottom = @_getScrollBottom()
userMessageBlockView = @messageBlocks[@messageBlocks.length - 1]
if ismessageFromNewUser(userMessageBlockView, message) or isTimeForNewBlock(message, userMessageBlockView)
userMessageBlockView = new UserMessageBlockView(user: message.get("user"))
@$(".sent-messages").append(userMessageBlockView.$el)
@messageBlocks.push userMessageBlockView
userMessageBlockView.appendMessage(message)
if oldScrollBottom <= 0
@_scrollToBottomOfMessages()
_renderConnectedUsers: () ->
users = @room.get("connectedUsers")
names = users
.reject((user) => user == @chat.user)
.map((user) -> "#{user.get("first_name")} #{user.get("last_name")}")
if names.length == 0
text = "No one else is online :("
else if names.length == 1
text = "#{names[0]} is also online"
else
text = "#{names.slice(0, -1).join(", ")} and #{names[names.length - 1]} are also online"
@$(".js-connected-users").text(text)
@_resizeSentMessageArea()
_resizeSentMessageArea: () ->
marginTop = @$(".js-header").outerHeight() + @$(".js-connected-users").outerHeight()
@$(".js-sent-message-area").css({
top: marginTop + "px"
})
_getScrollBottom: () ->
scrollEl = @$(".sent-message-area")
return scrollEl[0].scrollHeight - scrollEl.scrollTop() - scrollEl.innerHeight()
_scrollToBottomOfMessages: () ->
scrollEl = @$(".sent-message-area")
doScroll = ->
return scrollEl.scrollTop(scrollEl[0].scrollHeight - scrollEl.innerHeight())
MathJax.Hub.Queue(["Typeset", doScroll])
_notifyAboutNewMessage: (message) ->
isMessageNewToUser = message.get("user") != @chat.user and !message.get("preloaded")
isTextAreaFocused = @$("textarea").is(":focus")
if !isTextAreaFocused and isMessageNewToUser
@unseenMessages ||= 0
@unseenMessages += 1
@$el.addClass("new-messages")
@$(".new-message-alert").text(@unseenMessages)
_removeNotification: () ->
@unseenMessages = 0
@$el.removeClass("new-messages")
@$(".new-message-alert").text("")
_onTextAreaKeyDown: (e) ->
if e.keyCode == 13 # Enter
e.preventDefault()
message = @$("textarea").val()
@$("textarea").val("")
@_sendMessage(message)
_loadOlderMessages: (e) ->
e.preventDefault()
@room.get("messages").fetchMoreMessages()
_sendMessage: (content) ->
@room.sendMessage(content)
isMinimized: () ->
minimized = $.localStorage "chat.rooms.project-chat.minimized"
if !minimized?
minimized = false
return minimized
_setMinimizedState: (state) ->
$.localStorage "chat.rooms.project-chat.minimized", state
_initializeMinimizedState: () ->
minimized = @isMinimized()
if minimized
@_minimize(false)
_toggleMinimizeState: (e) ->
e.preventDefault()
minimized = @isMinimized()
if !minimized
@_setMinimizedState(true)
@_minimize()
else
@_setMinimizedState(false)
@_unminimize()
_minimize: (animate = true) ->
@$(".new-message-area").hide()
@$(".js-connected-users").hide()
@$el.addClass("minimized")
if animate
@$el.animate height: 20, width: 80
else
@$el.css height: 20, width: 80
_unminimize: () ->
@$(".new-message-area").show()
@$(".js-connected-users").show()
@$el.removeClass("minimized")
@$el.animate height: 260, width: 220, () =>
@_resizeSentMessageArea()
@_scrollToBottomOfMessages()

View file

@ -0,0 +1,40 @@
define [
"libs/underscore"
"libs/backbone"
"moment"
], (_, Backbone, moment) ->
ONE_WEEK = 7 * 24 * 60 * 60 * 1000
TimeMessageBlockView = Backbone.View.extend
className : "timeSinceMessage"
initialize: () ->
@autoRefresh()
setTimeOnce: (timestamp)->
if !@timestamp?
@timestamp = timestamp
@render()
return @
setTime: (@timestamp)->
@render()
return @
autoRefresh: ->
if @timestamp?
@render()
self = @
doIt = =>
self.autoRefresh()
setTimeout doIt, 60 * 1000
render: () ->
milisecondsSince = new Date().getTime() - @timestamp
if milisecondsSince > ONE_WEEK
time = moment(@timestamp).format("D/MMM/YY, h:mm:ss a")
else
time = moment(@timestamp).fromNow()
this.$el.html(time)

View file

@ -0,0 +1,64 @@
define [
"libs/underscore"
"libs/backbone"
"views/timeMessageBlockView"
"moment"
"https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML"
], (_, Backbone, TimeMessageBlockView, moment) ->
mathjaxConfig =
"HTML-CSS": { availableFonts: ["TeX"] },
TeX:
equationNumbers: { autoNumber: "AMS" },
useLabelIDs: false
tex2jax:
inlineMath: [ ['$','$'], ["\\(","\\)"] ],
displayMath: [ ['$$','$$'], ["\\[","\\]"] ],
processEscapes: true
MathJax.Hub.Config(mathjaxConfig);
UserMessageBlockView = Backbone.View.extend
initialize: () ->
@template = _.template($("#messageBlockTemplate").html())
@user = @options.user
@timeMessageBlock = new TimeMessageBlockView()
@render()
render: () ->
@setElement $(@template(
first_name: @user.get("first_name")
last_name: @user.get("last_name")
gravatar_url: @user.get("gravatar_url")
))
@$(".timeArea").html(@timeMessageBlock.$el)
getTime: ->
return @timeMessageBlock.timestamp
appendMessage: (message) ->
el = @buildHtml(message)
@$(".messages").append(el)
@_renderMathJax(el)
@timeMessageBlock.setTimeOnce message.get("timestamp")
prependMessage: (message) ->
el = @buildHtml(message)
@$(".messages").prepend(el)
@_renderMathJax(el)
@timeMessageBlock.setTime message.get("timestamp")
buildHtml : (message)->
time = moment(message.get("timestamp")).format("dddd, MMMM Do YYYY, h:mm:ss a")
el = $("<div class='message' title='#{time}'>")
el.text(message.get("content"))
return el
_renderMathJax: (element)->
if element?
MathJax.Hub.Queue(["Typeset", MathJax.Hub, element.get(0)])

View file

@ -0,0 +1,24 @@
script(type="text/templates")#chatWindowTemplate
.chat-window
.header.js-header
h3 Chat
.new-message-alert.js-new-message-alert
.window-controls
.js-minimize-toggle.minimize-toggle
.connected-users.js-connected-users
.sent-message-area.js-sent-message-area
.loading.js-loading
a(href="#").load-older-messages.js-load-older-messages Load older messages
.sent-messages
.new-message-area
textarea(placeholder="Your message")
script(type="text/templates")#messageBlockTemplate
.chat-block
.message-block
.timeArea
.avatar
img(src="{{ gravatar_url }}?d=mm&s=40", alt="{{first_name}} {{last_name}}")
span.name {{first_name}} {{last_name}}
.messages

File diff suppressed because it is too large Load diff

9478
services/chat/public/js/libs/jquery.js vendored Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,85 @@
/*!
* jquery.storage.js 0.0.3 - https://github.com/yckart/jquery.storage.js
* The client-side storage for every browser, on any device.
*
* Copyright (c) 2012 Yannick Albert (http://yckart.com)
* Licensed under the MIT license (http://www.opensource.org/licenses/mit-license.php).
* 2013/02/10
**/
define([], function() {
;(function($, window, document) {
'use strict';
$.map(['localStorage', 'sessionStorage'], function( method ) {
var defaults = {
cookiePrefix : 'fallback:' + method + ':',
cookieOptions : {
path : '/',
domain : document.domain,
expires : ('localStorage' === method) ? { expires: 365 } : undefined
}
};
try {
$.support[method] = method in window && window[method] !== null;
} catch (e) {
$.support[method] = false;
}
$[method] = function(key, value) {
var options = $.extend({}, defaults, $[method].options);
this.getItem = function( key ) {
var returns = function(key){
return JSON.parse($.support[method] ? window[method].getItem(key) : $.cookie(options.cookiePrefix + key));
};
if(typeof key === 'string') return returns(key);
var arr = [],
i = key.length;
while(i--) arr[i] = returns(key[i]);
return arr;
};
this.setItem = function( key, value ) {
value = JSON.stringify(value);
return $.support[method] ? window[method].setItem(key, value) : $.cookie(options.cookiePrefix + key, value, options.cookieOptions);
};
this.removeItem = function( key ) {
return $.support[method] ? window[method].removeItem(key) : $.cookie(options.cookiePrefix + key, null, $.extend(options.cookieOptions, {
expires: -1
}));
};
this.clear = function() {
if($.support[method]) {
return window[method].clear();
} else {
var reg = new RegExp('^' + options.cookiePrefix, ''),
opts = $.extend(options.cookieOptions, {
expires: -1
});
if(document.cookie && document.cookie !== ''){
$.map(document.cookie.split(';'), function( cookie ){
if(reg.test(cookie = $.trim(cookie))) {
$.cookie( cookie.substr(0,cookie.indexOf('=')), null, opts);
}
});
}
}
};
if (typeof key !== "undefined") {
return typeof value !== "undefined" ? ( value === null ? this.removeItem(key) : this.setItem(key, value) ) : this.getItem(key);
}
return this;
};
$[method].options = defaults;
});
}(jQuery, window, document));
});

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

26058
services/chat/public/js/r.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,373 @@
/**
* @license RequireJS text 2.0.7 Copyright (c) 2010-2012, The Dojo Foundation All Rights Reserved.
* Available via the MIT or new BSD license.
* see: http://github.com/requirejs/text for details
*/
/*jslint regexp: true */
/*global require, XMLHttpRequest, ActiveXObject,
define, window, process, Packages,
java, location, Components, FileUtils */
define(['module'], function (module) {
'use strict';
var text, fs, Cc, Ci,
progIds = ['Msxml2.XMLHTTP', 'Microsoft.XMLHTTP', 'Msxml2.XMLHTTP.4.0'],
xmlRegExp = /^\s*<\?xml(\s)+version=[\'\"](\d)*.(\d)*[\'\"](\s)*\?>/im,
bodyRegExp = /<body[^>]*>\s*([\s\S]+)\s*<\/body>/im,
hasLocation = typeof location !== 'undefined' && location.href,
defaultProtocol = hasLocation && location.protocol && location.protocol.replace(/\:/, ''),
defaultHostName = hasLocation && location.hostname,
defaultPort = hasLocation && (location.port || undefined),
buildMap = {},
masterConfig = (module.config && module.config()) || {};
text = {
version: '2.0.7',
strip: function (content) {
//Strips <?xml ...?> declarations so that external SVG and XML
//documents can be added to a document without worry. Also, if the string
//is an HTML document, only the part inside the body tag is returned.
if (content) {
content = content.replace(xmlRegExp, "");
var matches = content.match(bodyRegExp);
if (matches) {
content = matches[1];
}
} else {
content = "";
}
return content;
},
jsEscape: function (content) {
return content.replace(/(['\\])/g, '\\$1')
.replace(/[\f]/g, "\\f")
.replace(/[\b]/g, "\\b")
.replace(/[\n]/g, "\\n")
.replace(/[\t]/g, "\\t")
.replace(/[\r]/g, "\\r")
.replace(/[\u2028]/g, "\\u2028")
.replace(/[\u2029]/g, "\\u2029");
},
createXhr: masterConfig.createXhr || function () {
//Would love to dump the ActiveX crap in here. Need IE 6 to die first.
var xhr, i, progId;
if (typeof XMLHttpRequest !== "undefined") {
return new XMLHttpRequest();
} else if (typeof ActiveXObject !== "undefined") {
for (i = 0; i < 3; i += 1) {
progId = progIds[i];
try {
xhr = new ActiveXObject(progId);
} catch (e) {}
if (xhr) {
progIds = [progId]; // so faster next time
break;
}
}
}
return xhr;
},
/**
* Parses a resource name into its component parts. Resource names
* look like: module/name.ext!strip, where the !strip part is
* optional.
* @param {String} name the resource name
* @returns {Object} with properties "moduleName", "ext" and "strip"
* where strip is a boolean.
*/
parseName: function (name) {
var modName, ext, temp,
strip = false,
index = name.indexOf("."),
isRelative = name.indexOf('./') === 0 ||
name.indexOf('../') === 0;
if (index !== -1 && (!isRelative || index > 1)) {
modName = name.substring(0, index);
ext = name.substring(index + 1, name.length);
} else {
modName = name;
}
temp = ext || modName;
index = temp.indexOf("!");
if (index !== -1) {
//Pull off the strip arg.
strip = temp.substring(index + 1) === "strip";
temp = temp.substring(0, index);
if (ext) {
ext = temp;
} else {
modName = temp;
}
}
return {
moduleName: modName,
ext: ext,
strip: strip
};
},
xdRegExp: /^((\w+)\:)?\/\/([^\/\\]+)/,
/**
* Is an URL on another domain. Only works for browser use, returns
* false in non-browser environments. Only used to know if an
* optimized .js version of a text resource should be loaded
* instead.
* @param {String} url
* @returns Boolean
*/
useXhr: function (url, protocol, hostname, port) {
var uProtocol, uHostName, uPort,
match = text.xdRegExp.exec(url);
if (!match) {
return true;
}
uProtocol = match[2];
uHostName = match[3];
uHostName = uHostName.split(':');
uPort = uHostName[1];
uHostName = uHostName[0];
return (!uProtocol || uProtocol === protocol) &&
(!uHostName || uHostName.toLowerCase() === hostname.toLowerCase()) &&
((!uPort && !uHostName) || uPort === port);
},
finishLoad: function (name, strip, content, onLoad) {
content = strip ? text.strip(content) : content;
if (masterConfig.isBuild) {
buildMap[name] = content;
}
onLoad(content);
},
load: function (name, req, onLoad, config) {
//Name has format: some.module.filext!strip
//The strip part is optional.
//if strip is present, then that means only get the string contents
//inside a body tag in an HTML string. For XML/SVG content it means
//removing the <?xml ...?> declarations so the content can be inserted
//into the current doc without problems.
// Do not bother with the work if a build and text will
// not be inlined.
if (config.isBuild && !config.inlineText) {
onLoad();
return;
}
masterConfig.isBuild = config.isBuild;
var parsed = text.parseName(name),
nonStripName = parsed.moduleName +
(parsed.ext ? '.' + parsed.ext : ''),
url = req.toUrl(nonStripName),
useXhr = (masterConfig.useXhr) ||
text.useXhr;
//Load the text. Use XHR if possible and in a browser.
if (!hasLocation || useXhr(url, defaultProtocol, defaultHostName, defaultPort)) {
text.get(url, function (content) {
text.finishLoad(name, parsed.strip, content, onLoad);
}, function (err) {
if (onLoad.error) {
onLoad.error(err);
}
});
} else {
//Need to fetch the resource across domains. Assume
//the resource has been optimized into a JS module. Fetch
//by the module name + extension, but do not include the
//!strip part to avoid file system issues.
req([nonStripName], function (content) {
text.finishLoad(parsed.moduleName + '.' + parsed.ext,
parsed.strip, content, onLoad);
});
}
},
write: function (pluginName, moduleName, write, config) {
if (buildMap.hasOwnProperty(moduleName)) {
var content = text.jsEscape(buildMap[moduleName]);
write.asModule(pluginName + "!" + moduleName,
"define(function () { return '" +
content +
"';});\n");
}
},
writeFile: function (pluginName, moduleName, req, write, config) {
var parsed = text.parseName(moduleName),
extPart = parsed.ext ? '.' + parsed.ext : '',
nonStripName = parsed.moduleName + extPart,
//Use a '.js' file name so that it indicates it is a
//script that can be loaded across domains.
fileName = req.toUrl(parsed.moduleName + extPart) + '.js';
//Leverage own load() method to load plugin value, but only
//write out values that do not have the strip argument,
//to avoid any potential issues with ! in file names.
text.load(nonStripName, req, function (value) {
//Use own write() method to construct full module value.
//But need to create shell that translates writeFile's
//write() to the right interface.
var textWrite = function (contents) {
return write(fileName, contents);
};
textWrite.asModule = function (moduleName, contents) {
return write.asModule(moduleName, fileName, contents);
};
text.write(pluginName, nonStripName, textWrite, config);
}, config);
}
};
if (masterConfig.env === 'node' || (!masterConfig.env &&
typeof process !== "undefined" &&
process.versions &&
!!process.versions.node)) {
//Using special require.nodeRequire, something added by r.js.
fs = require.nodeRequire('fs');
text.get = function (url, callback, errback) {
try {
var file = fs.readFileSync(url, 'utf8');
//Remove BOM (Byte Mark Order) from utf8 files if it is there.
if (file.indexOf('\uFEFF') === 0) {
file = file.substring(1);
}
callback(file);
} catch (e) {
errback(e);
}
};
} else if (masterConfig.env === 'xhr' || (!masterConfig.env &&
text.createXhr())) {
text.get = function (url, callback, errback, headers) {
var xhr = text.createXhr(), header;
xhr.open('GET', url, true);
//Allow plugins direct access to xhr headers
if (headers) {
for (header in headers) {
if (headers.hasOwnProperty(header)) {
xhr.setRequestHeader(header.toLowerCase(), headers[header]);
}
}
}
//Allow overrides specified in config
if (masterConfig.onXhr) {
masterConfig.onXhr(xhr, url);
}
xhr.onreadystatechange = function (evt) {
var status, err;
//Do not explicitly handle errors, those should be
//visible via console output in the browser.
if (xhr.readyState === 4) {
status = xhr.status;
if (status > 399 && status < 600) {
//An http 4xx or 5xx error. Signal an error.
err = new Error(url + ' HTTP status: ' + status);
err.xhr = xhr;
errback(err);
} else {
callback(xhr.responseText);
}
if (masterConfig.onXhrComplete) {
masterConfig.onXhrComplete(xhr, url);
}
}
};
xhr.send(null);
};
} else if (masterConfig.env === 'rhino' || (!masterConfig.env &&
typeof Packages !== 'undefined' && typeof java !== 'undefined')) {
//Why Java, why is this so awkward?
text.get = function (url, callback) {
var stringBuffer, line,
encoding = "utf-8",
file = new java.io.File(url),
lineSeparator = java.lang.System.getProperty("line.separator"),
input = new java.io.BufferedReader(new java.io.InputStreamReader(new java.io.FileInputStream(file), encoding)),
content = '';
try {
stringBuffer = new java.lang.StringBuffer();
line = input.readLine();
// Byte Order Mark (BOM) - The Unicode Standard, version 3.0, page 324
// http://www.unicode.org/faq/utf_bom.html
// Note that when we use utf-8, the BOM should appear as "EF BB BF", but it doesn't due to this bug in the JDK:
// http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4508058
if (line && line.length() && line.charAt(0) === 0xfeff) {
// Eat the BOM, since we've already found the encoding on this file,
// and we plan to concatenating this buffer with others; the BOM should
// only appear at the top of a file.
line = line.substring(1);
}
if (line !== null) {
stringBuffer.append(line);
}
while ((line = input.readLine()) !== null) {
stringBuffer.append(lineSeparator);
stringBuffer.append(line);
}
//Make sure we return a JavaScript string and not a Java string.
content = String(stringBuffer.toString()); //String
} finally {
input.close();
}
callback(content);
};
} else if (masterConfig.env === 'xpconnect' || (!masterConfig.env &&
typeof Components !== 'undefined' && Components.classes &&
Components.interfaces)) {
//Avert your gaze!
Cc = Components.classes,
Ci = Components.interfaces;
Components.utils['import']('resource://gre/modules/FileUtils.jsm');
text.get = function (url, callback) {
var inStream, convertStream,
readData = {},
fileObj = new FileUtils.File(url);
//XPCOM, you so crazy
try {
inStream = Cc['@mozilla.org/network/file-input-stream;1']
.createInstance(Ci.nsIFileInputStream);
inStream.init(fileObj, 1, 0, false);
convertStream = Cc['@mozilla.org/intl/converter-input-stream;1']
.createInstance(Ci.nsIConverterInputStream);
convertStream.init(inStream, "utf-8", inStream.available(),
Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER);
convertStream.readString(inStream.available(), readData);
convertStream.close();
inStream.close();
callback(readData.value);
} catch (e) {
throw new Error((fileObj && fileObj.path || '') + ': ' + e);
}
};
}
return text;
});

View file

@ -0,0 +1,198 @@
@border-color: #999;
.chat-window {
border-bottom: none;
position: absolute;
bottom: 0px;
right: 10px;
height: 260px;
width: 220px;
background-color: white;
z-index: 10;
.header {
h3 {
background-color: rgb(40,40,40);
border: 1px solid #222;
border-bottom: none;
color: white;
font-size: 12px;
line-height: 12px;
padding: 4px 6px 5px;
font-weight: normal;
cursor: pointer;
}
position: relative;
.window-controls {
position: absolute;
top: 0px;
right: 4px;
bottom: 0px;
color: white;
cursor: pointer;
&:hover {
background-color: black;
}
.minimize-toggle {
width: 12px;
height: 100%;
padding: 0 4px;
background-repeat: no-repeat;
background-position: 4px center;
background-image: url(data:image/png;base64,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);
}
}
.new-message-alert {
display: none;
cursor: pointer;
position: absolute;
padding: 4px;
top: -16px;
left: 35px;
background-color: red;
color: white;
font-weight: bold;
padding: 1px 6px;
-moz-border-radius: 3px;
-webkit-border-radius: 3px;
border-radius: 3px;
&:after {
border: 3px solid red;
border-right: 3px solid transparent;
border-bottom: 3px solid transparent;
content: '';
position: absolute;
left: 6px;
bottom: -6px;
}
}
}
.connected-users {
font-size: 10px;
padding: 4px;
background-color: #ddd;
border: 1px solid @border-color;
border-top: none;
}
.sent-message-area {
border: 1px solid @border-color;
border-top: none;
border-bottom: none;
position: absolute;
top: 22px;
bottom: 28px;
left: 0px;
right: 0px;
font-size: 12px;
font-family: arial, san-serif;
overflow-y: scroll;
.chat-block {
position: relative;
padding-top: 4px;
padding-bottom: 4px;
.message-block {
min-height: 40px;
margin-bottom: 5px;
}
.timeSinceMessage {
color: @border-color;
text-align:center;
padding-bottom: 8px;
}
.avatar {
float: left;
width: 44px;
height: 44px;
}
.name {
font-weight: bold;
color: #999;
margin-top: 0;
}
.message {
margin: 3px;
}
}
a.load-older-messages {
padding: 10px;
background-color: #eee;
text-align:center;
display: block;
&:hover {
background-color: #ddd;
}
}
}
@new-message-area-height: 27px;
@new-message-area-padding-top: 3px;
.new-message-area {
position: absolute;
bottom: 0px;
height: @new-message-area-height;
left: 0px;
right: 0px;
border: 1px solid @border-color;
border-bottom: none;
textarea {
height: @new-message-area-height - 2 * @new-message-area-padding-top;
width: 212px;
border: none;
padding: @new-message-area-padding-top;
margin: 0;
resize: none;
font-size: 12px;
font-family: arial, san-serif;
color: #333;
}
}
}
.chat-window.disconnected {
.sent-message-area {
opacity: 0.6;
}
}
.chat-window.new-messages {
.header {
h3 {
background-color: #049cdb;
border-color: darken(#049cdb, 10%);
}
.new-message-alert {
display: block;
}
.window-controls {
&:hover {
background-color: darken(#049cdb, 10%);
}
}
}
}
.chat-window.minimized {
.header {
.window-controls {
.minimize-toggle {
background-image: url(data:image/png;base64,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);
}
}
}
}

124
services/chat/rakefile.rb Normal file
View file

@ -0,0 +1,124 @@
namespace "run" do
end
namespace "test" do
desc "Run the unit tests"
task :unit => ["compile:unittests"] do
puts "Running unit tests"
featurePath = ENV['feature']
puts featurePath
if featurePath.nil?
featurePath = ''
elsif featurePath.include? '/'
elsif !featurePath.include? '/'
featurePath +='/'
else
featurePath = ''
end
runner = ENV['MOCHA_RUNNER'] || "spec"
sh %{mocha -R #{runner} test/unit/js/#{featurePath}* --ignore-leaks} do |ok, res|
if ! ok
raise "error running unit tests : #{res}"
end
end
end
end
namespace "compile" do
desc "Compile server and client coffeescript files"
task :app => ["compile:serverApp", "compile:clientApp"]
desc "Compile the main serverside app folder"
task :serverApp do
puts "Compiling app"
sh %{coffee -o app/js/ -c app/coffee/} do |ok, res|
if ! ok
raise "error compiling app folder: #{res}"
end
puts 'Finished server app compile'
end
sh %{coffee -c app.coffee} do |ok, res|
if ! ok
raise "error compiling app file: #{res}"
end
puts 'Finished app.coffee compile'
end
end
desc "Compile the main client app folder"
task :clientApp do
puts "Compiling client app"
sh %{coffee -o public/js/ -c public/coffee/} do |ok, res|
if ! ok
raise "error compiling client app folder: #{res}"
end
end
sh %{mkdir -p public/js/html}
sh %{mkdir -p public/js/css}
sh %{jade < public/jade/templates.jade > public/js/html/templates.html} do |ok, res|
if !ok
raise "error compiling jade templates: #{res}"
end
end
sh %{lessc - < public/less/chat.less > public/js/css/chat.css} do |ok, res|
if !ok
raise "error compiling css: #{res}"
end
end
end
desc "compress the js"
task :compressAndCompileJs do
sh %{node public/js/r.js -o public/app.build.js} do |ok, res|
if ! ok
raise "error compiling client app folder: #{res}"
end
end
puts "Finished client app compile"
end
desc "Compile the unit tests folder"
task :unittests => ["compile:serverApp"] do
puts "Compiling Unit Tests to JS"
sh %{coffee -c -o test/unit/js/ test/unit/coffee/} do |ok, res|
if ! ok
raise "error compiling Unit tests : #{res}"
end
end
end
end
namespace 'bootstrap' do
desc "Creates a new Feature and module, and corresponding test framework file"
task :feature, :feature_name, :module_name do |task, args|
feature_name = args[:feature_name]
module_name = args[:module_name]
FileUtils.mkdir_p("app/coffee/Features/#{feature_name}")
File.open("app/coffee/Features/#{feature_name}/#{module_name}.coffee", "w") { |f|
f.write(<<-EOS
module.exports = #{module_name} =
EOS
)
}
FileUtils.mkdir_p("test/unit/coffee/#{feature_name}")
File.open("test/unit/coffee/#{feature_name}/#{module_name}Tests.coffee", "w") { |f|
f.write(<<-EOS
sinon = require('sinon')
chai = require('chai')
should = chai.should()
expect = chai.expect
modulePath = "../../../../app/js/Features/#{feature_name}/#{module_name}.js"
SandboxedModule = require('sandboxed-module')
events = require "events"
describe "#{module_name}", ->
beforeEach ->
@#{module_name} = SandboxedModule.require modulePath, requires:
EOS
)
}
end
end

View file

@ -0,0 +1,71 @@
sinon = require('sinon')
chai = require('chai')
should = chai.should()
expect = chai.expect
modulePath = "../../../../app/js/Features/Authentication/AuthenticationController.js"
SandboxedModule = require('sandboxed-module')
describe "AuthenticationController", ->
beforeEach ->
@AuthenticationController = SandboxedModule.require modulePath, requires:
"../WebApi/WebApiManager": @WebApiManager = {}
"../Users/UserFormatter": @UserFormatter = {}
"logger-sharelatex": @logger = { log: sinon.stub() }
@callback = sinon.stub()
describe "authClient", ->
beforeEach ->
@auth_token = "super-secret-auth-token"
@client =
params: {}
set: (key, value, callback) ->
@params[key] = value
callback()
@user =
id: "user-id-123"
email: "doug@sharelatex.com"
first_name: "Douglas"
last_name: "Adams"
@WebApiManager.getUserDetailsFromAuthToken = sinon.stub().callsArgWith(1, null, @user)
@UserFormatter.formatUserForClientSide = sinon.stub().returns({
id: @user.id
first_name: @user.first_name
last_name: @user.last_name
email: @user.email
gravatar_url: "//gravatar/url"
})
@AuthenticationController.authClient(@client, auth_token: @auth_token, @callback)
it "should get the user's data from the web api", ->
@WebApiManager.getUserDetailsFromAuthToken
.calledWith(@auth_token)
.should.equal true
it "should set the user's data and auth_token on the client object", ->
@client.params.should.deep.equal {
id: @user.id
first_name: @user.first_name
last_name: @user.last_name
email: @user.email
gravatar_url: "//gravatar/url"
auth_token: @auth_token
}
it "should call the callback with the user details (including the gravatar URL, but not the auth_token)", ->
@callback
.calledWith(null, {
id: @user.id
email: @user.email
first_name: @user.first_name
last_name: @user.last_name
gravatar_url: "//gravatar/url"
}).should.equal true
it "should log the request", ->
@logger.log
.calledWith(auth_token: @auth_token, "authenticating user")
.should.equal true
@logger.log
.calledWith(user: @user, auth_token: @auth_token, "authenticated user")
.should.equal true

View file

@ -0,0 +1,55 @@
sinon = require('sinon')
chai = require('chai')
should = chai.should()
expect = chai.expect
modulePath = "../../../../app/js/Features/Authorization/AuthorizationManager.js"
SandboxedModule = require('sandboxed-module')
events = require "events"
describe "AuthorizationManager", ->
beforeEach ->
@SocketManager = {}
@AuthorizationManager = SandboxedModule.require modulePath, requires:
"../WebApi/WebApiManager": @WebApiManager = {}
"../Sockets/SocketManager": @SocketManager
@callback = sinon.stub()
@user_id = "user-id-123"
@project_id = "project-id-456"
@auth_token = "auth-token-789"
@client =
params: {}
get: (key, callback = (error, value) ->) ->
callback null, @params[key]
describe "canClientJoinProjectRoom", ->
beforeEach ->
@client.params.auth_token = @auth_token
@client.params.id = @user_id
describe "when the client is a collaborator", ->
beforeEach ->
@collaborators = [
id: @user_id
]
@WebApiManager.getProjectCollaborators = sinon.stub().callsArgWith(2, null, @collaborators)
@AuthorizationManager.canClientJoinProjectRoom(@client, @project_id, @callback)
it "should get the list of collaborators from the web api", ->
@WebApiManager.getProjectCollaborators
.calledWith(@project_id, @auth_token)
.should.equal true
it "should return true", ->
@callback.calledWith(null, true).should.equal true
describe "when the client is not a collaborator", ->
beforeEach ->
@collaborators = [
id: "not the user id"
]
@WebApiManager.getProjectCollaborators = sinon.stub().callsArgWith(2, null, @collaborators)
@AuthorizationManager.canClientJoinProjectRoom(@client, @project_id, @callback)
it "should return false", ->
@callback.calledWith(null, false).should.equal true

View file

@ -0,0 +1,187 @@
sinon = require('sinon')
chai = require('chai')
should = chai.should()
expect = chai.expect
modulePath = "../../../../app/js/Features/Messages/MessageController.js"
SandboxedModule = require('sandboxed-module')
events = require "events"
tk = require "timekeeper"
ObjectId = require("mongojs").ObjectId
describe "MessageController", ->
beforeEach ->
tk.freeze(new Date())
@MessageController = SandboxedModule.require modulePath, requires:
"./MessageManager": @MessageManager = {}
"./MessageFormatter": @MessageFormatter = {}
"../Sockets/SocketManager": @SocketManager = {}
"../Authorization/AuthorizationManager": @AuthorizationManager = {}
"logger-sharelatex": @logger = { log: sinon.stub() }
"../../metrics": @metrics = {inc: sinon.stub()}
@callback = sinon.stub()
@client =
params:
id: @user_id = "user-id-123"
first_name: @first_name = "Douglas"
last_name: @last_name = "Adams"
email: @email = "doug@sharelatex.com"
gravatar_url: @gravatar_url = "//gravatar/url"
get: (key, callback = (error, value) ->) -> callback null, @params[key]
afterEach ->
tk.reset()
describe "sendMessage", ->
beforeEach ->
@MessageManager.createMessage = sinon.stub().callsArg(1)
@SocketManager.emitToRoom = sinon.stub()
@singlePopulatedMessage = {data:"here"}
@formattedMessage = {formatted:true}
@MessageFormatter.formatMessageForClientSide = sinon.stub().returns(@formattedMessage)
@MessageManager.populateMessagesWithUsers = sinon.stub().callsArgWith(1, null, [@singlePopulatedMessage])
@SocketManager.getClientAttributes = (client, attributes, callback) ->
values = (client.params[key] for key in attributes)
callback null, values
describe "when the client is authorized to send a message to the room", ->
beforeEach ->
@AuthorizationManager.canClientSendMessageToRoom = sinon.stub().callsArgWith(2, null, true)
@MessageController.sendMessage(@client, {
message:
content: @content = "Hello world"
room:
id: @room_id = "room-id-123"
}, @callback)
it "should check that the client can send a message to the room", ->
@AuthorizationManager.canClientSendMessageToRoom
.calledWith(@client, @room_id)
.should.equal true
it "should insert the message into the database", ->
@MessageManager.createMessage
.calledWith({
content: @content
user_id: @user_id
room_id: @room_id
timestamp: Date.now()
})
.should.equal true
it "should format the message for the client", ->
@MessageFormatter.formatMessageForClientSide.calledWith(@singlePopulatedMessage).should.equal true
it "should send the formatted message out to the other clients in the room", ->
@SocketManager.emitToRoom.calledWith(@room_id, "messageReceived", message:@formattedMessage).should.equal true
it "should record the message as a metric", ->
@metrics.inc
.calledWith("editor.instant-message")
.should.equal true
it "should call the callback", ->
@callback.called.should.equal true
describe "when the client is not authorized", ->
beforeEach ->
@AuthorizationManager.canClientSendMessageToRoom = sinon.stub().callsArgWith(2, null, false)
@MessageController.sendMessage(@client, {
message:
content: @content = "Hello world"
room:
id: @room_id = "room-id-123"
}, @callback)
it "should not insert the message into the database", ->
@MessageManager.createMessage.called.should.equal false
it "should not send the message out to the other clients in the room", ->
@SocketManager.emitToRoom.called.should.equal false
it "should call the callback with an error that doesn't give anything away", ->
@callback.calledWith("unknown room").should.equal true
describe "getMessage", ->
beforeEach ->
@room_id = "room-id-123"
@timestamp = Date.now()
@limit = 42
describe "when the client is authorized", ->
beforeEach ->
@messages = "messages without users stub"
@messagesWithUsers = "messages with users stub"
@formattedMessages = "formatted messages stub"
@MessageManager.getMessages = sinon.stub().callsArgWith(2, null, @messages)
@MessageManager.populateMessagesWithUsers = sinon.stub().callsArgWith(1, null, @messagesWithUsers)
@AuthorizationManager.canClientReadMessagesInRoom = sinon.stub().callsArgWith(2, null, true)
@MessageFormatter.formatMessagesForClientSide = sinon.stub().returns @formattedMessages
describe "with a timestamp and limit", ->
beforeEach ->
@MessageController.getMessages(@client, {
room:
id: @room_id,
before: @timestamp,
limit: @limit
}, @callback)
it "should get the requested messages", ->
@MessageManager.getMessages
.calledWith({
timestamp: $lt: @timestamp
room_id: @room_id
}, {
limit: @limit
order_by: "timestamp"
sort_order: -1
})
.should.equal true
it "should populate the messages with the users", ->
@MessageManager.populateMessagesWithUsers
.calledWith(@messages)
.should.equal true
it "should return the formatted messages", ->
@MessageFormatter.formatMessagesForClientSide
.calledWith(@messagesWithUsers)
.should.equal true
it "should call the callback with the formatted messages", ->
@callback
.calledWith(null, @formattedMessages)
.should.equal true
describe "without a timestamp or limit", ->
beforeEach ->
@MessageController.getMessages(@client, {
room:
id: @room_id,
}, @callback)
it "should get a default number of messages from the beginning", ->
@MessageManager.getMessages
.calledWith({
room_id: @room_id
}, {
limit: @MessageController.DEFAULT_MESSAGE_LIMIT
order_by: "timestamp"
sort_order: -1
})
.should.equal true
describe "when the client is not authorized", ->
beforeEach ->
@AuthorizationManager.canClientReadMessagesInRoom = sinon.stub().callsArgWith(2, null, false)
@MessageController.getMessages(@client, {
room:
id: @room_id,
before: @timestamp,
limit: @limit
}, @callback)
it "should call the callback with an error", ->
@callback.calledWith("unknown room").should.equal true

View file

@ -0,0 +1,13 @@
sinon = require('sinon')
chai = require('chai')
should = chai.should()
expect = chai.expect
modulePath = "../../../../app/js/Features/Messages/MessageFormatter.js"
SandboxedModule = require('sandboxed-module')
events = require "events"
ObjectId = require("mongojs").ObjectId
describe "MessageFormatter", ->
beforeEach ->
@MessageFormatter = SandboxedModule.require modulePath, requires: {}

View file

@ -0,0 +1,173 @@
should = require('chai').should()
SandboxedModule = require('sandboxed-module')
assert = require('assert')
path = require('path')
sinon = require('sinon')
modulePath = path.join __dirname, "../../../../app/js/Features/Messages/MessageHttpController"
expect = require("chai").expect
tk = require("timekeeper")
describe "MessagesHttpController", ->
beforeEach ->
@settings = {}
@date = Date.now()
tk.freeze(@date)
@MessagesHttpController = SandboxedModule.require modulePath, requires:
"settings-sharelatex":@settings
"logger-sharelatex": log:->
"./MessageManager": @MessageManager = {}
"./MessageFormatter": @MessageFormatter = {}
"../Rooms/RoomManager": @RoomManager = {}
@req =
body:{}
@res = {}
@project_id = "12321321"
@room_id = "Asdfadf adfafd"
@user_id = "09832910838239081203981"
@content = "my message here"
afterEach ->
tk.reset()
describe "sendMessage", ->
beforeEach ->
@initialMessage = {content:@content}
@MessageManager.createMessage = sinon.stub().callsArgWith(1, null, @initialMessage)
@req.params =
project_id:@project_id
@req.body =
user_id:@user_id
content:@content
@singlePopulatedMessage = {data:"here"}
@MessageManager.populateMessagesWithUsers = sinon.stub().callsArgWith(1, null, [@singlePopulatedMessage])
@RoomManager.findOrCreateRoom = sinon.stub().callsArgWith(1, null, @room = { _id : @room_id })
@formattedMessage = {formatted:true}
@MessageFormatter.formatMessageForClientSide = sinon.stub().returns(@formattedMessage)
it "should look up the room for the project", ->
@res.send = =>
@RoomManager.findOrCreateRoom
.calledWith({
project_id: @project_id
})
.should.equal true
done()
it "should create the message with the message manager", (done)->
@res.send = =>
@MessageManager.createMessage
.calledWith({
content: @content
user_id: @user_id
room_id: @room_id
timestamp: @date
})
.should.equal true
done()
@MessagesHttpController.sendMessage @req, @res
it "should return the formetted message", (done)->
@res.send = (code, data)=>
assert.deepEqual @MessageManager.populateMessagesWithUsers.args[0][0], [@initialMessage]
code.should.equal 201
data.should.equal @formattedMessage
done()
@MessagesHttpController.sendMessage @req, @res
describe "getMessages", ->
beforeEach ->
@project_id = "room-id-123"
@timestamp = Date.now()
@limit = 42
@messages = "messages without users stub"
@messagesWithUsers = "messages with users stub"
@formattedMessages = "formatted messages stub"
@RoomManager.findOrCreateRoom = sinon.stub().callsArgWith(1, null, @room = { _id : @room_id })
@MessageManager.getMessages = sinon.stub().callsArgWith(2, null, @messages)
@MessageManager.populateMessagesWithUsers = sinon.stub().callsArgWith(1, null, @messagesWithUsers)
@MessageFormatter.formatMessagesForClientSide = sinon.stub().returns @formattedMessages
describe "with a timestamp and limit", ->
beforeEach ->
@req.params =
project_id:@project_id
@req.query =
before: @timestamp,
limit: "#{@limit}"
it "should look up the room for the project", ->
@res.send = =>
@RoomManager.findOrCreateRoom
.calledWith({
project_id: @project_id
})
.should.equal true
done()
it "should get the requested messages", ->
@res.send = =>
@MessageManager.getMessages
.calledWith({
timestamp: $lt: @timestamp
room_id: @room_id
}, {
limit: @limit
order_by: "timestamp"
sort_order: -1
})
.should.equal true
@MessagesHttpController.getMessages(@req, @res)
it "should populate the messages with the users", (done)->
@res.send = =>
@MessageManager.populateMessagesWithUsers.calledWith(@messages).should.equal true
done()
@MessagesHttpController.getMessages(@req, @res)
it "should return the formatted messages", (done)->
@res.send = ()=>
@MessageFormatter.formatMessagesForClientSide.calledWith(@messagesWithUsers).should.equal true
done()
@MessagesHttpController.getMessages(@req, @res)
it "should send the formated messages back with a 200", (done)->
@res.send = (code, data)=>
code.should.equal 200
data.should.equal @formattedMessages
done()
@MessagesHttpController.getMessages(@req, @res)
describe "without a timestamp or limit", ->
beforeEach ->
@req.params =
project_id:@project_id
it "should get a default number of messages from the beginning", ->
@res.send = =>
@MessageManager.getMessages
.calledWith({
room_id: @room_id
}, {
limit: @MessagesHttpController.DEFAULT_MESSAGE_LIMIT
order_by: "timestamp"
sort_order: -1
})
.should.equal true
@MessagesHttpController.getMessages(@req, @res)

View file

@ -0,0 +1,60 @@
sinon = require('sinon')
chai = require('chai')
should = chai.should()
expect = chai.expect
modulePath = "../../../../app/js/Features/Messages/MessageManager.js"
SandboxedModule = require('sandboxed-module')
events = require "events"
ObjectId = require("mongojs").ObjectId
describe "MessageManager", ->
beforeEach ->
@MessageManager = SandboxedModule.require modulePath, requires:
"../WebApi/WebApiManager": @WebApiManager = {}
@callback = sinon.stub()
describe "populateMessagesWithUsers", ->
beforeEach ->
@user0 =
id: ObjectId().toString()
first_name: "Adam"
@user1 =
id: ObjectId().toString()
first_name: "Eve"
@users = {}
@users[@user0.id] = @user0
@users[@user1.id] = @user1
@messages = [{
content: "First message content"
user_id: ObjectId(@user0.id)
}, {
content: "Second message content"
user_id: ObjectId(@user0.id)
}, {
content: "Third message content"
user_id: ObjectId(@user1.id)
}]
@WebApiManager.getUserDetails = (user_id, callback = (error, user) ->) =>
callback null, @users[user_id]
sinon.spy @WebApiManager, "getUserDetails"
@MessageManager.populateMessagesWithUsers @messages, @callback
it "should insert user objects in the place of user_ids", ->
messages = @callback.args[0][1]
expect(messages).to.deep.equal [{
content: "First message content"
user: @user0
}, {
content: "Second message content"
user: @user0
}, {
content: "Third message content"
user: @user1
}]
it "should call getUserDetails once and only once for each user", ->
@WebApiManager.getUserDetails.calledWith(@user0.id).should.equal true
@WebApiManager.getUserDetails.calledWith(@user1.id).should.equal true
@WebApiManager.getUserDetails.calledTwice.should.equal true

View file

@ -0,0 +1,240 @@
sinon = require('sinon')
chai = require('chai')
should = chai.should()
expect = chai.expect
modulePath = "../../../../app/js/Features/Rooms/RoomController.js"
SandboxedModule = require('sandboxed-module')
events = require "events"
ObjectId = require("mongojs").ObjectId
class MockClient
params: {}
get: (key, callback = (error, value) ->) ->
callback null, @params[key]
describe "RoomController", ->
beforeEach ->
@SocketManager =
getClientAttributes: sinon.stub()
@RoomController = SandboxedModule.require modulePath, requires:
"../Authorization/AuthorizationManager": @AuthorizationManager = {}
"../Sockets/SocketManager": @SocketManager
"../Rooms/RoomManager": @RoomManager = {}
"logger-sharelatex": @logger = { log: sinon.stub() }
@project_id = ObjectId().toString()
@room_id = ObjectId().toString()
@room =
_id: ObjectId(@room_id)
project_id: ObjectId(@project_id)
@callback = sinon.stub()
@client =
params: {}
get: (key, callback = (error, value) ->) -> callback null, @params[key]
describe "joinRoom", ->
describe "when the client is authorized", ->
beforeEach ->
@AuthorizationManager.canClientJoinProjectRoom = sinon.stub().callsArgWith(2, null, true)
@RoomManager.findOrCreateRoom = sinon.stub().callsArgWith(1, null, @room)
@RoomController._addClientToRoom = sinon.stub().callsArg(2)
@RoomController._getClientsInRoom = sinon.stub().callsArgWith(1, null, @clients = ["client1", "client2"])
@RoomController.joinRoom @client, { room: project_id: @project_id }, @callback
it "should check that the client can join the room", ->
@AuthorizationManager.canClientJoinProjectRoom
.calledWith(@client, @project_id)
.should.equal true
it "should ensure that the room exists", ->
@RoomManager.findOrCreateRoom
.calledWith({ project_id: @project_id })
.should.equal true
it "should put the client into the room", ->
@RoomController._addClientToRoom
.calledWith(@client, @room_id)
.should.equal true
it "should get the clients already in the room", ->
@RoomController._getClientsInRoom
.calledWith(@room_id)
.should.equal true
it "should call the callback with the room id", ->
@callback.calledWith(null, {
room:
id: @room_id
connectedUsers: @clients
}).should.equal true
describe "when the client is not authorized", ->
beforeEach ->
@AuthorizationManager.canClientJoinProjectRoom = sinon.stub().callsArgWith(2, null, false)
@RoomController._addClientToRoom = sinon.stub().callsArg(2)
@RoomController.joinRoom @client, { room: project_id: @project_id }, @callback
it "should not put the client into the room", ->
@RoomController._addClientToRoom.called.should.equal false
it "should call the callback with an error that gives nothing away", ->
@callback.calledWith("unknown room").should.equal true
describe "leaveAllRooms", ->
beforeEach ->
@client = new MockClient()
@client.params =
id: "client-1-id"
first_name: "Douglas"
last_name: "Adams"
email: "doug@sharelatex.com"
gravatar_url: "//gravatar/url/1"
@room_ids = ["room-id-1", "room-id-2"]
@SocketManager.getRoomIdsClientHasJoined = sinon.stub().callsArgWith(1, null, @room_ids)
@RoomController.leaveRoom = sinon.stub().callsArg(2)
@RoomController.leaveAllRooms @client, @callback
it "should get the rooms the client has joined", ->
@SocketManager.getRoomIdsClientHasJoined
.calledWith(@client)
.should.equal true
it "should leave each room", ->
for room_id in @room_ids
@RoomController.leaveRoom
.calledWith(@client, room_id)
.should.equal true
it "should call the callback", ->
@callback.called.should.equal true
describe "leaveRoom", ->
beforeEach ->
@client = new MockClient()
@client.params =
id: "client-1-id"
first_name: "Douglas"
last_name: "Adams"
email: "doug@sharelatex.com"
gravatar_url: "//gravatar/url/1"
@RoomController._getClientAttributes = sinon.stub().callsArgWith(1, null, @client.params)
@SocketManager.removeClientFromRoom = sinon.stub().callsArg(2)
@SocketManager.emitToRoom = sinon.stub()
@RoomController.leaveRoom @client, @room_id, @callback
it "should leave the room", ->
@SocketManager.removeClientFromRoom
.calledWith(@client, @room_id)
.should.equal true
it "should tell the other clients in the room that we have left", ->
@SocketManager.emitToRoom
.calledWith(@room_id, "userLeft", {
room:
id: @room_id
user:
id : @client.params["id"]
first_name : @client.params["first_name"]
last_name : @client.params["last_name"]
email : @client.params["email"]
gravatar_url : @client.params["gravatar_url"]
})
.should.equal true
it "should call the callback", ->
@callback.called.should.equal true
describe "_getClientsInRoom", ->
beforeEach ->
@client1 = new MockClient()
@client1.params =
id: "client-1-id"
first_name: "Douglas"
last_name: "Adams"
email: "doug@sharelatex.com"
gravatar_url: "//gravatar/url/1"
@client2 = new MockClient()
@client2.params =
id: "client-2-id"
first_name: "James"
last_name: "Allen"
email: "james@sharelatex.com"
gravatar_url: "//gravatar/url/2"
@clients = [ @client1, @client2 ]
callCount = 0
@RoomController._getClientAttributes = (ignore, cb)=>
if callCount == 0
callCount++
cb(null, @client1.params)
else
cb null, @client2.params
@SocketManager.getClientsInRoom = sinon.stub().callsArgWith(1, null, @clients)
@RoomController._getClientsInRoom(@room_id, @callback)
it "should get the socket.io clients in the room", ->
@SocketManager.getClientsInRoom
.calledWith(@room_id)
.should.equal true
it "should return a formatted array of clients", ->
@callback
.calledWith(null, [{
id : @client1.params["id"]
first_name : @client1.params["first_name"]
last_name : @client1.params["last_name"]
email : @client1.params["email"]
gravatar_url : @client1.params["gravatar_url"]
}, {
id : @client2.params["id"]
first_name : @client2.params["first_name"]
last_name : @client2.params["last_name"]
email : @client2.params["email"]
gravatar_url : @client2.params["gravatar_url"]
}])
.should.equal true
describe "_addClientToRoom", ->
beforeEach ->
@client = new MockClient()
@client.params =
id: "client-1-id"
first_name: "Douglas"
last_name: "Adams"
email: "doug@sharelatex.com"
gravatar_url: "//gravatar/url/1"
@RoomController._getClientAttributes = sinon.stub().callsArgWith(1, null, @client.params)
@SocketManager.addClientToRoom = sinon.stub().callsArg(2)
@SocketManager.emitToRoom = sinon.stub()
@RoomController._addClientToRoom(@client, @room_id, @callback)
it "should add the client to the room", ->
@SocketManager.addClientToRoom
.calledWith(@client, @room_id)
.should.equal true
it "should tell the room that the client has been added", ->
@SocketManager.emitToRoom
.calledWith(@room_id, "userJoined", {
room:
id: @room_id
user:
id : @client.params["id"]
first_name : @client.params["first_name"]
last_name : @client.params["last_name"]
email : @client.params["email"]
gravatar_url : @client.params["gravatar_url"]
})
.should.equal true
it "should call the callback", ->
@callback.called.should.equal true

View file

@ -0,0 +1,58 @@
sinon = require('sinon')
chai = require('chai')
should = chai.should()
expect = chai.expect
modulePath = "../../../../app/js/Features/Rooms/RoomManager.js"
SandboxedModule = require('sandboxed-module')
events = require "events"
mongojs = require "mongojs"
ObjectId = mongojs.ObjectId
describe "RoomManager", ->
beforeEach ->
@RoomManager = SandboxedModule.require modulePath, requires:
"../../mongojs":
db: @db = { rooms: {} }
ObjectId: ObjectId
@callback = sinon.stub()
describe "findOrCreateRoom", ->
describe "when the room exists", ->
beforeEach ->
@project_id = ObjectId().toString()
@room =
_id: ObjectId()
project_id: ObjectId(@project_id)
@db.rooms.findOne = sinon.stub().callsArgWith(1, null, @room)
@RoomManager.findOrCreateRoom(project_id: @project_id, @callback)
it "should look up the room based on the query", ->
@db.rooms.findOne
.calledWith(project_id: ObjectId(@project_id))
.should.equal true
it "should return the room in the callback", ->
@callback
.calledWith(null, @room)
.should.equal true
describe "when the room does not exist", ->
beforeEach ->
@project_id = ObjectId().toString()
@room =
_id: ObjectId()
project_id: ObjectId(@project_id)
@db.rooms.findOne = sinon.stub().callsArgWith(1, null, null)
@db.rooms.save = sinon.stub().callsArgWith(1, null, @room)
@RoomManager.findOrCreateRoom(project_id: @project_id, @callback)
it "should create the room", ->
@db.rooms.save
.calledWith(project_id: ObjectId(@project_id))
.should.equal true
it "should return the room in the callback", ->
@callback
.calledWith(null, @room)
.should.equal true

View file

@ -0,0 +1,75 @@
SandboxedModule = require('sandboxed-module')
sinon = require('sinon')
require('chai').should()
modulePath = "../../../../app/js/Features/Sockets/RealTimeEventManager.js"
describe "RealTimeEventManager", ->
beforeEach ->
@settings =
redis:
web:
host: "host here"
port: "port here"
password: "password here"
@RealTimeEventManager = SandboxedModule.require modulePath, requires:
"redis":
createClient: () ->
auth:->
"../../server" : io: @io = {}
"settings-sharelatex":@settings
@RealTimeEventManager.rclientPub = publish: sinon.stub()
@RealTimeEventManager.rclientSub =
subscribe: sinon.stub()
on: sinon.stub()
@room_id = "room-id-here"
@message = "message-to-chat-here"
@payload = ["argument one", 42]
describe "emitToRoom", ->
beforeEach ->
@RealTimeEventManager.emitToRoom(@room_id, @message, @payload...)
it "should publish the message to redis", ->
@RealTimeEventManager.rclientPub.publish
.calledWith("chat-events", JSON.stringify(
room_id: @room_id,
message: @message
payload: @payload
))
.should.equal true
describe "listenForChatEvents", ->
beforeEach ->
@RealTimeEventManager._processEditorEvent = sinon.stub()
@RealTimeEventManager.listenForChatEvents()
it "should subscribe to the chat-events channel", ->
@RealTimeEventManager.rclientSub.subscribe
.calledWith("chat-events")
.should.equal true
it "should process the events with _processEditorEvent", ->
@RealTimeEventManager.rclientSub.on
.calledWith("message", sinon.match.func)
.should.equal true
describe "_processEditorEvent", ->
describe "with a designated room", ->
beforeEach ->
@io.sockets =
in: sinon.stub().returns(emit: @emit = sinon.stub())
data = JSON.stringify
room_id: @room_id
message: @message
payload: @payload
@RealTimeEventManager._processEditorEvent("chat-events", data)
it "should send the message to all clients in the room", ->
@io.sockets.in
.calledWith(@room_id)
.should.equal true
@emit.calledWith(@message, @payload...).should.equal true

View file

@ -0,0 +1,12 @@
sinon = require('sinon')
chai = require('chai')
should = chai.should()
expect = chai.expect
modulePath = "../../../../app/js/Features/Users/UserFormatter.js"
SandboxedModule = require('sandboxed-module')
events = require "events"
describe "UserFormatter", ->
beforeEach ->
@UserFormatter = SandboxedModule.require modulePath, requires: {}