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 ef76abd..248d80c 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -21,6 +21,17 @@ gulp.task('bundle', function() { .pipe(gulp.dest('src/public')); }); +gulp.task('dev', function() { + return browserify('src/js/main.js', { + debug: true + }).transform(babel.configure({ + presets: ['es2015'] + })).bundle() + .pipe(source('main.js')) + .pipe(buffer()) + .pipe(gulp.dest('src/public')); +}); + gulp.task('start', function() { nodemon({ script: 'index.js', @@ -29,7 +40,7 @@ gulp.task('start', function() { env: { 'NODE_ENV': 'development' }, - tasks: ['bundle'] + tasks: ['dev'] }); }); @@ -51,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 0b8337b..fec2c2b 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,13 @@ "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", "serve-favicon": "^2.3.0", "shortid": "^2.2.4", + "slug": "^0.9.1", "socket.io": "^1.4.0", "underscore": "^1.8.3", "uuid": "^2.0.1" @@ -36,8 +40,11 @@ "vinyl-source-stream": "^1.1.0" }, "scripts": { - "dev": "gulp start", - "test": "gulp test" + "start": "npm run bundle && node index.js", + "dev": "npm run bundle && gulp start", + "bundle": "gulp bundle", + "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 253f612..0f866e9 100644 --- a/readme.md +++ b/readme.md @@ -1,31 +1,30 @@ # 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 - - # For es6 compatability, be sure you have the latest stable version of Node JS installed + # Get latest version of NodeJS for ES2015 support npm install -g n n stable - npm install - # Bundle JS files - npm run bundle + # Starting dev environment + npm run dev + + # Running tests locally + brew install chromedriver + npm test # Start a local instance of darkwire npm start -Create a **.secret** file in **/src** folder with a your session secret. It doesn't matter what it is- just keep it private. + # Changing ports, default is 3000 + port=3000 npm start Darkwire is now running on `http://localhost:3000` -### Deployment - -Build source - - gulp bundle - ### How it works Darkwire uses a combination of asymmetric encryption (RSA-OAEP), symmetric session keys (AES-CBC) and signing keys (HMAC) for security. @@ -48,11 +47,11 @@ Darkwire does not provide any guarantee that the person you're communicating wit ## File Transfer -Files are not transferred over the wire-only the file name and extension. Darkwire encodes documents into base64 using [btoa](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/btoa) and is encrypted the same way chat messages are. +Darkwire encodes documents (up to 1MB) into base64 using [btoa](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/btoa) and is encrypted the same way chat messages are. 1. When a file is "uploaded", the document is encoded on the client and the server recieves the encrypted base64 string. 2. The server sends the encrypted base64 string to clients in the same chat room. -3. Clients recieving the encrypted base64 string then decrypts the string, then decodes the base64 string using [atob](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/atob). +3. Clients recieving the encrypted base64 string then decrypts and decodes the base64 string using [atob](https://developer.mozilla.org/en-US/docs/Web/API/WindowBase64/atob). ## Sockets & Server diff --git a/src/app.js b/src/app.js index 343660a..7d3193d 100644 --- a/src/app.js +++ b/src/app.js @@ -50,7 +50,13 @@ app.get('/:roomId', (req, res) => { let roomExists = _.findWhere(rooms, {_id: roomId}) || false; if (roomExists) { - return res.render('index', {username: shortid.generate()}); + return res.render('index', { + APP: { + version: process.env.npm_package_version, + ip: req.headers['x-forwarded-for'] + }, + username: shortid.generate() + }); } return res.redirect('/'); diff --git a/src/js/chat.js b/src/js/chat.js new file mode 100644 index 0000000..c9b6e9c --- /dev/null +++ b/src/js/chat.js @@ -0,0 +1,345 @@ +import _ from 'underscore'; +import sanitizeHtml from 'sanitize-html'; +import he from 'he'; + +export default class Chat { + constructor(darkwire, socket) { + this.usernamesInMemory = []; + this.FADE_TIME = 150; // ms + this.TYPING_TIMER_LENGTH = 400; // ms + this.typing = false; + this.lastTypingTime = null; + this.darkwire = darkwire; + this.socket = socket; + this.messages = $('.messages'); // Messages area + this.inputMessage = $('.inputMessage'); // Input message input box + this.chatPage = $('.chat.page'); + this.bindEvents(); + } + + // Log a message + log(message, options) { + let html = options && options.html === true || false; + let $el; + + let matchedUsernames = this.checkIfUsername(message.split(' ')); + + if (matchedUsernames.length > 0) { + for (let i = 0; i < matchedUsernames.length; i++) { + let usernameContainer = $('') + .text(matchedUsernames[i]) + .css('color', this.getUsernameColor(matchedUsernames[i])); + message = message.replace(matchedUsernames[i], usernameContainer.prop('outerHTML')); + } + } + + if (options && options.error) { + $el = $('
  • ').addClass('log').html(message); + } else if (options && options.info) { + $el = $('
  • ').addClass('log').html(message); + } else { + $el = $('
  • ').addClass('log').html(message); + } + + this.addMessageElement($el, options); + } + + checkIfUsername(words) { + let matchedUsernames = []; + this.darkwire.users.forEach((user) => { + let usernameMatch = new RegExp('^' + user.username + '$', 'g'); + for (let i = 0; i < words.length; i++) { + let exactMatch = words[i].match(usernameMatch) || false; + let usernameInMemory = this.usernamesInMemory.indexOf(words[i]) > -1; + + if (exactMatch && exactMatch.length > -1 || usernameInMemory) { + if (!usernameInMemory) { + this.usernamesInMemory.push(words[i]); + } + matchedUsernames.push(words[i]); + } + } + }); + return matchedUsernames; + } + + // Gets the color of a username through our hash function + getUsernameColor(username) { + const COLORS = [ + '#e21400', '#ffe400', '#ff8f00', + '#58dc00', '#dd9cff', '#4ae8c4', + '#3b88eb', '#f47777', '#d300e7', + '#99FF33', '#99CC33', '#999933', + '#996633', '#993333', '#990033', + ]; + // Compute hash code + let hash = 7; + for (let i = 0; i < username.length; i++) { + hash = username.charCodeAt(i) + (hash << 5) - hash; + } + // Calculate color + let index = Math.abs(hash % COLORS.length); + return COLORS[index]; + } + + bindEvents() { + var _this = this; + // Select message input when clicking message body, unless selecting text + this.messages.on('click', () => { + if (!this.getSelectedText()) { + this.inputMessage.focus(); + } + }); + + this.inputMessage.on('input propertychange paste change', function() { + _this.updateTyping(); + let message = $(this).val().trim(); + if (message.length) { + $('#send-message-btn').addClass('active'); + } else { + $('#send-message-btn').removeClass('active'); + } + }); + } + + // Updates the typing event + updateTyping() { + if (this.darkwire.connected) { + if (!this.typing) { + this.typing = true; + this.socket.emit('typing'); + } + this.lastTypingTime = (new Date()).getTime(); + + setTimeout(() => { + let typingTimer = (new Date()).getTime(); + let timeDiff = typingTimer - this.lastTypingTime; + if (timeDiff >= this.TYPING_TIMER_LENGTH && this.typing) { + this.socket.emit('stop typing'); + this.typing = false; + } + }, this.TYPING_TIMER_LENGTH); + } + } + + addChatTyping(data) { + data.typing = true; + data.message = 'is typing'; + this.addChatMessage(data); + } + + getSelectedText() { + let text = ''; + if (typeof window.getSelection != 'undefined') { + text = window.getSelection().toString(); + } else if (typeof document.selection != 'undefined' && document.selection.type == 'Text') { + text = document.selection.createRange().text; + } + return text; + } + + getTypingMessages(data) { + return $('.typing.message').filter(function(i) { + return $(this).data('username') === data.username; + }); + } + + removeChatTyping(data) { + this.getTypingMessages(data).fadeOut(function() { + $(this).remove(); + }); + } + + slashCommands(trigger) { + let validCommands = []; + let expectedParams = 0; + const triggerCommands = [{ + command: 'nick', + description: 'Changes nickname.', + paramaters: ['{username}'], + multiple: false, + usage: '/nick {username}', + action: () => { + let newUsername = trigger.params[0] || false; + + if (newUsername > 16) { + return this.log('Username cannot be greater than 16 characters.', {error: true}); + } + + // Remove things that arent digits or chars + newUsername = newUsername.replace(/[^A-Za-z0-9]/g, '-'); + + if (!newUsername.match(/^[A-Z0-9]/i)) { + return this.log('Username must start with a letter or number.', {error: true}); + } + + this.darkwire.updateUsername(window.username, newUsername).then((socketData) => { + let modifiedSocketData = { + username: window.username, + newUsername: socketData.username + }; + + this.socket.emit('update user', modifiedSocketData); + window.username = username = socketData.username; + }); + } + }, { + command: 'help', + description: 'Shows a list of commands.', + paramaters: [], + multiple: false, + usage: '/help', + action: () => { + validCommands = validCommands.map((command) => { + return '/' + command; + }); + + this.log('Valid commands: ' + validCommands.sort().join(', '), {info: true}); + } + }, { + command: 'me', + description: 'Invoke virtual action', + paramaters: ['{action}'], + multiple: true, + usage: '/me {action}', + action: () => { + + expectedParams = 100; + + let actionMessage = trigger.params.join(' '); + + this.darkwire.encodeMessage(actionMessage, 'action').then((socketData) => { + this.addChatMessage({ + username: username, + message: actionMessage, + messageType: 'action' + }); + this.socket.emit('new message', socketData); + }).catch((err) => { + console.log(err); + }); + } + }]; + + const color = () => { + const hexTex = new RegExp(/(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i); + }; + + triggerCommands.forEach((command) => { + validCommands.push(command.command); + }); + + let commandToTrigger = _.findWhere(triggerCommands, {command: trigger.command}); + + if (commandToTrigger) { + expectedParams = commandToTrigger.paramaters.length; + if (expectedParams && trigger.params.length > expectedParams || expectedParams && trigger.params.length < expectedParams) { + if (!commandToTrigger.multple && trigger.params.length < 1) { + return this.log('Missing or too many paramater. Usage: ' + commandToTrigger.usage, {error: true}); + } + } + + return commandToTrigger.action.call(); + } + + this.log(trigger.command + ' is not a valid command. Type /help for a list of valid commands.', {error: true}); + return false; + } + + executeCommand(trigger) { + trigger = trigger || false; + if (trigger) { + let command = trigger.command; + this.slashCommands(trigger); + } + } + + parseCommand(cleanedMessage) { + let trigger = { + command: null, + params: [] + }; + + if (cleanedMessage.indexOf('/') === 0) { + this.inputMessage.val(''); + let parsedCommand = cleanedMessage.replace('/', '').split(' '); + trigger.command = sanitizeHtml(parsedCommand[0]) || null; + // Get params + if (parsedCommand.length >= 2) { + for (let i = 1; i < parsedCommand.length; i++) { + trigger.params.push(parsedCommand[i]); + } + } + + return trigger; + } + + return false; + } + + addChatMessage(data, options) { + if (!data.message.trim().length) { + return; + } + + let messageType = data.messageType || 'text'; + + // Don't fade the message in if there is an 'X was typing' + let $typingMessages = this.getTypingMessages(data); + options = options || {}; + if ($typingMessages.length !== 0) { + options.fade = false; + $typingMessages.remove(); + } + + let $usernameDiv = $('') + .text(data.username) + .css('color', this.getUsernameColor(data.username)); + + let $messageBodyDiv = $(''); + + if (messageType === 'text' || messageType === 'action') { + if (messageType === 'action') { + $usernameDiv.css('color','').prepend('*'); + } + let unescapedMessage = unescape(data.message); + let lineBreaks = /<br \/>/g; + unescapedMessage = unescapedMessage.replace(lineBreaks, '
    '); + $messageBodyDiv.html(unescapedMessage); + } else { + $messageBodyDiv.html(this.darkwire.addFileToQueue(data)); + } + + let typingClass = data.typing ? 'typing' : ''; + let actionClass = data.messageType === 'action' ? 'action' : ''; + + let $messageDiv = $('
  • ') + .data('username', data.username) + .addClass(typingClass) + .addClass(actionClass) + .append($usernameDiv, $messageBodyDiv); + + this.addMessageElement($messageDiv, options); + } + + addMessageElement(el, options) { + let $el = $(el); + + if (!options) { + options = {}; + } + + $el.hide().fadeIn(this.FADE_TIME); + this.messages.append($el); + + this.messages[0].scrollTop = this.messages[0].scrollHeight; // minus 60 for key + } + + replaceMessage(id, message) { + let container = $(id); + if (container) { + container.html(message); + } + } + +} diff --git a/src/js/darkwire.js b/src/js/darkwire.js index 1b13949..e344391 100644 --- a/src/js/darkwire.js +++ b/src/js/darkwire.js @@ -9,9 +9,20 @@ export default class Darkwire { this._myUserId = false; this._connected = false; this._users = []; + this._fileQueue = []; this._keys = {}; } + getFile(id) { + let file = _.findWhere(this._fileQueue, {id: id}) || false; + + if (file) { + // TODO: Destroy object from memory when retrieved + } + + return file.data; + } + get keys() { return this._keys; } @@ -30,12 +41,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) { @@ -76,14 +110,48 @@ export default class Darkwire { return this._users; } - encodeMessage(message, messageType) { + updateUsername(username, newUsername) { + let user = null; + + return new Promise((resolve, reject) => { + if (newUsername) { + user = this.getUserByName(username); + } + + if (username) { + 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 + }); + } + } + }); + } + + encodeMessage(message, messageType, additionalData) { // Don't send unless other users exist return new Promise((resolve, reject) => { - // if (this._users.length <= 1) { - // console.log('rejected:' + this._users); - // reject(); - // return; - // }; + additionalData = additionalData || {}; // if there is a non-empty message and a socket connection if (message && this._connected) { @@ -95,7 +163,12 @@ export default class Darkwire { let signature = null; let signingKey = null; let encryptedMessageData = null; + let messageToEncode = { + text: escape(message), + additionalData: additionalData + }; + messageToEncode = JSON.stringify(messageToEncode); // Generate new secret key and vector for each message this._cryptoUtil.createSecretKey() .then((key) => { @@ -146,7 +219,7 @@ export default class Darkwire { }) .then((data) => { secretKeys = data; - messageData = this._cryptoUtil.convertStringToArrayBufferView(message); + messageData = this._cryptoUtil.convertStringToArrayBufferView(messageToEncode); return this._cryptoUtil.signKey(messageData, signingKey); }) .then((data) => { @@ -202,7 +275,7 @@ export default class Darkwire { }) .then((data) => { decryptedMessageData = data; - decryptedMessage = this._cryptoUtil.convertArrayBufferViewToString(new Uint8Array(data)); + decryptedMessage = JSON.parse(this._cryptoUtil.convertArrayBufferViewToString(new Uint8Array(data))); return this._cryptoUtil.decryptSigningKey(signingKeyArrayBuffer, this._keys.private); }) .then((data) => { @@ -217,10 +290,29 @@ export default class Darkwire { resolve({ username: username, message: decryptedMessage, - messageType: data.messageType + messageType: data.messageType, + timestamp: data.timestamp }); } }); }); } + + generateMessage(fileId, fileName, messageType) { + let message = '
    is attempting to send you ' + fileName + ' (' + messageType + ')'; + message += '
    WARNING: We cannot strictly verify the integrity of this file, its recipients or its owners. By accepting this file, you are liable for any risks that may arise from reciving this file.'; + message += '
    Accept File
    '; + + return message; + } + + addFileToQueue(data) { + let fileData = { + id: data.additionalData.fileId, + data: data + }; + this._fileQueue.push(fileData); + return this.generateMessage(data.additionalData.fileId, data.additionalData.fileName, data.messageType); + } + } diff --git a/src/js/fileHandler.js b/src/js/fileHandler.js index 509d955..fdedccc 100644 --- a/src/js/fileHandler.js +++ b/src/js/fileHandler.js @@ -1,9 +1,14 @@ +import _ from 'underscore'; +import uuid from 'uuid'; + export default class FileHandler { - constructor(darkwire, socket) { - if (window.File && window.FileReader && window.FileList && window.Blob && window.btoa && window.atob) { + constructor(darkwire, socket, chat) { + this.localFileQueue = []; + if (window.File && window.FileReader && window.FileList && window.Blob && window.btoa && window.atob && window.Blob && window.URL) { this._isSupported = true; this.darkwire = darkwire; this.socket = socket; + this.chat = chat; this.listen(); } else { this._isSupported = false; @@ -14,16 +19,22 @@ export default class FileHandler { return this._isSupported; } - encodeFile(event) { + set isSupported(state) { + this._isSupported = state; + return this; + } + + confirmTransfer(event) { + const validFileTypes = ['png','jpg','jpeg','gif','zip','rar','gzip','pdf','txt','json','doc','docx']; const file = event.target.files && event.target.files[0]; if (file) { + const fileExt = file.name.split('.').pop().toLowerCase(); - // let encodedFile = { - // fileName: file.name, - // fileSize: file.fileSize, - // base64: null - // }; + if (validFileTypes.indexOf(fileExt) <= -1) { + alert('file type not supported'); + return false; + } // Support for only 1MB if (file.size > 1000000) { @@ -31,32 +42,98 @@ export default class FileHandler { alert('Max filesize is 1MB.'); return false; } + let fileId = uuid.v4(); - const reader = new FileReader(); - - reader.onload = (readerEvent) => { - const base64 = window.btoa(readerEvent.target.result); - this.darkwire.encodeMessage(base64, 'file').then((socketData) => { - this.socket.emit('new message', socketData); - }); + let confirmMessage = 'You are about to send ' + file.name + ' to all parties in this chat. Confirm | Cancel'; + let fileData = { + id: fileId, + file: file }; + this.localFileQueue.push(fileData); + this.chat.addChatMessage({ + username: username, + message: confirmMessage + }); + this.filesSent++; - reader.readAsBinaryString(file); } return false; } - decodeFile(base64) { - return window.atob(base64); + encodeFile(fileId) { + const fileData = _.findWhere(this.localFileQueue, {id: fileId}); + const file = fileData.file || false; + + if (file) { + // TODO: Remove file from local queue + } else { + return false; + } + + const reader = new FileReader(); + const fileType = file.type || 'file'; + + reader.onload = (readerEvent) => { + const base64 = window.btoa(readerEvent.target.result); + const additionalData = { + fileId: fileId, + fileName: file.name + }; + this.darkwire.encodeMessage(base64, fileType, additionalData).then((socketData) => { + this.chat.replaceMessage('#transfer-' + fileId, 'Sent ' + file.name); + this.socket.emit('new message', socketData); + }); + this.resetInput(); + }; + + reader.readAsBinaryString(file); + } + + destroyFile(fileId) { + const file = _.findWhere(this.localFileQueue, {id: fileId}); + this.localFileQueue = _.without(this.localFileQueue, file); + return this.chat.replaceMessage('#transfer-' + fileId, 'The file transfer for ' + file.file.name + ' has been canceled.'); + } + + createBlob(base64, fileType) { + base64 = unescape(base64); + return new Promise((resolve, reject) => { + const sliceSize = 1024; + let byteCharacters = window.atob(base64); + let byteArrays = []; + + for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) { + let slice = byteCharacters.slice(offset, offset + sliceSize); + + let byteNumbers = new Array(slice.length); + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i); + } + + let byteArray = new Uint8Array(byteNumbers); + + byteArrays.push(byteArray); + } + + resolve(new window.Blob(byteArrays, {type: fileType})); + }); + } + + createUrlFromBlob(blob) { + return window.URL.createObjectURL(blob); } listen() { // browser API - document.getElementById('fileInput').addEventListener('change', this.encodeFile.bind(this), false); + document.getElementById('fileInput').addEventListener('change', this.confirmTransfer.bind(this), false); // darkwire return this; } + + resetInput() { + document.getElementById('fileInput').value = ''; + } } diff --git a/src/js/main.js b/src/js/main.js index 3f48076..9c4ff0b 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -1,6 +1,11 @@ +import _ from 'underscore'; import Darkwire from './darkwire'; import WindowHandler from './window'; 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; @@ -8,28 +13,9 @@ $(function() { const darkwire = new Darkwire(); const cryptoUtil = new CryptoUtil(); - let FADE_TIME = 150; // ms - let TYPING_TIMER_LENGTH = 400; // ms - - let COLORS = [ - '#e21400', '#ffe400', '#ff8f00', - '#58dc00', '#dd9cff', '#4ae8c4', - '#3b88eb', '#f47777', '#d300e7', - ]; - let $window = $(window); - let $messages = $('.messages'); // Messages area - let $inputMessage = $('.inputMessage'); // Input message input box - let $key = $('.key'); - let $genKey = $('.new_key'); let $participants = $('#participants'); - let $chatPage = $('.chat.page'); // The chatroom page - - let username; - let typing = false; - let lastTypingTime; - let roomId = window.location.pathname.length ? window.location.pathname : null; if (!roomId) { return; } @@ -43,7 +29,8 @@ $(function() { }); let socket = io(roomId); - const windowHandler = new WindowHandler(darkwire, socket); + const chat = new Chat(darkwire, socket); + const windowHandler = new WindowHandler(darkwire, socket, chat); FastClick.attach(document.body); @@ -56,232 +43,48 @@ $(function() { // Sets the client's username function initChat() { - username = window.username; // warn not incognitor - if (!fs) { - console.log('no fs'); - } else { + if (fs) { fs(window.TEMPORARY, 100, - log.bind(log, 'WARNING: Your browser is not in incognito mode!')); - } - - // If the username is valid - if (username) { - $chatPage.show(); - $inputMessage.focus(); - - Promise.all([ - cryptoUtil.createPrimaryKeys() - ]) - .then(function(data) { - darkwire.keys = { - public: data[0].publicKey, - private: data[0].privateKey - }; - return Promise.all([ - cryptoUtil.exportKey(data[0].publicKey, 'spki') - ]); - }) - .then(function(exportedKeys) { - // Tell the server your username and send public keys - socket.emit('add user', { - username: username, - publicKey: exportedKeys[0] + () => { + chat.log('WARNING: Your browser is not in incognito mode!', {error: true}); }); - }); } - } - - // Log a message - function log(message, options) { - let html = options && options.html === true || false; - let $el; - if (html) { - $el = $('
  • ').addClass('log').html(message); - } else { - $el = $('
  • ').addClass('log').text(message); - } - addMessageElement($el, options); - } - - // Adds the visual chat message to the message list - function addChatMessage(data, options) { - if (!data.message.trim().length) { - return; - } - - let messageType = data.messageType || 'text'; - - // Don't fade the message in if there is an 'X was typing' - let $typingMessages = getTypingMessages(data); - options = options || {}; - if ($typingMessages.length !== 0) { - options.fade = false; - $typingMessages.remove(); - } - - let $usernameDiv = $('') - .text(data.username) - .css('color', getUsernameColor(data.username)); - let $messageBodyDiv = $(''); - // TODO: Ask client if accept/reject attachment - // If reject, destroy object in memory - // If accept, render image or content dispose - if (messageType === 'file') { - let image = new Image(); - image.src = `data:image/png;base64,${data.message}`; - $messageBodyDiv.html(image); - } else { - $messageBodyDiv.html(data.message); - } - - let typingClass = data.typing ? 'typing' : ''; - - let $messageDiv = $('
  • ') - .data('username', data.username) - .addClass(typingClass) - .append($usernameDiv, $messageBodyDiv); - - addMessageElement($messageDiv, options); - } - - // Adds the visual chat typing message - function addChatTyping(data) { - data.typing = true; - data.message = 'is typing'; - addChatMessage(data); - } - - // Removes the visual chat typing message - function removeChatTyping(data) { - getTypingMessages(data).fadeOut(function() { - $(this).remove(); + chat.log(moment().format('MMMM Do YYYY, h:mm:ss a'), {info: true}); + darkwire.updateUsername(username).then((socketData) => { + chat.chatPage.show(); + chat.inputMessage.focus(); + socket.emit('add user', socketData); }); } - // Adds a message element to the messages and scrolls to the bottom - // el - The element to add as a message - // options.fade - If the element should fade-in (default = true) - // options.prepend - If the element should prepend - // all other messages (default = false) - function addMessageElement(el, options) { - let $el = $(el); - - // Setup default options - if (!options) { - options = {}; - } - if (typeof options.fade === 'undefined') { - options.fade = true; - } - if (typeof options.prepend === 'undefined') { - options.prepend = false; - } - - // Apply options - if (options.fade) { - $el.hide().fadeIn(FADE_TIME); - } - if (options.prepend) { - $messages.prepend($el); - } else { - $messages.append($el); - } - - $messages[0].scrollTop = $messages[0].scrollHeight; // minus 60 for key - } - // Prevents input from having injected markup function cleanInput(input) { - let message = $('
    ').html(input).text(); - message = Autolinker.link(message); - return message; - } - - // Updates the typing event - function updateTyping() { - if (darkwire.connected) { - if (!typing) { - typing = true; - socket.emit('typing'); - } - lastTypingTime = (new Date()).getTime(); - - setTimeout(function() { - let typingTimer = (new Date()).getTime(); - let timeDiff = typingTimer - lastTypingTime; - if (timeDiff >= TYPING_TIMER_LENGTH && typing) { - socket.emit('stop typing'); - typing = false; - } - }, TYPING_TIMER_LENGTH); - } - } - - // Gets the 'X is typing' messages of a user - function getTypingMessages(data) { - return $('.typing.message').filter(function(i) { - return $(this).data('username') === data.username; - }); - } - - // Gets the color of a username through our hash function - function getUsernameColor(username) { - // Compute hash code - let hash = 7; - for (let i = 0; i < username.length; i++) { - hash = username.charCodeAt(i) + (hash << 5) - hash; - } - // Calculate color - let index = Math.abs(hash % COLORS.length); - return COLORS[index]; + 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'); - typing = false; + chat.typing = false; + event.preventDefault(); } }); - $inputMessage.on('input propertychange paste change', function() { - updateTyping(); - let message = $(this).val().trim(); - if (message.length) { - $('#send-message-btn').addClass('active'); - } else { - $('#send-message-btn').removeClass('active'); - } - }); - // Select message input when closing modal $('.modal').on('hidden.bs.modal', function(e) { - $inputMessage.focus(); + chat.inputMessage.focus(); }); - // Select message input when clicking message body, unless selecting text - $('.messages').on('click', function() { - if (!getSelectedText()) { - $inputMessage.focus(); - } - }); - - function getSelectedText() { - var text = ''; - if (typeof window.getSelection != 'undefined') { - text = window.getSelection().toString(); - } else if (typeof document.selection != 'undefined' && document.selection.type == 'Text') { - text = document.selection.createRange().text; - } - return text; - } - // Whenever the server emits 'login', log the login message socket.on('user joined', function(data) { darkwire.connected = true; @@ -293,38 +96,43 @@ $(function() { $('#first-modal').modal('show'); } - log(data.username + ' joined'); + chat.log(data.username + ' joined'); renderParticipantsList(); }); }); + socket.on('user update', (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 socket.on('new message', function(data) { - darkwire.decodeMessage(data).then((data) => { + darkwire.decodeMessage(data).then((decodedMessage) => { if (!windowHandler.isActive) { windowHandler.notifyFavicon(); darkwire.audio.play(); } - if (data.messageType === 'file') { - // let file = windowHandler.fileHandler.decodeFile(data.message); - // let chatMessage = { - // username: data.username, - // message: file - // } - addChatMessage(data, {messageType: 'file'}); - } else { - addChatMessage(data); - } + + let data = { + username: decodedMessage.username, + message: decodedMessage.message.text, + messageType: decodedMessage.messageType, + additionalData: decodedMessage.message.additionalData + }; + chat.addChatMessage(data); }); }); // Whenever the server emits 'user left', log it in the chat body socket.on('user left', function(data) { - log(data.username + ' left'); + chat.log(data.username + ' left'); addParticipantsMessage(data); - removeChatTyping(data); + chat.removeChatTyping(data); darkwire.removeUser(data); @@ -333,12 +141,12 @@ $(function() { // Whenever the server emits 'typing', show the typing message socket.on('typing', function(data) { - addChatTyping(data); + chat.addChatTyping(data); }); // Whenever the server emits 'stop typing', kill the typing message socket.on('stop typing', function(data) { - removeChatTyping(data); + chat.removeChatTyping(data); }); initChat(); @@ -365,9 +173,9 @@ $(function() { let li; if (user.username === window.username) { // User is me - li = $('
  • ' + user.username + ' (you)
  • ').css('color', getUsernameColor(user.username)); + li = $('
  • ' + user.username + ' (you)
  • ').css('color', chat.getUsernameColor(user.username)); } else { - li = $('
  • ' + user.username + '
  • ').css('color', getUsernameColor(user.username)); + li = $('
  • ' + user.username + '
  • ').css('color', chat.getUsernameColor(user.username)); } $('#participants-modal ul.users') .append(li); @@ -377,27 +185,33 @@ $(function() { $('#send-message-btn').click(function() { handleMessageSending(); socket.emit('stop typing'); - typing = false; + chat.typing = false; }); $('.navbar-collapse ul li a').click(function() { $('.navbar-toggle:visible').click(); }); - let audioSwitch = $('input.bs-switch').bootstrapSwitch(); + let audioSwitch = $('input.sound-enabled').bootstrapSwitch(); audioSwitch.on('switchChange.bootstrapSwitch', function(event, state) { darkwire.audio.soundEnabled = state; }); function handleMessageSending() { - let message = $inputMessage; + let message = chat.inputMessage; let cleanedMessage = cleanInput(message.val()); + let slashCommand = chat.parseCommand(cleanedMessage); + + if (slashCommand) { + return chat.executeCommand(slashCommand, this); + } + // Prevent markup from being injected into the message darkwire.encodeMessage(cleanedMessage, 'text').then((socketData) => { message.val(''); $('#send-message-btn').removeClass('active'); - addChatMessage({ + chat.addChatMessage({ username: username, message: cleanedMessage }); @@ -407,4 +221,51 @@ $(function() { }); } + window.triggerFileTransfer = function(context) { + const fileId = context.getAttribute('data-file'); + if (fileId) { + return windowHandler.fileHandler.encodeFile(fileId); + } + + return chat.log('Requested file transfer is no longer valid. Please try again.', {error: true}); + }; + + window.triggerFileDestroy = function(context) { + const fileId = context.getAttribute('data-file'); + if (fileId) { + return windowHandler.fileHandler.destroyFile(fileId); + } + + return chat.log('Requested file transfer is no longer valid. Please try again.', {error: true}); + }; + + window.triggerFileDownload = function(context) { + const fileId = context.getAttribute('data-file'); + const file = darkwire.getFile(fileId); + windowHandler.fileHandler.createBlob(file.message, file.messageType).then((blob) => { + let url = windowHandler.fileHandler.createUrlFromBlob(blob); + + if (file) { + if (file.messageType.match('image.*')) { + let image = new Image(); + image.src = url; + chat.replaceMessage('#file-transfer-request-' + fileId, image); + } else { + let downloadLink = document.createElement('a'); + downloadLink.href = url; + downloadLink.target = '_blank'; + downloadLink.innerHTML = 'Download ' + file.additionalData.fileName; + chat.replaceMessage('#file-transfer-request-' + fileId, downloadLink); + } + } + + darkwire.encodeMessage('Downloaded ' + file.additionalData.fileName, 'text').then((socketData) => { + socket.emit('new message', socketData); + }).catch((err) => { + console.log(err); + }); + + }); + }; + }); diff --git a/src/js/window.js b/src/js/window.js index db196c9..abb9c8c 100644 --- a/src/js/window.js +++ b/src/js/window.js @@ -1,9 +1,9 @@ import FileHandler from './fileHandler'; export default class WindowHandler { - constructor(darkwire, socket) { + constructor(darkwire, socket, chat) { this._isActive = false; - this.fileHandler = new FileHandler(darkwire, socket); + this.fileHandler = new FileHandler(darkwire, socket, chat); this.newMessages = 0; this.favicon = new Favico({ @@ -35,6 +35,9 @@ export default class WindowHandler { e.preventDefault(); $('#fileInput').trigger('click'); }); + } else { + $('#send-file').remove(); + $('#fileInput').remove(); } } diff --git a/src/public/style.css b/src/public/style.css index 873608f..606e38b 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,25 @@ input { /* Input */ +.inputContainer { + position: fixed; + right: 0; + left: 0; + bottom: 0; + width: 100%; + padding-bottom: 10px; + height: 60px; +} + .inputMessage{ background: black !important; color: white !important; border: none; border-top: 1px solid #282828; - bottom: 0; - height: 60px; - left: 0; + // height: 60px; 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; @@ -296,3 +306,24 @@ html.no-touchevents .chat #input-icons { #fileInput { display: none; } + +.file-disclaimer { + font-size: 10px; + color: red; + line-height: 10px; +} + +.log-info { + color: #FFF; + font-weight: bold; +} + +.log-error { + color: yellow; + font-weight: bold; +} + +.action { + font-style: italic; + color: #00FF7F; +} 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 d8ea3bc..b2659ad 100644 --- a/src/room.js +++ b/src/room.js @@ -26,7 +26,8 @@ class Room { data: data.data, vector: data.vector, secretKeys: data.secretKeys, - signature: data.signature + signature: data.signature, + timestamp: new Date() }); }); @@ -46,7 +47,8 @@ class Room { thisIO.emit('user joined', { username: socket.username, numUsers: this.numUsers, - users: this.users + users: this.users, + timestamp: new Date() }); }); @@ -75,7 +77,8 @@ class Room { username: socket.username, numUsers: this.numUsers, users: this.users, - id: socket.user.id + id: socket.user.id, + timestamp: new Date() }); // remove room from rooms array @@ -84,10 +87,34 @@ class Room { } } }); + + // Update user + socket.on('update user', (data) => { + if (data.newUsername.length > 16) { + return false; + } + 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() + }); + } + + }); + }); } - get roomId() { + roomId() { return this.id; } } diff --git a/src/views/index.mustache b/src/views/index.mustache index 4d9f076..0e10cc6 100644 --- a/src/views/index.mustache +++ b/src/views/index.mustache @@ -53,11 +53,13 @@ - -
    - - - +
    + +
    + + + +
    @@ -68,13 +70,15 @@