trailofbits/tubertc

View on GitHub
public/js/login.js

Summary

Maintainability
D
2 days
Test Coverage
/**
 * @file This handles the following:
 *   + Parsing of the query string for a roomName (if it exists)
 *   + Checking localStorage to determine the following information:
 *     - Is a userName key set with a valid value? If so, autopopulate the username field with this
 *       value.
 *     - Is a capabilities key set with valid values? If so, alter the navBar buttons to reflect the
 *       capabilities values. Valid capabilities:
 *        = cameraIsEnabled : bool
 *        = micIsEnabled    : bool
 *        = dashModeEnabled : bool
 *     - Example:
 *       {
 *         "userName" : <string>,
 *         "cameraIsEnabled" : <bool>,
 *         "micIsEnabled"    : <bool>,
 *         "dashModeEnabled" : <bool>
 *       }
 *   + Bind the Join button with a handler that performs the following actions:
 *     - Verifies that userName and roomName are valid values, if not, use visual indication
 *       and focus to direct user to the problematic field
 *   + Update localStorage fields with the new values.
 *
 * @requires module:js/error
 * @requires module:js/navbar
 * @requires module:js/dialog
 * @requires module:js/vtc
 * @requires Chance.js
 */

'use strict';

/**
 * Generates a random room name.
 *
 * @returns {String} A random room name.
 * @public
 */
var generateRoomName = function() {
    return chance.word() + '-' + chance.hash().substring(0, 8);
};

/**
 * Converts a room name to an RTC room name.
 *
 * @param {String} roomName - The room name.
 * @returns {String} A room name suitable for RTC.
 * @public
 */
var toRtcRoomName = function(roomName) {
    return roomName
            .replace(/[^\w\s]/gi, '')
            .replace(/ /gi, '_');
};

// Provides a namespace to parse the room name from the querystring
var Query = {
    /**
     * Gets a room name.
     * DO NOT TRUST OUTPUT FROM THIS FUNCTION
     *
     * @returns {String} The room name.
     * @public
     */
    getRoomName: function() {
        var queryStart = '?room=';
        var queryRaw = document.location.search;
        if (queryRaw.length <= queryStart.length) {
            return null;
        }

        if (queryRaw.indexOf(queryStart) !== 0) {
            ErrorMetric.log('Query.getRoomName => Invalid querystring: ' + queryRaw);
            return null;
        }

        return unescape(queryRaw.substring(6));
    }
};

