/*! Icinga Web 2 | (c) 2019 Icinga GmbH | GPLv2+ */ ;(function(Icinga) { 'use strict'; const KEY_TTL = 7776000000; // 90 days (90×24×60×60×1000) /** * Icinga.Storage * * localStorage access * * @param {string} prefix */ Icinga.Storage = function(prefix) { /** * Prefix to use for keys * * @type {string} */ this.prefix = prefix; /** * Storage backend * * @type {Storage} */ this.backend = window.localStorage; }; /** * Callbacks for storage events on particular keys * * @type {{function}} */ Icinga.Storage.subscribers = {}; /** * Pass storage events to subscribers * * @param {StorageEvent} event */ window.addEventListener('storage', function(event) { var url = icinga.utils.parseUrl(event.url); if (! url.path.startsWith(icinga.config.baseUrl)) { // A localStorage is shared between all paths on the same origin. // So we need to make sure it's us who made a change. return; } if (typeof Icinga.Storage.subscribers[event.key] !== 'undefined') { var newValue = null, oldValue = null; if (!! event.newValue) { try { newValue = JSON.parse(event.newValue); } catch(error) { icinga.logger.error('[Storage] Failed to parse new value (\`' + event.newValue + '\`) for key "' + event.key + '". Error was: ' + error); event.storageArea.removeItem(event.key); return; } } if (!! event.oldValue) { try { oldValue = JSON.parse(event.oldValue); } catch(error) { icinga.logger.warn('[Storage] Failed to parse old value (\`' + event.oldValue + '\`) of key "' + event.key + '". Error was: ' + error); oldValue = null; } } Icinga.Storage.subscribers[event.key].forEach(function (subscriber) { subscriber[0].call(subscriber[1], newValue, oldValue, event); }); } }); /** * Create a new storage with `behavior.` as prefix * * @param {string} name * * @returns {Icinga.Storage} */ Icinga.Storage.BehaviorStorage = function(name) { return new Icinga.Storage('behavior.' + name); }; Icinga.Storage.prototype = { /** * Set the storage backend * * @param {Storage} backend */ setBackend: function(backend) { this.backend = backend; }, /** * Prefix the given key * * @param {string} key * * @returns {string} */ prefixKey: function(key) { var prefix = 'icinga.'; if (typeof this.prefix !== 'undefined') { prefix = prefix + this.prefix + '.'; } return prefix + key; }, /** * Store the given key-value pair * * @param {string} key * @param {*} value * * @returns {void} */ set: function(key, value) { this.backend.setItem(this.prefixKey(key), JSON.stringify(value)); }, /** * Get value for the given key * * @param {string} key * * @returns {*} */ get: function(key) { key = this.prefixKey(key); var value = this.backend.getItem(key); try { return JSON.parse(value); } catch(error) { icinga.logger.error('[Storage] Failed to parse value (\`' + value + '\`) of key "' + key + '". Error was: ' + error); this.backend.removeItem(key); return null; } }, /** * Remove given key from storage * * @param {string} key * * @returns {void} */ remove: function(key) { this.backend.removeItem(this.prefixKey(key)); }, /** * Subscribe with a callback for events on a particular key * * @param {string} key * @param {function} callback * @param {object} context * * @returns {void} */ onChange: function(key, callback, context) { if (this.backend !== window.localStorage) { throw new Error('[Storage] Only the localStorage emits events'); } var prefixedKey = this.prefixKey(key); if (typeof Icinga.Storage.subscribers[prefixedKey] === 'undefined') { Icinga.Storage.subscribers[prefixedKey] = []; } Icinga.Storage.subscribers[prefixedKey].push([callback, context]); } }; /** * Icinga.Storage.StorageAwareMap * * @param {object} items * @constructor */ Icinga.Storage.StorageAwareMap = function(items) { /** * Storage object * * @type {Icinga.Storage} */ this.storage = undefined; /** * Storage key * * @type {string} */ this.key = undefined; /** * Event listeners for our internal events * * @type {{}} */ this.eventListeners = { 'add': [], 'delete': [] }; /** * The internal (real) map * * @type {Map<*>} */ this.data = new Map(); // items is not passed directly because IE11 doesn't support constructor arguments if (typeof items !== 'undefined' && !! items) { Object.keys(items).forEach(function(key) { this.data.set(key, items[key]); }, this); } }; /** * Create a new StorageAwareMap for the given storage and key * * @param {Icinga.Storage} storage * @param {string} key * * @returns {Icinga.Storage.StorageAwareMap} */ Icinga.Storage.StorageAwareMap.withStorage = function(storage, key) { var items = storage.get(key); if (typeof items !== 'undefined' && !! items) { Object.keys(items).forEach(function(key) { var value = items[key]; if (typeof value !== 'object' || typeof value['lastAccess'] === 'undefined') { items[key] = {'value': value, 'lastAccess': Date.now()}; } else if (Date.now() - value['lastAccess'] > KEY_TTL) { delete items[key]; } }, this); } if (!! items && Object.keys(items).length) { storage.set(key, items); } else if (items !== null) { storage.remove(key); } return (new Icinga.Storage.StorageAwareMap(items).setStorage(storage, key)); }; Icinga.Storage.StorageAwareMap.prototype = { /** * Bind this map to the given storage and key * * @param {Icinga.Storage} storage * @param {string} key * * @returns {this} */ setStorage: function(storage, key) { this.storage = storage; this.key = key; if (storage.backend === window.localStorage) { storage.onChange(key, this.onChange, this); } return this; }, /** * Return a boolean indicating this map got a storage * * @returns {boolean} */ hasStorage: function() { return typeof this.storage !== 'undefined' && typeof this.key !== 'undefined'; }, /** * Update the storage * * @returns {void} */ updateStorage: function() { if (! this.hasStorage()) { return; } if (this.size > 0) { this.storage.set(this.key, this.toObject()); } else { this.storage.remove(this.key); } }, /** * Update the map * * @param {object} newValue */ onChange: function(newValue) { // Check for deletions first. Uses keys() to iterate over a copy this.keys().forEach(function (key) { if (newValue === null || typeof newValue[key] === 'undefined') { var value = this.data.get(key)['value']; this.data.delete(key); this.trigger('delete', key, value); } }, this); if (newValue === null) { return; } // Now check for new entries Object.keys(newValue).forEach(function(key) { var known = this.data.has(key); // Always override any known value as we want to keep track of all `lastAccess` changes this.data.set(key, newValue[key]); if (! known) { this.trigger('add', key, newValue[key]['value']); } }, this); }, /** * Register an event handler to handle storage updates * * Available events are: add, delete. The callback receives the * key and its value as first and second argument, respectively. * * @param {string} event * @param {function} callback * @param {object} thisArg * * @returns {this} */ on: function(event, callback, thisArg) { if (typeof this.eventListeners[event] === 'undefined') { throw new Error('Invalid event "' + event + '"'); } this.eventListeners[event].push([callback, thisArg]); return this; }, /** * Trigger all event handlers for the given event * * @param {string} event * @param {string} key * @param {*} value */ trigger: function(event, key, value) { this.eventListeners[event].forEach(function (handler) { var thisArg = handler[1]; if (typeof thisArg === 'undefined') { thisArg = this; } handler[0].call(thisArg, key, value); }); }, /** * Return the number of key/value pairs in the map * * @returns {number} */ get size() { return this.data.size; }, /** * Set the value for the key in the map * * @param {string} key * @param {*} value Default null * * @returns {this} */ set: function(key, value) { if (typeof value === 'undefined') { value = null; } this.data.set(key, {'value': value, 'lastAccess': Date.now()}); this.updateStorage(); return this; }, /** * Remove all key/value pairs from the map * * @returns {void} */ clear: function() { this.data.clear(); this.updateStorage(); }, /** * Remove the given key from the map * * @param {string} key * * @returns {boolean} */ delete: function(key) { var retVal = this.data.delete(key); this.updateStorage(); return retVal; }, /** * Return a list of [key, value] pairs for every item in the map * * @returns {Array} */ entries: function() { var list = []; if (this.size > 0) { this.data.forEach(function (value, key) { list.push([key, value['value']]); }); } return list; }, /** * Execute a provided function once for each item in the map, in insertion order * * @param {function} callback * @param {object} thisArg * * @returns {void} */ forEach: function(callback, thisArg) { if (typeof thisArg === 'undefined') { thisArg = this; } this.data.forEach(function(value, key) { callback.call(thisArg, value['value'], key); }); }, /** * Return the value associated to the key, or undefined if there is none * * @param {string} key * * @returns {*} */ get: function(key) { var value = this.data.get(key)['value']; this.set(key, value); // Update `lastAccess` return value; }, /** * Return a boolean asserting whether a value has been associated to the key in the map * * @param {string} key * * @returns {boolean} */ has: function(key) { return this.data.has(key); }, /** * Return an array of keys in the map * * @returns {Array} */ keys: function() { var list = []; if (this.size > 0) { // .forEach() is used because IE11 doesn't support .keys() this.data.forEach(function(_, key) { list.push(key); }); } return list; }, /** * Return an array of values in the map * * @returns {Array} */ values: function() { var list = []; if (this.size > 0) { // .forEach() is used because IE11 doesn't support .values() this.data.forEach(function(value) { list.push(value['value']); }); } return list; }, /** * Return this map as simple object * * @returns {object} */ toObject: function() { var obj = {}; if (this.size > 0) { this.data.forEach(function (value, key) { obj[key] = value; }); } return obj; } }; }(Icinga));