Bug 1852754: part 9) Add tests for dynamically loading <link rel="prefetch"> elements...
[gecko.git] / services / settings / Attachments.sys.mjs
blob5ddc6bb04699be2fc3fedf78de3e663522640184
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/. */
5 const lazy = {};
7 ChromeUtils.defineESModuleGetters(lazy, {
8   RemoteSettingsWorker:
9     "resource://services-settings/RemoteSettingsWorker.sys.mjs",
10   Utils: "resource://services-settings/Utils.sys.mjs",
11 });
13 class DownloadError extends Error {
14   constructor(url, resp) {
15     super(`Could not download ${url}`);
16     this.name = "DownloadError";
17     this.resp = resp;
18   }
21 class BadContentError extends Error {
22   constructor(path) {
23     super(`${path} content does not match server hash`);
24     this.name = "BadContentError";
25   }
28 class ServerInfoError extends Error {
29   constructor(error) {
30     super(`Server response is invalid ${error}`);
31     this.name = "ServerInfoError";
32     this.original = error;
33   }
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;
41   }
43   async _ensureRecordAndLazyBuffer() {
44     if (!this.recordAndLazyBufferPromise) {
45       this.recordAndLazyBufferPromise = this.getRecordAndLazyBuffer();
46     }
47     return this.recordAndLazyBufferPromise;
48   }
50   /**
51    * @returns {object} The attachment record, if found. null otherwise.
52    **/
53   async getRecord() {
54     try {
55       return (await this._ensureRecordAndLazyBuffer()).record;
56     } catch (e) {
57       return null;
58     }
59   }
61   /**
62    * @param {object} requestedRecord An attachment record
63    * @returns {boolean} Whether the requested record matches this record.
64    **/
65   async isMatchingRequestedRecord(requestedRecord) {
66     const record = await this.getRecord();
67     return (
68       record &&
69       record.last_modified === requestedRecord.last_modified &&
70       record.attachment.size === requestedRecord.attachment.size &&
71       record.attachment.hash === requestedRecord.attachment.hash
72     );
73   }
75   /**
76    * Generate the return value for the "download" method.
77    *
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.
82    **/
83   async getResult() {
84     const { record, readBuffer } = await this._ensureRecordAndLazyBuffer();
85     if (!this.bufferPromise) {
86       this.bufferPromise = readBuffer();
87     }
88     return { record, buffer: await this.bufferPromise };
89   }
92 export class Downloader {
93   static get DownloadError() {
94     return DownloadError;
95   }
96   static get BadContentError() {
97     return BadContentError;
98   }
99   static get ServerInfoError() {
100     return ServerInfoError;
101   }
103   constructor(...folders) {
104     this.folders = ["settings", ...folders];
105     this._cdnURLs = {};
106   }
108   /**
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).
112    */
113   get cacheImpl() {
114     throw new Error("This Downloader does not support caching");
115   }
117   /**
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.
121    *
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.
132    *                                          (default: `false`)
133    * @param {Boolean} options.fallbackToDump Use the remote settings dump as a
134    *                                         potential source of the attachment.
135    *                                         (default: `false`)
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.
144    */
145   async download(record, options) {
146     let {
147       retries,
148       checkHash,
149       attachmentId = record?.id,
150       fallbackToCache = false,
151       fallbackToDump = false,
152     } = options || {};
153     if (!attachmentId) {
154       // Check for pre-condition. This should not happen, but it is explicitly
155       // checked to avoid mixing up attachments, which could be dangerous.
156       throw new Error(
157         "download() was called without attachmentId or `record.id`"
158       );
159     }
161     const dumpInfo = new LazyRecordAndBuffer(() =>
162       this._readAttachmentDump(attachmentId)
163     );
164     const cacheInfo = new LazyRecordAndBuffer(() =>
165       this._readAttachmentCache(attachmentId)
166     );
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)) {
173         try {
174           return { ...(await dumpInfo.getResult()), _source: "dump_match" };
175         } catch (e) {
176           // Failed to read dump: record found but attachment file is missing.
177           console.error(e);
178         }
179       }
180     }
182     // Check if the requested attachment has already been cached.
183     if (record) {
184       if (await cacheInfo.isMatchingRequestedRecord(record)) {
185         try {
186           return { ...(await cacheInfo.getResult()), _source: "cache_match" };
187         } catch (e) {
188           // Failed to read cache, e.g. IndexedDB unusable.
189           console.error(e);
190         }
191       }
192     }
194     let errorIfAllFails;
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) {
199       try {
200         const newBuffer = await this.downloadAsBytes(record, {
201           retries,
202           checkHash,
203         });
204         const blob = new Blob([newBuffer]);
205         // Store in cache but don't wait for it before returning.
206         this.cacheImpl
207           .set(attachmentId, { record, blob })
208           .catch(e => console.error(e));
209         return { buffer: newBuffer, record, _source: "remote_match" };
210       } catch (e) {
211         // No network, corrupted content, etc.
212         errorIfAllFails = e;
213       }
214     }
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());
222     if (cacheRecord) {
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.
227         try {
228           return { ...(await dumpInfo.getResult()), _source: "dump_fallback" };
229         } catch (e) {
230           // Failed to read dump: record found but attachment file is missing.
231           console.error(e);
232         }
233       }
235       try {
236         return { ...(await cacheInfo.getResult()), _source: "cache_fallback" };
237       } catch (e) {
238         // Failed to read from cache, e.g. IndexedDB unusable.
239         console.error(e);
240       }
241     }
243     // Unable to find a valid attachment, fall back to the packaged dump.
244     if (fallbackToDump && (await dumpInfo.getRecord())) {
245       try {
246         return { ...(await dumpInfo.getResult()), _source: "dump_fallback" };
247       } catch (e) {
248         errorIfAllFails = e;
249       }
250     }
252     if (errorIfAllFails) {
253       throw errorIfAllFails;
254     }
256     throw new Downloader.DownloadError(attachmentId);
257   }
259   /**
260    * Is the record downloaded? This does not check if it was bundled.
261    *
262    * @param record A Remote Settings entry with attachment.
263    * @returns {Promise<boolean>}
264    */
265   isDownloaded(record) {
266     const cacheInfo = new LazyRecordAndBuffer(() =>
267       this._readAttachmentCache(record.id)
268     );
269     return cacheInfo.isMatchingRequestedRecord(record);
270   }
272   /**
273    * Delete the record attachment downloaded locally.
274    * No-op if the attachment does not exist.
275    *
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`)
281    */
282   async deleteDownloaded(record, options) {
283     let { attachmentId = record?.id } = options || {};
284     if (!attachmentId) {
285       // Check for pre-condition. This should not happen, but it is explicitly
286       // checked to avoid mixing up attachments, which could be dangerous.
287       throw new Error(
288         "deleteDownloaded() was called without attachmentId or `record.id`"
289       );
290     }
291     return this.cacheImpl.delete(attachmentId);
292   }
294   /**
295    * Clear the cache from obsolete downloaded attachments.
296    *
297    * @param {Array<String>} excludeIds List of attachments IDs to exclude from pruning.
298    */
299   async prune(excludeIds) {
300     return this.cacheImpl.prune(excludeIds);
301   }
303   /**
304    * @deprecated See https://bugzilla.mozilla.org/show_bug.cgi?id=1634127
305    *
306    * Download the record attachment into the local profile directory
307    * and return a file:// URL that points to the local path.
308    *
309    * No-op if the file was already downloaded and not corrupted.
310    *
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.
319    */
320   async downloadToDisk(record, options = {}) {
321     const { retries = 3 } = options;
322     const {
323       attachment: { filename, size, hash },
324     } = record;
325     const localFilePath = PathUtils.join(
326       PathUtils.localProfileDir,
327       ...this.folders,
328       filename
329     );
330     const localFileUrl = PathUtils.toFileURI(localFilePath);
332     await this._makeDirs();
334     let retried = 0;
335     while (true) {
336       if (
337         await lazy.RemoteSettingsWorker.checkFileHash(localFileUrl, size, hash)
338       ) {
339         return localFileUrl;
340       }
341       // File does not exist or is corrupted.
342       if (retried > retries) {
343         throw new Downloader.BadContentError(localFilePath);
344       }
345       try {
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.
350         });
351         await IOUtils.write(localFilePath, new Uint8Array(buffer), {
352           tmpPath: `${localFilePath}.tmp`,
353         });
354       } catch (e) {
355         if (retried >= retries) {
356           throw e;
357         }
358       }
359       retried++;
360     }
361   }
363   /**
364    * Download the record attachment and return its content as bytes.
365    *
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.
373    */
374   async downloadAsBytes(record, options = {}) {
375     const {
376       attachment: { location, hash, size },
377     } = record;
379     const remoteFileUrl = (await this._baseAttachmentsURL()) + location;
381     const { retries = 3, checkHash = true } = options;
382     let retried = 0;
383     while (true) {
384       try {
385         const buffer = await this._fetchAttachment(remoteFileUrl);
386         if (!checkHash) {
387           return buffer;
388         }
389         if (
390           await lazy.RemoteSettingsWorker.checkContentHash(buffer, size, hash)
391         ) {
392           return buffer;
393         }
394         // Content is corrupted.
395         throw new Downloader.BadContentError(location);
396       } catch (e) {
397         if (retried >= retries) {
398           throw e;
399         }
400       }
401       retried++;
402     }
403   }
405   /**
406    * @deprecated See https://bugzilla.mozilla.org/show_bug.cgi?id=1634127
407    *
408    * Delete the record attachment downloaded locally.
409    * This is the counterpart of `downloadToDisk()`.
410    * Use `deleteDownloaded()` if `download()` was used to retrieve
411    * the attachment.
412    *
413    * No-op if the related file does not exist.
414    *
415    * @param record A Remote Settings entry with attachment.
416    */
417   async deleteFromDisk(record) {
418     const {
419       attachment: { filename },
420     } = record;
421     const path = PathUtils.join(
422       PathUtils.localProfileDir,
423       ...this.folders,
424       filename
425     );
426     await IOUtils.remove(path);
427     await this._rmDirs();
428   }
430   async _baseAttachmentsURL() {
431     if (!this._cdnURLs[lazy.Utils.SERVER_URL]) {
432       const resp = await lazy.Utils.fetch(`${lazy.Utils.SERVER_URL}/`);
433       let serverInfo;
434       try {
435         serverInfo = await resp.json();
436       } catch (error) {
437         throw new Downloader.ServerInfoError(error);
438       }
439       // Server capabilities expose attachments configuration.
440       const {
441         capabilities: {
442           attachments: { base_url },
443         },
444       } = serverInfo;
445       // Make sure the URL always has a trailing slash.
446       this._cdnURLs[lazy.Utils.SERVER_URL] =
447         base_url + (base_url.endsWith("/") ? "" : "/");
448     }
449     return this._cdnURLs[lazy.Utils.SERVER_URL];
450   }
452   async _fetchAttachment(url) {
453     const headers = new Headers();
454     headers.set("Accept-Encoding", "gzip");
455     const resp = await lazy.Utils.fetch(url, { headers });
456     if (!resp.ok) {
457       throw new Downloader.DownloadError(url, resp);
458     }
459     return resp.arrayBuffer();
460   }
462   async _readAttachmentCache(attachmentId) {
463     const cached = await this.cacheImpl.get(attachmentId);
464     if (!cached) {
465       throw new Downloader.DownloadError(attachmentId);
466     }
467     return {
468       record: cached.record,
469       async readBuffer() {
470         const buffer = await cached.blob.arrayBuffer();
471         const { size, hash } = cached.record.attachment;
472         if (
473           await lazy.RemoteSettingsWorker.checkContentHash(buffer, size, hash)
474         ) {
475           return buffer;
476         }
477         // Really unexpected, could indicate corruption in IndexedDB.
478         throw new Downloader.BadContentError(attachmentId);
479       },
480     };
481   }
483   async _readAttachmentDump(attachmentId) {
484     async function fetchResource(resourceUrl) {
485       try {
486         return await fetch(resourceUrl);
487       } catch (e) {
488         throw new Downloader.DownloadError(resourceUrl);
489       }
490     }
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();
496     return {
497       record,
498       async readBuffer() {
499         return (await fetchResource(attachmentUrl)).arrayBuffer();
500       },
501     };
502   }
504   // Separate variable to allow tests to override this.
505   static _RESOURCE_BASE_URL = "resource://app/defaults";
507   async _makeDirs() {
508     const dirPath = PathUtils.join(PathUtils.localProfileDir, ...this.folders);
509     await IOUtils.makeDirectory(dirPath, { createAncestors: true });
510   }
512   async _rmDirs() {
513     for (let i = this.folders.length; i > 0; i--) {
514       const dirPath = PathUtils.join(
515         PathUtils.localProfileDir,
516         ...this.folders.slice(0, i)
517       );
518       try {
519         await IOUtils.remove(dirPath);
520       } catch (e) {
521         // This could fail if there's something in
522         // the folder we're not permitted to remove.
523         break;
524       }
525     }
526   }