forked from mirrors/gecko-dev
599 lines
17 KiB
JavaScript
599 lines
17 KiB
JavaScript
/* 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 = {};
|
|
|
|
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",
|
|
];
|