1/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
2/* This Source Code Form is subject to the terms of the Mozilla Public
3 * License, v. 2.0. If a copy of the MPL was not distributed with this
4 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5
6/**
7 * This is a small wrapper around XMLHttpRequest, which solves various
8 * inadequacies of the API, e.g. error handling. It is entirely generic and
9 * can be used for purposes outside of even mail.
10 *
11 * It does not provide download progress, but assumes that the
12 * fetched resource is so small (<1 10 KB) that the roundtrip and
13 * response generation is far more significant than the
14 * download time of the response. In other words, it's fine for RPC,
15 * but not for bigger file downloads.
16 */
17
18const EXPORTED_SYMBOLS = ["FetchHTTP"];
19
20const { AccountCreationUtils } = ChromeUtils.import(
21  "resource:///modules/accountcreation/AccountCreationUtils.jsm"
22);
23ChromeUtils.defineModuleGetter(
24  this,
25  "Sanitizer",
26  "resource:///modules/accountcreation/Sanitizer.jsm"
27);
28
29const { JXON } = ChromeUtils.import("resource:///modules/JXON.jsm");
30
31const {
32  Abortable,
33  alertPrompt,
34  assert,
35  ddump,
36  Exception,
37  gAccountSetupLogger,
38  getStringBundle,
39  UserCancelledException,
40} = AccountCreationUtils;
41
42/**
43 * Set up a fetch.
44 *
45 * @param {string} url - URL of the server function.
46 *    ATTENTION: The caller needs to make sure that the URL is secure to call.
47 * @param {Object} args - Additional parameters as properties, see below
48 *
49 * @param {Function({string} result)} successCallback
50 *   Called when the server call worked (no errors).
51 *   |result| will contain the body of the HTTP response, as string.
52 * @param {Function(ex)} errorCallback
53 *   Called in case of error. ex contains the error
54 *   with a user-displayable but not localized |.message| and maybe a
55 *   |.code|, which can be either
56 *  - an nsresult error code,
57 *  - an HTTP result error code (0...1000) or
58 *  - negative: 0...-100 :
59 *     -2 = can't resolve server in DNS etc.
60 *     -4 = response body (e.g. XML) malformed
61 *
62 * The following optional parameters are supported as properties of the |args| object:
63 *
64 * @param {Object, associative array} urlArgs - Parameters to add
65 *   to the end of the URL as query string. E.g.
66 *   { foo: "bla", bar: "blub blub" } will add "?foo=bla&bar=blub%20blub"
67 *   to the URL
68 *   (unless the URL already has a "?", then it adds "&foo...").
69 *   The values will be urlComponentEncoded, so pass them unencoded.
70 * @param {Object, associative array} headers - HTTP headers to be added
71 *   to the HTTP request.
72 *   { foo: "blub blub" } will add HTTP header "Foo: Blub blub".
73 *   The values will be passed verbatim.
74 * @param {boolean} post - HTTP GET or POST
75 *   Only influences the HTTP request method,
76 *   i.e. first line of the HTTP request, not the body or parameters.
77 *   Use POST when you modify server state,
78 *   GET when you only request information.
79 *   Default is GET.
80 * @param {Object, associative array} bodyFormArgs - Like urlArgs,
81 *   just that the params will be sent x-url-encoded in the body,
82 *   like a HTML form post.
83 *   The values will be urlComponentEncoded, so pass them unencoded.
84 *   This cannot be used together with |uploadBody|.
85 * @param {Object} uploadBody - Arbitrary object, which to use as
86 *   body of the HTTP request. Will also set the mimetype accordingly.
87 *   Only supported object types, currently only JXON is supported
88 *   (sending XML).
89 *   Usually, you have nothing to upload, so just pass |null|.
90 *   Only supported object types, currently supported:
91 *   JXON -> sending XML
92 *   JS object -> sending JSON
93 *   string -> sending text/plain
94 *   If you want to override the body mimetype, set header Content-Type below.
95 *   Usually, you have nothing to upload, so just leave it at |null|.
96 *   Default |null|.
97 * @param {boolean} allowCache (default true)
98 * @param {string} username (default null = no authentication)
99 * @param {string} password (default null = no authentication)
100 * @param {boolean} allowAuthPrompt (default true)
101 * @param {boolean} requireSecureAuth (default false)
102 *   Ignore the username and password unless we are using https:
103 *   This also applies to both https: to http: and http: to https: redirects.
104 */
105function FetchHTTP(url, args, successCallback, errorCallback) {
106  assert(typeof successCallback == "function", "BUG: successCallback");
107  assert(typeof errorCallback == "function", "BUG: errorCallback");
108  this._url = Sanitizer.string(url);
109  if (!args) {
110    args = {};
111  }
112  if (!args.urlArgs) {
113    args.urlArgs = {};
114  }
115  if (!args.headers) {
116    args.headers = {};
117  }
118
119  this._args = args;
120  this._args.post = Sanitizer.boolean(args.post || false); // default false
121  this._args.allowCache =
122    "allowCache" in args ? Sanitizer.boolean(args.allowCache) : true; // default true
123  this._args.allowAuthPrompt = Sanitizer.boolean(args.allowAuthPrompt || false); // default false
124  this._args.requireSecureAuth = Sanitizer.boolean(
125    args.requireSecureAuth || false
126  ); // default false
127  this._args.timeout = Sanitizer.integer(args.timeout || 5000); // default 5 seconds
128  this._successCallback = successCallback;
129  this._errorCallback = errorCallback;
130  this._logger = gAccountSetupLogger;
131  this._logger.info("Requesting <" + url + ">");
132}
133FetchHTTP.prototype = {
134  __proto__: Abortable.prototype,
135  _url: null, // URL as passed to ctor, without arguments
136  _args: null,
137  _successCallback: null,
138  _errorCallback: null,
139  _request: null, // the XMLHttpRequest object
140  result: null,
141
142  start() {
143    let url = this._url;
144    for (let name in this._args.urlArgs) {
145      url +=
146        (!url.includes("?") ? "?" : "&") +
147        name +
148        "=" +
149        encodeURIComponent(this._args.urlArgs[name]);
150    }
151    this._request = new XMLHttpRequest();
152    let request = this._request;
153    request.mozBackgroundRequest = !this._args.allowAuthPrompt;
154    let username = null,
155      password = null;
156    if (url.startsWith("https:") || !this._args.requireSecureAuth) {
157      username = this._args.username;
158      password = this._args.password;
159    }
160    request.open(
161      this._args.post ? "POST" : "GET",
162      url,
163      true,
164      username,
165      password
166    );
167    request.channel.loadGroup = null;
168    request.timeout = this._args.timeout;
169    // needs bug 407190 patch v4 (or higher) - uncomment if that lands.
170    // try {
171    //    var channel = request.channel.QueryInterface(Ci.nsIHttpChannel2);
172    //    channel.connectTimeout = 5;
173    //    channel.requestTimeout = 5;
174    //    } catch (e) { dump(e + "\n"); }
175
176    if (!this._args.allowCache) {
177      // Disable Mozilla HTTP cache
178      request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
179    }
180
181    // body
182    let mimetype = null;
183    let body = this._args.uploadBody;
184    if (typeof body == "object" && "nodeType" in body) {
185      // XML
186      mimetype = "text/xml; charset=UTF-8";
187      body = new XMLSerializer().serializeToString(body);
188    } else if (typeof body == "object") {
189      // JSON
190      mimetype = "text/json; charset=UTF-8";
191      body = JSON.stringify(body);
192    } else if (typeof body == "string") {
193      // Plaintext
194      // You can override the mimetype with { headers: {"Content-Type" : "text/foo" } }
195      mimetype = "text/plain; charset=UTF-8";
196      // body already set above
197    } else if (this._args.bodyFormArgs) {
198      mimetype = "application/x-www-form-urlencoded; charset=UTF-8";
199      body = "";
200      for (let name in this._args.bodyFormArgs) {
201        body +=
202          (body ? "&" : "") +
203          name +
204          "=" +
205          encodeURIComponent(this._args.bodyFormArgs[name]);
206      }
207    }
208
209    // Headers
210    if (mimetype && !("Content-Type" in this._args.headers)) {
211      request.setRequestHeader("Content-Type", mimetype);
212    }
213    if (username && password) {
214      // workaround, because open(..., username, password) does not work.
215      request.setRequestHeader(
216        "Authorization",
217        "Basic " +
218          btoa(
219            // btoa() takes a BinaryString.
220            String.fromCharCode(
221              ...new TextEncoder().encode(username + ":" + password)
222            )
223          )
224      );
225    }
226    for (let name in this._args.headers) {
227      request.setRequestHeader(name, this._args.headers[name]);
228      if (name == "Cookie") {
229        // Websites are not allowed to set this, but chrome is.
230        // Nevertheless, the cookie lib later overwrites our header.
231        // request.channel.setCookie(this._args.headers[name]); -- crashes
232        // So, deactivate that Firefox cookie lib.
233        request.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS;
234      }
235    }
236
237    var me = this;
238    request.onload = function() {
239      me._response(true);
240    };
241    request.onerror = function() {
242      me._response(false);
243    };
244    request.ontimeout = function() {
245      me._response(false);
246    };
247    request.send(body);
248    // Store the original stack so we can use it if there is an exception
249    this._callStack = Error().stack;
250  },
251  _response(success, exStored) {
252    try {
253      var errorCode = null;
254      var errorStr = null;
255
256      if (
257        success &&
258        this._request.status >= 200 &&
259        this._request.status < 300
260      ) {
261        // HTTP level success
262        try {
263          // response
264          var mimetype = this._request.getResponseHeader("Content-Type");
265          if (!mimetype) {
266            mimetype = "";
267          }
268          mimetype = mimetype.split(";")[0];
269          if (
270            mimetype == "text/xml" ||
271            mimetype == "application/xml" ||
272            mimetype == "text/rdf"
273          ) {
274            // XML
275            this.result = JXON.build(this._request.responseXML);
276          } else if (
277            mimetype == "text/json" ||
278            mimetype == "application/json"
279          ) {
280            // JSON
281            this.result = JSON.parse(this._request.responseText);
282          } else {
283            // Plaintext (fallback)
284            // ddump("mimetype: " + mimetype + " only supported as text");
285            this.result = this._request.responseText;
286          }
287        } catch (e) {
288          success = false;
289          errorStr = getStringBundle(
290            "chrome://messenger/locale/accountCreationUtil.properties"
291          ).GetStringFromName("bad_response_content.error");
292          errorCode = -4;
293        }
294      } else if (
295        this._args.username &&
296        this._request.responseURL.replace(/\/\/.*@/, "//") != this._url &&
297        this._request.responseURL.startsWith(
298          this._args.requireSecureAuth ? "https" : "http"
299        ) &&
300        !this._isRetry
301      ) {
302        // Redirects lack auth, see <https://stackoverflow.com/a/28411170>
303        this._logger.info(
304          "Call to <" +
305            this._url +
306            "> was redirected to <" +
307            this._request.responseURL +
308            ">, and failed. Re-trying the new URL with authentication again."
309        );
310        this._url = this._request.responseURL;
311        this._isRetry = true;
312        this.start();
313        return;
314      } else {
315        success = false;
316        try {
317          errorCode = this._request.status;
318          errorStr = this._request.statusText;
319        } catch (e) {
320          // In case .statusText throws (it's marked as [Throws] in the webidl),
321          // continue with empty errorStr.
322        }
323        if (!errorStr) {
324          // If we can't resolve the hostname in DNS etc., .statusText is empty.
325          errorCode = -2;
326          errorStr = getStringBundle(
327            "chrome://messenger/locale/accountCreationUtil.properties"
328          ).GetStringFromName("cannot_contact_server.error");
329          ddump(errorStr + " on <" + this._url + ">");
330        }
331      }
332
333      // Callbacks
334      if (success) {
335        try {
336          this._successCallback(this.result);
337        } catch (e) {
338          e.stack = this._callStack;
339          this._error(e);
340        }
341      } else if (exStored) {
342        this._error(exStored);
343      } else {
344        // Put the caller's stack into the exception
345        let e = new ServerException(errorStr, errorCode, this._url);
346        e.stack = this._callStack;
347        this._error(e);
348      }
349
350      if (this._finishedCallback) {
351        try {
352          this._finishedCallback(this);
353        } catch (e) {
354          Cu.reportError(e);
355        }
356      }
357    } catch (e) {
358      // error in our fetchhttp._response() code
359      this._error(e);
360    }
361  },
362  _error(e) {
363    try {
364      this._errorCallback(e);
365    } catch (e) {
366      // error in errorCallback, too!
367      Cu.reportError(e);
368      alertPrompt("Error in errorCallback for fetchhttp", e);
369    }
370  },
371  /**
372   * Call this between start() and finishedCallback fired.
373   */
374  cancel(ex) {
375    assert(!this.result, "Call already returned");
376
377    this._request.abort();
378
379    // Need to manually call error handler
380    // <https://bugzilla.mozilla.org/show_bug.cgi?id=218236#c11>
381    this._response(false, ex ? ex : new UserCancelledException());
382  },
383  /**
384   * Allows caller or lib to be notified when the call is done.
385   * This is useful to enable and disable a Cancel button in the UI,
386   * which allows to cancel the network request.
387   */
388  setFinishedCallback(finishedCallback) {
389    this._finishedCallback = finishedCallback;
390  },
391};
392
393function ServerException(msg, code, uri) {
394  Exception.call(this, msg);
395  this.code = code;
396  this.uri = uri;
397}
398ServerException.prototype = Object.create(Exception.prototype);
399ServerException.prototype.constructor = ServerException;
400