/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { IndexedDB: "resource://gre/modules/IndexedDB.sys.mjs", ProfilesDatastoreService: "moz-src:///toolkit/profile/ProfilesDatastoreService.sys.mjs", ASRouterPreferences: "resource:///modules/asrouter/ASRouterPreferences.sys.mjs", }); export class ASRouterStorage { // Tracks in-flight IDB write promises so the shutdown blocker can await them. #pendingWrites = new Set(); /** * @param storeNames Array of strings used to create all the required stores */ constructor({ storeNames, telemetry }) { if (!storeNames) { throw new Error("storeNames required"); } this.dbName = "ActivityStream"; this.dbVersion = 3; this.storeNames = storeNames; this.telemetry = telemetry; } get pendingWriteCount() { return this.#pendingWrites.size; } get db() { if (!this._db) { this._db = this.createOrOpenDb().catch(e => { this._db = null; throw e; }); } return this._db; } _trackedSet(storeName, key, value) { const p = this._set(storeName, key, value).finally(() => this.#pendingWrites.delete(p) ); p.catch(() => {}); this.#pendingWrites.add(p); } flush() { return Promise.allSettled(this.#pendingWrites); } /** * Public method that binds the store required by the consumer and exposes * the private db getters and setters. * * @param storeName String name of desired store */ getDbTable(storeName) { if (this.storeNames.includes(storeName)) { return { get: this._get.bind(this, storeName), getAll: this._getAll.bind(this, storeName), getAllKeys: this._getAllKeys.bind(this, storeName), set: this._trackedSet.bind(this, storeName), getSharedMessageImpressions: this.getSharedMessageImpressions.bind(this), getSharedMessageBlocklist: this.getSharedMessageBlocklist.bind(this), setSharedMessageImpressions: this.setSharedMessageImpressions.bind(this), setSharedMessageBlocked: this.setSharedMessageBlocked.bind(this), resetSharedMessageStorage: this.resetSharedMessageStorage.bind(this), }; } throw new Error(`Store name ${storeName} does not exist.`); } async _getStore(storeName, mode = "readonly") { return (await this.db).objectStore(storeName, mode); } _get(storeName, key) { return this._requestWrapper(async () => (await this._getStore(storeName)).get(key) ); } _getAll(storeName) { return this._requestWrapper(async () => (await this._getStore(storeName)).getAll() ); } _getAllKeys(storeName) { return this._requestWrapper(async () => (await this._getStore(storeName)).getAllKeys() ); } _set(storeName, key, value) { return this._requestWrapper(async () => (await this._getStore(storeName, "readwrite")).put(value, key) ); } _openDatabase() { return lazy.IndexedDB.open(this.dbName, this.dbVersion, db => { // If provided with array of objectStore names we need to create all the // individual stores. // createObjectStore is synchronous (returns IDBObjectStore, not // IDBRequest), so we must not wrap it with the async _requestWrapper: // its returned Promise would never be awaited inside this synchronous // callback, silently swallowing any error instead of letting it abort // the version-change transaction. this.storeNames.forEach(store => { if (!db.objectStoreNames.contains(store)) { db.createObjectStore(store); } }); }); } /** * Open a db (with this.dbName) if it exists. If it does not exist, create it. * If an error occurs, deleted the db and attempt to re-create it. * * @returns Promise that resolves with a db instance */ async createOrOpenDb() { try { const db = await this._openDatabase(); return this._registerLifecycleHandlers(db); } catch (e) { if (this.telemetry) { this.telemetry.handleUndesiredEvent({ event: "INDEXEDDB_OPEN_FAILED" }); } try { await lazy.IndexedDB.deleteDatabase(this.dbName); } catch (deleteErr) { if (this.telemetry) { this.telemetry.handleUndesiredEvent({ event: "INDEXEDDB_DELETE_FAILED", }); } } const db = await this._openDatabase(); return this._registerLifecycleHandlers(db); } } // Register event handlers on a newly opened database connection so that // external lifecycle events (version upgrades from other connections, or // the backend closing the connection due to storage pressure/corruption) // clear the cached _db promise and allow the next access to re-open. _registerLifecycleHandlers(db) { db.onversionchange = () => { db.close(); this._db = null; }; db.onclose = () => { this._db = null; }; return db; } async _requestWrapper(request) { let result = null; try { result = await request(); } catch (e) { if (this.telemetry) { this.telemetry.handleUndesiredEvent({ event: "TRANSACTION_FAILED" }); } throw e; } return result; } /** * Gets all of the message impression data * * @returns {object|null} All multiprofile message impressions or null if error occurs */ async getSharedMessageImpressions() { try { const conn = await lazy.ProfilesDatastoreService.getConnection(); if (!conn) { return null; } const rows = await conn.executeCached( `SELECT messageId, json(impressions) AS impressions FROM MessagingSystemMessageImpressions;` ); if (rows.length === 0) { return null; } const impressionsData = {}; for (const row of rows) { const messageId = row.getResultByName("messageId"); const impressions = JSON.parse(row.getResultByName("impressions")); impressionsData[messageId] = impressions; } return impressionsData; } catch (e) { lazy.ASRouterPreferences.console.error( `ASRouterStorage: Failed reading from MessagingSystemMessageImpressions`, e ); if (this.telemetry) { this.telemetry.handleUndesiredEvent({ event: "SHARED_DB_READ_FAILED", }); } return null; } } /** * Gets the message blocklist * * @returns {Array|null} The message blocklist, or null if error occurred */ async getSharedMessageBlocklist() { try { const conn = await lazy.ProfilesDatastoreService.getConnection(); if (!conn) { return null; } const rows = await conn.executeCached( `SELECT messageId FROM MessagingSystemMessageBlocklist;` ); return rows.map(row => row.getResultByName("messageId")); } catch (e) { lazy.ASRouterPreferences.console.error( `ASRouterStorage: Failed reading from MessagingSystemMessageBlocklist`, e ); if (this.telemetry) { this.telemetry.handleUndesiredEvent({ event: "SHARED_DB_READ_FAILED", }); } return null; } } /** * Set the message impressions for a given message ID * * @param {string} messageId - The message ID to set the impressions for * @param {Array|null} impressions - The new value of "impressions" (an array of * impression data or an emtpy array, or null to delete) * @returns {boolean} Success status */ async setSharedMessageImpressions(messageId, impressions) { let success = true; try { const conn = await lazy.ProfilesDatastoreService.getConnection(); if (!conn) { return false; } if (!messageId) { throw new Error( "Failed attempt to set shared message impressions with no message ID." ); } // If impressions is falsy or empty, delete the row. if (!impressions?.length) { await conn.executeBeforeShutdown( "ASRouter: setSharedMessageImpressions", async () => { await conn.executeCached( `DELETE FROM MessagingSystemMessageImpressions WHERE messageId = :messageId;`, { messageId, } ); } ); } else { await conn.executeBeforeShutdown( "ASRouter: setSharedMessageImpressions", async () => { await conn.executeCached( `INSERT INTO MessagingSystemMessageImpressions (messageId, impressions) VALUES ( :messageId, jsonb(:impressions) ) ON CONFLICT (messageId) DO UPDATE SET impressions = excluded.impressions;`, { messageId, impressions: JSON.stringify(impressions), } ); } ); } lazy.ProfilesDatastoreService.notify(); } catch (e) { lazy.ASRouterPreferences.console.error( `ASRouterStorage: Failed writing to MessagingSystemMessageImpressions`, e ); if (this.telemetry) { this.telemetry.handleUndesiredEvent({ event: "SHARED_DB_WRITE_FAILED", }); } success = false; } return success; } /** * Adds a message ID to the blocklist and removes impressions * for that message ID from the impressions table when isBlocked is true * and deletes message ID from the blocklist when isBlocked is false * * @param {string} messageId - The message ID to set the blocked status for * @param {boolean} [isBlocked=true] - If the message should be blocked (true) or unblocked (false) * @returns {boolean} Success status */ async setSharedMessageBlocked(messageId, isBlocked = true) { let success = true; if (isBlocked) { // Block the message, and clear impressions try { const conn = await lazy.ProfilesDatastoreService.getConnection(); if (!conn) { return false; } await conn.executeTransaction(async () => { await conn.executeCached( `INSERT INTO MessagingSystemMessageBlocklist (messageId) VALUES (:messageId);`, { messageId, } ); await conn.executeCached( `DELETE FROM MessagingSystemMessageImpressions WHERE messageId = :messageId;`, { messageId, } ); }); } catch (e) { lazy.ASRouterPreferences.console.error( `ASRouterStorage: Failed writing to MessagingSystemMessageBlocklist`, e ); if (this.telemetry) { this.telemetry.handleUndesiredEvent({ event: "SHARED_DB_WRITE_FAILED", }); } success = false; } } else { // Unblock the message try { const conn = await lazy.ProfilesDatastoreService.getConnection(); if (!conn) { return false; } await conn.executeBeforeShutdown( "ASRouter: setSharedMessageBlocked", async () => { await conn.executeCached( `DELETE FROM MessagingSystemMessageBlocklist WHERE messageId = :messageId;`, { messageId, } ); } ); } catch (e) { lazy.ASRouterPreferences.console.error( `ASRouterStorage: Failed writing to MessagingSystemMessageBlocklist`, e ); if (this.telemetry) { this.telemetry.handleUndesiredEvent({ event: "SHARED_DB_WRITE_FAILED", }); } success = false; } } lazy.ProfilesDatastoreService.notify(); return success; } async resetSharedMessageStorage() { let success = true; try { const conn = await lazy.ProfilesDatastoreService.getConnection(); if (!conn) { return false; } await conn.executeBeforeShutdown( "ASRouter: resetSharedMessageBlocklist", async () => { await conn.executeCached( `DELETE FROM MessagingSystemMessageBlocklist;` ); await conn.executeCached( `DELETE FROM MessagingSystemMessageImpressions;` ); } ); } catch (e) { lazy.ASRouterPreferences.console.error( `ASRouterStorage: Failed resetting MessagingSystemMessageBlocklist and MessagingSystemMessageImpressions`, e ); if (this.telemetry) { this.telemetry.handleUndesiredEvent({ event: "SHARED_DB_WRITE_FAILED", }); } success = false; } lazy.ProfilesDatastoreService.notify(); return success; } } export function getDefaultOptions(options) { return { collapsed: !!options.collapsed }; }