offline and local storage

This commit is contained in:
Antelle 2015-11-07 22:02:45 +03:00
parent 4e12537b7a
commit 1e4536a770
16 changed files with 464 additions and 117 deletions

View File

@ -13,7 +13,7 @@
- [ ] secure fields
- [ ] close files
- [ ] show sync date
- [ ] offline and local storage
- [x] offline and local storage
- [x] use dropbox chooser for keyfile
- [ ] trim history by rules
- [ ] advanced search

View File

@ -5,6 +5,8 @@ var AppModel = require('./models/app-model'),
KeyHandler = require('./comp/key-handler'),
Alerts = require('./comp/alerts'),
DropboxLink = require('./comp/dropbox-link'),
LastOpenFiles = require('./comp/last-open-files'),
Storage = require('./comp/storage'),
ThemeChanger = require('./util/theme-changer');
$(function() {
@ -20,7 +22,7 @@ $(function() {
if (appModel.settings.get('theme')) {
ThemeChanger.setTheme(appModel.settings.get('theme'));
}
if (['https:', 'file:', 'app:'].indexOf(location.protocol) < 0) {
if (['https:', 'file:', 'app:'].indexOf(location.protocol) < 0 && !localStorage.disableSecurityCheck) {
Alerts.error({ header: 'Not Secure!', icon: 'user-secret', esc: false, enter: false, click: false,
body: 'You have loaded this app with insecure connection. ' +
'Someone may be watching you and stealing your passwords. ' +
@ -37,7 +39,15 @@ $(function() {
}
function showApp() {
new AppView({ model: appModel }).render().showOpenFile(appModel.settings.get('lastOpenFile'));
var appView = new AppView({ model: appModel }).render();
var lastOpenFiles = LastOpenFiles.all();
var lastOpenFile = lastOpenFiles[0];
if (lastOpenFile && lastOpenFile.storage === 'file' && lastOpenFile.path) {
appView.showOpenFile(lastOpenFile.path);
} else {
appView.showOpenFile();
}
}
});

View File

@ -123,7 +123,11 @@ var DropboxLink = {
}).bind(this));
},
_handleUiError: function(err, callback) {
_handleUiError: function(err, alertCallback, callback) {
if (!alertCallback) {
alertCallback = Alerts.error.bind(Alerts);
}
console.error('Dropbox error', err);
switch (err.status) {
case Dropbox.ApiError.INVALID_TOKEN:
Alerts.yesno({
@ -138,25 +142,25 @@ var DropboxLink = {
});
return;
case Dropbox.ApiError.NOT_FOUND:
Alerts.error({
alertCallback({
header: 'Dropbox Sync Error',
body: 'The file was not found. Has it been removed from another computer?'
});
break;
case Dropbox.ApiError.OVER_QUOTA:
Alerts.error({
alertCallback({
header: 'Dropbox Full',
body: 'Your Dropbox is full, there\'s no space left anymore.'
});
break;
case Dropbox.ApiError.RATE_LIMITED:
Alerts.error({
alertCallback({
header: 'Dropbox Sync Error',
body: 'Too many requests to Dropbox have been made by this app. Please, try again later.'
});
break;
case Dropbox.ApiError.NETWORK_ERROR:
Alerts.error({
alertCallback({
header: 'Dropbox Sync Network Error',
body: 'Network error occured during Dropbox sync. Please, check your connection and try again.'
});
@ -164,23 +168,22 @@ var DropboxLink = {
case Dropbox.ApiError.INVALID_PARAM:
case Dropbox.ApiError.OAUTH_ERROR:
case Dropbox.ApiError.INVALID_METHOD:
Alerts.error({
alertCallback({
header: 'Dropbox Sync Error',
body: 'Something went wrong during Dropbox sync. Please, try again later. Error code: ' + err.status
});
break;
default:
Alerts.error({
alertCallback({
header: 'Dropbox Sync Error',
body: 'Something went wrong during Dropbox sync. Please, try again later. Error: ' + err
});
console.error('Dropbox error', err);
break;
}
callback(false);
},
_callAndHandleError: function(callName, args, callback) {
_callAndHandleError: function(callName, args, callback, errorAlertCallback) {
var that = this;
this._getClient(function(err, client) {
if (err) {
@ -188,9 +191,9 @@ var DropboxLink = {
}
client[callName].apply(client, args.concat(function(err, res) {
if (err) {
that._handleUiError(err, function(repeat) {
that._handleUiError(err, errorAlertCallback, function(repeat) {
if (repeat) {
that._callAndHandleError(callName, args, callback);
that._callAndHandleError(callName, args, callback, errorAlertCallback);
} else {
callback(err);
}
@ -223,8 +226,8 @@ var DropboxLink = {
}
},
openFile: function(fileName, complete) {
this._callAndHandleError('readFile', [fileName, { blob: true }], complete);
openFile: function(fileName, complete, errorAlertCallback) {
this._callAndHandleError('readFile', [fileName, { blob: true }], complete, errorAlertCallback);
},
getFileList: function(complete) {

View File

@ -0,0 +1,55 @@
'use strict';
var Storage = require('./storage');
var MaxItems = 5;
var LastOpenFiles = {
all: function() {
try {
return JSON.parse(localStorage.lastOpenFiles).map(function(f) {
f.dt = Date.parse(f.dt);
return f;
});
} catch (e) {
return [];
}
},
byName: function(name) {
return this.all().filter(function(f) { return f.name === name; })[0];
},
save: function(files) {
try {
localStorage.lastOpenFiles = JSON.stringify(files);
} catch (e) {
console.error('Error saving last open files', e);
}
},
add: function(name, storage, path, availOffline) {
console.log('Add last open file', name, storage, path);
var files = this.all();
files = files.filter(function(f) { return f.name !== name; });
files.unshift({ name: name, storage: storage, path: path, availOffline: availOffline, dt: new Date() });
while (files.length > MaxItems) {
files.pop();
}
this.save(files);
},
remove: function(name) {
console.log('Remove last open file', name);
var files = this.all();
files.forEach(function(file) {
if (file.name === name && file.availOffline) {
Storage.cache.remove(file.name);
}
}, this);
files = files.filter(function(file) { return file.name !== name; });
this.save(files);
}
};
module.exports = LastOpenFiles;

View File

@ -0,0 +1,13 @@
'use strict';
var Storage = {};
[
require('./storage-cache'),
require('./storage-dropbox'),
require('./storage-file')
].forEach(function(storage) {
Storage[storage.name] = storage;
});
module.exports = Storage;

View File

@ -0,0 +1,114 @@
'use strict';
var idb = window.indexedDB || window.mozIndexedDB || window.webkitIndexedDB || window.msIndexedDB;
var StorageCache = {
name: 'cache',
enabled: !!idb,
db: null,
errorOpening: null,
init: function(callback) {
if (this.db) {
return callback();
}
var that = this;
try {
var req = idb.open('FilesCache');
req.onerror = function (e) {
console.error('Error opening indexed db', e);
that.errorOpening = e;
callback(e);
};
req.onsuccess = function (e) {
that.db = e.target.result;
callback();
};
req.onupgradeneeded = function (e) {
var db = e.target.result;
db.createObjectStore('files');
};
} catch (e) {
console.error('Error opening indexed db', e);
callback(e);
}
},
save: function(id, data, callback) {
this.init((function(err) {
if (err) {
return callback(err);
}
try {
var req = this.db.transaction(['files'], 'readwrite').objectStore('files').put(data, id);
req.onsuccess = function () {
if (callback) {
callback();
}
};
req.onerror = function () {
console.error('Error saving to cache', id, req.error);
if (callback) {
callback(req.error);
}
};
} catch (e) {
console.error('Error saving to cache', id, e);
callback(e);
}
}).bind(this));
},
load: function(id, callback) {
this.init((function(err) {
if (err) {
return callback(null, err);
}
try {
var req = this.db.transaction(['files'], 'readonly').objectStore('files').get(id);
req.onsuccess = function () {
if (callback) {
callback(req.result);
}
};
req.onerror = function () {
console.error('Error loading from cache', id, req.error);
if (callback) {
callback(null, req.error);
}
};
} catch (e) {
console.error('Error loading from cache', id, e);
callback(null, e);
}
}).bind(this));
},
remove: function(id, callback) {
this.init((function(err) {
if (err) {
return callback(err);
}
try {
var req = this.db.transaction(['files'], 'readwrite').objectStore('files').delete(id);
req.onsuccess = function () {
if (callback) {
callback();
}
};
req.onerror = function () {
console.error('Error removing from cache', id, req.error);
if (callback) {
callback(req.error);
}
};
} catch(e) {
console.error('Error removing from cache', id, e);
callback(e);
}
}).bind(this));
}
};
module.exports = StorageCache;

View File

@ -0,0 +1,11 @@
'use strict';
var Launcher = require('../launcher');
var StorageDropbox = {
name: 'dropbox',
enabled: !Launcher
// TODO: move Dropbox storage operations here
};
module.exports = StorageDropbox;

View File

@ -0,0 +1,11 @@
'use strict';
var Launcher = require('../launcher');
var StorageFile = {
name: 'file',
enabled: !!Launcher
// TODO: move file storage operations here
};
module.exports = StorageFile;

View File

@ -36,9 +36,6 @@ var AppModel = Backbone.Model.extend({
page: 'file',
file: file
});
if (file.get('path')) {
AppSettingsModel.instance.set('lastOpenFile', file.get('path'));
}
this.refresh();
},

View File

@ -8,7 +8,6 @@ var FileName = 'app-settings.json';
var AppSettingsModel = Backbone.Model.extend({
defaults: {
theme: 'd',
lastOpenFile: '',
expandGroups: true
},

View File

@ -5,6 +5,8 @@ var Backbone = require('backbone'),
GroupModel = require('./group-model'),
Launcher = require('../comp/launcher'),
DropboxLink = require('../comp/dropbox-link'),
Storage = require('../comp/storage'),
LastOpenFiles = require('../comp/last-open-files'),
kdbxweb = require('kdbxweb'),
demoFileData = require('base64!../../resources/Demo.kdbx');
@ -26,7 +28,9 @@ var FileModel = Backbone.Model.extend({
oldKeyFileName: '',
passwordChanged: false,
keyFileChanged: false,
syncing: false
syncing: false,
availOffline: false,
offline: false
},
db: null,
@ -65,6 +69,7 @@ var FileModel = Backbone.Model.extend({
}
console.log('Opened file ' + this.get('name') + ': ' + Math.round(performance.now() - start) + 'ms, ' +
db.header.keyEncryptionRounds + ' rounds, ' + Math.round(fileData.byteLength / 1024) + ' kB');
this.postOpen(fileData);
}
}).bind(this));
} catch (e) {
@ -73,12 +78,38 @@ var FileModel = Backbone.Model.extend({
}
},
postOpen: function(fileData) {
var that = this;
if (!this.get('offline')) {
if (this.get('availOffline')) {
Storage.cache.save(this.get('name'), fileData, function (err) {
if (err) {
that.set('availOffline', false);
if (!that.get('storage')) {
return;
}
}
that.addToLastOpenFiles(!err);
});
} else {
if (this.get('storage')) {
this.addToLastOpenFiles(false);
}
Storage.cache.remove(this.get('name'));
}
}
},
addToLastOpenFiles: function(hasOfflineCache) {
LastOpenFiles.add(this.get('name'), this.get('storage'), this.get('path'), hasOfflineCache);
},
create: function(name) {
var password = kdbxweb.ProtectedValue.fromString('');
var credentials = new kdbxweb.Credentials(password);
this.db = kdbxweb.Kdbx.create(credentials, name);
this.readModel();
this.set({ open: true, created: true, opening: false, error: false, name: name });
this.set({ open: true, created: true, opening: false, error: false, name: name, offline: false });
},
createDemo: function() {
@ -201,7 +232,9 @@ var FileModel = Backbone.Model.extend({
},
getData: function(cb) {
return this.db.save(cb);
var data = this.db.save(cb);
return data;
},
getXml: function(cb) {
@ -214,6 +247,7 @@ var FileModel = Backbone.Model.extend({
this.forEachEntry({}, function(entry) {
entry.unsaved = false;
});
this.addToLastOpenFiles();
},
setPassword: function(password) {

View File

@ -6,6 +6,8 @@ var Backbone = require('backbone'),
SecureInput = require('../comp/secure-input'),
FileModel = require('../models/file-model'),
Launcher = require('../comp/launcher'),
LastOpenFiles = require('../comp/last-open-files'),
Storage = require('../comp/storage'),
DropboxLink = require('../comp/dropbox-link');
var OpenView = Backbone.View.extend({
@ -23,8 +25,7 @@ var OpenView = Backbone.View.extend({
'keypress .open__pass-input': 'inputKeypress',
'click .open__pass-enter-btn': 'openDb',
'click .open__settings-key-file': 'openKeyFile',
'change .open__settings-check-offline': 'changeOffline',
'click .open__last-itemo': 'openLast',
'click .open__last-item': 'openLast',
'dragover': 'dragover',
'dragleave': 'dragleave',
'drop': 'drop'
@ -49,12 +50,26 @@ var OpenView = Backbone.View.extend({
if (this.dragTimeout) {
clearTimeout(this.dragTimeout);
}
this.renderTemplate({ supportsDropbox: !Launcher });
this.renderTemplate({ supportsDropbox: !Launcher, lastOpenFiles: this.getLastOpenFiles() });
this.inputEl = this.$el.find('.open__pass-input');
this.passwordInput.setElement(this.inputEl);
return this;
},
getLastOpenFiles: function() {
return LastOpenFiles.all().map(function(f) {
switch (f.storage) {
case 'dropbox':
f.icon = 'dropbox';
break;
default:
f.icon = 'file-text';
break;
}
return f;
});
},
remove: function() {
this.passwordInput.reset();
Backbone.View.prototype.remove.apply(this, arguments);
@ -96,7 +111,7 @@ var OpenView = Backbone.View.extend({
reader.onload = (function(e) {
this[this.reading] = e.target.result;
if (this.reading === 'fileData') {
this.file.set('name', file.name.replace(/\.\w+$/i, ''));
this.file.set({ name: file.name.replace(/\.\w+$/i, ''), offline: false });
if (file.path) {
this.file.set({ path: file.path, storage: file.storage || 'file' });
}
@ -122,6 +137,9 @@ var OpenView = Backbone.View.extend({
displayOpenFile: function() {
this.$el.addClass('open--file');
this.$el.find('#open__settings-check-offline')[0].removeAttribute('disabled');
var canSwitchOffline = this.file.get('storage') !== 'file' && !this.file.get('offline');
this.$el.find('.open__settings-offline').toggleClass('hide', !canSwitchOffline);
this.$el.find('.open__settings-offline-warning').toggleClass('hide', !this.file.get('offline'));
this.inputEl[0].removeAttribute('readonly');
this.inputEl[0].setAttribute('placeholder', 'Password for ' + this.file.get('name'));
this.inputEl.focus();
@ -185,7 +203,7 @@ var OpenView = Backbone.View.extend({
openFile: function() {
if (!this.file.get('opening')) {
this.openAny('fileData'/*, '.kdbx'*/);
this.openAny('fileData');
}
},
@ -222,6 +240,11 @@ var OpenView = Backbone.View.extend({
openDb: function() {
if (!this.file.get('opening') && this.file.get('name')) {
var offlineChecked = this.$el.find('#open__settings-check-offline').is(':checked');
if (this.file.get('offline') ||
this.file.get('storage') !== 'file' && offlineChecked) {
this.file.set('availOffline', true);
}
var arg = {
password: this.passwordInput.value,
fileData: this.fileData,
@ -234,12 +257,6 @@ var OpenView = Backbone.View.extend({
}
},
changeOffline: function(e) {
if (!this.file.get('opening')) {
this.file.set('availOffline', !!e.target.checked);
}
},
inputKeydown: function(e) {
var code = e.keyCode || e.which;
if (code === Keys.DOM_VK_RETURN && this.passwordInput.length) {
@ -265,6 +282,142 @@ var OpenView = Backbone.View.extend({
this.$el.find('.open__file-warning').toggleClass('invisible', on);
},
openFromDropbox: function() {
if (this.dropboxLoading || this.file.get('opening')) {
return;
}
var that = this;
DropboxLink.authenticate(function(err) {
if (err) {
return;
}
that.dropboxLoading = 'file list';
that.displayDropboxLoading();
DropboxLink.getFileList(function(err, files) {
that.dropboxLoading = null;
that.displayDropboxLoading();
if (err) {
return;
}
var buttons = [];
var allFileNames = {};
files.forEach(function(file) {
var fileName = file.replace(/\.kdbx/i, '');
buttons.push({ result: file, title: fileName });
allFileNames[fileName] = true;
});
if (!buttons.length) {
Alerts.error({
header: 'Nothing found',
body: 'You have no files in your Dropbox which could be opened. Files are searched in your Dropbox app folder: Apps/KeeWeb'
});
return;
}
buttons.push({ result: '', title: 'Cancel' });
Alerts.alert({
header: 'Select a file',
body: 'Select a file from your Dropbox which you would like to open',
icon: 'dropbox',
buttons: buttons,
esc: '',
click: '',
success: that.openDropboxFile.bind(that),
cancel: function() {
that.dropboxLoading = null;
that.displayDropboxLoading();
}
});
LastOpenFiles.all().forEach(function(lastOpenFile) {
if (lastOpenFile.storage === 'dropbox' && !allFileNames[lastOpenFile.name]) {
that.delLast(lastOpenFile.name);
}
});
});
});
},
openDropboxFile: function(file) {
var fileName = file.replace(/\.kdbx/i, '');
this.dropboxLoading = fileName;
this.displayDropboxLoading();
var lastOpen = LastOpenFiles.byName(fileName);
var errorAlertCallback = lastOpen && lastOpen.storage === 'dropbox' && lastOpen.availOffline ?
this.dropboxErrorCallback.bind(this, fileName) : null;
DropboxLink.openFile(file, (function(err, data) {
this.dropboxLoading = null;
this.displayDropboxLoading();
if (err || !data || !data.size) {
return;
}
Object.defineProperties(data, {
storage: { value: 'dropbox' },
path: { value: file },
name: { value: fileName }
});
this.setFile(data);
}).bind(this), errorAlertCallback);
},
dropboxErrorCallback: function(fileName, alertConfig) {
alertConfig.body += '<br/>You have offline version of this file cached. Would you like to open it?';
alertConfig.buttons = [
{result: 'offline', title: 'Open offline file'},
{result: 'yes', title: 'OK'}
];
alertConfig.success = (function(result) {
if (result === 'offline') {
this.openCache(fileName, 'dropbox');
}
}).bind(this);
Alerts.error(alertConfig);
},
displayDropboxLoading: function() {
this.$el.find('.open__icon-dropbox .open__icon-i').toggleClass('flip3d', !!this.dropboxLoading);
},
openLast: function(e) {
if (this.dropboxLoading || this.file.get('opening')) {
return;
}
var name = $(e.target).closest('.open__last-item').data('name');
if ($(e.target).is('.open__last-item-icon-del')) {
this.delLast(name);
return;
}
var lastOpenFile = LastOpenFiles.byName(name);
switch (lastOpenFile.storage) {
case 'dropbox':
return this.openDropboxFile(lastOpenFile.path);
default:
return this.openCache(name);
}
},
openCache: function(name, storage) {
Storage.cache.load(name, (function(data, err) {
if (err) {
this.delLast(name);
Alerts.error({
header: 'Error loading file',
body: 'There was an error loading offline file ' + name + '. Please, open it from file'
});
} else {
this.fileData = data;
this.file.set({ name: name, offline: true, availOffline: true });
if (storage) {
this.file.set({ storage: storage });
}
this.displayOpenFile();
}
}).bind(this));
},
delLast: function(name) {
LastOpenFiles.remove(name);
this.$el.find('.open__last-item[data-name="' + name + '"]').remove();
},
dragover: function(e) {
e.preventDefault();
if (this.dragTimeout) {
@ -296,77 +449,6 @@ var OpenView = Backbone.View.extend({
if (dataFile) {
this.setFile(dataFile, keyFile);
}
},
openFromDropbox: function() {
if (this.dropboxLoading) {
return;
}
var that = this;
DropboxLink.authenticate(function(err) {
if (err) {
return;
}
that.dropboxLoading = 'file list';
that.displayDropboxLoading();
DropboxLink.getFileList(function(err, files) {
that.dropboxLoading = null;
that.displayDropboxLoading();
if (err) {
return;
}
var buttons = [];
files.forEach(function(file) {
buttons.push({ result: file, title: file.replace(/\.kdbx/i, '') });
});
if (!buttons.length) {
Alerts.error({
header: 'Nothing found',
body: 'You have no files in your Dropbox which could be opened. Files are searched in your Dropbox app folder: Apps/KeeWeb'
});
return;
}
buttons.push({ result: '', title: 'Cancel' });
Alerts.alert({
header: 'Select a file',
body: 'Select a file from your Dropbox which you would like to open',
icon: 'dropbox',
buttons: buttons,
esc: '',
click: '',
success: that.openDropboxFile.bind(that),
cancel: function() {
that.dropboxLoading = null;
that.displayDropboxLoading();
}
});
});
});
},
openDropboxFile: function(file) {
var fileName = file.replace(/\.kdbx/i, '');
this.dropboxLoading = fileName;
this.displayDropboxLoading();
DropboxLink.openFile(file, (function(err, data) {
this.dropboxLoading = null;
this.displayDropboxLoading();
if (err || !data || !data.size) {
// TODO:render
Alerts.error({ header: 'Failed to read file', body: 'Error reading Dropbox file: \n' + err });
return;
}
Object.defineProperties(data, {
storage: { value: 'dropbox' },
path: { value: file },
name: { value: fileName }
});
this.setFile(data);
}).bind(this));
},
displayDropboxLoading: function() {
this.$el.find('.open__icon-dropbox .open__icon-i').toggleClass('flip3d', !!this.dropboxLoading);
}
});

View File

@ -127,9 +127,6 @@ var SettingsAboutView = Backbone.View.extend({
Launcher.writeFile(path, data);
this.passwordChanged = false;
this.model.saved(path, 'file');
if (!AppSettingsModel.instance.get('lastOpenFile')) {
AppSettingsModel.instance.set('lastOpenFile', path);
}
} catch (e) {
console.error('Error saving file', path, e);
Alerts.error({

View File

@ -86,6 +86,7 @@
@include justify-content(space-between);
@include align-items(stretch);
&-key-file {
height: 2em;
.open--file:not(.open--opening) & { cursor: pointer; }
.open--key-file & { @include th { color: medium-color(); } }
&-dropbox {
@ -127,12 +128,30 @@
@include align-items(stretch);
margin-top: $base-spacing;
&-item {
@include display(flex);
@include flex-direction(row);
@include justify-content(flex-start);
@include align-items(baseline);
.open:not(.open--opening) & {
@include area-selectable;
}
@include th { color: muted-color(); }
padding: $base-padding-v 0;
>i { width: 2em; }
&-icon { width: 2em; }
&-text { @include flex-grow(1); }
&-icon-del {
opacity: 0;
.open__last-item:hover & {
opacity: .3;
cursor: pointer;
margin-right: $base-padding-h;
}
@include th { color: muted-color(); }
&:hover {
.open__last-item:hover & { opacity: 1; }
@include th { color: medium-color(); }
}
}
}
}

View File

@ -24,7 +24,7 @@ button.pika-next {
}
.pika-table th {
@include th { color: medium-color(); }; // TODO
@include th { color: medium-color(); };
}
.pika-button, button.pika-button {

View File

@ -38,18 +38,20 @@
<span class="open__settings-key-file-dropbox"> (from dropbox)</span>
<% } %>
</div>
<div class="open__settings-offline">
<div class="open__settings-offline hide">
<input type="checkbox" id="open__settings-check-offline" class="open__settings-check-offline" checked disabled />
<% if (supportsDropbox) { %>
<label for="open__settings-check-offline" class="open__settings-label-offline">make available offline</label>
<% } %>
</div>
<div class="open__settings-offline-warning hide muted-color"><i class="fa fa-exclamation-triangle"></i> saved offline version</div>
</div>
<div class="open__last">
<!--<div class="open__last-item"><i class="fa fa-dropbox"></i>My passwords</div>-->
<!--<div class="open__last-item"><i class="fa fa-dropbox"></i>Work</div>-->
<!--<div class="open__last-item"><i class="fa fa-dropbox"></i>Hard with many rounds</div>-->
<!--<div class="open__last-item"><i class="fa fa-file-text"></i>Local</div>-->
<% lastOpenFiles.forEach(function(file) { %>
<div class="open__last-item" data-name="<%- file.name %>">
<i class="fa fa-<%= file.icon %> open__last-item-icon"></i>
<span class="open__last-item-text"><%- file.name %></span>
<i class="fa fa-times open__last-item-icon-del"></i>
</div>
<% }); %>
</div>
</div>
<div class="open__dropzone">