summaryrefslogtreecommitdiffstats
path: root/browser/components/newtab/lib/RemoteImages.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/newtab/lib/RemoteImages.jsm')
-rw-r--r--browser/components/newtab/lib/RemoteImages.jsm609
1 files changed, 609 insertions, 0 deletions
diff --git a/browser/components/newtab/lib/RemoteImages.jsm b/browser/components/newtab/lib/RemoteImages.jsm
new file mode 100644
index 0000000000..4e048bac63
--- /dev/null
+++ b/browser/components/newtab/lib/RemoteImages.jsm
@@ -0,0 +1,609 @@
+/* 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/. */
+"use strict";
+
+const { JSONFile } = ChromeUtils.importESModule(
+ "resource://gre/modules/JSONFile.sys.mjs"
+);
+const { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+const { RemoteSettings } = ChromeUtils.import(
+ "resource://services-settings/remote-settings.js"
+);
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Downloader",
+ "resource://services-settings/Attachments.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "KintoHttpClient",
+ "resource://services-common/kinto-http-client.js"
+);
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "Utils",
+ "resource://services-settings/Utils.jsm"
+);
+
+const RS_MAIN_BUCKET = "main";
+const RS_COLLECTION = "ms-images";
+const RS_DOWNLOAD_MAX_RETRIES = 2;
+
+const REMOTE_IMAGES_PATH = PathUtils.join(
+ PathUtils.localProfileDir,
+ "settings",
+ RS_MAIN_BUCKET,
+ RS_COLLECTION
+);
+const REMOTE_IMAGES_DB_PATH = PathUtils.join(REMOTE_IMAGES_PATH, "db.json");
+
+const IMAGE_EXPIRY_DURATION = 30 * 24 * 60 * 60; // 30 days in seconds.
+
+const PREFETCH_FINISHED_TOPIC = "remote-images:prefetch-finished";
+
+/**
+ * Inspectors for FxMS messages.
+ *
+ * Each member is the name of a FxMS template (spotlight, infobar, etc.) and
+ * corresponds to a function that accepts a message and returns all record IDs
+ * for remote images.
+ */
+const MessageInspectors = {
+ spotlight(message) {
+ if (
+ message.content.template === "logo-and-content" &&
+ message.content.logo?.imageId
+ ) {
+ return [message.content.logo.imageId];
+ }
+ return [];
+ },
+};
+
+class _RemoteImages {
+ #dbPromise;
+
+ #fetching;
+
+ constructor() {
+ this.#dbPromise = null;
+ this.#fetching = new Map();
+
+ RemoteSettings(RS_COLLECTION).on("sync", () => this.#onSync());
+
+ // Ensure we migrate all our images to a JSONFile database.
+ this.withDb(() => {});
+ }
+
+ /**
+ * Load the database from disk.
+ *
+ * If the database does not yet exist, attempt a migration from legacy Remote
+ * Images (i.e., image files in |REMOTE_IMAGES_PATH|).
+ *
+ * @returns {Promise<JSONFile>} A promise that resolves with the database
+ * instance.
+ */
+ async #loadDb() {
+ let db;
+
+ if (!(await IOUtils.exists(REMOTE_IMAGES_DB_PATH))) {
+ db = await this.#migrate();
+ } else {
+ db = new JSONFile({ path: REMOTE_IMAGES_DB_PATH });
+ await db.load();
+ }
+
+ return db;
+ }
+
+ /**
+ * Reset the RemoteImages database
+ *
+ * NB: This is only meant to be used by unit tests.
+ *
+ * @returns {Promise<void>} A promise that resolves when the database has been
+ * reset.
+ */
+ reset() {
+ return this.withDb(async db => {
+ // We must reset |#dbPromise| *before* awaiting because if we do not, then
+ // another function could call withDb() while we are awaiting and get a
+ // promise that will resolve to |db| instead of getting null and forcing a
+ // db reload.
+ this.#dbPromise = null;
+ await db.finalize();
+ });
+ }
+
+ /*
+ * Execute |fn| with the RemoteSettings database.
+ *
+ * This ensures that only one caller can have a handle to the database at any
+ * given time (unless it is leaked through assignment from within |fn|). This
+ * prevents re-entrancy issues with multiple calls to cleanup() and calling
+ * cleanup while loading images.
+ *
+ * @param fn The function to call with the database.
+ */
+ async withDb(fn) {
+ const dbPromise = this.#dbPromise ?? this.#loadDb();
+
+ const { resolve, promise } = PromiseUtils.defer();
+ // NB: Update |#dbPromise| before awaiting anything so that the next call to
+ // |withDb()| will see the new value of |#dbPromise|.
+ this.#dbPromise = promise;
+
+ const db = await dbPromise;
+
+ try {
+ return await fn(db);
+ } finally {
+ resolve(db);
+ }
+ }
+
+ /**
+ * Patch a reference to a remote image in a message with a blob URL.
+ *
+ * @param message The remote image reference to be patched.
+ * @param replaceWith The property name that will be used to store the blob
+ * URL on |message|.
+ *
+ * @return A promise that resolves with an unloading function for the patched
+ * URL, or rejects with an error.
+ *
+ * If the message isn't patched (because there isn't a remote image)
+ * then the promise will resolve to null.
+ */
+ async patchMessage(message, replaceWith = "imageURL") {
+ if (!!message && !!message.imageId) {
+ const { imageId } = message;
+ const urls = await this.load(imageId);
+
+ if (urls.size) {
+ const blobURL = urls.get(imageId);
+
+ delete message.imageId;
+ message[replaceWith] = blobURL;
+
+ return () => this.unload(urls);
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Load remote images.
+ *
+ * If the images have not been previously downloaded, then they will be
+ * downloaded from RemoteSettings.
+ *
+ * @param {...string} imageIds The image IDs to load.
+ *
+ * @returns {object} An object mapping image Ids to blob: URLs.
+ * If an image could not be loaded, it will not be present
+ * in the returned object.
+ *
+ * After the caller is finished with the images, they must call
+ * |RemoteImages.unload()| on the object.
+ */
+ load(...imageIds) {
+ return this.withDb(async db => {
+ // Deduplicate repeated imageIds by using a Map.
+ const urls = new Map(imageIds.map(key => [key, undefined]));
+
+ await Promise.all(
+ Array.from(urls.keys()).map(async imageId => {
+ try {
+ urls.set(imageId, await this.#loadImpl(db, imageId));
+ } catch (e) {
+ console.error(`Could not load image ID ${imageId}: ${e}`);
+ urls.delete(imageId);
+ }
+ })
+ );
+
+ return urls;
+ });
+ }
+
+ async #loadImpl(db, imageId) {
+ const recordId = this.#getRecordId(imageId);
+
+ // If we are pre-fetching an image, we can piggy-back on that request.
+ if (this.#fetching.has(imageId)) {
+ const { record, arrayBuffer } = await this.#fetching.get(imageId);
+ return new Blob([arrayBuffer], { type: record.data.attachment.mimetype });
+ }
+
+ let blob;
+ if (db.data.images[recordId]) {
+ // We have previously fetched this image, we can load it from disk.
+ try {
+ blob = await this.#readFromDisk(db, recordId);
+ } catch (e) {
+ if (
+ !(
+ e instanceof Components.Exception &&
+ e.name === "NS_ERROR_FILE_NOT_FOUND"
+ )
+ ) {
+ throw e;
+ }
+ }
+
+ // Fall back to downloading if we cannot read it from disk.
+ }
+
+ if (typeof blob === "undefined") {
+ blob = await this.#download(db, recordId);
+ }
+
+ return URL.createObjectURL(blob);
+ }
+
+ /**
+ * Unload URLs returned by RemoteImages
+ *
+ * @param {Map<string, string>} urls The result of calling |RemoteImages.load()|.
+ **/
+ unload(urls) {
+ for (const url of urls.keys()) {
+ URL.revokeObjectURL(url);
+ }
+ }
+
+ #onSync() {
+ // This is OK to run while pre-fetches are ocurring. Pre-fetches don't check
+ // if there is a new version available, so there will be no race between
+ // syncing an updated image and pre-fetching
+ return this.withDb(async db => {
+ await this.#cleanup(db);
+
+ const recordsById = await RemoteSettings(RS_COLLECTION)
+ .db.list()
+ .then(records =>
+ Object.assign({}, ...records.map(record => ({ [record.id]: record })))
+ );
+
+ await Promise.all(
+ Object.values(db.data.images)
+ .filter(
+ entry => recordsById[entry.recordId]?.attachment.hash !== entry.hash
+ )
+ .map(entry => this.#download(db, entry.recordId, { fetchOnly: true }))
+ );
+ });
+ }
+
+ forceCleanup() {
+ return this.withDb(db => this.#cleanup(db));
+ }
+
+ /**
+ * Clean up all files that haven't been touched in 30d.
+ *
+ * @returns {Promise<undefined>} A promise that resolves once cleanup has
+ * finished.
+ */
+ async #cleanup(db) {
+ // This may run while background fetches are happening. However, that
+ // doesn't matter because those images will definitely not be expired.
+ const now = Date.now();
+ await Promise.all(
+ Object.values(db.data.images)
+ .filter(entry => now - entry.lastLoaded >= IMAGE_EXPIRY_DURATION)
+ .map(entry => {
+ const path = PathUtils.join(REMOTE_IMAGES_PATH, entry.recordId);
+ delete db.data.images[entry.recordId];
+
+ return IOUtils.remove(path).catch(e => {
+ console.error(
+ `Could not remove remote image ${entry.recordId}: ${e}`
+ );
+ });
+ })
+ );
+
+ db.saveSoon();
+ }
+
+ /**
+ * Return the record ID from an image ID.
+ *
+ * Prior to Firefox 101, imageIds were of the form ${recordId}.${extension} so
+ * that we could infer the mimetype.
+ *
+ * @returns The RemoteSettings record ID.
+ */
+ #getRecordId(imageId) {
+ const idx = imageId.lastIndexOf(".");
+ if (idx === -1) {
+ return imageId;
+ }
+ return imageId.substring(0, idx);
+ }
+
+ /**
+ * Read the image from disk
+ *
+ * @param {JSONFile} db The RemoteImages database.
+ * @param {string} recordId The record ID of the image.
+ *
+ * @returns A promise that resolves to a blob, or rejects with an Error.
+ */
+ async #readFromDisk(db, recordId) {
+ const path = PathUtils.join(REMOTE_IMAGES_PATH, recordId);
+
+ try {
+ const blob = await File.createFromFileName(path, {
+ type: db.data.images[recordId].mimetype,
+ });
+ db.data.images[recordId].lastLoaded = Date.now();
+
+ return blob;
+ } catch (e) {
+ // If we cannot read the file from disk, delete the entry.
+ delete db.data.images[recordId];
+
+ throw e;
+ } finally {
+ db.saveSoon();
+ }
+ }
+
+ /**
+ * Download an image from RemoteSettings.
+ *
+ * @param {JSONFile} db The RemoteImages database.
+ * @param {string} recordId The record ID of the image.
+ * @param {object} options Options for downloading the image.
+ * @param {boolean} options.fetchOnly Whether or not to only fetch the image.
+ *
+ * @returns If |fetchOnly| is true, a promise that resolves to undefined.
+ * If |fetchOnly| is false, a promise that resolves to a Blob of the
+ * image data.
+ */
+ async #download(db, recordId, { fetchOnly = false } = {}) {
+ // It is safe to call #unsafeDownload here because we hold the db while the
+ // entire download runs.
+ const { record, arrayBuffer } = await this.#unsafeDownload(recordId);
+ const { mimetype, hash } = record.data.attachment;
+
+ if (fetchOnly) {
+ Object.assign(db.data.images[recordId], { mimetype, hash });
+ } else {
+ db.data.images[recordId] = {
+ recordId,
+ mimetype,
+ hash,
+ lastLoaded: Date.now(),
+ };
+ }
+
+ db.saveSoon();
+
+ if (fetchOnly) {
+ return undefined;
+ }
+
+ return new Blob([arrayBuffer], { type: record.data.attachment.mimetype });
+ }
+
+ /**
+ * Download an image *without* holding a handle to the database.
+ *
+ * @param {string} recordId The record ID of the image to download
+ *
+ * @returns A promise that resolves to the RemoteSettings record and the
+ * downloaded ArrayBuffer.
+ */
+ async #unsafeDownload(recordId) {
+ const client = new lazy.KintoHttpClient(lazy.Utils.SERVER_URL);
+
+ const record = await client
+ .bucket(RS_MAIN_BUCKET)
+ .collection(RS_COLLECTION)
+ .getRecord(recordId);
+
+ const downloader = new lazy.Downloader(RS_MAIN_BUCKET, RS_COLLECTION);
+ const arrayBuffer = await downloader.downloadAsBytes(record.data, {
+ retries: RS_DOWNLOAD_MAX_RETRIES,
+ });
+
+ const path = PathUtils.join(REMOTE_IMAGES_PATH, recordId);
+
+ // Cache to disk.
+ //
+ // We do not await this promise because any other attempt to interact with
+ // the file via IOUtils will have to synchronize via the IOUtils event queue
+ // anyway.
+ //
+ // This is OK to do without holding the db because cleanup will not touch
+ // this image.
+ IOUtils.write(path, new Uint8Array(arrayBuffer));
+
+ return { record, arrayBuffer };
+ }
+
+ /**
+ * Prefetch images for the given messages.
+ *
+ * This will only acquire the db handle when we need to handle internal state
+ * so that other consumers can interact with RemoteImages while pre-fetches
+ * are happening.
+ *
+ * NB: This function is not intended to be awaited so that it can run the
+ * fetches in the background.
+ *
+ * @param {object[]} messages The FxMS messages to prefetch images for.
+ */
+ async prefetchImagesFor(messages) {
+ // Collect the list of record IDs from the message, if we have an inspector
+ // for it.
+ const recordIds = messages
+ .filter(
+ message =>
+ message.template && Object.hasOwn(MessageInspectors, message.template)
+ )
+ .flatMap(message => MessageInspectors[message.template](message))
+ .map(imageId => this.#getRecordId(imageId));
+
+ // If we find some messages, grab the db lock and queue the downloads of
+ // each.
+ if (recordIds.length) {
+ const promises = await this.withDb(
+ db =>
+ new Map(
+ recordIds.reduce((entries, recordId) => {
+ const promise = this.#beginPrefetch(db, recordId);
+
+ // If we already have the image, #beginPrefetching will return
+ // null instead of a promise.
+ if (promise !== null) {
+ this.#fetching.set(recordId, promise);
+ entries.push([recordId, promise]);
+ }
+
+ return entries;
+ }, [])
+ )
+ );
+
+ // We have dropped db lock and the fetches will continue in the background.
+ // If we do not drop the lock here, nothing can interact with RemoteImages
+ // while we are pre-fetching.
+ //
+ // As each prefetch request finishes, they will individually grab the db
+ // lock (inside #finishPrefetch or #handleFailedPrefetch) to update
+ // internal state.
+ const prefetchesFinished = Array.from(promises.entries()).map(
+ ([recordId, promise]) =>
+ promise.then(
+ result => this.#finishPrefetch(result),
+ () => this.#handleFailedPrefetch(recordId)
+ )
+ );
+
+ // Wait for all prefetches to finish before we send our notification.
+ await Promise.all(prefetchesFinished);
+
+ Services.obs.notifyObservers(null, PREFETCH_FINISHED_TOPIC);
+ }
+ }
+
+ /**
+ * Ensure the image for the given record ID has a database entry.
+ * Begin pre-fetching the requested image if we do not already have it locally.
+ *
+ * @param {JSONFile} db The database.
+ * @param {string} recordId The record ID of the image.
+ *
+ * @returns If the image is already cached locally, null is returned.
+ * Otherwise, a promise that resolves to an object including the
+ * recordId, the Remote Settings record, and the ArrayBuffer of the
+ * downloaded file.
+ */
+ #beginPrefetch(db, recordId) {
+ if (!Object.hasOwn(db.data.images, recordId)) {
+ // We kick off the download while we hold the db (so we can record the
+ // promise in #fetches), but we do not ensure that the download completes
+ // while we hold it.
+ //
+ // It is safe to call #unsafeDownload here and let the promises resolve
+ // outside this function because we record the recordId and promise in
+ // #fetching so any concurrent request to load the same image will re-use
+ // that promise and not trigger a second download (and therefore IO).
+ const promise = this.#unsafeDownload(recordId);
+ this.#fetching.set(recordId, promise);
+
+ return promise;
+ }
+
+ return null;
+ }
+
+ /**
+ * Finish prefetching an image.
+ *
+ * @param {object} options
+ * @param {object} options.record The Remote Settings record.
+ */
+ #finishPrefetch({ record }) {
+ return this.withDb(db => {
+ const { id: recordId } = record.data;
+ const { mimetype, hash } = record.data.attachment;
+
+ this.#fetching.delete(recordId);
+
+ db.data.images[recordId] = {
+ recordId,
+ mimetype,
+ hash,
+ lastLoaded: Date.now(),
+ };
+
+ db.saveSoon();
+ });
+ }
+
+ /**
+ * Remove the prefetch entry for a fetch that failed.
+ */
+ #handleFailedPrefetch(recordId) {
+ return this.withDb(db => {
+ this.#fetching.delete(recordId);
+ });
+ }
+
+ /**
+ * Migrate from a file-based store to an index-based store.
+ */
+ async #migrate() {
+ let children;
+ try {
+ children = await IOUtils.getChildren(REMOTE_IMAGES_PATH);
+
+ // Delete all previously cached entries.
+ await Promise.all(
+ children.map(async path => {
+ try {
+ await IOUtils.remove(path);
+ } catch (e) {
+ console.error(`RemoteImages could not delete ${path}: ${e}`);
+ }
+ })
+ );
+ } catch (e) {
+ if (!(DOMException.isInstance(e) && e.name === "NotFoundError")) {
+ throw e;
+ }
+ }
+
+ await IOUtils.makeDirectory(REMOTE_IMAGES_PATH);
+ const db = new JSONFile({ path: REMOTE_IMAGES_DB_PATH });
+ db.data = {
+ version: 1,
+ images: {},
+ };
+ db.saveSoon();
+ return db;
+ }
+}
+
+const RemoteImages = new _RemoteImages();
+
+const EXPORTED_SYMBOLS = [
+ "RemoteImages",
+ "REMOTE_IMAGES_PATH",
+ "REMOTE_IMAGES_DB_PATH",
+];