kiwix-js-pwa/www/js/lib/settingsStore.js

327 lines
14 KiB
JavaScript

'use strict';
define([], function () {
/**
* settingsStore.js
*
* A reader/writer framework for cookies or localStorage with full unicode support based on the Mozilla cookies framework.
* The Mozilla code has been adapted to test for the availability of the localStorage API, and to use it in preference to cookies.
*
* Mozilla version information:
*
* Revision #1 - September 4, 2014
*
* https://developer.mozilla.org/en-US/docs/Web/API/document.cookie
* https://developer.mozilla.org/User:fusionchess
*
* This framework is released under the GNU Public License, version 3 or later.
* http://www.gnu.org/licenses/gpl-3.0-standalone.html
*
* Syntaxes:
*
* * settingsStore.setItem(name, value[, end[, path[, domain[, secure]]]])
* * settingsStore.getItem(name)
* * settingsStore.removeItem(name[, path[, domain]])
* * settingsStore.hasItem(name)
*
*/
/**
* A RegExp of the settings keys used in the cookie that should be migrated to localStorage if the API is available
* DEV: It should not be necessary to keep this list up-to-date because any keys added after this list was created
* (April 2020) will already be stored in localStorage if it is available to the client's browser or platform and
* will not need to be migrated
* @type {RegExp}
*/
var regexpCookieKeysToMigrate = new RegExp([
'contentInjectionMode', 'lastSelectedArchive', 'lastSelectedArchivePath',
'imageDisplay', 'useMathJax', 'appVersion',
'listOfArchives', 'lastPageVisit', 'cssCache', 'cssUITheme',
'cssTheme', 'cssSource', 'removePageMaxWidth', 'hideActiveContentWarning',
'hideToolbars', 'allowHTMLExtraction', 'openAllSections', 'PWAInstalled',
'lastPageLoad', 'showFileSelectors'
].join('|'));
/**
* A list of deprecated keys that should be removed. Add any further keys to the list of strings separated by a comma.
* @type {Array}
*/
var deprecatedKeys = [
'lastContentInjectionMode', 'lastPageHTML', 'lastPageVisit', 'version', 'useCache'
];
/**
* The prefix that will be added to keys when stored in localStorage: this is used to prevent
* potential collision of key names with localStorage keys used by code inside ZIM archives
* It is set in init.js because it is needed early in app loading
* @type {String}
*/
var keyPrefix = params.keyPrefix;
// Tests for available Storage APIs (document.cookie or localStorage) and returns the best available of these
function getBestAvailableStorageAPI() {
// DEV: In FF extensions, cookies are blocked since at least FF 68.6 but possibly since FF 55 [kiwix-js #612]
var type = 'none';
// First test for localStorage API support
var localStorageTest;
try {
localStorageTest = 'localStorage' in window && window['localStorage'] !== null;
// DEV: Above test returns true in IE11 running from file:// protocol, but attempting to write a key to
// localStorage causes an exception; so to test fully, we must now attempt to write and remove a test key
if (localStorageTest) {
localStorage.setItem('tempKiwixStorageTest', '');
localStorage.removeItem('tempKiwixStorageTest');
}
} catch (e) {
localStorageTest = false;
}
// Now test for document.cookie API support
document.cookie = 'tempKiwixCookieTest=working; expires=Fri, 31 Dec 9999 23:59:59 GMT; SameSite=Strict';
var kiwixCookieTest = /tempKiwixCookieTest=working/.test(document.cookie);
// Remove test value by expiring the key
document.cookie = 'tempKiwixCookieTest=; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Strict';
if (kiwixCookieTest) type = 'cookie';
// Prefer localStorage if supported due to some platforms removing cookies once the session ends in some contexts
if (localStorageTest) type = 'local_storage';
// If both cookies and localStorage are supported, and document.cookie contains keys to migrate,
// migrate settings to use localStorage
if (kiwixCookieTest && localStorageTest && regexpCookieKeysToMigrate.test(document.cookie)) _migrateStorageSettings();
// Remove any deprecated keys
deprecatedKeys.forEach(function (key) {
if (localStorageTest) localStorage.removeItem(keyPrefix + key);
settingsStore.removeItem(key); // Because this runs before we have returned a store type, this will remove from cookie too
});
// Note that if this function returns 'none', the cookie implementations below will run anyway. This is because storing a cookie
// does not cause an exception even if cookies are blocked in some contexts, whereas accessing localStorage may cause an exception
return type;
}
/**
* Performs a full app reset, deleting all caches and settings
* Or, if a parameter is supplied, deletes or disables the object
* @param {String} object Optional name of the object to disable or delete ('cookie', 'localStorage', 'cacheAPI')
*/
function reset(object) {
var performReset = function () {
// 1. Clear any cookie entries
if (!object || object === 'cookie') {
var regexpCookieKeys = /(?:^|;)\s*([^=]+)=([^;]*)/ig;
var currentCookie = document.cookie;
var foundCrumb = false;
var cookieCrumb = regexpCookieKeys.exec(currentCookie);
while (cookieCrumb !== null) {
// DEV: Note that we don't use the keyPrefix in legacy cookie support
foundCrumb = true;
// This expiry date will cause the browser to delete the cookie crumb on next page refresh
document.cookie = cookieCrumb[1] + '=;expires=Thu, 21 Sep 1979 00:00:01 UTC;';
cookieCrumb = regexpCookieKeys.exec(currentCookie);
}
if (foundCrumb) console.debug('All cookie keys were expired...');
}
// 2. Clear any localStorage settings
if (!object || object === 'localStorage') {
if (/localStorage/.test(assetsCache.capability)) {
localStorage.clear();
console.debug('All Local Storage settings were deleted...');
}
}
// 3. Clear any IndexedDB entries
if (!object || object === 'indexedDB') {
if (/indexedDB/.test(assetsCache.capability)) {
var cache = require('cache');
cache.clear('reset');
}
}
// 4. Clear any (remaining) Cache API caches
if (!object || object === 'cacheAPI') {
getCacheNames(function (cacheNames) {
if (cacheNames && !cacheNames.error) {
var cnt = 0;
for (var cacheName in cacheNames) {
cnt++;
caches.delete(cacheNames[cacheName]).then(function () {
cnt--;
if (!cnt) {
// All caches deleted
console.debug('All Cache API caches were deleted...');
// Reload if user performed full reset or if appCache is needed
if (!object || params.appCache) _reloadApp();
}
});
}
} else {
console.debug('No Cache API caches were in use (or we do not have access to the names).');
// All operations complete, reload if user performed full reset or if appCache is needed
if (!object || params.appCache) _reloadApp();
}
});
}
};
var uiUtil = require('uiUtil');
// If no specific object was specified, we are doing a general reset, so ask user for confirmation
if (object) performReset();
else {
uiUtil.systemAlert('<b>WARNING:</b> This will reset the app to a freshly installed state, deleting all app caches and settings! <b>Make sure you have an Internet connection</b> if this is an offline PWA, because it will be erased and reloaded.',
'Warning!', true).then(function (confirm) {
if (confirm) performReset();
else console.debug('User cancelled');
});
}
}
// Gets cache names from Service Worker, as we cannot rely on having them in params.cacheNames
function getCacheNames(callback) {
if (navigator.serviceWorker && navigator.serviceWorker.controller) {
var channel = new MessageChannel();
channel.port1.onmessage = function (event) {
var names = event.data;
callback(names);
};
navigator.serviceWorker.controller.postMessage({
action: 'getCacheNames'
}, [channel.port2]);
} else {
callback(null);
}
}
// Deregisters all Service Workers and reboots the app
function _reloadApp() {
var reboot = function () {
console.debug('Performing app reload...');
setTimeout(function () {
window.location.href = location.origin + location.pathname + uriParams
}, 300);
};
// Blank the querystring, so that parameters are not set on reload
var uriParams = '';
if (~window.location.href.indexOf(params.PWAServer) && params.referrerExtensionURL) {
// However, if we're in a PWA that was called from local code, then by definition we must remain in SW mode and we need to
// ensure the user still has access to the referrerExtensionURL (so they can get back to local code from the UI)
uriParams = '?allowInternetAccess=truee&contentInjectionMode=serviceworker';
uriParams += '&referrerExtensionURL=' + encodeURIComponent(params.referrerExtensionURL);
}
if (navigator && navigator.serviceWorker) {
console.debug('Deregistering Service Workers...');
var cnt = 0;
navigator.serviceWorker.getRegistrations().then(function (registrations) {
if (!registrations.length) {
reboot();
return;
}
cnt++;
registrations.forEach(function (registration) {
registration.unregister().then(function () {
cnt--;
if (!cnt) {
console.debug('All Service Workers unregistered...');
reboot();
}
});
});
}).catch(function (err) {
console.error(err);
reboot();
});
} else {
console.debug('Performing app reload...');
reboot();
}
}
var settingsStore = {
getItem: function (sKey) {
if (!sKey) {
return null;
}
if (params.storeType !== 'local_storage') {
return decodeURIComponent(document.cookie.replace(new RegExp("(?:(?:^|.*;)\\s*" + encodeURIComponent(sKey).replace(/[-.+*]/g, "\\$&") + "\\s*\\=\\s*([^;]*).*$)|^.*$"), "$1")) || null;
} else {
return localStorage.getItem(keyPrefix + sKey);
}
},
setItem: function (sKey, sValue, vEnd, sPath, sDomain, bSecure) {
if (params.storeType !== 'local_storage') {
if (!sKey || /^(?:expires|max-age|path|domain|secure)$/i.test(sKey)) {
return false;
}
var sExpires = "";
if (vEnd) {
switch (vEnd.constructor) {
case Number:
sExpires = vEnd === Infinity ? "; expires=Fri, 31 Dec 9999 23:59:59 GMT" : "; max-age=" + vEnd;
break;
case String:
sExpires = "; expires=" + vEnd;
break;
case Date:
sExpires = "; expires=" + vEnd.toUTCString();
break;
}
}
document.cookie = encodeURIComponent(sKey) + "=" + encodeURIComponent(sValue) + sExpires + (sDomain ? "; domain=" + sDomain : "") + (sPath ? "; path=" + sPath : "") + (bSecure ? "; secure" : "");
} else {
localStorage.setItem(keyPrefix + sKey, sValue);
}
return true;
},
removeItem: function (sKey, sPath, sDomain) {
if (!this.hasItem(sKey)) {
return false;
}
if (params.storeType !== 'local_storage') {
document.cookie = encodeURIComponent(sKey) + "=; expires=Thu, 01 Jan 1970 00:00:00 GMT" + (sDomain ? "; domain=" + sDomain : "") + (sPath ? "; path=" + sPath : "");
} else {
localStorage.removeItem(keyPrefix + sKey);
}
return true;
},
hasItem: function (sKey) {
if (!sKey) {
return false;
}
if (params.storeType !== 'local_storage') {
return (new RegExp("(?:^|;\\s*)" + encodeURIComponent(sKey).replace(/[-.+*]/g, "\\$&") + "\\s*\\=")).test(document.cookie);
} else {
return localStorage.getItem(keyPrefix + sKey) === null ? false : true;
}
},
_cookieKeys: function () {
var aKeys = document.cookie.replace(/((?:^|\s*;)[^=]+)(?=;|$)|^\s*|\s*(?:=[^;]*)?(?:\1|$)/g, "").split(/\s*(?:=[^;]*)?;\s*/);
for (var nLen = aKeys.length, nIdx = 0; nIdx < nLen; nIdx++) {
aKeys[nIdx] = decodeURIComponent(aKeys[nIdx]);
}
return aKeys;
}
};
// One-off migration of storage settings from cookies to localStorage
function _migrateStorageSettings() {
console.log('Migrating Settings Store from cookies to localStorage...');
var cookieKeys = settingsStore._cookieKeys();
// Note that because migration occurs before setting params.storeType, settingsStore.getItem() will get the item from
// document.cookie instead of localStorage, which is the intended behaviour
for (var i = 0; i < cookieKeys.length; i++) {
if (regexpCookieKeysToMigrate.test(cookieKeys[i])) {
var migratedKey = keyPrefix + cookieKeys[i];
localStorage.setItem(migratedKey, settingsStore.getItem(cookieKeys[i]));
settingsStore.removeItem(cookieKeys[i]);
console.log('- ' + migratedKey);
}
}
console.log('Migration done.');
}
return {
getItem: settingsStore.getItem,
setItem: settingsStore.setItem,
removeItem: settingsStore.removeItem,
hasItem: settingsStore.hasItem,
getCacheNames: getCacheNames,
reset: reset,
getBestAvailableStorageAPI: getBestAvailableStorageAPI
};
});