Attachments.sys.mjs (21673B)
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/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 RemoteSettingsWorker: 9 "resource://services-settings/RemoteSettingsWorker.sys.mjs", 10 Utils: "resource://services-settings/Utils.sys.mjs", 11 }); 12 13 ChromeUtils.defineLazyGetter(lazy, "console", () => lazy.Utils.log); 14 15 class DownloadError extends Error { 16 constructor(url, resp) { 17 super(`Could not download ${url}`); 18 this.name = "DownloadError"; 19 this.resp = resp; 20 } 21 } 22 23 class DownloadBundleError extends Error { 24 constructor(url, resp) { 25 super(`Could not download bundle ${url}`); 26 this.name = "DownloadBundleError"; 27 this.resp = resp; 28 } 29 } 30 31 class BadContentError extends Error { 32 constructor(path) { 33 super(`${path} content does not match server hash`); 34 this.name = "BadContentError"; 35 } 36 } 37 38 class ServerInfoError extends Error { 39 constructor(error) { 40 super(`Server response is invalid ${error}`); 41 this.name = "ServerInfoError"; 42 this.original = error; 43 } 44 } 45 46 class NotFoundError extends Error { 47 constructor(url, resp) { 48 super(`Could not find ${url} in cache or dump`); 49 this.name = "NotFoundError"; 50 this.resp = resp; 51 } 52 } 53 54 // Helper for the `download` method for commonly used methods, to help with 55 // lazily accessing the record and attachment content. 56 class LazyRecordAndBuffer { 57 constructor(getRecordAndLazyBuffer) { 58 this.getRecordAndLazyBuffer = getRecordAndLazyBuffer; 59 } 60 61 async _ensureRecordAndLazyBuffer() { 62 if (!this.recordAndLazyBufferPromise) { 63 this.recordAndLazyBufferPromise = this.getRecordAndLazyBuffer(); 64 } 65 return this.recordAndLazyBufferPromise; 66 } 67 68 /** 69 * @returns {object} The attachment record, if found. null otherwise. 70 */ 71 async getRecord() { 72 try { 73 return (await this._ensureRecordAndLazyBuffer()).record; 74 } catch (e) { 75 return null; 76 } 77 } 78 79 /** 80 * @param {object} requestedRecord An attachment record 81 * @returns {boolean} Whether the requested record matches this record. 82 */ 83 async isMatchingRequestedRecord(requestedRecord) { 84 const record = await this.getRecord(); 85 return ( 86 record && 87 record.last_modified === requestedRecord.last_modified && 88 record.attachment.size === requestedRecord.attachment.size && 89 record.attachment.hash === requestedRecord.attachment.hash 90 ); 91 } 92 93 /** 94 * Generate the return value for the "download" method. 95 * 96 * @throws {*} if the record or attachment content is unavailable. 97 * @returns {object} An object with two properties: 98 * buffer: ArrayBuffer with the file content. 99 * record: Record associated with the bytes. 100 */ 101 async getResult() { 102 const { record, readBuffer } = await this._ensureRecordAndLazyBuffer(); 103 if (!this.bufferPromise) { 104 this.bufferPromise = readBuffer(); 105 } 106 return { record, buffer: await this.bufferPromise }; 107 } 108 } 109 110 export class Downloader { 111 static get DownloadError() { 112 return DownloadError; 113 } 114 static get DownloadBundleError() { 115 return DownloadBundleError; 116 } 117 static get BadContentError() { 118 return BadContentError; 119 } 120 static get ServerInfoError() { 121 return ServerInfoError; 122 } 123 static get NotFoundError() { 124 return NotFoundError; 125 } 126 127 constructor(bucketName, collectionName, ...subFolders) { 128 this.folders = ["settings", bucketName, collectionName, ...subFolders]; 129 this.bucketName = bucketName; 130 this.collectionName = collectionName; 131 } 132 133 /** 134 * @returns {object} An object with async "get", "set" and "delete" methods. 135 * The keys are strings, the values may be any object that 136 * can be stored in IndexedDB (including Blob). 137 */ 138 get cacheImpl() { 139 throw new Error("This Downloader does not support caching"); 140 } 141 142 /** 143 * Download attachment and return the result together with the record. 144 * If the requested record cannot be downloaded and fallbacks are enabled, the 145 * returned attachment may have a different record than the input record. 146 * 147 * @param {object} record A Remote Settings entry with attachment. 148 * If omitted, the attachmentId option must be set. 149 * @param {object} options Some download options. 150 * @param {number} [options.retries] Number of times download should be retried (default: `3`) 151 * @param {boolean} [options.checkHash] Check content integrity (default: `true`) 152 * @param {string} [options.attachmentId] The attachment identifier to use for 153 * caching and accessing the attachment. 154 * (default: `record.id`) 155 * @param {boolean} [options.cacheResult] if the client should cache a copy of the attachment. 156 * (default: `true`) 157 * @param {boolean} [options.fallbackToCache] Return the cached attachment when the 158 * input record cannot be fetched. 159 * (default: `false`) 160 * @param {boolean} [options.fallbackToDump] Use the remote settings dump as a 161 * potential source of the attachment. 162 * (default: `false`) 163 * @throws {Downloader.DownloadError} if the file could not be fetched. 164 * @throws {Downloader.BadContentError} if the downloaded content integrity is not valid. 165 * @throws {Downloader.ServerInfoError} if the server response is not valid. 166 * @throws {NetworkError} if fetching the server infos and fetching the attachment fails. 167 * @returns {object} An object with two properties: 168 * `buffer` `ArrayBuffer`: the file content. 169 * `record` `Object`: record associated with the attachment. 170 * `_source` `String`: identifies the source of the result. Used for testing. 171 */ 172 async download(record, options) { 173 return this.#fetchAttachment(record, options); 174 } 175 176 /** 177 * Downloads an attachment bundle for a given collection, if one exists. Fills in the cache 178 * for all attachments provided by the bundle. 179 * 180 * @param {boolean} force Set to true to force a sync even when local data exists 181 * @returns {boolean} True if all attachments were processed successfully, false if failed, null if skipped. 182 */ 183 async cacheAll(force = false) { 184 // If we're offline, don't try 185 if (lazy.Utils.isOffline) { 186 return null; 187 } 188 189 // Do nothing if local cache has some data and force is not true 190 if (!force && (await this.cacheImpl.hasData())) { 191 return null; 192 } 193 194 // Save attachments in bulks. 195 const BULK_SAVE_COUNT = 50; 196 197 const url = 198 (await lazy.Utils.baseAttachmentsURL()) + 199 `bundles/${this.bucketName}--${this.collectionName}.zip`; 200 const tmpZipFilePath = PathUtils.join( 201 PathUtils.tempDir, 202 `${Services.uuid.generateUUID().toString().slice(1, -1)}.zip` 203 ); 204 let allSuccess = true; 205 206 try { 207 // 1. Download the zip archive to disk 208 const resp = await lazy.Utils.fetch(url); 209 if (!resp.ok) { 210 throw new Downloader.DownloadBundleError(url, resp); 211 } 212 213 const downloaded = await resp.arrayBuffer(); 214 await IOUtils.write(tmpZipFilePath, new Uint8Array(downloaded), { 215 tmpPath: `${tmpZipFilePath}.tmp`, 216 }); 217 218 // 2. Read the zipped content 219 const zipReader = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance( 220 Ci.nsIZipReader 221 ); 222 223 const tmpZipFile = await IOUtils.getFile(tmpZipFilePath); 224 zipReader.open(tmpZipFile); 225 226 const cacheEntries = []; 227 const zipFiles = Array.from(zipReader.findEntries("*.meta.json")); 228 allSuccess = !!zipFiles.length; 229 230 for (let i = 0; i < zipFiles.length; i++) { 231 const lastLoop = i == zipFiles.length - 1; 232 const entryName = zipFiles[i]; 233 try { 234 // 3. Read the meta.json entry 235 const recordZStream = zipReader.getInputStream(entryName); 236 const recordDataLength = recordZStream.available(); 237 const recordStream = Cc[ 238 "@mozilla.org/scriptableinputstream;1" 239 ].createInstance(Ci.nsIScriptableInputStream); 240 recordStream.init(recordZStream); 241 const recordBytes = recordStream.readBytes(recordDataLength); 242 const recordBlob = new Blob([recordBytes], { 243 type: "application/json", 244 }); 245 const record = JSON.parse(await recordBlob.text()); 246 recordZStream.close(); 247 recordStream.close(); 248 249 // 4. Read the attachment entry 250 const zStream = zipReader.getInputStream(record.id); 251 const dataLength = zStream.available(); 252 const stream = Cc[ 253 "@mozilla.org/scriptableinputstream;1" 254 ].createInstance(Ci.nsIScriptableInputStream); 255 stream.init(zStream); 256 const fileBytes = stream.readBytes(dataLength); 257 const blob = new Blob([fileBytes]); 258 259 cacheEntries.push([record.id, { record, blob }]); 260 261 stream.close(); 262 zStream.close(); 263 } catch (ex) { 264 lazy.console.warn( 265 `${this.bucketName}/${this.collectionName}: Unable to extract attachment of ${entryName}.`, 266 ex 267 ); 268 allSuccess = false; 269 } 270 271 // 5. Save bulk to cache (last loop or reached count) 272 if (lastLoop || cacheEntries.length == BULK_SAVE_COUNT) { 273 try { 274 await this.cacheImpl.setMultiple(cacheEntries); 275 } catch (ex) { 276 lazy.console.warn( 277 `${this.bucketName}/${this.collectionName}: Unable to save attachments in cache`, 278 ex 279 ); 280 allSuccess = false; 281 } 282 cacheEntries.splice(0); // start new bulk. 283 } 284 } 285 } catch (ex) { 286 lazy.console.warn( 287 `${this.bucketName}/${this.collectionName}: Unable to retrieve remote-settings attachment bundle.`, 288 ex 289 ); 290 return false; 291 } 292 293 return allSuccess; 294 } 295 296 /** 297 * Gets an attachment from the cache or local dump, avoiding requesting it 298 * from the server. 299 * If the only found attachment hash does not match the requested record, the 300 * returned attachment may have a different record, e.g. packaged in binary 301 * resources or one that is outdated. 302 * 303 * @param {object} record A Remote Settings entry with attachment. 304 * If omitted, the attachmentId option must be set. 305 * @param {object} options Some download options. 306 * @param {number} [options.retries] Number of times download should be retried (default: `3`) 307 * @param {boolean} [options.checkHash] Check content integrity (default: `true`) 308 * @param {string} [options.attachmentId] The attachment identifier to use for 309 * caching and accessing the attachment. 310 * (default: `record.id`) 311 * @throws {Downloader.DownloadError} if the file could not be fetched. 312 * @throws {Downloader.BadContentError} if the downloaded content integrity is not valid. 313 * @throws {Downloader.ServerInfoError} if the server response is not valid. 314 * @throws {NetworkError} if fetching the server infos and fetching the attachment fails. 315 * @returns {object} An object with two properties: 316 * `buffer` `ArrayBuffer`: the file content. 317 * `record` `Object`: record associated with the attachment. 318 * `_source` `String`: identifies the source of the result. Used for testing. 319 */ 320 async get( 321 record, 322 options = { 323 attachmentId: record?.id, 324 } 325 ) { 326 return this.#fetchAttachment(record, { 327 ...options, 328 avoidDownload: true, 329 fallbackToCache: true, 330 fallbackToDump: true, 331 }); 332 } 333 334 // eslint-disable-next-line complexity 335 async #fetchAttachment(record, options) { 336 let { 337 retries, 338 checkHash, 339 attachmentId = record?.id, 340 fallbackToCache = false, 341 fallbackToDump = false, 342 avoidDownload = false, 343 cacheResult = true, 344 } = options || {}; 345 if (!attachmentId) { 346 // Check for pre-condition. This should not happen, but it is explicitly 347 // checked to avoid mixing up attachments, which could be dangerous. 348 throw new Error( 349 "download() was called without attachmentId or `record.id`" 350 ); 351 } 352 353 if (!lazy.Utils.LOAD_DUMPS) { 354 if (fallbackToDump) { 355 lazy.console.warn( 356 "#fetchAttachment: Forcing fallbackToDump to false due to Utils.LOAD_DUMPS being false" 357 ); 358 } 359 fallbackToDump = false; 360 } 361 362 avoidDownload = true; 363 fallbackToCache = true; 364 fallbackToDump = true; 365 366 const dumpInfo = new LazyRecordAndBuffer(() => 367 this._readAttachmentDump(attachmentId) 368 ); 369 const cacheInfo = new LazyRecordAndBuffer(() => 370 this._readAttachmentCache(attachmentId) 371 ); 372 373 // Check if an attachment dump has been packaged with the client. 374 // The dump is checked before the cache because dumps are expected to match 375 // the requested record, at least shortly after the release of the client. 376 if (fallbackToDump && record) { 377 if (await dumpInfo.isMatchingRequestedRecord(record)) { 378 try { 379 return { ...(await dumpInfo.getResult()), _source: "dump_match" }; 380 } catch (e) { 381 // Failed to read dump: record found but attachment file is missing. 382 console.error(e); 383 } 384 } 385 } 386 387 // Check if the requested attachment has already been cached. 388 if (record) { 389 if (await cacheInfo.isMatchingRequestedRecord(record)) { 390 try { 391 return { ...(await cacheInfo.getResult()), _source: "cache_match" }; 392 } catch (e) { 393 // Failed to read cache, e.g. IndexedDB unusable. 394 console.error(e); 395 } 396 } 397 } 398 399 let errorIfAllFails; 400 401 // There is no local version that matches the requested record. 402 // Try to download the attachment specified in record. 403 if (!avoidDownload && record && record.attachment) { 404 try { 405 const newBuffer = await this.downloadAsBytes(record, { 406 retries, 407 checkHash, 408 }); 409 if (cacheResult) { 410 const blob = new Blob([newBuffer]); 411 // Store in cache but don't wait for it before returning. 412 this.cacheImpl 413 .set(attachmentId, { record, blob }) 414 .catch(e => console.error(e)); 415 } 416 return { buffer: newBuffer, record, _source: "remote_match" }; 417 } catch (e) { 418 // No network, corrupted content, etc. 419 errorIfAllFails = e; 420 } 421 } 422 423 // Unable to find an attachment that matches the record. Consider falling 424 // back to local versions, even if their attachment hash do not match the 425 // one from the requested record. 426 427 // Unable to find a valid attachment, fall back to the cached attachment. 428 const cacheRecord = fallbackToCache && (await cacheInfo.getRecord()); 429 if (cacheRecord) { 430 const dumpRecord = fallbackToDump && (await dumpInfo.getRecord()); 431 if (dumpRecord?.last_modified >= cacheRecord.last_modified) { 432 // The dump can be more recent than the cache when the client (and its 433 // packaged dump) is updated. 434 try { 435 return { ...(await dumpInfo.getResult()), _source: "dump_fallback" }; 436 } catch (e) { 437 // Failed to read dump: record found but attachment file is missing. 438 console.error(e); 439 } 440 } 441 442 try { 443 return { ...(await cacheInfo.getResult()), _source: "cache_fallback" }; 444 } catch (e) { 445 // Failed to read from cache, e.g. IndexedDB unusable. 446 console.error(e); 447 } 448 } 449 450 // Unable to find a valid attachment, fall back to the packaged dump. 451 if (fallbackToDump && (await dumpInfo.getRecord())) { 452 try { 453 return { ...(await dumpInfo.getResult()), _source: "dump_fallback" }; 454 } catch (e) { 455 errorIfAllFails = e; 456 } 457 } 458 459 if (errorIfAllFails) { 460 throw errorIfAllFails; 461 } 462 463 if (avoidDownload) { 464 throw new Downloader.NotFoundError(attachmentId); 465 } 466 throw new Downloader.DownloadError(attachmentId); 467 } 468 469 /** 470 * Is the record downloaded? This does not check if it was bundled. 471 * 472 * @param record A Remote Settings entry with attachment. 473 * @returns {Promise<boolean>} 474 */ 475 isDownloaded(record) { 476 const cacheInfo = new LazyRecordAndBuffer(() => 477 this._readAttachmentCache(record.id) 478 ); 479 return cacheInfo.isMatchingRequestedRecord(record); 480 } 481 482 /** 483 * Delete the record attachment downloaded locally. 484 * No-op if the attachment does not exist. 485 * 486 * @param record A Remote Settings entry with attachment. 487 * @param {object} [options] Some options. 488 * @param {string} [options.attachmentId] The attachment identifier to use for 489 * accessing and deleting the attachment. 490 * (default: `record.id`) 491 */ 492 async deleteDownloaded(record, options) { 493 let { attachmentId = record?.id } = options || {}; 494 if (!attachmentId) { 495 // Check for pre-condition. This should not happen, but it is explicitly 496 // checked to avoid mixing up attachments, which could be dangerous. 497 throw new Error( 498 "deleteDownloaded() was called without attachmentId or `record.id`" 499 ); 500 } 501 return this.cacheImpl.delete(attachmentId); 502 } 503 504 /** 505 * Clear the cache from obsolete downloaded attachments. 506 * 507 * @param {Array<string>} excludeIds List of attachments IDs to exclude from pruning. 508 */ 509 async prune(excludeIds) { 510 return this.cacheImpl.prune(excludeIds); 511 } 512 /** 513 * Download the record attachment and return its content as bytes. 514 * 515 * @param {object} record A Remote Settings entry with attachment. 516 * @param {object} options Some download options. 517 * @param {number} options.retries Number of times download should be retried (default: `3`) 518 * @param {boolean} options.checkHash Check content integrity (default: `true`) 519 * @throws {Downloader.DownloadError} if the file could not be fetched. 520 * @throws {Downloader.BadContentError} if the downloaded content integrity is not valid. 521 * @returns {ArrayBuffer} the file content. 522 */ 523 async downloadAsBytes(record, options = {}) { 524 const { 525 attachment: { location, hash, size }, 526 } = record; 527 528 return (await this.#fetchAttachment(record)).buffer; 529 // eslint-disable-next-line no-unreachable 530 let baseURL; 531 try { 532 baseURL = await lazy.Utils.baseAttachmentsURL(); 533 } catch (error) { 534 throw new Downloader.ServerInfoError(error); 535 } 536 537 const remoteFileUrl = baseURL + location; 538 539 const { retries = 3, checkHash = true } = options; 540 let retried = 0; 541 while (true) { 542 try { 543 const buffer = await this._fetchAttachment(remoteFileUrl); 544 if (!checkHash) { 545 return buffer; 546 } 547 if ( 548 await lazy.RemoteSettingsWorker.checkContentHash(buffer, size, hash) 549 ) { 550 return buffer; 551 } 552 // Content is corrupted. 553 throw new Downloader.BadContentError(location); 554 } catch (e) { 555 if (retried >= retries) { 556 throw e; 557 } 558 } 559 retried++; 560 } 561 } 562 563 async _fetchAttachment(url) { 564 const headers = new Headers(); 565 headers.set("Accept-Encoding", "gzip"); 566 const resp = await lazy.Utils.fetch(url, { headers }); 567 if (!resp.ok) { 568 throw new Downloader.DownloadError(url, resp); 569 } 570 return resp.arrayBuffer(); 571 } 572 573 async _readAttachmentCache(attachmentId) { 574 const cached = await this.cacheImpl.get(attachmentId); 575 if (!cached) { 576 throw new Downloader.DownloadError(attachmentId); 577 } 578 return { 579 record: cached.record, 580 async readBuffer() { 581 const buffer = await cached.blob.arrayBuffer(); 582 const { size, hash } = cached.record.attachment; 583 if ( 584 await lazy.RemoteSettingsWorker.checkContentHash(buffer, size, hash) 585 ) { 586 return buffer; 587 } 588 // Really unexpected, could indicate corruption in IndexedDB. 589 throw new Downloader.BadContentError(attachmentId); 590 }, 591 }; 592 } 593 594 async _readAttachmentDump(attachmentId) { 595 async function fetchResource(resourceUrl) { 596 try { 597 return await fetch(resourceUrl); 598 } catch (e) { 599 throw new Downloader.DownloadError(resourceUrl); 600 } 601 } 602 const resourceUrlPrefix = 603 Downloader._RESOURCE_BASE_URL + "/" + this.folders.join("/") + "/"; 604 const recordUrl = `${resourceUrlPrefix}${attachmentId}.meta.json`; 605 const attachmentUrl = `${resourceUrlPrefix}${attachmentId}`; 606 const record = await (await fetchResource(recordUrl)).json(); 607 return { 608 record, 609 async readBuffer() { 610 return (await fetchResource(attachmentUrl)).arrayBuffer(); 611 }, 612 }; 613 } 614 615 // Separate variable to allow tests to override this. 616 static _RESOURCE_BASE_URL = "resource://app/defaults"; 617 } 618 619 /** 620 * A bare downloader that does not store anything in cache. 621 */ 622 export class UnstoredDownloader extends Downloader { 623 get cacheImpl() { 624 const cacheImpl = { 625 get: async () => {}, 626 set: async () => {}, 627 setMultiple: async () => {}, 628 delete: async () => {}, 629 prune: async () => {}, 630 hasData: async () => false, 631 }; 632 Object.defineProperty(this, "cacheImpl", { value: cacheImpl }); 633 return cacheImpl; 634 } 635 }