From 8cb71e8fadf289bd0436a026166235d2008ff7f6 Mon Sep 17 00:00:00 2001 From: James Allen Date: Fri, 15 Aug 2014 10:50:36 +0100 Subject: [PATCH] Initial Open Source commit --- services/chat/.gitignore | 11 + services/chat/Gruntfile.coffee | 124 + services/chat/app.coffee | 5 + .../AuthenticationController.coffee | 23 + .../Authorization/AuthorizationManager.coffee | 24 + .../Messages/MessageController.coffee | 84 + .../Features/Messages/MessageFormatter.coffee | 16 + .../Messages/MessageHttpController.coffee | 62 + .../Features/Messages/MessageManager.coffee | 52 + .../Features/Rooms/RoomController.coffee | 101 + .../coffee/Features/Rooms/RoomManager.coffee | 18 + .../Sockets/RealTimeEventManager.coffee | 25 + .../Features/Sockets/SocketManager.coffee | 40 + .../Features/Users/UserFormatter.coffee | 18 + .../Features/WebApi/WebApiManager.coffee | 32 + services/chat/app/coffee/metrics.coffee | 22 + services/chat/app/coffee/mongojs.coffee | 6 + services/chat/app/coffee/router.coffee | 31 + services/chat/app/coffee/server.coffee | 52 + services/chat/config/settings.defaults.coffee | 14 + services/chat/package.json | 39 + services/chat/public/app.build.js | 25 + services/chat/public/coffee/chat.coffee | 109 + .../coffee/collections/connectedUsers.coffee | 9 + .../public/coffee/collections/messages.coffee | 45 + .../chat/public/coffee/models/message.coffee | 5 + .../chat/public/coffee/models/room.coffee | 85 + .../chat/public/coffee/models/user.coffee | 13 + .../public/coffee/utils/staticLoader.coffee | 10 + .../public/coffee/views/chatWindowView.coffee | 219 + .../coffee/views/timeMessageBlockView.coffee | 40 + .../coffee/views/userMessageBlockView.coffee | 64 + services/chat/public/jade/templates.jade | 24 + services/chat/public/js/libs/backbone.js | 1571 + services/chat/public/js/libs/jquery.js | 9478 ++++++ .../chat/public/js/libs/jquery.storage.js | 85 + services/chat/public/js/libs/moment.js | 6 + services/chat/public/js/libs/underscore.js | 1227 + services/chat/public/js/r.js | 26058 ++++++++++++++++ services/chat/public/js/text.js | 373 + services/chat/public/less/chat.less | 198 + services/chat/rakefile.rb | 124 + .../AuthenticationControllerTests.coffee | 71 + .../AuthorizationManagerTests.coffee | 55 + .../Messages/MessageControllerTests.coffee | 187 + .../Messages/MessageFormatterTests.coffee | 13 + .../MessageHttpControllerTests.coffee | 173 + .../Messages/MessageManagerTests.coffee | 60 + .../coffee/Rooms/RoomControllerTests.coffee | 240 + .../unit/coffee/Rooms/RoomManagerTests.coffee | 58 + .../Sockets/RealTimeEventManagerTests.coffee | 75 + .../coffee/Users/UserFormatterTests.coffee | 12 + 52 files changed, 41511 insertions(+) create mode 100644 services/chat/.gitignore create mode 100644 services/chat/Gruntfile.coffee create mode 100644 services/chat/app.coffee create mode 100644 services/chat/app/coffee/Features/Authentication/AuthenticationController.coffee create mode 100644 services/chat/app/coffee/Features/Authorization/AuthorizationManager.coffee create mode 100644 services/chat/app/coffee/Features/Messages/MessageController.coffee create mode 100644 services/chat/app/coffee/Features/Messages/MessageFormatter.coffee create mode 100644 services/chat/app/coffee/Features/Messages/MessageHttpController.coffee create mode 100644 services/chat/app/coffee/Features/Messages/MessageManager.coffee create mode 100644 services/chat/app/coffee/Features/Rooms/RoomController.coffee create mode 100644 services/chat/app/coffee/Features/Rooms/RoomManager.coffee create mode 100644 services/chat/app/coffee/Features/Sockets/RealTimeEventManager.coffee create mode 100644 services/chat/app/coffee/Features/Sockets/SocketManager.coffee create mode 100644 services/chat/app/coffee/Features/Users/UserFormatter.coffee create mode 100644 services/chat/app/coffee/Features/WebApi/WebApiManager.coffee create mode 100644 services/chat/app/coffee/metrics.coffee create mode 100644 services/chat/app/coffee/mongojs.coffee create mode 100644 services/chat/app/coffee/router.coffee create mode 100644 services/chat/app/coffee/server.coffee create mode 100644 services/chat/config/settings.defaults.coffee create mode 100644 services/chat/package.json create mode 100644 services/chat/public/app.build.js create mode 100644 services/chat/public/coffee/chat.coffee create mode 100644 services/chat/public/coffee/collections/connectedUsers.coffee create mode 100644 services/chat/public/coffee/collections/messages.coffee create mode 100644 services/chat/public/coffee/models/message.coffee create mode 100644 services/chat/public/coffee/models/room.coffee create mode 100644 services/chat/public/coffee/models/user.coffee create mode 100644 services/chat/public/coffee/utils/staticLoader.coffee create mode 100644 services/chat/public/coffee/views/chatWindowView.coffee create mode 100644 services/chat/public/coffee/views/timeMessageBlockView.coffee create mode 100644 services/chat/public/coffee/views/userMessageBlockView.coffee create mode 100644 services/chat/public/jade/templates.jade create mode 100644 services/chat/public/js/libs/backbone.js create mode 100644 services/chat/public/js/libs/jquery.js create mode 100644 services/chat/public/js/libs/jquery.storage.js create mode 100644 services/chat/public/js/libs/moment.js create mode 100644 services/chat/public/js/libs/underscore.js create mode 100644 services/chat/public/js/r.js create mode 100644 services/chat/public/js/text.js create mode 100644 services/chat/public/less/chat.less create mode 100644 services/chat/rakefile.rb create mode 100644 services/chat/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee create mode 100644 services/chat/test/unit/coffee/Authorization/AuthorizationManagerTests.coffee create mode 100644 services/chat/test/unit/coffee/Messages/MessageControllerTests.coffee create mode 100644 services/chat/test/unit/coffee/Messages/MessageFormatterTests.coffee create mode 100644 services/chat/test/unit/coffee/Messages/MessageHttpControllerTests.coffee create mode 100644 services/chat/test/unit/coffee/Messages/MessageManagerTests.coffee create mode 100644 services/chat/test/unit/coffee/Rooms/RoomControllerTests.coffee create mode 100644 services/chat/test/unit/coffee/Rooms/RoomManagerTests.coffee create mode 100644 services/chat/test/unit/coffee/Sockets/RealTimeEventManagerTests.coffee create mode 100644 services/chat/test/unit/coffee/Users/UserFormatterTests.coffee diff --git a/services/chat/.gitignore b/services/chat/.gitignore new file mode 100644 index 0000000000..cb03337081 --- /dev/null +++ b/services/chat/.gitignore @@ -0,0 +1,11 @@ +**.swp + +app.js +app/js/ +test/unit/js/ +public/build/ + +node_modules/ + +/public/js/chat.js +plato/ diff --git a/services/chat/Gruntfile.coffee b/services/chat/Gruntfile.coffee new file mode 100644 index 0000000000..4ba1604d6f --- /dev/null +++ b/services/chat/Gruntfile.coffee @@ -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'] + diff --git a/services/chat/app.coffee b/services/chat/app.coffee new file mode 100644 index 0000000000..ba8ec252bb --- /dev/null +++ b/services/chat/app.coffee @@ -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" \ No newline at end of file diff --git a/services/chat/app/coffee/Features/Authentication/AuthenticationController.coffee b/services/chat/app/coffee/Features/Authentication/AuthenticationController.coffee new file mode 100644 index 0000000000..2b49cdd8c0 --- /dev/null +++ b/services/chat/app/coffee/Features/Authentication/AuthenticationController.coffee @@ -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) + + diff --git a/services/chat/app/coffee/Features/Authorization/AuthorizationManager.coffee b/services/chat/app/coffee/Features/Authorization/AuthorizationManager.coffee new file mode 100644 index 0000000000..0104f43f64 --- /dev/null +++ b/services/chat/app/coffee/Features/Authorization/AuthorizationManager.coffee @@ -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) + diff --git a/services/chat/app/coffee/Features/Messages/MessageController.coffee b/services/chat/app/coffee/Features/Messages/MessageController.coffee new file mode 100644 index 0000000000..26b3ace7c4 --- /dev/null +++ b/services/chat/app/coffee/Features/Messages/MessageController.coffee @@ -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") + diff --git a/services/chat/app/coffee/Features/Messages/MessageFormatter.coffee b/services/chat/app/coffee/Features/Messages/MessageFormatter.coffee new file mode 100644 index 0000000000..3d77cda1dd --- /dev/null +++ b/services/chat/app/coffee/Features/Messages/MessageFormatter.coffee @@ -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) diff --git a/services/chat/app/coffee/Features/Messages/MessageHttpController.coffee b/services/chat/app/coffee/Features/Messages/MessageHttpController.coffee new file mode 100644 index 0000000000..6417837a86 --- /dev/null +++ b/services/chat/app/coffee/Features/Messages/MessageHttpController.coffee @@ -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 \ No newline at end of file diff --git a/services/chat/app/coffee/Features/Messages/MessageManager.coffee b/services/chat/app/coffee/Features/Messages/MessageManager.coffee new file mode 100644 index 0000000000..affc699692 --- /dev/null +++ b/services/chat/app/coffee/Features/Messages/MessageManager.coffee @@ -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 + diff --git a/services/chat/app/coffee/Features/Rooms/RoomController.coffee b/services/chat/app/coffee/Features/Rooms/RoomController.coffee new file mode 100644 index 0000000000..015cff19ad --- /dev/null +++ b/services/chat/app/coffee/Features/Rooms/RoomController.coffee @@ -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 + diff --git a/services/chat/app/coffee/Features/Rooms/RoomManager.coffee b/services/chat/app/coffee/Features/Rooms/RoomManager.coffee new file mode 100644 index 0000000000..c9820467e8 --- /dev/null +++ b/services/chat/app/coffee/Features/Rooms/RoomManager.coffee @@ -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 + diff --git a/services/chat/app/coffee/Features/Sockets/RealTimeEventManager.coffee b/services/chat/app/coffee/Features/Sockets/RealTimeEventManager.coffee new file mode 100644 index 0000000000..eec636252f --- /dev/null +++ b/services/chat/app/coffee/Features/Sockets/RealTimeEventManager.coffee @@ -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...) \ No newline at end of file diff --git a/services/chat/app/coffee/Features/Sockets/SocketManager.coffee b/services/chat/app/coffee/Features/Sockets/SocketManager.coffee new file mode 100644 index 0000000000..cb70fb1195 --- /dev/null +++ b/services/chat/app/coffee/Features/Sockets/SocketManager.coffee @@ -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 + diff --git a/services/chat/app/coffee/Features/Users/UserFormatter.coffee b/services/chat/app/coffee/Features/Users/UserFormatter.coffee new file mode 100644 index 0000000000..1d703a45c1 --- /dev/null +++ b/services/chat/app/coffee/Features/Users/UserFormatter.coffee @@ -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}" diff --git a/services/chat/app/coffee/Features/WebApi/WebApiManager.coffee b/services/chat/app/coffee/Features/WebApi/WebApiManager.coffee new file mode 100644 index 0000000000..e5e1b5c553 --- /dev/null +++ b/services/chat/app/coffee/Features/WebApi/WebApiManager.coffee @@ -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 diff --git a/services/chat/app/coffee/metrics.coffee b/services/chat/app/coffee/metrics.coffee new file mode 100644 index 0000000000..b2e7c3dad8 --- /dev/null +++ b/services/chat/app/coffee/metrics.coffee @@ -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 diff --git a/services/chat/app/coffee/mongojs.coffee b/services/chat/app/coffee/mongojs.coffee new file mode 100644 index 0000000000..134fcd05ae --- /dev/null +++ b/services/chat/app/coffee/mongojs.coffee @@ -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 diff --git a/services/chat/app/coffee/router.coffee b/services/chat/app/coffee/router.coffee new file mode 100644 index 0000000000..36deca62d1 --- /dev/null +++ b/services/chat/app/coffee/router.coffee @@ -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) + + diff --git a/services/chat/app/coffee/server.coffee b/services/chat/app/coffee/server.coffee new file mode 100644 index 0000000000..551c2f4e02 --- /dev/null +++ b/services/chat/app/coffee/server.coffee @@ -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() + diff --git a/services/chat/config/settings.defaults.coffee b/services/chat/config/settings.defaults.coffee new file mode 100644 index 0000000000..d37bb95a2e --- /dev/null +++ b/services/chat/config/settings.defaults.coffee @@ -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: "" \ No newline at end of file diff --git a/services/chat/package.json b/services/chat/package.json new file mode 100644 index 0000000000..1a357f4fa9 --- /dev/null +++ b/services/chat/package.json @@ -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" + } +} diff --git a/services/chat/public/app.build.js b/services/chat/public/app.build.js new file mode 100644 index 0000000000..1614b5cb7b --- /dev/null +++ b/services/chat/public/app.build.js @@ -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 +}) \ No newline at end of file diff --git a/services/chat/public/coffee/chat.coffee b/services/chat/public/coffee/chat.coffee new file mode 100644 index 0000000000..473c916f83 --- /dev/null +++ b/services/chat/public/coffee/chat.coffee @@ -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 diff --git a/services/chat/public/coffee/collections/connectedUsers.coffee b/services/chat/public/coffee/collections/connectedUsers.coffee new file mode 100644 index 0000000000..eee80e014c --- /dev/null +++ b/services/chat/public/coffee/collections/connectedUsers.coffee @@ -0,0 +1,9 @@ +define [ + "libs/backbone" + "models/user" +], (Backbone, User) -> + ConnectedUsers = Backbone.Collection.extend + model: User + + initialize: (models, options) -> + {@chat, @room} = options diff --git a/services/chat/public/coffee/collections/messages.coffee b/services/chat/public/coffee/collections/messages.coffee new file mode 100644 index 0000000000..d054df89f2 --- /dev/null +++ b/services/chat/public/coffee/collections/messages.coffee @@ -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 \ No newline at end of file diff --git a/services/chat/public/coffee/models/message.coffee b/services/chat/public/coffee/models/message.coffee new file mode 100644 index 0000000000..5b83fe686a --- /dev/null +++ b/services/chat/public/coffee/models/message.coffee @@ -0,0 +1,5 @@ +define [ + "libs/backbone" +], (Backbone) -> + + Message = Backbone.Model.extend {} \ No newline at end of file diff --git a/services/chat/public/coffee/models/room.coffee b/services/chat/public/coffee/models/room.coffee new file mode 100644 index 0000000000..df51bb57e1 --- /dev/null +++ b/services/chat/public/coffee/models/room.coffee @@ -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 \ No newline at end of file diff --git a/services/chat/public/coffee/models/user.coffee b/services/chat/public/coffee/models/user.coffee new file mode 100644 index 0000000000..103e9c9c5f --- /dev/null +++ b/services/chat/public/coffee/models/user.coffee @@ -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 \ No newline at end of file diff --git a/services/chat/public/coffee/utils/staticLoader.coffee b/services/chat/public/coffee/utils/staticLoader.coffee new file mode 100644 index 0000000000..763f322087 --- /dev/null +++ b/services/chat/public/coffee/utils/staticLoader.coffee @@ -0,0 +1,10 @@ +define [ + "text!html/templates.html" + "text!css/chat.css" +], (templates, css)-> + + appendAssets : -> + $(document.body).append($(templates)) + style = $("