const DATABASE_NAME = "snippets_db"; const DATABASE_VERSION = 1; const SNIPPETS_OBJECTSTORE_NAME = "snippets"; export const SNIPPETS_UPDATE_INTERVAL_MS = 14400000; // 4 hours. const SNIPPETS_ENABLED_EVENT = "Snippets:Enabled"; const SNIPPETS_DISABLED_EVENT = "Snippets:Disabled"; import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm"; /** * SnippetsMap - A utility for cacheing values related to the snippet. It has * the same interface as a Map, but is optionally backed by * indexedDB for persistent storage. * Call .connect() to open a database connection and restore any * previously cached data, if necessary. * */ export class SnippetsMap extends Map { constructor(dispatch) { super(); this._db = null; this._dispatch = dispatch; } set(key, value) { super.set(key, value); return this._dbTransaction(db => db.put(value, key)); } delete(key) { super.delete(key); return this._dbTransaction(db => db.delete(key)); } clear() { super.clear(); this._dispatch(ac.OnlyToMain({type: at.SNIPPETS_BLOCKLIST_CLEARED})); return this._dbTransaction(db => db.clear()); } get blockList() { return this.get("blockList") || []; } /** * blockSnippetById - Blocks a snippet given an id * * @param {str|int} id The id of the snippet * @return {Promise} Resolves when the id has been written to indexedDB, * or immediately if the snippetMap is not connected */ async blockSnippetById(id) { if (!id) { return; } const {blockList} = this; if (!blockList.includes(id)) { blockList.push(id); this._dispatch(ac.AlsoToMain({type: at.SNIPPETS_BLOCKLIST_UPDATED, data: id})); await this.set("blockList", blockList); } } disableOnboarding() {} showFirefoxAccounts() { this._dispatch(ac.AlsoToMain({type: at.SHOW_FIREFOX_ACCOUNTS})); } getTotalBookmarksCount() { return new Promise(resolve => { this._dispatch(ac.OnlyToMain({type: at.TOTAL_BOOKMARKS_REQUEST})); global.RPMAddMessageListener("ActivityStream:MainToContent", function onMessage({data: action}) { if (action.type === at.TOTAL_BOOKMARKS_RESPONSE) { resolve(action.data); global.RPMRemoveMessageListener("ActivityStream:MainToContent", onMessage); } }); }); } getAddonsInfo() { return new Promise(resolve => { this._dispatch(ac.OnlyToMain({type: at.ADDONS_INFO_REQUEST})); global.RPMAddMessageListener("ActivityStream:MainToContent", function onMessage({data: action}) { if (action.type === at.ADDONS_INFO_RESPONSE) { resolve(action.data); global.RPMRemoveMessageListener("ActivityStream:MainToContent", onMessage); } }); }); } /** * connect - Attaches an indexedDB back-end to the Map so that any set values * are also cached in a store. It also restores any existing values * that are already stored in the indexedDB store. * * @return {type} description */ async connect() { // Open the connection const db = await this._openDB(); // Restore any existing values await this._restoreFromDb(db); // Attach a reference to the db this._db = db; } /** * _dbTransaction - Returns a db transaction wrapped with the given modifier * function as a Promise. If the db has not been connected, * it resolves immediately. * * @param {func} modifier A function to call with the transaction * @return {obj} A Promise that resolves when the transaction has * completed or errored */ _dbTransaction(modifier) { if (!this._db) { return Promise.resolve(); } return new Promise((resolve, reject) => { const transaction = modifier( this._db .transaction(SNIPPETS_OBJECTSTORE_NAME, "readwrite") .objectStore(SNIPPETS_OBJECTSTORE_NAME) ); transaction.onsuccess = event => resolve(); /* istanbul ignore next */ transaction.onerror = event => reject(transaction.error); }); } _openDB() { return new Promise((resolve, reject) => { const openRequest = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); /* istanbul ignore next */ openRequest.onerror = event => { // Try to delete the old database so that we can start this process over // next time. indexedDB.deleteDatabase(DATABASE_NAME); reject(event); }; openRequest.onupgradeneeded = event => { const db = event.target.result; if (!db.objectStoreNames.contains(SNIPPETS_OBJECTSTORE_NAME)) { db.createObjectStore(SNIPPETS_OBJECTSTORE_NAME); } }; openRequest.onsuccess = event => { let db = event.target.result; /* istanbul ignore next */ db.onerror = err => console.error(err); // eslint-disable-line no-console /* istanbul ignore next */ db.onversionchange = versionChangeEvent => versionChangeEvent.target.close(); resolve(db); }; }); } _restoreFromDb(db) { return new Promise((resolve, reject) => { let cursorRequest; try { cursorRequest = db.transaction(SNIPPETS_OBJECTSTORE_NAME) .objectStore(SNIPPETS_OBJECTSTORE_NAME).openCursor(); } catch (err) { // istanbul ignore next reject(err); // istanbul ignore next return; } /* istanbul ignore next */ cursorRequest.onerror = event => reject(event); cursorRequest.onsuccess = event => { let cursor = event.target.result; // Populate the cache from the persistent storage. if (cursor) { if (cursor.value !== "blockList") { this.set(cursor.key, cursor.value); } cursor.continue(); } else { // We are done. resolve(); } }; }); } } /** * SnippetsProvider - Initializes a SnippetsMap and loads snippets from a * remote location, or else default snippets if the remote * snippets cannot be retrieved. */ export class SnippetsProvider { constructor(dispatch) { // Initialize the Snippets Map and attaches it to a global so that // the snippet payload can interact with it. global.gSnippetsMap = new SnippetsMap(dispatch); this._onAction = this._onAction.bind(this); } get snippetsMap() { return global.gSnippetsMap; } async _refreshSnippets() { // Check if the cached version of of the snippets in snippetsMap. If it's too // old, blow away the entire snippetsMap. const cachedVersion = this.snippetsMap.get("snippets-cached-version"); if (cachedVersion !== this.appData.version) { this.snippetsMap.clear(); } // Has enough time passed for us to require an update? const lastUpdate = this.snippetsMap.get("snippets-last-update"); const needsUpdate = !(lastUpdate >= 0) || Date.now() - lastUpdate > SNIPPETS_UPDATE_INTERVAL_MS; if (needsUpdate && this.appData.snippetsURL) { this.snippetsMap.set("snippets-last-update", Date.now()); try { const response = await fetch(this.appData.snippetsURL); if (response.status === 200) { const payload = await response.text(); this.snippetsMap.set("snippets", payload); this.snippetsMap.set("snippets-cached-version", this.appData.version); } } catch (e) { console.error(e); // eslint-disable-line no-console } } } _showRemoteSnippets() { const snippetsEl = document.getElementById(this.elementId); const payload = this.snippetsMap.get("snippets"); if (!snippetsEl) { throw new Error(`No element was found with id '${this.elementId}'.`); } // This could happen if fetching failed if (!payload) { throw new Error("No remote snippets were found in gSnippetsMap."); } if (typeof payload !== "string") { throw new Error("Snippet payload was incorrectly formatted"); } // Note that injecting snippets can throw if they're invalid XML. // eslint-disable-next-line no-unsanitized/property snippetsEl.innerHTML = payload; this._logIfDevtools("Successfully added snippets."); // Scripts injected by innerHTML are inactive, so we have to relocate them // through DOM manipulation to activate their contents. for (const scriptEl of snippetsEl.getElementsByTagName("script")) { const relocatedScript = document.createElement("script"); relocatedScript.text = scriptEl.text; scriptEl.parentNode.replaceChild(relocatedScript, scriptEl); } } _onAction(msg) { if (msg.data.type === at.SNIPPET_BLOCKED) { if (!this.snippetsMap.blockList.includes(msg.data.data)) { this.snippetsMap.set("blockList", this.snippetsMap.blockList.concat(msg.data.data)); document.getElementById("snippets-container").style.display = "none"; } } } // istanbul ignore next _logIfDevtools(text) { if (this.devtoolsEnabled) { console.log("Legacy snippets:", text); // eslint-disable-line no-console } } /** * init - Fetch the snippet payload and show snippets * * @param {obj} options * @param {str} options.appData.snippetsURL The URL from which we fetch snippets * @param {int} options.appData.version The current snippets version * @param {str} options.elementId The id of the element in which to inject snippets * @param {bool} options.connect Should gSnippetsMap connect to indexedDB? */ async init(options) { Object.assign(this, { appData: {}, elementId: "snippets", connect: true, devtoolsEnabled: false, }, options); this._logIfDevtools("Initializing..."); // Add listener so we know when snippets are blocked on other pages if (global.RPMAddMessageListener) { global.RPMAddMessageListener("ActivityStream:MainToContent", this._onAction); } // TODO: Requires enabling indexedDB on newtab // Restore the snippets map from indexedDB if (this.connect) { try { await this.snippetsMap.connect(); } catch (e) { console.error(e); // eslint-disable-line no-console } } // Cache app data values so they can be accessible from gSnippetsMap for (const key of Object.keys(this.appData)) { if (key === "blockList") { this.snippetsMap.set("blockList", this.appData[key]); } else { this.snippetsMap.set(`appData.${key}`, this.appData[key]); } } // Refresh snippets, if enough time has passed. await this._refreshSnippets(); // Try showing remote snippets, falling back to defaults if necessary. try { this._showRemoteSnippets(); } catch (e) { this._logIfDevtools("Problem inserting remote snippets!"); console.error(e); // eslint-disable-line no-console } window.dispatchEvent(new Event(SNIPPETS_ENABLED_EVENT)); this.initialized = true; this._logIfDevtools("Finished initializing."); } uninit() { window.dispatchEvent(new Event(SNIPPETS_DISABLED_EVENT)); if (global.RPMRemoveMessageListener) { global.RPMRemoveMessageListener("ActivityStream:MainToContent", this._onAction); } this.initialized = false; } } /** * addSnippetsSubscriber - Creates a SnippetsProvider that Initializes * when the store has received the appropriate * Snippet data. * * @param {obj} store The redux store * @return {obj} Returns the snippets instance, asrouterContent instance and unsubscribe function */ export function addSnippetsSubscriber(store) { const snippets = new SnippetsProvider(store.dispatch); let initializing = false; store.subscribe(async () => { const state = store.getState(); /** * Sorry this code is so complicated. It will be removed soon. * This is what the different values actually mean: * * ASRouter.initialized Is ASRouter.jsm initialised? * ASRouter.allowLegacySnippets Are ASRouter snippets turned OFF (i.e. legacy snippets are allowed) * state.Prefs.values["feeds.snippets"] User preference for snippets * state.Snippets.initialized Is SnippetsFeed.jsm initialised? * snippets.initialized Is in-content snippets currently initialised? * state.Prefs.values.disableSnippets This pref is used to disable legacy snippets in an emergency * in a way that is not user-editable (true = disabled) */ /** If we should initialize snippets... */ if ( state.Prefs.values["feeds.snippets"] && state.ASRouter.initialized && state.ASRouter.allowLegacySnippets && !state.Prefs.values.disableSnippets && state.Snippets.initialized && !snippets.initialized && // Don't call init multiple times !initializing && location.href !== "about:welcome" && location.hash !== "#asrouter" ) { initializing = true; await snippets.init({appData: state.Snippets, devtoolsEnabled: state.Prefs.values["asrouter.devtoolsEnabled"]}); initializing = false; /** If we should remove snippets... */ } else if ( ( state.Prefs.values["feeds.snippets"] === false || state.Prefs.values.disableSnippets === true || (state.ASRouter.initialized && !state.ASRouter.allowLegacySnippets) ) && snippets.initialized ) { // Remove snippets snippets.uninit(); // istanbul ignore if if (state.Prefs.values["asrouter.devtoolsEnabled"]) { console.log("Legacy snippets removed"); // eslint-disable-line no-console } } }); // Returned for testing purposes return {snippets}; }