From 9d4dea4e95e8ac360fd10384935098af7bdc6a0c Mon Sep 17 00:00:00 2001 From: Mo Bitar Date: Sun, 5 Nov 2017 14:06:47 -0600 Subject: [PATCH 1/2] Desktop manager to allow local backups --- .../app/frontend/controllers/home.js | 1 + .../app/services/desktopManager.js | 47 +++++++++++++++++++ .../services/directives/views/accountMenu.js | 17 ++----- .../services/encryption/encryptionHelper.js | 2 + .../javascripts/app/services/modelManager.js | 27 ++++++++++- .../javascripts/app/services/syncManager.js | 15 +++++- 6 files changed, 93 insertions(+), 16 deletions(-) create mode 100644 app/assets/javascripts/app/services/desktopManager.js diff --git a/app/assets/javascripts/app/frontend/controllers/home.js b/app/assets/javascripts/app/frontend/controllers/home.js index 074aa5152..f72cedcfd 100644 --- a/app/assets/javascripts/app/frontend/controllers/home.js +++ b/app/assets/javascripts/app/frontend/controllers/home.js @@ -55,6 +55,7 @@ angular.module('app.frontend') themeManager.activateInitialTheme(); $scope.$apply(); + $rootScope.$broadcast("initial-data-loaded"); syncManager.sync(null); // refresh every 30s diff --git a/app/assets/javascripts/app/services/desktopManager.js b/app/assets/javascripts/app/services/desktopManager.js new file mode 100644 index 000000000..9240ccdf3 --- /dev/null +++ b/app/assets/javascripts/app/services/desktopManager.js @@ -0,0 +1,47 @@ +// An interface used by the Desktop app to interact with SN + +class DesktopManager { + + constructor($rootScope, modelManager, authManager) { + this.modelManager = modelManager; + this.authManager = authManager; + this.$rootScope = $rootScope; + + $rootScope.$on("initial-data-loaded", () => { + this.dataLoaded = true; + if(this.dataLoadHandler) { + this.dataLoadHandler(); + } + }); + + $rootScope.$on("major-data-change", () => { + if(this.majorDataChangeHandler) { + this.majorDataChangeHandler(); + } + }) + } + + desktop_setInitialDataLoadHandler(handler) { + this.dataLoadHandler = handler; + if(this.dataLoaded) { + this.dataLoadHandler(); + } + } + + desktop_requestBackupFile() { + let data = this.modelManager.getAllItemsJSONData( + this.authManager.keys(), + this.authManager.getAuthParams(), + this.authManager.protocolVersion(), + true /* return null on empty */ + ); + return data; + } + + desktop_setMajorDataChangeHandler(handler) { + this.majorDataChangeHandler = handler; + } + +} + +angular.module('app.frontend').service('desktopManager', DesktopManager); diff --git a/app/assets/javascripts/app/services/directives/views/accountMenu.js b/app/assets/javascripts/app/services/directives/views/accountMenu.js index 66358201a..32e9cbf4f 100644 --- a/app/assets/javascripts/app/services/directives/views/accountMenu.js +++ b/app/assets/javascripts/app/services/directives/views/accountMenu.js @@ -373,20 +373,9 @@ class AccountMenu { } $scope.itemsData = function(keys) { - var items = _.map(modelManager.allItems, function(item){ - var itemParams = new ItemParams(item, keys, authManager.protocolVersion()); - return itemParams.paramsForExportFile(); - }.bind(this)); - - var data = {items: items} - - if(keys) { - // auth params are only needed when encrypted with a standard file key - data["auth_params"] = authManager.getAuthParams(); - } - - var data = new Blob([JSON.stringify(data, null, 2 /* pretty print */)], {type: 'text/json'}); - return data; + let data = modelManager.getAllItemsJSONData(keys, authManager.getAuthParams(), authManager.protocolVersion()); + let blobData = new Blob([data], {type: 'text/json'}); + return blobData; } diff --git a/app/assets/javascripts/app/services/encryption/encryptionHelper.js b/app/assets/javascripts/app/services/encryption/encryptionHelper.js index 740a728be..6ec830665 100644 --- a/app/assets/javascripts/app/services/encryption/encryptionHelper.js +++ b/app/assets/javascripts/app/services/encryption/encryptionHelper.js @@ -122,6 +122,8 @@ class EncryptionHelper { var content = Neeto.crypto.decryptText(itemParams, true); if(!content) { item.errorDecrypting = true; + } else { + item.errorDecrypting = false; } item.content = content; } diff --git a/app/assets/javascripts/app/services/modelManager.js b/app/assets/javascripts/app/services/modelManager.js index f1c8d587a..81a075dc1 100644 --- a/app/assets/javascripts/app/services/modelManager.js +++ b/app/assets/javascripts/app/services/modelManager.js @@ -105,7 +105,7 @@ class ModelManager { // first loop should add and process items for (var json_obj of items) { - if((!json_obj.content_type || !json_obj.content) && !json_obj.deleted) { + if((!json_obj.content_type || !json_obj.content) && !json_obj.deleted && !json_obj.errorDecrypting) { // An item that is not deleted should never have empty content console.error("Server response item is corrupt:", json_obj); continue; @@ -363,6 +363,31 @@ class ModelManager { itemOne.setDirty(true); itemTwo.setDirty(true); } + + + /* + Archives + */ + + getAllItemsJSONData(keys, authParams, protocolVersion, returnNullIfEmpty) { + var items = _.map(this.allItems, (item) => { + var itemParams = new ItemParams(item, keys, protocolVersion); + return itemParams.paramsForExportFile(); + }); + + if(returnNullIfEmpty && items.length == 0) { + return null; + } + + var data = {items: items} + + if(keys) { + // auth params are only needed when encrypted with a standard file key + data["auth_params"] = authParams; + } + + return JSON.stringify(data, null, 2 /* pretty print */); + } } angular.module('app.frontend').service('modelManager', ModelManager); diff --git a/app/assets/javascripts/app/services/syncManager.js b/app/assets/javascripts/app/services/syncManager.js index 7fe980953..cac99a8dd 100644 --- a/app/assets/javascripts/app/services/syncManager.js +++ b/app/assets/javascripts/app/services/syncManager.js @@ -282,7 +282,8 @@ class SyncManager { this.handleItemsResponse(response.saved_items, omitFields); // Create copies of items or alternate their uuids if neccessary - this.handleUnsavedItemsResponse(response.unsaved) + var unsaved = response.unsaved; + this.handleUnsavedItemsResponse(unsaved) this.writeItemsToLocalStorage(saved, false, null); @@ -306,6 +307,18 @@ class SyncManager { }.bind(this), 10); // wait 10ms to allow UI to update } else { this.writeItemsToLocalStorage(this.allRetreivedItems, false, null); + + // The number of changed items that constitute a major change + // This is used by the desktop app to create backups + let majorDataChangeThreshold = 5; + if( + this.allRetreivedItems.length >= majorDataChangeThreshold || + saved.length >= majorDataChangeThreshold || + unsaved.length >= majorDataChangeThreshold + ) { + this.$rootScope.$broadcast("major-data-change"); + } + this.allRetreivedItems = []; this.callQueuedCallbacksAndCurrent(callback, response); From 4f675086d3f729b039c7be12ac2c93ef1440d3bf Mon Sep 17 00:00:00 2001 From: Mo Bitar Date: Mon, 6 Nov 2017 09:43:22 -0600 Subject: [PATCH 2/2] Allow encrypted backups when using passcode --- .../app/services/desktopManager.js | 20 ++++++++--- .../services/directives/views/accountMenu.js | 36 +++++++++++++++---- .../app/services/passcodeManager.js | 8 +++-- .../directives/account-menu.html.haml | 6 ++-- 4 files changed, 55 insertions(+), 15 deletions(-) diff --git a/app/assets/javascripts/app/services/desktopManager.js b/app/assets/javascripts/app/services/desktopManager.js index 9240ccdf3..1cb28506c 100644 --- a/app/assets/javascripts/app/services/desktopManager.js +++ b/app/assets/javascripts/app/services/desktopManager.js @@ -2,7 +2,8 @@ class DesktopManager { - constructor($rootScope, modelManager, authManager) { + constructor($rootScope, modelManager, authManager, passcodeManager) { + this.passcodeManager = passcodeManager; this.modelManager = modelManager; this.authManager = authManager; this.$rootScope = $rootScope; @@ -29,10 +30,21 @@ class DesktopManager { } desktop_requestBackupFile() { + var keys, authParams, protocolVersion; + if(this.authManager.offline() && this.passcodeManager.hasPasscode()) { + keys = this.passcodeManager.keys(); + authParams = this.passcodeManager.passcodeAuthParams(); + protocolVersion = authParams.version; + } else { + keys = this.authManager.keys(); + authParams = this.authManager.getAuthParams(); + protocolVersion = this.authManager.protocolVersion(); + } + let data = this.modelManager.getAllItemsJSONData( - this.authManager.keys(), - this.authManager.getAuthParams(), - this.authManager.protocolVersion(), + keys, + authParams, + protocolVersion, true /* return null on empty */ ); return data; diff --git a/app/assets/javascripts/app/services/directives/views/accountMenu.js b/app/assets/javascripts/app/services/directives/views/accountMenu.js index 32e9cbf4f..68806f322 100644 --- a/app/assets/javascripts/app/services/directives/views/accountMenu.js +++ b/app/assets/javascripts/app/services/directives/views/accountMenu.js @@ -8,13 +8,17 @@ class AccountMenu { }; } - controller($scope, authManager, modelManager, syncManager, dbManager, passcodeManager, $timeout, storageManager) { + controller($scope, $rootScope, authManager, modelManager, syncManager, dbManager, passcodeManager, $timeout, storageManager) { 'ngInject'; $scope.formData = {mergeLocal: true, url: syncManager.serverURL, ephemeral: false}; $scope.user = authManager.user; $scope.server = syncManager.serverURL; + $scope.encryptedBackupsAvailable = function() { + return authManager.user || passcodeManager.hasPasscode(); + } + $scope.syncStatus = syncManager.syncStatus; $scope.encryptionKey = function() { @@ -153,6 +157,9 @@ class AccountMenu { syncManager.markAllItemsDirtyAndSaveOffline(function(){ block(); }, true) + + // Allows desktop to make backup file + $rootScope.$broadcast("major-data-change"); } else { modelManager.resetLocalMemory(); storageManager.clearAllModels(function(){ @@ -174,7 +181,7 @@ class AccountMenu { /* Import/Export */ - $scope.archiveFormData = {encrypted: $scope.user ? true : false}; + $scope.archiveFormData = {encrypted: $scope.encryptedBackupsAvailable() ? true : false}; $scope.user = authManager.user; $scope.submitImportPassword = function() { @@ -361,8 +368,19 @@ class AccountMenu { $scope.downloadDataArchive = function() { // download in Standard File format - var keys = $scope.archiveFormData.encrypted ? authManager.keys() : null; - var data = $scope.itemsData(keys); + var keys, authParams, protocolVersion; + if($scope.archiveFormData.encrypted) { + if(authManager.offline() && passcodeManager.hasPasscode()) { + keys = passcodeManager.keys(); + authParams = passcodeManager.passcodeAuthParams(); + protocolVersion = authParams.version; + } else { + keys = authManager.keys(); + authParams = authManager.getAuthParams(); + protocolVersion = authManager.protocolVersion(); + } + } + var data = $scope.itemsData(keys, authParams, protocolVersion); downloadData(data, `SN Archive - ${new Date()}.txt`); // download as zipped plain text files @@ -372,8 +390,8 @@ class AccountMenu { } } - $scope.itemsData = function(keys) { - let data = modelManager.getAllItemsJSONData(keys, authManager.getAuthParams(), authManager.protocolVersion()); + $scope.itemsData = function(keys, authParams, protocolVersion) { + let data = modelManager.getAllItemsJSONData(keys, authParams, protocolVersion); let blobData = new Blob([data], {type: 'text/json'}); return blobData; } @@ -516,6 +534,8 @@ class AccountMenu { if(offline) { syncManager.markAllItemsDirtyAndSaveOffline(); + // Allows desktop to make backup file + $rootScope.$broadcast("major-data-change"); } }) }) @@ -529,8 +549,12 @@ class AccountMenu { } if(confirm(message)) { passcodeManager.clearPasscode(); + if(authManager.offline()) { syncManager.markAllItemsDirtyAndSaveOffline(); + // Don't create backup here, as if the user is temporarily removing the passcode to change it, + // we don't want to write unencrypted data to disk. + // $rootScope.$broadcast("major-data-change"); } } } diff --git a/app/assets/javascripts/app/services/passcodeManager.js b/app/assets/javascripts/app/services/passcodeManager.js index 2124b6363..e4862fbe6 100644 --- a/app/assets/javascripts/app/services/passcodeManager.js +++ b/app/assets/javascripts/app/services/passcodeManager.js @@ -22,8 +22,12 @@ angular.module('app.frontend') return this._keys; } + this.passcodeAuthParams = function() { + return JSON.parse(storageManager.getItem("offlineParams", StorageManager.Fixed)); + } + this.unlock = function(passcode, callback) { - var params = JSON.parse(storageManager.getItem("offlineParams", StorageManager.Fixed)); + var params = this.passcodeAuthParams(); Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: passcode}, params), function(keys){ if(keys.pw !== params.hash) { callback(false); @@ -40,7 +44,7 @@ angular.module('app.frontend') this.setPasscode = function(passcode, callback) { var cost = Neeto.crypto.defaultPasswordGenerationCost(); var salt = Neeto.crypto.generateRandomKey(512); - var defaultParams = {pw_cost: cost, pw_salt: salt}; + var defaultParams = {pw_cost: cost, pw_salt: salt, version: "002"}; Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: passcode}, defaultParams), function(keys) { defaultParams.hash = keys.pw; diff --git a/app/assets/templates/frontend/directives/account-menu.html.haml b/app/assets/templates/frontend/directives/account-menu.html.haml index d0b5ba927..87ca42432 100644 --- a/app/assets/templates/frontend/directives/account-menu.html.haml +++ b/app/assets/templates/frontend/directives/account-menu.html.haml @@ -141,15 +141,15 @@ .mt-25{"ng-if" => "!importData.loading"} %h4 Data Archives - .mt-5{"ng-if" => "user"} - %label.normal.inline{"ng-if" => "user"} + .mt-5{"ng-if" => "encryptedBackupsAvailable()"} + %label.normal.inline %input{"type" => "radio", "ng-model" => "archiveFormData.encrypted", "ng-value" => "true", "ng-change" => "archiveFormData.encrypted = true"} Encrypted %label.normal.inline %input{"type" => "radio", "ng-model" => "archiveFormData.encrypted", "ng-value" => "false", "ng-change" => "archiveFormData.encrypted = false"} Decrypted - %a.block.mt-5{"ng-click" => "downloadDataArchive()", "ng-class" => "{'mt-5' : !user}"} Export Data Archive + %a.block.mt-5{"ng-click" => "downloadDataArchive()", "ng-class" => "{'mt-5' : !user}"} Download Data Archive %label.block.mt-5 %input{"type" => "file", "style" => "display: none;", "file-change" => "->", "handler" => "importFileSelected(files)"}