var StorageCookie = {
    // The Object structure for our StorageCookie should look like the dictionary below:
    //   {
    //     "userName"        : <string>,
    //     "cameraIsEnabled" : <bool>,
    //     "micIsEnabled"    : <bool>,
    //     "dashModeEnabled" : <bool>
    //   }

    /**
     * Validates the StorageCookie.
     *
     * @param {Object} dict - StorageCookie configuration object.
     * @returns {Boolean} True if valid, false otherwise.
     * @private
     */
    _validate: function(dict) {
        return (typeof dict.userName === 'string' &&
                typeof dict.cameraIsEnabled === 'boolean' &&
                typeof dict.micIsEnabled === 'boolean' &&
                typeof dict.dashModeEnabled === 'boolean');
    },

    /**
     * Sets the StorageCookie.
     *
     * @param {Object} config - StorageCookie configuration object.
     * @returns {Boolean} True if successful, false otherwise.
     * @public
     */
    set: function(config) {
        if (this._validate(config)) {
            localStorage.tubertc = JSON.stringify(config);
            return true;
        } else {
            ErrorMetric.log('StorageCookie.set => invalid Object structure');
            ErrorMetric.log('                  => ' + JSON.stringify(config));
            return false;
        }
    },

    /**
     * Sets a value on the storage cookie. This function assumes the
     * existence of `localStorage.tubertc`. If it doesn't exist, it will
     * fail. Otherwise, it will find the "key" in `localStorage.tubertc`
     * and update it to the new value.
     *
     * @param {String} key - Key part of the key-value pair.
     * @param {*} value - This value can be of any type and
     * is returned whenever key is referenced.
     * @returns {Boolean} True if successful, false otherwise.
     * @public
     */
    setValue: function(key, value) {
        var config = this.get();
        if (config === null) {
            ErrorMetric.log('StorageCookie.setValue => StorageCookie.get had invalid return value');
            return false;
        } else {
            if (config[key] !== undefined) {
                ErrorMetric.log('StorageCookie.setValue => invalid key "' + key + '"');
                return false;
            } else {
                config[key] = value;
                return this.set(config);
            }
        }
    },

    /**
     * Gets the StorageCookie.
     * DO NOT TRUST THE RETURNED OBJECT: The userName field needs to be sanitized.
     *
     * @returns {Object} The StorageCookie dict if successful, `null` otherwise.
     * @public
     */
    get: function() {
        var rawConfig = localStorage.tubertc;
        if (rawConfig !== undefined) {
            // TODO: are we certain we can trust JSON.parse to parse localStorage?
            try {
                var config = JSON.parse(rawConfig);
                if (this._validate(config)) {
                    return config;
                } else {
                    ErrorMetric.log('StorageCookie.get => localStorage.tubertc Object is invalid');
                    ErrorMetric.log('                  => ' + JSON.stringify(config));
                    return null;
                }
            } catch (e) {
                ErrorMetric.log('StorageCookie.get => exception while trying to validate localStorage.tubertc');
                ErrorMetric.log('                  => ' + e);
                return null;
            }
        } else {
            ErrorMetric.log('StorageCookie.get => localStorage.tubertc does not exist');
            return null;
        }
    },

    /**
     * Gets the value associated with the key.
     * DO NOT TRUST userName: it needs to be sanitized before use!
     *
     * @param {String} key - The key to look up.
     * @returns {*} The value associated with the key, or `null`
     * if either the key is invalid or `StorageCookie.get()` fails.
     * @public
     */
    getValue: function(key) {
        var config = this.get();
        if (config !== null) {
            if (config[key] === undefined) {
                ErrorMetric.log('StorageCookie.getKey => invalid key "' + key + '"');
                return null;
            } else {
                return config[key];
            }
        } else {
            ErrorMetric.log('StorageCookie.getKey => StorageCookie.get had invalid return value');
            return null;
        }
    }
};

// jQuery selectors
var _joinBtn = $('#joinBtn');
var _loginMsg = $('#loginMsg');
var _loginAlert = $('#loginAlert');
var _userNameEntry = $('#userNameEntry');
var _roomNameEntry = $('#roomNameEntry');

