Merge pull request #58 from standardnotes/sync-provider-destruction

Sync refactor
This commit is contained in:
Mo Bitar
2017-01-29 18:01:19 -06:00
committed by GitHub
40 changed files with 1519 additions and 1429 deletions

View File

@@ -1,6 +1,7 @@
'use strict';
var Neeto = Neeto || {};
var SN = SN || {};
// detect IE8 and above, and edge.
// IE and Edge do not support pbkdf2 in WebCrypto, therefore we need to use CryptoJS
@@ -17,12 +18,9 @@ angular.module('app.frontend', [
'restangular'
])
.config(function (RestangularProvider, apiControllerProvider) {
.config(function (RestangularProvider, authManagerProvider) {
RestangularProvider.setDefaultHeaders({"Content-Type": "application/json"});
var url = apiControllerProvider.defaultServerURL();
RestangularProvider.setBaseUrl(url + "/api");
RestangularProvider.setFullRequestInterceptor(function(element, operation, route, url, headers, params, httpConfig) {
var token = localStorage.getItem("jwt");
if(token) {

View File

@@ -1,9 +1,9 @@
class BaseCtrl {
constructor($rootScope, modelManager, apiController, dbManager) {
constructor(syncManager, dbManager) {
dbManager.openDatabase(null, function(){
// new database, delete syncToken so that items can be refetched entirely from server
apiController.clearSyncToken();
apiController.sync();
syncManager.clearSyncToken();
syncManager.sync();
})
}
}

View File

@@ -19,9 +19,9 @@ angular.module('app.frontend')
/**
* Insert 4 spaces when a tab key is pressed,
* only used when inside of the text editor.
* If the shift key is pressed first, this event is
* not fired.
*/
* If the shift key is pressed first, this event is
* not fired.
*/
var handleTab = function (event) {
if (!event.shiftKey && event.which == 9) {
event.preventDefault();
@@ -29,13 +29,13 @@ angular.module('app.frontend')
var end = this.selectionEnd;
var spaces = " ";
// Insert 4 spaces
// Insert 4 spaces
this.value = this.value.substring(0, start)
+ spaces + this.value.substring(end);
// Place cursor 4 spaces away from where
// the tab key was pressed
this.selectionStart = this.selectionEnd = start + 4;
// Place cursor 4 spaces away from where
// the tab key was pressed
this.selectionStart = this.selectionEnd = start + 4;
}
}
@@ -88,7 +88,7 @@ angular.module('app.frontend')
}
}
})
.controller('EditorCtrl', function ($sce, $timeout, apiController, markdownRenderer, $rootScope, extensionManager) {
.controller('EditorCtrl', function ($sce, $timeout, authManager, markdownRenderer, $rootScope, extensionManager, syncManager) {
this.setNote = function(note, oldNote) {
this.editorMode = 'edit';
@@ -146,16 +146,20 @@ angular.module('app.frontend')
note.dummy = false;
this.save()(note, function(success){
if(success) {
apiController.clearDraft();
if(statusTimeout) $timeout.cancel(statusTimeout);
statusTimeout = $timeout(function(){
this.noteStatus = "All changes saved"
var status = "All changes saved"
if(authManager.offline()) {
status += " (offline)";
}
this.saveError = false;
this.noteStatus = status;
}.bind(this), 200)
} else {
if(statusTimeout) $timeout.cancel(statusTimeout);
statusTimeout = $timeout(function(){
this.noteStatus = "(Offline) — All changes saved"
this.saveError = true;
this.noteStatus = "Error saving"
}.bind(this), 200)
}
}.bind(this));
@@ -171,10 +175,6 @@ angular.module('app.frontend')
this.changesMade = function() {
this.note.hasChanges = true;
this.note.dummy = false;
if(apiController.isUserSignedIn()) {
// signed out users have local autosave, dont need draft saving
apiController.saveDraftToDisk(this.note);
}
if(saveTimeout) $timeout.cancel(saveTimeout);
if(statusTimeout) $timeout.cancel(statusTimeout);
@@ -236,9 +236,10 @@ angular.module('app.frontend')
}
this.deleteNote = function() {
apiController.clearDraft();
this.remove()(this.note);
this.showMenu = false;
if(confirm("Are you sure you want to delete this note?")) {
this.remove()(this.note);
this.showMenu = false;
}
}
this.clickedEditNote = function() {

View File

@@ -1,5 +1,5 @@
angular.module('app.frontend')
.directive("header", function(apiController, extensionManager){
.directive("header", function(authManager){
return {
restrict: 'E',
scope: {},
@@ -12,119 +12,54 @@ angular.module('app.frontend')
link:function(scope, elem, attrs, ctrl) {
scope.$on("sync:updated_token", function(){
ctrl.syncUpdated();
ctrl.findErrors();
ctrl.updateOfflineStatus();
})
scope.$on("sync:error", function(){
ctrl.findErrors();
ctrl.updateOfflineStatus();
})
}
}
})
.controller('HeaderCtrl', function ($state, apiController, modelManager, $timeout, extensionManager, dbManager) {
.controller('HeaderCtrl', function (authManager, modelManager, $timeout, dbManager, syncManager) {
this.user = apiController.user;
this.extensionManager = extensionManager;
this.loginData = {mergeLocal: true};
this.user = authManager.user;
this.changePasswordPressed = function() {
this.showNewPasswordForm = !this.showNewPasswordForm;
this.updateOfflineStatus = function() {
this.offline = authManager.offline();
}
this.updateOfflineStatus();
this.findErrors = function() {
this.error = syncManager.syncStatus.error;
}
this.findErrors();
this.accountMenuPressed = function() {
this.serverData = {url: apiController.getServer()};
this.serverData = {};
this.showAccountMenu = !this.showAccountMenu;
this.showFaq = false;
this.showNewPasswordForm = false;
this.showExtensionsMenu = false;
this.showIOMenu = false;
}
this.toggleExtensions = function() {
this.showAccountMenu = false;
this.showIOMenu = false;
this.showExtensionsMenu = !this.showExtensionsMenu;
}
this.toggleExtensionForm = function() {
this.newExtensionData = {};
this.showNewExtensionForm = !this.showNewExtensionForm;
}
this.submitNewExtensionForm = function() {
if(this.newExtensionData.url) {
extensionManager.addExtension(this.newExtensionData.url, function(response){
if(!response) {
alert("Unable to register this extension. Make sure the link is valid and try again.");
} else {
this.newExtensionData.url = "";
this.showNewExtensionForm = false;
}
}.bind(this))
}
}
this.selectedAction = function(action, extension) {
action.running = true;
extensionManager.executeAction(action, extension, null, function(response){
action.running = false;
if(response && response.error) {
action.error = true;
alert("There was an error performing this action. Please try again.");
} else {
action.error = false;
apiController.sync(null);
}
})
}
this.deleteExtension = function(extension) {
if(confirm("Are you sure you want to delete this extension?")) {
extensionManager.deleteExtension(extension);
}
}
this.reloadExtensionsPressed = function() {
if(confirm("For your security, reloading extensions will disable any currently enabled repeat actions.")) {
extensionManager.refreshExtensionsFromServer();
}
}
this.changeServer = function() {
apiController.setServer(this.serverData.url, true);
}
this.signOutPressed = function() {
this.toggleIO = function() {
this.showIOMenu = !this.showIOMenu;
this.showExtensionsMenu = false;
this.showAccountMenu = false;
apiController.signout(function(){
window.location.reload();
})
}
this.submitPasswordChange = function() {
this.passwordChangeData.status = "Generating New Keys...";
$timeout(function(){
if(data.password != data.password_confirmation) {
alert("Your new password does not match its confirmation.");
return;
}
apiController.changePassword(this.passwordChangeData.current_password, this.passwordChangeData.new_password, function(response){
})
}.bind(this))
}
this.localNotesCount = function() {
return modelManager.filteredNotes.length;
}
this.mergeLocalChanged = function() {
if(!this.loginData.mergeLocal) {
if(!confirm("Unchecking this option means any of the notes you have written while you were signed out will be deleted. Are you sure you want to discard these notes?")) {
this.loginData.mergeLocal = true;
}
}
}
this.refreshData = function() {
this.isRefreshing = true;
apiController.sync(function(response){
syncManager.sync(function(response){
$timeout(function(){
this.isRefreshing = false;
}.bind(this), 200)
@@ -139,110 +74,4 @@ angular.module('app.frontend')
this.syncUpdated = function() {
this.lastSyncDate = new Date();
}
this.loginSubmitPressed = function() {
this.loginData.status = "Generating Login Keys...";
$timeout(function(){
apiController.login(this.loginData.email, this.loginData.user_password, function(response){
if(!response || response.error) {
var error = response ? response.error : {message: "An unknown error occured."}
this.loginData.status = null;
if(!response || (response && !response.didDisplayAlert)) {
alert(error.message);
}
} else {
this.onAuthSuccess(response.user);
}
}.bind(this));
}.bind(this))
}
this.submitRegistrationForm = function() {
this.loginData.status = "Generating Account Keys...";
$timeout(function(){
apiController.register(this.loginData.email, this.loginData.user_password, function(response){
if(!response || response.error) {
var error = response ? response.error : {message: "An unknown error occured."}
this.loginData.status = null;
alert(error.message);
} else {
this.onAuthSuccess(response.user);
}
}.bind(this));
}.bind(this))
}
this.encryptionStatusForNotes = function() {
var allNotes = modelManager.filteredNotes;
return allNotes.length + "/" + allNotes.length + " notes encrypted";
}
this.archiveEncryptionFormat = {encrypted: true};
this.downloadDataArchive = function() {
var link = document.createElement('a');
link.setAttribute('download', 'notes.json');
link.href = apiController.itemsDataFile(this.archiveEncryptionFormat.encrypted);
link.click();
}
this.performImport = function(data, password) {
this.importData.loading = true;
// allow loading indicator to come up with timeout
$timeout(function(){
apiController.importJSONData(data, password, function(success, response){
console.log("Import response:", success, response);
this.importData.loading = false;
if(success) {
this.importData = null;
} else {
alert("There was an error importing your data. Please try again.");
}
}.bind(this))
}.bind(this))
}
this.submitImportPassword = function() {
this.performImport(this.importData.data, this.importData.password);
}
this.importFileSelected = function(files) {
this.importData = {};
var file = files[0];
var reader = new FileReader();
reader.onload = function(e) {
var data = JSON.parse(e.target.result);
$timeout(function(){
if(data.auth_params) {
// request password
this.importData.requestPassword = true;
this.importData.data = data;
} else {
this.performImport(data, null);
}
}.bind(this))
}.bind(this)
reader.readAsText(file);
}
this.onAuthSuccess = function(user) {
var block = function(){
window.location.reload();
this.showLogin = false;
this.showRegistration = false;
}.bind(this);
if(!this.loginData.mergeLocal) {
dbManager.clearAllItems(function(){
block();
});
} else {
block();
}
}
});
});

View File

@@ -1,15 +1,15 @@
angular.module('app.frontend')
.controller('HomeCtrl', function ($scope, $rootScope, $timeout, apiController, modelManager) {
.controller('HomeCtrl', function ($scope, $rootScope, $timeout, modelManager, syncManager, authManager) {
$rootScope.bodyClass = "app-body-class";
apiController.loadLocalItems(function(items){
syncManager.loadLocalItems(function(items) {
$scope.$apply();
apiController.sync(null);
syncManager.sync(null);
// refresh every 30s
setInterval(function () {
apiController.sync(null);
}, 30000);
// setInterval(function () {
// syncManager.sync(null);
// }, 30000);
});
$scope.allTag = new Tag({all: true});
@@ -31,7 +31,7 @@ angular.module('app.frontend')
modelManager.createRelationshipBetweenItems(note, tag);
}
apiController.sync();
syncManager.sync();
}
/*
@@ -56,8 +56,12 @@ angular.module('app.frontend')
}
$scope.tagsSave = function(tag, callback) {
if(!tag.title || tag.title.length == 0) {
$scope.notesRemoveTag(tag);
return;
}
tag.setDirty(true);
apiController.sync(callback);
syncManager.sync(callback);
}
/*
@@ -69,12 +73,9 @@ angular.module('app.frontend')
if(validNotes == 0) {
modelManager.setItemToBeDeleted(tag);
// if no more notes, delete tag
apiController.sync(function(){
syncManager.sync(function(){
// force scope tags to update on sub directives
$scope.tags = [];
$timeout(function(){
$scope.tags = modelManager.tags;
})
$scope.safeApply();
});
} else {
alert("To delete this tag, remove all its notes first.");
@@ -100,7 +101,7 @@ angular.module('app.frontend')
$scope.saveNote = function(note, callback) {
note.setDirty(true);
apiController.sync(function(response){
syncManager.sync(function(response){
if(response && response.error) {
if(!$scope.didShowErrorAlert) {
$scope.didShowErrorAlert = true;
@@ -137,8 +138,8 @@ angular.module('app.frontend')
return;
}
apiController.sync(function(){
if(!apiController.user) {
syncManager.sync(function(){
if(authManager.offline()) {
// when deleting items while ofline, we need to explictly tell angular to refresh UI
setTimeout(function () {
$scope.safeApply();

View File

@@ -24,7 +24,7 @@ angular.module('app.frontend')
}
}
})
.controller('NotesCtrl', function (apiController, $timeout, $rootScope, modelManager) {
.controller('NotesCtrl', function (authManager, $timeout, $rootScope, modelManager) {
$rootScope.$on("editorFocused", function(){
this.showMenu = false;
@@ -32,6 +32,11 @@ angular.module('app.frontend')
var isFirstLoad = true;
this.notesToDisplay = 20;
this.paginate = function() {
this.notesToDisplay += 20
}
this.tagDidChange = function(tag, oldTag) {
this.showMenu = false;
@@ -48,14 +53,8 @@ angular.module('app.frontend')
if(isFirstLoad) {
$timeout(function(){
var draft = apiController.getDraft();
if(draft) {
var note = draft;
this.selectNote(note);
} else {
this.createNewNote();
isFirstLoad = false;
}
this.createNewNote();
isFirstLoad = false;
}.bind(this))
} else if(tag.notes.length == 0) {
this.createNewNote();

View File

@@ -79,19 +79,20 @@ angular.module('app.frontend')
this.saveTag = function($event, tag) {
this.editingTag = null;
if(tag.title.length == 0) {
tag.title = originalTagName;
originalTagName = "";
$event.target.blur();
if(!tag.title || tag.title.length == 0) {
if(originalTagName) {
tag.title = originalTagName;
originalTagName = null;
} else {
// newly created tag without content
modelManager.removeItemLocally(tag);
}
return;
}
$event.target.blur();
if(!tag.title || tag.title.length == 0) {
return;
}
this.save()(tag, function(savedTag){
// _.merge(tag, savedTag);
this.selectTag(tag);
this.newTag = null;
}.bind(this));

View File

@@ -50,10 +50,6 @@ class Item {
}
}
alternateUUID() {
this.uuid = Neeto.crypto.generateUUID();
}
setDirty(dirty) {
this.dirty = dirty;
@@ -107,6 +103,10 @@ class Item {
// must override
}
isBeingRemovedLocally() {
}
removeAllRelationships() {
// must override
this.setDirty(true);

View File

@@ -1,18 +1,20 @@
class Action {
constructor(json) {
_.merge(this, json);
this.running = false; // in case running=true was synced with server since model is uploaded nondiscriminatory
this.error = false;
if(this.lastExecuted) {
// is string
this.lastExecuted = new Date(this.lastExecuted);
}
_.merge(this, json);
this.running = false; // in case running=true was synced with server since model is uploaded nondiscriminatory
this.error = false;
if(this.lastExecuted) {
// is string
this.lastExecuted = new Date(this.lastExecuted);
}
}
get permissionsString() {
permissionsString() {
console.log("permissions", this.permissions);
if(!this.permissions) {
return "";
}
var permission = this.permissions.charAt(0).toUpperCase() + this.permissions.slice(1); // capitalize first letter
permission += ": ";
for(var contentType of this.content_types) {
@@ -28,7 +30,7 @@ class Action {
return permission;
}
get encryptionModeString() {
encryptionModeString() {
if(this.verb != "post") {
return null;
}
@@ -54,6 +56,12 @@ class Extension extends Item {
this.encrypted = true;
this.content_type = "Extension";
if(json.actions) {
this.actions = json.actions.map(function(action){
return new Action(action);
})
}
}
actionsInGlobalContext() {

View File

@@ -56,6 +56,13 @@ class Note extends Item {
this.tags = [];
}
isBeingRemovedLocally() {
this.tags.forEach(function(tag){
_.pull(tag.notes, this);
}.bind(this))
super.isBeingRemovedLocally();
}
static filterDummyNotes(notes) {
var filtered = notes.filter(function(note){return note.dummy == false || note.dummy == null});
return filtered;

View File

@@ -55,6 +55,13 @@ class Tag extends Item {
this.notes = [];
}
isBeingRemovedLocally() {
this.notes.forEach(function(note){
_.pull(note.tags, this);
}.bind(this))
super.isBeingRemovedLocally();
}
get content_type() {
return "Tag";
}

View File

@@ -0,0 +1,57 @@
class ItemParams {
constructor(item, ek) {
this.item = item;
this.ek = ek;
}
paramsForExportFile() {
this.additionalFields = ["updated_at"];
this.forExportFile = true;
return _.omit(this.__params(), ["deleted"]);
}
paramsForExtension() {
return this.paramsForExportFile();
}
paramsForLocalStorage() {
this.additionalFields = ["updated_at", "dirty"];
this.forExportFile = true;
return this.__params();
}
paramsForSync() {
return this.__params(null, false);
}
__params() {
var itemCopy = _.cloneDeep(this.item);
console.assert(!this.item.dummy, "Item is dummy, should not have gotten here.", this.item.dummy)
var params = {uuid: this.item.uuid, content_type: this.item.content_type, deleted: this.item.deleted, created_at: this.item.created_at};
if(this.ek) {
EncryptionHelper.encryptItem(itemCopy, this.ek);
params.content = itemCopy.content;
params.enc_item_key = itemCopy.enc_item_key;
params.auth_hash = itemCopy.auth_hash;
}
else {
params.content = this.forExportFile ? itemCopy.createContentJSONFromProperties() : "000" + Neeto.crypto.base64(JSON.stringify(itemCopy.createContentJSONFromProperties()));
if(!this.forExportFile) {
params.enc_item_key = null;
params.auth_hash = null;
}
}
if(this.additionalFields) {
_.merge(params, _.pick(this.item, this.additionalFields));
}
return params;
}
}

View File

@@ -1,607 +0,0 @@
angular.module('app.frontend')
.provider('apiController', function () {
function domainName() {
var domain_comps = location.hostname.split(".");
var domain = domain_comps[domain_comps.length - 2] + "." + domain_comps[domain_comps.length - 1];
return domain;
}
var url;
this.defaultServerURL = function() {
if(!url) {
url = localStorage.getItem("server");
if(!url) {
url = "https://n3.standardnotes.org";
}
}
return url;
}
this.$get = function($rootScope, Restangular, modelManager, dbManager) {
return new ApiController($rootScope, Restangular, modelManager, dbManager);
}
function ApiController($rootScope, Restangular, modelManager, dbManager) {
var userData = localStorage.getItem("user");
if(userData) {
this.user = JSON.parse(userData);
} else {
// legacy, check for uuid
var idData = localStorage.getItem("uuid");
if(idData) {
this.user = {uuid: idData};
}
}
this.syncToken = localStorage.getItem("syncToken");
/*
Config
*/
this.getServer = function() {
if(!url) {
url = localStorage.getItem("server");
if(!url) {
url = "https://n3.standardnotes.org";
this.setServer(url);
}
}
return url;
}
this.setServer = function(url, refresh) {
localStorage.setItem("server", url);
if(refresh) {
window.location.reload();
}
}
/*
Auth
*/
this.getAuthParams = function() {
return JSON.parse(localStorage.getItem("auth_params"));
}
this.isUserSignedIn = function() {
return localStorage.getItem("jwt");
}
this.getAuthParamsForEmail = function(email, callback) {
var request = Restangular.one("auth", "params");
request.get({email: email}).then(function(response){
callback(response.plain());
})
.catch(function(response){
console.log("Error getting current user", response);
callback(response.data);
})
}
this.supportsPasswordDerivationCost = function(cost) {
// some passwords are created on platforms with stronger pbkdf2 capabilities, like iOS,
// which accidentally used 60,000 iterations (now adjusted), which CryptoJS can't handle here (WebCrypto can however).
// if user has high password cost and is using browser that doesn't support WebCrypto,
// we want to tell them that they can't login with this browser.
if(cost > 5000) {
return Neeto.crypto instanceof SNCryptoWeb ? true : false;
} else {
return true;
}
}
this.login = function(email, password, callback) {
this.getAuthParamsForEmail(email, function(authParams){
if(!authParams) {
callback(null);
return;
}
if(!this.supportsPasswordDerivationCost(authParams.pw_cost)) {
var string = "Your account was created on a platform with higher security capabilities than this browser supports. " +
"If we attempted to generate your login keys here, it would take hours. " +
"Please use a browser with more up to date security capabilities, like Google Chrome or Firefox, to login."
alert(string)
callback({didDisplayAlert: true});
return;
}
Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, authParams), function(keys){
this.setMk(keys.mk);
var request = Restangular.one("auth/sign_in");
var params = {password: keys.pw, email: email};
_.merge(request, params);
request.post().then(function(response){
localStorage.setItem("jwt", response.token);
localStorage.setItem("user", JSON.stringify(response.user));
localStorage.setItem("auth_params", JSON.stringify(authParams));
callback(response);
})
.catch(function(response){
callback(response.data);
})
}.bind(this));
}.bind(this))
}
this.register = function(email, password, callback) {
Neeto.crypto.generateInitialEncryptionKeysForUser({password: password, email: email}, function(keys, authParams){
this.setMk(keys.mk);
keys.mk = null;
var request = Restangular.one("auth");
var params = _.merge({password: keys.pw, email: email}, authParams);
_.merge(request, params);
request.post().then(function(response){
localStorage.setItem("jwt", response.token);
localStorage.setItem("user", JSON.stringify(response.user));
localStorage.setItem("auth_params", JSON.stringify(_.omit(authParams, ["pw_nonce"])));
callback(response);
})
.catch(function(response){
callback(response.data);
})
}.bind(this));
}
// this.changePassword = function(current_password, new_password) {
// this.getAuthParamsForEmail(email, function(authParams){
// if(!authParams) {
// callback(null);
// return;
// }
// Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: current_password, email: user.email}, authParams), function(currentKeys) {
// Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: new_password, email: user.email}, authParams), function(newKeys){
// var data = {};
// data.current_password = currentKeys.pw;
// data.password = newKeys.pw;
// data.password_confirmation = newKeys.pw;
//
// var user = this.user;
//
// this._performPasswordChange(currentKeys, newKeys, function(response){
// if(response && !response.error) {
// // this.showNewPasswordForm = false;
// // reencrypt data with new mk
// this.reencryptAllItemsAndSave(user, newKeys.mk, currentKeys.mk, function(success){
// if(success) {
// this.setMk(newKeys.mk);
// alert("Your password has been changed and your data re-encrypted.");
// } else {
// // rollback password
// this._performPasswordChange(newKeys, currentKeys, function(response){
// alert("There was an error changing your password. Your password has been rolled back.");
// window.location.reload();
// })
// }
// }.bind(this));
// } else {
// // this.showNewPasswordForm = false;
// alert("There was an error changing your password. Please try again.");
// }
// }.bind(this))
// }.bind(this));
// }.bind(this));
// }.bind(this));
// }
this._performPasswordChange = function(email, current_keys, new_keys, callback) {
var request = Restangular.one("auth");
var params = {password: new_keys.pw, password_confirmation: new_keys.pw, current_password: current_keys.pw, email: email};
_.merge(request, params);
request.patch().then(function(response){
callback(response);
})
}
/*
Items
*/
this.setSyncToken = function(syncToken) {
this.syncToken = syncToken;
localStorage.setItem("syncToken", this.syncToken);
}
this.syncWithOptions = function(callback, options = {}) {
if(this.syncOpInProgress) {
// will perform anoter sync after current completes
this.repeatSync = true;
return;
}
this.syncOpInProgress = true;
var allDirtyItems = modelManager.getDirtyItems();
// we want to write all dirty items to disk only if the user is not signed in, or if the sync op fails
// if the sync op succeeds, these items will be written to disk by handling the "saved_items" response from the server
var writeAllDirtyItemsToDisk = function(completion) {
this.writeItemsToLocalStorage(allDirtyItems, function(responseItems){
if(completion) {
completion();
}
})
}.bind(this);
if(!this.isUserSignedIn()) {
writeAllDirtyItemsToDisk(function(){
// delete anything needing to be deleted
allDirtyItems.forEach(function(item){
if(item.deleted) {
modelManager.removeItemLocally(item);
}
}.bind(this))
modelManager.clearDirtyItems(allDirtyItems);
}.bind(this))
this.syncOpInProgress = false;
if(callback) {
callback();
}
return;
}
let submitLimit = 100;
var dirtyItems = allDirtyItems.slice(0, submitLimit);
if(dirtyItems.length < allDirtyItems.length) {
// more items left to be synced, repeat
this.repeatSync = true;
} else {
this.repeatSync = false;
}
var request = Restangular.one("items/sync");
request.limit = 150;
request.sync_token = this.syncToken;
request.cursor_token = this.cursorToken;
request.items = _.map(dirtyItems, function(item){
return this.createRequestParamsForItem(item, options.additionalFields);
}.bind(this));
request.post().then(function(response) {
modelManager.clearDirtyItems(dirtyItems);
// handle sync token
this.setSyncToken(response.sync_token);
$rootScope.$broadcast("sync:updated_token", this.syncToken);
// handle cursor token (more results waiting, perform another sync)
this.cursorToken = response.cursor_token;
var retrieved = this.handleItemsResponse(response.retrieved_items, null);
// merge only metadata for saved items
var omitFields = ["content", "auth_hash"];
var saved = this.handleItemsResponse(response.saved_items, omitFields);
this.handleUnsavedItemsResponse(response.unsaved)
this.writeItemsToLocalStorage(saved, null);
this.writeItemsToLocalStorage(retrieved, null);
this.syncOpInProgress = false;
if(this.cursorToken || this.repeatSync == true) {
this.syncWithOptions(callback, options);
} else {
if(callback) {
callback(response);
}
}
}.bind(this))
.catch(function(response){
console.log("Sync error: ", response);
writeAllDirtyItemsToDisk();
this.syncOpInProgress = false;
if(callback) {
callback({error: "Sync error"});
}
}.bind(this))
}
this.sync = function(callback) {
this.syncWithOptions(callback, undefined);
}
this.handleUnsavedItemsResponse = function(unsaved) {
if(unsaved.length == 0) {
return;
}
console.log("Handle unsaved", unsaved);
for(var mapping of unsaved) {
var itemResponse = mapping.item;
var item = modelManager.findItem(itemResponse.uuid);
var error = mapping.error;
if(error.tag == "uuid_conflict") {
item.alternateUUID();
item.setDirty(true);
item.markAllReferencesDirty();
}
}
this.syncWithOptions(null, {additionalFields: ["created_at", "updated_at"]});
}
this.handleItemsResponse = function(responseItems, omitFields) {
this.decryptItems(responseItems);
return modelManager.mapResponseItemsToLocalModelsOmittingFields(responseItems, omitFields);
}
this.createRequestParamsForItem = function(item, additionalFields) {
return this.paramsForItem(item, true, additionalFields, false);
}
this.paramsForExportFile = function(item, encrypted) {
return _.omit(this.paramsForItem(item, encrypted, ["created_at", "updated_at"], true), ["deleted"]);
}
this.paramsForExtension = function(item, encrypted) {
return _.omit(this.paramsForItem(item, encrypted, ["created_at", "updated_at"], true), ["deleted"]);
}
this.paramsForItem = function(item, encrypted, additionalFields, forExportFile) {
var itemCopy = _.cloneDeep(item);
console.assert(!item.dummy, "Item is dummy, should not have gotten here.", item.dummy)
var params = {uuid: item.uuid, content_type: item.content_type, deleted: item.deleted};
if(encrypted) {
this.encryptSingleItem(itemCopy, this.retrieveMk());
params.content = itemCopy.content;
params.enc_item_key = itemCopy.enc_item_key;
params.auth_hash = itemCopy.auth_hash;
}
else {
params.content = forExportFile ? itemCopy.createContentJSONFromProperties() : "000" + Neeto.crypto.base64(JSON.stringify(itemCopy.createContentJSONFromProperties()));
if(!forExportFile) {
params.enc_item_key = null;
params.auth_hash = null;
}
}
if(additionalFields) {
_.merge(params, _.pick(item, additionalFields));
}
return params;
}
/*
Import
*/
this.clearSyncToken = function() {
this.syncToken = null;
localStorage.removeItem("syncToken");
}
this.importJSONData = function(data, password, callback) {
console.log("Importing data", data);
var onDataReady = function() {
var items = modelManager.mapResponseItemsToLocalModels(data.items);
items.forEach(function(item){
item.setDirty(true);
item.markAllReferencesDirty();
})
this.syncWithOptions(callback, {additionalFields: ["created_at", "updated_at"]});
}.bind(this)
if(data.auth_params) {
Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, data.auth_params), function(keys){
var mk = keys.mk;
try {
this.decryptItemsWithKey(data.items, mk);
// delete items enc_item_key since the user's actually key will do the encrypting once its passed off
data.items.forEach(function(item){
item.enc_item_key = null;
item.auth_hash = null;
})
onDataReady();
}
catch (e) {
console.log("Error decrypting", e);
alert("There was an error decrypting your items. Make sure the password you entered is correct and try again.");
callback(false, null);
return;
}
}.bind(this));
} else {
onDataReady();
}
}
/*
Export
*/
this.itemsDataFile = function(encrypted) {
var textFile = null;
var makeTextFile = function (text) {
var data = new Blob([text], {type: 'text/json'});
// If we are replacing a previously generated file we need to
// manually revoke the object URL to avoid memory leaks.
if (textFile !== null) {
window.URL.revokeObjectURL(textFile);
}
textFile = window.URL.createObjectURL(data);
// returns a URL you can use as a href
return textFile;
}.bind(this);
var items = _.map(modelManager.allItemsMatchingTypes(["Tag", "Note"]), function(item){
return this.paramsForExportFile(item, encrypted);
}.bind(this));
var data = {
items: items
}
if(encrypted) {
data["auth_params"] = this.getAuthParams();
}
return makeTextFile(JSON.stringify(data, null, 2 /* pretty print */));
}
this.staticifyObject = function(object) {
return JSON.parse(JSON.stringify(object));
}
this.writeItemsToLocalStorage = function(items, callback) {
var params = items.map(function(item) {
return this.paramsForItem(item, false, ["created_at", "updated_at", "dirty"], true)
}.bind(this));
dbManager.saveItems(params, callback);
}
this.loadLocalItems = function(callback) {
var params = dbManager.getAllItems(function(items){
var items = this.handleItemsResponse(items, null);
Item.sortItemsByDate(items);
callback(items);
}.bind(this))
}
/*
Drafts
*/
this.saveDraftToDisk = function(draft) {
localStorage.setItem("draft", JSON.stringify(draft));
}
this.clearDraft = function() {
localStorage.removeItem("draft");
}
this.getDraft = function() {
var draftString = localStorage.getItem("draft");
if(!draftString || draftString == 'undefined') {
return null;
}
var jsonObj = _.merge({content_type: "Note"}, JSON.parse(draftString));
return modelManager.createItem(jsonObj);
}
/*
Encrpytion
*/
this.retrieveMk = function() {
if(!this.mk) {
this.mk = localStorage.getItem("mk");
}
return this.mk;
}
this.setMk = function(mk) {
localStorage.setItem('mk', mk);
}
this.signout = function(callback) {
dbManager.clearAllItems(function(){
localStorage.clear();
callback();
});
}
this.encryptSingleItem = function(item, masterKey) {
var item_key = null;
if(item.enc_item_key) {
item_key = Neeto.crypto.decryptText(item.enc_item_key, masterKey);
} else {
item_key = Neeto.crypto.generateRandomEncryptionKey();
item.enc_item_key = Neeto.crypto.encryptText(item_key, masterKey);
}
var ek = Neeto.crypto.firstHalfOfKey(item_key);
var ak = Neeto.crypto.secondHalfOfKey(item_key);
var encryptedContent = "001" + Neeto.crypto.encryptText(JSON.stringify(item.createContentJSONFromProperties()), ek);
var authHash = Neeto.crypto.hmac256(encryptedContent, ak);
item.content = encryptedContent;
item.auth_hash = authHash;
item.local_encryption_scheme = "1.0";
}
this.decryptSingleItem = function(item, masterKey) {
var item_key = Neeto.crypto.decryptText(item.enc_item_key, masterKey);
var ek = Neeto.crypto.firstHalfOfKey(item_key);
var ak = Neeto.crypto.secondHalfOfKey(item_key);
var authHash = Neeto.crypto.hmac256(item.content, ak);
if(authHash !== item.auth_hash || !item.auth_hash) {
console.log("Authentication hash does not match.")
return;
}
var content = Neeto.crypto.decryptText(item.content.substring(3, item.content.length), ek);
item.content = content;
}
this.decryptItems = function(items) {
var masterKey = this.retrieveMk();
this.decryptItemsWithKey(items, masterKey);
}
this.decryptItemsWithKey = function(items, key) {
for (var item of items) {
if(item.deleted == true) {
continue;
}
var isString = typeof item.content === 'string' || item.content instanceof String;
if(isString) {
try {
if(item.content.substring(0, 3) == "001" && item.enc_item_key) {
// is encrypted
this.decryptSingleItem(item, key);
} else {
// is base64 encoded
item.content = Neeto.crypto.base64Decode(item.content.substring(3, item.content.length))
}
} catch (e) {
console.log("Error decrypting item", item, e);
continue;
}
}
}
}
this.reencryptAllItemsAndSave = function(user, newMasterKey, oldMasterKey, callback) {
var items = modelManager.allItems();
items.forEach(function(item){
if(item.content.substring(0, 3) == "001" && item.enc_item_key) {
// first decrypt item_key with old key
var item_key = Neeto.crypto.decryptText(item.enc_item_key, oldMasterKey);
// now encrypt item_key with new key
item.enc_item_key = Neeto.crypto.encryptText(item_key, newMasterKey);
}
});
this.saveBatchItems(user, items, function(success) {
callback(success);
}.bind(this));
}
}
});

View File

@@ -0,0 +1,178 @@
angular.module('app.frontend')
.provider('authManager', function () {
function domainName() {
var domain_comps = location.hostname.split(".");
var domain = domain_comps[domain_comps.length - 2] + "." + domain_comps[domain_comps.length - 1];
return domain;
}
this.$get = function($rootScope, Restangular, modelManager) {
return new AuthManager($rootScope, Restangular, modelManager);
}
function AuthManager($rootScope, Restangular, modelManager) {
var userData = localStorage.getItem("user");
if(userData) {
this.user = JSON.parse(userData);
} else {
// legacy, check for uuid
var idData = localStorage.getItem("uuid");
if(idData) {
this.user = {uuid: idData};
}
}
this.offline = function() {
return !this.user;
}
this.getAuthParams = function() {
return JSON.parse(localStorage.getItem("auth_params"));
}
this.getAuthParamsForEmail = function(url, email, callback) {
var requestUrl = url + "/auth/params";
var request = Restangular.oneUrl(requestUrl, requestUrl);
request.get({email: email}).then(function(response){
callback(response.plain());
})
.catch(function(response){
console.log("Error getting auth params", response);
callback(null);
})
}
this.supportsPasswordDerivationCost = function(cost) {
// some passwords are created on platforms with stronger pbkdf2 capabilities, like iOS,
// which accidentally used 60,000 iterations (now adjusted), which CryptoJS can't handle here (WebCrypto can however).
// if user has high password cost and is using browser that doesn't support WebCrypto,
// we want to tell them that they can't login with this browser.
if(cost > 5000) {
return Neeto.crypto instanceof SNCryptoWeb ? true : false;
} else {
return true;
}
}
this.login = function(url, email, password, callback) {
this.getAuthParamsForEmail(url, email, function(authParams){
if(!authParams) {
callback({error : {message: "Unable to get authentication parameters."}});
return;
}
if(!this.supportsPasswordDerivationCost(authParams.pw_cost)) {
var string = "Your account was created on a platform with higher security capabilities than this browser supports. " +
"If we attempted to generate your login keys here, it would take hours. " +
"Please use a browser with more up to date security capabilities, like Google Chrome or Firefox, to login."
alert(string)
callback({didDisplayAlert: true});
return;
}
console.log("compute encryption keys", password, authParams);
Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, authParams), function(keys){
var mk = keys.mk;
var requestUrl = url + "/auth/sign_in";
var request = Restangular.oneUrl(requestUrl, requestUrl);
var params = {password: keys.pw, email: email};
_.merge(request, params);
request.post().then(function(response){
this.handleAuthResponse(response, email, url, authParams, mk, keys.pw);
callback(response);
}.bind(this))
.catch(function(response){
console.log("Error logging in", response);
callback(response.data);
})
}.bind(this));
}.bind(this))
}
this.handleAuthResponse = function(response, email, url, authParams, mk, pw) {
localStorage.setItem("server", url);
localStorage.setItem("user", JSON.stringify(response.plain().user));
localStorage.setItem("auth_params", JSON.stringify(_.omit(authParams, ["pw_nonce"])));
localStorage.setItem("mk", mk);
localStorage.setItem("pw", pw);
localStorage.setItem("jwt", response.token);
}
this.register = function(url, email, password, callback) {
Neeto.crypto.generateInitialEncryptionKeysForUser({password: password, email: email}, function(keys, authParams){
var mk = keys.mk;
var requestUrl = url + "/auth";
var request = Restangular.oneUrl(requestUrl, requestUrl);
var params = _.merge({password: keys.pw, email: email}, authParams);
_.merge(request, params);
request.post().then(function(response){
this.handleAuthResponse(response, email, url, authParams, mk, keys.pw);
callback(response);
}.bind(this))
.catch(function(response){
console.log("Registration error", response);
callback(null);
})
}.bind(this));
}
// this.changePassword = function(current_password, new_password) {
// this.getAuthParamsForEmail(email, function(authParams){
// if(!authParams) {
// callback(null);
// return;
// }
// Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: current_password, email: user.email}, authParams), function(currentKeys) {
// Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: new_password, email: user.email}, authParams), function(newKeys){
// var data = {};
// data.current_password = currentKeys.pw;
// data.password = newKeys.pw;
// data.password_confirmation = newKeys.pw;
//
// var user = this.user;
//
// this._performPasswordChange(currentKeys, newKeys, function(response){
// if(response && !response.error) {
// // this.showNewPasswordForm = false;
// // reencrypt data with new mk
// this.reencryptAllItemsAndSave(user, newKeys.mk, currentKeys.mk, function(success){
// if(success) {
// this.setMk(newKeys.mk);
// alert("Your password has been changed and your data re-encrypted.");
// } else {
// // rollback password
// this._performPasswordChange(newKeys, currentKeys, function(response){
// alert("There was an error changing your password. Your password has been rolled back.");
// window.location.reload();
// })
// }
// }.bind(this));
// } else {
// // this.showNewPasswordForm = false;
// alert("There was an error changing your password. Please try again.");
// }
// }.bind(this))
// }.bind(this));
// }.bind(this));
// }.bind(this));
// }
this._performPasswordChange = function(url, email, current_keys, new_keys, callback) {
var requestUrl = url + "/auth";
var request = Restangular.oneUrl(requestUrl, requestUrl);
var params = {password: new_keys.pw, password_confirmation: new_keys.pw, current_password: current_keys.pw, email: email};
_.merge(request, params);
request.patch().then(function(response){
callback(response);
})
}
this.staticifyObject = function(object) {
return JSON.parse(JSON.stringify(object));
}
}
});

View File

@@ -98,11 +98,14 @@ class DBManager {
}, null)
}
deleteItem(item) {
deleteItem(item, callback) {
this.openDatabase((db) => {
var request = db.transaction("items", "readwrite").objectStore("items").delete(item.uuid);
request.onsuccess = function(event) {
console.log("Successfully deleted item", item.uuid);
if(callback) {
callback(true);
}
};
}, null)
}

View File

@@ -0,0 +1,42 @@
angular
.module('app.frontend')
.directive('delayHide', function($timeout) {
return {
restrict: 'A',
scope: {
show: '=',
delay: '@'
},
link: function(scope, elem, attrs) {
var showTimer;
showElement(false);
//This is where all the magic happens!
// Whenever the scope variable updates we simply
// show if it evaluates to 'true' and hide if 'false'
scope.$watch('show', function(newVal){
newVal ? showSpinner() : hideSpinner();
});
function showSpinner() {
showElement(true);
}
function hideSpinner() {
$timeout(showElement.bind(this, false), getDelay());
}
function showElement(show) {
show ? elem.css({display:''}) : elem.css({display:'none'});
}
function getDelay() {
var delay = parseInt(scope.delay);
return angular.isNumber(delay) ? delay : 200;
}
}
};
});

View File

@@ -0,0 +1,19 @@
angular.module('app.frontend').directive('infiniteScroll', [
'$rootScope', '$window', '$timeout', function($rootScope, $window, $timeout) {
return {
link: function(scope, elem, attrs) {
elem.css('overflow-x', 'hidden');
elem.css('height', 'inherit');
var offset = parseInt(attrs.threshold) || 0;
var e = elem[0]
elem.on('scroll', function(){
if(scope.$eval(attrs.canLoad) && e.scrollTop + e.offsetHeight >= e.scrollHeight - offset) {
scope.$apply(attrs.infiniteScroll);
}
});
}
};
}
]);

View File

@@ -0,0 +1,235 @@
class AccountMenu {
constructor() {
this.restrict = "E";
this.templateUrl = "frontend/directives/account-menu.html";
this.scope = {};
}
controller($scope, authManager, modelManager, syncManager, $timeout) {
'ngInject';
$scope.formData = {url: syncManager.serverURL};
$scope.user = authManager.user;
$scope.server = syncManager.serverURL;
$scope.syncStatus = syncManager.syncStatus;
$scope.changePasswordPressed = function() {
$scope.showNewPasswordForm = !$scope.showNewPasswordForm;
}
$scope.encryptionKey = function() {
return syncManager.masterKey;
}
$scope.serverPassword = function() {
return syncManager.serverPassword;
}
$scope.submitPasswordChange = function() {
$scope.passwordChangeData.status = "Generating New Keys...";
$timeout(function(){
if(data.password != data.password_confirmation) {
alert("Your new password does not match its confirmation.");
return;
}
authManager.changePassword($scope.passwordChangeData.current_password, $scope.passwordChangeData.new_password, function(response){
})
})
}
$scope.loginSubmitPressed = function() {
$scope.formData.status = "Generating Login Keys...";
console.log("logging in with url", $scope.formData.url);
$timeout(function(){
authManager.login($scope.formData.url, $scope.formData.email, $scope.formData.user_password, function(response){
if(!response || response.error) {
$scope.formData.status = null;
var error = response ? response.error : {message: "An unknown error occured."}
if(!response || (response && !response.didDisplayAlert)) {
alert(error.message);
}
} else {
$scope.onAuthSuccess();
}
});
})
}
$scope.submitRegistrationForm = function() {
$scope.formData.status = "Generating Account Keys...";
$timeout(function(){
authManager.register($scope.formData.url, $scope.formData.email, $scope.formData.user_password, function(response){
if(!response || response.error) {
$scope.formData.status = null;
var error = response ? response.error : {message: "An unknown error occured."}
alert(error.message);
} else {
$scope.onAuthSuccess();
}
});
})
}
$scope.onAuthSuccess = function() {
syncManager.markAllItemsDirtyAndSaveOffline(function(){
window.location.reload();
})
}
$scope.destroyLocalData = function() {
if(!confirm("Are you sure you want to end your session? This will delete all local items and extensions.")) {
return;
}
syncManager.destroyLocalData(function(){
window.location.reload();
})
}
/* Import/Export */
$scope.archiveFormData = {encrypted: $scope.user ? true : false};
$scope.user = authManager.user;
$scope.downloadDataArchive = function() {
var link = document.createElement('a');
link.setAttribute('download', 'notes.json');
var ek = $scope.archiveFormData.encrypted ? syncManager.masterKey : null;
link.href = $scope.itemsDataFile(ek);
link.click();
}
$scope.submitImportPassword = function() {
$scope.performImport($scope.importData.data, $scope.importData.password);
}
$scope.performImport = function(data, password) {
$scope.importData.loading = true;
// allow loading indicator to come up with timeout
$timeout(function(){
$scope.importJSONData(data, password, function(response){
$timeout(function(){
$scope.importData.loading = false;
$scope.importData = null;
if(!response) {
alert("There was an error importing your data. Please try again.");
}
})
})
})
}
$scope.importFileSelected = function(files) {
$scope.importData = {};
var file = files[0];
var reader = new FileReader();
reader.onload = function(e) {
var data = JSON.parse(e.target.result);
$timeout(function(){
if(data.auth_params) {
// request password
$scope.importData.requestPassword = true;
$scope.importData.data = data;
} else {
$scope.performImport(data, null);
}
})
}
reader.readAsText(file);
}
$scope.encryptionStatusForNotes = function() {
var allNotes = modelManager.filteredNotes;
return allNotes.length + "/" + allNotes.length + " notes encrypted";
}
$scope.importJSONData = function(data, password, callback) {
console.log("Importing data", data);
var onDataReady = function() {
var items = modelManager.mapResponseItemsToLocalModels(data.items);
items.forEach(function(item){
item.setDirty(true);
item.markAllReferencesDirty();
})
syncManager.sync(callback, {additionalFields: ["created_at", "updated_at"]});
}.bind(this)
if(data.auth_params) {
Neeto.crypto.computeEncryptionKeysForUser(_.merge({password: password}, data.auth_params), function(keys){
var mk = keys.mk;
try {
EncryptionHelper.decryptMultipleItems(data.items, mk, true);
// delete items enc_item_key since the user's actually key will do the encrypting once its passed off
data.items.forEach(function(item){
item.enc_item_key = null;
item.auth_hash = null;
})
onDataReady();
}
catch (e) {
console.log("Error decrypting", e);
alert("There was an error decrypting your items. Make sure the password you entered is correct and try again.");
callback(null);
return;
}
}.bind(this));
} else {
onDataReady();
}
}
/*
Export
*/
$scope.itemsDataFile = function(ek) {
var textFile = null;
var makeTextFile = function (text) {
var data = new Blob([text], {type: 'text/json'});
// If we are replacing a previously generated file we need to
// manually revoke the object URL to avoid memory leaks.
if (textFile !== null) {
window.URL.revokeObjectURL(textFile);
}
textFile = window.URL.createObjectURL(data);
// returns a URL you can use as a href
return textFile;
}.bind(this);
var items = _.map(modelManager.allItemsMatchingTypes(["Tag", "Note"]), function(item){
var itemParams = new ItemParams(item, ek);
return itemParams.paramsForExportFile();
}.bind(this));
var data = {
items: items
}
if(ek) {
// auth params are only needed when encrypted with a standard file key
data["auth_params"] = authManager.getAuthParams();
}
return makeTextFile(JSON.stringify(data, null, 2 /* pretty print */));
}
}
}
angular.module('app.frontend').directive('accountMenu', () => new AccountMenu);

View File

@@ -0,0 +1,64 @@
class GlobalExtensionsMenu {
constructor() {
this.restrict = "E";
this.templateUrl = "frontend/directives/global-extensions-menu.html";
this.scope = {
};
}
controller($scope, extensionManager, syncManager) {
'ngInject';
$scope.extensionManager = extensionManager;
$scope.toggleExtensionForm = function() {
$scope.newExtensionData = {};
$scope.showNewExtensionForm = !$scope.showNewExtensionForm;
}
$scope.submitNewExtensionForm = function() {
if($scope.newExtensionData.url) {
extensionManager.addExtension($scope.newExtensionData.url, function(response){
if(!response) {
alert("Unable to register this extension. Make sure the link is valid and try again.");
} else {
$scope.newExtensionData.url = "";
$scope.showNewExtensionForm = false;
}
})
}
}
$scope.selectedAction = function(action, extension) {
extensionManager.executeAction(action, extension, null, function(response){
if(response && response.error) {
action.error = true;
alert("There was an error performing this action. Please try again.");
} else {
action.error = false;
syncManager.sync(null);
}
})
}
$scope.changeExtensionEncryptionFormat = function(encrypted, extension) {
extensionManager.changeExtensionEncryptionFormat(encrypted, extension);
}
$scope.deleteExtension = function(extension) {
if(confirm("Are you sure you want to delete this extension?")) {
extensionManager.deleteExtension(extension);
}
}
$scope.reloadExtensionsPressed = function() {
if(confirm("For your security, reloading extensions will disable any currently enabled repeat actions.")) {
extensionManager.refreshExtensionsFromServer();
}
}
}
}
angular.module('app.frontend').directive('globalExtensionsMenu', () => new GlobalExtensionsMenu);

View File

@@ -1,11 +1,12 @@
class ExtensionManager {
constructor(Restangular, modelManager, apiController) {
constructor(Restangular, modelManager, authManager, syncManager) {
this.Restangular = Restangular;
this.modelManager = modelManager;
this.apiController = apiController;
this.authManager = authManager;
this.enabledRepeatActionUrls = JSON.parse(localStorage.getItem("enabledRepeatActionUrls")) || [];
this.decryptedExtensions = JSON.parse(localStorage.getItem("decryptedExtensions")) || [];
this.syncManager = syncManager;
modelManager.addItemSyncObserver("extensionManager", "Extension", function(items){
for (var ext of items) {
@@ -42,6 +43,7 @@ class ExtensionManager {
}
changeExtensionEncryptionFormat(encrypted, extension) {
console.log("changing encryption status");
if(encrypted) {
_.pull(this.decryptedExtensions, extension.url);
} else {
@@ -68,7 +70,7 @@ class ExtensionManager {
}
this.modelManager.setItemToBeDeleted(extension);
this.apiController.sync(null);
this.syncManager.sync(null);
}
/*
@@ -111,7 +113,7 @@ class ExtensionManager {
extension.url = url;
extension.setDirty(true);
this.modelManager.addItem(extension);
this.apiController.sync(null);
this.syncManager.sync(null);
}
return extension;
@@ -134,22 +136,30 @@ class ExtensionManager {
executeAction(action, extension, item, callback) {
if(this.extensionUsesEncryptedData(extension) && !this.apiController.isUserSignedIn()) {
if(this.extensionUsesEncryptedData(extension) && this.authManager.offline()) {
alert("To send data encrypted, you must have an encryption key, and must therefore be signed in.");
callback(null);
return;
}
var customCallback = function(response) {
action.running = false;
callback(response);
}
action.running = true;
switch (action.verb) {
case "get": {
this.Restangular.oneUrl(action.url, action.url).get().then(function(response){
action.error = false;
var items = response.items;
this.modelManager.mapResponseItemsToLocalModels(items);
callback(items);
customCallback(items);
}.bind(this))
.catch(function(response){
action.error = true;
customCallback(null);
})
break;
@@ -158,7 +168,7 @@ class ExtensionManager {
case "show": {
var win = window.open(action.url, '_blank');
win.focus();
callback();
customCallback();
break;
}
@@ -177,7 +187,7 @@ class ExtensionManager {
}
this.performPost(action, extension, params, function(response){
callback(response);
customCallback(response);
});
break;
@@ -261,20 +271,25 @@ class ExtensionManager {
var params = this.outgoingParamsForItem(item, extension);
return params;
}.bind(this))
this.performPost(action, extension, params, null);
action.running = true;
this.performPost(action, extension, params, function(){
action.running = false;
});
} else {
// todo
}
}
outgoingParamsForItem(item, extension) {
return this.apiController.paramsForExtension(item, this.extensionUsesEncryptedData(extension));
var itemParams = new ItemParams(item, this.syncManager.masterKey);
return itemParams.paramsForExtension();
}
performPost(action, extension, params, callback) {
var request = this.Restangular.oneUrl(action.url, action.url);
if(this.extensionUsesEncryptedData(extension)) {
request.auth_params = this.apiController.getAuthParams();
request.auth_params = this.authManager.getAuthParams();
}
_.merge(request, params);

View File

@@ -0,0 +1,6 @@
// Start from filter
angular.module('app.frontend').filter('startFrom', function() {
return function(input, start) {
return input.slice(start);
};
});

View File

@@ -0,0 +1,64 @@
class EncryptionHelper {
static encryptItem(item, key) {
var item_key = null;
if(item.enc_item_key) {
// we reuse the key, but this is optional
item_key = Neeto.crypto.decryptText(item.enc_item_key, key);
} else {
item_key = Neeto.crypto.generateRandomEncryptionKey();
item.enc_item_key = Neeto.crypto.encryptText(item_key, key);
}
var ek = Neeto.crypto.firstHalfOfKey(item_key);
var ak = Neeto.crypto.secondHalfOfKey(item_key);
var encryptedContent = "001" + Neeto.crypto.encryptText(JSON.stringify(item.createContentJSONFromProperties()), ek);
var authHash = Neeto.crypto.hmac256(encryptedContent, ak);
item.content = encryptedContent;
item.auth_hash = authHash;
}
static decryptItem(item, key) {
var item_key = Neeto.crypto.decryptText(item.enc_item_key, key);
var ek = Neeto.crypto.firstHalfOfKey(item_key);
var ak = Neeto.crypto.secondHalfOfKey(item_key);
var authHash = Neeto.crypto.hmac256(item.content, ak);
if(authHash !== item.auth_hash || !item.auth_hash) {
console.log("Authentication hash does not match.")
return;
}
var content = Neeto.crypto.decryptText(item.content.substring(3, item.content.length), ek);
item.content = content;
}
static decryptMultipleItems(items, key, throws) {
for (var item of items) {
if(item.deleted == true) {
continue;
}
var isString = typeof item.content === 'string' || item.content instanceof String;
if(isString) {
try {
if(item.content.substring(0, 3) == "001" && item.enc_item_key) {
// is encrypted
this.decryptItem(item, key);
} else {
// is base64 encoded
item.content = Neeto.crypto.base64Decode(item.content.substring(3, item.content.length))
}
} catch (e) {
if(throws) {
throw e;
}
console.log("Error decrypting item", item, e);
continue;
}
}
}
}
}

View File

@@ -22,6 +22,18 @@ class ModelManager {
})
}
alternateUUIDForItem(item, callback) {
// we need to clone this item and give it a new uuid, then delete item with old uuid from db (you can't mofidy uuid's in our indexeddb setup)
var newItem = this.createItem(item);
newItem.uuid = Neeto.crypto.generateUUID();
this.removeItemLocally(item, function(){
this.addItem(newItem);
newItem.setDirty(true);
newItem.markAllReferencesDirty();
callback();
}.bind(this));
}
allItemsMatchingTypes(contentTypes) {
return this.items.filter(function(item){
return (_.includes(contentTypes, item.content_type) || _.includes(contentTypes, "*")) && !item.dummy;
@@ -76,7 +88,6 @@ class ModelManager {
this.notifySyncObserversOfModels(models);
this.sortItems();
return models;
}
@@ -133,7 +144,9 @@ class ModelManager {
}
} else if(item.content_type == "Note") {
if(!_.find(this.notes, {uuid: item.uuid})) {
this.notes.unshift(item);
this.notes.splice(_.sortedLastIndexBy(this.notes, item, function(item){
return -item.created_at;
}), 0, item);
}
} else if(item.content_type == "Extension") {
if(!_.find(this._extensions, {uuid: item.uuid})) {
@@ -170,14 +183,6 @@ class ModelManager {
}
}
sortItems() {
Item.sortItemsByDate(this.notes);
this.tags.forEach(function(tag){
Item.sortItemsByDate(tag.notes);
})
}
addItemSyncObserver(id, type, callback) {
this.itemSyncObservers.push({id: id, type: type, callback: callback});
}
@@ -220,18 +225,21 @@ class ModelManager {
item.removeAllRelationships();
}
removeItemLocally(item) {
removeItemLocally(item, callback) {
_.pull(this.items, item);
item.isBeingRemovedLocally();
if(item.content_type == "Tag") {
_.pull(this.tags, item);
} else if(item.content_type == "Note") {
_.pull(this.notes, item);
} else if(item.content_type == "Extension") {
_.pull(this._extensions, item);
}
this.dbManager.deleteItem(item);
this.dbManager.deleteItem(item, callback);
}
/*

View File

@@ -0,0 +1,245 @@
class SyncManager {
constructor($rootScope, modelManager, authManager, dbManager, Restangular) {
this.$rootScope = $rootScope;
this.modelManager = modelManager;
this.authManager = authManager;
this.Restangular = Restangular;
this.dbManager = dbManager;
this.syncStatus = {};
}
get serverURL() {
return localStorage.getItem("server") || "https://n3.standardnotes.org";
}
get masterKey() {
return localStorage.getItem("mk");
}
get serverPassword() {
return localStorage.getItem("pw");
}
writeItemsToLocalStorage(items, offlineOnly, callback) {
var params = items.map(function(item) {
var itemParams = new ItemParams(item, null);
itemParams = itemParams.paramsForLocalStorage();
if(offlineOnly) {
delete itemParams.dirty;
}
return itemParams;
}.bind(this));
this.dbManager.saveItems(params, callback);
}
loadLocalItems(callback) {
var params = this.dbManager.getAllItems(function(items){
var items = this.handleItemsResponse(items, null, null);
Item.sortItemsByDate(items);
callback(items);
}.bind(this))
}
syncOffline(items, callback) {
this.writeItemsToLocalStorage(items, true, function(responseItems){
// delete anything needing to be deleted
for(var item of items) {
if(item.deleted) {
this.modelManager.removeItemLocally(item);
}
}
if(callback) {
callback({success: true});
}
}.bind(this))
}
markAllItemsDirtyAndSaveOffline(callback) {
var items = this.modelManager.allItems;
for(var item of items) {
item.setDirty(true);
}
this.writeItemsToLocalStorage(items, false, callback);
}
get syncURL() {
return this.serverURL + "/items/sync";
}
set syncToken(token) {
this._syncToken = token;
localStorage.setItem("syncToken", token);
}
get syncToken() {
if(!this._syncToken) {
this._syncToken = localStorage.getItem("syncToken");
}
return this._syncToken;
}
set cursorToken(token) {
this._cursorToken = token;
if(token) {
localStorage.setItem("cursorToken", token);
} else {
localStorage.removeItem("cursorToken");
}
}
get cursorToken() {
if(!this._cursorToken) {
this._cursorToken = localStorage.getItem("cursorToken");
}
return this._cursorToken;
}
sync(callback, options = {}) {
if(this.syncStatus.syncOpInProgress) {
this.repeatOnCompletion = true;
console.log("Sync op in progress; returning.");
return;
}
var allDirtyItems = this.modelManager.getDirtyItems();
// we want to write all dirty items to disk only if the user is offline, or if the sync op fails
// if the sync op succeeds, these items will be written to disk by handling the "saved_items" response from the server
if(this.authManager.offline()) {
this.syncOffline(allDirtyItems, callback);
this.modelManager.clearDirtyItems(allDirtyItems);
return;
}
var isContinuationSync = this.needsMoreSync;
this.repeatOnCompletion = false;
this.syncStatus.syncOpInProgress = true;
let submitLimit = 100;
var subItems = allDirtyItems.slice(0, submitLimit);
if(subItems.length < allDirtyItems.length) {
// more items left to be synced, repeat
this.needsMoreSync = true;
} else {
this.needsMoreSync = false;
}
if(!isContinuationSync) {
this.syncStatus.total = allDirtyItems.length;
this.syncStatus.current = 0;
}
var request = this.Restangular.oneUrl(this.syncURL, this.syncURL);
request.limit = 150;
request.items = _.map(subItems, function(item){
var itemParams = new ItemParams(item, localStorage.getItem("mk"));
itemParams.additionalFields = options.additionalFields;
return itemParams.paramsForSync();
}.bind(this));
request.sync_token = this.syncToken;
request.cursor_token = this.cursorToken;
request.post().then(function(response) {
this.modelManager.clearDirtyItems(subItems);
this.syncStatus.error = null;
this.$rootScope.$broadcast("sync:updated_token", this.syncToken);
var retrieved = this.handleItemsResponse(response.retrieved_items, null);
// merge only metadata for saved items
var omitFields = ["content", "auth_hash"];
var saved = this.handleItemsResponse(response.saved_items, omitFields);
this.handleUnsavedItemsResponse(response.unsaved)
this.writeItemsToLocalStorage(saved, false, null);
this.writeItemsToLocalStorage(retrieved, false, null);
this.syncStatus.syncOpInProgress = false;
this.syncStatus.current += subItems.length;
// set the sync token at the end, so that if any errors happen above, you can resync
this.syncToken = response.sync_token;
this.cursorToken = response.cursor_token;
if(this.cursorToken || this.repeatOnCompletion || this.needsMoreSync) {
setTimeout(function () {
this.sync(callback, options);
}.bind(this), 10); // wait 10ms to allow UI to update
} else {
if(callback) {
callback(response);
}
}
}.bind(this))
.catch(function(response){
console.log("Sync error: ", response);
var error = response.data ? response.data.error : {message: "Could not connect to server."};
this.syncStatus.syncOpInProgress = false;
this.syncStatus.error = error;
this.writeItemsToLocalStorage(allDirtyItems, false, null);
this.$rootScope.$broadcast("sync:error", error);
if(callback) {
callback({error: "Sync error"});
}
}.bind(this))
}
handleUnsavedItemsResponse(unsaved) {
if(unsaved.length == 0) {
return;
}
console.log("Handle unsaved", unsaved);
var i = 0;
var handleNext = function() {
if (i < unsaved.length) {
var mapping = unsaved[i];
var itemResponse = mapping.item;
var item = this.modelManager.findItem(itemResponse.uuid);
var error = mapping.error;
if(error.tag == "uuid_conflict") {
// uuid conflicts can occur if a user attempts to import an old data archive with uuids form the old account into a new account
this.modelManager.alternateUUIDForItem(item, handleNext);
}
++i;
} else {
this.sync(null, {additionalFields: ["created_at", "updated_at"]});
}
}.bind(this);
handleNext();
}
handleItemsResponse(responseItems, omitFields) {
EncryptionHelper.decryptMultipleItems(responseItems, localStorage.getItem("mk"));
return this.modelManager.mapResponseItemsToLocalModelsOmittingFields(responseItems, omitFields);
}
clearSyncToken() {
localStorage.removeItem("syncToken");
}
destroyLocalData(callback) {
this.dbManager.clearAllItems(function(){
localStorage.clear();
if(callback) {
callback();
}
});
}
}
angular.module('app.frontend').service('syncManager', SyncManager);

View File

@@ -5,7 +5,6 @@
margin-top: 18px;
}
.ext-header {
background-color: #ededed;
border-bottom: 1px solid #d3d3d3;
@@ -64,9 +63,7 @@
margin-top: 1px;
font-size: 12px;
}
}
}
}
}

View File

@@ -1,6 +1,155 @@
.header {
.pull-left {
float: left !important;
}
.pull-right {
float: right !important;
}
.mt-1 {
margin-top: 1px !important;
}
.mt-2 {
margin-top: 2px !important;
}
.mt-5 {
margin-top: 5px !important;
}
.mt-10 {
margin-top: 10px !important;
}
.mt-15 {
margin-top: 15px !important;
}
.mt-25 {
margin-top: 25px !important;
}
.mb-0 {
margin-bottom: 0px !important;
}
.mb-5 {
margin-bottom: 5px !important;
}
.mb-10 {
margin-bottom: 10px !important;
}
.mr-5 {
margin-right: 5px;
}
.faded {
opacity: 0.5;
}
.center-align {
text-align: center !important;
}
.center {
margin-left: auto !important;
margin-right: auto !important;
}
.block {
display: block !important;
}
.wrap {
word-wrap: break-word;
}
.one-line-overflow {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.small-v-space {
height: 6px;
display: block;
}
.medium-v-space {
height: 12px;
display: block;
}
.large-v-space {
height: 24px;
display: block;
}
.small-padding {
padding: 5px !important;
}
.medium-padding {
padding: 10px !important;
}
.pb-4 {
padding-bottom: 4px !important;
}
.pb-6 {
padding-bottom: 6px !important;
}
.pb-10 {
padding-bottom: 10px !important;
}
.large-padding {
padding: 22px !important;
}
.red {
color: red !important;
}
.blue {
color: $blue-color;
}
.bold {
font-weight: bold !important;
}
.normal {
font-weight: normal !important;
}
.small {
font-size: 10px !important;
}
.inline {
display: inline-block;
}
.fake-link {
font-weight: bold;
cursor: pointer;
color: $blue-color;
&:hover {
text-decoration: underline;
}
}
.footer-bar {
position: relative;
width: 100%;
padding: 5px;
background-color: #d8d7d9;
height: $header-height;
max-height: $header-height;
@@ -9,90 +158,125 @@
color: $dark-gray;
border-bottom: 1px solid rgba(#979799, 0.4);
.medium-text {
font-size: 14px;
}
a {
color: $dark-gray;
font-weight: bold;
cursor: pointer;
color: $blue-color;
&.gray {
color: $dark-gray !important;
}
&.block {
display: block !important;
}
}
p {
margin: 2px 0px;
font-size: 12px;
}
label {
font-weight: bold;
margin-bottom: 4px;
}
strong {
display: block;
}
h2 {
margin-bottom: 0px;
margin-top: 0px;
}
h3 {
font-size: 14px !important;
margin-top: 4px !important;
margin-bottom: 3px !important;
}
h4 {
margin-bottom: 4px !important;
font-size: 13px !important;
}
section {
padding: 5px;
padding-bottom: 2px;
margin-top: 5px;
&.inline-h {
padding-top: 5px;
padding-left: 0;
padding-right: 0;
}
}
input {
margin-bottom: 10px;
border-radius: 0px;
}
}
.header-content {
margin-bottom: 0px;
padding-top: 0px;
border-radius: 0px;
left: 0px;
right: 0px;
}
.panel-status-text {
margin-top: 20px;
font-style: italic;
font-size: 14px;
}
.menu {
margin-left: 15px;
padding-top: 5px;
margin-top: 0px;
color: #515263;
z-index: 1000;
margin-bottom: 0px;
.footer-bar-link {
font-size: 11px;
font-weight: bold;
margin-left: 8px;
color: #515263;
&.left {
float: left;
z-index: 1000;
display: inline-block;
position: relative;
cursor: pointer;
> a {
color: #515263;
}
}
&.right {
float: right;
margin-right: 10px;
}
.footer-bar-link .panel {
font-weight: normal;
font-size: 12px;
.login-panel .login-input {
border-radius: 0px;
}
max-height: 85vh;
position: absolute;
right: 0px;
bottom: 20px;
min-width: 300px;
z-index: 1000;
margin-top: 15px;
.items {
box-shadow: 0px 0px 15px rgba(black, 0.2);
border: none;
cursor: default;
overflow: auto;
background-color: white;
}
.item {
button.light {
font-weight: bold;
margin-bottom: 0px;
font-size: 12px;
height: 30px;
padding-top: 3px;
text-align: center;
margin-bottom: 6px;
background-color: white;
display: block;
width: 100%;
border: 1px solid rgba(gray, 0.15);
cursor: pointer;
color: $blue-color;
display: inline-block;
margin-right: 7px;
position: relative;
cursor: pointer;
font-weight: bold;
a {
color: #515263;
}
.panel {
position: absolute;
right: 0px;
bottom: 20px;
min-width: 300px;
z-index: 1000;
margin-top: 15px;
box-shadow: 0px 0px 15px rgba(black, 0.2);
border: none;
cursor: default;
max-height: 85vh;
overflow: auto;
background-color: white;
font-weight: normal;
.storage-text {
font-size: 14px;
}
.checkbox {
font-size: 14px;
font-weight: normal;
margin-left: auto;
margin-right: auto;
}
}
}
&:hover {
background-color: rgba(gray, 0.10);
}
}
@@ -104,162 +288,25 @@
float: left;
}
.gray-bg {
background-color: #f6f6f6;
border: 1px solid #f2f2f2;
}
.white-bg {
background-color: white;
border: 1px solid rgba(gray, 0.2);
}
.item.last-refreshed {
font-weight: normal !important;
cursor: default !important;
}
.item.account {
.email {
font-size: 18px;
font-weight: bold;
margin-bottom: 2px;
}
.server {
margin-bottom: 10px;
}
.links {
margin-bottom: 25px;
}
.link-item {
margin-bottom: 8px;
a {
font-size: 12px;
color: $blue-color;
font-weight: bold;
}
}
input {
border-radius: 0px;
}
.account-panel {
padding: 12px;
padding-bottom: 6px;
.account-items {
margin-top: 0px;
}
.account-item {
width: 100%;
margin-bottom: 34px;
a {
color: $blue-color;
font-weight: bold;
cursor: pointer;
}
> .icon-container {
display: block;
margin-bottom: 10px;
}
> .meta-container {
display: block;
font-size: 10px;
}
> .action-container {
font-size: 12px;
margin-top: 6px;
.status-title {
font-weight: bold;
}
.subtext {
font-size: 10px;
margin-top: 2px;
}
a {
display: block;
margin-bottom: -10px;
}
}
.import-password {
margin-top: 14px;
> .field {
display: block;
margin: 5px 0px;
}
}
.encryption-confirmation {
position: relative;
.buttons {
.cancel {
font-weight: normal;
margin-right: 3px;
}
}
}
&:last-child {
margin-bottom: 8px !important;
}
> .icon-container {
margin-bottom: 10px;
.icon {
height: 35px;
&.archive {
height: 30px;
}
}
}
.meta-container {
> .title {
font-size: 13px;
font-weight: bold;
}
> .desc {
font-size: 12px;
margin-top: 3px;
}
}
}
.membership-settings {
font-size: 14px;
}
}
}
a.disabled {
pointer-events: none;
}
.account-form {
margin-top: 10px;
}
.registration-login {
.login-forgot {
margin-top: 20px;
clear: both;
a {
display: block;
font-size: 13px !important;
text-align: center;
}
}
}
.spinner {
height: 10px;
width: 10px;
@@ -267,138 +314,14 @@ a.disabled {
border: 1px solid #515263;
border-right-color: transparent;
border-radius: 50%;
&.blue {
border: 1px solid $blue-color;
border-right-color: transparent;
}
}
@keyframes rotate {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/**
Extensions
*/
.extensions-panel {
font-size: 14px;
.extension-link {
margin-top: 8px;
a {
color: $blue-color !important;
font-weight: bold;
}
}
}
.extension-form {
margin-top: 8px;
}
.registered-extensions {
.extension {
margin-bottom: 18px;
background-color: #f6f6f6;
border: 1px solid #f2f2f2;
padding: 14px 6px;
padding-bottom: 8px;
color: black;
a {
color: $blue-color !important;
font-size: 12px !important;
font-weight: bold !important;
}
> .name {
font-weight: bold;
font-size: 16px;
margin-bottom: 6px;
text-align: center;
}
.encryption-format {
margin-top: 4px;
font-size: 12px;
text-align: center;
> .title {
font-size: 13px;
// font-weight: bold;
margin-bottom: 2px;
}
}
> .subtitle {
font-size: 14px;
margin-bottom: 10px;
}
> .actions {
margin-top: 15px;
font-size: 12px;
.action {
padding: 13px;
margin-bottom: 10px;
background-color: rgba(white, 0.9);
border: 1px solid rgba(gray, 0.15);
> .name {
font-weight: bold;
}
> .permissions {
margin-top: 2px;
a {
font-weight: normal !important;
}
}
> .execute {
font-weight: bold;
margin-bottom: 0px;
font-size: 12px;
height: 30px;
padding-top: 7px;
text-align: center;
margin-top: 6px;
border: 1px solid rgba(gray, 0.15);
cursor: pointer;
color: $blue-color;
&:hover {
background-color: rgba(gray, 0.10);
}
.execution-spinner {
margin-left: auto;
margin-right: auto;
text-align: center;
margin-top: 3px;
}
}
> .execute-type {
font-size: 12px;
margin-bottom: 1px;
}
> .error {
color: red;
margin-top: 6px;
}
> .last-run {
opacity: 0.5;
font-size: 11px;
margin-top: 6px;
}
}
}
}
}

View File

@@ -41,12 +41,11 @@
.note {
width: 100%;
// max-width: 100%;
padding: 15px;
// height: 70px;
border-bottom: 1px solid $bg-color;
cursor: pointer;
background-color: white;
> .name {
font-weight: 600;
overflow: hidden;

View File

@@ -0,0 +1,77 @@
.panel.panel-default.panel-right.account-data-menu
.panel-body.large-padding
%div{"ng-if" => "!user"}
%p Enter your <a href="https://standardnotes.org" target="_blank">Standard File</a> account information. You can also register for free using the default server address.
.small-v-space
%form.account-form.mt-5{'name' => "loginForm"}
%input.form-control{:name => 'server', :placeholder => 'Server URL', :required => true, :type => 'text', 'ng-model' => 'formData.url'}
%input.form-control{:autofocus => 'autofocus', :name => 'email', :placeholder => 'Email', :required => true, :type => 'email', 'ng-model' => 'formData.email'}
%input.form-control{:placeholder => 'Password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'formData.user_password'}
%div{"ng-if" => "!formData.status"}
%button.btn.dark-button.half-button{"ng-click" => "loginSubmitPressed()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
%span Sign In
%button.btn.dark-button.half-button{"ng-click" => "submitRegistrationForm()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
%span Register
%br
.block{"style" => "margin-top: 10px; font-size: 14px; font-weight: bold; text-align: center;"}
%a.btn{"ng-click" => "showResetForm = !showResetForm"} Passwords cannot be forgotten.
%em.block.center-align.mt-10{"ng-if" => "formData.status", "style" => "font-size: 14px;"} {{formData.status}}
%div{"ng-if" => "showResetForm"}
%p{"style" => "font-size: 13px; text-align: center;"}
Because notes are locally encrypted using a secret key derived from your password, there's no way to decrypt these notes if you forget your password.
For this reason, Standard Notes cannot offer a password reset option. You <strong>must</strong> make sure to store or remember your password.
%div{"ng-if" => "user"}
%h2 {{user.email}}
%p {{server}}
%a.block.mt-5{"ng-click" => "showCredentials = !showCredentials"} Show Credentials
%section.gray-bg.mt-10.medium-padding{"ng-if" => "showCredentials"}
%label.block
Encryption key:
.wrap.normal.mt-1 {{encryptionKey()}}
%label.block.mt-5.mb-0
Server password:
.wrap.normal.mt-1 {{serverPassword() ? serverPassword() : 'Not available. Sign out then sign back in to compute.'}}
%div.bold.mt-10.blue{"delay-hide" => "true", "show" => "syncStatus.syncOpInProgress", "delay" => "1000"}
.spinner.inline.mr-5.blue
Syncing
%span{"ng-if" => "syncStatus.total > 0"}: {{syncStatus.current}}/{{syncStatus.total}}
%p.bold.mt-10.red.block{"ng-if" => "syncStatus.error"} Error syncing: {{syncStatus.error.message}}
.medium-v-space
%h4 Local Encryption
%p Notes are encrypted locally before being sent to the server. Neither the server owner nor an intrusive entity can decrypt your locally encrypted notes.
%div.mt-5
%label Status:
{{encryptionStatusForNotes()}}
.mt-25{"ng-if" => "!importData.loading"}
%h4 Data Archives
.mt-5{"ng-if" => "user"}
%label.normal.inline{"ng-if" => "user"}
%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{"ng-click" => "downloadDataArchive()"} Download Data Archive
%label.block.mt-5
%input{"type" => "file", "style" => "display: none;", "file-change" => "->", "handler" => "importFileSelected(files)"}
.fake-link Import Data from Archive
%div{"ng-if" => "importData.requestPassword"}
Enter the account password associated with the import file.
%input{"type" => "text", "ng-model" => "importData.password"}
%button{"ng-click" => "submitImportPassword()"} Decrypt & Import
.spinner.mt-10{"ng-if" => "importData.loading"}
%a.block.mt-25.red{"ng-click" => "destroyLocalData()"} Destroy all local data

View File

@@ -0,0 +1,57 @@
.panel.panel-default.account-panel.panel-right
.panel-body
%div{"style" => "font-size: 18px;", "ng-if" => "!extensionManager.extensions.length"} No extensions installed
%div{"ng-if" => "extensionManager.extensions.length"}
%section.gray-bg.inline-h.mb-10.medium-padding{"ng-repeat" => "extension in extensionManager.extensions", "ng-init" => "extension.formData = {}"}
%h3.center-align {{extension.name}}
.center-align.centered.mt-10
%label.block.normal Send data:
%label.normal
%input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "true", "ng-change" => "changeExtensionEncryptionFormat(true, extension)"}
Encrypted
%label.normal
%input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "false", "ng-change" => "changeExtensionEncryptionFormat(false, extension)"}
Decrypted
.small-v-space
%section.inline-h.white-bg.medium-padding.mb-10.pb-4{"ng-repeat" => "action in extension.actionsInGlobalContext()"}
%label.block {{action.label}}
%em{"style" => "font-style: italic;"} {{action.desc}}
%em{"ng-if" => "action.repeat_mode == 'watch'"}
Repeats when a change is made to your items.
%em{"ng-if" => "action.repeat_mode == 'loop'"}
Repeats at most once every {{action.repeat_timeout}} seconds
%div
%a{"ng-click" => "action.showPermissions = !action.showPermissions"} {{action.showPermissions ? "Hide permissions" : "Show permissions"}}
%div{"ng-if" => "action.showPermissions"}
{{action.permissionsString()}}
%label.block.normal {{action.encryptionModeString()}}
%div
.mt-5{"ng-if" => "action.repeat_mode"}
%button.light{"ng-if" => "extensionManager.isRepeatActionEnabled(action)", "ng-click" => "extensionManager.disableRepeatAction(action, extension)"} Disable
%button.light{"ng-if" => "!extensionManager.isRepeatActionEnabled(action)", "ng-click" => "extensionManager.enableRepeatAction(action, extension)"} Enable
%button.light.mt-10{"ng-if" => "!action.running && !action.repeat_mode", "ng-click" => "selectedAction(action, extension)"}
Perform Action
.spinner.mb-5.centered.center-align.block{"ng-if" => "action.running"}
%p.mb-5.mt-5.small{"ng-if" => "!action.error && action.lastExecuted && !action.running"}
Last run {{action.lastExecuted | appDateTime}}
%label.red{"ng-if" => "action.error"}
Error performing action.
%a.block.center-align.mt-10{"ng-click" => "extension.showURL = !extension.showURL"} Show URL
%p.center-align.wrap{"ng-if" => "extension.showURL"} {{extension.url}}
%a.block.center-align.mt-5{"ng-click" => "deleteExtension(extension)"} Remove extension
.large-v-space
%a.block{"ng-click" => "toggleExtensionForm()"} Add new extension
%form.mt-10.mb-10{"ng-if" => "showNewExtensionForm"}
%input.form-control{:autofocus => 'autofocus', :name => 'url', :placeholder => 'Extension URL', :required => true, :type => 'url', 'ng-model' => 'newExtensionData.url'}
%button.btn.dark-button.btn-block{"ng-click" => "submitNewExtensionForm()", :type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
Add Extension
%a.block.mt-5{"ng-click" => "reloadExtensionsPressed()", "ng-if" => "extensionManager.extensions.length > 0"} Reload all extensions
%a.block.mt-5{"href" => "https://standardnotes.org/extensions", "target" => "_blank"} List of available extensions

View File

@@ -5,7 +5,7 @@
%input.input#note-title-editor{"ng-model" => "ctrl.note.title", "ng-keyup" => "$event.keyCode == 13 && ctrl.saveTitle($event)",
"ng-change" => "ctrl.nameChanged()", "ng-focus" => "ctrl.onNameFocus()",
"select-on-click" => "true"}
.save-status {{ctrl.noteStatus}}
.save-status{"ng-class" => "{'red bold': ctrl.saveError}"} {{ctrl.noteStatus}}
.tags
%input.tags-input{"type" => "text", "ng-keyup" => "$event.keyCode == 13 && ctrl.updateTagsFromTagsString($event, ctrl.tagsString)",
"ng-model" => "ctrl.tagsString", "placeholder" => "#tags", "ng-blur" => "ctrl.updateTagsFromTagsString($event, ctrl.tagsString)"}

View File

@@ -1,170 +1,25 @@
.header
.header-content
.menu.left
.items
.item.account
%div{"ng-click" => "ctrl.accountMenuPressed()"}
%div{"ng-if" => "ctrl.user"} Account
%div{"ng-if" => "!ctrl.user"} Sign in or Register
.panel.panel-default.account-panel.panel-right{"ng-if" => "ctrl.showAccountMenu"}
.panel-body
.account-items
.account-item.registration-login{"ng-if" => "!ctrl.user"}
.account-item
.meta-container
.title Server
.desc Enter your <a href="https://standardfile.org" target="_blank">Standard File</a> server address, or use the default.
.action-container
%form.account-form{'ng-submit' => 'ctrl.changeServer()', 'name' => "serverChangeForm"}
.form-tag.has-feedback
%input.form-control{:name => 'server', :placeholder => 'Server URL', :required => true, :type => 'text', 'ng-model' => 'ctrl.serverData.url'}
%button.btn.dark-button.btn-block{:type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
%span.ladda-label Set Server
.meta-container
.title Sign in or Register
.desc
%form.account-form{'name' => "loginForm"}
.form-tag.has-feedback
%input.form-control.login-input{:autofocus => 'autofocus', :name => 'email', :placeholder => 'Email', :required => true, :type => 'email', 'ng-model' => 'ctrl.loginData.email'}
.form-tag.has-feedback
%input.form-control.login-input{:placeholder => 'Password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'ctrl.loginData.user_password'}
.checkbox{"ng-if" => "ctrl.localNotesCount() > 0"}
%label
%input{"type" => "checkbox", "ng-model" => "ctrl.loginData.mergeLocal", "ng-bind" => "true", "ng-change" => "ctrl.mergeLocalChanged()"}
Merge local notes ({{ctrl.localNotesCount()}} notes)
%button.btn.dark-button.half-button{"ng-click" => "ctrl.loginSubmitPressed()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
%span Sign In
%button.btn.dark-button.half-button{"ng-click" => "ctrl.submitRegistrationForm()", "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
%span Register
%br
.login-forgot{"style" => "padding-top: 4px;"}
%a.btn.btn-link{"ng-click" => "ctrl.showResetForm = !ctrl.showResetForm"} Passwords cannot be forgotten.
.panel-status-text{"ng-if" => "ctrl.loginData.status", "style" => "font-size: 14px;"} {{ctrl.loginData.status}}
.footer-bar
.pull-left
.footer-bar-link
%a{"ng-click" => "ctrl.accountMenuPressed()", "ng-class" => "{red: ctrl.error}"} Account
%account-menu{"ng-if" => "ctrl.showAccountMenu"}
%div{"ng-if" => "ctrl.showResetForm"}
%p{"style" => "font-size: 13px; text-align: center;"}
Because notes are locally encrypted using a secret key derived from your password, there's no way to decrypt these notes if you forget your password.
For this reason, Standard Notes cannot offer a password reset option. You <strong>must</strong> make sure to store or remember your password.
.footer-bar-link
%a{"ng-click" => "ctrl.toggleExtensions()"} Extensions
%global-extensions-menu{"ng-if" => "ctrl.showExtensionsMenu"}
.account-item{"ng-if" => "ctrl.user"}
.email {{ctrl.user.email}}
.server {{ctrl.serverData.url}}
.links{"ng-if" => "ctrl.user"}
-# .link-item
-# %a{"ng-click" => "ctrl.changePasswordPressed()"} Change Password
-# %form.account-form{"ng-if" => "ctrl.showNewPasswordForm", 'ng-submit' => 'ctrl.submitPasswordChange()', 'name' => "passwordChangeForm"}
-# .form-tag.has-feedback
-# %input.form-control.login-input{:autofocus => 'autofocus', :name => 'current', :placeholder => 'Current password', :required => true, :type => 'password', 'ng-model' => 'ctrl.passwordChangeData.current_password'}
-# .form-tag.has-feedback
-# %input.form-control.login-input{:placeholder => 'New password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'ctrl.passwordChangeData.new_password', "autocomplete" => "new-password"}
-# .form-tag.has-feedback
-# %input.form-control.login-input{:placeholder => 'Confirm password', :name => 'password', :required => true, :type => 'password', 'ng-model' => 'ctrl.passwordChangeData.new_password_confirmation', "autocomplete" => "new-password"}
-# %button.btn.dark-button.btn-block{:type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
-# %span.ladda-label Change Password
-# .panel-status-text{"ng-if" => "ctrl.passwordChangeData.status", "style" => "font-size: 14px;"}
-# {{ctrl.passwordChangeData.status}}
.link-item
%a{"ng-click" => "ctrl.signOutPressed()"} Sign Out
.meta-container
.title Local Encryption
.desc Notes are encrypted locally before being sent to the server. Neither the server owner nor an intrusive entity can decrypt your locally encrypted notes.
.action-container
%span.status-title Status:
{{ctrl.encryptionStatusForNotes()}}
.account-item{"ng-if" => "ctrl.user"}
.meta-container
.title Data Archives
.options{"style" => "font-size: 12px; margin-top: 4px;"}
%label
%input{"type" => "radio", "ng-model" => "ctrl.archiveEncryptionFormat.encrypted", "ng-value" => "true", "ng-change" => "ctrl.archiveEncryptionFormat.encrypted = true"}
Encrypted
%label
%input{"type" => "radio", "ng-model" => "ctrl.archiveEncryptionFormat.encrypted", "ng-value" => "false", "ng-change" => "ctrl.archiveEncryptionFormat.encrypted = false"}
Decrypted
.action-container
%a{"ng-click" => "ctrl.downloadDataArchive()"} Download Data Archive
%br
%div{"ng-if" => "!ctrl.importData.loading"}
%label#import-archive
%input{"type" => "file", "style" => "display: none;", "file-change" => "->", "handler" => "ctrl.importFileSelected(files)"}
%a.disabled
%span
Import Data from Archive
.import-password{"ng-if" => "ctrl.importData.requestPassword"}
Enter the account password associated with the import file.
%input.field{"type" => "text", "ng-model" => "ctrl.importData.password"}
%button{"ng-click" => "ctrl.submitImportPassword()"} Decrypt & Import
.spinner{"ng-if" => "ctrl.importData.loading"}
.footer-bar-link
%a{"href" => "https://standardnotes.org", "target" => "_blank"}
Help
.item
%a{"ng-click" => "ctrl.toggleExtensions()"} Extensions
.panel.panel-default.account-panel.panel-right.extensions-panel{"ng-if" => "ctrl.showExtensionsMenu"}
.panel-body
%div{"style" => "font-size: 18px;", "ng-if" => "!ctrl.extensionManager.extensions.length"} No extensions installed
.registered-extensions{"ng-if" => "ctrl.extensionManager.extensions.length"}
.extension{"ng-repeat" => "extension in ctrl.extensionManager.extensions"}
.name {{extension.name}}
.encryption-format
.title Send data:
%label
%input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "true", "ng-change" => "ctrl.extensionManager.changeExtensionEncryptionFormat(true, extension)"}
Encrypted
%label
%input{"type" => "radio", "ng-model" => "extension.encrypted", "ng-value" => "false", "ng-change" => "ctrl.extensionManager.changeExtensionEncryptionFormat(false, extension)"}
Decrypted
.actions
.action{"ng-repeat" => "action in extension.actionsInGlobalContext()"}
.name {{action.label}}
.desc{"style" => "font-style: italic;"} {{action.desc}}
.execute-type{"ng-if" => "action.repeat_mode == 'watch'"}
Repeats when a change is made to your items.
.execute-type{"ng-if" => "action.repeat_mode == 'loop'"}
Repeats at most once every {{action.repeat_timeout}} seconds
.permissions
%a{"ng-click" => "action.showPermissions = !action.showPermissions"} {{action.showPermissions ? "Hide permissions" : "Show permissions"}}
%div{"ng-if" => "action.showPermissions"}
{{action.permissionsString}}
.encryption-type
%span {{action.encryptionModeString}}
.execute
%div{"ng-if" => "action.repeat_mode"}
%div{"ng-if" => "ctrl.extensionManager.isRepeatActionEnabled(action)", "ng-click" => "ctrl.extensionManager.disableRepeatAction(action, extension)"} Disable
%div{"ng-if" => "!ctrl.extensionManager.isRepeatActionEnabled(action)", "ng-click" => "ctrl.extensionManager.enableRepeatAction(action, extension)"} Enable
%div{"ng-if" => "!action.repeat_mode", "ng-click" => "ctrl.selectedAction(action, extension)"}
%div{"ng-if" => "!action.running"}
Perform Action
%div{"ng-if" => "action.running"}
.spinner.execution-spinner
.last-run{"ng-if" => "!action.error && action.lastExecuted && !action.running"}
Last run {{action.lastExecuted | appDateTime}}
.error{"ng-if" => "action.error"}
Error performing action.
%a{"ng-click" => "ctrl.deleteExtension(extension)", "style" => "margin-top: 22px; display: block; text-align: center;"} Remove extension
.pull-right
.extension-link
%a{"ng-click" => "ctrl.toggleExtensionForm()"} Add new extension
.footer-bar-link{"style" => "margin-right: 5px;"}
%div{"ng-if" => "ctrl.lastSyncDate", "style" => "float: left; font-weight: normal; margin-right: 8px;"}
%span{"ng-if" => "!ctrl.isRefreshing"}
Last refreshed {{ctrl.lastSyncDate | appDateTime}}
%span{"ng-if" => "ctrl.isRefreshing"}
.spinner{"style" => "margin-top: 2px;"}
%form.extension-form{"ng-if" => "ctrl.showNewExtensionForm"}
.form-tag.has-feedback
%input.form-control{:autofocus => 'autofocus', :name => 'url', :placeholder => 'Extension URL', :required => true, :type => 'url', 'ng-model' => 'ctrl.newExtensionData.url'}
%button.btn.dark-button.btn-block{"ng-click" => "ctrl.submitNewExtensionForm()", :type => 'submit', "data-style" => "expand-right", "data-size" => "s", "state" => "buttonState"}
%span.ladda-label Add Extension
.extension-link
%a{"ng-click" => "ctrl.reloadExtensionsPressed()", "ng-if" => "ctrl.extensionManager.extensions.length > 0"} Reload all extensions
.extension-link
%a{"href" => "https://standardnotes.org/extensions", "target" => "_blank"} List of available extensions
.item
%a{"href" => "https://standardnotes.org", "target" => "_blank"}
Help
.menu.right
.items
.item.last-refreshed{"ng-if" => "ctrl.lastSyncDate"}
%span{"ng-if" => "!ctrl.isRefreshing"}
Last refreshed {{ctrl.lastSyncDate | appDateTime}}
%span{"ng-if" => "ctrl.isRefreshing"}
.spinner
.item{"ng-click" => "ctrl.refreshData()"}
Refresh
%strong{"ng-if" => "ctrl.offline"} Offline
%a{"ng-if" => "!ctrl.offline", "ng-click" => "ctrl.refreshData()"} Refresh

View File

@@ -8,4 +8,5 @@
"tag" => "selectedTag", "remove" => "deleteNote"}
%editor-section{"ng-if" => "selectedNote", "note" => "selectedNote", "remove" => "deleteNote", "save" => "saveNote", "update-tags" => "updateTagsForNote"}
%header{"user" => "defaultUser"}
%header

View File

@@ -18,10 +18,11 @@
%li
%a.text{"ng-click" => "ctrl.selectedMenuItem(); ctrl.selectedTagDelete()"} Delete Tag
.note{"ng-repeat" => "note in ctrl.tag.notes | filter: ctrl.filterNotes",
"ng-click" => "ctrl.selectNote(note)", "ng-class" => "{'selected' : ctrl.selectedNote == note}"}
.name{"ng-if" => "note.title"}
{{note.title}}
.note-preview
{{note.text}}
.date {{(note.created_at | appDateTime) || 'Now'}}
%div{"infinite-scroll" => "ctrl.paginate()", "can-load" => "true", "threshold" => "200"}
.note{"ng-repeat" => "note in ctrl.tag.notes | limitTo:ctrl.notesToDisplay | filter: ctrl.filterNotes",
"ng-click" => "ctrl.selectNote(note)", "ng-class" => "{'selected' : ctrl.selectedNote == note}"}
.name{"ng-if" => "note.title"}
{{note.title}}
.note-preview
{{note.text}}
.date {{(note.created_at | appDateTime) || 'Now'}}

View File

@@ -13,5 +13,5 @@
%input.title{"ng-disabled" => "tag != ctrl.selectedTag", "ng-model" => "tag.title",
"ng-keyup" => "$event.keyCode == 13 && ctrl.saveTag($event, tag)", "mb-autofocus" => "true", "should-focus" => "ctrl.newTag",
"ng-change" => "ctrl.tagTitleDidChange(tag)", "ng-focus" => "ctrl.onTagTitleFocus(tag)"}
"ng-change" => "ctrl.tagTitleDidChange(tag)", "ng-focus" => "ctrl.onTagTitleFocus(tag)", "ng-blur" => "ctrl.saveTag($event, tag)"}
.count {{ctrl.noteCount(tag)}}