Laverna/laverna

View on GitHub
app/scripts/collections/modules/configs.js

Summary

Maintainability
B
4 hrs
Test Coverage
/**
 * Copyright (C) 2015 Laverna project Authors.
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 */
/* global define */
define([
    'underscore',
    'q',
    'marionette',
    'backbone.radio',
    'sjcl',
    'collections/modules/module',
    'collections/configs'
], function(_, Q, Marionette, Radio, sjcl, ModuleObject, Configs) {
    'use strict';

    /**
     * Collection module for Configs.
     *
     * Apart from the replies and events in collections/modules/module.js,
     * it also has additional replies and events.
     *
     * Triggers events on channel `configs`:
     * 1. event: `collection:empty` - if the collection is empty.
     * 2. event: `removed:profile`  - when some profile is removed.
     * 3. event: `changed`          - after configs are changed
     *
     * Replies on channel `configs` to:
     * 1. request: `get:config`     - returns a config.
     * 2. request: `get:object`     - returns configs in key=value format.
     * 3. request: `get:profiles`   - returns list of profiles
     * 4. request: `reset:encrypt`  - resets encryption configs backup.
     * 5. request: `save:objects`   - save several configs at once
     * 6. request: `create:profile` - create a new profile
     * 7. request: `remove:profile` - remove a profile
     * 8. request: `save:object`
     */
    var Collection = ModuleObject.extend({
        Collection: Configs,

        reply: function() {
            return {
                'save:object'    : this.saveObject,
                'save:objects'   : this.saveObjects,
                'create:profile' : this.createProfile,
                'remove:profile' : this.removeProfile,
                'get:config'     : this.getConfig,
                'get:object'     : this.getObject,
                'get:profiles'   : this.getProfiles,
                'reset:encrypt'  : this.resetEncrypt
            };
        },

        encryptionKeys: [
            'encrypt'    , 'encryptPass', 'encryptSalt'  ,
            'encryptIter', 'encryptTag' , 'encryptKeySize'
        ],

        /**
         * Reset encryptBackup
         */
        resetEncrypt: function() {
            var model = this.collection.get('encryptBackup');
            return this.saveModel(model, {value: {}});
        },

        /**
         * Create a new profile
         *
         * @type object Backbone.model - appProfiles model
         */
        createProfile: function(model, name) {
            return model.createProfile(name);
        },

        /**
         * Remove a profile
         */
        removeProfile: function(model, name) {
            return new Q(model.removeProfile(name, model))
            .then(function() {
                Radio.trigger('configs', 'removed:profile', name);
            });
        },

        /**
         * Save a config.
         * @type object Backbone model
         * @type object new value
         */
        saveModel: function(model, data) {
            var saveFunc = _.bind(ModuleObject.prototype.saveModel, this);

            if (!model.isPassword(data)) {
                return saveFunc(model, data);
            }

            // Always save passwords as sha256
            return new Q(Radio.request('encrypt', 'sha256', data.value))
            .then(function(result) {
                data.value = result;
                return saveFunc(model, data);
            });
        },

        /**
         * Update several configs at once
         * @type array array of configs
         * @type object Backbone model
         */
        saveObjects: function(objects, useDefault) {
            var promises = [],
                self  = this;

            // Backup current encryption configs
            if (objects.useDefaultConfigs) {
                promises.push(
                    this._backupEncrypt(useDefault.profileId)
                );
            }

            // Convert configs to a key = value object.
            objects = (_.isArray(objects) ? _.indexBy(objects, 'name') : objects);

            // Backup encryption configs
            this._backupEncryption(objects);

            // return;
            _.forEach(objects, function(object) {
                promises.push(
                    new Q(self.saveObject(object, useDefault, {profile: useDefault.profileId}))
                );
            }, this);

            return Q.all(promises)
            .then(function() {
                Radio.trigger('configs', 'changed', objects);
            });
        },

        /**
         * Saves an object to the database.
         * @type object
         * @type object Backbone model
         */
        saveObject: function(object, useDefault, options) {
            var self = this;

            return this.getModel(_.extend({}, options || {}, {name: object.name}))
            .then(function(model) {
                if (!model) {
                    return;
                }

                if (object.name === 'useDefaultConfigs') {
                    model = useDefault;
                }

                return self.saveModel(model, object);
            });
        },

        /**
         * Return the value of a specific config
         */
        getConfig: function(name, defaultValue) {
            var config = this.getObject()[name];
            return !_.isUndefined(config) ? config : defaultValue;
        },

        /**
         * Return configs as key=value
         */
        getObject: function() {
            return this.collection.getConfigs();
        },

        /**
         * Find a model by ID.
         * @type object options
         */
        getModel: function(options) {
            var getFunc = _.bind(ModuleObject.prototype.getModel, this),
                self    = this;

            options     = (typeof options === 'string' ? {name: options} : options);

            return getFunc(options)
            .then(function(model) {
                if (model) {
                    return model;
                }

                // If a model doesn't exist, return default values
                var collection  = new (self.changeDatabase(options))();
                return collection.getDefault(options.name);
            });
        },

        /**
         * Return all configs.
         * @type object options
         */
        getAll: function(options) {
            if (this.collection && this.collection.length) {
                return new Q(this.collection);
            }

            var self = this,
                profile = options.profile || this.defaultDB,
                getFunc = _.bind(ModuleObject.prototype.getAll, this);

            /**
             * Before fetching configs collection, find out whether
             * we should use configs from the default profile.
             */
            return this.useDefaultConfigs(options.profile)
            .then(function(profile) {
                options.profile = profile;
                return getFunc(options);
            })
            .then(function() {
                return self._checkBackup(profile);
            })
            .then(function() {
                return self._createDefault(options);
            })
            .fail(function(e) {
                console.error('Error:', e);
            });
        },

        /**
         * Return null if configs from the default profile should be used.
         *
         * @type string profile
         */
        useDefaultConfigs: function(profile) {
            return this.getModel({name: 'useDefaultConfigs', profile: profile})
            .then(function(model) {
                return (!model || Number(model.get('value')) ? null : profile);
            });
        },

        /**
         * Returns profiles which use configs from default profile or
         * if current profile doesn't use configs from default profile,
         * returns only current profile.
         */
        getProfiles: function() {
            var current = this.collection.profileId,
                backup  = this.collection.get('encryptBackup');

            // If it is not the default profile, return only current profile
            if (current !== this.defaultDB || backup.profileId !== this.defaultDB) {
                return new Q([backup.profileId]);
            }

            /*
             * If it is the default profile, return all profiles which
             * use configs from default profile.
             */
            return this.getModel({name: 'appProfiles'})
            .then(_.bind(this._getDefaultProfiles, this));
        },

        /**
         * Return profiles which use configs from default profile.
         * @type object Backbone model
         */
        _getDefaultProfiles: function(model) {
            var profiles = model.getValueJSON(),
                self     = this,
                promises = [];

            // Fetch `useDefaultConfigs` model of every profile
            _.each(profiles, function(profile) {
                promises.push(
                    self.getModel({
                        name    : 'useDefaultConfigs',
                        profile : profile
                    })
                );
            });

            return Q.all(promises)
            .then(function(profiles) {
                profiles = _.filter(profiles, function(profile) {
                    return (
                        Number(profile.get('value')) === 1 ||
                        profile.profileId === self.defaultDB
                    );
                });

                return _.pluck(profiles, 'profileId');
            });
        },

        /**
         * Check encryption backup
         */
        _checkBackup: function(profile) {
            var self = this;

            return this.getModel({name: 'encryptBackup'})
            .then(function(backup) {
                /**
                 * If it is the default profile or default backup is not empty,
                 * do nothing.
                 */
                if (profile === self.defaultDB ||
                   (!backup || !_.isEmpty(backup.get('value')))) {
                    return;
                }

                // Fetch current profile's encryption backup configs
                return self.getModel({
                    name    : 'encryptBackup',
                    profile : profile
                })
                .then(function(model) {
                    // If profile's backup is not empty, change backup model
                    if (!_.isEmpty(model.get('value'))) {
                        backup.set(model.toJSON());
                        backup.changeDB(profile);
                    }
                    return model;
                });
            });
        },

        /**
         * If collection is empty, create configs with default values.
         * @type object options
         */
        _createDefault: function(options) {
            if (!this.collection.hasNewConfigs()) {
                return new Q(this.collection);
            }

            var self = this;

            // Trigger an event if the collection is empty
            if (this.collection.length === 0) {
                this.vent.trigger('collection:empty');
            }

            // If the collection is empty, create default set of configs.
            return new Q(this.collection.migrateFromLocal())
            .then(_.bind(this.collection.createDefault, this.collection))
            .then(function() {
                var func = _.bind(ModuleObject.prototype.getAll, self);
                self.collection.trigger('reset:all');
                return func(options);
            })
            .thenResolve(self.collection);
        },

        /**
         * Check whether there are any changes in encryption configs.
         */
        _getEncryption: function(collection) {

            // Don't create a backup if encryption is not used in both new and old configs
            if ((!collection.encrypt || !Number(collection.encrypt.value)) &&
                !Number(this.getConfig('encrypt'))) {

                return [];
            }

            // Disable encryption if password is empty in both configs
            if ((!collection.encryptPass || !collection.encryptPass.value.length) &&
                !this.getConfig('encryptPass').length) {

                collection.encrypt = {value : '0', name: 'encrypt'};
                return [];
            }

            return _.filter(collection, function(value) {

                // Compare values
                if (typeof value === 'object') {
                    return (
                        _.indexOf(this.encryptionKeys, value.name) > -1 &&
                        this.getConfig(value.name) !== value.value &&
                        this._checkPassChanged(value)
                    );
                }

                return (_.indexOf(this.encryptionKeys, value) > -1);
            }, this);
        },

        _checkPassChanged: function(object) {
            if (object.name !== 'encryptPass') {
                return true;
            }

            var pass = this.getConfig('encryptPass');
            pass     = pass ? pass.toString() : pass;

            // Password salt was saved
            if (pass === object.value) {
                return false;
            }

            // Additional check to make sure it's not the same password
            var salt = sjcl.hash.sha256.hash(object.value);
            return (salt.toString() !== pass);
        },

        /**
         * Backup current encryption configs to current profile.
         */
        _backupEncrypt: function(profile) {
            var encrypt = _.pluck(this.collection.filter(function(model) {
                    return (_.indexOf(this.encryptionKeys, model.get('name')) > -1);
                }, this), 'id'),
                model   = this.collection.get('encryptBackup');

            model.changeDB(profile);
            return new Q(this.saveModel(model, {
                value: _.pick(this.collection.getConfigs(), encrypt)
            }));
        },

        /**
         * Backup encryption configs if there are any changes in them.
         */
        _backupEncryption: function(objects) {
            var changed = _.pluck(this._getEncryption(objects), 'name');

            /*
             * Don't create encryption backup if:
             * Encryption configs have not changed
             * or
             * there is already a backup.
             */
            if (_.isEmpty(changed) ||
                _.keys(this.getConfig('encryptBackup')).length) {
                return;
            }

            // Backup configs that are changed
            var configs = this.getObject();
            changed     = _.pick(configs, changed);

            // Password hasn't changed
            if (objects.encryptPass &&
                configs.encryptPass.toString() === objects.encryptPass.value.toString()) {
                delete changed.encryptPass;
            }

            if (!_.keys(changed).length) {
                return;
            }

            /**
             * Extend old backup from new.
             * That way we ensure that only the oldest configs will be saved.
             */
            objects.encryptBackup = {
                name  : 'encryptBackup',
                value : _.extend({}, changed, configs.encryptBackup)
            };

            return objects;
        },

    });

    /**
     * Initialize it automaticaly because everything depends on configs
     * collection and it should be available as soon as possible.
     */
    return new Collection();
});