/* 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; }