diff --git a/.gitignore b/.gitignore index 1c3273d..fa7e9cf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store node_modules npm-debug.log src/public/main.js diff --git a/.travis.yml b/.travis.yml index eb25b5f..0c9dcff 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,6 +20,6 @@ before_script: - "/sbin/start-stop-daemon --start --quiet --pidfile /tmp/custom_xvfb_99.pid --make-pidfile --background --exec /usr/bin/Xvfb -- :99 -ac -screen 0 1280x1024x16" - sleep 5 # give xvfb some time to start - gulp bundle - - node index.js & + - npm start & - sleep 5 -script: node_modules/mocha/bin/mocha test/unit --compilers js:babel-core/register && node_modules/nightwatch/bin/nightwatch --test test/acceptance/index.js --config test/acceptance/nightwatch.json -e chrome +script: npm run test-travis diff --git a/gulpfile.babel.js b/gulpfile.babel.js index 4231a4b..248d80c 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -62,7 +62,7 @@ gulp.task('test', function() { let acceptanceTest = spawn( 'node_modules/nightwatch/bin/nightwatch', - ['--test', 'test/acceptance/index.js', '--config', 'test/acceptance/nightwatch.json'], + ['--test', 'test/acceptance/index.js', '--config', 'test/acceptance/nightwatch-local.json'], {stdio: 'inherit'} ); diff --git a/package.json b/package.json index 53adc9d..a10deac 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "forever": "^0.15.1", "gulp": "^3.9.0", "gulp-uglify": "^1.5.1", + "he": "^0.5.0", "moment": "^2.11.2", "mustache-express": "^1.2.2", "sanitize-html": "^1.11.3", @@ -39,13 +40,10 @@ "vinyl-source-stream": "^1.1.0" }, "scripts": { - "dev": "gulp start", - "test": "gulp test" - }, - "scripts": { - "start": "gulp start", + "start": "npm run bundle && gulp start", "bundle": "gulp bundle", - "test": "gulp test" + "test": "npm run bundle && gulp test", + "test-travis": "node_modules/mocha/bin/mocha test/unit --compilers js:babel-core/register && node_modules/nightwatch/bin/nightwatch --test test/acceptance/index.js --config test/acceptance/nightwatch.json -e chrome" }, "author": "Daniel Seripap", "license": "MIT" diff --git a/readme.md b/readme.md index c167549..60da626 100644 --- a/readme.md +++ b/readme.md @@ -1,5 +1,7 @@ # Darkwire.io +[![Build Status](https://travis-ci.org/seripap/darkwire.io.svg?branch=master)](https://travis-ci.org/seripap/darkwire.io) [![GitHub release](https://img.shields.io/github/release/seripap/darkwire.io.svg)]() + Simple encrypted web chat. Powered by [socket.io](http://socket.io) and the [web cryptography API](https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto). ### Installation @@ -10,11 +12,12 @@ Simple encrypted web chat. Powered by [socket.io](http://socket.io) and the [web # Bundle JS files (for deployment) npm bundle - # Start a local instance of darkwire + # Running tests locally + brew install chromedriver # Installs chromedriver to /usr/local/bin + npm test + # Start a local instance of darkwire / for dev npm start -Create a **.secret** file in the **/src** folder with a your session secret. It doesn't matter what it is- just keep it private. - Darkwire is now running on `http://localhost:3000` ### Deployment diff --git a/src/js/chat.js b/src/js/chat.js index ac2aae8..c9b6e9c 100644 --- a/src/js/chat.js +++ b/src/js/chat.js @@ -1,5 +1,6 @@ import _ from 'underscore'; import sanitizeHtml from 'sanitize-html'; +import he from 'he'; export default class Chat { constructor(darkwire, socket) { @@ -172,11 +173,10 @@ export default class Chat { return this.log('Username must start with a letter or number.', {error: true}); } - this.darkwire.updateUsername(newUsername).then((socketData) => { + this.darkwire.updateUsername(window.username, newUsername).then((socketData) => { let modifiedSocketData = { username: window.username, - newUsername: socketData.username, - publicKey: socketData.publicKey + newUsername: socketData.username }; this.socket.emit('update user', modifiedSocketData); @@ -302,7 +302,10 @@ export default class Chat { if (messageType === 'action') { $usernameDiv.css('color','').prepend('*'); } - $messageBodyDiv.html(unescape(data.message)); + let unescapedMessage = unescape(data.message); + let lineBreaks = /<br \/>/g; + unescapedMessage = unescapedMessage.replace(lineBreaks, '
'); + $messageBodyDiv.html(unescapedMessage); } else { $messageBodyDiv.html(this.darkwire.addFileToQueue(data)); } diff --git a/src/js/darkwire.js b/src/js/darkwire.js index 284c0b6..1c1d3c8 100644 --- a/src/js/darkwire.js +++ b/src/js/darkwire.js @@ -51,12 +51,35 @@ export default class Darkwire { return this._connected; } + get audio() { + return this._audio; + } + get users() { return this._users; } - get audio() { - return this._audio; + getUserById(id) { + return _.findWhere(this._users, {id: id}); + } + + getUserByName(username) { + return _.findWhere(this._users, {username: username}); + } + + updateUser(data) { + return new Promise((resolve, reject) => { + let user = this.getUserById(data.id); + + if (!user) { + return reject(); + } + + let oldUsername = user.username; + + user.username = data.username; + resolve(oldUsername); + }); } addUser(data) { @@ -97,27 +120,40 @@ export default class Darkwire { return this._users; } - updateUsername(username) { + updateUsername(username, newUsername) { + let user = null; + return new Promise((resolve, reject) => { + if (newUsername) { + user = this.getUserByName(username); + } + if (username) { - Promise.all([ - this._cryptoUtil.createPrimaryKeys() - ]) - .then((data) => { - this._keys = { - public: data[0].publicKey, - private: data[0].privateKey - }; - return Promise.all([ - this._cryptoUtil.exportKey(data[0].publicKey, 'spki') - ]); - }) - .then((exportedKeys) => { - resolve({ - username: username, - publicKey: exportedKeys[0] + if (!user) { + Promise.all([ + this._cryptoUtil.createPrimaryKeys() + ]) + .then((data) => { + this._keys = { + public: data[0].publicKey, + private: data[0].privateKey + }; + return Promise.all([ + this._cryptoUtil.exportKey(data[0].publicKey, 'spki') + ]); + }) + .then((exportedKeys) => { + resolve({ + username: username, + publicKey: exportedKeys[0] + }); }); - }); + } else { + resolve({ + username: newUsername, + publicKey: user.publicKey + }); + } } }); } diff --git a/src/js/main.js b/src/js/main.js index 4b66ab3..ba820e7 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -5,6 +5,7 @@ import CryptoUtil from './crypto'; import Chat from './chat'; import moment from 'moment'; import sanitizeHtml from 'sanitize-html'; +import he from 'he'; let fs = window.RequestFileSystem || window.webkitRequestFileSystem; @@ -61,25 +62,21 @@ $(function() { // Prevents input from having injected markup function cleanInput(input) { - let message = sanitizeHtml(_.escape(input), { - allowedTags: ['b', 'i', 'em', 'strong', 'a'], - allowedAttributes: { - 'a': ['href'] - } - }); - // let message = $('
').html(input).text(); - message = Autolinker.link(message); - return _.escape(message); + input = input.replace(/\r?\n/g, '
'); + let sanitized = he.encode(input); + sanitized = Autolinker.link(sanitized); + return sanitized; } // Keyboard events $window.keydown(function(event) { // When the client hits ENTER on their keyboard and chat message input is focused - if (event.which === 13 && $('.inputMessage').is(':focus')) { + if (event.which === 13 && !event.shiftKey && $('.inputMessage').is(':focus')) { handleMessageSending(); socket.emit('stop typing'); chat.typing = false; + event.preventDefault(); } }); @@ -107,7 +104,10 @@ $(function() { }); socket.on('user update', (data) => { - updateUser(data); + darkwire.updateUser(data).then((oldUsername) => { + chat.log(oldUsername + ' changed name to ' + data.username); + renderParticipantsList(); + }); }); // Whenever the server emits 'new message', update the chat body @@ -227,20 +227,6 @@ $(function() { }); } - function updateUser(data) { - let logMessage = data.username + ' changed name to '; - darkwire.removeUser(data); - - data.username = data.newUsername; - logMessage += data.username; - let importKeysPromises = darkwire.addUser(data); - Promise.all(importKeysPromises).then(() => { - chat.log(logMessage); - renderParticipantsList(); - }); - - } - window.triggerFileTransfer = function(context) { const fileId = context.getAttribute('data-file'); if (fileId) { diff --git a/src/js/window.js b/src/js/window.js index fedd3ba..90f1df5 100644 --- a/src/js/window.js +++ b/src/js/window.js @@ -31,7 +31,6 @@ export default class WindowHandler { enableFileTransfer() { if (this.fileHandler.isSupported) { - console.log('enabled'); $('#send-file').click((e) => { e.preventDefault(); $('#fileInput').trigger('click'); diff --git a/src/public/style.css b/src/public/style.css index 619489a..9c4d41c 100644 --- a/src/public/style.css +++ b/src/public/style.css @@ -9,7 +9,7 @@ html { -webkit-font-smoothing: antialiased; } -html, body, input { +html, body, input, textarea { font-family: "SourceCodePro-Regular", "HelveticaNeue-Light", @@ -160,6 +160,10 @@ input { font-size: 16px; } +textarea { + font-size: 20px; +} + .log { color: gray; font-size: 70%; @@ -196,19 +200,24 @@ input { /* Input */ +.inputContainer { + position: fixed; + right: 0; + left: 0; + bottom: 0; + width: 100%; + padding-bottom: 10px; +} + .inputMessage{ background: black !important; color: white !important; border: none; border-top: 1px solid #282828; - bottom: 0; height: 60px; - left: 0; outline: none; - padding-left: 10px; - position: fixed; - right: 0; width: 100%; + padding: 10px 75px 10px 10px; /*Fix for inner shadow on iOS*/ -webkit-appearance: none; border-radius: 0px; @@ -218,7 +227,7 @@ input { position: fixed; bottom: 0px; right: 0px; - padding: 15px; + padding: 22px; cursor: pointer; } diff --git a/src/public/vendor/autogrow.js b/src/public/vendor/autogrow.js new file mode 100644 index 0000000..05e93a7 --- /dev/null +++ b/src/public/vendor/autogrow.js @@ -0,0 +1,76 @@ +(function($) +{ + /** + * Auto-growing textareas; technique ripped from Facebook + * + * + * http://github.com/jaz303/jquery-grab-bag/tree/master/javascripts/jquery.autogrow-textarea.js + */ + $.fn.autogrow = function(options) + { + return this.filter('textarea').each(function() + { + var self = this; + var $self = $(self); + var minHeight = $self.height(); + var noFlickerPad = $self.hasClass('autogrow-short') ? 0 : parseInt($self.css('lineHeight')) || 0; + var settings = $.extend({ + preGrowCallback: null, + postGrowCallback: null + }, options ); + + var shadow = $('
').css({ + position: 'absolute', + top: -10000, + left: -10000, + width: $self.width(), + fontSize: $self.css('fontSize'), + fontFamily: $self.css('fontFamily'), + fontWeight: $self.css('fontWeight'), + lineHeight: $self.css('lineHeight'), + resize: 'none', + 'word-wrap': 'break-word' + }).appendTo(document.body); + + var update = function(event) + { + var times = function(string, number) + { + for (var i=0, r=''; i/g, '>') + .replace(/\n$/, '
 ') + .replace(/\n/g, '
