import DOMPurify from 'dompurify';
import $ from 'jquery';
import browser from 'webextension-polyfill';
import TBLog from './tblog.ts';
const logger = TBLog('TBStorage');
/**
* The current subdomain (NOT full domain).
* @type {string}
*/
export const domain = window.location.hostname.split('.')[0];
logger.debug(`Domain: ${domain}`);
/**
* A list of all currently loaded settings keys.
* @type {string[]}
*/
export const settings = [];
/**
* An object mapping setting keys to their currently loaded values.
* @type {{[key: string]: any}}
*/
let TBsettingsObject;
/**
* A promise which will fulfill once the current settings are fetched from
* extension storage. Once this promise fulfills, it's safe to assume
* `TBsettingsObject` contains our settings. Settings reads are delayed until
* then - either by awaiting this promise, or by waiting for the
* `TBStorageLoaded` window event, which is emitted at the same time.
*/
const initialLoadPromise = browser.storage.local.get('tbsettings').then(sObject => {
if (sObject.tbsettings) {
TBsettingsObject = sObject.tbsettings;
// Paranoid, malicious settings might be stored.
purifyObject(TBsettingsObject);
} else {
TBsettingsObject = {};
}
// Once that's all done, this promise will fulfill and pending storage calls
// will be unblocked.
});
// Don't handle background page messages until we've got our initial settings.
initialLoadPromise.then(() => {
// Listen for updated settings and update the settings object.
browser.runtime.onMessage.addListener(message => {
// A complete settings object. Likely because settings have been saved or imported. Make sure to notify the user if they have settings open in this tab.
if (message.action === 'tb-settings-update') {
TBsettingsObject = message.payload.tbsettings;
const $body = $('body');
$body.find('.tb-window-footer').addClass('tb-footer-save-warning');
$('body').find('.tb-personal-settings .tb-save').before(
'<div class="tb-save-warning">Settings have been saved in a different browser tab! Saving from this window will overwrite those settings.</div>',
);
}
// Single setting. Usually reserved for background operations as such we'll simply update it in the backround.
if (message.action === 'tb-single-setting-update') {
TBsettingsObject[message.payload.key] = message.payload.value;
const keySplit = message.payload.key.split('.');
const detailObject = {
module: keySplit[1],
setting: keySplit[2],
value: message.payload.value,
};
window.dispatchEvent(
new CustomEvent('tbSingleSettingUpdate', {
detail: detailObject,
}),
);
}
});
});
/**
* Generates an anonymized version of the settings object, with some sensitive
* settings omitted and other settings represented differently.
* @returns {Promise<object>}
*/
export const getAnonymizedSettings = () =>
new Promise(resolve => {
settingsToObject(sObject => {
// settings we delete
delete sObject['Toolbox.Achievements.lastSeen'];
delete sObject['Toolbox.Achievements.last_seen'];
delete sObject['Toolbox.Bagels.bagelType'];
delete sObject['Toolbox.Bagels.enabled'];
delete sObject['Toolbox.Modbar.customCSS'];
delete sObject['Toolbox.ModMail.lastVisited'];
delete sObject['Toolbox.ModMail.replied'];
delete sObject['Toolbox.ModMail.subredditColorSalt'];
delete sObject['Toolbox.Notifier.lastChecked'];
delete sObject['Toolbox.Notifier.lastSeenModmail'];
delete sObject['Toolbox.Notifier.lastSeenUnmoderated'];
delete sObject['Toolbox.Notifier.modmailCount'];
delete sObject['Toolbox.Notifier.modqueueCount'];
delete sObject['Toolbox.Notifier.modqueuePushed'];
delete sObject['Toolbox.Notifier.unmoderatedCount'];
delete sObject['Toolbox.Notifier.unreadMessageCount'];
delete sObject['Toolbox.Notifier.unreadPushed'];
delete sObject['Toolbox.QueueTools.kitteh'];
delete sObject['Toolbox.RReasons.customRemovalReason'];
delete sObject['Toolbox.Snoo.enabled'];
delete sObject['Toolbox.Storage.settings'];
delete sObject['Toolbox.Utils.echoTest'];
delete sObject['Toolbox.Utils.tbDevs'];
// these settings we want the length of the val.
sObject['Toolbox.Comments.highlighted'] = undefindedOrLength(sObject['Toolbox.Comments.highlighted']);
sObject['Toolbox.ModButton.savedSubs'] = undefindedOrLength(sObject['Toolbox.ModButton.savedSubs']);
sObject['Toolbox.ModMail.botsToFilter'] = undefindedOrLength(sObject['Toolbox.ModMail.botsToFilter']);
sObject['Toolbox.ModMail.filteredSubs'] = undefindedOrLength(sObject['Toolbox.ModMail.filteredSubs']);
sObject['Toolbox.Modbar.shortcuts'] = undefindedOrLength(sObject['Toolbox.Modbar.shortcuts']);
sObject['Toolbox.QueueTools.botCheckmark'] = undefindedOrLength(sObject['Toolbox.QueueTools.botCheckmark']);
sObject['Toolbox.Utils.seenNotes'] = undefindedOrLength(sObject['Toolbox.Utils.seenNotes']);
// these settings we just want to know if they are populated at all
sObject['Toolbox.Achievements.save'] = undefindedOrTrue(sObject['Toolbox.Achievements.save']);
sObject['Toolbox.ModButton.lastAction'] = undefindedOrTrue(sObject['Toolbox.ModButton.lastAction']);
sObject['Toolbox.Modbar.lastExport'] = undefindedOrTrue(sObject['Toolbox.Modbar.lastExport']);
sObject['Toolbox.Notifier.modSubreddits'] = undefindedOrTrue(sObject['Toolbox.Notifier.modSubreddits']);
sObject['Toolbox.Notifier.modmailSubreddits'] = undefindedOrTrue(
sObject['Toolbox.Notifier.modmailSubreddits'],
);
sObject['Toolbox.Notifier.unmoderatedSubreddits'] = undefindedOrTrue(
sObject['Toolbox.Notifier.unmoderatedSubreddits'],
);
sObject['Toolbox.PNotes.noteWiki'] = undefindedOrTrue(sObject['Toolbox.PNotes.noteWiki']);
sObject['Toolbox.QueueTools.queueCreature'] = undefindedOrTrue(sObject['Toolbox.QueueTools.queueCreature']);
sObject['Toolbox.QueueTools.subredditColorSalt'] = undefindedOrTrue(
sObject['Toolbox.QueueTools.subredditColorSalt'],
);
sObject['Toolbox.Utils.settingSub'] = undefindedOrTrue(sObject['Toolbox.Utils.settingSub']);
resolve(sObject);
function undefindedOrLength (setting) {
return setting === undefined ? 0 : setting.length;
}
function undefindedOrTrue (setting) {
if (!setting) {
return false;
}
if (setting.length > 0) {
return true;
}
}
});
});
/**
* Clears all cache keys.
* @returns {Promise<void>}
*/
export async function clearCache () {
await browser.runtime.sendMessage({
action: 'tb-cache',
method: 'clear',
});
}
// The below block of code will keep watch for events that require clearing the cache like account switching and people accepting mod invites.
$('body').on(
'click',
'#RESAccountSwitcherDropdown .accountName, #header-bottom-right .logout, .toggle.moderator .option',
() => {
clearCache();
},
);
/**
* Saves current settings, then verifies that they've been saved accurately. If
* the save was successful, tells the background page to update the settings
* state of other tabs. Calls back with whether or not the save was successful.
* @param {(success: boolean) => void} callback
*/
export function verifiedSettingsSave (callback) {
settingsToObject(sObject => {
const settingsObject = sObject;
// save settings
browser.storage.local.set({
tbsettings: sObject,
}).then(() => {
// now verify them
browser.storage.local.get('tbsettings').then(returnObject => {
if (returnObject.tbsettings && isEquivalent(returnObject.tbsettings, settingsObject)) {
// Succes, tell other browser tabs with toolbox that there are new settings.
browser.runtime.sendMessage({
action: 'tb-global',
globalEvent: 'tb-settings-update',
payload: returnObject,
excludeBackground: true,
});
callback(true);
} else {
logger.debug('Settings could not be verified');
callback(false);
}
});
});
});
}
/**
* Uses DOMPurify to sanitize untrusted HTML strings.
* @param {string} input
* @returns {string}
*/
export function purify (input) {
return DOMPurify.sanitize(input, {SAFE_FOR_JQUERY: true});
}
// TODO: to be honest I'm not sure what this one does
function registerSetting (module, setting) {
// First parse out any of the ones we never want to save.
if (module === undefined || module === 'cache') {
return;
}
const keyName = `${module}.${setting}`;
if (!settings.includes(keyName)) {
settings.push(keyName);
}
}
/**
* Recursively sanitize an object's string values as untrusted HTML. String
* values that can be interpreted as JSON objects are parsed, sanitized, and
* re-stringified.
* @param {any} input
*/
export function purifyObject (input) {
for (const key in input) {
if (Object.prototype.hasOwnProperty.call(input, key)) {
const itemType = typeof input[key];
switch (itemType) {
case 'object':
purifyObject(input[key]);
break;
case 'string':
// If the string we're handling is a JSON string, purifying it before it's parsed will mangle
// the JSON and make it unusable. We try to parse every value, and if parsing returns an object
// or an array, we run purifyObject on the result and re-stringify the value, rather than
// trying to purify the string itself. This ensures that when the string is parsed somewhere
// else, it's already purified.
// TODO: Identify if this behavior is actually used anywhere
try {
const jsonObject = JSON.parse(input[key]);
// We only want to purify the parsed value if it's an object or array, otherwise we throw
// back and purify the raw string instead (see #461)
if (typeof jsonObject !== 'object' || jsonObject == null) {
throw new Error('not using the parsed result of this string');
}
purifyObject(jsonObject);
input[key] = JSON.stringify(jsonObject);
} catch (e) {
// Not json, simply purify
input[key] = purify(input[key]);
}
break;
case 'function':
// If we are dealing with an actual function something is really wrong and we'll overwrite it.
input[key] = 'function';
break;
case 'number':
case 'boolean':
case 'undefined':
// Do nothing with these as they are supposed to be safe.
break;
default:
// If we end here we are dealing with a type we don't expect to begin with. Begone!
input[key] = `unknown item type ${itemType}`;
}
}
}
}
// TODO: this is another purify function used exclusively for settings, I'm not
// sure how it works either.
function purifyThing (input) {
let output;
const itemType = typeof input;
switch (itemType) {
case 'object':
purifyObject(input);
output = input;
break;
case 'string':
// Let's see if we are dealing with json.
// We want to handle json properly otherwise the purify process will mess up things.
try {
const jsonObject = JSON.parse(input);
purifyObject(jsonObject);
output = JSON.stringify(jsonObject);
} catch (e) {
// Not json, simply purify
output = purify(input);
}
break;
case 'function':
// If we are dealing with an actual function something is really wrong and we'll overwrite it.
output = 'function';
break;
case 'number':
case 'boolean':
case 'undefined':
// Do nothing with these as they are supposed to be safe.
output = input;
break;
default:
// If we end here we are dealing with a type we don't expect to begin with. Begone!
output = `unknown item type ${itemType}`;
}
return output;
}
/**
* Calls back with a copy of the current settings object, containing all setting
* keys and their values.
* @param {(settings: object) => void} callback
*/
function settingsToObject (callback) {
initialLoadPromise.then(() => {
// We make a deep clone of the settings object so it can safely be used and manipulated for things like anonymized exports.
const settingsObject = JSON.parse(JSON.stringify(TBsettingsObject));
// We are paranoid, so we are going to purify the object first.s
purifyObject(settingsObject);
callback(settingsObject);
});
}
/**
* Promises a copy of the current settings object, containing all setting keys
* and their values.
* @returns {Promise<object>}
*/
// TODO: convert original function to promise
export const getSettings = () => new Promise(resolve => settingsToObject(resolve));
/**
* Commits the current settings to extension storage.
* @returns {Promise<void>}
*/
async function saveSettingsToBrowser () {
browser.storage.local.set({
tbsettings: await getSettings(),
});
}
/**
* Returns the value of a setting.
* @deprecated Use `getSettingAsync` instead
* @param {string} module The ID of the module the setting belongs to
* @param {string} setting The name of the setting
* @param {any} defaultVal The value returned if the setting is not set
* @returns {any}
*/
export function getSetting (module, setting, defaultVal) {
const storageKey = `Toolbox.${module}.${setting}`;
registerSetting(module, setting);
defaultVal = defaultVal !== undefined ? defaultVal : null;
let result;
if (TBsettingsObject[storageKey] === undefined) {
return defaultVal;
} else {
result = TBsettingsObject[storageKey];
// send back the default if, somehow, someone stored `null`
// NOTE: never, EVER store `null`!
if (
result === null
&& defaultVal !== null
) {
result = defaultVal;
}
// Again, being extra paranoid here but let's sanitize.
const sanitzedResult = purifyThing(result);
return sanitzedResult;
}
}
/**
* Returns the value of a setting.
* @param {string} module The ID of the module the setting belongs to
* @param {string} setting The name of the setting
* @param {any} defaultVal The value returned if the setting is not set
* @returns {Promise<any>}
*/
export async function getSettingAsync (module, setting, defaultVal) {
await initialLoadPromise;
return getSetting(module, setting, defaultVal);
}
/**
* Sets a setting to a new value.
* @deprecated Use `setSettingAsync` instead
* @param {string} module The ID of the module the setting belongs to
* @param {string} setting The name of the setting
* @param {any} value The new value of the setting
* @param {boolean} [syncSettings=true] If false, settings will not be committed
* to storage after performing this action
* @returns {any} The new value of the setting
*/
export function setSetting (module, setting, value, syncSettings = true) {
const storageKey = `Toolbox.${module}.${setting}`;
registerSetting(module, setting);
// Sanitize the setting
const sanitzedValue = purifyThing(value);
TBsettingsObject[storageKey] = sanitzedValue;
// try to save our settings.
if (syncSettings) {
saveSettingsToBrowser();
// Communicate the new setting to other open tabs.
browser.runtime.sendMessage({
action: 'tb-global',
globalEvent: 'tb-single-setting-update',
excludeBackground: true,
payload: {
key: storageKey,
value: sanitzedValue,
},
});
}
return getSetting(module, setting);
}
/**
* Sets a setting to a new value.
* @param {string} module The ID of the module the setting belongs to
* @param {string} setting The name of the setting
* @param {any} value The new value of the setting
* @param {boolean} [syncSettings=true] If false, settings will not be committed
* to storage after performing this action
* @returns {Promise<any>} The new value of the setting
*/
export async function setSettingAsync (module, setting, value, syncSettings = true) {
await initialLoadPromise;
return setSetting(module, setting, value, syncSettings);
}
/**
* Gets a value in the cache.
* @param {string} module The module that owns the cache key
* @param {string} setting The name of the cache key
* @param {any} [defaultVal] The value returned if there is no cached value
* @returns {Promise<any>}
*/
export function getCache (module, setting, defaultVal) {
return new Promise(resolve => {
const storageKey = `${module}.${setting}`;
const inputValue = defaultVal !== undefined ? defaultVal : null;
browser.runtime.sendMessage({
action: 'tb-cache',
method: 'get',
storageKey,
inputValue,
}).then(response => {
if (response.errorThrown !== undefined) {
logger.debug(`${storageKey} is corrupted. Sending default.`);
resolve(defaultVal);
} else {
resolve(response.value);
}
});
});
}
/**
* Sets a value in the cache.
* @param {string} module The ID of the module that owns the cache key
* @param {string} setting The name of the cache key
* @param {any} inputValue The new value of the cache key
* @returns {Promise<any>} Promises the new value of the cache key
*/
export function setCache (module, setting, inputValue) {
const storageKey = `${module}.${setting}`;
return new Promise(resolve => {
browser.runtime.sendMessage({
action: 'tb-cache',
method: 'set',
storageKey,
inputValue,
}).then(async () => {
const value = await getCache(module, setting);
resolve(value);
});
});
}
/**
* Checks whether two objects are deeply equivalent by recursively comparing
* their property values. Does not check reference equality.
* @param {any} a
* @param {any} b
* @returns {boolean}
*/
// based on: http://designpepper.com/blog/drips/object-equality-in-javascript.html
// added recursive object checks - al
function isEquivalent (a, b) {
// Create arrays of property names
const aProps = Object.getOwnPropertyNames(a);
const bProps = Object.getOwnPropertyNames(b);
// If number of properties is different,
// objects are not equivalent
if (aProps.length !== bProps.length) {
logger.debug(`length :${aProps.length} ${bProps.length}`);
return false;
}
for (let i = 0; i < aProps.length; i++) {
const propName = aProps[i];
const propA = a[propName];
const propB = b[propName];
// If values of same property are not equal,
// objects are not equivalent
if (propA !== propB) {
if (typeof propA === 'object' && typeof propB === 'object') {
if (!isEquivalent(propA, propB)) {
logger.debug(`prop :${propA} ${propB}`);
return false;
}
} else {
logger.debug(`prop :${propA} ${propB}`);
return false;
}
}
}
// If we made it this far, objects
// are considered equivalent
return true;
}