diff options
Diffstat (limited to 'services/settings/Attachments.sys.mjs')
-rw-r--r-- | services/settings/Attachments.sys.mjs | 527 |
1 files changed, 527 insertions, 0 deletions
diff --git a/services/settings/Attachments.sys.mjs b/services/settings/Attachments.sys.mjs new file mode 100644 index 0000000000..5ddc6bb046 --- /dev/null +++ b/services/settings/Attachments.sys.mjs @@ -0,0 +1,527 @@ +/* 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, { + RemoteSettingsWorker: + "resource://services-settings/RemoteSettingsWorker.sys.mjs", + Utils: "resource://services-settings/Utils.sys.mjs", +}); + +class DownloadError extends Error { + constructor(url, resp) { + super(`Could not download ${url}`); + this.name = "DownloadError"; + this.resp = resp; + } +} + +class BadContentError extends Error { + constructor(path) { + super(`${path} content does not match server hash`); + this.name = "BadContentError"; + } +} + +class ServerInfoError extends Error { + constructor(error) { + super(`Server response is invalid ${error}`); + this.name = "ServerInfoError"; + this.original = error; + } +} + +// Helper for the `download` method for commonly used methods, to help with +// lazily accessing the record and attachment content. +class LazyRecordAndBuffer { + constructor(getRecordAndLazyBuffer) { + this.getRecordAndLazyBuffer = getRecordAndLazyBuffer; + } + + async _ensureRecordAndLazyBuffer() { + if (!this.recordAndLazyBufferPromise) { + this.recordAndLazyBufferPromise = this.getRecordAndLazyBuffer(); + } + return this.recordAndLazyBufferPromise; + } + + /** + * @returns {object} The attachment record, if found. null otherwise. + **/ + async getRecord() { + try { + return (await this._ensureRecordAndLazyBuffer()).record; + } catch (e) { + return null; + } + } + + /** + * @param {object} requestedRecord An attachment record + * @returns {boolean} Whether the requested record matches this record. + **/ + async isMatchingRequestedRecord(requestedRecord) { + const record = await this.getRecord(); + return ( + record && + record.last_modified === requestedRecord.last_modified && + record.attachment.size === requestedRecord.attachment.size && + record.attachment.hash === requestedRecord.attachment.hash + ); + } + + /** + * Generate the return value for the "download" method. + * + * @throws {*} if the record or attachment content is unavailable. + * @returns {Object} An object with two properties: + * buffer: ArrayBuffer with the file content. + * record: Record associated with the bytes. + **/ + async getResult() { + const { record, readBuffer } = await this._ensureRecordAndLazyBuffer(); + if (!this.bufferPromise) { + this.bufferPromise = readBuffer(); + } + return { record, buffer: await this.bufferPromise }; + } +} + +export class Downloader { + static get DownloadError() { + return DownloadError; + } + static get BadContentError() { + return BadContentError; + } + static get ServerInfoError() { + return ServerInfoError; + } + + constructor(...folders) { + this.folders = ["settings", ...folders]; + this._cdnURLs = {}; + } + + /** + * @returns {Object} An object with async "get", "set" and "delete" methods. + * The keys are strings, the values may be any object that + * can be stored in IndexedDB (including Blob). + */ + get cacheImpl() { + throw new Error("This Downloader does not support caching"); + } + + /** + * Download attachment and return the result together with the record. + * If the requested record cannot be downloaded and fallbacks are enabled, the + * returned attachment may have a different record than the input record. + * + * @param {Object} record A Remote Settings entry with attachment. + * If omitted, the attachmentId option must be set. + * @param {Object} options Some download options. + * @param {Number} options.retries Number of times download should be retried (default: `3`) + * @param {Boolean} options.checkHash Check content integrity (default: `true`) + * @param {string} options.attachmentId The attachment identifier to use for + * caching and accessing the attachment. + * (default: `record.id`) + * @param {Boolean} options.fallbackToCache Return the cached attachment when the + * input record cannot be fetched. + * (default: `false`) + * @param {Boolean} options.fallbackToDump Use the remote settings dump as a + * potential source of the attachment. + * (default: `false`) + * @throws {Downloader.DownloadError} if the file could not be fetched. + * @throws {Downloader.BadContentError} if the downloaded content integrity is not valid. + * @throws {Downloader.ServerInfoError} if the server response is not valid. + * @throws {NetworkError} if fetching the server infos and fetching the attachment fails. + * @returns {Object} An object with two properties: + * `buffer` `ArrayBuffer`: the file content. + * `record` `Object`: record associated with the attachment. + * `_source` `String`: identifies the source of the result. Used for testing. + */ + async download(record, options) { + let { + retries, + checkHash, + attachmentId = record?.id, + fallbackToCache = false, + fallbackToDump = false, + } = options || {}; + if (!attachmentId) { + // Check for pre-condition. This should not happen, but it is explicitly + // checked to avoid mixing up attachments, which could be dangerous. + throw new Error( + "download() was called without attachmentId or `record.id`" + ); + } + + const dumpInfo = new LazyRecordAndBuffer(() => + this._readAttachmentDump(attachmentId) + ); + const cacheInfo = new LazyRecordAndBuffer(() => + this._readAttachmentCache(attachmentId) + ); + + // Check if an attachment dump has been packaged with the client. + // The dump is checked before the cache because dumps are expected to match + // the requested record, at least shortly after the release of the client. + if (fallbackToDump && record) { + if (await dumpInfo.isMatchingRequestedRecord(record)) { + try { + return { ...(await dumpInfo.getResult()), _source: "dump_match" }; + } catch (e) { + // Failed to read dump: record found but attachment file is missing. + console.error(e); + } + } + } + + // Check if the requested attachment has already been cached. + if (record) { + if (await cacheInfo.isMatchingRequestedRecord(record)) { + try { + return { ...(await cacheInfo.getResult()), _source: "cache_match" }; + } catch (e) { + // Failed to read cache, e.g. IndexedDB unusable. + console.error(e); + } + } + } + + let errorIfAllFails; + + // There is no local version that matches the requested record. + // Try to download the attachment specified in record. + if (record && record.attachment) { + try { + const newBuffer = await this.downloadAsBytes(record, { + retries, + checkHash, + }); + const blob = new Blob([newBuffer]); + // Store in cache but don't wait for it before returning. + this.cacheImpl + .set(attachmentId, { record, blob }) + .catch(e => console.error(e)); + return { buffer: newBuffer, record, _source: "remote_match" }; + } catch (e) { + // No network, corrupted content, etc. + errorIfAllFails = e; + } + } + + // Unable to find an attachment that matches the record. Consider falling + // back to local versions, even if their attachment hash do not match the + // one from the requested record. + + // Unable to find a valid attachment, fall back to the cached attachment. + const cacheRecord = fallbackToCache && (await cacheInfo.getRecord()); + if (cacheRecord) { + const dumpRecord = fallbackToDump && (await dumpInfo.getRecord()); + if (dumpRecord?.last_modified >= cacheRecord.last_modified) { + // The dump can be more recent than the cache when the client (and its + // packaged dump) is updated. + try { + return { ...(await dumpInfo.getResult()), _source: "dump_fallback" }; + } catch (e) { + // Failed to read dump: record found but attachment file is missing. + console.error(e); + } + } + + try { + return { ...(await cacheInfo.getResult()), _source: "cache_fallback" }; + } catch (e) { + // Failed to read from cache, e.g. IndexedDB unusable. + console.error(e); + } + } + + // Unable to find a valid attachment, fall back to the packaged dump. + if (fallbackToDump && (await dumpInfo.getRecord())) { + try { + return { ...(await dumpInfo.getResult()), _source: "dump_fallback" }; + } catch (e) { + errorIfAllFails = e; + } + } + + if (errorIfAllFails) { + throw errorIfAllFails; + } + + throw new Downloader.DownloadError(attachmentId); + } + + /** + * Is the record downloaded? This does not check if it was bundled. + * + * @param record A Remote Settings entry with attachment. + * @returns {Promise<boolean>} + */ + isDownloaded(record) { + const cacheInfo = new LazyRecordAndBuffer(() => + this._readAttachmentCache(record.id) + ); + return cacheInfo.isMatchingRequestedRecord(record); + } + + /** + * Delete the record attachment downloaded locally. + * No-op if the attachment does not exist. + * + * @param record A Remote Settings entry with attachment. + * @param {Object} options Some options. + * @param {string} options.attachmentId The attachment identifier to use for + * accessing and deleting the attachment. + * (default: `record.id`) + */ + async deleteDownloaded(record, options) { + let { attachmentId = record?.id } = options || {}; + if (!attachmentId) { + // Check for pre-condition. This should not happen, but it is explicitly + // checked to avoid mixing up attachments, which could be dangerous. + throw new Error( + "deleteDownloaded() was called without attachmentId or `record.id`" + ); + } + return this.cacheImpl.delete(attachmentId); + } + + /** + * Clear the cache from obsolete downloaded attachments. + * + * @param {Array<String>} excludeIds List of attachments IDs to exclude from pruning. + */ + async prune(excludeIds) { + return this.cacheImpl.prune(excludeIds); + } + + /** + * @deprecated See https://bugzilla.mozilla.org/show_bug.cgi?id=1634127 + * + * Download the record attachment into the local profile directory + * and return a file:// URL that points to the local path. + * + * No-op if the file was already downloaded and not corrupted. + * + * @param {Object} record A Remote Settings entry with attachment. + * @param {Object} options Some download options. + * @param {Number} options.retries Number of times download should be retried (default: `3`) + * @throws {Downloader.DownloadError} if the file could not be fetched. + * @throws {Downloader.BadContentError} if the downloaded file integrity is not valid. + * @throws {Downloader.ServerInfoError} if the server response is not valid. + * @throws {NetworkError} if fetching the attachment fails. + * @returns {String} the absolute file path to the downloaded attachment. + */ + async downloadToDisk(record, options = {}) { + const { retries = 3 } = options; + const { + attachment: { filename, size, hash }, + } = record; + const localFilePath = PathUtils.join( + PathUtils.localProfileDir, + ...this.folders, + filename + ); + const localFileUrl = PathUtils.toFileURI(localFilePath); + + await this._makeDirs(); + + let retried = 0; + while (true) { + if ( + await lazy.RemoteSettingsWorker.checkFileHash(localFileUrl, size, hash) + ) { + return localFileUrl; + } + // File does not exist or is corrupted. + if (retried > retries) { + throw new Downloader.BadContentError(localFilePath); + } + try { + // Download and write on disk. + const buffer = await this.downloadAsBytes(record, { + checkHash: false, // Hash will be checked on file. + retries: 0, // Already in a retry loop. + }); + await IOUtils.write(localFilePath, new Uint8Array(buffer), { + tmpPath: `${localFilePath}.tmp`, + }); + } catch (e) { + if (retried >= retries) { + throw e; + } + } + retried++; + } + } + + /** + * Download the record attachment and return its content as bytes. + * + * @param {Object} record A Remote Settings entry with attachment. + * @param {Object} options Some download options. + * @param {Number} options.retries Number of times download should be retried (default: `3`) + * @param {Boolean} options.checkHash Check content integrity (default: `true`) + * @throws {Downloader.DownloadError} if the file could not be fetched. + * @throws {Downloader.BadContentError} if the downloaded content integrity is not valid. + * @returns {ArrayBuffer} the file content. + */ + async downloadAsBytes(record, options = {}) { + const { + attachment: { location, hash, size }, + } = record; + + const remoteFileUrl = (await this._baseAttachmentsURL()) + location; + + const { retries = 3, checkHash = true } = options; + let retried = 0; + while (true) { + try { + const buffer = await this._fetchAttachment(remoteFileUrl); + if (!checkHash) { + return buffer; + } + if ( + await lazy.RemoteSettingsWorker.checkContentHash(buffer, size, hash) + ) { + return buffer; + } + // Content is corrupted. + throw new Downloader.BadContentError(location); + } catch (e) { + if (retried >= retries) { + throw e; + } + } + retried++; + } + } + + /** + * @deprecated See https://bugzilla.mozilla.org/show_bug.cgi?id=1634127 + * + * Delete the record attachment downloaded locally. + * This is the counterpart of `downloadToDisk()`. + * Use `deleteDownloaded()` if `download()` was used to retrieve + * the attachment. + * + * No-op if the related file does not exist. + * + * @param record A Remote Settings entry with attachment. + */ + async deleteFromDisk(record) { + const { + attachment: { filename }, + } = record; + const path = PathUtils.join( + PathUtils.localProfileDir, + ...this.folders, + filename + ); + await IOUtils.remove(path); + await this._rmDirs(); + } + + async _baseAttachmentsURL() { + if (!this._cdnURLs[lazy.Utils.SERVER_URL]) { + const resp = await lazy.Utils.fetch(`${lazy.Utils.SERVER_URL}/`); + let serverInfo; + try { + serverInfo = await resp.json(); + } catch (error) { + throw new Downloader.ServerInfoError(error); + } + // Server capabilities expose attachments configuration. + const { + capabilities: { + attachments: { base_url }, + }, + } = serverInfo; + // Make sure the URL always has a trailing slash. + this._cdnURLs[lazy.Utils.SERVER_URL] = + base_url + (base_url.endsWith("/") ? "" : "/"); + } + return this._cdnURLs[lazy.Utils.SERVER_URL]; + } + + async _fetchAttachment(url) { + const headers = new Headers(); + headers.set("Accept-Encoding", "gzip"); + const resp = await lazy.Utils.fetch(url, { headers }); + if (!resp.ok) { + throw new Downloader.DownloadError(url, resp); + } + return resp.arrayBuffer(); + } + + async _readAttachmentCache(attachmentId) { + const cached = await this.cacheImpl.get(attachmentId); + if (!cached) { + throw new Downloader.DownloadError(attachmentId); + } + return { + record: cached.record, + async readBuffer() { + const buffer = await cached.blob.arrayBuffer(); + const { size, hash } = cached.record.attachment; + if ( + await lazy.RemoteSettingsWorker.checkContentHash(buffer, size, hash) + ) { + return buffer; + } + // Really unexpected, could indicate corruption in IndexedDB. + throw new Downloader.BadContentError(attachmentId); + }, + }; + } + + async _readAttachmentDump(attachmentId) { + async function fetchResource(resourceUrl) { + try { + return await fetch(resourceUrl); + } catch (e) { + throw new Downloader.DownloadError(resourceUrl); + } + } + const resourceUrlPrefix = + Downloader._RESOURCE_BASE_URL + "/" + this.folders.join("/") + "/"; + const recordUrl = `${resourceUrlPrefix}${attachmentId}.meta.json`; + const attachmentUrl = `${resourceUrlPrefix}${attachmentId}`; + const record = await (await fetchResource(recordUrl)).json(); + return { + record, + async readBuffer() { + return (await fetchResource(attachmentUrl)).arrayBuffer(); + }, + }; + } + + // Separate variable to allow tests to override this. + static _RESOURCE_BASE_URL = "resource://app/defaults"; + + async _makeDirs() { + const dirPath = PathUtils.join(PathUtils.localProfileDir, ...this.folders); + await IOUtils.makeDirectory(dirPath, { createAncestors: true }); + } + + async _rmDirs() { + for (let i = this.folders.length; i > 0; i--) { + const dirPath = PathUtils.join( + PathUtils.localProfileDir, + ...this.folders.slice(0, i) + ); + try { + await IOUtils.remove(dirPath); + } catch (e) { + // This could fail if there's something in + // the folder we're not permitted to remove. + break; + } + } + } +} |