') + .replace(/ {2,}/g, function(space){ return times(' ', space.length - 1) + ' ' }); + + // Did enter get pressed? Resize in this keydown event so that the flicker doesn't occur. + if (event && event.data && event.data.event === 'keydown' && event.keyCode === 13) { + val += '
'; + } + + shadow.css('width', $self.width()); + shadow.html(val + (noFlickerPad === 0 ? '...' : '')); // Append '...' to resize pre-emptively. + + var newHeight=Math.max(shadow.height() + noFlickerPad, minHeight); + if(settings.preGrowCallback!=null){ + newHeight=settings.preGrowCallback($self,shadow,newHeight,minHeight); + } + + $self.height(newHeight); + + if(settings.postGrowCallback!=null){ + settings.postGrowCallback($self); + } + } + + $self.change(update).keyup(update).keydown({event:'keydown'},update); + $(window).resize(update); + + update(); + }); + }; +})(jQuery); diff --git a/src/room.js b/src/room.js index 5e11e04..b2659ad 100644 --- a/src/room.js +++ b/src/room.js @@ -93,27 +93,22 @@ class Room { if (data.newUsername.length > 16) { return false; } - this.users = _.without(this.users, socket.user); - let modifiedUser = { - id: socket.user.id, - username: data.newUsername, - publicKey: data.publicKey - }; - - this.users.push(modifiedUser); - - socket.username = data.newUsername; - socket.user = modifiedUser; - - thisIO.emit('user update', { - id: socket.user.id, - username: data.username, - newUsername: data.newUsername, - publicKey: data.publicKey, - users: this.users, - timestamp: new Date() + let user = _.find(this.users, (users) => { + return users === socket.user; }); + if (user) { + user.username = data.newUsername; + socket.username = user.username; + socket.user = user; + + thisIO.emit('user update', { + username: socket.username, + id: socket.user.id, + timestamp: new Date() + }); + } + }); }); diff --git a/src/views/index.mustache b/src/views/index.mustache index 58a643a..e9f1eef 100644 --- a/src/views/index.mustache +++ b/src/views/index.mustache @@ -53,11 +53,13 @@
- -
- - - +
+ +
+ + + +
@@ -173,6 +175,7 @@ + diff --git a/test/acceptance/app.js b/test/acceptance/app.js index 0e63b92..b18c63e 100644 --- a/test/acceptance/app.js +++ b/test/acceptance/app.js @@ -1,7 +1,11 @@ +/*jshint -W030 */ +import App from '../../package.json'; + describe('Darkwire', () => { - describe('starting a room', () => { + describe('Creating a room', () => { + var testingRoom = null; let browser; before((client, done) => { @@ -25,31 +29,31 @@ describe('Darkwire', () => { done(); }); - it('should show welcome modal', () => { + it('Should show welcome modal', () => { browser .waitForElementVisible('#first-modal', 5000) - .assert.containsText('#first-modal .modal-title', 'Welcome to darkwire.io'); + .expect.element('#first-modal').to.be.visible; }); - it('should have correct header', () => { - browser.expect.element('#first-modal .modal-title').text.to.equal('Welcome to darkwire.io'); + it('Should be started with NPM', () => { + browser.expect.element('#first-modal .modal-title').text.to.equal('Welcome to darkwire.io v' + App.version); }); - describe('opening a second window', () => { + describe('Joining chat room', () => { before((client, done) => { browser.url((result) => { let urlSplit = result.value.split('/'); - let roomId = urlSplit[urlSplit.length - 1]; - let url = 'http://localhost:3000/' + roomId; + testingRoom = urlSplit[urlSplit.length - 1]; + let url = 'http://localhost:3000/' + testingRoom; browser.execute(() => { window.open('http://localhost:3000/', '_blank'); }, [], () => { - browser.window_handles((result) => { + browser.windowHandles((result) => { browser.switchWindow(result.value[1], () => { browser.execute((id) => { window.open('http://localhost:3000/' + id, '_self'); - }, [roomId], () => { + }, [testingRoom], () => { done(); }); }); @@ -58,25 +62,26 @@ describe('Darkwire', () => { }); }); - it('should not show welcome modal', () => { + it('Should not show welcome modal', () => { browser.assert.hidden('#first-modal'); }); - describe('sending messages', () => { + describe('Sending chat message', () => { before((client, done) => { browser.waitForElementPresent('ul.users li:nth-child(2)', 5000, () => { - browser.setValue('input.inputMessage', ['Hello world', browser.Keys.RETURN], () => { + browser.setValue('textarea.inputMessage', ['Hello world!', browser.Keys.RETURN], () => { done(); }); }); }); - it('should work', () => { - browser.window_handles((result) => { + it('Should send a message', () => { + browser.windowHandles((result) => { browser.switchWindow(result.value[0], () => { browser.waitForElementPresent('span.messageBody', 5000, () => { - browser.assert.containsText('span.messageBody', 'Hello world'); + browser.pause(2000); + browser.assert.containsText('span.messageBody', 'Hello world!'); }); }); }); @@ -86,5 +91,129 @@ describe('Darkwire', () => { }); + describe('Slash Commands', () => { + + before((client, done) => { + let url = 'http://localhost:3000/' + testingRoom; + browser.url(url, () => { + browser.windowHandles((result) => { + browser.switchWindow(result.value[0], () => { + browser.execute((id) => { + window.open('http://localhost:3000/' + id, '_self'); + }, [testingRoom], () => { + done(); + }); + }); + }); + }); + }); + + describe('/me', () => { + + before((client, done) => { + browser.windowHandles((result) => { + browser.switchWindow(result.value[0], () => { + browser.waitForElementPresent('ul.users li:nth-child(2)', 5000, () => { + browser.setValue('textarea.inputMessage', ['/me is no stranger to love', browser.Keys.RETURN], () => { + done(); + }); + }); + }); + }); + }); + + it('Should express an interactive action', () => { + browser.windowHandles((result) => { + browser.switchWindow(result.value[0], () => { + browser.waitForElementPresent('span.messageBody', 5000, () => { + browser.pause(5000); + browser.assert.containsText('.action span.messageBody', 'is no stranger to love'); + }); + }); + }); + }); + + }); + + describe('/nick', () => { + + before((client, done) => { + browser.url('http://localhost:3000/' + testingRoom, () => { + browser.waitForElementPresent('ul.users li:nth-child(2)', 5000, () => { + browser.setValue('textarea.inputMessage', ['/nick rickAnsley', browser.Keys.RETURN], () => { + done(); + }); + }); + }); + }); + + it('Should change username', () => { + browser.windowHandles((result) => { + browser.switchWindow(result.value[3], () => { + browser.pause(5000); + browser.assert.containsText('.log:last-child', 'rickAnsley'); + }); + }); + }); + + }); + + }); + + describe('Before file transfer: Image: Confirm sending', () => { + + before((client, done) => { + let url = 'http://localhost:3000/' + testingRoom; + browser.url(url, () => { + browser.windowHandles((result) => { + browser.switchWindow(result.value[0], () => { + browser.execute((id) => { + window.open('http://localhost:3000/' + id, '_self'); + }, [testingRoom], () => { + browser.waitForElementPresent('#send-file', 5000, () => { + browser.execute(() => { + $('input[name="fileUploader"]').show(); + }, [], () => { + browser.waitForElementPresent('input[name="fileUploader"]', 5000, () => { + let testFile = __dirname + '/ricky.jpg'; + browser.setValue('input[name="fileUploader"]', testFile, (result) => { + done(); + }); + }); + }); + }); + }); + }); + }); + }); + }); + + it('Should prompt user confirmation', () => { + browser.windowHandles((result) => { + browser.switchWindow(result.value[0], () => { + browser.waitForElementPresent('span.messageBody', 5000, () => { + browser.pause(5000); + browser.assert.containsText('span.messageBody', 'You are about to send ricky.jpg to all parties in this chat. Confirm | Cancel'); + }); + }); + }); + }); + + it('Should show sent confirmation message', () => { + browser.windowHandles((result) => { + browser.switchWindow(result.value[0], () => { + browser.waitForElementPresent('span.messageBody a:first-child', 5000, () => { + browser.click('span.messageBody a:first-child', () => { + browser.waitForElementNotPresent('span.messageBody a:first-child', 5000, () => { + browser.assert.containsText('span.messageBody', 'Sent ricky.jpg'); + }); + }); + }); + }); + }); + }); + + }); + }); }); diff --git a/test/acceptance/fileUtility.js b/test/acceptance/fileUtility.js new file mode 100644 index 0000000..2a8371a --- /dev/null +++ b/test/acceptance/fileUtility.js @@ -0,0 +1 @@ +($('#fileInput').show)(); diff --git a/test/acceptance/nightwatch-local.json b/test/acceptance/nightwatch-local.json new file mode 100644 index 0000000..a5fd87c --- /dev/null +++ b/test/acceptance/nightwatch-local.json @@ -0,0 +1,52 @@ +{ + "src_folders" : ["test"], + "output_folder" : "reports", + "custom_commands_path" : "", + "custom_assertions_path" : "", + "page_objects_path" : "", + "globals_path" : "", + "test_runner" : "mocha", + "selenium" : { + "start_process" : true, + "server_path" : "test/acceptance/bin/selenium-server-standalone-2.52.0.jar", + "log_path" : false, + "host" : "127.0.0.1", + "port" : 4444, + "cli_args" : { + "webdriver.chrome.driver" : "/usr/local/bin/chromedriver", + "webdriver.ie.driver" : "" + } + }, + + "test_settings" : { + "default" : { + "launch_url" : "http://localhost", + "selenium_port" : 4444, + "selenium_host" : "localhost", + "silent": true, + "screenshots" : { + "enabled" : false, + "path" : "" + }, + "desiredCapabilities": { + "browserName": "chrome", + "javascriptEnabled": true, + "acceptSslCerts": true, + "chromeOptions" : { + "binary": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" + } + } + }, + + "chrome" : { + "desiredCapabilities": { + "browserName": "chrome", + "javascriptEnabled": true, + "acceptSslCerts": true, + "chromeOptions" : { + "args" : ["-e", "--no-sandbox"] + } + } + } + } +} diff --git a/test/acceptance/ricky.jpg b/test/acceptance/ricky.jpg new file mode 100644 index 0000000..5bca4a8 Binary files /dev/null and b/test/acceptance/ricky.jpg differ