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