525 lines
16 KiB
JavaScript
525 lines
16 KiB
JavaScript
/* global global:writable */
|
|
|
|
import * as semver from 'semver';
|
|
import sodium from 'sodium-javascript';
|
|
|
|
import { HttpClient } from '../../services/http-client/http-client';
|
|
|
|
export const LOCATION = {
|
|
LOCAL: 'local',
|
|
SESSION: 'session',
|
|
WINDOW: 'window',
|
|
HISTORY: 'history',
|
|
};
|
|
|
|
const LOCATION_SHADOWING = [ LOCATION.HISTORY, LOCATION.WINDOW, LOCATION.SESSION, LOCATION.LOCAL ];
|
|
|
|
export class StorageManager {
|
|
|
|
namespace;
|
|
version;
|
|
|
|
_options;
|
|
_global;
|
|
|
|
_encryptionKey = {};
|
|
|
|
constructor(namespace, version, options) {
|
|
this._debugLog('constructor', namespace, version, options);
|
|
|
|
if (typeof namespace === 'object') {
|
|
let sep = '_';
|
|
const namespace_arr = Array.from(namespace);
|
|
while (namespace_arr.some(str => str.includes(sep)))
|
|
sep = sep + '_';
|
|
|
|
this.namespace = Array.from(namespace).join(sep);
|
|
} else {
|
|
this.namespace = namespace;
|
|
}
|
|
this.version = semver.valid(version);
|
|
|
|
if (!namespace) {
|
|
throw new Error('Cannot setup StorageManager without namespace');
|
|
}
|
|
if (!this.version) {
|
|
throw new Error('Cannot setup StorageManager without valid semver version');
|
|
}
|
|
|
|
if (options !== undefined) {
|
|
this._options = options;
|
|
}
|
|
|
|
if (global !== undefined)
|
|
this._global = global;
|
|
else if (window !== undefined)
|
|
this._global = window;
|
|
else
|
|
throw new Error('Cannot setup StorageManager without window or global');
|
|
|
|
if (this._options.encryption) {
|
|
[LOCATION.LOCAL, LOCATION.SESSION, LOCATION.HISTORY].forEach((location) => {
|
|
const encryption = this._options.encryption.all || this._options.encryption[location];
|
|
if (encryption) this._requestStorageKey({ location: location, encryption: encryption });
|
|
});
|
|
}
|
|
}
|
|
|
|
save(key, value, options=this._options) {
|
|
this._debugLog('save', key, value, options);
|
|
|
|
if (!key) {
|
|
throw new Error('StorageManager.save called with invalid key');
|
|
}
|
|
|
|
if (options && options.location !== undefined && !Object.values(LOCATION).includes(options.location)) {
|
|
throw new Error('StorageManager.save called with unsupported location option');
|
|
}
|
|
|
|
const location = options && options.location !== undefined ? options.location : LOCATION_SHADOWING[0];
|
|
|
|
switch (location) {
|
|
case LOCATION.LOCAL: {
|
|
this._saveToLocalStorage({ ...this._getFromLocalStorage(options), [key]: value}, options);
|
|
break;
|
|
}
|
|
case LOCATION.SESSION: {
|
|
this._saveToSessionStorage({ ...this._getFromSessionStorage(options), [key]: value}, options);
|
|
break;
|
|
}
|
|
case LOCATION.WINDOW: {
|
|
this._saveToWindow({ ...this._getFromWindow(), [key]: value });
|
|
break;
|
|
}
|
|
case LOCATION.HISTORY: {
|
|
this._saveToHistory({ ...this._getFromHistory(), [key]: value }, options);
|
|
break;
|
|
}
|
|
default:
|
|
console.error('StorageManager.save cannot save item with unsupported location');
|
|
}
|
|
}
|
|
|
|
load(key, options=this._options) {
|
|
this._debugLog('load', key, options);
|
|
|
|
if (options && options.location !== undefined && !Object.values(LOCATION).includes(options.location)) {
|
|
throw new Error('StorageManager.load called with unsupported location option');
|
|
}
|
|
|
|
let locations = options && options.location !== undefined ? [options.location] : LOCATION_SHADOWING;
|
|
|
|
while (locations.length > 0) {
|
|
const location = locations.shift();
|
|
let val;
|
|
|
|
switch (location) {
|
|
case LOCATION.LOCAL: {
|
|
val = this._getFromLocalStorage(options)[key];
|
|
break;
|
|
}
|
|
case LOCATION.SESSION: {
|
|
val = this._getFromSessionStorage(options)[key];
|
|
break;
|
|
}
|
|
case LOCATION.WINDOW: {
|
|
val = this._getFromWindow()[key];
|
|
break;
|
|
}
|
|
case LOCATION.HISTORY: {
|
|
val = this._getFromHistory(options)[key];
|
|
break;
|
|
}
|
|
default:
|
|
console.error('StorageManager.load cannot load item with unsupported location');
|
|
}
|
|
|
|
if (val !== undefined || locations.length === 0) {
|
|
return val;
|
|
}
|
|
}
|
|
}
|
|
|
|
remove(key, options=this._options) {
|
|
this._debugLog('remove', key, options);
|
|
|
|
if (options && options.location !== undefined && !Object.values(LOCATION).includes(options.location)) {
|
|
throw new Error('StorageManager.load called with unsupported location option');
|
|
}
|
|
|
|
const locations = options && options.location !== undefined ? [options.location] : LOCATION_SHADOWING;
|
|
|
|
for (const location of locations) {
|
|
switch (location) {
|
|
case LOCATION.LOCAL: {
|
|
let val = this._getFromLocalStorage(options);
|
|
|
|
delete val[key];
|
|
|
|
return this._saveToLocalStorage(val, options);
|
|
}
|
|
case LOCATION.SESSION: {
|
|
let val = this._getFromSessionStorage(options);
|
|
|
|
delete val[key];
|
|
|
|
return this._saveToSessionStorage(val, options);
|
|
}
|
|
case LOCATION.WINDOW: {
|
|
let val = this._getFromWindow();
|
|
|
|
delete val[key];
|
|
|
|
return this._saveToWindow(val);
|
|
}
|
|
case LOCATION.HISTORY: {
|
|
let val = this._getFromHistory(options);
|
|
|
|
delete val[key];
|
|
|
|
return this._saveToHistory(val, options);
|
|
}
|
|
|
|
default:
|
|
console.error('StorageManager.load cannot load item with unsupported location');
|
|
}
|
|
}
|
|
}
|
|
|
|
clear(options) {
|
|
this._debugLog('clear', options);
|
|
|
|
if (options && options.location !== undefined && !Object.values(LOCATION).includes(options.location)) {
|
|
throw new Error('StorageManager.clear called with unsupported location option');
|
|
}
|
|
|
|
const locations = options && options.location !== undefined ? [options.location] : LOCATION_SHADOWING;
|
|
|
|
for (const location of locations) {
|
|
switch (location) {
|
|
case LOCATION.LOCAL:
|
|
return this._clearLocalStorage();
|
|
case LOCATION.SESSION:
|
|
return this._clearSessionStorage();
|
|
case LOCATION.WINDOW:
|
|
return this._clearWindow();
|
|
case LOCATION.HISTORY:
|
|
return this._clearHistory(options && options.history);
|
|
default:
|
|
console.error('StorageManager.clear cannot clear with unsupported location');
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
|
|
_getFromLocalStorage(options) {
|
|
this._debugLog('_getFromLocalStorage', options);
|
|
|
|
let state;
|
|
|
|
try {
|
|
state = JSON.parse(window.localStorage.getItem(this.namespace));
|
|
} catch {
|
|
state = null;
|
|
}
|
|
|
|
if (state === null || !state.version || !semver.satisfies(this.version, `^${state.version}`)) {
|
|
// remove item from localStorage if it stores an invalid state
|
|
this._clearLocalStorage();
|
|
return {};
|
|
}
|
|
|
|
if ('state' in state)
|
|
return this._getFromStorage(state.state, LOCATION.LOCAL, options);
|
|
else {
|
|
delete state.version;
|
|
return this._getFromStorage(state, LOCATION.LOCAL, options);
|
|
}
|
|
}
|
|
|
|
_saveToLocalStorage(state, options) {
|
|
this._debugLog('_saveToLocalStorage', state);
|
|
|
|
if (!state)
|
|
return this._clearLocalStorage();
|
|
|
|
let versionedState;
|
|
|
|
if ('version' in state || 'state' in state) {
|
|
versionedState = { version: this.version, state: state };
|
|
} else {
|
|
versionedState = { version: this.version, ...state };
|
|
}
|
|
|
|
window.localStorage.setItem(this.namespace, JSON.stringify(this._updateStorage({}, versionedState, LOCATION.LOCAL, options)));
|
|
}
|
|
|
|
_clearLocalStorage() {
|
|
this._debugLog('_clearLocalStorage');
|
|
|
|
window.localStorage.removeItem(this.namespace);
|
|
}
|
|
|
|
|
|
_getFromWindow() {
|
|
this._debugLog('_getFromWindow');
|
|
|
|
if (!this._global || !this._global.App)
|
|
return {};
|
|
|
|
if (!this._global.App.Storage || !this._global.App.Storage[this.namespace])
|
|
return {};
|
|
|
|
return this._global.App.Storage[this.namespace];
|
|
}
|
|
|
|
_saveToWindow(value) {
|
|
this._debugLog('_saveToWindow', value);
|
|
|
|
if (!this._global || !this._global.App) {
|
|
return console.error('StorageManager._saveToWindow called when window.App is not available');
|
|
}
|
|
|
|
if (!value)
|
|
return this._clearWindow();
|
|
|
|
if (!this._global.App.Storage)
|
|
this._global.App.Storage = {};
|
|
|
|
this._global.App.Storage[this.namespace] = value;
|
|
}
|
|
|
|
_clearWindow() {
|
|
this._debugLog('_clearWindow');
|
|
|
|
if (!this._global || !this._global.App) {
|
|
return console.error('StorageManager._saveToWindow called when window.App is not available');
|
|
}
|
|
|
|
if (this._global.App.Storage) {
|
|
delete this._global.App.Storage[this.namespace];
|
|
}
|
|
}
|
|
|
|
_getFromHistory(options) {
|
|
this._debugLog('_getFromHistory');
|
|
|
|
if (!this._global || !this._global.history)
|
|
return {};
|
|
|
|
if (!this._global.history.state || !this._global.history.state[this.namespace])
|
|
return {};
|
|
|
|
return this._getFromStorage(this._global.history.state[this.namespace], LOCATION.HISTORY, options);
|
|
}
|
|
|
|
_saveToHistory(value, options) {
|
|
this._debugLog('_saveToHistory', options);
|
|
|
|
|
|
if (!this._global || !this._global.history) {
|
|
throw new Error('StorageManager._saveToHistory called when window.history is not available');
|
|
}
|
|
|
|
const push = (options.history && typeof options.history.push !== 'undefined') ? !!options.history.push : true;
|
|
const title = (options.history && options.history.title) || (this._global.document && this._global.document.title) || '';
|
|
const url = (options.history && options.history.url) || (this._global.document && this._global.document.location);
|
|
|
|
const state = this._global.history.state || {};
|
|
state[this.namespace] = this._updateStorage({}, value, LOCATION.HISTORY, options);
|
|
|
|
this._debugLog('_saveToHistory', { state: state, push: push, title: title, url: url});
|
|
|
|
if (push)
|
|
this._global.history.pushState(state, title, url);
|
|
else
|
|
this._global.history.replaceState(state, title, url);
|
|
}
|
|
|
|
_clearHistory(options) {
|
|
this._debugLog('_clearHistory', options);
|
|
|
|
if (!this._global || !this._global.history) {
|
|
throw new Error('StorageManager._clearHistory called when window.history is not available');
|
|
}
|
|
|
|
const push = (options.history && typeof options.history.push !== 'undefined' ? !!options.history.push : true) || true;
|
|
const title = (options.history && options.history.title) || (this._global.document && this._global.document.title) || '';
|
|
const url = (options.history && options.history.url) || (this._global.document && this._global.document.location);
|
|
|
|
const state = this._global.history.state || {};
|
|
delete state[this.namespace];
|
|
|
|
if (push)
|
|
this._global.history.pushState(state, title, url);
|
|
else
|
|
this._global.history.replaceState(state, title, url);
|
|
}
|
|
|
|
addHistoryListener(listener, options=this._options, ...args) {
|
|
const modified_listener = (function(event, ...listener_args) { // eslint-disable-line no-unused-vars
|
|
this._global.setTimeout(() => listener(this._getFromHistory(options), ...listener_args));
|
|
}).bind(this);
|
|
|
|
this._global.addEventListener('popstate', modified_listener, args);
|
|
}
|
|
|
|
|
|
_getFromSessionStorage(options) {
|
|
this._debugLog('_getFromSessionStorage', options);
|
|
|
|
let state;
|
|
|
|
try {
|
|
state = JSON.parse(window.sessionStorage.getItem(this.namespace));
|
|
} catch {
|
|
state = null;
|
|
}
|
|
|
|
if (state === null || !state.version || !semver.satisfies(this.version, `^${state.version}`)) {
|
|
// remove item from session if it stores an invalid state
|
|
this._clearSessionStorage();
|
|
return {};
|
|
}
|
|
|
|
if ('state' in state)
|
|
return this._getFromStorage(state.state, LOCATION.SESSION, options);
|
|
else {
|
|
delete state.version;
|
|
return this._getFromStorage(state, LOCATION.SESSION, options);
|
|
}
|
|
}
|
|
|
|
_saveToSessionStorage(state, options) {
|
|
this._debugLog('_saveToSessionStorage', state);
|
|
|
|
if (!state)
|
|
return this._clearSessionStorage();
|
|
|
|
let versionedState;
|
|
|
|
if ('version' in state || 'state' in state) {
|
|
versionedState = { version: this.version, state: state };
|
|
} else {
|
|
versionedState = { version: this.version, ...state };
|
|
}
|
|
|
|
window.sessionStorage.setItem(this.namespace, JSON.stringify(this._updateStorage({}, versionedState, LOCATION.SESSION, options)));
|
|
}
|
|
|
|
_clearSessionStorage() {
|
|
this._debugLog('_clearSessionStorage');
|
|
|
|
window.sessionStorage.removeItem(this.namespace);
|
|
}
|
|
|
|
|
|
_getFromStorage(storage, location, options) {
|
|
this._debugLog('_getFromStorage', storage, location, options);
|
|
|
|
const encryption = options && options.encryption && (options.encryption.all || options.encryption[location]);
|
|
if (encryption && storage.encryption) {
|
|
return { ...storage, ...JSON.parse(decrypt(storage.encryption.ciphertext, this._encryptionKey[location]) || null) };
|
|
} else {
|
|
return storage;
|
|
}
|
|
}
|
|
|
|
_updateStorage(storage, update, location, options) {
|
|
this._debugLog('_updateStorage', storage, update, location, options);
|
|
|
|
const encryption = options && options.encryption && (options.encryption.all || options.encryption[location]);
|
|
if (encryption && storage.encryption) {
|
|
const updatedDecryptedStorage = { ...JSON.parse(decrypt(storage.encryption.ciphertext, this._encryptionKey[location]) || null), ...update };
|
|
console.log('updatedDecryptedStorage', updatedDecryptedStorage);
|
|
return { ...storage, encryption: { ...storage.encryption, ciphertext: encrypt(JSON.stringify(updatedDecryptedStorage), this._encryptionKey[location]) } };
|
|
} else {
|
|
return { ...storage, ...update };
|
|
}
|
|
}
|
|
|
|
_requestStorageKey(options=this._options) {
|
|
this._debugLog('_requestStorageKey', options);
|
|
|
|
if (!(options && options.location && options.encryption))
|
|
throw new Error('Storage Manager cannot request storage key with unsupported options!');
|
|
|
|
const enc = this.load('encryption', { ...options, encryption: false });
|
|
const requestBody = {
|
|
type : options.encryption,
|
|
length : sodium.crypto_secretbox_KEYBYTES,
|
|
salt : enc.salt,
|
|
timestamp : enc.timestamp,
|
|
};
|
|
|
|
this._global.App.httpClient.post({
|
|
url: '/user/storage-key',
|
|
headers: {
|
|
'Content-Type' : HttpClient.ACCEPT.JSON,
|
|
'Accept' : HttpClient.ACCEPT.JSON,
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
}).then(
|
|
(response) => response.json()
|
|
).then((response) => {
|
|
console.log('storage-manager got key from response:', response, 'with options:', options);
|
|
if (response.salt !== requestBody.salt || response.timestamp !== requestBody.timestamp) {
|
|
this.clear(options);
|
|
}
|
|
this.save('encryption', { salt: response.salt, timestamp: response.timestamp }, { ...options, encryption: false });
|
|
this._encryptionKey[options.location] = response.key;
|
|
}).catch(console.error);
|
|
}
|
|
|
|
_debugLog() {}
|
|
// _debugLog(fName, ...args) {
|
|
// console.log(`[DEBUGLOG] StorageManager.${fName}`, { args: args, instance: this });
|
|
// }
|
|
}
|
|
|
|
|
|
// TODO debug unnecessary calls of encrypt
|
|
function encrypt(plaintext, key) {
|
|
console.log('encrypt', plaintext, key);
|
|
|
|
if (!plaintext) return '';
|
|
if (!key) throw new Error('Cannot encrypt plaintext without a valid key!');
|
|
|
|
/* eslint-disable no-undef */
|
|
// TODO use const if possible
|
|
let plaintextB = Buffer.from(plaintext);
|
|
let cipherB = Buffer.alloc(plaintextB.length + sodium.crypto_secretbox_MACBYTES);
|
|
let nonceB = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES);
|
|
let keyB = Buffer.from(key);
|
|
/* eslint-enable no-undef */
|
|
|
|
sodium.crypto_secretbox_easy(cipherB, plaintextB, nonceB, keyB);
|
|
|
|
const result = cipherB;
|
|
console.log('encrypt result', result);
|
|
return result;
|
|
}
|
|
|
|
// TODO debug unnecessary calls of decrypt
|
|
function decrypt(ciphertext, key) {
|
|
console.log('decrypt', ciphertext, key);
|
|
|
|
if (!ciphertext) return '';
|
|
if (!key) throw new Error('Cannot decrypt ciphertext without a valid key!');
|
|
|
|
/* eslint-disable no-undef */
|
|
// TODO use const if possible
|
|
let cipherB = Buffer.from(ciphertext);
|
|
let plaintextB = Buffer.alloc(cipherB.length - sodium.crypto_secretbox_MACBYTES);
|
|
let nonceB = Buffer.alloc(sodium.crypto_secretbox_NONCEBYTES);
|
|
let keyB = Buffer.from(key);
|
|
/* eslint-enable no-undef */
|
|
|
|
sodium.crypto_secretbox_open_easy(plaintextB, cipherB, nonceB, keyB);
|
|
|
|
const result = plaintextB.toString();
|
|
console.log('decrypt result', result);
|
|
return result;
|
|
}
|