var Login = {
    _completionFn: null,

    /**
     * Check to see if the visitor is browsing a non-TLS instance of tuber
     * with Chrome 47 and above.
     *
     * @returns {true|false} The return status value. This is the status of the
     *                       check.
     * @private
     */
    _checkIfNonTlsAndChromeAbove47: function() {
        var userAgent = navigator.userAgent;
        if (window.location.protocol !== 'https:' &&
            (window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1')) {
            var browserString = null;
            var browserVersion = null;
            var browserVersionString = null;
            var browserStartIdx = userAgent.indexOf('Chrome/');
            if (browserStartIdx === -1) {
                browserStartIdx = userAgent.indexOf('Chromium/');
            }
            if (browserStartIdx === -1) {
                // Could not find Chrome or Chromium in user agent...
                // At this point, we should assume that it might be newer than 47
                return true;
            }
            var browserEndIdx = userAgent.indexOf(' ', browserStartIdx);
            if (browserEndIdx === -1) {
                browserString = userAgent.substring(browserStartIdx);
            } else {
                browserString = userAgent.substring(browserStartIdx, browserEndIdx);
            }
            browserVersionString = browserString.split('/')[1];
            if (browserVersionString.indexOf('.') !== -1) {
                browserVersion = parseInt(browserVersionString.split('.')[0], 10);
            } else {
                browserVersion = parseInt(browserVersionString, 10);
            }

            // Chrome/Chromium 47 introduces a policy requiring SSL for getUserMedia requests to
            // work correctly.
            if (browserVersion >= 47) {
                return true;
            }
        }
        return false;
    },

    /**
     * Checks to ensure that the current browser has
     * the support needed to successfully use tubertc.
     *
     * @returns {String|null} The return status value. It has three possible
     * states, which mean the following:
     * 'full'     - All APIs are supported and browser is well tested
     * 'ssl'      - All APIs are supported but the current tuber instance is
     *              non-TLS and the viewing browser is Chrome 47+
     * 'untested' - All APIs are supported but browser is not as well tested
     * null       - Some required APIs are not supported
     * @private
     */
    _browserCompatCheck: function() {
        var userAgent = navigator.userAgent;

        if (!('Notification' in window)) {
            ErrorMetric.log('_browserCompatCheck => browser does not support Notifications');
            ErrorMetric.log('                    => userAgent: ' + userAgent);

            return null;
        }

        if (!('localStorage' in window)) {
            ErrorMetric.log('_browserCompatCheck => browser does not support LocalStorage');
            ErrorMetric.log('                    => userAgent: ' + userAgent);

            return null;
        }

        if (!VTCCore.isBrowserSupported()) {
            ErrorMetric.log('_browserCompatCheck => browser does not support WebRTC');
            ErrorMetric.log('                    => userAgent: ' + userAgent);

            return null;
        }

        // @todo FIXME: We only have tested Chrome, need to refactor this once more browsers are tested
        if ('chrome' in window) {
            if (Login._checkIfNonTlsAndChromeAbove47()) {
                return 'ssl';
            }
            return 'full';
        } else {
            return 'untested';
        }
    },

    /**
     * Validates the user and room names.
     *
     * @returns {Boolean} True if user name and
     * room name are valid, false otherwise.
     * @private
     */
    _validate: function() {
        var userName = $.trim(_userNameEntry.val());
        var roomName = $.trim(_roomNameEntry.val());

        if (userName.length === 0) {
            _loginAlert
                .html('Please provide a <b>user name</b>.')
                .stop(true, false)
                .slideDown();
            _userNameEntry.focus();
            return false;
        }

        if (roomName.length === 0) {
            _loginAlert
                .html('Please provide a <b>room name</b>.')
                .stop(true, false)
                .slideDown();
            _roomNameEntry.focus();
            return false;
        }

        return true;
    },

    /**
     * Sets up the handlers for the initial "page" form.
     *
     * @param {Object} config - Configuration object of the form:
     * {
     *     cameraBtn : <StatefulButton>,
     *     micBtn    : <StatefulButton>,
     *     dashBtn   : <StatefulButton>
     * }
     *
     * @returns {Object} The current Login instance for chaining purposes.
     * @public
     */
    initialize: function(config) {
        var _this = this;
        if (typeof config.cameraBtn !== 'object' ||
            typeof config.micBtn !== 'object' ||
            typeof config.dashBtn !== 'object') {
            ErrorMetric.log('Log.initialize => config parameter is not valid');
            ErrorMetric.log('               => config.cameraBtn is ' + config.cameraBtn);
            ErrorMetric.log('               => config.micBtn is ' + config.micBtn);
            ErrorMetric.log('               => config.dashBtn is ' + config.dashBtn);

            // Break chaining to indicate error
            return null;
        }

        var compatStatus = this._browserCompatCheck();
        if (compatStatus === null) {
            _userNameEntry.prop('disabled', true);
            _roomNameEntry.prop('disabled', true);
            _joinBtn.prop('disabled', true);

            // @todo FIXME: proofread and make this better
            _loginAlert
                .html(
                    'Your browser <b>does not</b> support some of the required APIs.<br>' +
                    'tubertc will not work on your current system.<br><br>' +
                    'We recommend using <a href="http://www.google.com/chrome/">Google Chrome</a>.'
                )
                .slideDown();

            // Disable buttons since the app is disabled anyways.
            config.cameraBtn.disableButton();
            config.micBtn.disableButton();
            config.dashBtn.disableButton();

            ErrorMetric.log('Login.initialize => ' + navigator.userAgent + ' is not supported');

            // Break chaining to indicate error
            return null;
        } else if (compatStatus === 'untested') {
            // @todo FIXME: proofread and make this better
            _loginAlert
                .html(
                    'Your browser configuration has not been extensively tested. ' +
                    'There may be user interface artifacts or missing functionality.<br><br>' +
                    'We recommend using <a href="http://www.google.com/chrome/">Google Chrome</a>.'
                )
                .slideDown();
            ErrorMetric.log('Login.initialize => ' + navigator.userAgent + ' is untested');
        } else if (compatStatus === 'ssl') {
            // @todo FIXME: proofread and make this better
            _loginAlert
                .html(
                    'Starting with Chrome 47 and higher, WebRTC will cease to function on non-TLS sites.'
                )
                .slideDown();
            ErrorMetric.log('Login.initialize => ' + navigator.userAgent + ' requires SSL for getUserMedia ' +
                            'to work');
        }

        var userName = StorageCookie.getValue('userName');
        var roomName = Query.getRoomName();

        if (userName !== null) {
            // @todo Verify that this doesn't introduce XSS
            _userNameEntry.val(userName);
        }

        if (roomName !== null) {
            _roomNameEntry
                .val(roomName)
                .prop('disabled', true);
        } else {
            // No roomName was specified, to make it friendly to the user, generate one.
            // We don't set roomName because we want to make this field modifiable.
            _roomNameEntry
                .val(generateRoomName());
        }

        var scCameraEnabled = StorageCookie.getValue('cameraIsEnabled');
        var scMicEnabled = StorageCookie.getValue('micIsEnabled');
        var scDashMode = StorageCookie.getValue('dashModeEnabled');
        var _setInitialBtnState = function(initValue, btn) {
            if (initValue !== null && initValue !== btn.isSelected()) {
                btn.toggle();
            }
        };

        // Set button's initial state (from localStorage)
        _setInitialBtnState(scCameraEnabled, config.cameraBtn);
        _setInitialBtnState(scMicEnabled, config.micBtn);
        _setInitialBtnState(scDashMode, config.dashBtn);

        // Obtain the list of video sources, if none exist, disable the camera button
        easyrtc.getVideoSourceList(function(list) {
            if (list.length === 0) {
                _setInitialBtnState(false, config.cameraBtn);
                config.cameraBtn.disableButton();

                // @todo FIXME: maybe add a different sort of notification, like a tooltip?
                _loginMsg
                    .html('Disabling camera functionality because a camera could not be found.')
                    .slideDown();
            }
        });

        _userNameEntry.keypress(function(e) {
            // Detect when ENTER button is pressed
            if (e.which === 13) {
                if (roomName !== null) {
                    // Room is already populated from query string, simulate a click event
                    _joinBtn.click();
                } else {
                    // Room is not populated, switch focus to roomNameEntry
                    _roomNameEntry.focus();
                }
            }
        });

        _roomNameEntry.keypress(function(e) {
            // Detect when ENTER button is pressed
            if (e.which === 13) {
                _joinBtn.click();
            }
        });

        _joinBtn.click(function() {
            if (_this._validate()) {
                _loginAlert
                    .stop(true, false)
                    .slideUp();

                var params = {
                    userName: _userNameEntry.val(),
                    roomName: _roomNameEntry.val(),
                    rtcName: toRtcRoomName(_roomNameEntry.val()),
                    cameraIsEnabled: config.cameraBtn.isSelected(),
                    hasCamera: config.cameraBtn.isEnabled(),
                    micIsEnabled: config.micBtn.isSelected(),
                    hasMic: config.micBtn.isEnabled(),
                    dashIsEnabled: config.dashBtn.isSelected()
                };

                var trtcConfig = {
                    userName: params.userName,
                    cameraIsEnabled: params.cameraIsEnabled,
                    micIsEnabled: params.micIsEnabled,
                    dashModeEnabled: params.dashIsEnabled
                };
                StorageCookie.set(trtcConfig);

                if (_this._completionFn !== null) {
                    $('#loginContent').fadeOut(function() {
                        _this._completionFn(params);
                    });
                } else {
                    ErrorMetric.log('joinBtn.click => _completionFn not set');

                    // @todo FIXME: this case should not happen since we immediately call
                    //              done() to set the completion handler
                    Dialog.show('An Error Has Occurred', 'tubertc has broke!');
                }
            } else {
                ErrorMetric.log('joinBtn.click => failed to validate');
            }
        });

        return this;
    },

    /**
     * Completion handler, called when the "Join Room" button is clicked and all the input is validated.
     * At this point, both the userName and roomName are considered UNTRUSTED and should be sanitized
     * using Handlebars.
     *
     * @param {Function} completionFn - Completion callback of the form:
     * function({
     *     userName        : <String>,
     *     roomName        : <String>,
     *     rtcName         : <String>,
     *     cameraIsEnabled : <boolean>,
     *     hasCamera       : <boolean>,
     *     micIsEnabled    : <boolean>,
     *     hasMic          : <boolean>,
     *     dashModeEnabled : <boolean>
     * })
     * @returns {Object} The current Login instance for chaining purposes.
     * @public
     */
    done: function(completionFn) {
        this._completionFn = completionFn;
        return this;
    }
};