1/* Copyright 2012 Mozilla Foundation
2 *
3 * Licensed under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License.
5 * You may obtain a copy of the License at
6 *
7 *     http://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software
10 * distributed under the License is distributed on an "AS IS" BASIS,
11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 * See the License for the specific language governing permissions and
13 * limitations under the License.
14 */
15
16"use strict";
17
18var EXPORTED_SYMBOLS = ["PdfStreamConverter"];
19
20const PDFJS_EVENT_ID = "pdf.js.message";
21const PREF_PREFIX = "pdfjs";
22const PDF_VIEWER_ORIGIN = "resource://pdf.js";
23const PDF_VIEWER_WEB_PAGE = "resource://pdf.js/web/viewer.html";
24const MAX_NUMBER_OF_PREFS = 50;
25const MAX_STRING_PREF_LENGTH = 128;
26const PDF_CONTENT_TYPE = "application/pdf";
27
28const { XPCOMUtils } = ChromeUtils.import(
29  "resource://gre/modules/XPCOMUtils.jsm"
30);
31const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
32const { AppConstants } = ChromeUtils.import(
33  "resource://gre/modules/AppConstants.jsm"
34);
35
36ChromeUtils.defineModuleGetter(
37  this,
38  "AsyncPrefs",
39  "resource://gre/modules/AsyncPrefs.jsm"
40);
41ChromeUtils.defineModuleGetter(
42  this,
43  "NetUtil",
44  "resource://gre/modules/NetUtil.jsm"
45);
46
47ChromeUtils.defineModuleGetter(
48  this,
49  "NetworkManager",
50  "resource://pdf.js/PdfJsNetwork.jsm"
51);
52
53ChromeUtils.defineModuleGetter(
54  this,
55  "PrivateBrowsingUtils",
56  "resource://gre/modules/PrivateBrowsingUtils.jsm"
57);
58
59ChromeUtils.defineModuleGetter(
60  this,
61  "PdfJsTelemetry",
62  "resource://pdf.js/PdfJsTelemetry.jsm"
63);
64
65ChromeUtils.defineModuleGetter(this, "PdfJs", "resource://pdf.js/PdfJs.jsm");
66
67ChromeUtils.defineModuleGetter(
68  this,
69  "PdfSandbox",
70  "resource://pdf.js/PdfSandbox.jsm"
71);
72
73XPCOMUtils.defineLazyGlobalGetters(this, ["XMLHttpRequest"]);
74
75var Svc = {};
76XPCOMUtils.defineLazyServiceGetter(
77  Svc,
78  "mime",
79  "@mozilla.org/mime;1",
80  "nsIMIMEService"
81);
82XPCOMUtils.defineLazyServiceGetter(
83  Svc,
84  "handlers",
85  "@mozilla.org/uriloader/handler-service;1",
86  "nsIHandlerService"
87);
88
89XPCOMUtils.defineLazyGetter(this, "gOurBinary", () => {
90  let file = Services.dirsvc.get("XREExeF", Ci.nsIFile);
91  // Make sure to get the .app on macOS
92  if (AppConstants.platform == "macosx") {
93    while (file) {
94      if (/\.app\/?$/i.test(file.leafName)) {
95        break;
96      }
97      file = file.parent;
98    }
99  }
100  return file;
101});
102
103function getBoolPref(pref, def) {
104  try {
105    return Services.prefs.getBoolPref(pref);
106  } catch (ex) {
107    return def;
108  }
109}
110
111function getIntPref(pref, def) {
112  try {
113    return Services.prefs.getIntPref(pref);
114  } catch (ex) {
115    return def;
116  }
117}
118
119function getStringPref(pref, def) {
120  try {
121    return Services.prefs.getStringPref(pref);
122  } catch (ex) {
123    return def;
124  }
125}
126
127function log(aMsg) {
128  if (!getBoolPref(PREF_PREFIX + ".pdfBugEnabled", false)) {
129    return;
130  }
131  var msg = "PdfStreamConverter.js: " + (aMsg.join ? aMsg.join("") : aMsg);
132  Services.console.logStringMessage(msg);
133  dump(msg + "\n");
134}
135
136function getDOMWindow(aChannel, aPrincipal) {
137  var requestor = aChannel.notificationCallbacks
138    ? aChannel.notificationCallbacks
139    : aChannel.loadGroup.notificationCallbacks;
140  var win = requestor.getInterface(Ci.nsIDOMWindow);
141  // Ensure the window wasn't navigated to something that is not PDF.js.
142  if (!win.document.nodePrincipal.equals(aPrincipal)) {
143    return null;
144  }
145  return win;
146}
147
148function getActor(window) {
149  try {
150    return window.windowGlobalChild.getActor("Pdfjs");
151  } catch (ex) {
152    return null;
153  }
154}
155
156function getLocalizedStrings(path) {
157  var stringBundle = Services.strings.createBundle(
158    "chrome://pdf.js/locale/" + path
159  );
160
161  var map = {};
162  for (let string of stringBundle.getSimpleEnumeration()) {
163    var key = string.key,
164      property = "textContent";
165    var i = key.lastIndexOf(".");
166    if (i >= 0) {
167      property = key.substring(i + 1);
168      key = key.substring(0, i);
169    }
170    if (!(key in map)) {
171      map[key] = {};
172    }
173    map[key][property] = string.value;
174  }
175  return map;
176}
177
178function isValidMatchesCount(data) {
179  if (typeof data !== "object" || data === null) {
180    return false;
181  }
182  const { current, total } = data;
183  if (
184    typeof total !== "number" ||
185    total < 0 ||
186    typeof current !== "number" ||
187    current < 0 ||
188    current > total
189  ) {
190    return false;
191  }
192  return true;
193}
194
195// PDF data storage
196function PdfDataListener(length) {
197  this.length = length; // less than 0, if length is unknown
198  this.buffers = [];
199  this.loaded = 0;
200}
201
202PdfDataListener.prototype = {
203  append: function PdfDataListener_append(chunk) {
204    // In most of the cases we will pass data as we receive it, but at the
205    // beginning of the loading we may accumulate some data.
206    this.buffers.push(chunk);
207    this.loaded += chunk.length;
208    if (this.length >= 0 && this.length < this.loaded) {
209      this.length = -1; // reset the length, server is giving incorrect one
210    }
211    this.onprogress(this.loaded, this.length >= 0 ? this.length : void 0);
212  },
213  readData: function PdfDataListener_readData() {
214    if (this.buffers.length === 0) {
215      return null;
216    }
217    if (this.buffers.length === 1) {
218      return this.buffers.pop();
219    }
220    // There are multiple buffers that need to be combined into a single
221    // buffer.
222    let combinedLength = 0;
223    for (let buffer of this.buffers) {
224      combinedLength += buffer.length;
225    }
226    let combinedArray = new Uint8Array(combinedLength);
227    let writeOffset = 0;
228    while (this.buffers.length) {
229      let buffer = this.buffers.shift();
230      combinedArray.set(buffer, writeOffset);
231      writeOffset += buffer.length;
232    }
233    return combinedArray;
234  },
235  get isDone() {
236    return !!this.isDataReady;
237  },
238  finish: function PdfDataListener_finish() {
239    this.isDataReady = true;
240    if (this.oncompleteCallback) {
241      this.oncompleteCallback(this.readData());
242    }
243  },
244  error: function PdfDataListener_error(errorCode) {
245    this.errorCode = errorCode;
246    if (this.oncompleteCallback) {
247      this.oncompleteCallback(null, errorCode);
248    }
249  },
250  onprogress() {},
251  get oncomplete() {
252    return this.oncompleteCallback;
253  },
254  set oncomplete(value) {
255    this.oncompleteCallback = value;
256    if (this.isDataReady) {
257      value(this.readData());
258    }
259    if (this.errorCode) {
260      value(null, this.errorCode);
261    }
262  },
263};
264
265/**
266 * All the privileged actions.
267 */
268class ChromeActions {
269  constructor(domWindow, contentDispositionFilename) {
270    this.domWindow = domWindow;
271    this.contentDispositionFilename = contentDispositionFilename;
272    this.telemetryState = {
273      documentInfo: false,
274      firstPageInfo: false,
275      streamTypesUsed: {},
276      fontTypesUsed: {},
277      fallbackErrorsReported: {},
278    };
279    this.sandbox = null;
280    this.unloadListener = null;
281  }
282
283  createSandbox(data, sendResponse) {
284    function sendResp(res) {
285      if (sendResponse) {
286        sendResponse(res);
287      }
288      return res;
289    }
290
291    if (!getBoolPref(PREF_PREFIX + ".enableScripting", false)) {
292      return sendResp(false);
293    }
294
295    if (this.sandbox !== null) {
296      return sendResp(true);
297    }
298
299    try {
300      this.sandbox = new PdfSandbox(this.domWindow, data);
301    } catch (err) {
302      // If there's an error here, it means that something is really wrong
303      // on pdf.js side during sandbox initialization phase.
304      Cu.reportError(err);
305      return sendResp(false);
306    }
307
308    this.unloadListener = () => {
309      this.destroySandbox();
310    };
311    this.domWindow.addEventListener("unload", this.unloadListener);
312
313    return sendResp(true);
314  }
315
316  dispatchEventInSandbox(event) {
317    if (this.sandbox) {
318      this.sandbox.dispatchEvent(event);
319    }
320  }
321
322  destroySandbox() {
323    if (this.sandbox) {
324      this.domWindow.removeEventListener("unload", this.unloadListener);
325      this.sandbox.destroy();
326      this.sandbox = null;
327    }
328  }
329
330  isInPrivateBrowsing() {
331    return PrivateBrowsingUtils.isContentWindowPrivate(this.domWindow);
332  }
333
334  getWindowOriginAttributes() {
335    try {
336      return this.domWindow.document.nodePrincipal.originAttributes;
337    } catch (err) {
338      return {};
339    }
340  }
341
342  download(data, sendResponse) {
343    var self = this;
344    var originalUrl = data.originalUrl;
345    var blobUrl = data.blobUrl || originalUrl;
346    // The data may not be downloaded so we need just retry getting the pdf with
347    // the original url.
348    var originalUri = NetUtil.newURI(originalUrl);
349    var filename = data.filename;
350    if (
351      typeof filename !== "string" ||
352      (!/\.pdf$/i.test(filename) && !data.isAttachment)
353    ) {
354      filename = "document.pdf";
355    }
356    var blobUri = NetUtil.newURI(blobUrl);
357
358    // If the download was triggered from the ctrl/cmd+s or "Save Page As"
359    // launch the "Save As" dialog.
360    if (data.sourceEventType == "save") {
361      let actor = getActor(this.domWindow);
362      actor.sendAsyncMessage("PDFJS:Parent:saveURL", {
363        blobUrl,
364        filename,
365      });
366      return;
367    }
368
369    // The download is from the fallback bar or the download button, so trigger
370    // the open dialog to make it easier for users to save in the downloads
371    // folder or launch a different PDF viewer.
372    var extHelperAppSvc = Cc[
373      "@mozilla.org/uriloader/external-helper-app-service;1"
374    ].getService(Ci.nsIExternalHelperAppService);
375
376    var docIsPrivate = this.isInPrivateBrowsing();
377    var netChannel = NetUtil.newChannel({
378      uri: blobUri,
379      loadUsingSystemPrincipal: true,
380    });
381    if (
382      "nsIPrivateBrowsingChannel" in Ci &&
383      netChannel instanceof Ci.nsIPrivateBrowsingChannel
384    ) {
385      netChannel.setPrivate(docIsPrivate);
386    }
387    NetUtil.asyncFetch(netChannel, function(aInputStream, aResult) {
388      if (!Components.isSuccessCode(aResult)) {
389        if (sendResponse) {
390          sendResponse(true);
391        }
392        return;
393      }
394      // Create a nsIInputStreamChannel so we can set the url on the channel
395      // so the filename will be correct.
396      var channel = Cc[
397        "@mozilla.org/network/input-stream-channel;1"
398      ].createInstance(Ci.nsIInputStreamChannel);
399      channel.QueryInterface(Ci.nsIChannel);
400      try {
401        // contentDisposition/contentDispositionFilename is readonly before FF18
402        channel.contentDisposition = Ci.nsIChannel.DISPOSITION_ATTACHMENT;
403        if (self.contentDispositionFilename && !data.isAttachment) {
404          channel.contentDispositionFilename = self.contentDispositionFilename;
405        } else {
406          channel.contentDispositionFilename = filename;
407        }
408      } catch (e) {}
409      channel.setURI(originalUri);
410      channel.loadInfo = netChannel.loadInfo;
411      channel.contentStream = aInputStream;
412      if (
413        "nsIPrivateBrowsingChannel" in Ci &&
414        channel instanceof Ci.nsIPrivateBrowsingChannel
415      ) {
416        channel.setPrivate(docIsPrivate);
417      }
418
419      var listener = {
420        extListener: null,
421        onStartRequest(aRequest) {
422          var loadContext = self.domWindow.docShell.QueryInterface(
423            Ci.nsILoadContext
424          );
425          this.extListener = extHelperAppSvc.doContent(
426            data.isAttachment ? "application/octet-stream" : PDF_CONTENT_TYPE,
427            aRequest,
428            loadContext,
429            false
430          );
431          this.extListener.onStartRequest(aRequest);
432        },
433        onStopRequest(aRequest, aStatusCode) {
434          if (this.extListener) {
435            this.extListener.onStopRequest(aRequest, aStatusCode);
436          }
437          // Notify the content code we're done downloading.
438          if (sendResponse) {
439            sendResponse(false);
440          }
441        },
442        onDataAvailable(aRequest, aDataInputStream, aOffset, aCount) {
443          this.extListener.onDataAvailable(
444            aRequest,
445            aDataInputStream,
446            aOffset,
447            aCount
448          );
449        },
450      };
451
452      channel.asyncOpen(listener);
453    });
454  }
455
456  getLocale() {
457    return Services.locale.requestedLocale || "en-US";
458  }
459
460  getStrings(data) {
461    try {
462      // Lazy initialization of localizedStrings
463      if (!("localizedStrings" in this)) {
464        this.localizedStrings = getLocalizedStrings("viewer.properties");
465      }
466      var result = this.localizedStrings[data];
467      return JSON.stringify(result || null);
468    } catch (e) {
469      log("Unable to retrieve localized strings: " + e);
470      return "null";
471    }
472  }
473
474  supportsIntegratedFind() {
475    // Integrated find is only supported when we're not in a frame
476    return this.domWindow.windowGlobalChild.browsingContext.parent === null;
477  }
478
479  supportsDocumentFonts() {
480    var prefBrowser = getIntPref("browser.display.use_document_fonts", 1);
481    var prefGfx = getBoolPref("gfx.downloadable_fonts.enabled", true);
482    return !!prefBrowser && prefGfx;
483  }
484
485  supportedMouseWheelZoomModifierKeys() {
486    return {
487      ctrlKey: getIntPref("mousewheel.with_control.action", 3) === 3,
488      metaKey: getIntPref("mousewheel.with_meta.action", 1) === 3,
489    };
490  }
491
492  isInAutomation() {
493    return Cu.isInAutomation;
494  }
495
496  reportTelemetry(data) {
497    var probeInfo = JSON.parse(data);
498    switch (probeInfo.type) {
499      case "documentInfo":
500        if (!this.telemetryState.documentInfo) {
501          PdfJsTelemetry.onDocumentVersion(probeInfo.version);
502          PdfJsTelemetry.onDocumentGenerator(probeInfo.generator);
503          if (probeInfo.formType) {
504            PdfJsTelemetry.onForm(probeInfo.formType);
505          }
506          this.telemetryState.documentInfo = true;
507        }
508        break;
509      case "pageInfo":
510        if (!this.telemetryState.firstPageInfo) {
511          PdfJsTelemetry.onTimeToView(probeInfo.timestamp);
512          this.telemetryState.firstPageInfo = true;
513        }
514        break;
515      case "documentStats":
516        // documentStats can be called several times for one documents.
517        // if stream/font types are reported, trying not to submit the same
518        // enumeration value multiple times.
519        var documentStats = probeInfo.stats;
520        if (!documentStats || typeof documentStats !== "object") {
521          break;
522        }
523        var i,
524          streamTypes = documentStats.streamTypes,
525          key;
526        var STREAM_TYPE_ID_LIMIT = 20;
527        i = 0;
528        for (key in streamTypes) {
529          if (++i > STREAM_TYPE_ID_LIMIT) {
530            break;
531          }
532          if (!this.telemetryState.streamTypesUsed[key]) {
533            PdfJsTelemetry.onStreamType(key);
534            this.telemetryState.streamTypesUsed[key] = true;
535          }
536        }
537        var fontTypes = documentStats.fontTypes;
538        var FONT_TYPE_ID_LIMIT = 20;
539        i = 0;
540        for (key in fontTypes) {
541          if (++i > FONT_TYPE_ID_LIMIT) {
542            break;
543          }
544          if (!this.telemetryState.fontTypesUsed[key]) {
545            PdfJsTelemetry.onFontType(key);
546            this.telemetryState.fontTypesUsed[key] = true;
547          }
548        }
549        break;
550      case "print":
551        PdfJsTelemetry.onPrint();
552        break;
553      case "unsupportedFeature":
554        if (!this.telemetryState.fallbackErrorsReported[probeInfo.featureId]) {
555          PdfJsTelemetry.onFallbackError(probeInfo.featureId);
556          this.telemetryState.fallbackErrorsReported[
557            probeInfo.featureId
558          ] = true;
559        }
560        break;
561      case "tagged":
562        PdfJsTelemetry.onTagged(probeInfo.tagged);
563        break;
564    }
565  }
566
567  /**
568   * @param {Object} args - Object with `featureId` and `url` properties.
569   * @param {function} sendResponse - Callback function.
570   */
571  fallback(args, sendResponse) {
572    sendResponse(false);
573  }
574
575  updateFindControlState(data) {
576    if (!this.supportsIntegratedFind()) {
577      return;
578    }
579    // Verify what we're sending to the findbar.
580    var result = data.result;
581    var findPrevious = data.findPrevious;
582    var findPreviousType = typeof findPrevious;
583    if (
584      typeof result !== "number" ||
585      result < 0 ||
586      result > 3 ||
587      (findPreviousType !== "undefined" && findPreviousType !== "boolean")
588    ) {
589      return;
590    }
591    // Allow the `matchesCount` property to be optional, and ensure that
592    // it's valid before including it in the data sent to the findbar.
593    let matchesCount = null;
594    if (isValidMatchesCount(data.matchesCount)) {
595      matchesCount = data.matchesCount;
596    }
597    // Same for the `rawQuery` property.
598    let rawQuery = null;
599    if (typeof data.rawQuery === "string") {
600      rawQuery = data.rawQuery;
601    }
602
603    let actor = getActor(this.domWindow);
604    actor?.sendAsyncMessage("PDFJS:Parent:updateControlState", {
605      result,
606      findPrevious,
607      matchesCount,
608      rawQuery,
609    });
610  }
611
612  updateFindMatchesCount(data) {
613    if (!this.supportsIntegratedFind()) {
614      return;
615    }
616    // Verify what we're sending to the findbar.
617    if (!isValidMatchesCount(data)) {
618      return;
619    }
620
621    let actor = getActor(this.domWindow);
622    actor?.sendAsyncMessage("PDFJS:Parent:updateMatchesCount", data);
623  }
624
625  setPreferences(prefs, sendResponse) {
626    var defaultBranch = Services.prefs.getDefaultBranch(PREF_PREFIX + ".");
627    var numberOfPrefs = 0;
628    var prefValue, prefName;
629    for (var key in prefs) {
630      if (++numberOfPrefs > MAX_NUMBER_OF_PREFS) {
631        log(
632          "setPreferences - Exceeded the maximum number of preferences " +
633            "that is allowed to be set at once."
634        );
635        break;
636      } else if (!defaultBranch.getPrefType(key)) {
637        continue;
638      }
639      prefValue = prefs[key];
640      prefName = PREF_PREFIX + "." + key;
641      switch (typeof prefValue) {
642        case "boolean":
643          AsyncPrefs.set(prefName, prefValue);
644          break;
645        case "number":
646          AsyncPrefs.set(prefName, prefValue);
647          break;
648        case "string":
649          if (prefValue.length > MAX_STRING_PREF_LENGTH) {
650            log(
651              "setPreferences - Exceeded the maximum allowed length " +
652                "for a string preference."
653            );
654          } else {
655            AsyncPrefs.set(prefName, prefValue);
656          }
657          break;
658      }
659    }
660    if (sendResponse) {
661      sendResponse(true);
662    }
663  }
664
665  getPreferences(prefs, sendResponse) {
666    var defaultBranch = Services.prefs.getDefaultBranch(PREF_PREFIX + ".");
667    var currentPrefs = {},
668      numberOfPrefs = 0;
669    var prefValue, prefName;
670    for (var key in prefs) {
671      if (++numberOfPrefs > MAX_NUMBER_OF_PREFS) {
672        log(
673          "getPreferences - Exceeded the maximum number of preferences " +
674            "that is allowed to be fetched at once."
675        );
676        break;
677      } else if (!defaultBranch.getPrefType(key)) {
678        continue;
679      }
680      prefValue = prefs[key];
681      prefName = PREF_PREFIX + "." + key;
682      switch (typeof prefValue) {
683        case "boolean":
684          currentPrefs[key] = getBoolPref(prefName, prefValue);
685          break;
686        case "number":
687          currentPrefs[key] = getIntPref(prefName, prefValue);
688          break;
689        case "string":
690          currentPrefs[key] = getStringPref(prefName, prefValue);
691          break;
692      }
693    }
694    let result = JSON.stringify(currentPrefs);
695    if (sendResponse) {
696      sendResponse(result);
697    }
698    return result;
699  }
700}
701
702/**
703 * This is for range requests.
704 */
705class RangedChromeActions extends ChromeActions {
706  constructor(
707    domWindow,
708    contentDispositionFilename,
709    originalRequest,
710    rangeEnabled,
711    streamingEnabled,
712    dataListener
713  ) {
714    super(domWindow, contentDispositionFilename);
715    this.dataListener = dataListener;
716    this.originalRequest = originalRequest;
717    this.rangeEnabled = rangeEnabled;
718    this.streamingEnabled = streamingEnabled;
719
720    this.pdfUrl = originalRequest.URI.spec;
721    this.contentLength = originalRequest.contentLength;
722
723    // Pass all the headers from the original request through
724    var httpHeaderVisitor = {
725      headers: {},
726      visitHeader(aHeader, aValue) {
727        if (aHeader === "Range") {
728          // When loading the PDF from cache, firefox seems to set the Range
729          // request header to fetch only the unfetched portions of the file
730          // (e.g. 'Range: bytes=1024-'). However, we want to set this header
731          // manually to fetch the PDF in chunks.
732          return;
733        }
734        this.headers[aHeader] = aValue;
735      },
736    };
737    if (originalRequest.visitRequestHeaders) {
738      originalRequest.visitRequestHeaders(httpHeaderVisitor);
739    }
740
741    var self = this;
742    var xhr_onreadystatechange = function xhr_onreadystatechange() {
743      if (this.readyState === 1) {
744        // LOADING
745        var netChannel = this.channel;
746        // override this XMLHttpRequest's OriginAttributes with our cached parent window's
747        // OriginAttributes, as we are currently running under the SystemPrincipal
748        this.setOriginAttributes(self.getWindowOriginAttributes());
749        if (
750          "nsIPrivateBrowsingChannel" in Ci &&
751          netChannel instanceof Ci.nsIPrivateBrowsingChannel
752        ) {
753          var docIsPrivate = self.isInPrivateBrowsing();
754          netChannel.setPrivate(docIsPrivate);
755        }
756      }
757    };
758    var getXhr = function getXhr() {
759      var xhr = new XMLHttpRequest();
760      xhr.addEventListener("readystatechange", xhr_onreadystatechange);
761      return xhr;
762    };
763
764    this.networkManager = new NetworkManager(this.pdfUrl, {
765      httpHeaders: httpHeaderVisitor.headers,
766      getXhr,
767    });
768
769    // If we are in range request mode, this means we manually issued xhr
770    // requests, which we need to abort when we leave the page
771    domWindow.addEventListener("unload", function unload(e) {
772      domWindow.removeEventListener(e.type, unload);
773      self.abortLoading();
774    });
775  }
776
777  initPassiveLoading() {
778    let data, done;
779    if (!this.streamingEnabled) {
780      this.originalRequest.cancel(Cr.NS_BINDING_ABORTED);
781      this.originalRequest = null;
782      data = this.dataListener.readData();
783      done = this.dataListener.isDone;
784      this.dataListener = null;
785    } else {
786      data = this.dataListener.readData();
787      done = this.dataListener.isDone;
788
789      this.dataListener.onprogress = (loaded, total) => {
790        this.domWindow.postMessage(
791          {
792            pdfjsLoadAction: "progressiveRead",
793            loaded,
794            total,
795            chunk: this.dataListener.readData(),
796          },
797          PDF_VIEWER_ORIGIN
798        );
799      };
800      this.dataListener.oncomplete = () => {
801        if (!done && this.dataListener.isDone) {
802          this.domWindow.postMessage(
803            {
804              pdfjsLoadAction: "progressiveDone",
805            },
806            PDF_VIEWER_ORIGIN
807          );
808        }
809        this.dataListener = null;
810      };
811    }
812
813    this.domWindow.postMessage(
814      {
815        pdfjsLoadAction: "supportsRangedLoading",
816        rangeEnabled: this.rangeEnabled,
817        streamingEnabled: this.streamingEnabled,
818        pdfUrl: this.pdfUrl,
819        length: this.contentLength,
820        data,
821        done,
822        filename: this.contentDispositionFilename,
823      },
824      PDF_VIEWER_ORIGIN
825    );
826
827    return true;
828  }
829
830  requestDataRange(args) {
831    if (!this.rangeEnabled) {
832      return;
833    }
834
835    var begin = args.begin;
836    var end = args.end;
837    var domWindow = this.domWindow;
838    // TODO(mack): Support error handler. We're not currently not handling
839    // errors from chrome code for non-range requests, so this doesn't
840    // seem high-pri
841    this.networkManager.requestRange(begin, end, {
842      onDone: function RangedChromeActions_onDone(aArgs) {
843        domWindow.postMessage(
844          {
845            pdfjsLoadAction: "range",
846            begin: aArgs.begin,
847            chunk: aArgs.chunk,
848          },
849          PDF_VIEWER_ORIGIN
850        );
851      },
852      onProgress: function RangedChromeActions_onProgress(evt) {
853        domWindow.postMessage(
854          {
855            pdfjsLoadAction: "rangeProgress",
856            loaded: evt.loaded,
857          },
858          PDF_VIEWER_ORIGIN
859        );
860      },
861    });
862  }
863
864  abortLoading() {
865    this.networkManager.abortAllRequests();
866    if (this.originalRequest) {
867      this.originalRequest.cancel(Cr.NS_BINDING_ABORTED);
868      this.originalRequest = null;
869    }
870    this.dataListener = null;
871  }
872}
873
874/**
875 * This is for a single network stream.
876 */
877class StandardChromeActions extends ChromeActions {
878  constructor(
879    domWindow,
880    contentDispositionFilename,
881    originalRequest,
882    dataListener
883  ) {
884    super(domWindow, contentDispositionFilename);
885    this.originalRequest = originalRequest;
886    this.dataListener = dataListener;
887  }
888
889  initPassiveLoading() {
890    if (!this.dataListener) {
891      return false;
892    }
893
894    this.dataListener.onprogress = (loaded, total) => {
895      this.domWindow.postMessage(
896        {
897          pdfjsLoadAction: "progress",
898          loaded,
899          total,
900        },
901        PDF_VIEWER_ORIGIN
902      );
903    };
904
905    this.dataListener.oncomplete = (data, errorCode) => {
906      this.domWindow.postMessage(
907        {
908          pdfjsLoadAction: "complete",
909          data,
910          errorCode,
911          filename: this.contentDispositionFilename,
912        },
913        PDF_VIEWER_ORIGIN
914      );
915
916      this.dataListener = null;
917      this.originalRequest = null;
918    };
919
920    return true;
921  }
922
923  abortLoading() {
924    if (this.originalRequest) {
925      this.originalRequest.cancel(Cr.NS_BINDING_ABORTED);
926      this.originalRequest = null;
927    }
928    this.dataListener = null;
929  }
930}
931
932/**
933 * Event listener to trigger chrome privileged code.
934 */
935class RequestListener {
936  constructor(actions) {
937    this.actions = actions;
938  }
939
940  // Receive an event and synchronously or asynchronously responds.
941  receive(event) {
942    var message = event.target;
943    var doc = message.ownerDocument;
944    var action = event.detail.action;
945    var data = event.detail.data;
946    var sync = event.detail.sync;
947    var actions = this.actions;
948    if (!(action in actions)) {
949      log("Unknown action: " + action);
950      return;
951    }
952    var response;
953    if (sync) {
954      response = actions[action].call(this.actions, data);
955      event.detail.response = Cu.cloneInto(response, doc.defaultView);
956    } else {
957      if (!event.detail.responseExpected) {
958        doc.documentElement.removeChild(message);
959        response = null;
960      } else {
961        response = function sendResponse(aResponse) {
962          try {
963            var listener = doc.createEvent("CustomEvent");
964            let detail = Cu.cloneInto({ response: aResponse }, doc.defaultView);
965            listener.initCustomEvent("pdf.js.response", true, false, detail);
966            return message.dispatchEvent(listener);
967          } catch (e) {
968            // doc is no longer accessible because the requestor is already
969            // gone. unloaded content cannot receive the response anyway.
970            return false;
971          }
972        };
973      }
974      actions[action].call(this.actions, data, response);
975    }
976  }
977}
978
979function PdfStreamConverter() {}
980
981PdfStreamConverter.prototype = {
982  QueryInterface: ChromeUtils.generateQI([
983    "nsIStreamConverter",
984    "nsIStreamListener",
985    "nsIRequestObserver",
986  ]),
987
988  /*
989   * This component works as such:
990   * 1. asyncConvertData stores the listener
991   * 2. onStartRequest creates a new channel, streams the viewer
992   * 3. If range requests are supported:
993   *      3.1. Leave the request open until the viewer is ready to switch to
994   *           range requests.
995   *
996   *    If range rquests are not supported:
997   *      3.1. Read the stream as it's loaded in onDataAvailable to send
998   *           to the viewer
999   *
1000   * The convert function just returns the stream, it's just the synchronous
1001   * version of asyncConvertData.
1002   */
1003
1004  // nsIStreamConverter::convert
1005  convert(aFromStream, aFromType, aToType, aCtxt) {
1006    throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
1007  },
1008
1009  // nsIStreamConverter::asyncConvertData
1010  asyncConvertData(aFromType, aToType, aListener, aCtxt) {
1011    if (aCtxt && aCtxt instanceof Ci.nsIChannel) {
1012      aCtxt.QueryInterface(Ci.nsIChannel);
1013    }
1014    // We need to check if we're supposed to convert here, because not all
1015    // asyncConvertData consumers will call getConvertedType first:
1016    this.getConvertedType(aFromType, aCtxt);
1017
1018    // Store the listener passed to us
1019    this.listener = aListener;
1020  },
1021
1022  _usableHandler(handlerInfo) {
1023    let { preferredApplicationHandler } = handlerInfo;
1024    if (
1025      !preferredApplicationHandler ||
1026      !(preferredApplicationHandler instanceof Ci.nsILocalHandlerApp)
1027    ) {
1028      return false;
1029    }
1030    preferredApplicationHandler.QueryInterface(Ci.nsILocalHandlerApp);
1031    // We have an app, grab the executable
1032    let { executable } = preferredApplicationHandler;
1033    if (!executable) {
1034      return false;
1035    }
1036    return !executable.equals(gOurBinary);
1037  },
1038
1039  /*
1040   * Check if the user wants to use PDF.js. Returns true if PDF.js should
1041   * handle PDFs, and false if not. Will always return true on non-parent
1042   * processes.
1043   *
1044   * If the user has selected to open PDFs with a helper app, and we are that
1045   * helper app, or if the user has selected the OS default, and we are that
1046   * OS default, reset the preference back to pdf.js .
1047   *
1048   */
1049  _validateAndMaybeUpdatePDFPrefs() {
1050    let { processType, PROCESS_TYPE_DEFAULT } = Services.appinfo;
1051    // If we're not in the parent, or are the default, then just say yes.
1052    if (processType != PROCESS_TYPE_DEFAULT || PdfJs.cachedIsDefault()) {
1053      return { shouldOpen: true };
1054    }
1055
1056    // OK, PDF.js might not be the default. Find out if we've misled the user
1057    // into making Firefox an external handler or if we're the OS default and
1058    // Firefox is set to use the OS default:
1059    let mime = Svc.mime.getFromTypeAndExtension(PDF_CONTENT_TYPE, "pdf");
1060    // The above might throw errors. We're deliberately letting those bubble
1061    // back up, where they'll tell the stream converter not to use us.
1062
1063    if (!mime) {
1064      // This shouldn't happen, but we can't fix what isn't there. Assume
1065      // we're OK to handle with PDF.js
1066      return { shouldOpen: true };
1067    }
1068
1069    const { saveToDisk, useHelperApp, useSystemDefault } = Ci.nsIHandlerInfo;
1070    let { preferredAction, alwaysAskBeforeHandling } = mime;
1071    // return this info so getConvertedType can use it.
1072    let rv = { alwaysAskBeforeHandling, shouldOpen: false };
1073    // If the user has indicated they want to be asked or want to save to
1074    // disk, we shouldn't render inline immediately:
1075    if (alwaysAskBeforeHandling || preferredAction == saveToDisk) {
1076      return rv;
1077    }
1078    // If we have usable helper app info, don't use PDF.js
1079    if (preferredAction == useHelperApp && this._usableHandler(mime)) {
1080      return rv;
1081    }
1082    // If we want the OS default and that's not Firefox, don't use PDF.js
1083    if (preferredAction == useSystemDefault && !mime.isCurrentAppOSDefault()) {
1084      return rv;
1085    }
1086    rv.shouldOpen = true;
1087    // Log that we're doing this to help debug issues if people end up being
1088    // surprised by this behaviour.
1089    Cu.reportError("Found unusable PDF preferences. Fixing back to PDF.js");
1090
1091    mime.preferredAction = Ci.nsIHandlerInfo.handleInternally;
1092    mime.alwaysAskBeforeHandling = false;
1093    Svc.handlers.store(mime);
1094    return true;
1095  },
1096
1097  getConvertedType(aFromType, aChannel) {
1098    const HTML = "text/html";
1099    let channelURI = aChannel?.URI;
1100    // We can be invoked for application/octet-stream; check if we want the
1101    // channel first:
1102    if (aFromType != "application/pdf") {
1103      let ext = channelURI?.QueryInterface(Ci.nsIURL).fileExtension;
1104      let isPDF = ext.toLowerCase() == "pdf";
1105      let browsingContext = aChannel?.loadInfo.targetBrowsingContext;
1106      let toplevelOctetStream =
1107        aFromType == "application/octet-stream" &&
1108        browsingContext &&
1109        !browsingContext.parent;
1110      if (
1111        !isPDF ||
1112        !toplevelOctetStream ||
1113        !getBoolPref(PREF_PREFIX + ".handleOctetStream", false)
1114      ) {
1115        throw new Components.Exception(
1116          "Ignore PDF.js for this download.",
1117          Cr.NS_ERROR_FAILURE
1118        );
1119      }
1120      // fall through, this appears to be a pdf.
1121    }
1122
1123    let {
1124      alwaysAskBeforeHandling,
1125      shouldOpen,
1126    } = this._validateAndMaybeUpdatePDFPrefs();
1127
1128    if (shouldOpen) {
1129      return HTML;
1130    }
1131    // Hm, so normally, no pdfjs. However... if this is a file: channel there
1132    // are some edge-cases.
1133    if (channelURI?.schemeIs("file")) {
1134      // If we're loaded with system principal, we were likely handed the PDF
1135      // by the OS or directly from the URL bar. Assume we should load it:
1136      let triggeringPrincipal = aChannel.loadInfo?.triggeringPrincipal;
1137      if (triggeringPrincipal?.isSystemPrincipal) {
1138        return HTML;
1139      }
1140
1141      // If we're loading from a file: link, load it in PDF.js unless the user
1142      // has told us they always want to open/save PDFs.
1143      // This is because handing off the choice to open in Firefox itself
1144      // through the dialog doesn't work properly and making it work is
1145      // non-trivial (see https://bugzilla.mozilla.org/show_bug.cgi?id=1680147#c3 )
1146      // - and anyway, opening the file is what we do for *all*
1147      // other file types we handle internally (and users can then use other UI
1148      // to save or open it with other apps from there).
1149      if (triggeringPrincipal?.schemeIs("file") && alwaysAskBeforeHandling) {
1150        return HTML;
1151      }
1152    }
1153
1154    throw new Components.Exception("Can't use PDF.js", Cr.NS_ERROR_FAILURE);
1155  },
1156
1157  // nsIStreamListener::onDataAvailable
1158  onDataAvailable(aRequest, aInputStream, aOffset, aCount) {
1159    if (!this.dataListener) {
1160      return;
1161    }
1162
1163    var binaryStream = this.binaryStream;
1164    binaryStream.setInputStream(aInputStream);
1165    let chunk = new ArrayBuffer(aCount);
1166    binaryStream.readArrayBuffer(aCount, chunk);
1167    this.dataListener.append(new Uint8Array(chunk));
1168  },
1169
1170  // nsIRequestObserver::onStartRequest
1171  onStartRequest(aRequest) {
1172    // Setup the request so we can use it below.
1173    var isHttpRequest = false;
1174    try {
1175      aRequest.QueryInterface(Ci.nsIHttpChannel);
1176      isHttpRequest = true;
1177    } catch (e) {}
1178
1179    var rangeRequest = false;
1180    var streamRequest = false;
1181    if (isHttpRequest) {
1182      var contentEncoding = "identity";
1183      try {
1184        contentEncoding = aRequest.getResponseHeader("Content-Encoding");
1185      } catch (e) {}
1186
1187      var acceptRanges;
1188      try {
1189        acceptRanges = aRequest.getResponseHeader("Accept-Ranges");
1190      } catch (e) {}
1191
1192      var hash = aRequest.URI.ref;
1193      var isPDFBugEnabled = getBoolPref(PREF_PREFIX + ".pdfBugEnabled", false);
1194      rangeRequest =
1195        contentEncoding === "identity" &&
1196        acceptRanges === "bytes" &&
1197        aRequest.contentLength >= 0 &&
1198        !getBoolPref(PREF_PREFIX + ".disableRange", false) &&
1199        (!isPDFBugEnabled || !hash.toLowerCase().includes("disablerange=true"));
1200      streamRequest =
1201        contentEncoding === "identity" &&
1202        aRequest.contentLength >= 0 &&
1203        !getBoolPref(PREF_PREFIX + ".disableStream", false) &&
1204        (!isPDFBugEnabled ||
1205          !hash.toLowerCase().includes("disablestream=true"));
1206    }
1207
1208    aRequest.QueryInterface(Ci.nsIChannel);
1209
1210    aRequest.QueryInterface(Ci.nsIWritablePropertyBag);
1211
1212    var contentDispositionFilename;
1213    try {
1214      contentDispositionFilename = aRequest.contentDispositionFilename;
1215    } catch (e) {}
1216
1217    // Change the content type so we don't get stuck in a loop.
1218    aRequest.setProperty("contentType", aRequest.contentType);
1219    aRequest.contentType = "text/html";
1220    if (isHttpRequest) {
1221      // We trust PDF viewer, using no CSP
1222      aRequest.setResponseHeader("Content-Security-Policy", "", false);
1223      aRequest.setResponseHeader(
1224        "Content-Security-Policy-Report-Only",
1225        "",
1226        false
1227      );
1228      // The viewer does not need to handle HTTP Refresh header.
1229      aRequest.setResponseHeader("Refresh", "", false);
1230    }
1231
1232    PdfJsTelemetry.onViewerIsUsed();
1233    PdfJsTelemetry.onDocumentSize(aRequest.contentLength);
1234
1235    // Creating storage for PDF data
1236    var contentLength = aRequest.contentLength;
1237    this.dataListener = new PdfDataListener(contentLength);
1238    this.binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance(
1239      Ci.nsIBinaryInputStream
1240    );
1241
1242    // Create a new channel that is viewer loaded as a resource.
1243    var channel = NetUtil.newChannel({
1244      uri: PDF_VIEWER_WEB_PAGE,
1245      loadUsingSystemPrincipal: true,
1246    });
1247
1248    var listener = this.listener;
1249    var dataListener = this.dataListener;
1250    // Proxy all the request observer calls, when it gets to onStopRequest
1251    // we can get the dom window.  We also intentionally pass on the original
1252    // request(aRequest) below so we don't overwrite the original channel and
1253    // trigger an assertion.
1254    var proxy = {
1255      onStartRequest(request) {
1256        listener.onStartRequest(aRequest);
1257      },
1258      onDataAvailable(request, inputStream, offset, count) {
1259        listener.onDataAvailable(aRequest, inputStream, offset, count);
1260      },
1261      onStopRequest(request, statusCode) {
1262        var domWindow = getDOMWindow(channel, resourcePrincipal);
1263        if (!Components.isSuccessCode(statusCode) || !domWindow) {
1264          // The request may have been aborted and the document may have been
1265          // replaced with something that is not PDF.js, abort attaching.
1266          listener.onStopRequest(aRequest, statusCode);
1267          return;
1268        }
1269        var actions;
1270        if (rangeRequest || streamRequest) {
1271          actions = new RangedChromeActions(
1272            domWindow,
1273            contentDispositionFilename,
1274            aRequest,
1275            rangeRequest,
1276            streamRequest,
1277            dataListener
1278          );
1279        } else {
1280          actions = new StandardChromeActions(
1281            domWindow,
1282            contentDispositionFilename,
1283            aRequest,
1284            dataListener
1285          );
1286        }
1287        var requestListener = new RequestListener(actions);
1288        domWindow.document.addEventListener(
1289          PDFJS_EVENT_ID,
1290          function(event) {
1291            requestListener.receive(event);
1292          },
1293          false,
1294          true
1295        );
1296
1297        let actor = getActor(domWindow);
1298        actor?.init(actions.supportsIntegratedFind());
1299
1300        listener.onStopRequest(aRequest, statusCode);
1301
1302        if (domWindow.windowGlobalChild.browsingContext.parent) {
1303          // This will need to be changed when fission supports object/embed (bug 1614524)
1304          var isObjectEmbed = domWindow.frameElement
1305            ? domWindow.frameElement.tagName == "OBJECT" ||
1306              domWindow.frameElement.tagName == "EMBED"
1307            : false;
1308          PdfJsTelemetry.onEmbed(isObjectEmbed);
1309        }
1310      },
1311    };
1312
1313    // Keep the URL the same so the browser sees it as the same.
1314    channel.originalURI = aRequest.URI;
1315    channel.loadGroup = aRequest.loadGroup;
1316    channel.loadInfo.originAttributes = aRequest.loadInfo.originAttributes;
1317
1318    // We can use the resource principal when data is fetched by the chrome,
1319    // e.g. useful for NoScript. Make make sure we reuse the origin attributes
1320    // from the request channel to keep isolation consistent.
1321    var uri = NetUtil.newURI(PDF_VIEWER_WEB_PAGE);
1322    var resourcePrincipal = Services.scriptSecurityManager.createContentPrincipal(
1323      uri,
1324      aRequest.loadInfo.originAttributes
1325    );
1326    // Remember the principal we would have had before we mess with it.
1327    let originalPrincipal = Services.scriptSecurityManager.getChannelResultPrincipal(
1328      aRequest
1329    );
1330    aRequest.owner = resourcePrincipal;
1331    aRequest.setProperty("noPDFJSPrincipal", originalPrincipal);
1332
1333    channel.asyncOpen(proxy);
1334  },
1335
1336  // nsIRequestObserver::onStopRequest
1337  onStopRequest(aRequest, aStatusCode) {
1338    if (!this.dataListener) {
1339      // Do nothing
1340      return;
1341    }
1342
1343    if (Components.isSuccessCode(aStatusCode)) {
1344      this.dataListener.finish();
1345    } else {
1346      this.dataListener.error(aStatusCode);
1347    }
1348    delete this.dataListener;
1349    delete this.binaryStream;
1350  },
1351};
1352