1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
7 ChromeUtils.defineESModuleGetters(lazy, {
9 "resource://services-settings/RemoteSettingsWorker.sys.mjs",
10 Utils: "resource://services-settings/Utils.sys.mjs",
13 class DownloadError extends Error {
14 constructor(url, resp) {
15 super(`Could not download ${url}`);
16 this.name = "DownloadError";
21 class BadContentError extends Error {
23 super(`${path} content does not match server hash`);
24 this.name = "BadContentError";
28 class ServerInfoError extends Error {
30 super(`Server response is invalid ${error}`);
31 this.name = "ServerInfoError";
32 this.original = error;
36 // Helper for the `download` method for commonly used methods, to help with
37 // lazily accessing the record and attachment content.
38 class LazyRecordAndBuffer {
39 constructor(getRecordAndLazyBuffer) {
40 this.getRecordAndLazyBuffer = getRecordAndLazyBuffer;
43 async _ensureRecordAndLazyBuffer() {
44 if (!this.recordAndLazyBufferPromise) {
45 this.recordAndLazyBufferPromise = this.getRecordAndLazyBuffer();
47 return this.recordAndLazyBufferPromise;
51 * @returns {object} The attachment record, if found. null otherwise.
55 return (await this._ensureRecordAndLazyBuffer()).record;
62 * @param {object} requestedRecord An attachment record
63 * @returns {boolean} Whether the requested record matches this record.
65 async isMatchingRequestedRecord(requestedRecord) {
66 const record = await this.getRecord();
69 record.last_modified === requestedRecord.last_modified &&
70 record.attachment.size === requestedRecord.attachment.size &&
71 record.attachment.hash === requestedRecord.attachment.hash
76 * Generate the return value for the "download" method.
78 * @throws {*} if the record or attachment content is unavailable.
79 * @returns {Object} An object with two properties:
80 * buffer: ArrayBuffer with the file content.
81 * record: Record associated with the bytes.
84 const { record, readBuffer } = await this._ensureRecordAndLazyBuffer();
85 if (!this.bufferPromise) {
86 this.bufferPromise = readBuffer();
88 return { record, buffer: await this.bufferPromise };
92 export class Downloader {
93 static get DownloadError() {
96 static get BadContentError() {
97 return BadContentError;
99 static get ServerInfoError() {
100 return ServerInfoError;
103 constructor(...folders) {
104 this.folders = ["settings", ...folders];
109 * @returns {Object} An object with async "get", "set" and "delete" methods.
110 * The keys are strings, the values may be any object that
111 * can be stored in IndexedDB (including Blob).
114 throw new Error("This Downloader does not support caching");
118 * Download attachment and return the result together with the record.
119 * If the requested record cannot be downloaded and fallbacks are enabled, the
120 * returned attachment may have a different record than the input record.
122 * @param {Object} record A Remote Settings entry with attachment.
123 * If omitted, the attachmentId option must be set.
124 * @param {Object} options Some download options.
125 * @param {Number} options.retries Number of times download should be retried (default: `3`)
126 * @param {Boolean} options.checkHash Check content integrity (default: `true`)
127 * @param {string} options.attachmentId The attachment identifier to use for
128 * caching and accessing the attachment.
129 * (default: `record.id`)
130 * @param {Boolean} options.fallbackToCache Return the cached attachment when the
131 * input record cannot be fetched.
133 * @param {Boolean} options.fallbackToDump Use the remote settings dump as a
134 * potential source of the attachment.
136 * @throws {Downloader.DownloadError} if the file could not be fetched.
137 * @throws {Downloader.BadContentError} if the downloaded content integrity is not valid.
138 * @throws {Downloader.ServerInfoError} if the server response is not valid.
139 * @throws {NetworkError} if fetching the server infos and fetching the attachment fails.
140 * @returns {Object} An object with two properties:
141 * `buffer` `ArrayBuffer`: the file content.
142 * `record` `Object`: record associated with the attachment.
143 * `_source` `String`: identifies the source of the result. Used for testing.
145 async download(record, options) {
149 attachmentId = record?.id,
150 fallbackToCache = false,
151 fallbackToDump = false,
154 // Check for pre-condition. This should not happen, but it is explicitly
155 // checked to avoid mixing up attachments, which could be dangerous.
157 "download() was called without attachmentId or `record.id`"
161 const dumpInfo = new LazyRecordAndBuffer(() =>
162 this._readAttachmentDump(attachmentId)
164 const cacheInfo = new LazyRecordAndBuffer(() =>
165 this._readAttachmentCache(attachmentId)
168 // Check if an attachment dump has been packaged with the client.
169 // The dump is checked before the cache because dumps are expected to match
170 // the requested record, at least shortly after the release of the client.
171 if (fallbackToDump && record) {
172 if (await dumpInfo.isMatchingRequestedRecord(record)) {
174 return { ...(await dumpInfo.getResult()), _source: "dump_match" };
176 // Failed to read dump: record found but attachment file is missing.
182 // Check if the requested attachment has already been cached.
184 if (await cacheInfo.isMatchingRequestedRecord(record)) {
186 return { ...(await cacheInfo.getResult()), _source: "cache_match" };
188 // Failed to read cache, e.g. IndexedDB unusable.
196 // There is no local version that matches the requested record.
197 // Try to download the attachment specified in record.
198 if (record && record.attachment) {
200 const newBuffer = await this.downloadAsBytes(record, {
204 const blob = new Blob([newBuffer]);
205 // Store in cache but don't wait for it before returning.
207 .set(attachmentId, { record, blob })
208 .catch(e => console.error(e));
209 return { buffer: newBuffer, record, _source: "remote_match" };
211 // No network, corrupted content, etc.
216 // Unable to find an attachment that matches the record. Consider falling
217 // back to local versions, even if their attachment hash do not match the
218 // one from the requested record.
220 // Unable to find a valid attachment, fall back to the cached attachment.
221 const cacheRecord = fallbackToCache && (await cacheInfo.getRecord());
223 const dumpRecord = fallbackToDump && (await dumpInfo.getRecord());
224 if (dumpRecord?.last_modified >= cacheRecord.last_modified) {
225 // The dump can be more recent than the cache when the client (and its
226 // packaged dump) is updated.
228 return { ...(await dumpInfo.getResult()), _source: "dump_fallback" };
230 // Failed to read dump: record found but attachment file is missing.
236 return { ...(await cacheInfo.getResult()), _source: "cache_fallback" };
238 // Failed to read from cache, e.g. IndexedDB unusable.
243 // Unable to find a valid attachment, fall back to the packaged dump.
244 if (fallbackToDump && (await dumpInfo.getRecord())) {
246 return { ...(await dumpInfo.getResult()), _source: "dump_fallback" };
252 if (errorIfAllFails) {
253 throw errorIfAllFails;
256 throw new Downloader.DownloadError(attachmentId);
260 * Is the record downloaded? This does not check if it was bundled.
262 * @param record A Remote Settings entry with attachment.
263 * @returns {Promise<boolean>}
265 isDownloaded(record) {
266 const cacheInfo = new LazyRecordAndBuffer(() =>
267 this._readAttachmentCache(record.id)
269 return cacheInfo.isMatchingRequestedRecord(record);
273 * Delete the record attachment downloaded locally.
274 * No-op if the attachment does not exist.
276 * @param record A Remote Settings entry with attachment.
277 * @param {Object} options Some options.
278 * @param {string} options.attachmentId The attachment identifier to use for
279 * accessing and deleting the attachment.
280 * (default: `record.id`)
282 async deleteDownloaded(record, options) {
283 let { attachmentId = record?.id } = options || {};
285 // Check for pre-condition. This should not happen, but it is explicitly
286 // checked to avoid mixing up attachments, which could be dangerous.
288 "deleteDownloaded() was called without attachmentId or `record.id`"
291 return this.cacheImpl.delete(attachmentId);
295 * Clear the cache from obsolete downloaded attachments.
297 * @param {Array<String>} excludeIds List of attachments IDs to exclude from pruning.
299 async prune(excludeIds) {
300 return this.cacheImpl.prune(excludeIds);
304 * @deprecated See https://bugzilla.mozilla.org/show_bug.cgi?id=1634127
306 * Download the record attachment into the local profile directory
307 * and return a file:// URL that points to the local path.
309 * No-op if the file was already downloaded and not corrupted.
311 * @param {Object} record A Remote Settings entry with attachment.
312 * @param {Object} options Some download options.
313 * @param {Number} options.retries Number of times download should be retried (default: `3`)
314 * @throws {Downloader.DownloadError} if the file could not be fetched.
315 * @throws {Downloader.BadContentError} if the downloaded file integrity is not valid.
316 * @throws {Downloader.ServerInfoError} if the server response is not valid.
317 * @throws {NetworkError} if fetching the attachment fails.
318 * @returns {String} the absolute file path to the downloaded attachment.
320 async downloadToDisk(record, options = {}) {
321 const { retries = 3 } = options;
323 attachment: { filename, size, hash },
325 const localFilePath = PathUtils.join(
326 PathUtils.localProfileDir,
330 const localFileUrl = PathUtils.toFileURI(localFilePath);
332 await this._makeDirs();
337 await lazy.RemoteSettingsWorker.checkFileHash(localFileUrl, size, hash)
341 // File does not exist or is corrupted.
342 if (retried > retries) {
343 throw new Downloader.BadContentError(localFilePath);
346 // Download and write on disk.
347 const buffer = await this.downloadAsBytes(record, {
348 checkHash: false, // Hash will be checked on file.
349 retries: 0, // Already in a retry loop.
351 await IOUtils.write(localFilePath, new Uint8Array(buffer), {
352 tmpPath: `${localFilePath}.tmp`,
355 if (retried >= retries) {
364 * Download the record attachment and return its content as bytes.
366 * @param {Object} record A Remote Settings entry with attachment.
367 * @param {Object} options Some download options.
368 * @param {Number} options.retries Number of times download should be retried (default: `3`)
369 * @param {Boolean} options.checkHash Check content integrity (default: `true`)
370 * @throws {Downloader.DownloadError} if the file could not be fetched.
371 * @throws {Downloader.BadContentError} if the downloaded content integrity is not valid.
372 * @returns {ArrayBuffer} the file content.
374 async downloadAsBytes(record, options = {}) {
376 attachment: { location, hash, size },
379 const remoteFileUrl = (await this._baseAttachmentsURL()) + location;
381 const { retries = 3, checkHash = true } = options;
385 const buffer = await this._fetchAttachment(remoteFileUrl);
390 await lazy.RemoteSettingsWorker.checkContentHash(buffer, size, hash)
394 // Content is corrupted.
395 throw new Downloader.BadContentError(location);
397 if (retried >= retries) {
406 * @deprecated See https://bugzilla.mozilla.org/show_bug.cgi?id=1634127
408 * Delete the record attachment downloaded locally.
409 * This is the counterpart of `downloadToDisk()`.
410 * Use `deleteDownloaded()` if `download()` was used to retrieve
413 * No-op if the related file does not exist.
415 * @param record A Remote Settings entry with attachment.
417 async deleteFromDisk(record) {
419 attachment: { filename },
421 const path = PathUtils.join(
422 PathUtils.localProfileDir,
426 await IOUtils.remove(path);
427 await this._rmDirs();
430 async _baseAttachmentsURL() {
431 if (!this._cdnURLs[lazy.Utils.SERVER_URL]) {
432 const resp = await lazy.Utils.fetch(`${lazy.Utils.SERVER_URL}/`);
435 serverInfo = await resp.json();
437 throw new Downloader.ServerInfoError(error);
439 // Server capabilities expose attachments configuration.
442 attachments: { base_url },
445 // Make sure the URL always has a trailing slash.
446 this._cdnURLs[lazy.Utils.SERVER_URL] =
447 base_url + (base_url.endsWith("/") ? "" : "/");
449 return this._cdnURLs[lazy.Utils.SERVER_URL];
452 async _fetchAttachment(url) {
453 const headers = new Headers();
454 headers.set("Accept-Encoding", "gzip");
455 const resp = await lazy.Utils.fetch(url, { headers });
457 throw new Downloader.DownloadError(url, resp);
459 return resp.arrayBuffer();
462 async _readAttachmentCache(attachmentId) {
463 const cached = await this.cacheImpl.get(attachmentId);
465 throw new Downloader.DownloadError(attachmentId);
468 record: cached.record,
470 const buffer = await cached.blob.arrayBuffer();
471 const { size, hash } = cached.record.attachment;
473 await lazy.RemoteSettingsWorker.checkContentHash(buffer, size, hash)
477 // Really unexpected, could indicate corruption in IndexedDB.
478 throw new Downloader.BadContentError(attachmentId);
483 async _readAttachmentDump(attachmentId) {
484 async function fetchResource(resourceUrl) {
486 return await fetch(resourceUrl);
488 throw new Downloader.DownloadError(resourceUrl);
491 const resourceUrlPrefix =
492 Downloader._RESOURCE_BASE_URL + "/" + this.folders.join("/") + "/";
493 const recordUrl = `${resourceUrlPrefix}${attachmentId}.meta.json`;
494 const attachmentUrl = `${resourceUrlPrefix}${attachmentId}`;
495 const record = await (await fetchResource(recordUrl)).json();
499 return (await fetchResource(attachmentUrl)).arrayBuffer();
504 // Separate variable to allow tests to override this.
505 static _RESOURCE_BASE_URL = "resource://app/defaults";
508 const dirPath = PathUtils.join(PathUtils.localProfileDir, ...this.folders);
509 await IOUtils.makeDirectory(dirPath, { createAncestors: true });
513 for (let i = this.folders.length; i > 0; i--) {
514 const dirPath = PathUtils.join(
515 PathUtils.localProfileDir,
516 ...this.folders.slice(0, i)
519 await IOUtils.remove(dirPath);
521 // This could fail if there's something in
522 // the folder we're not permitted to remove.